@bacnh85/pi-web 0.5.2 → 0.5.3

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.
@@ -9,8 +9,8 @@ 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
@@ -54,7 +54,7 @@ export function parseDotenvValue(rawValue: string): string {
54
54
 
55
55
  export function parseDotenvFile(file: string): Record<string, string> | null {
56
56
  let text: string;
57
- try { text = fs.readFileSync(file, "utf8"); } catch (e: any) { if (e?.code === "ENOENT") return null; throw e; }
57
+ try { text = fs.readFileSync(file, "utf8"); } catch { return null; }
58
58
  const values: Record<string, string> = {};
59
59
  for (const rawLine of text.split(/\r?\n/)) {
60
60
  const line = rawLine.trim();
@@ -142,7 +142,10 @@ export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = p
142
142
  const explicitApiUrl = params.crawl4ai_api_url as string | undefined;
143
143
  const explicitApiToken = params.crawl4ai_api_token as string | undefined;
144
144
  const baseUrl = normalizeCrawl4aiApiUrl(explicitApiUrl || apiUrlLookup.value);
145
- const apiToken = explicitApiToken || apiTokenLookup.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) || "";
146
149
  const timeoutValue = (params.timeout_ms as number) || findEnvValue("CRAWL4AI_API_TIMEOUT_MS", cwd, includeCwdEnv).value;
147
150
  const timeoutMs = timeoutValue ? Number.parseInt(String(timeoutValue), 10) : 60000;
148
151
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) throw new Error("CRAWL4AI_API_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
@@ -171,7 +174,10 @@ export function loadFirecrawlConfig(params: Record<string, unknown> = {}, cwd =
171
174
  const apiKeyLookup = findEnvValue("FIRECRAWL_API_KEY", cwd, includeCwdEnv);
172
175
  const explicitApiKey = (params.firecrawl_api_key as string) || (params.api_key as string);
173
176
  const apiUrl = (params.firecrawl_api_url as string) || (params.api_url as string) || apiUrlLookup.value;
174
- const apiKey = explicitApiKey || apiKeyLookup.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) || "";
175
181
  const timeoutValue = (params.timeout_ms as number) || findEnvValue("FIRECRAWL_TIMEOUT_MS", cwd, includeCwdEnv).value;
176
182
  const baseUrl = normalizeFirecrawlBaseUrl(apiUrl);
177
183
  const isHosted = !apiUrl || baseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
@@ -62,17 +62,25 @@ export async function fetchReadableContent(
62
62
  const html = await response.text();
63
63
  const deps = loadReadableContentDependencies();
64
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) };
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();
68
72
  }
69
73
  // Fallback: strip non-content elements and use main/article/body
70
74
  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) };
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
  }
@@ -50,14 +50,11 @@ export function sanitizeError(error: unknown): string {
50
50
 
51
51
  export function truncateText(text: string): string {
52
52
  const lines = text.split("\n");
53
- let output = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
54
- while (Buffer.byteLength(output, "utf8") > OUTPUT_MAX_BYTES) {
55
- output = output.slice(0, Math.max(0, output.length - 1024));
56
- }
57
- if (lines.length > OUTPUT_MAX_LINES || output.length < text.length) {
58
- output += `\n\n[Web output truncated to ${OUTPUT_MAX_LINES} lines / ${OUTPUT_MAX_BYTES} bytes.]`;
59
- }
60
- return output;
53
+ const lineTruncated = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
54
+ const buf = Buffer.from(lineTruncated, "utf8");
55
+ if (buf.length <= OUTPUT_MAX_BYTES && lines.length <= OUTPUT_MAX_LINES) return text;
56
+ const truncated = buf.slice(0, OUTPUT_MAX_BYTES).toString("utf8").replace(/\uFFFD+$/g, "");
57
+ return truncated + `\n\n[Web output truncated to ${OUTPUT_MAX_LINES} lines / ${OUTPUT_MAX_BYTES} bytes.]`;
61
58
  }
62
59
 
63
60
  // ---------------------------------------------------------------------------
@@ -128,16 +128,22 @@ async function searchBrave(params: SearchParams, backends: BackendConfig): Promi
128
128
  }));
129
129
  if (params.include_content && results.length > 0) {
130
130
  const maxChars = params.content_chars ?? 5000;
131
- await Promise.all(
132
- results.map(async (r) => {
133
- try {
134
- const article = await fetchReadableContent(r.url, params.timeout_ms ?? 10000, params.signal);
135
- r.content = article.markdown.slice(0, maxChars);
136
- } catch (e: any) {
137
- r.content = `(Fetch error for ${r.url}: ${sanitizeError(e)})`;
138
- }
139
- }),
140
- );
131
+ // Cap concurrent content fetches to avoid OOM from parallel JSDOM parsing
132
+ const MAX_CONCURRENT_FETCHES = 5;
133
+ const batchSize = Math.min(results.length, MAX_CONCURRENT_FETCHES);
134
+ for (let i = 0; i < results.length; i += batchSize) {
135
+ const batch = results.slice(i, i + batchSize);
136
+ await Promise.all(
137
+ batch.map(async (r) => {
138
+ try {
139
+ const article = await fetchReadableContent(r.url, params.timeout_ms ?? 10000, params.signal);
140
+ r.content = article.markdown.slice(0, maxChars);
141
+ } catch (e: any) {
142
+ r.content = `(Fetch error for ${r.url}: ${sanitizeError(e)})`;
143
+ }
144
+ }),
145
+ );
146
+ }
141
147
  }
142
148
  return results;
143
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-web",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Pi extension for web search, page extraction, Firecrawl scraping/crawling, and Crawl4AI headless browser crawling.",
5
5
  "type": "module",
6
6
  "license": "MIT",