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 的黄框页 ——
和 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(`
+[Some Circle] Flagged Gallery - E-Hentai Galleries
+
+
+`, 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)
}