Files
2026-10-22 15:30:49 +07:00

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)
}