Paywall signal detection, sqlite driver registration, /img route fix

- Detect explicit paywall markers (article:content_tier meta, JSON-LD
  isAccessibleForFree) so metered articles fall back to Wayback even
  when body length is above threshold
- Blank-import go.mau.fi/util/dbutil/litestream so the sqlite3-fk-wal
  driver mautrix's cryptohelper depends on is registered
- Fix /img route: Go ServeMux requires {wildcard} to be a whole segment,
  so capture {name} and strip the .avif suffix in the handler
This commit is contained in:
prosolis
2026-05-25 07:50:36 -07:00
parent bddd15f7d1
commit 5ea64c1d32
5 changed files with 82 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ package ingestion
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -52,6 +53,7 @@ type ArticleMeta struct {
ImageURL string // og:image or twitter:image, absolute URL ImageURL string // og:image or twitter:image, absolute URL
BodyChars int // length of extracted visible body text BodyChars int // length of extracted visible body text
Fetched bool // true if we got an HTTP 200 with HTML Fetched bool // true if we got an HTTP 200 with HTML
Paywalled bool // true if the page explicitly declares gated access
} }
var articleClient = &http.Client{Timeout: 12 * time.Second} var articleClient = &http.Client{Timeout: 12 * time.Second}
@@ -92,9 +94,79 @@ func FetchArticleMeta(articleURL string) ArticleMeta {
ImageURL: extractOGImage(doc, articleURL), ImageURL: extractOGImage(doc, articleURL),
BodyChars: extractBodyChars(doc), BodyChars: extractBodyChars(doc),
Fetched: true, 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:
// - <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
// "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 // FetchOGImage is a thin wrapper around FetchArticleMeta kept for callers
// that only care about the image. Returns "" when not found. // that only care about the image. Returns "" when not found.
func FetchOGImage(articleURL string) string { func FetchOGImage(articleURL string) string {

View File

@@ -132,7 +132,7 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
// the article is gated, so swap in a Wayback snapshot for both // the article is gated, so swap in a Wayback snapshot for both
// Pete (image) and the reader (posted link). // Pete (image) and the reader (posted link).
meta := FetchArticleMeta(originalURL) meta := FetchArticleMeta(originalURL)
paywalled := !meta.Fetched || meta.BodyChars < PaywallBodyThreshold paywalled := !meta.Fetched || meta.Paywalled || meta.BodyChars < PaywallBodyThreshold
if paywalled { if paywalled {
if snap := ResolveWayback(originalURL); snap != "" { if snap := ResolveWayback(originalURL); snap != "" {
snapMeta := FetchArticleMeta(snap) snapMeta := FetchArticleMeta(snap)

View File

@@ -20,6 +20,8 @@ import (
"pete/internal/config" "pete/internal/config"
_ "go.mau.fi/util/dbutil/litestream" // registers "sqlite3-fk-wal" driver used by cryptohelper
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"

View File

@@ -69,7 +69,7 @@ func New(cfg config.WebConfig) (*Server, error) {
return nil, err return nil, err
} }
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.HandleFunc("GET /img/{hash}.avif", s.handleImg) mux.HandleFunc("GET /img/{name}", s.handleImg)
mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /{$}", s.handleIndex)
for _, ch := range channels { for _, ch := range channels {

View File

@@ -56,7 +56,12 @@ func thumbURL(src string) string {
} }
func (s *Server) handleImg(w http.ResponseWriter, r *http.Request) { func (s *Server) handleImg(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash") name := r.PathValue("name")
hash, ok := strings.CutSuffix(name, ".avif")
if !ok {
http.NotFound(w, r)
return
}
src := r.URL.Query().Get("u") src := r.URL.Query().Get("u")
if src == "" || thumbKey(src) != hash { if src == "" || thumbKey(src) != hash {
http.NotFound(w, r) http.NotFound(w, r)