diff --git a/frontend/js/renderer.js b/frontend/js/renderer.js index 873502b..79d3ef0 100644 --- a/frontend/js/renderer.js +++ b/frontend/js/renderer.js @@ -10,7 +10,7 @@ const logBuf = []; // ---------------- 渲染 ---------------- const STATUS_TEXT = { - queued: '等待', running: '解析中', downloading: '下载中', + queued: '等待', running: '准备中', parsing: '解析中', downloading: '下载中', done: '完成', failed: '失败', partial: '部分失败', }; @@ -45,8 +45,25 @@ function renderSelState() { function rowHtml(t) { const total = t.totalPages || 0; - const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0); - const badgeCls = 'b-' + (t.status || 'queued'); + + // 解析阶段:显示「已解析 N/M 张」,而不是 0/?(否则看着像卡死) + const parsing = t.status === 'parsing'; + let progCell; + if (parsing) { + const got = Number((t.pages || {})['_parsing'] || 0); + const pct = total ? Math.round((got / total) * 100) : 0; + progCell = `
+
+ 已解析 ${got}${total ? '/' + total : ''} 张 +
`; + } else { + const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0); + progCell = `
+
+ ${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''} +
`; + } + const title = t.title || t.raw || '(未解析)'; const err = t.error ? `${esc(t.error)}` @@ -63,13 +80,8 @@ function rowHtml(t) { ${esc(t.gid)}
${esc(title)}${err}
- -
-
- ${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''} -
- - ${STATUS_TEXT[t.status] || t.status} + ${progCell} + ${STATUS_TEXT[t.status] || t.status} ${acts.join(' ')} `; } @@ -79,11 +91,14 @@ function renderStats() { let dn = 0, tt = 0; for (const t of tasks) { if (t.status === 'queued') c.queued++; - else if (t.status === 'running' || t.status === 'downloading') c.running++; + else if (t.status === 'running' || t.status === 'parsing' || t.status === 'downloading') c.running++; else if (t.status === 'done') c.done++; else if (t.status === 'failed' || t.status === 'partial') c.failed++; - dn += t.done || 0; - tt += t.totalPages || 0; + // 解析阶段的任务还没进入下载,done 不计入总进度 + if (t.status !== 'parsing') { + dn += t.done || 0; + tt += t.totalPages || 0; + } } $('s-total').textContent = tasks.length; $('s-queued').textContent = c.queued; diff --git a/internal/core/engine.go b/internal/core/engine.go index ac9bd96..05144ba 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -273,6 +273,22 @@ type existingGallery struct { manifest *Manifest } +// reportParseProgress 解析阶段进度反馈。 +// +// 解析大画廊要翻上百屏(每屏 20 张),全程可能好几分钟。 +// 界面必须看得到在动,否则「解析中 0/?」会被当成卡死。 +func (e *Engine) reportParseProgress(task Task, done, total, page int) { + task.Status = "parsing" + task.TotalPages = total + task.Done = 0 + // 解析期用「已解析张数」当进度,下载阶段才是真正的完成数 + task.Pages = map[string]string{} + if done > 0 { + task.Pages["_parsing"] = strconv.Itoa(done) + } + e.save(task) +} + // findExistingGallery 在下载根目录里找这个 gid 已存在的画廊。 // // 两种匹配方式: @@ -366,7 +382,12 @@ func (e *Engine) runTask(ctx context.Context, task Task) { // 没有可用清单(或清单太旧)→ 正常解析画廊页 if gallery == nil { - g, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token) + g, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token, + func(done, total, page int) { + // 解析阶段也要有反馈,否则大画廊(上千张 = 上百屏) + // 会长时间显示「解析中 0/?」,看着像卡死。 + e.reportParseProgress(task, done, total, page) + }) if err != nil { if ctx.Err() != nil { e.markCanceled(task) @@ -464,7 +485,8 @@ func (e *Engine) runTask(ctx context.Context, task Task) { e.log("warn", fmt.Sprintf( "画廊 %s:本地清单的页面路径似乎已失效,重新解析画廊页", task.GID)) - if fresh, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token); err == nil { + if fresh, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token, + func(done, total, page int) { e.reportParseProgress(task, done, total, page) }); err == nil { // 用新解析结果重跑:重置页状态 st.reset(fresh) st.scanExisting() diff --git a/internal/eh/e2e_test.go b/internal/eh/e2e_test.go index f1c46b7..88cd6ee 100644 --- a/internal/eh/e2e_test.go +++ b/internal/eh/e2e_test.go @@ -110,7 +110,7 @@ func TestFetchGalleryPagePagination(t *testing.T) { defer func() { EHBase = oldBase }() c := NewClient(5*time.Second, "", "off", "auto") - g, err := c.FetchGalleryPage(context.Background(), gid, tok) + g, err := c.FetchGalleryPage(context.Background(), gid, tok, nil) if err != nil { t.Fatalf("FetchGalleryPage 失败: %v", err) } @@ -283,7 +283,7 @@ func TestFetchGalleryRestricted(t *testing.T) { defer func() { EHBase = oldBase }() c := NewClient(5*time.Second, "", "off", "auto") - _, err := c.FetchGalleryPage(context.Background(), "12345678", "abc") + _, err := c.FetchGalleryPage(context.Background(), "12345678", "abc", nil) if err == nil { t.Fatal("受限画廊应该报错") } diff --git a/internal/eh/eh.go b/internal/eh/eh.go index b295a4e..defc080 100644 --- a/internal/eh/eh.go +++ b/internal/eh/eh.go @@ -278,7 +278,8 @@ type Client struct { proxyMode string // off | http | socks5 xres string // auto | org | 数字(长边上限) - limiter *Limiter + transport *http.Transport // 全局共用,只有代理配置变了才重建 + limiter *Limiter } // NewClient 建客户端 @@ -319,6 +320,7 @@ func (c *Client) SetProxy(p, mode string) { c.proxyMode = mode } c.mu.Unlock() + c.rebuildTransport() // 代理变了,Transport 必须重建 } // SetXres 改分辨率策略 @@ -341,10 +343,32 @@ func (c *Client) snapshot() (string, string, time.Duration) { return c.proxy, c.proxyMode, c.timeout } -// httpClient 按当前代理配置构造 http.Client。 -// 每次调用新建(配置变更即时生效,且下载与页面请求的超时不同)。 +// httpClient 取可用的 http.Client。 +// +// ⚠️ Transport 必须复用:如果每次请求都新建 Transport,旧的那个会被丢弃, +// 但它持有的 keep-alive 连接不会立刻释放(要等 IdleConnTimeout), +// 于是每请求一条僵尸连接堆积起来 —— 实测能到 91 条, +// 既吃代理的连接配额,又让新请求变慢。 +// 所以这里只在「代理配置变化」时才重建 Transport,平时全局共用一个。 func (c *Client) httpClient(timeout time.Duration) *http.Client { - proxyURL, mode, defTimeout := c.snapshot() + c.mu.RLock() + tr := c.transport + defTimeout := c.timeout + c.mu.RUnlock() + + if tr == nil { + tr = c.rebuildTransport() + } + + if timeout <= 0 { + timeout = defTimeout + } + return &http.Client{Transport: tr, Timeout: timeout} +} + +// rebuildTransport 按当前代理配置重建 Transport,并关掉旧连接的空闲池 +func (c *Client) rebuildTransport() *http.Transport { + proxyURL, mode, _ := c.snapshot() tr := &http.Transport{ MaxIdleConns: 64, @@ -374,10 +398,26 @@ func (c *Client) httpClient(timeout time.Duration) *http.Client { } } - if timeout <= 0 { - timeout = defTimeout + c.mu.Lock() + old := c.transport + c.transport = tr + c.mu.Unlock() + + if old != nil { + old.CloseIdleConnections() + } + return tr +} + +// Close 关闭空闲连接(退出时调用) +func (c *Client) Close() { + c.mu.Lock() + tr := c.transport + c.transport = nil + c.mu.Unlock() + if tr != nil { + tr.CloseIdleConnections() } - return &http.Client{Transport: tr, Timeout: timeout} } func (c *Client) get(ctx context.Context, u string, timeout time.Duration) (*http.Response, error) { @@ -419,9 +459,19 @@ func (c *Client) getText(ctx context.Context, u string) (string, error) { // ---------------------------------------------------------------- 1) 画廊页 +// ParseProgress 解析进度回调 +// +// 大画廊要翻很多页(2000 张 = 100 页),没有进度反馈的话界面会一直显示 +// 「解析中 0/?」,看着像卡死了。 +// +// done = 已收集到的图片数 +// total = 站点声明的总图数(第一页才知道,可能为 0) +// page = 当前翻到第几屏(每屏 20 张) +type ParseProgress func(done, total, page int) + // FetchGalleryPage 取画廊页。注意:EH 每屏只列 20 张缩略图, // 必须翻 ?p=N 逐屏收集,否则静默只下到前 20 张且不报错。 -func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string) (*Gallery, error) { +func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string, onProgress ParseProgress) (*Gallery, error) { var ( pages []GalleryPageInfo seen = map[int]bool{} @@ -486,6 +536,13 @@ func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string) (*Gall if added == 0 { break // 这屏没新东西 -> 到头了 } + + // 报告进度:大画廊翻几十上百屏时,界面上要看得见在动 + // (放在 break 之前,保证最后一屏也会报告) + if onProgress != nil { + onProgress(len(pages), total, p+1) + } + if total > 0 && len(pages) >= total { break // 收齐 } diff --git a/internal/eh/transport_test.go b/internal/eh/transport_test.go new file mode 100644 index 0000000..52826a3 --- /dev/null +++ b/internal/eh/transport_test.go @@ -0,0 +1,248 @@ +package eh + +// 连接复用测试 +// +// 曾经的 bug:httpClient() 每次请求都新建 http.Transport,用完丢弃。 +// Transport 被丢弃后,它持有的 keep-alive 连接不会立刻释放(要等 +// IdleConnTimeout = 90s),于是每发一个请求就留下一条僵尸连接。 +// +// 实测踩坑现场:解析大画廊时 91 条 Established 连接,而解析是串行的, +// 正常应该只有 1 条。连接堆积会吃代理的连接配额,也让新请求变慢。 + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// buildGalleryHTML 造一页画廊 HTML +func buildGalleryHTML(gid string, from, to, total, screen int) string { + var sb strings.Builder + if screen == 0 { + sb.WriteString(`[Test] - E-Hentai Galleries`) + sb.WriteString(``) + sb.WriteString(`
`) + } + for i := from; i <= to; i++ { + sb.WriteString(fmt.Sprintf( + `
`, + i, gid, i, i, i)) + } + sb.WriteString(fmt.Sprintf(`Showing %d - %d of %d images`, from, to, total)) + if screen == 0 { + sb.WriteString(``) + } + return sb.String() +} + +// 统计服务端实际接受的连接数 +func TestTransportIsReused(t *testing.T) { + var conns int64 + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + srv.Config.ConnState = func(c net.Conn, state http.ConnState) { + if state == http.StateNew { + atomic.AddInt64(&conns, 1) + } + } + srv.Start() + defer srv.Close() + + c := NewClient(10*time.Second, "", "off", "auto") + + const n = 20 + for i := 0; i < n; i++ { + if _, err := c.getText(context.Background(), srv.URL+"/x"); err != nil { + t.Fatalf("第 %d 次请求失败: %v", i+1, err) + } + } + + got := atomic.LoadInt64(&conns) + // 连接复用后,20 次请求应该只用 1 条连接(串行)。 + // 修复前是每请求一条 = 20 条。 + if got > 3 { + t.Errorf("%d 次请求开了 %d 条连接 —— Transport 没复用,连接在泄漏", n, got) + } + t.Logf("%d 次请求共用了 %d 条连接", n, got) +} + +// 并发请求也应该复用连接,而不是每个 goroutine 一条 +func TestTransportReusedUnderConcurrency(t *testing.T) { + var conns int64 + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + srv.Config.ConnState = func(c net.Conn, state http.ConnState) { + if state == http.StateNew { + atomic.AddInt64(&conns, 1) + } + } + srv.Start() + defer srv.Close() + + c := NewClient(10*time.Second, "", "off", "auto") + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 5; j++ { + c.getText(context.Background(), srv.URL+"/x") + } + }() + } + wg.Wait() + + got := atomic.LoadInt64(&conns) + // 100 次并发请求,连接数应该远小于 100 + if got > 60 { + t.Errorf("100 次并发请求开了 %d 条连接,复用不充分", got) + } + t.Logf("100 次并发请求共用了 %d 条连接", got) +} + +// Close() 要真的能关掉空闲连接 +func TestClientCloseReleasesConnections(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + c := NewClient(10*time.Second, "", "off", "auto") + for i := 0; i < 5; i++ { + c.getText(context.Background(), srv.URL+"/x") + } + + c.mu.RLock() + tr := c.transport + c.mu.RUnlock() + if tr == nil { + t.Fatal("Transport 应该是复用的,不该为 nil") + } + + c.Close() + + c.mu.RLock() + still := c.transport + c.mu.RUnlock() + if still != nil { + t.Error("Close() 后 transport 应被清空") + } +} + +// 改代理配置后 Transport 要重建(否则还是走老代理) +func TestSetProxyRebuildsTransport(t *testing.T) { + c := NewClient(5*time.Second, "", "off", "auto") + + // 先建一个 + c.getText(context.Background(), "http://127.0.0.1:1/never") // 必然失败,只为触发建 Transport + c.mu.RLock() + first := c.transport + c.mu.RUnlock() + if first == nil { + t.Fatal("应该已经建好 Transport") + } + + // 改代理 + c.SetProxy("http://127.0.0.1:7890", "http") + + c.mu.RLock() + second := c.transport + c.mu.RUnlock() + if second == first { + t.Error("改代理后 Transport 必须重建,否则还走旧配置") + } + if second == nil { + t.Error("重建后不该是 nil") + } +} + +// 解析进度回调要真的被调用 +func TestParseProgressCallback(t *testing.T) { + const gid, tok, total = "4239999", "abcdef1234", 45 + + pages := map[int]string{} + for p := 0; p < 3; p++ { + from, to := p*20+1, p*20+20 + if to > total { + to = total + } + page := p + pages[page] = buildGalleryHTML(gid, from, to, total, page) + } + + mux := http.NewServeMux() + mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) { + p := 0 + if q := r.URL.Query().Get("p"); q != "" { + fmt.Sscanf(q, "%d", &p) + } + h, ok := pages[p] + if !ok { + h = buildGalleryHTML(gid, 0, 0, total, 9) + } + w.Write([]byte(h)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + old := EHBase + EHBase = srv.URL + defer func() { EHBase = old }() + + c := NewClient(5*time.Second, "", "off", "auto") + + var got []int + var maxDone int + g, err := c.FetchGalleryPage(context.Background(), gid, tok, func(done, tot, page int) { + got = append(got, done) + if done > maxDone { + maxDone = done + } + }) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if len(got) < 3 { + t.Errorf("应该至少回调 3 次(翻 3 屏),实际 %d 次", len(got)) + } + if maxDone != total { + t.Errorf("最后一次进度应达到 %d,实际 %d", total, maxDone) + } + if g.TotalPages != total { + t.Errorf("总页数不对: %d", g.TotalPages) + } + t.Logf("进度回调 %d 次: %v", len(got), got) +} + +// 传 nil 回调也不该 panic +func TestParseProgressNil(t *testing.T) { + const gid, tok, total = "4239998", "abcdef1234", 20 + + mux := http.NewServeMux() + mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(buildGalleryHTML(gid, 1, 20, total, 0))) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + old := EHBase + EHBase = srv.URL + defer func() { EHBase = old }() + + c := NewClient(5*time.Second, "", "off", "auto") + if _, err := c.FetchGalleryPage(context.Background(), gid, tok, nil); err != nil { + t.Errorf("nil 回调不该出错: %v", err) + } +} \ No newline at end of file