@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
|
@@ -0,0 +1,178 @@
|
|
|
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 type { LoadedHtmlPage } from "./browser-page";
|
|
9
|
+
import { browserFetch } from "./browser-page";
|
|
10
|
+
import { classifyProviderHttpError, withHardTimeout } from "./utils";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ecosia serves a server-rendered Vue/Nuxt results page (no `__NUXT_DATA__`
|
|
14
|
+
* JSON island — probed 2026-07), so both load paths parse the same markup:
|
|
15
|
+
* `<article data-test-id="organic-result">` blocks whose title anchor carries
|
|
16
|
+
* the final target URL directly (no redirect wrapper). The site fronts search
|
|
17
|
+
* with Cloudflare. Requests start with a browser-profiled fetch and escalate
|
|
18
|
+
* to the shared stealth browser only when the response is blocked or fails.
|
|
19
|
+
*
|
|
20
|
+
* Recency is ignored: Ecosia's web results expose no date filter in the UI
|
|
21
|
+
* and the legacy Bing-era `freshness` param is a server-side no-op (verified
|
|
22
|
+
* live), so per the {@link SearchParams.recency} contract the field must not
|
|
23
|
+
* be approximated.
|
|
24
|
+
*/
|
|
25
|
+
const ECOSIA_HOME_URL = "https://www.ecosia.org/";
|
|
26
|
+
const ECOSIA_SEARCH_URL = "https://www.ecosia.org/search";
|
|
27
|
+
const DEFAULT_NUM_RESULTS = 10;
|
|
28
|
+
const MAX_NUM_RESULTS = 20;
|
|
29
|
+
const RESULT_RENDER_TIMEOUT_MS = 10_000;
|
|
30
|
+
|
|
31
|
+
interface ParsedResult {
|
|
32
|
+
title: string;
|
|
33
|
+
url: string;
|
|
34
|
+
snippet?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate a result anchor href. Organic anchors carry the target URL
|
|
39
|
+
* directly; anything non-http(s) or pointing back at ecosia.org (internal
|
|
40
|
+
* navigation such as the images/news verticals) is rejected.
|
|
41
|
+
*/
|
|
42
|
+
function resolveResultUrl(href: string): string | undefined {
|
|
43
|
+
let url: URL;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(href, ECOSIA_HOME_URL);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
50
|
+
if (url.hostname === "ecosia.org" || url.hostname === "www.ecosia.org") return undefined;
|
|
51
|
+
return url.href;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pull organic results out of the page in document order. Each result is an
|
|
56
|
+
* `<article data-test-id="organic-result">` with the title inside
|
|
57
|
+
* `<h2 data-test-id="result-title">` wrapped by the target link, and the
|
|
58
|
+
* preview text inside `<p data-test-id="web-result-description">`. The inner
|
|
59
|
+
* paragraph is preferred over its `result-description` container because the
|
|
60
|
+
* container also holds screen-reader-only thumbnail captions on video rows.
|
|
61
|
+
* Ad slots (`data-test-id="ad-google"`) and entity/infobox cards use
|
|
62
|
+
* different test-ids and never match.
|
|
63
|
+
*/
|
|
64
|
+
function parseHtmlResults(html: string): ParsedResult[] {
|
|
65
|
+
const { document } = parseHTML(html);
|
|
66
|
+
const results: ParsedResult[] = [];
|
|
67
|
+
for (const article of document.querySelectorAll('article[data-test-id="organic-result"]')) {
|
|
68
|
+
const heading = article.querySelector('[data-test-id="result-title"]');
|
|
69
|
+
const href = heading?.closest("a")?.getAttribute("href");
|
|
70
|
+
if (!heading || !href) continue;
|
|
71
|
+
const url = resolveResultUrl(href);
|
|
72
|
+
if (!url) continue;
|
|
73
|
+
const title = (heading.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
74
|
+
if (!title) continue;
|
|
75
|
+
const description =
|
|
76
|
+
article.querySelector('[data-test-id="web-result-description"]') ??
|
|
77
|
+
article.querySelector('[data-test-id="result-description"]');
|
|
78
|
+
const snippet = (description?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
79
|
+
results.push({ title, url, snippet: snippet || undefined });
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `true` when Ecosia's Cloudflare front answered with the managed challenge
|
|
86
|
+
* instead of results. The observed page is a 403 titled "Ecosia Firewall"
|
|
87
|
+
* carrying the `_cf_chl_opt` bootstrap and the challenge-platform loader.
|
|
88
|
+
*/
|
|
89
|
+
function isBlockedPage(page: LoadedHtmlPage): boolean {
|
|
90
|
+
return (
|
|
91
|
+
page.status === 403 ||
|
|
92
|
+
page.status === 429 ||
|
|
93
|
+
page.html.includes("Ecosia Firewall") ||
|
|
94
|
+
page.html.includes("_cf_chl_opt") ||
|
|
95
|
+
page.html.includes("/cdn-cgi/challenge-platform/") ||
|
|
96
|
+
/confirm you.{0,3}re not a robot/i.test(page.html)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function callEcosiaHtml(params: SearchParams): Promise<string> {
|
|
101
|
+
const signal = withHardTimeout(params.signal);
|
|
102
|
+
const url = new URL(ECOSIA_SEARCH_URL);
|
|
103
|
+
url.searchParams.set("q", params.query);
|
|
104
|
+
|
|
105
|
+
let page: LoadedHtmlPage;
|
|
106
|
+
try {
|
|
107
|
+
page = await browserFetch(url.href, {
|
|
108
|
+
fetch: params.fetch,
|
|
109
|
+
signal,
|
|
110
|
+
referer: ECOSIA_HOME_URL,
|
|
111
|
+
browser: {
|
|
112
|
+
homeUrl: ECOSIA_HOME_URL,
|
|
113
|
+
ready: {
|
|
114
|
+
selector: 'article[data-test-id="organic-result"]',
|
|
115
|
+
timeoutMs: RESULT_RENDER_TIMEOUT_MS,
|
|
116
|
+
},
|
|
117
|
+
shouldFallback: isBlockedPage,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error instanceof SearchProviderError || params.signal?.aborted) throw error;
|
|
122
|
+
if (signal.aborted) {
|
|
123
|
+
throw new SearchProviderError("ecosia", "Ecosia search timed out.", 504);
|
|
124
|
+
}
|
|
125
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
126
|
+
throw new SearchProviderError("ecosia", `Ecosia search failed: ${message}`, 503);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isBlockedPage(page)) {
|
|
130
|
+
throw new SearchProviderError(
|
|
131
|
+
"ecosia",
|
|
132
|
+
"Ecosia blocked the request with a Cloudflare bot challenge. Ecosia's firewall throttles automated searches from datacenter/shared-egress IPs; try another web search provider such as DuckDuckGo, Brave, or Tavily.",
|
|
133
|
+
429,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (page.status < 200 || page.status >= 300) {
|
|
137
|
+
const classified = classifyProviderHttpError("ecosia", page.status, page.html);
|
|
138
|
+
if (classified) throw classified;
|
|
139
|
+
throw new SearchProviderError("ecosia", `Ecosia HTML error (${page.status})`, page.status);
|
|
140
|
+
}
|
|
141
|
+
return page.html;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Execute an Ecosia web search and parse the server-rendered result page. */
|
|
145
|
+
export async function searchEcosia(params: SearchParams): Promise<SearchResponse> {
|
|
146
|
+
const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
|
147
|
+
const html = await callEcosiaHtml(params);
|
|
148
|
+
const parsed = parseHtmlResults(html);
|
|
149
|
+
|
|
150
|
+
const sources: SearchSource[] = [];
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
for (const result of parsed) {
|
|
153
|
+
if (seen.has(result.url)) continue;
|
|
154
|
+
seen.add(result.url);
|
|
155
|
+
sources.push({ title: result.title, url: result.url, snippet: result.snippet });
|
|
156
|
+
if (sources.length >= numResults) break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { provider: "ecosia", sources };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Search provider for Ecosia (no API key required). */
|
|
163
|
+
export class EcosiaProvider extends SearchProvider {
|
|
164
|
+
readonly id = "ecosia";
|
|
165
|
+
readonly label = "Ecosia";
|
|
166
|
+
|
|
167
|
+
isAvailable(_authStorage: AuthStorage): boolean {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
search(params: SearchParams): Promise<SearchResponse> {
|
|
176
|
+
return searchEcosia(params);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
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 type { LoadedHtmlPage } from "./browser-page";
|
|
9
|
+
import { browserFetch } from "./browser-page";
|
|
10
|
+
import { withHardTimeout } from "./utils";
|
|
11
|
+
|
|
12
|
+
const GOOGLE_HOME_URL = "https://www.google.com/";
|
|
13
|
+
const GOOGLE_SEARCH_URL = "https://www.google.com/search";
|
|
14
|
+
const DEFAULT_NUM_RESULTS = 10;
|
|
15
|
+
const MAX_NUM_RESULTS = 20;
|
|
16
|
+
const RESULT_RENDER_TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
const RECENCY_TO_GOOGLE_TBS: Record<NonNullable<SearchParams["recency"]>, string> = {
|
|
19
|
+
day: "qdr:d",
|
|
20
|
+
week: "qdr:w",
|
|
21
|
+
month: "qdr:m",
|
|
22
|
+
year: "qdr:y",
|
|
23
|
+
};
|
|
24
|
+
const GOOGLE_SNIPPET_SELECTORS: readonly string[] = [
|
|
25
|
+
"[data-sncf='1'] .VwiC3b",
|
|
26
|
+
".VwiC3b",
|
|
27
|
+
".IsZvec",
|
|
28
|
+
".BNeawe.s3v9rd",
|
|
29
|
+
"[data-sncf='1']",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
interface ParsedResult {
|
|
33
|
+
title: string;
|
|
34
|
+
url: string;
|
|
35
|
+
snippet?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeText(value: string | null | undefined): string {
|
|
39
|
+
return (value ?? "").replace(/\s+/g, " ").trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unwrapResultUrl(href: string): string | undefined {
|
|
43
|
+
let url: URL;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(href, GOOGLE_HOME_URL);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if ((url.hostname === "google.com" || url.hostname === "www.google.com") && url.pathname === "/url") {
|
|
51
|
+
const target = url.searchParams.get("q") || url.searchParams.get("url");
|
|
52
|
+
if (!target) return undefined;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(target);
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
61
|
+
if (url.hostname === "google.com" || url.hostname === "www.google.com") return undefined;
|
|
62
|
+
return url.href;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findSnippet(heading: Element): string | undefined {
|
|
66
|
+
const container = heading.closest(".tF2Cxc, .MjjYud, .Gx5Zad") ?? heading.parentElement?.parentElement;
|
|
67
|
+
if (!container) return undefined;
|
|
68
|
+
|
|
69
|
+
for (const selector of GOOGLE_SNIPPET_SELECTORS) {
|
|
70
|
+
const text = normalizeText(container.querySelector(selector)?.textContent).replace(/\s*Read more$/i, "");
|
|
71
|
+
if (text) return text;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseHtmlResults(html: string): ParsedResult[] {
|
|
77
|
+
const { document } = parseHTML(html);
|
|
78
|
+
const results: ParsedResult[] = [];
|
|
79
|
+
for (const heading of document.querySelectorAll("h3")) {
|
|
80
|
+
const anchor = heading.closest("a");
|
|
81
|
+
const href = anchor?.getAttribute("href");
|
|
82
|
+
if (!href) continue;
|
|
83
|
+
const url = unwrapResultUrl(href);
|
|
84
|
+
if (!url) continue;
|
|
85
|
+
const title = normalizeText(heading.textContent);
|
|
86
|
+
if (!title) continue;
|
|
87
|
+
results.push({ title, url, snippet: findSnippet(heading) });
|
|
88
|
+
}
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildSearchUrl(params: SearchParams, numResults: number): string {
|
|
93
|
+
const url = new URL(GOOGLE_SEARCH_URL);
|
|
94
|
+
url.searchParams.set("q", params.query);
|
|
95
|
+
url.searchParams.set("num", String(numResults));
|
|
96
|
+
url.searchParams.set("hl", "en");
|
|
97
|
+
url.searchParams.set("gl", "us");
|
|
98
|
+
url.searchParams.set("udm", "14");
|
|
99
|
+
url.searchParams.set("pws", "0");
|
|
100
|
+
const tbs = params.recency ? RECENCY_TO_GOOGLE_TBS[params.recency] : undefined;
|
|
101
|
+
if (tbs) url.searchParams.set("tbs", tbs);
|
|
102
|
+
return url.href;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function blockReason(page: LoadedHtmlPage): "javascript" | "traffic" | undefined {
|
|
106
|
+
if (page.html.includes("/httpservice/retry/enablejs") && !/<h3\b/i.test(page.html)) return "javascript";
|
|
107
|
+
if (
|
|
108
|
+
page.status === 403 ||
|
|
109
|
+
page.status === 429 ||
|
|
110
|
+
page.url.includes("/sorry/") ||
|
|
111
|
+
/unusual traffic|detected unusual traffic|g-recaptcha/i.test(page.html)
|
|
112
|
+
) {
|
|
113
|
+
return "traffic";
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function callGoogleHtml(params: SearchParams, numResults: number): Promise<string> {
|
|
119
|
+
const signal = withHardTimeout(params.signal);
|
|
120
|
+
const url = buildSearchUrl(params, numResults);
|
|
121
|
+
let page: LoadedHtmlPage;
|
|
122
|
+
try {
|
|
123
|
+
page = await browserFetch(url, {
|
|
124
|
+
fetch: params.fetch,
|
|
125
|
+
signal,
|
|
126
|
+
referer: GOOGLE_HOME_URL,
|
|
127
|
+
browser: {
|
|
128
|
+
homeUrl: GOOGLE_HOME_URL,
|
|
129
|
+
ready: { selector: "a h3", timeoutMs: RESULT_RENDER_TIMEOUT_MS },
|
|
130
|
+
shouldFallback: candidate => blockReason(candidate) !== undefined,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof SearchProviderError || params.signal?.aborted) throw error;
|
|
135
|
+
if (signal.aborted) {
|
|
136
|
+
throw new SearchProviderError("google", "Google browser search timed out.", 504);
|
|
137
|
+
}
|
|
138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
+
throw new SearchProviderError("google", `Google browser search failed: ${message}`, 503);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const blocked = blockReason(page);
|
|
143
|
+
if (blocked === "traffic") {
|
|
144
|
+
throw new SearchProviderError(
|
|
145
|
+
"google",
|
|
146
|
+
"Google blocked the browser search with an automated-traffic challenge. Try another web search provider or retry later.",
|
|
147
|
+
429,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (page.status < 200 || page.status >= 300) {
|
|
151
|
+
throw new SearchProviderError("google", `Google HTML error (${page.status})`, page.status);
|
|
152
|
+
}
|
|
153
|
+
if (blocked === "javascript") {
|
|
154
|
+
throw new SearchProviderError(
|
|
155
|
+
"google",
|
|
156
|
+
"Google returned its JavaScript challenge instead of rendered search results.",
|
|
157
|
+
429,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return page.html;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Execute a Google web search with fetch-first loading and a headless-browser fallback. */
|
|
164
|
+
export async function searchGoogle(params: SearchParams): Promise<SearchResponse> {
|
|
165
|
+
const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
|
166
|
+
const html = await callGoogleHtml(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: "google", sources };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Fetch-first Google Search provider with a headless-browser fallback; no API key is required. */
|
|
182
|
+
export class GoogleProvider extends SearchProvider {
|
|
183
|
+
readonly id = "google";
|
|
184
|
+
readonly label = "Google";
|
|
185
|
+
|
|
186
|
+
isAvailable(_authStorage: AuthStorage): boolean {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
search(params: SearchParams): Promise<SearchResponse> {
|
|
191
|
+
return searchGoogle(params);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { AuthStorage } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { untilAborted } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { parseHTML } from "linkedom";
|
|
4
|
+
import type { Page } from "puppeteer-core";
|
|
5
|
+
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
6
|
+
import { SearchProviderError } from "../../../web/search/types";
|
|
7
|
+
import { clampNumResults } from "../utils";
|
|
8
|
+
import type { SearchParams } from "./base";
|
|
9
|
+
import { SearchProvider } from "./base";
|
|
10
|
+
import type { LoadedHtmlPage } from "./browser-page";
|
|
11
|
+
import { browserFetch } from "./browser-page";
|
|
12
|
+
import { classifyProviderHttpError, withHardTimeout } from "./utils";
|
|
13
|
+
|
|
14
|
+
const MOJEEK_ORIGIN = "https://www.mojeek.de";
|
|
15
|
+
const MOJEEK_HOME_URL = `${MOJEEK_ORIGIN}/?arc=none&lang=en&lb=en&theme=dark`;
|
|
16
|
+
const MOJEEK_SEARCH_URL = `${MOJEEK_ORIGIN}/search`;
|
|
17
|
+
const DEFAULT_NUM_RESULTS = 10;
|
|
18
|
+
const MAX_NUM_RESULTS = 20;
|
|
19
|
+
/**
|
|
20
|
+
* ALTCHA can complete quickly, but its verified redirect is occasionally
|
|
21
|
+
* delayed by queueing on the challenge backend.
|
|
22
|
+
*/
|
|
23
|
+
const CAPTCHA_SOLVE_TIMEOUT_MS = 45_000;
|
|
24
|
+
|
|
25
|
+
interface ParsedResult {
|
|
26
|
+
title: string;
|
|
27
|
+
url: string;
|
|
28
|
+
snippet?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate a result href. Mojeek links results directly to the target site
|
|
33
|
+
* (no redirect wrapper), so this only filters out non-HTTP schemes and
|
|
34
|
+
* intra-Mojeek navigation rows (verticals, paging) that share the markup.
|
|
35
|
+
*/
|
|
36
|
+
function normalizeResultUrl(href: string): string | undefined {
|
|
37
|
+
let url: URL;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(href, MOJEEK_HOME_URL);
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
44
|
+
if (
|
|
45
|
+
url.hostname === "mojeek.com" ||
|
|
46
|
+
url.hostname.endsWith(".mojeek.com") ||
|
|
47
|
+
url.hostname === "mojeek.co.uk" ||
|
|
48
|
+
url.hostname.endsWith(".mojeek.co.uk") ||
|
|
49
|
+
url.hostname === "mojeek.fr" ||
|
|
50
|
+
url.hostname.endsWith(".mojeek.fr") ||
|
|
51
|
+
url.hostname === "mojeek.de" ||
|
|
52
|
+
url.hostname.endsWith(".mojeek.de")
|
|
53
|
+
) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return url.href;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pull result blocks out of a Mojeek results page in document order.
|
|
61
|
+
*
|
|
62
|
+
* Each organic result renders as `ul.results-standard > li` with the title in
|
|
63
|
+
* `h2 > a.title` (href is the direct target URL) and the preview text in
|
|
64
|
+
* `p.s`. Clustered sub-results (`li.clu-result`) share the same shape; rows
|
|
65
|
+
* without a title anchor (infoboxes, spelling suggestions) are skipped.
|
|
66
|
+
*/
|
|
67
|
+
function parseHtmlResults(html: string): ParsedResult[] {
|
|
68
|
+
const { document } = parseHTML(html);
|
|
69
|
+
const results: ParsedResult[] = [];
|
|
70
|
+
for (const item of document.querySelectorAll("ul.results-standard > li")) {
|
|
71
|
+
const anchor = item.querySelector("h2 a.title") ?? item.querySelector("a.title");
|
|
72
|
+
const href = anchor?.getAttribute("href");
|
|
73
|
+
if (!href) continue;
|
|
74
|
+
const url = normalizeResultUrl(href);
|
|
75
|
+
if (!url) continue;
|
|
76
|
+
const title = (anchor?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
77
|
+
if (!title) continue;
|
|
78
|
+
const snippet = (item.querySelector("p.s")?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
79
|
+
results.push({ title, url, snippet: snippet || undefined });
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildSearchUrl(params: SearchParams, numResults: number): string {
|
|
85
|
+
const url = new URL(MOJEEK_SEARCH_URL);
|
|
86
|
+
url.searchParams.set("q", params.query);
|
|
87
|
+
url.searchParams.set("t", String(numResults));
|
|
88
|
+
url.searchParams.set("arc", "none");
|
|
89
|
+
url.searchParams.set("lang", "en");
|
|
90
|
+
url.searchParams.set("lb", "en");
|
|
91
|
+
url.searchParams.set("theme", "dark");
|
|
92
|
+
// Mojeek's `since` filter accepts the relative tokens day/week/month/year
|
|
93
|
+
// verbatim — the same vocabulary as `recency` (verified live: each window
|
|
94
|
+
// returns a near-disjoint, fresher result set). Dates reflect crawl or
|
|
95
|
+
// last-modification time per Mojeek's operator docs.
|
|
96
|
+
if (params.recency) url.searchParams.set("since", params.recency);
|
|
97
|
+
return url.href;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Solve Mojeek's ALTCHA interstitial and wait for its verified redirect to populate results. */
|
|
101
|
+
async function solveCaptcha(page: Page, signal: AbortSignal): Promise<void> {
|
|
102
|
+
if (await untilAborted(signal, () => page.$("ul.results-standard li"))) return;
|
|
103
|
+
|
|
104
|
+
const checkbox = await untilAborted(signal, () => page.$("altcha-widget input[type=checkbox]"));
|
|
105
|
+
if (!checkbox) return;
|
|
106
|
+
|
|
107
|
+
const navigation = page
|
|
108
|
+
.waitForNavigation({ waitUntil: "domcontentloaded", timeout: CAPTCHA_SOLVE_TIMEOUT_MS })
|
|
109
|
+
.catch(() => null);
|
|
110
|
+
await untilAborted(signal, () => checkbox.click());
|
|
111
|
+
await untilAborted(signal, () => navigation);
|
|
112
|
+
await untilAborted(signal, () =>
|
|
113
|
+
page.waitForSelector("ul.results-standard li", { timeout: CAPTCHA_SOLVE_TIMEOUT_MS }).catch(() => null),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isRobotPage(page: LoadedHtmlPage): boolean {
|
|
118
|
+
return (
|
|
119
|
+
(page.html.includes("altcha-widget") ||
|
|
120
|
+
page.html.includes("captcha-wrap") ||
|
|
121
|
+
/sending automated queries/i.test(page.html)) &&
|
|
122
|
+
!page.html.includes("results-standard")
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function callMojeekHtml(params: SearchParams, numResults: number): Promise<string> {
|
|
127
|
+
const signal = withHardTimeout(params.signal);
|
|
128
|
+
const url = buildSearchUrl(params, numResults);
|
|
129
|
+
let page: LoadedHtmlPage;
|
|
130
|
+
try {
|
|
131
|
+
page = await browserFetch(url, {
|
|
132
|
+
fetch: params.fetch,
|
|
133
|
+
signal,
|
|
134
|
+
randomizeHeaders: false,
|
|
135
|
+
referer: MOJEEK_HOME_URL,
|
|
136
|
+
browser: {
|
|
137
|
+
homeUrl: MOJEEK_HOME_URL,
|
|
138
|
+
afterNavigation: solveCaptcha,
|
|
139
|
+
shouldFallback: isRobotPage,
|
|
140
|
+
attempts: 2,
|
|
141
|
+
retryDelayMs: 1_000,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof SearchProviderError || params.signal?.aborted) throw error;
|
|
146
|
+
if (signal.aborted) {
|
|
147
|
+
throw new SearchProviderError("mojeek", "Mojeek search timed out.", 504);
|
|
148
|
+
}
|
|
149
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
150
|
+
throw new SearchProviderError("mojeek", `Mojeek search failed: ${message}`, 503);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Robot walls: the ALTCHA proof-of-work captcha page arrives as HTTP 200
|
|
154
|
+
// (`<title>Captcha</title>`, `altcha-widget`) and the "automated queries"
|
|
155
|
+
// refusal as HTTP 403. Both bodies are more actionable than their raw
|
|
156
|
+
// statuses, so check them before the generic status handling.
|
|
157
|
+
if (isRobotPage(page)) {
|
|
158
|
+
throw new SearchProviderError(
|
|
159
|
+
"mojeek",
|
|
160
|
+
"Mojeek blocked the request with its automated-queries wall. Mojeek rate-limits scripted searches from datacenter/shared-egress IPs; retry later or configure another provider such as Brave, Tavily, Exa, or Kagi.",
|
|
161
|
+
429,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (page.status < 200 || page.status >= 300) {
|
|
165
|
+
const classified = classifyProviderHttpError("mojeek", page.status, page.html);
|
|
166
|
+
if (classified) throw classified;
|
|
167
|
+
throw new SearchProviderError("mojeek", `Mojeek HTML error (${page.status})`, page.status);
|
|
168
|
+
}
|
|
169
|
+
return page.html;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Execute a Mojeek web search against the standard HTML results page. */
|
|
173
|
+
export async function searchMojeek(params: SearchParams): Promise<SearchResponse> {
|
|
174
|
+
const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
|
175
|
+
const html = await callMojeekHtml(params, numResults);
|
|
176
|
+
const parsed = parseHtmlResults(html);
|
|
177
|
+
|
|
178
|
+
const sources: SearchSource[] = [];
|
|
179
|
+
const seen = new Set<string>();
|
|
180
|
+
for (const result of parsed) {
|
|
181
|
+
if (seen.has(result.url)) continue;
|
|
182
|
+
seen.add(result.url);
|
|
183
|
+
sources.push({ title: result.title, url: result.url, snippet: result.snippet });
|
|
184
|
+
if (sources.length >= numResults) break;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { provider: "mojeek", sources };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Search provider for Mojeek (independent index, no API key required). */
|
|
191
|
+
export class MojeekProvider extends SearchProvider {
|
|
192
|
+
readonly id = "mojeek";
|
|
193
|
+
readonly label = "Mojeek";
|
|
194
|
+
|
|
195
|
+
isAvailable(_authStorage: AuthStorage): boolean {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
search(params: SearchParams): Promise<SearchResponse> {
|
|
204
|
+
return searchMojeek(params);
|
|
205
|
+
}
|
|
206
|
+
}
|