firecrawl 4.39.0 → 4.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/{chunk-4DPCCKWG.js → chunk-I2DFXQYR.js} +1 -1
- package/dist/index.cjs +169 -10
- package/dist/index.d.cts +167 -13
- package/dist/index.d.ts +167 -13
- package/dist/index.js +168 -10
- package/dist/{package-D2QZMRW7.js → package-V72LKFNW.js} +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +2 -2
- package/src/__tests__/unit/v2/extract.unit.test.ts +23 -0
- package/src/__tests__/unit/v2/threat-protection.unit.test.ts +18 -1
- package/src/__tests__/unit/v2/tools.unit.test.ts +297 -0
- package/src/v2/client.ts +39 -3
- package/src/v2/methods/extract.ts +2 -1
- package/src/v2/methods/scrape.ts +3 -1
- package/src/v2/methods/search.ts +8 -1
- package/src/v2/methods/tools.ts +137 -0
- package/src/v2/types.ts +186 -9
- package/src/v2/utils/errorHandler.ts +19 -3
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import { createServer, type Server } from "node:http";
|
|
11
|
+
import { FirecrawlClient } from "../../../v2/client";
|
|
12
|
+
|
|
13
|
+
import { scrapeAlexandria } from "../../../v2/methods/tools";
|
|
14
|
+
|
|
15
|
+
const tool = {
|
|
16
|
+
id: "particle/podcasts/episodes/search",
|
|
17
|
+
provider: "particle",
|
|
18
|
+
capability: "podcasts/episodes/search",
|
|
19
|
+
name: "Episode search",
|
|
20
|
+
description: "Find episodes",
|
|
21
|
+
creditsCost: 15,
|
|
22
|
+
perRecord: false,
|
|
23
|
+
options: [{ name: "semantic_search", type: "string" }],
|
|
24
|
+
response: { about: "Episodes", key: "data", fields: [] },
|
|
25
|
+
examples: { javascript: "example", python: "example", curl: "example" },
|
|
26
|
+
matchedBy: ["semantic", "domain"],
|
|
27
|
+
matchedUrls: ["https://podcasts.apple.com"],
|
|
28
|
+
};
|
|
29
|
+
const productionTool = {
|
|
30
|
+
id: "benzinga/calendar/ratings",
|
|
31
|
+
provider: "benzinga",
|
|
32
|
+
capability: "calendar/ratings",
|
|
33
|
+
name: "Analyst ratings",
|
|
34
|
+
description: "Ratings",
|
|
35
|
+
creditsCost: 5,
|
|
36
|
+
perRecord: false,
|
|
37
|
+
label: "Ratings",
|
|
38
|
+
whenToUse: "Analyst ratings for a ticker",
|
|
39
|
+
returns: { about: "Ratings" },
|
|
40
|
+
discovery: { urls: [] },
|
|
41
|
+
attribution: { required: true },
|
|
42
|
+
options: [{ name: "tickers", type: "string" }],
|
|
43
|
+
response: { fields: [] },
|
|
44
|
+
matchedBy: ["semantic"],
|
|
45
|
+
matchedUrls: [],
|
|
46
|
+
};
|
|
47
|
+
const termsRequired = {
|
|
48
|
+
success: false,
|
|
49
|
+
code: "THIRD_PARTY_DATA_TERMS_REQUIRED",
|
|
50
|
+
error: "An organization admin must accept the benzinga provider's terms",
|
|
51
|
+
requiresAction: {
|
|
52
|
+
type: "accept_terms",
|
|
53
|
+
terms: "benzinga",
|
|
54
|
+
version: "C-1.0.0-draft",
|
|
55
|
+
url: "https://www.firecrawl.dev/app/alexandria/benzinga",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const next = {
|
|
59
|
+
provider: "firecrawl",
|
|
60
|
+
capability: "find-tools",
|
|
61
|
+
options: { providers: ["particle"], level: "tools" },
|
|
62
|
+
};
|
|
63
|
+
const compactTools = [tool, productionTool].map(
|
|
64
|
+
({ provider, capability, description }) => ({
|
|
65
|
+
provider,
|
|
66
|
+
capability,
|
|
67
|
+
description,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
let server: Server;
|
|
71
|
+
let client: FirecrawlClient;
|
|
72
|
+
const sent: Array<{ body: any; id: string | undefined }> = [];
|
|
73
|
+
let attempts = 0;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
sent.length = 0;
|
|
77
|
+
attempts = 0;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
server = createServer(async (req, res) => {
|
|
82
|
+
let raw = "";
|
|
83
|
+
for await (const chunk of req) raw += chunk;
|
|
84
|
+
const body = JSON.parse(raw);
|
|
85
|
+
sent.push({ body, id: req.headers["x-request-id"] as string | undefined });
|
|
86
|
+
res.setHeader("content-type", "application/json");
|
|
87
|
+
if (req.url === "/v2/search")
|
|
88
|
+
return res.end(
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
success: true,
|
|
91
|
+
warning: "Example warning",
|
|
92
|
+
data: {
|
|
93
|
+
web: [{ url: "https://podcasts.apple.com" }],
|
|
94
|
+
tools:
|
|
95
|
+
body.toolDetail === "compact"
|
|
96
|
+
? compactTools
|
|
97
|
+
: [tool, productionTool],
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
if (req.url === "/v2/scrape" && body.url)
|
|
102
|
+
return res.end(
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
success: true,
|
|
105
|
+
data: { markdown: "Example", tools: compactTools },
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
if (body.alexandria[0].provider === "retry" && attempts++ === 0) {
|
|
109
|
+
res.statusCode = 502;
|
|
110
|
+
return res.end("{}");
|
|
111
|
+
}
|
|
112
|
+
if (body.alexandria[0].provider === "benzinga") {
|
|
113
|
+
res.statusCode = 403;
|
|
114
|
+
return res.end(JSON.stringify(termsRequired));
|
|
115
|
+
}
|
|
116
|
+
if (body.alexandria[0].provider === "denied") {
|
|
117
|
+
res.statusCode = 402;
|
|
118
|
+
return res.end(
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
success: false,
|
|
121
|
+
code: "insufficient_credits",
|
|
122
|
+
error: "Insufficient credits",
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const alexandria =
|
|
127
|
+
body.alexandria[0].provider === "firecrawl"
|
|
128
|
+
? [
|
|
129
|
+
{
|
|
130
|
+
...next,
|
|
131
|
+
creditsCost: 0,
|
|
132
|
+
data: {
|
|
133
|
+
level: "providers",
|
|
134
|
+
items: [{ id: "particle", name: "Particle", next }],
|
|
135
|
+
total: 1,
|
|
136
|
+
next: null,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
]
|
|
140
|
+
: [
|
|
141
|
+
{
|
|
142
|
+
provider: "retry",
|
|
143
|
+
capability: "a/b",
|
|
144
|
+
creditsCost: 15,
|
|
145
|
+
data: { nested: { value: 1 } },
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
provider: "x",
|
|
149
|
+
capability: "b",
|
|
150
|
+
error: {
|
|
151
|
+
code: "unavailable",
|
|
152
|
+
message: "Unavailable",
|
|
153
|
+
status: 503,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
res.end(
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
success: true,
|
|
160
|
+
scrape_id: "scrape-1",
|
|
161
|
+
data: { alexandria, creditsCost: alexandria.length === 1 ? 0 : 15 },
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
166
|
+
const port = (server.address() as { port: number }).port;
|
|
167
|
+
client = new FirecrawlClient({
|
|
168
|
+
apiKey: "fc-test",
|
|
169
|
+
apiUrl: `http://127.0.0.1:${port}`,
|
|
170
|
+
backoffFactor: 0,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
afterAll(async () => {
|
|
174
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("Alexandria contracts and execution", () => {
|
|
178
|
+
test.each(["compact", "summary", "full"] as const)(
|
|
179
|
+
"forwards %s discovery detail",
|
|
180
|
+
async (toolDetail) => {
|
|
181
|
+
const result = await client.search("records", { toolDetail });
|
|
182
|
+
if (toolDetail === "compact") expect(result.tools).toEqual(compactTools);
|
|
183
|
+
expect(sent.at(-1)?.body).toMatchObject({ toolDetail });
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
test("URL scrape forwards compact detail and preserves tools", async () => {
|
|
187
|
+
const result = await client.scrape("https://example.com", {
|
|
188
|
+
domainTools: true,
|
|
189
|
+
toolDetail: "compact",
|
|
190
|
+
});
|
|
191
|
+
expect(sent.at(-1)?.body).toMatchObject({
|
|
192
|
+
url: "https://example.com",
|
|
193
|
+
domainTools: true,
|
|
194
|
+
toolDetail: "compact",
|
|
195
|
+
});
|
|
196
|
+
expect(result.tools).toEqual(compactTools);
|
|
197
|
+
});
|
|
198
|
+
test("returns complete unified tools and warning beside web results", async () => {
|
|
199
|
+
const result = await client.search("podcasts", {
|
|
200
|
+
sources: ["web", { type: "alexandria" }],
|
|
201
|
+
domainTools: true,
|
|
202
|
+
limit: 2,
|
|
203
|
+
});
|
|
204
|
+
expect(result.tools).toEqual([tool, productionTool]);
|
|
205
|
+
expect(result.tools?.[1].examples).toBeUndefined();
|
|
206
|
+
expect(result.tools?.[1].whenToUse).toBe("Analyst ratings for a ticker");
|
|
207
|
+
expect(result.warning).toBe("Example warning");
|
|
208
|
+
expect(sent.at(-1)?.body).toMatchObject({
|
|
209
|
+
domainTools: true,
|
|
210
|
+
sources: ["web", { type: "alexandria" }],
|
|
211
|
+
});
|
|
212
|
+
expect(result).not.toHaveProperty("alexandria");
|
|
213
|
+
});
|
|
214
|
+
test("retains one ID through transport retries and per-tool failures", async () => {
|
|
215
|
+
const result = await client.scrape({
|
|
216
|
+
alexandria: [{ provider: "retry", capability: "a/b" }],
|
|
217
|
+
requestId: "same-request",
|
|
218
|
+
});
|
|
219
|
+
const retries = sent.filter(
|
|
220
|
+
(r) => r.body.alexandria?.[0].provider === "retry",
|
|
221
|
+
);
|
|
222
|
+
expect(retries).toHaveLength(2);
|
|
223
|
+
expect(retries.map((r) => r.id)).toEqual(["same-request", "same-request"]);
|
|
224
|
+
expect(retries[0].body).not.toHaveProperty("requestId");
|
|
225
|
+
expect(result.requestId).toBe("same-request");
|
|
226
|
+
expect(result.creditsCost).toBe(15);
|
|
227
|
+
expect(result.alexandria[1].error?.code).toBe("unavailable");
|
|
228
|
+
});
|
|
229
|
+
test("returns an error code and retry identity on failed execution", async () => {
|
|
230
|
+
await expect(
|
|
231
|
+
client.scrape({
|
|
232
|
+
alexandria: { provider: "denied", capability: "a/b" },
|
|
233
|
+
requestId: "denied-1",
|
|
234
|
+
}),
|
|
235
|
+
).rejects.toMatchObject({
|
|
236
|
+
status: 402,
|
|
237
|
+
code: "insufficient_credits",
|
|
238
|
+
requestId: "denied-1",
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
test("exposes requiresAction on a provider terms rejection", async () => {
|
|
242
|
+
await expect(
|
|
243
|
+
client.scrape({
|
|
244
|
+
alexandria: { provider: "benzinga", capability: "calendar/ratings" },
|
|
245
|
+
requestId: "terms-1",
|
|
246
|
+
}),
|
|
247
|
+
).rejects.toMatchObject({
|
|
248
|
+
status: 403,
|
|
249
|
+
code: "THIRD_PARTY_DATA_TERMS_REQUIRED",
|
|
250
|
+
message: termsRequired.error,
|
|
251
|
+
requestId: "terms-1",
|
|
252
|
+
requiresAction: termsRequired.requiresAction,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
test("walks with Find Tools and feeds next directly into scrape", async () => {
|
|
256
|
+
const found = await client.findTools({ providers: ["particle"], limit: 2 });
|
|
257
|
+
const result = await client.scrape({ alexandria: found.items[0].next! });
|
|
258
|
+
expect(result.creditsCost).toBe(0);
|
|
259
|
+
expect(sent.at(-1)?.id).toBeTruthy();
|
|
260
|
+
expect(sent.at(-1)?.body.alexandria).toEqual([next]);
|
|
261
|
+
});
|
|
262
|
+
test("rejects URL options and queryless browsing before dispatch", async () => {
|
|
263
|
+
const count = sent.length;
|
|
264
|
+
await expect(
|
|
265
|
+
client.scrape({ alexandria: next, url: "https://example.com" } as any),
|
|
266
|
+
).rejects.toThrow();
|
|
267
|
+
await expect(
|
|
268
|
+
client.search("", { sources: ["alexandria"] }),
|
|
269
|
+
).rejects.toThrow("Query cannot be empty");
|
|
270
|
+
expect(sent).toHaveLength(count);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test.each([
|
|
275
|
+
[undefined, 80000],
|
|
276
|
+
[1000, 31000],
|
|
277
|
+
[100000, 80000],
|
|
278
|
+
])(
|
|
279
|
+
"Alexandria timeout %s allows response delivery (%s ms)",
|
|
280
|
+
async (timeout, timeoutMs) => {
|
|
281
|
+
const http = {
|
|
282
|
+
post: jest.fn(async () => ({
|
|
283
|
+
status: 200,
|
|
284
|
+
data: {
|
|
285
|
+
success: true,
|
|
286
|
+
data: { alexandria: [], creditsCost: 0 },
|
|
287
|
+
},
|
|
288
|
+
})),
|
|
289
|
+
};
|
|
290
|
+
await scrapeAlexandria(http as any, [next], { timeout });
|
|
291
|
+
expect(http.post).toHaveBeenCalledWith(
|
|
292
|
+
"/v2/scrape",
|
|
293
|
+
expect.anything(),
|
|
294
|
+
expect.objectContaining({ timeoutMs }),
|
|
295
|
+
);
|
|
296
|
+
},
|
|
297
|
+
);
|
package/src/v2/client.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "./methods/scrape";
|
|
7
7
|
import { parse as parseMethod } from "./methods/parse";
|
|
8
8
|
import { search } from "./methods/search";
|
|
9
|
+
import { scrapeAlexandria, findTools } from "./methods/tools";
|
|
9
10
|
import { developerSearch as developerSearchMethod } from "./methods/developer";
|
|
10
11
|
import { map as mapMethod } from "./methods/map";
|
|
11
12
|
import { feedback as feedbackMethod, searchFeedback as searchFeedbackMethod } from "./methods/feedback";
|
|
@@ -47,6 +48,10 @@ import {
|
|
|
47
48
|
} from "./methods/monitor";
|
|
48
49
|
import type {
|
|
49
50
|
Document,
|
|
51
|
+
AlexandriaScrapeRequest,
|
|
52
|
+
FindToolsOptions,
|
|
53
|
+
FindToolsData,
|
|
54
|
+
AlexandriaScrapeData,
|
|
50
55
|
ParseFile,
|
|
51
56
|
ParseOptions,
|
|
52
57
|
ScrapeOptions,
|
|
@@ -171,11 +176,42 @@ export class FirecrawlClient {
|
|
|
171
176
|
*/
|
|
172
177
|
async scrape<Opts extends ScrapeOptions>(
|
|
173
178
|
url: string,
|
|
174
|
-
options: Opts
|
|
179
|
+
options: Opts,
|
|
175
180
|
): Promise<Omit<Document, "json"> & { json?: InferredJsonFromOptions<Opts> }>;
|
|
176
181
|
async scrape(url: string, options?: ScrapeCallOptions): Promise<Document>;
|
|
177
|
-
async scrape(
|
|
178
|
-
|
|
182
|
+
async scrape(request: AlexandriaScrapeRequest): Promise<AlexandriaScrapeData>;
|
|
183
|
+
async scrape(
|
|
184
|
+
url: string | AlexandriaScrapeRequest,
|
|
185
|
+
options?: ScrapeCallOptions,
|
|
186
|
+
): Promise<Document | AlexandriaScrapeData> {
|
|
187
|
+
if (typeof url === "string") return scrape(this.http, url, options);
|
|
188
|
+
if (
|
|
189
|
+
!url ||
|
|
190
|
+
options !== undefined ||
|
|
191
|
+
Object.keys(url).some(
|
|
192
|
+
(key) =>
|
|
193
|
+
![
|
|
194
|
+
"alexandria",
|
|
195
|
+
"requestId",
|
|
196
|
+
"timeout",
|
|
197
|
+
"integration",
|
|
198
|
+
"origin",
|
|
199
|
+
].includes(key),
|
|
200
|
+
)
|
|
201
|
+
) {
|
|
202
|
+
throw new Error("Provide an alexandria request without URL scrape options");
|
|
203
|
+
}
|
|
204
|
+
const { alexandria, ...opts } = url;
|
|
205
|
+
return scrapeAlexandria(
|
|
206
|
+
this.http,
|
|
207
|
+
Array.isArray(alexandria) ? alexandria : [alexandria],
|
|
208
|
+
opts,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Explore the catalogue without executing the tools it returns. */
|
|
213
|
+
async findTools(options?: FindToolsOptions): Promise<FindToolsData> {
|
|
214
|
+
return findTools(this.http, options);
|
|
179
215
|
}
|
|
180
216
|
/**
|
|
181
217
|
* Interact with the browser session associated with a scrape job.
|
|
@@ -40,7 +40,8 @@ function prepareExtractPayload(args: {
|
|
|
40
40
|
body.threatProtection = args.threatProtection;
|
|
41
41
|
if (args.scrapeOptions) {
|
|
42
42
|
ensureValidScrapeOptions(args.scrapeOptions);
|
|
43
|
-
|
|
43
|
+
const { toolDetail, domainTools, ...scrapeOptions } = args.scrapeOptions;
|
|
44
|
+
body.scrapeOptions = scrapeOptions;
|
|
44
45
|
}
|
|
45
46
|
return body;
|
|
46
47
|
}
|
package/src/v2/methods/scrape.ts
CHANGED
|
@@ -88,9 +88,11 @@ export async function scrape(
|
|
|
88
88
|
if (options) ensureValidScrapeOptions(options);
|
|
89
89
|
|
|
90
90
|
// autoResume is SDK behavior, never part of the wire payload.
|
|
91
|
-
const { autoResume, ...requestOptions } = options ?? {};
|
|
91
|
+
const { autoResume, domainTools, ...requestOptions } = options ?? {};
|
|
92
92
|
const payload: Record<string, unknown> = { url: url.trim() };
|
|
93
93
|
Object.assign(payload, requestOptions);
|
|
94
|
+
// Default off: omit entirely rather than send `false`.
|
|
95
|
+
if (domainTools) payload.domainTools = domainTools;
|
|
94
96
|
|
|
95
97
|
// Per-request timeout. With auto-resume disabled this is exactly the
|
|
96
98
|
// pre-existing behavior (explicit timeout +5s, else the client's own
|
package/src/v2/methods/search.ts
CHANGED
|
@@ -27,6 +27,8 @@ function prepareSearchPayload(req: SearchRequest): Record<string, unknown> {
|
|
|
27
27
|
const payload: Record<string, unknown> = {
|
|
28
28
|
query: req.query,
|
|
29
29
|
};
|
|
30
|
+
if (req.toolDetail != null) payload.toolDetail = req.toolDetail;
|
|
31
|
+
if (req.domainTools != null) payload.domainTools = req.domainTools;
|
|
30
32
|
if (req.sources) payload.sources = req.sources;
|
|
31
33
|
if (req.categories) payload.categories = req.categories;
|
|
32
34
|
if (req.includeDomains) payload.includeDomains = req.includeDomains;
|
|
@@ -87,6 +89,7 @@ export async function search(
|
|
|
87
89
|
success: boolean;
|
|
88
90
|
data?: Record<string, unknown>;
|
|
89
91
|
error?: string;
|
|
92
|
+
warning?: string;
|
|
90
93
|
}>(
|
|
91
94
|
"/v2/search",
|
|
92
95
|
payload,
|
|
@@ -99,10 +102,12 @@ export async function search(
|
|
|
99
102
|
}
|
|
100
103
|
const data = (res.data.data || {}) as Record<string, any>;
|
|
101
104
|
const out: SearchData = {};
|
|
105
|
+
if (res.data.warning) out.warning = res.data.warning;
|
|
102
106
|
if (data.web) out.web = transformArray<SearchResultWeb>(data.web);
|
|
103
107
|
if (data.news) out.news = transformArray<SearchResultNews>(data.news);
|
|
104
108
|
if (data.images)
|
|
105
109
|
out.images = transformArray<SearchResultImages>(data.images);
|
|
110
|
+
if (data.tools) out.tools = data.tools;
|
|
106
111
|
Object.defineProperty(out, "data", {
|
|
107
112
|
get() {
|
|
108
113
|
const parts: string[] = [];
|
|
@@ -110,9 +115,11 @@ export async function search(
|
|
|
110
115
|
if (out.news?.length) parts.push(`.news (${out.news.length} results)`);
|
|
111
116
|
if (out.images?.length)
|
|
112
117
|
parts.push(`.images (${out.images.length} results)`);
|
|
118
|
+
if (out.tools?.length)
|
|
119
|
+
parts.push(`.tools (${out.tools.length} results)`);
|
|
113
120
|
const available = parts.length
|
|
114
121
|
? parts.join(", ")
|
|
115
|
-
: ".web, .news, or .
|
|
122
|
+
: ".web, .news, .images, or .tools";
|
|
116
123
|
throw new Error(
|
|
117
124
|
`SearchData has no '.data'. Results are grouped by source: ${available}`,
|
|
118
125
|
);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AlexandriaCall,
|
|
3
|
+
AlexandriaOptions,
|
|
4
|
+
AlexandriaScrapeData,
|
|
5
|
+
AlexandriaScrapeResult,
|
|
6
|
+
FindToolsData,
|
|
7
|
+
FindToolsOptions,
|
|
8
|
+
} from "../types";
|
|
9
|
+
import { SdkError } from "../types";
|
|
10
|
+
import { HttpClient } from "../utils/httpClient";
|
|
11
|
+
import {
|
|
12
|
+
normalizeAxiosError,
|
|
13
|
+
throwForBadResponse,
|
|
14
|
+
} from "../utils/errorHandler";
|
|
15
|
+
|
|
16
|
+
const ALEXANDRIA_MAX_CALLS = 10;
|
|
17
|
+
|
|
18
|
+
function prepareAlexandriaPayload(
|
|
19
|
+
calls: AlexandriaCall[],
|
|
20
|
+
opts: AlexandriaOptions,
|
|
21
|
+
): Record<string, unknown> {
|
|
22
|
+
if (!Array.isArray(calls) || calls.length === 0) {
|
|
23
|
+
throw new Error("alexandria requires at least one call");
|
|
24
|
+
}
|
|
25
|
+
if (calls.length > ALEXANDRIA_MAX_CALLS) {
|
|
26
|
+
throw new Error(`alexandria accepts at most ${ALEXANDRIA_MAX_CALLS} calls`);
|
|
27
|
+
}
|
|
28
|
+
const alexandria = calls.map((call, index) => {
|
|
29
|
+
if (!call || typeof call.provider !== "string" || !call.provider.trim()) {
|
|
30
|
+
throw new Error(`alexandria[${index}].provider cannot be empty`);
|
|
31
|
+
}
|
|
32
|
+
if (typeof call.capability !== "string" || !call.capability.trim()) {
|
|
33
|
+
throw new Error(`alexandria[${index}].capability cannot be empty`);
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
Object.keys(call).some(
|
|
37
|
+
(key) => !["provider", "capability", "options"].includes(key),
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
throw new Error("Unknown alexandria call option");
|
|
41
|
+
const item: Record<string, unknown> = {
|
|
42
|
+
provider: call.provider.trim(),
|
|
43
|
+
capability: call.capability.trim(),
|
|
44
|
+
};
|
|
45
|
+
if (call.options != null) {
|
|
46
|
+
if (typeof call.options !== "object" || Array.isArray(call.options)) {
|
|
47
|
+
throw new Error(`alexandria[${index}].options must be an object`);
|
|
48
|
+
}
|
|
49
|
+
item.options = call.options;
|
|
50
|
+
}
|
|
51
|
+
return item;
|
|
52
|
+
});
|
|
53
|
+
if (
|
|
54
|
+
opts.timeout != null &&
|
|
55
|
+
(!Number.isInteger(opts.timeout) || opts.timeout <= 0)
|
|
56
|
+
) {
|
|
57
|
+
throw new Error("timeout must be a positive integer");
|
|
58
|
+
}
|
|
59
|
+
const payload: Record<string, unknown> = { alexandria };
|
|
60
|
+
if (opts.timeout != null) payload.timeout = opts.timeout;
|
|
61
|
+
if (opts.integration && opts.integration.trim())
|
|
62
|
+
payload.integration = opts.integration.trim();
|
|
63
|
+
if (opts.origin) payload.origin = opts.origin;
|
|
64
|
+
return payload;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function scrapeAlexandria(
|
|
68
|
+
http: HttpClient,
|
|
69
|
+
calls: AlexandriaCall[],
|
|
70
|
+
opts: AlexandriaOptions = {},
|
|
71
|
+
): Promise<AlexandriaScrapeData> {
|
|
72
|
+
const payload = prepareAlexandriaPayload(calls, opts);
|
|
73
|
+
const requestId = opts.requestId ?? crypto.randomUUID();
|
|
74
|
+
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(requestId))
|
|
75
|
+
throw new Error("Invalid requestId");
|
|
76
|
+
try {
|
|
77
|
+
const res = await http.post<{
|
|
78
|
+
success: boolean;
|
|
79
|
+
scrape_id?: string;
|
|
80
|
+
data?: { alexandria?: AlexandriaScrapeResult[]; creditsCost?: number };
|
|
81
|
+
error?: string;
|
|
82
|
+
}>("/v2/scrape", payload, {
|
|
83
|
+
headers: { "x-request-id": requestId },
|
|
84
|
+
// Allow response delivery after the API's capped execution deadline.
|
|
85
|
+
timeoutMs: Math.min(opts.timeout ?? 50000, 50000) + 30000,
|
|
86
|
+
});
|
|
87
|
+
if (res.status !== 200 || !res.data?.success) {
|
|
88
|
+
throwForBadResponse(res, "alexandria");
|
|
89
|
+
}
|
|
90
|
+
const data = res.data.data;
|
|
91
|
+
if (
|
|
92
|
+
!data ||
|
|
93
|
+
!Array.isArray(data.alexandria) ||
|
|
94
|
+
typeof data.creditsCost !== "number" ||
|
|
95
|
+
!Number.isInteger(data.creditsCost) ||
|
|
96
|
+
data.creditsCost < 0
|
|
97
|
+
) {
|
|
98
|
+
throw new SdkError("Invalid alexandria response");
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
scrapeId: res.data.scrape_id ?? "",
|
|
102
|
+
requestId,
|
|
103
|
+
alexandria: data.alexandria,
|
|
104
|
+
creditsCost: data.creditsCost,
|
|
105
|
+
};
|
|
106
|
+
} catch (err: any) {
|
|
107
|
+
try {
|
|
108
|
+
if (err?.isAxiosError) normalizeAxiosError(err, "alexandria");
|
|
109
|
+
throw err;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error && typeof error === "object")
|
|
112
|
+
Object.assign(error, { requestId });
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function findTools(
|
|
119
|
+
http: HttpClient,
|
|
120
|
+
options: FindToolsOptions = {},
|
|
121
|
+
): Promise<FindToolsData> {
|
|
122
|
+
const result = await scrapeAlexandria(http, [
|
|
123
|
+
{
|
|
124
|
+
provider: "firecrawl",
|
|
125
|
+
capability: "find-tools",
|
|
126
|
+
options: { ...options },
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
const item = result.alexandria[0];
|
|
130
|
+
if (!item) throw new SdkError("Missing Find Tools result");
|
|
131
|
+
if (item.error)
|
|
132
|
+
throw Object.assign(
|
|
133
|
+
new SdkError(item.error.message, item.error.status, item.error.code),
|
|
134
|
+
{ requestId: result.requestId },
|
|
135
|
+
);
|
|
136
|
+
return item.data as FindToolsData;
|
|
137
|
+
}
|