package ingestion import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/PuerkitoBio/goquery" "pete/internal/safehttp" ) // articleBodyMax bounds how much of an article response goquery will // ever buffer. ~5 MiB comfortably fits any real news article while // preventing a hostile origin from OOMing the process by streaming an // endless body within the request timeout window. const articleBodyMax = 5 << 20 // resolveURL turns a possibly-relative URL into an absolute one using // the base URL. Returns the raw input on parse failure. func resolveURL(base, ref string) string { ref = strings.TrimSpace(ref) if ref == "" { return "" } if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { return ref } if strings.HasPrefix(ref, "//") { if i := strings.Index(base, "://"); i > 0 { return base[:i+1] + ref } return "https:" + ref } i := strings.Index(base, "://") if i < 0 { return ref } rest := base[i+3:] slash := strings.Index(rest, "/") if slash < 0 { return fmt.Sprintf("%s%s", base, ref) } host := base[:i+3+slash] if strings.HasPrefix(ref, "/") { return host + ref } return host + "/" + ref } // PaywallBodyThreshold is the minimum visible body length (in characters) // for an article to be considered accessible. Anything below this is treated // as paywalled / gated, and the caller should fall back to an archive snapshot. const PaywallBodyThreshold = 500 // googlebotUA is what many metered publishers grant first-click access to. // Re-tried automatically when the default-UA fetch looks gated. const googlebotUA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" // ArticleMeta is what we can learn from fetching an article page directly. type ArticleMeta struct { ImageURL string // og:image or twitter:image, absolute URL BodyText string // extracted visible article body text (capped at MaxBodyChars) BodyChars int // length of extracted visible body text Fetched bool // true if we got an HTTP 200 with HTML FetchError bool // true if the fetch failed at the network/HTTP layer Paywalled bool // true if the page explicitly declares gated access // SubscriberOnly is true when the source publishes the article in a // form that genuinely has no public version — no archive snapshot, no // bypass UA, nothing. Callers should drop these from view entirely // rather than stamp them as paywalled. SubscriberOnly bool Status int // last HTTP status seen (0 on transport error) } // Gated reports whether the response carries a strong gating signal: an // explicit paywall meta/JSON-LD declaration, an HTTP 402 Payment Required, // or a 403 Forbidden after the bypass retry. A short body alone is not // considered gating — that's a heuristic used by callers separately. func (m ArticleMeta) Gated() bool { if m.Paywalled { return true } return m.Status == http.StatusPaymentRequired || m.Status == http.StatusForbidden } var articleClient = safehttp.NewClient(12 * time.Second) // FetchArticleMeta fetches an article URL with the default UA. If the result // looks gated (explicit paywall signal, HTTP 402/403, or body too short) it // retries once with a Googlebot UA + Google referer — the combination most // metered publishers grant first-click access to. Returns the best of the // two attempts. func FetchArticleMeta(articleURL string) ArticleMeta { if articleURL == "" { return ArticleMeta{} } first := fetchArticleMetaOnce(articleURL, userAgent, "") if !shouldRetryAsBot(first) { return first } second := fetchArticleMetaOnce(articleURL, googlebotUA, "https://www.google.com/") return pickBetter(first, second) } // shouldRetryAsBot returns true when the first attempt looks gated or too // thin to be the real article body. func shouldRetryAsBot(m ArticleMeta) bool { if m.FetchError { return false // transport failure won't be fixed by a different UA } if m.SubscriberOnly { return false // genuinely subscriber-only; no UA changes that } if m.Gated() { return true } if m.Fetched && m.BodyChars < PaywallBodyThreshold { return true } return false } // pickBetter chooses the more useful of two fetch attempts: prefer the one // that isn't gated, then the one with more body, then the one that fetched // at all. func pickBetter(a, b ArticleMeta) ArticleMeta { aGated, bGated := a.Gated(), b.Gated() if aGated != bGated { if bGated { return a } return b } if a.Fetched != b.Fetched { if b.Fetched { return b } return a } if b.BodyChars > a.BodyChars { return b } return a } func fetchArticleMetaOnce(articleURL, ua, referer string) ArticleMeta { ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", articleURL, nil) if err != nil { return ArticleMeta{FetchError: true} } req.Header.Set("User-Agent", ua) req.Header.Set("Accept", "text/html,application/xhtml+xml") if referer != "" { req.Header.Set("Referer", referer) } resp, err := articleClient.Do(req) if err != nil { return ArticleMeta{FetchError: true} } defer resp.Body.Close() meta := ArticleMeta{Status: resp.StatusCode} // 402 is unambiguous gating; 403 often is too (e.g. NYT-style hard wall). // But many sites 403 scrapers via Cloudflare while serving humans fine — // peek at the body and treat a Cloudflare challenge as a transport failure // rather than a paywall, so we don't stamp readable articles. if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusServiceUnavailable { if isCloudflareChallenge(resp) { return ArticleMeta{FetchError: true} } } if resp.StatusCode == http.StatusPaymentRequired { meta.Paywalled = true } return meta } doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, articleBodyMax)) if err != nil { return meta } meta.Fetched = true meta.ImageURL = extractOGImage(doc, articleURL) // Paywall detection reads