476 lines
13 KiB
Go
476 lines
13 KiB
Go
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
|
||
}
|