313 lines
8.4 KiB
Go
313 lines
8.4 KiB
Go
package playletser
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"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)
|
||
}
|
||
if base.Description != "" {
|
||
marker := fmt.Sprintf("第%d页", pageNum)
|
||
if !strings.Contains(base.Description, marker) {
|
||
base.Description = strings.TrimRight(base.Description, "。;; ") + " - " + 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
|
||
}
|
||
|
||
values := make([]string, 0, len(normalized))
|
||
keywords := append([]string(nil), base.Keywords...)
|
||
for _, selection := range normalized {
|
||
values = append(values, selection.Value)
|
||
keywords = append(keywords, selection.Value)
|
||
}
|
||
base.Title = joinSEOParts(base.Title, values)
|
||
base.Description = joinSEOParts(base.Description, values)
|
||
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 优先,短剧详情作字段级回退。
|
||
func ResolvePlayPageSEO(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) SeoInfo {
|
||
var chapterSEO, playletSEO SeoInfo
|
||
if chapter != nil {
|
||
chapterSEO = chapter.SeoInfo
|
||
}
|
||
if playlet != nil {
|
||
playletSEO = playlet.SeoInfo
|
||
}
|
||
return MergeSeoInfo(chapterSEO, playletSEO)
|
||
}
|
||
|
||
// ResolvePlayPageTitle 播放页标题:优先章节 seoInfo.title,否则短剧名 + 集数。
|
||
func ResolvePlayPageTitle(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) string {
|
||
seo := ResolvePlayPageSEO(chapter, playlet)
|
||
if seo.Title != "" {
|
||
return seo.Title
|
||
}
|
||
title := "播放"
|
||
if playlet != nil && playlet.Title != "" {
|
||
title = playlet.Title
|
||
}
|
||
if chapter != nil && chapter.CurrentEpisode > 0 {
|
||
return fmt.Sprintf("%s - 第%d集", title, chapter.CurrentEpisode)
|
||
}
|
||
return title
|
||
}
|