summaryrefslogtreecommitdiff
path: root/src/search.ts
blob: fa7ce35efd1a329095618521674f5a3e25d9b1b5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
/**
 * 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 { BrowserUnavailableError, NoResultsError, SearchChallengeError } from "./errors.ts";
import { PROBE_SCRIPT, 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 = "day" | "week" | "month" | "year";

export type SearchRequest = {
  query: string;
  numResults: number;
  recency?: Recency | 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;
};

/** Google's date-restrict vocabulary. */
const RECENCY_CODE: Record<Recency, string> = { day: "d", week: "w", month: "m", year: "y" };

/**
 * `udm=14` asks for the plain "Web" tab: no AI overview, no carousels, just
 * ranked links. That is both cheaper to parse and closer to what the agent
 * asked for. `hl=en` pins the result language so snippets do not change shape
 * with whatever locale the browser profile happens to carry.
 *
 * Two findings from testing against the real browser, both counter-intuitive
 * enough to be worth writing down:
 *
 *   - `tbs=qdr:*` — the parameter the Tools menu puts in the URL — renders an
 *     empty page in this profile, with `#search` present but no `#rso` and no
 *     results at all. `as_qdr=*`, the older advanced-search parameter, works and
 *     genuinely filters (verified: d/w/m/y all return dated results).
 *   - Any date restriction combined with `udm=14` also renders that empty page.
 *
 * So a time-limited search drops `udm` and uses `as_qdr`. The classic layout it
 * falls back to parses fine — the extractor was checked against both.
 */
export const buildSearchUrl = (request: SearchRequest): string => {
  const params = new URLSearchParams({
    q: request.query,
    num: String(request.numResults),
    hl: "en",
  });
  if (request.recency) params.set("as_qdr", RECENCY_CODE[request.recency]);
  else params.set("udm", "14");
  return `https://www.google.com/search?${params.toString()}`;
};

// ── 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);

/**
 * 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.
  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(request);

    try {
      report("navigating", `searching for "${request.query}"`);
      await page.navigate(searchUrl, signal);

      report("extracting", "reading results");
      let probe = await page.evaluate<PageProbe>(PROBE_SCRIPT, 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>(PROBE_SCRIPT, 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,
          detail: probe.challenge.detail || undefined,
        });
      }

      if (probe.results.length === 0) {
        // "#rso missing entirely" and "#rso present but empty" are different
        // faults, and saying which saves the next person a browser session.
        const diagnosis =
          probe.container === "rso"
            ? "The results container was rendered but nothing could be parsed out of it, which suggests Google's " +
              "result markup changed and castle_cdp_search's extractor needs updating."
            : "Google served a page with no results area at all. Most likely this is a soft rate-limit: as a " +
              "block decays Google stops serving the /sorry/ challenge page and returns an empty result page " +
              "instead, which is indistinguishable from a genuine zero-hit search (observed lasting ~90 minutes " +
              "after heavy querying). It can also mean the query truly has no hits, or that this combination of " +
              "search parameters is not honoured for this browser profile.";
        throw new NoResultsError(
          `No results could be read from ${probe.url || searchUrl} (page title: "${probe.title}"). ${diagnosis} ` +
            `Before concluding the extractor is broken, try a broader query and the same query without recency; ` +
            `if those are also empty, treat it as rate limiting and wait rather than retrying in a loop.`,
        );
      }

      return {
        results: probe.results.slice(0, request.numResults),
        searchUrl,
        finalUrl: probe.url || searchUrl,
        endpoint,
      };
    } 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();
  }
};