Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
112 changes: 112 additions & 0 deletions .github/workflows/blog-syndication.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Syndicate Blog Posts

on:
schedule:
# Daily at 13:00 UTC. Runs from the default branch only, per GitHub's cron rules.
- cron: '0 13 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Skip API calls and only print what would happen.'
type: boolean
default: false

permissions:
contents: write

concurrency:
group: blog-syndication
cancel-in-progress: false

jobs:
syndicate:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
# Token with write scope so the post-run commit can push the state file.
token: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Run API syndication script
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
python3 scripts/website/syndicate_blog_posts.py --dry-run
else
python3 scripts/website/syndicate_blog_posts.py
fi

- name: Detect browser-syndication credentials
id: browser_creds
env:
FOOJAY_USER: ${{ secrets.FOOJAY_USER }}
run: |
if [ -n "${FOOJAY_USER}" ]; then
echo "any_configured=true" >> "${GITHUB_OUTPUT}"
else
echo "any_configured=false" >> "${GITHUB_OUTPUT}"
fi

- name: Install markdown package (for browser-extension queue HTML)
run: |
set -euo pipefail
pip install markdown

- name: Install Playwright dependencies
if: ${{ steps.browser_creds.outputs.any_configured == 'true' }}
run: |
set -euo pipefail
pip install playwright
playwright install --with-deps chromium

- name: Run browser syndication script
if: ${{ steps.browser_creds.outputs.any_configured == 'true' }}
env:
FOOJAY_USER: ${{ secrets.FOOJAY_USER }}
FOOJAY_PASSWORD: ${{ secrets.FOOJAY_PASSWORD }}
run: |
set -euo pipefail
if [ "${{ inputs.dry_run }}" = "true" ]; then
python3 scripts/website/syndicate_browser_posts.py --dry-run
else
python3 scripts/website/syndicate_browser_posts.py
fi

- name: Queue browser-extension syndication tasks (Medium, DZone)
run: |
set -euo pipefail
python3 scripts/website/queue_browser_syndication.py

- name: Upload syndication screenshots on failure
if: ${{ always() && hashFiles('docs/website/reports/syndication-screenshots/**/*.png') != '' }}
uses: actions/upload-artifact@v4
with:
name: syndication-screenshots
path: docs/website/reports/syndication-screenshots/
if-no-files-found: ignore
retention-days: 14

- name: Commit updated syndication state and queue
if: ${{ inputs.dry_run != true }}
run: |
set -euo pipefail
if git diff --quiet -- scripts/website/syndication-state.json scripts/website/syndication-queue.json; then
echo "No state or queue changes to commit."
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add scripts/website/syndication-state.json scripts/website/syndication-queue.json
git commit -m "ci: record blog syndication results"
git push
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
**/dist/*
*.zip
CodenameOneDesigner/src/version.properties
*-storage-state.json
*-storage-state.*.json
/Ports/iOSPort/build/
/Ports/iOSPort/dist/
Ports/iOSPort/nbproject/private/private.xml
Expand Down
2 changes: 2 additions & 0 deletions docs/website/reports/syndication-screenshots/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.png
!.gitignore
97 changes: 97 additions & 0 deletions scripts/syndication-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Codename One Syndicator (Firefox extension)

Drives the Medium and DZone post editors from inside the user's logged-in
Firefox session, so syndication requests carry a real browser fingerprint
and `cf_clearance` cookie. This is the only way to syndicate to Medium /
DZone reliably — both sit behind aggressive Cloudflare bot detection that
rejects headless Playwright runs.

## How it fits together

```
┌───────────────────────────┐ ┌──────────────────────────┐
│ Daily CI cron │ │ User's Firefox │
│ blog-syndication.yml │ │ (this extension) │
│ │ │ │
│ 1. picks eligible posts │ │ 1. polls queue every │
│ 2. publishes via APIs │ │ 30 min │
│ (foojay, dev.to, │ │ 2. opens editor tab │
│ hashnode) │ │ per pending task │
│ 3. appends Medium/DZone │ ──────▶ │ 3. content script │
│ tasks to │ poll │ fills editor + │
│ syndication-queue │ via │ saves draft │
│ .json (committed) │ raw.gh │ 4. shows JSON patch │
└───────────────────────────┘ │ to paste back into │
│ syndication-state │
└──────────────────────────┘
```

* CI does not run the browser. It only knows which posts are eligible and
appends a task entry per browser-only platform to
`scripts/website/syndication-queue.json`. That commit is what makes the
task visible to the extension.
* The extension polls the raw GitHub URL of that file. When the user's
Firefox is online, queued tasks get processed. There is no daily
schedule pressure — a 3-day Firefox-offline gap is fine.
* The extension writes results into its local `chrome.storage` and the
popup UI prints a JSON patch the user can paste into
`scripts/website/syndication-state.json` to record the syndication
permanently. (Round-tripping the result via a GitHub PR from inside the
extension would require a committed token; we keep that boundary simple.)

## Install (Firefox)

1. `about:debugging#/runtime/this-firefox` → **Load Temporary Add-on…**
2. Pick `scripts/syndication-extension/manifest.json`.
3. The icon shows up in the toolbar. Click it → **Poll syndication queue
now** to test against whatever is currently in
`syndication-queue.json`.

For permanent install (across browser restarts) the extension needs to
be signed by Mozilla — out of scope for the first version.

## Adapters

Each target site has a content script that runs on its editor URL:

* `adapters/medium.js` — Medium new-story editor. Types title, presses
Enter, pastes body HTML via `execCommand('insertHTML')`, opens Story
Settings panel, fills canonical URL.
* `adapters/dzone.js` — DZone Froala editor. Sets title and subtitle via
React-style native value setters, calls
`FroalaEditor.INSTANCES[0].html.set` for the body, clicks **Save draft**.

To add a new platform (Bluesky, Mastodon, Threads, …):

1. Add an entry under `EDITOR_URLS` in `background.js`.
2. Drop a new `adapters/<site>.js` content script that reads the task
from `chrome.storage.local['task_for_<site>']` and reports back via
`cn1Syndicator.report(taskId, { success, url })`.
3. Add a `content_scripts` entry in `manifest.json` for that editor URL.
4. Have CI append `{ "site": "<site>", … }` task entries.

## Producing the queue (CI side)

`scripts/website/queue_browser_syndication.py` walks the same eligible-
posts logic the API syndicator uses, then appends a task per browser
platform to `scripts/website/syndication-queue.json` (skipping anything
already in `syndication-state.json` or already in the queue).

Daily workflow runs:

```bash
python3 scripts/website/queue_browser_syndication.py --platforms medium,dzone
```

Then commits the queue file back to master so the next extension poll
picks it up.

## Caveats

* The extension is unsigned, so a temporary install must be re-loaded
after every Firefox restart unless you self-sign or run from a
Developer Edition with `xpinstall.signatures.required` disabled.
* Adapter selectors break when target sites redesign. Each adapter is a
small, scoped file — fix the broken selectors and reload the extension.
* The queue is durable because it lives in the repo. A 3-day Firefox-
offline gap just means the tasks process when the user is back.
61 changes: 61 additions & 0 deletions scripts/syndication-extension/adapters/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Helpers shared by every adapter.

window.cn1Syndicator = window.cn1Syndicator || {};

window.cn1Syndicator.waitFor = function (predicate, { timeout = 30000, interval = 200 } = {}) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeout;
const tick = () => {
try {
const value = predicate();
if (value) {
resolve(value);
return;
}
} catch (err) {
// ignore until timeout
}
if (Date.now() > deadline) {
reject(new Error(`waitFor timed out after ${timeout}ms`));
return;
}
setTimeout(tick, interval);
};
tick();
});
};

window.cn1Syndicator.setReactValue = function (element, value) {
if (!element) return false;
const proto = Object.getPrototypeOf(element);
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
setter.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
return true;
};

window.cn1Syndicator.report = function (taskId, payload) {
chrome.runtime.sendMessage({ type: "syndication-complete", task_id: taskId, ...payload });
};

window.cn1Syndicator.getTaskFor = async function (site) {
const key = `task_for_${site}`;
const data = await chrome.storage.local.get(key);
return data[key] || null;
};

window.cn1Syndicator.downloadAsFile = async function (url, fileName) {
// Returns a File object suitable for handing to a hidden file input.
const resp = await fetch(url);
if (!resp.ok) throw new Error(`download ${url} -> ${resp.status}`);
const blob = await resp.blob();
return new File([blob], fileName, { type: blob.type || "image/jpeg" });
};

window.cn1Syndicator.attachFile = function (input, file) {
// Programmatically populate a hidden <input type="file">.
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
};
52 changes: 52 additions & 0 deletions scripts/syndication-extension/adapters/dzone.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// DZone adapter. Runs on https://dzone.com/content/article/post.html.
//
// DZone's editor is Froala. Title is a textarea (Angular-bound), body lives
// in window.FroalaEditor.INSTANCES[0]. The save mechanism is the "Save draft"
// button — Cloudflare doesn't challenge it because the request originates
// from the user's already-trusted browser session.

(async () => {
const { waitFor, setReactValue, report, getTaskFor } = window.cn1Syndicator;
const task = await getTaskFor("dzone");
if (!task) return;
console.log("[dzone-adapter] picked up task", task.slug);

try {
// Title (Angular ng-model)
const title = await waitFor(() => document.querySelector("textarea[name='title']"));
setReactValue(title, task.title);

// Subtitle / TL;DR — use description if present
if (task.description) {
const sub = document.querySelector("textarea[name='subtitle']");
if (sub) setReactValue(sub, task.description.slice(0, 300));
const meta = document.getElementById("meta-description-textarea");
if (meta) setReactValue(meta, task.description.slice(0, 155));
}

// Body — set via Froala's JS API
if (task.body_html) {
await waitFor(() => window.FroalaEditor && window.FroalaEditor.INSTANCES && window.FroalaEditor.INSTANCES.length);
const inst = window.FroalaEditor.INSTANCES[0];
inst.html.set(task.body_html);
if (inst.events && inst.events.trigger) inst.events.trigger("contentChanged");
}

// Wait a moment for Angular to digest the title and subtitle changes
// before clicking Save.
await new Promise((r) => setTimeout(r, 1500));

const save = Array.from(document.querySelectorAll("button"))
.find((b) => /^save\s*draft$/i.test((b.textContent || "").trim()));
if (!save) throw new Error("Save Draft button not found");
save.click();

// After save, DZone keeps you on post.html or redirects to drafts list.
// Wait a few seconds then report.
await new Promise((r) => setTimeout(r, 6000));
report(task.id, { success: true, url: location.href });
} catch (err) {
console.error("[dzone-adapter] failed", err);
report(task.id, { success: false, error: String(err) });
}
})();
61 changes: 61 additions & 0 deletions scripts/syndication-extension/adapters/medium.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Medium adapter. Runs on https://medium.com/new-story.
//
// Medium has a single contenteditable div for both title and body. Type the
// title, press Enter, then paste body HTML via the document selection API
// (Medium's editor accepts HTML pastes and converts to its internal format).
// Set canonical via the Story Settings panel after the body is in place.

(async () => {
const { waitFor, setReactValue, report, getTaskFor } = window.cn1Syndicator;
const task = await getTaskFor("medium");
if (!task) return;
console.log("[medium-adapter] picked up task", task.slug);

try {
const editor = await waitFor(() => document.querySelector("div.postArticle-content[contenteditable='true']"));
editor.focus();
document.execCommand("selectAll", false);
document.execCommand("delete", false);

// Type the title (Medium converts the first line to <h3.graf--title>)
document.execCommand("insertText", false, task.title);
document.execCommand("insertParagraph", false);

// Paste body as HTML so headings/images/code render.
if (task.body_html) {
// execCommand insertHTML works in Medium's contenteditable.
document.execCommand("insertHTML", false, task.body_html);
}

// Wait for Medium's auto-save to assign a draft URL (/p/<id>/edit).
await new Promise((r) => setTimeout(r, 4000));
let draftUrl = location.href;

// Story settings panel: click the gear/settings icon in the top bar
// (varies by layout — try a couple of selectors), find the
// "Customize canonical link" / canonical URL input, fill it.
try {
const gear = document.querySelector("button[aria-label*='Story settings' i], button[data-action='show-story-meta']");
if (gear) {
gear.click();
const canonical = await waitFor(
() => document.querySelector("input[placeholder*='canonical' i], input[placeholder*='URL of original' i]"),
{ timeout: 8000 }
);
setReactValue(canonical, task.canonical);
// Close the panel so auto-save fires
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
}
} catch (err) {
console.warn("[medium-adapter] could not set canonical via panel", err);
}

// One last wait so auto-save settles after the canonical change.
await new Promise((r) => setTimeout(r, 4000));
draftUrl = location.href;
report(task.id, { success: true, url: draftUrl });
} catch (err) {
console.error("[medium-adapter] failed", err);
report(task.id, { success: false, error: String(err) });
}
})();
Loading
Loading