@bacnh85/pi-web 0.2.1 → 0.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-web
2
2
 
3
- Pi extension for web search, readable page extraction, and Firecrawl scraping/crawling.
3
+ Pi extension for web search, readable page extraction, Firecrawl scraping/crawling, and Crawl4AI headless browser crawling.
4
4
 
5
5
  ## Install
6
6
 
@@ -43,6 +43,9 @@ Variables:
43
43
  - `FIRECRAWL_API_URL` — optional; defaults to `https://api.firecrawl.dev/v2`.
44
44
  - `FIRECRAWL_API_KEY` — required for hosted Firecrawl, optional for self-hosted instances without auth.
45
45
  - `FIRECRAWL_TIMEOUT_MS` — optional request timeout, default `60000`.
46
+ - `CRAWL4AI_BASE_URL` — optional; defaults to `http://172.30.55.22:11235`.
47
+ - `CRAWL4AI_API_TOKEN` — required if Crawl4AI server has auth enabled (v0.9+ default).
48
+ - `CRAWL4AI_TIMEOUT_MS` — optional request timeout, default `60000`.
46
49
 
47
50
  Secrets are never printed; status reports only show presence/source.
48
51
 
@@ -67,9 +70,18 @@ Firecrawl:
67
70
  - `firecrawl_map` — discover URLs for a site.
68
71
  - `firecrawl_crawl` — start a conservative crawl, optionally polling for results.
69
72
 
73
+ Crawl4AI (requires running Docker server at `CRAWL4AI_BASE_URL`):
74
+
75
+ - `crawl4ai_scrape` — extract clean LLM-ready markdown with content filtering (fit/raw/bm25/llm).
76
+ - `crawl4ai_crawl` — batch crawl 1-100 URLs with full result data (HTML, markdown, links, media).
77
+ - `crawl4ai_stream` — streaming crawl via SSE-style newline-delimited JSON.
78
+ - `crawl4ai_screenshot` — full-page PNG screenshot capture.
79
+ - `crawl4ai_pdf` — PDF document generation.
80
+ - `crawl4ai_status` — check Crawl4AI server health, version, and auth status.
81
+
70
82
  Utility:
71
83
 
72
- - `web_status` — show configured provider status without secrets.
84
+ - `web_status` — show all provider configuration status without secrets.
73
85
  - `/web-status` — command version of the status check.
74
86
 
75
87
  ## Library structure
@@ -85,10 +97,11 @@ The extension is split into `lib/` modules:
85
97
  | `lib/brave.ts` | Brave Search API fetch client |
86
98
  | `lib/searxng.ts` | SearXNG metasearch fetch client |
87
99
  | `lib/firecrawl.ts` | Firecrawl API fetch client with v2→v1 fallback |
100
+ | `lib/crawl4ai.ts` | Crawl4AI Docker API fetch client (scrape, crawl, stream, screenshot, PDF, health) |
88
101
 
89
102
  ## Retry behavior
90
103
 
91
- All HTTP-backed tools (`brave_search`, `searxng_search`, `firecrawl_*`) use a `withRetry` wrapper with exponential backoff (up to 2 retries, ~1s initial delay). Non-transient errors (auth, validation, not found) are never retried.
104
+ All HTTP-backed tools (`brave_search`, `searxng_search`, `firecrawl_*`, `crawl4ai_*`) use a `withRetry` wrapper with exponential backoff (up to 2 retries, ~1s initial delay). Non-transient errors (auth, validation, not found) are never retried.
92
105
 
93
106
  ## Migration from 0.1.x
94
107
 
@@ -113,5 +126,10 @@ npm run test:unit
113
126
  - Use `firecrawl_scrape` for known URLs when extraction quality matters, pages are dynamic, links are needed, or structured JSON extraction is requested.
114
127
  - Use `firecrawl_map` before crawling to discover candidate URLs and keep crawls targeted.
115
128
  - Use `firecrawl_crawl` only when multiple pages are required; keep limits conservative and use include/exclude paths.
129
+ - Use `crawl4ai_scrape` for JavaScript-rendered pages and when you need high-quality LLM-ready markdown.
130
+ - Use `crawl4ai_crawl` when you need full crawl data (HTML, links, media) from multiple URLs.
131
+ - Use `crawl4ai_screenshot` or `crawl4ai_pdf` for visual/printable snapshots of rendered pages.
132
+ - Prefer `web_content` or `firecrawl_scrape` over Crawl4AI for simple static pages — Crawl4AI launches a full headless browser and is more resource-intensive.
116
133
  - When answering from web content, cite source URLs from result links or metadata.
117
134
  - Use `web_status` when provider configuration is uncertain or a web tool fails due to credentials/config.
135
+ - Use `crawl4ai_status` when Crawl4AI-specific connection issues occur.
package/index.ts CHANGED
@@ -9,15 +9,33 @@ import {
9
9
  includeProjectEnv,
10
10
  normalizeSearxngBaseUrl,
11
11
  normalizeFirecrawlBaseUrl,
12
+ normalizeCrawl4aiBaseUrl,
12
13
  loadSearxngConfig,
13
14
  loadFirecrawlConfig,
15
+ loadCrawl4aiConfig,
14
16
  HOSTED_FIRECRAWL_BASE_URL,
17
+ DEFAULT_CRAWL4AI_BASE_URL,
15
18
  } from "./lib/config";
16
- import { truncateText, formatSearchResults, formatFirecrawlScrape, formatFirecrawlSearch } from "./lib/format";
19
+ import {
20
+ truncateText,
21
+ formatSearchResults,
22
+ formatFirecrawlScrape,
23
+ formatFirecrawlSearch,
24
+ formatCrawl4aiResult,
25
+ formatCrawl4aiStream,
26
+ } from "./lib/format";
17
27
  import { fetchReadableContent } from "./lib/content";
18
28
  import { fetchBraveResults } from "./lib/brave";
19
29
  import { fetchSearxngResults } from "./lib/searxng";
20
30
  import { firecrawlRequest, type FirecrawlResult } from "./lib/firecrawl";
31
+ import {
32
+ fetchCrawl4aiMarkdown,
33
+ fetchCrawl4aiCrawl,
34
+ fetchCrawl4aiStream,
35
+ fetchCrawl4aiScreenshot,
36
+ fetchCrawl4aiPdf,
37
+ fetchCrawl4aiHealth,
38
+ } from "./lib/crawl4ai";
21
39
 
22
40
  // ---------------------------------------------------------------------------
23
41
  // Schema fragments shared across tool registrations
@@ -39,6 +57,12 @@ const searxngControlSchema = {
39
57
  timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
40
58
  };
41
59
 
60
+ const crawl4aiControlSchema = {
61
+ crawl4ai_base_url: Type.Optional(Type.String({ description: "Crawl4AI base URL override. Defaults to CRAWL4AI_BASE_URL or http://172.30.55.22:11235." })),
62
+ crawl4ai_api_token: Type.Optional(Type.String({ description: "Crawl4AI API token override. Prefer CRAWL4AI_API_TOKEN." })),
63
+ timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
64
+ };
65
+
42
66
  // ---------------------------------------------------------------------------
43
67
  // Extension entry point
44
68
  // ---------------------------------------------------------------------------
@@ -361,15 +385,166 @@ export default function piWebExtension(pi: ExtensionAPI) {
361
385
  },
362
386
  });
363
387
 
388
+ // ── Crawl4AI Scrape ──────────────────────────────────────────────────
389
+ pi.registerTool({
390
+ name: "crawl4ai_scrape",
391
+ label: "Crawl4AI Scrape",
392
+ description:
393
+ "Extract clean LLM-ready markdown from a URL using Crawl4AI's headless browser engine, with optional content filtering (fit, raw, bm25, llm). Best for JavaScript-rendered pages and when you need high-quality markdown for AI consumption.",
394
+ promptSnippet: "Scrape with Crawl4AI",
395
+ promptGuidelines: [
396
+ "Prefer crawl4ai_scrape over web_content for dynamic/JS-rendered pages.",
397
+ "Use filter=raw for full DOM-to-markdown, filter=fit (default) for Readability-based clean content.",
398
+ "Use filter=bm25 with a query for relevance-ranked content extraction.",
399
+ ],
400
+ parameters: Type.Object({
401
+ ...crawl4aiControlSchema,
402
+ url: Type.String(),
403
+ filter: Type.Optional(Type.Union([Type.Literal("fit"), Type.Literal("raw"), Type.Literal("bm25"), Type.Literal("llm")], { default: "fit", description: "Content-filter strategy: fit (default), raw, bm25, or llm." })),
404
+ query: Type.Optional(Type.String({ description: "Query string used by BM25 or LLM filters." })),
405
+ content_chars: Type.Optional(Type.Number({ default: 20000 })),
406
+ }),
407
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
408
+ const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
409
+ const filter = (params.filter as string) || "fit";
410
+ const query = params.query as string | undefined;
411
+ const result = await fetchCrawl4aiMarkdown(config, params.url as string, filter, query, signal);
412
+ const text = `# Crawl4AI Markdown (filter: ${filter})\nURL: ${params.url}\n\n${result.markdown.slice(0, (params.content_chars as number) ?? 20000)}`;
413
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
414
+ },
415
+ });
416
+
417
+ // ── Crawl4AI Crawl ──────────────────────────────────────────────────
418
+ pi.registerTool({
419
+ name: "crawl4ai_crawl",
420
+ label: "Crawl4AI Crawl",
421
+ description:
422
+ "Crawl one or more URLs with Crawl4AI's headless browser, returning full crawl data including markdown, HTML, links, and media. Supports up to 100 URLs per request.",
423
+ promptSnippet: "Crawl with Crawl4AI",
424
+ promptGuidelines: [
425
+ "Use crawl4ai_crawl when you need full crawl data (HTML, links, media) from multiple URLs.",
426
+ "For single-URL markdown-only extraction, prefer crawl4ai_scrape.",
427
+ "Keep to 10 or fewer URLs per request for reasonable response times.",
428
+ ],
429
+ parameters: Type.Object({
430
+ ...crawl4aiControlSchema,
431
+ urls: Type.Array(Type.String(), { minItems: 1, maxItems: 100, description: "URLs to crawl (1-100)." }),
432
+ browser_config: Type.Optional(Type.Any({ description: "Optional BrowserConfig JSON object." })),
433
+ crawler_config: Type.Optional(Type.Any({ description: "Optional CrawlerRunConfig JSON object." })),
434
+ content_chars: Type.Optional(Type.Number({ default: 20000 })),
435
+ }),
436
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
437
+ const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
438
+ const urls = params.urls as string[];
439
+ const browserConfig = params.browser_config as Record<string, unknown> | undefined;
440
+ const crawlerConfig = params.crawler_config as Record<string, unknown> | undefined;
441
+ const result = await fetchCrawl4aiCrawl(config, urls, browserConfig, crawlerConfig, signal);
442
+ const text = formatCrawl4aiResult(result as unknown as Record<string, unknown>, (params.content_chars as number) ?? 20000);
443
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: result };
444
+ },
445
+ });
446
+
447
+ // ── Crawl4AI Stream ─────────────────────────────────────────────────
448
+ pi.registerTool({
449
+ name: "crawl4ai_stream",
450
+ label: "Crawl4AI Stream",
451
+ description:
452
+ "Crawl one or more URLs with Crawl4AI using streaming response (SSE-style JSON lines). Results arrive as each URL is processed, useful for long-running multi-URL crawls.",
453
+ promptSnippet: "Stream crawl with Crawl4AI",
454
+ promptGuidelines: [
455
+ "Use crawl4ai_stream for large batch crawls where you want results incrementally.",
456
+ "The response is newline-delimited JSON; each line is a complete CrawlResult.",
457
+ "The stream ends with a line containing {\"status\": \"completed\"}.",
458
+ ],
459
+ parameters: Type.Object({
460
+ ...crawl4aiControlSchema,
461
+ urls: Type.Array(Type.String(), { minItems: 1, maxItems: 100, description: "URLs to crawl (1-100)." }),
462
+ browser_config: Type.Optional(Type.Any({ description: "Optional BrowserConfig JSON object." })),
463
+ crawler_config: Type.Optional(Type.Any({ description: "Optional CrawlerRunConfig JSON object." })),
464
+ }),
465
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
466
+ const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
467
+ const urls = params.urls as string[];
468
+ const browserConfig = params.browser_config as Record<string, unknown> | undefined;
469
+ const crawlerConfig = params.crawler_config as Record<string, unknown> | undefined;
470
+ const raw = await fetchCrawl4aiStream(config, urls, browserConfig, crawlerConfig, signal);
471
+ const text = formatCrawl4aiStream(raw);
472
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: { raw_length: raw.length } };
473
+ },
474
+ });
475
+
476
+ // ── Crawl4AI Screenshot ─────────────────────────────────────────────
477
+ pi.registerTool({
478
+ name: "crawl4ai_screenshot",
479
+ label: "Crawl4AI Screenshot",
480
+ description:
481
+ "Capture a full-page PNG screenshot of a URL using Crawl4AI's headless browser. Returns a base64-encoded screenshot image.",
482
+ promptSnippet: "Screenshot with Crawl4AI",
483
+ promptGuidelines: [
484
+ "Use crawl4ai_screenshot when you need a visual snapshot of a rendered page.",
485
+ "The screenshot is returned as a base64-encoded PNG string.",
486
+ "Use the wait_for parameter to delay capture for dynamic content to render.",
487
+ ],
488
+ parameters: Type.Object({
489
+ ...crawl4aiControlSchema,
490
+ url: Type.String(),
491
+ wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before capturing screenshot." })),
492
+ wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images to load before capture." })),
493
+ }),
494
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
495
+ const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
496
+ const result = await fetchCrawl4aiScreenshot(config, params.url as string, params.wait_for as number | undefined, params.wait_for_images as boolean | undefined, signal);
497
+ const screenshot = result.screenshot as string | undefined;
498
+ const artifactUrl = result.url as string | undefined;
499
+ const mime = result.mime as string | undefined;
500
+ const size = result.size as number | undefined;
501
+ let text = `Screenshot: ${params.url}\n`;
502
+ if (screenshot) text += `Data: base64 PNG (${screenshot.length} chars)\n`;
503
+ if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
504
+ if (mime) text += `MIME: ${mime}\n`;
505
+ if (size) text += `Size: ${size} bytes\n`;
506
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...result, url: params.url } };
507
+ },
508
+ });
509
+
510
+ // ── Crawl4AI PDF ────────────────────────────────────────────────────
511
+ pi.registerTool({
512
+ name: "crawl4ai_pdf",
513
+ label: "Crawl4AI PDF",
514
+ description:
515
+ "Generate a PDF document of a URL using Crawl4AI's headless browser. Returns a base64-encoded PDF suitable for saving or further processing.",
516
+ promptSnippet: "PDF with Crawl4AI",
517
+ promptGuidelines: [
518
+ "Use crawl4ai_pdf when you need a printable or archivable snapshot of a page.",
519
+ "The PDF is returned as a base64-encoded string.",
520
+ ],
521
+ parameters: Type.Object({
522
+ ...crawl4aiControlSchema,
523
+ url: Type.String(),
524
+ }),
525
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
526
+ const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
527
+ const result = await fetchCrawl4aiPdf(config, params.url as string, signal);
528
+ const pdf = result.pdf as string | undefined;
529
+ const artifactUrl = result.url as string | undefined;
530
+ const size = result.size as number | undefined;
531
+ let text = `PDF: ${params.url}\n`;
532
+ if (pdf) text += `Data: base64 PDF (${pdf.length} chars)\n`;
533
+ if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
534
+ if (size) text += `Size: ${size} bytes\n`;
535
+ return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...result, url: params.url } };
536
+ },
537
+ });
538
+
364
539
  // ── Web Status ───────────────────────────────────────────────────────
365
540
  pi.registerTool({
366
541
  name: "web_status",
367
542
  label: "Web Provider Status",
368
543
  description:
369
- "Show Brave, SearXNG, and Firecrawl configuration status without printing secrets.",
544
+ "Show Brave, SearXNG, Firecrawl, and Crawl4AI configuration status without printing secrets.",
370
545
  promptSnippet: "Check web provider configuration",
371
546
  promptGuidelines: [
372
- "Use when web tools fail due to missing credentials/config or before comparing Brave, SearXNG, and Firecrawl availability.",
547
+ "Use when web tools fail due to missing credentials/config or before comparing Brave, SearXNG, Firecrawl, and Crawl4AI availability.",
373
548
  "Never print API key values; this tool reports only presence and source.",
374
549
  ],
375
550
  parameters: Type.Object({}),
@@ -380,6 +555,8 @@ export default function piWebExtension(pi: ExtensionAPI) {
380
555
  const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
381
556
  const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
382
557
  const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
558
+ const c4aiUrl = findEnvValue("CRAWL4AI_BASE_URL", cwd, trusted);
559
+ const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
383
560
  const status = {
384
561
  brave: { apiKeyFound: Boolean(braveKey.value), apiKeySource: braveKey.value ? braveKey.source : "not set" },
385
562
  searxng: {
@@ -393,11 +570,46 @@ export default function piWebExtension(pi: ExtensionAPI) {
393
570
  apiKeySource: fireKey.value ? fireKey.source : "not set",
394
571
  hostedMode: !fireUrl.value || normalizeFirecrawlBaseUrl(fireUrl.value).startsWith(HOSTED_FIRECRAWL_BASE_URL),
395
572
  },
573
+ crawl4ai: {
574
+ baseUrl: normalizeCrawl4aiBaseUrl(c4aiUrl.value),
575
+ baseUrlSource: c4aiUrl.source || "default",
576
+ apiTokenFound: Boolean(c4aiToken.value),
577
+ apiTokenSource: c4aiToken.value ? c4aiToken.source : "not set",
578
+ },
396
579
  };
397
580
  return { content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }], details: status };
398
581
  },
399
582
  });
400
583
 
584
+ // ── Crawl4AI Status ─────────────────────────────────────────────────
585
+ pi.registerTool({
586
+ name: "crawl4ai_status",
587
+ label: "Crawl4AI Server Status",
588
+ description:
589
+ "Check Crawl4AI server health and version. Connects to the configured Crawl4AI endpoint to verify availability.",
590
+ promptSnippet: "Check Crawl4AI server status",
591
+ promptGuidelines: [
592
+ "Use crawl4ai_status when Crawl4AI tools return connection errors.",
593
+ "Shows server version, response timestamp, and whether auth is configured.",
594
+ ],
595
+ parameters: Type.Object({
596
+ ...crawl4aiControlSchema,
597
+ }),
598
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
599
+ const config = loadCrawl4aiConfig(params, cwdFromContext(ctx), includeProjectEnv(ctx));
600
+ const health = await fetchCrawl4aiHealth(config, signal);
601
+ const text = [
602
+ `Crawl4AI Server Status`,
603
+ `Endpoint: ${config.baseUrl}`,
604
+ `Status: ${health.status || "unknown"}`,
605
+ `Version: ${health.version || "unknown"}`,
606
+ `Timestamp: ${health.timestamp ? new Date(health.timestamp * 1000).toISOString() : "unknown"}`,
607
+ `Auth token: ${config.apiToken ? "configured" : "not set (loopback only)"}`,
608
+ ].join("\n");
609
+ return { content: [{ type: "text" as const, text: text }], details: { config: { baseUrl: config.baseUrl, apiTokenConfigured: Boolean(config.apiToken) }, health } };
610
+ },
611
+ });
612
+
401
613
  // ── /web-status command ──────────────────────────────────────────────
402
614
  pi.registerCommand("web-status", {
403
615
  description: "Show web provider configuration status",
@@ -408,8 +620,17 @@ export default function piWebExtension(pi: ExtensionAPI) {
408
620
  const searxngUrl = findEnvValue("SEARXNG_BASE_URL", cwd, trusted);
409
621
  const fireKey = findEnvValue("FIRECRAWL_API_KEY", cwd, trusted);
410
622
  const fireUrl = findEnvValue("FIRECRAWL_API_URL", cwd, trusted);
623
+ const c4aiUrl = findEnvValue("CRAWL4AI_BASE_URL", cwd, trusted);
624
+ const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
411
625
  ctx.ui.notify(
412
- `Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}\nSearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})\nFirecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}\nFirecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`,
626
+ [
627
+ `Brave API key: ${braveKey.value ? `found (${braveKey.source})` : "not set"}`,
628
+ `SearXNG base URL: ${normalizeSearxngBaseUrl(searxngUrl.value)} (${searxngUrl.source || "default local"})`,
629
+ `Firecrawl base URL: ${normalizeFirecrawlBaseUrl(fireUrl.value)}`,
630
+ `Firecrawl API key: ${fireKey.value ? `found (${fireKey.source})` : "not set"}`,
631
+ `Crawl4AI base URL: ${normalizeCrawl4aiBaseUrl(c4aiUrl.value)} (${c4aiUrl.source || "default"})`,
632
+ `Crawl4AI API token: ${c4aiToken.value ? `found (${c4aiToken.source})` : "not set"}`,
633
+ ].join("\n"),
413
634
  "info",
414
635
  );
415
636
  },
package/lib/config.ts CHANGED
@@ -12,6 +12,7 @@ export const OUTPUT_MAX_BYTES = 50 * 1024;
12
12
  export const OUTPUT_MAX_LINES = 2_000;
13
13
  export const HOSTED_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev/v2";
14
14
  export const DEFAULT_SEARXNG_BASE_URL = "http://172.30.55.22:8888";
15
+ export const DEFAULT_CRAWL4AI_BASE_URL = "http://172.30.55.22:11235";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Environment loading
@@ -115,6 +116,39 @@ export function loadSearxngConfig(params: Record<string, unknown> = {}, cwd = pr
115
116
  return { baseUrl, source };
116
117
  }
117
118
 
119
+ // ---------------------------------------------------------------------------
120
+ // Crawl4AI config
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export interface Crawl4aiConfig {
124
+ baseUrl: string;
125
+ apiToken: string;
126
+ timeoutMs: number;
127
+ }
128
+
129
+ export function normalizeCrawl4aiBaseUrl(raw?: string): string {
130
+ let value = (raw || DEFAULT_CRAWL4AI_BASE_URL).trim();
131
+ if (!/^https?:\/\//i.test(value)) {
132
+ value = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(value)
133
+ ? `http://${value}`
134
+ : `https://${value}`;
135
+ }
136
+ return value.replace(/\/+$/, "");
137
+ }
138
+
139
+ export function loadCrawl4aiConfig(params: Record<string, unknown> = {}, cwd = process.cwd(), includeCwdEnv = false): Crawl4aiConfig {
140
+ const baseUrlLookup = findEnvValue("CRAWL4AI_BASE_URL", cwd, includeCwdEnv);
141
+ const apiTokenLookup = findEnvValue("CRAWL4AI_API_TOKEN", cwd, includeCwdEnv);
142
+ const explicitBaseUrl = params.crawl4ai_base_url as string | undefined;
143
+ const explicitApiToken = params.crawl4ai_api_token as string | undefined;
144
+ const baseUrl = normalizeCrawl4aiBaseUrl(explicitBaseUrl || baseUrlLookup.value);
145
+ const apiToken = explicitApiToken || apiTokenLookup.value || "";
146
+ const timeoutValue = (params.timeout_ms as number) || findEnvValue("CRAWL4AI_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_TIMEOUT_MS/timeout_ms must be an integer >= 1000.");
149
+ return { baseUrl, apiToken, timeoutMs };
150
+ }
151
+
118
152
  // ---------------------------------------------------------------------------
119
153
  // Firecrawl config
120
154
  // ---------------------------------------------------------------------------
@@ -0,0 +1,225 @@
1
+ // Crawl4AI API client.
2
+ // Talks to a self-hosted Crawl4AI Docker server (unclecode/crawl4ai).
3
+ // Endpoints: /crawl, /crawl/stream, /md, /screenshot, /pdf, /health
4
+
5
+ import type { Crawl4aiConfig } from "./config";
6
+ import { signalWithTimeout, withRetry } from "./retry";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Error
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export class Crawl4aiHttpError extends Error {
13
+ status: number;
14
+ constructor(status: number, statusText: string, text: string) {
15
+ super(`HTTP ${status}: ${statusText}${text ? `\n${text}` : ""}`);
16
+ this.status = status;
17
+ }
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Low-level HTTP helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ async function crawl4aiRequestJson(
25
+ config: Crawl4aiConfig,
26
+ method: string,
27
+ endpoint: string,
28
+ body?: unknown,
29
+ signal?: AbortSignal,
30
+ ): Promise<Record<string, unknown>> {
31
+ const headers: Record<string, string> = { Accept: "application/json" };
32
+ if (body !== undefined) headers["Content-Type"] = "application/json";
33
+ if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
34
+ const response = await fetch(`${config.baseUrl}${endpoint}`, {
35
+ method,
36
+ headers,
37
+ body: body === undefined ? undefined : JSON.stringify(body),
38
+ signal: signalWithTimeout(config.timeoutMs, signal),
39
+ });
40
+ const text = await response.text();
41
+ if (!response.ok) throw new Crawl4aiHttpError(response.status, response.statusText, text);
42
+ return text ? JSON.parse(text) : { success: true };
43
+ }
44
+
45
+ /**
46
+ * Crawl4AI API request with retry.
47
+ */
48
+ export async function crawl4aiRequest(
49
+ config: Crawl4aiConfig,
50
+ method: string,
51
+ endpoint: string,
52
+ body?: unknown,
53
+ signal?: AbortSignal,
54
+ ): Promise<Record<string, unknown>> {
55
+ return withRetry(async () => {
56
+ return await crawl4aiRequestJson(config, method, endpoint, body, signal);
57
+ }, { maxRetries: 2, retryableErrors: ["timeout", "econn", "etimedout", "network", "socket"] });
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Convenience wrappers
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface Crawl4aiMediaItem {
65
+ url?: string;
66
+ src?: string;
67
+ alt?: string;
68
+ type?: string;
69
+ score?: number;
70
+ }
71
+
72
+ export interface Crawl4aiLinkItem {
73
+ href?: string;
74
+ text?: string;
75
+ domain?: string;
76
+ }
77
+
78
+ export interface Crawl4aiMarkdownResult {
79
+ raw_markdown?: string;
80
+ markdown_with_citations?: string;
81
+ references_markdown?: string;
82
+ fit_markdown?: string;
83
+ fit_html?: string;
84
+ }
85
+
86
+ export interface Crawl4aiResult {
87
+ url?: string;
88
+ html?: string;
89
+ success?: boolean;
90
+ cleaned_html?: string;
91
+ markdown?: string | Crawl4aiMarkdownResult;
92
+ media?: { images?: Crawl4aiMediaItem[]; videos?: Crawl4aiMediaItem[] };
93
+ links?: { internal?: Crawl4aiLinkItem[]; external?: Crawl4aiLinkItem[] };
94
+ extracted_content?: string;
95
+ screenshot?: string;
96
+ pdf?: string;
97
+ metadata?: Record<string, unknown>;
98
+ error_message?: string;
99
+ status_code?: number;
100
+ redirected_url?: string;
101
+ [key: string]: unknown;
102
+ }
103
+
104
+ /**
105
+ * Fetch clean markdown via POST /md.
106
+ * filter_ — one of "fit", "raw", "bm25", "llm"
107
+ * query — optional query for bm25/llm filters
108
+ */
109
+ export async function fetchCrawl4aiMarkdown(
110
+ config: Crawl4aiConfig,
111
+ url: string,
112
+ filter_ = "fit",
113
+ query?: string,
114
+ signal?: AbortSignal,
115
+ ): Promise<{ markdown: string; success: boolean }> {
116
+ const body: Record<string, unknown> = { url, f: filter_ };
117
+ if (query) body.q = query;
118
+ const result = await crawl4aiRequest(config, "POST", "/md", body, signal);
119
+ return {
120
+ markdown: (result.markdown as string) || "",
121
+ success: result.success as boolean,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Crawl one or more URLs via POST /crawl.
127
+ */
128
+ export async function fetchCrawl4aiCrawl(
129
+ config: Crawl4aiConfig,
130
+ urls: string[],
131
+ browserConfig?: Record<string, unknown>,
132
+ crawlerConfig?: Record<string, unknown>,
133
+ signal?: AbortSignal,
134
+ ): Promise<{ success: boolean; results?: Crawl4aiResult[] }> {
135
+ const body: Record<string, unknown> = { urls };
136
+ if (browserConfig) body.browser_config = browserConfig;
137
+ if (crawlerConfig) body.crawler_config = crawlerConfig;
138
+ const result = await crawl4aiRequest(config, "POST", "/crawl", body, signal);
139
+ return {
140
+ success: result.success as boolean,
141
+ results: (result.results as Crawl4aiResult[]) || [],
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Crawl one or more URLs via POST /crawl/stream.
147
+ * Returns newline-delimited JSON lines as a string.
148
+ */
149
+ export async function fetchCrawl4aiStream(
150
+ config: Crawl4aiConfig,
151
+ urls: string[],
152
+ browserConfig?: Record<string, unknown>,
153
+ crawlerConfig?: Record<string, unknown>,
154
+ signal?: AbortSignal,
155
+ ): Promise<string> {
156
+ const body: Record<string, unknown> = { urls };
157
+ if (browserConfig) body.browser_config = browserConfig;
158
+ if (crawlerConfig) body.crawler_config = crawlerConfig;
159
+ const headers: Record<string, string> = { Accept: "application/json" };
160
+ if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
161
+ headers["Content-Type"] = "application/json";
162
+ const response = await fetch(`${config.baseUrl}/crawl/stream`, {
163
+ method: "POST",
164
+ headers,
165
+ body: JSON.stringify(body),
166
+ signal: signalWithTimeout(config.timeoutMs, signal),
167
+ });
168
+ if (!response.ok) {
169
+ const text = await response.text();
170
+ throw new Crawl4aiHttpError(response.status, response.statusText, text);
171
+ }
172
+ return await response.text();
173
+ }
174
+
175
+ /**
176
+ * Capture a screenshot via POST /screenshot.
177
+ */
178
+ export async function fetchCrawl4aiScreenshot(
179
+ config: Crawl4aiConfig,
180
+ url: string,
181
+ waitFor?: number,
182
+ waitForImages?: boolean,
183
+ signal?: AbortSignal,
184
+ ): Promise<Record<string, unknown>> {
185
+ const body: Record<string, unknown> = { url };
186
+ if (waitFor !== undefined) body.screenshot_wait_for = waitFor;
187
+ if (waitForImages !== undefined) body.wait_for_images = waitForImages;
188
+ return await crawl4aiRequest(config, "POST", "/screenshot", body, signal);
189
+ }
190
+
191
+ /**
192
+ * Generate a PDF via POST /pdf.
193
+ */
194
+ export async function fetchCrawl4aiPdf(
195
+ config: Crawl4aiConfig,
196
+ url: string,
197
+ signal?: AbortSignal,
198
+ ): Promise<Record<string, unknown>> {
199
+ return await crawl4aiRequest(config, "POST", "/pdf", { url }, signal);
200
+ }
201
+
202
+ /**
203
+ * Health check via GET /health.
204
+ */
205
+ export async function fetchCrawl4aiHealth(
206
+ config: Crawl4aiConfig,
207
+ signal?: AbortSignal,
208
+ ): Promise<{ status?: string; version?: string; timestamp?: number }> {
209
+ const headers: Record<string, string> = { Accept: "application/json" };
210
+ if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
211
+ const timeoutMs = Math.min(config.timeoutMs, 10000);
212
+ const response = await fetch(`${config.baseUrl}/health`, {
213
+ method: "GET",
214
+ headers,
215
+ signal: signalWithTimeout(timeoutMs, signal),
216
+ });
217
+ const text = await response.text();
218
+ if (!response.ok) throw new Crawl4aiHttpError(response.status, response.statusText, text);
219
+ const data = text ? JSON.parse(text) : {};
220
+ return {
221
+ status: data.status as string | undefined,
222
+ version: data.version as string | undefined,
223
+ timestamp: data.timestamp as number | undefined,
224
+ };
225
+ }
package/lib/format.ts CHANGED
@@ -90,6 +90,110 @@ export function formatFirecrawlScrape(data: Record<string, unknown>, maxChars =
90
90
  return parts.join("\n\n") || JSON.stringify(data, null, 2);
91
91
  }
92
92
 
93
+ // ---------------------------------------------------------------------------
94
+ // Crawl4AI result formatting
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function extractMarkdown(md: unknown): string {
98
+ if (!md) return "";
99
+ if (typeof md === "string") return md;
100
+ if (typeof md === "object") {
101
+ const obj = md as Record<string, unknown>;
102
+ return (obj.fit_markdown as string) || (obj.raw_markdown as string) || "";
103
+ }
104
+ return "";
105
+ }
106
+
107
+ function extractLinks(links: unknown): string[] {
108
+ if (!links) return [];
109
+ const arr = Array.isArray(links) ? links : typeof links === "object" ? Object.values(links) : [];
110
+ return arr.flatMap((item: any) => {
111
+ if (typeof item === "string") return [item];
112
+ if (item?.href) return [item.href];
113
+ if (item?.url) return [item.url];
114
+ return [];
115
+ });
116
+ }
117
+
118
+ export function formatCrawl4aiResult(data: Record<string, unknown>, maxChars = 20000): string {
119
+ // Handle wrapped response: { results: [...] }
120
+ const rawResults = (data.results as unknown[]) || [data];
121
+ return rawResults
122
+ .map((raw: any, i: number) => {
123
+ const parts: string[] = [];
124
+ const url = raw.url || raw.redirected_url || "";
125
+ const success = raw.success !== false;
126
+ const statusCode = raw.status_code || raw.metadata?.statusCode;
127
+
128
+ if (rawResults.length > 1) parts.push(`=== Result ${i + 1} ===`);
129
+ if (url) parts.push(`URL: ${url}`);
130
+ if (statusCode) parts.push(`Status: ${statusCode}`);
131
+ if (!success) {
132
+ parts.push(`Error: ${raw.error_message || "Crawl failed"}`);
133
+ return parts.join("\n");
134
+ }
135
+
136
+ // Markdown content
137
+ const md = extractMarkdown(raw.markdown);
138
+ if (md) {
139
+ const truncated = md.slice(0, maxChars);
140
+ parts.push(truncated);
141
+ if (truncated.length < md.length) parts.push("[Markdown truncated...]");
142
+ } else if (raw.extracted_content) {
143
+ const ec =
144
+ typeof raw.extracted_content === "string"
145
+ ? raw.extracted_content
146
+ : JSON.stringify(raw.extracted_content, null, 2);
147
+ parts.push(ec.slice(0, maxChars));
148
+ } else if (raw.cleaned_html) {
149
+ parts.push(`(Cleaned HTML: ${raw.cleaned_html.length} chars)`);
150
+ }
151
+
152
+ // Links
153
+ const links = raw.links as Record<string, unknown> | undefined;
154
+ if (links) {
155
+ const internalLinks = extractLinks(links.internal);
156
+ const externalLinks = extractLinks(links.external);
157
+ const allLinks = [...internalLinks, ...externalLinks];
158
+ if (allLinks.length) {
159
+ parts.push(`Links (${allLinks.length}): ${allLinks.slice(0, 50).join(", ")}`);
160
+ }
161
+ }
162
+
163
+ // Media
164
+ const media = raw.media as Record<string, unknown> | undefined;
165
+ if (media) {
166
+ const imageCount = (media.images as unknown[])?.length || 0;
167
+ const videoCount = (media.videos as unknown[])?.length || 0;
168
+ if (imageCount || videoCount) {
169
+ parts.push(`Media: ${imageCount} images, ${videoCount} videos`);
170
+ }
171
+ }
172
+
173
+ return parts.join("\n\n");
174
+ })
175
+ .join("\n\n---\n\n") || "(No data)";
176
+ }
177
+
178
+ export function formatCrawl4aiStream(lines: string, _maxChars = 20000): string {
179
+ const results: string[] = [];
180
+ for (const line of lines.split("\n")) {
181
+ const trimmed = line.trim();
182
+ if (!trimmed) continue;
183
+ try {
184
+ const parsed = JSON.parse(trimmed);
185
+ if (parsed.status === "completed") {
186
+ results.push("[Stream complete]");
187
+ break;
188
+ }
189
+ results.push(formatCrawl4aiResult(parsed, _maxChars));
190
+ } catch {
191
+ results.push(trimmed.slice(0, 1000));
192
+ }
193
+ }
194
+ return results.join("\n\n---\n\n");
195
+ }
196
+
93
197
  export function formatFirecrawlSearch(data: Record<string, unknown>, maxChars = 5000): string {
94
198
  const rootData = data.data as Record<string, unknown> | undefined;
95
199
  const web = (rootData?.web as unknown[]) || [];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-web",
3
- "version": "0.2.1",
4
- "description": "Pi extension for web search, page extraction, and Firecrawl scraping/crawling.",
3
+ "version": "0.3.0",
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",
7
7
  "publishConfig": {
@@ -19,7 +19,9 @@
19
19
  "web-search",
20
20
  "brave-search",
21
21
  "firecrawl",
22
- "scraping"
22
+ "crawl4ai",
23
+ "scraping",
24
+ "crawling"
23
25
  ],
24
26
  "scripts": {
25
27
  "test": "npx mocha",