tags). Returns Fetched=false on any
// network/HTTP failure so callers can branch on accessibility.
func FetchArticleMeta(articleURL string) ArticleMeta {
if articleURL == "" {
return 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{}
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := articleClient.Do(req)
if err != nil {
return ArticleMeta{}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ArticleMeta{}
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return ArticleMeta{}
}
return ArticleMeta{
ImageURL: extractOGImage(doc, articleURL),
BodyChars: extractBodyChars(doc),
Fetched: true,
Paywalled: detectPaywall(doc),
}
}
// detectPaywall checks the page for explicit gating signals that publishers
// expose for Google News and crawlers. We treat the article as paywalled if:
// - (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
// "isAccessibleForFree": false anywhere in its (possibly nested or arrayed)
// structure. Publishers vary wildly in shape, so we walk generically.
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)
}
func walkJSONLDForGated(v any) bool {
switch x := v.(type) {
case map[string]any:
if raw, ok := x["isAccessibleForFree"]; ok {
switch r := raw.(type) {
case bool:
if !r {
return true
}
case string:
s := strings.ToLower(strings.TrimSpace(r))
if s == "false" || s == "no" {
return true
}
}
}
for _, vv := range x {
if walkJSONLDForGated(vv) {
return true
}
}
case []any:
for _, vv := range x {
if walkJSONLDForGated(vv) {
return true
}
}
}
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 ( tags, falling back to all ), 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(resp.Body)
if err != nil {
return ""
}
return extractBodyText(doc)
}
func extractBodyText(doc *goquery.Document) string {
sel := doc.Find("article p, main p")
if sel.Length() == 0 {
sel = doc.Find("p")
}
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)
if b.Len() >= MaxBodyChars {
return
}
})
out := strings.TrimSpace(b.String())
if len(out) > MaxBodyChars {
out = out[:MaxBodyChars]
}
return out
}
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))
}
}
return ""
}
// extractBodyChars concatenates the text of tags inside tags, and returns the trimmed length.
func extractBodyChars(doc *goquery.Document) int {
sel := doc.Find("article p, main p")
if sel.Length() == 0 {
sel = doc.Find("p")
}
var b strings.Builder
sel.Each(func(_ int, s *goquery.Selection) {
t := strings.TrimSpace(s.Text())
if t == "" {
return
}
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(t)
})
return len(strings.TrimSpace(b.String()))
}