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() }