Move Hashnode syndication off the deprecated GraphQL API#4956
Conversation
Hashnode shut down free public GraphQL access at gql.hashnode.com on
2026-05-13 and moved it behind a paid / allow-listed offering, so the
existing API syndicator fails with a JSON parse error on every run.
Drive the Hashnode web editor instead, from a saved Playwright
storageState, as part of the existing browser-syndication script.
- syndicate_blog_posts.py: drop the publish_to_hashnode mutation and
related constants; the only API target left is dev.to.
- syndicate_browser_posts.py: add HashnodeAdapter. Click Write to land
on /draft/<id>, fill title and (cover-image-stripped) body, upload
the cover via expect_file_chooser on the popover's Upload Image
button, reveal and fill the subheading textarea with the post's
description, then in the publish dialog's Discovery tab clear any
stale tag pills, add java/mobile-development/ios/android/opensource
via exact-match dropdown selection (Escape-then-Enter fallback for
tags absent from autocomplete), and set the canonical URL.
Hashnode reuses a single per-user draft slot — Cmd+A+Delete clears
the contenteditable and the canonical input before writing so
consecutive runs do not inherit prior content.
- export_storage_state.py: add a Hashnode profile keyed on the
hashnode-session cookie so `--site hashnode --from-firefox-profile`
extracts a working session.
- queue_browser_syndication.py: drop hashnode from the queue
defaults; it is now driven directly by syndicate_browser_posts.py.
- blog-syndication.yml: drop the HASHNODE_TOKEN / publication-id
env vars from the API step; the browser syndicator skips Hashnode
automatically in CI because HASHNODE_STORAGE_STATE is intentionally
not exposed there.
- syndication-state.json: record the metal-and-skins dev.to entry
(which CI published but failed to commit when the Hashnode step
errored) plus the published Hashnode entry, so the next cron picks
up the following post instead of duplicating either target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the adapter filled title/body/cover/subheading/tags/canonical and then clicked Close, leaving the post as a draft for the maintainer to publish manually. Click the in-dialog Publish button instead so each cron cycle ships the post end-to-end, and capture the resulting public article URL (e.g. https://debugagent.com/<slug>) for syndication-state.json. This also resolves the "single per-user draft slot" overwrite hazard: once a draft is published the slot is freed and the next "Write" click on the dashboard creates a fresh /draft/<id>, so consecutive weekly runs no longer clobber each other. Falls back to Close-as-draft if any step in the dialog flow fails so the editorial work isn't lost — the caller can then publish manually from the editor UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Follow-up: auto-publish the draft instead of leaving it for manual review.
This also resolves the "single per-user draft slot" hazard from the original PR description — once a draft is published the slot is freed, so the next weekly run lands on a fresh Selectors validated against the live editor; the actual end-to-end publish round-trip will fire on the next eligible cron (when |
✅ Continuous Quality ReportTest & Coverage
Static Analysis
Generated automatically by the PR CI workflow. |
Cloudflare Preview
|
Summary
gql.hashnode.comon 2026-05-13 and moved it behind a paid / allow-listed offering, so every blog-syndication cron has been failing on the Hashnode step withExpecting value: line 1 column 1 (char 0)(the JSON parser choking on the HTML redirect to the announcement page).HashnodeAdapterinside the existing browser-syndication script. The adapter handles cover image upload (viaexpect_file_chooser), subheading, the five canonical tags (java,mobile-development,ios,android,opensource), and the canonical URL, and strips the leading cover-image markdown from the body so the cover isn't shown twice.syndicate_browser_posts.py's default platforms (alongside foojay). CI does not holdHASHNODE_STORAGE_STATEas a secret, so the platform is skipped automatically in cron runs; the maintainer drives it locally.What's in the diff
scripts/website/syndicate_blog_posts.py— remove thepublish_to_hashnodeGraphQL mutation and related constants. The API syndicator now only targets dev.to.scripts/website/syndicate_browser_posts.py— addHashnodeAdapter(~280 LOC) and a_download_to_temphelper for the cover-image upload. Adapter selectors were verified against the live editor.scripts/website/export_storage_state.py— add a Hashnode site profile keyed on thehashnode-sessioncookie so--site hashnode --from-firefox-profileextracts a working session.scripts/website/queue_browser_syndication.py— drophashnodefromDEFAULT_PLATFORMS; the queue is now back tomedium,dzone(Hashnode is driven inline by the browser script)..github/workflows/blog-syndication.yml— drop theHASHNODE_TOKEN/HASHNODE_PUBLICATION_IDenv vars from the API step and rename the queue step accordingly.scripts/website/syndication-state.json— record themetal-and-skinsentries for dev.to (CI published it during the failed run but the post-step commit was aborted on the Hashnode error) and Hashnode (just published from the new adapter), so the next cron picks upskills-java17-and-theme-accentsonce it crosses the 7-day age threshold.Test plan
python3 scripts/website/syndicate_blog_posts.py --dry-run(today): reportsNo syndication candidate found today— correct; metal-and-skins is recorded for dev.to.python3 scripts/website/syndicate_browser_posts.py --dry-run(today): selectsmetal-and-skinsfor foojay (the still-unsyndicated platform on that post) and reportshashnode: already syndicated; skipping.python3 scripts/website/queue_browser_syndication.py --dry-run(today): queuesmedium:metal-and-skins+dzone:metal-and-skins(no Hashnode entry — Hashnode no longer routes through the queue).--today 2026-05-22to confirmskills-java17-and-theme-accentsis the next post picked up by dev.to and (a cycle later, once metal-and-skins's foojay+medium+dzone entries are also done) by Hashnode.https://hashnode.com/draft/67a41962e690bb4ecd9fd9b8with the right title, subheading (trimmed description), cover image (1024×512), body (leading cover-image markdown stripped), the five exact tag pills, and the correct canonical URL. Draft was reviewed and published manually.Notes for reviewers
HashnodeAdapterdocstring lists each step of the editor flow and the one Hashnode product-design quirk: the "Write" button always lands on a single per-user draft slot, so consecutive syndication runs reuse the same/draft/<id>URL and overwrite the previous one. The maintainer needs to publish or delete each Hashnode draft before the next weekly cron, otherwise the next post will clobber it. Not something we can fix from the UI side.opensource-inactiveahead ofopensource), so the tag-add loop prefers the exact-match dropdown item when present and falls back toEscape-then-Enter(which commits the literal typed value as a new tag) with a per-tag retry that verifies the pill count actually increased.🤖 Generated with Claude Code