@bacnh85/pi-web 0.5.2 → 0.5.4
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/extensions/index.ts +368 -368
- package/extensions/lib/brave.ts +30 -30
- package/extensions/lib/config.ts +99 -93
- package/extensions/lib/content.ts +67 -59
- package/extensions/lib/crawl4ai.ts +97 -97
- package/extensions/lib/extract.ts +137 -137
- package/extensions/lib/firecrawl.ts +28 -28
- package/extensions/lib/format.ts +138 -141
- package/extensions/lib/retry.ts +32 -32
- package/extensions/lib/search.ts +174 -168
- package/extensions/lib/searxng.ts +49 -49
- package/extensions/types.d.ts +1 -1
- package/package.json +1 -1
package/extensions/lib/brave.ts
CHANGED
|
@@ -4,38 +4,38 @@ import { sanitizeSnippet, type SearchResultItem } from "./format";
|
|
|
4
4
|
import { withRetry } from "./retry";
|
|
5
5
|
|
|
6
6
|
export interface BraveResult extends SearchResultItem {
|
|
7
|
-
|
|
7
|
+
age: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export async function fetchBraveResults(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
query: string,
|
|
12
|
+
count: number,
|
|
13
|
+
country: string,
|
|
14
|
+
freshness: string,
|
|
15
|
+
apiKey: string,
|
|
16
|
+
signal?: AbortSignal,
|
|
17
17
|
): Promise<BraveResult[]> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
18
|
+
return withRetry(async () => {
|
|
19
|
+
if (signal?.aborted) throw signal.reason ?? new Error("Operation aborted.");
|
|
20
|
+
const params = new URLSearchParams({ q: query, count: String(count), country });
|
|
21
|
+
if (freshness) params.append("freshness", freshness);
|
|
22
|
+
const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, {
|
|
23
|
+
headers: {
|
|
24
|
+
Accept: "application/json",
|
|
25
|
+
"Accept-Encoding": "gzip",
|
|
26
|
+
"X-Subscription-Token": apiKey,
|
|
27
|
+
},
|
|
28
|
+
signal,
|
|
29
|
+
});
|
|
30
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}\n${await response.text()}`);
|
|
31
|
+
const data: any = await response.json();
|
|
32
|
+
return (data.web?.results || [])
|
|
33
|
+
.slice(0, count)
|
|
34
|
+
.map((r: any) => ({
|
|
35
|
+
title: sanitizeSnippet(r.title || ""),
|
|
36
|
+
url: r.url || "",
|
|
37
|
+
snippet: sanitizeSnippet(r.description || ""),
|
|
38
|
+
age: sanitizeSnippet(r.age || r.page_age || ""),
|
|
39
|
+
}));
|
|
40
|
+
}, undefined, signal);
|
|
41
41
|
}
|
package/extensions/lib/config.ts
CHANGED
|
@@ -9,70 +9,70 @@ import path from "node:path";
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
11
|
export const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
|
|
12
|
-
export const DEFAULT_SEARXNG_BASE_URL = "http://
|
|
13
|
-
export const DEFAULT_CRAWL4AI_API_URL = "http://
|
|
12
|
+
export const DEFAULT_SEARXNG_BASE_URL = "http://127.0.0.1:8888";
|
|
13
|
+
export const DEFAULT_CRAWL4AI_API_URL = "http://127.0.0.1:11235";
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Environment loading
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
|
|
19
19
|
export function piConfigDirs(): string[] {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
return process.env.PI_CODING_AGENT_DIR
|
|
21
|
+
? [process.env.PI_CODING_AGENT_DIR]
|
|
22
|
+
: [path.join(os.homedir(), ".pi", "agent")];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function envFileCandidates(cwd = process.cwd(), includeCwd = true): string[] {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
return [
|
|
27
|
+
...(includeCwd ? [path.resolve(cwd, ".env.local"), path.resolve(cwd, ".env")] : []),
|
|
28
|
+
...piConfigDirs().flatMap((dir) => [path.join(dir, ".env.local"), path.join(dir, ".env")]),
|
|
29
|
+
];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function stripInlineComment(value: string): string {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
let quote = "";
|
|
34
|
+
let escaped = false;
|
|
35
|
+
for (let i = 0; i < value.length; i++) {
|
|
36
|
+
const char = value[i];
|
|
37
|
+
if (escaped) { escaped = false; continue; }
|
|
38
|
+
if (char === "\\") { escaped = true; continue; }
|
|
39
|
+
if (quote) { if (char === quote) quote = ""; continue; }
|
|
40
|
+
if (char === '"' || char === "'") { quote = char; continue; }
|
|
41
|
+
if (char === "#" && (i === 0 || /\s/.test(value[i - 1]))) return value.slice(0, i);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export function parseDotenvValue(rawValue: string): string {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
let value = stripInlineComment(rawValue).trim();
|
|
48
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
49
|
+
value = value.slice(1, -1);
|
|
50
|
+
return value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
|
|
51
|
+
}
|
|
52
|
+
return value.trim();
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export function parseDotenvFile(file: string): Record<string, string> | null {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
let text: string;
|
|
57
|
+
try { text = fs.readFileSync(file, "utf8"); } catch { return null; }
|
|
58
|
+
const values: Record<string, string> = {};
|
|
59
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
60
|
+
const line = rawLine.trim();
|
|
61
|
+
if (!line || line.startsWith("#")) continue;
|
|
62
|
+
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
63
|
+
if (match) values[match[1]] = parseDotenvValue(match[2]);
|
|
64
|
+
}
|
|
65
|
+
return values;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export function findEnvValue(name: string, cwd = process.cwd(), includeCwd = true): { value?: string; source: string; checkedFiles: string[] } {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
if (process.env[name]) return { value: process.env[name], source: "process.env", checkedFiles: [] };
|
|
70
|
+
const checkedFiles = envFileCandidates(cwd, includeCwd);
|
|
71
|
+
for (const file of checkedFiles) {
|
|
72
|
+
const parsed = parseDotenvFile(file);
|
|
73
|
+
if (parsed?.[name]) return { value: parsed[name], source: file, checkedFiles };
|
|
74
|
+
}
|
|
75
|
+
return { value: undefined, source: "", checkedFiles };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// ---------------------------------------------------------------------------
|
|
@@ -80,11 +80,11 @@ export function findEnvValue(name: string, cwd = process.cwd(), includeCwd = tru
|
|
|
80
80
|
// ---------------------------------------------------------------------------
|
|
81
81
|
|
|
82
82
|
export function cwdFromContext(ctx: Record<string, unknown>): string {
|
|
83
|
-
|
|
83
|
+
return typeof ctx?.cwd === "string" && (ctx.cwd as string) ? (ctx.cwd as string) : process.cwd();
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
export function includeProjectEnv(ctx: Record<string, unknown>): boolean {
|
|
87
|
-
|
|
87
|
+
return typeof ctx?.isProjectTrusted === "function" ? (ctx.isProjectTrusted as () => boolean)() : false;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
// ---------------------------------------------------------------------------
|
|
@@ -92,13 +92,13 @@ export function includeProjectEnv(ctx: Record<string, unknown>): boolean {
|
|
|
92
92
|
// ---------------------------------------------------------------------------
|
|
93
93
|
|
|
94
94
|
function normalizeHttpUrl(raw: string | undefined, fallback: string): string {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
let value = (raw || fallback).trim();
|
|
96
|
+
if (!/^https?:\/\//i.test(value)) {
|
|
97
|
+
value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
|
|
98
|
+
? `http://${value}`
|
|
99
|
+
: `https://${value}`;
|
|
100
|
+
}
|
|
101
|
+
return value.replace(/\/+$/, "");
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// ---------------------------------------------------------------------------
|
|
@@ -106,20 +106,20 @@ function normalizeHttpUrl(raw: string | undefined, fallback: string): string {
|
|
|
106
106
|
// ---------------------------------------------------------------------------
|
|
107
107
|
|
|
108
108
|
export interface SearxngConfig {
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
baseUrl: string;
|
|
110
|
+
source: string;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
export function normalizeSearxngBaseUrl(raw?: string): string {
|
|
114
|
-
|
|
114
|
+
return normalizeHttpUrl(raw, DEFAULT_SEARXNG_BASE_URL);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
export function loadSearxngConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): SearxngConfig {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
const baseUrlEnv = findEnvValue("SEARXNG_BASE_URL", cwd, includeCwdEnv);
|
|
119
|
+
const explicitBaseUrl = params.searxng_base_url as string | undefined;
|
|
120
|
+
const baseUrl = normalizeSearxngBaseUrl(explicitBaseUrl || (params.base_url as string) || baseUrlEnv.value);
|
|
121
|
+
const source = explicitBaseUrl || params.base_url ? "tool parameter" : baseUrlEnv.source || "default local";
|
|
122
|
+
return { baseUrl, source };
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
// ---------------------------------------------------------------------------
|
|
@@ -127,26 +127,29 @@ export function loadSearxngConfig(params: Record<string, unknown> = {}, cwd = pr
|
|
|
127
127
|
// ---------------------------------------------------------------------------
|
|
128
128
|
|
|
129
129
|
export interface Crawl4aiConfig {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
baseUrl: string;
|
|
131
|
+
apiToken: string;
|
|
132
|
+
timeoutMs: number;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
export function normalizeCrawl4aiApiUrl(raw?: string): string {
|
|
136
|
-
|
|
136
|
+
return normalizeHttpUrl(raw, DEFAULT_CRAWL4AI_API_URL);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): Crawl4aiConfig {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
140
|
+
const apiUrlLookup = findEnvValue("CRAWL4AI_API_URL", cwd, includeCwdEnv);
|
|
141
|
+
const apiTokenLookup = findEnvValue("CRAWL4AI_API_TOKEN", cwd, includeCwdEnv);
|
|
142
|
+
const explicitApiUrl = params.crawl4ai_api_url as string | undefined;
|
|
143
|
+
const explicitApiToken = params.crawl4ai_api_token as string | undefined;
|
|
144
|
+
const baseUrl = normalizeCrawl4aiApiUrl(explicitApiUrl || apiUrlLookup.value);
|
|
145
|
+
// Security: only use env-var API token when URL is default or also from env var.
|
|
146
|
+
// If the URL is overridden via params but no explicit token was provided, don't send
|
|
147
|
+
// the env-var token to a potentially attacker-controlled URL.
|
|
148
|
+
const apiToken = explicitApiToken || (explicitApiUrl ? "" : apiTokenLookup.value) || "";
|
|
149
|
+
const timeoutValue = (params.timeout_ms as number) || findEnvValue("CRAWL4AI_API_TIMEOUT_MS", cwd, includeCwdEnv).value;
|
|
150
|
+
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
151
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("CRAWL4AI_API_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
|
|
152
|
+
return { baseUrl, apiToken, timeoutMs };
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
// ---------------------------------------------------------------------------
|
|
@@ -154,29 +157,32 @@ export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = p
|
|
|
154
157
|
// ---------------------------------------------------------------------------
|
|
155
158
|
|
|
156
159
|
export interface FirecrawlConfig {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
baseUrl: string;
|
|
161
|
+
apiKey: string;
|
|
162
|
+
isHosted: boolean;
|
|
163
|
+
timeoutMs: number;
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
export function normalizeFirecrawlBaseUrl(raw?: string): string {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
let value = normalizeHttpUrl(raw, HOSTED_FIRECRAWL_BASE_URL);
|
|
168
|
+
if (!/\/v\d+$/i.test(value)) value += "/v2";
|
|
169
|
+
return value;
|
|
167
170
|
}
|
|
168
171
|
|
|
169
172
|
export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): FirecrawlConfig {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
173
|
+
const apiUrlLookup = findEnvValue("FIRECRAWL_API_URL", cwd, includeCwdEnv);
|
|
174
|
+
const apiKeyLookup = findEnvValue("FIRECRAWL_API_KEY", cwd, includeCwdEnv);
|
|
175
|
+
const explicitApiKey = (params.firecrawl_api_key as string) || (params.api_key as string);
|
|
176
|
+
const apiUrl = (params.firecrawl_api_url as string) || (params.api_url as string) || apiUrlLookup.value;
|
|
177
|
+
// Security: only use env-var API key when URL is default or also from env var.
|
|
178
|
+
// If the URL is overridden via params but no explicit key was provided, don't send
|
|
179
|
+
// the env-var key to a potentially attacker-controlled URL.
|
|
180
|
+
const apiKey = explicitApiKey || (apiUrl !== apiUrlLookup.value && apiUrl ? "" : apiKeyLookup.value) || "";
|
|
181
|
+
const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd, includeCwdEnv).value;
|
|
182
|
+
const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
|
|
183
|
+
const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
|
|
184
|
+
const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
|
|
185
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("FIRECRAWL_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
|
|
186
|
+
if (isHosted && !apiKey) throw new Error("FIRECRAWL_API_KEY is required for hosted Firecrawl.");
|
|
187
|
+
return { baseUrl, apiKey, isHosted, timeoutMs };
|
|
182
188
|
}
|
|
@@ -6,73 +6,81 @@ import { signalWithTimeout } from "./retry";
|
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
7
|
|
|
8
8
|
export interface ReadableContentDeps {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
Readability: any;
|
|
10
|
+
JSDOM: any;
|
|
11
|
+
TurndownService: any;
|
|
12
|
+
gfm: unknown;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function loadReadableContentDependencies(): ReadableContentDeps {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
16
|
+
try {
|
|
17
|
+
return {
|
|
18
|
+
Readability: require("@mozilla/readability").Readability,
|
|
19
|
+
JSDOM: require("jsdom").JSDOM,
|
|
20
|
+
TurndownService: require("turndown"),
|
|
21
|
+
gfm: require("turndown-plugin-gfm").gfm,
|
|
22
|
+
};
|
|
23
|
+
} catch (error: any) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Readable content extraction dependencies are missing. Install pi-web dependencies with \`npm install\` in the pi-web extension directory, or install the package with Pi so runtime dependencies are installed. Original error: ${error?.message || error}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export function htmlToMarkdown(html: string, deps = loadReadableContentDependencies()): string {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
const turndown = new deps.TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
|
|
32
|
+
turndown.use(deps.gfm as any);
|
|
33
|
+
turndown.addRule("removeEmptyLinks", {
|
|
34
|
+
filter: (node: any) => node.nodeName === "A" && !node.textContent?.trim(),
|
|
35
|
+
replacement: () => "",
|
|
36
|
+
});
|
|
37
|
+
return turndown
|
|
38
|
+
.turndown(html)
|
|
39
|
+
.replace(/\[\\?\[\s*\\?\]\]\([^)]*\)/g, "")
|
|
40
|
+
.replace(/ +/g, " ")
|
|
41
|
+
.replace(/\s+,/g, ",")
|
|
42
|
+
.replace(/\s+\./g, ".")
|
|
43
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
44
|
+
.trim();
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export async function fetchReadableContent(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
url: string,
|
|
49
|
+
timeoutMs = 15000,
|
|
50
|
+
signal?: AbortSignal,
|
|
51
51
|
): Promise<{ title: string; markdown: string }> {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
52
|
+
const response = await fetch(url, {
|
|
53
|
+
headers: {
|
|
54
|
+
"User-Agent":
|
|
55
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36",
|
|
56
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
57
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
58
|
+
},
|
|
59
|
+
signal: signalWithTimeout(timeoutMs, signal),
|
|
60
|
+
});
|
|
61
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
62
|
+
const html = await response.text();
|
|
63
|
+
const deps = loadReadableContentDependencies();
|
|
64
|
+
const dom = new deps.JSDOM(html, { url });
|
|
65
|
+
try {
|
|
66
|
+
const article = new deps.Readability(dom.window.document).parse();
|
|
67
|
+
if (article?.content) {
|
|
68
|
+
return { title: article.title || "", markdown: htmlToMarkdown(article.content, deps) };
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
dom.window.close();
|
|
72
|
+
}
|
|
73
|
+
// Fallback: strip non-content elements and use main/article/body
|
|
74
|
+
const fallbackDoc = new deps.JSDOM(html, { url });
|
|
75
|
+
try {
|
|
76
|
+
const doc = fallbackDoc.window.document;
|
|
77
|
+
doc.querySelectorAll("script, style, noscript, nav, header, footer, aside").forEach((el: any) => el.remove());
|
|
78
|
+
const title = doc.querySelector("title")?.textContent?.trim() || "";
|
|
79
|
+
const main = doc.querySelector("main, article, [role='main'], .content, #content") || doc.body;
|
|
80
|
+
const fallbackHtml = main?.innerHTML || "";
|
|
81
|
+
if (fallbackHtml.trim().length <= 100) throw new Error("Could not extract readable content from this page.");
|
|
82
|
+
return { title, markdown: htmlToMarkdown(fallbackHtml, deps) };
|
|
83
|
+
} finally {
|
|
84
|
+
fallbackDoc.window.close();
|
|
85
|
+
}
|
|
78
86
|
}
|