Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.
SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
(a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
rep (URL stability) -> quality score, so an accepted page never retires to a
rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).
Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
picker (bundled data, no CDN) for the blurb editor.
Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.
Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+62
-9
@@ -11,6 +11,7 @@ import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from .feeds import MAX_BACKOFF_MINUTES
|
||||
from .localtime import local_now
|
||||
from .paywall import is_paywalled, is_paywalled_for_source
|
||||
|
||||
# UA substrings that mark automated clients. Crawlers run JS on a throttled
|
||||
@@ -78,6 +79,7 @@ def feed(
|
||||
follow_sources: list[int] | None = None,
|
||||
follow_tags: list[str] | None = None,
|
||||
since: str | None = None,
|
||||
match: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return articles with categorical filters applied in SQL.
|
||||
|
||||
@@ -92,6 +94,14 @@ def feed(
|
||||
"""
|
||||
clauses = ["a.duplicate_of IS NULL", "src.content_visible = 1"]
|
||||
params: list = []
|
||||
# Full-text search: join the FTS index and MATCH first, so its bound param
|
||||
# leads and relevance can drive the ordering. All the boundary clauses below
|
||||
# still apply, so search mirrors exactly what the visitor feed would show.
|
||||
fts_join = ""
|
||||
if match:
|
||||
fts_join = "JOIN article_search ON article_search.article_id = a.id"
|
||||
clauses.append("article_search MATCH ?")
|
||||
params.append(match)
|
||||
if accepted_only:
|
||||
clauses.append("s.accepted = 1")
|
||||
if topic:
|
||||
@@ -155,17 +165,19 @@ def feed(
|
||||
where = "WHERE " + " AND ".join(clauses)
|
||||
params.extend([limit, offset])
|
||||
|
||||
order_by = (
|
||||
"COALESCE(a.published_at, a.discovered_at) DESC, rank_score DESC"
|
||||
if sort == "latest"
|
||||
else "rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC"
|
||||
)
|
||||
if match:
|
||||
order_by = "bm25(article_search), COALESCE(a.published_at, a.discovered_at) DESC" # relevance, then recency
|
||||
elif sort == "latest":
|
||||
order_by = "COALESCE(a.published_at, a.discovered_at) DESC, rank_score DESC"
|
||||
else:
|
||||
order_by = "rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC"
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT {_ARTICLE_COLUMNS}
|
||||
FROM articles a
|
||||
JOIN sources src ON src.id = a.source_id
|
||||
JOIN article_scores s ON s.article_id = a.id
|
||||
{fts_join}
|
||||
{where}
|
||||
ORDER BY {order_by}
|
||||
LIMIT ? OFFSET ?
|
||||
@@ -175,6 +187,27 @@ def feed(
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def reindex_search(conn: sqlite3.Connection) -> int:
|
||||
"""Rebuild the article_search FTS index from the accepted, non-duplicate corpus
|
||||
(title/description/source name/tags). A cheap full rebuild (a few thousand
|
||||
rows); run on each ingest cycle and lazily on first search. Live visibility /
|
||||
boundary filtering is applied at query time, so it doesn't need reindexing."""
|
||||
conn.execute("DELETE FROM article_search")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO article_search (article_id, title, body, source_name, tags)
|
||||
SELECT a.id, a.title, COALESCE(a.description, ''), src.name,
|
||||
COALESCE((SELECT group_concat(t.tag, ' ') FROM article_tags t WHERE t.article_id = a.id), '')
|
||||
FROM articles a
|
||||
JOIN sources src ON src.id = a.source_id
|
||||
JOIN article_scores s ON s.article_id = a.id
|
||||
WHERE s.accepted = 1 AND a.duplicate_of IS NULL
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
return conn.execute("SELECT COUNT(*) FROM article_search").fetchone()[0]
|
||||
|
||||
|
||||
def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = 10) -> dict:
|
||||
"""Return a stored daily brief (latest if no date) with its ranked items."""
|
||||
target_date = brief_date or _latest_brief_date(conn)
|
||||
@@ -344,6 +377,8 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
|
||||
(SELECT COUNT(*) FROM articles a WHERE a.source_id = s.id) AS total_articles,
|
||||
(SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id
|
||||
WHERE a.source_id = s.id AND sc.accepted = 1) AS accepted_total,
|
||||
(SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id
|
||||
WHERE a.source_id = s.id AND sc.reason_code = 'non_english') AS non_english,
|
||||
(SELECT COUNT(*) FROM articles a WHERE a.source_id = s.id AND a.duplicate_of IS NOT NULL) AS duplicates,
|
||||
(SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id
|
||||
WHERE a.source_id = s.id AND sc.accepted = 1 AND a.duplicate_of IS NULL) AS served,
|
||||
@@ -365,7 +400,14 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
|
||||
d = dict(r)
|
||||
total = d["total_articles"] or 0
|
||||
accepted = d["accepted_total"] or 0
|
||||
d["acceptance_rate"] = round(100 * accepted / total) if total else None
|
||||
non_english = d.get("non_english") or 0
|
||||
# Acceptance is judged over articles actually scored in English — non-English
|
||||
# items are HELD (awaiting translation), not calm-filter rejections, so they
|
||||
# don't drag a multilingual source's rate down.
|
||||
judged = total - non_english
|
||||
d["acceptance_rate"] = round(100 * accepted / judged) if judged else None
|
||||
d["non_english"] = non_english
|
||||
d["non_english_rate"] = round(100 * non_english / total) if total else None
|
||||
d["duplicate_rate"] = round(100 * d["duplicates"] / total) if total else None
|
||||
# Curation quality: of what this source got ACCEPTED, how much was a
|
||||
# duplicate of content already served (accepted_total − served = accepted dupes).
|
||||
@@ -459,7 +501,9 @@ def _attention(content: dict, sources: list[dict], feedback_unread: int, now: da
|
||||
|
||||
_SRC_ART_FILTERS = {
|
||||
"accepted": "AND s.accepted = 1",
|
||||
"rejected": "AND s.accepted = 0",
|
||||
# 'rejected' = calm-filter rejections only; non-English is HELD, its own bucket.
|
||||
"rejected": "AND s.accepted = 0 AND COALESCE(s.reason_code,'') != 'non_english'",
|
||||
"held": "AND s.reason_code = 'non_english'",
|
||||
"no_image": "AND (a.image_url IS NULL OR a.image_url = '')",
|
||||
"duplicates": "AND a.duplicate_of IS NOT NULL",
|
||||
}
|
||||
@@ -493,6 +537,7 @@ def source_articles(conn: sqlite3.Connection, source_id: int, filter: str = "all
|
||||
"published_at": r["published_at"] or r["discovered_at"],
|
||||
"accepted": r["accepted"],
|
||||
"reason": r["reason_text"] or r["reason_code"], # the "why" behind accept/reject
|
||||
"held": r["reason_code"] == "non_english", # held for language, not rejected
|
||||
"topic": r["topic"],
|
||||
"flavor": r["flavor"],
|
||||
"paywalled": is_paywalled_for_source(r["canonical_url"], override), # effective (domain rule + override)
|
||||
@@ -510,7 +555,8 @@ def source_articles_summary(conn: sqlite3.Connection, source_id: int) -> dict:
|
||||
"""
|
||||
SELECT COUNT(*) total,
|
||||
COALESCE(SUM(s.accepted = 1), 0) accepted,
|
||||
COALESCE(SUM(s.accepted = 0), 0) rejected,
|
||||
COALESCE(SUM(s.accepted = 0 AND COALESCE(s.reason_code,'') != 'non_english'), 0) rejected,
|
||||
COALESCE(SUM(s.reason_code = 'non_english'), 0) non_english,
|
||||
COALESCE(SUM(a.image_url IS NULL OR a.image_url = ''), 0) no_image,
|
||||
COALESCE(SUM(a.duplicate_of IS NOT NULL), 0) duplicates
|
||||
FROM articles a LEFT JOIN article_scores s ON s.article_id = a.id
|
||||
@@ -523,6 +569,7 @@ def source_articles_summary(conn: sqlite3.Connection, source_id: int) -> dict:
|
||||
url = (srow["homepage_url"] or srow["feed_url"]) if srow else None
|
||||
return {
|
||||
"total": agg["total"], "accepted": agg["accepted"], "rejected": agg["rejected"],
|
||||
"non_english": agg["non_english"], # held for language (not a calm-filter rejection)
|
||||
"no_image": agg["no_image"], "duplicates": agg["duplicates"],
|
||||
"paywalled": is_paywalled_for_source(url, override), # effective
|
||||
"paywall_domain": is_paywalled(url), # what the domain rule alone says
|
||||
@@ -533,6 +580,11 @@ def source_articles_summary(conn: sqlite3.Connection, source_id: int) -> dict:
|
||||
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"""Aggregate, non-personal usage stats for the admin dashboard."""
|
||||
since = f"-{days} days"
|
||||
# "Today" for timestamp-based counters is the SITE-LOCAL day (GOODNEWS_TZ), not
|
||||
# UTC: otherwise an evening error (e.g. 22:53 local) lands on the next UTC day and
|
||||
# reads as a fresh "today" the following morning — the exact false-alarm we hit.
|
||||
local_day_start = (local_now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
def scalar(sql, params=()):
|
||||
return conn.execute(sql, params).fetchone()[0] or 0
|
||||
@@ -658,7 +710,8 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
# check routinely and would read as real users seeing blank screens.
|
||||
"client_errors": {
|
||||
"today": scalar(
|
||||
f"SELECT COUNT(*) FROM client_errors WHERE date(created_at)=date('now') AND {_NOT_BOT_SQL}"
|
||||
f"SELECT COUNT(*) FROM client_errors WHERE created_at >= ? AND {_NOT_BOT_SQL}",
|
||||
(local_day_start,),
|
||||
),
|
||||
"window": scalar(
|
||||
f"SELECT COUNT(*) FROM client_errors WHERE created_at>=date('now',?) AND {_NOT_BOT_SQL}",
|
||||
|
||||
Reference in New Issue
Block a user