diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-08 21:13:15 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-08 21:13:15 +0530 |
| commit | c5c42df5976ed63faac2adcf3b04a4ad2f519b32 (patch) | |
| tree | 77d389a590864eaf5c2408c9bc99ed07f55343d8 /src/domains/search | |
| parent | 7826c950adee89512162185d07fee565ab0e96cc (diff) | |
feat: add browser_web_search tool (real-Chrome SERP scrape, no API key)
Thin query→ranked-links primitive. Isolated-attach tab lifecycle mirroring
browser_open_urls (never touches the user's current tab); pure Google SERP
parser with semantic (a h3) extraction and redirect unwrapping; CAPTCHA and
no-results surfaced as invalid_state + details.reason. Fixture-based parser
tests (9 scenarios). Establishes the isolated-tab lifecycle and tsx+fixture
test convention that browser_read_page and deep-research build on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/domains/search')
| -rw-r--r-- | src/domains/search/extract.ts | 44 | ||||
| -rw-r--r-- | src/domains/search/google-serp.ts | 114 | ||||
| -rw-r--r-- | src/domains/search/web-search.ts | 106 |
3 files changed, 264 insertions, 0 deletions
diff --git a/src/domains/search/extract.ts b/src/domains/search/extract.ts new file mode 100644 index 0000000..52f7f86 --- /dev/null +++ b/src/domains/search/extract.ts @@ -0,0 +1,44 @@ +/** + * The JavaScript source evaluated inside the Google SERP page to capture raw + * anchors + page text. This string crosses the CDP boundary via + * Runtime.evaluate (returnByValue) and returns a JSON string that + * `parseGoogleSerp` / `classifySerp` consume on the harness side. + * + * ADR-0001: anchors are selected structurally (a link carrying an <h3>), not by + * Google CSS class names, so cosmetic markup churn does not break extraction. + */ + +/** Max bytes of page text captured for CAPTCHA / no-results classification. */ +const PAGE_TEXT_LIMIT = 4000; + +/** + * Build the extraction expression. Takes no interpolated values, so it is a + * fixed source string (no injection surface). Returns + * `JSON.stringify({ anchors, pageText })`. + */ +export const buildSerpExtractionExpr = (): string => ` + (() => { + const main = document.querySelector('#search, #rso, #main') || document.body; + const headings = Array.from(main.querySelectorAll('a h3')); + const seen = new Set(); + const anchors = []; + for (const h3 of headings) { + const a = h3.closest('a[href]'); + if (!a) continue; + const href = a.href; + if (!href || seen.has(href)) continue; + seen.add(href); + const container = + a.closest('div.g') || + a.closest('div[data-hveid]') || + a.parentElement?.parentElement || + a.parentElement; + const containerText = container ? container.innerText || '' : ''; + const heading = h3.innerText || h3.textContent || ''; + const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500); + anchors.push({ href, heading, snippet }); + } + const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT}); + return JSON.stringify({ anchors, pageText }); + })() +`; diff --git a/src/domains/search/google-serp.ts b/src/domains/search/google-serp.ts new file mode 100644 index 0000000..7e49bea --- /dev/null +++ b/src/domains/search/google-serp.ts @@ -0,0 +1,114 @@ +/** + * Pure Google SERP parsing — no CDP, no browser. Everything here is a pure + * function over data captured from the page, so it is unit-testable against + * saved fixtures (see test/deep-research/serp-parser-test.ts). + * + * ADR-0001: results are extracted by structure/semantics (anchors carrying an + * <h3> heading in the main results column), never by brittle Google CSS class + * names, and Google redirect wrappers are unwound to the real target URL. + */ + +/** One raw anchor captured in-page: a link with a heading and nearby snippet. */ +export type SerpAnchor = { + readonly href: string; + readonly heading: string; + readonly snippet: string; +}; + +/** The JSON payload the in-page extraction expression returns. */ +export type SerpExtraction = { + readonly anchors: ReadonlyArray<SerpAnchor>; + readonly pageText: string; +}; + +/** A parsed, ranked search result — the tool's public output shape. */ +export type SearchResult = { + readonly title: string; + readonly url: string; + readonly snippet: string; + readonly rank: number; +}; + +/** Why a SERP yielded no usable results, or that it did. */ +export type SerpVerdict = "ok" | "captcha" | "no_results"; + +const CAPTCHA_MARKERS: ReadonlyArray<string> = [ + "unusual traffic", + "not a robot", + "recaptcha", + "detected unusual", + "systems have detected", + "before you continue to google", + "our systems have detected unusual traffic", +]; + +/** + * Decode a Google redirect wrapper (`/url?q=<real>&sa=...`) to its target. + * Returns the input unchanged when it is not a wrapper. + */ +const unwrapRedirect = (href: string): string => { + const wrapper = href.match(/^(?:https?:\/\/[^/]*google\.[^/]*)?\/url\?/i); + if (!wrapper) return href; + const query = href.slice(href.indexOf("?") + 1); + const target = new URLSearchParams(query).get("q"); + return target ?? href; +}; + +const isExternalResult = (url: string): boolean => { + if (!/^https?:\/\//i.test(url)) return false; + try { + const host = new URL(url).hostname.toLowerCase(); + if (host === "google.com" || host.endsWith(".google.com")) return false; + if (host === "googleusercontent.com" || host.endsWith(".googleusercontent.com")) return false; + return true; + } catch { + return false; + } +}; + +/** Normalized key for dedupe: origin + pathname, trailing slash and hash removed. */ +const dedupeKey = (url: string): string => { + try { + const u = new URL(url); + return `${u.origin}${u.pathname.replace(/\/$/, "")}`.toLowerCase(); + } catch { + return url.toLowerCase(); + } +}; + +/** + * Parse captured anchors into ranked results: unwrap redirects, keep only + * external http(s) links, dedupe by normalized url (first wins), rank by input + * order, cap to `limit`. + */ +export const parseGoogleSerp = (anchors: ReadonlyArray<SerpAnchor>, limit: number): ReadonlyArray<SearchResult> => { + const seen = new Set<string>(); + const results: SearchResult[] = []; + for (const anchor of anchors) { + if (results.length >= limit) break; + const url = unwrapRedirect(anchor.href); + if (!isExternalResult(url)) continue; + const key = dedupeKey(url); + if (seen.has(key)) continue; + seen.add(key); + results.push({ + title: anchor.heading.trim(), + url, + snippet: anchor.snippet.trim(), + rank: results.length + 1, + }); + } + return results; +}; + +/** + * Decide whether the SERP is usable, blocked by a CAPTCHA, or genuinely empty. + * `resultCount` is the number of parsed results; `pageText` is a bounded slice + * of the page's visible text. + */ +export const classifySerp = (pageText: string, resultCount: number): SerpVerdict => { + if (resultCount > 0) return "ok"; + const lower = pageText.toLowerCase(); + if (CAPTCHA_MARKERS.some((marker) => lower.includes(marker))) return "captcha"; + return "no_results"; +}; diff --git a/src/domains/search/web-search.ts b/src/domains/search/web-search.ts new file mode 100644 index 0000000..e5d343d --- /dev/null +++ b/src/domains/search/web-search.ts @@ -0,0 +1,106 @@ +import { Type } from "typebox"; +import { type Result, err, ok } from "../../util/result"; +import { applyTruncation } from "../../util/truncate"; +import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool"; +import { + closeIsolatedTab, + evalInIsolatedTab, + navigateIsolatedTab, + openIsolatedTab, + waitForIsolatedLoad, +} from "../isolated-tab"; +import { buildSerpExtractionExpr } from "./extract"; +import { classifySerp, parseGoogleSerp, type SearchResult, type SerpExtraction } from "./google-serp"; + +const LOAD_TIMEOUT_MS = 15_000; + +const WebSearchArgs = Type.Object({ + query: Type.String({ description: "The search query." }), + limit: Type.Optional( + Type.Integer({ default: 10, minimum: 1, maximum: 30, description: "Max results to return. Default: 10." }), + ), +}); + +const buildSerpUrl = (query: string, limit: number): string => + `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`; + +const isSerpExtraction = (value: unknown): value is SerpExtraction => { + if (typeof value !== "object" || value === null) return false; + const v = value as Record<string, unknown>; + return Array.isArray(v["anchors"]) && typeof v["pageText"] === "string"; +}; + +const formatResults = (query: string, results: ReadonlyArray<SearchResult>): string => { + const lines = results.map((r) => `${r.rank}. ${r.title}\n ${r.url}\n ${r.snippet}`); + return `${results.length} result(s) for "${query}":\n\n${lines.join("\n\n")}`; +}; + +export const webSearchTool = defineBrowserTool({ + name: "browser_web_search", + label: "Browser Web Search", + description: + "Search the web and return ranked results (title, url, snippet, rank). Scrapes a Google results page in the real Chrome — no API key. Returns links only; use browser_read_page to read a result's content.", + promptSnippet: "Search the web for a query", + promptGuidelines: [ + "Returns ranked {title, url, snippet, rank} — links only, no page content. Follow up with browser_read_page.", + "On a CAPTCHA / bot wall the call fails with kind:'invalid_state' and details.reason:'captcha' (plus serpUrl) — surface it to the user; do not retry in a tight loop.", + "On zero results it fails with details.reason:'no_results' — rephrase the query and try again.", + "Runs in its own isolated tab and never disturbs the user's current tab.", + ], + parameters: WebSearchArgs, + async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> { + const limit = args.limit ?? 10; + const serpUrl = buildSerpUrl(args.query, limit); + + const opened = await openIsolatedTab(client); + if (!opened.success) return err(opened.error); + const tab = opened.data; + + try { + const navigated = await navigateIsolatedTab(client, tab, serpUrl); + if (!navigated.success) return err(navigated.error); + + const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal); + if (!loaded.success) return err(loaded.error); + + const extracted = await evalInIsolatedTab(client, tab, buildSerpExtractionExpr()); + if (!extracted.success) return err(extracted.error); + + const parsedJson = typeof extracted.data === "string" ? safeParse(extracted.data) : extracted.data; + if (!isSerpExtraction(parsedJson)) { + return err({ kind: "internal", message: "SERP extraction returned an unexpected shape" }); + } + + const results = parseGoogleSerp(parsedJson.anchors, limit); + const verdict = classifySerp(parsedJson.pageText, results.length); + if (verdict !== "ok") { + return err({ + kind: "invalid_state", + message: verdict === "captcha" ? "Google served a CAPTCHA / bot wall" : "no results found", + details: { reason: verdict, serpUrl }, + }); + } + + const truncated = await applyTruncation(formatResults(args.query, results), "search"); + return ok({ + text: truncated.text, + details: { + results, + engine: "google", + query: args.query, + ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}), + }, + }); + } finally { + await closeIsolatedTab(client, tab); + } + }, +}); + +const safeParse = (text: string): unknown => { + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +}; |
