Engagement tracking began 2026-06-30 — rolling windows are still filling, so a low 7d/{range}d here is partly warm-up, not all bots. Compare d7 confidently after a week, the {range}d window after {range} days.
{stats.visitors.engaged_today}Today
{stats.visitors.engaged_d7}Last 7 days
diff --git a/goodnews/api.py b/goodnews/api.py
index b281beb..95c4713 100644
--- a/goodnews/api.py
+++ b/goodnews/api.py
@@ -1437,7 +1437,11 @@ def create_app() -> FastAPI:
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="source not found")
conn.commit()
- return {"ok": True, "policy": pol}
+ # Leaving 'cache' (e.g. permission revoked) → take the re-hosted copies down now,
+ # not just make them inaccessible. Setting TO 'cache' just flips the flag; the
+ # cycle warms it.
+ purged = newsimg.purge_source(conn, sid) if pol != "cache" else 0
+ return {"ok": True, "policy": pol, "purged": purged}
# --- Source candidates (supervised add-a-source pipeline) ----------------
diff --git a/goodnews/newsimg.py b/goodnews/newsimg.py
index 281ce10..015762a 100644
--- a/goodnews/newsimg.py
+++ b/goodnews/newsimg.py
@@ -216,6 +216,31 @@ def fetch_and_cache(url: str | None) -> Path | None:
return dest
+def purge_source(conn, source_id: int) -> int:
+ """Delete every cached file for a source's article image URLs. Called when a source
+ leaves 'cache' policy (revoked permission / re-classified), so the re-hosted copies
+ come down immediately rather than lingering inaccessible on disk. Returns webp count."""
+ rows = conn.execute(
+ "SELECT DISTINCT image_url FROM articles WHERE source_id = ? "
+ "AND image_url IS NOT NULL AND image_url != ''",
+ (source_id,),
+ ).fetchall()
+ cdir = cache_dir()
+ removed = 0
+ for r in rows:
+ key = _key(r[0])
+ for suffix in (".webp", ".fail"):
+ p = cdir / f"{key}{suffix}"
+ try:
+ if p.exists():
+ p.unlink()
+ if suffix == ".webp":
+ removed += 1
+ except OSError:
+ pass
+ return removed
+
+
def warm(conn, limit: int = 200) -> int:
"""Pre-fetch display copies for the newest ACCEPTED, CANONICAL articles whose SOURCE
is cleared to cache (image_policy='cache'), so the API only ever serves cache hits.
diff --git a/tests/test_admin.py b/tests/test_admin.py
index 4827e9e..51c6f2a 100644
--- a/tests/test_admin.py
+++ b/tests/test_admin.py
@@ -600,6 +600,7 @@ def test_source_image_policy(tmp_path, monkeypatch):
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
assert c.execute("SELECT image_policy FROM sources WHERE id=2").fetchone()[0] == "cache"
c.close()
- assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "remote"}).json()["policy"] == "remote"
+ r = tc.post("/api/admin/sources/2/image-policy", json={"policy": "remote"}).json()
+ assert r["policy"] == "remote" and r["purged"] == 0 # leaving cache purges (no files here)
assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "bogus"}).status_code == 422
assert tc.post("/api/admin/sources/999/image-policy", json={"policy": "cache"}).status_code == 404
diff --git a/tests/test_newsimg.py b/tests/test_newsimg.py
index 3976847..07636a6 100644
--- a/tests/test_newsimg.py
+++ b/tests/test_newsimg.py
@@ -193,6 +193,22 @@ def test_warm_only_caches_cache_policy_sources(cache, monkeypatch):
assert newsimg.warm(conn) == 0 # idempotent — already cached
+def test_purge_source_removes_cached_copies(cache, monkeypatch):
+ conn = connect(":memory:"); init_db(conn)
+ conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
+ conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'O','http://o/f','cache')")
+ for aid, sid, img in ((1, 1, "https://x/1.jpg"), (2, 1, "https://x/2.jpg"), (3, 2, "https://x/3.jpg")):
+ conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) "
+ "VALUES (?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img))
+ conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,1)", (aid,))
+ conn.commit()
+ assert newsimg.warm(conn) == 3 # all three cached (both sources 'cache')
+ removed = newsimg.purge_source(conn, 1) # source 1 leaves cache → its 2 copies go
+ assert removed == 2
+ assert newsimg.path_for("https://x/1.jpg") is None and newsimg.path_for("https://x/2.jpg") is None
+ assert newsimg.path_for("https://x/3.jpg") is not None # source 2 untouched
+
+
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("GOODNEWS_DB", str(tmp_path / "t.sqlite3"))