first commit
This commit is contained in:
@@ -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 实体(& → &),保留 ?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 → <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
|
||||
}
|
||||
Reference in New Issue
Block a user