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 }