Skip to content

Commit 8783175

Browse files
shai-almogclaude
andauthored
Syndicate Hugo blog posts to dev.to and Hashnode (#4872)
* Syndicate Hugo blog posts to dev.to and Hashnode Daily GitHub Action that picks the oldest blog post under docs/website/content/blog dated after 2026-04-30, at least 7 days old, and not yet syndicated to a given platform. The script absolutizes relative links/images, inserts a one-sentence "What is Codename One" blurb after the fold, and POSTs to each platform with canonical_url pointing back to the original on www.codenameone.com. Per-platform state in scripts/website/syndication-state.json so partial failures retry only the failed side. Requires repo secrets: DEVTO_API_KEY, HASHNODE_TOKEN, HASHNODE_PUBLICATION_ID. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Syndicate blog posts to foojay.io as draft Adds foojay.io as a third syndication target. Unlike dev.to and Hashnode the foojay flow creates a WP draft via /wp-json/wp/v2/posts so the foojay editors can review before publishing. The canonical link is surfaced as a visible note at the top of the draft (rather than a meta field) so the reviewer can wire it up using whichever SEO plugin foojay runs. Side effects: - platforms with missing credentials are now skipped at startup with a note instead of failing the whole run, so adding a new platform later does not strand the candidate selector - requests now send a real User-Agent and Accept header (Cloudflare in front of foojay rejected the default Python-urllib UA with error 1010) - foojay credentials (FOOJAY_USER / FOOJAY_PASSWORD) wired through the workflow as optional secrets; the script auto-skips foojay until both are configured Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Set foojay canonical via Yoast meta instead of body note foojay runs Yoast SEO, so the canonical URL is now sent as meta._yoast_wpseo_canonical on the WP draft. Yoast registers that key as a REST-exposed post meta, so the standard /wp-json/wp/v2/posts payload carries it through. The visible "originally published" line at the top of the draft body is dropped — Yoast handles the SEO directive and the "What is Codename One" blurb still provides reader-facing attribution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Drop foojay syndication target foojay.io has Wordfence configured to disable WordPress Application Passwords, so there is no usable Basic Auth path for the WP REST API from the syndication script. Removing the foojay code path until / unless foojay editorial offers an alternative auth method (JWT, per-user API key, etc.). The User-Agent header and skip-when-unconfigured behaviour introduced alongside the foojay work are kept — they are useful for the remaining platforms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Browser-driven syndication for foojay, HackerNoon, DZone, Medium Adds scripts/website/syndicate_browser_posts.py — a Playwright-based counterpart to the API syndicator. Each target site has its own adapter (login + draft submission). State and post selection are shared with the API script via syndication-state.json, so a post is "candidate" until all configured platforms — API and browser — have a record. Adapters: - foojay: hybrid path. Playwright drives wp-login.php to obtain a real session (Wordfence has Application Passwords disabled, so token auth is out), then the script POSTs the draft via /wp-json/wp/v2/posts using the session cookies + X-WP-Nonce. Pure UI submission was attempted but Cloudflare in front of foojay challenges form POSTs and drops the payload, so drafts never landed. Yoast canonical isn't REST-writable on this Yoast install, so the canonical is surfaced as a visible note at the top of the draft body for the editor reviewer. Validated end- to-end against the live site (draft #123656). - hackernoon, dzone, medium: standard browser flow. Selectors are best-effort and need a one-time validation pass against each live site via --validate-only --headed. medium has no password login, so it relies on a base64-encoded MEDIUM_STORAGE_STATE secret exported from a manually logged-in browser session. Workflow additions: - Detects whether any browser-syndication secret is configured; only installs Playwright + Chromium when something will actually run. - Uploads the Playwright screenshot directory as a CI artifact on any outcome (kept for 14 days), so selector failures are debuggable. - Screenshots dir is gitignored. Per-platform secrets (all optional; missing = platform skipped): FOOJAY_USER, FOOJAY_PASSWORD HACKERNOON_USER, HACKERNOON_PASSWORD DZONE_USER, DZONE_PASSWORD MEDIUM_STORAGE_STATE Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add Medium storage-state export helper scripts/website/export_medium_storage.py captures a logged-in Medium session as a base64 blob suitable for the MEDIUM_STORAGE_STATE secret that the browser syndicator's MediumAdapter requires. Three modes: - --from-firefox-profile (no second login): reads cookies.sqlite from the user's existing Firefox profile and builds the storage state JSON directly. Auto-detects the most recently used profile under ~/Library/Application Support/Firefox/Profiles/. Refuses to write state if the profile is not actually logged in (uid cookie missing or prefixed with `lo_`). - --browser firefox|chrome|chromium|msedge: launches Playwright with the requested browser, opens medium.com/m/signin, and polls cookies every 3s until a non-`lo_` uid appears. 10-minute timeout default. - --interactive: same launch but waits on stdin instead of polling (useful when running attached to a real terminal). Output is written as JSON to --output and (unless --no-base64) printed as a base64 blob ready to paste as a repo secret. The local JSON file is gitignored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * DZone via stored session; HackerNoon React-typing fix Two structural changes to the browser syndicator: 1. DZone — switch from password-based login to a saved Playwright storageState loaded from a DZONE_STORAGE_STATE secret. DZone's AngularJS doLogin() requires a reCAPTCHA token (visible in scope.credentials.recaptchaToken) that Google's invisible reCAPTCHA does not issue to headless browsers; the auth request is never sent. Same approach as the existing MediumAdapter. 2. HackerNoon — replace .fill() with .press_sequentially() because the login inputs are React-controlled. .fill() set DOM .value but never updated React's internal state, so doLogin() ran with empty fields. With per-character typing the form actually submits; HackerNoon's "Invalid email or password" message now surfaces (instead of a silent no-op) when credentials don't match. Also fail-fast on a stuck-on-/login URL with the explicit error text. Helper script renamed export_medium_storage.py -> export_storage_state.py and generalized to support multiple sites via --site {medium,dzone}, with per-site cookie host filter and login detector. Browser-launch path picks Playwright Firefox when --browser firefox is requested. Workflow updated for the new DZONE_STORAGE_STATE secret name; gitignore generalized to exclude all *-storage-state.json scratch files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Wire up DZone storage-state path (content of prior commit) Previous commit only captured the file rename; this one carries the actual code changes for DZone (storage-state auth via DZONE_STORAGE_STATE), HackerNoon (React-friendly press_sequentially typing + fail-fast on stuck-on-/login), the multi-site export helper (--site, profiles for medium and dzone, Firefox cookie host filter), the workflow secret rename (DZONE_USER/PASSWORD -> DZONE_STORAGE_STATE), and the broader *-storage-state.json gitignore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * foojay draft polish: sidebar, categories, tags, featured image Three groups of changes based on review of foojay draft #123656: 1. Body rendering (applies to all syndication targets): - Strip the trailing Hugo "## Discussion" + giscus shortcode block so the syndicated copy ends at the actual article body. - Strip any remaining {{< shortcode >}} forms. - Replace the markdown-blockquote "What is Codename One?" with an HTML <aside> styled as a left-bordered callout so it reads as a sidebar instead of a quote. 2. FoojayAdapter post creation now sets: - categories=[1722] (Java) - a `codenameone` tag (created lazily via /wp/v2/tags if missing) - featured_media: downloads the post's cover image from www.codenameone.com and uploads it to /wp/v2/media, then assigns the returned id as the post's featured image - excerpt from the post's `description` front-matter - meta._yoast_wpseo_canonical / _title / _metadesc are sent in the payload as a best-effort; Yoast on foojay does not register these for REST writes, so they are silently dropped. The canonical URL is also kept as a hidden HTML comment at the top of the body so the editor reviewer can paste it into Yoast's metabox. 3. Refactor the WP REST plumbing into _rest_get / _rest_post helpers and centralise the User-Agent string used by both Playwright and urllib calls. Verified against draft #123658: categories, tags, featured_media, excerpt, sidebar, and footer-strip all confirmed via /wp/v2/posts/... ?context=edit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * foojay: Yoast canonical / SEO meta via XML-RPC REST silently drops _yoast_wpseo_* meta keys (not registered for REST writes) and Cloudflare blocks both new-post and update form submits to /wp-admin/post.php with a JS challenge that loses the form payload. WordPress XML-RPC is unprotected on foojay (Wordfence's app-password block does not extend to xmlrpc.php), accepts the user's normal password, and wp.editPost's custom_fields parameter lets us write the underscore-prefixed Yoast meta keys directly. After REST creates the draft, the foojay adapter now follows up with an XML-RPC wp.editPost that sets: _yoast_wpseo_canonical -> the original codenameone.com/blog/... URL _yoast_wpseo_title -> the post title _yoast_wpseo_metadesc -> the post description, trimmed to 155 chars on a word boundary Verified end-to-end against draft #123664: Yoast metabox now shows the canonical, SEO title, and meta description correctly. The visible canonical HTML comment at the top of the body content is kept as a secondary signal for the editor reviewer. Also bumps the cover-image download timeout to 120s after a transient 60s timeout on the prior run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * HackerNoon drawer login + Quill editor; dev.to/hashnode draft mode HackerNoon ---------- The /login page form is decorative — submitting it does nothing useful. The actually-working login lives in a drawer that opens from the header "Login" button on hackernoon.com. Switched the adapter to: - navigate to hackernoon.com home page - dismiss the Iubenda cookie banner - click header "Login" button to open the drawer - press_sequentially the email + password into the drawer's React- controlled inputs (fill() doesn't update React state) - click drawer "Log In" button - confirm login by polling for the .hackernoon.com `hasAuthCookie` Editor flow: - navigate to hackernoon.com/new - click "Start Draft" (which routes to app.hackernoon.com/articles/new) - fill title (textarea[name='title'][placeholder='Title']) and the SEO description textarea - paste body into the Quill rich-text editor (div.ql-editor) with a leading "Originally published at <canonical>" line for the editorial reviewer - in normal mode, click "Submit Story for Review!"; in --validate-only mode, screenshot and exit dev.to / Hashnode ----------------- Adds --draft-mode flag to syndicate_blog_posts.py so the API path can be verified without going live. dev.to switches to published=false; Hashnode switches from publishPost to createDraft. Production cron runs without the flag and publishes as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Polish: HN drawer + Save, DZone + Medium real selectors, storage fixes foojay ------ - Trim Yoast meta description to 140 chars (was 155 — Yoast's snippet preview cut at ~142 chars, leaving the full version visibly truncated). - dev.to draft URL now points at the dashboard editor instead of the public canonical URL (which 404s for unpublished drafts). storage state helper -------------------- - Auto-detect Firefox cookie expiries that are stored in milliseconds (some cookies in cookies.sqlite use ms despite the documented seconds unit). Anything > ~year 5138 is treated as ms and divided by 1000; fixes "valid expires" Playwright errors when loading state. - Generalize the dzone profile's logged-in detector: matches the Spring Security `remember-me` cookie or any per-session `dz<hash>` cookie (excluding the anonymous `dzuuid`). HackerNoon ---------- - Wait for SPA hydration via networkidle + visible-state poll on the Start Draft button (3s sleep was too short). - Switch from "Submit Story for Review!" (gated on extra fields) to the always-enabled "Save" toolbar button so a draft lands every run. - Disambiguate the "Save" selector with .first since two Save buttons exist on the editor page. - Verified end-to-end: draft created at app.hackernoon.com/articles/<id>. DZone ----- - Use the real editor URL /content/article/post.html (the previously- guessed /articles/new is 404; /articles/create is Cloudflare 403). - Replace untested TinyMCE-iframe selectors with the live Froala editor. Fill title (textarea[name='title']), TL;DR (subtitle), and meta description; set the body via Froala's JS API (FroalaEditor.INSTANCES[0].html.set) instead of clipboard paste, which Froala's paste handler unreliably eats. Medium ------ - Updated to the actual editor — a single contenteditable div.postArticle-content with placeholder "Title\\nTell your story…". Type title, press Enter, paste body, wait for Medium's auto-save to redirect to /p/<draftId>/edit. Medium's editor mounts inconsistently when driven from headless Playwright (Cloudflare bot detection appears to vary by request); behaviour is best-effort and the run may need manual retry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * HackerNoon: render markdown to HTML; record May 1 syndication state HackerNoon ---------- - Title was being saved with leading characters dropped ("umbing" instead of "Liquid Glass, Material 3, And A Lot Of Plumbing"). HN's React-controlled textarea drops chars when fed via press_sequentially faster than its onChange debounce. .fill() sets the value via the CDP InputHandler API which React picks up correctly. - Body was being pasted as raw markdown text and lost all formatting (no headings, images, links, code blocks). Now converted to HTML with python-markdown and injected through Quill's clipboard API (clipboard.dangerouslyPasteHTML), which translates the HTML into Quill's Delta format. Headings, images, paragraphs, code fences, and links all render in the editor as a result. Workflow gets `markdown` added to the pip install for the browser- syndication step. State file ---------- Records the existing dev.to + Hashnode syndications of the May 1 liquid-glass post so the daily cron does not create duplicates: - dev.to: 3620800 -> https://dev.to/codenameone/liquid-glass-... - hashnode: 69fb2f0263ebe40f84df66db -> https://debugagent.com/liquid-glass-material-3-... Production cron runs in publish (non-draft) mode by default so future posts go live automatically once they hit the 7-day cooling window. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * HackerNoon: title typing, no-body-canonical, drawer canonical, cover Title ----- .fill() leaves the title empty; press_sequentially with a small keystroke delay (5-10ms) loses leading characters because HN's React onChange debouncer drops them. press_sequentially(delay=80) types slowly enough that every character registers, and the full title ("Liquid Glass, Material 3, And A Lot Of Plumbing") survives instead of becoming "umbing". Body ---- Drops the visible "Originally published at <canonical>" line from the top of the body — the canonical now lives in HN's Story Settings drawer where it is supposed to be. Description (SEO meta) is also typed via press_sequentially because the same React-debounce issue applies. Canonical via Story Settings drawer ----------------------------------- After saving title + body, the adapter: - clicks the No button under "Is this story original on HackerNoon?" (button.css-p9s3bq:text-is('No'), scoped to drawer styling so we don't accidentally hit a confirm-modal "No" with class="negative") - fills the canonical URL into the input that appears below (input.firstSeenAt / placeholder "www.example.com/yourstory") Cover image ----------- Downloads the post's header image to a tempfile and uploads it via the input[type=file][accept*='image'] hidden file input using Playwright's set_input_files(). No file picker dialog is opened. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * HackerNoon drawer fields persist; Medium/DZone moved to Firefox extension HackerNoon drawer ----------------- The Story Settings drawer has its own internal scroll container, so Playwright's scroll_into_view_if_needed scrolls the page but leaves drawer elements outside the viewport — clicks and fills timed out and none of {description, cover image, canonical} actually persisted. Switch to JS-driven interactions for everything inside the drawer: - Description / canonical: set value via React's native value setter (Object.getOwnPropertyDescriptor(prototype, 'value').set) and dispatch an `input` event so React's onChange picks it up. - "Is this story original on HackerNoon?" → No: call .click() on the button element directly via page.evaluate (no viewport check). - Cover image: set_input_files on the hidden file input continues to work (no visibility requirement) — the previous failure was cascading from the description timeout aborting the run before the upload completed. Verified end-to-end: title, description, canonical, and cover image all persist on the saved draft. Code blocks ----------- python-markdown emits <pre><code class="language-X">. Quill's syntax module reads the language from the <pre>'s data-language attribute, not the <code>'s class — without it, Quill mis-tags Java as JavaScript. Added _retag_code_blocks_for_quill() that rewrites those blocks into <pre class="ql-syntax language-X" data-language="X" spellcheck="false">. Medium / DZone via Firefox extension ------------------------------------ Both sites are gated by aggressive Cloudflare bot detection that headless Playwright can't pass reliably. Pivoting to a Firefox extension that runs inside the user's already-trusted browser session: scripts/syndication-extension/ -- the extension itself manifest.json manifest v3 background.js polls a queue file in the repo every 30 min popup.html/.js pending/completed list + JSON state-patch to paste back into syndication-state.json adapters/ common.js shared waitFor / React-setter / file-attach helpers medium.js title via execCommand insertText, body via execCommand insertHTML, canonical via Story Settings panel dzone.js title/subtitle via React setters, body via FroalaEditor.INSTANCES[0].html.set, click Save draft scripts/website/queue_browser_syndication.py Walks the same eligible-posts logic as the API syndicator and appends a per-platform task entry to syndication-queue.json (de-duped by id). scripts/website/syndication-queue.json Committed queue file the extension polls. The daily CI job appends to it; the user's Firefox processes the queue when online; results land in syndication-state.json after the user pastes the patch from the extension's popup. The Playwright DZoneAdapter and MediumAdapter are removed — they were unreliable. The browser-syndication script now defaults to foojay,hackernoon only. A new workflow step runs queue_browser_syndication.py on every cron run; the commit-state step now also commits queue updates. The extension is unsigned, so a temporary install from about:debugging#/runtime/this-firefox is required and persists until Firefox is restarted. README inside the extension dir documents installation, architecture, and how to add new platforms (Bluesky, Mastodon, etc. — same pattern, different adapter file). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Remove HackerNoon syndication target HackerNoon charges business sites for canonical-URL support. Without a canonical link back to www.codenameone.com the post becomes a duplicate of the original — which defeats the SEO model the syndication pipeline is designed around. Removed: - HackerNoonAdapter class and all selectors (login drawer, Quill body, Story Settings drawer, cover image, canonical-via-No flow) - HACKERNOON_USER / HACKERNOON_PASSWORD secret passthrough in the workflow + their detection in the browser-creds gate - HN-specific helpers in syndicate_browser_posts.py: _markdown_to_html, _retag_code_blocks_for_quill, _download_to_temp, _escape_html, the unused `re` import Workflow tweak: `markdown` is now installed in its own unconditional step (queue_browser_syndication.py needs it whether or not Playwright runs), so removing HN doesn't break the Medium/DZone queue path. Default platforms for syndicate_browser_posts.py is now `foojay` only. The full active syndication set: API: dev.to, hashnode (syndicate_blog_posts.py) Hybrid: foojay (Playwright login + WP REST + XML-RPC for Yoast) Browser: medium, dzone (Firefox extension via syndication-queue.json) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Resolve PR code-quality warnings CodeQL flagged two patterns: 1. Unused local `slug` in publish_to_hashnode() draft branch — dropped. The draft URL is built from node.id only; the slug was assigned but never read. 2. Empty except clauses with `pass` in syndicate_browser_posts.py — added inside-the-body explanatory comments documenting why each exception is intentionally swallowed: - _upload_featured_media: media-item title rename is cosmetic; upload itself already succeeded. - run_adapter clipboard grant: Firefox/WebKit reject the chromium- only clipboard-* perms; adapters fall back to editor-specific APIs. The other empty-except findings were on adapter classes that have since been removed (HackerNoon, DZone, Medium adapter classes are no longer in syndicate_browser_posts.py — Medium and DZone moved to the Firefox extension; HackerNoon was dropped because it charges business sites for canonical-URL support). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c4bd2e4 commit 8783175

17 files changed

Lines changed: 2177 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
name: Syndicate Blog Posts
2+
3+
on:
4+
schedule:
5+
# Daily at 13:00 UTC. Runs from the default branch only, per GitHub's cron rules.
6+
- cron: '0 13 * * *'
7+
workflow_dispatch:
8+
inputs:
9+
dry_run:
10+
description: 'Skip API calls and only print what would happen.'
11+
type: boolean
12+
default: false
13+
14+
permissions:
15+
contents: write
16+
17+
concurrency:
18+
group: blog-syndication
19+
cancel-in-progress: false
20+
21+
jobs:
22+
syndicate:
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Check out repository
26+
uses: actions/checkout@v4
27+
with:
28+
# Token with write scope so the post-run commit can push the state file.
29+
token: ${{ secrets.GITHUB_TOKEN }}
30+
31+
- name: Set up Python
32+
uses: actions/setup-python@v5
33+
with:
34+
python-version: '3.12'
35+
36+
- name: Run API syndication script
37+
id: syndicate_api
38+
env:
39+
DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
40+
HASHNODE_TOKEN: ${{ secrets.HASHNODE_TOKEN }}
41+
HASHNODE_PUBLICATION_ID: ${{ secrets.HASHNODE_PUBLICATION_ID }}
42+
run: |
43+
set -euo pipefail
44+
if [ "${{ inputs.dry_run }}" = "true" ]; then
45+
python3 scripts/website/syndicate_blog_posts.py --dry-run
46+
else
47+
python3 scripts/website/syndicate_blog_posts.py
48+
fi
49+
50+
- name: Detect browser-syndication credentials
51+
id: browser_creds
52+
env:
53+
FOOJAY_USER: ${{ secrets.FOOJAY_USER }}
54+
run: |
55+
if [ -n "${FOOJAY_USER}" ]; then
56+
echo "any_configured=true" >> "${GITHUB_OUTPUT}"
57+
else
58+
echo "any_configured=false" >> "${GITHUB_OUTPUT}"
59+
fi
60+
61+
- name: Install markdown package (for browser-extension queue HTML)
62+
run: |
63+
set -euo pipefail
64+
pip install markdown
65+
66+
- name: Install Playwright dependencies
67+
if: ${{ steps.browser_creds.outputs.any_configured == 'true' }}
68+
run: |
69+
set -euo pipefail
70+
pip install playwright
71+
playwright install --with-deps chromium
72+
73+
- name: Run browser syndication script
74+
if: ${{ steps.browser_creds.outputs.any_configured == 'true' }}
75+
env:
76+
FOOJAY_USER: ${{ secrets.FOOJAY_USER }}
77+
FOOJAY_PASSWORD: ${{ secrets.FOOJAY_PASSWORD }}
78+
run: |
79+
set -euo pipefail
80+
if [ "${{ inputs.dry_run }}" = "true" ]; then
81+
python3 scripts/website/syndicate_browser_posts.py --dry-run
82+
else
83+
python3 scripts/website/syndicate_browser_posts.py
84+
fi
85+
86+
- name: Queue browser-extension syndication tasks (Medium, DZone)
87+
run: |
88+
set -euo pipefail
89+
python3 scripts/website/queue_browser_syndication.py
90+
91+
- name: Upload syndication screenshots on failure
92+
if: ${{ always() && hashFiles('docs/website/reports/syndication-screenshots/**/*.png') != '' }}
93+
uses: actions/upload-artifact@v4
94+
with:
95+
name: syndication-screenshots
96+
path: docs/website/reports/syndication-screenshots/
97+
if-no-files-found: ignore
98+
retention-days: 14
99+
100+
- name: Commit updated syndication state and queue
101+
if: ${{ inputs.dry_run != true }}
102+
run: |
103+
set -euo pipefail
104+
if git diff --quiet -- scripts/website/syndication-state.json scripts/website/syndication-queue.json; then
105+
echo "No state or queue changes to commit."
106+
exit 0
107+
fi
108+
git config user.name 'github-actions[bot]'
109+
git config user.email 'github-actions[bot]@users.noreply.github.com'
110+
git add scripts/website/syndication-state.json scripts/website/syndication-queue.json
111+
git commit -m "ci: record blog syndication results"
112+
git push

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
**/dist/*
3131
*.zip
3232
CodenameOneDesigner/src/version.properties
33+
*-storage-state.json
34+
*-storage-state.*.json
3335
/Ports/iOSPort/build/
3436
/Ports/iOSPort/dist/
3537
Ports/iOSPort/nbproject/private/private.xml
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.png
2+
!.gitignore
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Codename One Syndicator (Firefox extension)
2+
3+
Drives the Medium and DZone post editors from inside the user's logged-in
4+
Firefox session, so syndication requests carry a real browser fingerprint
5+
and `cf_clearance` cookie. This is the only way to syndicate to Medium /
6+
DZone reliably — both sit behind aggressive Cloudflare bot detection that
7+
rejects headless Playwright runs.
8+
9+
## How it fits together
10+
11+
```
12+
┌───────────────────────────┐ ┌──────────────────────────┐
13+
│ Daily CI cron │ │ User's Firefox │
14+
│ blog-syndication.yml │ │ (this extension) │
15+
│ │ │ │
16+
│ 1. picks eligible posts │ │ 1. polls queue every │
17+
│ 2. publishes via APIs │ │ 30 min │
18+
│ (foojay, dev.to, │ │ 2. opens editor tab │
19+
│ hashnode) │ │ per pending task │
20+
│ 3. appends Medium/DZone │ ──────▶ │ 3. content script │
21+
│ tasks to │ poll │ fills editor + │
22+
│ syndication-queue │ via │ saves draft │
23+
│ .json (committed) │ raw.gh │ 4. shows JSON patch │
24+
└───────────────────────────┘ │ to paste back into │
25+
│ syndication-state │
26+
└──────────────────────────┘
27+
```
28+
29+
* CI does not run the browser. It only knows which posts are eligible and
30+
appends a task entry per browser-only platform to
31+
`scripts/website/syndication-queue.json`. That commit is what makes the
32+
task visible to the extension.
33+
* The extension polls the raw GitHub URL of that file. When the user's
34+
Firefox is online, queued tasks get processed. There is no daily
35+
schedule pressure — a 3-day Firefox-offline gap is fine.
36+
* The extension writes results into its local `chrome.storage` and the
37+
popup UI prints a JSON patch the user can paste into
38+
`scripts/website/syndication-state.json` to record the syndication
39+
permanently. (Round-tripping the result via a GitHub PR from inside the
40+
extension would require a committed token; we keep that boundary simple.)
41+
42+
## Install (Firefox)
43+
44+
1. `about:debugging#/runtime/this-firefox`**Load Temporary Add-on…**
45+
2. Pick `scripts/syndication-extension/manifest.json`.
46+
3. The icon shows up in the toolbar. Click it → **Poll syndication queue
47+
now** to test against whatever is currently in
48+
`syndication-queue.json`.
49+
50+
For permanent install (across browser restarts) the extension needs to
51+
be signed by Mozilla — out of scope for the first version.
52+
53+
## Adapters
54+
55+
Each target site has a content script that runs on its editor URL:
56+
57+
* `adapters/medium.js` — Medium new-story editor. Types title, presses
58+
Enter, pastes body HTML via `execCommand('insertHTML')`, opens Story
59+
Settings panel, fills canonical URL.
60+
* `adapters/dzone.js` — DZone Froala editor. Sets title and subtitle via
61+
React-style native value setters, calls
62+
`FroalaEditor.INSTANCES[0].html.set` for the body, clicks **Save draft**.
63+
64+
To add a new platform (Bluesky, Mastodon, Threads, …):
65+
66+
1. Add an entry under `EDITOR_URLS` in `background.js`.
67+
2. Drop a new `adapters/<site>.js` content script that reads the task
68+
from `chrome.storage.local['task_for_<site>']` and reports back via
69+
`cn1Syndicator.report(taskId, { success, url })`.
70+
3. Add a `content_scripts` entry in `manifest.json` for that editor URL.
71+
4. Have CI append `{ "site": "<site>", … }` task entries.
72+
73+
## Producing the queue (CI side)
74+
75+
`scripts/website/queue_browser_syndication.py` walks the same eligible-
76+
posts logic the API syndicator uses, then appends a task per browser
77+
platform to `scripts/website/syndication-queue.json` (skipping anything
78+
already in `syndication-state.json` or already in the queue).
79+
80+
Daily workflow runs:
81+
82+
```bash
83+
python3 scripts/website/queue_browser_syndication.py --platforms medium,dzone
84+
```
85+
86+
Then commits the queue file back to master so the next extension poll
87+
picks it up.
88+
89+
## Caveats
90+
91+
* The extension is unsigned, so a temporary install must be re-loaded
92+
after every Firefox restart unless you self-sign or run from a
93+
Developer Edition with `xpinstall.signatures.required` disabled.
94+
* Adapter selectors break when target sites redesign. Each adapter is a
95+
small, scoped file — fix the broken selectors and reload the extension.
96+
* The queue is durable because it lives in the repo. A 3-day Firefox-
97+
offline gap just means the tasks process when the user is back.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Helpers shared by every adapter.
2+
3+
window.cn1Syndicator = window.cn1Syndicator || {};
4+
5+
window.cn1Syndicator.waitFor = function (predicate, { timeout = 30000, interval = 200 } = {}) {
6+
return new Promise((resolve, reject) => {
7+
const deadline = Date.now() + timeout;
8+
const tick = () => {
9+
try {
10+
const value = predicate();
11+
if (value) {
12+
resolve(value);
13+
return;
14+
}
15+
} catch (err) {
16+
// ignore until timeout
17+
}
18+
if (Date.now() > deadline) {
19+
reject(new Error(`waitFor timed out after ${timeout}ms`));
20+
return;
21+
}
22+
setTimeout(tick, interval);
23+
};
24+
tick();
25+
});
26+
};
27+
28+
window.cn1Syndicator.setReactValue = function (element, value) {
29+
if (!element) return false;
30+
const proto = Object.getPrototypeOf(element);
31+
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
32+
setter.call(element, value);
33+
element.dispatchEvent(new Event("input", { bubbles: true }));
34+
return true;
35+
};
36+
37+
window.cn1Syndicator.report = function (taskId, payload) {
38+
chrome.runtime.sendMessage({ type: "syndication-complete", task_id: taskId, ...payload });
39+
};
40+
41+
window.cn1Syndicator.getTaskFor = async function (site) {
42+
const key = `task_for_${site}`;
43+
const data = await chrome.storage.local.get(key);
44+
return data[key] || null;
45+
};
46+
47+
window.cn1Syndicator.downloadAsFile = async function (url, fileName) {
48+
// Returns a File object suitable for handing to a hidden file input.
49+
const resp = await fetch(url);
50+
if (!resp.ok) throw new Error(`download ${url} -> ${resp.status}`);
51+
const blob = await resp.blob();
52+
return new File([blob], fileName, { type: blob.type || "image/jpeg" });
53+
};
54+
55+
window.cn1Syndicator.attachFile = function (input, file) {
56+
// Programmatically populate a hidden <input type="file">.
57+
const dt = new DataTransfer();
58+
dt.items.add(file);
59+
input.files = dt.files;
60+
input.dispatchEvent(new Event("change", { bubbles: true }));
61+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// DZone adapter. Runs on https://dzone.com/content/article/post.html.
2+
//
3+
// DZone's editor is Froala. Title is a textarea (Angular-bound), body lives
4+
// in window.FroalaEditor.INSTANCES[0]. The save mechanism is the "Save draft"
5+
// button — Cloudflare doesn't challenge it because the request originates
6+
// from the user's already-trusted browser session.
7+
8+
(async () => {
9+
const { waitFor, setReactValue, report, getTaskFor } = window.cn1Syndicator;
10+
const task = await getTaskFor("dzone");
11+
if (!task) return;
12+
console.log("[dzone-adapter] picked up task", task.slug);
13+
14+
try {
15+
// Title (Angular ng-model)
16+
const title = await waitFor(() => document.querySelector("textarea[name='title']"));
17+
setReactValue(title, task.title);
18+
19+
// Subtitle / TL;DR — use description if present
20+
if (task.description) {
21+
const sub = document.querySelector("textarea[name='subtitle']");
22+
if (sub) setReactValue(sub, task.description.slice(0, 300));
23+
const meta = document.getElementById("meta-description-textarea");
24+
if (meta) setReactValue(meta, task.description.slice(0, 155));
25+
}
26+
27+
// Body — set via Froala's JS API
28+
if (task.body_html) {
29+
await waitFor(() => window.FroalaEditor && window.FroalaEditor.INSTANCES && window.FroalaEditor.INSTANCES.length);
30+
const inst = window.FroalaEditor.INSTANCES[0];
31+
inst.html.set(task.body_html);
32+
if (inst.events && inst.events.trigger) inst.events.trigger("contentChanged");
33+
}
34+
35+
// Wait a moment for Angular to digest the title and subtitle changes
36+
// before clicking Save.
37+
await new Promise((r) => setTimeout(r, 1500));
38+
39+
const save = Array.from(document.querySelectorAll("button"))
40+
.find((b) => /^save\s*draft$/i.test((b.textContent || "").trim()));
41+
if (!save) throw new Error("Save Draft button not found");
42+
save.click();
43+
44+
// After save, DZone keeps you on post.html or redirects to drafts list.
45+
// Wait a few seconds then report.
46+
await new Promise((r) => setTimeout(r, 6000));
47+
report(task.id, { success: true, url: location.href });
48+
} catch (err) {
49+
console.error("[dzone-adapter] failed", err);
50+
report(task.id, { success: false, error: String(err) });
51+
}
52+
})();
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Medium adapter. Runs on https://medium.com/new-story.
2+
//
3+
// Medium has a single contenteditable div for both title and body. Type the
4+
// title, press Enter, then paste body HTML via the document selection API
5+
// (Medium's editor accepts HTML pastes and converts to its internal format).
6+
// Set canonical via the Story Settings panel after the body is in place.
7+
8+
(async () => {
9+
const { waitFor, setReactValue, report, getTaskFor } = window.cn1Syndicator;
10+
const task = await getTaskFor("medium");
11+
if (!task) return;
12+
console.log("[medium-adapter] picked up task", task.slug);
13+
14+
try {
15+
const editor = await waitFor(() => document.querySelector("div.postArticle-content[contenteditable='true']"));
16+
editor.focus();
17+
document.execCommand("selectAll", false);
18+
document.execCommand("delete", false);
19+
20+
// Type the title (Medium converts the first line to <h3.graf--title>)
21+
document.execCommand("insertText", false, task.title);
22+
document.execCommand("insertParagraph", false);
23+
24+
// Paste body as HTML so headings/images/code render.
25+
if (task.body_html) {
26+
// execCommand insertHTML works in Medium's contenteditable.
27+
document.execCommand("insertHTML", false, task.body_html);
28+
}
29+
30+
// Wait for Medium's auto-save to assign a draft URL (/p/<id>/edit).
31+
await new Promise((r) => setTimeout(r, 4000));
32+
let draftUrl = location.href;
33+
34+
// Story settings panel: click the gear/settings icon in the top bar
35+
// (varies by layout — try a couple of selectors), find the
36+
// "Customize canonical link" / canonical URL input, fill it.
37+
try {
38+
const gear = document.querySelector("button[aria-label*='Story settings' i], button[data-action='show-story-meta']");
39+
if (gear) {
40+
gear.click();
41+
const canonical = await waitFor(
42+
() => document.querySelector("input[placeholder*='canonical' i], input[placeholder*='URL of original' i]"),
43+
{ timeout: 8000 }
44+
);
45+
setReactValue(canonical, task.canonical);
46+
// Close the panel so auto-save fires
47+
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
48+
}
49+
} catch (err) {
50+
console.warn("[medium-adapter] could not set canonical via panel", err);
51+
}
52+
53+
// One last wait so auto-save settles after the canonical change.
54+
await new Promise((r) => setTimeout(r, 4000));
55+
draftUrl = location.href;
56+
report(task.id, { success: true, url: draftUrl });
57+
} catch (err) {
58+
console.error("[medium-adapter] failed", err);
59+
report(task.id, { success: false, error: String(err) });
60+
}
61+
})();

0 commit comments

Comments
 (0)