Skip to content

Plan and build the Ads page - #787

Open
jrhizor wants to merge 10 commits into
mainfrom
jrhizor/st-georges-v1
Open

jrhizor wants to merge 10 commits into
mainfrom
jrhizor/st-georges-v1

Conversation

@jrhizor

@jrhizor jrhizor commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Closes #168.

Plan in docs/ads-plan.md, plus the page built as components and Storybook stories (Pages/Ads). No route, no server function, no migration — apps/web/src/components/ads/types.ts is the contract server/ads.ts will have to satisfy. Nothing user-facing yet, so no changeset.

We already have the data, and it is retroactive

BrightData's ChatGPT scraper returns an ads object on every run and we persist it verbatim in prompt_runs.raw_output. History goes back to 2026-05-11 — 5,900+ ad impressions across 400+ advertisers. Google AI Mode carries its own ads array and is live and growing today.

Against Elmo's own tracked prompts: AirOps, Profound, Searchable and Ahrefs are buying against our exact prompt set, and we can read their ad copy. All four are already on the competitor list, so attribution works with no extra config.

ChatGPT ads stopped on 2026-08-28, and the data is nowhere else

I probed all three ChatGPT scrapers live against production credentials on 2026-09-16:

Provider ads field Ad markers in the raw, un-stripped body
BrightData {"carousel_cards": null} none in 754 KB of answer_html — no bzrcdn.openai.com/tessera, no utm_medium=paid, no "Sponsored"
DataForSEO LLM Scraper field does not exist none
Oxylabs field does not exist none in raw_response, which is ChatGPT's own SSE conversation stream

So: not a parsing loss on our side, and not recoverable from another provider. The stored data points at the cause — ads only ever appeared on runs where BrightData also reported the model field, and that session type collapsed at exactly the right moment:

Week runs w/ model of which had ads runs w/o model of which had ads
2026-08-10 999 254 (25%) 384 3
2026-08-17 868 231 (27%) 743 0
2026-08-24 114 41 (36%) 1,494 0
2026-08-31 54 0 1,958 0
2026-09-14 17 0 947 0

That reads as a BrightData capture regression rather than OpenAI withdrawing ads — a vendor ticket, not a code change. It doesn't block this: the May–August history earns the page on its own and AI Mode keeps it live. But it is exactly why the page must not render "0 ads" as "nobody is buying".

This should land on top of #711

Ads are an L1 extraction artifact in that PR's terms — read straight from raw_output, identical for every tenant, with brand/competitor attribution applied at read time exactly as categorizeDomain already does for citations. So ads need no deriver, and more importantly no backfill job:

  • extractRun() returns { textContent, citations } and is the single extraction entry point. Ads become a third key.
  • reprocess already replaces a run's citations wholesale when extractor_version is stale. replaceAdImpressions is the same five lines.
  • Bumping EXTRACTOR_VERSION 1 → 2 is detected on worker start and replays every stored run at low concurrency behind live processing.

#711's own plan doc names this case: "a new deriver such as ads detection is a version bump plus the rows it needs, never a one-off script." On main today it would instead need a bespoke chained job over 1.8 GB of TOAST.

Rollup tables aren't needed for v1 and that's a measurement, not a preference: ad_impressions is ~9k rows / <10 MB against citations' 1.1M rows / 673 MB. When volume warrants it, rollup_ad_advertisers is one entry in rebuildRange's table list, one INSERT … SELECT … GROUP BY, and a ROLLUP_VERSION bump. One wrinkle recorded in the plan: it must key on advertiser_key, not domain — same reason competitor mentions key by name (#709), plus AI Mode genuinely has no domain on some rows.

Performance

Reading ads out of raw_output per request is a non-starter: prompt_runs is 2 GB of TOAST for 74k rows, a single de-TOASTing 30-day aggregate takes ~800 ms warm, and the competitor-join version timed out at 120 s. A normalized ad_impressions table with a covering index mirroring citations_brand_created_analytics_idx is ~1% of the Citations row count, so it should be faster than the Citations page, not merely as fast.

The page

Order: stats → platform strip → ad-rate trend → advertisers → contested prompts → auction board → movement → creatives.

The board sits after both ranked lists rather than leading. It's the densest thing on the page and reads far better once the names in it are familiar.

Platform filtering reuses the existing FilteredListShell filter bar with trackedTargets narrowed to the new AD_CAPABLE_MODELS — same URL-backed control as every other page, and the filter and the denominator read the same list so they can't drift. Each advertiser row and creative card carries its platform's icon, because the same advertiser on both surfaces is two different buys.

Empty is the common case

Ads are sparse, so "nothing here" gets four distinct answers rather than one: no ad-capable platform tracked, no answers in the window, no ads and none ever seen, and — the one that matters — ads that used to appear and have stopped. That last case carries a warning on the platform's row with the date of the last ad, because "nobody is buying" and "our scraper lost the surface" produce an identical empty table and mean opposite things.

Notes

  • Advertiser domain is nullable throughout. AI Mode puts a Google aclk redirect in its URL field and sometimes a display name ("Avnet", "Amsive") where a host belongs, so rows key on advertiserKey and the domain-dependent affordances — favicon, host label, "track as competitor" — are conditional.
  • Fixtures are real captured payloads from both surfaces, so the layouts are exercised against the shape and sparsity the live data actually has. Two departures, noted in the file: the "Paid vs. Earned" citation counts are synthesized (that join isn't in the payload) and impressions are a top-N sample, scaled so every total agrees.
  • paid-vs-earned-card.tsx is built but not placed — it's the only section needing a citations join, and it earns less than the six that ship.
  • Not exercised: the Storybook Vitest project (its bundled Chromium isn't installed here). Rendering was verified by screenshotting every story through system Chrome.

Verification

pnpm lint, pnpm turbo check-types, and all unit suites pass (cli 32, config 106, deployment 47, lib 571, web 545).


Open workspace in Conductor

Components and Storybook stories only — no route, no server function, no
schema. The data contract in components/ads/types.ts is what server/ads.ts
will have to satisfy.

Fixtures are real captured ChatGPT ad payloads from Elmo's own tracked
prompts, so the layouts are exercised against the shape and sparsity the
live data actually has rather than a tidier invention.
Drops the three variants for the one that was chosen, with the auction
board folded in after the two ranked lists it cross-tabulates.

Google AI Mode ads join ChatGPT's, which the page has to handle rather
than average away: the two surfaces have different payload shapes, AI
Mode often gives a display name where a domain belongs, and one of them
is currently silent. So advertisers key on an explicit advertiserKey
with a nullable domain, and each platform gets its own ad rate.

Empty is the common case here and splits four ways — no ad-capable
platform, no answers, no ads ever, and ads that stopped. The last is the
one that matters: reading it as "nobody is buying" when a scraper lost
the surface is the expensive mistake.
@vercel

vercel Bot commented Sep 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
elmo Ready Ready Preview Sep 16, 2026 11:12pm UTC

Request Review

The cliff is 2026-08-25, not the 28th, and ads were not the only thing
lost: every field that only exists when the collector completes a real
browsing turn went with them. web_search_triggered and search_sources
came back on 09-01; model, web_search_query and ads did not.

Which means ChatGPT query fan-out has been answering "unavailable" for
three weeks — a live regression, same root cause, worth the same ticket.

Ruled out: our own code (the only provider change in the window landed a
day after the cliff and is a pure refactor), OpenAI pulling ads (they
expanded), and geo (a US-pinned commercial probe rendered the shopping
carousel and still returned no ads).

jrhizor commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: traced the ChatGPT blackout, and it's worse than "ads stopped"

Answering the three questions directly.

Did we change something? No.

The only change to packages/lib/src/providers/registry/brightdata.ts in the window is #637 on 2026-08-26 — a day after the cliff, and a pure refactor (extracts triggerSnapshot, swaps a local extractSources for the shared extractor). Trigger body byte-identical, rawOutput strip untouched. No change to SCRAPE_TARGETS, the dataset id, or the web_search flag. And ads is passed straight through — we have no code path that could null it.

The real cliff is 2026-08-25, and ads weren't the only casualty

Five fields collapsed on one day, while citations kept working:

Changelogs and news: nothing announced, because the field was never documented

Ruled out geo too — and found a knob we should turn anyway

ChatGPT ads were US-only for most of 2026 and we've never pinned a country, so this looked like the answer. It isn't. country is a supported dataset input (BrightData publishes a ChatGPT country list) and we've never sent it. Probed with country: "US" on 09-16: accepted and echoed, search_sources populated — model, web_search_query and ads still null.

The sharpest probe: country: "US" + a deliberately commercial prompt, where the shopping carousel rendered (shopping_visible: true, six products) — the collector did reach the monetizable part of the answer — and ads was still null. The product carousel works; the ad carousel doesn't.

We should send country: "US" regardless. It doesn't fix this, but it makes the sample deterministic instead of leaving the exit country to BrightData, and country is a dimension worth controlling now that ads serve in more markets.

The ask

Dataset gd_m7aof0k82r803d5bjm returned web_search_query, model, ads and search_sources on ~50% of runs through 2026-08-24. From 2026-08-25 all five browsing-turn fields dropped to near zero. search_sources and web_search_triggered recovered on 2026-09-01; web_search_query, model and ads have not. Reproduced 2026-09-16 with and without country: "US", including on a commercial prompt where shopping_visible was true and six products came back. Same prompts, same dataset, no change on our side.

Also worth asking whether ads is supported going forward. If it's not, ChatGPT ad tracking rests on a field they can drop again without notice — which belongs in the decision about how much to build on it.

Full write-up in docs/ads-plan.md §5.

Not our extractor: August payloads carried web_search_query under the
same name we read, nothing replaced it, and reportedWebQueries already
degrades to the unavailable sentinel rather than claiming zero fan-out.

Not BrightData alone either — DataForSEO's fan_out_queries is null on a
live probe too, which points at a ChatGPT UI change both DOM readers
lost rather than one vendor's bug.

Oxylabs reads the SSE stream instead of the DOM and still has both the
queries and the model, so moving the ChatGPT target there restores
fan-out with no code change. It does not bring ads back: the stream
carries no ad markers at all.

jrhizor commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Query fan-out: broken on their end, not ours — and recoverable today

I over-attributed this to BrightData in the last comment. The fan-out half is broader than that, and there's a fix already written.

Not our extractor

  • August payloads carried web_search_query as a populated array under the same field name we read (e.g. ["Speakeasy Delhi reviews cocktails"]). Today it's null.
  • Nothing replaced it. A live probe has no query anywhere: not in search_sources (entries are url/title/snippet/rank/date_published), and not in 770 KB of answer_html — zero "Searched for" chips, zero search_model_queries.
  • Our extractor reads the right fields, has the metadata.search_model_queries fallback, and degrades honestly: reportedWebQueries writes the unavailable sentinel rather than claiming zero fan-out, because citations prove a search ran. The page is telling the truth about a gap it can't fill.

Not BrightData alone either

DataForSEO's ChatGPT LLM Scraper returns fan_out_queries: null on a live probe too — a field we already read correctly. Two independent vendors driving chatgpt.com lost the same thing on the same schedule, which points at a ChatGPT UI change around 08-25 that both DOM-reading scrapers stopped matching, rather than one vendor's bug.

Oxylabs still has it

Oxylabs reads ChatGPT's SSE conversation stream rather than the rendered DOM, and the data is right there:

"metadata": {
  "search_model_queries": { "queries": ["best noise cancelling headphones 2026 …"] },
  "resolved_model_slug": "gpt-5-6"
}

A live Oxylabs run on 09-16 returned search_queries populated and llm_model: "gpt-5-6" — both things BrightData lost. Our Oxylabs extractor already reads search_queries via OXYLABS_QUERY_KEYS, so moving the ChatGPT target to Oxylabs restores query fan-out with no code change. It needs OXYLABS_USERNAME/OXYLABS_PASSWORD in the cloud env — currently only set for demo.

This also explains the shape of the outage: resolved_model_slug is alive in the stream while BrightData reports model: null. What broke is DOM chrome, not the underlying answer.

Ads don't come back this way

The SSE stream carries no ad markers at all — no tessera image host, no sponsored — consistent with ads being delivered by a separate call rather than in the conversation stream. Oxylabs fixes fan-out; it doesn't fix ads.

Revised actions

  1. Restore fan-out: move the ChatGPT target to Oxylabs. Live user-visible bug, fix already written, just needs credentials.
  2. Raise the regression with BrightData and DataForSEO — both lost the same fields.
  3. Send country: "US" on the ChatGPT trigger for determinism (doesn't fix either issue).

docs/ads-plan.md §5 updated.

Ads did not stop: trackers have ChatGPT ad penetration at ~51% of US
replies and holding, so our zero is a capture failure rather than the
market moving. Eligibility is what makes it invisible — ads ride the
conversation stream as a type:"ads" event on /backend-anon/, served only
to free or logged-out sessions in a handful of English-speaking markets,
and nothing in the payload says when a session fails that test.

Cloro sells this deliberately behind include.ads. Our cloro.ts already
builds that include object for ChatGPT and asks for two of its six
fields; ads and shopping are both there and both omitted. The same
provider already sends include.searchQueries, so one flag would fix the
fan-out regression too.

BrightData has no equivalent flag — the echoed input confirms the whole
accepted set — and its ads field is undocumented, which is worth knowing
before building on it.

jrhizor commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Correcting the record: ads never stopped, and there is a flag — on Cloro

Three answers, and the first one revises what I said above.

1. Yes, plenty of people have noticed — ads are at ~51% of US replies

I was wrong to frame this as "ChatGPT ads stopped". They didn't:

So our zero across ~5,000 runs is a capture failure, not the market. Two caveats worth carrying into the design: the rate is genuinely volatile (26.5% in late May → 0.05% → ~51% by July, "still visibly tuning") and it's an English-speaking-market product, "roughly zero" elsewhere.

What it takes to be served an ad — and why this is invisible to us

Ads ride the conversation SSE stream itself — a "type": "ads" event at the end of /backend-anon/f/conversation. The unit is single_advertiser_ad_unit with advertiser_brand + carousel_cards[] + target.value carrying utm_source=chatgpt&utm_medium=cpc, assets from bzrcdn.openai.com. That matches BrightData's ads object field-for-field.

Eligibility is narrow, and is probably the mechanism:

  • Free/Go tiers and unauthenticated sessions only — the endpoint is literally /backend-anon/, anonymous sessions carry persona: "chatgpt-noauth". Plus/Pro/Business are never served ads, nor are predicted-underage accounts.
  • US and a few English-speaking markets. Cloro calls country "required for accurate ad detection."

A scraper that drifts into an authenticated session, a paid tier, or a non-US exit returns a perfectly valid answer with no ads, forever, and nothing in the payload says why.

That also explains Oxylabs: its SSE stream had no ad event, but that session's reasoning_titles came back in Romanian — a non-US exit, so not ad-eligible. Can't conclude anything from it either way.

2. Per-provider ad support

Provider Ads field Status Notes
Cloro yes — opt-in include.ads untested, not configured Sells this as a product. Already pins country: "US".
BrightData yes, but undocumented null since 08-25 Not in their docs' field list; no flag to request it
DataForSEO none item_types are text/products/table only
Oxylabs none No ads field; SSE had no ad event on a non-US session
Olostep unknown not configured @olostep/chatgpt-results; no public field list found

No CLORO_API_KEY or OLOSTEP_API_KEY in any env, and the secrets table is empty, so I couldn't live-test those two.

3. The flag: include.ads, and we're already building the object

Cloro's ChatGPT endpoint takes an include object with six flags: markdown, html, rawResponse, searchQueries, shopping, ads ("Return sponsored blocks").

packages/lib/src/providers/registry/cloro.ts:16 already builds it — and asks for two of the six:

chatgpt: { taskType: "CHATGPT", field: "prompt", include: { markdown: true, searchQueries: true } },

ads and shopping are both available and both omitted. Response shape is brand { name, url, favicon } + cards[] { title, body, url, image } — maps onto the ad_impressions schema in §2.1 with no rework.

Cloro fixes both problems with one flag, since include.searchQueries is already on: ads and the query fan-out regression.

On BrightData there's no equivalent. The full accepted input set — confirmed by the input object it echoes back — is url, prompt, country, index, web_search, additional_prompt, geolocation, plus an undocumented require_sources. Nothing selects output fields. The only knob touching ad eligibility is country, which we've never sent in production.

Revised priority

  1. Trial Cloro on the ChatGPT target — free tier is 500 credits/month, enough to answer "do ads come back" in an afternoon. Highest value by a wide margin.
  2. Restore fan-out now regardless: Oxylabs still returns search_queries and our extractor already reads it; needs only cloud credentials.
  3. Send country: "US" on the BrightData trigger so the exit country stops being the vendor's choice.
  4. Report the regression to BrightData and DataForSEO. Note model is a documented output field of their dataset and is now always null.

What this changes about the plan

AD_CAPABLE_MODELS isn't the whole eligibility story. A surface can be ad-capable and still never show one because of who the scraper looks like — free vs. paid, logged out vs. in, US vs. not. That's unobservable from the payload, which makes the gone-quiet warning in §3.1 a first-class part of the page rather than a nicety.

docs/ads-plan.md §5 rewritten.

ChatGPT was the only model producing fan-out at all — every other
tracked model contributes zero, by design or by provider — so its drop
from 58% to 1.1% of runs leaves the page showing its empty state for
every cloud brand rather than merely thinning out.

Nothing in our code is broken: the extractor reads the right fields, the
sentinel records "searched but would not say what", and the page says so
in as many words. The data just stops arriving.

Cloro charges one +2 surcharge for any combination of raw response,
fan-out, ads and shopping. We already send searchQueries, so ads and
shopping are free.

jrhizor commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Cloro pricing, and exactly what's broken with fan-out

Adding ads + shopping to Cloro is free for us

Their credit table:

ChatGPT — 5 credits
"Any combination of raw response, query fan-out, ads, and shopping | +2 credits"

One surcharge for the whole group, not per flag. markdown and html are free. We already send searchQueries: true, so we're already paying the +2 at 7 credits/run — turning on ads and shopping costs nothing extra.

At Hobby pricing ($0.40/1k credits) that's $0.0028/run either way, against the $0.01/run our internal estimate carries for both BrightData and Cloro. Their free tier (500 credits ≈ 70 runs) is enough to evaluate without a contract.

Fan-out: nothing in our code is broken, and the page is blank rather than degraded

The chain, end to end:

  1. BrightData stopped populating web_search_query — an array through 08-24 (["Speakeasy Delhi reviews cocktails"]), null since 08-25.
  2. extractWebQueries() reads web_search_query, then falls back to metadata.search_model_queries. BrightData exposes no metadata, so both miss → [].
  3. reportedWebQueries([], { webSearch: true, searchProven: citations.length > 0 }) stores the unavailable sentinel rather than an empty array, because citations prove a search ran. Designed behaviour for "searched, but wouldn't say what".
  4. genuineFanoutWq() filters unavailable out in SQL, so those runs contribute no rows.
  5. The page renders its KPI row plus "No web queries in this period — the engines you track didn't expose any searches for these prompts and filters."

Every layer behaves correctly and reports the gap honestly. The data just stops arriving.

The scale is the problem: ChatGPT was the only model producing fan-out at all.

Model Aug 1–24 runs with fan-out Sept 1+ runs with fan-out
chatgpt 3,245 1,895 (58%) 5,054 58 (1.1%)
claude 3,128 0 4,410 0
google-ai-mode 3,104 0 1,902 0
google-ai-overview 3,105 0 4,438 0
gemini 10 0 2,550 0
perplexity 9 0 10 0

Every other tracked model contributes zero both before and after, by design or by provider. So Query Fan-Out isn't partially degraded — it shows the empty state for every cloud brand. ChatGPT was carrying it alone and dropped to ~1%.

DataForSEO's fan_out_queries is null on a live probe too, so it isn't BrightData-specific.

The country test is still running

Running it as an A/B rather than more one-off samples: 8 high-commercial-intent prompts with country: "US" vs. the same 8 with no country, recording model / web_search_query / search_sources / shopping / ads per run. At the ~51% penetration trackers report, zero-of-eight would be p≈0.004 — decisive either way, and the control isolates country as the variable.

Incidental finding while running it: an 8-input batch snapshot has been >45 min on BrightData, against ~4 min for the single-input snapshots production sends. Not a production issue (we send one input per snapshot, under a 12-minute poll timeout), but worth knowing before anyone batches inputs to save on trigger overhead.

jrhizor commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Tested: country: "US" on BrightData does not bring ads back

Eight high-commercial-intent prompts, all pinned to country: "US", run 2026-09-16:

prompt country model searched web_search_query sources shopping ads
best project management software for remote teams US null true null 3 0 no
best noise cancelling headphones US null true null 4 3 no
best running shoes for beginners US null true null 4 6 no
best mattress for back pain US null true null 3 4 no
best travel credit card US null true null 4 0 no
best web hosting for a small ecommerce store US null true null 4 0 no
best crm for small business US null true null 5 0 no
cheapest car insurance quotes US null true null 5 0 no

0 of 8. Against the ~51% US penetration trackers report, p ≈ 0.004 — not a sampling accident.

Two details that make this conclusive rather than merely negative:

  • Three of the eight rendered a shopping carousel (3, 6 and 4 products). The collector reached the monetizable part of the answer and still returned no ad — so this isn't "the run failed", it's specifically the ad unit that's missing.
  • model and web_search_query stayed null on all eight. Same regression, unmoved by geo.

country is accepted and echoed back correctly; it just changes nothing that matters here.

Still send it. One field, doesn't fix this, but today the exit country is whatever BrightData picks — a variable worth removing now that ads serve in more markets.

A no-country control arm on the same eight prompts is still running; it can only confirm the negative, so it doesn't change the conclusion. I'll append it when it lands.

docs/ads-plan.md §5 updated (80c81bc).

GET /datasets/v3/scrapers returns full input and output schemas. The
ChatGPT Search dataset declares 29 output fields and ads is one of them,
alongside model and web_search_query — all three declared, all three now
always null, which is a contract violation rather than a courtesy field
quietly retired.

Its whole input schema is url, prompt, country, index, require_sources,
additional_prompt, web_search, geolocation; nothing requests ads or
selects fields, and collect_by_url is its only mode. Of all 1,078
scrapers on the account exactly two declare an ads field, and they are
the two we already run.
Every remaining parameter tested one snapshot each. Forcing web_search
was the best hypothesis left — we set it on every run, a deviation from
their default nobody had questioned — and it is not the cause: omitting
it changes nothing, and setting it false still returns
web_search_triggered true, so the flag does not control searching at all.

Shopping rendered on most of the runs, so the collector keeps reaching
the monetizable part of the answer. Against the ~51% US penetration
trackers report, fourteen consecutive misses is p of about 0.00006.

Also: geolocation needs numeric lat/lon/radius despite declaring type
text, and the account has only a serp zone, so the Browser API route
that would let us read the ads event off the stream ourselves is not
provisioned.

jrhizor commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Exhausted BrightData: ads is a declared field, and every input is now tested

Went at their API rather than their docs.

ads is declared — this is a contract violation, not a retired courtesy field

GET /datasets/v3/scrapers returns full input/output schemas for all 1,078 scrapers on the account:

  • gd_m7aof0k82r803d5bjm ("ChatGPT Search", category AI Search) declares 29 output fields, and ads is one of them — alongside model ("Model used for answer") and web_search_query ("Model-generated web search queries (exact strings, in execution order)"). All three declared, all three now always null.
  • Correction to my earlier comment: ads isn't undocumented. It's missing from their prose docs; the API catalog still promises it. That makes the bug report much stronger.
  • Complete input schema: url, prompt, country, index, require_sources, additional_prompt, web_search, geolocation. Nothing requests ads or selects fields.
  • Only scraper_type is collect_by_url — no alternative collection mode.
  • Across all 1,078 scrapers, exactly two declare an ads field: ChatGPT Search and Google AI Mode Search. Both are ones we already run. No separate ChatGPT-ads dataset exists.

Every remaining input tested — all negative

One snapshot each, country: "US", commercial prompt:

variant country model searched web_search_query sources shopping ads
web_search omitted (their default) US null true null 3 4 no
web_search: false US null true null 4 3 no
require_sources: true US null true null 5 3 no
geolocation NYC (40.7128,-74.0060,10000) US null true null 6 4 no

Forcing web_search was the best hypothesis left — we set it true on every run, a deliberate deviation from their default nobody had questioned, and it plausibly routes ChatGPT into a search-mode UI. It isn't the cause: omitting it changes nothing, and setting it false still returns web_search_triggered: true, so the flag doesn't even control whether a search happens.

Running total: 14 US-pinned runs, zero ads, shopping rendering on at least seven. Against ~51% US penetration, p ≈ 0.00006.

Two incidental findings

  • geolocation is not free text despite declaring type: "text" — it needs numeric latitude, longitude, radius or the crawler errors out.
  • The account has exactly one zone: sdk_serp (type serp). The Browser API / Web Unlocker route isn't provisioned.

What's actually left on BrightData

Driving chatgpt.com ourselves through Browser API / Web Unlocker and reading the type: "ads" event off the SSE stream — the way Oxylabs gets search_queries. That's a different product, a new zone, and a real integration, not a flag. It's also the only option that survives them breaking the parsed field again.

Versus Cloro, which returns ads deliberately behind include.ads, costs us nothing extra (we already pay the +2 for searchQueries), and fixes fan-out in the same call. That comparison hasn't changed — it's just better evidenced now.

docs/ads-plan.md §5 updated (aa6fbe5).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chatgpt ads tracking

1 participant