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.
- package/CHANGELOG.md +25 -0
- package/README.md +15 -8
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +1 -2
- package/src/cdp/commands.ts +28 -0
- package/src/client.ts +10 -3
- package/src/daemon/bridge.ts +4 -3
- package/src/daemon/protocol.ts +11 -0
- package/src/daemon/transport.ts +1 -0
- package/src/domains/cdp-call.ts +2 -1
- package/src/domains/click.ts +5 -2
- package/src/domains/form.ts +40 -31
- package/src/domains/js.ts +20 -2
- package/src/domains/keyboard.ts +171 -35
- package/src/domains/page/click.page.ts +22 -0
- package/src/domains/page/fill.page.ts +70 -1
- package/src/domains/page/serp.page.ts +29 -16
- package/src/domains/page.ts +1 -1
- package/src/domains/screenshot.ts +21 -1
- package/src/domains/search/engines/bing.ts +47 -0
- package/src/domains/search/engines/brave.ts +23 -0
- package/src/domains/search/engines/duckduckgo.ts +42 -0
- package/src/domains/search/engines/google.ts +95 -0
- package/src/domains/search/engines/index.ts +13 -0
- package/src/domains/search/engines/registry.ts +29 -0
- package/src/domains/search/redirect-resolver.ts +1 -1
- package/src/domains/search/serp.ts +157 -0
- package/src/domains/search/web-search.ts +235 -105
- package/src/domains/snapshot.ts +1 -1
- package/src/domains/target.ts +83 -7
- package/src/recipes/recordable.ts +4 -3
- package/src/registry.ts +1 -2
- package/src/util/image-size.ts +16 -0
- package/src/domains/search/google-serp.ts +0 -155
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
export type SerpAnchor = {
|
|
2
|
-
readonly href: string;
|
|
3
|
-
readonly heading: string;
|
|
4
|
-
readonly snippet: string;
|
|
5
|
-
// Visible breadcrumb url ("https://host › path › seg"); the only place the target survives once Google wraps hrefs in /goto.
|
|
6
|
-
readonly cite?: string;
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
export type SerpExtraction = {
|
|
10
|
-
readonly anchors: ReadonlyArray<SerpAnchor>;
|
|
11
|
-
readonly pageText: string;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export type SearchResult = {
|
|
15
|
-
readonly title: string;
|
|
16
|
-
readonly url: string;
|
|
17
|
-
readonly snippet: string;
|
|
18
|
-
readonly rank: number;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export type SerpVerdict = "ok" | "captcha" | "no_results" | "extraction_mismatch";
|
|
22
|
-
|
|
23
|
-
const CAPTCHA_MARKERS: ReadonlyArray<string> = [
|
|
24
|
-
"unusual traffic",
|
|
25
|
-
"not a robot",
|
|
26
|
-
"recaptcha",
|
|
27
|
-
"detected unusual",
|
|
28
|
-
"systems have detected",
|
|
29
|
-
"before you continue to google",
|
|
30
|
-
"our systems have detected unusual traffic",
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
const unwrapRedirect = (href: string): string => {
|
|
34
|
-
const wrapper = /^(?:https?:\/\/[^/]*google\.[^/]*)?\/url\?/i.exec(href);
|
|
35
|
-
if (!wrapper) return href;
|
|
36
|
-
const query = href.slice(href.indexOf("?") + 1);
|
|
37
|
-
const target = new URLSearchParams(query).get("q");
|
|
38
|
-
return target ?? href;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const CITE_SEPARATOR = /\s*›\s*/;
|
|
42
|
-
const TRUNCATION = /(?:\.\.\.|…)/;
|
|
43
|
-
|
|
44
|
-
// Rebuilds a url from Google's visible breadcrumb cite. Segments after a truncation marker are unknowable, so the
|
|
45
|
-
// path stops at the last complete segment rather than guessing.
|
|
46
|
-
export const urlFromCite = (cite: string): string | undefined => {
|
|
47
|
-
const segments = cite
|
|
48
|
-
.trim()
|
|
49
|
-
.split(CITE_SEPARATOR)
|
|
50
|
-
.map((segment) => segment.trim())
|
|
51
|
-
.filter((segment) => segment.length > 0);
|
|
52
|
-
const origin = segments[0];
|
|
53
|
-
if (origin === undefined || TRUNCATION.test(origin)) return undefined;
|
|
54
|
-
// A cite naming any other scheme (ftp:, mailto:) is not a web result; prefixing it would forge a bogus https url.
|
|
55
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(origin) && !/^https?:\/\//i.test(origin)) return undefined;
|
|
56
|
-
const withScheme = /^https?:\/\//i.test(origin) ? origin : `https://${origin}`;
|
|
57
|
-
const path: string[] = [];
|
|
58
|
-
for (const segment of segments.slice(1)) {
|
|
59
|
-
if (TRUNCATION.test(segment)) break;
|
|
60
|
-
path.push(segment);
|
|
61
|
-
}
|
|
62
|
-
const url = [withScheme.replace(/\/$/, ""), ...path].join("/");
|
|
63
|
-
return isExternalResult(url) ? url : undefined;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// Only a real Google /goto url counts: the daemon fetches these, so a look-alike host, userinfo, a port or http
|
|
67
|
-
// must never qualify. Hosts are google.com or www.google.<tld>, nothing else.
|
|
68
|
-
const GOOGLE_GOTO_HOST = /^(?:www\.)?google\.[a-z]{2,3}(?:\.[a-z]{2})?$/;
|
|
69
|
-
export const isGotoRedirect = (href: string): boolean => {
|
|
70
|
-
try {
|
|
71
|
-
const u = new URL(href);
|
|
72
|
-
return (
|
|
73
|
-
u.protocol === "https:" &&
|
|
74
|
-
u.username === "" &&
|
|
75
|
-
u.password === "" &&
|
|
76
|
-
u.port === "" &&
|
|
77
|
-
GOOGLE_GOTO_HOST.test(u.hostname.toLowerCase()) &&
|
|
78
|
-
u.pathname === "/goto"
|
|
79
|
-
);
|
|
80
|
-
} catch {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
export type ResolvedTargets = ReadonlyMap<string, string>;
|
|
86
|
-
|
|
87
|
-
// Direct external hrefs win. A /goto wrapper is opaque: the server-resolved redirect target is exact, and the cite is
|
|
88
|
-
// a display breadcrumb that only approximates the path, so it is the fallback when no target was resolved.
|
|
89
|
-
const resolveResultUrl = (anchor: SerpAnchor, resolved: ResolvedTargets): string | undefined => {
|
|
90
|
-
if (!isGotoRedirect(anchor.href)) {
|
|
91
|
-
const unwrapped = unwrapRedirect(anchor.href);
|
|
92
|
-
return isExternalResult(unwrapped) ? unwrapped : undefined;
|
|
93
|
-
}
|
|
94
|
-
const target = resolved.get(anchor.href);
|
|
95
|
-
if (target !== undefined && isExternalResult(target)) return target;
|
|
96
|
-
if (anchor.cite !== undefined && anchor.cite.trim() !== "") return urlFromCite(anchor.cite);
|
|
97
|
-
return undefined;
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
// Any Google property, on any TLD (google.com, google.co.uk, consent.google.de, googleusercontent.com), is never a
|
|
101
|
-
// result: a sorry or consent page is where a rate-limited /goto redirect lands.
|
|
102
|
-
const GOOGLE_PROPERTY = /(?:^|\.)google(?:usercontent)?\.(?:com?\.[a-z]{2}|[a-z]{2,3})$/;
|
|
103
|
-
|
|
104
|
-
const isExternalResult = (url: string): boolean => {
|
|
105
|
-
if (!/^https?:\/\//i.test(url)) return false;
|
|
106
|
-
try {
|
|
107
|
-
const host = new URL(url).hostname.toLowerCase();
|
|
108
|
-
if (GOOGLE_PROPERTY.test(host)) return false;
|
|
109
|
-
return true;
|
|
110
|
-
} catch {
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const dedupeKey = (url: string): string => {
|
|
116
|
-
try {
|
|
117
|
-
const u = new URL(url);
|
|
118
|
-
return `${u.origin}${u.pathname.replace(/\/$/, "")}`.toLowerCase();
|
|
119
|
-
} catch {
|
|
120
|
-
return url.toLowerCase();
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
export const parseGoogleSerp = (
|
|
125
|
-
anchors: ReadonlyArray<SerpAnchor>,
|
|
126
|
-
limit: number,
|
|
127
|
-
resolved: ResolvedTargets = new Map(),
|
|
128
|
-
): ReadonlyArray<SearchResult> => {
|
|
129
|
-
const seen = new Set<string>();
|
|
130
|
-
const results: SearchResult[] = [];
|
|
131
|
-
for (const anchor of anchors) {
|
|
132
|
-
if (results.length >= limit) break;
|
|
133
|
-
const url = resolveResultUrl(anchor, resolved);
|
|
134
|
-
if (url === undefined) continue;
|
|
135
|
-
const key = dedupeKey(url);
|
|
136
|
-
if (seen.has(key)) continue;
|
|
137
|
-
seen.add(key);
|
|
138
|
-
results.push({
|
|
139
|
-
title: anchor.heading.trim(),
|
|
140
|
-
url,
|
|
141
|
-
snippet: anchor.snippet.trim(),
|
|
142
|
-
rank: results.length + 1,
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
return results;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
export const classifySerp = (pageText: string, resultCount: number, anchorCount: number): SerpVerdict => {
|
|
149
|
-
if (resultCount > 0) return "ok";
|
|
150
|
-
const lower = pageText.toLowerCase();
|
|
151
|
-
if (CAPTCHA_MARKERS.some((marker) => lower.includes(marker))) return "captcha";
|
|
152
|
-
// Anchors were found but every one was filtered out: the markup changed, not the result set.
|
|
153
|
-
if (anchorCount > 0) return "extraction_mismatch";
|
|
154
|
-
return "no_results";
|
|
155
|
-
};
|