846 lines
24 KiB
Go
846 lines
24 KiB
Go
package seoser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"videoweb/app/service/configser"
|
|
"videoweb/app/service/playletser"
|
|
)
|
|
|
|
const brandName = "黄果短剧"
|
|
|
|
type ldThing map[string]any
|
|
|
|
// FAQItem 同时驱动页面可见内容与 FAQPage JSON-LD,避免标记内容与页面正文不一致。
|
|
type FAQItem struct {
|
|
Question string
|
|
Answer string
|
|
}
|
|
|
|
// PageJSONLD 按页面数据生成可被 Schema.org / 富媒体测试校验的 JSON-LD。
|
|
// 仅收录页生成;非收录页返回空。pageCanonical 为当前请求完整 URL。
|
|
func PageJSONLD(siteURL, pageCanonical, path string, data map[string]any, cfg configser.AppConfigVM) template.JS {
|
|
siteURL = strings.TrimRight(strings.TrimSpace(siteURL), "/")
|
|
pageCanonical = strings.TrimSpace(pageCanonical)
|
|
path = strings.TrimSpace(path)
|
|
if !IsIndexablePath(path) {
|
|
return ""
|
|
}
|
|
pageURL := pageCanonical
|
|
if pageURL == "" {
|
|
pageURL = ensureTrailingSlash(AbsURL(siteURL, path))
|
|
}
|
|
graph := make([]any, 0, 8)
|
|
graph = append(graph, organizationNode(siteURL, cfg), websiteNode(siteURL, cfg))
|
|
|
|
if detail, ok := asPlaylet(data["detail"]); ok {
|
|
graph = append(graph, detailNodes(siteURL, pageURL, detail)...)
|
|
} else if playlet, hasPlaylet := asPlaylet(data["playlet"]); hasPlaylet || data["chapter"] != nil {
|
|
chapter, _ := asChapter(data["chapter"])
|
|
graph = append(graph, playNodes(siteURL, pageURL, playlet, chapter)...)
|
|
} else {
|
|
pageNode := webpageNode(siteURL, pageURL, data, cfg)
|
|
graph = append(graph, pageNode)
|
|
if crumb := genericBreadcrumb(siteURL, pageURL, data); crumb != nil {
|
|
pageNode["breadcrumb"] = ldThing{"@id": pageURL + "#breadcrumb"}
|
|
graph = append(graph, crumb)
|
|
}
|
|
if items := itemListNode(siteURL, pageURL, data); items != nil {
|
|
pageNode["mainEntity"] = ldThing{"@id": pageURL + "#itemlist"}
|
|
graph = append(graph, items)
|
|
}
|
|
if faq := faqPageNode(siteURL, pageURL, data); faq != nil {
|
|
pageNode["hasPart"] = ldThing{"@id": pageURL + "#faq"}
|
|
graph = append(graph, faq)
|
|
}
|
|
}
|
|
|
|
raw, err := json.Marshal(ldThing{
|
|
"@context": "https://schema.org",
|
|
"@graph": graph,
|
|
})
|
|
if err != nil {
|
|
return template.JS("{}")
|
|
}
|
|
return template.JS(raw)
|
|
}
|
|
|
|
func organizationNode(siteURL string, cfg configser.AppConfigVM) ldThing {
|
|
logo := AbsURL(siteURL, "/favicon.png")
|
|
node := ldThing{
|
|
"@type": "Organization",
|
|
"@id": siteURL + "/#organization",
|
|
"name": siteName(cfg),
|
|
"url": AbsURL(siteURL, "/"),
|
|
"logo": ldThing{
|
|
"@type": "ImageObject",
|
|
"@id": siteURL + "/#logo",
|
|
"url": logo,
|
|
"contentUrl": logo,
|
|
"width": 512,
|
|
"height": 512,
|
|
},
|
|
}
|
|
return node
|
|
}
|
|
|
|
func websiteNode(siteURL string, cfg configser.AppConfigVM) ldThing {
|
|
desc := strings.TrimSpace(cfg.SeoInfo.Description)
|
|
if desc == "" {
|
|
desc = "黄果短剧汇集热门短剧、今日更新与精选推荐,免费在线观看高清短剧。"
|
|
}
|
|
node := ldThing{
|
|
"@type": "WebSite",
|
|
"@id": siteURL + "/#website",
|
|
"name": siteName(cfg),
|
|
"url": AbsURL(siteURL, "/"),
|
|
"description": desc,
|
|
"inLanguage": "zh-CN",
|
|
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
|
}
|
|
return node
|
|
}
|
|
|
|
func detailNodes(siteURL, pageURL string, d *playletser.PlayletInfoResp) []any {
|
|
detailURL := pageURL
|
|
playURL := AbsURL(siteURL, playletPath("/play", d.Slug, d.ID))
|
|
name := firstNonEmpty(d.Title, d.SeoInfo.Title, "短剧详情")
|
|
desc := firstNonEmpty(d.SeoInfo.Description, d.Desc, fmt.Sprintf("在线观看《%s》,高清短剧全集免费看。", d.Title))
|
|
cover := absMedia(siteURL, firstNonEmpty(d.Cover, d.Banner))
|
|
seriesID := detailURL + "#tvseries"
|
|
|
|
series := ldThing{
|
|
"@type": "TVSeries",
|
|
"@id": seriesID,
|
|
"name": name,
|
|
"description": desc,
|
|
"url": detailURL,
|
|
"inLanguage": "zh-CN",
|
|
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
|
"potentialAction": ldThing{
|
|
"@type": "WatchAction",
|
|
"target": ldThing{
|
|
"@type": "EntryPoint",
|
|
"urlTemplate": playURL,
|
|
},
|
|
},
|
|
}
|
|
if cover != "" {
|
|
series["image"] = []string{cover}
|
|
series["thumbnailUrl"] = []string{cover}
|
|
}
|
|
if d.TotalEpisode > 0 {
|
|
series["numberOfEpisodes"] = d.TotalEpisode
|
|
}
|
|
if published := isoDate(firstNonEmpty(d.OnlineAt, d.CreatedAt)); published != "" {
|
|
series["datePublished"] = published
|
|
}
|
|
if modified := isoDate(firstNonEmpty(d.UpdatedAt, d.MediaUpdatedAt)); modified != "" {
|
|
series["dateModified"] = modified
|
|
}
|
|
if genres := uniqueStrings(append(append([]string{}, d.SeoInfo.Keywords...), append(d.TagNames, d.ThemeNames...)...)); len(genres) > 0 {
|
|
series["genre"] = genres
|
|
}
|
|
if actor := strings.TrimSpace(d.Anchor); actor != "" {
|
|
series["actor"] = ldThing{"@type": "Person", "name": actor}
|
|
}
|
|
|
|
crumb := breadcrumb(siteURL, [][2]string{
|
|
{"首页", AbsURL(siteURL, "/")},
|
|
{"短剧", AbsURL(siteURL, "/list/ai-short-play/")},
|
|
{name, detailURL},
|
|
})
|
|
page := contentWebPageNode(siteURL, detailURL, name, desc, "WebPage")
|
|
page["mainEntity"] = ldThing{"@id": seriesID}
|
|
page["breadcrumb"] = ldThing{"@id": detailURL + "#breadcrumb"}
|
|
return []any{page, crumb, series}
|
|
}
|
|
|
|
func playNodes(siteURL, pageURL string, playlet *playletser.PlayletInfoResp, chapter *playletser.PlayletChapterInfoResp) []any {
|
|
playletID := int64(0)
|
|
playletTitle := "短剧"
|
|
cover := ""
|
|
desc := "黄果短剧高清在线播放。"
|
|
published := ""
|
|
modified := ""
|
|
contentURL := ""
|
|
playURL := pageURL
|
|
detailURL := AbsURL(siteURL, "/")
|
|
seriesID := ""
|
|
totalEpisodes := 0
|
|
genres := []string(nil)
|
|
|
|
if playlet != nil {
|
|
playletID = playlet.ID
|
|
playletTitle = firstNonEmpty(playlet.Title, playlet.SeoInfo.Title, playletTitle)
|
|
cover = absMedia(siteURL, firstNonEmpty(playlet.Cover, playlet.Banner))
|
|
desc = firstNonEmpty(chapterSEODesc(chapter), playlet.SeoInfo.Description, playlet.Desc, fmt.Sprintf("正在播放《%s》", playlet.Title))
|
|
published = isoDateTime(firstNonEmpty(
|
|
playlet.OnlineAt,
|
|
playlet.MediaCreatedAt,
|
|
playlet.CreatedAt,
|
|
))
|
|
modified = isoDateTime(firstNonEmpty(playlet.MediaUpdatedAt, playlet.UpdatedAt))
|
|
detailURL = AbsURL(siteURL, playletPath("/detail", playlet.Slug, playlet.ID))
|
|
seriesID = detailURL + "#tvseries"
|
|
totalEpisodes = playlet.TotalEpisode
|
|
genres = uniqueStrings(append(append([]string{}, playlet.TagNames...), playlet.ThemeNames...))
|
|
}
|
|
episode := 0
|
|
duration := 0
|
|
if chapter != nil {
|
|
episode = chapter.CurrentEpisode
|
|
duration = chapter.MediaTime
|
|
contentURL = videoContentURL(siteURL, chapter.VideoURL)
|
|
if playletID == 0 && chapter.PlayletID > 0 {
|
|
playletID = chapter.PlayletID
|
|
detailURL = AbsURL(siteURL, playletPath("/detail", "", playletID))
|
|
}
|
|
}
|
|
|
|
videoName := playletTitle
|
|
if episode > 0 {
|
|
videoName = fmt.Sprintf("%s - 第%d集", playletTitle, episode)
|
|
}
|
|
|
|
crumbs := [][2]string{{"首页", AbsURL(siteURL, "/")}}
|
|
if playletID > 0 {
|
|
crumbs = append(crumbs, [2]string{playletTitle, detailURL})
|
|
}
|
|
crumbs = append(crumbs, [2]string{videoName, playURL})
|
|
|
|
page := contentWebPageNode(siteURL, playURL, videoName, desc, "WebPage")
|
|
page["breadcrumb"] = ldThing{"@id": playURL + "#breadcrumb"}
|
|
nodes := []any{page, breadcrumb(siteURL, crumbs)}
|
|
|
|
if playlet != nil {
|
|
series := ldThing{
|
|
"@type": "TVSeries",
|
|
"@id": seriesID,
|
|
"name": playletTitle,
|
|
"description": firstNonEmpty(playlet.SeoInfo.Description, playlet.Desc, desc),
|
|
"url": detailURL,
|
|
"inLanguage": "zh-CN",
|
|
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
|
}
|
|
if cover != "" {
|
|
series["image"] = []string{cover}
|
|
series["thumbnailUrl"] = []string{cover}
|
|
}
|
|
if totalEpisodes > 0 {
|
|
series["numberOfEpisodes"] = totalEpisodes
|
|
}
|
|
if len(genres) > 0 {
|
|
series["genre"] = genres
|
|
}
|
|
if published != "" {
|
|
series["datePublished"] = published
|
|
}
|
|
if modified != "" {
|
|
series["dateModified"] = modified
|
|
}
|
|
nodes = append(nodes, series)
|
|
}
|
|
|
|
// Google 的 VideoObject 富媒体结果要求 name、thumbnailUrl、uploadDate。
|
|
// 缺少真实封面或发布日期时只输出 WebPage,避免用虚构值凑齐必填字段。
|
|
if cover == "" || published == "" {
|
|
return nodes
|
|
}
|
|
|
|
videoID := playURL + "#video"
|
|
video := ldThing{
|
|
"@type": "VideoObject",
|
|
"@id": videoID,
|
|
"name": videoName,
|
|
"description": desc,
|
|
"url": playURL,
|
|
"mainEntityOfPage": ldThing{"@id": playURL + "#webpage"},
|
|
"inLanguage": "zh-CN",
|
|
"isFamilyFriendly": false,
|
|
"contentRating": "adult",
|
|
"isPartOf": ldThing{"@id": siteURL + "/#website"},
|
|
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
|
"potentialAction": ldThing{
|
|
"@type": "WatchAction",
|
|
"target": ldThing{
|
|
"@type": "EntryPoint",
|
|
"urlTemplate": playURL,
|
|
},
|
|
},
|
|
}
|
|
video["thumbnailUrl"] = []string{cover}
|
|
video["image"] = []string{cover}
|
|
video["uploadDate"] = published
|
|
if modified != "" {
|
|
video["dateModified"] = modified
|
|
}
|
|
if contentURL != "" {
|
|
video["contentUrl"] = contentURL
|
|
}
|
|
if duration > 0 {
|
|
video["duration"] = fmt.Sprintf("PT%dS", duration)
|
|
}
|
|
if chapter != nil && chapter.CountBrowse > 0 {
|
|
video["interactionStatistic"] = ldThing{
|
|
"@type": "InteractionCounter",
|
|
"interactionType": ldThing{"@type": "WatchAction"},
|
|
"userInteractionCount": chapter.CountBrowse,
|
|
}
|
|
}
|
|
if seriesID != "" {
|
|
video["partOfSeries"] = ldThing{"@id": seriesID}
|
|
} else if playletID > 0 {
|
|
video["partOfSeries"] = ldThing{"@type": "TVSeries", "name": playletTitle, "url": detailURL}
|
|
}
|
|
if episode > 0 {
|
|
video["episodeNumber"] = episode
|
|
}
|
|
if len(genres) > 0 {
|
|
video["genre"] = genres
|
|
video["keywords"] = strings.Join(genres, ",")
|
|
}
|
|
if chapter != nil {
|
|
if chapter.Width > 0 {
|
|
video["width"] = chapter.Width
|
|
}
|
|
if chapter.Height > 0 {
|
|
video["height"] = chapter.Height
|
|
}
|
|
if chapter.MediaSize > 0 {
|
|
video["contentSize"] = fmt.Sprintf("%d bytes", chapter.MediaSize)
|
|
}
|
|
}
|
|
|
|
page["mainEntity"] = ldThing{"@id": videoID}
|
|
nodes = append(nodes, video)
|
|
if episode > 0 {
|
|
ep := ldThing{
|
|
"@type": "TVEpisode",
|
|
"@id": playURL + "#episode",
|
|
"name": videoName,
|
|
"description": desc,
|
|
"episodeNumber": episode,
|
|
"url": playURL,
|
|
"associatedMedia": ldThing{"@id": videoID},
|
|
}
|
|
if seriesID != "" {
|
|
ep["partOfSeries"] = ldThing{"@id": seriesID}
|
|
} else {
|
|
ep["partOfSeries"] = ldThing{"@type": "TVSeries", "name": playletTitle, "url": detailURL}
|
|
}
|
|
if cover != "" {
|
|
ep["image"] = []string{cover}
|
|
}
|
|
if published != "" {
|
|
ep["datePublished"] = published
|
|
}
|
|
nodes = append(nodes, ep)
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
func webpageNode(siteURL, pageURL string, data map[string]any, cfg configser.AppConfigVM) ldThing {
|
|
name := strFrom(data["title"])
|
|
if name == "" {
|
|
name = strFrom(data["pageName"])
|
|
}
|
|
if name == "" {
|
|
name = brandName
|
|
}
|
|
pathOnly := pageURL
|
|
if i := strings.IndexByte(pathOnly, '?'); i >= 0 {
|
|
pathOnly = pathOnly[:i]
|
|
}
|
|
if j := strings.Index(pathOnly, "://"); j >= 0 {
|
|
if k := strings.IndexByte(pathOnly[j+3:], '/'); k >= 0 {
|
|
pathOnly = pathOnly[j+3+k:]
|
|
} else {
|
|
pathOnly = "/"
|
|
}
|
|
}
|
|
pathOnly = strings.TrimSuffix(pathOnly, "/")
|
|
if pathOnly == "" {
|
|
pathOnly = "/"
|
|
}
|
|
brand := siteName(cfg)
|
|
if !strings.Contains(name, brand) && pathOnly != "/" {
|
|
name = name + " - " + brand
|
|
}
|
|
desc := pageDescription(data, cfg, pathOnly)
|
|
if desc == "" {
|
|
desc = brandName + "短剧在线观看。"
|
|
}
|
|
typ := "WebPage"
|
|
switch {
|
|
case isCollectionPath(pathOnly):
|
|
typ = "CollectionPage"
|
|
case pathOnly == "/about-us" || pathOnly == "/aboutUs":
|
|
typ = "AboutPage"
|
|
}
|
|
return contentWebPageNode(siteURL, pageURL, name, desc, typ)
|
|
}
|
|
|
|
func siteName(cfg configser.AppConfigVM) string {
|
|
return firstNonEmpty(cfg.SeoInfo.WebSiteName, brandName)
|
|
}
|
|
|
|
func contentWebPageNode(siteURL, pageURL, name, desc, typ string) ldThing {
|
|
return ldThing{
|
|
"@type": typ,
|
|
"@id": pageURL + "#webpage",
|
|
"name": name,
|
|
"description": desc,
|
|
"url": pageURL,
|
|
"inLanguage": "zh-CN",
|
|
"isPartOf": ldThing{"@id": siteURL + "/#website"},
|
|
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
|
}
|
|
}
|
|
|
|
func isCollectionPath(path string) bool {
|
|
for _, prefix := range []string{"/list", "/series", "/rank", "/today-update", "/topic", "/tag", "/category"} {
|
|
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return path == "/nav"
|
|
}
|
|
|
|
// itemListNode 为首页及列表型页面输出当前 SSR 首屏实际展示的站内详情链接。
|
|
// Google 的 summary-page ItemList 只需要 position 与唯一的站内详情 URL。
|
|
func itemListNode(siteURL, pageURL string, data map[string]any) ldThing {
|
|
urls := make([]string, 0, 24)
|
|
appendPlaylet := func(slug string, id int64) {
|
|
path := playletser.DetailPath(slug, id)
|
|
if path != "#" {
|
|
urls = append(urls, AbsURL(siteURL, path))
|
|
}
|
|
}
|
|
|
|
if list, ok := data["list"].([]playletser.CommonData); ok {
|
|
for _, item := range list {
|
|
appendPlaylet(item.Slug, item.ID)
|
|
}
|
|
}
|
|
if categories, ok := data["homeCategoryData"].([]playletser.HomeCategoryData); ok {
|
|
for _, category := range categories {
|
|
for _, item := range category.List {
|
|
appendPlaylet(item.Slug, item.ID)
|
|
}
|
|
}
|
|
}
|
|
if banners, ok := data["homeBannerData"].([]playletser.HomeBannerData); ok {
|
|
for _, item := range banners {
|
|
if itemURL := structuredURL(siteURL, item.URL); itemURL != "" {
|
|
urls = append(urls, itemURL)
|
|
}
|
|
}
|
|
}
|
|
if themes, ok := data["list"].([]playletser.PlayletThemeItem); ok {
|
|
for _, item := range themes {
|
|
path := playletser.RecommendCanonicalPath(item.ID, item.Slug)
|
|
urls = append(urls, AbsURL(siteURL, path))
|
|
}
|
|
}
|
|
seen := make(map[string]struct{}, len(urls))
|
|
unique := make([]string, 0, len(urls))
|
|
for _, itemURL := range urls {
|
|
itemURL = strings.TrimSpace(itemURL)
|
|
if itemURL == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[itemURL]; exists {
|
|
continue
|
|
}
|
|
seen[itemURL] = struct{}{}
|
|
unique = append(unique, itemURL)
|
|
}
|
|
if len(unique) < 2 {
|
|
return nil
|
|
}
|
|
|
|
start := 1
|
|
offset := intFrom(data["itemListOffset"])
|
|
if offset == 0 {
|
|
offset = intFrom(data["rankOffset"])
|
|
}
|
|
if offset > 0 {
|
|
start += offset
|
|
}
|
|
items := make([]any, 0, len(unique))
|
|
for i, itemURL := range unique {
|
|
items = append(items, ldThing{
|
|
"@type": "ListItem",
|
|
"position": start + i,
|
|
"url": itemURL,
|
|
})
|
|
}
|
|
return ldThing{
|
|
"@type": "ItemList",
|
|
"@id": pageURL + "#itemlist",
|
|
"numberOfItems": len(items),
|
|
"itemListElement": items,
|
|
}
|
|
}
|
|
|
|
func faqPageNode(siteURL, pageURL string, data map[string]any) ldThing {
|
|
faqItems, ok := data["faqItems"].([]FAQItem)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
questions := make([]any, 0, len(faqItems))
|
|
for _, item := range faqItems {
|
|
question := strings.TrimSpace(item.Question)
|
|
answer := strings.TrimSpace(item.Answer)
|
|
if question == "" || answer == "" {
|
|
continue
|
|
}
|
|
questions = append(questions, ldThing{
|
|
"@type": "Question",
|
|
"name": question,
|
|
"acceptedAnswer": ldThing{
|
|
"@type": "Answer",
|
|
"text": answer,
|
|
},
|
|
})
|
|
}
|
|
if len(questions) == 0 {
|
|
return nil
|
|
}
|
|
return ldThing{
|
|
"@type": "FAQPage",
|
|
"@id": pageURL + "#faq",
|
|
"url": pageURL,
|
|
"inLanguage": "zh-CN",
|
|
"isPartOf": ldThing{"@id": pageURL + "#webpage"},
|
|
"mainEntity": questions,
|
|
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
|
}
|
|
}
|
|
|
|
func breadcrumb(siteURL string, items [][2]string) ldThing {
|
|
list := make([]any, 0, len(items))
|
|
for i, item := range items {
|
|
name, loc := strings.TrimSpace(item[0]), strings.TrimSpace(item[1])
|
|
if name == "" || loc == "" {
|
|
continue
|
|
}
|
|
list = append(list, ldThing{
|
|
"@type": "ListItem",
|
|
"position": i + 1,
|
|
"name": name,
|
|
"item": loc,
|
|
})
|
|
}
|
|
pageURL := ""
|
|
if len(items) > 0 {
|
|
pageURL = strings.TrimSpace(items[len(items)-1][1])
|
|
}
|
|
return ldThing{
|
|
"@type": "BreadcrumbList",
|
|
"@id": pageURL + "#breadcrumb",
|
|
"itemListElement": list,
|
|
}
|
|
}
|
|
|
|
func genericBreadcrumb(siteURL, pageURL string, data map[string]any) ldThing {
|
|
if pageURL == "" || pageURL == AbsURL(siteURL, "/") {
|
|
return nil
|
|
}
|
|
path := pagePath(pageURL)
|
|
if !hasVisibleBreadcrumb(path) {
|
|
return nil
|
|
}
|
|
name := firstNonEmpty(strFrom(data["pageTitle"]), strFrom(data["pageName"]), strFrom(data["title"]), "内容页")
|
|
if strFrom(data["pageKey"]) == "topic" && strings.HasPrefix(path, "/topic/") {
|
|
return breadcrumb(siteURL, [][2]string{
|
|
{"首页", AbsURL(siteURL, "/")},
|
|
{"专题", AbsURL(siteURL, "/topic/")},
|
|
{name, pageURL},
|
|
})
|
|
}
|
|
return breadcrumb(siteURL, [][2]string{
|
|
{"首页", AbsURL(siteURL, "/")},
|
|
{name, pageURL},
|
|
})
|
|
}
|
|
|
|
func hasVisibleBreadcrumb(path string) bool {
|
|
for _, prefix := range []string{"/list", "/series", "/rank", "/today-update", "/topic", "/tag", "/category", "/richtext"} {
|
|
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
|
return true
|
|
}
|
|
}
|
|
switch path {
|
|
case "/home-road", "/about-us", "/aboutUs", "/user-agreement", "/userAgreement",
|
|
"/privacy-policy", "/privacyPolicy", "/help-center", "/helpCenter":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func structuredURL(siteURL, raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || raw == "#" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(raw, "/") {
|
|
return AbsURL(siteURL, raw)
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return ""
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
func pagePath(pageURL string) string {
|
|
u, err := url.Parse(strings.TrimSpace(pageURL))
|
|
if err == nil && u.Path != "" {
|
|
return strings.TrimSuffix(u.Path, "/")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func pageDescription(data map[string]any, cfg configser.AppConfigVM, path string) string {
|
|
if page, ok := asRichText(data["richtext"]); ok && page.Found {
|
|
return firstNonEmpty(page.Subtitle, page.Title+" - 黄果短剧官方内容。")
|
|
}
|
|
switch seo := data["seoInfo"].(type) {
|
|
case playletser.SeoInfo:
|
|
if desc := strings.TrimSpace(seo.Description); desc != "" {
|
|
return desc
|
|
}
|
|
case *playletser.SeoInfo:
|
|
if seo != nil {
|
|
if desc := strings.TrimSpace(seo.Description); desc != "" {
|
|
return desc
|
|
}
|
|
}
|
|
}
|
|
if desc := firstNonEmpty(
|
|
strFrom(data["topicDescription"]),
|
|
strFrom(data["tagDescription"]),
|
|
strFrom(data["categoryDescription"]),
|
|
); desc != "" {
|
|
return desc
|
|
}
|
|
if path == "/" {
|
|
return firstNonEmpty(strings.TrimSpace(cfg.SeoInfo.Description), "黄果短剧汇集热门短剧、今日更新与精选推荐,免费在线观看高清短剧。")
|
|
}
|
|
if path == "/nav" {
|
|
return firstNonEmpty(strings.TrimSpace(cfg.SeoNavInfo.DhDescription), "黄果短剧官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。")
|
|
}
|
|
for prefix, desc := range map[string]string{
|
|
"/list": "浏览黄果短剧全部短剧列表,按分类、标签筛选海量高清短剧,发现更多精彩剧集。",
|
|
"/series": "黄果短剧精选AI成人剧集,持续更新热门漫剧、独家原创剧集,支持免费在线观看。",
|
|
"/rank": "黄果短剧排行榜实时更新热播短剧,查看热门榜、飙升榜。",
|
|
"/today-update": "黄果短剧今日更新,第一时间追更最新上线短剧与连载剧集。",
|
|
"/topic": "黄果短剧精选短剧专题与专题作品,方便按主题发现内容。",
|
|
"/tag": "黄果短剧标签内容页,按题材标签浏览相关短剧。",
|
|
"/category": "黄果短剧分类内容页,按内容分类浏览最新上架短剧。",
|
|
"/richtext": "黄果短剧官方说明与帮助内容。",
|
|
} {
|
|
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
|
return desc
|
|
}
|
|
}
|
|
switch path {
|
|
case "/download-app":
|
|
return "下载黄果短剧APP,随时随地追看热门短剧。"
|
|
case "/home-road":
|
|
return "黄果短剧官方最新地址发布页,实时同步国内可用线路、永久入口与防失联联系方式。"
|
|
case "/about-us", "/aboutUs":
|
|
return "了解黄果短剧的品牌、产品与服务。"
|
|
case "/user-agreement", "/userAgreement":
|
|
return "黄果短剧用户协议,说明使用平台服务的权利与义务。"
|
|
case "/privacy-policy", "/privacyPolicy":
|
|
return "黄果短剧隐私政策,说明个人信息的收集、使用与保护方式。"
|
|
case "/help-center", "/helpCenter":
|
|
return "黄果短剧帮助中心,提供账号、观影、充值与常见问题解答。"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func intFrom(v any) int {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int32:
|
|
return int(n)
|
|
case int64:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func playletPath(route, slug string, id int64) string {
|
|
key := strings.TrimSpace(slug)
|
|
if key == "" && id > 0 {
|
|
key = strconv.FormatInt(id, 10)
|
|
}
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimRight(route, "/") + "/" + url.PathEscape(key) + "/"
|
|
}
|
|
|
|
func chapterSEODesc(ch *playletser.PlayletChapterInfoResp) string {
|
|
if ch == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(ch.SeoInfo.Description)
|
|
}
|
|
|
|
func asPlaylet(v any) (*playletser.PlayletInfoResp, bool) {
|
|
switch t := v.(type) {
|
|
case *playletser.PlayletInfoResp:
|
|
if t != nil {
|
|
return t, true
|
|
}
|
|
case playletser.PlayletInfoResp:
|
|
return &t, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func asChapter(v any) (*playletser.PlayletChapterInfoResp, bool) {
|
|
switch t := v.(type) {
|
|
case *playletser.PlayletChapterInfoResp:
|
|
if t != nil {
|
|
return t, true
|
|
}
|
|
case playletser.PlayletChapterInfoResp:
|
|
return &t, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func asRichText(v any) (configser.RichTextPageVM, bool) {
|
|
switch t := v.(type) {
|
|
case configser.RichTextPageVM:
|
|
return t, true
|
|
case *configser.RichTextPageVM:
|
|
if t != nil {
|
|
return *t, true
|
|
}
|
|
}
|
|
return configser.RichTextPageVM{}, false
|
|
}
|
|
|
|
func absMedia(siteURL, raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
return AbsURL(siteURL, raw)
|
|
}
|
|
|
|
// videoContentURL 仅接受可定位真实媒体字节的 HTTP(S) 或站内绝对路径。
|
|
// 诸如哈希、播放器状态值等上游字段不能误标为 Google VideoObject.contentUrl。
|
|
func videoContentURL(siteURL, raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(raw, "/") {
|
|
return AbsURL(siteURL, raw)
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return ""
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func isoDate(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.HasPrefix(raw, "0001") || strings.HasPrefix(raw, "1970-01-01") {
|
|
return ""
|
|
}
|
|
layouts := []string{
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02",
|
|
"2006/01/02 15:04:05",
|
|
"2006/01/02",
|
|
}
|
|
for _, layout := range layouts {
|
|
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
|
|
return t.Format("2006-01-02")
|
|
}
|
|
}
|
|
if len(raw) >= 10 {
|
|
return raw[:10]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isoDateTime 为 Google VideoObject.uploadDate 生成带时区的 ISO 8601 DateTime。
|
|
// 上游无时区的中文站点时间按 UTC+8 解释,避免让 Google 自行猜测时区。
|
|
func isoDateTime(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.HasPrefix(raw, "0001") || strings.HasPrefix(raw, "1970-01-01") {
|
|
return ""
|
|
}
|
|
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
|
return parsed.Format(time.RFC3339)
|
|
}
|
|
chinaTime := time.FixedZone("UTC+8", 8*60*60)
|
|
for _, layout := range []string{
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02T15:04:05",
|
|
"2006/01/02 15:04:05",
|
|
"2006-01-02",
|
|
"2006/01/02",
|
|
} {
|
|
if parsed, err := time.ParseInLocation(layout, raw, chinaTime); err == nil {
|
|
return parsed.Format(time.RFC3339)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func uniqueStrings(in []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if s := strings.TrimSpace(v); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func strFrom(v any) string {
|
|
s, _ := v.(string)
|
|
return strings.TrimSpace(s)
|
|
}
|