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 | |
| 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')
| -rw-r--r-- | src/domains/isolated-tab.ts | 117 | ||||
| -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 | ||||
| -rw-r--r-- | src/registry.ts | 3 |
5 files changed, 384 insertions, 0 deletions
diff --git a/src/domains/isolated-tab.ts b/src/domains/isolated-tab.ts new file mode 100644 index 0000000..c759cec --- /dev/null +++ b/src/domains/isolated-tab.ts @@ -0,0 +1,117 @@ +/** + * Isolated-attach tab lifecycle (ADR-0002 v1). Opens a harness-owned tab, + * attaches to it by its own CDP sessionId, and drives it via callOnTarget / + * evaluateJs(expr, sid) WITHOUT ever touching the global "current" tab — so a + * search or page-read never disturbs the tab the user is looking at, even when + * several subagents run concurrently. + * + * Mirrors the create/attach sequence in browser_open_urls + * (src/domains/navigate.ts:84-139). Every page evaluation is bounded by a + * timeout, so a JS dialog on the (uninstrumented) isolated tab surfaces as a + * typed timeout rather than hanging — see the dialog-hang caveat in plan.md. + */ +import type { BrowserClient } from "../client"; +import { type Result, err, ok } from "../util/result"; +import { safeJs } from "../util/js-template"; +import type { ToolErr } from "../util/tool"; + +/** A tab attached by its own sessionId, ready to drive via callOnTarget. */ +export type IsolatedTab = { + readonly targetId: string; + readonly sessionId: string; +}; + +const EVAL_TIMEOUT_MS = 15_000; +const READY_POLL_INTERVAL_MS = 50; + +const toToolErr = (message: string, kind: ToolErr["kind"] = "cdp_error"): ToolErr => ({ kind, message }); + +/** + * Open a harness-owned tab and attach to it, returning its targetId + private + * sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done. + */ +export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => { + const hw = client.ownership().harnessWindow(); + const createParams: Record<string, unknown> = hw + ? { url: "about:blank", openerId: hw } + : { url: "about:blank", newWindow: true }; + + const created = await client.session().callBrowser("Target.createTarget", createParams); + if (!created.success) return err(toToolErr(created.error.message)); + const { targetId } = created.data as { targetId: string }; + + if (createParams["newWindow"]) client.ownership().setHarnessWindow(targetId); + client.ownership().add(targetId); + + const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true }); + if (!attached.success) { + await client.closeTab(targetId); + return err(toToolErr(attached.error.message)); + } + const { sessionId } = attached.data as { sessionId: string }; + + const enabled = await client.session().callOnTarget("Page.enable", {}, sessionId); + if (!enabled.success) { + await client.closeTab(targetId); + return err(toToolErr(enabled.error.message)); + } + return ok({ targetId, sessionId }); +}; + +/** Navigate an isolated tab to a URL (does not wait for load). */ +export const navigateIsolatedTab = async ( + client: BrowserClient, + tab: IsolatedTab, + url: string, +): Promise<Result<void, ToolErr>> => { + const nav = await client.session().callOnTarget("Page.navigate", { url }, tab.sessionId, { + timeoutMs: EVAL_TIMEOUT_MS, + }); + if (!nav.success) return err(toToolErr(nav.error.message)); + return ok(undefined); +}; + +/** Poll the isolated tab until document.readyState === 'complete' or timeout. */ +export const waitForIsolatedLoad = async ( + client: BrowserClient, + tab: IsolatedTab, + timeoutMs: number, + signal?: AbortSignal, +): Promise<Result<void, ToolErr>> => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (signal?.aborted) return err(toToolErr("aborted", "internal")); + const r = await client.evaluateJs(safeJs`document.readyState`, tab.sessionId); + if (r.success && r.data === "complete") return ok(undefined); + await new Promise((res) => setTimeout(res, READY_POLL_INTERVAL_MS)); + } + return err(toToolErr(`page did not finish loading in ${Math.round(timeoutMs / 1000)}s`, "timeout")); +}; + +/** + * Evaluate an expression in the isolated tab and return the raw value. Bounds + * the eval with a timeout so a blocking dialog cannot hang the tool. + */ +export const evalInIsolatedTab = async ( + client: BrowserClient, + tab: IsolatedTab, + expression: string, +): Promise<Result<unknown, ToolErr>> => { + const r = await client.session().callOnTarget( + "Runtime.evaluate", + { expression, returnByValue: true, awaitPromise: true }, + tab.sessionId, + { timeoutMs: EVAL_TIMEOUT_MS }, + ); + if (!r.success) return err(toToolErr(r.error.message, r.error.kind === "timeout" ? "timeout" : "cdp_error")); + const data = r.data as { result?: { value?: unknown }; exceptionDetails?: unknown }; + if (data.exceptionDetails) { + return err(toToolErr(`page evaluation failed: ${JSON.stringify(data.exceptionDetails)}`)); + } + return ok(data.result?.value); +}; + +/** Close an isolated tab and drop its ownership. Best-effort; never throws. */ +export const closeIsolatedTab = async (client: BrowserClient, tab: IsolatedTab): Promise<void> => { + await client.closeTab(tab.targetId).catch(() => {}); +}; 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; + } +}; diff --git a/src/registry.ts b/src/registry.ts index 22e2fa6..c7ff5c8 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -19,6 +19,7 @@ import { consoleTool } from "./domains/console"; import { snapshotTool } from "./domains/snapshot"; import { executeJsTool, runScriptTool } from "./domains/js"; import { setupTool } from "./domains/setup"; +import { webSearchTool } from "./domains/search/web-search"; // Mutation tools that modify page / browser state are marked `serialized: true` // so they run through a shared async mutex. Observation / read-only tools are @@ -61,6 +62,8 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [ snapshotTool, executeJsTool, runScriptTool, + // Opens and owns an isolated tab to scrape a SERP — same class as openUrlsTool. + { ...webSearchTool, serialized: true }, ]; export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => { |
