aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--castle-search/README.md20
-rw-r--r--castle-search/src/errors.ts19
-rw-r--r--castle-search/src/format.ts8
-rw-r--r--castle-search/src/google-links.ts15
-rw-r--r--castle-search/src/index.ts37
-rw-r--r--castle-search/src/resolve.ts53
-rw-r--r--castle-search/src/search.ts47
-rw-r--r--castle-search/test/google-links.test.ts41
-rw-r--r--castle-search/test/resolve.test.ts50
9 files changed, 182 insertions, 108 deletions
diff --git a/castle-search/README.md b/castle-search/README.md
index 23a36e6..f3fb5e9 100644
--- a/castle-search/README.md
+++ b/castle-search/README.md
@@ -184,8 +184,24 @@ Two modes, because the engines genuinely differ:
DuckDuckGo and Bing both route outbound links through redirectors
(`duckduckgo.com/l/?uddg=…`, `bing.com/ck/a?…&u=a1<base64url>`); both are
-unwrapped in the page so the agent gets real URLs. Google and Brave link
-straight out.
+unwrapped in the page so the agent gets real URLs. Brave links straight out.
+
+**Google wraps every result** as `google.com/goto?url=<opaque token>` (rolled
+out June–August 2026; Google called it "technical measures against evolving
+forms of abuse" and has not documented it). The token is not reversible and the
+target is not in the DOM. So search leaves Google links wrapped, prints the
+breadcrumb Google shows for each, and `castle_cdp_resolve` follows one link in
+Chrome — one tab, closed afterwards — when the agent decides it wants it.
+Resolve lazily: a plain HTTP client (curl, Node `fetch`) is answered with a
+"Verifying your request" page and a 17–19 s countdown after a handful of
+requests, while a click or a direct navigation in Chrome redirects at once
+(measured 2026-11-06). If Chrome does get the countdown, `GoogleVerificationError`
+says so; it is never waited out or clicked through. Never resolve links with
+plain HTTP: that was tried first, and the testing itself triggered the check.
+
+An engine's own "no results" page is not an extractor failure. DuckDuckGo's
+notice is itself a `.result`, so `emptyMarker` identifies it and the error says
+the query was too narrow rather than blaming markup or a rate limit.
Markup will change. When it does, `NoResultsError` distinguishes "container
rendered but unparseable" (that engine's extractor needs updating) from "no
diff --git a/castle-search/src/errors.ts b/castle-search/src/errors.ts
index 2318b27..95fa7ef 100644
--- a/castle-search/src/errors.ts
+++ b/castle-search/src/errors.ts
@@ -81,3 +81,22 @@ export class SearchAbortedError extends Error {
}
export const messageOf = (e: unknown): string => (e instanceof Error ? e.message : String(e));
+
+/**
+ * Google answered a `/goto` link with its "Verifying your request" page instead
+ * of redirecting. It is a bot check with a countdown; it is reported, never
+ * waited out or clicked through.
+ */
+export class GoogleVerificationError extends Error {
+ override readonly name = "GoogleVerificationError";
+ readonly pageUrl: string;
+ constructor(pageUrl: string) {
+ super(
+ "Google showed its \"Verifying your request\" page instead of redirecting, so the real URL could not be " +
+ "read. This is a rate-based bot check: opening many links in a row triggers it, and it eases after a few " +
+ "minutes. Do not retry in a loop. Use the breadcrumb printed under the result to judge where it points, " +
+ "pick fewer links, or search again with another engine (duckduckgo, brave, bing), which return real URLs.",
+ );
+ this.pageUrl = pageUrl;
+ }
+}
diff --git a/castle-search/src/format.ts b/castle-search/src/format.ts
index aef6c3b..9c7c3cc 100644
--- a/castle-search/src/format.ts
+++ b/castle-search/src/format.ts
@@ -8,6 +8,7 @@
*/
import type { SearchResult } from "./extract.ts";
+import { isGoogleGoto } from "./google-links.ts";
export type FormatInput = {
query: string;
@@ -36,6 +37,13 @@ export const formatResults = (input: FormatInput): string => {
input.results.forEach((result, index) => {
lines.push(`${index + 1}. ${result.title}`);
lines.push(` ${result.url}`);
+ // Google wraps every link and only reveals the target when followed. Say so
+ // here, next to the link, with what Google displays for it — that is often
+ // enough to decide without resolving anything.
+ if (isGoogleGoto(result.url)) {
+ const shown = result.display ? ` Google shows it as: ${result.display}.` : "";
+ lines.push(` [Google-wrapped link, real URL not yet known.${shown} Pass it to castle_cdp_resolve if you need to open it.]`);
+ }
if (result.snippet) lines.push(` ${result.snippet}`);
lines.push("");
});
diff --git a/castle-search/src/google-links.ts b/castle-search/src/google-links.ts
new file mode 100644
index 0000000..7500e2d
--- /dev/null
+++ b/castle-search/src/google-links.ts
@@ -0,0 +1,15 @@
+/**
+ * Google wraps every outbound result in `https://www.google.com/goto?url=<opaque
+ * token>`. The token is a protobuf blob with no URL in it and the target appears
+ * nowhere in the page, so the only way to learn it is to follow the link.
+ *
+ * It is followed in Chrome, lazily, one link at a time, when the caller asks
+ * (see `resolveLink` in search.ts) — not here, and not for every result. A
+ * plain HTTP client gets Google's "Verifying your request" countdown after a
+ * handful of requests; the browser, with its own profile, gets the redirect.
+ */
+
+const GOTO = /^https?:\/\/(www\.)?google\.(?:com?\.)?[a-z]{2,3}\/goto\?url=[A-Za-z0-9_-]+$/;
+
+/** Only these may be opened by castle_cdp_resolve — it is not a general navigator. */
+export const isGoogleGoto = (url: string): boolean => GOTO.test(url);
diff --git a/castle-search/src/index.ts b/castle-search/src/index.ts
index 29cdaaf..9b7c143 100644
--- a/castle-search/src/index.ts
+++ b/castle-search/src/index.ts
@@ -43,6 +43,7 @@ import {
describeConfiguredEndpoint,
describeConfiguredEngine,
disconnect,
+ resolveLink,
search,
} from "./search.ts";
@@ -202,6 +203,38 @@ export default function (pi: ExtensionAPI): void {
});
pi.registerTool({
+ name: "castle_cdp_resolve",
+ label: "Castle Resolve",
+ description:
+ "Turn one Google-wrapped result link (https://www.google.com/goto?url=…, as printed by castle_cdp_search " +
+ "with engine google) into the real destination URL, by following it in the same Chrome. Opens one tab, " +
+ "reads where it lands, closes it. It does not read the page. Resolve only the results you mean to open: " +
+ "Google answers a burst of links with a \"Verifying your request\" countdown, which this tool reports " +
+ "instead of waiting out. Other engines already return real URLs and need no resolving.",
+ promptSnippet: "Resolve one Google-wrapped result link to its real URL (castle_cdp_resolve)",
+ promptGuidelines: [
+ "castle_cdp_search on Google returns wrapped links. Read the titles, snippets and the breadcrumb Google " +
+ "shows for each, decide which result you actually need, and resolve only that one with castle_cdp_resolve. " +
+ "Do not resolve every result: that burst is what triggers Google's verification page.",
+ ],
+ parameters: Type.Object({
+ url: Type.String({
+ description: "A https://www.google.com/goto?url=… link exactly as castle_cdp_search printed it.",
+ minLength: 1,
+ }),
+ }),
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
+ const timeout = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
+ const out = await resolveLink(params.url.trim(), combined);
+ return {
+ content: [{ type: "text", text: `${out.to}\n(resolved via Chrome at ${out.endpoint})` }],
+ details: { from: out.from, to: out.to, endpoint: out.endpoint },
+ };
+ },
+ });
+
+ pi.registerTool({
name: "castle_cdp_search",
label: "Castle Search",
description:
@@ -209,7 +242,9 @@ export default function (pi: ExtensionAPI): void {
"DevTools Protocol. Returns ranked results with titles, URLs and snippets. Because it uses a real " +
"logged-in browser rather than a search API, it reaches pages that block datacentre traffic. It " +
"returns search results only — it does not fetch or read the linked pages, so follow up with a " +
- "fetch/read tool for full page content. Supports four engines with independent indexes and " +
+ "fetch/read tool for full page content. Google results come back as wrapped links with the " +
+ "breadcrumb Google shows; resolve the ones you need with castle_cdp_resolve. " +
+ "Supports four engines with independent indexes and " +
`independent rate limits (${ENGINE_IDS.join(", ")}); pass engine to switch when one is blocked. ` +
`Output is truncated at ${formatSize(DEFAULT_MAX_BYTES)} or ${DEFAULT_MAX_LINES} lines.`,
promptSnippet:
diff --git a/castle-search/src/resolve.ts b/castle-search/src/resolve.ts
deleted file mode 100644
index f432f7d..0000000
--- a/castle-search/src/resolve.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * Google wraps every outbound result in `https://www.google.com/goto?url=<opaque
- * token>`. The token is a protobuf blob with no URL in it, and the real target
- * appears nowhere in the DOM, so the only way to learn it is to ask Google: an
- * unauthenticated GET answers `302 Location: <target>`.
- *
- * That cannot be done from the page (cross-origin fetches see an opaque
- * redirect), so it is done here, one request per result, in parallel.
- */
-
-import type { SearchResult } from "./extract.ts";
-
-const GOTO = /^https?:\/\/(www\.)?google\.[a-z.]+\/goto\?/;
-const PER_LINK_TIMEOUT_MS = 4_000;
-
-export const isGoogleGoto = (url: string): boolean => GOTO.test(url);
-
-const resolveOne = async (url: string, signal?: AbortSignal): Promise<string> => {
- const timeout = AbortSignal.timeout(PER_LINK_TIMEOUT_MS);
- try {
- const res = await fetch(url, {
- redirect: "manual",
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
- headers: { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/157.0.0.0 Safari/537.36" },
- });
- const location = res.headers.get("location");
- return location && /^https?:/.test(location) ? location : url;
- } catch {
- // Left wrapped rather than dropped: the wrapped link still redirects to the
- // right page when followed, so the agent has a working link either way.
- return url;
- }
-};
-
-/**
- * Replaces Google `/goto` links with their targets; anything else passes through.
- *
- * Best effort, and it degrades with volume: after a handful of requests Google
- * answers 200 "Verifying your request… continue in a few seconds" (a 17-19 s
- * countdown) instead of 302, and every link then stays wrapped. Waiting that out
- * or skipping it is not on the table. A link that stays wrapped keeps the page's
- * own breadcrumb in its snippet, so the agent can still tell where it points.
- */
-export const resolveGoogleLinks = (results: SearchResult[], signal?: AbortSignal): Promise<SearchResult[]> =>
- Promise.all(
- results.map(async (r) => {
- if (!isGoogleGoto(r.url)) return r;
- const url = await resolveOne(r.url, signal);
- if (!isGoogleGoto(url) || !r.display) return { ...r, url };
- return { ...r, url, snippet: `[link unresolved; Google shows it as: ${r.display}] ${r.snippet}`.trim() };
- }),
- );
-
diff --git a/castle-search/src/search.ts b/castle-search/src/search.ts
index c64735d..b5effb8 100644
--- a/castle-search/src/search.ts
+++ b/castle-search/src/search.ts
@@ -8,7 +8,8 @@
* first search and closed by an idempotent `session_shutdown`.
*/
-import { resolveGoogleLinks } from "./resolve.ts";
+import { isGoogleGoto } from "./google-links.ts";
+import { GoogleVerificationError } from "./errors.ts";
import { BrowserConnection, PageSession, raceAbort } from "./cdp.ts";
import {
type CdpTarget,
@@ -332,7 +333,7 @@ export const search = async (
}
return {
- results: await resolveGoogleLinks(probe.results.slice(0, request.numResults), signal),
+ results: probe.results.slice(0, request.numResults),
searchUrl,
finalUrl: probe.url || searchUrl,
endpoint,
@@ -350,3 +351,45 @@ export const search = async (
release();
}
};
+
+export type ResolveOutcome = { from: string; to: string; endpoint: string };
+
+/**
+ * Follows one Google `/goto` link in Chrome and reports where it lands.
+ *
+ * Lazy by design: search leaves the links wrapped, and the caller resolves only
+ * the ones it means to open. One tab, one navigation, closed afterwards — the
+ * pattern of a person clicking a result, which Google serves directly.
+ *
+ * If Google answers with its "Verifying your request" page instead, the tab is
+ * still on google.com when the page loads. That is reported as such. The
+ * countdown is not waited out and nothing is clicked.
+ */
+export const resolveLink = async (url: string, signal: AbortSignal): Promise<ResolveOutcome> => {
+ if (!isGoogleGoto(url)) {
+ throw new Error(
+ "castle_cdp_resolve only follows Google result links of the form https://www.google.com/goto?url=… " +
+ "as printed by castle_cdp_search. Results from other engines already carry their real URL.",
+ );
+ }
+ const release = await acquire(signal);
+ try {
+ const conn = await ensureConnection(signal);
+ const endpoint = describeCdpTarget(conn.target);
+ const page = await PageSession.open(conn, signal);
+ try {
+ await page.navigate(url, signal);
+ const landed = await page.evaluate<string>("location.href", signal);
+ if (!landed || isGoogleHost(landed)) {
+ throw new GoogleVerificationError(landed || url);
+ }
+ return { from: url, to: landed, endpoint };
+ } finally {
+ await page.close();
+ }
+ } finally {
+ release();
+ }
+};
+
+const isGoogleHost = (url: string): boolean => /^https?:\/\/([a-z0-9-]+\.)*google\.(?:com?\.)?[a-z]{2,3}\//.test(url);
diff --git a/castle-search/test/google-links.test.ts b/castle-search/test/google-links.test.ts
new file mode 100644
index 0000000..f31f112
--- /dev/null
+++ b/castle-search/test/google-links.test.ts
@@ -0,0 +1,41 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { formatResults } from "../src/format.ts";
+import { isGoogleGoto } from "../src/google-links.ts";
+import { getEngine } from "../src/engines.ts";
+import { resolveLink } from "../src/search.ts";
+
+test("only well-formed google /goto links count", () => {
+ assert.equal(isGoogleGoto("https://www.google.com/goto?url=CAESabc_-9"), true);
+ assert.equal(isGoogleGoto("https://www.google.co.uk/goto?url=CAESabc"), true);
+ assert.equal(isGoogleGoto("https://www.google.com/search?q=x"), false);
+ assert.equal(isGoogleGoto("https://example.com/goto?url=CAES"), false);
+ // Not a general navigator: nothing may ride along after the token.
+ assert.equal(isGoogleGoto("https://www.google.com/goto?url=CAES&x=https://evil.example"), false);
+ assert.equal(isGoogleGoto("https://www.google.com.evil.example/goto?url=CAES"), false);
+});
+
+test("the google self-host filter lets /goto through but still blocks google's own pages", () => {
+ const re = new RegExp(getEngine("google").extraction.selfHostPattern);
+ assert.equal(re.test("https://www.google.com/goto?url=CAES"), false);
+ assert.equal(re.test("https://www.google.com/search?q=x"), true);
+ assert.equal(re.test("https://accounts.google.com/ServiceLogin"), false);
+});
+
+test("resolveLink refuses anything that is not a google /goto link, before touching the browser", async () => {
+ await assert.rejects(() => resolveLink("https://example.com/", AbortSignal.timeout(1000)), /only follows Google result links/);
+ await assert.rejects(() => resolveLink("file:///etc/passwd", AbortSignal.timeout(1000)), /only follows Google result links/);
+});
+
+test("a wrapped result says so, shows Google's breadcrumb, and names the tool to resolve it", () => {
+ const text = formatResults({
+ query: "q", searchUrl: "u", finalUrl: "u", endpoint: "e", engine: "Google", engineSource: "s",
+ results: [
+ { title: "Wrapped", url: "https://www.google.com/goto?url=CAES", snippet: "snip", display: "GitHub — https://github.com › a › b" },
+ { title: "Direct", url: "https://example.com/", snippet: "" },
+ ],
+ });
+ assert.match(text, /\[Google-wrapped link, real URL not yet known\. Google shows it as: GitHub — https:\/\/github\.com › a › b\. Pass it to castle_cdp_resolve/);
+ assert.equal((text.match(/Google-wrapped/g) ?? []).length, 1);
+});
diff --git a/castle-search/test/resolve.test.ts b/castle-search/test/resolve.test.ts
deleted file mode 100644
index bcd4ef4..0000000
--- a/castle-search/test/resolve.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import assert from "node:assert/strict";
-import { test } from "node:test";
-
-import { isGoogleGoto, resolveGoogleLinks } from "../src/resolve.ts";
-import { getEngine } from "../src/engines.ts";
-
-test("google /goto links are recognised, other google links are not", () => {
- assert.equal(isGoogleGoto("https://www.google.com/goto?url=CAES"), true);
- assert.equal(isGoogleGoto("https://www.google.co.uk/goto?url=CAES"), true);
- assert.equal(isGoogleGoto("https://www.google.com/search?q=x"), false);
- assert.equal(isGoogleGoto("https://example.com/goto?url=x"), false);
-});
-
-test("the google self-host filter lets /goto through but still blocks google's own pages", () => {
- const re = new RegExp(getEngine("google").extraction.selfHostPattern);
- assert.equal(re.test("https://www.google.com/goto?url=CAES"), false);
- assert.equal(re.test("https://www.google.com/search?q=x"), true);
- assert.equal(re.test("https://accounts.google.com/ServiceLogin"), false);
-});
-
-test("non-google results pass through untouched", async () => {
- const input = [{ title: "t", url: "https://example.com/", snippet: "" }];
- assert.deepEqual(await resolveGoogleLinks(input), input);
-});
-
-test("a link that cannot be resolved keeps the page's breadcrumb in its snippet", async () => {
- const wrapped = "https://www.google.com/goto?url=CAES";
- const real = globalThis.fetch;
- // Google's interstitial: 200 with no Location header.
- globalThis.fetch = (async () => new Response("<html>Verifying your request</html>", { status: 200 })) as typeof fetch;
- try {
- const [r] = await resolveGoogleLinks([{ title: "t", url: wrapped, snippet: "s", display: "GitHub — https://github.com › a › b" }]);
- assert.equal(r?.url, wrapped);
- assert.match(r?.snippet ?? "", /^\[link unresolved; Google shows it as: GitHub — https:\/\/github\.com › a › b\] s$/);
- } finally {
- globalThis.fetch = real;
- }
-});
-
-test("a link that resolves is replaced and the snippet is left alone", async () => {
- const real = globalThis.fetch;
- globalThis.fetch = (async () => new Response(null, { status: 302, headers: { location: "https://github.com/a/b" } })) as typeof fetch;
- try {
- const [r] = await resolveGoogleLinks([{ title: "t", url: "https://www.google.com/goto?url=CAES", snippet: "s", display: "x" }]);
- assert.equal(r?.url, "https://github.com/a/b");
- assert.equal(r?.snippet, "s");
- } finally {
- globalThis.fetch = real;
- }
-});