@mendable/firecrawl-js 4.35.0 → 4.38.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/dist/{chunk-666CR5HH.js → chunk-KSTNOGOD.js} +1 -1
- package/dist/index.cjs +114 -15
- package/dist/index.d.cts +224 -2
- package/dist/index.d.ts +224 -2
- package/dist/index.js +115 -16
- package/dist/{package-ZOVFJE7Z.js → package-OS7RONXY.js} +1 -1
- package/package.json +1 -1
- package/src/__tests__/unit/v2/agent-effort-trace.unit.test.ts +140 -0
- package/src/__tests__/unit/v2/agent-list.unit.test.ts +68 -0
- package/src/__tests__/unit/v2/scrapeAutoResume.test.ts +145 -0
- package/src/v1/index.ts +1 -0
- package/src/v2/client.ts +35 -4
- package/src/v2/methods/agent.ts +46 -1
- package/src/v2/methods/scrape.ts +123 -19
- package/src/v2/types.ts +209 -0
- package/src/v2/utils/httpClient.ts +6 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, jest, test } from "@jest/globals";
|
|
2
|
+
import { listAgents } from "../../../v2/methods/agent";
|
|
3
|
+
|
|
4
|
+
describe("v2.agent listAgents unit", () => {
|
|
5
|
+
const sampleAgent = {
|
|
6
|
+
id: "018f3c5e-0000-7000-8000-000000000000",
|
|
7
|
+
createdAt: "2026-08-31T12:00:00.000Z",
|
|
8
|
+
targetHint: "https://example.com",
|
|
9
|
+
origin: "api",
|
|
10
|
+
settings: { hidden: false, starred: false },
|
|
11
|
+
status: "completed",
|
|
12
|
+
options: {
|
|
13
|
+
urls: ["https://example.com"],
|
|
14
|
+
prompt: "Find the pricing",
|
|
15
|
+
model: "spark-1-pro",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
test("hits GET /v2/agent without query by default", async () => {
|
|
20
|
+
const get = jest.fn().mockResolvedValue({
|
|
21
|
+
status: 200,
|
|
22
|
+
data: { success: true, agents: [sampleAgent] },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const res = await listAgents({ get } as any);
|
|
26
|
+
|
|
27
|
+
expect(get).toHaveBeenCalledWith("/v2/agent");
|
|
28
|
+
expect(res.success).toBe(true);
|
|
29
|
+
expect(res.agents).toHaveLength(1);
|
|
30
|
+
expect(res.agents![0].id).toBe(sampleAgent.id);
|
|
31
|
+
expect(res.agents![0].settings.starred).toBe(false);
|
|
32
|
+
expect(res.next).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("passes before as a query param", async () => {
|
|
36
|
+
const get = jest.fn().mockResolvedValue({
|
|
37
|
+
status: 200,
|
|
38
|
+
data: {
|
|
39
|
+
success: true,
|
|
40
|
+
agents: [sampleAgent],
|
|
41
|
+
next: "https://api.firecrawl.dev/v2/agent?before=1756600000000",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const res = await listAgents({ get } as any, { before: 1756600000000 });
|
|
46
|
+
|
|
47
|
+
expect(get).toHaveBeenCalledWith("/v2/agent?before=1756600000000");
|
|
48
|
+
expect(res.next).toBe(
|
|
49
|
+
"https://api.firecrawl.dev/v2/agent?before=1756600000000",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("does not auto-paginate when next is present", async () => {
|
|
54
|
+
const get = jest.fn().mockResolvedValue({
|
|
55
|
+
status: 200,
|
|
56
|
+
data: {
|
|
57
|
+
success: true,
|
|
58
|
+
agents: [sampleAgent],
|
|
59
|
+
next: "https://api.firecrawl.dev/v2/agent?before=1756600000000",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const res = await listAgents({ get } as any);
|
|
64
|
+
|
|
65
|
+
expect(get).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(res.agents).toHaveLength(1);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
processingContinuesDelayMs,
|
|
4
|
+
scrape,
|
|
5
|
+
} from "../../../v2/methods/scrape";
|
|
6
|
+
|
|
7
|
+
const DOC = { markdown: "# big doc" };
|
|
8
|
+
|
|
9
|
+
function processingContinues408(retryAfterSeconds = 10) {
|
|
10
|
+
return {
|
|
11
|
+
isAxiosError: true,
|
|
12
|
+
response: {
|
|
13
|
+
status: 408,
|
|
14
|
+
headers: { "retry-after": String(retryAfterSeconds) },
|
|
15
|
+
data: {
|
|
16
|
+
success: false,
|
|
17
|
+
code: "SCRAPE_TIMEOUT",
|
|
18
|
+
error: "Request timed out, but this 700-page PDF is still being processed on our side.",
|
|
19
|
+
details: {
|
|
20
|
+
state: "processing_continues",
|
|
21
|
+
documentPages: 700,
|
|
22
|
+
jobStatus: "running",
|
|
23
|
+
estimatedRemainingSeconds: retryAfterSeconds,
|
|
24
|
+
retryAfterSeconds,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("v2.scrape auto-resume", () => {
|
|
32
|
+
test("resumes after processing_continues and returns the finished document", async () => {
|
|
33
|
+
const post = jest
|
|
34
|
+
.fn()
|
|
35
|
+
.mockRejectedValueOnce(processingContinues408(10))
|
|
36
|
+
.mockResolvedValueOnce({ status: 200, data: { success: true, data: DOC } });
|
|
37
|
+
const sleeps: number[] = [];
|
|
38
|
+
const sleepImpl = async (ms: number) => {
|
|
39
|
+
sleeps.push(ms);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const doc = await scrape({ post } as any, "https://example.com/big.pdf", undefined, {
|
|
43
|
+
sleepImpl,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(doc).toEqual(DOC);
|
|
47
|
+
expect(post).toHaveBeenCalledTimes(2);
|
|
48
|
+
expect(sleeps).toEqual([10_000]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("autoResume: false surfaces the timeout immediately and never sleeps", async () => {
|
|
52
|
+
const post = jest.fn().mockRejectedValueOnce(processingContinues408());
|
|
53
|
+
const sleepImpl = jest.fn();
|
|
54
|
+
|
|
55
|
+
await expect(
|
|
56
|
+
scrape({ post } as any, "https://example.com/big.pdf", { autoResume: false }, {
|
|
57
|
+
sleepImpl: sleepImpl as any,
|
|
58
|
+
}),
|
|
59
|
+
).rejects.toBeDefined();
|
|
60
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(sleepImpl).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("autoResume never enters the wire payload", async () => {
|
|
65
|
+
const post = jest
|
|
66
|
+
.fn()
|
|
67
|
+
.mockResolvedValueOnce({ status: 200, data: { success: true, data: DOC } });
|
|
68
|
+
await scrape({ post } as any, "https://example.com/x", { autoResume: true });
|
|
69
|
+
const payload = post.mock.calls[0][1];
|
|
70
|
+
expect(payload).toEqual({ url: "https://example.com/x" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("gives up after the attempt bound and surfaces the error", async () => {
|
|
74
|
+
const post = jest.fn().mockRejectedValue(processingContinues408(10));
|
|
75
|
+
const sleepImpl = jest.fn().mockResolvedValue(undefined);
|
|
76
|
+
|
|
77
|
+
await expect(
|
|
78
|
+
scrape({ post } as any, "https://example.com/big.pdf", undefined, {
|
|
79
|
+
sleepImpl: sleepImpl as any,
|
|
80
|
+
}),
|
|
81
|
+
).rejects.toBeDefined();
|
|
82
|
+
// 1 initial + 5 resumes.
|
|
83
|
+
expect(post).toHaveBeenCalledTimes(6);
|
|
84
|
+
expect(sleepImpl).toHaveBeenCalledTimes(5);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("plain timeouts without the processing signal do not resume", async () => {
|
|
88
|
+
const post = jest.fn().mockRejectedValueOnce({
|
|
89
|
+
isAxiosError: true,
|
|
90
|
+
response: {
|
|
91
|
+
status: 408,
|
|
92
|
+
headers: {},
|
|
93
|
+
data: { success: false, code: "SCRAPE_TIMEOUT", error: "Request timed out" },
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const sleepImpl = jest.fn();
|
|
97
|
+
|
|
98
|
+
await expect(
|
|
99
|
+
scrape({ post } as any, "https://example.com/slow", undefined, {
|
|
100
|
+
sleepImpl: sleepImpl as any,
|
|
101
|
+
}),
|
|
102
|
+
).rejects.toBeDefined();
|
|
103
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(sleepImpl).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("request timeout: opt-out keeps legacy behavior, resume adds the cushion", async () => {
|
|
108
|
+
const ok = { status: 200, data: { success: true, data: DOC } };
|
|
109
|
+
// Opt-out, no explicit timeout → no per-request override at all.
|
|
110
|
+
const post1 = jest.fn().mockResolvedValueOnce(ok);
|
|
111
|
+
await scrape({ post: post1 } as any, "https://example.com/x", { autoResume: false });
|
|
112
|
+
expect(post1.mock.calls[0][2]).toEqual({});
|
|
113
|
+
// Opt-out with explicit timeout → the pre-existing +5s.
|
|
114
|
+
const post2 = jest.fn().mockResolvedValueOnce(ok);
|
|
115
|
+
await scrape({ post: post2 } as any, "https://example.com/x", {
|
|
116
|
+
autoResume: false,
|
|
117
|
+
timeout: 10_000,
|
|
118
|
+
});
|
|
119
|
+
expect(post2.mock.calls[0][2]).toEqual({ timeoutMs: 15_000 });
|
|
120
|
+
// Resume enabled → cushion over the server wall, respecting a larger
|
|
121
|
+
// configured client default as the floor.
|
|
122
|
+
const post3 = jest.fn().mockResolvedValueOnce(ok);
|
|
123
|
+
await scrape(
|
|
124
|
+
{ post: post3, getTimeoutMs: () => 600_000 } as any,
|
|
125
|
+
"https://example.com/x",
|
|
126
|
+
);
|
|
127
|
+
expect(post3.mock.calls[0][2]).toEqual({ timeoutMs: 630_000 });
|
|
128
|
+
// Resume enabled, no client getter → server wall default + cushion.
|
|
129
|
+
const post4 = jest.fn().mockResolvedValueOnce(ok);
|
|
130
|
+
await scrape({ post: post4 } as any, "https://example.com/x", { timeout: 900_000 });
|
|
131
|
+
expect(post4.mock.calls[0][2]).toEqual({ timeoutMs: 930_000 });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("processingContinuesDelayMs clamps and falls back sensibly", () => {
|
|
135
|
+
expect(processingContinuesDelayMs(processingContinues408(1))).toBe(5_000);
|
|
136
|
+
expect(processingContinuesDelayMs(processingContinues408(3_600))).toBe(600_000);
|
|
137
|
+
// No payload field → Retry-After header.
|
|
138
|
+
const headerOnly = processingContinues408(0);
|
|
139
|
+
delete (headerOnly.response.data.details as any).retryAfterSeconds;
|
|
140
|
+
headerOnly.response.headers["retry-after"] = "90";
|
|
141
|
+
expect(processingContinuesDelayMs(headerOnly)).toBe(90_000);
|
|
142
|
+
expect(processingContinuesDelayMs(new Error("net"))).toBeUndefined();
|
|
143
|
+
expect(processingContinuesDelayMs(undefined)).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/v1/index.ts
CHANGED
package/src/v2/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HttpClient } from "./utils/httpClient";
|
|
2
2
|
import {
|
|
3
|
-
scrape,
|
|
3
|
+
scrape, type ScrapeCallOptions,
|
|
4
4
|
interact as interactMethod,
|
|
5
5
|
stopInteraction as stopInteractionMethod,
|
|
6
6
|
} from "./methods/scrape";
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
batchScrape as batchWaiter,
|
|
27
27
|
} from "./methods/batch";
|
|
28
28
|
import { startExtract, getExtractStatus, extract as extractWaiter } from "./methods/extract";
|
|
29
|
-
import { startAgent, getAgentStatus, cancelAgent, agent as agentWaiter } from "./methods/agent";
|
|
29
|
+
import { startAgent, getAgentStatus, getAgentTrace, getAgentSnapshot, cancelAgent, listAgents, agent as agentWaiter } from "./methods/agent";
|
|
30
30
|
import {
|
|
31
31
|
browser as browserMethod,
|
|
32
32
|
browserExecute,
|
|
@@ -68,6 +68,10 @@ import type {
|
|
|
68
68
|
ExtractResponse,
|
|
69
69
|
AgentResponse,
|
|
70
70
|
AgentStatusResponse,
|
|
71
|
+
AgentTraceResponse,
|
|
72
|
+
AgentSnapshotResponse,
|
|
73
|
+
AgentListOptions,
|
|
74
|
+
AgentListResponse,
|
|
71
75
|
CrawlOptions,
|
|
72
76
|
BatchScrapeOptions,
|
|
73
77
|
PaginationConfig,
|
|
@@ -168,8 +172,8 @@ export class FirecrawlClient {
|
|
|
168
172
|
url: string,
|
|
169
173
|
options: Opts
|
|
170
174
|
): Promise<Omit<Document, "json"> & { json?: InferredJsonFromOptions<Opts> }>;
|
|
171
|
-
async scrape(url: string, options?:
|
|
172
|
-
async scrape(url: string, options?:
|
|
175
|
+
async scrape(url: string, options?: ScrapeCallOptions): Promise<Document>;
|
|
176
|
+
async scrape(url: string, options?: ScrapeCallOptions): Promise<Document> {
|
|
173
177
|
return scrape(this.http, url, options);
|
|
174
178
|
}
|
|
175
179
|
/**
|
|
@@ -519,6 +523,17 @@ export class FirecrawlClient {
|
|
|
519
523
|
async getAgentStatus(jobId: string): Promise<AgentStatusResponse> {
|
|
520
524
|
return getAgentStatus(this.http, jobId);
|
|
521
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* List agent runs, most recent first.
|
|
528
|
+
*
|
|
529
|
+
* Pages are fixed at 20 runs. To fetch the next page, pass the `before`
|
|
530
|
+
* value from the previous page's `next` URL. This method does not
|
|
531
|
+
* auto-paginate.
|
|
532
|
+
* @param options.before Only return runs created before this unix ms timestamp.
|
|
533
|
+
*/
|
|
534
|
+
async listAgents(options?: AgentListOptions): Promise<AgentListResponse> {
|
|
535
|
+
return listAgents(this.http, options);
|
|
536
|
+
}
|
|
522
537
|
/**
|
|
523
538
|
* Convenience waiter: start an agent and poll until it finishes.
|
|
524
539
|
* @param args Agent request plus waiter controls (pollInterval, timeout seconds).
|
|
@@ -535,6 +550,22 @@ export class FirecrawlClient {
|
|
|
535
550
|
async cancelAgent(jobId: string): Promise<boolean> {
|
|
536
551
|
return cancelAgent(this.http, jobId);
|
|
537
552
|
}
|
|
553
|
+
/**
|
|
554
|
+
* Get the execution trace of an agent job (spark-2 runs only).
|
|
555
|
+
* @param jobId Agent job id.
|
|
556
|
+
* @param options.liveView Also include currently active browser sessions with live view URLs.
|
|
557
|
+
*/
|
|
558
|
+
async getAgentTrace(jobId: string, options?: { liveView?: boolean }): Promise<AgentTraceResponse> {
|
|
559
|
+
return getAgentTrace(this.http, jobId, options);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Get the full content of an artifact snapshot referenced by a trace event.
|
|
563
|
+
* @param jobId Agent job id.
|
|
564
|
+
* @param snapshotId Snapshot id from an artifact.updated trace event.
|
|
565
|
+
*/
|
|
566
|
+
async getAgentSnapshot(jobId: string, snapshotId: string): Promise<AgentSnapshotResponse> {
|
|
567
|
+
return getAgentSnapshot(this.http, jobId, snapshotId);
|
|
568
|
+
}
|
|
538
569
|
|
|
539
570
|
// Browser
|
|
540
571
|
/**
|
package/src/v2/methods/agent.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentResponse, type AgentStatusResponse, type AgentWebhookConfig, type AuditMetadata, type ThreatProtectionOptions } from "../types";
|
|
1
|
+
import { type AgentEffort, type AgentListOptions, type AgentListResponse, type AgentResponse, type AgentSnapshotResponse, type AgentStatusResponse, type AgentTraceResponse, type AgentWebhookConfig, type AuditMetadata, type ThreatProtectionOptions } from "../types";
|
|
2
2
|
import { HttpClient } from "../utils/httpClient";
|
|
3
3
|
import { normalizeAxiosError, throwForBadResponse } from "../utils/errorHandler";
|
|
4
4
|
import { isZodSchema, zodSchemaToJsonSchema } from "../../utils/zodSchemaToJson";
|
|
@@ -13,6 +13,7 @@ function prepareAgentPayload(args: {
|
|
|
13
13
|
maxCredits?: number;
|
|
14
14
|
strictConstrainToURLs?: boolean;
|
|
15
15
|
model?: "spark-1-pro" | "spark-1-mini" | "spark-2";
|
|
16
|
+
effort?: AgentEffort;
|
|
16
17
|
webhook?: string | AgentWebhookConfig;
|
|
17
18
|
threatProtection?: ThreatProtectionOptions;
|
|
18
19
|
auditMetadata?: AuditMetadata;
|
|
@@ -28,6 +29,7 @@ function prepareAgentPayload(args: {
|
|
|
28
29
|
if (args.maxCredits !== null && args.maxCredits !== undefined) body.maxCredits = args.maxCredits;
|
|
29
30
|
if (args.strictConstrainToURLs !== null && args.strictConstrainToURLs !== undefined) body.strictConstrainToURLs = args.strictConstrainToURLs;
|
|
30
31
|
if (args.model !== null && args.model !== undefined) body.model = args.model;
|
|
32
|
+
if (args.effort !== null && args.effort !== undefined) body.effort = args.effort;
|
|
31
33
|
if (args.webhook != null) body.webhook = args.webhook;
|
|
32
34
|
if (args.threatProtection != null)
|
|
33
35
|
body.threatProtection = args.threatProtection;
|
|
@@ -59,6 +61,18 @@ export async function getAgentStatus(http: HttpClient, jobId: string): Promise<A
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
export async function listAgents(http: HttpClient, options?: AgentListOptions): Promise<AgentListResponse> {
|
|
65
|
+
try {
|
|
66
|
+
const query = options?.before !== undefined ? `?before=${options.before}` : "";
|
|
67
|
+
const res = await http.get<AgentListResponse>(`/v2/agent${query}`);
|
|
68
|
+
if (res.status !== 200) throwForBadResponse(res, "list agents");
|
|
69
|
+
return res.data;
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
if (err?.isAxiosError) return normalizeAxiosError(err, "list agents");
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
62
76
|
export async function waitAgent(
|
|
63
77
|
http: HttpClient,
|
|
64
78
|
jobId: string,
|
|
@@ -84,6 +98,37 @@ export async function agent(
|
|
|
84
98
|
return waitAgent(http, jobId, args.pollInterval ?? 2, args.timeout);
|
|
85
99
|
}
|
|
86
100
|
|
|
101
|
+
export async function getAgentTrace(
|
|
102
|
+
http: HttpClient,
|
|
103
|
+
jobId: string,
|
|
104
|
+
options?: { liveView?: boolean }
|
|
105
|
+
): Promise<AgentTraceResponse> {
|
|
106
|
+
try {
|
|
107
|
+
const query = options?.liveView ? "?liveView=true" : "";
|
|
108
|
+
const res = await http.get<AgentTraceResponse>(`/v2/agent/${jobId}/trace${query}`);
|
|
109
|
+
if (res.status !== 200) throwForBadResponse(res, "agent trace");
|
|
110
|
+
return res.data;
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
if (err?.isAxiosError) return normalizeAxiosError(err, "agent trace");
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function getAgentSnapshot(
|
|
118
|
+
http: HttpClient,
|
|
119
|
+
jobId: string,
|
|
120
|
+
snapshotId: string
|
|
121
|
+
): Promise<AgentSnapshotResponse> {
|
|
122
|
+
try {
|
|
123
|
+
const res = await http.get<AgentSnapshotResponse>(`/v2/agent/${jobId}/snapshots/${snapshotId}`);
|
|
124
|
+
if (res.status !== 200) throwForBadResponse(res, "agent snapshot");
|
|
125
|
+
return res.data;
|
|
126
|
+
} catch (err: any) {
|
|
127
|
+
if (err?.isAxiosError) return normalizeAxiosError(err, "agent snapshot");
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
87
132
|
export async function cancelAgent(http: HttpClient, jobId: string): Promise<boolean> {
|
|
88
133
|
try {
|
|
89
134
|
const res = await http.delete<{ success: boolean }>(`/v2/agent/${jobId}`);
|
package/src/v2/methods/scrape.ts
CHANGED
|
@@ -12,38 +12,142 @@ import {
|
|
|
12
12
|
normalizeAxiosError,
|
|
13
13
|
} from "../utils/errorHandler";
|
|
14
14
|
|
|
15
|
+
/** Auto-resume bounds: a resume only ever follows the server's explicit
|
|
16
|
+
* "processing continues" signal, and stops after this many resumes or
|
|
17
|
+
* this much total sleeping — whichever comes first. */
|
|
18
|
+
const RESUME_MAX_ATTEMPTS = 5;
|
|
19
|
+
const RESUME_MAX_TOTAL_WAIT_MS = 20 * 60 * 1000;
|
|
20
|
+
const RESUME_MIN_DELAY_MS = 5 * 1000;
|
|
21
|
+
const RESUME_MAX_DELAY_MS = 10 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* When a request failed because the document is still processing
|
|
25
|
+
* server-side (large PDFs outlive their request window by design), the
|
|
26
|
+
* error carries `details.state === "processing_continues"` plus a retry
|
|
27
|
+
* delay. Returns that delay in ms, or undefined for every other error.
|
|
28
|
+
*/
|
|
29
|
+
export function processingContinuesDelayMs(err: unknown): number | undefined {
|
|
30
|
+
const response = (err as { isAxiosError?: boolean; response?: any })
|
|
31
|
+
?.isAxiosError
|
|
32
|
+
? (err as { response?: any }).response
|
|
33
|
+
: undefined;
|
|
34
|
+
if (!response || response.status !== 408) return undefined;
|
|
35
|
+
const body = response.data as
|
|
36
|
+
| { code?: string; details?: { state?: string; retryAfterSeconds?: number } }
|
|
37
|
+
| undefined;
|
|
38
|
+
if (
|
|
39
|
+
body?.code !== "SCRAPE_TIMEOUT" ||
|
|
40
|
+
body?.details?.state !== "processing_continues"
|
|
41
|
+
) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const headerSeconds = Number(response.headers?.["retry-after"]);
|
|
45
|
+
const seconds =
|
|
46
|
+
typeof body.details.retryAfterSeconds === "number" &&
|
|
47
|
+
Number.isFinite(body.details.retryAfterSeconds)
|
|
48
|
+
? body.details.retryAfterSeconds
|
|
49
|
+
: Number.isFinite(headerSeconds)
|
|
50
|
+
? headerSeconds
|
|
51
|
+
: 60;
|
|
52
|
+
return Math.min(RESUME_MAX_DELAY_MS, Math.max(RESUME_MIN_DELAY_MS, seconds * 1000));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Options for a direct scrape call. `autoResume` lives here — NOT on the
|
|
57
|
+
* shared ScrapeOptions — because that type is reused verbatim inside
|
|
58
|
+
* batch/crawl/search/extract payloads, where an SDK-only key would leak
|
|
59
|
+
* onto the wire and fail strict endpoints.
|
|
60
|
+
*/
|
|
61
|
+
export type ScrapeCallOptions = ScrapeOptions & {
|
|
62
|
+
/**
|
|
63
|
+
* SDK-only (never sent to the API). Large documents (big PDFs) that
|
|
64
|
+
* outlive the request window keep processing server-side; the API's
|
|
65
|
+
* timeout error then carries `details.state === "processing_continues"`
|
|
66
|
+
* and a Retry-After. When this is not `false`, the SDK sleeps that
|
|
67
|
+
* long and re-issues the same request — the retry attaches to the
|
|
68
|
+
* in-flight job and returns the finished result, so a large document
|
|
69
|
+
* behaves like one slow successful call. Bounded (at most 5 resumes /
|
|
70
|
+
* 20 minutes total wait); set `false` to surface the timeout error
|
|
71
|
+
* immediately instead.
|
|
72
|
+
*/
|
|
73
|
+
autoResume?: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
15
76
|
export async function scrape(
|
|
16
77
|
http: HttpClient,
|
|
17
78
|
url: string,
|
|
18
|
-
options?:
|
|
79
|
+
options?: ScrapeCallOptions,
|
|
80
|
+
deps: { sleepImpl?: (ms: number) => Promise<void> } = {},
|
|
19
81
|
): Promise<Document> {
|
|
82
|
+
const sleep =
|
|
83
|
+
deps.sleepImpl ??
|
|
84
|
+
((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
|
|
20
85
|
if (!url || !url.trim()) {
|
|
21
86
|
throw new Error("URL cannot be empty");
|
|
22
87
|
}
|
|
23
88
|
if (options) ensureValidScrapeOptions(options);
|
|
24
89
|
|
|
90
|
+
// autoResume is SDK behavior, never part of the wire payload.
|
|
91
|
+
const { autoResume, ...requestOptions } = options ?? {};
|
|
25
92
|
const payload: Record<string, unknown> = { url: url.trim() };
|
|
26
|
-
|
|
93
|
+
Object.assign(payload, requestOptions);
|
|
27
94
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
95
|
+
// Per-request timeout. With auto-resume disabled this is exactly the
|
|
96
|
+
// pre-existing behavior (explicit timeout +5s, else the client's own
|
|
97
|
+
// default). With auto-resume on, the client-side timeout must outlast
|
|
98
|
+
// the SERVER's wall — options.timeout, or the API's 5-minute default —
|
|
99
|
+
// otherwise axios can abort in a photo-finish with the server's
|
|
100
|
+
// processing_continues response and the resume signal is lost exactly
|
|
101
|
+
// when it matters. The caller's configured client timeout is
|
|
102
|
+
// respected as a floor, never overridden downward.
|
|
103
|
+
const requestTimeout: { timeoutMs?: number } =
|
|
104
|
+
autoResume === false
|
|
105
|
+
? typeof options?.timeout === "number"
|
|
37
106
|
? { timeoutMs: options.timeout + 5000 }
|
|
38
|
-
: {}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
107
|
+
: {}
|
|
108
|
+
: {
|
|
109
|
+
timeoutMs:
|
|
110
|
+
Math.max(
|
|
111
|
+
typeof options?.timeout === "number" ? options.timeout : 300_000,
|
|
112
|
+
typeof http.getTimeoutMs === "function" ? http.getTimeoutMs() : 0,
|
|
113
|
+
) + 30_000,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let resumes = 0;
|
|
117
|
+
let resumeWaitedMs = 0;
|
|
118
|
+
while (true) {
|
|
119
|
+
try {
|
|
120
|
+
const res = await http.post<{
|
|
121
|
+
success: boolean;
|
|
122
|
+
data?: Document;
|
|
123
|
+
error?: string;
|
|
124
|
+
}>(
|
|
125
|
+
"/v2/scrape",
|
|
126
|
+
payload,
|
|
127
|
+
requestTimeout,
|
|
128
|
+
);
|
|
129
|
+
if (res.status !== 200 || !res.data?.success) {
|
|
130
|
+
throwForBadResponse(res, "scrape");
|
|
131
|
+
}
|
|
132
|
+
return (res.data.data || {}) as Document;
|
|
133
|
+
} catch (err: any) {
|
|
134
|
+
const delayMs = autoResume !== false ? processingContinuesDelayMs(err) : undefined;
|
|
135
|
+
if (
|
|
136
|
+
delayMs !== undefined &&
|
|
137
|
+
resumes < RESUME_MAX_ATTEMPTS &&
|
|
138
|
+
resumeWaitedMs + delayMs <= RESUME_MAX_TOTAL_WAIT_MS
|
|
139
|
+
) {
|
|
140
|
+
// The document keeps processing server-side; the retry attaches
|
|
141
|
+
// to the same in-flight job (content adoption) and returns the
|
|
142
|
+
// finished result instead of restarting the work.
|
|
143
|
+
resumes += 1;
|
|
144
|
+
resumeWaitedMs += delayMs;
|
|
145
|
+
await sleep(delayMs);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (err?.isAxiosError) return normalizeAxiosError(err, "scrape");
|
|
149
|
+
throw err;
|
|
42
150
|
}
|
|
43
|
-
return (res.data.data || {}) as Document;
|
|
44
|
-
} catch (err: any) {
|
|
45
|
-
if (err?.isAxiosError) return normalizeAxiosError(err, "scrape");
|
|
46
|
-
throw err;
|
|
47
151
|
}
|
|
48
152
|
}
|
|
49
153
|
|