first commit

This commit is contained in:
2026-10-11 12:56:38 +08:00
commit 7d21f317a4
41 changed files with 10758 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
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
}