Files
web_huangguo_web/app/service/playletser/seo.go
T
fish03andClaude Opus 5.5 bef9f8b4ed Initial commit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:43:16 +07:00

313 lines
8.4 KiB
Go
Raw 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 (
"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
}