diff --git a/scripts/syndication-extension/README.md b/scripts/syndication-extension/README.md deleted file mode 100644 index bbfe4e9a5d..0000000000 --- a/scripts/syndication-extension/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# 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/.js` content script that reads the task - from `chrome.storage.local['task_for_']` 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": "", … }` 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. diff --git a/scripts/syndication-extension/adapters/common.js b/scripts/syndication-extension/adapters/common.js deleted file mode 100644 index d24aa06e8a..0000000000 --- a/scripts/syndication-extension/adapters/common.js +++ /dev/null @@ -1,61 +0,0 @@ -// 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 . - const dt = new DataTransfer(); - dt.items.add(file); - input.files = dt.files; - input.dispatchEvent(new Event("change", { bubbles: true })); -}; diff --git a/scripts/syndication-extension/adapters/dzone.js b/scripts/syndication-extension/adapters/dzone.js deleted file mode 100644 index 18a5a241b7..0000000000 --- a/scripts/syndication-extension/adapters/dzone.js +++ /dev/null @@ -1,52 +0,0 @@ -// 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) }); - } -})(); diff --git a/scripts/syndication-extension/adapters/medium.js b/scripts/syndication-extension/adapters/medium.js deleted file mode 100644 index b852b850a4..0000000000 --- a/scripts/syndication-extension/adapters/medium.js +++ /dev/null @@ -1,61 +0,0 @@ -// 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 ) - 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//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) }); - } -})(); diff --git a/scripts/syndication-extension/background.js b/scripts/syndication-extension/background.js deleted file mode 100644 index c748813ae6..0000000000 --- a/scripts/syndication-extension/background.js +++ /dev/null @@ -1,124 +0,0 @@ -// Background service worker for the Codename One Syndicator extension. -// -// Polls a JSON queue file in the repo at a slow cadence (default 30 min) and, -// for each pending task, opens the relevant editor in a new tab. Each -// adapter content script picks up the task from chrome.storage when its tab -// loads, fills the editor, saves a draft, and reports back here so the task -// can be marked complete. -// -// The queue file lives at: -// https://raw.githubusercontent.com/codenameone/CodenameOne/master/ -// scripts/website/syndication-queue.json -// -// Format: -// { -// "tasks": [ -// { -// "id": "", -// "site": "medium" | "dzone", -// "slug": "", -// "title": "", -// "canonical": "", -// "body_html": "", -// "description": "", -// "cover_image_url": "" -// } -// ] -// } -// -// Once an adapter completes a task, results land in chrome.storage under -// `completed_tasks`. The popup UI surfaces them so the user can manually -// commit the matching state-file update back to the repo. (Round-tripping -// the result via a GitHub PR from inside the extension would require a -// committed token; we keep the trust boundary simple by leaving that to -// the user.) - -const QUEUE_URL = "https://raw.githubusercontent.com/codenameone/CodenameOne/master/scripts/website/syndication-queue.json"; -const POLL_INTERVAL_MINUTES = 30; -const EDITOR_URLS = { - medium: "https://medium.com/new-story", - dzone: "https://dzone.com/content/article/post.html", -}; - -chrome.runtime.onInstalled.addListener(() => { - chrome.alarms.create("poll", { periodInMinutes: POLL_INTERVAL_MINUTES }); - chrome.storage.local.set({ completed_tasks: [], pending_tasks: [], last_poll: null }); -}); - -chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "poll") void runPoll(); -}); - -chrome.action.onClicked.addListener(() => { - void runPoll(); -}); - -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg && msg.type === "poll-now") { - runPoll().then(() => sendResponse({ ok: true })).catch((err) => sendResponse({ ok: false, error: String(err) })); - return true; // keep the channel open for the async response - } -}); - -async function runPoll() { - await chrome.storage.local.set({ last_poll: new Date().toISOString() }); - let queue; - try { - const resp = await fetch(QUEUE_URL, { cache: "no-store" }); - if (!resp.ok) { - console.warn("[syndicator] queue fetch failed", resp.status); - return; - } - queue = await resp.json(); - } catch (err) { - console.warn("[syndicator] queue fetch error", err); - return; - } - const completed = (await chrome.storage.local.get("completed_tasks")).completed_tasks || []; - const completedIds = new Set(completed.map((c) => c.id)); - const tasks = (queue.tasks || []).filter((t) => !completedIds.has(t.id)); - await chrome.storage.local.set({ pending_tasks: tasks }); - for (const task of tasks) { - await processTask(task); - } -} - -async function processTask(task) { - const editorUrl = EDITOR_URLS[task.site]; - if (!editorUrl) { - console.warn("[syndicator] unknown site", task.site); - return; - } - // Stash the task in storage keyed by site; the content script reads it on load. - await chrome.storage.local.set({ [`task_for_${task.site}`]: task }); - const tab = await chrome.tabs.create({ url: editorUrl, active: false }); - // Wait for completion message from the content script (with a long timeout) - // — the content script signals via chrome.runtime.sendMessage. - await new Promise((resolve) => { - const listener = (msg, sender) => { - if (msg && msg.type === "syndication-complete" && msg.task_id === task.id) { - chrome.runtime.onMessage.removeListener(listener); - chrome.storage.local.get("completed_tasks").then(({ completed_tasks = [] }) => { - completed_tasks.push({ - id: task.id, - site: task.site, - slug: task.slug, - url: msg.url || tab.url, - success: msg.success, - error: msg.error || null, - completed_at: new Date().toISOString(), - }); - chrome.storage.local.set({ completed_tasks }); - }); - // Close the tab once the adapter is done (give it a beat to flush state). - setTimeout(() => chrome.tabs.remove(tab.id).catch(() => {}), 2000); - resolve(); - } - }; - chrome.runtime.onMessage.addListener(listener); - setTimeout(() => { - chrome.runtime.onMessage.removeListener(listener); - resolve(); - }, 5 * 60 * 1000); // 5-minute hard timeout per task - }); -} diff --git a/scripts/syndication-extension/manifest.json b/scripts/syndication-extension/manifest.json deleted file mode 100644 index 1af57c51ca..0000000000 --- a/scripts/syndication-extension/manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "manifest_version": 3, - "name": "Codename One Syndicator", - "version": "0.1.0", - "description": "Syndicates Codename One blog posts to sites that block headless automation (Medium, DZone). Runs inside the user's logged-in browser session, so requests carry a real browser fingerprint and bypass Cloudflare bot detection.", - "permissions": [ - "storage", - "alarms", - "tabs", - "scripting" - ], - "host_permissions": [ - "https://medium.com/*", - "https://dzone.com/*", - "https://raw.githubusercontent.com/codenameone/CodenameOne/*" - ], - "background": { - "scripts": ["background.js"] - }, - "browser_specific_settings": { - "gecko": { - "id": "syndicator@codenameone.com" - } - }, - "action": { - "default_title": "Codename One Syndicator", - "default_popup": "popup.html" - }, - "content_scripts": [ - { - "matches": ["https://medium.com/new-story", "https://medium.com/p/*/edit"], - "js": ["adapters/common.js", "adapters/medium.js"], - "run_at": "document_idle" - }, - { - "matches": ["https://dzone.com/content/article/*"], - "js": ["adapters/common.js", "adapters/dzone.js"], - "run_at": "document_idle" - } - ] -} diff --git a/scripts/syndication-extension/popup.html b/scripts/syndication-extension/popup.html deleted file mode 100644 index 62bb31eb10..0000000000 --- a/scripts/syndication-extension/popup.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - -CN1 Syndicator - - - -

Codename One Syndicator

- - -
-

Pending

-
-

Completed

-
-

State patch (paste into syndication-state.json)

-
(none yet)
- - - diff --git a/scripts/syndication-extension/popup.js b/scripts/syndication-extension/popup.js deleted file mode 100644 index 4b47ea7c33..0000000000 --- a/scripts/syndication-extension/popup.js +++ /dev/null @@ -1,59 +0,0 @@ -// Popup UI: list pending + completed syndication tasks, surface a JSON -// patch the user can drop into scripts/website/syndication-state.json -// to record the syndication results in the repo. - -async function refresh() { - const data = await chrome.storage.local.get(["pending_tasks", "completed_tasks", "last_poll"]); - const pending = data.pending_tasks || []; - const completed = data.completed_tasks || []; - const lastPoll = data.last_poll || "never"; - - document.getElementById("status").textContent = `Last poll: ${lastPoll}`; - document.getElementById("pending").innerHTML = - pending.length === 0 - ? '
none
' - : pending.map((t) => `
${t.site}: ${t.slug}
`).join(""); - document.getElementById("completed").innerHTML = - completed.length === 0 - ? '
none
' - : completed - .map( - (c) => - `
${c.site}: ${c.slug}
` + - `${c.success ? "OK" : "FAIL"} ` + - `${c.url || "(no url)"}` + - `${c.error ? `
${c.error}` : ""}` + - `
${c.completed_at}
` - ) - .join(""); - - // Build a JSON patch grouped by slug - const patch = { posts: {} }; - for (const c of completed) { - if (!c.success) continue; - if (!patch.posts[c.slug]) patch.posts[c.slug] = {}; - patch.posts[c.slug][c.site] = { - url: c.url, - syndicated_at: c.completed_at, - }; - } - document.getElementById("state-patch").textContent = - completed.filter((c) => c.success).length === 0 ? "(none yet)" : JSON.stringify(patch, null, 2); -} - -document.getElementById("poll").addEventListener("click", async () => { - await chrome.runtime.sendMessage({ type: "poll-now" }).catch(() => {}); - // The background's onClicked handler also runs poll on action click; but - // the popup is its own action. Send a message AND fall back to invoking - // the alarm by triggering the browser action explicitly via chrome.alarms. - setTimeout(refresh, 500); -}); - -document.getElementById("clear").addEventListener("click", async () => { - await chrome.storage.local.set({ completed_tasks: [] }); - refresh(); -}); - -// Make the popup poll immediately when opened -chrome.runtime.sendMessage({ type: "poll-now" }).catch(() => {}); -refresh();