package playletser import ( "fmt" "html/template" "net/url" "strings" "time" ) // TemplateFuncs 模板辅助:仅做展示格式化 / URL,不把接口字段映射成另一套名字。 func TemplateFuncs() template.FuncMap { return template.FuncMap{ "detailURL": detailURL, "playURL": playURL, "chapterPlayURL": chapterPlayURL, "categoryURL": categoryURL, "categoryDetailURL": categoryDetailURL, "themeListURL": themeListURLByKey, "coverURL": coverURL, "formatHeat": formatHeat, "formatCollect": formatCollectHeat, "formatLikeCount": formatLikeCount, "episodeLabel": episodeText, "displayTag": displayTag, "firstTagName": firstTagName, "themeTagText": themeTagText, "tagListURL": tagListURL, "cardTag": cardTag, "cardTags": cardTags, "namedTags": namedTags, "namedThemeTags": namedThemeTags, "firstName": firstName, "rankClass": rankClass, "add": addInt, "contains": strings.Contains, "hasPrefix": strings.HasPrefix, "footerLinkURL": footerLinkURL, "footerLinkIsExternal": footerLinkIsExternal, "footerContactURL": footerContactURL, "footerContactName": footerContactName, "footerContactValue": footerContactValue, "navDisplayURL": navDisplayURL, "urlquery": url.QueryEscape, "pathescape": url.PathEscape, "metaLine": metaLine, "updateStatusText": updateStatusText, "browseText": browseText, "usableAnchor": usableAnchor, "playletYearText": playletYearText, "playletDateText": playletDateText, "isPlayletAllFree": isPlayletAllFree, "absInt64": absInt64Tpl, "absURL": absURLTpl, "canonicalOr": canonicalOr, "joinStrings": joinStrings, "dict": dict, } } // dict builds a map for nested template calls: {{template "x" dict "K" v}}. func dict(values ...interface{}) (map[string]interface{}, error) { if len(values)%2 != 0 { return nil, fmt.Errorf("dict requires an even number of arguments") } out := make(map[string]interface{}, len(values)/2) for i := 0; i < len(values); i += 2 { key, ok := values[i].(string) if !ok { return nil, fmt.Errorf("dict keys must be strings") } out[key] = values[i+1] } return out, nil } func navDisplayURL(raw string) string { value := strings.TrimSpace(raw) value = strings.TrimPrefix(value, "https://") value = strings.TrimPrefix(value, "http://") return strings.TrimRight(value, "/") } // footerLinkURL 按配置地址跳转:/richtext/xxx 进入富文本页,http 开头视为外链原样返回。 func footerLinkURL(_, raw string) string { raw = strings.TrimSpace(raw) if raw == "" || raw == "/" || raw == "#" { return "" } if footerLinkIsExternal(raw) { return raw } if href := footerRichtextHref(raw); href != "" { return href } return normalizeFooterInternalURL(raw) } func footerLinkIsExternal(raw string) bool { return strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "http") } func footerRichtextHref(raw string) string { path := raw if i := strings.IndexAny(raw, "?#"); i >= 0 { path = raw[:i] } path = strings.TrimRight(strings.TrimSpace(path), "/") if path == "/richtext" { return "/richtext/" } if strings.HasPrefix(path, "/richtext/") { return path + "/" } return "" } func normalizeFooterInternalURL(raw string) string { parsed, err := url.Parse(raw) if err != nil { return raw } if parsed.Scheme != "" || parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") { return raw } if parsed.Path != "/" && !strings.HasSuffix(parsed.Path, "/") { if parsed.RawQuery == "" && parsed.Fragment == "" { return raw + "/" } parsed.Path += "/" href := parsed.Path if parsed.RawQuery != "" { href += "?" + parsed.RawQuery } if parsed.Fragment != "" { href += "#" + parsed.Fragment } return href } return raw } // footerContactURL 把页脚联系方式转换为可点击地址,并使用全站配置补齐按钮型文案。 func footerContactURL(name, value, gmailAddr, tgGroup, customerServiceHost string) string { normalizedName := strings.ToLower(strings.ReplaceAll(footerContactName(name), " ", "")) switch { case strings.Contains(normalizedName, "官方群") || strings.Contains(normalizedName, "telegram") || strings.Contains(normalizedName, "tg群"): return firstContactURL(tgGroup, value) case strings.Contains(normalizedName, "商务") || strings.Contains(normalizedName, "合作"): return firstContactURL(customerServiceHost, value, gmailAddr) case strings.Contains(normalizedName, "邮箱") || strings.Contains(normalizedName, "email") || strings.Contains(normalizedName, "e-mail"): return firstContactURL(value, gmailAddr) default: return firstContactURL(value) } } func footerContactName(name string) string { return cleanFooterContactAction(name) } func footerContactValue(value string) string { return cleanFooterContactAction(value) } func cleanFooterContactAction(value string) string { value = strings.TrimSpace(value) for _, action := range []string{"立即联系", "立即加入"} { value = strings.ReplaceAll(value, action, "") } for { cleaned := strings.TrimSpace(strings.Trim(value, "::")) if cleaned == value { return cleaned } value = cleaned } } func firstContactURL(values ...string) string { for _, value := range values { if href := normalizeContactURL(value); href != "" { return href } } return "" } func normalizeContactURL(value string) string { value = footerContactValue(value) if value == "" || strings.ContainsAny(value, " \t\r\n") { return "" } lower := strings.ToLower(value) if strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "tel:") { return value } if strings.HasPrefix(value, "//") { return "https:" + value } if strings.HasPrefix(value, "/") { return value } if strings.Contains(value, "@") && strings.Contains(value, ".") { return "mailto:" + value } if strings.Contains(value, ".") { return "https://" + value } return "" } // absURLTpl 模板内绝对化 URL:absURL foreverUrlAddr path func absURLTpl(siteURL, path string) string { return joinAbs(siteURL, path) } // canonicalOr 优先使用接口返回的 canonical,否则用 foreverUrlAddr+fallbackPath。 func canonicalOr(siteURL, preferred, fallbackPath string) string { preferred = strings.TrimSpace(preferred) if preferred != "" { if strings.HasPrefix(preferred, "http://") || strings.HasPrefix(preferred, "https://") { return preferred } return joinAbs(siteURL, preferred) } return joinAbs(siteURL, fallbackPath) } func joinAbs(siteURL, path string) string { siteURL = strings.TrimRight(strings.TrimSpace(siteURL), "/") path = strings.TrimSpace(path) if path == "" { if siteURL == "" { return "/" } return siteURL + "/" } if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } if !strings.HasPrefix(path, "/") { path = "/" + path } if siteURL == "" { return path } return siteURL + path } // coverURL 选出封面地址。返回 template.URL,避免 html/template 把 data-src // 当 URL 上下文时把已百分号编码的中文路径再次转成 %25xx(导致封面 404)。 func coverURL(primary, fallback string) template.URL { return template.URL(firstNonEmpty(primary, fallback)) } func displayTag(item CommonData) string { if name := firstName(item.TagNames); name != "" { return name } if name := firstName(item.ThemeNames); name != "" { return name } return "短剧" } func firstTagName(item CommonData) string { if len(item.TagNames) == 0 { return "" } return strings.TrimSpace(item.TagNames[0]) } func firstName(names []string) string { for _, n := range names { n = strings.TrimSpace(n) if n != "" { return n } } return "" } func themeTagText(themeNames, tagNames []string) string { theme := firstName(themeNames) tag := firstName(tagNames) switch { case theme != "" && tag != "" && theme != tag: return theme + " · " + tag case tag != "": return tag default: return theme } } func addInt(a, b int) int { return a + b } func absInt64Tpl(n int64) int64 { if n < 0 { return -n } return n } func metaLine(item CommonData) string { cat := firstName(item.ThemeNames) if cat == "" { cat = firstName(item.TagNames) } if item.TotalEpisode > 0 { if cat != "" { return fmt.Sprintf("%s·%d集", cat, item.TotalEpisode) } return fmt.Sprintf("%d集", item.TotalEpisode) } return cat } // playletYearText 详情 PC 元信息年份;无有效 onlineAt 时展示「年份未知」。 func playletYearText(onlineAt string) string { onlineAt = strings.TrimSpace(onlineAt) if len(onlineAt) < 4 { return "年份未知" } year := onlineAt[:4] for _, c := range year { if c < '0' || c > '9' { return "年份未知" } } if year == "0001" || year == "1970" { return "年份未知" } return year } // playletDateText formats the first valid detail-page date as YYYY.MM.DD. func playletDateText(values ...string) string { for _, value := range values { value = strings.TrimSpace(value) if len(value) < 10 { continue } date := strings.NewReplacer("-", ".", "/", ".").Replace(value[:10]) parts := strings.Split(date, ".") if len(parts) != 3 || len(parts[0]) != 4 || len(parts[1]) != 2 || len(parts[2]) != 2 { continue } validDigits := true for _, part := range parts { for _, c := range part { if c < '0' || c > '9' { validDigits = false break } } if !validDigits { break } } if !validDigits || parts[0] == "0001" || parts[0] == "1970" { continue } parsed, err := time.Parse("2006.01.02", date) if err != nil { continue } return parsed.Format("2006.01.02") } return "时间未知" } // isPlayletAllFree 全部剧集 PayType==0 时视为全站免费。 func isPlayletAllFree(chapters []PlayletChapterData) bool { if len(chapters) == 0 { return false } for _, ch := range chapters { if ch.PayType != 0 { return false } } return true }