作为右边界(若不存在则取到末尾)。
endIdx := strings.Index(html[startIdx:], `
/- 这种"单页"链接。
if !strings.Contains(href, "/s/") {
continue
}
// 绝对 URL 直接保留;相对路径保持原样,由 absURL 解析。
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
out = append(out, href)
} else {
out = append(out, href)
}
}
return out
}
// absURL 把 href 解析成基于 base 的绝对 URL。
func absURL(base, href string) (string, error) {
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href, nil
}
b, err := url.Parse(base)
if err != nil {
return "", err
}
r, err := url.Parse(href)
if err != nil {
return "", err
}
return b.ResolveReference(r).String(), nil
}
// extractImageURL 从单页响应中抽取图片直链。
func extractImageURL(html string) (string, error) {
if m := reImgID.FindStringSubmatch(html); m != nil {
return htmlDecodeEntities(m[1]), nil
}
if m := reFullImg.FindStringSubmatch(html); m != nil {
return htmlDecodeEntities(m[1]), nil
}
if m := reAfterScriptIfr.FindStringSubmatch(html); m != nil {
return htmlDecodeEntities(m[1]), nil
}
return "", ErrParseSinglePage
}
// htmlDecodeEntities 解码少量常见 HTML 实体(页面里偶尔出现 & 等)。
func htmlDecodeEntities(s string) string {
r := strings.NewReplacer(
"&", "&",
"<", "<",
">", ">",
""", `"`,
"'", "'",
)
return r.Replace(s)
}
// ProgressFunc 是可选的进度回调:调用方(通常是 main.go)注入,
// crawler 在关键节点回调,level ∈ {"info","warn","error"},msg 为单行文本。
// 为 nil 时静默,不阻塞流程。
type ProgressFunc func(level, msg string)
// Run 是对外入口:输入画廊 URL,完成后写 pre-download/.json。
// 命名冲突时返回 ErrDupGallery,不会覆盖现有文件。
// progress 为可选回调,nil 时不打印中间过程。
func Run(ctx context.Context, rawURL string, progress ProgressFunc) (*store.Gallery, error) {
if err := checkHost(rawURL); err != nil {
return nil, err
}
c := NewClient()
log := func(level, msg string) {
if progress != nil {
progress(level, msg)
}
}
log("info", "校验 host 通过,准备请求画廊首页")
// Step 1:取 ?p=0,得到总页数与画廊标题。
firstURL, err := joinPageURL(rawURL, 0)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrBadURL, err)
}
log("info", "拉取画廊首页 (?p=0): "+firstURL)
firstHTML, err := c.doGet(ctx, firstURL, rawURL)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrFetchGallery, err)
}
log("info", fmt.Sprintf("首页响应 %d 字节", len(firstHTML)))
pagesLength, err := extractPagesLength(firstHTML)
if err != nil {
return nil, err
}
rawTitle := extractRawTitle(firstHTML)
if rawTitle == "" {
return nil, fmt.Errorf("%w: 未找到画廊标题 (h1#gj / h1#gn)", ErrParseGallery)
}
sanitized := sanitize(rawTitle)
log("info", fmt.Sprintf("解析到画廊标题: %q (清洗后: %s),总页码 %d", rawTitle, sanitized, pagesLength))
// 命名冲突检查。
exists, err := store.GalleryExists(sanitized)
if err != nil {
return nil, err
}
if exists {
log("warn", fmt.Sprintf("画廊已存在: %s.json,跳过抓取", sanitized))
return nil, fmt.Errorf("%w: %s.json", ErrDupGallery, sanitized)
}
// Step 2:逐页拿所有单页 URL,共 pagesLength 页。
// 翻页规则对齐 reference/E-Hentai Downloader-1.36.2.js 第 14256 行附近的循环。
// 每页的 base URL 是该页请求的绝对 URL(用于把相对路径拼成绝对 URL)。
pageURLs := make([]string, 0, pagesLength*20)
if urls := extractSinglePageURLs(firstHTML); len(urls) > 0 {
for _, u := range urls {
abs, err := absURL(firstURL, u)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrBadURL, err)
}
pageURLs = append(pageURLs, abs)
}
}
log("info", fmt.Sprintf("首页解析到 %d 个单页链接", len(pageURLs)))
for p := 1; p < pagesLength; p++ {
if err := ctx.Err(); err != nil {
return nil, err
}
pageListURL, err := joinPageURL(rawURL, p)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrBadURL, err)
}
log("info", fmt.Sprintf("拉取画廊列表 ?p=%d (%d/%d): %s", p, p, pagesLength-1, pageListURL))
html, err := c.doGet(ctx, pageListURL, rawURL)
if err != nil {
return nil, fmt.Errorf("%w: p=%d %v", ErrFetchGallery, p, err)
}
batch := 0
if urls := extractSinglePageURLs(html); len(urls) > 0 {
for _, u := range urls {
abs, err := absURL(pageListURL, u)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrBadURL, err)
}
pageURLs = append(pageURLs, abs)
batch++
}
}
log("info", fmt.Sprintf("?p=%d 解析到 %d 个单页链接 (累计 %d)", p, batch, len(pageURLs)))
}
log("info", fmt.Sprintf("共收集 %d 个单页 URL,开始逐页解析图片直链", len(pageURLs)))
// Step 3:逐个单页拉取,抽出图片直链。
images := make(map[string]string, len(pageURLs))
for i, singleURL := range pageURLs {
if err := ctx.Err(); err != nil {
return nil, err
}
log("info", fmt.Sprintf("[%d/%d] 拉取单页: %s", i+1, len(pageURLs), singleURL))
html, err := c.doGet(ctx, singleURL, rawURL)
if err != nil {
return nil, fmt.Errorf("%w: 单页 %d %v", ErrFetchSinglePage, i+1, err)
}
imgURL, err := extractImageURL(html)
if err != nil {
return nil, fmt.Errorf("%w: 单页 %d %v", ErrParseSinglePage, i+1, err)
}
key := fmt.Sprintf("%04d", i+1)
images[key] = imgURL
if (i+1)%10 == 0 || i+1 == len(pageURLs) {
log("info", fmt.Sprintf("已解析 %d/%d 张图片直链", i+1, len(pageURLs)))
}
}
if len(images) == 0 {
return nil, ErrZeroPage
}
g := &store.Gallery{
GalleryName: sanitized,
RawTitle: rawTitle,
SourceURL: rawURL,
TotalPages: len(images),
FetchedAt: time.Now().UTC().Format(time.RFC3339),
Pages: images,
}
log("info", fmt.Sprintf("解析完成: %d 张图片,准备写入 pre-download/%s.json", len(images), sanitized))
// 保存前再 double-check,避免与其他并发请求冲突。
if exists, err := store.GalleryExists(sanitized); err == nil && exists {
log("warn", fmt.Sprintf("保存前再次检测到 %s.json 已存在,放弃写入", sanitized))
return nil, fmt.Errorf("%w: %s.json", ErrDupGallery, sanitized)
}
if err := store.SaveGallery(g); err != nil {
return nil, err
}
log("info", fmt.Sprintf("已写入 pre-download/%s.json", sanitized))
return g, nil
}