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" 后缀,最干净 ──
//
// ⚠️ 别抓