Skip to content

Move Hashnode syndication off the deprecated GraphQL API#4956

Merged
shai-almog merged 2 commits into
masterfrom
hashnode-browser-syndication
May 16, 2026
Merged

Move Hashnode syndication off the deprecated GraphQL API#4956
shai-almog merged 2 commits into
masterfrom
hashnode-browser-syndication

Conversation

@shai-almog
Copy link
Copy Markdown
Collaborator

Summary

  • 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 every blog-syndication cron has been failing on the Hashnode step with Expecting value: line 1 column 1 (char 0) (the JSON parser choking on the HTML redirect to the announcement page).
  • Drive the Hashnode web editor instead, from a saved Playwright storageState, as a new HashnodeAdapter inside the existing browser-syndication script. The adapter handles cover image upload (via expect_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.
  • Hashnode is now part of syndicate_browser_posts.py's default platforms (alongside foojay). CI does not hold HASHNODE_STORAGE_STATE as 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 the publish_to_hashnode GraphQL mutation and related constants. The API syndicator now only targets dev.to.
  • scripts/website/syndicate_browser_posts.py — add HashnodeAdapter (~280 LOC) and a _download_to_temp helper 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 the hashnode-session cookie so --site hashnode --from-firefox-profile extracts a working session.
  • scripts/website/queue_browser_syndication.py — drop hashnode from DEFAULT_PLATFORMS; the queue is now back to medium,dzone (Hashnode is driven inline by the browser script).
  • .github/workflows/blog-syndication.yml — drop the HASHNODE_TOKEN / HASHNODE_PUBLICATION_ID env vars from the API step and rename the queue step accordingly.
  • scripts/website/syndication-state.json — record the metal-and-skins entries 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 up skills-java17-and-theme-accents once it crosses the 7-day age threshold.

Test plan

  • python3 scripts/website/syndicate_blog_posts.py --dry-run (today): reports No syndication candidate found today — correct; metal-and-skins is recorded for dev.to.
  • python3 scripts/website/syndicate_browser_posts.py --dry-run (today): selects metal-and-skins for foojay (the still-unsyndicated platform on that post) and reports hashnode: already syndicated; skipping.
  • python3 scripts/website/queue_browser_syndication.py --dry-run (today): queues medium:metal-and-skins + dzone:metal-and-skins (no Hashnode entry — Hashnode no longer routes through the queue).
  • Simulated --today 2026-05-22 to confirm skills-java17-and-theme-accents is 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.
  • End-to-end Hashnode run against the live editor with a fresh Firefox-derived storageState: created https://hashnode.com/draft/67a41962e690bb4ecd9fd9b8 with 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

  • The HashnodeAdapter docstring 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.
  • Hashnode's tag autocomplete sometimes ranks variants ahead of the canonical tag (opensource-inactive ahead of opensource), so the tag-add loop prefers the exact-match dropdown item when present and falls back to Escape-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

shai-almog and others added 2 commits May 16, 2026 06:10
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>
@shai-almog
Copy link
Copy Markdown
Collaborator Author

Follow-up: auto-publish the draft instead of leaving it for manual review.

HashnodeAdapter.submit_draft now clicks the in-dialog Publish button after tags and canonical URL are set, waits for Hashnode to navigate away from /draft/<id> to the live article URL on the publication domain, and records that URL in syndication-state.json. If anything in the dialog flow fails the adapter falls back to Close-as-draft so the editorial work isn't lost.

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 /draft/<id> instead of overwriting the previous syndication.

Selectors validated against the live editor; the actual end-to-end publish round-trip will fire on the next eligible cron (when skills-java17-and-theme-accents crosses the 7-day age threshold on 2026-05-22).

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 16, 2026

✅ Continuous Quality Report

Test & Coverage

Static Analysis

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

Generated automatically by the PR CI workflow.

@github-actions
Copy link
Copy Markdown
Contributor

Cloudflare Preview

@shai-almog shai-almog merged commit 7ee2b98 into master May 16, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant