pi-unsloth-webtools 0.3.0 → 0.4.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/README.md +55 -2
- package/agent-dir.ts +19 -0
- package/cache.ts +154 -0
- package/engines.ts +82 -9
- package/html-to-md.ts +84 -52
- package/index.ts +117 -63
- package/package.json +4 -1
- package/pdf.ts +6 -1
- package/settings.ts +114 -0
- package/web-access.ts +53 -27
- package/web-fetch.ts +296 -115
- package/web-search.ts +7 -1
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ Mirrors Unsloth Studio's `web_search` tool:
|
|
|
39
39
|
- Transient engine failures (network errors or null responses) are retried once with a short
|
|
40
40
|
backoff inside the same timeout budget (a retry that cannot fit in the remaining budget is
|
|
41
41
|
skipped); timeouts and cancellations are never retried.
|
|
42
|
+
- Sweeps stop as soon as enough results are gathered: engines still in flight are aborted
|
|
43
|
+
instead of being allowed to run to their timeout.
|
|
42
44
|
|
|
43
45
|
### web_fetch
|
|
44
46
|
|
|
@@ -48,16 +50,22 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
|
|
|
48
50
|
- URL validation: http/https only, no credentials or encoded hostnames, hostname/port checks (any
|
|
49
51
|
port 1–65535 is permitted; SSRF protection is enforced at the resolved-IP layer, not by port
|
|
50
52
|
allowlists).
|
|
53
|
+
Canonical public IPv4 literals are accepted like IPv6 literals; private literals are still
|
|
54
|
+
blocked at the resolved-IP layer (non-canonical numeric encodings like `0x7f.0.0.1` /
|
|
55
|
+
`013.0.0.1` / `2130706433` are rejected by URL validation before DNS).
|
|
51
56
|
- DNS resolution with SSRF protection: every resolved address is validated against
|
|
52
57
|
private/loopback/link-local/CGNAT/documentation/multicast/reserved ranges, then the validated IP
|
|
53
58
|
is pinned for the connection (custom `lookup` + SNI `servername`), so DNS cannot rebind between
|
|
54
59
|
validation and fetch; resolution shares the caller's abort signal and the overall deadline,
|
|
55
60
|
so a stuck resolver cannot outlive the fetch.
|
|
61
|
+
When a host publishes both IPv4 and IPv6 addresses, IPv4 is preferred (broken IPv6 routes
|
|
62
|
+
cannot stall a fetch), and a connection failure falls back to the next validated address for
|
|
63
|
+
the same host before giving up.
|
|
56
64
|
- GitHub repo root pages are rewritten to the unauthenticated README API
|
|
57
65
|
(`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
|
|
58
66
|
(`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
|
|
59
67
|
returning error-page content.
|
|
60
|
-
- Up to 4
|
|
68
|
+
- Up to 5 total requests (initial fetch + up to 4 HTTP redirects or `<meta http-equiv="refresh">` refreshes), each re-validated and re-resolved against the same rules.
|
|
61
69
|
- 512 KiB download cap (10 MiB for PDFs), overall deadline + per-hop socket timeouts, abort-aware
|
|
62
70
|
(`signal` cancels mid-flight). Fetches cut off by a download cap are marked with a trailing
|
|
63
71
|
truncation notice, so a partial page is not mistaken for a complete one.
|
|
@@ -124,6 +132,50 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
|
|
|
124
132
|
deliberately pins the Studio snapshot — seven engines, bing disabled upstream, no vqd,
|
|
125
133
|
no pagination — so engine behavior matches Studio rather than ddgs head.
|
|
126
134
|
|
|
135
|
+
## When to use alternatives
|
|
136
|
+
|
|
137
|
+
This package is intentionally a faithful, zero-dependency port of Studio's pipeline. Use it
|
|
138
|
+
when you need deterministic, offline-friendly behavior with strong SSRF guarantees and test
|
|
139
|
+
parity with `unsloth/studio`. For other tradeoffs, prefer:
|
|
140
|
+
|
|
141
|
+
| Need | Use |
|
|
142
|
+
|---|---|
|
|
143
|
+
| Browser-like TLS/HTTP fingerprinting to unblock bot-defended pages | `pi-smart-fetch` (`wreq-js` `chrome_145`) |
|
|
144
|
+
| Headless Chrome for JS-rendered SPAs/YouTube/Reddit threads | `georgebashi/pi-web-fetch` (puppeteer + trafilatura) |
|
|
145
|
+
| Hosted search with semantic ranking and no scraping | `Brave Search API` / `Tavily` / `Exa` via `pi-ollama-web-search` |
|
|
146
|
+
| Prompt-focused page distillation to save context | `pi-web-fetch` `prompt` -> sub-agent or Claude Code `WebFetch(url,prompt)` |
|
|
147
|
+
| Batch fetching many URLs concurrently | `pi-smart-fetch` `batch_web_fetch` or call `web_fetch` in parallel |
|
|
148
|
+
|
|
149
|
+
Mixing is supported: `pi install npm:pi-unsloth-webtools npm:pi-smart-fetch` lets the model
|
|
150
|
+
choose the best tool per URL. No need to fork this package to add those features.
|
|
151
|
+
|
|
152
|
+
## Configuration
|
|
153
|
+
|
|
154
|
+
Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project overrides global):
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"unslothWebTools": {
|
|
159
|
+
"maxResults": 5,
|
|
160
|
+
"maxChars": 50000,
|
|
161
|
+
"timeoutMs": 60000
|
|
162
|
+
},
|
|
163
|
+
"webFetch": {
|
|
164
|
+
"maxChars": 50000,
|
|
165
|
+
"timeoutMs": 15000
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
| Key | Default | Description |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `unslothWebTools.maxResults` | `5` | Default `maxResults` for `web_search` (clamped 1-20) |
|
|
173
|
+
| `unslothWebTools.maxChars` / `webFetch.maxChars` / `smartFetchDefaultMaxChars` | tool param | Default `maxChars` for `web_fetch` and `web_search` url mode |
|
|
174
|
+
| `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` | Default `timeoutMs` (>=1000) |
|
|
175
|
+
| `webSearch.maxResults` / `smartWebSearch.resultsPerQuery` | same as above | Legacy aliases for `maxResults` |
|
|
176
|
+
|
|
177
|
+
Tool params always win over file defaults.
|
|
178
|
+
|
|
127
179
|
## Development
|
|
128
180
|
|
|
129
181
|
```sh
|
|
@@ -158,7 +210,8 @@ The suite ports Unsloth Studio's own tests for these tools:
|
|
|
158
210
|
- `test/entities.test.ts`: `decodeHtmlEntities` parity with CPython `html.unescape`,
|
|
159
211
|
legacy refs, longest-prefix rule, Windows-1252 numeric mappings, invalid codepoints
|
|
160
212
|
- `test/smoke.test.ts`: live network checks against real hosts, including a per-engine
|
|
161
|
-
result-health sweep (at least
|
|
213
|
+
result-health sweep (at least two engines must return well-formed results; engines
|
|
214
|
+
that block or reset connections from datacenter IPs count as unhealthy, not failures)
|
|
162
215
|
|
|
163
216
|
The seams (`seams.resolve` / `seams.request` / `rawFetch`) replace the network stack
|
|
164
217
|
with fakes, mirroring how the Studio suite monkeypatches `_validate_and_resolve_host`
|
package/agent-dir.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
function expandAgentDir(value: string): string {
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
if (trimmed === "~") return homedir();
|
|
7
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return join(homedir(), trimmed.slice(2));
|
|
8
|
+
return trimmed;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function agentDir(): string {
|
|
12
|
+
const env = (process.env.PI_CODING_AGENT_DIR ?? process.env.PI_AGENT_DIR)?.trim();
|
|
13
|
+
if (env) return expandAgentDir(env);
|
|
14
|
+
try {
|
|
15
|
+
const home = homedir();
|
|
16
|
+
if (home) return join(home, ".pi", "agent");
|
|
17
|
+
} catch {}
|
|
18
|
+
return "";
|
|
19
|
+
}
|
package/cache.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile, chmod } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { agentDir } from "./agent-dir.ts";
|
|
6
|
+
import { canonicalizeHref } from "./engines.ts";
|
|
7
|
+
|
|
8
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
9
|
+
const MAX_ENTRIES = 128;
|
|
10
|
+
const MAX_BYTES = 128 * 1024 * 1024;
|
|
11
|
+
const STALE_MAX_AGE_MS = 24 * CACHE_TTL_MS;
|
|
12
|
+
|
|
13
|
+
function cacheDir(): string {
|
|
14
|
+
const env = process.env.PI_UNSLOTH_CACHE_DIR?.trim();
|
|
15
|
+
if (env) return env;
|
|
16
|
+
const base = agentDir();
|
|
17
|
+
if (base) return join(base, "pi-unsloth-cache");
|
|
18
|
+
return join(tmpdir(), "pi-unsloth-cache");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cacheKey(url: string): string {
|
|
22
|
+
const canon = canonicalizeHref(url) || url;
|
|
23
|
+
return createHash("sha256").update(canon).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cachePath(key: string): string {
|
|
27
|
+
return join(cacheDir(), `${key}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function secureChmod(path: string, mode: number): Promise<void> {
|
|
31
|
+
if (process.platform === "win32") return;
|
|
32
|
+
try {
|
|
33
|
+
await chmod(path, mode);
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function unlinkSafe(path: string): Promise<boolean> {
|
|
38
|
+
try {
|
|
39
|
+
await unlink(path);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function ensureDir(): Promise<void> {
|
|
47
|
+
const dir = cacheDir();
|
|
48
|
+
try {
|
|
49
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
await secureChmod(dir, 0o700);
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CacheEntry {
|
|
55
|
+
url: string;
|
|
56
|
+
body: string;
|
|
57
|
+
contentType: string;
|
|
58
|
+
timestamp: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function getCached(url: string): Promise<CacheEntry | null> {
|
|
62
|
+
const key = cacheKey(url);
|
|
63
|
+
const path = cachePath(key);
|
|
64
|
+
try {
|
|
65
|
+
const raw = await readFile(path, "utf8");
|
|
66
|
+
const entry = JSON.parse(raw) as CacheEntry;
|
|
67
|
+
if (!entry || typeof entry.body !== "string" || typeof entry.contentType !== "string" || typeof entry.timestamp !== "number") return null;
|
|
68
|
+
if (!Number.isFinite(entry.timestamp)) return null;
|
|
69
|
+
return entry;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function setCached(url: string, body: string, contentType: string): Promise<void> {
|
|
76
|
+
await ensureDir();
|
|
77
|
+
const key = cacheKey(url);
|
|
78
|
+
const path = cachePath(key);
|
|
79
|
+
const entry: CacheEntry = { url: canonicalizeHref(url) || url, body, contentType, timestamp: Date.now() };
|
|
80
|
+
const tmpPath = `${path}.tmp.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}`;
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(tmpPath, JSON.stringify(entry), { mode: 0o600 });
|
|
83
|
+
await secureChmod(tmpPath, 0o600);
|
|
84
|
+
await rename(tmpPath, path);
|
|
85
|
+
await secureChmod(path, 0o600);
|
|
86
|
+
} catch {
|
|
87
|
+
await unlinkSafe(tmpPath);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await prune();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function prune(): Promise<void> {
|
|
94
|
+
const dir = cacheDir();
|
|
95
|
+
let files: string[];
|
|
96
|
+
try {
|
|
97
|
+
files = await readdir(dir);
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
102
|
+
if (!jsonFiles.length) return;
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const entries: { file: string; size: number; timestamp: number }[] = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
for (const file of jsonFiles) {
|
|
107
|
+
const full = join(dir, file);
|
|
108
|
+
try {
|
|
109
|
+
const s = await stat(full);
|
|
110
|
+
let timestamp = s.mtimeMs;
|
|
111
|
+
try {
|
|
112
|
+
const raw = await readFile(full, "utf8");
|
|
113
|
+
const entry = JSON.parse(raw) as CacheEntry;
|
|
114
|
+
if (entry && typeof entry.timestamp === "number" && Number.isFinite(entry.timestamp)) timestamp = entry.timestamp;
|
|
115
|
+
} catch {}
|
|
116
|
+
if (now - timestamp >= STALE_MAX_AGE_MS) {
|
|
117
|
+
await unlinkSafe(full);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
entries.push({ file: full, size: s.size, timestamp });
|
|
121
|
+
total += s.size;
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
const expired = entries.filter((e) => now - e.timestamp >= CACHE_TTL_MS).sort((a, b) => a.timestamp - b.timestamp);
|
|
125
|
+
for (const e of expired) {
|
|
126
|
+
if (entries.length <= MAX_ENTRIES && total <= MAX_BYTES) break;
|
|
127
|
+
if (await unlinkSafe(e.file)) {
|
|
128
|
+
total -= e.size;
|
|
129
|
+
const idx = entries.indexOf(e);
|
|
130
|
+
if (idx !== -1) entries.splice(idx, 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (entries.length <= MAX_ENTRIES && total <= MAX_BYTES) return;
|
|
134
|
+
entries.sort((a, b) => a.timestamp - b.timestamp);
|
|
135
|
+
let idx = 0;
|
|
136
|
+
while (idx < entries.length && (entries.length > MAX_ENTRIES || total > MAX_BYTES)) {
|
|
137
|
+
const e = entries[idx];
|
|
138
|
+
if (await unlinkSafe(e.file)) {
|
|
139
|
+
total -= e.size;
|
|
140
|
+
entries.splice(idx, 1);
|
|
141
|
+
} else {
|
|
142
|
+
idx++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isFresh(entry: CacheEntry, now = Date.now()): boolean {
|
|
148
|
+
return now - entry.timestamp < CACHE_TTL_MS;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function staleNotice(entry: CacheEntry): string {
|
|
152
|
+
const date = new Date(entry.timestamp).toISOString().slice(0, 10);
|
|
153
|
+
return `\n\n*STALE cache from ${date} — network fetch failed, serving cached copy*`;
|
|
154
|
+
}
|
package/engines.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { appendFile, chmod, mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
2
4
|
import { collapseWhitespace, decodeHtmlEntities, feedHtml } from "./html-to-md.ts";
|
|
3
5
|
import type { AttrDict } from "./html-to-md.ts";
|
|
4
6
|
import { randomUserAgent } from "./user-agents.ts";
|
|
7
|
+
import { agentDir } from "./agent-dir.ts";
|
|
8
|
+
import { MAX_SIGNAL_TIMEOUT_MS } from "./web-access.ts";
|
|
5
9
|
export class EmptySweepError extends Error {
|
|
6
10
|
constructor() {
|
|
7
11
|
super("No results found");
|
|
@@ -9,8 +13,10 @@ export class EmptySweepError extends Error {
|
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
export class SearchTimeoutError extends Error {
|
|
12
|
-
|
|
16
|
+
providers: string[];
|
|
17
|
+
constructor(providers: string[] = []) {
|
|
13
18
|
super("timed out");
|
|
19
|
+
this.providers = providers;
|
|
14
20
|
}
|
|
15
21
|
}
|
|
16
22
|
|
|
@@ -556,7 +562,8 @@ async function httpFetch(
|
|
|
556
562
|
.join("; ")
|
|
557
563
|
: null;
|
|
558
564
|
if (cookie) headers["Cookie"] = cookie;
|
|
559
|
-
const
|
|
565
|
+
const timeoutMs = Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, options.timeoutMs));
|
|
566
|
+
const signals: AbortSignal[] = [AbortSignal.timeout(timeoutMs)];
|
|
560
567
|
if (options.signal) signals.push(options.signal);
|
|
561
568
|
let response: Response;
|
|
562
569
|
try {
|
|
@@ -840,6 +847,40 @@ export function rankResults(docs: SearchResult[], query: string): SearchResult[]
|
|
|
840
847
|
}
|
|
841
848
|
return [...wiki, ...both, ...titleOnly, ...bodyOnly, ...neither];
|
|
842
849
|
}
|
|
850
|
+
async function recordSweepStats(query: string, maxResults: number, started: number, timedOutProviders: string[], resultCount: number): Promise<void> {
|
|
851
|
+
const flag = process.env.PI_UNSLOTH_WEBTOOLS_STATS?.trim();
|
|
852
|
+
if (!flag) return;
|
|
853
|
+
const lower = flag.toLowerCase();
|
|
854
|
+
if (lower === "0" || lower === "false" || lower === "no" || lower === "off") return;
|
|
855
|
+
const base = agentDir();
|
|
856
|
+
if (!base) return;
|
|
857
|
+
const isDefaultFlag = lower === "1" || lower === "true" || lower === "yes" || lower === "on";
|
|
858
|
+
const statsPath = isDefaultFlag ? join(base, "pi-unsloth-webtools-stats.jsonl") : isAbsolute(flag) ? flag : join(base, flag);
|
|
859
|
+
try {
|
|
860
|
+
const dir = dirname(statsPath);
|
|
861
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
862
|
+
if (process.platform !== "win32") {
|
|
863
|
+
try {
|
|
864
|
+
await chmod(dir, 0o700);
|
|
865
|
+
} catch {}
|
|
866
|
+
}
|
|
867
|
+
const sortedProviders = [...timedOutProviders].sort();
|
|
868
|
+
const entry = JSON.stringify({
|
|
869
|
+
ts: new Date().toISOString(),
|
|
870
|
+
query,
|
|
871
|
+
maxResults,
|
|
872
|
+
durationMs: Date.now() - started,
|
|
873
|
+
timedOutProviders: sortedProviders,
|
|
874
|
+
resultCount,
|
|
875
|
+
});
|
|
876
|
+
await appendFile(statsPath, entry + "\n", { mode: 0o600 });
|
|
877
|
+
if (process.platform !== "win32") {
|
|
878
|
+
try {
|
|
879
|
+
await chmod(statsPath, 0o600);
|
|
880
|
+
} catch {}
|
|
881
|
+
}
|
|
882
|
+
} catch {}
|
|
883
|
+
}
|
|
843
884
|
|
|
844
885
|
function shuffledEngines(): Engine[] {
|
|
845
886
|
const shuffled = [...TEXT_ENGINES];
|
|
@@ -858,12 +899,22 @@ export async function autoTextSearch(
|
|
|
858
899
|
timeoutMs: number,
|
|
859
900
|
signal?: AbortSignal,
|
|
860
901
|
): Promise<SearchResult[]> {
|
|
902
|
+
const started = Date.now();
|
|
861
903
|
const engines = shuffledEngines();
|
|
862
|
-
const deadline =
|
|
904
|
+
const deadline = started + timeoutMs;
|
|
863
905
|
const seenProviders = new Set<string>();
|
|
864
906
|
const aggregator = new ResultsAggregator();
|
|
865
907
|
const ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
|
|
866
|
-
|
|
908
|
+
const controller = new AbortController();
|
|
909
|
+
let onAbort: (() => void) | undefined;
|
|
910
|
+
if (signal) {
|
|
911
|
+
if (signal.aborted) controller.abort();
|
|
912
|
+
else {
|
|
913
|
+
onAbort = () => controller.abort();
|
|
914
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const timedOutProviders = new Set<string>();
|
|
867
918
|
let cancelled = false;
|
|
868
919
|
const uniqueProviders = new Set(engines.map((e) => e.provider)).size;
|
|
869
920
|
const maxWorkers = Math.min(uniqueProviders, Math.ceil(maxResults / 10) + 1);
|
|
@@ -872,19 +923,27 @@ export async function autoTextSearch(
|
|
|
872
923
|
const run = async (engine: Engine) => {
|
|
873
924
|
let results: SearchResult[] | null = null;
|
|
874
925
|
for (let attempt = 0; attempt < 2 && results === null; attempt++) {
|
|
926
|
+
if (controller.signal.aborted) {
|
|
927
|
+
if (signal?.aborted) cancelled = true;
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
875
930
|
const budgetLeft = deadline - Date.now();
|
|
876
931
|
if (budgetLeft <= 0) return;
|
|
877
932
|
if (attempt > 0 && budgetLeft < ENGINE_RETRY_BACKOFF_MS) return;
|
|
878
933
|
const remaining = Math.max(1, budgetLeft);
|
|
934
|
+
const engineSignal = signal
|
|
935
|
+
? AbortSignal.any([signal, controller.signal])
|
|
936
|
+
: controller.signal;
|
|
879
937
|
try {
|
|
880
|
-
results = await engine.search(query, ctx, remaining,
|
|
938
|
+
results = await engine.search(query, ctx, remaining, engineSignal);
|
|
881
939
|
} catch (e) {
|
|
882
940
|
if (e instanceof SearchCancelled) {
|
|
941
|
+
if (controller.signal.aborted && !signal?.aborted) return;
|
|
883
942
|
cancelled = true;
|
|
884
943
|
return;
|
|
885
944
|
}
|
|
886
945
|
if (e instanceof SearchTimeoutError) {
|
|
887
|
-
|
|
946
|
+
timedOutProviders.add(engine.name);
|
|
888
947
|
return;
|
|
889
948
|
}
|
|
890
949
|
}
|
|
@@ -895,15 +954,20 @@ export async function autoTextSearch(
|
|
|
895
954
|
cancelled = true;
|
|
896
955
|
return;
|
|
897
956
|
}
|
|
957
|
+
if (controller.signal.aborted) return;
|
|
898
958
|
}
|
|
899
959
|
}
|
|
900
960
|
if (results && results.length) {
|
|
901
961
|
aggregator.extend(results);
|
|
902
962
|
seenProviders.add(engine.provider);
|
|
963
|
+
if (aggregator.size >= maxResults) controller.abort();
|
|
903
964
|
}
|
|
904
965
|
};
|
|
905
966
|
while (i < engines.length) {
|
|
906
|
-
if (aggregator.size >= maxResults || cancelled)
|
|
967
|
+
if (aggregator.size >= maxResults || cancelled) {
|
|
968
|
+
controller.abort();
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
907
971
|
const engine = engines[i++];
|
|
908
972
|
if (seenProviders.has(engine.provider)) continue;
|
|
909
973
|
pending.push(run(engine));
|
|
@@ -913,9 +977,18 @@ export async function autoTextSearch(
|
|
|
913
977
|
}
|
|
914
978
|
}
|
|
915
979
|
await Promise.allSettled(pending);
|
|
980
|
+
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
|
916
981
|
if (cancelled) throw new SearchCancelled();
|
|
917
982
|
const results = rankResults(aggregator.extractDicts(), query);
|
|
918
|
-
if (results.length)
|
|
919
|
-
|
|
983
|
+
if (results.length) {
|
|
984
|
+
void recordSweepStats(query, maxResults, started, [...timedOutProviders], results.length);
|
|
985
|
+
return results.slice(0, maxResults);
|
|
986
|
+
}
|
|
987
|
+
if (timedOutProviders.size) {
|
|
988
|
+
const sorted = [...timedOutProviders].sort();
|
|
989
|
+
void recordSweepStats(query, maxResults, started, sorted, 0);
|
|
990
|
+
throw new SearchTimeoutError(sorted);
|
|
991
|
+
}
|
|
992
|
+
void recordSweepStats(query, maxResults, started, [], 0);
|
|
920
993
|
throw new EmptySweepError();
|
|
921
994
|
}
|
package/html-to-md.ts
CHANGED
|
@@ -228,8 +228,11 @@ interface HtmlHandlers {
|
|
|
228
228
|
handleCharRef(name: string): void;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
const START_TAG_NAME_RE =
|
|
232
|
-
const ATTR_NAME_RE =
|
|
231
|
+
const START_TAG_NAME_RE = /[a-zA-Z][^\s/>]*/y;
|
|
232
|
+
const ATTR_NAME_RE = /[^\s=/>]+/y;
|
|
233
|
+
const ENTITY_NAMED_RE = /&([A-Za-z][A-Za-z0-9.-]*);/y;
|
|
234
|
+
const ENTITY_NUMERIC_RE = /&#([xX][0-9a-fA-F]+|[0-9]+);?/y;
|
|
235
|
+
const ENTITY_LEGACY_RE = /&([A-Za-z][A-Za-z0-9.-]*)(?=[^A-Za-z0-9]|$)/y;
|
|
233
236
|
|
|
234
237
|
const RAW_TEXT_TAGS = [
|
|
235
238
|
"script",
|
|
@@ -242,9 +245,23 @@ const RAW_TEXT_TAGS = [
|
|
|
242
245
|
"noframes",
|
|
243
246
|
];
|
|
244
247
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
+
function findRawTextClose(
|
|
249
|
+
input: string,
|
|
250
|
+
lowerInput: string,
|
|
251
|
+
from: number,
|
|
252
|
+
name: string,
|
|
253
|
+
): { start: number; end: number } | null {
|
|
254
|
+
const needle = `</${name}`;
|
|
255
|
+
let pos = from;
|
|
256
|
+
while (true) {
|
|
257
|
+
const hit = lowerInput.indexOf(needle, pos);
|
|
258
|
+
if (hit === -1) return null;
|
|
259
|
+
let j = hit + needle.length;
|
|
260
|
+
while (j < input.length && /\s/.test(input[j])) j++;
|
|
261
|
+
if (j < input.length && input[j] === ">") return { start: hit, end: j + 1 };
|
|
262
|
+
pos = hit + 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
248
265
|
|
|
249
266
|
function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, boolean] {
|
|
250
267
|
const attrs: AttrDict = {};
|
|
@@ -257,10 +274,11 @@ function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, bo
|
|
|
257
274
|
pos++;
|
|
258
275
|
continue;
|
|
259
276
|
}
|
|
260
|
-
|
|
277
|
+
ATTR_NAME_RE.lastIndex = pos;
|
|
278
|
+
const nameMatch = ATTR_NAME_RE.exec(input);
|
|
261
279
|
if (!nameMatch) return [attrs, -1, false];
|
|
262
280
|
const name = nameMatch[0].toLowerCase();
|
|
263
|
-
pos
|
|
281
|
+
pos = nameMatch.index + nameMatch[0].length;
|
|
264
282
|
while (pos < input.length && /\s/.test(input[pos])) pos++;
|
|
265
283
|
let value: string | null = null;
|
|
266
284
|
if (pos < input.length && input[pos] === "=") {
|
|
@@ -285,64 +303,69 @@ function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, bo
|
|
|
285
303
|
}
|
|
286
304
|
|
|
287
305
|
function scanTag(
|
|
288
|
-
|
|
306
|
+
input: string,
|
|
289
307
|
i: number,
|
|
290
308
|
): { end: number; kind: "comment" | "decl" | "end" | "start" | "startend"; name?: string; attrs?: AttrDict } | null {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const close = html.indexOf("-->", i + 4);
|
|
309
|
+
if (input.startsWith("!--", i + 1)) {
|
|
310
|
+
const close = input.indexOf("-->", i + 4);
|
|
294
311
|
if (close === -1) return null;
|
|
295
312
|
return { end: close + 3, kind: "decl" };
|
|
296
313
|
}
|
|
297
|
-
|
|
314
|
+
const next = input[i + 1];
|
|
315
|
+
if (next === "!" || next === "?") {
|
|
298
316
|
let j = i + 2;
|
|
299
|
-
while (j <
|
|
300
|
-
if (j >=
|
|
317
|
+
while (j < input.length && input[j] !== ">") j++;
|
|
318
|
+
if (j >= input.length) return null;
|
|
301
319
|
return { end: j + 1, kind: "decl" };
|
|
302
320
|
}
|
|
303
|
-
if (
|
|
321
|
+
if (next === "/") {
|
|
304
322
|
let j = i + 2;
|
|
305
|
-
while (j <
|
|
306
|
-
const name =
|
|
323
|
+
while (j < input.length && !/[\s>]/.test(input[j])) j++;
|
|
324
|
+
const name = input.slice(i + 2, j).toLowerCase();
|
|
307
325
|
if (!name) return null;
|
|
308
|
-
while (j <
|
|
309
|
-
if (j >=
|
|
326
|
+
while (j < input.length && input[j] !== ">") j++;
|
|
327
|
+
if (j >= input.length) return null;
|
|
310
328
|
return { end: j + 1, kind: "end", name };
|
|
311
329
|
}
|
|
312
|
-
|
|
330
|
+
START_TAG_NAME_RE.lastIndex = i + 1;
|
|
331
|
+
const nameMatch = START_TAG_NAME_RE.exec(input);
|
|
313
332
|
if (!nameMatch) return null;
|
|
314
333
|
const name = nameMatch[0].toLowerCase();
|
|
315
|
-
const [attrs,
|
|
316
|
-
if (
|
|
317
|
-
return { end:
|
|
334
|
+
const [attrs, nextPos, selfClosing] = parseAttrsUntilClose(input, i + 1 + nameMatch[0].length);
|
|
335
|
+
if (nextPos === -1) return null;
|
|
336
|
+
return { end: nextPos, kind: selfClosing ? "startend" : "start", name, attrs };
|
|
318
337
|
}
|
|
319
338
|
|
|
320
339
|
|
|
321
340
|
export function feedHtml(input: string, handlers: HtmlHandlers): void {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
341
|
+
let lowerInput: string | null = null;
|
|
342
|
+
const emitTextRange = (start: number, end: number) => {
|
|
343
|
+
if (end <= start) return;
|
|
344
|
+
let pos = start;
|
|
345
|
+
while (pos < end) {
|
|
346
|
+
const amp = input.indexOf("&", pos);
|
|
347
|
+
if (amp === -1 || amp >= end) {
|
|
348
|
+
handlers.handleData(input.slice(pos, end));
|
|
329
349
|
return;
|
|
330
350
|
}
|
|
331
|
-
if (amp > pos) handlers.handleData(
|
|
332
|
-
|
|
333
|
-
|
|
351
|
+
if (amp > pos) handlers.handleData(input.slice(pos, amp));
|
|
352
|
+
ENTITY_NAMED_RE.lastIndex = amp;
|
|
353
|
+
const named = ENTITY_NAMED_RE.exec(input);
|
|
354
|
+
if (named && named.index === amp && amp + named[0].length <= end) {
|
|
334
355
|
handlers.handleEntityRef(named[1]);
|
|
335
356
|
pos = amp + named[0].length;
|
|
336
357
|
continue;
|
|
337
358
|
}
|
|
338
|
-
|
|
339
|
-
|
|
359
|
+
ENTITY_NUMERIC_RE.lastIndex = amp;
|
|
360
|
+
const numeric = ENTITY_NUMERIC_RE.exec(input);
|
|
361
|
+
if (numeric && numeric.index === amp && amp + numeric[0].length <= end) {
|
|
340
362
|
handlers.handleCharRef(numeric[1]);
|
|
341
363
|
pos = amp + numeric[0].length;
|
|
342
364
|
continue;
|
|
343
365
|
}
|
|
344
|
-
|
|
345
|
-
|
|
366
|
+
ENTITY_LEGACY_RE.lastIndex = amp;
|
|
367
|
+
const legacy = ENTITY_LEGACY_RE.exec(input);
|
|
368
|
+
if (legacy && legacy.index === amp && amp + legacy[0].length <= end) {
|
|
346
369
|
handlers.handleEntityRef(legacy[1]);
|
|
347
370
|
pos = amp + legacy[0].length;
|
|
348
371
|
continue;
|
|
@@ -364,28 +387,28 @@ export function feedHtml(input: string, handlers: HtmlHandlers): void {
|
|
|
364
387
|
i++;
|
|
365
388
|
continue;
|
|
366
389
|
}
|
|
367
|
-
|
|
390
|
+
emitTextRange(textStart, i);
|
|
368
391
|
if (tag.kind === "start") {
|
|
369
|
-
const
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
if (
|
|
374
|
-
handlers.handleStartTag(
|
|
375
|
-
|
|
376
|
-
handlers.handleEndTag(
|
|
377
|
-
textStart =
|
|
392
|
+
const rawName = tag.name!;
|
|
393
|
+
if (RAW_TEXT_TAGS.includes(rawName)) {
|
|
394
|
+
lowerInput ??= input.toLowerCase();
|
|
395
|
+
const close = findRawTextClose(input, lowerInput, tag.end, rawName);
|
|
396
|
+
if (close) {
|
|
397
|
+
handlers.handleStartTag(rawName, tag.attrs!);
|
|
398
|
+
emitTextRange(tag.end, close.start);
|
|
399
|
+
handlers.handleEndTag(rawName);
|
|
400
|
+
textStart = close.end;
|
|
378
401
|
i = textStart;
|
|
379
402
|
continue;
|
|
380
403
|
}
|
|
381
404
|
}
|
|
382
|
-
handlers.handleStartTag(
|
|
405
|
+
handlers.handleStartTag(rawName, tag.attrs!);
|
|
383
406
|
} else if (tag.kind === "startend") handlers.handleStartEndTag(tag.name!, tag.attrs!);
|
|
384
407
|
else if (tag.kind === "end") handlers.handleEndTag(tag.name!);
|
|
385
408
|
textStart = tag.end;
|
|
386
409
|
i = tag.end;
|
|
387
410
|
}
|
|
388
|
-
|
|
411
|
+
emitTextRange(textStart, input.length);
|
|
389
412
|
}
|
|
390
413
|
|
|
391
414
|
function popMarksAbove(marks: number[], index: number): void {
|
|
@@ -1063,8 +1086,13 @@ function visibleLineChars(line: string): number {
|
|
|
1063
1086
|
let openBracket = false;
|
|
1064
1087
|
while (i < n) {
|
|
1065
1088
|
if (line[i] === "\\") {
|
|
1066
|
-
|
|
1067
|
-
|
|
1089
|
+
if (i + 1 < n) {
|
|
1090
|
+
total += 2;
|
|
1091
|
+
i += 2;
|
|
1092
|
+
} else {
|
|
1093
|
+
total += 1;
|
|
1094
|
+
i += 1;
|
|
1095
|
+
}
|
|
1068
1096
|
continue;
|
|
1069
1097
|
}
|
|
1070
1098
|
if (line[i] === "[") openBracket = true;
|
|
@@ -1074,7 +1102,11 @@ function visibleLineChars(line: string): number {
|
|
|
1074
1102
|
while (j < n && depth) {
|
|
1075
1103
|
const char = line[j];
|
|
1076
1104
|
if (char === "\\") {
|
|
1077
|
-
j
|
|
1105
|
+
if (j + 1 < n) {
|
|
1106
|
+
j += 2;
|
|
1107
|
+
} else {
|
|
1108
|
+
j += 1;
|
|
1109
|
+
}
|
|
1078
1110
|
continue;
|
|
1079
1111
|
}
|
|
1080
1112
|
depth += (char === "(" ? 1 : 0) - (char === ")" ? 1 : 0);
|