来源:hgdjnet-server 9bfbafe10d417deea959bd53117bc88a10e60a83 项目身份 HKDJ / JHA-216,独立官网仓库,Jenkins 守卫键 hkdjnet 测试目录 /server/hkdj_net,App/Web API 30331/30332 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
311 lines
7.8 KiB
Go
311 lines
7.8 KiB
Go
package rss
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"hkdj-web-server/pub/common/log"
|
|
"hkdj-web-server/pub/common/netutil"
|
|
"hkdj-web-server/pub/common/utils"
|
|
"hkdj-web-server/pub/models/cfg"
|
|
"hkdj-web-server/pub/pubdef"
|
|
"hkdj-web-server/web/service/srv_cfg"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
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()
|
|
}
|