pi-wsearch 0.6.0 → 0.7.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 +8 -11
- package/package.json +1 -1
- package/src/cache.ts +66 -34
- package/src/config.ts +14 -1
- package/src/dom.ts +13 -12
- package/src/engines.ts +298 -308
- package/src/fetch.ts +1 -5
- package/src/fuse.ts +27 -15
- package/src/keys.ts +2 -22
- package/src/login.ts +1 -1
- package/src/scrape.ts +58 -481
- package/src/text.ts +418 -0
- package/src/tools.ts +55 -66
- package/src/transport.ts +144 -113
- package/src/view.ts +2 -7
- package/src/constants.ts +0 -6
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ ranked that page and where each placed it. A hit returned by one engine alone
|
|
|
13
13
|
shows just that engine and rank: `⇐ exa#3`. Agreement is the spam defense;
|
|
14
14
|
generative-SEO can stuff one index, not three.
|
|
15
15
|
|
|
16
|
-
**Pick your engines.**
|
|
16
|
+
**Pick your engines.** Ten are supported; three or four is plenty. Use
|
|
17
17
|
whatever keys you have, even one:
|
|
18
18
|
|
|
19
19
|
- **Rare strings match literally.** `pgvector`, CVE numbers, error codes match
|
|
@@ -26,7 +26,7 @@ whatever keys you have, even one:
|
|
|
26
26
|
answers.
|
|
27
27
|
|
|
28
28
|
**Supported providers:** Serper, Tavily, Exa, Brave, Jina, Kagi, You.com,
|
|
29
|
-
Firecrawl, TinyFish
|
|
29
|
+
Firecrawl, TinyFish, Parallel, Keenable
|
|
30
30
|
|
|
31
31
|
### WebFetch
|
|
32
32
|
|
|
@@ -46,11 +46,8 @@ local budget cuts.
|
|
|
46
46
|
**Reads are free.** Page parsing is built in, bills nothing per read, and
|
|
47
47
|
needs no key. Real headings become the map, TOC echoes get dropped. Paid
|
|
48
48
|
readers only step in for JavaScript-heavy pages or when the local read fails.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
durations, carousel markers and relative-time chips are stripped from news
|
|
52
|
-
pages (whole-line rules only; prose is untouched), and a reader that returns
|
|
53
|
-
a 404-page template is skipped so the chain moves on to the next backend.
|
|
49
|
+
A reader that returns a 404-page template is skipped so the chain moves on to
|
|
50
|
+
the next backend.
|
|
54
51
|
|
|
55
52
|
**Tracking links cache as one page.** `?utm_source=…` variants of the same URL
|
|
56
53
|
share one 24 hour cache entry. Faster repeats, smaller disk.
|
|
@@ -63,7 +60,7 @@ mapped IPv6 forms included) are rejected before any request is made.
|
|
|
63
60
|
as untrusted input: a claim only becomes a verdict after a second source
|
|
64
61
|
corroborates it.
|
|
65
62
|
|
|
66
|
-
**Optional readers:** Firecrawl, Tavily, Exa, Jina, fallback only
|
|
63
|
+
**Optional readers:** Firecrawl, Tavily, Exa, Jina, Parallel, Keenable, fallback only
|
|
67
64
|
|
|
68
65
|
## Install
|
|
69
66
|
|
|
@@ -100,8 +97,8 @@ Search needs at least one provider key; fetch needs none. Two ways to set one:
|
|
|
100
97
|
Either place accepts a `!command` value, so the key can come from Keychain or
|
|
101
98
|
1Password at use time and never sit on disk. Session env wins over the file,
|
|
102
99
|
the file over shell config. `/websearch` shows which providers are armed and
|
|
103
|
-
which are dormant. No keys yet? Tavily, Exa, Firecrawl,
|
|
104
|
-
free tiers.
|
|
100
|
+
which are dormant. No keys yet? Tavily, Exa, Firecrawl, TinyFish, Parallel, and
|
|
101
|
+
Keenable have free tiers; Keenable's starts at 100K requests/mo.
|
|
105
102
|
|
|
106
103
|
## Commands
|
|
107
104
|
|
|
@@ -138,7 +135,7 @@ One command, five subcommands:
|
|
|
138
135
|
/websearch
|
|
139
136
|
```
|
|
140
137
|
|
|
141
|
-
|
|
138
|
+
Notifies you on your current configured providers.
|
|
142
139
|
4. Ask the agent something current:
|
|
143
140
|
|
|
144
141
|
> What's the current Node.js LTS version?
|
package/package.json
CHANGED
package/src/cache.ts
CHANGED
|
@@ -1,79 +1,111 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { rename, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_CAP = 400;
|
|
7
|
+
const STALE_RESERVE_MS = 24 * 3600_000;
|
|
6
8
|
|
|
7
9
|
interface Entry {
|
|
8
10
|
exp: number;
|
|
9
11
|
value: unknown;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
order: string[];
|
|
15
|
-
}
|
|
16
|
-
|
|
14
|
+
/** Map iteration order = insertion order, so delete+re-set IS the LRU move;
|
|
15
|
+
* no parallel order array to splice. */
|
|
17
16
|
export class TtlCache {
|
|
18
|
-
private
|
|
17
|
+
private entries = new Map<string, Entry>();
|
|
18
|
+
private loaded = false;
|
|
19
|
+
private write = Promise.resolve();
|
|
19
20
|
constructor(
|
|
20
21
|
private file: string,
|
|
21
22
|
private cap = DEFAULT_CAP,
|
|
22
|
-
) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
private ensureLoaded() {
|
|
26
|
+
if (this.loaded) return;
|
|
27
|
+
this.loaded = true;
|
|
26
28
|
try {
|
|
27
29
|
if (!existsSync(this.file)) return;
|
|
28
|
-
const raw = JSON.parse(readFileSync(this.file, 'utf8'))
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as {
|
|
31
|
+
entries?: Record<string, Entry>;
|
|
32
|
+
order?: string[];
|
|
33
|
+
} | null;
|
|
34
|
+
if (!raw || typeof raw !== 'object' || typeof raw.entries !== 'object') return;
|
|
35
|
+
const total = Object.keys(raw.entries).length;
|
|
36
|
+
const cutoff = Date.now() - STALE_RESERVE_MS;
|
|
37
|
+
// Legacy files carried an explicit LRU `order` array: seed the Map in
|
|
38
|
+
// that order so eviction under the cap starts at the true oldest.
|
|
39
|
+
const order: string[] = Array.isArray(raw.order)
|
|
40
|
+
? [...raw.order.filter((k): k is string => typeof k === 'string'), ...Object.keys(raw.entries)]
|
|
41
|
+
: Object.keys(raw.entries);
|
|
42
|
+
for (const k of order) {
|
|
43
|
+
const e = raw.entries[k];
|
|
44
|
+
if (typeof e?.exp !== 'number' || !Number.isFinite(e.exp)) continue; // corrupt: dead
|
|
45
|
+
if (e.exp <= cutoff) continue; // beyond the stale reserve: dead
|
|
46
|
+
this.entries.set(k, e);
|
|
32
47
|
}
|
|
48
|
+
// Rewrite only when a stale/corrupt entry was actually dropped; a
|
|
49
|
+
// healthy file must never be rewritten on load.
|
|
50
|
+
if (this.entries.size !== total) this.persist();
|
|
33
51
|
} catch {
|
|
34
|
-
this.
|
|
52
|
+
this.entries.clear();
|
|
35
53
|
}
|
|
36
54
|
}
|
|
55
|
+
|
|
37
56
|
private persist() {
|
|
38
57
|
try {
|
|
39
58
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
40
|
-
const tmp = this.file
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
const tmp = `${this.file}.tmp`;
|
|
60
|
+
this.write = this.write
|
|
61
|
+
.then(() => JSON.stringify({ entries: Object.fromEntries(this.entries) }))
|
|
62
|
+
.then((data) => writeFile(tmp, data, 'utf8'))
|
|
63
|
+
.then(() => rename(tmp, this.file))
|
|
64
|
+
.catch(() => {});
|
|
43
65
|
} catch {
|
|
44
66
|
/* cache writes are best-effort */
|
|
45
67
|
}
|
|
46
68
|
}
|
|
47
|
-
|
|
48
|
-
|
|
69
|
+
|
|
70
|
+
flush(): Promise<void> {
|
|
71
|
+
return this.write;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Entry valid while unexpired; corrupt exp (poisoned store) counts as
|
|
75
|
+
* expired, never "never expires". */
|
|
49
76
|
get(key: string): unknown | undefined {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.store.order.push(key);
|
|
77
|
+
this.ensureLoaded();
|
|
78
|
+
const e = this.entries.get(key);
|
|
79
|
+
if (!e || e.exp < Date.now()) return undefined;
|
|
80
|
+
this.touch(key, e);
|
|
55
81
|
return e.value;
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
/** Expired copy WITHOUT deleting it (stale-if-error reserve). */
|
|
59
85
|
peekStale(key: string): { value: unknown; ageHours: number } | undefined {
|
|
60
|
-
|
|
86
|
+
this.ensureLoaded();
|
|
87
|
+
const e = this.entries.get(key);
|
|
61
88
|
if (!e || typeof e.exp !== 'number' || !Number.isFinite(e.exp)) return undefined;
|
|
62
89
|
const ageHours = (Date.now() - e.exp) / 3600_000;
|
|
63
90
|
if (ageHours <= 0) return undefined;
|
|
64
91
|
return { value: e.value, ageHours };
|
|
65
92
|
}
|
|
93
|
+
|
|
66
94
|
set(key: string, value: unknown, ttlMs: number) {
|
|
67
|
-
this.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
delete this.store.entries[old];
|
|
95
|
+
this.ensureLoaded();
|
|
96
|
+
this.touch(key, { exp: Date.now() + ttlMs, value });
|
|
97
|
+
while (this.entries.size > this.cap) {
|
|
98
|
+
const oldest = this.entries.keys().next().value;
|
|
99
|
+
if (oldest === undefined) break;
|
|
100
|
+
this.entries.delete(oldest);
|
|
74
101
|
}
|
|
75
102
|
this.persist();
|
|
76
103
|
}
|
|
104
|
+
|
|
105
|
+
private touch(key: string, e: Entry) {
|
|
106
|
+
this.entries.delete(key);
|
|
107
|
+
this.entries.set(key, e);
|
|
108
|
+
}
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
export function cacheKey(parts: string[]): string {
|
package/src/config.ts
CHANGED
|
@@ -9,6 +9,11 @@ import { spawnSync } from 'node:child_process';
|
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
|
|
12
|
+
export const DEFAULT_MAX_RESULTS = 8;
|
|
13
|
+
|
|
14
|
+
/** The abort message: a UX contract (pi renders isError red), one home. */
|
|
15
|
+
export const ABORT_ERROR = 'aborted (user interrupt)';
|
|
16
|
+
|
|
12
17
|
export interface WebConfig {
|
|
13
18
|
serperKey?: string;
|
|
14
19
|
tavilyKey?: string;
|
|
@@ -19,6 +24,8 @@ export interface WebConfig {
|
|
|
19
24
|
kagiKey?: string;
|
|
20
25
|
youKey?: string;
|
|
21
26
|
tinyfishKey?: string;
|
|
27
|
+
parallelKey?: string;
|
|
28
|
+
keenableKey?: string;
|
|
22
29
|
cacheDir: string;
|
|
23
30
|
allowPrivate?: boolean;
|
|
24
31
|
searchEngines?: string;
|
|
@@ -34,7 +41,9 @@ export type ProviderKey =
|
|
|
34
41
|
| 'jinaKey'
|
|
35
42
|
| 'kagiKey'
|
|
36
43
|
| 'youKey'
|
|
37
|
-
| 'tinyfishKey'
|
|
44
|
+
| 'tinyfishKey'
|
|
45
|
+
| 'parallelKey'
|
|
46
|
+
| 'keenableKey';
|
|
38
47
|
|
|
39
48
|
interface KeySpec {
|
|
40
49
|
id: string;
|
|
@@ -52,6 +61,8 @@ export const SPECS: KeySpec[] = [
|
|
|
52
61
|
{ id: 'kagi', key: 'kagiKey', envNames: ['KAGI_API_KEY', 'KAGI_KEY'] },
|
|
53
62
|
{ id: 'you', key: 'youKey', envNames: ['YOUCOM_API_KEY', 'YDC_API_KEY'] },
|
|
54
63
|
{ id: 'tinyfish', key: 'tinyfishKey', envNames: ['TINYFISH_API_KEY', 'TINYFISH_KEY'] },
|
|
64
|
+
{ id: 'parallel', key: 'parallelKey', envNames: ['PARALLEL_API_KEY', 'PARALLEL_KEY'] },
|
|
65
|
+
{ id: 'keenable', key: 'keenableKey', envNames: ['KEENABLE_API_KEY', 'KEENABLE_KEY'] },
|
|
55
66
|
];
|
|
56
67
|
|
|
57
68
|
export interface ProviderInfo {
|
|
@@ -71,6 +82,8 @@ const PROVIDER_LABELS: Record<string, string> = {
|
|
|
71
82
|
kagi: 'Kagi',
|
|
72
83
|
you: 'You.com',
|
|
73
84
|
tinyfish: 'TinyFish',
|
|
85
|
+
parallel: 'Parallel',
|
|
86
|
+
keenable: 'Keenable',
|
|
74
87
|
};
|
|
75
88
|
|
|
76
89
|
export const PROVIDERS: ProviderInfo[] = SPECS.map((s) => ({
|
package/src/dom.ts
CHANGED
|
@@ -215,15 +215,15 @@ function renderBlock(n: DomNode, out: string[]): void {
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
const DATE_KEYWORDS = ['datePublished', 'dateCreated', 'uploadDate', 'publishedAt'];
|
|
219
|
-
const DATE_META_NAMES = [
|
|
218
|
+
const DATE_KEYWORDS = new Set(['datePublished', 'dateCreated', 'uploadDate', 'publishedAt']);
|
|
219
|
+
const DATE_META_NAMES = new Set([
|
|
220
220
|
'article:published_time',
|
|
221
221
|
'datePublished',
|
|
222
222
|
'publish-date',
|
|
223
223
|
'pubdate',
|
|
224
224
|
'parsely-pub-date',
|
|
225
225
|
'date',
|
|
226
|
-
];
|
|
226
|
+
]);
|
|
227
227
|
|
|
228
228
|
function toIso(v: string | undefined): string | undefined {
|
|
229
229
|
if (!v) return undefined;
|
|
@@ -232,14 +232,15 @@ function toIso(v: string | undefined): string | undefined {
|
|
|
232
232
|
return new Date(ms).toISOString().slice(0, 10);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
const pickIsoDate = (value: unknown): string | undefined => {
|
|
236
|
+
if (typeof value === 'string') {
|
|
237
|
+
const iso = toIso(value);
|
|
238
|
+
if (iso) return iso;
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
};
|
|
242
|
+
|
|
235
243
|
function jsonLdDate(nodes: DomNode[]): string | undefined {
|
|
236
|
-
const pick = (value: unknown): string | undefined => {
|
|
237
|
-
if (typeof value === 'string') {
|
|
238
|
-
const iso = toIso(value);
|
|
239
|
-
if (iso) return iso;
|
|
240
|
-
}
|
|
241
|
-
return undefined;
|
|
242
|
-
};
|
|
243
244
|
const walk = (value: unknown): string | undefined => {
|
|
244
245
|
if (Array.isArray(value)) {
|
|
245
246
|
for (const v of value) {
|
|
@@ -250,7 +251,7 @@ function jsonLdDate(nodes: DomNode[]): string | undefined {
|
|
|
250
251
|
}
|
|
251
252
|
if (typeof value === 'object' && value !== null) {
|
|
252
253
|
for (const key of DATE_KEYWORDS) {
|
|
253
|
-
const hit =
|
|
254
|
+
const hit = pickIsoDate((value as Record<string, unknown>)[key]);
|
|
254
255
|
if (hit) return hit;
|
|
255
256
|
}
|
|
256
257
|
for (const v of Object.values(value as Record<string, unknown>)) {
|
|
@@ -317,7 +318,7 @@ export function headScan(html: string): HeadScan {
|
|
|
317
318
|
if (name === 'meta') {
|
|
318
319
|
const key = (attribs['property'] ?? attribs['name'] ?? '').toLowerCase().trim();
|
|
319
320
|
if (!ogTitle && key === 'og:title') ogTitle = (attribs['content'] ?? '').trim().slice(0, 200);
|
|
320
|
-
if (!metaDate && DATE_META_NAMES.
|
|
321
|
+
if (!metaDate && DATE_META_NAMES.has(key)) metaDate = toIso(attribs['content']);
|
|
321
322
|
}
|
|
322
323
|
if (name === 'time' && !timeDate && attribs['datetime']) timeDate = toIso(attribs['datetime']);
|
|
323
324
|
for (const c of n.children ?? []) visit(c);
|