Initial commit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fish03
2026-09-23 14:43:16 +07:00
co-authored by Claude Opus 5.5
commit bef9f8b4ed
663 changed files with 81305 additions and 0 deletions
@@ -0,0 +1,25 @@
package router
import (
"os"
"testing"
"github.com/gin-gonic/gin"
)
func TestCategoryDetailTemplateParses(t *testing.T) {
workingDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(".."); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(workingDir) })
engine := gin.New()
setupEngine(engine)
if engine.HTMLRender == nil {
t.Fatal("HTML renderer was not initialized")
}
}
+16
View File
@@ -0,0 +1,16 @@
package router
import (
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// applyClientForwardHeaders 向上游透传客户端 IP / UA(与 API 上游一致):
// X-Real-IP、User-Agent、X-User-Agent。
func applyClientForwardHeaders(header map[string]string, c *gin.Context) {
if header == nil {
return
}
useragent.ApplyToHeader(header, c, useragent.DefaultVer)
}
+321
View File
@@ -0,0 +1,321 @@
package router
import (
"encoding/json"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"videoweb/app/api/web"
"videoweb/app/appg"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
"videoweb/app/service/seoser"
"github.com/gin-gonic/gin"
)
func Init() *gin.Engine {
setGinMode()
engine := gin.Default()
engine.HandleMethodNotAllowed = true
setupEngine(engine)
engine.NoRoute(renderNotFound)
engine.NoMethod(renderNotFound)
registerM3U8Routes(engine)
registerRoutes(engine)
return engine
}
// DHInit 导航站独立端口:仅暴露官方导航页及静态资源。
func DHInit() *gin.Engine {
setGinMode()
engine := gin.Default()
engine.HandleMethodNotAllowed = true
setupEngine(engine)
engine.StaticFile("/favicon.ico", "./static/favicon.ico")
engine.StaticFile("/favicon.svg", "./static/favicon.svg")
engine.StaticFile("/favicon.png", "./static/favicon.png")
engine.GET("/ui/dialog/:name", web.DialogFragment)
engine.GET("/", web.Nav)
engine.GET("/nav/", web.Nav)
engine.GET("/dh/", web.Nav)
engine.NoRoute(renderNotFound)
engine.NoMethod(renderNotFound)
return engine
}
func setGinMode() {
switch strings.ToLower(strings.TrimSpace(appg.Conf.RunMod)) {
case "test":
gin.SetMode(gin.TestMode)
case "prod":
gin.SetMode(gin.ReleaseMode)
default:
gin.SetMode(gin.DebugMode)
}
}
func setupEngine(engine *gin.Engine) {
tplFuncs := playletser.TemplateFuncs()
tplFuncs["InJSONScript"] = inJSONScript
tplFuncs["jsonStr"] = jsonStr
tplFuncs["InTrackerAppId"] = inTrackerAppId
engine.SetFuncMap(tplFuncs)
engine.LoadHTMLFiles(listHTMLTemplates("templates")...)
engine.Use(staticCacheHeaders(), crawlerHeaders())
registerBrowserDiscoveryRoutes(engine)
engine.Static("/static", "./static")
}
// registerBrowserDiscoveryRoutes 处理 Chrome DevTools 的本地工作区探测请求。
// 该请求并非站点页面,返回 204 可避免进入业务 404 页面和产生误导日志。
func registerBrowserDiscoveryRoutes(engine *gin.Engine) {
engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=86400")
c.Header("X-Robots-Tag", "noindex, nofollow")
c.Status(http.StatusNoContent)
})
}
// staticCacheHeaders 为静态资源设置浏览器缓存,减少重复下载。
func staticCacheHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/static/") {
c.Header("Cache-Control", "public, max-age=604800, stale-while-revalidate=86400")
}
c.Next()
}
}
// crawlerHeaders 防止接口、片段和健康检查等非 HTML 内容进入搜索索引。
func crawlerHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/ui/") ||
strings.HasPrefix(path, "/h5/") ||
path == "/healthz" || path == "/enkey" || path == "/newenkey" {
c.Header("X-Robots-Tag", "noindex, nofollow")
}
c.Next()
}
}
// listHTMLTemplates 收集 templates 下页面模板,跳过遗留广告文件。
func listHTMLTemplates(root string) []string {
var files []string
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
name := strings.ToLower(d.Name())
if !strings.HasSuffix(name, ".html") {
return nil
}
if name == "banners.html" {
return nil
}
files = append(files, path)
return nil
})
if len(files) == 0 {
panic("no html templates found under " + root)
}
return files
}
// inJSONScript 将任意数据序列化为可嵌入 <script> 的 JSON。
func inJSONScript(v any) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
return template.JS(b)
}
// jsonStr 将字符串序列化为可嵌入 application/ld+json 的 JSON 字符串字面量。
func jsonStr(s string) template.JS {
b, err := json.Marshal(s)
if err != nil {
return template.JS(`""`)
}
return template.JS(b)
}
func inTrackerAppId() string {
return configser.ResolveTrackerAppID()
}
func registerRoutes(r *gin.Engine) {
r.GET("/", web.Index)
r.GET("/ui/dialog/:name", web.DialogFragment)
r.POST("/api/auth/register", web.Register)
r.POST("/api/auth/login", web.Login)
r.POST("/api/auth/resetPassword", web.ResetPassword)
r.POST("/api/auth/guest", web.Guest)
r.POST("/api/auth/logout", web.Logout)
r.POST("/api/auth/profile", web.UpdateProfile)
r.GET("/api/auth/getInfo", web.GetUserInfo)
r.POST("/api/upload/image", web.UploadImage)
r.POST("/api/feedback/add", web.AddFeedback)
r.POST("/api/contactus/add", web.AddContactUs)
r.GET("/api/comment/list", web.CommentList)
r.POST("/api/comment/send", web.CommentSend)
r.POST("/api/comment/like/set", web.CommentLikeSet)
r.POST("/api/playlet/like/set", web.PlayletLikeSet)
r.POST("/api/playlet/collect/set", web.PlayletCollectSet)
r.POST("/api/playlet/forward/heat", web.PlayletForwardHeat)
r.GET("/api/playlet/chapter/detail", web.ChapterDetail)
r.GET("/api/playlet/chapter/list", web.ChapterList)
r.GET("/api/playlet/operate/list", web.OperateList)
r.GET("/api/playlet/hot-like", web.HotLike)
r.GET("/api/app/playlet/hot-like", web.HotLike)
r.POST("/api/playlet/chapter/like/set", web.ChapterLikeSet)
r.POST("/api/playlet/chapter/collect/set", web.ChapterCollectSet)
r.POST("/api/playlet/chapter/watch/report", web.ChapterWatchReport)
r.POST("/api/playlet/history/clear", web.HistoryClear)
r.POST("/api/playlet/chapter/unlock", web.ChapterUnlock)
r.POST("/api/coin/buy", web.CoinBuy)
r.POST("/api/vip/pay", web.VipPay)
r.GET("/list/", web.List)
r.GET("/list/:slug/", web.List)
r.GET("/list/:slug/:sortOrder/", web.List)
r.GET("/list/:slug/:sortOrder/page/:page/", web.List)
r.GET("/series/", web.Series)
r.GET("/series/page/:page/", web.Series)
r.GET("/series/:filter/", web.Series)
r.GET("/series/:filter/page/:page/", web.Series)
// 标签页面使用 tag 路由;tag-list 仅作为历史地址保留并由 handler 301 到 canonical。
r.GET("/tag/", web.TagList)
r.GET("/tag/page/:page/", web.TagList)
r.GET("/tag/:slug/", web.TagList)
r.GET("/tag/:slug/page/:page/", web.TagList)
r.GET("/tag-list/", web.TagList)
r.GET("/tag-list/page/:page/", web.TagList)
r.GET("/tag-list/:slug/", web.TagList)
r.GET("/tag-list/:slug/page/:page/", web.TagList)
r.GET("/rank/", web.Rank)
r.GET("/rank/page/:page/", web.Rank)
r.GET("/rank/:sortOrder/", web.Rank)
r.GET("/rank/:sortOrder/page/:page/", web.Rank)
r.GET("/today-update/", web.TodayUpdate)
r.GET("/today-update/page/:page/", web.TodayUpdate)
// 分类详情使用语义化 category 路由,分类标识优先使用 slug、无 slug 时使用 ID。
r.GET("/category/:slug/", web.CategoryDetail)
r.GET("/category/:slug/page/:page/", web.CategoryDetail)
// 专题列表和详情统一使用 topic 路由;more 仅作为历史地址保留并由 handler 301 到 canonical。
r.GET("/topic/", web.ThemeIndex)
r.GET("/topic/page/:page/", web.ThemeIndex)
r.GET("/topic/:slug/", web.More)
r.GET("/topic/:slug/page/:page/", web.More)
r.GET("/more/", web.More)
r.GET("/more/page/:page/", web.More)
r.GET("/more/:slug/", web.More)
r.GET("/more/:slug/page/:page/", web.More)
r.GET("/download-app/", func(c *gin.Context) {
web.HTMLOK(c, "downloadApp.html", gin.H{
"title": "下载APP",
"navActive": "downloadApp",
})
})
r.GET("/application/", web.Application)
r.GET("/home-road/", web.HomeRoad)
r.GET("/detail/:slug/", web.Detail)
r.GET("/play/:slug/", web.Play)
r.GET("/play/:slug/:chapterId/", web.Play)
r.GET("/login/", web.AuthIndex("login"))
r.GET("/login/return/*redirect", web.AuthIndex("login"))
r.GET("/register/", web.AuthIndex("register"))
r.GET("/user-center/", web.UserCenter)
r.GET("/deposit/", web.Deposit)
r.GET("/deposit/:tab/", web.Deposit)
r.GET("/collect/", web.Collect)
r.GET("/collect/page/:page/", web.Collect)
r.GET("/collect/:type/", web.Collect)
r.GET("/collect/:type/page/:page/", web.Collect)
r.GET("/history/", web.History)
r.GET("/history/page/:page/", web.History)
r.GET("/setting/", web.Setting)
r.GET("/edit-profile/", web.EditProfile)
r.GET("/edit-password/", web.EditPassword)
r.GET("/search/", web.Search)
r.GET("/search/:keyword/", web.Search)
r.GET("/search/:keyword/page/:page/", web.Search)
r.GET("/about-us/", web.RichtextPage("关于我们"))
r.GET("/user-agreement/", web.RichtextPage("用户协议"))
r.GET("/privacy-policy/", web.RichtextPage("隐私政策"))
r.GET("/help-center/", web.RichtextPage("帮助中心"))
r.GET("/richtext/", web.Richtext)
r.GET("/richtext/:title/", web.Richtext)
r.GET("/feedback/", func(c *gin.Context) {
web.HTMLOK(c, "feedback.html", gin.H{
"title": "问题反馈",
"noindex": true,
"robots": "noindex,nofollow",
})
})
r.GET("/nav/", web.Nav)
r.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
})
registerSitemapRoutes(r)
r.GET("/robots.txt", web.RobotsTXT)
registerSEOVerifyRoutes(r)
r.GET("/favicon.ico", func(c *gin.Context) {
c.File("./static/favicon.ico")
})
r.GET("/favicon.svg", func(c *gin.Context) {
c.File("./static/favicon.svg")
})
r.GET("/favicon.png", func(c *gin.Context) {
c.File("./static/favicon.png")
})
}
func renderNotFound(c *gin.Context) {
web.HTML(c, http.StatusNotFound, "404.html", gin.H{
"path": c.Request.URL.Path,
"noindex": true,
"robots": "noindex,follow",
})
}
func registerSEOVerifyRoutes(r *gin.Engine) {
codes := seoser.VerifyFromConfig()
if codes.BaiduHTML != "" {
r.GET("/"+codes.BaiduHTML, web.SEOVerifyFile)
}
if codes.IndexNowKey != "" {
r.GET("/"+codes.IndexNowKey+".txt", web.SEOVerifyFile)
}
}
+140
View File
@@ -0,0 +1,140 @@
package router
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestContentPagesOnlyRegisterPathParameterRoutes(t *testing.T) {
engine := gin.New()
registerRoutes(engine)
routes := engine.Routes()
registered := make(map[string]bool, len(routes))
handlers := make(map[string]string, len(routes))
for _, route := range routes {
key := route.Method + " " + route.Path
registered[key] = true
handlers[key] = route.Handler
}
for _, route := range []string{
"GET /detail/:slug",
"GET /play/:slug",
"GET /play/:slug/:chapterId",
"GET /topic",
"GET /topic/page/:page",
"GET /topic/:slug",
"GET /topic/:slug/page/:page",
"GET /tag",
"GET /tag/page/:page",
"GET /tag/:slug",
"GET /tag/:slug/page/:page",
"GET /series",
"GET /series/page/:page",
"GET /series/:filter",
"GET /series/:filter/page/:page",
"GET /category/:slug",
"GET /category/:slug/page/:page",
"GET /home-road",
} {
if !registered[route] {
t.Fatalf("missing path parameter route %s", route)
}
}
for _, route := range []string{
"POST /api/auth/resetPassword",
"POST /api/contactus/add",
"GET /api/playlet/chapter/list",
"GET /api/playlet/operate/list",
"GET /api/playlet/hot-like",
"GET /api/app/playlet/hot-like",
} {
if !registered[route] {
t.Fatalf("missing documented API route %s", route)
}
}
for _, route := range []string{"GET /detail", "GET /play"} {
if registered[route] {
t.Fatalf("legacy query route should not be registered: %s", route)
}
}
for route, handlerSuffix := range map[string]string{
"GET /topic": ".ThemeIndex",
"GET /topic/page/:page": ".ThemeIndex",
"GET /topic/:slug": ".More",
"GET /tag": ".TagList",
"GET /tag/:slug": ".TagList",
"GET /category/:slug": ".CategoryDetail",
} {
if !strings.HasSuffix(handlers[route], handlerSuffix) {
t.Fatalf("handler for %s = %q, want suffix %q", route, handlers[route], handlerSuffix)
}
}
}
func TestLegacyTagListRouteRedirectsToSemanticTagRoute(t *testing.T) {
engine := gin.New()
registerRoutes(engine)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/tag-list/fantasy/page/2", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusMovedPermanently)
}
if location := recorder.Header().Get("Location"); location != "/tag/fantasy/page/2" {
t.Fatalf("Location = %q, want %q", location, "/tag/fantasy/page/2")
}
}
func TestLegacyAISeriesListRedirectsToSeriesPage(t *testing.T) {
engine := gin.New()
registerRoutes(engine)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/list/ai-series/1/page/3", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusMovedPermanently)
}
if location := recorder.Header().Get("Location"); location != "/series/popular/page/3" {
t.Fatalf("Location = %q, want %q", location, "/series/popular/page/3")
}
}
func TestCrawlerHeadersNoIndexNonHTMLRoutes(t *testing.T) {
engine := gin.New()
engine.Use(crawlerHeaders())
engine.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) })
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
engine.ServeHTTP(recorder, request)
if got := recorder.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
t.Fatalf("X-Robots-Tag = %q, want noindex, nofollow", got)
}
}
func TestChromeDevToolsDiscoveryReturnsNoContent(t *testing.T) {
engine := gin.New()
registerBrowserDiscoveryRoutes(engine)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/.well-known/appspecific/com.chrome.devtools.json", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent)
}
if got := recorder.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
t.Fatalf("X-Robots-Tag = %q, want noindex, nofollow", got)
}
}
+183
View File
@@ -0,0 +1,183 @@
package router
import (
"encoding/json"
"net/http"
"strings"
"videoweb/app/appg"
commonhttp "videoweb/pub/common/http"
"videoweb/pub/common/log"
"videoweb/pub/common/m3u8"
"github.com/gin-gonic/gin"
)
func registerM3U8Routes(r *gin.Engine) {
r.GET("/h5/m3u8/*path", proxyM3U8)
r.GET("/enkey", serveM3U8Key)
r.GET("/newenkey", serveNewM3U8Key)
}
func proxyM3U8(c *gin.Context) {
rawPath := strings.TrimPrefix(c.Param("path"), "/")
if rawPath == "" {
c.Status(http.StatusBadRequest)
return
}
if c.Request.URL.RawQuery != "" {
rawPath += "?" + c.Request.URL.RawQuery
}
serverAddr := strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
if serverAddr == "" {
log.Warn("proxyM3U8 empty serverAddr")
c.Status(http.StatusBadGateway)
return
}
targetURL := serverAddr + "/h5/m3u8/" + rawPath
// 播放地址本身带签名 token;勿强制带登录 Authorization。
body, status, err := fetchM3U8Upstream(c, targetURL, "")
if err != nil {
log.Warn("proxyM3U8 request failed", log.Any("target", targetURL), log.E(err))
c.Status(http.StatusBadGateway)
return
}
if status != http.StatusOK {
c.Status(status)
return
}
if code, ok := jsonErrorCode(body); ok {
log.Warn("proxyM3U8 upstream error",
log.Any("target", targetURL),
log.Any("code", code),
log.Any("body", string(body)))
c.Data(http.StatusBadGateway, "application/json; charset=utf-8", body)
return
}
contentType := detectMediaContentType(rawPath)
if isM3U8Content(rawPath, body) {
contentType = "application/vnd.apple.mpegurl"
}
c.Data(http.StatusOK, contentType, body)
}
func fetchM3U8Upstream(c *gin.Context, targetURL, auth string) ([]byte, int, error) {
header := map[string]string{
"Accept": "*/*",
}
if auth != "" {
header["Authorization"] = auth
}
applyClientForwardHeaders(header, c)
resp, err := commonhttp.GetLargeFile(targetURL, header)
if err != nil {
return nil, 0, err
}
return resp.Data, resp.StatusCode, nil
}
func jsonErrorCode(body []byte) (int, bool) {
if len(body) == 0 || body[0] != '{' {
return 0, false
}
var payload struct {
Code int `json:"code"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return 0, false
}
if payload.Code == 200 || payload.Code == 0 {
return 0, false
}
return payload.Code, true
}
func isM3U8Content(rawPath string, body []byte) bool {
if strings.Contains(strings.Split(rawPath, "?")[0], ".m3u8") {
return true
}
return len(body) > 0 && body[0] == '#'
}
func detectMediaContentType(rawPath string) string {
pathOnly := strings.Split(rawPath, "?")[0]
switch {
case strings.HasSuffix(pathOnly, ".m3u8"):
return "application/vnd.apple.mpegurl"
case strings.HasSuffix(pathOnly, ".ts"):
return "video/mp2t"
default:
return "application/octet-stream"
}
}
func serveM3U8Key(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Cache-Control", "no-store")
if key, ok := fetchUpstreamEnkey(c, c.Request.URL.RawQuery); ok {
c.Data(http.StatusOK, "application/octet-stream", key)
return
}
buffer, err := m3u8.Enkey(c.Request.Context())
if err != nil || buffer == nil || buffer.Len() == 0 {
log.Warn("serveM3U8Key local key unavailable", log.E(err))
c.Status(http.StatusBadGateway)
return
}
c.Data(http.StatusOK, "application/octet-stream", buffer.Bytes())
}
func serveNewM3U8Key(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Cache-Control", "no-store")
key, ok := fetchUpstreamKey(c, "/newenkey", c.Request.URL.RawQuery)
if !ok {
c.Status(http.StatusBadGateway)
return
}
c.Data(http.StatusOK, "application/octet-stream", key)
}
func fetchUpstreamEnkey(c *gin.Context, rawQuery string) ([]byte, bool) {
return fetchUpstreamKey(c, "/enkey", rawQuery)
}
func fetchUpstreamKey(c *gin.Context, keyPath, rawQuery string) ([]byte, bool) {
serverAddr := strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
if serverAddr == "" {
return nil, false
}
targetURL := serverAddr + keyPath
if rawQuery = strings.TrimSpace(rawQuery); rawQuery != "" {
targetURL += "?" + rawQuery
}
header := map[string]string{
"Accept": "*/*",
}
applyClientForwardHeaders(header, c)
resp, err := commonhttp.GetLargeFile(targetURL, header)
if err != nil {
log.Warn("fetchUpstreamKey request failed", log.Any("target", targetURL), log.E(err))
return nil, false
}
if resp.StatusCode != http.StatusOK || len(resp.Data) == 0 || jsonErrorCodeBody(resp.Data) {
log.Warn("fetchUpstreamKey invalid response",
log.Any("target", targetURL),
log.Any("status", resp.StatusCode),
log.Any("len", len(resp.Data)))
return nil, false
}
return resp.Data, true
}
func jsonErrorCodeBody(body []byte) bool {
_, ok := jsonErrorCode(body)
return ok
}
+70
View File
@@ -0,0 +1,70 @@
package router
import (
"bytes"
"html/template"
"strings"
"testing"
)
func TestNavigationPageUsesDedicatedVerificationAndAnalytics(t *testing.T) {
navHTML := renderSEOCommonForTrackingTest(t, true)
for _, want := range []string{
"UEtS2h9mIjbFSM_VHEciNQ5iWuFPq9d-Q2WO0SFyPAw",
"eafd02fb6517fc76",
"112390883",
"G-SY26Y2MSHS",
} {
if !strings.Contains(navHTML, want) {
t.Fatalf("navigation SEO output does not contain %q", want)
}
}
for _, unwanted := range []string{"112387687", "G-TZQS2NZ3TN", "6bb06ed5659790d0"} {
if strings.Contains(navHTML, unwanted) {
t.Fatalf("navigation SEO output unexpectedly contains main-site id %q", unwanted)
}
}
}
func TestMainSiteKeepsMainVerificationAndAnalytics(t *testing.T) {
mainHTML := renderSEOCommonForTrackingTest(t, false)
for _, want := range []string{
"r7geXn5kBugzOSLZzyw4v3NBDMQwAmzFCVkqp92odh0",
"6bb06ed5659790d0",
"112387687",
"G-TZQS2NZ3TN",
} {
if !strings.Contains(mainHTML, want) {
t.Fatalf("main-site SEO output does not contain %q", want)
}
}
for _, unwanted := range []string{"112390883", "G-SY26Y2MSHS", "eafd02fb6517fc76"} {
if strings.Contains(mainHTML, unwanted) {
t.Fatalf("main-site SEO output unexpectedly contains navigation id %q", unwanted)
}
}
}
func renderSEOCommonForTrackingTest(t *testing.T, isNavPage bool) string {
t.Helper()
tpl, err := template.New("tracking-test").Funcs(template.FuncMap{
"InTrackerAppId": func() string { return "test-app" },
"jsonStr": func(any) template.JS { return template.JS(`""`) },
}).ParseFiles("../templates/base/seoCommon.html", "../templates/base/trackerInit.html")
if err != nil {
t.Fatal(err)
}
data := map[string]any{
"isNavPage": isNavPage,
"seoVerify": map[string]any{"Baidu": ""},
"appConfig": map[string]any{
"ReportSDKConfig": "",
"SeoInfo": map[string]any{"WebSiteName": ""},
},
}
var out bytes.Buffer
if err := tpl.ExecuteTemplate(&out, "seoCommon", data); err != nil {
t.Fatal(err)
}
return out.String()
}
+90
View File
@@ -0,0 +1,90 @@
package router
import (
"html"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
const sitemapDir = "./templates/page/sitemaps"
var sitemapLocPattern = regexp.MustCompile(`<loc>([^<]+)</loc>`)
// registerSitemapRoutes 暴露 hgdj_server app 生成的 sitemap 索引及分片文件。
func registerSitemapRoutes(r *gin.Engine) {
r.StaticFile("/sitemap.xml", filepath.Join(sitemapDir, "sitemap.xml"))
r.GET("/sitemaps/:name", serveSitemapShard)
}
func serveSitemapShard(c *gin.Context) {
name := c.Param("name")
if !isValidSitemapShardName(name) {
renderNotFound(c)
return
}
raw, err := os.ReadFile(filepath.Join(sitemapDir, name))
if err != nil {
renderNotFound(c)
return
}
c.Data(http.StatusOK, "application/xml; charset=utf-8", normalizeSitemapPageURLs(raw))
}
// normalizeSitemapPageURLs 让运行时生成的旧 sitemap 与当前 canonical 路由保持一致。
// sitemap 索引和静态文件地址不经过这里,仍保留文件型 URL(无尾斜杠)。
func normalizeSitemapPageURLs(raw []byte) []byte {
return sitemapLocPattern.ReplaceAllFunc(raw, func(match []byte) []byte {
parts := sitemapLocPattern.FindSubmatch(match)
if len(parts) != 2 {
return match
}
loc := html.UnescapeString(strings.TrimSpace(string(parts[1])))
u, err := url.Parse(loc)
if err != nil || u.Path == "" {
return match
}
u.Path = canonicalSitemapPagePath(u.Path)
return []byte("<loc>" + html.EscapeString(u.String()) + "</loc>")
})
}
func canonicalSitemapPagePath(path string) string {
switch {
case path == "/more":
path = "/topic"
case strings.HasPrefix(path, "/more/"):
path = "/topic/" + strings.TrimPrefix(path, "/more/")
case path == "/tag-list":
path = "/tag"
case strings.HasPrefix(path, "/tag-list/"):
path = "/tag/" + strings.TrimPrefix(path, "/tag-list/")
}
if path != "/" && !strings.HasSuffix(path, "/") {
path += "/"
}
return path
}
// isValidSitemapShardName 校验分片文件名,防止路径遍历。
// 合法示例:sitemap-pages.xml、sitemap-playletdetail-0.xml
func isValidSitemapShardName(name string) bool {
if !strings.HasPrefix(name, "sitemap-") || !strings.HasSuffix(name, ".xml") {
return false
}
base := strings.TrimSuffix(name, ".xml")
if len(base) <= len("sitemap-") {
return false
}
for _, ch := range base[len("sitemap-"):] {
if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') {
return false
}
}
return true
}