feat: 双层并发 + 全局限流 + 整轮扫尾 + 画廊目录清单

原实现只能「多画廊并行,每个画廊串行下」,单张大画廊下得很慢。
按用户多年使用习惯(5 画廊 × 每画廊 8 张)改造为双层并发。

- 双层并发:画廊并发(默认5) × 页并发(默认8) = 最多 40 张同时下载,
  两个值都在设置面板可调(1-20)。页级 worker 池 + mutex 保护计数。
- 全局限流:对齐参考实现的 nextRequestTime 算法,限所有请求
  (含 /s/ 解析与图片本体),默认 0 秒关闭。
- 整轮扫尾:跑完后自动重扫失败页 1 轮(同样走完整 4 次退避重试),
  仍失败则停下并标记 partial,换线路后手动点开始即可续传。
- 画廊目录清单 .ehdl.json:记录 gid/token/总页数/已完成页,
  即使 state.json 丢失也能算出「应该多少张、还差多少张」。

顺带修复:
- Start() 双重加锁导致死锁(引擎完全无法启动)
- clampInt 越界时返回默认值而非边界值(设 30 会静默变 8)
- 直链正则写死 hath.network,CDN 换域名会全军覆没 -> 加图片 URL 兜底
- ReconcileTask 会误报「有改动」

测试从 37 增至 62,其中并发/限流/扫尾用 httptest 模拟 EH 实测:
- 页并发峰值 = 8、画廊并发峰值 = 5(完全生效)
- 12 次请求限流 80ms 实测 966ms(期望 880ms)
- 断点续传 4 张已存在时只发 5 次请求
This commit is contained in:
2026-10-09 00:11:38 +08:00
parent beb1dac980
commit 0447875b6d
17 changed files with 1981 additions and 130 deletions
+41 -5
View File
@@ -172,6 +172,8 @@ var (
// hath 全图 URL:随机子域 + 随机端口 + keystamp 签名
reHath = regexp.MustCompile(`(?i)https://[a-z0-9-]+(?:\.[a-z0-9-]+)*\.hath\.network(?::\d+)?/[^\s"'<>)\]]+`)
// 兜底:CDN 换域名时,从页面里抓第一个看起来像图片的 URL
reImgAny = regexp.MustCompile(`(?i)https?://[^\s"'<>)\]]+\.(?:png|jpe?g|webp|gif)(?:\?[^\s"'<>)\]]*)?`)
)
// parseMeta 从画廊页 HTML 的 #taglist 表格解析 艺术家/社团/分类/标签
@@ -275,6 +277,8 @@ type Client struct {
proxy string
proxyMode string // off | http | socks5
xres string // auto | org | 数字(长边上限)
limiter *Limiter
}
// NewClient 建客户端
@@ -285,7 +289,26 @@ func NewClient(timeout time.Duration, proxyURL, proxyMode, xres string) *Client
if xres == "" {
xres = "auto"
}
return &Client{timeout: timeout, proxy: proxyURL, proxyMode: proxyMode, xres: xres}
return &Client{
timeout: timeout,
proxy: proxyURL,
proxyMode: proxyMode,
xres: xres,
limiter: NewLimiter(0),
}
}
// SetRequestInterval 设置全局请求间隔(0 = 不限流)
func (c *Client) SetRequestInterval(d time.Duration) {
if d < 0 {
d = 0
}
c.limiter.SetInterval(d)
}
// RequestInterval 当前请求间隔
func (c *Client) RequestInterval() time.Duration {
return c.limiter.Interval()
}
// SetProxy 改代理配置
@@ -358,6 +381,11 @@ func (c *Client) httpClient(timeout time.Duration) *http.Client {
}
func (c *Client) get(ctx context.Context, u string, timeout time.Duration) (*http.Response, error) {
// 全局限流:所有请求(含 /s/ 解析与画廊页)统一排队
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
@@ -506,11 +534,14 @@ func (c *Client) ResolveImageURL(ctx context.Context, sPath string) (string, err
if err != nil {
return "", err
}
m := reHath.FindString(html)
if m == "" {
return "", fmt.Errorf("未能从 /s/ 页面解析出图片地址: %s", sPath)
if m := reHath.FindString(html); m != "" {
return c.applyXres(DecodeEntities(m)), nil
}
return c.applyXres(DecodeEntities(m)), nil
// CDN 换域名时的兜底:抓页面上第一个图片 URL
if m := reImgAny.FindString(html); m != "" {
return c.applyXres(DecodeEntities(m)), nil
}
return "", fmt.Errorf("未能从 /s/ 页面解析出图片地址: %s", sPath)
}
var (
@@ -542,6 +573,11 @@ func (c *Client) applyXres(u string) string {
func (c *Client) DownloadImage(ctx context.Context, imgURL, destPath string, onProgress func(received, total int64)) (int64, error) {
_, _, baseTimeout := c.snapshot()
// 图片本体也要限流
if err := c.limiter.Wait(ctx); err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil)
if err != nil {
return 0, err
+87
View File
@@ -0,0 +1,87 @@
package eh
// 全局限流器
//
// 算法对齐参考实现(E-Hentai Downloader 的「请求间隔」设置):
// 用一个全局的 nextRequestTime,每次请求都把自己排到它后面,再把它往后推。
//
// next = max(next, now) + interval
// 等待到 next 时刻再真正发出
//
// 效果是「所有请求之间至少间隔 interval 毫秒」,与并发数无关 ——
// 哪怕 40 个 worker 同时来,也会被串成每 interval 放行一个。
//
// interval = 0 时完全旁路,不产生任何额外开销。
import (
"context"
"sync"
"time"
)
// Limiter 全局请求节流器
type Limiter struct {
mu sync.Mutex
interval time.Duration
next time.Time // 下一个请求允许发出的时刻
}
// NewLimiter 建限流器(interval <= 0 表示不限流)
func NewLimiter(interval time.Duration) *Limiter {
if interval < 0 {
interval = 0
}
return &Limiter{interval: interval}
}
// SetInterval 运行时改间隔
func (l *Limiter) SetInterval(d time.Duration) {
if d < 0 {
d = 0
}
l.mu.Lock()
l.interval = d
l.mu.Unlock()
}
// Interval 当前间隔
func (l *Limiter) Interval() time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
return l.interval
}
// Wait 占用一个请求名额,必要时阻塞到放行时刻。
// 返回 error 是为了能响应取消(点「停止」时不该继续排队)。
func (l *Limiter) Wait(ctx context.Context) error {
l.mu.Lock()
interval := l.interval
if interval <= 0 {
l.mu.Unlock()
return ctx.Err()
}
now := time.Now()
// 关键:如果过去的排队已经堆积,next 可能远在未来,
// 这里取 max 让第一个请求立刻发出去,避免冷启动时集体等一大轮。
if l.next.Before(now) {
l.next = now
}
at := l.next
l.next = at.Add(interval)
l.mu.Unlock()
delay := time.Until(at)
if delay <= 0 {
return ctx.Err()
}
t := time.NewTimer(delay)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
+197
View File
@@ -0,0 +1,197 @@
package eh
// 限流器测试:验证「所有请求之间至少间隔 N 毫秒」,
// 以及并发情况下也不会穿透(这正是对齐参考脚本的关键行为)。
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// testWriter 是 httptest 响应写的最小接口
type testWriter interface {
WriteHeader(int)
Write([]byte) (int, error)
}
func newTestServer(t *testing.T, h func(testWriter)) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h(w)
}))
}
// interval=0 必须完全旁路,不能有任何额外延迟
func TestLimiterZeroIsNoop(t *testing.T) {
l := NewLimiter(0)
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
l.Wait(context.Background())
}()
}
wg.Wait()
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Errorf("interval=0 时不该有等待,实际耗时 %v", elapsed)
}
}
// 串行请求:每次都该等满间隔
func TestLimiterSerialSpacing(t *testing.T) {
const interval = 100 * time.Millisecond
l := NewLimiter(interval)
ctx := context.Background()
start := time.Now()
for i := 0; i < 3; i++ {
if err := l.Wait(ctx); err != nil {
t.Fatal(err)
}
}
elapsed := time.Since(start)
// 3 个请求 => 等 2 次间隔(第 1 个立即放行)
wantMin := 2 * interval
if elapsed < wantMin {
t.Errorf("3 个请求应至少等 %v,实际 %v(限流没生效)", wantMin, elapsed)
}
}
// 并发请求也不能穿透:N 个同时来,应该被拉成串行
func TestLimiterConcurrentSpacing(t *testing.T) {
const (
interval = 50 * time.Millisecond
n = 5
)
l := NewLimiter(interval)
ctx := context.Background()
start := time.Now()
release := make([]time.Time, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
if err := l.Wait(ctx); err != nil {
t.Error(err)
return
}
release[idx] = time.Now()
}(i)
}
wg.Wait()
elapsed := time.Since(start)
// n 个请求 => 至少等 (n-1) 个间隔
wantMin := time.Duration(n-1) * interval
if elapsed < wantMin {
t.Errorf("%d 个并发请求应至少耗时 %v,实际 %v(限流被穿透)", n, wantMin, elapsed)
}
}
// 取消要能立即返回,不能卡在排队里
func TestLimiterCancel(t *testing.T) {
l := NewLimiter(500 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
// 先占掉一个名额,让后面的排在后面
l.Wait(context.Background())
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
start := time.Now()
err := l.Wait(ctx)
if err == nil {
t.Error("取消后 Wait 应该返回错误")
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Errorf("取消后应立即返回,实际等��� %v", elapsed)
}
}
// 运行时改间隔要生效
func TestLimiterSetInterval(t *testing.T) {
l := NewLimiter(0)
if l.Interval() != 0 {
t.Error("初始间隔应为 0")
}
l.SetInterval(200 * time.Millisecond)
if l.Interval() != 200*time.Millisecond {
t.Errorf("改间隔失败: %v", l.Interval())
}
// 负数要被夹成 0
l.SetInterval(-100 * time.Millisecond)
if l.Interval() != 0 {
t.Errorf("负间隔应夹成 0,实际 %v", l.Interval())
}
}
// 负数构造要夹成 0
func TestNewLimiterNegative(t *testing.T) {
l := NewLimiter(-time.Second)
if l.Interval() != 0 {
t.Errorf("负间隔应夹成 0,实际 %v", l.Interval())
}
}
// Client 的限流开关
func TestClientRequestInterval(t *testing.T) {
c := NewClient(5*time.Second, "", "off", "auto")
if c.RequestInterval() != 0 {
t.Error("默认不该限流")
}
c.SetRequestInterval(150 * time.Millisecond)
if c.RequestInterval() != 150*time.Millisecond {
t.Errorf("设置失败: %v", c.RequestInterval())
}
c.SetRequestInterval(-5)
if c.RequestInterval() != 0 {
t.Errorf("负数应夹成 0,实际 %v", c.RequestInterval())
}
}
// 限流要真的作用到 HTTP 请求上(起个服务器实测)
func TestLimiterAppliesToRequests(t *testing.T) {
srv := newTestServer(t, func(w testWriter) {
w.WriteHeader(200)
w.Write([]byte("ok"))
})
defer srv.Close()
const interval = 60 * time.Millisecond
c := NewClient(5*time.Second, "", "off", "auto")
c.SetRequestInterval(interval)
start := time.Now()
for i := 0; i < 4; i++ {
if _, err := c.getText(context.Background(), srv.URL+"/x"); err != nil {
t.Fatalf("第 %d 次请求失败: %v", i+1, err)
}
}
elapsed := time.Since(start)
// 4 个请求 = 8 次 HTTP 调用?不 —— 这里只有 getText,一次一个请求,
// 但下载会额外算一次。总共 4 次请求,至少 3 个间隔。
wantMin := 3 * interval
if elapsed < wantMin {
t.Errorf("4 次请求应至少耗时 %v,实际 %v", wantMin, elapsed)
}
}