firecrawl 4.41.0 → 4.42.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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  require_package
3
- } from "./chunk-I2DFXQYR.js";
3
+ } from "./chunk-YG554MNR.js";
4
4
 
5
5
  // src/v2/utils/httpClient.ts
6
6
  import axios from "axios";
@@ -18,6 +18,21 @@ function getVersion() {
18
18
  }
19
19
  }
20
20
 
21
+ // src/utils/apiOrigin.ts
22
+ function pinToApiOrigin(apiUrl, url) {
23
+ const normalized = url.replace(/[\t\n\r]/g, "").replace(/^[\u0000-\u0020]+/, "");
24
+ if (!/^([a-z][a-z\d+\-.]*:|[\\/]{2})/i.test(normalized)) return url;
25
+ const base = URL.canParse(apiUrl) ? new URL(apiUrl) : null;
26
+ if (!base?.host) {
27
+ throw new Error(`apiUrl must be an absolute URL, got ${JSON.stringify(apiUrl)}`);
28
+ }
29
+ const target = new URL(normalized, base);
30
+ const pinned = new URL(base.origin);
31
+ pinned.pathname = target.pathname;
32
+ pinned.search = target.search;
33
+ return pinned.href;
34
+ }
35
+
21
36
  // src/v2/utils/httpClient.ts
22
37
  var HttpClient = class {
23
38
  instance;
@@ -58,6 +73,7 @@ var HttpClient = class {
58
73
  config.headers = {
59
74
  ...config.headers || {}
60
75
  };
76
+ if (config.url) config.url = pinToApiOrigin(this.apiUrl, config.url);
61
77
  let lastError;
62
78
  for (let attempt = 0; attempt < this.maxRetries; attempt++) {
63
79
  try {
@@ -185,6 +201,12 @@ var JobTimeoutError = class extends SdkError {
185
201
  }
186
202
  };
187
203
 
204
+ // src/v2/utils/agentHints.ts
205
+ function agentHintMetadata(body) {
206
+ const hints = body?.agent_hints;
207
+ return Array.isArray(hints) && hints.every((hint) => typeof hint === "string") ? { agent_hints: [...hints] } : {};
208
+ }
209
+
188
210
  // src/utils/zodSchemaToJson.ts
189
211
  import { zodToJsonSchema as zodToJsonSchemaLib } from "zod-to-json-schema";
190
212
  function isZodSchema(value) {
@@ -418,7 +440,7 @@ function throwForBadResponse(resp, action) {
418
440
  const status = resp.status;
419
441
  const body = resp.data || {};
420
442
  const msg = body?.error || body?.message || `Request failed (${status}) while trying to ${action}`;
421
- throw new SdkError(
443
+ throw Object.assign(new SdkError(
422
444
  msg,
423
445
  status,
424
446
  body?.code,
@@ -426,14 +448,14 @@ function throwForBadResponse(resp, action) {
426
448
  void 0,
427
449
  body?.chargeId,
428
450
  parseRequiresAction(body?.requiresAction)
429
- );
451
+ ), agentHintMetadata(body));
430
452
  }
431
453
  function normalizeAxiosError(err, action) {
432
454
  const status = err.response?.status;
433
455
  const body = err.response?.data;
434
456
  const message = body?.error || err.message || `Request failed${status ? ` (${status})` : ""} while trying to ${action}`;
435
457
  const code = body?.code || err.code;
436
- throw new SdkError(
458
+ throw Object.assign(new SdkError(
437
459
  message,
438
460
  status,
439
461
  code,
@@ -441,7 +463,7 @@ function normalizeAxiosError(err, action) {
441
463
  void 0,
442
464
  body?.chargeId,
443
465
  parseRequiresAction(body?.requiresAction)
444
- );
466
+ ), agentHintMetadata(body));
445
467
  }
446
468
  function isRetryableError(err) {
447
469
  if (err instanceof JobTimeoutError) {
@@ -509,7 +531,7 @@ async function scrape(http, url, options, deps = {}) {
509
531
  if (res.status !== 200 || !res.data?.success) {
510
532
  throwForBadResponse(res, "scrape");
511
533
  }
512
- return res.data.data || {};
534
+ return { ...res.data.data, ...agentHintMetadata(res.data) };
513
535
  } catch (err) {
514
536
  const delayMs = autoResume !== false ? processingContinuesDelayMs(err) : void 0;
515
537
  if (delayMs !== void 0 && resumes < RESUME_MAX_ATTEMPTS && resumeWaitedMs + delayMs <= RESUME_MAX_TOTAL_WAIT_MS) {
@@ -626,7 +648,7 @@ async function parse(http, file, options) {
626
648
  if (res.status !== 200 || !res.data?.success) {
627
649
  throwForBadResponse(res, "parse");
628
650
  }
629
- return res.data.data || {};
651
+ return { ...res.data.data, ...agentHintMetadata(res.data) };
630
652
  } catch (err) {
631
653
  if (err?.isAxiosError) return normalizeAxiosError(err, "parse");
632
654
  throw err;
@@ -700,7 +722,7 @@ async function search(http, request) {
700
722
  throwForBadResponse(res, "search");
701
723
  }
702
724
  const data = res.data.data || {};
703
- const out = {};
725
+ const out = { ...agentHintMetadata(res.data) };
704
726
  if (res.data.warning) out.warning = res.data.warning;
705
727
  if (data.web) out.web = transformArray(data.web);
706
728
  if (data.news) out.news = transformArray(data.news);
@@ -905,7 +927,7 @@ async function map(http, url, options) {
905
927
  description: item.description
906
928
  });
907
929
  }
908
- return { id: res.data.id, links };
930
+ return { id: res.data.id, links, ...agentHintMetadata(res.data) };
909
931
  } catch (err) {
910
932
  if (err?.isAxiosError) return normalizeAxiosError(err, "map");
911
933
  throw err;
@@ -2667,7 +2689,7 @@ var FirecrawlApp = class {
2667
2689
  if (typeof process !== "undefined" && process.env && process.env.npm_package_version) {
2668
2690
  return process.env.npm_package_version;
2669
2691
  }
2670
- const packageJson = await import("./package-V72LKFNW.js");
2692
+ const packageJson = await import("./package-FOKY5K5X.js");
2671
2693
  return packageJson.default.version;
2672
2694
  } catch (error) {
2673
2695
  const isTest = typeof process !== "undefined" && (process.env.JEST_WORKER_ID != null || false);
@@ -2882,7 +2904,7 @@ var FirecrawlApp = class {
2882
2904
  throw new FirecrawlError("No crawl ID provided", 400);
2883
2905
  }
2884
2906
  const headers = this.prepareHeaders();
2885
- const targetURL = new URL(nextURL ?? `${this.apiUrl}/v1/crawl/${id}`);
2907
+ const targetURL = new URL(nextURL ?? `${this.apiUrl}/v1/crawl/${id}`, this.apiUrl);
2886
2908
  if (skip !== void 0) {
2887
2909
  targetURL.searchParams.set("skip", skip.toString());
2888
2910
  }
@@ -3127,7 +3149,7 @@ var FirecrawlApp = class {
3127
3149
  throw new FirecrawlError("No batch scrape ID provided", 400);
3128
3150
  }
3129
3151
  const headers = this.prepareHeaders();
3130
- const targetURL = new URL(nextURL ?? `${this.apiUrl}/v1/batch/scrape/${id}`);
3152
+ const targetURL = new URL(nextURL ?? `${this.apiUrl}/v1/batch/scrape/${id}`, this.apiUrl);
3131
3153
  if (skip !== void 0) {
3132
3154
  targetURL.searchParams.set("skip", skip.toString());
3133
3155
  }
@@ -3339,7 +3361,7 @@ var FirecrawlApp = class {
3339
3361
  */
3340
3362
  async getRequest(url, headers) {
3341
3363
  try {
3342
- return await axios2.get(url, { headers });
3364
+ return await axios2.get(pinToApiOrigin(this.apiUrl, url), { headers });
3343
3365
  } catch (error) {
3344
3366
  if (error instanceof AxiosError && error.response) {
3345
3367
  return error.response;
@@ -1,4 +1,4 @@
1
1
  import {
2
2
  require_package
3
- } from "./chunk-I2DFXQYR.js";
3
+ } from "./chunk-YG554MNR.js";
4
4
  export default require_package();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firecrawl",
3
- "version": "4.41.0",
3
+ "version": "4.42.1",
4
4
  "description": "JavaScript SDK for the Firecrawl API: web scraping, crawling, web search, and scientific literature search over a research paper index of PubMed, bioRxiv, medRxiv and arXiv abstracts",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -7,7 +7,9 @@ overrides:
7
7
  "rollup@<4.59.0": ">=4.59.0"
8
8
  "picomatch@<4.0.4": ">=4.0.4"
9
9
  handlebars: ">=4.7.9"
10
- "brace-expansion@>=5.0.0 <5.0.9": "5.0.9"
10
+ # GHSA-q2hr-2g5m-vwhr / GHSA-qhr7-859c-m2p7 / GHSA-6j4f-fj2g-mc7p:
11
+ # pin the 5.x line only. Exact 5.0.12 cannot resolve to 6.x.
12
+ "brace-expansion@>=5.0.0 <5.0.12": "5.0.12"
11
13
  "js-yaml@>=3.0.0 <3.15.2": "3.15.2"
12
14
  "js-yaml@>=4.0.0 <4.3.2": "4.3.2"
13
15
  "axios@>=1.0.0 <1.16.0": "1.16.1"
@@ -0,0 +1,81 @@
1
+ import { describe, test, expect, jest, afterEach } from "@jest/globals";
2
+ import axios from "axios";
3
+ import FirecrawlApp from "../../../v1";
4
+
5
+ const API_URL = "https://api.firecrawl.dev";
6
+ const API_KEY = "fc-test";
7
+
8
+ const origins: Array<[string, string]> = [
9
+ ["same origin", API_URL],
10
+ ["cross host", "https://evil.example"],
11
+ ["protocol-relative", "//evil.example"],
12
+ ["different port", "https://api.firecrawl.dev:8443"],
13
+ ["different scheme", "http://api.firecrawl.dev"],
14
+ ["whitespace-prefixed", "\n https://evil.example"],
15
+ ];
16
+
17
+ function mockGet(respond: (url: URL) => unknown) {
18
+ const sent: Array<{ url: string; authorization: unknown }> = [];
19
+ jest.spyOn(axios, "get").mockImplementation(async (url: string, config?: any) => {
20
+ sent.push({ url, authorization: config?.headers?.Authorization });
21
+ return { status: 200, data: respond(new URL(url)) };
22
+ });
23
+ return sent;
24
+ }
25
+
26
+ function expectPinned(sent: Array<{ url: string; authorization: unknown }>, expectedUrls: string[]) {
27
+ expect(sent.map(req => req.url)).toEqual(expectedUrls);
28
+ for (const req of sent) {
29
+ expect(new URL(req.url).origin).toBe(API_URL);
30
+ expect(req.url).not.toContain("evil.example");
31
+ expect(req.authorization).toBe(`Bearer ${API_KEY}`);
32
+ }
33
+ }
34
+
35
+ const completedPage = (next: string) => ({
36
+ success: true,
37
+ status: "completed",
38
+ total: 2,
39
+ completed: 2,
40
+ expiresAt: "2030-01-01T00:00:00Z",
41
+ data: [{ markdown: "a" }],
42
+ next,
43
+ });
44
+ const lastPage = () => ({ success: true, status: "completed", data: [{ markdown: "b" }] });
45
+
46
+ describe("v1 pins next URLs to the api_url origin", () => {
47
+ afterEach(() => {
48
+ jest.restoreAllMocks();
49
+ });
50
+
51
+ describe.each(origins)("%s next", (_label, origin) => {
52
+ const app = new FirecrawlApp({ apiKey: API_KEY, apiUrl: API_URL });
53
+
54
+ test.each([
55
+ ["checkCrawlStatus", "/v1/crawl/abc"],
56
+ ["checkBatchScrapeStatus", "/v1/batch/scrape/abc"],
57
+ ] as const)("%s getAllData follows server next", async (method, path) => {
58
+ const sent = mockGet(url => (url.searchParams.has("skip") ? lastPage() : completedPage(`${origin}${path}?skip=10`)));
59
+ const res: any = await app[method]("abc", true);
60
+ expect(res.data).toHaveLength(2);
61
+ expectPinned(sent, [`${API_URL}${path}`, `${API_URL}${path}?skip=10`]);
62
+ });
63
+
64
+ test.each([
65
+ ["checkCrawlStatus", "/v1/crawl/abc"],
66
+ ["checkBatchScrapeStatus", "/v1/batch/scrape/abc"],
67
+ ] as const)("%s caller-supplied nextURL", async (method, path) => {
68
+ const sent = mockGet(lastPage);
69
+ await app[method]("abc", false, `${origin}${path}?skip=10`);
70
+ expectPinned(sent, [`${API_URL}${path}?skip=10`]);
71
+ });
72
+
73
+ test("monitorJobStatus follows server next", async () => {
74
+ const path = "/v1/crawl/abc";
75
+ const sent = mockGet(url => (url.searchParams.has("skip") ? lastPage() : completedPage(`${origin}${path}?skip=10`)));
76
+ const res: any = await app.monitorJobStatus("abc", app.prepareHeaders(), 0);
77
+ expect(res.data).toHaveLength(2);
78
+ expectPinned(sent, [`${API_URL}${path}`, `${API_URL}${path}?skip=10`]);
79
+ });
80
+ });
81
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it, jest } from "@jest/globals";
2
+ import { search } from "../../../v2/methods/search";
3
+ import { scrape } from "../../../v2/methods/scrape";
4
+ import { parse } from "../../../v2/methods/parse";
5
+ import { map } from "../../../v2/methods/map";
6
+ import {
7
+ normalizeAxiosError,
8
+ throwForBadResponse,
9
+ } from "../../../v2/utils/errorHandler";
10
+
11
+ const hints = [
12
+ "Scrape a selected search result if you need full page content.",
13
+ "Evaluate this result before submitting feedback.",
14
+ ];
15
+ function httpFor(body: object) {
16
+ return {
17
+ post: jest.fn<any>().mockResolvedValue({ status: 200, data: body }),
18
+ postMultipart: jest
19
+ .fn<any>()
20
+ .mockResolvedValue({ status: 200, data: body }),
21
+ } as any;
22
+ }
23
+
24
+ describe("agent hints survive SDK response unwrapping", () => {
25
+ it("preserves search hints without adding them to records", async () => {
26
+ const web = [{ url: "https://example.com" }];
27
+ const result = await search(
28
+ httpFor({ success: true, data: { web }, agent_hints: hints }),
29
+ { query: "test" },
30
+ );
31
+ expect(result.web).toEqual(web);
32
+ expect(result.web[0]).not.toHaveProperty("agent_hints");
33
+ expect(result.agent_hints).toEqual(hints);
34
+ expect(JSON.parse(JSON.stringify(result)).agent_hints).toEqual(hints);
35
+ });
36
+
37
+ it("keeps existing search output unchanged when hints are absent", async () => {
38
+ const result = await search(httpFor({ success: true, data: { web: [] } }), {
39
+ query: "test",
40
+ });
41
+ expect(Object.keys(result)).toEqual(["web"]);
42
+ });
43
+
44
+ it.each(["scrape", "parse"])(
45
+ "keeps %s hints separate from document content",
46
+ async (method) => {
47
+ const http = httpFor({
48
+ success: true,
49
+ data: { markdown: "# Page" },
50
+ agent_hints: hints,
51
+ });
52
+ const result =
53
+ method === "scrape"
54
+ ? await scrape(http, "https://example.com")
55
+ : await parse(http, { filename: "page.txt", data: "Page" });
56
+ expect(result.markdown).toBe("# Page");
57
+ expect(result.agent_hints).toEqual(hints);
58
+ },
59
+ );
60
+
61
+ it("preserves map metadata alongside normalized links", async () => {
62
+ const result = await map(
63
+ httpFor({
64
+ success: true,
65
+ id: "map-id",
66
+ links: ["https://example.com"],
67
+ agent_hints: hints,
68
+ }),
69
+ "https://example.com",
70
+ );
71
+ expect(result).toEqual({
72
+ id: "map-id",
73
+ links: [{ url: "https://example.com" }],
74
+ agent_hints: hints,
75
+ });
76
+ });
77
+
78
+ it.each([null, "unexpected", [123]])(
79
+ "ignores malformed optional hints: %j",
80
+ async (value) => {
81
+ const result = await search(
82
+ httpFor({ success: true, data: {}, agent_hints: value }),
83
+ { query: "test" },
84
+ );
85
+ expect(result).not.toHaveProperty("agent_hints");
86
+ },
87
+ );
88
+
89
+ it("preserves hints on HTTP and Axios errors without changing their status", () => {
90
+ const response = {
91
+ status: 400,
92
+ data: {
93
+ success: false,
94
+ code: "INVALID",
95
+ error: "Invalid request",
96
+ agent_hints: hints,
97
+ },
98
+ } as any;
99
+ for (const raise of [
100
+ () => throwForBadResponse(response, "search"),
101
+ () =>
102
+ normalizeAxiosError({ response, isAxiosError: true } as any, "search"),
103
+ ]) {
104
+ try {
105
+ raise();
106
+ throw new Error("Expected SDK error");
107
+ } catch (error) {
108
+ expect(error).toMatchObject({
109
+ status: 400,
110
+ code: "INVALID",
111
+ agent_hints: hints,
112
+ });
113
+ }
114
+ }
115
+ });
116
+ });
@@ -161,4 +161,126 @@ describe("v2.agent threads unit", () => {
161
161
  "/v2/agent/threads/thread-1?includeData=true",
162
162
  );
163
163
  });
164
+ test("startAgent forwards exchange.onTermsRequired and a terms approve", async () => {
165
+ const post = okPost();
166
+ const exchange = {
167
+ onTermsRequired: "ask" as const,
168
+ approve: { approvalId: "0199aaaa-0000-7000-8000-000000000000" },
169
+ };
170
+
171
+ await startAgent({ post } as any, {
172
+ prompt: "Continue",
173
+ threadId: "thread-1",
174
+ exchange,
175
+ });
176
+
177
+ expect(post).toHaveBeenCalledWith("/v2/agent", {
178
+ prompt: "Continue",
179
+ threadId: "thread-1",
180
+ exchange,
181
+ });
182
+ });
183
+
184
+ test("startAgent forwards exchange.onTermsRequired skip", async () => {
185
+ const post = okPost();
186
+
187
+ await startAgent({ post } as any, {
188
+ prompt: "Find the key business contact at exa.ai",
189
+ exchange: { onTermsRequired: "skip" },
190
+ });
191
+
192
+ expect(post).toHaveBeenCalledWith("/v2/agent", {
193
+ prompt: "Find the key business contact at exa.ai",
194
+ exchange: { onTermsRequired: "skip" },
195
+ });
196
+ });
197
+
198
+ test("getAgentStatus parses skippedProviders, requiresAction and a terms approval", async () => {
199
+ const approvalId = "0199aaaa-0000-7000-8000-000000000000";
200
+ const get = jest.fn().mockResolvedValue({
201
+ status: 200,
202
+ data: {
203
+ success: true,
204
+ status: "completed",
205
+ data: null,
206
+ model: "spark-2",
207
+ expiresAt: "2026-09-02T00:00:00.000Z",
208
+ exchange: {
209
+ enabled: true,
210
+ onTermsRequired: "ask",
211
+ paidCalls: 0,
212
+ creditsUsed: null,
213
+ skippedProviders: [
214
+ {
215
+ provider: "apollo",
216
+ name: "Apollo",
217
+ capability: "people/search",
218
+ adds: "verified work emails",
219
+ reason: "terms_required",
220
+ version: "F-1.0.0",
221
+ termsUrl: "https://www.firecrawl.dev/app/alexandria/apollo",
222
+ },
223
+ ],
224
+ requiresAction: {
225
+ type: "accept_terms",
226
+ approvalId,
227
+ providers: [
228
+ {
229
+ provider: "apollo",
230
+ name: "Apollo",
231
+ version: "F-1.0.0",
232
+ digest: null,
233
+ url: "https://www.firecrawl.dev/app/alexandria/apollo",
234
+ show: {
235
+ provider: "firecrawl",
236
+ capability: "terms/show",
237
+ options: { provider: "apollo" },
238
+ },
239
+ accept: {
240
+ provider: "firecrawl",
241
+ capability: "terms/accept",
242
+ options: {
243
+ provider: "apollo",
244
+ version: "F-1.0.0",
245
+ digest: null,
246
+ confirmed: true,
247
+ },
248
+ },
249
+ },
250
+ ],
251
+ },
252
+ },
253
+ pendingApproval: {
254
+ id: approvalId,
255
+ kind: "terms",
256
+ reason: "Apollo could add verified work emails.",
257
+ calls: [],
258
+ terms: [
259
+ {
260
+ provider: "apollo",
261
+ name: "Apollo",
262
+ version: "F-1.0.0",
263
+ digest: null,
264
+ url: "https://www.firecrawl.dev/app/alexandria/apollo",
265
+ },
266
+ ],
267
+ resolution: null,
268
+ },
269
+ },
270
+ });
271
+
272
+ const status = await getAgentStatus({ get } as any, "agent-job");
273
+
274
+ expect(status.exchange!.onTermsRequired).toBe("ask");
275
+ expect(status.exchange!.skippedProviders![0].reason).toBe("terms_required");
276
+ const action = status.exchange!.requiresAction!;
277
+ expect(action.approvalId).toBe(approvalId);
278
+ expect(action.providers[0].accept.capability).toBe("terms/accept");
279
+ expect(action.providers[0].accept.options.digest).toBeNull();
280
+ const pending = status.pendingApproval!;
281
+ expect(pending.kind).toBe("terms");
282
+ if (pending.kind !== "terms") throw new Error("expected a terms approval");
283
+ expect(pending.calls).toEqual([]);
284
+ expect(pending.terms[0].provider).toBe("apollo");
285
+ });
164
286
  });
@@ -0,0 +1,104 @@
1
+ import { describe, test, expect } from "@jest/globals";
2
+ import axios, { type AxiosAdapter } from "axios";
3
+ import { FirecrawlClient } from "../../../v2/client";
4
+ import { pinToApiOrigin } from "../../../utils/apiOrigin";
5
+
6
+ const API_URL = "https://api.firecrawl.dev";
7
+ const API_KEY = "fc-test";
8
+
9
+ const origins: Array<[string, string]> = [
10
+ ["same origin", API_URL],
11
+ ["cross host", "https://evil.example"],
12
+ ["protocol-relative", "//evil.example"],
13
+ ["different port", "https://api.firecrawl.dev:8443"],
14
+ ["different scheme", "http://api.firecrawl.dev"],
15
+ ["whitespace-prefixed", "\n https://evil.example"],
16
+ ];
17
+
18
+ function makeClient(firstPage: (next: string) => unknown, lastPage: unknown, next: string) {
19
+ const client = new FirecrawlClient({ apiKey: API_KEY, apiUrl: API_URL });
20
+ const sent: Array<{ url: string; authorization: unknown }> = [];
21
+ const adapter: AxiosAdapter = async config => {
22
+ const url = axios.getUri(config);
23
+ sent.push({ url, authorization: config.headers.Authorization });
24
+ const data = new URL(url).searchParams.has("skip") ? lastPage : firstPage(next);
25
+ return { data, status: 200, statusText: "OK", headers: {}, config };
26
+ };
27
+ (client as any).http.instance.defaults.adapter = adapter;
28
+ return { client, sent };
29
+ }
30
+
31
+ function expectPinned(sent: Array<{ url: string; authorization: unknown }>, path: string) {
32
+ expect(sent).toHaveLength(2);
33
+ expect(sent[1]!.url).toBe(`${API_URL}${path}?skip=10`);
34
+ for (const req of sent) {
35
+ expect(new URL(req.url).origin).toBe(API_URL);
36
+ expect(req.url).not.toContain("evil.example");
37
+ expect(req.authorization).toBe(`Bearer ${API_KEY}`);
38
+ }
39
+ }
40
+
41
+ describe("v2 pagination pins next URLs to the api_url origin", () => {
42
+ describe.each(origins)("%s next", (_label, origin) => {
43
+ test("crawl status", async () => {
44
+ const path = "/v2/crawl/abc";
45
+ const { client, sent } = makeClient(
46
+ next => ({ success: true, status: "completed", completed: 2, total: 2, next, data: [{ markdown: "a" }] }),
47
+ { success: true, next: null, data: [{ markdown: "b" }] },
48
+ `${origin}${path}?skip=10`,
49
+ );
50
+ const job = await client.getCrawlStatus("abc");
51
+ expect(job.data).toHaveLength(2);
52
+ expectPinned(sent, path);
53
+ });
54
+
55
+ test("batch scrape status", async () => {
56
+ const path = "/v2/batch/scrape/abc";
57
+ const { client, sent } = makeClient(
58
+ next => ({ success: true, status: "completed", completed: 2, total: 2, next, data: [{ markdown: "a" }] }),
59
+ { success: true, next: null, data: [{ markdown: "b" }] },
60
+ `${origin}${path}?skip=10`,
61
+ );
62
+ const job = await client.getBatchScrapeStatus("abc");
63
+ expect(job.data).toHaveLength(2);
64
+ expectPinned(sent, path);
65
+ });
66
+
67
+ test("monitor check", async () => {
68
+ const path = "/v2/monitor/m1/checks/c1";
69
+ const { client, sent } = makeClient(
70
+ next => ({ success: true, data: { id: "c1", pages: [{ url: "https://a.example" }], next } }),
71
+ { success: true, data: { pages: [{ url: "https://b.example" }], next: null } },
72
+ `${origin}${path}?skip=10`,
73
+ );
74
+ const check = await client.getMonitorCheck("m1", "c1");
75
+ expect(check.pages).toHaveLength(2);
76
+ expectPinned(sent, path);
77
+ });
78
+ });
79
+ });
80
+
81
+ describe("pinToApiOrigin", () => {
82
+ test("rewrites absolute URLs onto the api origin, keeping path and query and dropping the fragment", () => {
83
+ expect(pinToApiOrigin(API_URL, "https://evil.example/v2/crawl/abc?skip=10#frag")).toBe(
84
+ `${API_URL}/v2/crawl/abc?skip=10`,
85
+ );
86
+ expect(pinToApiOrigin("http://localhost:3002", "https://evil.example:444/v2/crawl/abc?skip=10")).toBe(
87
+ "http://localhost:3002/v2/crawl/abc?skip=10",
88
+ );
89
+ expect(pinToApiOrigin(API_URL, "\\\\evil.example/v2/crawl/abc")).toBe(`${API_URL}/v2/crawl/abc`);
90
+ for (const url of ["\nhttps://evil.example/v2/crawl/abc", "h\tttps://evil.example/v2/crawl/abc", "\u0000 //evil.example/v2/crawl/abc", "/\\evil.example/v2/crawl/abc"]) {
91
+ expect(pinToApiOrigin(API_URL, url)).toBe(`${API_URL}/v2/crawl/abc`);
92
+ }
93
+ });
94
+
95
+ test("leaves relative URLs unchanged", () => {
96
+ expect(pinToApiOrigin(API_URL, "/v2/crawl/abc?skip=10")).toBe("/v2/crawl/abc?skip=10");
97
+ });
98
+
99
+ test("throws when apiUrl is not absolute and the URL needs pinning", () => {
100
+ expect(() => pinToApiOrigin("api.firecrawl.dev", "https://evil.example/v2/crawl/abc")).toThrow(
101
+ "apiUrl must be an absolute URL",
102
+ );
103
+ });
104
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Rewrite an absolute or protocol-relative URL onto apiUrl's scheme, host and
3
+ * port (keeping path and query, dropping any fragment) so credentials never
4
+ * leave the configured API origin. Relative URLs are returned unchanged.
5
+ * Throws if apiUrl is not an absolute URL.
6
+ */
7
+ export function pinToApiOrigin(apiUrl: string, url: string): string {
8
+ const normalized = url.replace(/[\t\n\r]/g, "").replace(/^[\u0000-\u0020]+/, "");
9
+ if (!/^([a-z][a-z\d+\-.]*:|[\\/]{2})/i.test(normalized)) return url;
10
+ const base = URL.canParse(apiUrl) ? new URL(apiUrl) : null;
11
+ if (!base?.host) {
12
+ throw new Error(`apiUrl must be an absolute URL, got ${JSON.stringify(apiUrl)}`);
13
+ }
14
+ const target = new URL(normalized, base);
15
+ const pinned = new URL(base.origin);
16
+ pinned.pathname = target.pathname;
17
+ pinned.search = target.search;
18
+ return pinned.href;
19
+ }
package/src/v1/index.ts CHANGED
@@ -2,6 +2,8 @@ import axios, { type AxiosResponse, type AxiosRequestHeaders, AxiosError } from
2
2
  import * as zt from "zod";
3
3
  import { zodSchemaToJsonSchema } from "../utils/zodSchemaToJson";
4
4
  import { TypedEventTarget } from "typescript-event-target";
5
+ import type { RequiresAction } from "../v2/types";
6
+ import { pinToApiOrigin } from "../utils/apiOrigin";
5
7
 
6
8
  /**
7
9
  * Configuration interface for FirecrawlApp.
@@ -439,6 +441,8 @@ export interface CrawlErrorsResponse {
439
441
  url: string,
440
442
  code?: string,
441
443
  error: string,
444
+ /** Present when the page needs provider terms accepted first. */
445
+ requiresAction?: RequiresAction,
442
446
  }[];
443
447
 
444
448
  /**
@@ -919,7 +923,7 @@ export default class FirecrawlApp {
919
923
  }
920
924
 
921
925
  const headers: AxiosRequestHeaders = this.prepareHeaders();
922
- const targetURL = new URL(nextURL ?? `${this.apiUrl}/v1/crawl/${id}`);
926
+ const targetURL = new URL(nextURL ?? `${this.apiUrl}/v1/crawl/${id}`, this.apiUrl);
923
927
  if (skip !== undefined) {
924
928
  targetURL.searchParams.set("skip", skip.toString());
925
929
  }
@@ -1207,7 +1211,7 @@ export default class FirecrawlApp {
1207
1211
  }
1208
1212
 
1209
1213
  const headers: AxiosRequestHeaders = this.prepareHeaders();
1210
- const targetURL = new URL(nextURL ?? `${this.apiUrl}/v1/batch/scrape/${id}`);
1214
+ const targetURL = new URL(nextURL ?? `${this.apiUrl}/v1/batch/scrape/${id}`, this.apiUrl);
1211
1215
  if (skip !== undefined) {
1212
1216
  targetURL.searchParams.set("skip", skip.toString());
1213
1217
  }
@@ -1448,7 +1452,7 @@ export default class FirecrawlApp {
1448
1452
  headers: AxiosRequestHeaders
1449
1453
  ): Promise<AxiosResponse> {
1450
1454
  try {
1451
- return await axios.get(url, { headers });
1455
+ return await axios.get(pinToApiOrigin(this.apiUrl, url), { headers });
1452
1456
  } catch (error) {
1453
1457
  if (error instanceof AxiosError && error.response) {
1454
1458
  return error.response as AxiosResponse;