The Portugal News moved its RSS behind AWS WAF, which 405s Pete's honest bot UA (confirmed: bot UA 0/6, browser UA 6/6 from the server IP). Add an optional per-source user_agent that falls back to the default bot UA, and set The Portugal News to a browser UA. Not load-related — 30-min polls and the IP isn't banned.
249 lines
7.8 KiB
Go
249 lines
7.8 KiB
Go
package ingestion
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"pete/internal/config"
|
|
"pete/internal/dedup"
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// ProcessFunc is called for each new feed item that needs routing.
|
|
type ProcessFunc func(ctx context.Context, item *FeedItem)
|
|
|
|
// Poller manages per-source RSS polling goroutines.
|
|
type Poller struct {
|
|
sources []config.SourceConfig
|
|
process ProcessFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewPoller creates a poller for the given sources.
|
|
func NewPoller(sources []config.SourceConfig, process ProcessFunc) *Poller {
|
|
return &Poller{
|
|
sources: sources,
|
|
process: process,
|
|
}
|
|
}
|
|
|
|
// Start launches a goroutine per enabled source. Blocks until ctx is cancelled.
|
|
func (p *Poller) Start(ctx context.Context) {
|
|
for _, src := range p.sources {
|
|
if !src.Enabled {
|
|
slog.Info("source disabled, skipping", "source", src.Name)
|
|
continue
|
|
}
|
|
p.wg.Add(1)
|
|
go p.pollSource(ctx, src)
|
|
}
|
|
}
|
|
|
|
// Wait blocks until all polling goroutines have exited.
|
|
func (p *Poller) Wait() {
|
|
p.wg.Wait()
|
|
}
|
|
|
|
func (p *Poller) pollSource(ctx context.Context, src config.SourceConfig) {
|
|
defer p.wg.Done()
|
|
|
|
interval := time.Duration(src.PollIntervalMinutes) * time.Minute
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
slog.Info("starting poller", "source", src.Name, "interval", interval)
|
|
|
|
// Poll immediately on start, then on ticker
|
|
p.pollOnce(ctx, src)
|
|
|
|
consecutiveFailures := 0
|
|
const adminWarningThreshold = 5
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Info("poller stopping", "source", src.Name)
|
|
return
|
|
case <-ticker.C:
|
|
if err := p.pollOnceWithErr(ctx, src); err != nil {
|
|
consecutiveFailures++
|
|
slog.Error("feed poll failed",
|
|
"source", src.Name,
|
|
"err", err,
|
|
"consecutive_failures", consecutiveFailures,
|
|
)
|
|
if consecutiveFailures == adminWarningThreshold {
|
|
slog.Warn("persistent feed failure",
|
|
"source", src.Name,
|
|
"failures", consecutiveFailures,
|
|
)
|
|
}
|
|
} else {
|
|
consecutiveFailures = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Poller) pollOnce(ctx context.Context, src config.SourceConfig) {
|
|
if err := p.pollOnceWithErr(ctx, src); err != nil {
|
|
slog.Error("feed poll failed", "source", src.Name, "err", err)
|
|
}
|
|
}
|
|
|
|
func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) error {
|
|
items, err := FetchFeed(src.FeedURL, src.UserAgent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
newCount := 0
|
|
for i := range items {
|
|
if ctx.Err() != nil {
|
|
slog.Info("poller: cancellation observed mid-loop", "source", src.Name, "processed", newCount)
|
|
return nil
|
|
}
|
|
if storage.IsGUIDSeen(items[i].GUID) {
|
|
continue
|
|
}
|
|
|
|
// Drop items in languages this source isn't configured to surface.
|
|
// Items without a language tag pass through — only filter when the
|
|
// feed explicitly marks an item with a non-matching language.
|
|
if src.Language != "" && items[i].Language != "" &&
|
|
!strings.HasPrefix(items[i].Language, strings.ToLower(src.Language)) {
|
|
slog.Info("dropping story (language filter)",
|
|
"guid", items[i].GUID, "source", src.Name,
|
|
"item_lang", items[i].Language, "want", src.Language)
|
|
continue
|
|
}
|
|
|
|
// Dedup checks first — canonical and headline use the ORIGINAL URL so
|
|
// they stay stable whether we end up swapping to a Wayback snapshot.
|
|
originalURL := items[i].ArticleURL
|
|
canonical := dedup.CanonicalURL(originalURL)
|
|
headlineNorm := dedup.NormalizeHeadline(items[i].Headline)
|
|
|
|
if storage.IsCanonicalSeen(canonical) {
|
|
slog.Info("dropping duplicate story (canonical URL match)",
|
|
"guid", items[i].GUID, "url_canonical", canonical, "source", src.Name)
|
|
continue
|
|
}
|
|
if storage.IsHeadlineSeen(src.Name, headlineNorm) {
|
|
slog.Info("dropping duplicate story (headline match)",
|
|
"guid", items[i].GUID, "headline_norm", headlineNorm, "source", src.Name)
|
|
continue
|
|
}
|
|
|
|
// Genuinely new story — fetch the article page once (with an internal
|
|
// bypass-UA retry on gating signals). We use it for:
|
|
// 1. og:image fallback when the feed didn't give us one.
|
|
// 2. Paywall detection: explicit gating signals or a body too short
|
|
// to be a real article means we swap in an archive snapshot for
|
|
// both Pete (image) and the reader (posted link).
|
|
//
|
|
// Pure transport failures (FetchError) are NOT treated as gating —
|
|
// swapping in a stale snapshot for a transient blip is worse than
|
|
// posting the live link.
|
|
meta := FetchArticleMeta(originalURL)
|
|
// Some sources (e.g. LWN's subscriber-only articles) have no public
|
|
// version reachable by any workaround. Drop these from view entirely
|
|
// rather than stamping them as paywalled.
|
|
if meta.SubscriberOnly {
|
|
// No DB row written, so the next poll will re-fetch and re-detect
|
|
// this URL — fine in practice since LWN's feed is tiny and the
|
|
// alternative (a tombstone row) leaks into every story query.
|
|
slog.Info("dropping subscriber-only story",
|
|
"guid", items[i].GUID, "url", originalURL, "source", src.Name)
|
|
continue
|
|
}
|
|
gated := meta.Gated() || (meta.Fetched && meta.BodyChars < PaywallBodyThreshold)
|
|
// paywalled tracks whether the link the user will click is still
|
|
// gated — i.e. gating was detected AND no archive workaround worked.
|
|
// When a snapshot swap succeeds, the reader gets a readable page, so
|
|
// we don't stamp it.
|
|
paywalled := false
|
|
if gated {
|
|
workedAround := false
|
|
if snap := resolveArchive(originalURL); snap != "" {
|
|
snapMeta := FetchArticleMeta(snap)
|
|
if snapMeta.Fetched {
|
|
items[i].ArticleURL = snap
|
|
if items[i].ImageURL == "" && snapMeta.ImageURL != "" {
|
|
items[i].ImageURL = snapMeta.ImageURL
|
|
}
|
|
workedAround = true
|
|
slog.Info("paywall detected, using archive snapshot",
|
|
"guid", items[i].GUID, "original", originalURL,
|
|
"snapshot", snap, "body_chars", meta.BodyChars,
|
|
"status", meta.Status)
|
|
} else {
|
|
slog.Debug("paywall detected but snapshot fetch failed",
|
|
"guid", items[i].GUID, "original", originalURL, "snapshot", snap)
|
|
}
|
|
} else {
|
|
slog.Debug("paywall detected but no archive snapshot available",
|
|
"guid", items[i].GUID, "url", originalURL,
|
|
"body_chars", meta.BodyChars, "status", meta.Status)
|
|
}
|
|
paywalled = !workedAround
|
|
} else if items[i].ImageURL == "" && meta.ImageURL != "" {
|
|
items[i].ImageURL = meta.ImageURL
|
|
slog.Debug("og:image fallback used",
|
|
"guid", items[i].GUID, "url", originalURL, "image", meta.ImageURL)
|
|
}
|
|
|
|
// Stamp source metadata onto the item
|
|
items[i].Source = src.Name
|
|
items[i].DirectRoute = src.DirectRoute
|
|
|
|
// Store immediately (before routing) to prevent re-ingestion.
|
|
// Future-dated pubDates (publishers occasionally post-date drafts or
|
|
// have a misconfigured clock) get clamped to ingest time so they don't
|
|
// stick at the top of the queue indefinitely.
|
|
seenAt := time.Now().Unix()
|
|
publishedAt := items[i].PublishedAt
|
|
if publishedAt > seenAt {
|
|
publishedAt = seenAt
|
|
}
|
|
if err := storage.InsertStory(&storage.Story{
|
|
GUID: items[i].GUID,
|
|
Headline: items[i].Headline,
|
|
Lede: items[i].Lede,
|
|
ImageURL: items[i].ImageURL,
|
|
ArticleURL: items[i].ArticleURL,
|
|
URLCanonical: canonical,
|
|
HeadlineNorm: headlineNorm,
|
|
Source: items[i].Source,
|
|
Paywalled: paywalled,
|
|
SeenAt: seenAt,
|
|
PublishedAt: publishedAt,
|
|
}); err != nil {
|
|
slog.Error("failed to insert story", "guid", items[i].GUID, "err", err)
|
|
continue
|
|
}
|
|
|
|
p.process(ctx, &items[i])
|
|
newCount++
|
|
}
|
|
|
|
if newCount > 0 {
|
|
slog.Info("ingested new stories", "source", src.Name, "count", newCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveArchive tries Wayback first (well-behaved availability API, fresh
|
|
// snapshots filtered) and falls back to archive.today, which often has
|
|
// captures Wayback doesn't — especially for hard-walled publishers.
|
|
func resolveArchive(articleURL string) string {
|
|
if snap := ResolveWayback(articleURL); snap != "" {
|
|
return snap
|
|
}
|
|
return ResolveArchiveToday(articleURL)
|
|
}
|