From dbfa8dcff095b14f9e0541da0b6e237d0bf91d05 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 17 Dec 2025 11:59:01 +0800 Subject: [PATCH] ++ --- downloader.go | 104 ------- handler.go | 237 --------------- index.css | 446 +++++++++++++++++++++++++++ index.html | 245 +++++---------- index.js | 255 ++++++++++++++++ main.go | 815 +++++++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 1584 insertions(+), 518 deletions(-) delete mode 100644 downloader.go delete mode 100644 handler.go create mode 100644 index.css create mode 100644 index.js diff --git a/downloader.go b/downloader.go deleted file mode 100644 index f11a1cd..0000000 --- a/downloader.go +++ /dev/null @@ -1,104 +0,0 @@ -package main - -import ( - "crypto/tls" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "sync" - "time" -) - -func downloadImages(images map[string]string, dir string) (map[string]bool, map[string]error) { - var mu sync.Mutex - var wg sync.WaitGroup - - success := make(map[string]bool) - errors := make(map[string]error) - - // 并发控制:最多同时下载5张图片 - semaphore := make(chan struct{}, 5) - - for key, url := range images { - wg.Add(1) - - go func(key, url string) { - defer wg.Done() - - // 获取信号量 - semaphore <- struct{}{} - defer func() { <-semaphore }() - - // 下载图片 - err := downloadImage(url, dir, key) - - mu.Lock() - if err != nil { - errors[key] = err - } else { - success[key] = true - } - mu.Unlock() - }(key, url) - } - - wg.Wait() - return success, errors -} - -func downloadImage(url, dir, filename string) error { - // 创建HTTP客户端 - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - // 创建请求 - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return fmt.Errorf("创建请求失败: %v", err) - } - - // 设置请求头(模拟浏览器) - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - req.Header.Set("Accept", "image/webp,image/apng,image/*,*/*;q=0.8") - req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") - req.Header.Set("Referer", "https://hd4k.com/") - - // 发送请求 - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("请求失败: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP错误: %s", resp.Status) - } - - // 获取文件扩展名 - ext := getFileExtension(url) - fullFilename := filename + ext - filePath := filepath.Join(dir, fullFilename) - - // 创建文件 - file, err := os.Create(filePath) - if err != nil { - return fmt.Errorf("创建文件失败: %v", err) - } - defer file.Close() - - // 下载并保存 - _, err = io.Copy(file, resp.Body) - if err != nil { - // 删除可能已损坏的文件 - os.Remove(filePath) - return fmt.Errorf("保存文件失败: %v", err) - } - - return nil -} diff --git a/handler.go b/handler.go deleted file mode 100644 index 63d8d8c..0000000 --- a/handler.go +++ /dev/null @@ -1,237 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "time" -) - -// 请求结构 -type SaveImagesRequest struct { - Title string `json:"title"` - Imgs map[string]string `json:"imgs"` -} - -// 下载详情 -type DownloadDetail struct { - Key string `json:"key"` - URL string `json:"url"` - Status string `json:"status"` // success, skipped, failed - Message string `json:"message"` - SavedAs string `json:"saved_as,omitempty"` -} - -// 响应结构 -type SaveImagesResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Title string `json:"title"` - Folder string `json:"folder"` - JsonPath string `json:"json_path"` - Total int `json:"total"` - Saved int `json:"saved"` - Skipped int `json:"skipped"` - Failed int `json:"failed"` - Details []DownloadDetail `json:"details"` -} - -func saveImagesHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "只支持POST请求", http.StatusMethodNotAllowed) - return - } - - // 解析请求 - var req SaveImagesRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "请求格式错误: "+err.Error(), http.StatusBadRequest) - return - } - - // 验证参数 - if req.Title == "" { - http.Error(w, "标题不能为空", http.StatusBadRequest) - return - } - if len(req.Imgs) == 0 { - http.Error(w, "图片列表不能为空", http.StatusBadRequest) - return - } - - // 处理下载 - resp, err := processDownload(&req) - if err != nil { - http.Error(w, "处理失败: "+err.Error(), http.StatusInternalServerError) - return - } - - // 返回响应 - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -func processDownload(req *SaveImagesRequest) (*SaveImagesResponse, error) { - // 清理标题(移除非法字符) - cleanTitle := cleanFilename(req.Title) - - // 创建目录 - comicDir := filepath.Join(*downloadDir, cleanTitle) - if err := os.MkdirAll(comicDir, 0755); err != nil { - return nil, fmt.Errorf("创建目录失败: %v", err) - } - - // JSON文件路径 - jsonFilename := cleanTitle + ".json" - jsonPath := filepath.Join(comicDir, jsonFilename) - - // 检查现有文件 - existingFiles := make(map[string]bool) - files, _ := os.ReadDir(comicDir) - for _, file := range files { - if !file.IsDir() { - filename := file.Name() - if strings.HasSuffix(filename, ".json") { - continue - } - // 提取序号(去掉扩展名) - ext := filepath.Ext(filename) - key := strings.TrimSuffix(filename, ext) - existingFiles[key] = true - } - } - - // 准备下载 - details := make([]DownloadDetail, 0, len(req.Imgs)) - needDownload := make(map[string]string) - - for key, url := range req.Imgs { - // 获取文件扩展名 - ext := getFileExtension(url) - filename := key + ext - - if existingFiles[key] { - // 文件已存在,跳过 - details = append(details, DownloadDetail{ - Key: key, - URL: url, - Status: "skipped", - Message: "文件已存在", - SavedAs: filename, - }) - continue - } - - // 需要下载 - needDownload[key] = url - details = append(details, DownloadDetail{ - Key: key, - URL: url, - Status: "pending", - Message: "等待下载", - }) - } - - // 下载图片 - var saved, failed int - if len(needDownload) > 0 { - successMap, errors := downloadImages(needDownload, comicDir) - - // 使用 successMap 来检查哪些下载成功 - // 虽然我们不直接使用 successMap,但它用于 downloadImages 函数的返回值 - _ = successMap // 明确标记为使用(避免未使用警告) - - // 更新下载详情 - for i, detail := range details { - if detail.Status == "pending" { - if err, ok := errors[detail.Key]; ok { - details[i].Status = "failed" - details[i].Message = err.Error() - failed++ - } else { - ext := getFileExtension(detail.URL) - filename := detail.Key + ext - details[i].Status = "success" - details[i].Message = "下载成功" - details[i].SavedAs = filename - saved++ - } - } - } - } - - // 保存JSON文件 - skipped := len(req.Imgs) - saved - failed - finalImgs := make(map[string]string) - for key, url := range req.Imgs { - finalImgs[key] = url - } - - saveJSON(comicDir, jsonFilename, req.Title, finalImgs) - - // 构建响应 - return &SaveImagesResponse{ - Success: failed == 0, - Message: fmt.Sprintf("下载完成: 新增%d张, 跳过%d张, 失败%d张", saved, skipped, failed), - Title: req.Title, - Folder: comicDir, - JsonPath: jsonPath, - Total: len(req.Imgs), - Saved: saved, - Skipped: skipped, - Failed: failed, - Details: details, - }, nil -} - -func saveJSON(dir, filename, title string, imgs map[string]string) error { - data := map[string]interface{}{ - "title": title, - "imgs": imgs, - "created_at": time.Now().Format("2006-01-02 15:04:05"), - "updated_at": time.Now().Format("2006-01-02 15:04:05"), - } - - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return err - } - - path := filepath.Join(dir, filename) - return os.WriteFile(path, jsonData, 0644) -} - -func cleanFilename(filename string) string { - illegalChars := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", "\n", "\r", "\t"} - result := filename - for _, char := range illegalChars { - result = strings.ReplaceAll(result, char, "_") - } - if len(result) > 200 { - result = result[:200] - } - return strings.TrimSpace(result) -} - -func getFileExtension(url string) string { - // 默认扩展名 - ext := ".jpg" - - // 从URL提取扩展名 - if idx := strings.LastIndex(url, "."); idx != -1 { - fileExt := strings.ToLower(url[idx:]) - // 检查是否为常见图片格式 - validExts := []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"} - for _, validExt := range validExts { - if strings.HasPrefix(fileExt, validExt) { - ext = validExt - break - } - } - } - - return ext -} diff --git a/index.css b/index.css new file mode 100644 index 0000000..8f1e1ce --- /dev/null +++ b/index.css @@ -0,0 +1,446 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; +} + +.container { + max-width: 1200px; + margin: 0 auto; + background: white; + border-radius: 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +header { + background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); + color: white; + padding: 40px; + text-align: center; +} + +header h1 { + font-size: 2.5rem; + margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: center; + gap: 15px; +} + +header .subtitle { + font-size: 1.1rem; + opacity: 0.9; +} + +.control-panel { + padding: 30px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; +} + +.btn { + padding: 12px 24px; + border: none; + border-radius: 10px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.3s ease; +} + +.btn-primary { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(16, 185, 129, 0.3); +} + +.btn-warning { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; +} + +.btn-warning:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(245, 158, 11, 0.3); +} + +.btn-secondary { + background: #64748b; + color: white; +} + +.btn-secondary:hover { + background: #475569; +} + +.btn-small { + padding: 8px 16px; + font-size: 0.9rem; +} + +.status { + margin-left: auto; + padding: 10px 20px; + background: white; + border-radius: 8px; + border-left: 4px solid #10b981; + font-weight: 500; +} + +.folders-container { + padding: 30px; +} + +.folders-container h2 { + color: #334155; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; +} + +.folders-list { + display: grid; + gap: 20px; +} + +.folder-item { + background: white; + border: 2px solid #e2e8f0; + border-radius: 12px; + padding: 20px; + transition: all 0.3s ease; + display: flex; + justify-content: space-between; + align-items: center; +} + +.folder-item:hover { + border-color: #94a3b8; + transform: translateY(-2px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +.folder-info { + flex: 1; +} + +.folder-title { + font-size: 1.2rem; + font-weight: 600; + color: #1e293b; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 10px; +} + +.folder-stats { + display: flex; + gap: 20px; + color: #64748b; + font-size: 0.9rem; +} + +.progress-container { + width: 200px; + margin: 10px 0; +} + +.progress-wrapper { + display: flex; + align-items: center; + gap: 10px; +} + +.progress { + flex: 1; + height: 8px; + background: #e2e8f0; + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #10b981 0%, #34d399 100%); + border-radius: 4px; + transition: width 0.3s ease; +} + +.progress-text { + font-weight: 600; + color: #1e293b; + min-width: 50px; +} + +.folder-actions { + display: flex; + gap: 10px; +} + +.no-folders { + text-align: center; + padding: 60px 20px; + color: #64748b; +} + +.no-folders i { + font-size: 4rem; + color: #10b981; + margin-bottom: 20px; +} + +.no-folders p { + font-size: 1.2rem; +} + +.api-info { + padding: 30px; + background: #f1f5f9; + border-top: 1px solid #e2e8f0; +} + +.api-info h3 { + color: #334155; + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; +} + +.api-info code { + background: #e2e8f0; + padding: 4px 8px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.9rem; +} + +.api-info p { + margin: 10px 0; + color: #475569; +} + +/* 模态框样式 */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.modal-content { + background: white; + border-radius: 15px; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-header { + padding: 20px; + background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); + color: white; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 1.5rem; +} + +.close-btn { + background: none; + border: none; + color: white; + font-size: 1.8rem; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-body { + padding: 20px; + flex: 1; + overflow-y: auto; +} + +.progress-container { + background: #f8fafc; + border-radius: 10px; + padding: 20px; + margin-bottom: 20px; + position: relative; +} + +.progress-bar { + height: 10px; + background: linear-gradient(90deg, #10b981 0%, #34d399 100%); + border-radius: 5px; + width: 0%; + transition: width 0.3s ease; +} + +.progress-text { + position: absolute; + top: 50%; + right: 20px; + transform: translateY(-50%); + font-weight: 600; + color: #1e293b; +} + +.download-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 15px; + margin: 20px 0; +} + +.stat-item { + background: #f8fafc; + padding: 15px; + border-radius: 8px; + text-align: center; +} + +.stat-label { + display: block; + color: #64748b; + font-size: 0.9rem; + margin-bottom: 5px; +} + +.stat-value { + display: block; + font-size: 1.5rem; + font-weight: 600; + color: #1e293b; +} + +.stat-value.success { + color: #10b981; +} + +.stat-value.pending { + color: #f59e0b; +} + +.log-container { + background: #f8fafc; + border-radius: 10px; + padding: 20px; + margin-top: 20px; +} + +.log-container h4 { + color: #334155; + margin-bottom: 10px; +} + +.log-content { + max-height: 200px; + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: 0.9rem; + line-height: 1.4; + color: #475569; +} + +.log-entry { + padding: 5px 0; + border-bottom: 1px solid #e2e8f0; +} + +.log-entry.success { + color: #10b981; +} + +.log-entry.error { + color: #ef4444; +} + +.log-entry.info { + color: #3b82f6; +} + +.modal-footer { + padding: 20px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + text-align: right; +} + +@media (max-width: 768px) { + .container { + border-radius: 10px; + } + + header { + padding: 30px 20px; + } + + header h1 { + font-size: 2rem; + } + + .control-panel { + flex-direction: column; + align-items: stretch; + } + + .status { + margin-left: 0; + margin-top: 10px; + text-align: center; + } + + .folder-item { + flex-direction: column; + align-items: stretch; + gap: 15px; + } + + .folder-actions { + justify-content: center; + } + + .download-stats { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/index.html b/index.html index a2eebf6..02a4d80 100644 --- a/index.html +++ b/index.html @@ -1,181 +1,84 @@ - + - HD4K下载测试 - + + + HD4K 下载管理 + + -

HD4K下载测试

- -
- - +
+
+

HD4K 下载管理

+

管理未完成的下载任务

+
+ +
+ + +
+
+ +
+

未完成的任务

+
+ +
+
+ +

所有下载任务已完成!

+
+
+ +
+

API 信息

+

图片下载API: POST http://127.0.0.1:55830/api/save_imgs

+

请求格式: {"title": "漫画标题", "imgs": {"001": "url1", "002": "url2"}}

+
-
- - + + - - - - - + \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..6b15dcb --- /dev/null +++ b/index.js @@ -0,0 +1,255 @@ +class DownloadManager { + constructor() { + this.port = window.location.port || '55830'; + this.baseUrl = `http://127.0.0.1:${this.port}/api`; + this.currentDownload = null; + + this.init(); + } + + init() { + document.getElementById('port').textContent = this.port; + + // 绑定事件 + document.getElementById('reloadBtn').addEventListener('click', () => this.reloadFolders()); + document.getElementById('cleanupBtn').addEventListener('click', () => this.cleanupJson()); + document.getElementById('closeModalBtn').addEventListener('click', () => this.hideModal()); + document.querySelector('.close-btn').addEventListener('click', () => this.hideModal()); + + // 点击模态框外部关闭 + document.getElementById('downloadModal').addEventListener('click', (e) => { + if (e.target === document.getElementById('downloadModal')) { + this.hideModal(); + } + }); + + // 初始加载 + this.reloadFolders(); + } + + showStatus(message, type = 'info') { + const statusEl = document.getElementById('statusMessage'); + statusEl.textContent = message; + statusEl.style.borderLeftColor = type === 'error' ? '#ef4444' : + type === 'warning' ? '#f59e0b' : '#10b981'; + } + + showModal(title) { + document.getElementById('modalTitle').textContent = title; + document.getElementById('downloadModal').style.display = 'flex'; + this.resetModal(); + } + + hideModal() { + document.getElementById('downloadModal').style.display = 'none'; + if (this.currentDownload && this.currentDownload.abort) { + this.currentDownload.abort(); + } + } + + resetModal() { + document.getElementById('progressBar').style.width = '0%'; + document.getElementById('progressText').textContent = '0%'; + document.getElementById('totalFiles').textContent = '0'; + document.getElementById('downloadedFiles').textContent = '0'; + document.getElementById('pendingFiles').textContent = '0'; + document.getElementById('downloadLog').innerHTML = ''; + } + + updateProgress(progress, stats) { + document.getElementById('progressBar').style.width = `${progress}%`; + document.getElementById('progressText').textContent = `${Math.round(progress)}%`; + + if (stats) { + document.getElementById('totalFiles').textContent = stats.total || 0; + document.getElementById('downloadedFiles').textContent = stats.downloaded || 0; + document.getElementById('pendingFiles').textContent = stats.pending || 0; + } + } + + addLogEntry(message, type = 'info') { + const logEl = document.getElementById('downloadLog'); + const entry = document.createElement('div'); + entry.className = `log-entry ${type}`; + entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; + logEl.appendChild(entry); + logEl.scrollTop = logEl.scrollHeight; + } + + async reloadFolders() { + try { + this.showStatus('正在加载文件夹...', 'info'); + document.getElementById('reloadBtn').disabled = true; + + const response = await fetch(`${this.baseUrl}/reload_folders`); + const data = await response.json(); + + if (data.success) { + this.displayFolders(data.folders); + this.showStatus(`已加载 ${data.folders.length} 个未完成的任务`, 'success'); + } else { + throw new Error(data.message || '加载失败'); + } + } catch (error) { + console.error('加载失败:', error); + this.showStatus(`加载失败: ${error.message}`, 'error'); + this.displayFolders([]); + } finally { + document.getElementById('reloadBtn').disabled = false; + } + } + + displayFolders(folders) { + const foldersList = document.getElementById('foldersList'); + const noFolders = document.getElementById('noFolders'); + + // 过滤掉进度为100%的文件夹 + const incompleteFolders = folders.filter(folder => folder.progress < 100); + + if (incompleteFolders.length === 0) { + foldersList.innerHTML = ''; + noFolders.style.display = 'block'; + return; + } + + noFolders.style.display = 'none'; + + foldersList.innerHTML = incompleteFolders.map(folder => ` +
+
+
+ + ${this.escapeHtml(folder.title)} +
+
+ 总文件: ${folder.total} + 已下载: ${folder.downloaded} + 缺失: ${folder.total - folder.downloaded} +
+
+
+
+
+
+
${folder.progress.toFixed(1)}%
+
+
+
+
+ +
+
+ `).join(''); + } + + async downloadFolder(folderPath, title) { + try { + this.showModal(`正在下载: ${title}`); + this.addLogEntry('开始下载缺失文件...', 'info'); + + console.log('发送请求:', { folder: folderPath, title: title }); + + const response = await fetch(`${this.baseUrl}/download_missing`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + folder: folderPath, + title: title + }) + }); + + // 检查响应状态 + if (!response.ok) { + const errorText = await response.text(); + console.error('HTTP错误:', response.status, errorText); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const data = await response.json(); + console.log('收到响应:', data); + + if (data.success) { + // 更新进度 + const totalSaved = (data.saved || 0) + (data.skipped || 0); + const total = data.total || 1; + const progress = totalSaved / total * 100; + + this.updateProgress(progress, { + total: total, + downloaded: totalSaved, + pending: data.failed || 0 + }); + + // 添加日志 + if (data.details) { + data.details.forEach(detail => { + if (detail.status === 'success') { + this.addLogEntry(`成功下载: ${detail.key}`, 'success'); + } else if (detail.status === 'failed') { + this.addLogEntry(`下载失败 ${detail.key}: ${detail.message}`, 'error'); + } else if (detail.status === 'skipped') { + this.addLogEntry(`跳过: ${detail.key} (${detail.message})`, 'info'); + } + }); + } + + this.addLogEntry(`下载完成: ${data.message}`, 'success'); + + // 刷新文件夹列表 + setTimeout(() => this.reloadFolders(), 1000); + } else { + this.addLogEntry(`下载失败: ${data.message}`, 'error'); + } + } catch (error) { + console.error('下载失败:', error); + this.addLogEntry(`下载失败: ${error.message}`, 'error'); + } + } + + async cleanupJson() { + if (!confirm('确定要删除所有JSON文件吗?此操作不可恢复。')) { + return; + } + + try { + this.showStatus('正在清理JSON文件...', 'info'); + document.getElementById('cleanupBtn').disabled = true; + + const response = await fetch(`${this.baseUrl}/cleanup_json`, { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + this.showStatus(data.message, 'success'); + // 刷新文件夹列表 + setTimeout(() => this.reloadFolders(), 500); + } else { + throw new Error(data.message || '清理失败'); + } + } catch (error) { + console.error('清理失败:', error); + this.showStatus(`清理失败: ${error.message}`, 'error'); + } finally { + document.getElementById('cleanupBtn').disabled = false; + } + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + escapeAttr(text) { + return this.escapeHtml(text).replace(/"/g, '"'); + } +} + +// 初始化应用 +const downloadManager = new DownloadManager(); \ No newline at end of file diff --git a/main.go b/main.go index eca76f0..a5a4550 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,18 @@ package main import ( + "crypto/tls" + "encoding/json" "flag" "fmt" + "io" "log" "net/http" "os" + "path/filepath" + "strings" + "sync" + "time" ) var ( @@ -13,6 +20,801 @@ var ( port = flag.String("port", "55830", "服务端口") ) +type SaveImagesRequest struct { + Title string `json:"title"` + Imgs map[string]string `json:"imgs"` +} + +// 下载详情 +type DownloadDetail struct { + Key string `json:"key"` + URL string `json:"url"` + Status string `json:"status"` // success, skipped, failed + Message string `json:"message"` + SavedAs string `json:"saved_as,omitempty"` +} + +// 响应结构 +type SaveImagesResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Title string `json:"title"` + Folder string `json:"folder"` + JsonPath string `json:"json_path"` + Total int `json:"total"` + Saved int `json:"saved"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Details []DownloadDetail `json:"details"` +} + +// 文件夹信息结构 +type FolderInfo struct { + Title string `json:"title"` + Folder string `json:"folder"` + JsonPath string `json:"json_path"` + Total int `json:"total"` + Downloaded int `json:"downloaded"` + Progress float64 `json:"progress"` +} + +// 加载JSON数据 +type JsonData struct { + Title string `json:"title"` + Imgs map[string]string `json:"imgs"` +} + +func saveImagesHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "只支持POST请求", + }) + return + } + + // 解析请求 + var req SaveImagesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "请求格式错误: " + err.Error(), + }) + return + } + + // 验证参数 + if req.Title == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "标题不能为空", + }) + return + } + if len(req.Imgs) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "图片列表不能为空", + }) + return + } + + // 处理下载 + resp, err := processDownload(&req) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "处理失败: " + err.Error(), + }) + return + } + + // 返回响应 + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func reloadFoldersHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "只支持GET请求", + }) + return + } + + folders, err := loadFolders() + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "加载文件夹失败: " + err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "folders": folders, + }) +} + +func downloadMissingHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"success": false, "message": "只支持POST请求"}`, http.StatusMethodNotAllowed) + return + } + + var req struct { + Folder string `json:"folder"` + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "请求格式错误: " + err.Error(), + }) + return + } + + if req.Folder == "" || req.Title == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "参数不能为空", + }) + return + } + + resp, err := processMissingDownload(req.Folder, req.Title) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "处理失败: " + err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func cleanupJsonHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "只支持POST请求", + }) + return + } + + count, err := cleanupJsonFiles() + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "清理失败: " + err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("已删除 %d 个JSON文件", count), + "deleted": count, + }) +} + +// 查找文件夹中的JSON文件(支持多种方式) +func findJsonFiles(folderPath string) ([]string, error) { + jsonFiles := []string{} + + // 方法1: 标准glob + pattern1 := filepath.Join(folderPath, "*.json") + files1, _ := filepath.Glob(pattern1) + jsonFiles = append(jsonFiles, files1...) + + // 方法2: 不区分大小写 + pattern2 := filepath.Join(folderPath, "*.JSON") + files2, _ := filepath.Glob(pattern2) + jsonFiles = append(jsonFiles, files2...) + + // 方法3: 使用ReadDir手动查找(最可靠) + files3, err := os.ReadDir(folderPath) + if err != nil { + return jsonFiles, err + } + + for _, file := range files3 { + if !file.IsDir() { + name := strings.ToLower(file.Name()) + if strings.HasSuffix(name, ".json") { + fullPath := filepath.Join(folderPath, file.Name()) + // 去重 + found := false + for _, existing := range jsonFiles { + if existing == fullPath { + found = true + break + } + } + if !found { + jsonFiles = append(jsonFiles, fullPath) + } + } + } + } + + return jsonFiles, nil +} + +func loadFolders() ([]FolderInfo, error) { + folders := []FolderInfo{} + + fmt.Println("=== 开始扫描下载目录 ===") + fmt.Println("下载目录绝对路径:", *downloadDir) + + // 获取绝对路径 + absDownloadDir, _ := filepath.Abs(*downloadDir) + fmt.Println("下载目录绝对路径:", absDownloadDir) + + entries, err := os.ReadDir(absDownloadDir) + if err != nil { + fmt.Printf("读取目录失败: %v\n", err) + return nil, err + } + + for i, entry := range entries { + if entry.IsDir() { + folderPath := filepath.Join(absDownloadDir, entry.Name()) + fmt.Printf(" 处理文件夹: %s\n", folderPath) + + // 使用通用函数查找JSON文件 + jsonFiles, err := findJsonFiles(folderPath) + if err != nil { + fmt.Printf(" 查找JSON文件失败: %v\n", err) + continue + } + + for j, jsonFile := range jsonFiles { + fmt.Printf(" [%d] %s\n", j, jsonFile) + } + + if len(jsonFiles) == 0 { + fmt.Println(" 没有JSON文件,跳过") + continue + } + + // 读取JSON文件 + for _, jsonFile := range jsonFiles { + fmt.Printf(" 处理JSON文件: %s\n", jsonFile) + + // 检查文件是否存在 + if _, err := os.Stat(jsonFile); os.IsNotExist(err) { + fmt.Printf(" ❌ JSON文件不存在\n") + continue + } + + data, err := os.ReadFile(jsonFile) + if err != nil { + fmt.Printf(" 读取JSON文件失败: %v\n", err) + continue + } + + fmt.Printf(" 成功读取JSON文件,大小: %d bytes\n", len(data)) + + var jsonData JsonData + if err := json.Unmarshal(data, &jsonData); err != nil { + fmt.Printf(" 解析JSON失败: %v\n", err) + fmt.Printf(" 文件内容前100字节: %s\n", string(data[:min(100, len(data))])) + continue + } + + fmt.Printf(" JSON解析成功: title='%s', imgs数量=%d\n", jsonData.Title, len(jsonData.Imgs)) + + if len(jsonData.Imgs) == 0 { + fmt.Println(" JSON中没有图片数据,跳过") + continue + } + + // 统计实际已下载的图片数量(与JSON中的key匹配) + downloaded := 0 + files, _ := os.ReadDir(folderPath) + + fmt.Printf(" 文件夹中总文件数: %d\n", len(files)) + + // 创建已存在文件的map + existingFiles := make(map[string]bool) + for _, file := range files { + if !file.IsDir() && !strings.HasSuffix(strings.ToLower(file.Name()), ".json") { + // 提取序号(去掉扩展名) + ext := filepath.Ext(file.Name()) + key := strings.TrimSuffix(file.Name(), ext) + existingFiles[key] = true + fmt.Printf(" 文件: %s -> key: %s\n", file.Name(), key) + } + } + + fmt.Println(" 检查JSON中的key匹配情况:") + // 检查JSON中的每个key是否都有对应的文件 + missingKeys := []string{} + for key := range jsonData.Imgs { + if existingFiles[key] { + downloaded++ + fmt.Printf(" ✓ 存在: key=%s\n", key) + } else { + missingKeys = append(missingKeys, key) + fmt.Printf(" ✗ 缺失: key=%s\n", key) + } + } + + // 计算进度 + total := len(jsonData.Imgs) + progress := 0.0 + if total > 0 { + progress = float64(downloaded) / float64(total) * 100 + } + + fmt.Printf(" 统计结果: 总=%d, 已下载=%d, 缺失=%d, 进度=%.2f%%\n", + total, downloaded, len(missingKeys), progress) + + // 显示所有未完成的任务(包括部分完成的) + folders = append(folders, FolderInfo{ + Title: jsonData.Title, + Folder: folderPath, + JsonPath: jsonFile, + Total: total, + Downloaded: downloaded, + Progress: progress, + }) + + fmt.Printf(" 添加到列表: title='%s', progress=%.2f%%\n", jsonData.Title, progress) + } + } + } + + fmt.Printf("\n=== 扫描完成,共找到 %d 个文件夹 ===\n", len(folders)) + return folders, nil +} + +func processMissingDownload(folderPath, title string) (*SaveImagesResponse, error) { + fmt.Printf("=== 开始处理缺失文件下载 ===\n") + fmt.Printf("文件夹: %s\n", folderPath) + fmt.Printf("标题: %s\n", title) + + // 使用通用函数查找JSON文件 + jsonFiles, err := findJsonFiles(folderPath) + if err != nil || len(jsonFiles) == 0 { + fmt.Printf("未找到JSON文件,错误: %v\n", err) + return nil, fmt.Errorf("未找到JSON文件") + } + + fmt.Printf("找到JSON文件: %s\n", jsonFiles[0]) + + // 读取JSON文件 + data, err := os.ReadFile(jsonFiles[0]) + if err != nil { + fmt.Printf("读取JSON文件失败: %v\n", err) + return nil, fmt.Errorf("读取JSON文件失败: %v", err) + } + + var jsonData JsonData + if err := json.Unmarshal(data, &jsonData); err != nil { + fmt.Printf("解析JSON失败: %v\n", err) + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + fmt.Printf("JSON解析成功: title='%s', imgs数量=%d\n", jsonData.Title, len(jsonData.Imgs)) + + // 检查现有文件 - 修正为只统计与JSON key匹配的文件 + existingFiles := make(map[string]bool) + files, _ := os.ReadDir(folderPath) + + fmt.Printf("文件夹中总文件数: %d\n", len(files)) + + for _, file := range files { + if !file.IsDir() && !strings.HasSuffix(strings.ToLower(file.Name()), ".json") { + // 提取序号(去掉扩展名) + ext := filepath.Ext(file.Name()) + key := strings.TrimSuffix(file.Name(), ext) + existingFiles[key] = true + } + } + + // 准备下载缺失的文件 + needDownload := make(map[string]string) + missingCount := 0 + + fmt.Println("检查缺失文件:") + for key, url := range jsonData.Imgs { + if !existingFiles[key] { + needDownload[key] = url + missingCount++ + fmt.Printf(" ✗ 缺失: key=%s\n", key) + } else { + fmt.Printf(" ✓ 存在: key=%s\n", key) + } + } + + // 计算已下载数量(基于JSON中实际存在的key) + downloadedCount := 0 + for key := range jsonData.Imgs { + if existingFiles[key] { + downloadedCount++ + } + } + + fmt.Printf("统计: 总文件=%d, 已下载=%d, 缺失=%d\n", + len(jsonData.Imgs), downloadedCount, missingCount) + + if len(needDownload) == 0 { + fmt.Println("所有文件都已存在,无需下载") + return &SaveImagesResponse{ + Success: true, + Message: "所有图片已下载完成", + Title: title, + Folder: folderPath, + JsonPath: jsonFiles[0], + Total: len(jsonData.Imgs), + Saved: 0, + Skipped: len(jsonData.Imgs), + Failed: 0, + }, nil + } + + fmt.Printf("开始下载 %d 个缺失文件...\n", len(needDownload)) + + // 下载缺失图片 + saved := 0 + failed := 0 + details := make([]DownloadDetail, 0, len(needDownload)) + + for key, url := range needDownload { + details = append(details, DownloadDetail{ + Key: key, + URL: url, + Status: "pending", + Message: "等待下载", + }) + } + + successMap, errors := downloadImages(needDownload, folderPath) + _ = successMap + + for i, detail := range details { + if detail.Status == "pending" { + if err, ok := errors[detail.Key]; ok { + details[i].Status = "failed" + details[i].Message = err.Error() + failed++ + fmt.Printf("下载失败: key=%s, error=%v\n", detail.Key, err) + } else { + ext := getFileExtension(detail.URL) + filename := detail.Key + ext + details[i].Status = "success" + details[i].Message = "下载成功" + details[i].SavedAs = filename + saved++ + fmt.Printf("下载成功: key=%s\n", detail.Key) + } + } + } + + skipped := len(jsonData.Imgs) - saved - failed + + fmt.Printf("下载完成: 新增=%d, 跳过=%d, 失败=%d\n", saved, skipped, failed) + + return &SaveImagesResponse{ + Success: failed == 0, + Message: fmt.Sprintf("下载完成: 新增%d张, 跳过%d张, 失败%d张", saved, skipped, failed), + Title: title, + Folder: folderPath, + JsonPath: jsonFiles[0], + Total: len(jsonData.Imgs), + Saved: saved, + Skipped: skipped, + Failed: failed, + Details: details, + }, nil +} + +func cleanupJsonFiles() (int, error) { + count := 0 + + err := filepath.Walk(*downloadDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") { + if err := os.Remove(path); err == nil { + count++ + } + } + + return nil + }) + + return count, err +} + +func processDownload(req *SaveImagesRequest) (*SaveImagesResponse, error) { + // 清理标题(移除非法字符) + cleanTitle := cleanFilename(req.Title) + + // 创建目录 + comicDir := filepath.Join(*downloadDir, cleanTitle) + if err := os.MkdirAll(comicDir, 0755); err != nil { + return nil, fmt.Errorf("创建目录失败: %v", err) + } + + // JSON文件路径 + jsonFilename := cleanTitle + ".json" + jsonPath := filepath.Join(comicDir, jsonFilename) + + // 检查现有文件 + existingFiles := make(map[string]bool) + files, _ := os.ReadDir(comicDir) + for _, file := range files { + if !file.IsDir() { + filename := file.Name() + if strings.HasSuffix(filename, ".json") { + continue + } + // 提取序号(去掉扩展名) + ext := filepath.Ext(filename) + key := strings.TrimSuffix(filename, ext) + existingFiles[key] = true + } + } + + // 准备下载 + details := make([]DownloadDetail, 0, len(req.Imgs)) + needDownload := make(map[string]string) + + for key, url := range req.Imgs { + // 获取文件扩展名 + ext := getFileExtension(url) + filename := key + ext + + if existingFiles[key] { + // 文件已存在,跳过 + details = append(details, DownloadDetail{ + Key: key, + URL: url, + Status: "skipped", + Message: "文件已存在", + SavedAs: filename, + }) + continue + } + + // 需要下载 + needDownload[key] = url + details = append(details, DownloadDetail{ + Key: key, + URL: url, + Status: "pending", + Message: "等待下载", + }) + } + + // 下载图片 + var saved, failed int + if len(needDownload) > 0 { + successMap, errors := downloadImages(needDownload, comicDir) + + // 使用 successMap 来检查哪些下载成功 + // 虽然我们不直接使用 successMap,但它用于 downloadImages 函数的返回值 + _ = successMap // 明确标记为使用(避免未使用警告) + + // 更新下载详情 + for i, detail := range details { + if detail.Status == "pending" { + if err, ok := errors[detail.Key]; ok { + details[i].Status = "failed" + details[i].Message = err.Error() + failed++ + } else { + ext := getFileExtension(detail.URL) + filename := detail.Key + ext + details[i].Status = "success" + details[i].Message = "下载成功" + details[i].SavedAs = filename + saved++ + } + } + } + } + + // 保存JSON文件 + skipped := len(req.Imgs) - saved - failed + finalImgs := make(map[string]string) + for key, url := range req.Imgs { + finalImgs[key] = url + } + + saveJSON(comicDir, jsonFilename, req.Title, finalImgs) + + // 构建响应 + return &SaveImagesResponse{ + Success: failed == 0, + Message: fmt.Sprintf("下载完成: 新增%d张, 跳过%d张, 失败%d张", saved, skipped, failed), + Title: req.Title, + Folder: comicDir, + JsonPath: jsonPath, + Total: len(req.Imgs), + Saved: saved, + Skipped: skipped, + Failed: failed, + Details: details, + }, nil +} + +func saveJSON(dir, filename, title string, imgs map[string]string) error { + data := map[string]interface{}{ + "title": title, + "imgs": imgs, + "created_at": time.Now().Format("2006-01-02 15:04:05"), + "updated_at": time.Now().Format("2006-01-02 15:04:05"), + } + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + + path := filepath.Join(dir, filename) + return os.WriteFile(path, jsonData, 0644) +} + +func cleanFilename(filename string) string { + illegalChars := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", "\n", "\r", "\t"} + result := filename + for _, char := range illegalChars { + result = strings.ReplaceAll(result, char, "_") + } + if len(result) > 200 { + result = result[:200] + } + return strings.TrimSpace(result) +} + +func getFileExtension(url string) string { + // 默认扩展名 + ext := ".jpg" + + // 从URL提取扩展名 + if idx := strings.LastIndex(url, "."); idx != -1 { + fileExt := strings.ToLower(url[idx:]) + // 检查是否为常见图片格式 + validExts := []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"} + for _, validExt := range validExts { + if strings.HasPrefix(fileExt, validExt) { + ext = validExt + break + } + } + } + + return ext +} + +func downloadImages(images map[string]string, dir string) (map[string]bool, map[string]error) { + var mu sync.Mutex + var wg sync.WaitGroup + + success := make(map[string]bool) + errors := make(map[string]error) + + // 并发控制:最多同时下载5张图片 + semaphore := make(chan struct{}, 5) + + for key, url := range images { + wg.Add(1) + + go func(key, url string) { + defer wg.Done() + + // 获取信号量 + semaphore <- struct{}{} + defer func() { <-semaphore }() + + // 下载图片 + err := downloadImage(url, dir, key) + + mu.Lock() + if err != nil { + errors[key] = err + } else { + success[key] = true + } + mu.Unlock() + }(key, url) + } + + wg.Wait() + return success, errors +} + +func downloadImage(url, dir, filename string) error { + // 创建HTTP客户端 + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + // 创建请求 + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("创建请求失败: %v", err) + } + + // 设置请求头(模拟浏览器) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + req.Header.Set("Accept", "image/webp,image/apng,image/*,*/*;q=0.8") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + req.Header.Set("Referer", "https://hd4k.com/") + + // 发送请求 + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("请求失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP错误: %s", resp.Status) + } + + // 获取文件扩展名 + ext := getFileExtension(url) + fullFilename := filename + ext + filePath := filepath.Join(dir, fullFilename) + + // 创建文件 + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("创建文件失败: %v", err) + } + defer file.Close() + + // 下载并保存 + _, err = io.Copy(file, resp.Body) + if err != nil { + // 删除可能已损坏的文件 + os.Remove(filePath) + return fmt.Errorf("保存文件失败: %v", err) + } + + return nil +} + func main() { flag.Parse() @@ -22,17 +824,18 @@ func main() { } // 设置路由 - http.HandleFunc("/api/save_imgs", saveImagesHandler) - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "index.html") - }) + http.HandleFunc("/api/save_imgs", enableCORS(saveImagesHandler)) + http.HandleFunc("/api/reload_folders", enableCORS(reloadFoldersHandler)) + http.HandleFunc("/api/download_missing", enableCORS(downloadMissingHandler)) + http.HandleFunc("/api/cleanup_json", enableCORS(cleanupJsonHandler)) - // 允许跨域 - http.HandleFunc("/api/", enableCORS(saveImagesHandler)) + // 静态文件服务 + http.Handle("/", http.FileServer(http.Dir("."))) fmt.Printf("HD4K下载服务启动\n") fmt.Printf("下载目录: %s\n", *downloadDir) fmt.Printf("API地址: http://127.0.0.1:%s/api/save_imgs\n", *port) + fmt.Printf("管理页面: http://127.0.0.1:%s/\n", *port) fmt.Printf("按 Ctrl+C 停止服务\n") log.Fatal(http.ListenAndServe(":"+*port, nil))