first commit

This commit is contained in:
2026-10-11 12:56:38 +08:00
commit 7d21f317a4
41 changed files with 10758 additions and 0 deletions
+998
View File
@@ -0,0 +1,998 @@
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
}