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:
+41
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user