aboutsummaryrefslogtreecommitdiff
path: root/castle-search/src/resolve.ts
blob: f432f7d69d84ff1a23f431d9513f73021aabc2c7 (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
/**
 * 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() };
    }),
  );