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/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
- // ------------------------------------------------------------------- github
34
+ // -------------------------------------------------------------- pure router
32
35
 
33
- function ghHeaders(): Record<string, string> {
34
- const token = process.env.GITHUB_TOKEN;
35
- return token ? { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" } : { Accept: "application/vnd.github+json" };
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 githubAdapter: Adapter = async (url, signal) => {
39
- const parts = url.pathname.split("/").filter(Boolean); // [o, r, (blob|tree|issues|pull), ...]
40
- if (parts.length < 2) return null;
41
- const [owner, repo, kind, ...rest] = parts;
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
- // raw file: /blob/{ref}/{path...} or /raw/{ref}/{path...}
44
- if ((kind === "blob" || kind === "raw") && rest.length >= 2) {
45
- const [, ref, ...path] = [kind, rest[0], ...rest.slice(1)];
46
- const raw = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path.join("/")}`;
47
- const text = await getText(raw, signal);
48
- return { text: text.slice(0, 200_000), source: "github-raw" };
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
- // issue / pull: API markdown
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
- const base = `https://api.github.com/repos/${owner}/${repo}/${isPr ? "pulls" : "issues"}/${num}`;
57
- const item = await getJson<any>(base, signal, ghHeaders());
58
- let md = `# ${item.title ?? `${owner}/${repo}#${num}`}\n\n${isPr && item.message ? "" : ""}${item.body ?? "(no body)"}\n`;
59
- md = md.replace(/^(# .*\n\n)+/, `# ${item.title}\n\n`); // collapse stray dupes
60
- const comments = await getJson<any[]>(`https://api.github.com/repos/${owner}/${repo}/issues/${num}/comments?per_page=30`, signal, ghHeaders()).catch(() => []);
61
- if (Array.isArray(comments) && comments.length > 0) {
62
- md += `\n---\n\n## Comments\n\n` + comments
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
- const meta = await getJson<any>(`https://api.github.com/repos/${owner}/${repo}`, signal, ghHeaders());
72
- let md = `# ${meta.full_name}\n\n${meta.description ?? ""}\n\n`;
73
- md += `★ ${meta.stargazers_count} · ${meta.language ?? "?"} · updated ${(meta.updated_at ?? "").slice(0, 10)}\n`;
74
- if (meta.license?.spdx_id) md += `License: ${meta.license.spdx_id}\n`;
75
- if (Array.isArray(meta.topics) && meta.topics.length) md += `Tags: ${meta.topics.slice(0, 8).join(", ")}\n`;
76
- const branches = ["HEAD", meta.default_branch ?? "main"];
77
- for (const b of branches) {
78
- try {
79
- const readme = await getText(`https://raw.githubusercontent.com/${owner}/${repo}/${b}/README.md`, signal);
80
- md += `\n---\n\n${readme}`;
81
- break;
82
- } catch {
83
- /* try next branch name */
84
- }
85
- }
86
- return { text: md.slice(0, 120_000), source: "github-api" };
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
- return s
96
- .replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/g, (_, c) => `\n\`\`\`\n${c.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&")}\n\`\`\`\n`)
97
- .replace(/<code>([\s\S]*?)<\/code>/g, (_, c) => `\`${c.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&")}\``)
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
- .replace(/</g, "<")
101
- .replace(/>/g, ">")
102
- .replace(/"/g, '"')
103
- .replace(/&#39;/g, "'")
104
- .replace(/&/g, "&")
105
- .replace(/\n{3,}/g, "\n\n")
106
- .trim();
107
- }
108
-
109
- const stackOverflowAdapter: Adapter = async (url, signal) => {
110
- const m = url.pathname.match(/\/questions\/(\d+)/);
111
- if (!m) return null;
112
- const id = m[1];
113
- const q = await getJson<any>(
114
- `https://api.stackexchange.com/2.3/questions/${id}/answers?order=desc&sort=votes&site=stackoverflow&filter=withbody&pagesize=5`,
115
- signal,
116
- );
117
- const qData = await getJson<any>(`https://api.stackexchange.com/2.3/questions/${id}?site=stackoverflow&filter=!9Z(-wwYGT`, signal).catch(() => null);
118
- const question = qData?.items?.[0];
119
- let md = question ? `# ${question.title}\n\n${stripHtml(question.body ?? "")}\n` : "";
120
- const answers = (q.items ?? []) as any[];
121
- if (answers.length > 0) {
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
- const hnAdapter: Adapter = async (url, signal) => {
138
- if (!/(^|\.)news\.ycombinator\.com$/.test(url.hostname)) return null;
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
- const redditAdapter: Adapter = async (url, signal) => {
155
- if (!/(^|\.)reddit\.com$/.test(url.hostname) || !url.pathname.includes("/comments/")) return null;
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
- const wikipediaAdapter: Adapter = async (url, signal) => {
178
- if (!/(^|\.)wikipedia\.org$/.test(url.hostname)) return null;
179
- const m = url.pathname.match(/^\/wiki\/([^/:#]+)$/);
180
- if (!m) return null;
181
- const title = decodeURIComponent(m[1]);
182
- const summary = await getJson<any>(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`, signal).catch(() => null);
183
- const apiHost = url.hostname.startsWith("en.") ? url.hostname : "en.wikipedia.org";
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
- if (summary?.extract && !md.includes(summary.extract.slice(0, 80))) {
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
- const ADAPTERS: Array<[RegExp, Adapter]> = [
199
- [/^github\.com$/, githubAdapter],
200
- [/^(www\.)?stackoverflow\.com$/, stackOverflowAdapter],
201
- [/^(www\.)?reddit\.com$/, redditAdapter],
202
- [/^news\.ycombinator\.com$/, hnAdapter],
203
- [/^(en\.)?wikipedia\.org$/, wikipediaAdapter],
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. Returns null when no adapter matches or all
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
- if (u.protocol !== "http:" && u.protocol !== "https:") return null;
218
- for (const [hostRe, adapter] of ADAPTERS) {
219
- if (!hostRe.test(u.hostname)) continue;
220
- try {
221
- const r = await adapter(u, signal);
222
- if (r && r.text.length > 80) return r;
223
- } catch {
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
- const body = JSON.stringify(entries);
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
- writeFileSync(file, JSON.stringify(entries.slice(0, Math.ceil(entries.length / 2))));
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
- // flush on exit (best effort)
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
  }