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