Files
Pete/internal/ingestion/article.go
prosolis 2ea5f7a6f7 Strip inline scripts from reader body extraction
Datawrapper embed resizers (and other inline scripts) were leaking into
reader mode as literal text. Two extraction paths were affected:

- parser.go extractContentText/extractLede stripped only script tags via
  htmlTagRe, leaving the JS body behind. This is the path that usually
  wins for feeds shipping full content:encoded (e.g. Politico).
- article.go goquery paths call .Text(), which concatenates script source.

Both now drop whole <script>/<style>/<noscript> elements before pulling
text. Paywall detection (JSON-LD) still runs before the goquery strip.
2026-07-07 21:33:35 -07:00

597 lines
18 KiB
Go

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 <script type="application/ld+json"> and the LWN
// marker, so run it before we strip non-content nodes below.
meta.Paywalled = detectPaywall(doc)
meta.SubscriberOnly = detectSubscriberOnly(articleURL, doc)
// Strip <script>/<style>/etc. before pulling body text — goquery's .Text()
// includes the source of inline scripts (e.g. Datawrapper embed resizers),
// which would otherwise leak verbatim into reader mode.
stripNonContent(doc)
// One body extraction serves both purposes: the text feeds reader mode and
// its length is the paywall/thin-body signal.
meta.BodyText = extractBodyText(doc)
meta.BodyChars = len(meta.BodyText)
return meta
}
// detectSubscriberOnly returns true for sources known to publish articles
// with no public version reachable by any workaround we have (archive
// snapshots, bypass UAs, etc). Currently only LWN's subscriber-only
// articles, which display a fixed marker in the article body.
func detectSubscriberOnly(articleURL string, doc *goquery.Document) bool {
u, err := url.Parse(articleURL)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
if host != "lwn.net" && !strings.HasSuffix(host, ".lwn.net") {
return false
}
body := strings.ToLower(doc.Find("body").Text())
return strings.Contains(body, "available only to lwn subscribers")
}
// isCloudflareChallenge reports whether a non-200 response is Cloudflare's
// bot-block or JS-challenge page rather than a real publisher gate. The
// signatures are stable: cf-* response headers, the cf-error-details body
// markup, and a handful of recognizable titles. We read at most 8 KiB so a
// huge body can't stall the poller.
func isCloudflareChallenge(resp *http.Response) bool {
if resp == nil {
return false
}
if resp.Header.Get("Server") == "cloudflare" || resp.Header.Get("CF-RAY") != "" || resp.Header.Get("cf-mitigated") != "" {
// Header alone isn't enough — plenty of real publishers sit behind
// Cloudflare. Confirm with a body marker below.
}
buf, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
if err != nil {
return false
}
s := strings.ToLower(string(buf))
markers := []string{
"/cdn-cgi/styles/cf",
"cf-error-details",
"attention required! | cloudflare",
"<title>just a moment",
"sorry, you have been blocked",
"cloudflare ray id",
}
for _, m := range markers {
if strings.Contains(s, m) {
return true
}
}
return false
}
// detectPaywall checks the page for explicit gating signals that publishers
// expose for Google News and crawlers. We treat the article as paywalled if:
// - <meta name="article:content_tier" content="metered|locked"> (Conde Nast,
// Hearst, many WordPress VIP sites including Wired)
// - JSON-LD with "isAccessibleForFree": false (schema.org standard, used by
// NYT, WaPo, Bloomberg, FT, WSJ, and most metered publishers)
func detectPaywall(doc *goquery.Document) bool {
tier, _ := doc.Find(`meta[name="article:content_tier"]`).First().Attr("content")
switch strings.ToLower(strings.TrimSpace(tier)) {
case "metered", "locked":
return true
}
gated := false
doc.Find(`script[type="application/ld+json"]`).EachWithBreak(func(_ int, s *goquery.Selection) bool {
if jsonLDDeclaresGated(s.Text()) {
gated = true
return false
}
return true
})
return gated
}
// jsonLDDeclaresGated returns true if the JSON-LD payload contains an
// Article-typed object (Article, NewsArticle, Report, BlogPosting, etc.)
// with "isAccessibleForFree" set falsy. We restrict to Article types so
// that embedded related-content or breadcrumb markup doesn't flip a free
// article to gated.
func jsonLDDeclaresGated(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return false
}
return walkJSONLDForGated(v)
}
// articleSchemaTypes are the schema.org @type values we treat as "the main
// article" for paywall purposes.
var articleSchemaTypes = map[string]bool{
"article": true,
"newsarticle": true,
"report": true,
"reportagenewsarticle": true,
"blogposting": true,
"scholarlyarticle": true,
"techarticle": true,
"opinionnewsarticle": true,
"analysisnewsarticle": true,
"backgroundnewsarticle": true,
"reviewnewsarticle": true,
}
func walkJSONLDForGated(v any) bool {
switch x := v.(type) {
case map[string]any:
if isArticleType(x["@type"]) && declaresGated(x["isAccessibleForFree"]) {
return true
}
for _, vv := range x {
if walkJSONLDForGated(vv) {
return true
}
}
case []any:
for _, vv := range x {
if walkJSONLDForGated(vv) {
return true
}
}
}
return false
}
func isArticleType(v any) bool {
switch t := v.(type) {
case string:
return articleSchemaTypes[strings.ToLower(strings.TrimSpace(t))]
case []any:
for _, e := range t {
if isArticleType(e) {
return true
}
}
}
return false
}
func declaresGated(v any) bool {
switch r := v.(type) {
case bool:
return !r
case string:
s := strings.ToLower(strings.TrimSpace(r))
return s == "false" || s == "no"
}
return false
}
// FetchOGImage is a thin wrapper around FetchArticleMeta kept for callers
// that only care about the image. Returns "" when not found.
func FetchOGImage(articleURL string) string {
return FetchArticleMeta(articleURL).ImageURL
}
// MaxBodyChars is the cap on body text returned by FetchArticleBody. Keeps
// LLM prompts bounded; most news articles fit well under this.
const MaxBodyChars = 8000
// FetchArticleBody fetches the article and returns the concatenated visible
// body text (<article>/<main> <p> tags, falling back to all <p>), trimmed
// and capped at MaxBodyChars. Returns "" on any fetch failure.
func FetchArticleBody(articleURL string) string {
if articleURL == "" {
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", articleURL, nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := articleClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, articleBodyMax))
if err != nil {
return ""
}
stripNonContent(doc)
return extractBodyText(doc)
}
// stripNonContent removes nodes whose text content is never article prose but
// which goquery's .Text() would otherwise concatenate into the extracted body:
// inline scripts (Datawrapper/embed resizers, analytics), CSS, and the fallback
// markup inside <noscript>/<template>. Must run after any logic that inspects
// these nodes (e.g. JSON-LD paywall detection).
func stripNonContent(doc *goquery.Document) {
doc.Find("script, style, noscript, template").Remove()
}
func extractBodyText(doc *goquery.Document) string {
out := joinParagraphText(doc.Find("article p, main p"))
if len(out) < PaywallBodyThreshold {
if alt := joinParagraphText(doc.Find("p")); len(alt) > len(out) {
out = alt
}
}
// Sites like Phoronix use <br>-separated text inside <article> rather
// than <p> tags, and sites like Anime News Network skip semantic
// article/main tags entirely and put the body in a custom div. Fall
// back to the raw text of the longest known container.
if len(out) < PaywallBodyThreshold {
if alt := bestContainerText(doc); len(alt) > len(out) {
out = alt
}
}
return truncateUTF8(out, MaxBodyChars)
}
// bodyContainerSelectors is the list of selectors we try when <article>/<main>
// aren't present or are too thin. Ordered loosely from semantic to generic;
// we take the longest match across all of them.
var bodyContainerSelectors = []string{
"article",
"main",
`[itemprop="articleBody"]`,
`[role="main"]`,
".article-body",
".article-content",
".entry-content",
".post-content",
".story-body",
".KonaBody", // Anime News Network
"#maincontent",
"#content",
}
func bestContainerText(doc *goquery.Document) string {
var best string
for _, sel := range bodyContainerSelectors {
if t := containerText(doc.Find(sel).First()); len(t) > len(best) {
best = t
}
}
return best
}
func joinParagraphText(sel *goquery.Selection) string {
var b strings.Builder
sel.Each(func(_ int, s *goquery.Selection) {
t := strings.TrimSpace(s.Text())
if t == "" {
return
}
if b.Len() > 0 {
b.WriteString("\n\n")
}
b.WriteString(t)
})
return strings.TrimSpace(b.String())
}
func containerText(sel *goquery.Selection) string {
if sel.Length() == 0 {
return ""
}
// Collapse whitespace runs so <br>-laden markup doesn't produce a wall
// of blank lines, but preserve enough structure to look like prose.
return strings.Join(strings.Fields(sel.Text()), " ")
}
func extractOGImage(doc *goquery.Document, base string) string {
selectors := []string{
`meta[property="og:image:secure_url"]`,
`meta[property="og:image:url"]`,
`meta[property="og:image"]`,
`meta[name="twitter:image:src"]`,
`meta[name="twitter:image"]`,
}
for _, sel := range selectors {
if v, ok := doc.Find(sel).First().Attr("content"); ok && strings.TrimSpace(v) != "" {
return resolveURL(base, strings.TrimSpace(v))
}
}
// Sites like Hardcore Gaming 101 publish no og:image — fall back to the
// WordPress featured image (.wp-post-image), then the first reasonably-
// sized <img> inside <article>/<main>.
if v, ok := doc.Find(`img.wp-post-image`).First().Attr("src"); ok {
if url := pickImageSrc(doc.Find(`img.wp-post-image`).First(), v); url != "" {
return resolveURL(base, url)
}
}
var found string
doc.Find(`article img, main img`).EachWithBreak(func(_ int, s *goquery.Selection) bool {
raw, _ := s.Attr("src")
url := pickImageSrc(s, raw)
if url == "" || !looksLikeContentImage(s, url) {
return true
}
found = url
return false
})
if found != "" {
return resolveURL(base, found)
}
return ""
}
// pickImageSrc returns the most useful image URL from an <img> selection,
// preferring data-src/data-lazy-src (lazy-load) over src when src is a
// placeholder (empty or data: URI).
func pickImageSrc(s *goquery.Selection, src string) string {
src = strings.TrimSpace(src)
if !strings.HasPrefix(src, "data:") && src != "" {
return src
}
for _, attr := range []string{"data-src", "data-lazy-src", "data-original"} {
if v, ok := s.Attr(attr); ok {
v = strings.TrimSpace(v)
if v != "" && !strings.HasPrefix(v, "data:") {
return v
}
}
}
return ""
}
// looksLikeContentImage filters out obvious non-article images: gravatars,
// social-share icons, tracking pixels. Anything with declared dimensions
// under 100px on either side is treated as chrome.
func looksLikeContentImage(s *goquery.Selection, url string) bool {
low := strings.ToLower(url)
for _, bad := range []string{"gravatar.com", "/avatar/", "/icons/", "share-", "pixel.gif", "sprite"} {
if strings.Contains(low, bad) {
return false
}
}
if w := atoiAttr(s, "width"); w > 0 && w < 100 {
return false
}
if h := atoiAttr(s, "height"); h > 0 && h < 100 {
return false
}
return true
}
func atoiAttr(s *goquery.Selection, name string) int {
v, ok := s.Attr(name)
if !ok {
return 0
}
n := 0
for _, c := range strings.TrimSpace(v) {
if c < '0' || c > '9' {
break
}
n = n*10 + int(c-'0')
}
return n
}
// extractBodyChars returns the visible body length used to gauge whether a
// page looks like a real article. It tries <p> tags inside <article>/<main>,
// then all <p> tags, then the raw text inside <article>/<main> — that last
// fallback catches sites like Phoronix that separate paragraphs with <br>
// instead of <p>.
func extractBodyChars(doc *goquery.Document) int {
best := len(joinParagraphText(doc.Find("article p, main p")))
if best < PaywallBodyThreshold {
if n := len(joinParagraphText(doc.Find("p"))); n > best {
best = n
}
}
if best < PaywallBodyThreshold {
if n := len(bestContainerText(doc)); n > best {
best = n
}
}
return best
}