first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user