pi-webfind 0.5.2 → 0.6.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 +14 -2
- package/extensions/web-search.ts +162 -73
- package/lib/adapters.ts +295 -117
- package/lib/apis.ts +2 -0
- package/lib/cache.ts +42 -16
- package/lib/engine.ts +489 -91
- package/lib/extract.ts +377 -61
- package/lib/fetcher.ts +443 -217
- package/lib/net.ts +93 -0
- package/lib/rank.ts +89 -16
- package/lib/safe.ts +94 -0
- package/lib/version.ts +1 -1
- package/package.json +20 -16
- package/themes/claude-dark.json +80 -0
package/lib/adapters.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* All free, no keys (GitHub optionally honours GITHUB_TOKEN for rate limits).
|
|
7
7
|
*/
|
|
8
8
|
import { getJson } from "./apis.ts";
|
|
9
|
+
import { decodeEntities } from "./engine.ts";
|
|
9
10
|
|
|
10
11
|
import { TOOL_UA as UA } from "./version.ts";
|
|
11
12
|
|
|
@@ -24,121 +25,273 @@ async function getText(url: string, signal?: AbortSignal, headers?: Record<strin
|
|
|
24
25
|
export interface AdapterResult {
|
|
25
26
|
text: string;
|
|
26
27
|
source: string; // e.g. "github-api"
|
|
28
|
+
/** publication date (YYYY-MM-DD) when the API provides one */
|
|
29
|
+
date?: string;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
type Adapter = (url: URL, signal?: AbortSignal) => Promise<AdapterResult | null>;
|
|
30
33
|
|
|
31
|
-
//
|
|
34
|
+
// -------------------------------------------------------------- pure router
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Pure, I/O-free routing decision: `name` keys the RUN table below, `upstream`
|
|
38
|
+
* names the exact URL(s) the runner will fetch (used by fetch_page's pre-flight
|
|
39
|
+
* status line and by tests — matchAdapter never touches the network).
|
|
40
|
+
*/
|
|
41
|
+
export interface AdapterMatch {
|
|
42
|
+
name: string;
|
|
43
|
+
upstream: string[];
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
const GH_RESERVED = new Set([
|
|
47
|
+
"features", "topics", "marketplace", "orgs", "sponsors", "login", "settings",
|
|
48
|
+
"explore", "trending", "collections", "events", "about", "pricing", "apps",
|
|
49
|
+
"enterprise", "customer-stories", "security", "readme", "site", "team",
|
|
50
|
+
]);
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
function hasTraversal(segs: string[]): boolean {
|
|
53
|
+
return segs.some((s) => {
|
|
54
|
+
try {
|
|
55
|
+
return decodeURIComponent(s) === "..";
|
|
56
|
+
} catch {
|
|
57
|
+
return true; // malformed encoding — treat as blocked
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
50
61
|
|
|
51
|
-
|
|
62
|
+
function matchGithub(url: URL): AdapterMatch | null {
|
|
63
|
+
if (!/^(www\.)?github\.com$/.test(url.hostname)) return null;
|
|
64
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
65
|
+
const [owner, repo, kind, ...rest] = parts;
|
|
66
|
+
if (!owner || GH_RESERVED.has(owner) || !repo) return null;
|
|
67
|
+
if ((kind === "blob" || kind === "raw") && rest.length >= 2 && !hasTraversal(rest)) {
|
|
68
|
+
const [ref, ...path] = rest;
|
|
69
|
+
return { name: "github-raw", upstream: [`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path.join("/")}`] };
|
|
70
|
+
}
|
|
71
|
+
if (kind === "tree" && rest.length >= 2 && !hasTraversal(rest)) {
|
|
72
|
+
const [ref, ...dir] = rest;
|
|
73
|
+
return { name: "github-tree-api", upstream: [`https://api.github.com/repos/${owner}/${repo}/contents/${dir.join("/")}?ref=${encodeURIComponent(ref!)}`] };
|
|
74
|
+
}
|
|
52
75
|
if (kind === "issues" || kind === "pull") {
|
|
53
76
|
const num = Number(rest[0]);
|
|
54
77
|
if (!num) return null;
|
|
55
78
|
const isPr = kind === "pull";
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.map((c) => `**${c.user?.login ?? "?"}** (${(c.created_at ?? "").slice(0, 10)}):\n\n${c.body ?? ""}`)
|
|
64
|
-
.join("\n\n---\n\n");
|
|
65
|
-
}
|
|
66
|
-
return { text: md.slice(0, 100_000), source: isPr ? "github-pr-api" : "github-issue-api" };
|
|
79
|
+
return {
|
|
80
|
+
name: isPr ? "github-pr-api" : "github-issue-api",
|
|
81
|
+
upstream: [
|
|
82
|
+
`https://api.github.com/repos/${owner}/${repo}/${isPr ? "pulls" : "issues"}/${num}`,
|
|
83
|
+
`https://api.github.com/repos/${owner}/${repo}/issues/${num}/comments?per_page=30`,
|
|
84
|
+
],
|
|
85
|
+
};
|
|
67
86
|
}
|
|
68
|
-
|
|
69
|
-
// repo root: metadata + README
|
|
70
87
|
if (parts.length === 2) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
return {
|
|
89
|
+
name: "github-api",
|
|
90
|
+
upstream: [
|
|
91
|
+
`https://api.github.com/repos/${owner}/${repo}`,
|
|
92
|
+
`https://raw.githubusercontent.com/${owner}/${repo}/HEAD/README.md`,
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const SE_SITES = /^(stackoverflow|superuser|serverfault)\.com$|^([a-z0-9-]+)\.stackexchange\.com$/i;
|
|
100
|
+
|
|
101
|
+
function matchStackExchange(url: URL): AdapterMatch | null {
|
|
102
|
+
const m = url.hostname.match(SE_SITES);
|
|
103
|
+
if (!m) return null;
|
|
104
|
+
const site = (m[1] ?? m[2] ?? "stackoverflow").toLowerCase();
|
|
105
|
+
const p = url.pathname;
|
|
106
|
+
let qm = p.match(/\/(?:questions|q)\/(\d+)/);
|
|
107
|
+
const am = p.match(/\/a\/(\d+)/);
|
|
108
|
+
if (qm) {
|
|
109
|
+
return {
|
|
110
|
+
name: "stackexchange-api",
|
|
111
|
+
upstream: [
|
|
112
|
+
`https://api.stackexchange.com/2.3/questions/${qm[1]}/answers?order=desc&sort=votes&site=${site}&filter=withbody&pagesize=5`,
|
|
113
|
+
`https://api.stackexchange.com/2.3/questions/${qm[1]}?site=${site}&filter=!9Z(-wwYGT`,
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (am) {
|
|
118
|
+
// answer permalink: one hop — the answer body carries question_id, so the
|
|
119
|
+
// runner links back to the question instead of fetching its title
|
|
120
|
+
return {
|
|
121
|
+
name: "stackexchange-api",
|
|
122
|
+
upstream: [`https://api.stackexchange.com/2.3/answers/${am[1]}?order=desc&sort=votes&site=${site}&filter=withbody`],
|
|
123
|
+
};
|
|
87
124
|
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function matchReddit(url: URL): AdapterMatch | null {
|
|
129
|
+
if (!/^(www\.|old\.|new\.|np\.)?reddit\.com$/.test(url.hostname)) return null;
|
|
130
|
+
if (!url.pathname.includes("/comments/")) return null;
|
|
131
|
+
return { name: "reddit-json", upstream: [url.protocol + "//" + url.host + url.pathname.replace(/\/$/, "") + ".json?limit=30"] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function matchHn(url: URL): AdapterMatch | null {
|
|
135
|
+
if (!/^news\.ycombinator\.com$/.test(url.hostname)) return null;
|
|
136
|
+
const id = url.searchParams.get("id");
|
|
137
|
+
if (!id || !/^\d+$/.test(id)) return null;
|
|
138
|
+
return { name: "hn-algolia", upstream: [`https://hn.algolia.com/api/v1/items/${id}`] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function matchWikipedia(url: URL): AdapterMatch | null {
|
|
142
|
+
const m = url.hostname.match(/^([a-z-]+)\.(m\.)?wikipedia\.org$/i);
|
|
143
|
+
if (!m) return null;
|
|
144
|
+
const lang = m[1]!;
|
|
145
|
+
if (!lang || lang === "www") return null;
|
|
146
|
+
const pm = url.pathname.match(/^\/wiki\/([^/:#]+)$/);
|
|
147
|
+
if (!pm) return null;
|
|
148
|
+
const title = encodeURIComponent(decodeURIComponent(pm[1]!));
|
|
149
|
+
return {
|
|
150
|
+
name: "wikipedia-rest",
|
|
151
|
+
upstream: [
|
|
152
|
+
`https://${lang}.wikipedia.org/api/rest_v1/page/summary/${title}`,
|
|
153
|
+
`https://${lang}.wikipedia.org/api/rest_v1/page/html/${title}`,
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
88
157
|
|
|
158
|
+
function matchArxiv(url: URL): AdapterMatch | null {
|
|
159
|
+
if (!/^(www\.)?arxiv\.org$/.test(url.hostname)) return null;
|
|
160
|
+
const m = url.pathname.match(/^\/abs\/([^?#]+)$/);
|
|
161
|
+
if (!m) return null; // /pdf/{id} deliberately unrouted: the generic PDF path handles it
|
|
162
|
+
return { name: "arxiv-api", upstream: [`https://export.arxiv.org/api/query?id_list=${m[1]}&max_results=1`] };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const MATCHERS: Array<(u: URL) => AdapterMatch | null> = [
|
|
166
|
+
matchGithub,
|
|
167
|
+
matchStackExchange,
|
|
168
|
+
matchReddit,
|
|
169
|
+
matchHn,
|
|
170
|
+
matchWikipedia,
|
|
171
|
+
matchArxiv,
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/** Route a URL to an adapter without any I/O. null = generic fetch path. */
|
|
175
|
+
export function matchAdapter(url: URL): AdapterMatch | null {
|
|
176
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
177
|
+
for (const fn of MATCHERS) {
|
|
178
|
+
const m = fn(url);
|
|
179
|
+
if (m) return m;
|
|
180
|
+
}
|
|
89
181
|
return null;
|
|
90
|
-
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ------------------------------------------------------------------- github
|
|
185
|
+
|
|
186
|
+
function ghHeaders(): Record<string, string> {
|
|
187
|
+
const token = process.env.GITHUB_TOKEN;
|
|
188
|
+
return token ? { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" } : { Accept: "application/vnd.github+json" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function runGithubRepo(url: URL, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
192
|
+
const [owner, repo] = url.pathname.split("/").filter(Boolean);
|
|
193
|
+
if (!owner || !repo) return null;
|
|
194
|
+
const meta = await getJson<any>(`https://api.github.com/repos/${owner}/${repo}`, signal, ghHeaders());
|
|
195
|
+
let md = `# ${meta.full_name}\n\n${meta.description ?? ""}\n\n`;
|
|
196
|
+
md += `★ ${meta.stargazers_count} · ${meta.language ?? "?"} · updated ${(meta.updated_at ?? "").slice(0, 10)}\n`;
|
|
197
|
+
if (meta.license?.spdx_id) md += `License: ${meta.license.spdx_id}\n`;
|
|
198
|
+
if (Array.isArray(meta.topics) && meta.topics.length) md += `Tags: ${meta.topics.slice(0, 8).join(", ")}\n`;
|
|
199
|
+
// raw.githubusercontent.com resolves the literal ref HEAD to the default branch —
|
|
200
|
+
// no metadata round trip for the branch name
|
|
201
|
+
try {
|
|
202
|
+
const readme = await getText(`https://raw.githubusercontent.com/${owner}/${repo}/HEAD/README.md`, signal);
|
|
203
|
+
md += `\n---\n\n${readme}`;
|
|
204
|
+
} catch {
|
|
205
|
+
/* repo without a root README: metadata only */
|
|
206
|
+
}
|
|
207
|
+
return { text: md.slice(0, 120_000), source: "github-api" };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runGithubRaw(url: URL, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
211
|
+
// /{owner}/{repo}/(blob|raw)/{ref}/{path...}
|
|
212
|
+
const [, , , , ref, ...path] = url.pathname.split("/").filter(Boolean);
|
|
213
|
+
if (!ref || path.length === 0) return null;
|
|
214
|
+
const raw = `https://raw.githubusercontent.com/${url.pathname.split("/").filter(Boolean)[0]}/${url.pathname.split("/").filter(Boolean)[1]}/${ref}/${path.join("/")}`;
|
|
215
|
+
const text = await getText(raw, signal);
|
|
216
|
+
return { text: text.slice(0, 200_000), source: "github-raw" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function runGithubTree(m: AdapterMatch, _url: URL, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
220
|
+
// upstream[0] = contents API for the directory
|
|
221
|
+
const items = await getJson<any[]>(m.upstream[0]!, signal, ghHeaders());
|
|
222
|
+
if (!Array.isArray(items)) return null;
|
|
223
|
+
const lines = items.map((f) => `${f.type === "dir" ? "- d " : "- f "}${f.name}${f.type === "file" && f.size != null ? ` (${f.size}B)` : ""}`);
|
|
224
|
+
const dirName = decodeURIComponent(m.upstream[0]!.split("contents/")[1]?.split("?")[0] ?? "");
|
|
225
|
+
return { text: `# ${dirName || "repository root"}\n\n${lines.join("\n")}\n`, source: "github-tree-api" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function runGithubIssue(url: URL, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
229
|
+
const segs = url.pathname.split("/").filter(Boolean);
|
|
230
|
+
const [owner, repo, kind, numStr] = segs;
|
|
231
|
+
const num = Number(numStr);
|
|
232
|
+
if (!owner || !repo || !num) return null;
|
|
233
|
+
const isPr = kind === "pull";
|
|
234
|
+
const base = `https://api.github.com/repos/${owner}/${repo}/${isPr ? "pulls" : "issues"}/${num}`;
|
|
235
|
+
const item = await getJson<any>(base, signal, ghHeaders());
|
|
236
|
+
let md = `# ${item.title ?? `${owner}/${repo}#${num}`}\n\n${item.body ?? "(no body)"}\n`;
|
|
237
|
+
const comments = await getJson<any[]>(`https://api.github.com/repos/${owner}/${repo}/issues/${num}/comments?per_page=30`, signal, ghHeaders()).catch(() => []);
|
|
238
|
+
if (Array.isArray(comments) && comments.length > 0) {
|
|
239
|
+
md += `\n---\n\n## Comments\n\n` + comments
|
|
240
|
+
.map((c) => `**${c.user?.login ?? "?"}** (${(c.created_at ?? "").slice(0, 10)}):\n\n${c.body ?? ""}`)
|
|
241
|
+
.join("\n\n---\n\n");
|
|
242
|
+
}
|
|
243
|
+
return { text: md.slice(0, 100_000), source: isPr ? "github-pr-api" : "github-issue-api" };
|
|
244
|
+
}
|
|
91
245
|
|
|
92
246
|
// ------------------------------------------------------- stackoverflow
|
|
93
247
|
|
|
94
248
|
function stripHtml(s: string): string {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
249
|
+
// keep <pre><code>/<code> bodies verbatim (still entity-encoded), strip the
|
|
250
|
+
// surrounding tags, then decode entities exactly once over the whole result —
|
|
251
|
+
// code and prose decode identically and already-decoded text can't re-mangle
|
|
252
|
+
// (decodeEntities' named-entity pattern requires the trailing semicolon)
|
|
253
|
+
const out = s
|
|
254
|
+
.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/g, (_, c) => `\n\`\`\`\n${c}\n\`\`\`\n`)
|
|
255
|
+
.replace(/<code>([\s\S]*?)<\/code>/g, (_, c) => `\`${c}\``)
|
|
98
256
|
.replace(/<(p|br|div|li|h[1-6])[^>]*>/gi, "\n")
|
|
99
|
-
.replace(/<[^>]+>/g, "")
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
md += `\n---\n\n## Answers\n\n` + answers
|
|
123
|
-
.map((a) => `${a.is_accepted ? "**Accepted answer**\n\n" : ""}${stripHtml(a.body ?? "")}`)
|
|
124
|
-
.join("\n\n---\n\n");
|
|
125
|
-
} else {
|
|
126
|
-
md += "\n(no answers yet)\n";
|
|
257
|
+
.replace(/<[^>]+>/g, "");
|
|
258
|
+
return decodeEntities(out).replace(/\n{3,}/g, "\n\n").trim();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function runStackExchange(m: AdapterMatch, _url: URL, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
262
|
+
const site = new URL(m.upstream[0]!).searchParams.get("site") ?? "stackoverflow";
|
|
263
|
+
const qm = m.upstream[0]!.match(/\/questions\/(\d+)\//);
|
|
264
|
+
if (qm) {
|
|
265
|
+
// question page: answers + question body
|
|
266
|
+
const id = qm[1]!;
|
|
267
|
+
const q = await getJson<any>(m.upstream[0]!, signal);
|
|
268
|
+
const qData = await getJson<any>(`https://api.stackexchange.com/2.3/questions/${id}?site=${site}&filter=!9Z(-wwYGT`, signal).catch(() => null);
|
|
269
|
+
const question = qData?.items?.[0];
|
|
270
|
+
let md = question ? `# ${question.title}\n\n${stripHtml(question.body ?? "")}\n` : "";
|
|
271
|
+
const answers = (q.items ?? []) as any[];
|
|
272
|
+
if (answers.length > 0) {
|
|
273
|
+
md += `\n---\n\n## Answers\n\n` + answers
|
|
274
|
+
.map((a) => `${a.is_accepted ? "**Accepted answer**\n\n" : ""}${stripHtml(a.body ?? "")}`)
|
|
275
|
+
.join("\n\n---\n\n");
|
|
276
|
+
} else {
|
|
277
|
+
md += "\n(no answers yet)\n";
|
|
278
|
+
}
|
|
279
|
+
return { text: md.slice(0, 100_000), source: "stackexchange-api" };
|
|
127
280
|
}
|
|
281
|
+
// /a/{id} answer permalink: one hop, link back to the question
|
|
282
|
+
const am = m.upstream[0]!.match(/\/answers\/(\d+)/);
|
|
283
|
+
if (!am) return null;
|
|
284
|
+
const data = await getJson<any>(m.upstream[0]!, signal).catch(() => null);
|
|
285
|
+
const a = data?.items?.[0];
|
|
286
|
+
if (!a) return null;
|
|
287
|
+
const md = `${a.is_accepted ? "**Accepted answer**\n\n" : ""}${stripHtml(a.body ?? "")}\n\n[question ${a.question_id}](https://${site === "stackoverflow" ? "stackoverflow.com" : site + ".stackexchange.com"}/questions/${a.question_id})`;
|
|
128
288
|
return { text: md.slice(0, 100_000), source: "stackexchange-api" };
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
function stripTags(s: string): string {
|
|
132
|
-
return s.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
133
289
|
}
|
|
134
290
|
|
|
135
291
|
// ----------------------------------------------------------------- hackernews
|
|
136
292
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const id = url.searchParams.get("id");
|
|
140
|
-
if (!id) return null;
|
|
141
|
-
const item = await getJson<any>(`https://hn.algolia.com/api/v1/items/${id}`, signal);
|
|
293
|
+
async function runHn(m: AdapterMatch, _url: URL, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
294
|
+
const item = await getJson<any>(m.upstream[0]!, signal);
|
|
142
295
|
const flat = (n: any, depth: number): string => {
|
|
143
296
|
let s = "";
|
|
144
297
|
if (n.text) s += `${" ".repeat(depth)}- ${String(n.text).replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim()}\n`;
|
|
@@ -151,9 +304,8 @@ const hnAdapter: Adapter = async (url, signal) => {
|
|
|
151
304
|
|
|
152
305
|
// ------------------------------------------------------------------ reddit
|
|
153
306
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const data = (await getJson<any>(url.protocol + "//" + url.host + url.pathname.replace(/\/$/, "") + ".json?limit=30", signal)) as any;
|
|
307
|
+
async function runReddit(m: AdapterMatch, _url: URL, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
308
|
+
const data = (await getJson<any>(m.upstream[0]!, signal)) as any;
|
|
157
309
|
const post = Array.isArray(data) ? data[0]?.data?.children?.[0]?.data : null;
|
|
158
310
|
if (!post) return null;
|
|
159
311
|
let md = `# ${post.title}\n\nr/${post.subreddit} · ↑${post.ups} · u/${post.author}\n\n${post.selftext ?? ""}\n`;
|
|
@@ -174,37 +326,65 @@ const redditAdapter: Adapter = async (url, signal) => {
|
|
|
174
326
|
|
|
175
327
|
// ------------------------------------------------------------- wikipedia
|
|
176
328
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const raw = await getText(`https://${apiHost}/api/rest_v1/page/html/${encodeURIComponent(title)}`, signal).catch(() => "");
|
|
185
|
-
if (!raw) return null;
|
|
329
|
+
async function runWikipedia(m: AdapterMatch, _url: URL, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
330
|
+
const [summaryUrl, htmlUrl] = m.upstream as [string, string];
|
|
331
|
+
const summary = await getJson<any>(summaryUrl, signal).catch(() => null);
|
|
332
|
+
// 404 page: summary 404s AND the action=raw fetch 404s (with an error body) —
|
|
333
|
+
// bail so the caller's generic path reports HTTP 404 instead of error text
|
|
334
|
+
const raw = await getText(htmlUrl, signal).catch(() => "");
|
|
335
|
+
if (!summary || !raw) return null;
|
|
186
336
|
// reuse the extractor's markdown conversion
|
|
337
|
+
const url = new URL(htmlUrl);
|
|
187
338
|
const { htmlToMarkdown } = await import("./extract.ts");
|
|
188
339
|
const converted = htmlToMarkdown(raw, url.href, 200_000);
|
|
189
340
|
let md = converted.text;
|
|
190
|
-
|
|
341
|
+
// lede dedupe: compare the summary extract against the markdown lede with
|
|
342
|
+
// decoration stripped (was a raw includes() that always failed on **bold**)
|
|
343
|
+
const stripMd = (s: string) => s.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim();
|
|
344
|
+
const lede = stripMd(md.split(/\n#{1,6}\s/)[0] ?? "").slice(0, 300).toLowerCase();
|
|
345
|
+
if (summary?.extract && !lede.includes(stripMd(summary.extract).slice(0, 60).toLowerCase())) {
|
|
191
346
|
md = `${summary.extract}\n\n${md}`;
|
|
192
347
|
}
|
|
193
348
|
return { text: md.slice(0, 200_000), source: "wikipedia-rest" };
|
|
194
|
-
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function runArxiv(m: AdapterMatch, _url: URL, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
352
|
+
const atom = await getText(m.upstream[0]!, signal);
|
|
353
|
+
const entry = atom.match(/<entry>([\s\S]*?)<\/entry>/)?.[1];
|
|
354
|
+
if (!entry) return null;
|
|
355
|
+
const pick = (tag: string) => entry.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`))?.[1]?.trim();
|
|
356
|
+
const dec = (s?: string) => decodeEntities(s ?? "").replace(/\s+/g, " ").trim();
|
|
357
|
+
const [title, summary] = [dec(pick("title")), dec(pick("summary"))];
|
|
358
|
+
if (!title || !summary) return null;
|
|
359
|
+
const authors = [...entry.matchAll(/<author>\s*<name>([\s\S]*?)<\/name>/g)].map((a) => decodeEntities(a[1]!));
|
|
360
|
+
const id = pick("id")?.replace("http://arxiv.org/abs/", "") ?? "";
|
|
361
|
+
const [published, updated] = [pick("published")?.slice(0, 10), pick("updated")?.slice(0, 10)];
|
|
362
|
+
const md = `# ${title}\n\n${authors.join(", ")}\n\nPublished ${published ?? "?"}` +
|
|
363
|
+
(updated && updated !== published ? ` · updated ${updated}` : "") +
|
|
364
|
+
`\n\n## Abstract\n\n${summary}\n\n[PDF](https://arxiv.org/pdf/${id})\n`;
|
|
365
|
+
return { text: md.slice(0, 60_000), source: "arxiv-api", date: published };
|
|
366
|
+
}
|
|
195
367
|
|
|
196
368
|
// ------------------------------------------------------------------ router
|
|
197
369
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
370
|
+
type AdapterFn = (m: AdapterMatch, url: URL, signal?: AbortSignal) => Promise<AdapterResult | null>;
|
|
371
|
+
|
|
372
|
+
const RUN: Record<string, AdapterFn> = {
|
|
373
|
+
"github-api": (_, url, signal) => runGithubRepo(url, signal),
|
|
374
|
+
"github-raw": (_, url, signal) => runGithubRaw(url, signal),
|
|
375
|
+
"github-tree-api": runGithubTree,
|
|
376
|
+
"github-issue-api": (_, url, signal) => runGithubIssue(url, signal),
|
|
377
|
+
"github-pr-api": (_, url, signal) => runGithubIssue(url, signal),
|
|
378
|
+
"stackexchange-api": runStackExchange,
|
|
379
|
+
"reddit-json": runReddit,
|
|
380
|
+
"hn-algolia": runHn,
|
|
381
|
+
"wikipedia-rest": runWikipedia,
|
|
382
|
+
"arxiv-api": runArxiv,
|
|
383
|
+
};
|
|
205
384
|
|
|
206
385
|
/**
|
|
207
|
-
* Try site adapters for a URL.
|
|
386
|
+
* Try site adapters for a URL. matchAdapter is pure; dispatch goes through the
|
|
387
|
+
* RUN table with the original URL. Returns null when no adapter matches or all
|
|
208
388
|
* fail — caller falls back to the generic HTML pipeline.
|
|
209
389
|
*/
|
|
210
390
|
export async function trySiteAdapter(url: string, signal?: AbortSignal): Promise<AdapterResult | null> {
|
|
@@ -214,15 +394,13 @@ export async function trySiteAdapter(url: string, signal?: AbortSignal): Promise
|
|
|
214
394
|
} catch {
|
|
215
395
|
return null;
|
|
216
396
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
return null; // adapter exists but failed — generic path is more honest than an error
|
|
225
|
-
}
|
|
397
|
+
const m = matchAdapter(u);
|
|
398
|
+
if (!m) return null;
|
|
399
|
+
try {
|
|
400
|
+
const r = await (RUN[m.name] ?? (async () => null))(m, u, signal);
|
|
401
|
+
if (r && r.text.length > 80) return r;
|
|
402
|
+
} catch {
|
|
403
|
+
return null; // adapter exists but failed — generic path is more honest than an error
|
|
226
404
|
}
|
|
227
405
|
return null;
|
|
228
406
|
}
|
package/lib/apis.ts
CHANGED
|
@@ -43,6 +43,7 @@ interface SeItem {
|
|
|
43
43
|
tags?: string[];
|
|
44
44
|
excerpt?: string;
|
|
45
45
|
creation_date: number;
|
|
46
|
+
question_id: number;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export async function searchStackOverflow(
|
|
@@ -192,6 +193,7 @@ interface HnHit {
|
|
|
192
193
|
story_text?: string | null;
|
|
193
194
|
comment_text?: string | null;
|
|
194
195
|
created_at: string;
|
|
196
|
+
story_title?: string | null;
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
export async function searchHackerNews(
|
package/lib/cache.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* memory stays the hot path; disk survives restarts (search pages, fetched
|
|
6
6
|
* article text). Writes are debounced and flushed on a timer + process exit.
|
|
7
7
|
*/
|
|
8
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
|
|
@@ -101,14 +101,17 @@ export function createDiskBackedCache(opts: {
|
|
|
101
101
|
try {
|
|
102
102
|
mkdirSync(dir, { recursive: true });
|
|
103
103
|
const entries = [...map.entries()];
|
|
104
|
-
|
|
104
|
+
let body = JSON.stringify(entries);
|
|
105
105
|
if (body.length > DISK_LIMIT_BYTES) {
|
|
106
106
|
// over budget: keep the newest half
|
|
107
107
|
entries.sort((a, b) => b[1].at - a[1].at);
|
|
108
|
-
|
|
109
|
-
} else {
|
|
110
|
-
writeFileSync(file, body);
|
|
108
|
+
body = JSON.stringify(entries.slice(0, Math.ceil(entries.length / 2)));
|
|
111
109
|
}
|
|
110
|
+
// atomic-ish: write to a sibling temp file, rename over the target — a
|
|
111
|
+
// process killed mid-write leaves the last good snapshot + an orphaned
|
|
112
|
+
// .tmp that load() never reads
|
|
113
|
+
writeFileSync(`${file}.tmp`, body);
|
|
114
|
+
renameSync(`${file}.tmp`, file);
|
|
112
115
|
} catch {
|
|
113
116
|
// disk full/readonly — cache silently degrades to memory-only
|
|
114
117
|
}
|
|
@@ -125,17 +128,7 @@ export function createDiskBackedCache(opts: {
|
|
|
125
128
|
if (typeof timer === "object" && "unref" in (timer as any)) (timer as any).unref?.();
|
|
126
129
|
};
|
|
127
130
|
|
|
128
|
-
|
|
129
|
-
process.on("exit", () => flush());
|
|
130
|
-
try {
|
|
131
|
-
process.on("SIGINT", () => {
|
|
132
|
-
flush();
|
|
133
|
-
});
|
|
134
|
-
} catch {
|
|
135
|
-
/* not always available */
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return {
|
|
131
|
+
const api = {
|
|
139
132
|
get(key: string) {
|
|
140
133
|
load();
|
|
141
134
|
const hit = map.get(key);
|
|
@@ -157,4 +150,37 @@ export function createDiskBackedCache(opts: {
|
|
|
157
150
|
},
|
|
158
151
|
flushSync: flush,
|
|
159
152
|
};
|
|
153
|
+
registry.add(api as never);
|
|
154
|
+
ensureExitHandlers();
|
|
155
|
+
return api;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ------------------------------------------------------- exit-handler registry
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One process-level pair of exit/SIGINT listeners flushes every live cache —
|
|
162
|
+
* per-instance listeners (the old shape) hit Node's MaxListenersExceededWarning
|
|
163
|
+
* after ~10 caches (pi extension reloads).
|
|
164
|
+
*/
|
|
165
|
+
const registry = new Set<{ flushSync: () => void }>();
|
|
166
|
+
let handlersRegistered = false;
|
|
167
|
+
|
|
168
|
+
function ensureExitHandlers(): void {
|
|
169
|
+
if (handlersRegistered) return;
|
|
170
|
+
handlersRegistered = true;
|
|
171
|
+
const flushAll = () => {
|
|
172
|
+
for (const c of registry) {
|
|
173
|
+
try {
|
|
174
|
+
c.flushSync();
|
|
175
|
+
} catch {
|
|
176
|
+
/* best effort */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
process.on("exit", flushAll);
|
|
181
|
+
try {
|
|
182
|
+
process.on("SIGINT", flushAll);
|
|
183
|
+
} catch {
|
|
184
|
+
/* not always available */
|
|
185
|
+
}
|
|
160
186
|
}
|