feat(pesacheck_meedan_bridge): Make the source CMS configurable (Ghost + Superdesk) - #1211
koechkevin wants to merge 4 commits into
Conversation
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as outdated.
This comment was marked as outdated.
PesaCheck is moving from Ghost to Superdesk, so the bridge can no longer be Ghost-shaped throughout. Fetching, parsing, summaries and language detection move behind a provider interface, and PESACHECK_PROVIDER picks which CMS a run reads. - provider_base.py: the normalized Article the rest of the bridge sees - provider_ghost.py: today's Content API behaviour, unchanged - provider_superdesk.py: Publisher's GraphQL API, filtered to the tenant and to articles carrying a Debunk verdict, paginated by limit/offset - providers.py: the registry; each provider validates its own settings, so Superdesk no longer needs a Ghost key - database: source and language columns, added by an idempotent migration that backfills medium/ghost for existing rows; the fetch checkpoint is now per provider, so switching back and forth neither re-posts nor loses place - PESACHECK_POSTS_LIMIT replaces PESACHECK_GHOST_POSTS_LIMIT, falling back to it so deployments keep their value Providers are flat modules rather than a package: py/BUILD globs *.py in that directory only, and a subpackage needs its own BUILD plus correct pex dependency inference, which can't be verified without Pants locally.
bfcb45f to
7a83eac
Compare
kilemensi
left a comment
There was a problem hiding this comment.
Nice refactor — the provider split is clean and the per-provider settings validation is a real improvement.
One problem with the cutover itself: the first Superdesk run will re-post fact-checks Check already has, and can skip articles. Details inline on main.py. The other two comments are a question about URLs and a docs fix.
Separately from this PR, and worth raising on pesacheck-ui: old Ghost URLs (pesacheck.org/<slug>/) currently 404 on the new site. They fall through to the catch-all app/[...slug] route, which only renders Publisher pages, and nothing uses swp_redirect_route yet. Every existing Check report and tipline response links to those URLs.
People need to sleep😂 |
…tover Review feedback on #1211. A provider's first run had no checkpoint, so it fetched the newest page and posted it. At the Ghost -> Superdesk cutover those same fact-checks are already in Check under Ghost's guids and URLs: the new guids don't match feed_exists, and Check doesn't reject them either, because its signature is an MD5 over set_fact_check (which carries the URL) plus the team. We'd have published duplicate reports. Anything published beyond one page since the last Ghost run would also have been skipped. A provider with no articles of its own now starts from the newest article stored under any source, i.e. where the previous provider stopped. Confirmed migrated articles keep their original published_at: staging holds fact-checks back to 2018-11-15. That fallback can reach far back, so catch-up is now bounded: - fetch oldest first when catching up (newest page when there is no checkpoint at all), so a partial run still advances the checkpoint - stop at PESACHECK_MAX_ARTICLES (default 100) per run and continue on the next one Without the bound, a stale checkpoint walked years of archive in one run: a local test with only 2024-era rows paginated ~2 years of Ghost posts and would have posted them all to Check. Also fixes a crash the new tests caught: get_checkpoint() used UTC, which main.py no longer imported after the provider refactor, so any legacy Medium row (naive timestamps) would raise NameError.
Review feedback on #1211. Publisher has no canonical-URL field (swp_redirect_route is empty), so the bridge builds the URL. The shape decides more than the link: Check's duplicate signature covers the fact-check URL, so a new shape makes already-imported articles look new, and existing Check reports all link to the Ghost-era form. PESACHECK_ARTICLE_URL_TEMPLATE defaults to {site}/{slug}/, which matches those existing reports, and can be switched to {site}/fact-checks/{desk}/{slug} once the new site serves that canonically. Publisher slugs match the Ghost slugs today (spot-checked two live URLs, both 200).
kilemensi
left a comment
There was a problem hiding this comment.
One new bug — both providers
provider_ghost.py:70 and provider_superdesk.py:137: the max_articles early return bypasses the oldest-first reversal
at the end of the function.
if max_articles is not None and len(posts) >= max_articles:
return posts[:max_articles] # no reversal
...
return posts if since else list(reversed(posts))
On the no-checkpoint path the fetch is order=desc, so if that early return fires it hands back newest-first,
contradicting the function's own docstring ("Returns oldest first either way"). main.py then processes newest-first,
the checkpoint jumps straight to the newest article, and a mid-batch store failure (break) permanently strands every
older article in the batch — the exact invariant the comment at main.py:157 asserts.
It needs PESACHECK_POSTS_LIMIT >= PESACHECK_MAX_ARTICLES to trigger, so the shipped defaults (15 vs 100) are safe and
it's latent today. But it's config-reachable by anyone raising the page size to cut round-trips, it's silent, and the
failure mode is permanent article loss. Fix is to break with the slice instead of returning, letting the single exit
handle ordering.
… order Review feedback on #1211. The max_articles early return skipped the reversal at the single exit, so on the no-checkpoint path (which fetches newest first) a capped run handed back newest-first. main() would then advance the checkpoint to the newest article, and a mid-batch store failure would strand every older article in the batch for good. Latent at the shipped defaults (page size 15, cap 100) since the cap can't fire on the first page, but reachable by raising the page size to the cap or beyond. Break with the slice instead, so ordering stays with the single exit.
Why
PesaCheck is moving off Ghost: the new site is built on Superdesk + Publisher, read over a Hasura GraphQL API (
pesacheck-ui). The bridge was Ghost-shaped throughout — fetching, parsing, summary extraction and language detection were all inlined inmain.py.After this, the CMS is a config choice, so the cutover is an env change plus a restart, and rollback is flipping it back.
What changed
A provider interface
main.pyno longer knows about any CMS. Each provider exposesname,fetch(since, limit, max_articles)returning raw records, andparse(record)returning a normalizedArticle(guid, title, url, published_at, plain-text summary, categories, language, author, thumbnail).provider_base.pyArticle+ the shared User-Agent +html_to_textprovider_ghost.pyprovider_superdesk.pyproviders.pyget_provider()Each provider validates its own settings, so running Superdesk no longer requires a Ghost API key (
settings.pyused to demand it at import).Providers are flat modules, not a
providers/package:py/BUILDglobs*.pyin that directory only, and a subpackage would need its own BUILD file plus correct pex dependency inference — unverifiable without Pants locally, and a mistake would surface as an ImportError in the deployed cron rather than at build time.Superdesk mapping
One query against
swp_article, filtered by tenant and to articles carrying aDebunkverdict — that clause is what separates fact-checks from homepage blocks, team profiles and Media Centre entries on the same routes.metadataarrives as a JSON-encoded string and is parsed.guidmetadata.guid(survives a re-import), falling back toidurlPESACHECK_ARTICLE_URL_TEMPLATEover{site},{desk},{slug}summarylead, HTML-strippedlanguagemetadata.language— no more guessing from tag namescategoriesDebunklang,countries,content_type,Harm_typeCutover safety (from review)
Two problems with switching provider, both fixed:
The first Superdesk run would have re-posted fact-checks Check already has. With no checkpoint it fetched the newest page and posted it. Migrated articles carry new guids, so
feed_existsdoesn't match, and Check does not reject them: its signature isMD5([set_fact_check.to_json, team_id])(project_media_creators.rb) andset_fact_checkcarries the URL, which differs between the two sites. That would have published duplicate reports. Anything published beyond one page since the last Ghost run would also have been skipped.A provider with no articles of its own now starts from the newest article stored under any source — where the previous provider stopped. This relies on migrated articles keeping their original
published_at, which they do: staging holds fact-checks back to 2018-11-15.That fallback can reach far back, so catch-up is now bounded. Articles are fetched oldest first when catching up (newest page when there's no checkpoint at all), so a partial run still advances the checkpoint, and a run stops at
PESACHECK_MAX_ARTICLES(default 100) and continues on the next one. Without this, a stale checkpoint walked years of archive in a single run: a local test with only 2024-era rows paginated ~2 years of Ghost posts and would have posted all of them to Check.The new tests also caught a latent crash:
get_checkpoint()usedUTC, whichmain.pystopped importing during the refactor, so any legacy Medium row (naive timestamps) raisedNameError.Article URL
Publisher has no canonical-URL field —
swp_redirect_routeexists but is empty — so the bridge builds the URL, and the shape matters beyond the link: Check's duplicate signature covers it, so a new shape makes already-imported articles look new.PESACHECK_ARTICLE_URL_TEMPLATEdefaults to{site}/{slug}/, the Ghost-era shape every fact-check already in Check links to, and switches to{site}/fact-checks/{desk}/{slug}with one setting once the new site serves that canonically. Publisher slugs match the Ghost slugs today (two live URLs spot-checked, both 200).pesacheck-ui.Database
guid NOT LIKE 'http%'meant "Ghost", which breaks once Superdesk guids (UUIDs) arrive. Addedsourceandlanguagecolumns via an idempotent migration that backfillsmedium/ghostfor existing rows. The fetch checkpoint is scoped to the active provider, falling back to all sources as described above. Legacy Medium summary handling keys offsourceinstead of sniffing the guid.Settings
PESACHECK_PROVIDERghostghost|superdeskPESACHECK_POSTS_LIMITPESACHECK_GHOST_POSTS_LIMIT, else 15PESACHECK_MAX_ARTICLESPESACHECK_SITE_URLhttps://pesacheck.orgPESACHECK_ARTICLE_URL_TEMPLATE{site}/{slug}/PESACHECK_SUPERDESK_GRAPHQL_URLPESACHECK_SUPERDESK_TENANT_CODEPESACHECK_SUPERDESK_PRESHARED_AUTHx-preshared-authTesting
pants test pesacheck_meedan_bridge/py::, green in CI): provider registry and per-provider settings validation; Ghost behaviour; Superdesk mapping, tenant/Debunk filter, paging, GraphQL errors, preshared-auth header, unparseable metadata, guid fallback, URL template incl. an article with no route; the Ghost→Superdesk cutover; the catch-up cap and the oldest-first ordering contract; legacy naive timestamps in the global checkpoint; the schema migration; plus everything inherited from feat(pesacheck_meedan_bridge): Fetch articles from Ghost Content API #1208/fix(pesacheck_meedan_bridge): Stop retrying fact-checks Check already has #1210.graphql-staging.pesacheck.org(tenant123abc, 14,773 fact-checks): mapping verified on real rows; generated URLs return 200.source=medium.ghostresumed from the Ghost checkpoint.flake8,ruff format,isort,banditpass.Deploying the switch
PESACHECK_SUPERDESK_GRAPHQL_URLandPESACHECK_SUPERDESK_TENANT_CODE(production, not staging).PESACHECK_SITE_URL, andPESACHECK_ARTICLE_URL_TEMPLATEif the new site's shape should be canonical.PESACHECK_PROVIDER=superdesk.Production already has Ghost articles stored, so the first Superdesk run resumes from the last Ghost one rather than re-importing. Keep an eye on the first run's Sentry summary.
Not included
PESACHECK_SUPERDESK_PRESHARED_AUTHis the sturdier answer to the Cloudflare problem we hit in production, where the WAF rule is pinned to the Dokku host's non-elastic IP. Worth adopting for Ghost too, separately.🤖 Generated with Claude Code