Files
hd4k-downloader/internal/hd4k/site.go
T
2026-10-11 12:56:38 +08:00

347 lines
9.7 KiB
Go
Raw 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
import (
"fmt"
"net/url"
"strings"
)
// 站点配置
//
// 这类站点是「随时会换域名的采集站」:参考项目的实测里,
// www.4khd.com / m.4khd.com 已经全 404,只有 migmg.ssuu.uk 还活着。
//
// 所以**域名不写死在代码里**,而是做成设置项:
// 站点挂了就往列表里加一条,代码不用改、也不用重新编译。
//
// 站点之间共享同一套页面结构(同一套 WordPress 主题 + 同一套
// Jetpack Photon 图床),所以解析规则只有一份,按 host 分流即可。
// Site 一个受支持的站点
type Site struct {
Host string `json:"host"` // 主域名,不带协议,如 migmg.ssuu.uk
Label string `json:"label"` // 界面显示名
// Aliases 别名域名。页面上散布的链接未必指向主域名 ——
// 实测 migmg.ssuu.uk 的首页里 90 个 /content/ 链接**全部**指向
// www.4khd.com,而那个域名已经 404。收到别名域名的链接时一律归一到主域名。
Aliases []string `json:"aliases,omitempty"`
Enabled bool `json:"enabled"` // 是否启用
// Scheme 协议,留空按 https 处理。
// 只有站点真的跑在明文 http(或本地 mock 测试)时才需要填 http。
Scheme string `json:"scheme,omitempty"`
}
// DefaultSites 内置的默认站点
//
// 4khd.com 系列(主域 + www + m)都作为 migmg.ssuu.uk 的别名,
// 这样不管用户从哪个域名复制链接,后端都会归一到唯一的主域名。
func DefaultSites() []Site {
return []Site{
{
Host: "migmg.ssuu.uk",
Label: "4KHD (migmg)",
Aliases: []string{"www.4khd.com", "4khd.com", "m.4khd.com"},
Enabled: true,
},
}
}
// SchemeOrDefault 协议,缺省 https
func (s Site) SchemeOrDefault() string {
if s.Scheme == "http" {
return "http"
}
return "https"
}
// SiteBase 站点的规范 origin
func (s Site) SiteBase() string { return s.SchemeOrDefault() + "://" + s.Host }
// NormalizeHost 清洗用户输入的域名:
// 去掉协议、去掉路径、去掉默认端口,转小写。
// "https://migmg.ssuu.uk/" → "migmg.ssuu.uk"
//
// ⚠️ **非默认端口要保留**(127.0.0.1:8080 和 127.0.0.1:9090 是两个站点)。
// 早期版本一刀切砍掉端口,会让本地 mock / 自建站全部塌成同一个 host。
func NormalizeHost(raw string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
// 用户可能直接粘整条 URL
if i := strings.Index(s, "://"); i >= 0 {
s = s[i+3:]
}
// 去掉路径/查询/锚点
if i := strings.IndexAny(s, "/?#"); i >= 0 {
s = s[:i]
}
// 只砍默认端口,保留其它端口
s = strings.TrimSuffix(s, ":443")
s = strings.TrimSuffix(s, ":80")
return strings.ToLower(strings.TrimSpace(s))
}
// OriginOf 从完整 URL 里取 origin(scheme://host[:port])。
// 解析失败时返回空串。
func OriginOf(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" {
return ""
}
scheme := u.Scheme
if scheme == "" {
scheme = "https"
}
return scheme + "://" + u.Host
}
// FindSite 按域名找站点配置。找不到时返回一个「临时启用」的站点,
// 这样用户粘贴了一个没配过的域名也能直接用(宽容处理)。
func FindSite(sites []Site, host string) (Site, bool) {
h := NormalizeHost(host)
for _, s := range sites {
if NormalizeHost(s.Host) == h {
return s, s.Enabled
}
for _, a := range s.Aliases {
if NormalizeHost(a) == h {
return s, s.Enabled // 别名 → 返回主站点
}
}
}
return Site{Host: h, Label: h, Enabled: true}, true
}
// AllHosts 站点列表里的全部域名(主域名 + 别名),给油猴生成 @match 用
func AllHosts(sites []Site) []string {
var out []string
seen := map[string]bool{}
for _, s := range sites {
for _, h := range append([]string{s.Host}, s.Aliases...) {
n := NormalizeHost(h)
if n == "" || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
}
return out
}
// IsKnownHost 判断这个域名是否在已知站点列表里(用于油猴 @match 生成)
func IsKnownHost(sites []Site, host string) bool {
h := NormalizeHost(host)
for _, s := range sites {
if NormalizeHost(s.Host) == h {
return true
}
}
return false
}
// GalleryRef 一个画廊的引用(从用户输入或油猴推送来)
type GalleryRef struct {
ID string `json:"id"` // 全局唯一标识:host + 规范化路径
URL string `json:"url"` // 第 1 页的规范 URL
Host string `json:"host"` // 所属站点域名
Path string `json:"path"` // /content/09/myua-sweetbox-vol52.html
Slug string `json:"slug"` // myua-sweetbox-vol52
Raw string `json:"raw"` // 用户原始输入
Title string `json:"title"` // 标题(油猴推送时带,缺失时解析页面补)
}
// 画廊页 URL 的形态:/content/{分类数字}/{slug}.html
// 分类数字是站点内部的分类 ID(09/11/29/21…),不是日期也不是 ID。
// ParseGalleryURL 把任意输入(整条 URL / 路径)解析成 GalleryRef。
//
// 支持这些输入形式:
//
// https://migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html
// migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html
// /content/09/myua-sweetbox-vol52.html
// https://migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html/2 ← 分页页,自动归一到第1页
func ParseGalleryURL(raw string, sites []Site) (GalleryRef, error) {
s := strings.TrimSpace(raw)
if s == "" {
return GalleryRef{}, fmt.Errorf("空链接")
}
// 允许裸 host(补上 https)
if !strings.Contains(s, "://") && !strings.HasPrefix(s, "/") {
if i := strings.Index(s, "/"); i > 0 {
s = "https://" + s
} else {
return GalleryRef{}, fmt.Errorf("不是画廊链接: %s", raw)
}
}
u, err := url.Parse(s)
if err != nil {
return GalleryRef{}, fmt.Errorf("链接格式错误: %s", raw)
}
host := NormalizeHost(u.Host)
if host == "" {
return GalleryRef{}, fmt.Errorf("链接里没有域名: %s", raw)
}
site, _ := FindSite(sites, host)
// 协议优先级:站点配置里显式写了 Scheme 就用它;
// 否则沿用用户粘贴的协议(默认 https)。
// 这样本地 mock(http://127.0.0.1:PORT)也能直接跑通整条链路。
scheme := site.SchemeOrDefault()
if site.Scheme == "" && u.Scheme != "" {
scheme = u.Scheme
}
// ⚠️ 域名归一化到**主域名**。
// 页面上散布的链接未必指向主域名:实测 migmg.ssuu.uk 首页里 90 个
// /content/ 链接全都指向 www.4khd.com,而那个域名已经 404。
// 不归一的话,同一个画廊会因为「从哪个域名复制的链接」变成不同任务。
primary := NormalizeHost(site.Host)
if primary == "" {
primary = host
}
base := scheme + "://" + primary
path := u.Path
if path == "" || path == "/" {
return GalleryRef{}, fmt.Errorf("不是画廊链接(缺少 /content/ 路径): %s", raw)
}
// 剥掉分页后缀:…/xxx.html/2 → …/xxx.html
path = StripPageSuffix(path)
slug := SlugFromPath(path)
if slug == "" {
return GalleryRef{}, fmt.Errorf("不是画廊链接(路径不含 /content/{数字}/{名字}.html): %s", raw)
}
return GalleryRef{
ID: primary + path,
URL: base + path,
Host: primary,
Path: path,
Slug: slug,
Raw: raw,
Title: "",
}, nil
}
// StripPageSuffix 去掉分页后缀和尾斜杠。
//
// /content/09/x.html → /content/09/x.html
// /content/09/x.html/2 → /content/09/x.html
// /content/09/x.html/12 → /content/09/x.html
// /content/09/x.html/abc → /content/09/x.html ← 非数字段不是分页
// /content/09/x.html/ → /content/09/x.html
//
// ⚠️ 尾斜杠这行是必须的:站点 .page-links 里第 1 页的 href 是
// `…vol52.html/`(带斜杠),而画廊自身的 URL 不带。不归一化的话
// 同一个画廊会被当成两个不同任务。
func StripPageSuffix(p string) string {
for {
i := strings.LastIndex(p, "/")
if i <= 0 {
return p
}
last := p[i+1:]
if last == "" {
// 尾斜杠:直接砍掉
p = p[:i]
continue
}
if isAllDigits(last) {
p = p[:i]
continue
}
return p
}
}
// SlugFromPath 从画廊路径里取出 slug(带 .html)
//
// /content/09/myua-sweetbox-vol52.html → myua-sweetbox-vol52.html
func SlugFromPath(p string) string {
const marker = "/content/"
i := strings.Index(p, marker)
if i < 0 {
return ""
}
rest := p[i+len(marker):]
// rest 形如 "09/myua-sweetbox-vol52.html"
parts := strings.SplitN(rest, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return ""
}
if !strings.HasSuffix(parts[1], ".html") {
return ""
}
return parts[1]
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// PageURL 第 n 页的 URL(n=1 就是画廊原地址)
func PageURL(base string, n int) string {
if n <= 1 {
return base
}
return strings.TrimRight(base, "/") + "/" + itoa(n)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// ParseGalleryInput 解析多行输入 → 去重 → 画廊列表。
// 与参考项目保持一致的行为:每行一个链接,空行和 // 注释忽略。
func ParseGalleryInput(text string, sites []Site) []GalleryRef {
var out []GalleryRef
seen := map[string]bool{}
fields := strings.FieldsFunc(text, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == '\t'
})
for _, f := range fields {
f = strings.TrimSpace(f)
if f == "" || strings.HasPrefix(f, "#") || strings.HasPrefix(f, "//") {
continue
}
ref, err := ParseGalleryURL(f, sites)
if err != nil {
continue
}
if seen[ref.ID] {
continue
}
seen[ref.ID] = true
out = append(out, ref)
}
return out
}