From fd303f6f2bc4ef6fd7cc08e4dece7d413d1e7549 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:35:56 +0300 Subject: [PATCH] Preserve social acquisition and coordinate LinkedIn Page publishing --- .github/workflows/website-docs.yml | 2 + AGENTS.md | 9 ++ docs/website/assets/js/cn1-acquisition.js | 84 ++++++++++++ docs/website/assets/js/cn1-crisp.js | 2 + docs/website/layouts/partials/footer.html | 2 + docs/website/scripts/test-acquisition.mjs | 65 +++++++++ scripts/website/SOCIAL_OPERATIONS.md | 97 +++++++++++++ .../install_linkedin_coordinator_guard.py | 67 +++++++++ scripts/website/linkedin_page_calendar.py | 129 ++++++++++++++++++ scripts/website/social_campaigns.py | 86 ++++++++++++ scripts/website/test_social_operations.py | 118 ++++++++++++++++ 11 files changed, 661 insertions(+) create mode 100644 docs/website/assets/js/cn1-acquisition.js create mode 100644 docs/website/scripts/test-acquisition.mjs create mode 100644 scripts/website/SOCIAL_OPERATIONS.md create mode 100644 scripts/website/install_linkedin_coordinator_guard.py create mode 100644 scripts/website/linkedin_page_calendar.py create mode 100644 scripts/website/social_campaigns.py create mode 100644 scripts/website/test_social_operations.py diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 9978768ed55..71b7470569f 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -355,6 +355,8 @@ jobs: set -euo pipefail node docs/website/scripts/test-build-first-funnel.mjs docs/website/public node docs/website/scripts/test-cn1-crisp-events.mjs + node docs/website/scripts/test-acquisition.mjs + python3 -m unittest discover -s scripts/website -p test_social_operations.py node docs/website/scripts/test-initializr-download-beacon.mjs node docs/website/scripts/test-initializr-chat-clearance.mjs diff --git a/AGENTS.md b/AGENTS.md index e9647cc67bc..c622c5beacc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,14 @@ # Agent Instructions +## LinkedIn Page coordination + +All Codename One Page publication, including ad hoc promotions, goes through the +existing social publishing chat and its private Page calendar. Do not publish +directly from another task or the legacy browser syndicator. Submit exact-copy +candidates for approval; a repository queue entry is not a calendar reservation +or a new approval. Read `scripts/website/SOCIAL_OPERATIONS.md` before proposing or +executing Page writes. Keep drafts, approvals and calendar data outside this repo. + ## Scope These instructions apply to the entire repository. They are aimed at helping Codex agents build and run the core unit tests that live under `maven/core-unittests`. diff --git a/docs/website/assets/js/cn1-acquisition.js b/docs/website/assets/js/cn1-acquisition.js new file mode 100644 index 00000000000..2edb5faf1f8 --- /dev/null +++ b/docs/website/assets/js/cn1-acquisition.js @@ -0,0 +1,84 @@ +(() => { + // First tagged acquisition, not internal topic-interest or last-click tracking. + const KEY = "cn1-acquisition-v1"; + const TTL = 24 * 60 * 60 * 1000; + const FIELDS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"]; + const VALUE = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/; + const ENTRY_PATHS = new Set(["/console", "/console/", "/console/index.html", + "/secure", "/secure/", "/secure/index.html", "/secure/signup", "/login", "/register"]); + let touch = null; + const decorated = new Map(); + + function consent() { + const cookie = document.cookie.split(";").map(x => x.trim()) + .find(x => x.startsWith("cn1_crisp_consent_v2=")); + if (cookie) return cookie.slice(cookie.indexOf("=") + 1) === "accepted"; + try { return localStorage.getItem("cn1-crisp-consent-v2") === "accepted"; } + catch (_) { return false; } + } + + function valid(candidate) { + return candidate && Number.isFinite(candidate.at) && candidate.at <= Date.now() && + Date.now() - candidate.at < TTL && candidate.tags && + ["social", "community", "oss", "email"].includes(candidate.tags.utm_medium) && + VALUE.test(candidate.tags.utm_source || "") && VALUE.test(candidate.tags.utm_campaign || "") && + Object.entries(candidate.tags).every(([k, v]) => FIELDS.includes(k) && VALUE.test(v)); + } + + function capture() { + if (!consent()) { + touch = null; + try { sessionStorage.removeItem(KEY); } catch (_) { /* storage unavailable */ } + return null; + } + if (valid(touch)) return touch; + try { touch = JSON.parse(sessionStorage.getItem(KEY)); } catch (_) { touch = null; } + if (valid(touch)) return touch; + touch = null; + const params = new URLSearchParams(window.location.search); + const tags = {}; + for (const field of FIELDS) { + if (params.has(field)) tags[field] = params.get(field); + } + const candidate = { at: Date.now(), tags }; + if (valid(candidate)) touch = candidate; + try { + if (touch) sessionStorage.setItem(KEY, JSON.stringify(touch)); + else sessionStorage.removeItem(KEY); + } catch (_) { /* retain this page's touch when storage is unavailable */ } + return touch; + } + + function decorate(link) { + const current = link.getAttribute("href"); + const previous = decorated.get(link); + // Other scripts (for example pricing) may have changed the destination. + const original = previous && previous.result === current ? previous.original : current; + if (previous && previous.result === current) link.setAttribute("href", original); + decorated.delete(link); + const acquisition = capture(); + if (!acquisition || !original) return; + let url; + try { url = new URL(original, window.location.href); } catch (_) { return; } + if (url.origin !== "https://cloud.codenameone.com" || !ENTRY_PATHS.has(url.pathname)) return; + // Replace the whole acquisition tuple, never mix a later CTA campaign with first touch. + FIELDS.forEach(field => url.searchParams.delete(field)); + Object.entries(acquisition.tags).forEach(([key, value]) => url.searchParams.set(key, value)); + const result = url.href; + link.setAttribute("href", result); + decorated.set(link, { original, result }); + } + + function refresh() { + capture(); + document.querySelectorAll("a[href]").forEach(decorate); + } + refresh(); + // Covers newly created CTAs, pricing switches, keyboard clicks and middle-clicks. + ["click", "auxclick", "contextmenu"].forEach(type => document.addEventListener(type, event => { + const link = event.target.closest?.("a[href]"); + if (link) decorate(link); + }, true)); + window.addEventListener("cn1-consent-changed", refresh); + window.addEventListener("pageshow", refresh); +})(); diff --git a/docs/website/assets/js/cn1-crisp.js b/docs/website/assets/js/cn1-crisp.js index 76b1ac82807..4e4f8b701e5 100644 --- a/docs/website/assets/js/cn1-crisp.js +++ b/docs/website/assets/js/cn1-crisp.js @@ -299,6 +299,7 @@ const acceptConsent = () => { setConsent("accepted"); + window.dispatchEvent?.(new Event("cn1-consent-changed")); closeBanner(); loadCrisp(); reportOssArrival(); @@ -307,6 +308,7 @@ const declineConsent = () => { setConsent("declined"); + window.dispatchEvent?.(new Event("cn1-consent-changed")); clearPendingEvents(); closeBanner(); hideCrisp(); diff --git a/docs/website/layouts/partials/footer.html b/docs/website/layouts/partials/footer.html index 59e25f6e859..6cbe23b4f5e 100644 --- a/docs/website/layouts/partials/footer.html +++ b/docs/website/layouts/partials/footer.html @@ -79,6 +79,7 @@

Community

{{- $cn1Demo := resources.Get "js/cn1-demo.js" | minify | fingerprint }} {{- $cn1Pricing := resources.Get "js/cn1-pricing.js" | minify | fingerprint }} {{- $cn1Crisp := resources.Get "js/cn1-crisp.js" | minify | fingerprint }} +{{- $cn1Acquisition := resources.Get "js/cn1-acquisition.js" | minify | fingerprint }} {{- $cn1PortStatus := resources.Get "js/cn1-port-status.js" | minify | fingerprint }} {{- if or (hasPrefix (.RelPermalink | default "") "/demo-") (eq .Layout "demos") }} @@ -88,6 +89,7 @@

Community

{{- end }} + {{- if eq .Layout "port-status" -}} {{- end -}} diff --git a/docs/website/scripts/test-acquisition.mjs b/docs/website/scripts/test-acquisition.mjs new file mode 100644 index 00000000000..d8665a5b7bf --- /dev/null +++ b/docs/website/scripts/test-acquisition.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +const source = fs.readFileSync(new URL('../assets/js/cn1-acquisition.js', import.meta.url), 'utf8'); +const KEY = 'cn1-acquisition-v1'; +const values = new Map(); +const store = { getItem: k => values.get(k) || null, setItem: (k,v) => values.set(k,v), removeItem: k => values.delete(k) }; +const tags = '?utm_source=bluesky&utm_medium=social&utm_campaign=social-2026-10&utm_content=2026-10-20-v2-b1'; +const link = href => ({ href, getAttribute() { return this.href; }, setAttribute(_,v) { this.href=v; }, closest() { return this; } }); +function load(search=tags, consent='accepted', storage=store) { + const links = [link('https://cloud.codenameone.com/console/'), + link('https://cloud.codenameone.com/secure/signup?level=pro&mode=annual#pay'), + link('https://cloud.codenameone.com/register'), link('https://evil.example/console/'), + link('/pricing/'), link('https://cloud.codenameone.com.evil.example/console/'), + link('https://cloud.codenameone.com/api/v2/build?utm_source=untouched')]; + const events = {}, winEvents = {}; + const document = { cookie: `cn1_crisp_consent_v2=${consent}`, querySelectorAll: () => links, + addEventListener: (k,v) => events[k]=v }; + const window = { location: { search, href:'https://www.codenameone.com/blog/demo/'+search }, + addEventListener: (k,v) => winEvents[k]=v }; + vm.runInNewContext(source, {window,document,sessionStorage:storage,localStorage:store,URL,URLSearchParams,Date,Map,Set}); + return {links,events,winEvents,document}; +} +function content(l) { return new URL(l.href).searchParams.get('utm_content'); } +values.clear(); +let s=load(); +assert.equal(content(s.links[0]),'2026-10-20-v2-b1'); +assert.equal(new URL(s.links[1].href).searchParams.get('level'),'pro'); +assert.equal(new URL(s.links[1].href).hash,'#pay'); +assert.equal(content(s.links[2]),'2026-10-20-v2-b1'); +assert.equal(s.links[3].href,'https://evil.example/console/'); +assert.equal(s.links[4].href,'/pricing/'); +assert.equal(s.links[5].href,'https://cloud.codenameone.com.evil.example/console/'); +assert.equal(s.links[6].href,'https://cloud.codenameone.com/api/v2/build?utm_source=untouched'); +assert.equal(content(load('').links[0]),'2026-10-20-v2-b1','survives internal navigation'); +assert.equal(content(load(tags.replace('bluesky','x').replace('-b1','-x1')).links[0]),'2026-10-20-v2-b1','first touch survives later campaign'); +s.links[1].href='https://cloud.codenameone.com/secure/signup?level=pro&mode=monthly'; +s.events.auxclick({target:s.links[1]}); +assert.equal(new URL(s.links[1].href).searchParams.get('mode'),'monthly'); +assert.equal(content(s.links[1]),'2026-10-20-v2-b1'); +s.document.cookie='cn1_crisp_consent_v2=declined'; +s.winEvents['cn1-consent-changed'](); +assert.equal(content(s.links[0]),null,'withdrawal restores untagged links'); +assert.equal(values.has(KEY),false); +for (const consent of ['declined','']) { + s=load(tags,consent); + assert.equal(content(s.links[0]),null); + assert.equal(values.has(KEY),false); +} +s=load(tags,''); +s.document.cookie='cn1_crisp_consent_v2=accepted'; +s.winEvents['cn1-consent-changed'](); +assert.equal(content(s.links[0]),'2026-10-20-v2-b1','consent acceptance on landing captures tags'); +const old=JSON.parse(values.get(KEY));old.at=Date.now()-25*3600000;values.set(KEY,JSON.stringify(old)); +assert.equal(content(load('').links[0]),null,'expired touch not forwarded'); +values.clear(); +assert.equal(content(load('?utm_source=x&utm_medium=social&utm_campaign=bad%20campaign').links[0]),null); +assert.equal(content(load('?utm_source=codenameone&utm_medium=website&utm_campaign=homepage').links[0]),null,'internal topic click not acquisition'); +const blocked={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}}; +assert.equal(content(load(tags,'accepted',blocked).links[0]),'2026-10-20-v2-b1','current-page fallback'); +values.clear(); +const mixed=load();mixed.links[0].href='https://cloud.codenameone.com/console/?utm_source=other&utm_term=other'; +mixed.events.click({target:mixed.links[0]}); +assert.equal(new URL(mixed.links[0].href).searchParams.get('utm_term'),null,'never mix attribution tuples'); +console.log('Acquisition navigation, consent, expiry, dynamic links and origin-boundary checks passed'); diff --git a/scripts/website/SOCIAL_OPERATIONS.md b/scripts/website/SOCIAL_OPERATIONS.md new file mode 100644 index 00000000000..c81971d62b2 --- /dev/null +++ b/scripts/website/SOCIAL_OPERATIONS.md @@ -0,0 +1,97 @@ +# Social attribution and Page coordination + +## A post to a first successful build + +Use `social_campaigns.py link` when preparing a draft, before exact-link approval: + +```sh +python3 scripts/website/social_campaigns.py link \ + --url https://www.codenameone.com/playground/ --source bluesky \ + --campaign social-2026-10 --item 2026-10-20-v2-b1 +``` + +`utm_source` distinguishes the account/channel; `utm_campaign` groups the campaign; +`utm_content` is the unique immutable batch-version/item ID. Keep a private mapping +to the final platform permalink. Never put emails, handles of readers, or user IDs +in these parameters. Revising a reviewed link requires review of that version. +Already published/scheduled items are not silently retagged. + +The website's `cn1-acquisition.js` preserves the first tagged acquisition for +24 hours in consent-gated session storage, across internal navigation. It forwards +the tuple only to allowlisted HTTPS BuildCloud entry links, including dynamic +pricing links, preserving the plan and fragment. Internal `utm_medium=website` +topic-interest links cannot replace acquisition. Declining analytics clears the +stored touch and restores decorated links. No consent, blocked storage after +navigation, another device/browser, a closed tab or an expired touch creates a +coverage gap, not evidence of an organic acquisition. + +BuildCloud already captures console UTMs in the authentication session and claims +them once for a newly created account (password and federated auth). Its direct +`/login`, `/register` and `/secure/signup` paths must also capture the entry; +deploy the matching BuildCloud change before calling those journeys verified. +Existing-user logins are not new acquisitions. The existing +`/api/v2/console/admin/funnel-cohorts` aggregate groups by source, medium, campaign +and content and joins durable successful build evidence. No new user-level export +or tracking service is needed. + +During the authorized overnight analytics session, save the existing read-only +admin aggregate response privately, with collection time and deployment versions. +Then run: + +```sh +python3 scripts/website/social_campaigns.py report /private/path/funnel.json --window d30 +``` + +The output retains only aggregate fields. `successful` counts new users in a +signup cohort who have achieved a successful build, even after a failed first +attempt; it is not `firstAttemptSucceeded`, build volume, or a causal result. +Rolling 30/90-day signup cohorts are not seven-day activation event windows. +Report unknown if diagnostics are missing, and flag disagreement with headline +activation rather than silently choosing a favorable count. Declined consent, +missing UTM identities, and historical untagged posts cannot be reconstructed. + +## One LinkedIn Page publisher + +The existing overnight social publishing workflow owns all Page writes, including +ad hoc promotions. Other tasks and repository social artifacts supply candidates; +they must not publish independently. The legacy local browser driver delegates +LinkedIn to this coordinator before opening a browser. Article syndication is +unchanged. Source-date-only repository tasks are not rolled to a new date or +silently approved by being present in the queue. + +Private state lives under the social automation's `resources/` directory: +`linkedin-page-calendar.json` plus the existing approval/publication ledger. The +calendar is not approval evidence. Every action still requires the human approval +in the publishing chat. Scripts and backups may live there; no private state is +committed here. + +Before any Page composer or schedule action, within the existing Firefox window: + +1. Acquire the existing single-browser-worker lock. Verify the Page identity. +2. Read the actual live Page feed AND all native scheduled posts, other pending + private batches and repository syndication queue/state. Record every relevant + outside post/schedule with its actual public instant and permalink/ID. Resolve + unknown timestamps before proceeding. Previously scheduled items need + verification, never a second publication. +3. Update the calendar using `record`. Confirm cancellations remotely before + recording `cancelled`. A reservation left by a crash remains blocking until + reconciled. Record any collision even if a different producer caused it. +4. Run `audit` with the observation time, evidence file and the inspected time + range. It must cover at least 20 hours before AND after the target publication, + including future native schedules. Never invent a fresh audit from old state. +5. Run `reserve --id BATCH-ITEM --at OFFSET-TIMESTAMP --approval-ref CHAT-EVIDENCE`. + The tool atomically rejects another reservation within 20 hours, stale native + evidence, missed times and duplicate IDs. Planning `check` is not permission + to publish. The normal interval remains 24 hours. +6. Only after success perform the exact approved native action. Immediately + `record --status scheduled|published --url VERIFIED-URL-OR-ID --evidence FILE` + using that same ID/instant and update the publication ledger. If the outcome + is ambiguous, retain the reservation and inspect remotely before retrying. + +All commands take `--calendar /private/path/linkedin-page-calendar.json` before +the subcommand. An absent or corrupt calendar fails closed. Do not invent an empty +replacement. The CLI never moves, cancels or publishes remote content. A collision +means defer the candidate for a new approved time, not reshuffle approved posts. +Manual writes outside the coordinator cannot be prevented by a local script; the +next native audit must reconcile them. No browser work is permitted during the +day merely to refresh this calendar. diff --git a/scripts/website/install_linkedin_coordinator_guard.py b/scripts/website/install_linkedin_coordinator_guard.py new file mode 100644 index 00000000000..7ce937ed504 --- /dev/null +++ b/scripts/website/install_linkedin_coordinator_guard.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Install the local legacy-runner LinkedIn handoff; never starts a browser. + +The runner lives outside this repository. Keep a private backup and diff before +installing. Article platforms keep their existing behavior; queue/state are untouched. +""" +import argparse +import difflib +from pathlib import Path + +MARKER = "LINKEDIN_COORDINATOR_HANDOFF_V1" +MESSAGE = "LinkedIn is owned by the overnight Firefox coordinator; submit this queue item for exact approval there." + + +def guarded(source): + if MARKER in source: + return source + anchors = [ + (' site = task["site"]\n driver_fn = DRIVERS.get(site)', + f' # {MARKER}: never launch the legacy browser for LinkedIn.\n' + ' site = task["site"]\n' + ' if site == "linkedin":\n' + f' return False, None, {MESSAGE!r}\n' + ' driver_fn = DRIVERS.get(site)'), + (' """Publish one due LinkedIn post as Shai or the Codename One page."""\n', + ' """Legacy entry retained for callers; all LinkedIn writes are centralized."""\n' + f' raise RuntimeError({MESSAGE!r})\n'), + (' pending = [t for t in unfinished if is_due(t, now)]', + ' delegated = [t for t in unfinished if t.get("site") == "linkedin"]\n' + ' for t in delegated:\n' + f' print("Delegated " + t["id"] + ": " + {MESSAGE!r})\n' + ' unfinished = [t for t in unfinished if t.get("site") != "linkedin"]\n' + ' pending = [t for t in unfinished if is_due(t, now)]'), + ] + for before, after in anchors: + if source.count(before) != 1: + raise ValueError("Runner changed: inspect before installing; no files modified") + source = source.replace(before, after) + compile(source, '', 'exec') + return source + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('runner', type=Path) + p.add_argument('--backup-dir', type=Path, required=True) + p.add_argument('--apply', action='store_true') + a = p.parse_args() + before = a.runner.read_text() + after = guarded(before) + if before == after: + print('Coordinator handoff already installed') + return + diff = ''.join(difflib.unified_diff(before.splitlines(True), after.splitlines(True), + fromfile=str(a.runner), tofile=str(a.runner))) + if not a.apply: + print(diff) + return + a.backup_dir.mkdir(parents=True, exist_ok=True) + backup = a.backup_dir / 'syndicate_browser.before-linkedin-handoff.py' + with backup.open('x') as f: f.write(before) + (a.backup_dir / 'linkedin-handoff.patch').write_text(diff) + a.runner.write_text(after) + print('Installed coordinator handoff; backup: ' + str(backup)) + + +if __name__ == '__main__': main() diff --git a/scripts/website/linkedin_page_calendar.py b/scripts/website/linkedin_page_calendar.py new file mode 100644 index 00000000000..e7ed7a46ddf --- /dev/null +++ b/scripts/website/linkedin_page_calendar.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Private, atomic Page publication reservations. Never publishes or approves content. + +One coordinator owns Page writes; other producers submit candidates to it. +Reservations persist across crashes until remotely reconciled. Calendar files +must stay outside the public repository. Native feed + scheduled queue evidence +is required immediately before reserving a slot for a public write. +""" +import argparse +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +import fcntl +import json +import os +from pathlib import Path + +GAP = timedelta(hours=20) +FRESH = timedelta(minutes=15) + + +def instant(value): + d = datetime.fromisoformat(value.replace("Z", "+00:00")) + if d.tzinfo is None: + raise ValueError("A UTC offset is required") + return d.astimezone(timezone.utc) + + +def conflicts(state, item_id, at): + at = instant(at) + return [s for s in state["slots"] if s["id"] != item_id and s["status"] != "cancelled" + and abs(at - instant(s["at"])) < GAP] + + +def reserve(state, item_id, at, approval, now): + audit = state.get("native_audit", {}) + if not audit or not timedelta(0) <= now - instant(audit["observed_at"]) <= FRESH: + raise ValueError("Fresh native Page feed AND scheduled-post audit required (15 minutes)") + target = instant(at) + if target < now - timedelta(minutes=1): + raise ValueError("Missed publication window; do not roll forward") + if instant(audit["from"]) > target - GAP or instant(audit["through"]) < target + GAP: + raise ValueError("Native audit must cover the 20 hours before AND after publication") + existing = next((s for s in state["slots"] if s["id"] == item_id), None) + if existing: + if existing["at"] != at or existing["status"] == "cancelled": + raise ValueError("Existing ID cannot be moved or revived; review a new version") + raise ValueError("Item already recorded/reserved; verify remotely, never publish again") + collisions = conflicts(state, item_id, at) + if collisions: + raise ValueError("Page cadence collision: " + ", ".join(s["id"] for s in collisions)) + if not approval.strip(): + raise ValueError("Human approval reference required; calendar is not authorization") + row = {"id": item_id, "at": at, "status": "reserved", "approval_ref": approval, + "recorded_at": now.isoformat()} + state["slots"].append(row) + return row + + +@contextmanager +def calendar(path): + # Never start with an empty calendar just because the real file disappeared. + path = Path(path) + with path.with_suffix(path.suffix + ".lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + with path.open() as f: + state = json.load(f) + if state.get("account") != "linkedin:5041007" or not isinstance(state.get("slots"), list): + raise ValueError("Wrong or malformed Page calendar") + yield state + temp = path.with_suffix(path.suffix + f".{os.getpid()}.tmp") + try: + temp.write_text(json.dumps(state, indent=2) + "\n") + temp.replace(path) + finally: + temp.unlink(missing_ok=True) + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--calendar", required=True) + sub = p.add_subparsers(dest="command", required=True) + for cmd in ("check", "reserve", "record"): + c = sub.add_parser(cmd) + c.add_argument("--id", required=True) + c.add_argument("--at", required=True) + if cmd == "reserve": c.add_argument("--approval-ref", required=True) + if cmd == "record": + c.add_argument("--status", choices=["published", "scheduled", "cancelled"], required=True) + c.add_argument("--evidence", required=True) + c.add_argument("--url", required=True) + c = sub.add_parser("audit") + for name in ("observed-at", "from", "through", "evidence"): + c.add_argument("--" + name, required=True) + a = p.parse_args() + now = datetime.now(timezone.utc) + try: + with calendar(a.calendar) as state: + if a.command == "audit": + if not timedelta(0) <= now - instant(a.observed_at) <= FRESH: + raise ValueError("Audit must describe a current native observation") + if instant(a.__dict__["from"]) >= instant(a.through): + raise ValueError("Invalid audit coverage") + state["native_audit"] = {"observed_at": a.observed_at, "from": a.__dict__["from"], + "through": a.through, "evidence": a.evidence} + result = state["native_audit"] + elif a.command == "check": + result = {"conflicts": conflicts(state, a.id, a.at), "planning_only": True} + elif a.command == "reserve": + result = reserve(state, a.id, a.at, a.approval_ref, now) + else: + instant(a.at) + existing = next((s for s in state["slots"] if s["id"] == a.id), None) + if existing is not None and existing["at"] != a.at: + raise ValueError("Timing changed; reconcile explicitly with a new observation ID") + row = {"id": a.id, "at": a.at, "status": a.status, "url": a.url, + "evidence": a.evidence, "recorded_at": now.isoformat()} + # Record outside publications even if they reveal a collision. + if existing is not None: existing.update(row) + else: state["slots"].append(row) + result = row + print(json.dumps(result, indent=2)) + if a.command == "check" and result["conflicts"]: return 1 + return 0 + except (ValueError, KeyError, OSError) as e: + p.exit(2, str(e) + "\n") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/website/social_campaigns.py b/scripts/website/social_campaigns.py new file mode 100644 index 00000000000..8e853a792f1 --- /dev/null +++ b/scripts/website/social_campaigns.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Build reviewed social links and summarize existing aggregate BuildCloud exports. + +No network, credentials, public publishing, or raw user export is involved. +""" +import argparse +import json +import re +from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode + +SOURCES = {"bluesky", "mastodon", "x", "linkedin-page", "linkedin-shai", + "youtube-codenameone", "youtube-debugagent", "reddit"} +TOKEN = re.compile(r"^[a-z0-9][a-z0-9_.:-]{0,127}$") +FIELDS = ("signedUp", "reachedConsole", "attempted", "successful", "active30d") + + +def campaign_link(url, source, campaign, item): + parts = urlsplit(url) + if parts.scheme != "https" or parts.netloc not in {"www.codenameone.com", "codenameone.com", "cloud.codenameone.com"}: + raise ValueError("Only canonical Codename One HTTPS destinations may be tagged") + if source not in SOURCES or not TOKEN.fullmatch(campaign) or not TOKEN.fullmatch(item): + raise ValueError("Invalid source, campaign or stable batch/item ID") + query = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if not k.startswith("utm_")] + query += [("utm_source", source), ("utm_medium", "community" if source == "reddit" else "social"), + ("utm_campaign", campaign), ("utm_content", item)] + return urlunsplit(parts._replace(query=urlencode(query))) + + +def summarize(payload, window): + funnel = payload.get("funnelCohorts", payload) + w = funnel.get("windows", {}).get(window) + if not isinstance(w, dict) or w.get("diagnosticsAvailable") is False: + raise ValueError("Requested cohort/attribution diagnostics unavailable; do not report zero") + a = w.get("acquisitionAttribution") + if not isinstance(a, dict) or not isinstance(a.get("byCampaign"), list): + raise ValueError("Aggregate campaign rows missing; do not report zero") + rows, all_signups, all_successes = [], 0, 0 + for c in a["byCampaign"]: + if any(type(c.get(k)) is not int or c[k] < 0 for k in FIELDS): + raise ValueError("Incomplete or invalid campaign counts") + if not c["successful"] <= c["attempted"] <= c["signedUp"]: + raise ValueError("Campaign funnel counts do not reconcile") + all_signups += c["signedUp"] + all_successes += c["successful"] + if c.get("medium") not in {"social", "community"} or c.get("source") not in SOURCES: + continue + # Explicit field allowlist: never carry recent-user identities into reports. + rows.append({k: c.get(k) for k in ("source", "medium", "campaign", "content", *FIELDS)}) + eligible = a.get("eligibleSignups") + if type(eligible) is not int or eligible != all_signups: + raise ValueError("Campaign signup denominator does not reconcile") + headline = w.get("activated") + return {"cohort_window": window, "days": w.get("days"), "eligible_signups": eligible, + "attribution_rows": a.get("attributed"), "coverage_pct": a.get("coveragePct"), + "campaign_successes_all_sources": all_successes, "headline_activated": headline, + "successes_reconcile_with_headline": None if headline is None else headline == all_successes, + "social_posts": rows, + "social_attributed_new_users_with_successful_build": sum(c["successful"] for c in rows), + "definition": "Unique new users in this signup cohort with at least one observed successful build; not build counts, click conversions, or causal lift.", + "limitations": ["First-touch attribution; missing/declined/expired/cross-device journeys remain unassigned.", + "Rolling signup cohort outcomes as of export, not first-build events occurring in the reporting week.", + "A first attempt may fail before the first successful build. Do not use firstAttemptSucceeded as activation."]} + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest="command", required=True) + link = sub.add_parser("link") + for name in ("url", "source", "campaign", "item"): + link.add_argument("--" + name, required=True) + report = sub.add_parser("report") + report.add_argument("export", help="Private saved aggregate funnel-cohorts JSON") + report.add_argument("--window", default="d30", choices=["d30", "d90"]) + a = p.parse_args() + try: + if a.command == "link": + print(campaign_link(a.url, a.source, a.campaign, a.item)) + else: + with open(a.export) as f: + print(json.dumps(summarize(json.load(f), a.window), indent=2)) + except (ValueError, OSError) as e: + p.exit(2, str(e) + "\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/website/test_social_operations.py b/scripts/website/test_social_operations.py new file mode 100644 index 00000000000..a8fa3a6bbf5 --- /dev/null +++ b/scripts/website/test_social_operations.py @@ -0,0 +1,118 @@ +import copy +from datetime import datetime, timezone, timedelta +import json +from pathlib import Path +import tempfile +import unittest + +from linkedin_page_calendar import calendar, conflicts, reserve +from social_campaigns import campaign_link, summarize +from install_linkedin_coordinator_guard import guarded + + +class CalendarTests(unittest.TestCase): + def setUp(self): + self.now = datetime(2026, 10, 19, 23, 5, tzinfo=timezone.utc) + self.state = {"account": "linkedin:5041007", "slots": [], "native_audit": { + "observed_at": self.now.isoformat(), "from": "2026-10-18T00:00:00Z", + "through": "2026-10-23T00:00:00Z", "evidence": "feed and scheduled queue"}} + + def test_catches_future_native_schedule_and_published_post(self): + self.state['slots'] = [{"id": "outside-producer", "at": "2026-10-20T15:30:00+03:00", "status": "scheduled"}] + with self.assertRaisesRegex(ValueError, 'collision'): + reserve(self.state, 'tabs', '2026-10-20T20:30:00+03:00', 'chat approval', self.now) + self.state['slots'][0]['status'] = 'published' + self.assertTrue(conflicts(self.state, 'tabs', '2026-10-20T20:30:00+03:00')) + + def test_exact_twenty_hours_and_timezone_offsets(self): + self.state['slots'] = [{"id": "a", "at": "2026-10-20T02:00:00+03:00", "status": "published"}] + self.assertFalse(conflicts(self.state, 'b', '2026-10-20T19:00:00Z')) + self.assertTrue(conflicts(self.state, 'b', '2026-10-20T18:59:59Z')) + # Fall-back DST offsets are compared as instants, not local wall times. + self.state['slots'][0]['at'] = '2026-10-24T20:30:00+03:00' + self.assertFalse(conflicts(self.state, 'b', '2026-10-25T16:30:00+02:00')) + + def test_crashed_reservation_blocks_retry_and_other_producers(self): + reserve(self.state, 'a', '2026-10-20T20:30:00+03:00', 'chat approval', self.now) + for item in ['a', 'b']: + with self.assertRaises(ValueError): + reserve(self.state, item, '2026-10-20T20:30:00+03:00', 'chat approval', self.now) + + def test_stale_audit_missing_future_coverage_and_naive_time_fail_closed(self): + with self.assertRaisesRegex(ValueError, 'Fresh'): + reserve(self.state, 'a', '2026-10-20T20:30:00+03:00', 'approval', self.now+timedelta(minutes=16)) + self.state['native_audit']['through'] = '2026-10-20T22:00:00+03:00' + with self.assertRaisesRegex(ValueError, 'before AND after'): + reserve(self.state, 'a', '2026-10-20T20:30:00+03:00', 'approval', self.now) + with self.assertRaises(ValueError): conflicts(self.state, 'a', '2026-10-20T20:30:00') + + def test_atomic_persistence_and_missing_calendar(self): + with tempfile.TemporaryDirectory() as td: + path = Path(td)/'calendar.json' + with self.assertRaises(FileNotFoundError): + with calendar(path): pass + path.write_text(json.dumps(self.state)) + with calendar(path) as state: + reserve(state, 'a', '2026-10-20T20:30:00+03:00', 'approval', self.now) + with self.assertRaises(ValueError): + with calendar(path) as state: + reserve(state, 'b', '2026-10-20T21:30:00+03:00', 'approval', self.now) + self.assertEqual(['a'],[s['id'] for s in json.loads(path.read_text())['slots']]) + + +class AttributionTests(unittest.TestCase): + def test_link_preserves_destination_and_has_unique_item(self): + result=campaign_link('https://www.codenameone.com/demo/?mode=java#code','linkedin-page','social-2026-10','2026-10-20-v2-l1') + self.assertIn('mode=java&',result) + self.assertIn('utm_content=2026-10-20-v2-l1',result) + self.assertTrue(result.endswith('#code')) + with self.assertRaises(ValueError): campaign_link('https://evil.example/','x','campaign','post') + + def payload(self): + return {'windows':{'d30':{'days':30,'activated':1,'diagnosticsAvailable':True,'acquisitionAttribution':{ + 'eligibleSignups':2,'attributed':1,'coveragePct':50,'byCampaign':[ + {'source':'x','medium':'social','campaign':'social-2026-10','content':'2026-10-20-v2-x1', + 'signedUp':1,'reachedConsole':1,'attempted':1,'successful':1,'active30d':1,'email':'NEVER EXPORT'}, + {'source':'unattributed','medium':'','campaign':'','content':'','signedUp':1,'reachedConsole':0,'attempted':0,'successful':0,'active30d':0}]}}}} + + def test_aggregate_activation_and_identity_allowlist(self): + report=summarize(self.payload(),'d30') + self.assertEqual(1,report['social_attributed_new_users_with_successful_build']) + self.assertTrue(report['successes_reconcile_with_headline']) + self.assertNotIn('NEVER EXPORT',json.dumps(report)) + p=self.payload();p['windows']['d30']['activated']=0 + self.assertFalse(summarize(p,'d30')['successes_reconcile_with_headline']) + + def test_missing_diagnostics_is_unknown_not_zero(self): + for p in [{}, {'windows': {'d30': {'diagnosticsAvailable':False}}}]: + with self.assertRaises(ValueError):summarize(p,'d30') + p=self.payload();del p['windows']['d30']['acquisitionAttribution']['byCampaign'][0]['successful'] + with self.assertRaises(ValueError): summarize(p,'d30') + + +class LegacyHandoffTests(unittest.TestCase): + def test_guard_blocks_before_any_browser_initialization(self): + source = ''' +DRIVERS = {} +def process_linkedin(driver, task, log): + """Publish one due LinkedIn post as Shai or the Codename One page.""" + raise AssertionError("browser reached") +def process_task(task, log, profile_dir, chrome_version): + site = task["site"] + driver_fn = DRIVERS.get(site) + raise AssertionError("browser reached") +def main(): + if True: + pending = [t for t in unfinished if is_due(t, now)] +''' + code = guarded(source) + self.assertEqual(code, guarded(code)) + ns={};exec(code,ns) + self.assertFalse(ns['process_task']({'site':'linkedin'}, None, None, None)[0]) + with self.assertRaisesRegex(RuntimeError, 'coordinator'): + ns['process_linkedin'](None,{},None) + with self.assertRaises(AssertionError): + ns['process_task']({'site':'medium'},None,None,None) + + +if __name__ == '__main__': unittest.main()