@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.
@@ -10,37 +10,37 @@ import { signalWithTimeout, withRetry, HttpError } from "./retry";
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
12
  async function crawl4aiRequestJson(
13
- config: Crawl4aiConfig,
14
- method: string,
15
- endpoint: string,
16
- body?: unknown,
17
- signal?: AbortSignal,
13
+ config: Crawl4aiConfig,
14
+ method: string,
15
+ endpoint: string,
16
+ body?: unknown,
17
+ signal?: AbortSignal,
18
18
  ): Promise<Record<string, unknown>> {
19
- const headers: Record<string, string> = { Accept: "application/json" };
20
- if (body !== undefined) headers["Content-Type"] = "application/json";
21
- if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
22
- const response = await fetch(`${config.baseUrl}${endpoint}`, {
23
- method,
24
- headers,
25
- body: body === undefined ? undefined : JSON.stringify(body),
26
- signal: signalWithTimeout(config.timeoutMs, signal),
27
- });
28
- const text = await response.text();
29
- if (!response.ok) throw new HttpError(response.status, response.statusText, text);
30
- return text ? JSON.parse(text) : { success: true };
19
+ const headers: Record<string, string> = { Accept: "application/json" };
20
+ if (body !== undefined) headers["Content-Type"] = "application/json";
21
+ if (config.apiToken) headers.Authorization = `Bearer ${config.apiToken}`;
22
+ const response = await fetch(`${config.baseUrl}${endpoint}`, {
23
+ method,
24
+ headers,
25
+ body: body === undefined ? undefined : JSON.stringify(body),
26
+ signal: signalWithTimeout(config.timeoutMs, signal),
27
+ });
28
+ const text = await response.text();
29
+ if (!response.ok) throw new HttpError(response.status, response.statusText, text);
30
+ return text ? JSON.parse(text) : { success: true };
31
31
  }
32
32
 
33
33
  /**
34
34
  * Crawl4AI API request with retry.
35
35
  */
36
36
  export async function crawl4aiRequest(
37
- config: Crawl4aiConfig,
38
- method: string,
39
- endpoint: string,
40
- body?: unknown,
41
- signal?: AbortSignal,
37
+ config: Crawl4aiConfig,
38
+ method: string,
39
+ endpoint: string,
40
+ body?: unknown,
41
+ signal?: AbortSignal,
42
42
  ): Promise<Record<string, unknown>> {
43
- return withRetry(() => crawl4aiRequestJson(config, method, endpoint, body, signal), 3, signal);
43
+ return withRetry(() => crawl4aiRequestJson(config, method, endpoint, body, signal), 3, signal);
44
44
  }
45
45
 
46
46
  // ---------------------------------------------------------------------------
@@ -48,43 +48,43 @@ export async function crawl4aiRequest(
48
48
  // ---------------------------------------------------------------------------
49
49
 
50
50
  export interface Crawl4aiMediaItem {
51
- url?: string;
52
- src?: string;
53
- alt?: string;
54
- type?: string;
55
- score?: number;
51
+ url?: string;
52
+ src?: string;
53
+ alt?: string;
54
+ type?: string;
55
+ score?: number;
56
56
  }
57
57
 
58
58
  export interface Crawl4aiLinkItem {
59
- href?: string;
60
- text?: string;
61
- domain?: string;
59
+ href?: string;
60
+ text?: string;
61
+ domain?: string;
62
62
  }
63
63
 
64
64
  export interface Crawl4aiMarkdownResult {
65
- raw_markdown?: string;
66
- markdown_with_citations?: string;
67
- references_markdown?: string;
68
- fit_markdown?: string;
69
- fit_html?: string;
65
+ raw_markdown?: string;
66
+ markdown_with_citations?: string;
67
+ references_markdown?: string;
68
+ fit_markdown?: string;
69
+ fit_html?: string;
70
70
  }
71
71
 
72
72
  export interface Crawl4aiResult {
73
- url?: string;
74
- html?: string;
75
- success?: boolean;
76
- cleaned_html?: string;
77
- markdown?: string | Crawl4aiMarkdownResult;
78
- media?: { images?: Crawl4aiMediaItem[]; videos?: Crawl4aiMediaItem[] };
79
- links?: { internal?: Crawl4aiLinkItem[]; external?: Crawl4aiLinkItem[] };
80
- extracted_content?: string;
81
- screenshot?: string;
82
- pdf?: string;
83
- metadata?: Record<string, unknown>;
84
- error_message?: string;
85
- status_code?: number;
86
- redirected_url?: string;
87
- [key: string]: unknown;
73
+ url?: string;
74
+ html?: string;
75
+ success?: boolean;
76
+ cleaned_html?: string;
77
+ markdown?: string | Crawl4aiMarkdownResult;
78
+ media?: { images?: Crawl4aiMediaItem[]; videos?: Crawl4aiMediaItem[] };
79
+ links?: { internal?: Crawl4aiLinkItem[]; external?: Crawl4aiLinkItem[] };
80
+ extracted_content?: string;
81
+ screenshot?: string;
82
+ pdf?: string;
83
+ metadata?: Record<string, unknown>;
84
+ error_message?: string;
85
+ status_code?: number;
86
+ redirected_url?: string;
87
+ [key: string]: unknown;
88
88
  }
89
89
 
90
90
  /**
@@ -93,79 +93,79 @@ export interface Crawl4aiResult {
93
93
  * query — optional query for bm25/llm filters
94
94
  */
95
95
  export async function fetchCrawl4aiMarkdown(
96
- config: Crawl4aiConfig,
97
- url: string,
98
- filter_ = "fit",
99
- query?: string,
100
- signal?: AbortSignal,
96
+ config: Crawl4aiConfig,
97
+ url: string,
98
+ filter_ = "fit",
99
+ query?: string,
100
+ signal?: AbortSignal,
101
101
  ): Promise<{ markdown: string; success: boolean }> {
102
- const body: Record<string, unknown> = { url, f: filter_ };
103
- if (query) body.q = query;
104
- const result = await crawl4aiRequest(config, "POST", "/md", body, signal);
105
- return {
106
- markdown: (result.markdown as string) || "",
107
- success: result.success as boolean,
108
- };
102
+ const body: Record<string, unknown> = { url, f: filter_ };
103
+ if (query) body.q = query;
104
+ const result = await crawl4aiRequest(config, "POST", "/md", body, signal);
105
+ return {
106
+ markdown: (result.markdown as string) || "",
107
+ success: result.success as boolean,
108
+ };
109
109
  }
110
110
 
111
111
  /**
112
112
  * Crawl one or more URLs via POST /crawl.
113
113
  */
114
114
  export async function fetchCrawl4aiCrawl(
115
- config: Crawl4aiConfig,
116
- urls: string[],
117
- browserConfig?: Record<string, unknown>,
118
- crawlerConfig?: Record<string, unknown>,
119
- signal?: AbortSignal,
115
+ config: Crawl4aiConfig,
116
+ urls: string[],
117
+ browserConfig?: Record<string, unknown>,
118
+ crawlerConfig?: Record<string, unknown>,
119
+ signal?: AbortSignal,
120
120
  ): Promise<{ success: boolean; results?: Crawl4aiResult[] }> {
121
- const body: Record<string, unknown> = { urls };
122
- if (browserConfig) body.browser_config = browserConfig;
123
- if (crawlerConfig) body.crawler_config = crawlerConfig;
124
- const result = await crawl4aiRequest(config, "POST", "/crawl", body, signal);
125
- return {
126
- success: result.success as boolean,
127
- results: (result.results as Crawl4aiResult[]) || [],
128
- };
121
+ const body: Record<string, unknown> = { urls };
122
+ if (browserConfig) body.browser_config = browserConfig;
123
+ if (crawlerConfig) body.crawler_config = crawlerConfig;
124
+ const result = await crawl4aiRequest(config, "POST", "/crawl", body, signal);
125
+ return {
126
+ success: result.success as boolean,
127
+ results: (result.results as Crawl4aiResult[]) || [],
128
+ };
129
129
  }
130
130
 
131
131
  /**
132
132
  * Capture a screenshot via POST /screenshot.
133
133
  */
134
134
  export async function fetchCrawl4aiScreenshot(
135
- config: Crawl4aiConfig,
136
- url: string,
137
- waitFor?: number,
138
- waitForImages?: boolean,
139
- signal?: AbortSignal,
135
+ config: Crawl4aiConfig,
136
+ url: string,
137
+ waitFor?: number,
138
+ waitForImages?: boolean,
139
+ signal?: AbortSignal,
140
140
  ): Promise<Record<string, unknown>> {
141
- const body: Record<string, unknown> = { url };
142
- if (waitFor !== undefined) body.screenshot_wait_for = waitFor;
143
- if (waitForImages !== undefined) body.wait_for_images = waitForImages;
144
- return await crawl4aiRequest(config, "POST", "/screenshot", body, signal);
141
+ const body: Record<string, unknown> = { url };
142
+ if (waitFor !== undefined) body.screenshot_wait_for = waitFor;
143
+ if (waitForImages !== undefined) body.wait_for_images = waitForImages;
144
+ return await crawl4aiRequest(config, "POST", "/screenshot", body, signal);
145
145
  }
146
146
 
147
147
  /**
148
148
  * Generate a PDF via POST /pdf.
149
149
  */
150
150
  export async function fetchCrawl4aiPdf(
151
- config: Crawl4aiConfig,
152
- url: string,
153
- signal?: AbortSignal,
151
+ config: Crawl4aiConfig,
152
+ url: string,
153
+ signal?: AbortSignal,
154
154
  ): Promise<Record<string, unknown>> {
155
- return await crawl4aiRequest(config, "POST", "/pdf", { url }, signal);
155
+ return await crawl4aiRequest(config, "POST", "/pdf", { url }, signal);
156
156
  }
157
157
 
158
158
  /**
159
159
  * Health check via GET /health.
160
160
  */
161
161
  export async function fetchCrawl4aiHealth(
162
- config: Crawl4aiConfig,
163
- signal?: AbortSignal,
162
+ config: Crawl4aiConfig,
163
+ signal?: AbortSignal,
164
164
  ): Promise<{ status?: string; version?: string; timestamp?: number }> {
165
- const data = await crawl4aiRequest({ ...config, timeoutMs: Math.min(config.timeoutMs, 10000) }, "GET", "/health", undefined, signal);
166
- return {
167
- status: data.status as string | undefined,
168
- version: data.version as string | undefined,
169
- timestamp: data.timestamp as number | undefined,
170
- };
165
+ const data = await crawl4aiRequest({ ...config, timeoutMs: Math.min(config.timeoutMs, 10000) }, "GET", "/health", undefined, signal);
166
+ return {
167
+ status: data.status as string | undefined,
168
+ version: data.version as string | undefined,
169
+ timestamp: data.timestamp as number | undefined,
170
+ };
171
171
  }
@@ -16,180 +16,180 @@ export type ExtractMode = "auto" | "static" | "dynamic" | "full";
16
16
  export type ExtractAttemptStatus = "success" | "empty" | "error";
17
17
 
18
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
- crawl4ai_api_token?: string;
28
- crawl4ai_api_url?: string;
29
- signal?: AbortSignal;
30
- /** Internal: caller-provided ctx for env lookup. */
31
- _ctx?: Record<string, unknown>;
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
+ crawl4ai_api_token?: string;
28
+ crawl4ai_api_url?: string;
29
+ signal?: AbortSignal;
30
+ /** Internal: caller-provided ctx for env lookup. */
31
+ _ctx?: Record<string, unknown>;
32
32
  }
33
33
 
34
34
  export interface ExtractResult {
35
- title: string;
36
- markdown: string;
37
- backend: string;
38
- links?: string[];
39
- structured?: unknown;
35
+ title: string;
36
+ markdown: string;
37
+ backend: string;
38
+ links?: string[];
39
+ structured?: unknown;
40
40
  }
41
41
 
42
42
  export interface ExtractAttempt {
43
- mode: Exclude<ExtractMode, "auto">;
44
- backend: string;
45
- status: ExtractAttemptStatus;
46
- message: string;
47
- contentLength: number;
43
+ mode: Exclude<ExtractMode, "auto">;
44
+ backend: string;
45
+ status: ExtractAttemptStatus;
46
+ message: string;
47
+ contentLength: number;
48
48
  }
49
49
 
50
50
  export interface ExtractDiagnostics {
51
- result: ExtractResult;
52
- attempts: ExtractAttempt[];
53
- selectedMode: Exclude<ExtractMode, "auto">;
54
- fallbackUsed: boolean;
51
+ result: ExtractResult;
52
+ attempts: ExtractAttempt[];
53
+ selectedMode: Exclude<ExtractMode, "auto">;
54
+ fallbackUsed: boolean;
55
55
  }
56
56
 
57
57
  const MIN_USEFUL_MARKDOWN_CHARS = 120;
58
58
 
59
59
  function isUseful(result: ExtractResult | null, mode: ExtractMode, explicit: boolean): result is ExtractResult {
60
- if (!result) return false;
61
- const length = result.markdown.trim().length;
62
- if (length === 0) return false;
63
- return explicit || mode !== "static" || length >= MIN_USEFUL_MARKDOWN_CHARS;
60
+ if (!result) return false;
61
+ const length = result.markdown.trim().length;
62
+ if (length === 0) return false;
63
+ return explicit || mode !== "static" || length >= MIN_USEFUL_MARKDOWN_CHARS;
64
64
  }
65
65
 
66
66
  function structuredSection(value: unknown): string {
67
- if (value === undefined) return "";
68
- return `\n\n## Structured extraction\n\n\`\`\`json\n${JSON.stringify(value, null, 2).slice(0, 10000)}\n\`\`\``;
67
+ if (value === undefined) return "";
68
+ return `\n\n## Structured extraction\n\n\`\`\`json\n${JSON.stringify(value, null, 2).slice(0, 10000)}\n\`\`\``;
69
69
  }
70
70
 
71
71
  async function extractStatic(params: ExtractParams): Promise<ExtractResult | null> {
72
- const article = await fetchReadableContent(params.url, params.timeout_ms ?? 15000, params.signal);
73
- return {
74
- title: article.title ?? "",
75
- markdown: article.markdown.slice(0, params.content_chars ?? 20000),
76
- backend: "static",
77
- };
72
+ const article = await fetchReadableContent(params.url, params.timeout_ms ?? 15000, params.signal);
73
+ return {
74
+ title: article.title ?? "",
75
+ markdown: article.markdown.slice(0, params.content_chars ?? 20000),
76
+ backend: "static",
77
+ };
78
78
  }
79
79
 
80
80
  function findStructuredPayload(result: FirecrawlResult): unknown {
81
- const data = result.data as Record<string, unknown> | undefined;
82
- if (!data) return undefined;
83
- return data.json ?? data.extract ?? data.structuredData ?? data.llm_extraction;
81
+ const data = result.data as Record<string, unknown> | undefined;
82
+ if (!data) return undefined;
83
+ return data.json ?? data.extract ?? data.structuredData ?? data.llm_extraction;
84
84
  }
85
85
 
86
86
  async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
87
- let fcConfig: FirecrawlConfig;
88
- try {
89
- fcConfig = loadFirecrawlConfig({}, cwdFromContext(ctx ?? {}), includeProjectEnv(ctx ?? {}));
90
- } catch (e) {
91
- throw new Error(`Firecrawl configuration unavailable: ${sanitizeError(e)}`);
92
- }
93
-
94
- const formats: string[] = ["markdown"];
95
- if (params.prompt || params.schema) {
96
- formats.push("json");
97
- }
98
- const body: Record<string, unknown> = {
99
- url: params.url,
100
- formats,
101
- onlyMainContent: true,
102
- ...(params.wait_for ? { waitFor: params.wait_for } : {}),
103
- ...(params.mobile ? { mobile: true } : {}),
104
- };
105
- if (params.prompt || params.schema) {
106
- const jsonOptions: Record<string, unknown> = {};
107
- if (params.prompt) jsonOptions.prompt = params.prompt;
108
- if (params.schema) jsonOptions.schema = params.schema;
109
- body.jsonOptions = jsonOptions;
110
- }
111
- const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body, params.signal) as FirecrawlResult;
112
- const structured = findStructuredPayload(result);
113
- const raw = `${formatFirecrawlScrape(result as Record<string, unknown>, params.content_chars ?? 20000)}${structuredSection(structured)}`;
114
- const titleMatch = raw.match(/^# (.+)$/m);
115
- return {
116
- title: titleMatch ? titleMatch[1] : "",
117
- markdown: raw,
118
- backend: "dynamic",
119
- structured,
120
- };
87
+ let fcConfig: FirecrawlConfig;
88
+ try {
89
+ fcConfig = loadFirecrawlConfig({}, cwdFromContext(ctx ?? {}), includeProjectEnv(ctx ?? {}));
90
+ } catch (e) {
91
+ throw new Error(`Firecrawl configuration unavailable: ${sanitizeError(e)}`);
92
+ }
93
+
94
+ const formats: string[] = ["markdown"];
95
+ if (params.prompt || params.schema) {
96
+ formats.push("json");
97
+ }
98
+ const body: Record<string, unknown> = {
99
+ url: params.url,
100
+ formats,
101
+ onlyMainContent: true,
102
+ ...(params.wait_for ? { waitFor: params.wait_for } : {}),
103
+ ...(params.mobile ? { mobile: true } : {}),
104
+ };
105
+ if (params.prompt || params.schema) {
106
+ const jsonOptions: Record<string, unknown> = {};
107
+ if (params.prompt) jsonOptions.prompt = params.prompt;
108
+ if (params.schema) jsonOptions.schema = params.schema;
109
+ body.jsonOptions = jsonOptions;
110
+ }
111
+ const result = await firecrawlRequest(fcConfig, "POST", "/scrape", body, params.signal) as FirecrawlResult;
112
+ const structured = findStructuredPayload(result);
113
+ const raw = `${formatFirecrawlScrape(result as Record<string, unknown>, params.content_chars ?? 20000)}${structuredSection(structured)}`;
114
+ const titleMatch = raw.match(/^# (.+)$/m);
115
+ return {
116
+ title: titleMatch ? titleMatch[1] : "",
117
+ markdown: raw,
118
+ backend: "dynamic",
119
+ structured,
120
+ };
121
121
  }
122
122
 
123
123
  async function extractFull(params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
124
- let c4aiConfig: Crawl4aiConfig;
125
- try {
126
- c4aiConfig = loadCrawl4aiConfig(
127
- { crawl4ai_api_token: params.crawl4ai_api_token, crawl4ai_api_url: params.crawl4ai_api_url, timeout_ms: params.timeout_ms },
128
- cwdFromContext(ctx ?? {}),
129
- includeProjectEnv(ctx ?? {}),
130
- );
131
- } catch (e) {
132
- throw new Error(`Crawl4AI configuration unavailable: ${sanitizeError(e)}`);
133
- }
134
- const result = await fetchCrawl4aiMarkdown(c4aiConfig, params.url, "fit", undefined, params.signal);
135
- return {
136
- title: "",
137
- markdown: result.markdown.slice(0, params.content_chars ?? 20000),
138
- backend: "full",
139
- };
124
+ let c4aiConfig: Crawl4aiConfig;
125
+ try {
126
+ c4aiConfig = loadCrawl4aiConfig(
127
+ { crawl4ai_api_token: params.crawl4ai_api_token, crawl4ai_api_url: params.crawl4ai_api_url, timeout_ms: params.timeout_ms },
128
+ cwdFromContext(ctx ?? {}),
129
+ includeProjectEnv(ctx ?? {}),
130
+ );
131
+ } catch (e) {
132
+ throw new Error(`Crawl4AI configuration unavailable: ${sanitizeError(e)}`);
133
+ }
134
+ const result = await fetchCrawl4aiMarkdown(c4aiConfig, params.url, "fit", undefined, params.signal);
135
+ return {
136
+ title: "",
137
+ markdown: result.markdown.slice(0, params.content_chars ?? 20000),
138
+ backend: "full",
139
+ };
140
140
  }
141
141
 
142
142
  function modesFor(params: ExtractParams): Array<Exclude<ExtractMode, "auto">> {
143
- const mode = params.mode ?? "auto";
144
- if (mode !== "auto") return [mode];
145
- return ["static", "dynamic", "full"];
143
+ const mode = params.mode ?? "auto";
144
+ if (mode !== "auto") return [mode];
145
+ return ["static", "dynamic", "full"];
146
146
  }
147
147
 
148
148
  async function runExtractor(mode: Exclude<ExtractMode, "auto">, params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
149
- if (mode === "static") return extractStatic(params);
150
- if (mode === "dynamic") return extractDynamic(params, ctx);
151
- return extractFull(params, ctx);
149
+ if (mode === "static") return extractStatic(params);
150
+ if (mode === "dynamic") return extractDynamic(params, ctx);
151
+ return extractFull(params, ctx);
152
152
  }
153
153
 
154
154
  export async function extractWithDiagnostics(params: ExtractParams): Promise<ExtractDiagnostics> {
155
- const ctx = params._ctx;
156
- const explicit = (params.mode ?? "auto") !== "auto";
157
- const modes = modesFor(params);
158
- const attempts: ExtractAttempt[] = [];
159
-
160
- for (const mode of modes) {
161
- try {
162
- const result = await runExtractor(mode, params, ctx);
163
- if (isUseful(result, mode, explicit)) {
164
- if (!explicit && attempts.length > 0) {
165
- result.markdown = `[Extraction fell back to ${mode === "dynamic" ? "Firecrawl Scrape (dynamic mode)" : "Crawl4AI (full browser mode)"}]\n\n${result.markdown}`;
166
- }
167
- attempts.push({ mode, backend: result.backend, status: "success", message: `Selected ${mode}`, contentLength: result.markdown.length });
168
- return { result, attempts, selectedMode: mode, fallbackUsed: attempts.length > 1 };
169
- }
170
- const emptyResult = result as ExtractResult | null;
171
- const length = emptyResult?.markdown.trim().length ?? 0;
172
- attempts.push({ mode, backend: emptyResult?.backend ?? mode, status: "empty", message: `${mode} returned insufficient content`, contentLength: length });
173
- } catch (e) {
174
- attempts.push({ mode, backend: mode, status: "error", message: sanitizeError(e), contentLength: 0 });
175
- if (explicit) break;
176
- }
177
- }
178
-
179
- // All modes exhausted — return the best attempt with diagnostics instead of throwing
180
- const lastAttempt = attempts[attempts.length - 1];
181
- const bestResult: ExtractResult = {
182
- title: "",
183
- markdown: `[All extraction modes failed for ${params.url}]\n\n` +
184
- attempts.map((a) => ` ${a.mode}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n") +
185
- "\n\nTry web_screenshot for a visual snapshot, or verify the URL is accessible.",
186
- backend: lastAttempt?.backend ?? "none",
187
- };
188
- return {
189
- result: bestResult,
190
- attempts,
191
- selectedMode: lastAttempt?.mode ?? "static",
192
- fallbackUsed: true,
193
- };
155
+ const ctx = params._ctx;
156
+ const explicit = (params.mode ?? "auto") !== "auto";
157
+ const modes = modesFor(params);
158
+ const attempts: ExtractAttempt[] = [];
159
+
160
+ for (const mode of modes) {
161
+ try {
162
+ const result = await runExtractor(mode, params, ctx);
163
+ if (isUseful(result, mode, explicit)) {
164
+ if (!explicit && attempts.length > 0) {
165
+ result.markdown = `[Extraction fell back to ${mode === "dynamic" ? "Firecrawl Scrape (dynamic mode)" : "Crawl4AI (full browser mode)"}]\n\n${result.markdown}`;
166
+ }
167
+ attempts.push({ mode, backend: result.backend, status: "success", message: `Selected ${mode}`, contentLength: result.markdown.length });
168
+ return { result, attempts, selectedMode: mode, fallbackUsed: attempts.length > 1 };
169
+ }
170
+ const emptyResult = result as ExtractResult | null;
171
+ const length = emptyResult?.markdown.trim().length ?? 0;
172
+ attempts.push({ mode, backend: emptyResult?.backend ?? mode, status: "empty", message: `${mode} returned insufficient content`, contentLength: length });
173
+ } catch (e) {
174
+ attempts.push({ mode, backend: mode, status: "error", message: sanitizeError(e), contentLength: 0 });
175
+ if (explicit) break;
176
+ }
177
+ }
178
+
179
+ // All modes exhausted — return the best attempt with diagnostics instead of throwing
180
+ const lastAttempt = attempts[attempts.length - 1];
181
+ const bestResult: ExtractResult = {
182
+ title: "",
183
+ markdown: `[All extraction modes failed for ${params.url}]\n\n` +
184
+ attempts.map((a) => ` ${a.mode}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n") +
185
+ "\n\nTry web_screenshot for a visual snapshot, or verify the URL is accessible.",
186
+ backend: lastAttempt?.backend ?? "none",
187
+ };
188
+ return {
189
+ result: bestResult,
190
+ attempts,
191
+ selectedMode: lastAttempt?.mode ?? "static",
192
+ fallbackUsed: true,
193
+ };
194
194
  }
195
195
 
@@ -8,11 +8,11 @@ import { signalWithTimeout, withRetry, HttpError } from "./retry";
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  export interface FirecrawlResult {
11
- success: boolean;
12
- data?: Record<string, unknown>;
13
- warning?: string;
14
- id?: string;
15
- [key: string]: unknown;
11
+ success: boolean;
12
+ data?: Record<string, unknown>;
13
+ warning?: string;
14
+ id?: string;
15
+ [key: string]: unknown;
16
16
  }
17
17
 
18
18
  // ---------------------------------------------------------------------------
@@ -20,35 +20,35 @@ export interface FirecrawlResult {
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
22
  async function firecrawlRequestJson(
23
- config: FirecrawlConfig,
24
- method: string,
25
- endpoint: string,
26
- body?: unknown,
27
- signal?: AbortSignal,
23
+ config: FirecrawlConfig,
24
+ method: string,
25
+ endpoint: string,
26
+ body?: unknown,
27
+ signal?: AbortSignal,
28
28
  ): Promise<Record<string, unknown>> {
29
- const headers: Record<string, string> = { Accept: "application/json" };
30
- if (body !== undefined) headers["Content-Type"] = "application/json";
31
- if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
32
- const response = await fetch(`${config.baseUrl}${endpoint}`, {
33
- method,
34
- headers,
35
- body: body === undefined ? undefined : JSON.stringify(body),
36
- signal: signalWithTimeout(config.timeoutMs, signal),
37
- });
38
- const text = await response.text();
39
- if (!response.ok) throw new HttpError(response.status, response.statusText, text);
40
- return text ? JSON.parse(text) : { success: true };
29
+ const headers: Record<string, string> = { Accept: "application/json" };
30
+ if (body !== undefined) headers["Content-Type"] = "application/json";
31
+ if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
32
+ const response = await fetch(`${config.baseUrl}${endpoint}`, {
33
+ method,
34
+ headers,
35
+ body: body === undefined ? undefined : JSON.stringify(body),
36
+ signal: signalWithTimeout(config.timeoutMs, signal),
37
+ });
38
+ const text = await response.text();
39
+ if (!response.ok) throw new HttpError(response.status, response.statusText, text);
40
+ return text ? JSON.parse(text) : { success: true };
41
41
  }
42
42
 
43
43
  /**
44
44
  * Firecrawl API request with retry.
45
45
  */
46
46
  export async function firecrawlRequest(
47
- config: FirecrawlConfig,
48
- method: string,
49
- endpoint: string,
50
- body?: unknown,
51
- signal?: AbortSignal,
47
+ config: FirecrawlConfig,
48
+ method: string,
49
+ endpoint: string,
50
+ body?: unknown,
51
+ signal?: AbortSignal,
52
52
  ): Promise<Record<string, unknown>> {
53
- return withRetry(() => firecrawlRequestJson(config, method, endpoint, body, signal), undefined, signal);
53
+ return withRetry(() => firecrawlRequestJson(config, method, endpoint, body, signal), undefined, signal);
54
54
  }