Files
2026-09-29 15:30:33 +07:00

476 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package playletser
import (
"fmt"
"html/template"
"net/url"
"strings"
"time"
"videoweb/app/service/configser"
)
// TemplateFuncs 模板辅助:仅做展示格式化 / URL,不把接口字段映射成另一套名字。
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"detailURL": detailURL,
"playURL": playURL,
"chapterPlayURL": chapterPlayURL,
"categoryURL": categoryURL,
"categoryDetailURL": categoryDetailURL,
"themeListURL": themeListURLByKey,
"tagOptionsURL": tagListURLByKey,
"allListURL": AllListURL,
"moreListURL": MoreListURL,
"moreSeriesListURL": MoreSeriesListURL,
"navMegaIcon": NavMegaIcon,
"topicListURL": TopicListURL,
"topicDetailURL": TopicDetailURL,
"topicCoverURLs": TopicCoverURLs,
"tagPageURL": TagListURL,
"hotRankPageURL": HotRankPageURL,
"searchResultsURL": SearchResultsURL,
"coverURL": coverURL,
"formatHeat": formatHeat,
"formatCollect": formatCollectHeat,
"formatLikeCount": formatLikeCount,
"formatMediaTime": formatMediaTime,
"playletDisplayName": playletDisplayName,
"rankNo": RankNo,
"episodeLabel": episodeText,
"displayTag": displayTag,
"firstTagName": firstTagName,
"themeTagText": themeTagText,
"tagListURL": tagListURL,
"cardTag": cardTag,
"cardTags": cardTags,
"namedTags": namedTags,
"namedThemeTags": namedThemeTags,
"firstName": firstName,
"rankClass": rankClass,
"add": addInt,
"mod": modInt,
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"footerLinkURL": footerLinkURL,
"footerLinkIsExternal": footerLinkIsExternal,
"externalLinkAttrs": externalLinkAttrs,
"footerContactURL": footerContactURL,
"footerContactName": footerContactName,
"footerContactValue": footerContactValue,
"navDisplayURL": navDisplayURL,
"urlquery": url.QueryEscape,
"pathescape": url.PathEscape,
"metaLine": metaLine,
"adSlice": AdSlice,
"cssCover": cssCover,
"cssCoverPlaceholder": cssCoverPlaceholder,
"updateStatusText": updateStatusText,
"browseText": browseText,
"usableAnchor": usableAnchor,
"playletYearText": playletYearText,
"playletDateText": playletDateText,
"isPlayletAllFree": isPlayletAllFree,
"formatShortDateTime": formatShortDateTime,
"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(name, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "/" || raw == "#" {
return ""
}
if footerLinkIsExternal(raw) {
return raw
}
if href := footerRichtextHref(name, raw); href != "" {
return href
}
return normalizeFooterInternalURL(raw)
}
func footerLinkIsExternal(raw string) bool {
return isExternalWebURL(raw)
}
// externalLinkAttrs 为运行时配置的站外网页链接补齐统一的打开方式和 SEO 关系。
// 返回固定属性而不是拼接用户输入,避免配置值进入 HTML 属性上下文。
func externalLinkAttrs(raw string) template.HTMLAttr {
if !isExternalWebURL(raw) {
return ""
}
return template.HTMLAttr(`target="_blank" rel="sponsored nofollow noopener"`)
}
func isExternalWebURL(raw string) bool {
value := strings.ToLower(strings.TrimSpace(raw))
return strings.HasPrefix(value, "http://") ||
strings.HasPrefix(value, "https://") ||
strings.HasPrefix(value, "//")
}
func footerRichtextHref(name, 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/") {
title := strings.TrimPrefix(path, "/richtext/")
if decoded, err := url.PathUnescape(title); err == nil {
title = decoded
}
if semanticPath, ok := configser.RichTextSemanticHref(name); ok {
return semanticPath
}
return configser.RichTextHref(title)
}
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, "/") {
parsed.Path = strings.TrimRight(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
}
// ImgPlaceholder 默认/失败占位图(与 imgDecypt.js 保持一致)。
const ImgPlaceholder = "/static/images/placeholder.png?v=2"
// coverURL 选出封面地址。返回 template.URL,避免 html/template 把 data-src
// 当 URL 上下文时把已百分号编码的中文路径再次转成 %25xx(导致封面 404)。
// 无有效地址时回退占位图。
func coverURL(values ...string) template.URL {
u := firstNonEmpty(values...)
if u == "" {
return template.URL(ImgPlaceholder)
}
return template.URL(u)
}
// cssCover 生成卡片封面 CSS 变量,供 style="{{cssCover ...}}" 使用。
// 无有效地址时使用占位图。
func cssCover(values ...string) template.CSS {
u := firstNonEmpty(values...)
if u == "" {
u = ImgPlaceholder
}
return template.CSS("--cover:url(" + u + ")")
}
// cssCoverPlaceholder 仅占位,真实图放 data-cover-src 由前端解密后写入 --cover。
func cssCoverPlaceholder() template.CSS {
return template.CSS("--cover:url(" + ImgPlaceholder + ")")
}
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 modInt(a, b int) int {
if b == 0 {
return 0
}
return a % b
}
func absInt64Tpl(n int64) int64 {
if n < 0 {
return -n
}
return n
}
func formatShortDateTime(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "0001") {
return ""
}
raw = strings.ReplaceAll(raw, "T", " ")
if i := strings.Index(raw, "."); i > 0 {
raw = raw[:i]
}
if i := strings.IndexAny(raw, "Z+"); i > 10 {
raw = strings.TrimSpace(raw[:i])
}
if len(raw) >= 16 {
raw = raw[:16]
}
raw = strings.ReplaceAll(raw, "-", "/")
return raw
}
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
}