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
}