Files
2026-10-11 12:56:38 +08:00

999 lines
30 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package hd4k
// 4KHD 站点协议核心
//
// ── 与参考项目(E-Hentai)的根本差异 ──────────────────────────────
//
// 参考项目每个画廊要 2N+1 次请求:画廊页翻页拿 /s/ 链接,再逐页请求 /s/
// 换 hath 直链(随机子域 + 随机端口 + 时间戳签名),最后才下载图片。
//
// 4KHD 完全不同(2026-10-11 实测):
//
// ① 图片 URL 直接写在画廊页 HTML 里
// <p><a href="…"><img loading="lazy" src="…" width="1300" height="1500"></a><br />
// `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
// 末页之后 <link rel="next"> 消失 —— 这是唯一的「还有下一页」信号。
//
// ── 分辨率 ───────────────────────────────────────────────────────
//
// 页面里的 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" 后缀,最干净 ──
//
// ⚠️ 别抓 <h1> —— 那是站点名 "4KHD",不是画廊标题(实测 L205)。
reH3Title = regexp.MustCompile(`(?is)<h3[^>]*class=["'][^"']*wp-block-post-title[^"']*["'][^>]*>(.*?)</h3>`)
reTitle = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
reOGTitle = regexp.MustCompile(`(?is)<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']*)["']`)
// 分页总数:.page-link-box 里一次列全所有页码
reNumPagesItem = regexp.MustCompile(`(?is)<li[^>]*class=["'][^"']*numpages[^"']*["'][^>]*>(.*?)</li>`)
reLiInner = regexp.MustCompile(`(?s)<[^>]*>`)
reLiNum = regexp.MustCompile(`(\d+)`)
// 保留 rel=next 作为「总页数算错时的兜底」,也用于校验
reNextLink = regexp.MustCompile(`(?is)<link[^>]+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 里出现**两次** —— 一次在 <a href>,
// 一次在 <img src>,两者内容完全相同。不去重的话 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 实体(&amp; → &),保留 ?w=1300 这类关键参数
// (这个 w 值是实测唯一可用的分辨率变体,改成别的会 429)。
func normalizePhotonURL(raw string) string {
u := strings.TrimSpace(raw)
u = strings.TrimRight(u, `'"`)
u = strings.ReplaceAll(u, "&amp;", "&")
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 → <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 种(&#8211; &#038; &raquo;),但标题和分享链接里
// &#038; 特别容易漏 —— 不解的话目录名会带上一串数字。
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 进制解析:&#x41; 是 '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
}