package ingestion import ( "context" "fmt" "html" "log/slog" "net/http" "regexp" "strconv" "strings" "time" "unicode/utf8" "github.com/mmcdole/gofeed" ext "github.com/mmcdole/gofeed/extensions" "pete/internal/safehttp" ) const userAgent = "Pete/1.0 (newsbot; +https://github.com/reala-misaki/pete)" // maxFeedBytes caps how much of a feed body we'll buffer into the parser, so a // hostile feed streaming an endless body within the request timeout can't OOM // the process. Generous headroom — real RSS/Atom feeds are well under this. const maxFeedBytes = 16 << 20 var feedClient = safehttp.NewClient(30 * time.Second) // FeedItem represents a parsed RSS item ready for routing. type FeedItem struct { GUID string Headline string Lede string Content string // full article text from content:encoded, block structure preserved; "" when the feed ships only a snippet ImageURL string ArticleURL string Source string DirectRoute string Language string // per-item (or dc:language) when present; "" otherwise PublishedAt int64 // unix seconds from RSS pubDate / Atom published (or updated as fallback); 0 if absent or unparseable } var ( htmlTagRe = regexp.MustCompile(`<[^>]*>`) wsRe = regexp.MustCompile(`\s+`) imgSrcRe = regexp.MustCompile(`(?i)]+src=["']([^"']+)["']`) // Used by extractContentText to preserve paragraph structure when turning // content:encoded HTML into plain text. blockCloseRe = regexp.MustCompile(`(?i)`) brRe = regexp.MustCompile(`(?i)`) hspaceRe = regexp.MustCompile(`[ \t\f\r]+`) manyNewlineRe = regexp.MustCompile(`\n{3,}`) ) // maxContentChars bounds the stored article text. Generous enough for any real // article while keeping row size and the reader payload sane. const maxContentChars = 20000 // FetchFeed fetches and parses an RSS/Atom feed, returning items. ua overrides // the User-Agent header; pass "" to use Pete's honest default bot UA. func FetchFeed(feedURL, ua string) ([]FeedItem, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) if err != nil { return nil, err } if ua == "" { ua = userAgent } req.Header.Set("User-Agent", ua) resp, err := feedClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("feed fetch %s: status %d", feedURL, resp.StatusCode) } feed, err := gofeed.NewParser().Parse(safehttp.LimitedBody(resp.Body, maxFeedBytes)) if err != nil { return nil, err } items := make([]FeedItem, 0, len(feed.Items)) for _, item := range feed.Items { fi := FeedItem{ GUID: itemGUID(item), Headline: strings.TrimSpace(item.Title), Lede: extractLede(item.Description), Content: extractContentText(item.Content), ImageURL: NormalizeImageURL(extractImageURL(item)), ArticleURL: strings.TrimSpace(item.Link), Language: itemLanguage(item), PublishedAt: itemPublished(item), } if fi.GUID == "" || fi.ArticleURL == "" { continue } items = append(items, fi) } slog.Debug("fetched feed", "url", feedURL, "items", len(items)) return items, nil } // itemLanguage returns the per-item language tag if the feed provides one. // Politico Europe puts a raw inside each ; gofeed parks // unknown item-level elements in item.Custom. We also check the standard // dc:language extension for feeds that use Dublin Core. func itemLanguage(item *gofeed.Item) string { if item.Custom != nil { if v, ok := item.Custom["language"]; ok { return strings.ToLower(strings.TrimSpace(v)) } } if item.DublinCoreExt != nil && len(item.DublinCoreExt.Language) > 0 { return strings.ToLower(strings.TrimSpace(item.DublinCoreExt.Language[0])) } return "" } func itemGUID(item *gofeed.Item) string { if item.GUID != "" { return item.GUID } return item.Link } // itemPublished returns the item's publish time as unix seconds, falling back // to the updated time if pubDate was absent. Returns 0 when neither parses; // the poller clamps future timestamps to ingest time. func itemPublished(item *gofeed.Item) int64 { if item.PublishedParsed != nil { return item.PublishedParsed.Unix() } if item.UpdatedParsed != nil { return item.UpdatedParsed.Unix() } return 0 } // extractLede returns the feed description verbatim, with HTML stripped. func extractLede(desc string) string { if desc == "" { return "" } // Replace tags with a space so adjacent blocks like

don't fuse words. text := htmlTagRe.ReplaceAllString(desc, " ") text = html.UnescapeString(text) text = wsRe.ReplaceAllString(text, " ") return strings.TrimSpace(text) } // extractContentText converts a feed's content:encoded HTML into plain text // with paragraph breaks preserved, for reader mode. Block-level boundaries // (

, ,
, list items, headings…) become newlines before the // remaining tags are stripped, so the reader can re-wrap the text into // paragraphs. Returns "" when the feed carried no content:encoded. func extractContentText(raw string) string { if strings.TrimSpace(raw) == "" { return "" } s := brRe.ReplaceAllString(raw, "\n") s = blockCloseRe.ReplaceAllString(s, "\n\n") s = htmlTagRe.ReplaceAllString(s, "") s = html.UnescapeString(s) lines := strings.Split(s, "\n") for i, ln := range lines { lines[i] = strings.TrimSpace(hspaceRe.ReplaceAllString(ln, " ")) } s = manyNewlineRe.ReplaceAllString(strings.Join(lines, "\n"), "\n\n") s = strings.TrimSpace(s) return truncateUTF8(s, maxContentChars) } // truncateUTF8 caps s at max bytes without splitting a multi-byte rune. A plain // byte slice can leave a partial trailing rune, which is invalid UTF-8; that // corrupt byte then breaks the RSS content:encoded XML (Go's xml encoder writes // it raw) and shows as a replacement char in JSON. Trim back to a rune boundary. func truncateUTF8(s string, max int) string { if len(s) <= max { return s } s = s[:max] for len(s) > 0 { if r, size := utf8.DecodeLastRuneInString(s); r == utf8.RuneError && size <= 1 { s = s[:len(s)-1] // strip one byte of an incomplete trailing rune continue } break } return s } // extractImageURL tries to find an image URL from feed item metadata. // Order: media:content/thumbnail, enclosures, item.Image, then tags // scraped from content:encoded / description (catches feeds like Ars that // embed the lead image in the article body but not as a media:* element). func extractImageURL(item *gofeed.Item) string { // Check media content (common in RSS 2.0 with media namespace). // When multiple media:content variants are advertised (Guardian RSS lists // 140/460/700/1200…) we want the widest signed URL we can find. if item.Extensions != nil { if media, ok := item.Extensions["media"]; ok { if contents, ok := media["content"]; ok { if url := widestURL(contents); url != "" { return url } } if thumbs, ok := media["thumbnail"]; ok { if url := widestURL(thumbs); url != "" { return url } } // media:group wraps nested media:content / media:thumbnail in some feeds if groups, ok := media["group"]; ok { for _, g := range groups { for _, child := range g.Children { for _, n := range child { if url := n.Attrs["url"]; url != "" { return url } } } } } } } // Check enclosures for _, enc := range item.Enclosures { if strings.HasPrefix(enc.Type, "image/") && enc.URL != "" { return enc.URL } } // Check item image if item.Image != nil && item.Image.URL != "" { return item.Image.URL } // Scrape first from content:encoded or description if url := firstImgSrc(item.Content); url != "" { return url } if url := firstImgSrc(item.Description); url != "" { return url } return "" } // widestURL picks the entry with the largest declared width attribute, falling // back to the first URL if none have a parsable width. func widestURL(entries []ext.Extension) string { best := "" bestW := -1 for _, e := range entries { url := e.Attrs["url"] if url == "" { continue } w, _ := strconv.Atoi(e.Attrs["width"]) if w > bestW { best = url bestW = w } } return best } func firstImgSrc(htmlBody string) string { if htmlBody == "" { return "" } m := imgSrcRe.FindStringSubmatch(htmlBody) if len(m) < 2 { return "" } return html.UnescapeString(m[1]) }