Initial commit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
package rss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/utils"
|
||||
"videoweb/pub/models/cfg"
|
||||
"videoweb/pub/pubdef"
|
||||
"videoweb/web/service/srv_cfg"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRSSItems = 200 // RSS 条目上限
|
||||
)
|
||||
|
||||
var generateMu sync.Mutex
|
||||
|
||||
// RSS 2.0 结构定义
|
||||
type rssFeed struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Atom string `xml:"xmlns:atom,attr"`
|
||||
Channel rssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
type rssChannel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Language string `xml:"language"`
|
||||
LastBuildDate string `xml:"lastBuildDate"`
|
||||
AtomLink atomLink `xml:"atom:link"`
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
|
||||
type atomLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
GUID string `xml:"guid"`
|
||||
Enclosure *rssEnclosure `xml:"enclosure,omitempty"`
|
||||
}
|
||||
|
||||
type rssEnclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Length string `xml:"length,attr"`
|
||||
}
|
||||
|
||||
// rssEntry 内部排序用,合并文章与工具后统一输出
|
||||
type rssEntry struct {
|
||||
Id int64
|
||||
Title string
|
||||
Link string
|
||||
Description string
|
||||
PubAt time.Time
|
||||
Cover string
|
||||
}
|
||||
|
||||
func GenerateRSS() {
|
||||
generateMu.Lock()
|
||||
defer generateMu.Unlock()
|
||||
|
||||
ctx := context.Background()
|
||||
config, serr := srv_cfg.GetSystemConfig(ctx)
|
||||
if serr != nil {
|
||||
log.Error("GenerateRSS GetSystemConfig", log.Any("err", serr))
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
log.Error("GenerateRSS GetSystemConfig nil config")
|
||||
return
|
||||
}
|
||||
//
|
||||
//entries := loadRSSEntries(ctx, config.WebAddr, maxRSSItems)
|
||||
//items := buildRSSItems(config, entries)
|
||||
//feedURL := utils.JoinUrlPath(config.WebAddr, "/rss.xml")
|
||||
//feed := rssFeed{
|
||||
// Version: "2.0",
|
||||
// Atom: "http://www.w3.org/2005/Atom",
|
||||
// Channel: rssChannel{
|
||||
// Title: config.WebTitle,
|
||||
// Link: config.WebAddr,
|
||||
// Description: channelDescription(config),
|
||||
// Language: "zh-cn",
|
||||
// LastBuildDate: time.Now().Format(time.RFC1123Z),
|
||||
// AtomLink: atomLink{
|
||||
// Href: feedURL,
|
||||
// Rel: "self",
|
||||
// Type: "application/rss+xml",
|
||||
// },
|
||||
// Items: items,
|
||||
// },
|
||||
//}
|
||||
|
||||
//const filename = "rss.xml"
|
||||
//if err := writeRSSFile(filename, feed); err != nil {
|
||||
// log.Error("GenerateRSS write", log.Any("err", err))
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//src := filepath.Join(pubdef.OutputDir, filename+".tmp")
|
||||
//dst := filepath.Join(pubdef.OutputDir, filename)
|
||||
//if err := os.Rename(src, dst); err != nil {
|
||||
// log.Error("GenerateRSS rename", log.Any("err", err))
|
||||
// os.Remove(src)
|
||||
// return
|
||||
//}
|
||||
|
||||
//log.Info("GenerateRSS done", log.Any("items", len(items)))
|
||||
}
|
||||
|
||||
func channelDescription(config *cfg.SysCfg) string {
|
||||
if config.WebDescription != "" {
|
||||
return config.WebDescription
|
||||
}
|
||||
return "AI 工具导航与资讯更新"
|
||||
}
|
||||
|
||||
//
|
||||
//func loadRSSEntries(ctx context.Context, baseURL string, limit int) []rssEntry {
|
||||
// baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
// articleFilter := bson.M{
|
||||
// articlemod.FN.Article.ReviewStatus: 1,
|
||||
// articlemod.FN.Article.Slug: bson.M{"$nin": []interface{}{"", nil}},
|
||||
// }
|
||||
// articleOpt := options.Find().
|
||||
// SetSort(bson.D{{Key: articlemod.FN.Article.CreatedAt, Value: -1}}).
|
||||
// SetLimit(int64(limit))
|
||||
// articles, _ := articlemod.FindMany(ctx, articleFilter, articleOpt)
|
||||
//
|
||||
// toolFilter := bson.M{
|
||||
// toolmod.FN.Tool.ReviewStatus: 1,
|
||||
// toolmod.FN.Tool.Slug: bson.M{"$nin": []interface{}{"", nil}},
|
||||
// }
|
||||
// toolOpt := options.Find().
|
||||
// SetSort(bson.D{{Key: toolmod.FN.Tool.CreatedAt, Value: -1}}).
|
||||
// SetLimit(int64(limit))
|
||||
// tools, _ := toolmod.FindMany(ctx, toolFilter, toolOpt)
|
||||
//
|
||||
// entries := make([]rssEntry, 0, len(articles)+len(tools))
|
||||
// for _, a := range articles {
|
||||
// if a == nil || a.Slug == "" {
|
||||
// continue
|
||||
// }
|
||||
// desc := a.Intro
|
||||
// if desc == "" {
|
||||
// desc = a.Title
|
||||
// }
|
||||
// entries = append(entries, rssEntry{
|
||||
// Id: a.Id,
|
||||
// Title: a.Title,
|
||||
// Link: articleDetailURL(baseURL, a.Slug),
|
||||
// Description: desc,
|
||||
// PubAt: a.CreatedAt,
|
||||
// Cover: a.Cover,
|
||||
// })
|
||||
// }
|
||||
// for _, t := range tools {
|
||||
// if t == nil || t.Slug == "" {
|
||||
// continue
|
||||
// }
|
||||
// desc := t.Intro
|
||||
// if desc == "" {
|
||||
// desc = t.Desc
|
||||
// }
|
||||
// if desc == "" {
|
||||
// desc = t.Title
|
||||
// }
|
||||
// cover := t.Logo
|
||||
// if cover == "" {
|
||||
// cover = t.Snapshot
|
||||
// }
|
||||
// entries = append(entries, rssEntry{
|
||||
// Id: t.Id,
|
||||
// Title: t.Title,
|
||||
// Link: toolDetailURL(baseURL, t.Slug),
|
||||
// Description: desc,
|
||||
// PubAt: t.CreatedAt,
|
||||
// Cover: cover,
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// sort.Slice(entries, func(i, j int) bool {
|
||||
// return entries[i].PubAt.After(entries[j].PubAt)
|
||||
// })
|
||||
// if len(entries) > limit {
|
||||
// entries = entries[:limit]
|
||||
// }
|
||||
// return entries
|
||||
//}
|
||||
//
|
||||
//func buildRSSItems(config *cfg.SysCfg, entries []rssEntry) []rssItem {
|
||||
// logoURL := utils.JoinUrlPath(strings.TrimSuffix(config.WebAddr, "/"), "/static/images/logo.png")
|
||||
// imgCdn := imageCdn(config)
|
||||
//
|
||||
// items := make([]rssItem, 0, len(entries))
|
||||
// for _, e := range entries {
|
||||
// if e.Link == "" {
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// pubDate := ""
|
||||
// if !e.PubAt.IsZero() {
|
||||
// pubDate = e.PubAt.Format(time.RFC1123Z)
|
||||
// }
|
||||
//
|
||||
// item := rssItem{
|
||||
// Title: e.Title,
|
||||
// Link: e.Link,
|
||||
// Description: e.Description,
|
||||
// PubDate: pubDate,
|
||||
// GUID: e.Link,
|
||||
// }
|
||||
//
|
||||
// encURL, encType := resolveEnclosureURL(config, imgCdn, logoURL, e.Cover, e.Id)
|
||||
// item.Enclosure = &rssEnclosure{
|
||||
// URL: encURL,
|
||||
// Type: encType,
|
||||
// Length: "0",
|
||||
// }
|
||||
// items = append(items, item)
|
||||
// }
|
||||
// return items
|
||||
//}
|
||||
|
||||
func imageCdn(config *cfg.SysCfg) string {
|
||||
if config.ImgWebCdn != "" {
|
||||
return config.ImgWebCdn
|
||||
}
|
||||
return config.UnencryptedCdn
|
||||
}
|
||||
|
||||
func resolveEnclosureURL(config *cfg.SysCfg, imgCdn, logoURL, cover string, id int64) (url, mime string) {
|
||||
mime = "image/jpeg"
|
||||
if cover != "" && imgCdn != "" {
|
||||
url = netutil.MergeURL(imgCdn, cover)
|
||||
} else if len(config.SitemapDefaultImages) > 0 && imgCdn != "" {
|
||||
img := config.SitemapDefaultImages[absModIndex(id, len(config.SitemapDefaultImages))]
|
||||
url = netutil.MergeURL(imgCdn, img)
|
||||
} else {
|
||||
url = logoURL
|
||||
mime = "image/png"
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(url), ".png") {
|
||||
mime = "image/png"
|
||||
} else if strings.HasSuffix(strings.ToLower(url), ".webp") {
|
||||
mime = "image/webp"
|
||||
}
|
||||
return url, mime
|
||||
}
|
||||
|
||||
func toolDetailURL(baseURL, slug string) string {
|
||||
return utils.JoinUrlPath(baseURL, fmt.Sprintf("/tool/details/%s", slug))
|
||||
}
|
||||
|
||||
func articleDetailURL(baseURL, slug string) string {
|
||||
return utils.JoinUrlPath(baseURL, fmt.Sprintf("/article/details/%s", slug))
|
||||
}
|
||||
|
||||
func absModIndex(id int64, length int) int {
|
||||
if length <= 0 {
|
||||
return 0
|
||||
}
|
||||
idx := id % int64(length)
|
||||
if idx < 0 {
|
||||
idx = -idx
|
||||
}
|
||||
return int(idx)
|
||||
}
|
||||
|
||||
func writeRSSFile(filename string, data interface{}) error {
|
||||
if err := os.MkdirAll(pubdef.OutputDir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", pubdef.OutputDir, err)
|
||||
}
|
||||
path := filepath.Join(pubdef.OutputDir, filename+".tmp")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", filename, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.WriteString(xml.Header); err != nil {
|
||||
return fmt.Errorf("write header %s: %w", filename, err)
|
||||
}
|
||||
encoder := xml.NewEncoder(file)
|
||||
encoder.Indent("", " ")
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return fmt.Errorf("encode %s: %w", filename, err)
|
||||
}
|
||||
if err := encoder.Flush(); err != nil {
|
||||
return fmt.Errorf("flush %s: %w", filename, err)
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
Reference in New Issue
Block a user