Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/blog-syndication.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ jobs:
id: syndicate_api
env:
DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
HASHNODE_TOKEN: ${{ secrets.HASHNODE_TOKEN }}
HASHNODE_PUBLICATION_ID: ${{ secrets.HASHNODE_PUBLICATION_ID }}
run: |
set -euo pipefail
if [ "${{ inputs.dry_run }}" = "true" ]; then
Expand Down
16 changes: 15 additions & 1 deletion scripts/website/export_storage_state.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
"""Export a logged-in browser session for syndication targets that block
password-based automation (Medium has no password login at all; DZone
gates its login form behind invisible reCAPTCHA).
gates its login form behind invisible reCAPTCHA; Hashnode shut down
their free public GraphQL API in May 2026 and the only remaining path
is the web editor).

Two paths:

Expand All @@ -18,6 +20,7 @@

python3 scripts/website/export_storage_state.py --site medium --from-firefox-profile
python3 scripts/website/export_storage_state.py --site dzone --browser firefox
python3 scripts/website/export_storage_state.py --site hashnode --from-firefox-profile
"""

from __future__ import annotations
Expand Down Expand Up @@ -63,6 +66,17 @@
for c in cookies
),
},
"hashnode": {
"signin_url": "https://hashnode.com/login",
"cookie_host_glob": "%hashnode.com",
# Hashnode's web app authenticates the session with a single
# `hashnode-session` cookie on hashnode.com. Its presence (and
# non-trivial length) signals a signed-in session.
"is_logged_in": lambda cookies: any(
c.get("name") == "hashnode-session" and len(c.get("value") or "") > 32
for c in cookies
),
},
}


Expand Down
25 changes: 16 additions & 9 deletions scripts/website/queue_browser_syndication.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,29 @@
"""Append browser-only syndication tasks to syndication-queue.json.

This script is the bridge between the daily CI cron (which knows which
posts are eligible for syndication) and the Codename One Syndicator
Firefox extension (which runs inside the user's logged-in Firefox to
drive Medium/DZone editors past Cloudflare).
posts are eligible for syndication) and a local syndication tool that
runs on the maintainer's machine and drives Medium / DZone / Hashnode
editors from inside a signed-in browser session.

Daily flow:

1. CI runs the API syndicator (foojay, dev.to, hashnode) directly.
1. CI runs the API syndicator (dev.to) and the Playwright syndicator
(foojay) directly.
2. CI runs *this* script for `medium,dzone` (or whatever browser
platforms are configured) — it appends a task entry to
syndication-queue.json for every eligible post that does not
already have an entry in syndication-state.json for that platform.
3. The committed queue file is what the extension polls. When the
user's Firefox is online, the extension processes pending tasks.
4. The extension's popup surfaces a JSON patch the user can paste into
syndication-state.json (or a small local script can ingest the
extension's results).
3. The committed queue file is what the local tool reads. When it
runs on the maintainer's machine it processes pending tasks
against the already-signed-in browser session.
4. The local tool writes the resulting URLs back into
syndication-state.json.

Hashnode is *not* queued here: their free public GraphQL API shut down
on 2026-05-13, but Hashnode's web editor is reachable from
``syndicate_browser_posts.py`` directly via a saved Playwright
storageState, so it is driven inline by that script (HashnodeAdapter)
instead of going through this queue.

Tasks are deduplicated by id (`<platform>:<slug>`).
"""
Expand Down
80 changes: 8 additions & 72 deletions scripts/website/syndicate_blog_posts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Syndicate Codename One Hugo blog posts to dev.to and Hashnode.
"""Syndicate Codename One Hugo blog posts to dev.to.

Selects the oldest blog post under ``docs/website/content/blog`` that:

Expand All @@ -12,6 +12,12 @@
``www.codenameone.com`` and records the resulting URL / id in
``scripts/website/syndication-state.json``.

Hashnode used to be driven from here too, but Hashnode shut down their
free public GraphQL API on 2026-05-13 and moved it behind a paid /
allow-listed offering. Hashnode is now syndicated via the Firefox
extension queue alongside Medium and DZone — see
``queue_browser_syndication.py``.

Designed to run from a daily GitHub Action with only the Python standard
library available.
"""
Expand Down Expand Up @@ -53,14 +59,8 @@
_HUGO_SHORTCODE_RE = re.compile(r"\{\{<[^>]*>\}\}|\{\{%[^%]*%\}\}")

DEVTO_TAGS = ["java", "mobile", "android", "ios"]
HASHNODE_TAGS = [
{"slug": "java", "name": "Java"},
{"slug": "mobile", "name": "Mobile"},
{"slug": "android", "name": "Android"},
{"slug": "ios", "name": "iOS"},
]

DEFAULT_PLATFORMS = "devto,hashnode"
DEFAULT_PLATFORMS = "devto"


@dataclass
Expand Down Expand Up @@ -325,60 +325,6 @@ def publish_to_devto(post: Post, body_markdown: str, api_key: str, draft: bool =
}


def publish_to_hashnode(post: Post, body_markdown: str, token: str, publication_id: str,
draft: bool = False) -> dict[str, Any]:
if draft:
mutation = """
mutation CreateDraft($input: CreateDraftInput!) {
createDraft(input: $input) {
draft { id slug }
}
}
""".strip()
else:
mutation = """
mutation PublishPost($input: PublishPostInput!) {
publishPost(input: $input) {
post { id slug url }
}
}
""".strip()

input_obj: dict[str, Any] = {
"title": post.title,
"contentMarkdown": body_markdown,
"publicationId": publication_id,
"tags": HASHNODE_TAGS,
"originalArticleURL": post.canonical_url,
}
cover = post.cover_image
if cover:
input_obj["coverImageOptions"] = {"coverImageURL": cover}
subtitle = str(post.front_matter.get("description") or "").strip()
if subtitle:
input_obj["subtitle"] = subtitle[:250]

response = http_post_json(
"https://gql.hashnode.com",
headers={"Authorization": token},
payload={"query": mutation, "variables": {"input": input_obj}},
)
if response.get("errors"):
raise RuntimeError(f"hashnode GraphQL errors: {response['errors']}")
data = response.get("data") or {}
if draft:
node = data.get("createDraft", {}).get("draft", {})
url = f"https://hashnode.com/draft/{node.get('id')}" if node.get("id") else None
else:
node = data.get("publishPost", {}).get("post", {})
url = node.get("url")
return {
"id": node.get("id"),
"url": url,
"syndicated_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
}


def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="Do not call any APIs; print what would happen.")
Expand Down Expand Up @@ -421,8 +367,6 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
def is_platform_configured(platform: str) -> bool:
if platform == "devto":
return bool(os.environ.get("DEVTO_API_KEY"))
if platform == "hashnode":
return bool(os.environ.get("HASHNODE_TOKEN") and os.environ.get("HASHNODE_PUBLICATION_ID"))
return False


Expand Down Expand Up @@ -478,14 +422,6 @@ def main(argv: list[str]) -> int:
candidate, body_markdown, os.environ["DEVTO_API_KEY"],
draft=args.draft_mode,
)
elif platform == "hashnode":
result = publish_to_hashnode(
candidate,
body_markdown,
os.environ["HASHNODE_TOKEN"],
os.environ["HASHNODE_PUBLICATION_ID"],
draft=args.draft_mode,
)
else:
raise RuntimeError(f"unknown platform: {platform}")
except Exception as err: # noqa: BLE001 — surface any failure as per-platform
Expand Down
Loading
Loading