feat: 清单存 /s/ 路径,重开程序跳过画廊页解析
画廊页要翻 ?p=N 分页收集全部页(206 张 = 11 次请求)。之前每次运行都要
重新解析一遍,即使本地已经有完整清单。现改为:
- 清单升级到 v2,pageMap 存每页的 /s/ 路径与文件名
- 下载前先按 gid 找磁盘上的画廊目录,读到 v2 清单就直接下载,不请求画廊页
- 解析完立即写清单,崩溃/关程序也不丢
为什么存 /s/ 路径而不是 hath 直链:
hath 直链是「随机子域 + 随机端口 + 时间戳签名」,每次都变且会过期,
存下来也没法用(手动复制到浏览器打开,下次同样失效)。/s/ 路径是稳定的。
降级策略(都实测过):
- v1 旧清单(没有 pageMap)→ 重新解析,并把清单升级成 v2
- 清单 JSON 损坏 → 重新解析,跑完重写一份正常的
- 清单的 /s/ 路径过期(待下页几乎全失败)→ 自动重新解析一次再重试
- 找不到清单但目录存在(换了目录模板)→ 按新模板名/旧标题名找到目录继续
另外 pageMap 里存了 meta,否则从清单重建时目录模板(如 {artist}/...)
会退化到兜底值,算出来的目录名和之前对不上。
测试从 68 增至 74,其中 TestManifestSkipsReparse 实测第二次运行
画廊页请求数为 0 增量。
This commit is contained in:
+225
-56
@@ -267,6 +267,67 @@ func (e *Engine) safeRunTask(ctx context.Context, t Task) {
|
||||
e.runTask(ctx, t)
|
||||
}
|
||||
|
||||
// existingGallery 是磁盘上已存在的画廊信息
|
||||
type existingGallery struct {
|
||||
dir string
|
||||
manifest *Manifest
|
||||
}
|
||||
|
||||
// findExistingGallery 在下载根目录里找这个 gid 已存在的画廊。
|
||||
//
|
||||
// 两种匹配方式:
|
||||
// 1. 按 .ehdl.json 清单里的 gid 精确匹配(最可靠,拿到目录绝对路径)
|
||||
// 2. 没有清单时,退而按「模板算出的目录名」或「旧版标题目录名」猜
|
||||
//
|
||||
// 找到就返回,下载时可直接复用清单里的 /s/ 路径,省掉画廊页解析。
|
||||
func (e *Engine) findExistingGallery(root string, task Task) *existingGallery {
|
||||
// 1) 按清单里的 gid 匹配
|
||||
if md := FindManifestByGID(root, task.GID); md != nil {
|
||||
return &existingGallery{dir: md.Dir, manifest: md.Manifest}
|
||||
}
|
||||
|
||||
// 2) 没有清单:按可能的目录名猜(换个模板/旧版标题目录的情况)
|
||||
for _, name := range e.guessDirNames(task, nil) {
|
||||
p := filepath.Join(root, name)
|
||||
if st, err := os.Stat(p); err == nil && st.IsDir() {
|
||||
m, _ := ReadManifest(p)
|
||||
return &existingGallery{dir: p, manifest: m}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// guessDirNames 猜这个画廊可能的目录名(当前模板算出来的 + 旧版标题目录)
|
||||
//
|
||||
// meta 优先用已找到的清单里那份(那是上次解析时拿到的真值),
|
||||
// 没有清单时才用任务标题试算。
|
||||
func (e *Engine) guessDirNames(task Task, meta *eh.GalleryMeta) []string {
|
||||
tpl := e.store.Settings().DirTemplate
|
||||
if tpl == "" {
|
||||
tpl = "{title}"
|
||||
}
|
||||
|
||||
probe := eh.Gallery{GID: task.GID, Title: task.Title}
|
||||
if meta != nil {
|
||||
probe.Meta = *meta
|
||||
}
|
||||
if probe.Title == "" {
|
||||
probe.Title = task.GID
|
||||
}
|
||||
|
||||
var names []string
|
||||
if d := BuildDir(tpl, &probe); d != "" {
|
||||
names = append(names, d)
|
||||
}
|
||||
// 旧版:只按标题建目录
|
||||
if task.Title != "" {
|
||||
if s := SanitizeSegment(task.Title, task.GID); s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||
task.Status = "running"
|
||||
task.Error = ""
|
||||
@@ -276,15 +337,45 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||
task.FailedList = []FailedItem{}
|
||||
e.save(task)
|
||||
|
||||
// ① 画廊页(内部自动翻 ?p=N 分页收集全部页)
|
||||
gallery, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
e.markCanceled(task)
|
||||
root := e.store.Settings().DownloadRoot
|
||||
if root == "" {
|
||||
root = DefaultDownloadRoot()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 先看磁盘上有没有这个画廊的目录和清单。
|
||||
// 清单存在 => 之前解析过了,直接拿来下载,省掉 N 次画廊页分页请求。
|
||||
// ------------------------------------------------------------
|
||||
existing := e.findExistingGallery(root, task)
|
||||
|
||||
var gallery *eh.Gallery
|
||||
usedManifest := false
|
||||
|
||||
if existing != nil {
|
||||
if g, ok := existing.manifest.GalleryFromManifest(); ok {
|
||||
gallery = g
|
||||
usedManifest = true
|
||||
e.log("info", fmt.Sprintf(
|
||||
"画廊 %s 用本地清单(%d 页),跳过重新解析", task.GID, len(g.Pages)))
|
||||
} else {
|
||||
// 目录在、清单是旧版本(v1,没有 /s/ 路径)—— 仍需解析
|
||||
e.log("info", fmt.Sprintf(
|
||||
"画廊 %s 有目录但清单不含页面路径,仍需重新解析", task.GID))
|
||||
}
|
||||
}
|
||||
|
||||
// 没有可用清单(或清单太旧)→ 正常解析画廊页
|
||||
if gallery == nil {
|
||||
g, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
e.markCanceled(task)
|
||||
return
|
||||
}
|
||||
e.fail(task, fmt.Errorf("解析画廊页失败: %w", err))
|
||||
return
|
||||
}
|
||||
e.fail(task, fmt.Errorf("解析画廊页失败: %w", err))
|
||||
return
|
||||
gallery = g
|
||||
}
|
||||
|
||||
task.Title = gallery.Title
|
||||
@@ -295,10 +386,6 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||
e.save(task)
|
||||
|
||||
// 目录按模板生成
|
||||
root := e.store.Settings().DownloadRoot
|
||||
if root == "" {
|
||||
root = DefaultDownloadRoot()
|
||||
}
|
||||
dir := filepath.Join(root, task.Dir)
|
||||
|
||||
// 老版本一律用「标题」当目录名。用户中途换了模板时,
|
||||
@@ -316,6 +403,14 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 清单里的目录名对不上时(比如换了模板),以磁盘上实际存在的为准
|
||||
if existing != nil && existing.dir != "" && existing.dir != dir {
|
||||
dir = existing.dir
|
||||
rel, err := filepath.Rel(root, dir)
|
||||
if err == nil {
|
||||
task.Dir = rel
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
e.fail(task, fmt.Errorf("创建目录失败: %w", err))
|
||||
@@ -332,6 +427,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||
token: task.Token,
|
||||
title: gallery.Title,
|
||||
total: len(gallery.Pages),
|
||||
meta: gallery.Meta,
|
||||
engine: e,
|
||||
}
|
||||
|
||||
@@ -340,29 +436,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||
st.saveManifest()
|
||||
|
||||
// 页级 worker 池
|
||||
n := e.pageConcurrency
|
||||
if n > len(st.pending) {
|
||||
n = len(st.pending)
|
||||
}
|
||||
if n > 0 {
|
||||
var wg sync.WaitGroup
|
||||
work := make(chan eh.GalleryPageInfo, len(st.pending))
|
||||
for _, p := range st.pending {
|
||||
work <- p
|
||||
}
|
||||
close(work)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for p := range work {
|
||||
st.safeDownloadPage(ctx, p)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
runPageWorkers(ctx, e, st, st.pending)
|
||||
|
||||
// ---- 整轮扫尾:把刚失败的页再扫一遍 ----
|
||||
if st.failedCount() > 0 && ctx.Err() == nil {
|
||||
@@ -371,28 +445,7 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||
retry := st.collectFailed()
|
||||
// 扫尾时清掉 failedList,让结果反映最终状态
|
||||
st.clearFailures()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
work := make(chan eh.GalleryPageInfo, len(retry))
|
||||
for _, p := range retry {
|
||||
work <- p
|
||||
}
|
||||
close(work)
|
||||
|
||||
n := e.pageConcurrency
|
||||
if n > len(retry) {
|
||||
n = len(retry)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for p := range work {
|
||||
st.safeDownloadPage(ctx, p)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
runPageWorkers(ctx, e, st, retry)
|
||||
|
||||
if st.failedCount() > 0 {
|
||||
e.log("warn", fmt.Sprintf(
|
||||
@@ -403,6 +456,31 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 清单失效自愈 ----
|
||||
// 如果这次是靠清单跳过解析的,但所有待下页几乎全失败,
|
||||
// 说明清单里的 /s/ 路径已经过期(EH 换了 key,或作者更新了画廊)。
|
||||
// 这时重新解析一次画廊页,拿到新的 /s/ 路径再试。
|
||||
if usedManifest && ctx.Err() == nil && st.allFailed() {
|
||||
e.log("warn", fmt.Sprintf(
|
||||
"画廊 %s:本地清单的页面路径似乎已失效,重新解析画廊页", task.GID))
|
||||
|
||||
if fresh, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token); err == nil {
|
||||
// 用新解析结果重跑:重置页状态
|
||||
st.reset(fresh)
|
||||
st.scanExisting()
|
||||
st.saveManifest()
|
||||
|
||||
retry := st.collectAllPending()
|
||||
runPageWorkers(ctx, e, st, retry)
|
||||
|
||||
if st.failedCount() == 0 {
|
||||
e.log("ok", fmt.Sprintf("画廊 %s:重新解析后全部下载完成", task.GID))
|
||||
}
|
||||
} else if ctx.Err() == nil {
|
||||
e.log("error", fmt.Sprintf("画廊 %s:重新解析失败:%v", task.GID, err))
|
||||
}
|
||||
}
|
||||
|
||||
st.saveManifest()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
@@ -419,6 +497,35 @@ func (e *Engine) runTask(ctx context.Context, task Task) {
|
||||
e.save(t)
|
||||
}
|
||||
|
||||
// runPageWorkers 用页级并发下载给定页面(阻塞直到完成)
|
||||
func runPageWorkers(ctx context.Context, e *Engine, st *pageState, pages []eh.GalleryPageInfo) {
|
||||
if len(pages) == 0 {
|
||||
return
|
||||
}
|
||||
n := e.PageConcurrency()
|
||||
if n > len(pages) {
|
||||
n = len(pages)
|
||||
}
|
||||
|
||||
work := make(chan eh.GalleryPageInfo, len(pages))
|
||||
for _, p := range pages {
|
||||
work <- p
|
||||
}
|
||||
close(work)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for p := range work {
|
||||
st.safeDownloadPage(ctx, p)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// pageState 是「一个画廊的页级下载状态」,所有字段都由 mutex 保护,
|
||||
// 因为页级 worker 是并发的。
|
||||
type pageState struct {
|
||||
@@ -434,12 +541,51 @@ type pageState struct {
|
||||
|
||||
pages []eh.GalleryPageInfo
|
||||
pending []eh.GalleryPageInfo
|
||||
meta eh.GalleryMeta
|
||||
|
||||
done map[int]bool
|
||||
failed map[int]string // 页码 -> 错误
|
||||
failedL []FailedItem
|
||||
}
|
||||
|
||||
// reset 用新的解析结果重置页状态(清单失效后重新解析时用)
|
||||
func (s *pageState) reset(g *eh.Gallery) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pages = g.Pages
|
||||
s.pending = nil
|
||||
s.total = len(g.Pages)
|
||||
s.title = g.Title
|
||||
s.meta = g.Meta
|
||||
}
|
||||
|
||||
// allFailed 判断是否「有待下页面且全部失败」—— 用来识别清单过期
|
||||
func (s *pageState) allFailed() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.pending) == 0 {
|
||||
return false // 没有待下页面,不是清单的问题
|
||||
}
|
||||
// 只下了一页而它失败了不算(可能只是单页问题)
|
||||
if len(s.pending) == 1 && len(s.done) > 0 {
|
||||
return false
|
||||
}
|
||||
return len(s.done) == 0 && len(s.failed) > 0
|
||||
}
|
||||
|
||||
// collectAllPending 取出所有还没成功的页
|
||||
func (s *pageState) collectAllPending() []eh.GalleryPageInfo {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []eh.GalleryPageInfo
|
||||
for _, p := range s.pages {
|
||||
if !s.done[p.Page] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scanExisting 扫描已有文件(断点续传的第一依据:文件在且非空 = 完成)
|
||||
func (s *pageState) scanExisting() {
|
||||
s.mu.Lock()
|
||||
@@ -597,11 +743,32 @@ func (s *pageState) markCanceled() {
|
||||
s.engine.markCanceled(s.snapshot())
|
||||
}
|
||||
|
||||
// saveManifest 写画廊目录清单。
|
||||
//
|
||||
// 存的是每页的 /s/ 路径(稳定),不是 hath 直链(随机子域+随机端口+时间戳签名,
|
||||
// 每次都变、存了也没用)。下次重开程序时,读这份清单就能跳过画廊页解析。
|
||||
func (s *pageState) saveManifest() {
|
||||
s.mu.Lock()
|
||||
done := keysOf(s.done)
|
||||
failed := keysOf(s.failed)
|
||||
t := *s.task
|
||||
|
||||
meta := s.meta
|
||||
pageMap := make(map[string]ManifestPage, len(s.pages))
|
||||
for _, p := range s.pages {
|
||||
k := strconv.Itoa(p.Page)
|
||||
errMsg := ""
|
||||
if msg, bad := s.failed[p.Page]; bad {
|
||||
errMsg = msg
|
||||
}
|
||||
pageMap[k] = ManifestPage{
|
||||
Page: p.Page,
|
||||
File: targetName(p, s.gid, s.total),
|
||||
SPath: p.SPath,
|
||||
Finished: s.done[p.Page],
|
||||
Error: errMsg,
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
WriteManifest(s.dir, Manifest{
|
||||
@@ -611,6 +778,8 @@ func (s *pageState) saveManifest() {
|
||||
TotalPages: s.total,
|
||||
DonePages: done,
|
||||
FailedPage: failed,
|
||||
PageMap: pageMap,
|
||||
Meta: &meta,
|
||||
CreatedAt: t.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user