@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.
@@ -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
- age: string;
7
+ age: string;
8
8
  }
9
9
 
10
10
  export async function fetchBraveResults(
11
- query: string,
12
- count: number,
13
- country: string,
14
- freshness: string,
15
- apiKey: string,
16
- signal?: AbortSignal,
11
+ query: string,
12
+ count: number,
13
+ country: string,
14
+ freshness: string,
15
+ apiKey: string,
16
+ signal?: AbortSignal,
17
17
  ): Promise<BraveResult[]> {
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);
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
  }
@@ -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://172.30.55.22:8888";
13
- export const DEFAULT_CRAWL4AI_API_URL = "http://172.30.55.22:11235";
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
- return process.env.PI_CODING_AGENT_DIR
21
- ? [process.env.PI_CODING_AGENT_DIR]
22
- : [path.join(os.homedir(), ".pi", "agent")];
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
- 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
- ];
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
- 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;
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
- 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();
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
- let text: string;
57
- try { text = fs.readFileSync(file, "utf8"); } catch (e: any) { if (e?.code === "ENOENT") return null; throw e; }
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;
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
- 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 };
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
- return typeof ctx?.cwd === "string" && (ctx.cwd as string) ? (ctx.cwd as string) : process.cwd();
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
- return typeof ctx?.isProjectTrusted === "function" ? (ctx.isProjectTrusted as () => boolean)() : false;
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
- 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(/\/+$/, "");
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
- baseUrl: string;
110
- source: string;
109
+ baseUrl: string;
110
+ source: string;
111
111
  }
112
112
 
113
113
  export function normalizeSearxngBaseUrl(raw?: string): string {
114
- return normalizeHttpUrl(raw, DEFAULT_SEARXNG_BASE_URL);
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
- 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 };
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
- baseUrl: string;
131
- apiToken: string;
132
- timeoutMs: number;
130
+ baseUrl: string;
131
+ apiToken: string;
132
+ timeoutMs: number;
133
133
  }
134
134
 
135
135
  export function normalizeCrawl4aiApiUrl(raw?: string): string {
136
- return normalizeHttpUrl(raw, DEFAULT_CRAWL4AI_API_URL);
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
- 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
- const apiToken = explicitApiToken || apiTokenLookup.value || "";
146
- const timeoutValue = (params.timeout_ms as number) || findEnvValue("CRAWL4AI_API_TIMEOUT_MS", cwd, includeCwdEnv).value;
147
- const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
148
- if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("CRAWL4AI_API_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
149
- return { baseUrl, apiToken, timeoutMs };
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
- baseUrl: string;
158
- apiKey: string;
159
- isHosted: boolean;
160
- timeoutMs: number;
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
- let value = normalizeHttpUrl(raw, HOSTED_FIRECRAWL_BASE_URL);
165
- if (!/\/v\d+$/i.test(value)) value += "/v2";
166
- return value;
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
- const apiUrlLookup = findEnvValue("FIRECRAWL_API_URL", cwd, includeCwdEnv);
171
- const apiKeyLookup = findEnvValue("FIRECRAWL_API_KEY", cwd, includeCwdEnv);
172
- const explicitApiKey = (params.firecrawl_api_key as string) || (params.api_key as string);
173
- const apiUrl = (params.firecrawl_api_url as string) || (params.api_url as string) || apiUrlLookup.value;
174
- const apiKey = explicitApiKey || apiKeyLookup.value || "";
175
- const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd, includeCwdEnv).value;
176
- const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
177
- const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
178
- const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
179
- if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("FIRECRAWL_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
180
- if (isHosted && !apiKey) throw new Error("FIRECRAWL_API_KEY is required for hosted Firecrawl.");
181
- return { baseUrl, apiKey, isHosted, timeoutMs };
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
- Readability: any;
10
- JSDOM: any;
11
- TurndownService: any;
12
- gfm: unknown;
9
+ Readability: any;
10
+ JSDOM: any;
11
+ TurndownService: any;
12
+ gfm: unknown;
13
13
  }
14
14
 
15
15
  export function loadReadableContentDependencies(): ReadableContentDeps {
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
- }
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
- 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();
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
- url: string,
49
- timeoutMs = 15000,
50
- signal?: AbortSignal,
48
+ url: string,
49
+ timeoutMs = 15000,
50
+ signal?: AbortSignal,
51
51
  ): Promise<{ title: string; markdown: string }> {
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
- const article = new deps.Readability(dom.window.document).parse();
66
- if (article?.content) {
67
- return { title: article.title || "", markdown: htmlToMarkdown(article.content, deps) };
68
- }
69
- // Fallback: strip non-content elements and use main/article/body
70
- const fallbackDoc = new deps.JSDOM(html, { url });
71
- const doc = fallbackDoc.window.document;
72
- doc.querySelectorAll("script, style, noscript, nav, header, footer, aside").forEach((el: any) => el.remove());
73
- const title = doc.querySelector("title")?.textContent?.trim() || "";
74
- const main = doc.querySelector("main, article, [role='main'], .content, #content") || doc.body;
75
- const fallbackHtml = main?.innerHTML || "";
76
- if (fallbackHtml.trim().length <= 100) throw new Error("Could not extract readable content from this page.");
77
- return { title, markdown: htmlToMarkdown(fallbackHtml, deps) };
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
  }