382 lines
11 KiB
Go
382 lines
11 KiB
Go
package playletser
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// PageOutOfRange 判断分页是否越界(第 1 页始终合法,即使列表为空)。
|
||
func PageOutOfRange(pageNum, pageSize int, totalCount int64) bool {
|
||
if pageNum <= 1 {
|
||
return false
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = defaultListPageSize
|
||
}
|
||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||
if totalPages < 1 {
|
||
return true
|
||
}
|
||
return pageNum > totalPages
|
||
}
|
||
|
||
// PageTitleSuffix 分页标题后缀:第 1 页为空,其余为「 - 第N页」。
|
||
func PageTitleSuffix(pageNum int) string {
|
||
if pageNum <= 1 {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf(" - 第%d页", pageNum)
|
||
}
|
||
|
||
// PaginatedTitle 为第 2 页及以后追加页码,并避免重复追加。
|
||
func PaginatedTitle(title string, pageNum int) string {
|
||
title = strings.TrimSpace(title)
|
||
if title == "" || pageNum <= 1 {
|
||
return title
|
||
}
|
||
marker := fmt.Sprintf("第%d页", pageNum)
|
||
if strings.Contains(title, marker) {
|
||
return title
|
||
}
|
||
return title + PageTitleSuffix(pageNum)
|
||
}
|
||
|
||
// PaginateSeoInfo 统一生成分页页的唯一 title 与 description。
|
||
// fallbackDescription 仅在上游未提供 description 时使用。
|
||
func PaginateSeoInfo(base SeoInfo, fallbackDescription string, pageNum int) SeoInfo {
|
||
base.Normalize()
|
||
if pageNum <= 1 {
|
||
return base
|
||
}
|
||
base.Title = PaginatedTitle(base.Title, pageNum)
|
||
if base.Description == "" {
|
||
base.Description = strings.TrimSpace(fallbackDescription)
|
||
}
|
||
marker := fmt.Sprintf("第%d页", pageNum)
|
||
if base.Description != "" {
|
||
if !strings.Contains(base.Description, marker) {
|
||
base.Description = strings.TrimRight(base.Description, "。;; ") + " - " + marker
|
||
}
|
||
}
|
||
keywordFound := false
|
||
for _, keyword := range base.Keywords {
|
||
if strings.EqualFold(strings.TrimSpace(keyword), marker) {
|
||
keywordFound = true
|
||
break
|
||
}
|
||
}
|
||
if !keywordFound {
|
||
base.Keywords = append(base.Keywords, marker)
|
||
}
|
||
base.Normalize()
|
||
return base
|
||
}
|
||
|
||
// UnmarshalJSON 兼容 swagger proto.SeoInfo,并容忍上游别名 / 字符串 keywords。
|
||
// 主字段:title、description、keywords[]、canonicalUrl。
|
||
func (s *SeoInfo) UnmarshalJSON(raw []byte) error {
|
||
if s == nil {
|
||
return nil
|
||
}
|
||
raw = bytes.TrimSpace(raw)
|
||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||
return nil
|
||
}
|
||
if raw[0] == '"' {
|
||
var encoded string
|
||
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||
return err
|
||
}
|
||
encoded = strings.TrimSpace(encoded)
|
||
if encoded == "" {
|
||
return nil
|
||
}
|
||
inner := bytes.TrimSpace([]byte(encoded))
|
||
if len(inner) == 0 || inner[0] != '{' {
|
||
return nil
|
||
}
|
||
return s.UnmarshalJSON(inner)
|
||
}
|
||
var aux struct {
|
||
Title string `json:"title"`
|
||
WebTitle string `json:"webTitle"`
|
||
Description string `json:"description"`
|
||
WebDescription string `json:"webDescription"`
|
||
CanonicalURL string `json:"canonicalUrl"`
|
||
Canonical string `json:"canonical"`
|
||
WebSiteName string `json:"webSiteName"`
|
||
Keywords json.RawMessage `json:"keywords"`
|
||
Keyworld json.RawMessage `json:"keyworld"`
|
||
WebKeyworld json.RawMessage `json:"webKeyworld"`
|
||
}
|
||
if err := json.Unmarshal(raw, &aux); err != nil {
|
||
return err
|
||
}
|
||
s.Title = firstNonEmpty(aux.Title, aux.WebTitle)
|
||
s.Description = firstNonEmpty(aux.Description, aux.WebDescription)
|
||
s.CanonicalURL = firstNonEmpty(aux.CanonicalURL, aux.Canonical)
|
||
s.WebSiteName = strings.TrimSpace(aux.WebSiteName)
|
||
s.Keywords = decodeKeywordList(aux.Keywords, aux.Keyworld, aux.WebKeyworld)
|
||
s.Normalize()
|
||
return nil
|
||
}
|
||
|
||
// Normalize 去掉 seoInfo 空白项,保证模板可直接渲染。
|
||
func (s *SeoInfo) Normalize() {
|
||
if s == nil {
|
||
return
|
||
}
|
||
s.Title = strings.TrimSpace(s.Title)
|
||
s.Description = strings.TrimSpace(s.Description)
|
||
s.CanonicalURL = strings.TrimSpace(s.CanonicalURL)
|
||
s.WebSiteName = strings.TrimSpace(s.WebSiteName)
|
||
if len(s.Keywords) == 0 {
|
||
return
|
||
}
|
||
out := make([]string, 0, len(s.Keywords))
|
||
seen := make(map[string]struct{}, len(s.Keywords))
|
||
for _, k := range s.Keywords {
|
||
k = strings.TrimSpace(k)
|
||
if k == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[k]; ok {
|
||
continue
|
||
}
|
||
seen[k] = struct{}{}
|
||
out = append(out, k)
|
||
}
|
||
s.Keywords = out
|
||
}
|
||
|
||
func decodeKeywordList(vals ...json.RawMessage) []string {
|
||
for _, raw := range vals {
|
||
if items := parseKeywordsRaw(raw); len(items) > 0 {
|
||
return items
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func parseKeywordsRaw(raw json.RawMessage) []string {
|
||
raw = bytes.TrimSpace(raw)
|
||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||
return nil
|
||
}
|
||
switch raw[0] {
|
||
case '"':
|
||
var s string
|
||
if err := json.Unmarshal(raw, &s); err != nil {
|
||
return nil
|
||
}
|
||
return splitKeywordText(s)
|
||
case '[':
|
||
var anyItems []any
|
||
if err := json.Unmarshal(raw, &anyItems); err != nil {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(anyItems))
|
||
for _, it := range anyItems {
|
||
s, ok := it.(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
s = strings.TrimSpace(s)
|
||
if s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func splitKeywordText(s string) []string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
s = strings.NewReplacer(",", ",", "、", ",", ";", ",", ";", ",").Replace(s)
|
||
parts := strings.Split(s, ",")
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if t := strings.TrimSpace(p); t != "" {
|
||
out = append(out, t)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func joinStrings(items []string, sep string) string {
|
||
parts := make([]string, 0, len(items))
|
||
for _, s := range items {
|
||
s = strings.TrimSpace(s)
|
||
if s != "" {
|
||
parts = append(parts, s)
|
||
}
|
||
}
|
||
return strings.Join(parts, sep)
|
||
}
|
||
|
||
// MergeSeoInfo 按字段合并 SEO:优先 primary,缺失时回退 fallback。
|
||
func MergeSeoInfo(primary, fallback SeoInfo) SeoInfo {
|
||
out := SeoInfo{
|
||
Title: firstNonEmpty(primary.Title, fallback.Title),
|
||
Description: firstNonEmpty(primary.Description, fallback.Description),
|
||
CanonicalURL: firstNonEmpty(primary.CanonicalURL, fallback.CanonicalURL),
|
||
WebSiteName: firstNonEmpty(primary.WebSiteName, fallback.WebSiteName),
|
||
}
|
||
if len(primary.Keywords) > 0 {
|
||
out.Keywords = append([]string(nil), primary.Keywords...)
|
||
} else {
|
||
out.Keywords = append([]string(nil), fallback.Keywords...)
|
||
}
|
||
out.Normalize()
|
||
return out
|
||
}
|
||
|
||
// SEOSelection 描述页面当前选中的筛选维度,用于生成差异化 TDK。
|
||
type SEOSelection struct {
|
||
Dimension string
|
||
Value string
|
||
}
|
||
|
||
// DifferentiateSeoInfo 按基础 TDK - 选项1 - 选项2 的顺序拼接筛选条件。
|
||
func DifferentiateSeoInfo(base SeoInfo, selections []SEOSelection) SeoInfo {
|
||
base.Normalize()
|
||
normalized := make([]SEOSelection, 0, len(selections))
|
||
seen := make(map[string]struct{}, len(selections))
|
||
for _, selection := range selections {
|
||
dimension := strings.TrimSpace(selection.Dimension)
|
||
value := strings.TrimSpace(selection.Value)
|
||
if value == "" {
|
||
continue
|
||
}
|
||
key := dimension + "\x00" + value
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
normalized = append(normalized, SEOSelection{Dimension: dimension, Value: value})
|
||
}
|
||
if len(normalized) == 0 {
|
||
return base
|
||
}
|
||
|
||
titleValues := make([]string, 0, len(normalized))
|
||
descriptionValues := make([]string, 0, len(normalized))
|
||
keywords := append([]string(nil), base.Keywords...)
|
||
for _, selection := range normalized {
|
||
if !strings.Contains(base.Title, selection.Value) {
|
||
titleValues = append(titleValues, selection.Value)
|
||
}
|
||
if !strings.Contains(base.Description, selection.Value) {
|
||
descriptionValues = append(descriptionValues, selection.Value)
|
||
}
|
||
keywords = append(keywords, selection.Value)
|
||
}
|
||
base.Title = joinSEOParts(base.Title, titleValues)
|
||
base.Description = joinSEOParts(base.Description, descriptionValues)
|
||
base.Keywords = keywords
|
||
base.Normalize()
|
||
return base
|
||
}
|
||
|
||
func joinSEOParts(base string, values []string) string {
|
||
parts := make([]string, 0, len(values)+1)
|
||
if base = strings.TrimSpace(base); base != "" {
|
||
parts = append(parts, base)
|
||
}
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
parts = append(parts, value)
|
||
}
|
||
}
|
||
return strings.Join(parts, " - ")
|
||
}
|
||
|
||
// ResolvePlayPageSEO 播放页 SEO:章节详情 seoInfo 优先,短剧详情作字段级回退。
|
||
// description 追加剧名与集数,避免同一短剧的多个播放页生成重复描述。
|
||
func ResolvePlayPageSEO(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) SeoInfo {
|
||
var chapterSEO, playletSEO SeoInfo
|
||
if chapter != nil {
|
||
chapterSEO = chapter.SeoInfo
|
||
}
|
||
if playlet != nil {
|
||
playletSEO = playlet.SeoInfo
|
||
}
|
||
seo := MergeSeoInfo(chapterSEO, playletSEO)
|
||
seo.Description = resolvePlayPageDescription(seo.Description, chapter, playlet)
|
||
return seo
|
||
}
|
||
|
||
func resolvePlayPageDescription(description string, chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) string {
|
||
description = strings.TrimSpace(description)
|
||
if description == "" && playlet != nil {
|
||
description = firstNonEmpty(playlet.Desc, playlet.Description)
|
||
}
|
||
if chapter == nil || chapter.CurrentEpisode <= 0 {
|
||
return description
|
||
}
|
||
|
||
episode := fmt.Sprintf("第%d集", chapter.CurrentEpisode)
|
||
if strings.Contains(description, episode) {
|
||
return description
|
||
}
|
||
|
||
title := "本剧"
|
||
if playlet != nil && strings.TrimSpace(playlet.Title) != "" {
|
||
title = fmt.Sprintf("《%s》", strings.TrimSpace(playlet.Title))
|
||
}
|
||
suffix := fmt.Sprintf("%s%s高清在线观看。", title, episode)
|
||
if description == "" {
|
||
return "正在播放" + suffix
|
||
}
|
||
return strings.TrimRight(description, "。;;,, -") + ";" + suffix
|
||
}
|
||
|
||
// ResolvePlayPageTitle 仅保留包含真实剧名的 SEO 标题,防止站点通用标题覆盖不同短剧。
|
||
func ResolvePlayPageTitle(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) string {
|
||
var chapterTitle, playletTitle, name string
|
||
if chapter != nil {
|
||
chapterTitle = strings.TrimSpace(chapter.SeoInfo.Title)
|
||
}
|
||
if playlet != nil {
|
||
name = strings.TrimSpace(playlet.Title)
|
||
playletTitle = strings.TrimSpace(playlet.SeoInfo.Title)
|
||
}
|
||
title := firstNonEmpty(name, "在线播放")
|
||
for _, candidate := range []string{chapterTitle, playletTitle} {
|
||
if candidate != "" && (name == "" || strings.Contains(candidate, name)) {
|
||
title = candidate
|
||
break
|
||
}
|
||
}
|
||
if chapter != nil && chapter.CurrentEpisode > 0 {
|
||
// 剧名中的数字属于作品名称,只在 SEO 的额外文字中识别已有集数。
|
||
if title != name {
|
||
episodeText := strings.TrimSpace(strings.Replace(title, name, "", 1))
|
||
for _, match := range playTitleEpisodePattern.FindAllStringSubmatch(episodeText, -1) {
|
||
// 末尾独立数字仅在章节 SEO 中视为集数,避免误判短剧 SEO 中的年份。
|
||
if match[3] != "" && title != chapterTitle {
|
||
continue
|
||
}
|
||
n, _ := strconv.Atoi(firstNonEmpty(match[1], match[2], match[3]))
|
||
if n == chapter.CurrentEpisode {
|
||
return title
|
||
}
|
||
}
|
||
}
|
||
return fmt.Sprintf("%s - 第%d集", title, chapter.CurrentEpisode)
|
||
}
|
||
return title
|
||
}
|
||
|
||
var playTitleEpisodePattern = regexp.MustCompile(`(?i)(?:第\s*([0-9]+)\s*集|\bEP\s*([0-9]+)\b|(?:^|\s)([0-9]+)$)`)
|