@alexion42/pi-web-search 0.1.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/.pi/tasks/tasks-019e595f-0b95-7b09-9237-a0c6fbbda360.json +4 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/TOOLS.md +103 -0
- package/activity.ts +101 -0
- package/banner.png +0 -0
- package/code-search.ts +107 -0
- package/exa.ts +520 -0
- package/extract.ts +342 -0
- package/github-api.ts +196 -0
- package/github-extract.ts +634 -0
- package/index.ts +885 -0
- package/package.json +46 -0
- package/pdf-extract.ts +192 -0
- package/pi-web-fetch-demo.mp4 +0 -0
- package/rsc-extract.ts +338 -0
- package/search.ts +49 -0
- package/storage.ts +71 -0
- package/test/pdf-extract.test.mjs +95 -0
- package/types.ts +20 -0
- package/utils.ts +44 -0
package/extract.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Readability } from "@mozilla/readability";
|
|
2
|
+
import { parseHTML } from "linkedom";
|
|
3
|
+
import TurndownService from "turndown";
|
|
4
|
+
import pLimit from "p-limit";
|
|
5
|
+
import { activityMonitor } from "./activity.js";
|
|
6
|
+
import { extractRSCContent } from "./rsc-extract.js";
|
|
7
|
+
import { extractPDFToMarkdown, isPDF } from "./pdf-extract.js";
|
|
8
|
+
import { extractGitHub } from "./github-extract.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
11
|
+
const CONCURRENT_LIMIT = 3;
|
|
12
|
+
|
|
13
|
+
const NON_RECOVERABLE_ERRORS = ["Unsupported content type", "Response too large"];
|
|
14
|
+
const MIN_USEFUL_CONTENT = 500;
|
|
15
|
+
|
|
16
|
+
function errorMessage(err: unknown): string {
|
|
17
|
+
return err instanceof Error ? err.message : String(err);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isConfigParseError(err: unknown): boolean {
|
|
21
|
+
return errorMessage(err).startsWith("Failed to parse ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isAbortError(err: unknown): boolean {
|
|
25
|
+
return errorMessage(err).toLowerCase().includes("abort");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function abortedResult(url: string): ExtractedContent {
|
|
29
|
+
return { url, title: "", content: "", error: "Aborted" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const turndown = new TurndownService({
|
|
33
|
+
headingStyle: "atx",
|
|
34
|
+
codeBlockStyle: "fenced",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const fetchLimit = pLimit(CONCURRENT_LIMIT);
|
|
38
|
+
|
|
39
|
+
export interface ExtractedContent {
|
|
40
|
+
url: string;
|
|
41
|
+
title: string;
|
|
42
|
+
content: string;
|
|
43
|
+
error: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ExtractOptions {
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
forceClone?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const JINA_READER_BASE = "https://r.jina.ai/";
|
|
52
|
+
const JINA_TIMEOUT_MS = 30000;
|
|
53
|
+
|
|
54
|
+
async function extractWithJinaReader(
|
|
55
|
+
url: string,
|
|
56
|
+
signal?: AbortSignal,
|
|
57
|
+
): Promise<ExtractedContent | null> {
|
|
58
|
+
const jinaUrl = JINA_READER_BASE + url;
|
|
59
|
+
|
|
60
|
+
const activityId = activityMonitor.logStart({ type: "api", query: `jina: ${url}` });
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(jinaUrl, {
|
|
64
|
+
headers: {
|
|
65
|
+
"Accept": "text/markdown",
|
|
66
|
+
"X-No-Cache": "true",
|
|
67
|
+
},
|
|
68
|
+
signal: AbortSignal.any([
|
|
69
|
+
AbortSignal.timeout(JINA_TIMEOUT_MS),
|
|
70
|
+
...(signal ? [signal] : []),
|
|
71
|
+
]),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
activityMonitor.logComplete(activityId, res.status);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const content = await res.text();
|
|
80
|
+
activityMonitor.logComplete(activityId, res.status);
|
|
81
|
+
|
|
82
|
+
const contentStart = content.indexOf("Markdown Content:");
|
|
83
|
+
if (contentStart < 0) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const markdownPart = content.slice(contentStart + 17).trim();
|
|
88
|
+
|
|
89
|
+
// Check for failed JS rendering or minimal content
|
|
90
|
+
if (markdownPart.length < 100 ||
|
|
91
|
+
markdownPart.startsWith("Loading...") ||
|
|
92
|
+
markdownPart.startsWith("Please enable JavaScript")) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const title = extractHeadingTitle(markdownPart) ?? (new URL(url).pathname.split("/").pop() || url);
|
|
97
|
+
return { url, title, content: markdownPart, error: null };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
100
|
+
if (message.toLowerCase().includes("abort")) {
|
|
101
|
+
activityMonitor.logComplete(activityId, 0);
|
|
102
|
+
} else {
|
|
103
|
+
activityMonitor.logError(activityId, message);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function extractContent(
|
|
110
|
+
url: string,
|
|
111
|
+
signal?: AbortSignal,
|
|
112
|
+
options?: ExtractOptions,
|
|
113
|
+
): Promise<ExtractedContent> {
|
|
114
|
+
if (signal?.aborted) {
|
|
115
|
+
return { url, title: "", content: "", error: "Aborted" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate URL format
|
|
119
|
+
try {
|
|
120
|
+
new URL(url);
|
|
121
|
+
} catch {
|
|
122
|
+
return { url, title: "", content: "", error: "Invalid URL" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Try GitHub extraction first
|
|
126
|
+
try {
|
|
127
|
+
const ghResult = await extractGitHub(url, signal, options?.forceClone);
|
|
128
|
+
if (ghResult) return ghResult;
|
|
129
|
+
if (signal?.aborted) return abortedResult(url);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const message = errorMessage(err);
|
|
132
|
+
if (isAbortError(err)) return abortedResult(url);
|
|
133
|
+
if (isConfigParseError(err)) {
|
|
134
|
+
return { url, title: "", content: "", error: message };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// HTTP extraction with fallback chain
|
|
139
|
+
const httpResult = await extractViaHttp(url, signal, options);
|
|
140
|
+
|
|
141
|
+
if (signal?.aborted) return abortedResult(url);
|
|
142
|
+
if (!httpResult.error) return httpResult;
|
|
143
|
+
if (NON_RECOVERABLE_ERRORS.some(prefix => httpResult.error!.startsWith(prefix))) return httpResult;
|
|
144
|
+
|
|
145
|
+
// Try Jina Reader for JS-rendered pages
|
|
146
|
+
const jinaResult = await extractWithJinaReader(url, signal);
|
|
147
|
+
if (jinaResult) return jinaResult;
|
|
148
|
+
if (signal?.aborted) return abortedResult(url);
|
|
149
|
+
|
|
150
|
+
// Final error state
|
|
151
|
+
return { ...httpResult, error: httpResult.error };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isLikelyJSRendered(html: string): boolean {
|
|
155
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
156
|
+
if (!bodyMatch) return false;
|
|
157
|
+
|
|
158
|
+
const bodyHtml = bodyMatch[1];
|
|
159
|
+
|
|
160
|
+
const textContent = bodyHtml
|
|
161
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
162
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
163
|
+
.replace(/<[^>]+>/g, "")
|
|
164
|
+
.replace(/\s+/g, " ")
|
|
165
|
+
.trim();
|
|
166
|
+
|
|
167
|
+
const scriptCount = (html.match(/<script/gi) || []).length;
|
|
168
|
+
|
|
169
|
+
return textContent.length < 500 && scriptCount > 3;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function extractViaHttp(
|
|
173
|
+
url: string,
|
|
174
|
+
signal?: AbortSignal,
|
|
175
|
+
options?: ExtractOptions,
|
|
176
|
+
): Promise<ExtractedContent> {
|
|
177
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
178
|
+
const activityId = activityMonitor.logStart({ type: "fetch", url });
|
|
179
|
+
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
182
|
+
|
|
183
|
+
const onAbort = () => controller.abort();
|
|
184
|
+
signal?.addEventListener("abort", onAbort);
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const response = await fetch(url, {
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
headers: {
|
|
190
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
191
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
192
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
193
|
+
"Cache-Control": "no-cache",
|
|
194
|
+
"Sec-Fetch-Dest": "document",
|
|
195
|
+
"Sec-Fetch-Mode": "navigate",
|
|
196
|
+
"Sec-Fetch-Site": "none",
|
|
197
|
+
"Sec-Fetch-User": "?1",
|
|
198
|
+
"Upgrade-Insecure-Requests": "1",
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (!response.ok) {
|
|
203
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
204
|
+
return {
|
|
205
|
+
url,
|
|
206
|
+
title: "",
|
|
207
|
+
content: "",
|
|
208
|
+
error: `HTTP ${response.status}: ${response.statusText}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const contentLengthHeader = response.headers.get("content-length");
|
|
213
|
+
const contentType = response.headers.get("content-type") || "";
|
|
214
|
+
const isPDFContent = isPDF(url, contentType);
|
|
215
|
+
const maxResponseSize = isPDFContent ? 20 * 1024 * 1024 : 5 * 1024 * 1024;
|
|
216
|
+
if (contentLengthHeader) {
|
|
217
|
+
const contentLength = parseInt(contentLengthHeader, 10);
|
|
218
|
+
if (contentLength > maxResponseSize) {
|
|
219
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
220
|
+
return {
|
|
221
|
+
url,
|
|
222
|
+
title: "",
|
|
223
|
+
content: "",
|
|
224
|
+
error: `Response too large (${Math.round(contentLength / 1024 / 1024)}MB)`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (isPDFContent) {
|
|
230
|
+
try {
|
|
231
|
+
const buffer = await response.arrayBuffer();
|
|
232
|
+
const result = await extractPDFToMarkdown(buffer, url);
|
|
233
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
234
|
+
return {
|
|
235
|
+
url,
|
|
236
|
+
title: result.title,
|
|
237
|
+
content: `PDF extracted and saved to: ${result.outputPath}\n\nPages: ${result.pages}\nCharacters: ${result.chars}`,
|
|
238
|
+
error: null,
|
|
239
|
+
};
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
242
|
+
activityMonitor.logError(activityId, message);
|
|
243
|
+
return { url, title: "", content: "", error: `PDF extraction failed: ${message}` };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (contentType.includes("application/octet-stream") ||
|
|
248
|
+
contentType.includes("image/") ||
|
|
249
|
+
contentType.includes("audio/") ||
|
|
250
|
+
contentType.includes("video/") ||
|
|
251
|
+
contentType.includes("application/zip")) {
|
|
252
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
253
|
+
return {
|
|
254
|
+
url,
|
|
255
|
+
title: "",
|
|
256
|
+
content: "",
|
|
257
|
+
error: `Unsupported content type: ${contentType.split(";")[0]}`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const text = await response.text();
|
|
262
|
+
const isHTML = contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
|
|
263
|
+
|
|
264
|
+
if (!isHTML) {
|
|
265
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
266
|
+
const title = extractTextTitle(text, url);
|
|
267
|
+
return { url, title, content: text, error: null };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { document } = parseHTML(text);
|
|
271
|
+
const reader = new Readability(document as unknown as Document);
|
|
272
|
+
const article = reader.parse();
|
|
273
|
+
|
|
274
|
+
if (!article) {
|
|
275
|
+
const rscResult = extractRSCContent(text);
|
|
276
|
+
if (rscResult) {
|
|
277
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
278
|
+
return { url, title: rscResult.title, content: rscResult.content, error: null };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
282
|
+
|
|
283
|
+
const jsRendered = isLikelyJSRendered(text);
|
|
284
|
+
const errorMsg = jsRendered
|
|
285
|
+
? "Page appears to be JavaScript-rendered (content loads dynamically)"
|
|
286
|
+
: "Could not extract readable content from HTML structure";
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
url,
|
|
290
|
+
title: "",
|
|
291
|
+
content: "",
|
|
292
|
+
error: errorMsg,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const markdown = turndown.turndown(article.content);
|
|
297
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
298
|
+
|
|
299
|
+
if (markdown.length < MIN_USEFUL_CONTENT) {
|
|
300
|
+
return {
|
|
301
|
+
url,
|
|
302
|
+
title: article.title || "",
|
|
303
|
+
content: markdown,
|
|
304
|
+
error: isLikelyJSRendered(text)
|
|
305
|
+
? "Page appears to be JavaScript-rendered (content loads dynamically)"
|
|
306
|
+
: "Extracted content appears incomplete",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { url, title: article.title || "", content: markdown, error: null };
|
|
311
|
+
} catch (err) {
|
|
312
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
313
|
+
if (message.toLowerCase().includes("abort")) {
|
|
314
|
+
activityMonitor.logComplete(activityId, 0);
|
|
315
|
+
} else {
|
|
316
|
+
activityMonitor.logError(activityId, message);
|
|
317
|
+
}
|
|
318
|
+
return { url, title: "", content: "", error: message };
|
|
319
|
+
} finally {
|
|
320
|
+
clearTimeout(timeoutId);
|
|
321
|
+
signal?.removeEventListener("abort", onAbort);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function extractHeadingTitle(text: string): string | null {
|
|
326
|
+
const match = text.match(/^#{1,2}\s+(.+)/m);
|
|
327
|
+
if (!match) return null;
|
|
328
|
+
const cleaned = match[1].replace(/\*+/g, "").trim();
|
|
329
|
+
return cleaned || null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function extractTextTitle(text: string, url: string): string {
|
|
333
|
+
return extractHeadingTitle(text) ?? (new URL(url).pathname.split("/").pop() || url);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function fetchAllContent(
|
|
337
|
+
urls: string[],
|
|
338
|
+
signal?: AbortSignal,
|
|
339
|
+
options?: ExtractOptions,
|
|
340
|
+
): Promise<ExtractedContent[]> {
|
|
341
|
+
return Promise.all(urls.map((url) => fetchLimit(() => extractContent(url, signal, options))));
|
|
342
|
+
}
|
package/github-api.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import type { ExtractedContent } from "./extract.js";
|
|
3
|
+
import type { GitHubUrlInfo } from "./github-extract.js";
|
|
4
|
+
|
|
5
|
+
const MAX_TREE_ENTRIES = 200;
|
|
6
|
+
const MAX_INLINE_FILE_CHARS = 100_000;
|
|
7
|
+
|
|
8
|
+
let ghAvailable: boolean | null = null;
|
|
9
|
+
let ghHintShown = false;
|
|
10
|
+
|
|
11
|
+
export async function checkGhAvailable(): Promise<boolean> {
|
|
12
|
+
if (ghAvailable !== null) return ghAvailable;
|
|
13
|
+
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
execFile("gh", ["--version"], { timeout: 5000 }, (err) => {
|
|
16
|
+
ghAvailable = !err;
|
|
17
|
+
resolve(ghAvailable);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function showGhHint(): void {
|
|
23
|
+
if (!ghHintShown) {
|
|
24
|
+
ghHintShown = true;
|
|
25
|
+
console.error("[pi-web-search] Install `gh` CLI for better GitHub repo access including private repos.");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function checkRepoSize(owner: string, repo: string): Promise<number | null> {
|
|
30
|
+
if (!(await checkGhAvailable())) return null;
|
|
31
|
+
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
execFile("gh", ["api", `repos/${owner}/${repo}`, "--jq", ".size"], { timeout: 10000 }, (err, stdout) => {
|
|
34
|
+
if (err) {
|
|
35
|
+
resolve(null);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const kb = parseInt(stdout.trim(), 10);
|
|
39
|
+
resolve(Number.isNaN(kb) ? null : kb);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function getDefaultBranch(owner: string, repo: string): Promise<string | null> {
|
|
45
|
+
if (!(await checkGhAvailable())) return null;
|
|
46
|
+
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
execFile("gh", ["api", `repos/${owner}/${repo}`, "--jq", ".default_branch"], { timeout: 10000 }, (err, stdout) => {
|
|
49
|
+
if (err) {
|
|
50
|
+
resolve(null);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const branch = stdout.trim();
|
|
54
|
+
resolve(branch || null);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function fetchTreeViaApi(owner: string, repo: string, ref: string): Promise<string | null> {
|
|
60
|
+
if (!(await checkGhAvailable())) return null;
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
execFile(
|
|
64
|
+
"gh",
|
|
65
|
+
["api", `repos/${owner}/${repo}/git/trees/${ref}?recursive=1`, "--jq", ".tree[].path"],
|
|
66
|
+
{ timeout: 15000, maxBuffer: 5 * 1024 * 1024 },
|
|
67
|
+
(err, stdout) => {
|
|
68
|
+
if (err) {
|
|
69
|
+
resolve(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const paths = stdout.trim().split("\n").filter(Boolean);
|
|
73
|
+
if (paths.length === 0) {
|
|
74
|
+
resolve(null);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const truncated = paths.length > MAX_TREE_ENTRIES;
|
|
78
|
+
const display = paths.slice(0, MAX_TREE_ENTRIES).join("\n");
|
|
79
|
+
resolve(truncated ? display + `\n... (${paths.length} total entries)` : display);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchReadmeViaApi(owner: string, repo: string, ref: string): Promise<string | null> {
|
|
86
|
+
if (!(await checkGhAvailable())) return null;
|
|
87
|
+
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
execFile(
|
|
90
|
+
"gh",
|
|
91
|
+
["api", `repos/${owner}/${repo}/readme?ref=${ref}`, "--jq", ".content"],
|
|
92
|
+
{ timeout: 10000 },
|
|
93
|
+
(err, stdout) => {
|
|
94
|
+
if (err) {
|
|
95
|
+
resolve(null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const decoded = Buffer.from(stdout.trim(), "base64").toString("utf-8");
|
|
100
|
+
resolve(decoded.length > 8192 ? decoded.slice(0, 8192) + "\n\n[README truncated at 8K chars]" : decoded);
|
|
101
|
+
} catch {
|
|
102
|
+
resolve(null);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function fetchFileViaApi(owner: string, repo: string, path: string, ref: string): Promise<string | null> {
|
|
110
|
+
if (!(await checkGhAvailable())) return null;
|
|
111
|
+
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
execFile(
|
|
114
|
+
"gh",
|
|
115
|
+
["api", `repos/${owner}/${repo}/contents/${path}?ref=${ref}`, "--jq", ".content"],
|
|
116
|
+
{ timeout: 10000, maxBuffer: 2 * 1024 * 1024 },
|
|
117
|
+
(err, stdout) => {
|
|
118
|
+
if (err) {
|
|
119
|
+
resolve(null);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
resolve(Buffer.from(stdout.trim(), "base64").toString("utf-8"));
|
|
124
|
+
} catch {
|
|
125
|
+
resolve(null);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function fetchViaApi(
|
|
133
|
+
url: string,
|
|
134
|
+
owner: string,
|
|
135
|
+
repo: string,
|
|
136
|
+
info: GitHubUrlInfo,
|
|
137
|
+
sizeNote?: string,
|
|
138
|
+
): Promise<ExtractedContent | null> {
|
|
139
|
+
const ref = info.ref || (await getDefaultBranch(owner, repo));
|
|
140
|
+
if (!ref) return null;
|
|
141
|
+
|
|
142
|
+
const lines: string[] = [];
|
|
143
|
+
if (sizeNote) {
|
|
144
|
+
lines.push(sizeNote);
|
|
145
|
+
lines.push("");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (info.type === "blob" && info.path) {
|
|
149
|
+
const content = await fetchFileViaApi(owner, repo, info.path, ref);
|
|
150
|
+
if (!content) return null;
|
|
151
|
+
|
|
152
|
+
lines.push(`## ${info.path}`);
|
|
153
|
+
if (content.length > MAX_INLINE_FILE_CHARS) {
|
|
154
|
+
lines.push(content.slice(0, MAX_INLINE_FILE_CHARS));
|
|
155
|
+
lines.push(`\n[File truncated at 100K chars]`);
|
|
156
|
+
} else {
|
|
157
|
+
lines.push(content);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
url,
|
|
162
|
+
title: `${owner}/${repo} - ${info.path}`,
|
|
163
|
+
content: lines.join("\n"),
|
|
164
|
+
error: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const [tree, readme] = await Promise.all([
|
|
169
|
+
fetchTreeViaApi(owner, repo, ref),
|
|
170
|
+
fetchReadmeViaApi(owner, repo, ref),
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
if (!tree && !readme) return null;
|
|
174
|
+
|
|
175
|
+
if (tree) {
|
|
176
|
+
lines.push("## Structure");
|
|
177
|
+
lines.push(tree);
|
|
178
|
+
lines.push("");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (readme) {
|
|
182
|
+
lines.push("## README.md");
|
|
183
|
+
lines.push(readme);
|
|
184
|
+
lines.push("");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
lines.push("This is an API-only view. Clone the repo or use `read`/`bash` for deeper exploration.");
|
|
188
|
+
|
|
189
|
+
const title = info.path ? `${owner}/${repo} - ${info.path}` : `${owner}/${repo}`;
|
|
190
|
+
return {
|
|
191
|
+
url,
|
|
192
|
+
title,
|
|
193
|
+
content: lines.join("\n"),
|
|
194
|
+
error: null,
|
|
195
|
+
};
|
|
196
|
+
}
|