aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-08 21:13:15 +0530
committeramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-08 21:13:15 +0530
commitc5c42df5976ed63faac2adcf3b04a4ad2f519b32 (patch)
tree77d389a590864eaf5c2408c9bc99ed07f55343d8 /src
parent7826c950adee89512162185d07fee565ab0e96cc (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.ts117
-rw-r--r--src/domains/search/extract.ts44
-rw-r--r--src/domains/search/google-serp.ts114
-rw-r--r--src/domains/search/web-search.ts106
-rw-r--r--src/registry.ts3
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 => {