aboutsummaryrefslogtreecommitdiff
path: root/castle-search/src/search.ts
diff options
context:
space:
mode:
Diffstat (limited to 'castle-search/src/search.ts')
-rw-r--r--castle-search/src/search.ts339
1 files changed, 339 insertions, 0 deletions
diff --git a/castle-search/src/search.ts b/castle-search/src/search.ts
new file mode 100644
index 0000000..1306443
--- /dev/null
+++ b/castle-search/src/search.ts
@@ -0,0 +1,339 @@
+/**
+ * Search orchestration: resolve a browser, borrow a page, navigate, extract,
+ * hand the page back.
+ *
+ * The connection is process-wide and lazy. It is *not* opened by the extension
+ * factory — pi runs factories in invocations that never start a session, and
+ * the docs are explicit that sockets must not start there. It is opened on the
+ * first search and closed by an idempotent `session_shutdown`.
+ */
+
+import { BrowserConnection, PageSession, raceAbort } from "./cdp.ts";
+import {
+ type CdpTarget,
+ describeCdpTarget,
+ describeResolutionFailure,
+ resolveCdpTarget,
+} from "./endpoint.ts";
+import { describeEngineFailure, describeEngineSource, resolveEngine } from "./engine-target.ts";
+import { readStoredEngine } from "./engine-store.ts";
+import {
+ ENGINE_IDS as allEngineIdList,
+ type EngineDefinition,
+ type EngineId,
+ type Recency,
+ describeRecencySupport,
+ enginesSupportingRecency,
+ getEngine,
+} from "./engines.ts";
+
+const allEngineIds = (): readonly EngineId[] => allEngineIdList;
+import {
+ BrowserUnavailableError,
+ NoResultsError,
+ RecencyUnsupportedError,
+ SearchChallengeError,
+} from "./errors.ts";
+import { buildProbeScript, type PageProbe, type SearchResult } from "./extract.ts";
+import { readStoredTarget } from "./target-store.ts";
+
+/** Per-search wall clock, covering connect, navigate and extract together. */
+export const SEARCH_TIMEOUT_MS = 20_000;
+/** How many searches may drive the shared browser at once. */
+export const MAX_CONCURRENT_SEARCHES = 2;
+/** Results sometimes land a beat after the load event; re-read a couple of times. */
+const EXTRACT_RETRIES = 2;
+const EXTRACT_RETRY_DELAY_MS = 700;
+
+export type { Recency } from "./engines.ts";
+
+export type SearchRequest = {
+ query: string;
+ numResults: number;
+ recency?: Recency | undefined;
+ /** Per-call engine override; falls back to the configured default. */
+ engine?: string | null | undefined;
+};
+
+export type SearchOutcome = {
+ results: SearchResult[];
+ searchUrl: string;
+ /** The URL the page settled on, which differs from searchUrl after a redirect. */
+ finalUrl: string;
+ endpoint: string;
+ engine: EngineDefinition;
+ /** Where the engine choice came from, for the "via" line in the output. */
+ engineSource: string;
+};
+
+/** Build the SERP URL for a request. Exported for tests. */
+export const buildSearchUrl = (engine: EngineDefinition, request: SearchRequest): string =>
+ engine.buildUrl(request.query, request.numResults, request.recency);
+
+/**
+ * Resolve the engine, refusing rather than silently ignoring a recency window
+ * the chosen engine cannot express.
+ *
+ * Dropping an unsupported filter would be the same failure mode as Google's
+ * `tbs=qdr:` — the caller asks for recent results, gets whatever the engine
+ * ranked, and has no way to tell. Naming the engines that *do* support the
+ * window makes the error actionable, since the model can retry with one.
+ */
+export const selectEngine = async (
+ request: SearchRequest,
+): Promise<{ engine: EngineDefinition; source: string }> => {
+ const resolution = resolveEngine(request.engine, process.env, await readStoredEngine());
+ if (resolution.kind === "invalid") {
+ throw new BrowserUnavailableError(describeEngineFailure(resolution));
+ }
+ const engine = getEngine(resolution.id);
+
+ if (request.recency && engine.recency[request.recency] === undefined) {
+ const alternatives = enginesSupportingRecency(request.recency);
+ throw new RecencyUnsupportedError(
+ `${engine.label} cannot restrict results to the past ${request.recency}. ` +
+ `It supports: ${describeRecencySupport(engine)}. ` +
+ (alternatives.length > 0
+ ? `Engines that support "${request.recency}": ${alternatives.join(", ")}. ` +
+ `Retry with engine set to one of those, or drop the recency parameter.`
+ : `No configured engine supports that window; drop the recency parameter.`),
+ );
+ }
+
+ return { engine, source: describeEngineSource(resolution.source) };
+};
+
+/** For status reporting — resolves the configured engine without searching. */
+export const describeConfiguredEngine = async (): Promise<{ id: EngineId; source: string; label: string }> => {
+ const resolution = resolveEngine(null, process.env, await readStoredEngine());
+ if (resolution.kind === "invalid") {
+ return { id: "google", source: describeEngineFailure(resolution), label: "unresolved" };
+ }
+ return {
+ id: resolution.id,
+ source: describeEngineSource(resolution.source),
+ label: getEngine(resolution.id).label,
+ };
+};
+
+// ── connection lifecycle ───────────────────────────────────────────────────
+
+let connection: BrowserConnection | null = null;
+/** In-flight connect, so two concurrent searches share one dial rather than racing. */
+let connecting: Promise<BrowserConnection> | null = null;
+
+const resolveTarget = async (): Promise<CdpTarget> => {
+ const resolution = resolveCdpTarget(process.env, await readStoredTarget());
+ if (resolution.kind === "invalid") {
+ throw new BrowserUnavailableError(describeResolutionFailure(resolution));
+ }
+ return resolution.target;
+};
+
+/**
+ * Return a live connection, dialling one if there isn't one or the last one
+ * died. Reconnecting transparently matters here: castle recycles Chrome hourly,
+ * so a session that searched an hour ago is holding a dead socket.
+ */
+const ensureConnection = async (signal?: AbortSignal): Promise<BrowserConnection> => {
+ if (connection && connection.isOpen) return connection;
+ if (connection) {
+ connection.close();
+ connection = null;
+ }
+
+ if (!connecting) {
+ // Assigned synchronously, before the first await inside: two searches
+ // starting in the same tick must share one dial, not open two sockets and
+ // leak whichever loses the assignment race.
+ connecting = (async () => {
+ const target = await resolveTarget();
+ const conn = await BrowserConnection.connect(target);
+ connection = conn;
+ return conn;
+ })().finally(() => {
+ connecting = null;
+ });
+ }
+
+ // The caller's signal is raced here rather than passed into connect(). A
+ // shared dial must not be cancelled by whichever caller happened to start it
+ // — one search pressing Esc would otherwise fail the other search waiting on
+ // the same connection. connect() is bounded by its own timeouts regardless.
+ return raceAbort(connecting, signal);
+};
+
+/** Idempotent: safe to call from `session_shutdown` however many times it fires. */
+export const disconnect = (): void => {
+ connection?.close();
+ connection = null;
+};
+
+/** For status reporting — never dials. */
+export const describeConfiguredEndpoint = async (): Promise<string> => {
+ const resolution = resolveCdpTarget(process.env, await readStoredTarget());
+ return resolution.kind === "invalid"
+ ? describeResolutionFailure(resolution)
+ : describeCdpTarget(resolution.target);
+};
+
+// ── concurrency cap ────────────────────────────────────────────────────────
+
+let active = 0;
+const waiting: Array<() => void> = [];
+
+/** Give a held slot to the next waiter, or return it to the pool. */
+const handOff = (): void => {
+ const next = waiting.shift();
+ // Hand the slot straight over rather than decrementing and letting the waiter
+ // re-check, so a queued search cannot be overtaken by a newly arriving one.
+ if (next) next();
+ else active--;
+};
+
+const acquire = async (signal?: AbortSignal): Promise<() => void> => {
+ if (active < MAX_CONCURRENT_SEARCHES) {
+ active++;
+ } else {
+ let resolver!: () => void;
+ const queued = new Promise<void>((resolve) => {
+ resolver = resolve;
+ });
+ waiting.push(resolver);
+ try {
+ await raceAbort(queued, signal);
+ // The slot was handed over by handOff(), which already counted it.
+ } catch (e) {
+ // An abort while queued must not strand the slot. Either we are still in
+ // the queue — drop out of it — or the slot was handed to us in the same
+ // tick the abort landed, in which case give it back. Without this, two
+ // cancelled-while-queued searches would wedge the semaphore permanently.
+ const index = waiting.indexOf(resolver);
+ if (index !== -1) waiting.splice(index, 1);
+ else handOff();
+ throw e;
+ }
+ }
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ handOff();
+ };
+};
+
+// ── the search itself ──────────────────────────────────────────────────────
+
+export type ProgressReporter = (phase: SearchPhase, note: string) => void;
+export type SearchPhase = "queued" | "connecting" | "opening" | "navigating" | "extracting";
+
+const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
+ raceAbort(new Promise<void>((resolve) => setTimeout(resolve, ms)), signal);
+
+/**
+ * The other engines, named in failure messages. A blocked engine is the case
+ * where the agent most needs to know it has somewhere else to go, and telling
+ * it inside the error beats hoping it remembers the guidelines.
+ */
+const otherEngines = (current: EngineId): readonly string[] =>
+ allEngineIds().filter((id) => id !== current);
+
+/**
+ * Run one search. Throws on every failure — see errors.ts for why a returned
+ * error object would be worse than useless here.
+ */
+export const search = async (
+ request: SearchRequest,
+ signal: AbortSignal,
+ report: ProgressReporter,
+): Promise<SearchOutcome> => {
+ // Reported before acquiring, so a search that dies waiting for a slot is
+ // distinguishable from one that died reaching the browser. The deadline is
+ // the caller's and covers the queue wait too — with a cap of two and searches
+ // that take a couple of seconds, waiting is the rare case, and a tool call
+ // that silently takes twice its stated deadline would be worse.
+ // Resolved before acquiring a slot: an unknown engine name or an unsupported
+ // recency window is a caller error, and should not wait behind other searches
+ // or touch the browser at all.
+ const { engine, source: engineSource } = await selectEngine(request);
+ const probeScript = buildProbeScript(engine.extraction);
+
+ report("queued", "waiting for a search slot");
+ const release = await acquire(signal);
+ try {
+ report("connecting", "resolving browser");
+ const conn = await ensureConnection(signal);
+ const endpoint = describeCdpTarget(conn.target);
+
+ report("opening", `opening a page on ${endpoint}`);
+ const page = await PageSession.open(conn, signal);
+ const searchUrl = buildSearchUrl(engine, request);
+
+ try {
+ report("navigating", `searching ${engine.label} for "${request.query}"`);
+ await page.navigate(searchUrl, signal);
+
+ report("extracting", "reading results");
+ let probe = await page.evaluate<PageProbe>(probeScript, signal);
+ for (let attempt = 0; attempt < EXTRACT_RETRIES; attempt++) {
+ if (probe?.challenge || (probe?.results?.length ?? 0) > 0) break;
+ await sleep(EXTRACT_RETRY_DELAY_MS, signal);
+ probe = await page.evaluate<PageProbe>(probeScript, signal);
+ }
+
+ if (!probe) {
+ throw new BrowserUnavailableError("the page returned nothing — the extraction script did not run");
+ }
+
+ if (probe.challenge) {
+ throw new SearchChallengeError({
+ challenge: probe.challenge.kind,
+ // The page the human must clear is where the browser *ended up*, which
+ // after a consent redirect is not the URL we asked for.
+ pageUrl: probe.url || searchUrl,
+ endpoint,
+ engine: engine.label,
+ alternatives: otherEngines(engine.id),
+ detail: probe.challenge.detail || undefined,
+ });
+ }
+
+ if (probe.results.length === 0) {
+ // "container missing entirely" and "container present but empty" are
+ // different faults, and saying which saves the next person a browser
+ // session.
+ const diagnosis =
+ probe.container === "found"
+ ? `${engine.label}'s results container was rendered but nothing could be parsed out of it, which ` +
+ `suggests its markup changed and castle_cdp_search's extractor for ${engine.id} needs updating.`
+ : `${engine.label} served a page with no results area at all. This is often a soft rate-limit: as a ` +
+ `block decays an engine stops serving its challenge page and returns an empty result page instead, ` +
+ `which is indistinguishable from a genuine zero-hit search (observed on Google lasting ~90 minutes ` +
+ `after heavy querying). It can also mean the query truly has no hits.`;
+ throw new NoResultsError(
+ `No results could be read from ${probe.url || searchUrl} (page title: "${probe.title}"). ${diagnosis} ` +
+ `Before concluding the extractor is broken, retry with a different engine (${otherEngines(engine.id).join(", ")}); ` +
+ `if those also come back empty the query is the problem, and if only this one does, it is rate limiting — ` +
+ `wait rather than retrying in a loop.`,
+ );
+ }
+
+ return {
+ results: probe.results.slice(0, request.numResults),
+ searchUrl,
+ finalUrl: probe.url || searchUrl,
+ endpoint,
+ engine,
+ engineSource,
+ };
+ } finally {
+ // Always, including the challenge path. Leaving the tab open would let the
+ // operator solve the captcha in place, but it would also litter a browser
+ // someone else is using with abandoned tabs on every failure; the error
+ // carries the URL instead, and the cookie it sets is profile-wide.
+ await page.close();
+ }
+ } finally {
+ release();
+ }
+};