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 {
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 tags. Fall back to the container's raw text in that case.
if len(out) < PaywallBodyThreshold {
if alt := containerText(doc.Find("article, main").First()); len(alt) > len(out) {
out = alt
}
}
if len(out) > MaxBodyChars {
out = out[:MaxBodyChars]
}
return out
}
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 tags inside tags, then the raw text inside .
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(containerText(doc.Find("article, main").First())); n > best {
best = n
}
}
return best
}
-separated text inside
-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 inside
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
// instead of