pi-browser-harness 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ import {
2
+ classifySerpWith,
3
+ externalResult,
4
+ parseSerp,
5
+ type ResolvedTargets,
6
+ type SearchEngine,
7
+ type SearchResult,
8
+ type SerpAnchor,
9
+ type SerpVerdict,
10
+ urlFromCite,
11
+ } from "../serp";
12
+
13
+ const CAPTCHA_MARKERS: ReadonlyArray<string> = [
14
+ "unusual traffic",
15
+ "not a robot",
16
+ "recaptcha",
17
+ "detected unusual",
18
+ "systems have detected",
19
+ "before you continue to google",
20
+ "our systems have detected unusual traffic",
21
+ ];
22
+
23
+ // Any Google property, on any TLD (google.com, google.co.uk, consent.google.de, googleusercontent.com), is never a
24
+ // result: a sorry or consent page is where a rate-limited /goto redirect lands.
25
+ const GOOGLE_PROPERTY = /(?:^|\.)google(?:usercontent)?\.(?:com?\.[a-z]{2}|[a-z]{2,3})$/;
26
+
27
+ const isExternalResult = externalResult((host) => GOOGLE_PROPERTY.test(host));
28
+
29
+ const unwrapRedirect = (href: string): string => {
30
+ const wrapper = /^(?:https?:\/\/[^/]*google\.[^/]*)?\/url\?/i.exec(href);
31
+ if (!wrapper) return href;
32
+ const query = href.slice(href.indexOf("?") + 1);
33
+ const target = new URLSearchParams(query).get("q");
34
+ return target ?? href;
35
+ };
36
+
37
+ // Rebuilds a url from Google's visible breadcrumb cite, the only place the target survives once Google wraps hrefs
38
+ // in /goto.
39
+ export const googleUrlFromCite = (cite: string): string | undefined => urlFromCite(cite, isExternalResult);
40
+
41
+ // Only a real Google /goto url counts: the daemon fetches these, so a look-alike host, userinfo, a port or http
42
+ // must never qualify. Hosts are google.com or www.google.<tld>, nothing else.
43
+ const GOOGLE_GOTO_HOST = /^(?:www\.)?google\.[a-z]{2,3}(?:\.[a-z]{2})?$/;
44
+ export const isGotoRedirect = (href: string): boolean => {
45
+ try {
46
+ const u = new URL(href);
47
+ return (
48
+ u.protocol === "https:" &&
49
+ u.username === "" &&
50
+ u.password === "" &&
51
+ u.port === "" &&
52
+ GOOGLE_GOTO_HOST.test(u.hostname.toLowerCase()) &&
53
+ u.pathname === "/goto"
54
+ );
55
+ } catch {
56
+ return false;
57
+ }
58
+ };
59
+
60
+ // Direct external hrefs win. A /goto wrapper is opaque: the server-resolved redirect target is exact, and the cite is
61
+ // a display breadcrumb that only approximates the path, so it is the fallback when no target was resolved.
62
+ const resultUrl = (anchor: SerpAnchor, resolved: ResolvedTargets): string | undefined => {
63
+ if (!isGotoRedirect(anchor.href)) {
64
+ const unwrapped = unwrapRedirect(anchor.href);
65
+ return isExternalResult(unwrapped) ? unwrapped : undefined;
66
+ }
67
+ const target = resolved.get(anchor.href);
68
+ if (target !== undefined && isExternalResult(target)) return target;
69
+ if (anchor.cite !== undefined && anchor.cite.trim() !== "") return googleUrlFromCite(anchor.cite);
70
+ return undefined;
71
+ };
72
+
73
+ export const google: SearchEngine = {
74
+ id: "google",
75
+ name: "Google",
76
+ buildUrl: (query, limit) => `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`,
77
+ selectors: {
78
+ root: "#search, #rso, #main",
79
+ heading: "a h3",
80
+ containers: ["div.g", "div[data-hveid]"],
81
+ cite: "cite",
82
+ },
83
+ captchaMarkers: CAPTCHA_MARKERS,
84
+ resultUrl,
85
+ needsRedirectResolve: isGotoRedirect,
86
+ };
87
+
88
+ export const parseGoogleSerp = (
89
+ anchors: ReadonlyArray<SerpAnchor>,
90
+ limit: number,
91
+ resolved: ResolvedTargets = new Map(),
92
+ ): ReadonlyArray<SearchResult> => parseSerp(google, anchors, limit, resolved);
93
+
94
+ export const classifyGoogleSerp = (pageText: string, resultCount: number, anchorCount: number): SerpVerdict =>
95
+ classifySerpWith(CAPTCHA_MARKERS, pageText, resultCount, anchorCount);
@@ -0,0 +1,13 @@
1
+ // Composition root for the built-in engines. The order of mounts is the default fallback ladder. To add an engine,
2
+ // write engines/<id>.ts exporting a SearchEngine and mount it here — or mount it onto defaultEngines() and inject the
3
+ // result into createWebSearchTool from outside.
4
+ import { bing } from "./bing";
5
+ import { brave } from "./brave";
6
+ import { duckduckgo } from "./duckduckgo";
7
+ import { google } from "./google";
8
+ import { createEngineRegistry, type EngineRegistry } from "./registry";
9
+
10
+ export const defaultEngines = (): EngineRegistry =>
11
+ createEngineRegistry().mount(google).mount(bing).mount(duckduckgo).mount(brave);
12
+
13
+ export { createEngineRegistry, type EngineRegistry } from "./registry";
@@ -0,0 +1,29 @@
1
+ import type { SearchEngine } from "../serp";
2
+
3
+ // An immutable set of mounted engines. Mount order is the fallback ladder: the first engine mounted is tried first.
4
+ export type EngineRegistry = {
5
+ // A new registry with `engine` appended to the ladder. Throws on a duplicate id: that is a wiring bug, caught at
6
+ // composition time rather than surfacing as a confusing search.
7
+ readonly mount: (engine: SearchEngine) => EngineRegistry;
8
+ readonly get: (id: string) => SearchEngine | undefined;
9
+ readonly ids: () => ReadonlyArray<string>;
10
+ readonly ladder: () => ReadonlyArray<SearchEngine>;
11
+ };
12
+
13
+ const ENGINE_ID = /^[a-z][a-z0-9-]*$/;
14
+
15
+ const fromEngines = (engines: ReadonlyArray<SearchEngine>): EngineRegistry => {
16
+ const byId = new Map(engines.map((e) => [e.id, e] as const));
17
+ return {
18
+ mount: (engine) => {
19
+ if (!ENGINE_ID.test(engine.id)) throw new Error(`search engine id "${engine.id}" must match ${String(ENGINE_ID)}`);
20
+ if (byId.has(engine.id)) throw new Error(`search engine "${engine.id}" is already mounted`);
21
+ return fromEngines([...engines, engine]);
22
+ },
23
+ get: (id) => byId.get(id),
24
+ ids: () => engines.map((e) => e.id),
25
+ ladder: () => engines,
26
+ };
27
+ };
28
+
29
+ export const createEngineRegistry = (): EngineRegistry => fromEngines([]);
@@ -2,7 +2,7 @@
2
2
  // following it. The blob cannot be decoded client-side and the page's <cite> is only a display breadcrumb, so this
3
3
  // is the one source of the exact url. Google answers the redirect to a cookie-less GET; HEAD returns 200 instead.
4
4
 
5
- import { isGotoRedirect } from "./google-serp";
5
+ import { isGotoRedirect } from "./engines/google";
6
6
 
7
7
  const REDIRECT_STATUSES: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
8
8
 
@@ -0,0 +1,157 @@
1
+ // Engine-neutral SERP vocabulary and parsing. Each engine (engines/*.ts) supplies how to find result anchors on its
2
+ // page and how to turn an anchor into its target url; ranking, dedupe and verdicts are shared here.
3
+
4
+ export type SerpAnchor = {
5
+ readonly href: string;
6
+ readonly heading: string;
7
+ readonly snippet: string;
8
+ // Visible breadcrumb url ("https://host › path › seg"); the fallback when the href is an opaque redirect.
9
+ readonly cite?: string;
10
+ };
11
+
12
+ export type SerpExtraction = {
13
+ readonly anchors: ReadonlyArray<SerpAnchor>;
14
+ readonly pageText: string;
15
+ };
16
+
17
+ export type SearchResult = {
18
+ readonly title: string;
19
+ readonly url: string;
20
+ readonly snippet: string;
21
+ readonly rank: number;
22
+ };
23
+
24
+ export type SerpVerdict = "ok" | "captcha" | "no_results" | "extraction_mismatch";
25
+
26
+ // Redirect hrefs resolved server-side (Google /goto), keyed by href.
27
+ export type ResolvedTargets = ReadonlyMap<string, string>;
28
+
29
+ // Selectors handed to the page-side extractor. Every result is found from its heading: the title element is either
30
+ // the result link itself or sits inside / around it.
31
+ export type SerpSelectors = {
32
+ // Results column; the extractor falls back to <body> when none match.
33
+ readonly root: string;
34
+ // The title element of each organic result.
35
+ readonly heading: string;
36
+ // Result-block selectors, tried in order with closest(); the first match is the result's container.
37
+ readonly containers: ReadonlyArray<string>;
38
+ // Snippet element inside the container; without it the snippet is the container text minus the heading.
39
+ readonly snippet?: string;
40
+ // Breadcrumb element inside the container.
41
+ readonly cite: string;
42
+ };
43
+
44
+ // A pluggable search engine. Engines are plain values mounted into an EngineRegistry (engines/registry.ts); nothing
45
+ // else in the search code knows which engines exist.
46
+ export type SearchEngine = {
47
+ // Stable lower-case identifier the agent passes as `engine` ("google", "bing").
48
+ readonly id: string;
49
+ // Human name, used in error messages ("Bing served a CAPTCHA").
50
+ readonly name: string;
51
+ readonly buildUrl: (query: string, limit: number) => string;
52
+ readonly selectors: SerpSelectors;
53
+ // Lower-case phrases that mark a bot wall / challenge page.
54
+ readonly captchaMarkers: ReadonlyArray<string>;
55
+ // The anchor's external target, or undefined when it is not an organic web result.
56
+ readonly resultUrl: (anchor: SerpAnchor, resolved: ResolvedTargets) => string | undefined;
57
+ // For engines that render results with JavaScript after the load event: the tool waits (briefly) for this selector
58
+ // to match before extracting. A page that never matches is still extracted and classified (captcha / no_results).
59
+ readonly readySelector?: string;
60
+ // Hrefs that can only be decoded by asking the engine's server for the redirect (Google /goto).
61
+ readonly needsRedirectResolve?: (href: string) => boolean;
62
+ };
63
+
64
+ const CITE_SEPARATOR = /\s*›\s*/;
65
+ const TRUNCATION = /(?:\.\.\.|…)/;
66
+
67
+ export const isWebUrl = (url: string): boolean => {
68
+ if (!/^https?:\/\//i.test(url)) return false;
69
+ try {
70
+ new URL(url);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ };
76
+
77
+ export const hostOf = (url: string): string | undefined => {
78
+ try {
79
+ return new URL(url).hostname.toLowerCase();
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ };
84
+
85
+ // Rebuilds a url from a visible breadcrumb cite. Segments after a truncation marker are unknowable, so the path stops
86
+ // at the last complete segment rather than guessing. Returns only urls `isResult` accepts.
87
+ export const urlFromCite = (cite: string, isResult: (url: string) => boolean): string | undefined => {
88
+ const segments = cite
89
+ .trim()
90
+ .split(CITE_SEPARATOR)
91
+ .map((segment) => segment.trim())
92
+ .filter((segment) => segment.length > 0);
93
+ const origin = segments[0];
94
+ if (origin === undefined || TRUNCATION.test(origin)) return undefined;
95
+ // A cite naming any other scheme (ftp:, mailto:) is not a web result; prefixing it would forge a bogus https url.
96
+ if (/^[a-z][a-z0-9+.-]*:/i.test(origin) && !/^https?:\/\//i.test(origin)) return undefined;
97
+ const withScheme = /^https?:\/\//i.test(origin) ? origin : `https://${origin}`;
98
+ const path: string[] = [];
99
+ for (const segment of segments.slice(1)) {
100
+ if (TRUNCATION.test(segment)) break;
101
+ path.push(segment);
102
+ }
103
+ const url = [withScheme.replace(/\/$/, ""), ...path].join("/");
104
+ return isResult(url) ? url : undefined;
105
+ };
106
+
107
+ // A web url whose host is not one of the engine's own properties.
108
+ export const externalResult =
109
+ (isOwnHost: (host: string) => boolean) =>
110
+ (url: string): boolean => {
111
+ if (!isWebUrl(url)) return false;
112
+ const host = hostOf(url);
113
+ return host !== undefined && !isOwnHost(host);
114
+ };
115
+
116
+ const dedupeKey = (url: string): string => {
117
+ try {
118
+ const u = new URL(url);
119
+ return `${u.origin}${u.pathname.replace(/\/$/, "")}`.toLowerCase();
120
+ } catch {
121
+ return url.toLowerCase();
122
+ }
123
+ };
124
+
125
+ export const parseSerp = (
126
+ engine: SearchEngine,
127
+ anchors: ReadonlyArray<SerpAnchor>,
128
+ limit: number,
129
+ resolved: ResolvedTargets = new Map(),
130
+ ): ReadonlyArray<SearchResult> => {
131
+ const seen = new Set<string>();
132
+ const results: SearchResult[] = [];
133
+ for (const anchor of anchors) {
134
+ if (results.length >= limit) break;
135
+ const url = engine.resultUrl(anchor, resolved);
136
+ if (url === undefined) continue;
137
+ const key = dedupeKey(url);
138
+ if (seen.has(key)) continue;
139
+ seen.add(key);
140
+ results.push({ title: anchor.heading.trim(), url, snippet: anchor.snippet.trim(), rank: results.length + 1 });
141
+ }
142
+ return results;
143
+ };
144
+
145
+ export const classifySerpWith = (
146
+ captchaMarkers: ReadonlyArray<string>,
147
+ pageText: string,
148
+ resultCount: number,
149
+ anchorCount: number,
150
+ ): SerpVerdict => {
151
+ if (resultCount > 0) return "ok";
152
+ const lower = pageText.toLowerCase();
153
+ if (captchaMarkers.some((marker) => lower.includes(marker))) return "captcha";
154
+ // Anchors were found but every one was filtered out: the markup changed, not the result set.
155
+ if (anchorCount > 0) return "extraction_mismatch";
156
+ return "no_results";
157
+ };