Initial commit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:07:42 +08:00
co-authored by Claude Opus 5.5
commit 7974a42301
633 changed files with 120248 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
package ipstat
import (
"context"
"time"
"videoweb/pub/common/enum/redisconst"
"videoweb/pub/common/log"
"videoweb/pub/common/utils"
"videoweb/pub/models/ipdailystatmod"
"videoweb/pub/pubdef"
"videoweb/pub/redis"
)
const syncInterval = 5 * time.Minute
func StartSync() {
go func() {
syncOnce()
ticker := time.NewTicker(syncInterval)
defer ticker.Stop()
for range ticker.C {
syncOnce()
}
}()
}
func syncOnce() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
now := time.Now().In(utils.LocBJ)
appId := int64(pubdef.AppId)
for _, offset := range []int{0, -1} {
date := now.AddDate(0, 0, offset).Format("20060102")
key := redisconst.IpStatKey(appId, date)
count, err := redis.SCard(key)
if err != nil {
log.Warn("ipstat sync: SCard failed", log.Any("date", date), log.E(err))
continue
}
if err := ipdailystatmod.UpsertCount(ctx, appId, date, count); err != nil {
log.Warn("ipstat sync: UpsertCount failed",
log.Any("appId", appId), log.Any("date", date), log.E(err))
}
}
}
+323
View File
@@ -0,0 +1,323 @@
package sitemap
import (
"context"
"strings"
"sync"
"time"
"videoweb/pub/common/utils"
"videoweb/pub/models/playletcategorymod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playlettagmod"
"videoweb/pub/models/playletthememod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
type idWithTime struct {
Id int64
Slug string
LastMod time.Time
}
type staticPageEntry struct {
path string
changeFreq string
priority float32
}
func buildStaticURLs(webAddr string, entries []staticPageEntry) []URL {
urls := make([]URL, 0, len(entries))
now := time.Now().Format(time.DateOnly)
for _, entry := range entries {
urls = append(urls, URL{
Loc: utils.JoinUrlPathKeepSlash(webAddr, entry.path),
LastMod: now,
ChangeFreq: entry.changeFreq,
Priority: entry.priority,
})
}
return urls
}
func entityPath(prefix, slug string, id int64) string {
if segment := utils.PathSegment(slug, id); segment != "" {
return strings.TrimSuffix(prefix, "/") + "/" + segment + "/"
}
if strings.HasSuffix(prefix, "/") {
return prefix
}
return prefix + "/"
}
func hgdjStaticPages() []staticPageEntry {
return []staticPageEntry{
{path: "/", changeFreq: "daily", priority: 1.0},
{path: "/list/ai-short-play/", changeFreq: "daily", priority: 0.9},
{path: "/list/ai-short-play/popular/", changeFreq: "daily", priority: 0.8},
{path: "/list/ai-short-play/original/", changeFreq: "daily", priority: 0.8},
{path: "/series/", changeFreq: "daily", priority: 0.9},
{path: "/series/popular/", changeFreq: "daily", priority: 0.8},
{path: "/series/original/", changeFreq: "daily", priority: 0.8},
{path: "/rank/", changeFreq: "daily", priority: 0.8},
{path: "/today-update/", changeFreq: "daily", priority: 0.8},
{path: "/topic/", changeFreq: "daily", priority: 0.8},
{path: "/application/", changeFreq: "weekly", priority: 0.6},
{path: "/home-road/", changeFreq: "weekly", priority: 0.6},
{path: "/download-app/", changeFreq: "monthly", priority: 0.5},
{path: "/nav/", changeFreq: "monthly", priority: 0.5},
{path: "/about-us/", changeFreq: "yearly", priority: 0.3},
{path: "/help-center/", changeFreq: "yearly", priority: 0.3},
}
}
// getURL 并发拉取短剧数据并写入 sitemap 分片,URL 规则对齐 jha-1043-hgdj-web。
func getURL(sg *SitemapGenerator) {
webAddr := strings.TrimSpace(sg.WebAddr)
if webAddr == "" {
return
}
ctx := context.Background()
var (
wg sync.WaitGroup
listed listedPlaylets
themeItems []idWithTime
tagItems []idWithTime
categoryItems []idWithTime
)
wg.Add(4)
go func() {
defer wg.Done()
listed = getPlayletList(ctx)
}()
go func() {
defer wg.Done()
themeItems = getThemeList(ctx)
}()
go func() {
defer wg.Done()
tagItems = getTagList(ctx)
}()
go func() {
defer wg.Done()
categoryItems = getCategoryList(ctx)
}()
wg.Wait()
// 专题/标签没有上架短剧时不写入,避免空列表页被收录。
// 分类页无绑定短剧时会回退展示全部上架短剧,因此启用分类都写入。
themeItems = keepWithListedPlaylets(themeItems, listed.themeIDs)
tagItems = keepWithListedPlaylets(tagItems, listed.tagIDs)
pageURLs := buildStaticURLs(webAddr, hgdjStaticPages())
for _, item := range themeItems {
pageURLs = append(pageURLs, URL{
Loc: utils.JoinUrlPathKeepSlash(webAddr, entityPath("/topic", item.Slug, item.Id)),
LastMod: formatLastMod(item.LastMod),
ChangeFreq: "daily",
Priority: 0.8,
})
}
for _, item := range categoryItems {
pageURLs = append(pageURLs, URL{
Loc: utils.JoinUrlPathKeepSlash(webAddr, entityPath("/category", item.Slug, item.Id)),
LastMod: formatLastMod(item.LastMod),
ChangeFreq: "daily",
Priority: 0.8,
})
}
for _, item := range tagItems {
pageURLs = append(pageURLs, URL{
Loc: utils.JoinUrlPathKeepSlash(webAddr, entityPath("/tag", item.Slug, item.Id)),
LastMod: formatLastMod(item.LastMod),
ChangeFreq: "weekly",
Priority: 0.6,
})
}
if len(pageURLs) > 0 {
_ = sg.writeSitemapFile("sitemap-pages.xml", pageURLs)
}
if urls := buildPlayletPathURLs(webAddr, listed.items, "/detail", "weekly", 0.7); len(urls) > 0 {
_ = sg.writeSitemapFiles("sitemap-playletdetail", urls)
}
if urls := buildPlayletPathURLs(webAddr, listed.items, "/play", "weekly", 0.6); len(urls) > 0 {
_ = sg.writeSitemapFiles("sitemap-playletplay", urls)
}
}
type listedPlaylets struct {
items []idWithTime
themeIDs map[int64]struct{}
tagIDs map[int64]struct{}
categoryIDs map[int64]struct{}
}
func keepWithListedPlaylets(items []idWithTime, listed map[int64]struct{}) []idWithTime {
if len(items) == 0 || len(listed) == 0 {
return nil
}
out := make([]idWithTime, 0, len(items))
for _, item := range items {
if _, ok := listed[item.Id]; ok {
out = append(out, item)
}
}
return out
}
func addListedIDs(dst map[int64]struct{}, ids []int64) {
for _, id := range ids {
if id > 0 {
dst[id] = struct{}{}
}
}
}
func buildPlayletPathURLs(webAddr string, items []idWithTime, prefix string, changeFreq string, priority float32) []URL {
urls := make([]URL, 0, len(items))
for _, item := range items {
lastMod := formatLastMod(item.LastMod)
if lastMod == "" {
lastMod = time.Now().Format(time.DateOnly)
}
urls = append(urls, URL{
Loc: utils.JoinUrlPathKeepSlash(webAddr, entityPath(prefix, item.Slug, item.Id)),
LastMod: lastMod,
ChangeFreq: changeFreq,
Priority: priority,
})
}
return urls
}
func formatLastMod(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.DateOnly)
}
func getPlayletList(ctx context.Context) listedPlaylets {
result := listedPlaylets{
items: make([]idWithTime, 0),
themeIDs: make(map[int64]struct{}),
tagIDs: make(map[int64]struct{}),
categoryIDs: make(map[int64]struct{}),
}
filter := bson.M{
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}
pageSize := int64(1000)
for i := int64(0); i < 500; i++ {
skip := i * pageSize
list, _ := playletmod.FindManyPlaylet(ctx, filter, options.Find().
SetSort(bson.M{playletmod.FN.Playlet.Id: -1}).
SetSkip(skip).
SetLimit(pageSize).
SetProjection(bson.M{
playletmod.FN.Playlet.Id: 1,
playletmod.FN.Playlet.Slug: 1,
playletmod.FN.Playlet.UpdatedAt: 1,
playletmod.FN.Playlet.OnlineAt: 1,
playletmod.FN.Playlet.ThemeIds: 1,
playletmod.FN.Playlet.TagIds: 1,
playletmod.FN.Playlet.CategoryIds: 1,
}))
for _, info := range list {
if info == nil || info.Id <= 0 {
continue
}
lastMod := info.UpdatedAt
if lastMod.IsZero() {
lastMod = info.OnlineAt
}
result.items = append(result.items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: lastMod})
addListedIDs(result.themeIDs, info.ThemeIds)
addListedIDs(result.tagIDs, info.TagIds)
addListedIDs(result.categoryIDs, info.CategoryIds)
}
if len(list) < int(pageSize) {
break
}
}
return result
}
func getThemeList(ctx context.Context) []idWithTime {
filter := bson.M{
playletthememod.FN.PlayletTheme.Status: true,
}
pageSize := int64(1000)
items := make([]idWithTime, 0)
for i := int64(0); i < 100; i++ {
skip := i * pageSize
list, _ := playletthememod.FindManyPlayletTheme(ctx, filter, options.Find().
SetSort(bson.M{playletthememod.FN.PlayletTheme.Rank: -1}).
SetSkip(skip).
SetLimit(pageSize))
for _, info := range list {
if info == nil || info.Id <= 0 || strings.TrimSpace(info.Name) == "" {
continue
}
items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt})
}
if len(list) < int(pageSize) {
break
}
}
return items
}
func getTagList(ctx context.Context) []idWithTime {
filter := bson.M{
playlettagmod.FN.PlayletTag.Status: true,
}
pageSize := int64(1000)
items := make([]idWithTime, 0)
for i := int64(0); i < 100; i++ {
skip := i * pageSize
list, _ := playlettagmod.FindManyPlayletTag(ctx, filter, options.Find().
SetSort(bson.M{playlettagmod.FN.PlayletTag.Rank: -1}).
SetSkip(skip).
SetLimit(pageSize))
for _, info := range list {
if info == nil || info.Id <= 0 || strings.TrimSpace(info.Name) == "" {
continue
}
items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt})
}
if len(list) < int(pageSize) {
break
}
}
return items
}
func getCategoryList(ctx context.Context) []idWithTime {
filter := bson.M{
playletcategorymod.FN.PlayletCategory.Status: true,
}
pageSize := int64(1000)
items := make([]idWithTime, 0)
for i := int64(0); i < 100; i++ {
skip := i * pageSize
list, _ := playletcategorymod.FindManyPlayletCategory(ctx, filter, options.Find().
SetSort(bson.M{playletcategorymod.FN.PlayletCategory.Rank: -1}).
SetSkip(skip).
SetLimit(pageSize))
for _, info := range list {
if info == nil || info.Id <= 0 || strings.TrimSpace(info.Name) == "" {
continue
}
items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt})
}
if len(list) < int(pageSize) {
break
}
}
return items
}
+35
View File
@@ -0,0 +1,35 @@
package sitemap
import (
"strings"
"sync"
"videoweb/app/service/configser"
"videoweb/pub/common/log"
)
var generateMu sync.Mutex
// GenerateSitemap 生成全站 sitemap 分片及索引文件。
func GenerateSitemap() {
generateMu.Lock()
defer generateMu.Unlock()
webAddr := strings.TrimSpace(configser.GetConfigInfo().WebAddr)
if webAddr == "" {
log.Warn("GenerateSitemap: WebAddr is empty, skip")
return
}
generator := NewSitemapGenerator(webAddr, OutputDir)
if err := generator.Generate(); err != nil {
log.Error("GenerateSitemap failed", log.E(err))
return
}
if err := writeRobotsTXT(webAddr); err != nil {
log.Error("GenerateSitemap write robots.txt failed", log.E(err))
return
}
log.Info("GenerateSitemap done",
log.Any("files", len(generator.SitemapFiles)),
log.Any("webAddr", webAddr))
}
+35
View File
@@ -0,0 +1,35 @@
package sitemap
import (
"os"
"time"
"videoweb/pub/common/log"
)
func StartSitemapJob(interval time.Duration, outDir string) {
if interval <= 0 {
interval = 6 * time.Hour
}
if outDir == "" {
outDir = OutputDir
}
go func() {
if mkErr := os.MkdirAll(outDir, 0755); mkErr != nil {
log.Error("StartSitemapJob mkdir failed", log.E(mkErr))
return
}
if mkErr := os.MkdirAll(PageOutputDir, 0755); mkErr != nil {
log.Error("StartSitemapJob mkdir page dir failed", log.E(mkErr))
return
}
GenerateSitemap()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
GenerateSitemap()
}
}()
}
+263
View File
@@ -0,0 +1,263 @@
package sitemap
import (
"encoding/xml"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
stylesheetIndex = `<?xml-stylesheet type="text/xsl" href="/static/sitemapindex.xsl"?>` + "\n"
stylesheetURLSet = `<?xml-stylesheet type="text/xsl" href="/static/sitemap.xsl"?>` + "\n"
defaultMaxURLsPerFile = 40000
// OutputDir 分片 sitemap 输出目录(相对 app 进程 cwd)
OutputDir = "../h5/templates/page/sitemaps"
// PageOutputDir sitemap 索引输出目录,供 /sitemap.xml 静态读取
PageOutputDir = "../h5/templates/page"
)
type URL struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod"`
ChangeFreq string `xml:"changefreq"`
Priority float32 `xml:"priority"`
}
type Sitemap struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
URLs []URL `xml:"url"`
}
type SitemapIndex struct {
XMLName xml.Name `xml:"sitemapindex"`
Xmlns string `xml:"xmlns,attr"`
Sitemaps []SitemapIndexEntry `xml:"sitemap"`
}
type SitemapIndexEntry struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod"`
}
type SitemapGenerator struct {
MaxURLsPerFile int
OutputDir string
WebAddr string
SitemapFiles []string
}
func NewSitemapGenerator(webAddr, outputDir string) *SitemapGenerator {
return &SitemapGenerator{
MaxURLsPerFile: defaultMaxURLsPerFile,
OutputDir: outputDir,
WebAddr: webAddr,
SitemapFiles: make([]string, 0),
}
}
func (sg *SitemapGenerator) maxURLsPerFile() int {
if sg.MaxURLsPerFile > 0 {
return sg.MaxURLsPerFile
}
return defaultMaxURLsPerFile
}
func (sg *SitemapGenerator) Generate() error {
sg.SitemapFiles = make([]string, 0)
if err := os.MkdirAll(sg.OutputDir, 0755); err != nil {
return err
}
if err := os.MkdirAll(PageOutputDir, 0755); err != nil {
return err
}
getURL(sg)
if err := sg.generateSitemapIndex(); err != nil {
return err
}
return copySitemapIndexToPageRoot()
}
func (sg *SitemapGenerator) writeSitemapFile(filename string, urls []URL) error {
sitemap := Sitemap{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: urls,
}
filePath := filepath.Join(sg.OutputDir, filename)
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
if _, err := file.WriteString(xml.Header); err != nil {
return err
}
if _, err := file.WriteString(stylesheetURLSet); err != nil {
return err
}
encoder := xml.NewEncoder(file)
encoder.Indent("", " ")
if err := encoder.Encode(sitemap); err != nil {
return err
}
sg.SitemapFiles = append(sg.SitemapFiles, filename)
return nil
}
func (sg *SitemapGenerator) writeSitemapFiles(baseName string, urls []URL) error {
baseName = strings.TrimSuffix(strings.TrimSpace(baseName), ".xml")
if baseName == "" {
return nil
}
if len(urls) == 0 {
sg.cleanupSitemapShards(baseName, 0)
return nil
}
limit := sg.maxURLsPerFile()
shardCount := (len(urls) + limit - 1) / limit
if shardCount == 1 {
sg.cleanupSitemapShards(baseName, 1)
return sg.writeSitemapFile(baseName+".xml", urls)
}
sg.cleanupSitemapShards(baseName, shardCount)
for i := 0; i < shardCount; i++ {
start := i * limit
end := start + limit
if end > len(urls) {
end = len(urls)
}
filename := fmt.Sprintf("%s-%d.xml", baseName, i)
if err := sg.writeSitemapFile(filename, urls[start:end]); err != nil {
return err
}
}
return nil
}
func (sg *SitemapGenerator) cleanupSitemapShards(baseName string, shardCount int) {
entries, err := os.ReadDir(sg.OutputDir)
if err != nil {
return
}
prefix := baseName + "-"
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".xml") {
idxText := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".xml")
idx, err := strconv.Atoi(idxText)
if err != nil {
continue
}
if shardCount <= 1 || idx >= shardCount {
_ = os.Remove(filepath.Join(sg.OutputDir, name))
}
continue
}
if shardCount > 1 && name == baseName+".xml" {
_ = os.Remove(filepath.Join(sg.OutputDir, name))
}
}
}
func (sg *SitemapGenerator) generateSitemapIndex() error {
index := SitemapIndex{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
Sitemaps: make([]SitemapIndexEntry, 0, len(sg.SitemapFiles)),
}
now := time.Now().Format(time.DateOnly)
for _, filename := range sg.SitemapFiles {
index.Sitemaps = append(index.Sitemaps, SitemapIndexEntry{
Loc: fmt.Sprintf("%s/sitemaps/%s", strings.TrimSuffix(sg.WebAddr, "/"), filename),
LastMod: now,
})
}
filePath := filepath.Join(sg.OutputDir, "sitemap.xml")
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
if _, err := file.WriteString(xml.Header); err != nil {
return err
}
if _, err := file.WriteString(stylesheetIndex); err != nil {
return err
}
encoder := xml.NewEncoder(file)
encoder.Indent("", " ")
return encoder.Encode(index)
}
func copySitemapIndexToPageRoot() error {
src := filepath.Join(OutputDir, "sitemap.xml")
dst := filepath.Join(PageOutputDir, "sitemap.xml")
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
func writeRobotsTXT(webAddr string) error {
body := buildRobotsTXT(webAddr)
if body == "" {
return nil
}
if err := os.MkdirAll(PageOutputDir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(PageOutputDir, "robots.txt"), []byte(body), 0644)
}
func buildRobotsTXT(webAddr string) string {
base := strings.TrimSuffix(strings.TrimSpace(webAddr), "/")
if base == "" {
return ""
}
body := "User-agent: *\nAllow: /\n\n" +
"# 非 HTML 接口与工具路由不抓取\n" +
"Disallow: /api/\n" +
"Disallow: /ui/\n" +
"Disallow: /healthz\n" +
"Disallow: /h5/\n" +
"Disallow: /enkey\n" +
"Disallow: /newenkey\n\n" +
"# 登录、搜索和账户 HTML 页面允许抓取,以便爬虫读取页面内 noindex。\n\n" +
"Sitemap: " + base + "/sitemap.xml\n"
if host := hostFromWebAddr(base); host != "" {
body += "Host: " + host + "\n"
}
return body
}
func hostFromWebAddr(webAddr string) string {
webAddr = strings.TrimSpace(webAddr)
webAddr = strings.TrimPrefix(webAddr, "https://")
webAddr = strings.TrimPrefix(webAddr, "http://")
if i := strings.IndexByte(webAddr, '/'); i >= 0 {
webAddr = webAddr[:i]
}
return webAddr
}
+13
View File
@@ -0,0 +1,13 @@
package seo
import (
"time"
"videoweb/app/skd/job/sitemap"
"videoweb/pub/common/log"
)
// Start 启动 sitemap 生成定时任务。
func Start() {
sitemap.StartSitemapJob(6*time.Hour, sitemap.OutputDir)
log.Info("seo skd started")
}