From a078a00f4510a3bea918abb837e305b1c2ae4ee6 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 11 Oct 2026 11:41:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(eh):=20=E8=87=AA=E5=8A=A8=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=20EH=20=E7=9A=84=20Content=20Warning=20=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:部分画廊(多被标成 Offensive For Everyone)解析必然失败,报 「页面结构无法解析(EH 可能又改版)」——这条报错是误导的,跟改版无关。 根因:EH 对这类画廊第一次访问只返回一个 3~4KB 的黄框拦截页, 和 meta 都在,但一个 /s/ 链接都没有,正则自然一条都匹配不上。 放行需要请求一次 ?nw=always,而 EH 靠下发的 nw=1 Cookie 记住 「别再警告」——可 Client 根本没设 CookieJar,Cookie 下发即丢, 于是永远过不去。 实测:4166903 / 4021921 / 3799635 / 3627469 四个画廊修复后 分别解析出 45/95/36/73 页,与站点声明完全一致。 改动: - Client 持有 cookiejar 并挂到 http.Client.Jar (Go 1.26 起 http.Transport 已经没有 Jar 字段了,只能挂 Client) - 新增 getTextAutoWarn:撞上拦截页自动 ?nw=always 放行一次再重试, nw=1 进 jar 后全站不再警告 - 放行失败时的报错明说是内容警告,并提示去浏览器点一次, 不再甩锅给「EH 可能又改版」 --- internal/eh/contentwarning_test.go | 136 +++++++++++++++++++++++++++++ internal/eh/eh.go | 50 ++++++++++- 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 internal/eh/contentwarning_test.go diff --git a/internal/eh/contentwarning_test.go b/internal/eh/contentwarning_test.go new file mode 100644 index 0000000..cfbfcab --- /dev/null +++ b/internal/eh/contentwarning_test.go @@ -0,0 +1,136 @@ +package eh + +// 回归测试:EH「Content Warning」拦截页(2026-10 线上问题) +// +// 现象:部分画廊(多被标成 Offensive For Everyone)第一次访问只返回 +// 一个 3~4KB 的黄框页 —— <title> 和 meta 都在,但一个 /s/ 链接都没有, +// 旧代码因此报「页面结构无法解析(EH 可能又改版)」,纯属误导。 +// +// 放行方式:请求一次 ?nw=always,EH 会种下 nw=1 Cookie。 +// 关键是这个 Cookie 必须被记住,所以 Client 必须带 cookie jar。 + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// warnPage 复刻 EH 的内容警告页(关键特征:没有 /s/ 链接、没有 taglist) +func warnPage(gid, token string) string { + return fmt.Sprintf(`<!DOCTYPE html><html><head> +<title>[Some Circle] Flagged Gallery - E-Hentai Galleries + + +
+

Content Warning

+

This gallery has been flagged as Offensive For Everyone.

+

[View Gallery]

+

[Never Warn Me Again]

+
`, EHBase, gid, token, EHBase, gid, token) +} + +// 拦截页 + nw=always 放行(真实行为:带 nw=always 的请求会被种下 nw=1) +func TestFetchGalleryPageContentWarningAutoBypass(t *testing.T) { + const ( + gid = "4166903" + tok = "97d35eebb2" + ) + + real := mockGalleryHTML(t, gid, tok, 25)[0] + + mux := http.NewServeMux() + mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) { + // 真实 EH:看到 nw=always 就种下 nw=1 Cookie,然后进画廊 + if r.URL.Query().Get("nw") == "always" { + http.SetCookie(w, &http.Cookie{Name: "nw", Value: "1", Path: "/"}) + w.Write([]byte(real)) + return + } + c, err := r.Cookie("nw") + if err != nil || c.Value != "1" { + w.Write([]byte(warnPage(gid, tok))) // 还没放行 -> 拦截页 + return + } + w.Write([]byte(real)) // 已放行 -> 真正的画廊页 + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + oldBase := EHBase + EHBase = srv.URL + defer func() { EHBase = oldBase }() + + c := NewClient(5*time.Second, "", "off", "auto") + defer c.Close() + + g, err := c.FetchGalleryPage(context.Background(), gid, tok, nil) + if err != nil { + t.Fatalf("撞到内容警告页应该自动放行,不该失败: %v", err) + } + if len(g.Pages) == 0 { + t.Fatal("自动放行后应至少解析出 1 页") + } + if len(g.Pages) != 20 { + t.Errorf("第 1 屏应解析出 20 页,实际 %d", len(g.Pages)) + } + t.Logf("自动放行成功,解析出 %d 页(总 %d)", len(g.Pages), g.TotalPages) +} + +// 只在 jar 缺失时才会失败——所以单独锁住「Client 必须带 jar」 +func TestClientHasCookieJar(t *testing.T) { + c := NewClient(5*time.Second, "", "off", "auto") + defer c.Close() + if c.httpClient(0).Jar == nil { + t.Fatal("http.Client 没有 cookie jar —— EH 的 nw=1 记不住,内容警告页永远过不去") + } +} + +// 拦截页里识别不出来时的报错文案,不能再说「EH 可能又改版」了 +func TestContentWarningDetection(t *testing.T) { + if !isContentWarning(warnPage("1", "aa")) { + t.Error("应识别出内容警告页") + } + if !isContentWarning(`

Content Warning

`) { + t.Error("大小写/空白差异也应识别") + } + if isContentWarning(`thumb`) { + t.Error("正常画廊页不该被误判成警告页") + } +} + +func TestContentWarningErrorMessage(t *testing.T) { + const ( + gid = "4999999" + tok = "deadbeef01" + ) + // 永远只返回拦截页:即使放行也拿不到内容 + mux := http.NewServeMux() + mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(warnPage(gid, tok))) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + oldBase := EHBase + EHBase = srv.URL + defer func() { EHBase = oldBase }() + + c := NewClient(5*time.Second, "", "off", "auto") + defer c.Close() + + _, err := c.FetchGalleryPage(context.Background(), gid, tok, nil) + if err == nil { + t.Fatal("应当失败") + } + if !strings.Contains(err.Error(), "Offensive For Everyone") { + t.Errorf("报错应说明是内容警告,实际: %v", err) + } + if strings.Contains(err.Error(), "改版") { + t.Errorf("报错不该再提「改版」,那会误导排查: %v", err) + } +} diff --git a/internal/eh/eh.go b/internal/eh/eh.go index defc080..3de3bca 100644 --- a/internal/eh/eh.go +++ b/internal/eh/eh.go @@ -20,6 +20,7 @@ import ( "io" "net" "net/http" + "net/http/cookiejar" "net/url" "os" "regexp" @@ -280,6 +281,11 @@ type Client struct { transport *http.Transport // 全局共用,只有代理配置变了才重建 limiter *Limiter + + // jar 必须有:EH 对部分画廊先弹「Content Warning」拦截页, + // 放行靠的是它下发的 nw=1 Cookie。没有 jar 的话每次请求都是 + // 无状态连接,Cookie 下发即丢,于是永远过不去(2026-10 修)。 + jar http.CookieJar } // NewClient 建客户端 @@ -290,12 +296,14 @@ func NewClient(timeout time.Duration, proxyURL, proxyMode, xres string) *Client if xres == "" { xres = "auto" } + jar, _ := cookiejar.New(nil) // cookiejar.New 只在 nil options 时出错 return &Client{ timeout: timeout, proxy: proxyURL, proxyMode: proxyMode, xres: xres, limiter: NewLimiter(0), + jar: jar, } } @@ -363,7 +371,9 @@ func (c *Client) httpClient(timeout time.Duration) *http.Client { if timeout <= 0 { timeout = defTimeout } - return &http.Client{Transport: tr, Timeout: timeout} + // Jar 挂在 Client 上(Go 1.26 起 http.Transport 已经没有 Jar 字段了), + // 少一个 jar 就会退回无状态模式,EH 的 nw=1 记不住。 + return &http.Client{Transport: tr, Timeout: timeout, Jar: c.jar} } // rebuildTransport 按当前代理配置重建 Transport,并关掉旧连接的空闲池 @@ -457,6 +467,37 @@ func (c *Client) getText(ctx context.Context, u string) (string, error) { return string(b), nil } +// reContentWarning EH 的「Content Warning」拦截页特征。 +// +// 这类画廊(多被标成 Offensive For Everyone)第一次访问只会拿到一个 +// 3~4KB 的黄框页,标题和 meta 都在,但**一个 /s/ 链接都没有**, +// 于是旧代码直接报「页面结构无法解析(EH 可能又改版)」——纯误导。 +var reContentWarning = regexp.MustCompile(`(?i)

\s*Content Warning\s*

|nw=always`) + +func isContentWarning(html string) bool { + return reContentWarning.MatchString(html) +} + +// getTextAutoWarn 抓文本;撞上内容警告页就自动放行一次再重试。 +// +// 放行方式是请求一次 ?nw=always —— EH 会种下 nw=1 Cookie, +// 而 jar 会把它记住,之后所有画廊页、/s/ 页都不用再警告。 +func (c *Client) getTextAutoWarn(ctx context.Context, u string) (string, error) { + html, err := c.getText(ctx, u) + if err != nil || !isContentWarning(html) { + return html, err + } + + sep := "?" + if strings.Contains(u, "?") { + sep = "&" + } + if _, werr := c.getText(ctx, u+sep+"nw=always"); werr != nil { + return html, nil // 放行失败就原样返回,交给调用方判断 + } + return c.getText(ctx, u) +} + // ---------------------------------------------------------------- 1) 画廊页 // ParseProgress 解析进度回调 @@ -489,7 +530,7 @@ func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string, onProg if p > 0 { u += "?p=" + strconv.Itoa(p) } - html, err := c.getText(ctx, u) + html, err := c.getTextAutoWarn(ctx, u) if err != nil { return nil, fmt.Errorf("解析画廊页失败: %w", err) } @@ -552,6 +593,11 @@ func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string, onProg if reRestricted.MatchString(lastHTML) { return nil, fmt.Errorf("画廊受限或不存在(无 cookie 访问不了): %s", gid) } + if isContentWarning(lastHTML) { + return nil, fmt.Errorf( + "画廊被 EH 标记为「Offensive For Everyone」,自动放行没成功"+ + "(请在浏览器里打开一次并点 Never Warn Me Again): %s", gid) + } return nil, fmt.Errorf("页面结构无法解析(EH 可能又改版): %s", gid) }