See which AI assistants cite your site, for which questions, and what share of them you win
What this is. The full build, in seven stages, with the prompts. Stage 1 gets you real numbers in about half an hour with nothing installed. Stages 2 to 7 turn it into something that runs itself and tells you what changed.
Why it matters. Your content performance is split across four places that do not talk to each other: AI citations in Bing, classic search in Google, classic search in Bing, and the visits that actually reach your site. Each answers a different question and none of them answers the one that matters, which is what to build next. Pull all four together and you can finally see which content type earns citations, which topics you already hold share on, and which pages return nothing at all.
Be realistic. The finished thing runs to a few thousand lines. You are not typing that — the prompts generate it. Stage 1 is genuinely easy. Stage 6 is fiddly and will need occasional attention forever.
Why this build does not use a standard pixel. There is no Google Analytics tag and no Microsoft Clarity anywhere in this. That is deliberate, and on Medicare or ACA pages it is not optional.
Clarity records sessions. It captures what a visitor sees and types, which on a quoting flow means date of birth, medications and conditions. Microsoft does not offer a BAA for Clarity. Field masking reduces the exposure but session replay is structurally the wrong tool on health pages.
GA4 reports to Google. Every hit sends the page URL, the referrer and a persistent client identifier. Google will not sign a BAA covering GA4. When the URL or the event parameters carry health context, that combination is the exact fact pattern behind the hospital pixel litigation.
So stage 4 is a first-party beacon. Your endpoint, your storage, nothing leaving your infrastructure by default. If you still want GA4 numbers, you forward them server-side with the health context stripped and a pseudonymous id, so Google receives counts rather than people.
Scope it correctly. This applies to Medicare, ACA and health. On life, annuities and P&C a client-side GA4 tag is fine. Agencies over-apply this and end up blind on business where they never needed to be. None of this is legal advice; run your own setup past your counsel.
What you get: Your real citation counts and the exact questions behind them. No server, no code deployed, nothing to maintain.
(async () => {
const t = await (await fetch('/webmasters/auth/token', {credentials:'include'})).text();
const H = {'Content-Type':'application/json','X-CSRF-Token':t};
const list = (prompt('Domains to check, comma separated', 'yoursite.com') || '').split(',')
.map(s => s.trim()).filter(Boolean);
const out = [];
for (const d of list) {
const body = JSON.stringify({ siteUrl: 'https://' + d + '/' });
const r = await fetch('/webmasters/api/aiperformance/citationstats',
{method:'POST', headers:H, credentials:'include', body});
if (!r.ok) { out.push(d + ': HTTP ' + r.status + ' (not verified on this account?)'); continue; }
const stats = (await r.json()).CitationStats || [];
const total = stats.reduce((a,x) => a + x.Citations, 0);
const peak = stats.length ? Math.max(...stats.map(x => x.Citations)) : 0;
const q = await fetch('/webmasters/api/aiperformance/searchqueries/stats',
{method:'POST', headers:H, credentials:'include', body});
const queries = q.ok ? ((await q.json()).Queries || []) : [];
out.push(d + ': ' + total + ' citations over ' + stats.length + ' days, peak ' + peak +
', ' + queries.length + ' grounding queries');
queries.slice(0,5).forEach(x => out.push(' "' + x.GroundingQuery + '" ' +
x.Citations + ' cites, ' + Math.round((x.CitationRate||0)*1000)/10 + '% share'));
}
console.log(out.join('\n'));
alert(out.join('\n'));
})();
What you get: Clicks, impressions, position and your query list for every property, on a schedule, without opening a browser.
Write a Node.js script that pulls Google Search Console data using a service-account JSON key, with no external dependencies (no googleapis package — sign the JWT with node:crypto). Requirements: - Read the service account key from a path in an env var, default ./service-account.json - Mint a JWT and exchange it for an access token against https://oauth2.googleapis.com/token, scope https://www.googleapis.com/auth/webmasters - List every property the service account can see (GET /webmasters/v3/sites) - For each property, query searchAnalytics for the last 90 days with dimensions ['query'] and ['page'], rowLimit 25000 - For each property write one JSON line to gsc-daily.jsonl with: date, site, clicks, impressions, position, queryCount, pageCount, topQueries (top 10), topPages (top 10) - Also flag "opportunities": queries with 10+ impressions ranking worse than position 10 - Handle a property returning no data without crashing the whole run - Log one summary line per property Explain how to schedule it with cron, and what to do if a property returns 403.
What you get: Impressions, clicks and query lists from Bing. Unlike the AI report, this one has a real API and takes minutes.
Write a Node script that pulls Bing Webmaster Tools data for a list of sites and appends one JSON line per site per day to bing-daily.jsonl.
Details:
- Endpoint pattern: https://ssl.bing.com/webmaster/api.svc/json/<Method>?siteUrl=<urlencoded>&apikey=<KEY>
- Use GetRankAndTrafficStats, GetQueryStats and GetPageStats
- Responses are wrapped in {"d": ...} — unwrap it
- Dates come back as Microsoft /Date(ms-offset)/ — parse to YYYY-MM-DD
- Read the site list from the same brands.json the rest of the project uses. Do NOT hardcode domains.
- Per site record: date, site, last 7 days of impressions/clicks, 7-day totals, queryCount, count of conversational queries (7+ words), topQueries, topPages
- If a site returns nothing, log it and continue
Note: Microsoft is retiring the SOAP and POX APIs on 2026-08-31. The /json/ endpoint above is the REST one and is NOT affected — do not migrate away from it.
What you get: A first-party record of every visit, classified by source, including the AI referrals that Analytics files as 'direct'.
Build a minimal first-party visit beacon: a Node endpoint plus the browser snippet that feeds it.
Server (POST /hit):
- Accept a small JSON body: {s: site id, p: path, r: referrer HOSTNAME only, plus optional utm fields}
- Reject unknown site ids and anything oversized
- Classify each visit into one source: ai, search, social, paid, email, referral, direct
- ai = chatgpt.com, chat.openai.com, perplexity.ai, copilot.microsoft.com, gemini.google.com, claude.ai, you.com, duckduckgo.com, search.brave.com
- paid wins if utm_medium matches cpc/ppc/paid
- Append one JSON line to hits.jsonl. Respond 204 with no body.
- CORS: accept a plain text/plain body so the browser request stays preflight-free.
Browser snippet:
- Fire on EVERY pageview, not only AI referrals
- Send the referrer HOSTNAME only, never the full URL
- Skip our own hostname so internal clicks are not logged as referrals
- Include any utm_* params from the landing URL
- Use navigator.sendBeacon so it never delays the page
- Cookieless. No identifiers of any kind.
Explain how to gate it behind a consent banner for sites that need it.
What you get: A single page showing AI citations, Google and Bing performance side by side, per brand, with the grounding queries underneath.
Build a small Node HTTP server (no framework) that serves a read-only analytics dashboard.
Data sources, all newline-delimited JSON files on disk:
- gsc-daily.jsonl — Google Search Console daily pulls
- bing-daily.jsonl — Bing Webmaster daily pulls
- bing-ai.json — AI citation counts + grounding queries per brand
- hits.jsonl — first-party visit beacon
Requirements:
- A brands.json config maps a short id to {name, domain, color}. Everything else derives from it — never hardcode a brand list anywhere else.
- GET /dash/<brand>?key=TOKEN renders an HTML dashboard for ONE brand. A token maps to the brands it may see; a master token sees all.
- Tabs: Overview, Search performance, AI citations, Traffic sources, Content plan.
- IMPORTANT: scope every per-brand number to that brand only. Do not let one brand's cited sources or panel questions appear on another brand's page.
- Show "—" not "0" when a metric was never captured, so a gap is visually distinct from a measured zero.
- Show a STALE badge if a data file is more than 3 days old.
- No build step, no client framework. Inline the CSS. Charts as inline SVG.
Explain how to add a new brand.
What you get: The Bing AI numbers refresh daily without you opening a browser.
Write a Node script using Playwright that captures Bing Webmaster Tools AI Performance data headlessly and posts it to my collector.
Constraints:
- Bing has NO API for AI Performance. It is backed by two internal endpoints that authenticate off the signed-in session:
GET /webmasters/auth/token -> anti-forgery token (plain text)
POST /webmasters/api/aiperformance/citationstats -> {CitationStats:[{Date,Citations,UniqueCitedPages}]}
POST /webmasters/api/aiperformance/searchqueries/stats -> {Queries:[{GroundingQuery,Intent,Topics,Citations,CitationRate}]}
Both take {siteUrl} and require header X-CSRF-Token. They IGNORE date-range params and return the full history.
- Use chromium.launchPersistentContext so the login survives between runs.
- Support a --login flag that opens a visible browser, waits for sign-in, and auto-detects success by polling a real data call (do NOT just wait for the window to close).
- CRITICAL: a signed-OUT session still receives a valid-looking token. Detect signed-out by checking whether the DATA calls return 401/403 for every site, not by checking the token.
- Exit codes: 0 ok, 2 signed out (tell the user to re-run --login), 3 failed. Write logs/last-status.json every run.
- A 404 from searchqueries means "no data yet" for that site, not an error.
Then explain how to schedule it daily on Windows Task Scheduler and on cron.
What you get: The four feeds stop being four dashboards and become one answer. This is the stage most people skip, and it is the reason to build the rest.
Write a Node script called reconcile.mjs that reads the JSONL feeds my collectors write (Bing AI citations, Google Search Console, Bing classic search, and first-party visits) and produces one verdict per site per day. For each site it must compare the feeds against each other and emit flags, not just numbers: - pages indexed with zero impressions over 14 days (the soft-404 signature: unknown paths returning 200 with the homepage) - pages strong in Bing AI citations but absent from Google's query list - pages ranking in Google with zero citations in Bing AI - a citation count that dropped more than 40% week over week with no corresponding ranking drop - any feed that has not written a line in 48 hours (a dead collector, not a real zero) Group results by URL pattern so I can see which content type is performing: derive the pattern from the first path segment. Severity: critical, warning, info. Write results to reconcile-YYYY-MM-DD.json and print a summary table. Read the site list from brands.json. Never hardcode it. Important: scope every aggregate by site. A metric computed across all sites but attributed to one is the bug I most want to avoid here.
If you get stuck, that is expected — particularly at stage 5, where the brand-scoping bug is easy to introduce and hard to spot, and stage 6, where sessions expire quietly. Bring it to the group and we will work through it.