diff --git a/.github/workflows/blog-syndication.yml b/.github/workflows/blog-syndication.yml index dee7470bb5..56969a2ed9 100644 --- a/.github/workflows/blog-syndication.yml +++ b/.github/workflows/blog-syndication.yml @@ -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 diff --git a/scripts/website/export_storage_state.py b/scripts/website/export_storage_state.py index 9f0500dce4..bf89ab23b5 100755 --- a/scripts/website/export_storage_state.py +++ b/scripts/website/export_storage_state.py @@ -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: @@ -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 @@ -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 + ), + }, } diff --git a/scripts/website/queue_browser_syndication.py b/scripts/website/queue_browser_syndication.py index 6c15018c59..985028cdcf 100644 --- a/scripts/website/queue_browser_syndication.py +++ b/scripts/website/queue_browser_syndication.py @@ -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 (`:`). """ diff --git a/scripts/website/syndicate_blog_posts.py b/scripts/website/syndicate_blog_posts.py index 01f6303c7e..0997068a08 100755 --- a/scripts/website/syndicate_blog_posts.py +++ b/scripts/website/syndicate_blog_posts.py @@ -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: @@ -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. """ @@ -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 @@ -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.") @@ -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 @@ -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 diff --git a/scripts/website/syndicate_browser_posts.py b/scripts/website/syndicate_browser_posts.py index fa39757d30..c06511af73 100755 --- a/scripts/website/syndicate_browser_posts.py +++ b/scripts/website/syndicate_browser_posts.py @@ -25,12 +25,22 @@ are missing, just like the API script): foojay : FOOJAY_USER, FOOJAY_PASSWORD + hashnode : HASHNODE_STORAGE_STATE (base64-encoded Playwright + storageState JSON — produce with + ``scripts/website/export_storage_state.py --site hashnode``) DZone and Medium are NOT driven from this Playwright script — both sit behind aggressive Cloudflare bot detection that cannot be bypassed -reliably from headless automation. They are queued to the Codename One -Syndicator Firefox extension instead, which runs inside the user's -already-trusted browser session. See scripts/syndication-extension/. +reliably from headless automation. They are queued by +``queue_browser_syndication.py`` to ``syndication-queue.json`` and +handled manually from an already-signed-in browser session. + +Hashnode used to be driven from the API syndicator (gql.hashnode.com +GraphQL) but Hashnode shut down free public GraphQL access on 2026-05-13 +and moved it behind a paid / allow-listed offering, so we drive its +web editor here from a signed-in storage state instead. The Hashnode +adapter is intended to be run locally, not from CI — CI does not hold +the storage-state secret. HackerNoon was previously supported here but removed: HackerNoon charges business sites for canonical URL support, which makes it @@ -45,7 +55,10 @@ import datetime as dt import json import os +import re import sys +import tempfile +import urllib.request from dataclasses import dataclass from pathlib import Path from typing import Any, Callable @@ -66,11 +79,17 @@ SCREENSHOT_DIR = Path(__file__).resolve().parents[2] / "docs" / "website" / "reports" / "syndication-screenshots" -# DZone and Medium are no longer driven from this Playwright script — both -# are gated by Cloudflare bot detection that headless browsers cannot pass -# reliably. Their syndication is queued to the Codename One Syndicator -# Firefox extension via scripts/website/queue_browser_syndication.py. -DEFAULT_PLATFORMS = "foojay" +# DZone and Medium are not driven from this Playwright script — both are +# gated by Cloudflare bot detection that headless browsers cannot pass +# reliably. Their syndication is queued to syndication-queue.json via +# scripts/website/queue_browser_syndication.py and handled manually from +# an already-signed-in browser session. +# +# Hashnode IS driven from here (HashnodeAdapter below) using a saved +# storage state — see the module docstring. CI does not hold the +# HASHNODE_STORAGE_STATE secret so the platform is skipped automatically +# in cron runs; the maintainer runs the script locally to drive it. +DEFAULT_PLATFORMS = "foojay,hashnode" _UA_STR = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0) AppleWebKit/537.36 " @@ -131,6 +150,22 @@ def _load_base64_storage_state(env_var: str) -> Path: return path +def _download_to_temp(url: str) -> Path: + """Download ``url`` into a temp file, preserving the URL's basename + so the upload target sees a friendly filename.""" + basename = url.rsplit("/", 1)[-1].split("?", 1)[0] or "cover" + suffix = "." + basename.rsplit(".", 1)[-1] if "." in basename else "" + request = urllib.request.Request(url, headers={"User-Agent": _UA_STR}) + with urllib.request.urlopen(request, timeout=60) as response: + data = response.read() + fd, path = tempfile.mkstemp(prefix="cn1-syndic-", suffix=suffix) + try: + os.write(fd, data) + finally: + os.close(fd) + return Path(path) + + def _save_screenshot(page, slug: str, label: str) -> Path: SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") @@ -423,8 +458,490 @@ def _rest_post(self, url: str, cookie_header: str, nonce: str, return json.loads(raw) if raw else {} +class HashnodeAdapter: + """Hashnode — Playwright + storage-state auth. + + Hashnode shut down free public GraphQL API access on 2026-05-13 and + moved it behind a paid / allow-listed offering, so we drive the web + editor directly from a signed-in browser session. Auth is via + ``HASHNODE_STORAGE_STATE`` (base64-encoded Playwright storageState + JSON); produce one with:: + + python3 scripts/website/export_storage_state.py --site hashnode \\ + --from-firefox-profile + + Flow (verified against the 2026-05 Hashnode UI): + + 1. Land on the dashboard and click the "Write" button — Hashnode + opens its current empty draft slot and redirects to + /draft/. (Once a draft is published the slot is freed and + the next click creates a fresh draft, so weekly runs no + longer overwrite each other.) + 2. Fill the title textarea (placeholder "Article Title...") and + paste the markdown body into the contenteditable editor. The + leading header-image markdown line is stripped from the body + because it lives separately as the "Cover" image (see step 3). + 3. Click the "Cover" header button to open the cover popover, + upload the post's cover image into the file input + (input[type='file'][accept='image/*']), and wait for the + upload to finish. + 4. Click the "Subheading" header button to reveal the subheading + textarea, then fill it with the post's description (trimmed). + 5. Open the publish dialog (the top "Publish" button in the + header bar — there is a second "Publish" button inside the + dialog that actually publishes; the dialog one is what we + click in step 7). + 6. Switch to the Discovery tab, clear any pre-existing tag pills, + add the five canonical tags (java, mobile-development, ios, + android, opensource), toggle the "Add a canonical URL" switch + on if needed, and fill the canonical URL pointing back at + www.codenameone.com. + 7. Click the in-dialog Publish button. Hashnode publishes the + post and redirects to the live article URL on the user's + publication domain (e.g. https://debugagent.com/); + that URL is what gets recorded in syndication-state.json. + + Falls back to Close-as-draft if any step in 6–7 fails so the + editorial work isn't lost — the caller can then publish manually + from the editor UI. + """ + + name = "hashnode" + # /create/story 404s on the modern Hashnode UI; the "Write" button on + # the dashboard is the only entry point that creates a fresh draft + # bound to the user's primary publication. + DASHBOARD_URL = "https://hashnode.com/" + + TITLE_SELECTOR = "textarea[placeholder='Article Title...']" + BODY_SELECTOR = "div[contenteditable='true']" + # Cover popover trigger has two captions: "Cover" when no cover is + # set, "Change cover" once one is uploaded. Both buttons share + # data-slot='popover-trigger'. + COVER_BUTTON_SELECTORS = [ + "button[data-slot='popover-trigger']:has(span:text-is('Cover'))", + "button[data-slot='popover-trigger']:has-text('Change cover')", + ] + COVER_UPLOAD_IMAGE_BUTTON_SELECTOR = "button:has-text('Upload Image')" + SUBHEADING_BUTTON_SELECTOR = "button:has(span:text-is('Subheading'))" + SUBHEADING_TEXTAREA_SELECTOR = "textarea[placeholder='Add a subheading']" + DISCOVERY_TAB_SELECTOR = "button[role='tab']:has-text('Discovery')" + TAGS_INPUT_SELECTOR = "input#editor-tags" + CANONICAL_TOGGLE_SELECTOR = "label:has-text('Add a canonical URL')" + CANONICAL_INPUT_SELECTOR = "input[placeholder='https://example.com/original-article']" + CLOSE_DIALOG_SELECTOR = "button:has-text('Close')" + # The in-dialog Publish button (distinct from the top-bar Publish + # which just opens the dialog) lives inside the open Radix dialog + # alongside "Submit for Review" and "Close". + DIALOG_PUBLISH_SELECTOR = "[role='dialog'][data-state='open'] button:text-is('Publish')" + + # The five tags every Codename One syndicated post carries on + # Hashnode. All five exist as canonical Hashnode tags so a + # type-the-name + Enter sequence resolves to the right pill. + TAGS = ["java", "mobile-development", "ios", "android", "opensource"] + + # Max subheading length. Hashnode's textarea does not visibly + # enforce a limit, but long subheadings clip in card previews and + # social shares. + SUBHEADING_MAX = 250 + + # Strips the first "header image" paragraph from the rendered body + # so the cover image isn't duplicated (once as the Cover, once + # inline at the top of the article). + _LEADING_COVER_IMAGE_RE = re.compile( + r"\A\s*!\[[^\]]*\]\([^)\s]+\)\s*\n?", + re.MULTILINE, + ) + + # Stable copy of the JS used to pick the top-bar "Publish" button (the + # publish dialog also contains a "Publish" button — clicking that + # one would actually publish, which we never want). + _CLICK_TOP_PUBLISH_JS = """ + () => { + const btns = Array.from(document.querySelectorAll('button')) + .filter(b => b.innerText && b.innerText.trim() === 'Publish'); + btns.sort((a,b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); + if (btns[0]) btns[0].click(); + } + """ + + @staticmethod + def is_configured() -> bool: + return bool(os.environ.get("HASHNODE_STORAGE_STATE")) + + @staticmethod + def storage_state_path() -> Path: + return _load_base64_storage_state("HASHNODE_STORAGE_STATE") + + def login(self, page) -> None: + # No-op: storage state was loaded into the browser context already. + return + + def submit_draft(self, page, ctx: AdapterContext) -> dict[str, Any]: + mod = "Meta" if sys.platform == "darwin" else "Control" + + page.goto(self.DASHBOARD_URL, wait_until="domcontentloaded", timeout=45000) + page.wait_for_timeout(4000) + + # "Write" opens the user's current draft slot and redirects to + # /draft/. If the dashboard already auto-redirected us + # there (Hashnode does this when an in-progress draft is open + # in another tab), skip the click — wait_for_url would never + # fire since the URL never changes. + if "/draft/" not in page.url: + page.locator("button:has-text('Write')").first.click() + page.wait_for_url("**/draft/*", timeout=30000) + draft_url = page.url + # Let the editor hydrate before typing into it — there are two + # contenteditables to disambiguate (article body vs Hashnode AI + # textarea) and the title field is also lazy-mounted. + page.wait_for_timeout(8000) + + # --- Title + body --- + title_field = page.locator(self.TITLE_SELECTOR).first + title_field.wait_for(state="visible", timeout=20000) + title_field.click() + title_field.fill(ctx.post.title) + + body_editor = page.locator(self.BODY_SELECTOR).first + body_editor.click() + # Hashnode reuses the same auto-saved empty draft across + # consecutive "Write" clicks, so on the second run the body + # already has whatever we typed last time. Playwright's + # locator.fill() does not clear a contenteditable, so we + # manually select-all + delete before pasting. + page.keyboard.press(f"{mod}+A") + page.keyboard.press("Delete") + page.wait_for_timeout(500) + # Strip the leading cover-image markdown — Hashnode hosts the + # cover image via the "Cover" button (see _set_cover_image), so + # leaving it inline at the top of the body would render it + # twice. + body_for_paste = self._LEADING_COVER_IMAGE_RE.sub("", ctx.body_markdown, count=1) + # Hashnode's editor accepts markdown pasted into the body — it + # tokenizes headings, code fences, links, images, etc. on paste. + # Clipboard paste is the most robust way to insert a large body + # without triggering per-keystroke autocomplete or slash-menus. + page.evaluate("text => navigator.clipboard.writeText(text)", body_for_paste) + page.keyboard.press(f"{mod}+V") + # Hashnode autosaves a few seconds after typing stops. + page.wait_for_timeout(5000) + + # --- Cover image --- + cover_set = self._set_cover_image(page, ctx) + + # --- Subheading --- + subheading_set = self._set_subheading(page, ctx) + + if ctx.validate_only: + shot = _save_screenshot(page, ctx.post.slug, "hashnode-editor") + return { + "validated": True, + "screenshot": str(shot), + "draft_url": draft_url, + "cover_set": cover_set, + "subheading_set": subheading_set, + } + + # --- Publish-dialog Discovery tab: tags + canonical URL, then publish --- + canonical_set = False + tags_set = False + published_url: str | None = None + try: + self._open_publish_dialog_discovery(page) + tags_set = self._set_tags(page, mod) + canonical_set = self._set_canonical_url(page, ctx.post.canonical_url, mod) + published_url = self._publish_from_dialog(page, draft_url) + except Exception as err: # noqa: BLE001 — surface as non-fatal + print(f" [hashnode] publish-dialog flow failed (non-fatal): {err}", + file=sys.stderr) + # If publishing failed, leave the post as a draft so the + # editorial work isn't lost. Hashnode autosaves drafts on Close. + if published_url is None: + try: + page.locator(self.CLOSE_DIALOG_SELECTOR).first.click(timeout=8000) + page.wait_for_timeout(3000) + except Exception: # noqa: BLE001 + pass + + return { + "url": published_url or draft_url, + "published": published_url is not None, + "draft_url": draft_url, + "cover_set": cover_set, + "subheading_set": subheading_set, + "tags_set": tags_set, + "canonical_set": canonical_set, + "syndicated_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), + } + + # ------------------------------------------------------------------ # + # Step helpers — each returns True on success and False on a + # recoverable failure so the overall flow can keep going. + # ------------------------------------------------------------------ # + + def _set_cover_image(self, page, ctx: AdapterContext) -> bool: + cover_url = ctx.post.cover_image + if not cover_url: + return False + try: + tmp_path = _download_to_temp(cover_url) + except Exception as err: # noqa: BLE001 — cover is best-effort + print(f" [hashnode] cover image download failed (non-fatal): {err}", + file=sys.stderr) + return False + try: + # Try both empty-state ("Cover") and set-state ("Change + # cover") selectors so re-runs on a draft with an existing + # cover still resolve the popover trigger. + cover_btn = _find_first(page, self.COVER_BUTTON_SELECTORS, timeout=15000) + cover_btn.click() + page.wait_for_timeout(2000) + # Hashnode wraps the in a custom + # uploader: Playwright's set_input_files dispatches the + # change event but the wrapper ignores it. Driving the + # native file chooser through expect_file_chooser is the + # only reliable way to upload. + with page.expect_file_chooser(timeout=10000) as fc_info: + page.locator(self.COVER_UPLOAD_IMAGE_BUTTON_SELECTOR).first.click() + fc_info.value.set_files(str(tmp_path)) + # Upload + thumbnail render takes ~3-6 seconds on a small + # JPEG and the popover closes itself on success. + page.wait_for_timeout(8000) + return True + except Exception as err: # noqa: BLE001 + print(f" [hashnode] cover image upload failed (non-fatal): {err}", + file=sys.stderr) + return False + finally: + try: + tmp_path.unlink(missing_ok=True) + except Exception: # noqa: BLE001 + pass + + def _set_subheading(self, page, ctx: AdapterContext) -> bool: + subheading = str(ctx.post.front_matter.get("description") or "").strip() + if not subheading: + return False + if len(subheading) > self.SUBHEADING_MAX: + subheading = subheading[: self.SUBHEADING_MAX].rsplit(" ", 1)[0].rstrip(",.;:") + "…" + try: + # The Subheading button only appears when the subheading + # textarea is not already shown. If it's missing, the + # textarea is already there from a prior run on this draft. + if page.locator(self.SUBHEADING_TEXTAREA_SELECTOR).count() == 0: + page.locator(self.SUBHEADING_BUTTON_SELECTOR).first.click(timeout=8000) + page.wait_for_timeout(1500) + field = page.locator(self.SUBHEADING_TEXTAREA_SELECTOR).first + field.click() + # Same fill-vs-React quirk as the canonical URL field. + field.press("Control+A" if sys.platform != "darwin" else "Meta+A") + page.keyboard.press("Delete") + page.keyboard.type(subheading, delay=5) + return True + except Exception as err: # noqa: BLE001 + print(f" [hashnode] subheading fill failed (non-fatal): {err}", + file=sys.stderr) + return False + + def _open_publish_dialog_discovery(self, page) -> None: + """Open the publish dialog and switch to the Discovery tab. + + The top-bar Publish click is intermittently swallowed when the + editor hasn't fully hydrated, so retry until the Discovery tab + is reachable. + """ + for _ in range(4): + page.evaluate(self._CLICK_TOP_PUBLISH_JS) + page.wait_for_timeout(3000) + if page.locator(self.DISCOVERY_TAB_SELECTOR).count() > 0: + break + page.wait_for_timeout(2000) + page.locator(self.DISCOVERY_TAB_SELECTOR).first.click(timeout=15000) + page.wait_for_timeout(2000) + + def _set_tags(self, page, mod: str) -> bool: + try: + tags_input = page.locator(self.TAGS_INPUT_SELECTOR).first + tags_input.wait_for(state="visible", timeout=10000) + # Existing tags render as buttons in a row sibling to the + # input. Clicking a pill removes it. Synthetic .click() via + # JS is ignored — Hashnode listens for native pointer + # events — so we use Playwright's real click and loop on + # the live count. + pill_row = tags_input.locator("xpath=ancestor::div[contains(@class,'space-y-3')][1]") + for _ in range(20): # hard cap so we never loop forever + pills = pill_row.locator("button:has-text('#')") + count = pills.count() + if count == 0: + break + pills.first.click() + page.wait_for_timeout(400) + # Add the canonical tag set. Typing alone + Enter triggers + # Hashnode's autocomplete and frequently commits a + # "fuzzy-match" tag (e.g. "java" → "javascript", + # "opensource" → "opensource-inactive"). Strategy: + # + # 1. Type the full tag name. + # 2. Click the exact-match dropdown suggestion if one is + # present (its first line is "#" followed by a + # post-count line). + # 3. If no exact-match suggestion is available, press + # Escape to dismiss the autocomplete dropdown, then + # Enter to commit the literal typed value. Escape + # before Enter prevents Enter from picking the + # currently-highlighted fuzzy suggestion. + # + # After each iteration verify the target pill landed; if + # not, retry once more. + for tag in self.TAGS: + pills_before = self._pill_count(pill_row) + for attempt in range(2): + tags_input.click() + page.keyboard.press(f"{mod}+A") + page.keyboard.press("Delete") + page.keyboard.type(tag, delay=15) + page.wait_for_timeout(1200) + if not self._click_exact_tag_suggestion(page, tag): + page.keyboard.press("Escape") + page.wait_for_timeout(200) + # Escape moves focus off the input on some + # builds; re-focus, restore the typed value, + # and commit with Enter so Hashnode treats + # this as a "create new tag" rather than + # selecting an autocomplete option. + tags_input.click() + if tags_input.input_value() != tag: + page.keyboard.press(f"{mod}+A") + page.keyboard.press("Delete") + page.keyboard.type(tag, delay=15) + page.wait_for_timeout(300) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + tags_input.click() + page.keyboard.press("Enter") + page.wait_for_timeout(1200) + if self._pill_count(pill_row) > pills_before: + break + print(f" [hashnode] tag '{tag}' did not land (attempt {attempt + 1}); retrying", + file=sys.stderr) + else: + print(f" [hashnode] tag '{tag}' could not be added after retry; skipping", + file=sys.stderr) + return True + except Exception as err: # noqa: BLE001 + print(f" [hashnode] tag set failed (non-fatal): {err}", + file=sys.stderr) + return False + + @staticmethod + def _pill_count(pill_row) -> int: + return pill_row.locator("button:has-text('#')").count() + + @staticmethod + def _click_exact_tag_suggestion(page, tag: str) -> bool: + """Click the dropdown suggestion whose first line is exactly ``#``. + + Suggestions render as ``button.flex.w-full.items-start`` and + their innerText is two lines: ``#\\n posts``. + """ + clicked = page.evaluate( + """ + (tag) => { + const wanted = '#' + tag; + const buttons = Array.from(document.querySelectorAll( + "button.flex.w-full.items-start" + )); + const match = buttons.find(b => { + const r = b.getBoundingClientRect(); + if (r.width === 0) return false; + const firstLine = (b.innerText || '').split('\\n', 1)[0].trim(); + return firstLine === wanted; + }); + if (!match) return null; + const r = match.getBoundingClientRect(); + return {x: r.left + r.width / 2, y: r.top + r.height / 2}; + } + """, + tag, + ) + if not clicked: + return False + # Use a real mouse click — Hashnode's React handlers ignore + # synthetic button.click() in the same way the pills do. + page.mouse.click(clicked["x"], clicked["y"]) + return True + + def _publish_from_dialog(self, page, draft_url: str) -> str | None: + """Click the in-dialog Publish button and wait for the redirect + to the live article URL. Returns the published URL on success + or None on failure (the caller falls back to a Close-as-draft + flow so the editorial work isn't lost). + """ + try: + publish_btn = page.locator(self.DIALOG_PUBLISH_SELECTOR).first + publish_btn.wait_for(state="visible", timeout=8000) + publish_btn.click() + except Exception as err: # noqa: BLE001 + print(f" [hashnode] in-dialog Publish click failed (non-fatal): {err}", + file=sys.stderr) + return None + # Hashnode publishes asynchronously: the dialog closes, the + # backend renders the post, and the page navigates to the + # public URL (e.g. https://debugagent.com/). Wait for + # the URL to leave /draft/ — but cap the wait so a failed + # publish (e.g. validation error inside the dialog) doesn't + # block forever. + try: + page.wait_for_url( + lambda url: bool(url) and "/draft/" not in url and url != draft_url, + timeout=60000, + ) + except Exception as err: # noqa: BLE001 + print(f" [hashnode] publish did not navigate within 60s (non-fatal): {err}", + file=sys.stderr) + return None + # The first post-publish URL is sometimes an intermediate + # /publishing/... route while the article finishes building. + # Let the final URL settle. + page.wait_for_timeout(3000) + published_url = page.url + if "/draft/" in published_url or published_url == draft_url: + return None + return published_url + + def _set_canonical_url(self, page, canonical_url: str, mod: str) -> bool: + try: + # "Add a canonical URL" is a Radix switch label that + # reveals the input below it. Only click if the input is + # not already visible (clicking a second time would toggle + # it back off). + if page.locator(self.CANONICAL_INPUT_SELECTOR).count() == 0: + page.locator(self.CANONICAL_TOGGLE_SELECTOR).first.click(timeout=8000) + page.wait_for_timeout(1500) + canonical_input = page.locator(self.CANONICAL_INPUT_SELECTOR).first + # Locator.fill() programmatically replaces the value, but + # Hashnode's React form does not re-render from the + # resulting input event when a prior value (from an + # earlier syndication) is already in the field. Select-all + # + type generates real key events so React picks up the + # change. + canonical_input.click() + page.keyboard.press(f"{mod}+A") + page.keyboard.press("Delete") + page.wait_for_timeout(300) + page.keyboard.type(canonical_url, delay=5) + canonical_input.press("Tab") # blur to flush onBlur handlers + page.wait_for_timeout(2000) + return True + except Exception as err: # noqa: BLE001 + print(f" [hashnode] canonical URL set failed (non-fatal): {err}", + file=sys.stderr) + return False + + ADAPTERS: dict[str, Callable[[], Any]] = { "foojay": FoojayAdapter, + "hashnode": HashnodeAdapter, } @@ -443,6 +960,19 @@ def run_adapter(adapter, post: Post, body_markdown: str, headed: bool, validate_ "viewport": {"width": 1400, "height": 900}, "user_agent": _UA_STR, } + # Adapters that authenticate via a saved Playwright storageState + # (Hashnode, and historically Medium/DZone) expose a + # `storage_state_path()` classmethod that returns a path to the + # decoded JSON. FoojayAdapter logs in with user+password and does + # not define it. + storage_state_method = getattr(adapter, "storage_state_path", None) + if callable(storage_state_method): + try: + context_kwargs["storage_state"] = str(storage_state_method()) + except KeyError as err: + raise AdapterError( + f"{adapter.name} needs a storage-state env var: {err}" + ) from err context = browser.new_context(**context_kwargs) # Grant clipboard access so navigator.clipboard.writeText() succeeds. try: diff --git a/scripts/website/syndication-queue.json b/scripts/website/syndication-queue.json index a88ee7c122..8dc21808e4 100644 --- a/scripts/website/syndication-queue.json +++ b/scripts/website/syndication-queue.json @@ -1,5 +1,5 @@ { - "_comment": "Queue of browser-only syndication tasks consumed by the Codename One Syndicator Firefox extension. The extension polls https://raw.githubusercontent.com/codenameone/CodenameOne/master/scripts/website/syndication-queue.json and processes any task whose id is not already in the user's local 'completed_tasks' list. Tasks are appended by scripts/website/queue_browser_syndication.py which is invoked by the daily blog-syndication workflow.", + "_comment": "Queue of browser-only syndication tasks (Medium, DZone, Hashnode) drained by a local syndication tool that runs on the maintainer's machine inside an already-signed-in browser session. Tasks are appended by scripts/website/queue_browser_syndication.py which is invoked by the daily blog-syndication workflow, and the local tool writes the resulting URLs back into syndication-state.json.", "tasks": [ { "id": "medium:liquid-glass-material-3-modern-native-themes", diff --git a/scripts/website/syndication-state.json b/scripts/website/syndication-state.json index c3f0c6fbb0..a61cd35d9b 100644 --- a/scripts/website/syndication-state.json +++ b/scripts/website/syndication-state.json @@ -28,6 +28,21 @@ "url": "https://dzone.com/content/3653548/edit.html", "syndicated_at": "2026-05-09T16:48:46+00:00" } + }, + "metal-and-skins": { + "devto": { + "id": 3678044, + "url": "https://dev.to/codenameone/metal-and-skins-4e00", + "syndicated_at": "2026-05-15T13:34:26+00:00" + }, + "hashnode": { + "url": "https://hashnode.com/draft/67a41962e690bb4ecd9fd9b8", + "cover_set": true, + "subheading_set": true, + "tags_set": true, + "canonical_set": true, + "syndicated_at": "2026-05-15T18:42:17+00:00" + } } } }