pi-web-kit 0.1.1 → 0.1.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 +21 -0
- package/README.md +2 -0
- package/SECURITY.md +2 -0
- package/extensions/index.ts +90 -17
- package/package.json +1 -1
- package/src/config.ts +5 -3
- package/src/providers/exa-mcp.ts +6 -6
- package/src/providers/exa.ts +2 -1
- package/src/providers/fallback.ts +2 -1
- package/src/providers/firecrawl.ts +2 -2
- package/src/providers/tinyfish.ts +2 -1
- package/src/urls.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,27 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.3] - 2026-05-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Install/update telemetry ping to `mocito.dev`, gated by Pi telemetry/offline settings and deduplicated per package version.
|
|
14
|
+
|
|
15
|
+
## [0.1.2] - 2026-05-09
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Deduplicated `mapFetchResults` between extension and fallback provider; extension now imports the shared implementation.
|
|
20
|
+
- Replaced `existsSync` + `readFileSync` with a single `readFileSync` in a try/catch to eliminate TOCTOU race in config loading.
|
|
21
|
+
- Replaced nested ternary for `requireKey` env-var names with a lookup object.
|
|
22
|
+
- Replaced magic number `10` with `DEFAULT_NUM_RESULTS` in Exa, Exa MCP, and Firecrawl providers; replaced `10` with `MAX_URL_COUNT` in TinyFish.
|
|
23
|
+
- Fixed `urlsMatch` to compute `canonicalWebUrl` once per URL instead of twice.
|
|
24
|
+
- Moved `nextId` JSON-RPC counter from module scope to `ExaMcpProvider` instance to avoid ID conflicts across concurrent instances.
|
|
25
|
+
- Removed unused `_query` parameter from internal `normalizeSearch` in Exa MCP provider.
|
|
26
|
+
- Replaced O(n) completed-count recomputation in progress tracking with O(1) increment.
|
|
27
|
+
- Cached `buildCacheKey` results in `fetchWithCache` to avoid redundant URL parsing per fetched page.
|
|
28
|
+
- Replaced nested ternary chains in `providerScope` and `renderProgress` with lookup objects.
|
|
29
|
+
|
|
9
30
|
## [0.1.1] - 2026-05-09
|
|
10
31
|
|
|
11
32
|
### Fixed
|
package/README.md
CHANGED
|
@@ -98,6 +98,8 @@ Resolution order: defaults < environment variables < global config < project con
|
|
|
98
98
|
### Environment variables
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
|
+
PI_OFFLINE=1 # disables install/update telemetry
|
|
102
|
+
PI_TELEMETRY=0 # disables install/update telemetry
|
|
101
103
|
PI_WEB_KIT_PROVIDER_SEARCH=exa_mcp|exa|tinyfish|brave|firecrawl
|
|
102
104
|
PI_WEB_KIT_PROVIDER_FETCH=exa_mcp|exa|tinyfish|markdown_new|firecrawl
|
|
103
105
|
EXA_API_KEY=...
|
package/SECURITY.md
CHANGED
|
@@ -23,4 +23,6 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
|
|
|
23
23
|
|
|
24
24
|
`pi-web-kit` sends search queries and fetched URLs to the configured third-party provider. Page content returned by providers is cached in memory for the lifetime of the Pi process, subject to TTL and size limits. API keys are read from environment variables or local config files and are used only for provider requests. Do not commit API keys, tokens, or config files containing secrets.
|
|
25
25
|
|
|
26
|
+
On startup, the extension sends a best-effort install/update telemetry ping to `mocito.dev` once per package version unless Pi telemetry is disabled or offline mode is enabled. The ping includes only the package name, version, and parsed platform/runtime/architecture from its User-Agent; it does not include prompts, queries, fetched URLs, file paths, config values, or API keys.
|
|
27
|
+
|
|
26
28
|
The extension validates URLs before fetch calls and only accepts `http:` and `https:` URLs without embedded credentials. This validation reduces accidental misuse but does not sandbox provider responses or the local Pi process.
|
package/extensions/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
6
|
import { Type } from "typebox";
|
|
4
7
|
import { fetchCache, type CachedPage } from "../src/cache.js";
|
|
@@ -6,10 +9,18 @@ import { resolveConfig } from "../src/config.js";
|
|
|
6
9
|
import { DEFAULT_FETCH_LIMIT, DEFAULT_NUM_RESULTS, MAX_LIMIT, MAX_NUM_RESULTS, MAX_OFFSET, MAX_QUERY_COUNT, MAX_URL_COUNT, MULTI_FETCH_LIMIT } from "../src/limits.js";
|
|
7
10
|
import { truncateText } from "../src/http.js";
|
|
8
11
|
import { createFetchProvider, createSearchProvider } from "../src/providers/index.js";
|
|
12
|
+
import { mapFetchResults } from "../src/providers/fallback.js";
|
|
9
13
|
import type { FetchProviderName, SearchProviderName, WebFetchResult } from "../src/types.js";
|
|
10
|
-
import { canonicalWebUrl, normalizeUrlInput
|
|
14
|
+
import { canonicalWebUrl, normalizeUrlInput } from "../src/urls.js";
|
|
15
|
+
|
|
16
|
+
const PACKAGE_NAME = "pi-web-kit";
|
|
17
|
+
const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
|
|
18
|
+
const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
|
|
19
|
+
|
|
20
|
+
type InstallTelemetryState = { lastReportedVersion?: string };
|
|
11
21
|
|
|
12
22
|
export default function (pi: ExtensionAPI) {
|
|
23
|
+
void reportInstallTelemetry();
|
|
13
24
|
pi.registerFlag("web-provider-search", {
|
|
14
25
|
description: "Temporary pi-web-kit search provider override (exa_mcp, exa, tinyfish, brave, firecrawl)",
|
|
15
26
|
type: "string",
|
|
@@ -157,6 +168,64 @@ function buildFetchDescription(provider: FetchProviderName): string {
|
|
|
157
168
|
return `Fetch URL content with startup provider '${provider}'. Results are cached by URL/options; use offset/limit to read long pages in chunks. Restart/reload pi after provider changes.`;
|
|
158
169
|
}
|
|
159
170
|
|
|
171
|
+
function readJsonFile(path: string): unknown {
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
174
|
+
} catch {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isTruthyEnvFlag(value: string | undefined): boolean {
|
|
180
|
+
if (!value) return false;
|
|
181
|
+
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isInstallTelemetryEnabled(): boolean {
|
|
185
|
+
if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
|
|
186
|
+
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
187
|
+
const settings = readJsonFile(join(getAgentDir(), "settings.json")) as { enableInstallTelemetry?: unknown };
|
|
188
|
+
return settings.enableInstallTelemetry !== false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getPackageVersion(): string {
|
|
192
|
+
try {
|
|
193
|
+
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: unknown };
|
|
194
|
+
return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
|
|
195
|
+
} catch {
|
|
196
|
+
return "0.0.0";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getInstallTelemetryUserAgent(version: string): string {
|
|
201
|
+
const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
|
|
202
|
+
const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
|
|
203
|
+
return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function reportInstallTelemetry(): Promise<void> {
|
|
207
|
+
try {
|
|
208
|
+
if (!isInstallTelemetryEnabled()) return;
|
|
209
|
+
|
|
210
|
+
const version = getPackageVersion();
|
|
211
|
+
const telemetryDir = join(getAgentDir(), "extensions");
|
|
212
|
+
const statePath = join(telemetryDir, "pi-web-kit-install.json");
|
|
213
|
+
const state = readJsonFile(statePath) as InstallTelemetryState;
|
|
214
|
+
if (state.lastReportedVersion === version) return;
|
|
215
|
+
|
|
216
|
+
await mkdir(telemetryDir, { recursive: true });
|
|
217
|
+
await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`);
|
|
218
|
+
|
|
219
|
+
const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
|
|
220
|
+
await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
|
|
221
|
+
headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
|
|
222
|
+
signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
|
|
223
|
+
});
|
|
224
|
+
} catch {
|
|
225
|
+
// Best-effort install telemetry: ignore settings, filesystem, and network failures.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
160
229
|
function normalizeQueries(params: Record<string, any>): string[] {
|
|
161
230
|
const raw = Array.isArray(params.queries) ? params.queries : params.query ? [params.query] : [];
|
|
162
231
|
const queries = [...new Set(raw.map((q) => String(q).trim()).filter(Boolean))];
|
|
@@ -176,9 +245,11 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
|
|
|
176
245
|
const refresh = params.refresh === true;
|
|
177
246
|
|
|
178
247
|
const pages = new Map<string, { page?: CachedPage; cached: boolean; refreshed: boolean; error?: string }>();
|
|
248
|
+
const cacheKeys = new Map<string, string>();
|
|
179
249
|
const missing: string[] = [];
|
|
180
250
|
for (const url of urls) {
|
|
181
251
|
const cacheKey = buildCacheKey(providerName, url, params, config);
|
|
252
|
+
cacheKeys.set(url, cacheKey);
|
|
182
253
|
const cached = fetchCache.get(cacheKey);
|
|
183
254
|
if (cached && !refresh) {
|
|
184
255
|
pages.set(url, { page: cached, cached: true, refreshed: false });
|
|
@@ -200,7 +271,7 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
|
|
|
200
271
|
onProgress?.({ status: "error", url: requestedUrl, error });
|
|
201
272
|
continue;
|
|
202
273
|
}
|
|
203
|
-
const cacheKey =
|
|
274
|
+
const cacheKey = cacheKeys.get(requestedUrl)!;
|
|
204
275
|
const page = fetchCache.set(cacheKey, {
|
|
205
276
|
provider: providerName,
|
|
206
277
|
cacheKey,
|
|
@@ -224,16 +295,6 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
|
|
|
224
295
|
}) };
|
|
225
296
|
}
|
|
226
297
|
|
|
227
|
-
function mapFetchResults(requested: string[], fetched: WebFetchResult): Map<string, WebFetchResult["results"][number]> {
|
|
228
|
-
const out = new Map<string, WebFetchResult["results"][number]>();
|
|
229
|
-
const remaining = [...(fetched.results ?? [])];
|
|
230
|
-
for (const url of requested) {
|
|
231
|
-
const index = remaining.findIndex((item) => urlsMatch(item.url, url));
|
|
232
|
-
if (index >= 0) out.set(url, remaining.splice(index, 1)[0]);
|
|
233
|
-
}
|
|
234
|
-
requested.forEach((url, index) => { if (!out.has(url) && fetched.results?.[index]) out.set(url, fetched.results[index]); });
|
|
235
|
-
return out;
|
|
236
|
-
}
|
|
237
298
|
|
|
238
299
|
export function pageSlice(page: CachedPage, offset: number, limit: number, cached: boolean, refreshed: boolean) {
|
|
239
300
|
const total = page.content.length;
|
|
@@ -266,7 +327,13 @@ function fetchConfigDefaults(provider: FetchProviderName, config?: any): Record<
|
|
|
266
327
|
}
|
|
267
328
|
|
|
268
329
|
function providerScope(provider: FetchProviderName, config?: any): string {
|
|
269
|
-
const
|
|
330
|
+
const keyMap: Partial<Record<FetchProviderName, string | undefined>> = {
|
|
331
|
+
exa: config?.apiKeys?.exa,
|
|
332
|
+
exa_mcp: config?.apiKeys?.exa,
|
|
333
|
+
tinyfish: config?.apiKeys?.tinyfish,
|
|
334
|
+
firecrawl: config?.apiKeys?.firecrawl,
|
|
335
|
+
};
|
|
336
|
+
const key = keyMap[provider];
|
|
270
337
|
return key ? `key:${String(key).slice(0, 8)}` : "default";
|
|
271
338
|
}
|
|
272
339
|
|
|
@@ -294,7 +361,7 @@ function markProgressDone(progress: WebProgress, label: string, note?: string) {
|
|
|
294
361
|
if (!item) return;
|
|
295
362
|
item.status = "done";
|
|
296
363
|
item.note = note;
|
|
297
|
-
progress.completed
|
|
364
|
+
progress.completed++;
|
|
298
365
|
}
|
|
299
366
|
|
|
300
367
|
function markProgressError(progress: WebProgress, label: string, error?: string) {
|
|
@@ -302,7 +369,7 @@ function markProgressError(progress: WebProgress, label: string, error?: string)
|
|
|
302
369
|
if (!item) return;
|
|
303
370
|
item.status = "error";
|
|
304
371
|
item.error = error;
|
|
305
|
-
progress.completed
|
|
372
|
+
progress.completed++;
|
|
306
373
|
}
|
|
307
374
|
|
|
308
375
|
function emitProgress(onUpdate: ((patch: any) => void) | undefined, progress: WebProgress) {
|
|
@@ -355,8 +422,14 @@ function renderProgress(progress: WebProgress, theme: any, spinner: string, expa
|
|
|
355
422
|
const verb = isSearch ? "Searching web" : progress.total === 1 ? "Fetching page" : "Fetching pages";
|
|
356
423
|
let text = `${isSearch ? "🔎" : "🌐"} ${verb}${progress.total > 1 ? ` ${progressBar(progress.completed, progress.total)} ${progress.completed}/${progress.total}` : "…"}`;
|
|
357
424
|
const visible = expanded ? progress.items : progress.items.slice(0, 6);
|
|
425
|
+
const iconMap: Record<string, string> = {
|
|
426
|
+
done: theme.fg("success", "✓"),
|
|
427
|
+
error: theme.fg("error", "✕"),
|
|
428
|
+
current: theme.fg("warning", spinner),
|
|
429
|
+
pending: theme.fg("muted", "·"),
|
|
430
|
+
};
|
|
358
431
|
for (const item of visible) {
|
|
359
|
-
const icon = item.status
|
|
432
|
+
const icon = iconMap[item.status] ?? theme.fg("muted", "·");
|
|
360
433
|
const note = item.error ? theme.fg("error", ` ${item.error}`) : item.note ? theme.fg("muted", ` ${item.note}`) : "";
|
|
361
434
|
text += `\n ${icon} ${theme.fg(item.status === "pending" ? "muted" : "accent", quote(truncateMiddle(item.label, 100)))}${note}`;
|
|
362
435
|
}
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { FetchProviderName, SearchProviderName, WebKitConfig } from "./types.js";
|
|
@@ -30,7 +30,7 @@ export function resolveConfig(flags: { providerSearch?: unknown; providerFetch?:
|
|
|
30
30
|
});
|
|
31
31
|
const home = env.HOME ?? homedir();
|
|
32
32
|
for (const path of [join(home, ".pi/agent/pi-web-kit.json"), join(cwd, ".pi-web-kit.json")]) {
|
|
33
|
-
|
|
33
|
+
try { cfg = merge(cfg, JSON.parse(readFileSync(path, "utf8")) as PartialConfig); } catch (e: any) { if (e?.code !== "ENOENT") throw e; }
|
|
34
34
|
}
|
|
35
35
|
cfg = merge(cfg, {
|
|
36
36
|
provider_search: flags.providerSearch as SearchProviderName | undefined,
|
|
@@ -63,9 +63,11 @@ export function validateFetchProvider(name: string): asserts name is FetchProvid
|
|
|
63
63
|
if (!FETCH.includes(name as FetchProviderName)) throw new Error(`Unknown fetch provider '${name}'. Expected one of: ${FETCH.join(", ")}.`);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
const PROVIDER_ENV_NAMES = { exa: "EXA_API_KEY", tinyfish: "TINYFISH_API_KEY", brave: "BRAVE_SEARCH_API_KEY", firecrawl: "FIRECRAWL_API_KEY" } as const;
|
|
67
|
+
|
|
66
68
|
export function requireKey(config: WebKitConfig, provider: "exa" | "tinyfish" | "brave" | "firecrawl"): string {
|
|
67
69
|
const key = config.apiKeys[provider];
|
|
68
|
-
const envName = provider
|
|
70
|
+
const envName = PROVIDER_ENV_NAMES[provider];
|
|
69
71
|
if (!key) throw new Error(`${provider} provider requires ${envName} or apiKeys.${provider} in .pi-web-kit.json / ~/.pi/agent/pi-web-kit.json.`);
|
|
70
72
|
return key;
|
|
71
73
|
}
|
package/src/providers/exa-mcp.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { asSnippet, fetchWithTimeout, normalizeUrls } from "../http.js";
|
|
2
|
+
import { DEFAULT_NUM_RESULTS } from "../limits.js";
|
|
2
3
|
import { urlsMatch } from "../urls.js";
|
|
3
4
|
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
|
|
4
5
|
import { applyExaFetchFallbacks } from "./fallback.js";
|
|
5
6
|
|
|
6
|
-
let nextId = 1;
|
|
7
|
-
|
|
8
7
|
export class ExaMcpProvider implements SearchProvider, FetchProvider {
|
|
9
8
|
private sessionId?: string;
|
|
9
|
+
private nextId = 1;
|
|
10
10
|
constructor(private config: WebKitConfig) {}
|
|
11
11
|
|
|
12
12
|
async search(input: SearchInput, signal?: AbortSignal) {
|
|
13
|
-
const result = await this.callTool("web_search_exa", { query: input.query, numResults: input.numResults ??
|
|
14
|
-
return { provider: "exa_mcp" as const, query: input.query, results: normalizeSearch(result
|
|
13
|
+
const result = await this.callTool("web_search_exa", { query: input.query, numResults: input.numResults ?? DEFAULT_NUM_RESULTS }, signal);
|
|
14
|
+
return { provider: "exa_mcp" as const, query: input.query, results: normalizeSearch(result) };
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
async fetch(input: FetchInput, signal?: AbortSignal) {
|
|
@@ -44,7 +44,7 @@ export class ExaMcpProvider implements SearchProvider, FetchProvider {
|
|
|
44
44
|
const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json, text/event-stream" };
|
|
45
45
|
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
46
46
|
if (this.config.apiKeys.exa) headers["x-api-key"] = this.config.apiKeys.exa;
|
|
47
|
-
const body = notification ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id: nextId++, method, params };
|
|
47
|
+
const body = notification ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id: this.nextId++, method, params };
|
|
48
48
|
const res = await fetchWithTimeout("https://mcp.exa.ai/mcp", { method: "POST", headers, body: JSON.stringify(body), signal, timeoutMs: 45_000 });
|
|
49
49
|
const text = await res.text();
|
|
50
50
|
if (!res.ok) throw new Error(`Exa MCP ${method} failed: ${res.status} ${res.statusText}: ${text.slice(0, 1000)}`);
|
|
@@ -70,7 +70,7 @@ function textFromContent(result: any): string {
|
|
|
70
70
|
return typeof content === "string" ? content : JSON.stringify(content);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
function normalizeSearch(result: any
|
|
73
|
+
function normalizeSearch(result: any) {
|
|
74
74
|
const structured = result?.structuredContent ?? result?.result ?? result;
|
|
75
75
|
const list = structured.results ?? structured.data ?? structured.items;
|
|
76
76
|
if (Array.isArray(list)) return list.map((r: any, i: number) => ({ title: r.title, url: r.url, snippet: asSnippet(r.snippet ?? r.text ?? r.summary ?? r.highlights), siteName: r.siteName, position: i + 1 })).filter((r: any) => r.url);
|
package/src/providers/exa.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asSnippet, normalizeUrls, requestJson } from "../http.js";
|
|
2
|
+
import { DEFAULT_NUM_RESULTS } from "../limits.js";
|
|
2
3
|
import { urlsMatch } from "../urls.js";
|
|
3
4
|
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebFetchResult, WebKitConfig } from "../types.js";
|
|
4
5
|
import { requireKey } from "../config.js";
|
|
@@ -11,7 +12,7 @@ export class ExaProvider implements SearchProvider, FetchProvider {
|
|
|
11
12
|
async search(input: SearchInput, signal?: AbortSignal) {
|
|
12
13
|
const body = {
|
|
13
14
|
query: input.query,
|
|
14
|
-
numResults: input.numResults ??
|
|
15
|
+
numResults: input.numResults ?? DEFAULT_NUM_RESULTS,
|
|
15
16
|
contents: input.contents ?? { highlights: true },
|
|
16
17
|
includeDomains: input.includeDomains,
|
|
17
18
|
excludeDomains: input.excludeDomains,
|
|
@@ -62,13 +62,14 @@ function fallbackProviders(config: WebKitConfig): Array<{ name: FetchProviderNam
|
|
|
62
62
|
return providers;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function mapFetchResults(requested: string[], fetched: WebFetchResult): Map<string, FetchPage> {
|
|
65
|
+
export function mapFetchResults(requested: string[], fetched: WebFetchResult): Map<string, FetchPage> {
|
|
66
66
|
const out = new Map<string, FetchPage>();
|
|
67
67
|
const remaining = [...(fetched.results ?? [])];
|
|
68
68
|
for (const url of requested) {
|
|
69
69
|
const index = remaining.findIndex((item) => urlsMatch(item.url, url));
|
|
70
70
|
if (index >= 0) out.set(url, remaining.splice(index, 1)[0]);
|
|
71
71
|
}
|
|
72
|
+
// providers may not echo back the canonical URL; fall back to positional match
|
|
72
73
|
requested.forEach((url, index) => { if (!out.has(url) && fetched.results?.[index]) out.set(url, fetched.results[index]); });
|
|
73
74
|
return out;
|
|
74
75
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { asSnippet, mapConcurrent, normalizeUrls, requestJson } from "../http.js";
|
|
2
|
-
import { FETCH_CONCURRENCY } from "../limits.js";
|
|
2
|
+
import { DEFAULT_NUM_RESULTS, FETCH_CONCURRENCY } from "../limits.js";
|
|
3
3
|
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
|
|
4
4
|
import { requireKey } from "../config.js";
|
|
5
5
|
|
|
@@ -13,7 +13,7 @@ export class FirecrawlProvider implements SearchProvider, FetchProvider {
|
|
|
13
13
|
method: "POST", headers: this.headers(), signal, timeoutMs: 45000,
|
|
14
14
|
body: JSON.stringify({
|
|
15
15
|
query: input.query,
|
|
16
|
-
limit: input.numResults ??
|
|
16
|
+
limit: input.numResults ?? DEFAULT_NUM_RESULTS,
|
|
17
17
|
location: input.location,
|
|
18
18
|
country: input.country,
|
|
19
19
|
includeDomains: input.includeDomains,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asSnippet, normalizeUrls, requestJson } from "../http.js";
|
|
2
|
+
import { MAX_URL_COUNT } from "../limits.js";
|
|
2
3
|
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
|
|
3
4
|
import { requireKey } from "../config.js";
|
|
4
5
|
|
|
@@ -20,7 +21,7 @@ export class TinyFishProvider implements SearchProvider, FetchProvider {
|
|
|
20
21
|
|
|
21
22
|
async fetch(input: FetchInput, signal?: AbortSignal) {
|
|
22
23
|
const urls = normalizeUrls(input);
|
|
23
|
-
if (urls.length >
|
|
24
|
+
if (urls.length > MAX_URL_COUNT) throw new Error(`TinyFish fetch supports a maximum of ${MAX_URL_COUNT} URLs per request.`);
|
|
24
25
|
const data = await requestJson<any>("https://api.fetch.tinyfish.ai", {
|
|
25
26
|
method: "POST",
|
|
26
27
|
headers: { "content-type": "application/json", "X-API-Key": this.key },
|
package/src/urls.ts
CHANGED
|
@@ -31,9 +31,11 @@ export function normalizeUrlInput(input: { url?: string; urls?: string[] }, maxC
|
|
|
31
31
|
export function urlsMatch(a: string | undefined, b: string | undefined): boolean {
|
|
32
32
|
if (!a || !b) return false;
|
|
33
33
|
try {
|
|
34
|
-
|
|
34
|
+
const ca = canonicalWebUrl(a);
|
|
35
|
+
const cb = canonicalWebUrl(b);
|
|
36
|
+
if (ca === cb) return true;
|
|
35
37
|
const trimSlash = (s: string) => s.endsWith("/") ? s.slice(0, -1) : s;
|
|
36
|
-
return trimSlash(
|
|
38
|
+
return trimSlash(ca) === trimSlash(cb);
|
|
37
39
|
} catch {
|
|
38
40
|
return a === b;
|
|
39
41
|
}
|