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 }