@mendable/firecrawl-js 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/{chunk-I2DFXQYR.js → chunk-YG554MNR.js} +1 -1
- package/dist/index.cjs +34 -12
- package/dist/index.d.cts +135 -12
- package/dist/index.d.ts +135 -12
- package/dist/index.js +35 -13
- package/dist/{package-V72LKFNW.js → package-FOKY5K5X.js} +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +3 -1
- package/src/__tests__/unit/v1/next-url-pinning.test.ts +81 -0
- package/src/__tests__/unit/v2/agent-hints.test.ts +116 -0
- package/src/__tests__/unit/v2/agent-thread.unit.test.ts +122 -0
- package/src/__tests__/unit/v2/next-url-pinning.test.ts +104 -0
- package/src/utils/apiOrigin.ts +19 -0
- package/src/v1/index.ts +7 -3
- package/src/v2/methods/batch.ts +1 -1
- package/src/v2/methods/crawl.ts +1 -1
- package/src/v2/methods/map.ts +2 -1
- package/src/v2/methods/parse.ts +2 -1
- package/src/v2/methods/scrape.ts +2 -1
- package/src/v2/methods/search.ts +2 -1
- package/src/v2/types.ts +138 -11
- package/src/v2/utils/agentHints.ts +7 -0
- package/src/v2/utils/errorHandler.ts +5 -5
- package/src/v2/utils/httpClient.ts +2 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
require_package
|
|
3
|
-
} from "./chunk-
|
|
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-
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mendable/firecrawl-js",
|
|
3
|
-
"version": "4.
|
|
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",
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -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
|
-
|
|
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;
|