package hd4k // 4KHD 站点协议核心 // // ── 与参考项目(E-Hentai)的根本差异 ────────────────────────────── // // 参考项目每个画廊要 2N+1 次请求:画廊页翻页拿 /s/ 链接,再逐页请求 /s/ // 换 hath 直链(随机子域 + 随机端口 + 时间戳签名),最后才下载图片。 // // 4KHD 完全不同(2026-10-11 实测): // // ① 图片 URL 直接写在画廊页 HTML 里 //


// `loading="lazy"` 只是浏览器原生懒加载,src 已经在源码里,不是 AJAX 注入。 // ② URL 稳定不变 —— Jetpack Photon 图床的路径由文件哈希决定,不含签名/时间戳 // ③ 无防盗链 —— 裸请求(无 Referer、无 Cookie)实测 200 // // 所以一个画廊只需要 N 次请求(N = 分页数),而且拿到的 URL 可以直接存进 // 清单复用,下次开程序连解析都不用做。 // // ── 分页规则 ───────────────────────────────────────────────────── // // 第 1 页 /content/09/myua-sweetbox-vol52.html // 第 2 页 /content/09/myua-sweetbox-vol52.html/2 // 末页之后 消失 —— 这是唯一的「还有下一页」信号。 // // ── 分辨率 ─────────────────────────────────────────────────────── // // 页面里的 URL 走 Jetpack Photon 代理(i0.wp.com),但我们**不直接用它**。 // 批量下载会打爆 Photon 免费 CDN 的额度,被封后连裸请求都 429 // ("Access has been disabled due to excessive usage"),而同一时刻 // 源站 pic.4khd.com 零防盗链、20 并发零失败。 // 所以解析时一律还原成源站直链(见 OriginURL)。 import ( "context" "errors" "fmt" "io" "net" "net/http" "net/http/cookiejar" "net/url" "os" "regexp" "strconv" "strings" "sync" "time" "golang.org/x/net/proxy" ) const defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" // ---------------------------------------------------------------- 数据结构 // ImageInfo 一张图片的信息 type ImageInfo struct { Index int `json:"index"` // 1 起,按页面顺序 URL string `json:"url"` // 图片直链(稳定,可存清单) Filename string `json:"filename"` // 原始文件名(含序号,如 xxx-001.webp) } // GalleryMeta 画廊的附加信息(4KHD 没有 EH 那套 artist/group/tag 标签体系) type GalleryMeta struct { Category string `json:"category"` // 路径里的分类数字,如 "09" Slug string `json:"slug"` Host string `json:"host"` } // Gallery 一个画廊的完整信息 type Gallery struct { ID string `json:"id"` URL string `json:"url"` Host string `json:"host"` Path string `json:"path"` Slug string `json:"slug"` Title string `json:"title"` TotalPages int `json:"totalPages"` // HTML 分页数(不是图片数) Total int `json:"total"` // 图片总数 Images []ImageInfo `json:"images"` Meta GalleryMeta `json:"meta"` } // ---------------------------------------------------------------- HTTP 客户端 // Client 站点 HTTP 客户端 type Client struct { mu sync.RWMutex timeout time.Duration proxy string proxyMode string // off | http | socks5 sites []Site transport *http.Transport // 全局共用,只有代理配置变了才重建 limiter *Limiter jar http.CookieJar } // NewClient 建客户端 func NewClient(timeout time.Duration, proxyURL, proxyMode string, sites []Site) *Client { if timeout <= 0 { timeout = 30 * time.Second } if len(sites) == 0 { sites = DefaultSites() } jar, _ := cookiejar.New(nil) return &Client{ timeout: timeout, proxy: proxyURL, proxyMode: proxyMode, sites: sites, limiter: NewLimiter(0), jar: jar, } } // SetSites 换站点列表(用户在设置里改了域名) func (c *Client) SetSites(sites []Site) { c.mu.Lock() c.sites = sites c.mu.Unlock() } // Sites 当前站点列表 func (c *Client) Sites() []Site { c.mu.RLock() defer c.mu.RUnlock() return append([]Site(nil), c.sites...) } // 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() } // Cooldown 触发全局冷却:所有 worker 一起等 d 再继续。 // 遇到 429 时由引擎调用 —— 各自退避会持续往已限流的服务器打请求, // 一起停下才缓得过来。详见 Limiter.Cooldown。 func (c *Client) Cooldown(d time.Duration) { c.limiter.Cooldown(d) } // CooldownLeft 剩余冷却时长(0 = 没在冷却) func (c *Client) CooldownLeft() time.Duration { return c.limiter.CooldownLeft() } // SetProxy 改代理配置 func (c *Client) SetProxy(p, mode string) { c.mu.Lock() c.proxy = p if mode != "" { c.proxyMode = mode } c.mu.Unlock() c.rebuildTransport() } // SetTimeout 改超时 func (c *Client) SetTimeout(d time.Duration) { c.mu.Lock() c.timeout = d c.mu.Unlock() } func (c *Client) snapshot() (string, string, time.Duration, []Site) { c.mu.RLock() defer c.mu.RUnlock() return c.proxy, c.proxyMode, c.timeout, append([]Site(nil), c.sites...) } func (c *Client) httpClient(timeout time.Duration) *http.Client { 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, Jar: c.jar} } func (c *Client) rebuildTransport() *http.Transport { proxyURL, mode, _, _ := c.snapshot() tr := &http.Transport{ MaxIdleConns: 64, MaxIdleConnsPerHost: 16, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ForceAttemptHTTP2: true, } if proxyURL != "" && mode != "off" && mode != "" { switch mode { case "socks5": raw := strings.TrimPrefix(strings.TrimPrefix(proxyURL, "socks5://"), "socks5h://") var auth *proxy.Auth if u, err := url.Parse("socks5://" + raw); err == nil && u.User != nil { pw, _ := u.User.Password() auth = &proxy.Auth{User: u.User.Username(), Password: pw} } if dialer, err := proxy.SOCKS5("tcp", raw, auth, proxy.Direct); err == nil { tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.Dial(network, addr) } } default: if p, err := url.Parse(proxyURL); err == nil { tr.Proxy = http.ProxyURL(p) } } } 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() } } func (c *Client) get(ctx context.Context, u, referer string) (*http.Response, error) { 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 } req.Header.Set("User-Agent", defaultUA) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "en-US,en;q=0.9") if referer != "" { req.Header.Set("Referer", referer) } resp, err := c.httpClient(0).Do(req) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 400 { resp.Body.Close() return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, u) } return resp, nil } func (c *Client) getText(ctx context.Context, u, referer string) (string, error) { resp, err := c.get(ctx, u, referer) if err != nil { return "", err } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(b), nil } // ---------------------------------------------------------------- 画廊解析 // ParseProgress 解析进度回调(已解析的图片数 / 当前页 / 已读页数) type ParseProgress func(images int, page int, pagesFetched int) // maxPages 防御性上限:防止站点分页结构异常导致无限翻页 const maxPages = 2000 // parseConcurrency 并发抓分页的并发数 // // 与参考项目(EH)的关键差别:EH 必须串行翻页(下一页的 /s/ 链接要 // 从上一页解析结果里拿),4KHD 的每页是**完全独立**的 —— 总页数在 // 第 1 页的 .page-link-box 里就一次列全了,剩下的页可以同时抓。 // 130 页的画廊从串行 130 次往返变成 2 波并发。 const parseConcurrency = 4 // FetchGallery 解析一个画廊:抓完所有分页,收集全部图片直链。 // // 这是整个协议里唯一「多请求」的环节,但 4KHD 的图片直链是稳定哈希路径, // 拿到后可以存进清单长期复用 —— 所以清单命中时根本不会走到这里。 func (c *Client) FetchGallery(ctx context.Context, ref GalleryRef, onProgress ParseProgress) (*Gallery, error) { _, _, _, sites := c.snapshot() _, _ = FindSite(sites, ref.Host) // 校验站点已知(未知站点也放行) // Referer 用画廊自身的 origin —— 比拿站点配置硬拼更准: // 用户可能粘了个 http 链接或带端口的地址。 referer := OriginOf(ref.URL) + "/" g := &Gallery{ ID: ref.ID, URL: ref.URL, Host: ref.Host, Path: ref.Path, Slug: ref.Slug, Title: ref.Title, Images: []ImageInfo{}, Meta: GalleryMeta{Slug: ref.Slug, Host: ref.Host, Category: CategoryFromPath(ref.Path)}, } // ── 第 1 页:拿标题 + 总页数 ────────────────────────────── html1, err := c.getText(ctx, ref.URL, referer) if err != nil { return nil, fmt.Errorf("抓取画廊页失败: %w", err) } first := ParseGalleryPageHTML(html1) if g.Title == "" { g.Title = first.Title } if first.TotalPages < 1 { first.TotalPages = 1 } totalPages := first.TotalPages if totalPages > maxPages { totalPages = maxPages } // 收集:页码 -> 该页解析结果 parsed := make([]ParsedPage, totalPages) parsed[0] = first // ── 并发抓取第 2..N 页 ──────────────────────────────────── if totalPages > 1 { type result struct { page int pp ParsedPage err error } sem := make(chan struct{}, parseConcurrency) ch := make(chan result, totalPages-1) for p := 2; p <= totalPages; p++ { sem <- struct{}{} go func(page int) { defer func() { <-sem }() if ctx.Err() != nil { ch <- result{page: page, err: ctx.Err()} return } u := PageURL(ref.URL, page) h, err := c.getText(ctx, u, referer) if err != nil { ch <- result{page: page, err: fmt.Errorf("抓取第 %d 页失败: %w", page, err)} return } ch <- result{page: page, pp: ParseGalleryPageHTML(h)} }(p) } var firstErr error for i := 0; i < totalPages-1; i++ { r := <-ch if r.err != nil { if firstErr == nil { firstErr = r.err } continue } // 页码是 1-based,parsed 是 0-based → 必须减 1。 // (写成 parsed[r.page] 会让最后一页越界 panic,e2e 测试抓到过。) if r.page >= 1 && r.page <= totalPages { parsed[r.page-1] = r.pp } if onProgress != nil { onProgress(0, r.page, totalPages) } } if firstErr != nil && ctx.Err() == nil { // 部分页失败不致命:已有的图照下,失败的页不写进清单, // 下次运行会从清单重新解析补齐。 if len(collectImages(parsed)) == 0 { return nil, firstErr } } } // ── 汇总 ────────────────────────────────────────────────── // 去重:防止两页之间出现重叠导致同一张图下两次 seen := map[string]bool{} for _, pp := range parsed { if pp.Title != "" && g.Title == "" { g.Title = pp.Title } for _, img := range pp.Images { if seen[img.URL] { continue } seen[img.URL] = true g.Images = append(g.Images, img) } } if len(g.Images) == 0 { return nil, fmt.Errorf("画廊页里没有解析到任何图片(站点可能改版了)") } renameUnique(g.Images) g.TotalPages = totalPages g.Total = len(g.Images) if onProgress != nil { onProgress(g.Total, totalPages, totalPages) } if g.Title == "" { g.Title = strings.TrimSuffix(g.Slug, ".html") } return g, nil } // collectImages 收集所有已成功解析页的图片(判断「是否至少有一页成功」用) func collectImages(pages []ParsedPage) []ImageInfo { var out []ImageInfo for _, p := range pages { out = append(out, p.Images...) } return out } // CategoryFromPath 从 /content/{数字}/{slug}.html 里取分类数字 func CategoryFromPath(p string) string { const marker = "/content/" i := strings.Index(p, marker) if i < 0 { return "" } rest := p[i+len(marker):] parts := strings.SplitN(rest, "/", 2) if len(parts) != 2 { return "" } return parts[0] } // renameUnique 重新编号并保证文件名唯一 func renameUnique(imgs []ImageInfo) { used := map[string]int{} for i := range imgs { imgs[i].Index = i + 1 name := imgs[i].Filename if name == "" { name = fmt.Sprintf("%04d.webp", imgs[i].Index) } if n, dup := used[name]; dup { used[name] = n + 1 name = uniqueVariant(name, n+1) imgs[i].Filename = name used[imgs[i].Filename] = 1 continue } used[name] = 1 imgs[i].Filename = name } } // uniqueVariant 给重名文件加后缀:a-001.webp → a-001(2).webp func uniqueVariant(name string, n int) string { ext := "" base := name if i := strings.LastIndex(name, "."); i > 0 { base, ext = name[:i], name[i:] } return fmt.Sprintf("%s(%d)%s", base, n, ext) } // absolutize 把相对链接补成绝对链接 func absolutize(href, base string) string { if href == "" { return "" } if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { return href } b, err := url.Parse(base) if err != nil { return "" } r, err := url.Parse(href) if err != nil { return "" } return b.ResolveReference(r).String() } // ---------------------------------------------------------------- 下载 // StatusError HTTP 非 2xx 的错误,带上状态码与 Retry-After。 // // 引擎靠它区分「普通失败」和「限流」:限流要全局冷却所有 worker, // 而不是各自退避重试(那样会持续往已限流的服务器上打请求,缓不过来)。 type StatusError struct { Code int RetryAfter time.Duration // 服务器要求的等待时长,0 = 没给 URL string } func (e *StatusError) Error() string { if e.RetryAfter > 0 { return fmt.Sprintf("HTTP %d 限流,服务器要求等 %ds (%s)", e.Code, int(e.RetryAfter.Seconds()), e.URL) } return fmt.Sprintf("HTTP %d 下载图片失败 (%s)", e.Code, e.URL) } // IsRateLimited 判断是不是限流类错误(429 / 503 / 403) func IsRateLimited(err error) bool { var se *StatusError if errors.As(err, &se) { switch se.Code { case 429, 503, 403: return true } } return false } // DownloadImage 流式下载到 destPath(先写 .part 再 rename,保证原子性) func (c *Client) DownloadImage(ctx context.Context, imgURL, destPath, referer string, onProgress func(received, total int64)) (int64, error) { _, _, baseTimeout, _ := c.snapshot() if err := c.limiter.Wait(ctx); err != nil { return 0, err } // 兜底:老清单里存的可能是 Photon 代理 URL,会被 429 封禁。 // 解析阶段已经转过一次,这里是为续传老任务再兜一次。 imgURL = OriginURL(imgURL) req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil) if err != nil { return 0, err } req.Header.Set("User-Agent", defaultUA) req.Header.Set("Accept", "image/webp,image/*,*/*;q=0.8") // 实测无防盗链,裸请求也能 200;带上 Referer 只是更保险 if referer != "" { req.Header.Set("Referer", referer) } resp, err := c.httpClient(baseTimeout * 2).Do(req) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 400 { return 0, &StatusError{ Code: resp.StatusCode, RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After")), URL: imgURL, } } tmp := destPath + ".part" f, err := os.Create(tmp) if err != nil { return 0, err } received, copyErr := io.Copy(f, &progressReader{ r: resp.Body, total: resp.ContentLength, onProgress: onProgress, }) closeErr := f.Close() if copyErr != nil { os.Remove(tmp) return 0, copyErr } if closeErr != nil { os.Remove(tmp) return 0, closeErr } if received == 0 { os.Remove(tmp) return 0, errors.New("下载内容为空") } if err := os.Rename(tmp, destPath); err != nil { os.Remove(tmp) return 0, err } return received, nil } type progressReader struct { r io.Reader total int64 received int64 onProgress func(received, total int64) } func (p *progressReader) Read(b []byte) (int, error) { n, err := p.r.Read(b) if n > 0 { p.received += int64(n) if p.onProgress != nil { p.onProgress(p.received, p.total) } } return n, err } // ---------------------------------------------------------------- HTML 解析 var ( // 正文图片 URL:Jetpack Photon 的 i0/i1/i2.wp.com CDN // 域名不写死成 i0 —— Photon 有多个分片域名,写死会在切片后全部失效。 rePhoton = regexp.MustCompile(`https://i\d+\.wp\.com/[^\s"'<>\\)]+`) // ── 以下四条是「相关推荐区」图片的固定特征(2026-10-11 三页实测)── // // 不能靠路径第 3 段的时间戳区分 —— 实测正文图和推荐图的第 3 段形式完全 // 一样(/-erMImPpOefE/ash78JqNF8l/AAAAAAAA-Mc/ vs /Y0YTNkV1Y8l/AAAAAAACJVk/), // 都是 base36 时间戳,正文图里根本不存在「特别早」的时间戳。 reRecommendCover = regexp.MustCompile(`4KHD-beautifulGirls\.webp`) // 推荐位统一封面 reRecommendSize = regexp.MustCompile(`/w1090-h1500-p-k-no-rw/`) // 推荐位尺寸段 reRecommendQuery = regexp.MustCompile(`\?resize=`) // 推荐位查询参数 // 宽松兜底:任何 http(s) 的图片直链。 // // 为什么不直接用它当主路径:实测正文图和推荐图走的是**同一个 CDN** // (都是 pic.4khd.com/… 挂到 i0.wp.com),域名本身区分不了两者, // 只能靠上面那三个特征区分。所以 wp.com 匹配是首选(实测 3 页 53 张 // 全对),只有它颗粒无收时才退到这里 —— 宁可多过滤,也不整页失败。 reAnyImage = regexp.MustCompile(`https?://[^\s"'<>\\)]+\.(?:webp|jpe?g|png|gif)(?:\?[^\s"'<>\\)]*)?`) // ── 标题:h3 是页面可见标题且没有 " - 4KHD" 后缀,最干净 ── // // ⚠️ 别抓

—— 那是站点名 "4KHD",不是画廊标题(实测 L205)。 reH3Title = regexp.MustCompile(`(?is)]*class=["'][^"']*wp-block-post-title[^"']*["'][^>]*>(.*?)

`) reTitle = regexp.MustCompile(`(?is)]*>(.*?)`) reOGTitle = regexp.MustCompile(`(?is)]+property=["']og:title["'][^>]+content=["']([^"']*)["']`) // 分页总数:.page-link-box 里一次列全所有页码 reNumPagesItem = regexp.MustCompile(`(?is)]*class=["'][^"']*numpages[^"']*["'][^>]*>(.*?)`) reLiInner = regexp.MustCompile(`(?s)<[^>]*>`) reLiNum = regexp.MustCompile(`(\d+)`) // 保留 rel=next 作为「总页数算错时的兜底」,也用于校验 reNextLink = regexp.MustCompile(`(?is)]+rel=["']next["'][^>]*>`) reHref = regexp.MustCompile(`(?is)href=["']([^"']+)["']`) // HTML 标签(取 textContent 时用) reTag = regexp.MustCompile(`(?s)<[^>]*>`) reNumPhotos = regexp.MustCompile(`(\d+)\s*photos`) ) // ParsedPage 单页解析结果 type ParsedPage struct { Title string Images []ImageInfo Next string TotalPages int // 从 .page-link-box 读到的总页数(0 = 读不到) } // ParseGalleryPageHTML 解析一页画廊 HTML(纯函数,可独立测试) func ParseGalleryPageHTML(html string) ParsedPage { p := ParsedPage{Images: []ImageInfo{}, TotalPages: extractTotalPages(html)} p.Title = extractTitle(html) p.Next = extractNext(html) // ⚠️ 必须按页去重:每张图在 HTML 里出现**两次** —— 一次在 , // 一次在 ,两者内容完全相同。不去重的话 20 张会解析成 40 张 // (真实样本夹具 p1=40 / p2=40 / p3=26,正是这个 bug 被测出来的)。 // // 两级策略: // 1) 先只认 Jetpack Photon(i\d+.wp.com)—— 实测最准,53/53 全对; // 2) 一张都没有时(站点换 CDN、或本地 mock)才退到宽松匹配。 // 否则整个画廊会直接报「没解析到图片」而彻底下不了。 seen := map[string]bool{} add := func(raw string) { u := normalizePhotonURL(raw) if u == "" || seen[u] { return } if isRecommendImage(u) { return } seen[u] = true // 存源站直链:Photon 免费 CDN 会被批量下载打爆额度而 429 封禁, // 源站没这个限制(实测 20 并发零失败)。详见 OriginURL 注释。 origin := OriginURL(u) seen[origin] = true // 同一张图的代理版和源站版算同一张 p.Images = append(p.Images, ImageInfo{URL: origin, Filename: filenameFromURL(origin)}) } for _, raw := range rePhoton.FindAllString(html, -1) { add(raw) } if len(p.Images) == 0 { for _, raw := range reAnyImage.FindAllString(html, -1) { add(raw) } } return p } // isRecommendImage 判断是不是「相关推荐」区的图(不是正文图) func isRecommendImage(u string) bool { return reRecommendCover.MatchString(u) || reRecommendSize.MatchString(u) || reRecommendQuery.MatchString(u) } // extractTotalPages 从 .page-link-box 的 li.numpages 数量取总页数 func extractTotalPages(html string) int { items := reNumPagesItem.FindAllStringSubmatch(html, -1) if len(items) == 0 { return 0 } max := 0 for _, it := range items { inner := reLiInner.ReplaceAllString(it[1], "") m := reLiNum.FindString(inner) if m == "" { continue } n, err := strconvAtoi(m) if err != nil { continue } if n > max { max = n } } return max } // normalizePhotonURL 规整化图片 URL: // 去掉 HTML 实体(& → &),保留 ?w=1300 这类关键参数 // (这个 w 值是实测唯一可用的分辨率变体,改成别的会 429)。 func normalizePhotonURL(raw string) string { u := strings.TrimSpace(raw) u = strings.TrimRight(u, `'"`) u = strings.ReplaceAll(u, "&", "&") return u } // OriginURL 把 Jetpack Photon 的代理 URL 还原成源站直链。 // // ⚠️ 这是 2026-10-11 实测逼出来的改动:批量下载走 Photon 会打爆 // WordPress 免费 CDN 的额度,被封禁后**连裸请求都返回 429**, // 响应体是 "Access has been disabled due to excessive usage"。 // 同一时刻浏览器还能加载,是因为浏览器那点量远没到限额。 // // 实测对照(同一张图,同一时刻): // // Photon 代理 → HTTP 429(151 字节) // 源站直连 → HTTP 200(128056 字节,与 Photon 正常时字节数完全一致) // 源站 20 并发 → 20/20 成功,零限流 // // Photon 只是代理,URL 形如 // // https://i0.wp.com/pic.4khd.com/<路径>?w=1300 // // 去掉前缀即得源站直链(查询串是 Photon 的 resize 参数,源站不认,须去掉)。 // 源站路径里的 w1300-rw 段是原站自己的缩略图目录,与 Photon 的 ?w= 无关, // 所以去掉查询串后画质不变(字节数已验证)。 // // 非 Photon 的 URL 原样返回,不做任何改动。 func OriginURL(raw string) string { low := strings.ToLower(raw) idx := strings.Index(low, "://i") if idx < 0 { return raw } rest := low[idx+3:] dot := strings.Index(rest, ".wp.com/") if dot < 0 { return raw } scheme := raw[:idx] origin := scheme + "://" + raw[idx+3+dot+len(".wp.com/"):] if i := strings.IndexAny(origin, "?#"); i >= 0 { origin = origin[:i] } return origin } // IsPhoton 判断是不是 Jetpack Photon 的代理 URL func IsPhoton(raw string) bool { low := strings.ToLower(raw) idx := strings.Index(low, "://i") if idx < 0 { return false } return strings.Index(low[idx+3:], ".wp.com/") >= 0 } // parseRetryAfter 解析 Retry-After 头(RFC 7231 允许秒数或 HTTP 日期) func parseRetryAfter(v string) time.Duration { v = strings.TrimSpace(v) if v == "" { return 0 } if secs, err := strconv.Atoi(v); err == nil { if secs <= 0 { return 0 } if secs > 3600 { // 服务器要我们等一小时以上,直接封顶 1 小时 return time.Hour } return time.Duration(secs) * time.Second } if t, err := http.ParseTime(v); err == nil { if d := time.Until(t); d > 0 { if d > time.Hour { return time.Hour } return d } } return 0 } // filenameFromURL 从 URL 取文件名(去掉查询串) func filenameFromURL(u string) string { s := u if i := strings.IndexAny(s, "?#"); i >= 0 { s = s[:i] } i := strings.LastIndex(s, "/") if i < 0 { return "" } name := s[i+1:] if unesc, err := url.PathUnescape(name); err == nil { name = unesc } return name } // extractTitle 提取画廊标题 // // 优先级:h3.wp-block-post-title → → og:title // // ⚠️ 空白必须折叠:实测 h3 里是**双空格**("Myu_a(뮤아)␣␣SWEETBOX", // 且分享链接里 %20%20 证实双空格才是原始标题),而 <title>/og:title // 被 WordPress 规范化成了单空格。严格字符串比较会不相等。 // cleanText 里的 strings.Fields+Join 已经把这件事处理掉了。 func extractTitle(html string) string { for _, re := range []*regexp.Regexp{reH3Title, reTitle, reOGTitle} { m := re.FindStringSubmatch(html) if m == nil { continue } t := cleanText(m[1]) t = stripTitleSuffix(t) if t != "" { return t } } return "" } // stripTitleSuffix 去掉站点后缀。 // // "Myu_a …[190MB-53photos] - 4KHD" → "Myu_a …[190MB-53photos]" // // h3 本来就没有这个后缀,这一步是为 <title>/og:title 兜底的。 func stripTitleSuffix(t string) string { if i := strings.Index(t, " - 4KHD"); i >= 0 { t = t[:i] } return strings.TrimSpace(t) } // cleanText 去标签 + 解实体 + 压空白 func cleanText(s string) string { s = reTag.ReplaceAllString(s, "") s = DecodeEntities(s) s = strings.Join(strings.Fields(s), " ") return strings.TrimSpace(s) } func extractNext(html string) string { m := reNextLink.FindString(html) if m == "" { return "" } hm := reHref.FindStringSubmatch(m) if hm == nil { return "" } return strings.TrimSpace(hm[1]) } var ( reNumEntity = regexp.MustCompile(`&#(\d+);`) reHexEntity = regexp.MustCompile(`&#[xX]([0-9a-fA-F]+);`) reNamedEnt = map[string]string{ "amp": "&", "lt": "<", "gt": ">", "quot": `"`, "apos": "'", "nbsp": " ", "lowbar": "_", "sol": "/", "period": ".", "comma": ",", "lpar": "(", "rpar": ")", "num": "#", "excl": "!", "quest": "?", "commat": "@", "dollar": "$", "percnt": "%", "ast": "*", "plus": "+", "equals": "=", "verbar": "|", "bsol": `\`, "grave": "`", "Hat": "^", "colon": ":", "semi": ";", "lsqb": "[", "rsqb": "]", "lcub": "{", "rcub": "}", "dash": "-", "hellip": "…", "mdash": "—", "ndash": "–", "rsquo": "’", "lsquo": "‘", "rdquo": "”", "ldquo": "“", "middot": "·", "deg": "°", "raquo": "»", "laquo": "«", "copy": "©", "reg": "®", "trade": "™", "eacute": "é", "egrave": "è", "agrave": "à", "uuml": "ü", "ouml": "ö", "auml": "ä", "szlig": "ß", "euro": "€", "pound": "£", "yen": "¥", "sect": "§", "para": "¶", "middot2": "·", "bull": "•", "dagger": "†", } reNamedEntity = regexp.MustCompile(`&([a-zA-Z][a-zA-Z0-9]*);`) ) // DecodeEntities 解 HTML 实体。 // // 实测这个站正文里只出现 3 种(– & »),但标题和分享链接里 // & 特别容易漏 —— 不解的话目录名会带上一串数字。 func DecodeEntities(s string) string { s = reNumEntity.ReplaceAllStringFunc(s, func(m string) string { n, err := strconvAtoiBase(m[2:len(m)-1], 10) if err != nil || n <= 0 || n > 0x10FFFF { return m } return string(rune(n)) }) s = reHexEntity.ReplaceAllStringFunc(s, func(m string) string { // ⚠️ 必须按 16 进制解析:A 是 'A'(65),不是 0x41 当十进制的 ')'。 n, err := strconvAtoiBase(m[3:len(m)-1], 16) if err != nil || n <= 0 || n > 0x10FFFF { return m } return string(rune(n)) }) s = reNamedEntity.ReplaceAllStringFunc(s, func(m string) string { if v, ok := reNamedEnt[m[1:len(m)-1]]; ok { return v } return m }) return s } func strconvAtoi(s string) (int, error) { return strconvAtoiBase(s, 10) } // strconvAtoiBase 十进制/十六进制都能解(不引 strconv 包,省一个 import) func strconvAtoiBase(s string, base int) (int, error) { if s == "" { return 0, errors.New("空") } n := 0 for _, r := range s { var d int switch { case r >= '0' && r <= '9': d = int(r - '0') case base == 16 && r >= 'a' && r <= 'f': d = int(r-'a') + 10 case base == 16 && r >= 'A' && r <= 'F': d = int(r-'A') + 10 default: return 0, errors.New("非法数字") } n = n*base + d if n > 0x10FFFF { return n, nil } } return n, nil } // TotalFromTitle 从标题的 [190MB-53photos] 里取图片数(辅助信息,日志用) func TotalFromTitle(t string) int { m := reNumPhotos.FindStringSubmatch(t) if m == nil { return 0 } n, err := strconvAtoi(m[1]) if err != nil { return 0 } return n }