You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
ImagesDownloader/prompt.txt

1702 lines
43 KiB

.
├── Tampermonkey
   └── hdk4.js
├── build
   ├── hd4k-downloader-macos-arm
   ├── hd4k-downloader-macos-intel
   └── hd4k-downloader-windows.exe
├── build.sh
├── downloads
├── go.mod
├── index.css
├── index.html
├── index.js
├── main.go
├── prompt.txt
└── test.txt
---
main.go
package main
import (
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
var (
downloadDir = flag.String("dir", "./downloads", "下载目录")
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 _, 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
// 更新下载详情
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()
// 创建下载目录
if err := os.MkdirAll(*downloadDir, 0755); err != nil {
log.Fatal("创建下载目录失败:", err)
}
// 设置路由
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.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))
}
func enableCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
---
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HD4K 下载管理</title>
<link rel="stylesheet" href="index.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="container">
<header>
<h1><i class="fas fa-download"></i> HD4K 下载管理</h1>
<p class="subtitle">管理未完成的下载任务</p>
</header>
<div class="control-panel">
<button id="reloadBtn" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> 重载下载文件夹
</button>
<button id="cleanupBtn" class="btn btn-warning">
<i class="fas fa-trash-alt"></i> 清理JSON文件
</button>
<div class="status" id="statusMessage"></div>
</div>
<div class="folders-container">
<h2><i class="fas fa-folder-open"></i> 未完成的任务</h2>
<div id="foldersList" class="folders-list">
<!-- 文件夹列表将在这里动态生成 -->
</div>
<div id="noFolders" class="no-folders">
<i class="fas fa-check-circle"></i>
<p>所有下载任务已完成!</p>
</div>
</div>
<div class="api-info">
<h3><i class="fas fa-info-circle"></i> API 信息</h3>
<p>图片下载API: <code>POST http://127.0.0.1:<span id="port">55830</span>/api/save_imgs</code></p>
<p>请求格式: <code>{"title": "漫画标题", "imgs": {"001": "url1", "002": "url2"}}</code></p>
</div>
</div>
<!-- 下载进度模态框 -->
<div id="downloadModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">下载进度</h3>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<div class="progress-container">
<div class="progress-bar" id="progressBar"></div>
<div class="progress-text" id="progressText">0%</div>
</div>
<div class="download-stats">
<div class="stat-item">
<span class="stat-label">总文件数:</span>
<span class="stat-value" id="totalFiles">0</span>
</div>
<div class="stat-item">
<span class="stat-label">已下载:</span>
<span class="stat-value success" id="downloadedFiles">0</span>
</div>
<div class="stat-item">
<span class="stat-label">待下载:</span>
<span class="stat-value pending" id="pendingFiles">0</span>
</div>
</div>
<div class="log-container">
<h4>下载日志</h4>
<div class="log-content" id="downloadLog"></div>
</div>
</div>
<div class="modal-footer">
<button id="closeModalBtn" class="btn btn-secondary">关闭</button>
</div>
</div>
</div>
<script src="index.js"></script>
</body>
</html>
---
index.css
* {
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;
}
}
---
index.js
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 => `
<div class="folder-item">
<div class="folder-info">
<div class="folder-title">
<i class="fas fa-book"></i>
${this.escapeHtml(folder.title)}
</div>
<div class="folder-stats">
<span>总文件: ${folder.total}</span>
<span>已下载: ${folder.downloaded}</span>
<span>缺失: ${folder.total - folder.downloaded}</span>
</div>
<div class="progress-container">
<div class="progress-wrapper">
<div class="progress">
<div class="progress-fill" style="width: ${folder.progress}%"></div>
</div>
<div class="progress-text">${folder.progress.toFixed(1)}%</div>
</div>
</div>
</div>
<div class="folder-actions">
<button class="btn btn-primary btn-small" onclick="downloadManager.downloadFolder('${this.escapeAttr(folder.folder)}', '${this.escapeAttr(folder.title)}')">
<i class="fas fa-download"></i> 下载缺失文件 (${folder.total - folder.downloaded})
</button>
</div>
</div>
`).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, '&quot;');
}
}
// 初始化应用
const downloadManager = new DownloadManager();
---
帮我修改一下这份代码, 现在打开项目的页面路由, 会出现类似文件服务器的界面, 就是把我项目所有的文件都显示出来了, 就是显示不了 html的页面
你帮我看看什么问题?
另外, 前端推送过来的数据格式是
{
"title": "测试",
"imgs": {
"0001": "https://icon-icons.com/images/menu_photos.png",
"0002": "https://icon-icons.com/images/flags/zh.webp"
}
}
现在前端推送过来的数据变成了
{
"title": "测试",
"source": "hd4k"
"imgs": {
"0001": "https://icon-icons.com/images/menu_photos.png",
"0002": "https://icon-icons.com/images/flags/zh.webp"
}
}
这个 source , 是用于, 创建文件夹的, 现在的下载文件的文件夹路径是 ./downloads/title的值, 我需要改成 ./downloads/source的值/title的值
如果source的值不存在则创建一个