@oh-my-pi/pi-coding-agent 16.4.2 → 16.4.3
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 +42 -0
- package/dist/cli.js +17514 -17799
- package/dist/types/advisor/config.d.ts +4 -3
- package/dist/types/commit/agentic/agent.d.ts +1 -0
- package/dist/types/config/settings-schema.d.ts +28 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +10 -22
- package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
- package/dist/types/modes/components/move-overlay.d.ts +1 -1
- package/dist/types/modes/components/status-line/component.d.ts +3 -0
- package/dist/types/modes/components/status-line/types.d.ts +3 -0
- package/dist/types/modes/interactive-mode.d.ts +8 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +16 -0
- package/dist/types/task/executor.d.ts +31 -0
- package/dist/types/tools/__tests__/vibe-render.test.d.ts +1 -0
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
- package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
- package/dist/types/tools/browser/run-output.d.ts +25 -0
- package/dist/types/tools/browser/tab-worker.d.ts +16 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/vibe.d.ts +161 -0
- package/dist/types/utils/changelog.d.ts +35 -1
- package/dist/types/vibe/runtime.d.ts +124 -0
- package/dist/types/vibe/state.d.ts +4 -0
- package/dist/types/web/search/provider.d.ts +2 -0
- package/dist/types/web/search/providers/bing.d.ts +14 -0
- package/dist/types/web/search/providers/browser-headers.d.ts +9 -0
- package/dist/types/web/search/providers/browser-page.d.ts +32 -0
- package/dist/types/web/search/providers/ecosia.d.ts +14 -0
- package/dist/types/web/search/providers/google.d.ts +13 -0
- package/dist/types/web/search/providers/mojeek.d.ts +14 -0
- package/dist/types/web/search/providers/public.d.ts +37 -0
- package/dist/types/web/search/providers/startpage.d.ts +14 -0
- package/dist/types/web/search/providers/yahoo.d.ts +14 -0
- package/dist/types/web/search/types.d.ts +28 -0
- package/package.json +15 -18
- package/scripts/build-binary.ts +56 -73
- package/scripts/bundle-dist.ts +36 -40
- package/scripts/compile-binary.ts +68 -0
- package/scripts/generate-docs-index.ts +3 -93
- package/scripts/legacy-pi-virtual-module.ts +192 -0
- package/src/advisor/__tests__/advisor.test.ts +13 -0
- package/src/advisor/__tests__/config.test.ts +36 -0
- package/src/advisor/config.ts +11 -8
- package/src/commit/agentic/agent.ts +4 -0
- package/src/commit/agentic/index.ts +46 -21
- package/src/edit/index.ts +10 -76
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +328 -162
- package/src/extensibility/plugins/legacy-pi-virtual-modules.d.ts +4 -0
- package/src/internal-urls/docs-index.ts +2 -1
- package/src/internal-urls/skill-protocol.ts +1 -1
- package/src/lsp/deferred-diagnostics.ts +66 -0
- package/src/main.ts +13 -13
- package/src/mcp/transports/stdio.test.ts +45 -1
- package/src/mcp/transports/stdio.ts +6 -3
- package/src/modes/acp/acp-agent.ts +65 -1
- package/src/modes/acp/acp-event-mapper.ts +5 -0
- package/src/modes/acp/acp-mode.ts +11 -0
- package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
- package/src/modes/components/advisor-config.ts +15 -7
- package/src/modes/components/move-overlay.ts +2 -3
- package/src/modes/components/status-line/component.ts +6 -0
- package/src/modes/components/status-line/segments.ts +6 -0
- package/src/modes/components/status-line/types.ts +3 -0
- package/src/modes/controllers/command-controller.ts +8 -10
- package/src/modes/interactive-mode.ts +117 -1
- package/src/modes/types.ts +2 -0
- package/src/prompts/system/eager-task.md +2 -2
- package/src/prompts/system/system-prompt.md +9 -2
- package/src/prompts/system/vibe-mode-active.md +23 -0
- package/src/prompts/tools/browser.md +3 -3
- package/src/prompts/tools/grep.md +1 -1
- package/src/prompts/tools/vibe-kill.md +3 -0
- package/src/prompts/tools/vibe-list.md +3 -0
- package/src/prompts/tools/vibe-send.md +9 -0
- package/src/prompts/tools/vibe-spawn.md +10 -0
- package/src/prompts/tools/vibe-turn-result.md +19 -0
- package/src/prompts/tools/vibe-wait.md +8 -0
- package/src/sdk.ts +5 -0
- package/src/session/agent-session.ts +103 -13
- package/src/session/snapcompact-inline.ts +3 -19
- package/src/slash-commands/builtin-registry.ts +24 -8
- package/src/task/agents.ts +0 -2
- package/src/task/executor.ts +105 -0
- package/src/tools/__tests__/vibe-render.test.ts +210 -0
- package/src/tools/bash-skill-urls.ts +1 -1
- package/src/tools/browser/cmux/cmux-tab.ts +46 -48
- package/src/tools/browser/cmux/rpc.ts +50 -0
- package/src/tools/browser/run-output.ts +76 -0
- package/src/tools/browser/tab-worker.ts +264 -129
- package/src/tools/glob.ts +20 -6
- package/src/tools/index.ts +1 -0
- package/src/tools/read.ts +17 -9
- package/src/tools/renderers.ts +6 -0
- package/src/tools/vibe.ts +608 -0
- package/src/tools/write.ts +15 -2
- package/src/utils/changelog.ts +106 -9
- package/src/utils/git.ts +0 -1
- package/src/utils/title-generator.ts +70 -7
- package/src/vibe/runtime.ts +710 -0
- package/src/vibe/state.ts +4 -0
- package/src/web/search/index.ts +14 -6
- package/src/web/search/provider.ts +37 -1
- package/src/web/search/providers/bing.ts +197 -0
- package/src/web/search/providers/browser-headers.ts +92 -0
- package/src/web/search/providers/browser-page.ts +123 -0
- package/src/web/search/providers/duckduckgo.ts +13 -34
- package/src/web/search/providers/ecosia.ts +178 -0
- package/src/web/search/providers/google.ts +193 -0
- package/src/web/search/providers/mojeek.ts +206 -0
- package/src/web/search/providers/public.ts +201 -0
- package/src/web/search/providers/startpage.ts +213 -0
- package/src/web/search/providers/yahoo.ts +179 -0
- package/src/web/search/types.ts +35 -0
- package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +0 -10
- package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +0 -10
- package/scripts/generate-legacy-pi-bundled-registry.ts +0 -420
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +0 -1011
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +0 -3430
- package/src/internal-urls/docs-index.generated.txt +0 -2
- package/src/prompts/agents/plan.md +0 -47
package/src/web/search/index.ts
CHANGED
|
@@ -231,12 +231,20 @@ export async function runSearchQuery(
|
|
|
231
231
|
params: SearchQueryParams,
|
|
232
232
|
options: { authStorage?: AuthStorage; sessionId?: string; signal?: AbortSignal } = {},
|
|
233
233
|
): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
234
|
+
const createdAuthStorage = options.authStorage ? undefined : await discoverAuthStorage();
|
|
235
|
+
const authStorage = options.authStorage ?? createdAuthStorage;
|
|
236
|
+
if (!authStorage) {
|
|
237
|
+
throw new Error("Failed to initialize authentication storage");
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
return await executeSearch("cli-web-search", params, {
|
|
241
|
+
authStorage,
|
|
242
|
+
sessionId: options.sessionId,
|
|
243
|
+
signal: options.signal,
|
|
244
|
+
});
|
|
245
|
+
} finally {
|
|
246
|
+
createdAuthStorage?.close();
|
|
247
|
+
}
|
|
240
248
|
}
|
|
241
249
|
|
|
242
250
|
/**
|
|
@@ -114,6 +114,41 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
|
|
|
114
114
|
label: SEARCH_PROVIDER_LABELS.duckduckgo,
|
|
115
115
|
load: async () => new (await import("./providers/duckduckgo")).DuckDuckGoProvider(),
|
|
116
116
|
},
|
|
117
|
+
google: {
|
|
118
|
+
id: "google",
|
|
119
|
+
label: SEARCH_PROVIDER_LABELS.google,
|
|
120
|
+
load: async () => new (await import("./providers/google")).GoogleProvider(),
|
|
121
|
+
},
|
|
122
|
+
bing: {
|
|
123
|
+
id: "bing",
|
|
124
|
+
label: SEARCH_PROVIDER_LABELS.bing,
|
|
125
|
+
load: async () => new (await import("./providers/bing")).BingProvider(),
|
|
126
|
+
},
|
|
127
|
+
yahoo: {
|
|
128
|
+
id: "yahoo",
|
|
129
|
+
label: SEARCH_PROVIDER_LABELS.yahoo,
|
|
130
|
+
load: async () => new (await import("./providers/yahoo")).YahooProvider(),
|
|
131
|
+
},
|
|
132
|
+
ecosia: {
|
|
133
|
+
id: "ecosia",
|
|
134
|
+
label: SEARCH_PROVIDER_LABELS.ecosia,
|
|
135
|
+
load: async () => new (await import("./providers/ecosia")).EcosiaProvider(),
|
|
136
|
+
},
|
|
137
|
+
startpage: {
|
|
138
|
+
id: "startpage",
|
|
139
|
+
label: SEARCH_PROVIDER_LABELS.startpage,
|
|
140
|
+
load: async () => new (await import("./providers/startpage")).StartpageProvider(),
|
|
141
|
+
},
|
|
142
|
+
mojeek: {
|
|
143
|
+
id: "mojeek",
|
|
144
|
+
label: SEARCH_PROVIDER_LABELS.mojeek,
|
|
145
|
+
load: async () => new (await import("./providers/mojeek")).MojeekProvider(),
|
|
146
|
+
},
|
|
147
|
+
public: {
|
|
148
|
+
id: "public",
|
|
149
|
+
label: SEARCH_PROVIDER_LABELS.public,
|
|
150
|
+
load: async () => new (await import("./providers/public")).PublicWebProvider(),
|
|
151
|
+
},
|
|
117
152
|
};
|
|
118
153
|
|
|
119
154
|
const instanceCache = new Map<SearchProviderId, SearchProvider>();
|
|
@@ -180,7 +215,8 @@ export function setExcludedSearchProviders(providers: readonly SearchProviderId[
|
|
|
180
215
|
excludedProvIds = new Set(providers);
|
|
181
216
|
}
|
|
182
217
|
|
|
183
|
-
|
|
218
|
+
/** `true` when settings exclude `id` from web search (auto chain and the Public Web fan-out). */
|
|
219
|
+
export function isSearchProviderExcluded(id: SearchProviderId): boolean {
|
|
184
220
|
return excludedProvIds.has(id);
|
|
185
221
|
}
|
|
186
222
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { AuthStorage } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { parseHTML } from "linkedom";
|
|
3
|
+
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
4
|
+
import { SearchProviderError } from "../../../web/search/types";
|
|
5
|
+
import { clampNumResults } from "../utils";
|
|
6
|
+
import type { SearchParams } from "./base";
|
|
7
|
+
import { SearchProvider } from "./base";
|
|
8
|
+
import { browserFetch } from "./browser-page";
|
|
9
|
+
import { classifyProviderHttpError, withHardTimeout } from "./utils";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Bing's HTML search frontend. A plain GET with browser navigation headers
|
|
13
|
+
* returns a fully server-rendered results page — no JavaScript challenge on
|
|
14
|
+
* the organic path — so we parse it directly without a real browser.
|
|
15
|
+
*/
|
|
16
|
+
const BING_HOME_URL = "https://www.bing.com/";
|
|
17
|
+
const BING_SEARCH_URL = "https://www.bing.com/search";
|
|
18
|
+
const DEFAULT_NUM_RESULTS = 10;
|
|
19
|
+
const MAX_NUM_RESULTS = 20;
|
|
20
|
+
const MS_PER_DAY = 86_400_000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recency → Bing `filters=ex1:"…"` freshness codes, as emitted by Bing's own
|
|
24
|
+
* "Any time" dropdown. `year` has no fixed code; the dropdown emits a custom
|
|
25
|
+
* epoch-day range (`ez5_<start>_<end>`, days since 1970-01-01) which
|
|
26
|
+
* {@link recencyToFilters} computes. Bing parses the parameter (the SERP
|
|
27
|
+
* filter UI reflects it) but enforcement is server-side and vantage-dependent.
|
|
28
|
+
*/
|
|
29
|
+
const RECENCY_TO_BING_EZ: Record<Exclude<NonNullable<SearchParams["recency"]>, "year">, string> = {
|
|
30
|
+
day: "ez1",
|
|
31
|
+
week: "ez2",
|
|
32
|
+
month: "ez3",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Snippet containers observed on Bing result blocks, in preference order. */
|
|
36
|
+
const BING_SNIPPET_SELECTORS: readonly string[] = [".b_caption p", "p[class*='b_lineclamp']", ".b_algoSlug"];
|
|
37
|
+
|
|
38
|
+
interface ParsedResult {
|
|
39
|
+
title: string;
|
|
40
|
+
url: string;
|
|
41
|
+
snippet?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build the `filters` value for a recency window, mirroring Bing's dropdown URLs. */
|
|
45
|
+
function recencyToFilters(recency: NonNullable<SearchParams["recency"]>): string {
|
|
46
|
+
if (recency === "year") {
|
|
47
|
+
const epochDay = Math.floor(Date.now() / MS_PER_DAY);
|
|
48
|
+
return `ex1:"ez5_${epochDay - 365}_${epochDay}"`;
|
|
49
|
+
}
|
|
50
|
+
return `ex1:"${RECENCY_TO_BING_EZ[recency]}"`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a Bing result href to the underlying target URL.
|
|
55
|
+
*
|
|
56
|
+
* Organic hrefs are usually wrapped as `https://www.bing.com/ck/a?…&u=a1<payload>`
|
|
57
|
+
* where the payload after the literal `a1` prefix is the unpadded base64url
|
|
58
|
+
* encoding of the target URL. Direct external hrefs also occur; Bing-internal
|
|
59
|
+
* links (vertical tabs, ads plumbing) and non-http(s) schemes are rejected.
|
|
60
|
+
*/
|
|
61
|
+
function unwrapResultUrl(href: string): string | undefined {
|
|
62
|
+
let url: URL;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(href, BING_HOME_URL);
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (url.hostname === "bing.com" || url.hostname.endsWith(".bing.com")) {
|
|
70
|
+
if (url.pathname !== "/ck/a") return undefined;
|
|
71
|
+
const wrapped = url.searchParams.get("u");
|
|
72
|
+
if (!wrapped?.startsWith("a1")) return undefined;
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(Buffer.from(wrapped.slice(2), "base64url").toString("utf-8"));
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
81
|
+
return url.href;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findSnippet(item: Element): string | undefined {
|
|
85
|
+
for (const selector of BING_SNIPPET_SELECTORS) {
|
|
86
|
+
const text = (item.querySelector(selector)?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
87
|
+
if (text) return text;
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pull organic result blocks out of the page in document order.
|
|
94
|
+
*
|
|
95
|
+
* Each organic hit is an `<li class="b_algo">` with the title link in
|
|
96
|
+
* `h2 > a[href]` (sitelink/attribution anchors live outside the `h2`) and the
|
|
97
|
+
* preview text in one of {@link BING_SNIPPET_SELECTORS}. Ads, answer cards,
|
|
98
|
+
* and the "no results" row use other classes and fall out naturally.
|
|
99
|
+
*/
|
|
100
|
+
function parseHtmlResults(html: string): ParsedResult[] {
|
|
101
|
+
const { document } = parseHTML(html);
|
|
102
|
+
const results: ParsedResult[] = [];
|
|
103
|
+
for (const item of document.querySelectorAll("li.b_algo")) {
|
|
104
|
+
const anchor = item.querySelector("h2 a[href]");
|
|
105
|
+
const href = anchor?.getAttribute("href");
|
|
106
|
+
if (!href) continue;
|
|
107
|
+
const url = unwrapResultUrl(href);
|
|
108
|
+
if (!url) continue;
|
|
109
|
+
const title = (anchor?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
110
|
+
if (!title) continue;
|
|
111
|
+
results.push({ title, url, snippet: findSnippet(item) });
|
|
112
|
+
}
|
|
113
|
+
return results;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `true` when Bing answered with its CAPTCHA/consent interstitial instead of
|
|
118
|
+
* a results page. The challenge redirects to `/turing/captcha/…`; body
|
|
119
|
+
* markers are only trusted when no organic result block is present so a
|
|
120
|
+
* search *about* CAPTCHAs never trips the detector.
|
|
121
|
+
*/
|
|
122
|
+
function isChallengeResponse(html: string, finalUrl: string): boolean {
|
|
123
|
+
if (finalUrl.includes("/turing/captcha")) return true;
|
|
124
|
+
if (html.includes('class="b_algo"')) return false;
|
|
125
|
+
return /turing\/captcha|b_captcha|px-captcha|verify (?:that )?you are (?:a )?human/i.test(html);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildSearchUrl(params: SearchParams, numResults: number): string {
|
|
129
|
+
const url = new URL(BING_SEARCH_URL);
|
|
130
|
+
url.searchParams.set("q", params.query);
|
|
131
|
+
url.searchParams.set("count", String(numResults));
|
|
132
|
+
url.searchParams.set("mkt", "en-US");
|
|
133
|
+
url.searchParams.set("setlang", "en");
|
|
134
|
+
if (params.recency) url.searchParams.set("filters", recencyToFilters(params.recency));
|
|
135
|
+
return url.href;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function callBingHtml(params: SearchParams, numResults: number): Promise<string> {
|
|
139
|
+
const url = buildSearchUrl(params, numResults);
|
|
140
|
+
const page = await browserFetch(url, {
|
|
141
|
+
fetch: params.fetch ?? fetch,
|
|
142
|
+
signal: withHardTimeout(params.signal),
|
|
143
|
+
referer: BING_HOME_URL,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const body = page.html;
|
|
147
|
+
if (isChallengeResponse(body, page.url)) {
|
|
148
|
+
throw new SearchProviderError(
|
|
149
|
+
"bing",
|
|
150
|
+
"Bing blocked the request with a CAPTCHA challenge. Bing throttles automated searches from datacenter/shared-egress IPs; try the duckduckgo or mojeek provider, or configure a credentialed provider such as Brave, Tavily, Exa, or Kagi.",
|
|
151
|
+
429,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (page.status < 200 || page.status >= 300) {
|
|
155
|
+
const classified = classifyProviderHttpError("bing", page.status, body);
|
|
156
|
+
if (classified) throw classified;
|
|
157
|
+
throw new SearchProviderError("bing", `Bing HTML error (${page.status})`, page.status);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Execute a Bing web search via the server-rendered HTML results page. */
|
|
164
|
+
export async function searchBing(params: SearchParams): Promise<SearchResponse> {
|
|
165
|
+
const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
|
166
|
+
const html = await callBingHtml(params, numResults);
|
|
167
|
+
const parsed = parseHtmlResults(html);
|
|
168
|
+
|
|
169
|
+
const sources: SearchSource[] = [];
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
for (const result of parsed) {
|
|
172
|
+
if (seen.has(result.url)) continue;
|
|
173
|
+
seen.add(result.url);
|
|
174
|
+
sources.push({ title: result.title, url: result.url, snippet: result.snippet });
|
|
175
|
+
if (sources.length >= numResults) break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { provider: "bing", sources };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Search provider for Bing (no API key required). */
|
|
182
|
+
export class BingProvider extends SearchProvider {
|
|
183
|
+
readonly id = "bing";
|
|
184
|
+
readonly label = "Bing";
|
|
185
|
+
|
|
186
|
+
isAvailable(_authStorage: AuthStorage): boolean {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
search(params: SearchParams): Promise<SearchResponse> {
|
|
195
|
+
return searchBing(params);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { HeaderGenerator } from "header-generator";
|
|
2
|
+
|
|
3
|
+
// Instantiate the singleton header generator.
|
|
4
|
+
// This matches modern browsers from real-world query statistics
|
|
5
|
+
// and randomizes between Chrome, Firefox, Safari, and Edge.
|
|
6
|
+
const generator = new HeaderGenerator({
|
|
7
|
+
browserListQuery: "last 3 versions",
|
|
8
|
+
devices: ["desktop"],
|
|
9
|
+
operatingSystems: ["windows", "macos", "linux"],
|
|
10
|
+
locales: ["en-US", "en"],
|
|
11
|
+
httpVersion: "2",
|
|
12
|
+
strict: false,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// A fallback desktop Mac Chrome navigation fingerprint matching
|
|
16
|
+
// the previous static default setup for deterministic or non-randomized calls.
|
|
17
|
+
const CHROME_FALLBACK_HEADERS: Record<string, string> = {
|
|
18
|
+
Accept:
|
|
19
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
20
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
21
|
+
"Cache-Control": "max-age=0",
|
|
22
|
+
Priority: "u=0, i",
|
|
23
|
+
"Sec-Ch-Ua": '"Google Chrome";v="149", "Chromium";v="149", ";Not A Brand";v="99"',
|
|
24
|
+
"Sec-Ch-Ua-Mobile": "?0",
|
|
25
|
+
"Sec-Ch-Ua-Platform": '"macOS"',
|
|
26
|
+
"Sec-Fetch-Dest": "document",
|
|
27
|
+
"Sec-Fetch-Mode": "navigate",
|
|
28
|
+
"Sec-Fetch-Site": "none",
|
|
29
|
+
"Sec-Fetch-User": "?1",
|
|
30
|
+
"Upgrade-Insecure-Requests": "1",
|
|
31
|
+
"User-Agent":
|
|
32
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function canonicalizeHeaderNames(headers: Record<string, string>): Record<string, string> {
|
|
36
|
+
const canonicalized: Record<string, string> = {};
|
|
37
|
+
|
|
38
|
+
for (const key in headers) {
|
|
39
|
+
const value = headers[key];
|
|
40
|
+
if (value === undefined) continue;
|
|
41
|
+
|
|
42
|
+
// Retain Client Hints (sec-ch-ua*) in their standard lower-case representation
|
|
43
|
+
if (key.startsWith("sec-ch-ua")) {
|
|
44
|
+
canonicalized[key] = value;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Retain diagnostics or other HTTP/2 custom lower-case keys
|
|
49
|
+
if (["dnt", "rtt", "ect"].includes(key)) {
|
|
50
|
+
canonicalized[key.toUpperCase()] = value;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Retain HTTP/2 specific pseudo headers if any, or general standard casing overrides
|
|
55
|
+
if (key === "te") {
|
|
56
|
+
canonicalized.TE = value;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Pascalize words separated by hyphens (e.g. accept-language -> Accept-Language)
|
|
61
|
+
const pascalized = key
|
|
62
|
+
.split("-")
|
|
63
|
+
.map(part => (part[0] ? part[0].toUpperCase() + part.slice(1).toLowerCase() : ""))
|
|
64
|
+
.join("-");
|
|
65
|
+
|
|
66
|
+
canonicalized[pascalized] = value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return canonicalized;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a fresh, internally consistent desktop navigation fingerprint for one HTTP request.
|
|
74
|
+
* By default, this randomizes across valid modern versions of Chrome, Firefox, Edge, and Safari
|
|
75
|
+
* using real-world traffic data. Set `randomized` to `false` when a fetch must preserve a
|
|
76
|
+
* stable Mac Chrome identity.
|
|
77
|
+
*/
|
|
78
|
+
export function buildBrowserNavigationHeaders(options?: { randomized?: boolean }): Record<string, string> {
|
|
79
|
+
const randomized = options?.randomized !== false;
|
|
80
|
+
if (!randomized) {
|
|
81
|
+
return { ...CHROME_FALLBACK_HEADERS };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Generate realistic, consistent headers with the Bayesian generator
|
|
86
|
+
const generated = generator.getHeaders();
|
|
87
|
+
return canonicalizeHeaderNames(generated);
|
|
88
|
+
} catch {
|
|
89
|
+
// Gracefully recover to the robust default profile on unexpected generator errors
|
|
90
|
+
return { ...CHROME_FALLBACK_HEADERS };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { FetchImpl } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { untilAborted } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import type { Page } from "puppeteer-core";
|
|
4
|
+
import { applyStealthPatches, applyViewport } from "../../../tools/browser/launch";
|
|
5
|
+
import { acquireBrowser, holdBrowser, releaseBrowser } from "../../../tools/browser/registry";
|
|
6
|
+
import { buildBrowserNavigationHeaders } from "./browser-headers";
|
|
7
|
+
import { SEARCH_HARD_TIMEOUT_MS } from "./utils";
|
|
8
|
+
|
|
9
|
+
/** HTML plus the response status and final URL after redirects or browser navigation. */
|
|
10
|
+
export interface LoadedHtmlPage {
|
|
11
|
+
html: string;
|
|
12
|
+
status: number;
|
|
13
|
+
url: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface BrowserFallbackOptions {
|
|
17
|
+
homeUrl?: string;
|
|
18
|
+
ready?: { selector: string; timeoutMs: number };
|
|
19
|
+
afterNavigation?: (page: Page, signal: AbortSignal) => Promise<void>;
|
|
20
|
+
shouldFallback: (page: LoadedHtmlPage) => boolean;
|
|
21
|
+
attempts?: number;
|
|
22
|
+
retryDelayMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Controls a browser-profiled fetch and its optional headless-browser fallback. */
|
|
26
|
+
export interface BrowserFetchOptions {
|
|
27
|
+
fetch?: FetchImpl;
|
|
28
|
+
signal: AbortSignal;
|
|
29
|
+
randomizeHeaders?: boolean;
|
|
30
|
+
referer?: string;
|
|
31
|
+
init?: Omit<RequestInit, "headers" | "signal">;
|
|
32
|
+
headers?: Readonly<Record<string, string>>;
|
|
33
|
+
browser?: BrowserFallbackOptions;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function fetchHtmlPage(url: string, options: BrowserFetchOptions, fetchImpl: FetchImpl): Promise<LoadedHtmlPage> {
|
|
37
|
+
const response = await fetchImpl(url, {
|
|
38
|
+
...options.init,
|
|
39
|
+
headers: {
|
|
40
|
+
...buildBrowserNavigationHeaders({ randomized: options.randomizeHeaders }),
|
|
41
|
+
...(options.referer ? { Referer: options.referer, "Sec-Fetch-Site": "same-origin" } : {}),
|
|
42
|
+
...options.headers,
|
|
43
|
+
},
|
|
44
|
+
signal: options.signal,
|
|
45
|
+
});
|
|
46
|
+
return { html: await response.text(), status: response.status, url: response.url || url };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function browseHtmlPage(
|
|
50
|
+
url: string,
|
|
51
|
+
options: BrowserFallbackOptions,
|
|
52
|
+
signal: AbortSignal,
|
|
53
|
+
): Promise<LoadedHtmlPage> {
|
|
54
|
+
const { homeUrl, ready } = options;
|
|
55
|
+
const attempts = Math.max(1, options.attempts ?? 1);
|
|
56
|
+
const handle = await untilAborted(signal, () =>
|
|
57
|
+
acquireBrowser(
|
|
58
|
+
{ kind: "headless", headless: true },
|
|
59
|
+
{
|
|
60
|
+
cwd: process.cwd(),
|
|
61
|
+
signal,
|
|
62
|
+
},
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
if (!("browser" in handle)) {
|
|
66
|
+
await releaseBrowser(handle, { kill: false });
|
|
67
|
+
throw new Error("Headless browser acquisition returned a non-Puppeteer browser");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
holdBrowser(handle);
|
|
71
|
+
let page: Page | undefined;
|
|
72
|
+
try {
|
|
73
|
+
const activePage = await untilAborted(signal, () => handle.browser.newPage());
|
|
74
|
+
page = activePage;
|
|
75
|
+
await applyViewport(activePage);
|
|
76
|
+
await applyStealthPatches(handle.browser, activePage, handle.stealth);
|
|
77
|
+
if (homeUrl) {
|
|
78
|
+
await untilAborted(signal, () =>
|
|
79
|
+
activePage.goto(homeUrl, { waitUntil: "domcontentloaded", timeout: SEARCH_HARD_TIMEOUT_MS }),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
83
|
+
if (attempt > 0 && options.retryDelayMs) await Bun.sleep(options.retryDelayMs);
|
|
84
|
+
|
|
85
|
+
const response = await untilAborted(signal, () =>
|
|
86
|
+
activePage.goto(url, { waitUntil: "domcontentloaded", timeout: SEARCH_HARD_TIMEOUT_MS }),
|
|
87
|
+
);
|
|
88
|
+
if (options.afterNavigation) await options.afterNavigation(activePage, signal);
|
|
89
|
+
if (ready) {
|
|
90
|
+
await untilAborted(signal, () =>
|
|
91
|
+
activePage.waitForSelector(ready.selector, { timeout: ready.timeoutMs }).catch(() => null),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const loaded = {
|
|
95
|
+
html: await untilAborted(signal, () => activePage.content()),
|
|
96
|
+
status: response?.status() ?? 200,
|
|
97
|
+
url: activePage.url(),
|
|
98
|
+
};
|
|
99
|
+
if (!options.shouldFallback(loaded) || attempt === attempts - 1) return loaded;
|
|
100
|
+
}
|
|
101
|
+
throw new Error("Browser fallback exhausted without a response");
|
|
102
|
+
} finally {
|
|
103
|
+
await page?.close().catch(() => undefined);
|
|
104
|
+
await releaseBrowser(handle, { kill: false });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Fetch with a fresh browser profile, escalating rejected production responses to the stealth browser. */
|
|
109
|
+
export async function browserFetch(url: string, options: BrowserFetchOptions): Promise<LoadedHtmlPage> {
|
|
110
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
111
|
+
let page: LoadedHtmlPage;
|
|
112
|
+
try {
|
|
113
|
+
page = await fetchHtmlPage(url, options, fetchImpl);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (options.fetch || !options.browser) throw error;
|
|
116
|
+
return browseHtmlPage(url, options.browser, options.signal);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!options.browser || options.fetch) return page;
|
|
120
|
+
const isSuccessful = page.status >= 200 && page.status < 300;
|
|
121
|
+
if (isSuccessful && !options.browser.shouldFallback(page)) return page;
|
|
122
|
+
return browseHtmlPage(url, options.browser, options.signal);
|
|
123
|
+
}
|
|
@@ -4,6 +4,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
|
|
4
4
|
import { clampNumResults } from "../utils";
|
|
5
5
|
import type { SearchParams } from "./base";
|
|
6
6
|
import { SearchProvider } from "./base";
|
|
7
|
+
import { browserFetch } from "./browser-page";
|
|
7
8
|
import { classifyProviderHttpError, withHardTimeout } from "./utils";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -28,15 +29,6 @@ const RECENCY_TO_DDG_DF: Record<NonNullable<SearchParams["recency"]>, string> =
|
|
|
28
29
|
year: "y",
|
|
29
30
|
};
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
-
* Browser-like UA so DDG serves the standard results page instead of the
|
|
33
|
-
* mobile-only or noscript variants. DDG returns HTTP 202 plus an anomaly
|
|
34
|
-
* modal when it suspects automation; we surface that as a clear error so
|
|
35
|
-
* the orchestrator can fall through to the next provider with context.
|
|
36
|
-
*/
|
|
37
|
-
const BROWSER_USER_AGENT =
|
|
38
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
|
39
|
-
|
|
40
32
|
interface ParsedResult {
|
|
41
33
|
title: string;
|
|
42
34
|
url: string;
|
|
@@ -133,35 +125,22 @@ async function callDuckDuckGoHtml(params: SearchParams): Promise<string> {
|
|
|
133
125
|
// Add b: "" parameter as specified in the browser fetch template to match real browser form submission
|
|
134
126
|
form.set("b", "");
|
|
135
127
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
body: form.toString(),
|
|
139
|
-
headers: {
|
|
140
|
-
Accept:
|
|
141
|
-
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
142
|
-
"Accept-Language": "en,en-US;q=0.9",
|
|
143
|
-
"Cache-Control": "max-age=0",
|
|
144
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
145
|
-
Priority: "u=0, i",
|
|
146
|
-
"Sec-Ch-Ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
|
147
|
-
"Sec-Ch-Ua-Mobile": "?0",
|
|
148
|
-
"Sec-Ch-Ua-Platform": '"macOS"',
|
|
149
|
-
"Sec-Fetch-Dest": "document",
|
|
150
|
-
"Sec-Fetch-Mode": "navigate",
|
|
151
|
-
"Sec-Fetch-Site": "same-origin",
|
|
152
|
-
"Sec-Fetch-User": "?1",
|
|
153
|
-
"Upgrade-Insecure-Requests": "1",
|
|
154
|
-
"User-Agent": BROWSER_USER_AGENT,
|
|
155
|
-
Referer: "https://html.duckduckgo.com/",
|
|
156
|
-
},
|
|
128
|
+
const page = await browserFetch(DUCKDUCKGO_HTML_URL, {
|
|
129
|
+
fetch: params.fetch ?? fetch,
|
|
157
130
|
signal: withHardTimeout(params.signal),
|
|
131
|
+
referer: "https://html.duckduckgo.com/",
|
|
132
|
+
init: {
|
|
133
|
+
method: "POST",
|
|
134
|
+
body: form.toString(),
|
|
135
|
+
},
|
|
136
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
158
137
|
});
|
|
159
138
|
|
|
160
|
-
const body =
|
|
161
|
-
if (
|
|
162
|
-
const classified = classifyProviderHttpError("duckduckgo",
|
|
139
|
+
const body = page.html;
|
|
140
|
+
if (page.status < 200 || page.status >= 300) {
|
|
141
|
+
const classified = classifyProviderHttpError("duckduckgo", page.status, body);
|
|
163
142
|
if (classified) throw classified;
|
|
164
|
-
throw new SearchProviderError("duckduckgo", `DuckDuckGo HTML error (${
|
|
143
|
+
throw new SearchProviderError("duckduckgo", `DuckDuckGo HTML error (${page.status})`, page.status);
|
|
165
144
|
}
|
|
166
145
|
|
|
167
146
|
if (isAnomalyResponse(body)) {
|