Reader mode presents the stories on a page one at a time in a focused overlay, marking each read as it's shown. Left/right arrows (or the header book button / `f`) page through them; read stories dim on the grid. Read state is device-local in localStorage. Backing this required actually capturing article bodies, which Pete wasn't doing — it kept only the RSS <description> lede and discarded content:encoded: - stories.content column (idempotent migration; old rows fall back to lede) - parser keeps content:encoded as paragraph-preserving text - article fetch already done for paywall detection now also returns its body, so ingest stores the richer of feed-content vs scraped body with no extra request (prefers the archive snapshot body for paywalled stories) - GET /api/article?id= serves the stored text; card queries now select id and expose it as data-id for the reader Tests cover content extraction, the storage round-trip, and the article endpoint + card rendering end to end.
270 lines
7.9 KiB
Go
270 lines
7.9 KiB
Go
package ingestion
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"html"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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 <language> (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)<img[^>]+src=["']([^"']+)["']`)
|
|
|
|
// Used by extractContentText to preserve paragraph structure when turning
|
|
// content:encoded HTML into plain text.
|
|
blockCloseRe = regexp.MustCompile(`(?i)</(p|div|li|h[1-6]|blockquote|article|section|figure|figcaption|ul|ol|table|tr|pre)>`)
|
|
brRe = regexp.MustCompile(`(?i)<br\s*/?>`)
|
|
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 <language> inside each <item>; 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 </p><p> 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
|
|
// (</p>, </div>, <br>, 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)
|
|
if len(s) > maxContentChars {
|
|
s = s[:maxContentChars]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// extractImageURL tries to find an image URL from feed item metadata.
|
|
// Order: media:content/thumbnail, enclosures, item.Image, then <img> 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 <img src> 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])
|
|
}
|