package web import ( "net/http" "strconv" "strings" "videoweb/app/service/configser" "videoweb/app/service/playletser" "videoweb/app/service/seoser" "videoweb/app/service/translationser" "videoweb/app/service/videoser" "github.com/gin-gonic/gin" ) // HTML 渲染页面并注入公共导航、配置与 SEO 数据。 func HTML(c *gin.Context, code int, name string, data gin.H) { if data == nil { data = gin.H{} } // 导航分类:GET /api/app/translation/category/list → categoryList(包含分类 seoInfo) if _, ok := data["categoryList"]; !ok { data["categoryList"] = translationser.FetchHeaderCategoryListOrDefault(c) } if _, ok := data["requestPath"]; !ok { data["requestPath"] = c.Request.URL.Path } // 抽屉题材:GET /api/app/theme/list if _, ok := data["themeList"]; !ok { data["themeList"] = playletser.FetchThemesOrEmpty(c, 0) } // 顶栏短剧下拉快捷入口:homeConfigData type=1 if _, ok := data["homeConfigPlaylets"]; !ok { if configs, ok := data["homeConfigData"].([]playletser.HomeConfigData); ok { data["homeConfigPlaylets"] = playletser.HomeConfigsByType(configs, 1) } else { home := playletser.FetchHomeOrEmpty(c) data["homeConfigPlaylets"] = playletser.HomeConfigsByType(home.HomeConfigData, 1) } } // PC 搜索下拉热搜:GET /api/app/playlet/hot-like if _, ok := data["searchTrends"]; !ok { trends := hotSearchTrends(playletser.FetchHotLikeOrEmpty(c).HotSearch) if len(trends) > 8 { trends = trends[:8] } data["searchTrends"] = trends } // 公共配置:GET /api/app/config/info → config / appConfig if _, ok := data["appConfig"]; !ok { vm := configser.FetchAppConfigVMOrEmpty(c) data["appConfig"] = vm if _, ok := data["config"]; !ok { data["config"] = vm.Config } } else if _, ok := data["config"]; !ok { if vm, ok := data["appConfig"].(configser.AppConfigVM); ok { data["config"] = vm.Config } } attachPageSEO(c, code, data) c.HTML(code, name, data) } // HTMLOK 以 200 渲染页面。 func HTMLOK(c *gin.Context, name string, data gin.H) { if pageRepresentsMissingResource(c, data) { NotFound(c) return } HTML(c, http.StatusOK, name, data) } func toInt64(v any) int64 { switch n := v.(type) { case int64: return n case int: return int64(n) case int32: return int64(n) case float64: return int64(n) case string: i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64) return i default: return 0 } } // attachPageSEO 在一个入口统一生成页面级 SEO 信号,避免各模板各自维护后产生冲突。 func attachPageSEO(c *gin.Context, code int, data gin.H) { if c == nil || c.Request == nil { return } cfg, ok := data["appConfig"].(configser.AppConfigVM) if !ok { cfg = configser.FetchAppConfigVMOrEmpty(c) data["appConfig"] = cfg } path := c.Request.URL.Path pageTitle, _ := data["title"].(string) pageTitle = strings.TrimSpace(pageTitle) seoInfo := pageSEOFromData(data["seoInfo"]) siteName := firstNonEmptySEO(seoInfo.WebSiteName, cfg.SeoInfo.WebSiteName, "黄果短剧") routeConfigSEO, _ := data["routeConfigSEO"].(bool) if !routeConfigSEO { seoInfo = replaceSitewideSEOWithRouteSEO(path, data, seoInfo, pageSEOFromData(cfg.SeoInfo), siteName) } navigationSEOApplied := false if categories, ok := data["categoryList"].([]translationser.TranslationCategory); ok { headerNav := translationser.BuildHeaderNav(&translationser.AppTranslationInfo{CategoryList: categories}) activeKey, _ := data["navActive"].(string) if navSEO, found := translationser.FindHeaderNavSEOByPath(headerNav, path); found { // 与 jha-1040-cgdj-web 一致:导航分类页面以分类接口 TDK 为主,页面接口只补缺。 seoInfo = playletser.MergeSeoInfo(playletser.SeoInfo{ Title: navSEO.Title, Description: navSEO.Description, Keywords: navSEO.Keyworld, }, seoInfo) navigationSEOApplied = true } else if !routeConfigSEO { // 当前项目的详情/播放页会复用 navActive;这类页面保持内容 TDK 为主,导航只补缺。 if navSEO, found := translationser.FindHeaderNavSEO(headerNav, activeKey, path); found { seoInfo = playletser.MergeSeoInfo(seoInfo, playletser.SeoInfo{ Title: navSEO.Title, Description: navSEO.Description, Keywords: navSEO.Keyworld, }) } } } if selections, ok := data["seoSelections"].([]playletser.SEOSelection); ok && len(selections) > 0 { seoInfo = playletser.DifferentiateSeoInfo(seoInfo, selections) } if seoInfo.Title == "" { seoInfo.Title = firstNonEmptySEO(pageTitle, defaultPageTitle(path, siteName)) } if seoInfo.Description == "" { seoInfo.Description = defaultMetaDescription(path, seoInfo.Title, data, siteName) } if len(seoInfo.Keywords) == 0 { seoInfo.Keywords = defaultMetaKeywords(path, siteName) } if pageNum := paginationPage(data["pagination"]); pageNum > 1 { seoInfo = playletser.PaginateSeoInfo(seoInfo, seoInfo.Description, pageNum) } metaTitle := seoInfo.Title if !navigationSEOApplied { metaTitle = ensureBrandTitle(metaTitle, siteName) } data["title"] = metaTitle data["metaTitle"] = metaTitle data["metaDescription"] = seoInfo.Description data["metaKeywords"] = strings.Join(seoInfo.Keywords, ",") data["siteName"] = siteName data["seoInfo"] = seoInfo siteURL := seoser.ResolveSiteURL(c, cfg) data["siteURL"] = siteURL data["foreverUrlAddr"] = siteURL data["seoVerify"] = seoser.VerifyFromConfig() data["ogType"] = pageOGType(path) data["ogImage"] = pageOGImage(siteURL, path, data) attachPaginationLinks(data, siteURL) robots, _ := data["robots"].(string) robots = strings.TrimSpace(robots) if robots == "" { robots = defaultRobotsForPath(path, code) } if !robotsHasNoIndex(robots) && pageShouldNotIndex(c, data) { robots = "noindex,follow" } data["robots"] = robots noindex := robotsHasNoIndex(robots) if noindex { data["noindex"] = true c.Header("X-Robots-Tag", strings.ReplaceAll(robots, ",", ", ")) } else { delete(data, "noindex") } canonicalEligible := code >= http.StatusOK && code < http.StatusMultipleChoices && !noindex && seoser.IsIndexablePath(path) if canonicalEligible { canonicalURL := seoser.CanonicalWithoutQuery(seoser.PageCanonicalURL(siteURL, cfg.ForeverURLAddr, c)) if canonicalPath, _ := data["canonicalPath"].(string); strings.HasPrefix(strings.TrimSpace(canonicalPath), "/") { canonicalURL = seoser.CanonicalWithoutQuery(seoser.AbsURL(siteURL, strings.TrimSpace(canonicalPath))) } canonicalURL = seoser.CanonicalWithoutQuery(seoser.ContentCanonicalURL(siteURL, canonicalURL, map[string]any(data))) data["canonicalURL"] = canonicalURL data["jsonLD"] = seoser.PageJSONLD(siteURL, canonicalURL, c.Request.URL.RequestURI(), map[string]any(data), cfg) } else { delete(data, "canonicalURL") delete(data, "jsonLD") } } func pageShouldNotIndex(c *gin.Context, data gin.H) bool { if isNavPage, _ := data["isNavPage"].(bool); isNavPage { return true } if c != nil && c.Request != nil { path := c.Request.URL.Path if isEpisodePlayPath(path, c.Param("ep")) { return true } if strings.HasPrefix(path, "/series-play/") { if canWatch, ok := data["canWatch"].(bool); ok && !canWatch { return true } } } return pageRepresentsMissingResource(c, data) } // pageRepresentsMissingResource 识别不存在的实体、非法分页与越界分页。 // 这些请求必须返回真实 404,不能仅依赖 noindex 掩盖 200 软 404。 func pageRepresentsMissingResource(c *gin.Context, data gin.H) bool { if valid, exists := data["seoEntityValid"].(bool); exists && !valid { return true } pageNum := int(toInt64(data["pageNum"])) if c != nil { if rawPage := strings.TrimSpace(c.Param("page")); rawPage != "" { requestedPage, err := strconv.Atoi(rawPage) // 分页二级路由只允许第 2 页及以后;第 1 页使用更短的规范地址。 if err != nil || requestedPage <= 1 { return true } pageNum = requestedPage } } pageSize := int(toInt64(data["pageSize"])) totalCount, hasTotal := data["totalCount"] return hasTotal && playletser.PageOutOfRange(pageNum, pageSize, toInt64(totalCount)) } func pageSEOFromData(value any) playletser.SeoInfo { var seo playletser.SeoInfo switch value := value.(type) { case playletser.SeoInfo: seo = value case *playletser.SeoInfo: if value != nil { seo = *value } case videoser.SeoInfo: seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName} case *videoser.SeoInfo: if value != nil { seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName} } case configser.SeoInfo: seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName} case *configser.SeoInfo: if value != nil { seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName} } } seo.Normalize() return seo } // replaceSitewideSEOWithRouteSEO 只替换接口误复用的全站首页 TDK。 // 页面接口真正下发的独立 SEO 仍保持最高优先级。 func replaceSitewideSEOWithRouteSEO(path string, data gin.H, seo, siteDefault playletser.SeoInfo, siteName string) playletser.SeoInfo { routeSEO, ok := routeSpecificSEO(path, data, siteName) if !ok { return seo } if sameNonEmptySEOValue(seo.Title, siteDefault.Title) { seo.Title = routeSEO.Title } if sameNonEmptySEOValue(seo.Description, siteDefault.Description) { seo.Description = routeSEO.Description } if sameSEOKeywords(seo.Keywords, siteDefault.Keywords) { seo.Keywords = append([]string(nil), routeSEO.Keywords...) } seo.Normalize() return seo } func isEpisodePlayPath(path, epParam string) bool { if strings.TrimSpace(epParam) != "" { return true } segments := strings.Split(strings.Trim(path, "/"), "/") return len(segments) >= 3 && segments[0] == "play" && segments[1] != "" && segments[2] != "" } func routeSpecificSEO(path string, data gin.H, siteName string) (playletser.SeoInfo, bool) { path = strings.TrimRight(strings.TrimSpace(path), "/") title := "" switch { case path == "/list" || strings.HasPrefix(path, "/list/"): title = firstNonEmptySEO(dataString(data, "pageHeading"), "全部短剧") case path == "/series" || strings.HasPrefix(path, "/series/"): title = firstNonEmptySEO(dataString(data, "pageHeading"), "剧集与动画") case path == "/topic" || strings.HasPrefix(path, "/topic/"): title = "专题剧单" case strings.HasPrefix(path, "/topic-detail/"): if name := dataString(data, "name"); name != "" { title = name + "专题短剧" } else { title = "短剧专题" } case strings.HasPrefix(path, "/tags/"): if name := dataString(data, "tagName"); name != "" { title = name + "标签短剧" } else { title = "标签短剧" } case strings.HasPrefix(path, "/more-list/"): title = firstNonEmptySEO(dataString(data, "configName"), "精选短剧") case strings.HasPrefix(path, "/more-series-list/"): title = firstNonEmptySEO(dataString(data, "configName"), "精选剧集") case path == "/rank" || strings.HasPrefix(path, "/rank/"): title = "短剧排行榜" case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"): title = "热门短剧推荐" case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"): title = "最近上新短剧" default: return playletser.SeoInfo{}, false } return playletser.SeoInfo{ Title: title, Description: defaultMetaDescription(path, title, data, siteName), Keywords: defaultMetaKeywords(path, siteName), }, true } func dataString(data gin.H, key string) string { value, _ := data[key].(string) return strings.TrimSpace(value) } func sameNonEmptySEOValue(value, generic string) bool { value = strings.TrimSpace(value) generic = strings.TrimSpace(generic) return generic != "" && strings.EqualFold(value, generic) } func sameSEOKeywords(value, generic []string) bool { if len(generic) == 0 || len(value) != len(generic) { return false } for i := range value { if !strings.EqualFold(strings.TrimSpace(value[i]), strings.TrimSpace(generic[i])) { return false } } return true } func paginationPage(value any) int { switch value := value.(type) { case playletser.PaginationVM: return value.Page case *playletser.PaginationVM: if value != nil { return value.Page } } return 0 } func attachPaginationLinks(data gin.H, siteURL string) { var pagination playletser.PaginationVM switch value := data["pagination"].(type) { case playletser.PaginationVM: pagination = value case *playletser.PaginationVM: if value == nil { return } pagination = *value default: return } if !pagination.Visible { return } if pagination.HasPrev && strings.TrimSpace(pagination.PrevURL) != "" { data["paginationPrevURL"] = seoser.AbsURL(siteURL, pagination.PrevURL) } if pagination.HasNext && strings.TrimSpace(pagination.NextURL) != "" { data["paginationNextURL"] = seoser.AbsURL(siteURL, pagination.NextURL) } } func defaultRobotsForPath(path string, code int) string { if code >= http.StatusBadRequest { return "noindex,follow" } if seoser.IsIndexablePath(path) { return "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" } if seoser.IsPrivatePath(path) { return "noindex,nofollow" } return "noindex,follow" } func robotsHasNoIndex(robots string) bool { for _, directive := range strings.Split(strings.ToLower(robots), ",") { if strings.TrimSpace(directive) == "noindex" { return true } } return false } func defaultPageTitle(path, siteName string) string { path = strings.TrimRight(strings.TrimSpace(path), "/") if siteName == "" { siteName = "黄果短剧" } switch { case path == "": return siteName + " - 热门短剧在线看" case path == "/about": return "关于我们" case path == "/nav" || path == "/dh": return siteName + " · 官方导航" case path == "/help": return "常见问题" case path == "/terms": return "用户条款" case path == "/privacy": return "隐私政策" case path == "/2257": return "2257 合规声明" case path == "/dmca": return "DMCA 版权声明" case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile": return "搜索" case path == "/list" || strings.HasPrefix(path, "/list/"): return "全部短剧" case path == "/series" || strings.HasPrefix(path, "/series/"): return "剧集与动画" case path == "/topic" || strings.HasPrefix(path, "/topic/"): return "专题剧单" case path == "/rank" || strings.HasPrefix(path, "/rank/"): return "短剧排行榜" case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"): return "最近上新" case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"): return "热门推荐" case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"): return "在线播放" default: return siteName } } func defaultMetaDescription(path, title string, data gin.H, siteName string) string { for _, key := range []string{"description", "desc", "heroDesc", "categoryDescription", "tagDescription", "topicDescription"} { if value, _ := data[key].(string); strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } if siteName == "" { siteName = "黄果短剧" } cleanTitle := strings.TrimSpace(strings.TrimSuffix(title, " - "+siteName)) path = strings.TrimRight(strings.TrimSpace(path), "/") if isNavPage, _ := data["isNavPage"].(bool); isNavPage { return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。" } switch { case path == "": return siteName + "汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。" case path == "/about": return "了解" + siteName + "的平台理念、内容特色与联系方式。" case path == "/nav" || path == "/dh": return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。" case path == "/help": return "查看" + siteName + "常见问题、播放帮助、账号使用与客服支持信息。" case path == "/terms": return "查看" + siteName + "用户服务条款及平台使用规则。" case path == "/privacy": return "查看" + siteName + "隐私政策与个人信息保护说明。" case path == "/2257": return "查看" + siteName + " 18 U.S.C. 第 2257 章合规声明、年龄限制与内容反馈处理原则。" case path == "/dmca": return "查看" + siteName + " DMCA 版权声明、投诉材料要求与处理流程说明。" case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile": return "在" + siteName + "搜索剧名、演员、标签与相关作品。" case strings.HasPrefix(path, "/play/"): return "在线观看《" + cleanTitle + "》,查看选集、剧情信息与相关推荐。" case strings.HasPrefix(path, "/series-play/"): return "在线观看《" + cleanTitle + "》,浏览视频信息与相关推荐。" case path == "/list" || strings.HasPrefix(path, "/list/"): return "浏览" + siteName + "全部短剧,按题材、更新状态和热度筛选精彩作品。" case path == "/series" || strings.HasPrefix(path, "/series/"): return "浏览" + siteName + "精选剧集与动画,按类型、专题和热度发现更多内容。" case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"): return "浏览" + siteName + "精选专题剧单,发现同题材热门作品与持续更新内容。" case path == "/rank" || strings.HasPrefix(path, "/rank/"): return "查看" + siteName + "实时更新的短剧排行榜,浏览热播榜、新剧榜与追剧榜。" case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"): return "浏览" + siteName + "热门推荐,发现近期高热度、广受欢迎的精选短剧。" case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"): return "查看" + siteName + "最近上新的短剧与剧集,及时追更最新内容。" default: return cleanTitle + ",浏览" + siteName + "相关内容与推荐。" } } func defaultMetaKeywords(path, siteName string) []string { path = strings.TrimRight(strings.TrimSpace(path), "/") if siteName == "" { siteName = "黄果短剧" } switch { case path == "": return []string{siteName, "短剧", "在线短剧", "热门短剧"} case path == "/list" || strings.HasPrefix(path, "/list/"): return []string{"短剧列表", "短剧分类", "在线短剧", siteName} case path == "/series" || strings.HasPrefix(path, "/series/"): return []string{"剧集", "动画", "在线观看", siteName} case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"): return []string{"在线播放", "短剧播放", siteName} case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"): return []string{"短剧专题", "专题剧单", siteName} case path == "/rank" || strings.HasPrefix(path, "/rank/"): return []string{"短剧排行榜", "热门短剧", siteName} case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"): return []string{"热门短剧", "短剧推荐", siteName} case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"): return []string{"最新短剧", "最近上新", siteName} case strings.HasPrefix(path, "/tags/"): return []string{"标签短剧", "短剧分类", siteName} case strings.HasPrefix(path, "/more-list/"): return []string{"精选短剧", "热门短剧", siteName} case strings.HasPrefix(path, "/more-series-list/"): return []string{"精选剧集", "剧集推荐", siteName} default: return []string{siteName, "在线短剧"} } } func ensureBrandTitle(title, siteName string) string { title = strings.TrimSpace(title) siteName = strings.TrimSpace(siteName) if title == "" { return siteName } if siteName == "" || strings.Contains(title, siteName) || strings.Contains(title, "黄果短剧") { return title } return title + " - " + siteName } func pageOGType(path string) string { path = strings.TrimRight(strings.TrimSpace(path), "/") if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") { return "video.episode" } if path == "/terms" || path == "/privacy" || path == "/2257" || path == "/dmca" { return "article" } return "website" } func pageOGImage(siteURL string, path string, data gin.H) string { path = strings.TrimRight(strings.TrimSpace(path), "/") // 播放页分享小卡片统一用品牌 OG 图,避免微信等抓取剧集封面。 if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") { return seoser.AbsURL(siteURL, "/static/og-default.png") } if playlet, ok := data["playlet"].(*playletser.PlayletInfoResp); ok && playlet != nil { if cover := firstNonEmptySEO(playlet.Cover, playlet.Banner); cover != "" { return seoser.AbsURL(siteURL, cover) } } if cover, _ := data["cover"].(string); strings.TrimSpace(cover) != "" { return seoser.AbsURL(siteURL, cover) } return seoser.AbsURL(siteURL, "/static/og-default.png") } func firstNonEmptySEO(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return value } } return "" }