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.
563 lines
18 KiB
563 lines
18 KiB
// Package main 是 ehentai-go 下载器的入口。
|
|
//
|
|
// 当前已挂载路由:
|
|
// - GET /healthz 连通性探针
|
|
// - GET /api/list 列出 pre-download/*.json 概要
|
|
// - POST /api/galleries/check 检查 {title} 是否已在 pre-download/
|
|
// - POST /api/galleries/save 保存前端 JS 组装好的画廊 JSON
|
|
// - GET /api/galleries/{title}/missing-images 返回还没下载的图片 key 列表(供前端确认要 push 哪些)
|
|
// - POST /api/galleries/{title}/images/{key} 接收前端 push 的图片二进制(multipart)
|
|
// - GET /api/events SSE 事件流
|
|
//
|
|
// 全部抓图链路(抓单页 → 解析直链 → 拉图片)由浏览器端油猴脚本完成,
|
|
// 后端只负责存。这样彻底绕开 Go 端被 e-hentai TLS fingerprint 拦截的问题。
|
|
//
|
|
// 全局只允许一个跑任务;并发触发返回 409 E_TASK_RUNNING。
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"ehentai-go/internal/crawler"
|
|
"ehentai-go/internal/store"
|
|
"ehentai-go/web"
|
|
)
|
|
|
|
// MinImageBytes 视为「下载成功」的最小字节数,小于此值视为占位/错误页。
|
|
const MinImageBytes = 50 * 1024
|
|
|
|
// (events.Hub 已被移除:前端面板已删,不需要 SSE 进度推送,
|
|
// 所有抓图结果由浏览器油猴脚本直接拿到响应即可。)
|
|
|
|
func main() {
|
|
port := flag.String("port", "8990", "HTTP 监听端口(也可用 EHENTAI_PORT 环境变量)")
|
|
flag.Parse()
|
|
if env := os.Getenv("EHENTAI_PORT"); env != "" {
|
|
*port = env
|
|
}
|
|
addr := ":" + *port
|
|
|
|
if err := ensureRuntimeDirs(); err != nil {
|
|
log.Fatalf("[main] %v", err)
|
|
}
|
|
// 测试支持:从 EHENTAI_TEST_HOSTS 环境变量注入额外 host 白名单(逗号分隔)。
|
|
// 生产环境不要设置这个变量。
|
|
crawler.AddAllowedHostsFromEnv()
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
mux.Handle("GET /", web.IndexHandler())
|
|
mux.HandleFunc("POST /api/galleries/check", handleGalleryCheck)
|
|
mux.HandleFunc("POST /api/galleries/save", handleGallerySave)
|
|
// 浏览器侧脚本推送图片二进制所用的端点。路径里同时带 gallery 与 key,
|
|
// 这样 multipart 不用塞文件名,后端从路径里拿就够了。
|
|
mux.HandleFunc("POST /api/galleries/{title}/images/{key}", handleGalleryImagePush)
|
|
mux.HandleFunc("GET /api/galleries/{title}/missing-images", handleGalleryMissingImages)
|
|
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
|
|
// 优雅退出:收到 SIGINT/SIGTERM 时给正在处理的请求最多 10 秒收尾。
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
go func() {
|
|
log.Printf("[main] ehentai-go listening on http://localhost%s", srv.Addr)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatalf("[main] ListenAndServe: %v", err)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
log.Println("[main] shutting down...")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
log.Printf("[main] shutdown error: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
log.Println("[main] bye")
|
|
}
|
|
|
|
// ensureRuntimeDirs 启动前检查/创建 pre-download 与 downloads 目录,
|
|
// 并把每条目录的状态打印到日志,便于排查权限与同名文件占位。
|
|
func ensureRuntimeDirs() error {
|
|
created, err := store.EnsureDirs()
|
|
if err != nil {
|
|
return fmt.Errorf("ensure runtime dirs: %w", err)
|
|
}
|
|
allDirs := []string{store.PreDownloadDir, store.DownloadsDir}
|
|
for _, d := range allDirs {
|
|
log.Printf("[main] runtime dir ok: %s", d)
|
|
}
|
|
if len(created) > 0 {
|
|
log.Printf("[main] 已自动创建 %d 个目录: %v", len(created), created)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeJSON 统一 JSON 响应。
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// writeErr 把 error 映射成统一错误响应。
|
|
// 同时根据错误码选一个合适的 HTTP status。
|
|
func writeErr(w http.ResponseWriter, err error) {
|
|
code := "E_INTERNAL"
|
|
msg := err.Error()
|
|
for _, prefix := range []string{
|
|
"E_BAD_URL", "E_FETCH_GALLERY", "E_PARSE_GALLERY",
|
|
"E_ZERO_PAGE", "E_DUP_GALLERY", "E_FETCH_SINGLE_PAGE",
|
|
"E_NOT_FOUND", "E_NO_GALLERY",
|
|
} {
|
|
if strings.HasPrefix(msg, prefix) {
|
|
code = prefix
|
|
break
|
|
}
|
|
}
|
|
status := http.StatusInternalServerError
|
|
switch code {
|
|
case "E_BAD_URL":
|
|
status = http.StatusBadRequest
|
|
case "E_ZERO_PAGE", "E_NO_GALLERY":
|
|
status = http.StatusUnprocessableEntity
|
|
case "E_NOT_FOUND":
|
|
status = http.StatusNotFound
|
|
}
|
|
writeJSON(w, status, map[string]any{
|
|
"error": code,
|
|
"message": msg,
|
|
})
|
|
}
|
|
|
|
// galleryCheckReq POST /api/galleries/check 请求体。
|
|
type galleryCheckReq struct {
|
|
Title string `json:"title"` // 已 sanitize 的画廊名(JS 端做完)
|
|
}
|
|
|
|
// handleGalleryCheck POST /api/galleries/check — 检查 title 对应的 json 是否已存在。
|
|
// 用于前端 JS 弹框「画廊已存在,是否覆盖 / 跳过」之类。
|
|
// 返回 200: {exists: bool, name: <sanitized>, pages?: map<key,单页URL>, total_pages?: int}
|
|
// 当 exists=true 时会一并把已有 json 的 pages/total_pages 返回,让续传场景下前端不必再翻页解析单页 URL,
|
|
// 直接用 pageURLByKey 就能精准重试 missing 图片(否则 pageURLByKey 是空的,missing 永远补不上)。
|
|
func handleGalleryCheck(w http.ResponseWriter, r *http.Request) {
|
|
var req galleryCheckReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "E_BAD_REQUEST",
|
|
"message": fmt.Sprintf("解析请求体失败: %v", err),
|
|
})
|
|
return
|
|
}
|
|
name := strings.TrimSpace(req.Title)
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "E_BAD_REQUEST",
|
|
"message": "title 不能为空",
|
|
})
|
|
return
|
|
}
|
|
exists, err := store.GalleryExists(name)
|
|
if err != nil {
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
resp := map[string]any{
|
|
"exists": exists,
|
|
"name": name,
|
|
}
|
|
if exists {
|
|
// 续传场景:把已有的 pages + total_pages 一起返回,前端就不用再翻页解析画廊列表页了。
|
|
// 这样 pageURLByKey 能被正确填上,missing 的图才能真正进入重试流程。
|
|
g, loadErr := store.LoadGallery(name)
|
|
if loadErr != nil {
|
|
// JSON 文件存在却读不出来(损坏/权限问题),不要静默继续,直接报错让前端看见。
|
|
writeErr(w, loadErr)
|
|
return
|
|
}
|
|
resp["pages"] = g.Pages
|
|
resp["total_pages"] = g.TotalPages
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// gallerySaveReq POST /api/galleries/save 请求体。
|
|
// 直接接收前端 JS 组装好的 store.Gallery JSON,落到 pre-download/<name>.json。
|
|
// 字段名与 store.Gallery 保持一致,这里只是再包一层防 raw_title/size 过大。
|
|
type gallerySaveReq struct {
|
|
store.Gallery
|
|
}
|
|
|
|
// handleGallerySave POST /api/galleries/save — 保存前端 JS 解析好的画廊 JSON。
|
|
// 已存在同名 json 时返回 409 E_DUP_GALLERY,前端弹框由用户决定是否覆盖(本接口暂不支持 overwrite,
|
|
// 后续如需可加 ?overwrite=1 参数)。
|
|
func handleGallerySave(w http.ResponseWriter, r *http.Request) {
|
|
var req gallerySaveReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "E_BAD_REQUEST",
|
|
"message": fmt.Sprintf("解析请求体失败: %v", err),
|
|
})
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.GalleryName) == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "E_BAD_REQUEST",
|
|
"message": "gallery_name 不能为空",
|
|
})
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.SourceURL) != "" {
|
|
// 兜底:如果前端忘了 sanitize,但给了原始 URL,这里再校验一遍 host。
|
|
if err := crawler.ValidateHost(req.SourceURL); err != nil {
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
}
|
|
if len(req.Pages) == 0 {
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
|
"error": "E_ZERO_PAGE",
|
|
"message": "pages 不能为空,前端必须先解析到至少一张图片直链",
|
|
})
|
|
return
|
|
}
|
|
if req.TotalPages == 0 {
|
|
req.TotalPages = len(req.Pages)
|
|
}
|
|
if req.FetchedAt == "" {
|
|
req.FetchedAt = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
exists, err := store.GalleryExists(req.GalleryName)
|
|
if err != nil {
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
if exists {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": "E_DUP_GALLERY",
|
|
"message": fmt.Sprintf("pre-download/%s.json 已存在", req.GalleryName),
|
|
})
|
|
return
|
|
}
|
|
if err := store.SaveGallery(&req.Gallery); err != nil {
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
log.Printf("[save] 已保存画廊: %s (%d 张)", req.GalleryName, req.TotalPages)
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"name": req.GalleryName,
|
|
"total_pages": req.TotalPages,
|
|
"path": store.GalleryJSONPath(req.GalleryName),
|
|
})
|
|
}
|
|
|
|
// handleGalleryMissingImages GET /api/galleries/{title}/missing-images
|
|
// 返回 gallery JSON 里尚未在 downloads/<title>/ 中以最小有效大小落盘的 key 列表。
|
|
// 浏览器端油猴脚本据此确认「该给后端 push 哪些张」。
|
|
func handleGalleryMissingImages(w http.ResponseWriter, r *http.Request) {
|
|
title := strings.TrimSpace(r.PathValue("title"))
|
|
if title == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "E_BAD_REQUEST",
|
|
"message": "title 不能为空",
|
|
})
|
|
return
|
|
}
|
|
g, err := store.LoadGallery(title)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrGalleryNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{
|
|
"error": "E_NOT_FOUND",
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
outDir := store.GalleryDownloadDir(title)
|
|
// 直接扫 downloads/<title>/ 下所有「4 位数字.<ext>」命名的文件,
|
|
// 这样不依赖 store.Gallery.Pages 里 URL 的形态 — 前端存的是单页 URL,
|
|
// 推断不到扩展名,旧逻辑会把所有 key 都当成 missing,导致续传失效。
|
|
onDisk, scanErr := scanDownloadedKeys(outDir)
|
|
if scanErr != nil {
|
|
writeErr(w, scanErr)
|
|
return
|
|
}
|
|
missing := make([]string, 0, len(g.Pages))
|
|
for key := range g.Pages {
|
|
if ok := onDisk[key]; !ok {
|
|
missing = append(missing, key)
|
|
}
|
|
}
|
|
// 按 key 字典序排序输出,前端按顺序并发即可。
|
|
sort.Strings(missing)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"name": title,
|
|
"total": len(g.Pages),
|
|
"missing": missing,
|
|
"missing_num": len(missing),
|
|
})
|
|
}
|
|
|
|
// handleGalleryImagePush POST /api/galleries/{title}/images/{key}
|
|
// 接收浏览器 push 过来的图片二进制(multipart,字段名 "file")。
|
|
// 落地到 downloads/<title>/<key><ext>,ext 由 Content-Type 或 multipart 文件名推断;
|
|
// 写入大小 < MinImageBytes (1KB) 视为失败,避免把限流占位 / 错误页当真图保存。
|
|
func handleGalleryImagePush(w http.ResponseWriter, r *http.Request) {
|
|
title := strings.TrimSpace(r.PathValue("title"))
|
|
key := strings.TrimSpace(r.PathValue("key"))
|
|
if title == "" || key == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "E_BAD_REQUEST",
|
|
"message": "title / key 不能为空",
|
|
})
|
|
return
|
|
}
|
|
// 不合法 key(防路径穿越)直接拒。
|
|
if !isValidKey(key) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "E_BAD_KEY",
|
|
"message": fmt.Sprintf("key %q 不合法(必须是 4 位数字)", key),
|
|
})
|
|
return
|
|
}
|
|
// 必须先有对应的 gallery JSON(防止 push 到不存在的画廊)。
|
|
if _, err := store.LoadGallery(title); err != nil {
|
|
if errors.Is(err, store.ErrGalleryNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{
|
|
"error": "E_NOT_FOUND",
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
|
|
// 限制 body 上限:200MB,超过直接拒(单图最多几十 MB)。
|
|
const maxBody = 200 << 20
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
|
|
|
|
// 优先 multipart;若不是 multipart,按 raw body 接收。
|
|
var (
|
|
ext string
|
|
filename string
|
|
contentType = r.Header.Get("Content-Type")
|
|
reader io.Reader = r.Body
|
|
)
|
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
|
if err := r.ParseMultipartForm(maxBody); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "E_BAD_REQUEST",
|
|
"message": fmt.Sprintf("解析 multipart 失败: %v", err),
|
|
})
|
|
return
|
|
}
|
|
file, hdr, ferr := r.FormFile("file")
|
|
if ferr != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "E_BAD_REQUEST",
|
|
"message": fmt.Sprintf("读取 form file 文件 失败: %v", ferr),
|
|
})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
reader = file
|
|
filename = hdr.Filename
|
|
if filename != "" {
|
|
ext = strings.ToLower(filepath.Ext(filename))
|
|
}
|
|
}
|
|
// multipart 没拿到文件名,或 raw body,按 Content-Type / 启发式猜扩展名。
|
|
if ext == "" {
|
|
ext = extFromContentType(contentType)
|
|
}
|
|
|
|
outDir := store.GalleryDownloadDir(title)
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
// 临时文件 + 原子 rename。
|
|
finalPath := filepath.Join(outDir, key+ext)
|
|
tmpPath := finalPath + ".part"
|
|
f, err := os.Create(tmpPath)
|
|
if err != nil {
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
written, err := io.Copy(f, reader)
|
|
if cerr := f.Close(); cerr != nil && err == nil {
|
|
err = cerr
|
|
}
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
if written < MinImageBytes {
|
|
_ = os.Remove(tmpPath)
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
|
"error": "E_TOO_SMALL",
|
|
"message": fmt.Sprintf("body 仅 %d < %d 字节,疑似限流占位 / 错误页", written, MinImageBytes),
|
|
"key": key,
|
|
})
|
|
return
|
|
}
|
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
writeErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"name": title,
|
|
"key": key,
|
|
"path": finalPath,
|
|
"bytes": written,
|
|
"filename": filepath.Base(finalPath),
|
|
})
|
|
}
|
|
|
|
// isValidOnDisk 文件存在且大小 >= MinImageBytes(1KB)。
|
|
func isValidOnDisk(path string) (bool, error) {
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return fi.Size() >= MinImageBytes, nil
|
|
}
|
|
|
|
// isValidKey key 必须是 4 位数字(对齐 store.Gallery.Pages 的 padding)。
|
|
func isValidKey(key string) bool {
|
|
if len(key) != 4 {
|
|
return false
|
|
}
|
|
for _, c := range key {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// scanDownloadedKeys 扫描 downloads/<title>/ 目录下所有「0001.<ext>」命名的文件,
|
|
// 返回 key 集合(只看 4 位数字前缀,扩展名任意)。文件 ≥ MinImageBytes 才算「在」。
|
|
// 用目录扫描而不是 URL 推断扩展名,是因为 store.Gallery.Pages 里存的是单页 URL,
|
|
// 老逻辑拿单页 URL 推 ext 永远推不出来,导致所有 key 都误判为 missing。
|
|
func scanDownloadedKeys(outDir string) (map[string]bool, error) {
|
|
out := make(map[string]bool)
|
|
entries, err := os.ReadDir(outDir)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return out, nil
|
|
}
|
|
return nil, fmt.Errorf("scan %s: %w", outDir, err)
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
// 跳过 .part 等临时文件
|
|
if strings.HasSuffix(name, ".part") {
|
|
continue
|
|
}
|
|
// 取主文件名第一段「.」前的部分作为 key
|
|
key := name
|
|
if i := strings.Index(name, "."); i >= 0 {
|
|
key = name[:i]
|
|
}
|
|
if !isValidKey(key) {
|
|
continue
|
|
}
|
|
info, statErr := e.Info()
|
|
if statErr != nil {
|
|
return nil, fmt.Errorf("stat %s: %w", name, statErr)
|
|
}
|
|
if info.Size() >= MinImageBytes {
|
|
out[key] = true
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// extFromContentType 根据 Content-Type 推扩展名;不能识别返回 ".bin"。
|
|
func extFromContentType(ct string) string {
|
|
ct = strings.ToLower(strings.TrimSpace(ct))
|
|
if i := strings.Index(ct, ";"); i >= 0 {
|
|
ct = ct[:i]
|
|
}
|
|
ct = strings.TrimSpace(ct)
|
|
switch ct {
|
|
case "image/jpeg", "image/jpg":
|
|
return ".jpg"
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/webp":
|
|
return ".webp"
|
|
case "image/gif":
|
|
return ".gif"
|
|
case "image/bmp":
|
|
return ".bmp"
|
|
case "image/svg+xml":
|
|
return ".svg"
|
|
case "image/avif":
|
|
return ".avif"
|
|
}
|
|
// multipart 没正确设置 Content-Type 时,header 可能形如 application/octet-stream
|
|
// 留空,文件落 .bin。spec 上传时主动设置 Content-Type 更稳。
|
|
return ".bin"
|
|
}
|
|
|
|
// extFromPageURL 从 store.Gallery.Pages 里的 URL 推断扩展名:
|
|
// - 老的图片直链(以 keystamp= / hath.network / fullimg 等 /结尾)取 .ext
|
|
// - 单页 URL(e-hentai /s/...)拿不到图片扩展名,返回 ""。
|
|
func extFromPageURL(rawURL string) string {
|
|
u := rawURL
|
|
// 只看 host 在 hath.* / 包含 keystamp / 包含 /fullimg 的当作「图片直链」启发式,
|
|
// 简单截取尾段扩展。
|
|
if strings.Contains(u, "keystamp=") || strings.Contains(u, "/fullimg") {
|
|
base := u
|
|
if i := strings.Index(base, "?"); i >= 0 {
|
|
base = base[:i]
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(base))
|
|
return ext
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// (acquireTask / releaseTask / newTaskID 已随 /api/download 一起移除;
|
|
// 现在所有抓取和图片下载都在浏览器端油猴脚本里串行/并发执行,
|
|
// 后端只负责存图片,无任务槽位概念。)
|
|
|
|
// (handleEvents / writeSSE 已随前端面板一起移除:现在没有前端要 SSE 进度了,
|
|
// 浏览器油猴脚本拿到的响应本身就是最终结果。)
|
|
|