Files
web_huangguo2_h5/app/service/playletser/seo.go
T
谢宇宁andClaude Opus 5.5 0fbdba5ccf Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:47:47 +07:00

382 lines
11 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"
"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]+)$)`)