Maintenance deleted every story older than 30 days and then swept up any user_story_state row pointing at a story that no longer existed. A bookmark is one of those rows, so every saved story quietly vanished a month after Pete first saw it, which is exactly what saving it was meant to prevent. A bookmarked story is now exempt from the prune; read markers on pruned stories still go. Maintenance also only ran at startup, on a service that can go weeks between deploys, so nothing was pruned for weeks and then everything was at once. It runs at startup and then daily. Pruning daily makes one old hole steady-state: the stories row is Pete's only memory of a GUID, so a slow feed still listing month-old items would meet them again as new the day after their rows went. The poller now skips items published before the retention window. Undated items can't be judged and still pass.
599 lines
18 KiB
Go
599 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"pete/internal/config"
|
|
"pete/internal/ingestion"
|
|
"pete/internal/matrix"
|
|
"pete/internal/poster"
|
|
"pete/internal/scheduler"
|
|
"pete/internal/storage"
|
|
"pete/internal/web"
|
|
|
|
webpush "github.com/SherClockHolmes/webpush-go"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// runGenVAPID prints a fresh VAPID keypair for the [web.push] config block.
|
|
func runGenVAPID() {
|
|
priv, pub, err := webpush.GenerateVAPIDKeys()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "genvapid: failed:", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("# Add these under [web.push] in your config:")
|
|
fmt.Printf("vapid_public_key = %q\n", pub)
|
|
fmt.Printf("vapid_private_key = %q\n", priv)
|
|
}
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "config.toml", "path to config file")
|
|
seed := flag.Bool("seed", false, "ingest current feed items as seen without posting, then exit")
|
|
test := flag.Bool("test", false, "post one story to verify the full pipeline, then exit")
|
|
testSource := flag.String("test-source", "", "source name to use for -test (default: first enabled)")
|
|
local := flag.Bool("local", false, "web/RSS-only mode: poll feeds and serve the web UI, no Matrix login or posting")
|
|
backfillPaywall := flag.Bool("backfill-paywall", false, "re-check paywalled rows with current detection logic; clear false positives and fill missing thumbnails, then exit")
|
|
genVAPID := flag.Bool("genvapid", false, "generate a VAPID keypair for web.push and print it, then exit")
|
|
flag.Parse()
|
|
|
|
// -genvapid needs neither config nor database; handle it before anything else.
|
|
if *genVAPID {
|
|
runGenVAPID()
|
|
return
|
|
}
|
|
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
|
Level: slog.LevelInfo,
|
|
})))
|
|
|
|
// Load config
|
|
cfg, err := config.Load(*configPath)
|
|
if err != nil {
|
|
slog.Error("failed to load config", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("config loaded",
|
|
"sources", len(cfg.Sources),
|
|
"channels", len(cfg.Matrix.Channels),
|
|
)
|
|
|
|
// Initialize database
|
|
if err := storage.Init(cfg.Storage.DBPath); err != nil {
|
|
slog.Error("database init failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer storage.Close()
|
|
|
|
// Seed mode: ingest all current feed items as seen, then exit
|
|
if *seed {
|
|
runSeed(cfg)
|
|
return
|
|
}
|
|
|
|
if *backfillPaywall {
|
|
runBackfillPaywall()
|
|
return
|
|
}
|
|
|
|
// Test mode: post one story to verify the full pipeline, then exit
|
|
if *test {
|
|
runTest(cfg, *testSource)
|
|
return
|
|
}
|
|
|
|
// Local mode: poll feeds + serve web UI only. No Matrix, no posting queue.
|
|
if *local {
|
|
runLocal(cfg)
|
|
return
|
|
}
|
|
|
|
// Create Matrix client
|
|
mx, err := matrix.New(cfg.Matrix)
|
|
if err != nil {
|
|
slog.Error("matrix client init failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Create post queue
|
|
queue := poster.NewQueue(cfg.Posting, mx)
|
|
|
|
// Wire reaction handler
|
|
mx.SetReactionHandler(poster.HandleReaction)
|
|
|
|
// Allowlist of users permitted to run in-room commands. The channel rooms
|
|
// are public, so commands are gated to configured admins; an empty list
|
|
// disables them entirely (fail closed) rather than allowing anyone.
|
|
admins := make(map[id.UserID]bool, len(cfg.Matrix.Admins))
|
|
for _, a := range cfg.Matrix.Admins {
|
|
if a = strings.TrimSpace(a); a != "" {
|
|
admins[id.UserID(a)] = true
|
|
}
|
|
}
|
|
|
|
// Wire in-room commands. All are gated to the admin allowlist; the room
|
|
// rooms are public so anything not from an admin is silently ignored.
|
|
mx.SetMessageHandler(func(channel string, roomID id.RoomID, eventID id.EventID, sender id.UserID, body string) {
|
|
cmd := strings.TrimSpace(body)
|
|
if cmd != "!post" && cmd != "!petestats" {
|
|
return
|
|
}
|
|
if !admins[sender] {
|
|
slog.Warn("command ignored: sender not in matrix.admins allowlist", "cmd", cmd, "channel", channel, "sender", sender)
|
|
return
|
|
}
|
|
|
|
if cmd == "!petestats" {
|
|
slog.Info("!petestats requested", "channel", channel, "sender", sender)
|
|
plain, htmlBody := formatStats(storage.GetMetricsSummary())
|
|
if err := mx.PostThreadedReply(channel, eventID, plain, htmlBody); err != nil {
|
|
slog.Warn("!petestats: failed to send reply", "err", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// !post: force-publish the next queued story for the room's channel.
|
|
slog.Info("!post requested", "channel", channel, "sender", sender)
|
|
if queue.ForcePost(channel) {
|
|
return
|
|
}
|
|
// In-memory queue empty (common in round-robin mode). Fall back to the
|
|
// newest classified, not-yet-posted story for this channel.
|
|
story, err := storage.GetNewestPostableStoryByChannel(channel)
|
|
if err != nil {
|
|
slog.Error("!post: db lookup failed", "channel", channel, "err", err)
|
|
return
|
|
}
|
|
if story != nil {
|
|
imageURL := ""
|
|
if story.ImageURL != "" && ingestion.ValidateImageURL(story.ImageURL) {
|
|
imageURL = story.ImageURL
|
|
}
|
|
queue.PostNow(poster.QueueItem{
|
|
GUID: story.GUID,
|
|
Headline: story.Headline,
|
|
Lede: story.Lede,
|
|
ImageURL: imageURL,
|
|
ArticleURL: story.ArticleURL,
|
|
Source: story.Source,
|
|
Channel: story.Channel,
|
|
Platforms: storage.UnmarshalPlatforms(story.Platforms),
|
|
})
|
|
return
|
|
}
|
|
if err := mx.PostThreadedReply(channel, eventID,
|
|
"nothing available for "+channel,
|
|
"nothing available for <code>"+channel+"</code>",
|
|
); err != nil {
|
|
slog.Warn("!post: failed to send empty reply", "err", err)
|
|
}
|
|
})
|
|
|
|
// Set up graceful shutdown
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
// Start Matrix sync loop
|
|
mx.Start(ctx)
|
|
slog.Info("matrix sync started")
|
|
|
|
// Start post queue
|
|
go queue.Start(ctx)
|
|
slog.Info("post queue started")
|
|
|
|
postingEnabled := *cfg.Posting.Enabled
|
|
roundRobinMode := cfg.Posting.RoundRobin.Enabled
|
|
if !postingEnabled {
|
|
slog.Info("automatic Matrix posting is disabled (posting.enabled=false); stories will still be ingested and served to the web UI, and !post still works")
|
|
}
|
|
|
|
// Pipeline callback: route the story to its direct_route channel, then
|
|
// either enqueue immediately or leave it for the round-robin scheduler.
|
|
processItem := func(_ context.Context, item *ingestion.FeedItem) {
|
|
if item.DirectRoute == "" {
|
|
slog.Error("item has no direct_route, skipping", "guid", item.GUID, "source", item.Source)
|
|
storage.MarkClassified(item.GUID, "_discarded", "[]")
|
|
return
|
|
}
|
|
|
|
channel := item.DirectRoute
|
|
storage.MarkClassified(item.GUID, channel, "[]")
|
|
|
|
// Posting disabled: classify and keep for the web UI / manual !post,
|
|
// but never auto-enqueue to Matrix.
|
|
if !postingEnabled {
|
|
return
|
|
}
|
|
|
|
if roundRobinMode {
|
|
slog.Info("story routed, awaiting round-robin tick",
|
|
"guid", item.GUID, "source", item.Source, "channel", channel)
|
|
return
|
|
}
|
|
|
|
imageURL := ""
|
|
if item.ImageURL != "" && ingestion.ValidateImageURL(item.ImageURL) {
|
|
imageURL = item.ImageURL
|
|
}
|
|
|
|
queue.Enqueue(poster.QueueItem{
|
|
GUID: item.GUID,
|
|
Headline: item.Headline,
|
|
Lede: item.Lede,
|
|
ImageURL: imageURL,
|
|
ArticleURL: item.ArticleURL,
|
|
Source: item.Source,
|
|
Channel: channel,
|
|
})
|
|
}
|
|
|
|
// Start pollers
|
|
poller := ingestion.NewPoller(cfg.Sources, processItem)
|
|
poller.Start(ctx)
|
|
slog.Info("pollers started")
|
|
|
|
if postingEnabled && roundRobinMode {
|
|
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
|
|
for name := range cfg.Matrix.Channels {
|
|
// The adventure channel drives its own posting: priority beats go
|
|
// live at ingest, bulletins wait for the daily digest. Leaving it in
|
|
// the rotation would let the scheduler post bulletins one-by-one
|
|
// (they're "classified, not yet posted") and steal them from the digest.
|
|
if cfg.Adventure.Enabled && name == cfg.Adventure.Channel {
|
|
continue
|
|
}
|
|
channelNames = append(channelNames, name)
|
|
}
|
|
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
|
|
if rr == nil {
|
|
slog.Warn("round-robin enabled but no channels are configured; nothing will post")
|
|
} else {
|
|
go rr.Start(ctx)
|
|
}
|
|
}
|
|
|
|
// Adapter: the web ingest handler posts priority adventure beats live to
|
|
// Matrix through the same queue everything else uses (bypasses pacing).
|
|
//
|
|
// Adventure carries its own enable switch and is push-based: facts arrive
|
|
// from gogobee at ingest, they are not polled, classified or paced, and they
|
|
// never enter the round-robin rotation. So it is gated on [adventure] alone,
|
|
// NOT on posting.enabled — that flag governs the RSS pipeline's chatter, and
|
|
// an operator running "news on the web only, adventure live in the games
|
|
// room" is a legitimate configuration. A nil poster (adventure off, or no
|
|
// channel) still keeps both the live beats and the digest loop shut.
|
|
var advPost web.PriorityPoster
|
|
if cfg.Adventure.Enabled && cfg.Adventure.Channel != "" {
|
|
advPost = func(p web.AdvPost) {
|
|
queue.PostNow(poster.QueueItem{
|
|
GUID: p.GUID,
|
|
Headline: p.Headline,
|
|
Lede: p.Lede,
|
|
ImageURL: p.ImageURL,
|
|
ArticleURL: p.ArticleURL,
|
|
Source: p.Source,
|
|
Channel: p.Channel,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Start the read-only web UI alongside the Matrix bot.
|
|
if cfg.Web.Enabled {
|
|
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled, cfg.Adventure, advPost)
|
|
if err != nil {
|
|
slog.Error("web server init failed", "err", err)
|
|
} else {
|
|
go ws.Start(ctx)
|
|
ws.StartPushSender(ctx)
|
|
ws.StartAdventureAlerts(ctx)
|
|
ws.StartAdventureDigest(ctx)
|
|
ws.StartTriviaBank(ctx)
|
|
ws.StartTableClock(ctx)
|
|
}
|
|
}
|
|
|
|
// Prune now and then daily. Startup-only meant a service that ran for weeks
|
|
// between deploys never pruned at all.
|
|
storage.StartMaintenance(ctx)
|
|
|
|
// Wait for shutdown signal
|
|
sig := <-sigCh
|
|
slog.Info("shutdown signal received", "signal", sig)
|
|
cancel()
|
|
|
|
poller.Wait()
|
|
queue.Wait()
|
|
mx.Stop()
|
|
slog.Info("pete stopped")
|
|
}
|
|
|
|
// formatStats renders a usage summary for the !petestats reply, returning a
|
|
// plain-text and an HTML body. Daily uniques are reported per-day only: the
|
|
// privacy salt rotates each day, so they cannot be honestly summed across days.
|
|
func formatStats(m storage.MetricsSummary) (plain, htmlBody string) {
|
|
const topN = 8
|
|
|
|
var p, h strings.Builder
|
|
p.WriteString("📊 Pete usage\n")
|
|
h.WriteString("📊 <b>Pete usage</b><br>")
|
|
|
|
fmt.Fprintf(&p, "Today: %s views · ~%s unique visitors\n",
|
|
comma(m.ViewsToday), comma(m.UniquesToday))
|
|
fmt.Fprintf(&h, "Today: <b>%s</b> views · ~<b>%s</b> unique visitors<br>",
|
|
comma(m.ViewsToday), comma(m.UniquesToday))
|
|
|
|
fmt.Fprintf(&p, "All time: %s views\n", comma(m.TotalViews))
|
|
fmt.Fprintf(&h, "All time: <b>%s</b> views<br>", comma(m.TotalViews))
|
|
|
|
if len(m.Pages) > 0 {
|
|
pages := m.Pages
|
|
if len(pages) > topN {
|
|
pages = pages[:topN]
|
|
}
|
|
parts := make([]string, 0, len(pages))
|
|
for _, pg := range pages {
|
|
parts = append(parts, fmt.Sprintf("%s %s", pg.Path, comma(pg.Total)))
|
|
}
|
|
fmt.Fprintf(&p, "Top pages (all time): %s\n", strings.Join(parts, " · "))
|
|
fmt.Fprintf(&h, "Top pages (all time): %s<br>",
|
|
template.HTMLEscapeString(strings.Join(parts, " · ")))
|
|
}
|
|
|
|
if len(m.Last7Days) > 0 {
|
|
parts := make([]string, 0, len(m.Last7Days))
|
|
for _, d := range m.Last7Days {
|
|
label := time.Unix(d.Day*86400, 0).UTC().Format("Mon")
|
|
parts = append(parts, fmt.Sprintf("%s %s", label, comma(d.Uniques)))
|
|
}
|
|
fmt.Fprintf(&p, "Daily uniques (7d): %s", strings.Join(parts, " · "))
|
|
fmt.Fprintf(&h, "Daily uniques (7d): %s",
|
|
template.HTMLEscapeString(strings.Join(parts, " · ")))
|
|
}
|
|
|
|
return p.String(), h.String()
|
|
}
|
|
|
|
// comma formats a non-negative int with thousands separators (e.g. 12503 →
|
|
// "12,503").
|
|
func comma(n int) string {
|
|
s := strconv.Itoa(n)
|
|
if n < 0 {
|
|
return s // not expected for counts; leave untouched
|
|
}
|
|
var b strings.Builder
|
|
for i, c := range s {
|
|
if i > 0 && (len(s)-i)%3 == 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
b.WriteRune(c)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func runLocal(cfg *config.Config) {
|
|
cfg.Web.Enabled = true
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
processItem := func(_ context.Context, item *ingestion.FeedItem) {
|
|
if item.DirectRoute == "" {
|
|
slog.Info("local: item has no direct_route, discarding", "guid", item.GUID, "source", item.Source)
|
|
storage.MarkClassified(item.GUID, "_discarded", "[]")
|
|
return
|
|
}
|
|
storage.MarkClassified(item.GUID, item.DirectRoute, "[]")
|
|
slog.Info("local: story classified",
|
|
"guid", item.GUID, "source", item.Source, "channel", item.DirectRoute, "headline", item.Headline)
|
|
}
|
|
|
|
poller := ingestion.NewPoller(cfg.Sources, processItem)
|
|
poller.Start(ctx)
|
|
slog.Info("local: pollers started")
|
|
|
|
// Local mode never connects to Matrix, so it's always web-only: adventure
|
|
// stories still ingest and render, but nothing posts (nil priority poster).
|
|
ws, err := web.New(cfg.Web, cfg.Sources, false, cfg.Adventure, nil)
|
|
if err != nil {
|
|
slog.Error("local: web server init failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
go ws.Start(ctx)
|
|
// Push only needs the web auth layer, not Matrix, so a web-only deployment
|
|
// still runs the digest sender — otherwise subscriptions accumulate but no
|
|
// digest ever fires. Adventure alerts are the same: they read facts already in
|
|
// the database and never touch Matrix, so -local is a full-fidelity way to
|
|
// exercise them.
|
|
ws.StartPushSender(ctx)
|
|
ws.StartAdventureAlerts(ctx)
|
|
slog.Info("local: web UI listening", "addr", cfg.Web.ListenAddr)
|
|
|
|
storage.StartMaintenance(ctx)
|
|
|
|
sig := <-sigCh
|
|
slog.Info("local: shutdown signal received", "signal", sig)
|
|
cancel()
|
|
poller.Wait()
|
|
slog.Info("pete stopped")
|
|
}
|
|
|
|
func runSeed(cfg *config.Config) {
|
|
total := 0
|
|
for _, src := range cfg.Sources {
|
|
if !src.Enabled {
|
|
continue
|
|
}
|
|
items, err := ingestion.FetchFeed(src.FeedURL, src.UserAgent)
|
|
if err != nil {
|
|
slog.Error("seed: feed fetch failed", "source", src.Name, "err", err)
|
|
continue
|
|
}
|
|
for _, item := range items {
|
|
if storage.IsGUIDSeen(item.GUID) {
|
|
continue
|
|
}
|
|
if err := storage.InsertStory(&storage.Story{
|
|
GUID: item.GUID,
|
|
Headline: item.Headline,
|
|
Lede: item.Lede,
|
|
ImageURL: item.ImageURL,
|
|
ArticleURL: item.ArticleURL,
|
|
Source: src.Name,
|
|
Classified: true,
|
|
SeenAt: timeNow(),
|
|
}); err != nil {
|
|
slog.Error("seed: insert failed", "guid", item.GUID, "err", err)
|
|
continue
|
|
}
|
|
total++
|
|
}
|
|
slog.Info("seed: source done", "source", src.Name, "items", len(items))
|
|
}
|
|
slog.Info("seed complete — all current stories marked as seen", "total", total)
|
|
}
|
|
|
|
func runTest(cfg *config.Config, sourceName string) {
|
|
// Find the first story from the matching (or first enabled) feed
|
|
var testItem *ingestion.FeedItem
|
|
var src config.SourceConfig
|
|
for _, s := range cfg.Sources {
|
|
if !s.Enabled {
|
|
continue
|
|
}
|
|
if sourceName != "" && s.Name != sourceName {
|
|
continue
|
|
}
|
|
items, err := ingestion.FetchFeed(s.FeedURL, s.UserAgent)
|
|
if err != nil {
|
|
slog.Error("test: feed fetch failed", "source", s.Name, "err", err)
|
|
continue
|
|
}
|
|
if len(items) > 0 {
|
|
item := items[0]
|
|
item.Source = s.Name
|
|
item.DirectRoute = s.DirectRoute
|
|
testItem = &item
|
|
src = s
|
|
break
|
|
}
|
|
}
|
|
if testItem == nil {
|
|
slog.Error("test: no stories found in any feed")
|
|
os.Exit(1)
|
|
}
|
|
|
|
slog.Info("test: selected story",
|
|
"source", src.Name,
|
|
"headline", testItem.Headline,
|
|
"guid", testItem.GUID,
|
|
)
|
|
|
|
channel := testItem.DirectRoute
|
|
if channel == "" {
|
|
slog.Error("test: source has no direct_route", "source", src.Name)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate image
|
|
imageURL := ""
|
|
if testItem.ImageURL != "" && ingestion.ValidateImageURL(testItem.ImageURL) {
|
|
imageURL = testItem.ImageURL
|
|
}
|
|
|
|
// Connect to Matrix and post
|
|
mx, err := matrix.New(cfg.Matrix)
|
|
if err != nil {
|
|
slog.Error("test: matrix init failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer mx.Stop()
|
|
|
|
story := &matrix.PostableStory{
|
|
ImageURL: imageURL,
|
|
Headline: testItem.Headline,
|
|
ArticleURL: testItem.ArticleURL,
|
|
Lede: testItem.Lede,
|
|
Source: testItem.Source,
|
|
Channel: channel,
|
|
}
|
|
|
|
eventID, _, err := mx.PostStory(channel, story)
|
|
if err != nil {
|
|
slog.Error("test: post failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
slog.Info("test: story posted successfully",
|
|
"channel", channel,
|
|
"event_id", eventID,
|
|
"headline", testItem.Headline,
|
|
)
|
|
}
|
|
|
|
func timeNow() int64 {
|
|
return time.Now().Unix()
|
|
}
|
|
|
|
// runBackfillPaywall re-runs the current paywall detection logic against
|
|
// every row marked paywalled=1. For each row we re-fetch the stored article
|
|
// URL; if the new logic no longer considers it gated we clear the flag and,
|
|
// when the row has no thumbnail, populate it from the live og:image.
|
|
func runBackfillPaywall() {
|
|
rows, err := storage.Get().Query(`SELECT guid, article_url, image_url FROM stories WHERE paywalled = 1`)
|
|
if err != nil {
|
|
slog.Error("backfill: query failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
type rec struct{ guid, url, img string }
|
|
var pending []rec
|
|
for rows.Next() {
|
|
var r rec
|
|
if err := rows.Scan(&r.guid, &r.url, &r.img); err != nil {
|
|
slog.Error("backfill: scan failed", "err", err)
|
|
continue
|
|
}
|
|
pending = append(pending, r)
|
|
}
|
|
rows.Close()
|
|
slog.Info("backfill: starting", "candidates", len(pending))
|
|
|
|
cleared, imaged, stillGated := 0, 0, 0
|
|
for _, r := range pending {
|
|
meta := ingestion.FetchArticleMeta(r.url)
|
|
gated := meta.Gated() || (meta.Fetched && meta.BodyChars < ingestion.PaywallBodyThreshold)
|
|
if gated {
|
|
stillGated++
|
|
slog.Info("backfill: still gated", "guid", r.guid, "status", meta.Status, "body_chars", meta.BodyChars)
|
|
continue
|
|
}
|
|
newImg := r.img
|
|
if newImg == "" && meta.ImageURL != "" {
|
|
newImg = meta.ImageURL
|
|
imaged++
|
|
}
|
|
if _, err := storage.Get().Exec(`UPDATE stories SET paywalled = 0, image_url = ? WHERE guid = ?`, newImg, r.guid); err != nil {
|
|
slog.Error("backfill: update failed", "guid", r.guid, "err", err)
|
|
continue
|
|
}
|
|
cleared++
|
|
slog.Info("backfill: cleared paywall flag", "guid", r.guid, "filled_image", newImg != r.img)
|
|
}
|
|
slog.Info("backfill complete", "cleared", cleared, "still_gated", stillGated, "thumbnails_filled", imaged)
|
|
}
|