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

When you need Upload Template instead

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 Rendering (Cloudflare): when you enable Browser Rendering on your Cloudflare account, the URL clone path will automatically render the page in a real Chromium instance before capture. The active template panel labels the render mode (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:

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:

  1. Plain text replacements — most surgical, preserves entity encoding. Pairs come from replacements_json + the auto source-city/source-state pair.
  2. <title> swap — replaces the visible content of the first <title> tag.
  3. Meta description / OG / Twitter — order-agnostic regex; updates content of name="description", property="og:title", name="twitter:title", etc.
  4. First H1 swap — only when h1_text is supplied.
  5. Cities-served <ul> rewrite — finds the first heading containing "Cities Served" / "Service Area" / "Areas We Serve" and replaces every <li> in the next <ul>.
  6. JSON-LD mutation — every application/ld+json block is parsed; areaServed, description, and LocalBusiness/Service name are updated; the block is re-serialized.
  7. Optional banner — injected immediately after the opening <body> tag.
  8. 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:

Audit checks

Every variant is audited automatically; results show in the right-hand "Audit summary" panel and on the Result tab.

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
The owner email [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

"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.