# AI answers on the SERP — findings, 2026-08-04 Status: **investigated, not implemented, not decided.** Nothing in `src/` has changed. This is the field notes for whoever picks it up. The question was whether the AI answer block that Google, Bing and Brave now put above their results ("AI Overview", the Bing generative answer, Brave's AI summary) is worth pulling into `castle_cdp_search`'s output alongside the ranked results. It is not obviously worth it — the cost is real and is set out below — so the decision was deferred rather than taken. Everything here was read off the live SERPs in castle's Chrome on 2026-08-04, in the signed-in `~/.chrome-cdp-profile`, `hl=en`. Same rule as `engines.ts`: what is written down is what was observed. Where something was *not* verified it says so. ## What is there, per engine | Engine | Block | Container observed | Sources exposed | | --- | --- | --- | --- | | Google | "AI Overview" | `#m-x-content` | yes, with titles | | Bing | generative answer | `li.b_ans.b_top.b_qnacdxcont` → `.gs_caphead_main` | yes, cards in `.gs_cit` | | Brave | AI summary | `#chatllm-main-answer-content` | no | | DuckDuckGo | none | — | — | DuckDuckGo is a genuine "none", not an unfinished check: the engine points at the no-JS `html.duckduckgo.com` endpoint, which cannot render one. There is no `.zci`/zero-click block on it either — that was checked directly. ### Google The overview lives **outside `#search` and `#rso`**, above them. That was verified (`rso.contains(node) === false`), and it means the current extractor neither sees it nor is disturbed by it — no existing behaviour is at risk here. Structure of `#m-x-content`, on a query that had one: ``` #m-x-content [jsname=txosbe] "AI Overview" ← the header [jsname=HKDuG] the answer body ← also contains the citations … answer prose [role=dialog] "6 sites" + cards + "Show all" ``` Hooks, ranked by how much they can be trusted: - `#m-x-content` — an id, present on both loads. The most durable thing found. **But see the trap below: the id alone is not sufficient.** - `[role=heading]` with the exact text `AI Overview`, and `[aria-label="Show more AI Overview"]` on the collapse control. Semantic, and the natural anchor if the id ever moves. - `[role=dialog]` around the citation carousel (`data-type=hovc`). - Class names — **useless**, as everywhere else on Google. The answer body was `.LT6XE` on one load and `.jloFI GkDqAd rJweXb` on the next, same query, minutes apart. This is the same churn `engines.ts` already warns about. **Citations are the good part.** Anchors inside `#m-x-content` carry the source title in `aria-label`, suffixed `". Opens in a new tab."`: ``` aria-label="Open source agentic startup LangChain hits $1.25B valuation | TechCrunch. Opens in a new tab." href="https://techcrunch.com/2025/10/21/open-source-agentic-startup-langchain-hits-1-25b-valuation/" ``` Their `innerText` is empty — the visible label is an icon — so `aria-label` is the only way to get a title. Links out directly, no redirector, so the existing `unwrap: "none"` holds. Google's own hosts appear among them (a privacy-policy link) and are already excluded by `selfHostPattern`. Two traps: 1. **`#m-x-content` exists before the overview does, holding something else.** Polling a fresh load at 250ms intervals caught it at 1502ms containing 28,864 characters — approximately the whole SERP — and at 1752ms containing 142, then growing to its real 903. So the id is reused by, or briefly wraps, a larger shell. An extractor that reads it on sight will occasionally return the entire page as the "AI answer". **Require the text to begin with `AI Overview`, or anchor on the heading and climb, before trusting the node.** 2. **"Show more" is not hiding any of the answer.** Clicking it took the text from 1124 to 1262 characters, and the 138 added were the disclaimer footer ("Double-check important information… AI responses may include mistakes"). The answer body is complete in the DOM while still visually truncated. So there is no need to click anything — which is good, because clicking in a browser a human is using is exactly what this package avoids. To render usefully, the extractor has to drop the `AI Overview` header line, the `[role=dialog]` citation carousel, the "Show all" / "Dismiss" / "Learn how" buttons and the disclaimer. Cleanest approach found: `cloneNode(true)`, remove those by selector on the clone, take `innerText`. The page itself is never mutated, which matters on a shared browser. ### Bing ``` li.b_ans.b_top.b_qnacdxcont .gs_caphead_main the answer prose ← clean, nothing else in it .gs_cit_cont .gs_cit source card: name, title, snippet .gs_cit … .cit_exp_cont "View all" ``` The `gs_` prefix ("generative search") is descriptive rather than hashed, and `.gs_caphead_main` held the answer and *only* the answer — no header, no cards, no disclaimer. The nicest of the three to extract. `li.b_ans` on its own is **not** a safe selector: "People also ask" is also a `li.b_ans`. The discriminator is `b_qnacdxcont` on the `li`, or the presence of `.gs_caphead_main` inside it. Citation anchors go through Bing's `/ck/a?…&u=a1…` redirector, so the existing `unwrap: "bing"` already handles them — no new unwrapping needed. No URL change is needed: the answer appears on the plain `bing.com/search?q=…` the engine already builds. ### Brave `#chatllm-main-answer-content` contained the answer and nothing else — no header, no disclaimer (that sits outside it, in `cite.chatllm-disclaimer`), no citations. The wrapper is `#llm-snippet`, a `.snippet.standalone` sitting first in `#results`. Worth noting: `engines.ts` already restricts Brave's results to `.snippet[data-type="web"]` specifically to exclude this block. That filter stays correct — the AI answer would be read separately, not as a result. No sources are exposed. The only anchors inside are `search.brave.com/ask?…` follow-up prompts ("Elaborate", "Who are LangChain's main competitors?"), which `selfHostPattern` already discards. There is a "+4 More" control that may reveal sources; **not investigated** — it would need a click, and clicking was avoided. No URL change needed. ## The two reasons this is not free ### 1. Google's AI Overview requires giving up `udm=14` `udm` is Google's undocumented vertical selector — the parameter its own tab strip writes. `udm=2` is Images, `udm=7` Videos, `udm=12` News, `udm=28` Shopping, and `udm=14` is the "Web" tab added in May 2024: plain ranked links, no AI Overview, no knowledge panel, no carousels. `engines.ts` sets it as Google's default precisely for that cleanliness. So "no AI Overview" is not incidental to `udm=14`, it is part of what the Web tab *is*. Every page where `#m-x-content` was found was a no-`udm` classic SERP. Enabling overviews on Google means dropping `udm`. That is less alarming than it sounds: the engine **already** drops `udm` for any time-limited search, because `udm=14` plus a date filter renders an empty page (README, trap 2). Heading-mode extraction is therefore already exercised against the classic layout. The cost is that the classic layout puts back all the furniture — knowledge panels, "People also ask", carousels — that the extractor then has to keep ignoring. ### 2. They stream in after the results are ready, and are often absent Measured from navigation start, polling at 250ms via an `initScript`: | Engine | results parseable | AI answer appears | AI answer settles | | --- | --- | --- | --- | | Google | ~500ms | ~1500ms | ~2300ms | | Brave | ~290ms | ~1260ms | **still growing at 5000ms** | | Bing | ~520ms | — (absent on that query) | — | `search.ts` reads results and returns as soon as anything parses, retrying at most twice at 700ms (`EXTRACT_RETRIES`, `EXTRACT_RETRY_DELAY_MS`) and breaking the moment `results.length > 0`. In other words it returns roughly 2s before Google's overview lands and 5s+ before Brave's finishes. **Capturing an AI answer means deliberately waiting past the point where the useful part of the job is already done.** And the wait is frequently wasted, because the blocks are query-dependent: - `how does postgres MVCC vacuum work` → **no** Google AI Overview at all, still absent after 15s, in a foreground tab. (Checked in a foreground tab specifically to rule out background-tab throttling as the cause — an earlier `window.open` probe was discarded for that reason.) - `who founded anthropic and when` → **no** Bing generative answer after 13.5s; Bing served an ordinary big-Wikipedia card instead. - `LangChain valuation Series C funding 2026` → all three produced one. There is no cheap way to know in advance which case you are in, so a fixed wait pays the full cost on every query that was never going to have an answer. ## The decision that was not taken Three shapes were on the table: 1. **Opt-in `aiAnswer` parameter (the one that was going to be recommended).** Default off: today's latency and today's `udm=14` layout, byte-identical behaviour. When true: Google drops `udm`, and the probe polls until the text stops growing or a budget (~6s) expires. Costs nothing unless asked for, and the model can ask for it when the query is a question rather than a lookup. Downside: another parameter on a tool that already has four, and the model has to decide when to set it. 2. **Always on.** Simplest surface, no new parameter. Every search pays ~2s (Google) to ~5s (Brave), much of it waiting for answers that never arrive, against a 20s whole-call deadline (`SEARCH_TIMEOUT_MS`) that also has to cover connect and navigate. 3. **Opportunistic, never wait.** Include an AI answer only if it is already rendered when results are read. Zero added latency, but Google would essentially never contribute one (`udm=14` stays), and Brave would usually be caught mid-sentence — see the growth curve above. Risks putting a truncated, confident-sounding half-answer in front of the model, which is worse than putting none there. Unresolved either way: whether an AI answer belongs in the model's context at all. It is a summary generated by another model from pages the agent could read itself, it carries no provenance beyond its citations, and `promptGuidelines` currently tells the model in plain terms not to treat a snippet as the page. An AI answer invites exactly that confusion at greater length. If it goes in, it should be labelled unmistakably as the engine's generated text — engine name included, e.g. "Google AI Overview" — and never merged into the numbered results. ## If you implement it - `ExtractionConfig` in `src/engines.ts` is the right place for the per-engine selectors; it is already plain JSON crossing into the page. - `PageProbe` in `src/extract.ts` gains an optional field; `SearchResult` should **not** — an AI answer is not a hit and must not be numbered among them. - The settle-poll belongs in the page script, not in round trips from `search.ts`. One `Runtime.evaluate` that polls locally until the text stops growing is one round trip; polling from the host is one per sample. - `formatResults` in `src/format.ts` puts it above the numbered list. Mind the budget note at the top of that file — an overview plus citations is another 1-2KB on top of the 15KB worst case. - Tests: `test/search-url.test.ts` is where a "Google drops `udm` when an AI answer is wanted" assertion goes, next to the existing parameter-trap tests. The extraction itself cannot be tested without a browser; the existing suite does not try, and this should not either. - Re-verify every selector before trusting it. Google's classes churned between two loads minutes apart during this very investigation, and these blocks are changing faster than the rest of the SERP.