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