@datafuel/sdk 0.1.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/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/index.cjs +922 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +554 -0
- package/dist/index.d.ts +554 -0
- package/dist/index.js +873 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/client.ts +525 -0
- package/src/core.ts +388 -0
- package/src/errors.ts +136 -0
- package/src/index.ts +69 -0
- package/src/models.ts +362 -0
package/src/core.ts
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request building and answer classification. Pure functions, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Every decision lives here — which envelope a module needs, where the sticky
|
|
5
|
+
* session keys go, how `ai` expands, whether an answer is retryable — so the
|
|
6
|
+
* client only moves bytes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { APIError, Unavailable } from "./errors.js";
|
|
10
|
+
import type { AI, Engine, Proxy, ScrapeOptions } from "./models.js";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_BASE_URL = "https://scraping-api.datafuel.ai/api/v1";
|
|
13
|
+
|
|
14
|
+
/** Kept in step with package.json by a test; see test/hardening.test.ts. */
|
|
15
|
+
export const VERSION = "0.1.0";
|
|
16
|
+
|
|
17
|
+
/** How long a synchronous call waits for a result before giving up. */
|
|
18
|
+
export const DEFAULT_TIMEOUT_MS = 180_000;
|
|
19
|
+
/** Gap between two attempts at a task the API is still processing. */
|
|
20
|
+
export const STILL_PROCESSING_DELAY_MS = 2_000;
|
|
21
|
+
export const STILL_PROCESSING = "TASK_STILL_PROCESSING";
|
|
22
|
+
/** The API rejects a longer key with 400; catching it here saves the round trip. */
|
|
23
|
+
export const MAX_IDEMPOTENCY_KEY = 255;
|
|
24
|
+
|
|
25
|
+
/** One HTTP call, ready for the client to send. */
|
|
26
|
+
export class Request {
|
|
27
|
+
constructor(
|
|
28
|
+
readonly method: string,
|
|
29
|
+
readonly path: string,
|
|
30
|
+
readonly params?: Record<string, string>,
|
|
31
|
+
readonly body?: Record<string, unknown>,
|
|
32
|
+
readonly idempotencyKey?: string,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
/** GETs are safe by nature, writes because they carry an idempotency key. */
|
|
36
|
+
get retryable(): boolean {
|
|
37
|
+
return this.method === "GET" || this.idempotencyKey !== undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Method and path only. The body can hold an LLM key or a cookie header, so
|
|
42
|
+
* neither logging nor JSON.stringify may spill it.
|
|
43
|
+
*/
|
|
44
|
+
toString(): string {
|
|
45
|
+
return `<Request ${this.method} ${this.path}>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
toJSON(): string {
|
|
49
|
+
return this.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
[Symbol.for("nodejs.util.inspect.custom")](): string {
|
|
53
|
+
return this.toString();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Escape an id before it becomes part of a URL path.
|
|
59
|
+
*
|
|
60
|
+
* An id is caller data. Unescaped, a stray `/` or `?` in one would move the
|
|
61
|
+
* request to a different endpoint.
|
|
62
|
+
*/
|
|
63
|
+
export function pathSegment(value: string): string {
|
|
64
|
+
return encodeURIComponent(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function newIdempotencyKey(): string {
|
|
68
|
+
return crypto.randomUUID();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The headers every call carries. The idempotency key sits beside the API key. */
|
|
72
|
+
export function headers(
|
|
73
|
+
apiKey: string,
|
|
74
|
+
userAgent: string,
|
|
75
|
+
request: Request,
|
|
76
|
+
): Record<string, string> {
|
|
77
|
+
const out: Record<string, string> = {
|
|
78
|
+
"X-API-Key": apiKey,
|
|
79
|
+
Accept: "application/json",
|
|
80
|
+
"User-Agent": userAgent,
|
|
81
|
+
};
|
|
82
|
+
if (request.body !== undefined) out["Content-Type"] = "application/json";
|
|
83
|
+
if (request.idempotencyKey !== undefined) out["Idempotency-Key"] = request.idempotencyKey;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The API takes multi-field extractors as a JSON object string. */
|
|
88
|
+
function selector(value: string | Record<string, string>): string {
|
|
89
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function aiAttributes(ai: AI): Record<string, unknown> {
|
|
93
|
+
const out: Record<string, unknown> = { result_use_ai: true };
|
|
94
|
+
if (ai.prompt) out.result_ai_prompt = ai.prompt;
|
|
95
|
+
if (ai.format !== undefined) out.result_ai_format = ai.format;
|
|
96
|
+
if (ai.provider) out.ai_provider = ai.provider;
|
|
97
|
+
if (ai.model) out.ai_model = ai.model;
|
|
98
|
+
if (ai.apiKey) out.ai_api_key = ai.apiKey;
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Catch a half-filled AI key before it costs a request. The API does not check
|
|
104
|
+
* these, so a missing one would be spent on a scrape whose AI step then fails.
|
|
105
|
+
*/
|
|
106
|
+
export function validateAI(ai: AI): void {
|
|
107
|
+
const given = {
|
|
108
|
+
provider: ai.provider !== undefined,
|
|
109
|
+
model: ai.model !== undefined,
|
|
110
|
+
apiKey: ai.apiKey !== undefined,
|
|
111
|
+
};
|
|
112
|
+
const present = Object.values(given).filter(Boolean).length;
|
|
113
|
+
if (present > 0 && present < 3) {
|
|
114
|
+
const missing = Object.entries(given)
|
|
115
|
+
.filter(([, ok]) => !ok)
|
|
116
|
+
.map(([name]) => name)
|
|
117
|
+
.join(", ");
|
|
118
|
+
throw new TypeError(`ai needs provider, model and apiKey together; missing: ${missing}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Flatten the page options into the API's `attributes` object.
|
|
124
|
+
*
|
|
125
|
+
* Unset options are left out of the body entirely, never sent as null.
|
|
126
|
+
*/
|
|
127
|
+
export function scrapeAttributes(options: ScrapeOptions = {}): Record<string, unknown> {
|
|
128
|
+
const attrs: Record<string, unknown> = {};
|
|
129
|
+
if (options.format) attrs.result_format = options.format;
|
|
130
|
+
if (options.jsRendering) attrs.js_rendering = true;
|
|
131
|
+
if (options.waitFor) attrs.wait_for_selector = options.waitFor;
|
|
132
|
+
if (options.waitForTimeoutMs) attrs.wait_for_selector_timeout_ms = options.waitForTimeoutMs;
|
|
133
|
+
if (options.jsInstructions) attrs.js_instructions = options.jsInstructions;
|
|
134
|
+
if (options.blockResource) attrs.block_resource = options.blockResource;
|
|
135
|
+
if (options.mainContentOnly) attrs.main_content_only = true;
|
|
136
|
+
if (options.includeImages !== undefined) attrs.include_images = options.includeImages;
|
|
137
|
+
if (options.extract) attrs.extract_selector = selector(options.extract);
|
|
138
|
+
if (options.extractRegex) attrs.extract_regex = selector(options.extractRegex);
|
|
139
|
+
if (options.template) attrs.result_template = options.template;
|
|
140
|
+
if (options.method) attrs.method = options.method;
|
|
141
|
+
if (options.body) attrs.body = options.body;
|
|
142
|
+
if (options.contentType) attrs.content_type = options.contentType;
|
|
143
|
+
if (options.headers) attrs.headers = options.headers;
|
|
144
|
+
if (options.headerOrder) attrs.header_order = options.headerOrder;
|
|
145
|
+
if (options.cookies) attrs.cookie_string = options.cookies;
|
|
146
|
+
if (options.userAgent) attrs.user_agent = options.userAgent;
|
|
147
|
+
if (options.userAgentType) attrs.user_agent_type = options.userAgentType;
|
|
148
|
+
if (options.ai) {
|
|
149
|
+
validateAI(options.ai);
|
|
150
|
+
Object.assign(attrs, aiAttributes(options.ai));
|
|
151
|
+
}
|
|
152
|
+
return attrs;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The proxy fields that travel in the request envelope. */
|
|
156
|
+
export function proxyEnvelope(proxy: Proxy | undefined): Record<string, unknown> {
|
|
157
|
+
const out: Record<string, unknown> = {};
|
|
158
|
+
if (!proxy) return out;
|
|
159
|
+
if (proxy.type) out.proxy_type = proxy.type;
|
|
160
|
+
if (proxy.country) out.proxy_country = proxy.country;
|
|
161
|
+
if (proxy.city) out.proxy_city = proxy.city;
|
|
162
|
+
if (proxy.state) out.proxy_state = proxy.state;
|
|
163
|
+
if (proxy.asn) out.proxy_asn = proxy.asn;
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The sticky-session fields, which travel in `attributes`. */
|
|
168
|
+
export function proxySession(proxy: Proxy | undefined): Record<string, unknown> {
|
|
169
|
+
const out: Record<string, unknown> = {};
|
|
170
|
+
if (!proxy) return out;
|
|
171
|
+
if (proxy.sessionId) out.proxy_session_id = proxy.sessionId;
|
|
172
|
+
if (proxy.ttl) out.proxy_ttl = proxy.ttl;
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The body every write shares. */
|
|
177
|
+
export function envelope(
|
|
178
|
+
taskType: string,
|
|
179
|
+
attributes: Record<string, unknown>,
|
|
180
|
+
extra: { proxy?: Proxy | undefined; multithreaded?: boolean | undefined } = {},
|
|
181
|
+
): Record<string, unknown> {
|
|
182
|
+
const body: Record<string, unknown> = { type: taskType, ...proxyEnvelope(extra.proxy) };
|
|
183
|
+
if (extra.multithreaded !== undefined) body.multithreaded = extra.multithreaded;
|
|
184
|
+
body.attributes = attributes;
|
|
185
|
+
return body;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function key(explicit: string | undefined): string {
|
|
189
|
+
if (explicit !== undefined && explicit.length > MAX_IDEMPOTENCY_KEY) {
|
|
190
|
+
throw new TypeError(`idempotencyKey is longer than ${MAX_IDEMPOTENCY_KEY} characters`);
|
|
191
|
+
}
|
|
192
|
+
return explicit ?? newIdempotencyKey();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function buildScrape(
|
|
196
|
+
url: string,
|
|
197
|
+
options: ScrapeOptions,
|
|
198
|
+
proxy: Proxy | undefined,
|
|
199
|
+
idempotencyKey: string,
|
|
200
|
+
): Request {
|
|
201
|
+
const attrs = { ...scrapeAttributes(options), url, ...proxySession(proxy) };
|
|
202
|
+
return new Request(
|
|
203
|
+
"POST",
|
|
204
|
+
"/task",
|
|
205
|
+
undefined,
|
|
206
|
+
envelope("unlocker", attrs, { proxy }),
|
|
207
|
+
idempotencyKey,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildJob(
|
|
212
|
+
urls: string[],
|
|
213
|
+
options: ScrapeOptions,
|
|
214
|
+
proxy: Proxy | undefined,
|
|
215
|
+
sequential: boolean,
|
|
216
|
+
idempotencyKey: string,
|
|
217
|
+
): Request {
|
|
218
|
+
const attrs = { ...scrapeAttributes(options), urls: [...urls] };
|
|
219
|
+
return new Request(
|
|
220
|
+
"POST",
|
|
221
|
+
"/job",
|
|
222
|
+
undefined,
|
|
223
|
+
envelope("unlocker", attrs, { proxy, multithreaded: !sequential }),
|
|
224
|
+
idempotencyKey,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Options for the llm_scraping module, which reads its proxy country here. */
|
|
229
|
+
export interface AskOptions {
|
|
230
|
+
engine: Engine | string;
|
|
231
|
+
websearch?: boolean;
|
|
232
|
+
followUp?: string;
|
|
233
|
+
country?: string;
|
|
234
|
+
format?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function askAttributes(
|
|
238
|
+
promptField: string,
|
|
239
|
+
prompt: string | string[],
|
|
240
|
+
options: AskOptions,
|
|
241
|
+
): Record<string, unknown> {
|
|
242
|
+
const attrs: Record<string, unknown> = { [promptField]: prompt, engine: options.engine };
|
|
243
|
+
if (options.websearch) attrs.websearch = true;
|
|
244
|
+
if (options.followUp) attrs.follow_up_prompt = options.followUp;
|
|
245
|
+
if (options.country) attrs.proxy_country = options.country;
|
|
246
|
+
if (options.format) attrs.result_format = options.format;
|
|
247
|
+
return attrs;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function buildAsk(prompt: string, options: AskOptions, idempotencyKey: string): Request {
|
|
251
|
+
const attrs = askAttributes("prompt", prompt, options);
|
|
252
|
+
return new Request("POST", "/task", undefined, envelope("llm_scraping", attrs), idempotencyKey);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function buildAskJob(
|
|
256
|
+
prompts: string[],
|
|
257
|
+
options: AskOptions,
|
|
258
|
+
sequential: boolean,
|
|
259
|
+
idempotencyKey: string,
|
|
260
|
+
): Request {
|
|
261
|
+
const attrs = askAttributes("prompts", [...prompts], options);
|
|
262
|
+
return new Request(
|
|
263
|
+
"POST",
|
|
264
|
+
"/job",
|
|
265
|
+
undefined,
|
|
266
|
+
envelope("llm_scraping", attrs, { multithreaded: !sequential }),
|
|
267
|
+
idempotencyKey,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Options for `map`. */
|
|
272
|
+
export interface MapOptions {
|
|
273
|
+
/** Keep only links whose URL or title contains this. */
|
|
274
|
+
search?: string;
|
|
275
|
+
/** Map only this sitemap, from a previous `SiteMap.sitemaps`. */
|
|
276
|
+
sitemap?: string;
|
|
277
|
+
/** Default 5000, capped at 10000. */
|
|
278
|
+
limit?: number;
|
|
279
|
+
includeSubdomains?: boolean;
|
|
280
|
+
ignoreSitemap?: boolean;
|
|
281
|
+
sitemapOnly?: boolean;
|
|
282
|
+
userAgent?: string;
|
|
283
|
+
userAgentType?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function buildMap(
|
|
287
|
+
url: string,
|
|
288
|
+
options: MapOptions,
|
|
289
|
+
proxy: Proxy | undefined,
|
|
290
|
+
idempotencyKey: string,
|
|
291
|
+
): Request {
|
|
292
|
+
const attrs: Record<string, unknown> = { url };
|
|
293
|
+
if (options.search) attrs.search = options.search;
|
|
294
|
+
if (options.sitemap) attrs.sitemap = options.sitemap;
|
|
295
|
+
if (options.limit) attrs.limit = options.limit;
|
|
296
|
+
if (options.includeSubdomains) attrs.include_subdomains = true;
|
|
297
|
+
if (options.ignoreSitemap) attrs.ignore_sitemap = true;
|
|
298
|
+
if (options.sitemapOnly) attrs.sitemap_only = true;
|
|
299
|
+
if (options.userAgent) attrs.user_agent = options.userAgent;
|
|
300
|
+
if (options.userAgentType) attrs.user_agent_type = options.userAgentType;
|
|
301
|
+
Object.assign(attrs, proxySession(proxy));
|
|
302
|
+
return new Request("POST", "/map", undefined, envelope("map", attrs, { proxy }), idempotencyKey);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Options for `crawl`, on top of the page options applied to every page. */
|
|
306
|
+
export interface CrawlOptions extends ScrapeOptions {
|
|
307
|
+
/** Default 100, max 10000. The hard budget of the crawl. */
|
|
308
|
+
maxPages?: number;
|
|
309
|
+
/** Default 3, max 10. The start URL is depth 0. */
|
|
310
|
+
maxDepth?: number;
|
|
311
|
+
/** RE2 on path?query; when set only matches are followed. */
|
|
312
|
+
includePaths?: string[];
|
|
313
|
+
/** RE2 on path?query; exclude wins. */
|
|
314
|
+
excludePaths?: string[];
|
|
315
|
+
includeSubdomains?: boolean;
|
|
316
|
+
allowBackwardLinks?: boolean;
|
|
317
|
+
/** Pages in flight, default 5. */
|
|
318
|
+
concurrency?: number;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function buildCrawl(
|
|
322
|
+
url: string,
|
|
323
|
+
options: CrawlOptions,
|
|
324
|
+
proxy: Proxy | undefined,
|
|
325
|
+
idempotencyKey: string,
|
|
326
|
+
): Request {
|
|
327
|
+
if (options.ai) {
|
|
328
|
+
throw new TypeError("crawl does not support ai: the API rejects result_use_ai on crawls");
|
|
329
|
+
}
|
|
330
|
+
const attrs: Record<string, unknown> = { ...scrapeAttributes(options), url };
|
|
331
|
+
if (options.maxPages) attrs.max_pages = options.maxPages;
|
|
332
|
+
if (options.maxDepth) attrs.max_depth = options.maxDepth;
|
|
333
|
+
if (options.concurrency) attrs.concurrency = options.concurrency;
|
|
334
|
+
if (options.includePaths?.length) attrs.include_paths = [...options.includePaths];
|
|
335
|
+
if (options.excludePaths?.length) attrs.exclude_paths = [...options.excludePaths];
|
|
336
|
+
if (options.includeSubdomains) attrs.include_subdomains = true;
|
|
337
|
+
if (options.allowBackwardLinks) attrs.allow_backward_links = true;
|
|
338
|
+
return new Request(
|
|
339
|
+
"POST",
|
|
340
|
+
"/crawl",
|
|
341
|
+
undefined,
|
|
342
|
+
envelope("crawl", attrs, { proxy }),
|
|
343
|
+
idempotencyKey,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function crawlResultsRequest(crawlId: string, cursor?: string, limit?: number): Request {
|
|
348
|
+
const params: Record<string, string> = {};
|
|
349
|
+
if (cursor) params.cursor = cursor;
|
|
350
|
+
if (limit) params.limit = String(limit);
|
|
351
|
+
return new Request(
|
|
352
|
+
"GET",
|
|
353
|
+
`/crawl/${pathSegment(crawlId)}/results`,
|
|
354
|
+
Object.keys(params).length > 0 ? params : undefined,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Whether the API answered "the task is still running" instead of a result. */
|
|
359
|
+
export function stillProcessing(body: unknown): boolean {
|
|
360
|
+
return (
|
|
361
|
+
body !== null &&
|
|
362
|
+
typeof body === "object" &&
|
|
363
|
+
(body as Record<string, unknown>).code === STILL_PROCESSING
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Go's retry policy: transport errors, 429, 502, 503, 504 — nothing else. */
|
|
368
|
+
export function shouldRetry(error: unknown): boolean {
|
|
369
|
+
if (error instanceof Unavailable) {
|
|
370
|
+
// A deliberate switch-off is a 503 too, but retrying it only adds load: it
|
|
371
|
+
// stays off until an operator turns it back on.
|
|
372
|
+
if (error.code === "MODULE_UNAVAILABLE" || error.code === "ENGINE_UNAVAILABLE") return false;
|
|
373
|
+
}
|
|
374
|
+
if (error instanceof APIError) return [429, 502, 503, 504].includes(error.status);
|
|
375
|
+
return true; // transport error
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 500ms, 1s, 2s, 4s, then 8s flat — halved and randomized. Retry-After wins. */
|
|
379
|
+
export function backoff(attempt: number, retryAfterSeconds = 0): number {
|
|
380
|
+
if (retryAfterSeconds > 0) return Math.min(retryAfterSeconds * 1000, 30_000);
|
|
381
|
+
const base = 500 * 2 ** Math.min(attempt, 4);
|
|
382
|
+
return base / 2 + Math.random() * (base / 2);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Gap before re-sending a task the API is still processing. */
|
|
386
|
+
export function pollDelay(attempt: number): number {
|
|
387
|
+
return Math.max(STILL_PROCESSING_DELAY_MS, backoff(attempt));
|
|
388
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors this package throws.
|
|
3
|
+
*
|
|
4
|
+
* Two families, as in the Go and Python SDKs:
|
|
5
|
+
*
|
|
6
|
+
* - {@link APIError} and its subclasses — the API refused the request.
|
|
7
|
+
* - {@link TaskFailed} / {@link Blocked} — the API accepted the task but the
|
|
8
|
+
* page could not be scraped. Failed tasks are refunded.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Result } from "./models.js";
|
|
12
|
+
|
|
13
|
+
/** Base class for every error this package throws. */
|
|
14
|
+
export class DataFuelError extends Error {
|
|
15
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = new.target.name;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** No key was passed and DATAFUEL_API_KEY is empty. Thrown before any request. */
|
|
22
|
+
export class NoApiKey extends DataFuelError {}
|
|
23
|
+
|
|
24
|
+
/** The request never got an answer: DNS, connection, abort, read timeout. */
|
|
25
|
+
export class TransportError extends DataFuelError {}
|
|
26
|
+
|
|
27
|
+
/** A non-2xx answer from the API itself. */
|
|
28
|
+
export class APIError extends DataFuelError {
|
|
29
|
+
readonly status: number;
|
|
30
|
+
readonly code: string;
|
|
31
|
+
/** Seconds the API asked us to wait, from Retry-After. 0 when absent. */
|
|
32
|
+
readonly retryAfter: number;
|
|
33
|
+
|
|
34
|
+
constructor(status: number, code: string, message: string, retryAfter = 0) {
|
|
35
|
+
super(code ? `${message} (${status} ${code})` : `${message} (${status})`);
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.retryAfter = retryAfter;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 401: the API key is missing or invalid. */
|
|
43
|
+
export class Unauthorized extends APIError {}
|
|
44
|
+
/** 404: unknown id, or one that belongs to another account. */
|
|
45
|
+
export class NotFound extends APIError {}
|
|
46
|
+
/** 429: the account's request rate or concurrency limit was reached. */
|
|
47
|
+
export class RateLimited extends APIError {}
|
|
48
|
+
/** 402: not enough credits for this task. */
|
|
49
|
+
export class InsufficientCredits extends APIError {}
|
|
50
|
+
/** 400 INVALID_ATTRIBUTES: the attributes do not match the task type. */
|
|
51
|
+
export class InvalidAttributes extends APIError {}
|
|
52
|
+
/** 422: the key was already used for a different request. */
|
|
53
|
+
export class IdempotencyKeyReused extends APIError {}
|
|
54
|
+
/** 503: an operator switched something off. The message carries the reason. */
|
|
55
|
+
export class Unavailable extends APIError {}
|
|
56
|
+
/** 503 MODULE_UNAVAILABLE: this task type is switched off. Nothing was charged. */
|
|
57
|
+
export class ModuleUnavailable extends Unavailable {}
|
|
58
|
+
/** 503 ENGINE_UNAVAILABLE: this LLM engine is switched off. Nothing was charged. */
|
|
59
|
+
export class EngineUnavailable extends Unavailable {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The task was accepted but could not be completed. Refunded.
|
|
63
|
+
*
|
|
64
|
+
* `result` carries the envelope: `statusCode`, `blocked`, `protection`, `error`.
|
|
65
|
+
*/
|
|
66
|
+
export class TaskFailed extends DataFuelError {
|
|
67
|
+
readonly result: Result;
|
|
68
|
+
|
|
69
|
+
constructor(result: Result) {
|
|
70
|
+
const detail = result.error ?? result.payload?.error_detail ?? "no detail";
|
|
71
|
+
super(`task failed: ${detail}`);
|
|
72
|
+
this.result = result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Anti-bot vendor recognised on the target, when there was one. */
|
|
76
|
+
get protection(): string | undefined {
|
|
77
|
+
return this.result.protection;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The target refused or challenged the request (403/429/503, anti-bot wall).
|
|
83
|
+
*
|
|
84
|
+
* Refunded. Retry with `jsRendering: true` or a Premium proxy.
|
|
85
|
+
*/
|
|
86
|
+
export class Blocked extends TaskFailed {}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A wait ran out of time. The work keeps running and billing server-side.
|
|
90
|
+
*
|
|
91
|
+
* `id` picks it back up (`waitCrawl`, `jobResults`, or a re-send under the same
|
|
92
|
+
* idempotency key); `status` is the last one seen, when there was one.
|
|
93
|
+
*/
|
|
94
|
+
export class WaitTimeout extends DataFuelError {
|
|
95
|
+
readonly id: string | undefined;
|
|
96
|
+
readonly status: unknown;
|
|
97
|
+
|
|
98
|
+
constructor(message: string, id?: string, status?: unknown) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.id = id;
|
|
101
|
+
this.status = status;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const BY_CODE: Record<string, new (s: number, c: string, m: string, r?: number) => APIError> = {
|
|
106
|
+
MODULE_UNAVAILABLE: ModuleUnavailable,
|
|
107
|
+
ENGINE_UNAVAILABLE: EngineUnavailable,
|
|
108
|
+
INSUFFICIENT_CREDITS: InsufficientCredits,
|
|
109
|
+
INVALID_ATTRIBUTES: InvalidAttributes,
|
|
110
|
+
IDEMPOTENCY_KEY_REUSED: IdempotencyKeyReused,
|
|
111
|
+
INVALID_API_KEY: Unauthorized,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const BY_STATUS: Record<number, new (s: number, c: string, m: string, r?: number) => APIError> = {
|
|
115
|
+
401: Unauthorized,
|
|
116
|
+
402: InsufficientCredits,
|
|
117
|
+
404: NotFound,
|
|
118
|
+
422: IdempotencyKeyReused,
|
|
119
|
+
429: RateLimited,
|
|
120
|
+
503: Unavailable,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Map an error answer onto the narrowest class that fits. */
|
|
124
|
+
export function apiError(status: number, body: unknown, retryAfter = 0): APIError {
|
|
125
|
+
let code = "";
|
|
126
|
+
let message = "";
|
|
127
|
+
if (body !== null && typeof body === "object") {
|
|
128
|
+
const record = body as Record<string, unknown>;
|
|
129
|
+
code = typeof record.code === "string" ? record.code : "";
|
|
130
|
+
message = typeof record.message === "string" ? record.message : "";
|
|
131
|
+
} else if (typeof body === "string" && body.length > 0 && body.length <= 300) {
|
|
132
|
+
message = body;
|
|
133
|
+
}
|
|
134
|
+
const Cls = BY_CODE[code] ?? BY_STATUS[status] ?? APIError;
|
|
135
|
+
return new Cls(status, code, message || `HTTP ${status}`, retryAfter);
|
|
136
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript client for the DataFuel scraping API.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { DataFuel } from "@datafuel/sdk";
|
|
6
|
+
*
|
|
7
|
+
* const df = new DataFuel(); // reads DATAFUEL_API_KEY
|
|
8
|
+
* console.log(await df.markdown("https://example.com"));
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Pick the call by the shape of the work:
|
|
12
|
+
*
|
|
13
|
+
* | You have | Call | Waits? |
|
|
14
|
+
* |---|---|---|
|
|
15
|
+
* | One URL | `scrape` / `markdown` | yes |
|
|
16
|
+
* | A site, need its URL list | `map` | yes |
|
|
17
|
+
* | A start URL, many pages | `crawl` / `startCrawl` | `crawl` does |
|
|
18
|
+
* | A list of known URLs | `runJob` / `createJob` | `runJob` does |
|
|
19
|
+
* | A question for an AI engine | `ask` | yes |
|
|
20
|
+
*
|
|
21
|
+
* Start with plain `scrape`. Turn on `jsRendering` only when the page comes
|
|
22
|
+
* back empty: it is slower and costs five times the credits on a Basic proxy.
|
|
23
|
+
* `map` a section before you `crawl` it — one credit, and it tells you how big
|
|
24
|
+
* the section is.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export { DataFuel } from "./client.js";
|
|
28
|
+
export type { ClientOptions } from "./client.js";
|
|
29
|
+
export { DEFAULT_BASE_URL, VERSION } from "./core.js";
|
|
30
|
+
export type { AskOptions, CrawlOptions, MapOptions } from "./core.js";
|
|
31
|
+
export {
|
|
32
|
+
APIError,
|
|
33
|
+
Blocked,
|
|
34
|
+
DataFuelError,
|
|
35
|
+
EngineUnavailable,
|
|
36
|
+
IdempotencyKeyReused,
|
|
37
|
+
InsufficientCredits,
|
|
38
|
+
InvalidAttributes,
|
|
39
|
+
ModuleUnavailable,
|
|
40
|
+
NoApiKey,
|
|
41
|
+
NotFound,
|
|
42
|
+
RateLimited,
|
|
43
|
+
TaskFailed,
|
|
44
|
+
TransportError,
|
|
45
|
+
Unauthorized,
|
|
46
|
+
Unavailable,
|
|
47
|
+
WaitTimeout,
|
|
48
|
+
} from "./errors.js";
|
|
49
|
+
export { Capabilities, CrawlPage, isDone, Result } from "./models.js";
|
|
50
|
+
export type {
|
|
51
|
+
AI,
|
|
52
|
+
CallOptions,
|
|
53
|
+
Capability,
|
|
54
|
+
CrawlResult,
|
|
55
|
+
CrawlResultsPage,
|
|
56
|
+
CrawlStatus,
|
|
57
|
+
Engine,
|
|
58
|
+
Format,
|
|
59
|
+
JobResults,
|
|
60
|
+
JobStatus,
|
|
61
|
+
Link,
|
|
62
|
+
Payload,
|
|
63
|
+
Profile,
|
|
64
|
+
Proxy,
|
|
65
|
+
ProxyType,
|
|
66
|
+
ScrapeOptions,
|
|
67
|
+
SiteMap,
|
|
68
|
+
Status,
|
|
69
|
+
} from "./models.js";
|