Files
2026-09-29 15:30:33 +07:00

601 lines
21 KiB
Go
Raw Permalink 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 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 ""
}