Personalizer docs
Everything you need to know to feed the Personalizer a CSV and get back N self-contained personalized HTMLs that live anywhere. Includes API reference for power users.
What this is
Personalizer is a Cloudflare-hosted tool that takes one HTML template +
one CSV of locations and produces N personalized HTML files, one per row,
with city/state/neighborhood substitutions, JSON-LD area-served updates, and an automatic QA pass. Output is
always self-contained — every CSS, font, image, and SVG sprite is inlined as a data: URI so the
HTML works in any CMS Custom HTML module.
Clone vs Upload
The URL clone path does a static fetch of your source page and inlines its CSS, fonts, images, and SVG sprite. That's perfect for traditional server-rendered sites (WordPress, custom-coded landing pages, classic CMSes). It's not always sufficient for sites that hydrate via JavaScript after load.
When the URL clone is enough
- WordPress / Webflow / Squarespace landing pages with normal HTML
- Custom-coded marketing sites that ship rendered HTML
- Any page where "View source" already shows the visible content
When you need Upload Template instead
-
Single-page apps (Next.js, Nuxt, Vue, React, Svelte) where View Source shows
<div id="root"></div>and content fills in via JS - Pages with cookie consent banners that must render on the variant for legal compliance
- Pages with chat widgets, embedded forms (HubSpot, Calendly), or maps that need to render reliably
- Anything where the static clone shows missing icons, empty sections, or broken menus
For these cases, run scripts/site_duplicator.py locally (it uses a real headless browser, so it
captures the post-hydration DOM), then click Upload Template in the app and select the
resulting HTML. The resulting clone is byte-identical to what a visitor sees.
browser vs static)
so you always know what you got.
Caveats the cloner detects automatically
After every clone, the active template panel surfaces a list of caveats. They include:
- SPA framework detected — Next.js, Nuxt, React, Vue, Svelte, Remix, Angular
- Empty body — captured HTML has fewer than 80 visible characters (almost certainly an SPA shell)
- Embedded widgets detected — HubSpot forms, Calendly, Intercom, Drift, Zendesk, Tidio, Tally, Typeform, YouTube, Vimeo, Google Maps
- Cookie consent platform preserved — Cookiebot, OneTrust, Termly, Osano, Iubenda, CookieYes, TrustArc, Usercentrics, Didomi, Klaro, Quantcast, custom Cookielaw scripts
CSV format
The CSV is read with a quoted-field-aware parser. The first row is the header. The default columns:
| Column | Required | Description |
|---|---|---|
zip |
recommended | Postal code; used in the output filename and for default replacements. |
city_display |
yes | The city / neighborhood / area name shown to the visitor (e.g. "Downtown Phoenix"). |
state_abbr |
recommended | Two-letter state code (e.g. "AZ"). Combined with city_display in titles & JSON-LD. |
anchor_neighborhoods |
optional | Pipe-delimited list of "anchor" neighborhoods to feature. |
served_neighborhoods |
optional | Pipe-delimited list — used to rewrite any "Cities Served" / "Areas We Serve" <ul>. |
title |
optional | Override the <title> tag for this row. If absent, an auto title is generated. |
description |
optional |
Override meta name="description", og:description, and
twitter:description.
|
h1_text |
optional | Override the first <h1>. If absent, the source H1 is left as-is and other primitives handle the swap. |
banner |
optional | If set, injects a "personalized for you" banner at the top of <body>. |
replacements_json |
optional |
JSON array of [from, to] pairs applied as plain text replacements. Useful for phone
numbers, area codes, etc.
|
Plus the CSV editor's "Source city / Source state" inputs which are auto-rewritten to each row's target.
Sample CSV:
zip,city_display,state_abbr,anchor_neighborhoods,served_neighborhoods,title,description 85001,Downtown Phoenix,AZ,Roosevelt Row|Willo|Encanto,Downtown Phoenix|Roosevelt Row|Willo|Encanto|Coronado,"Trusted In-Home Care in Downtown Phoenix, AZ","Personalized in-home care across Downtown Phoenix and surrounding neighborhoods." 85016,Biltmore,AZ,Arcadia|Camelback East|Madison,Biltmore|Arcadia|Camelback East|Madison Heights,"Trusted In-Home Care in Biltmore, AZ","Personalized in-home care across Biltmore and surrounding neighborhoods."
Token reference
The engine applies primitives in this order; later primitives can rely on earlier ones:
-
Plain text replacements — most surgical, preserves entity encoding. Pairs come from
replacements_json+ the auto source-city/source-state pair. - <title> swap — replaces the visible content of the first <title> tag.
-
Meta description / OG / Twitter — order-agnostic regex; updates content of
name="description",property="og:title",name="twitter:title", etc. - First H1 swap — only when
h1_textis supplied. - Cities-served <ul> rewrite — finds the first heading containing "Cities Served" / "Service Area" / "Areas We Serve" and replaces every <li> in the next <ul>.
-
JSON-LD mutation — every
application/ld+jsonblock is parsed;areaServed,description, and LocalBusiness/Servicenameare updated; the block is re-serialized. - Optional banner — injected immediately after the opening <body> tag.
- Optional CSS overrides — appended just before </head>.
Engine pipeline
The engine is implemented in plain JavaScript, runs on Cloudflare Workers, and is a faithful port of
scripts/site_personalizer.py:
-
functions/lib/personalizer-cloner.js— fetches a public URL, inlines stylesheets/fonts/images/SVG sprite, pinswindow.location.host, strips known trackers (GA, GTM, FB pixel, Hotjar, Matomo, Adobe). functions/lib/personalizer-engine.js— applies the personalization primitives.functions/lib/personalizer-audit.js— runs the QA pass per page.functions/lib/streaming-zip.js— STORE-mode streaming ZIP encoder for the bulk download.
Audit checks
Every variant is audited automatically; results show in the right-hand "Audit summary" panel and on the Result tab.
- Title contains the row's city. Failure if missing.
- Meta description / OG / Twitter contain the city. Warning if missing.
- Body mentions the city at least 3 times. Warning otherwise.
- JSON-LD references the city somewhere. Warning otherwise.
-
SVG sprite integrity — if
<symbol>blocks are present,viewBoxmust be preserved (camelCase). Failure otherwise. -
Host pinning —
window.location.hostmust be rewritten to the source origin. -
Source-page leakage — terms passed via
leakTermsmust not appear unless they're in the row's own neighborhoods.
API reference
All endpoints live under /api/personalizer/*. Auth is required (Google sign-in), session ID via
X-Session-Id header or ?sessionId=… query param. Pricing & health are public.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/personalizer/me |
Current user, tier, monthly usage, limits. |
| GET | /api/personalizer/pricing |
Pricing card data (public). |
| GET | /api/personalizer/health |
Uptime probe (public). |
| POST | /api/personalizer/clone |
Body {url, name?} → {templateId, byteSize, warnings}. |
| POST | /api/personalizer/upload-template |
Multipart file upload. |
| GET | /api/personalizer/templates |
List your templates. |
| GET | /api/personalizer/template/:id |
Stream the template HTML (or ?mode=json for envelope). |
| DELETE | /api/personalizer/templates?id=… |
Soft-delete a template. |
| POST | /api/personalizer/preview |
Body {templateId, profile} → personalized HTML (no save, no quota). |
| POST | /api/personalizer/generate |
Body {templateId, profiles, sourceCity, sourceState, baseReplacements?} → manifest.
|
| GET | /api/personalizer/exports |
List your exports. |
| GET | /api/personalizer/exports/:id |
Manifest with per-variant audit + downloads. |
| GET | /api/personalizer/exports/:id/file/:name |
Stream a single variant HTML. |
| GET | /api/personalizer/exports/:id/zip |
Streaming ZIP of every variant + manifest. |
| POST | /api/personalizer/checkout |
Body {tier} → {checkoutUrl}. |
| POST | /api/personalizer/stripe-webhook |
Stripe → tier upgrade/downgrade. Signature-verified. |
Tiers & quota
| Tier | Price | Variants/mo | Templates | Best for |
|---|---|---|---|---|
| Free | $0 | 1 | 1 | Try-it tier |
| Solo | $29/mo | 20 | 5 | Solo marketers, single-location SMBs |
| Pro | $99/mo | 90 | 20 | In-house marketing teams |
| Agency | $299/mo | unlimited | unlimited | Multi-client agencies |
[email protected] is always treated as Agency tier — the site
dogfoods itself. Quotas reset on the first of each calendar month UTC. Billing handled by Stripe Checkout
(subscriptions).
FAQ
Is anything stored long-term?
Templates are kept in R2 until you delete them. Exports are kept for 7 days (Solo/Pro) or 30 days (Agency); the ZIP and per-variant HTML are removable on request. Audit results are kept indefinitely for analytics.
Why 6.5 MB per page?
Self-contained HTML inlines fonts (~500 KB), images (~3–4 MB), and an SVG sprite (~50 KB). The trade-off is "drop into any CMS module" portability. Output remains under HubSpot's 25 MB Custom HTML limit.
Can I run an open URL through the cloner?
Yes — Personalizer is the "Open URL" architecture from the build plan. We block private/internal hosts (SSRF protection) and require Google sign-in to prevent abuse.
What happens if a clone fails?
The endpoint returns 502 with the underlying error message. Common causes: 403 from the source server, exotic
frameworks (full SPA hydration), or an over-25-MB initial HTML body. In those cases, run
scripts/site_duplicator.py locally and use the "Upload self-contained HTML" path.
Does it support non-English?
Yes — the engine is text-based. Title casing, data: URIs, and JSON-LD are all UTF-8 throughout.
Can my dev team self-host the engine?
The Python engine in scripts/ is open source. The JS engine in functions/lib/ is
intentionally minimal so it can be lifted into another Workers project; the storage layer is Cloudflare R2 +
D1.
Troubleshooting
"clone_failed" / 502 on the URL clone
- Confirm the URL returns 200 in a clean browser.
-
Some sites block non-browser User-Agents. The cloner identifies itself as
AhmeegoPersonalizerBot/1.0; allowlist that UA in your origin or use the upload path. - Try a deeper page (the bot homepage often has heavier JS than a location/sub-page).
"template_quota_exceeded" / 402
Delete an unused template (left panel) or upgrade your tier from the Plan & usage panel.
"quota_exceeded" / 402
Generating those rows would exceed this month's variant cap. Upgrade your tier or wait for the monthly reset (UTC 00:00 on the 1st).
The personalized iframe shows missing icons
That's the SVG sprite case-preservation bug we explicitly defend against. If it recurs on a new template, please file an issue with the source URL.