Paywall detection: swap to Wayback snapshot when article body is gated

Fetch each new article once and measure visible <p> text. If body is below
500 chars (or fetch fails), resolve a Wayback snapshot via the
archive.org/wayback/available API and use that URL for both the og:image
fallback and the posted link. Dedup keys stay derived from the original
URL so paywalled/non-paywalled hits collide as before.

- New: internal/ingestion/article.go (FetchArticleMeta via goquery)
- New: internal/ingestion/wayback.go (ResolveWayback)
- Removed: internal/ingestion/og.go (folded into article.go)
- poller.go: dedup first, then one article fetch, then snapshot fallback
This commit is contained in:
prosolis
2026-05-22 17:53:54 -07:00
parent ca6663c051
commit e26b69e43f
5 changed files with 238 additions and 110 deletions

View File

@@ -106,17 +106,10 @@ func (p *Poller) pollOnceWithErr(src config.SourceConfig) error {
continue
}
// Last-resort image fallback: feed gave us nothing, scrape og:image
// from the article page. Only runs for genuinely new items.
if items[i].ImageURL == "" {
if og := FetchOGImage(items[i].ArticleURL); og != "" {
items[i].ImageURL = og
slog.Debug("og:image fallback used",
"guid", items[i].GUID, "url", items[i].ArticleURL, "image", og)
}
}
canonical := dedup.CanonicalURL(items[i].ArticleURL)
// 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) {
@@ -130,6 +123,38 @@ func (p *Poller) pollOnceWithErr(src config.SourceConfig) error {
continue
}
// Genuinely new story — fetch the article page once. We use it for:
// 1. og:image fallback when the feed didn't give us one.
// 2. Paywall detection: visible body text below the threshold means
// the article is gated, so swap in a Wayback snapshot for both
// Pete (image) and the reader (posted link).
meta := FetchArticleMeta(originalURL)
paywalled := !meta.Fetched || meta.BodyChars < PaywallBodyThreshold
if paywalled {
if snap := ResolveWayback(originalURL); snap != "" {
snapMeta := FetchArticleMeta(snap)
if snapMeta.Fetched {
items[i].ArticleURL = snap
if items[i].ImageURL == "" && snapMeta.ImageURL != "" {
items[i].ImageURL = snapMeta.ImageURL
}
slog.Info("paywall detected, using wayback snapshot",
"guid", items[i].GUID, "original", originalURL,
"snapshot", snap, "body_chars", meta.BodyChars)
} else {
slog.Debug("paywall detected but snapshot fetch failed",
"guid", items[i].GUID, "original", originalURL, "snapshot", snap)
}
} else {
slog.Debug("paywall detected but no wayback snapshot available",
"guid", items[i].GUID, "url", originalURL, "body_chars", meta.BodyChars)
}
} 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].FeedHint = src.FeedHint