@bacnh85/pi-web 0.2.1 → 0.4.1

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/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_API_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 normalizeCrawl4aiApiUrl(raw?: string): string {
130
+ let value = (raw || DEFAULT_CRAWL4AI_API_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 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 };
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/extract.ts ADDED
@@ -0,0 +1,189 @@
1
+ // Unified content extraction orchestrator.
2
+ // Modes:
3
+ // "static" → JSDOM+Readability (no external API)
4
+ // "dynamic" → Firecrawl Scrape (JS rendering/structured JSON)
5
+ // "full" → Crawl4AI markdown endpoint
6
+ // "auto" → static → dynamic → full
7
+
8
+ import { cwdFromContext, includeProjectEnv } from "./config";
9
+ import { loadFirecrawlConfig, loadCrawl4aiConfig, type FirecrawlConfig, type Crawl4aiConfig } from "./config";
10
+ import { fetchReadableContent } from "./content";
11
+ import { firecrawlRequest, type FirecrawlResult } from "./firecrawl";
12
+ import { fetchCrawl4aiMarkdown } from "./crawl4ai";
13
+ import { formatFirecrawlScrape } from "./format";
14
+
15
+ export type ExtractMode = "auto" | "static" | "dynamic" | "full";
16
+ export type ExtractAttemptStatus = "success" | "empty" | "error";
17
+
18
+ export interface ExtractParams {
19
+ url: string;
20
+ mode?: ExtractMode;
21
+ prompt?: string;
22
+ schema?: unknown;
23
+ content_chars?: number;
24
+ timeout_ms?: number;
25
+ wait_for?: number;
26
+ mobile?: boolean;
27
+ signal?: AbortSignal;
28
+ /** Internal: caller-provided ctx for env lookup. */
29
+ _ctx?: Record<string, unknown>;
30
+ }
31
+
32
+ export interface ExtractResult {
33
+ title: string;
34
+ markdown: string;
35
+ backend: string;
36
+ links?: string[];
37
+ structured?: unknown;
38
+ }
39
+
40
+ export interface ExtractAttempt {
41
+ mode: Exclude<ExtractMode, "auto">;
42
+ backend: string;
43
+ status: ExtractAttemptStatus;
44
+ message: string;
45
+ contentLength: number;
46
+ }
47
+
48
+ export interface ExtractDiagnostics {
49
+ result: ExtractResult;
50
+ attempts: ExtractAttempt[];
51
+ selectedMode: Exclude<ExtractMode, "auto">;
52
+ fallbackUsed: boolean;
53
+ }
54
+
55
+ const MIN_USEFUL_MARKDOWN_CHARS = 120;
56
+
57
+ function sanitizeError(error: unknown): string {
58
+ const raw = error instanceof Error ? error.message : String(error);
59
+ return raw
60
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
61
+ .replace(/api[_-]?key[=:][^\s&]+/gi, "api_key=[REDACTED]")
62
+ .slice(0, 500);
63
+ }
64
+
65
+ function isUseful(result: ExtractResult | null, mode: ExtractMode, explicit: boolean): result is ExtractResult {
66
+ if (!result) return false;
67
+ const length = result.markdown.trim().length;
68
+ if (length === 0) return false;
69
+ return explicit || mode !== "static" || length >= MIN_USEFUL_MARKDOWN_CHARS;
70
+ }
71
+
72
+ function structuredSection(value: unknown): string {
73
+ if (value === undefined) return "";
74
+ return `\n\n## Structured extraction\n\n\`\`\`json\n${JSON.stringify(value, null, 2).slice(0, 10000)}\n\`\`\``;
75
+ }
76
+
77
+ async function extractStatic(params: ExtractParams): Promise<ExtractResult | null> {
78
+ const article = await fetchReadableContent(params.url, params.timeout_ms ?? 15000, params.signal);
79
+ return {
80
+ title: article.title ?? "",
81
+ markdown: article.markdown.slice(0, params.content_chars ?? 20000),
82
+ backend: "static",
83
+ };
84
+ }
85
+
86
+ function findStructuredPayload(result: FirecrawlResult): unknown {
87
+ const data = result.data as Record<string, unknown> | undefined;
88
+ if (!data) return undefined;
89
+ return data.json ?? data.extract ?? data.structuredData ?? data.llm_extraction;
90
+ }
91
+
92
+ async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
93
+ let fcConfig: FirecrawlConfig;
94
+ try {
95
+ fcConfig = loadFirecrawlConfig({}, cwdFromContext(ctx ?? {}), includeProjectEnv(ctx ?? {}));
96
+ } catch (e) {
97
+ throw new Error(`Firecrawl configuration unavailable: ${sanitizeError(e)}`);
98
+ }
99
+
100
+ const formats: unknown[] = ["markdown"];
101
+ if (params.prompt || params.schema) {
102
+ const jsonEntry: Record<string, unknown> = { type: "json" };
103
+ if (params.prompt) jsonEntry.prompt = params.prompt;
104
+ if (params.schema) jsonEntry.schema = params.schema;
105
+ formats.push(jsonEntry);
106
+ }
107
+ const body: Record<string, unknown> = {
108
+ url: params.url,
109
+ formats,
110
+ onlyMainContent: true,
111
+ ...(params.wait_for ? { waitFor: params.wait_for } : {}),
112
+ ...(params.mobile ? { mobile: true } : {}),
113
+ };
114
+ const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body, true, params.signal) as FirecrawlResult;
115
+ const structured = findStructuredPayload(result);
116
+ const raw = `${formatFirecrawlScrape(result as Record<string, unknown>, params.content_chars ?? 20000)}${structuredSection(structured)}`;
117
+ const titleMatch = raw.match(/^# (.+)$/m);
118
+ return {
119
+ title: titleMatch ? titleMatch[1] : "",
120
+ markdown: raw,
121
+ backend: "dynamic",
122
+ structured,
123
+ };
124
+ }
125
+
126
+ async function extractFull(params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
127
+ let c4aiConfig: Crawl4aiConfig;
128
+ try {
129
+ c4aiConfig = loadCrawl4aiConfig({}, cwdFromContext(ctx ?? {}), includeProjectEnv(ctx ?? {}));
130
+ } catch (e) {
131
+ throw new Error(`Crawl4AI configuration unavailable: ${sanitizeError(e)}`);
132
+ }
133
+ const result = await fetchCrawl4aiMarkdown(c4aiConfig, params.url, "fit", undefined, params.signal);
134
+ return {
135
+ title: "",
136
+ markdown: result.markdown.slice(0, params.content_chars ?? 20000),
137
+ backend: "full",
138
+ };
139
+ }
140
+
141
+ function modesFor(params: ExtractParams): Array<Exclude<ExtractMode, "auto">> {
142
+ const mode = params.mode ?? "auto";
143
+ if (mode !== "auto") return [mode];
144
+ return ["static", "dynamic", "full"];
145
+ }
146
+
147
+ async function runExtractor(mode: Exclude<ExtractMode, "auto">, params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
148
+ if (mode === "static") return extractStatic(params);
149
+ if (mode === "dynamic") return extractDynamic(params, ctx);
150
+ return extractFull(params, ctx);
151
+ }
152
+
153
+ export async function extractWithDiagnostics(params: ExtractParams): Promise<ExtractDiagnostics> {
154
+ const ctx = params._ctx;
155
+ const explicit = (params.mode ?? "auto") !== "auto";
156
+ const modes = modesFor(params);
157
+ const attempts: ExtractAttempt[] = [];
158
+
159
+ for (const mode of modes) {
160
+ try {
161
+ const result = await runExtractor(mode, params, ctx);
162
+ if (isUseful(result, mode, explicit)) {
163
+ if (!explicit && attempts.length > 0) {
164
+ result.markdown = `[Extraction fell back to ${mode === "dynamic" ? "Firecrawl Scrape (dynamic mode)" : "Crawl4AI (full browser mode)"}]\n\n${result.markdown}`;
165
+ }
166
+ attempts.push({ mode, backend: result.backend, status: "success", message: `Selected ${mode}`, contentLength: result.markdown.length });
167
+ return { result, attempts, selectedMode: mode, fallbackUsed: attempts.length > 1 };
168
+ }
169
+ const emptyResult = result as ExtractResult | null;
170
+ const length = emptyResult?.markdown.trim().length ?? 0;
171
+ attempts.push({ mode, backend: emptyResult?.backend ?? mode, status: "empty", message: `${mode} returned insufficient content`, contentLength: length });
172
+ } catch (e) {
173
+ attempts.push({ mode, backend: mode, status: "error", message: sanitizeError(e), contentLength: 0 });
174
+ if (explicit) break;
175
+ }
176
+ }
177
+
178
+ const diagnostics = attempts.map((a) => `${a.mode}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n");
179
+ const mode = params.mode ?? "auto";
180
+ throw new Error(
181
+ `Extraction failed for ${params.url} with mode="${mode}".\n${diagnostics}\n` +
182
+ "Try a different mode or use web_screenshot for a visual snapshot.",
183
+ );
184
+ }
185
+
186
+ /** Compatibility wrapper returning only the selected extraction result. */
187
+ export async function universalExtract(params: ExtractParams): Promise<ExtractResult> {
188
+ return (await extractWithDiagnostics(params)).result;
189
+ }
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[]) || [];