@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/dist/index.js ADDED
@@ -0,0 +1,873 @@
1
+ // src/errors.ts
2
+ var DataFuelError = class extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = new.target.name;
6
+ }
7
+ };
8
+ var NoApiKey = class extends DataFuelError {
9
+ };
10
+ var TransportError = class extends DataFuelError {
11
+ };
12
+ var APIError = class extends DataFuelError {
13
+ status;
14
+ code;
15
+ /** Seconds the API asked us to wait, from Retry-After. 0 when absent. */
16
+ retryAfter;
17
+ constructor(status, code, message, retryAfter = 0) {
18
+ super(code ? `${message} (${status} ${code})` : `${message} (${status})`);
19
+ this.status = status;
20
+ this.code = code;
21
+ this.retryAfter = retryAfter;
22
+ }
23
+ };
24
+ var Unauthorized = class extends APIError {
25
+ };
26
+ var NotFound = class extends APIError {
27
+ };
28
+ var RateLimited = class extends APIError {
29
+ };
30
+ var InsufficientCredits = class extends APIError {
31
+ };
32
+ var InvalidAttributes = class extends APIError {
33
+ };
34
+ var IdempotencyKeyReused = class extends APIError {
35
+ };
36
+ var Unavailable = class extends APIError {
37
+ };
38
+ var ModuleUnavailable = class extends Unavailable {
39
+ };
40
+ var EngineUnavailable = class extends Unavailable {
41
+ };
42
+ var TaskFailed = class extends DataFuelError {
43
+ result;
44
+ constructor(result) {
45
+ const detail = result.error ?? result.payload?.error_detail ?? "no detail";
46
+ super(`task failed: ${detail}`);
47
+ this.result = result;
48
+ }
49
+ /** Anti-bot vendor recognised on the target, when there was one. */
50
+ get protection() {
51
+ return this.result.protection;
52
+ }
53
+ };
54
+ var Blocked = class extends TaskFailed {
55
+ };
56
+ var WaitTimeout = class extends DataFuelError {
57
+ id;
58
+ status;
59
+ constructor(message, id, status) {
60
+ super(message);
61
+ this.id = id;
62
+ this.status = status;
63
+ }
64
+ };
65
+ var BY_CODE = {
66
+ MODULE_UNAVAILABLE: ModuleUnavailable,
67
+ ENGINE_UNAVAILABLE: EngineUnavailable,
68
+ INSUFFICIENT_CREDITS: InsufficientCredits,
69
+ INVALID_ATTRIBUTES: InvalidAttributes,
70
+ IDEMPOTENCY_KEY_REUSED: IdempotencyKeyReused,
71
+ INVALID_API_KEY: Unauthorized
72
+ };
73
+ var BY_STATUS = {
74
+ 401: Unauthorized,
75
+ 402: InsufficientCredits,
76
+ 404: NotFound,
77
+ 422: IdempotencyKeyReused,
78
+ 429: RateLimited,
79
+ 503: Unavailable
80
+ };
81
+ function apiError(status, body, retryAfter = 0) {
82
+ let code = "";
83
+ let message = "";
84
+ if (body !== null && typeof body === "object") {
85
+ const record2 = body;
86
+ code = typeof record2.code === "string" ? record2.code : "";
87
+ message = typeof record2.message === "string" ? record2.message : "";
88
+ } else if (typeof body === "string" && body.length > 0 && body.length <= 300) {
89
+ message = body;
90
+ }
91
+ const Cls = BY_CODE[code] ?? BY_STATUS[status] ?? APIError;
92
+ return new Cls(status, code, message || `HTTP ${status}`, retryAfter);
93
+ }
94
+
95
+ // src/core.ts
96
+ var DEFAULT_BASE_URL = "https://scraping-api.datafuel.ai/api/v1";
97
+ var VERSION = "0.1.0";
98
+ var DEFAULT_TIMEOUT_MS = 18e4;
99
+ var STILL_PROCESSING_DELAY_MS = 2e3;
100
+ var STILL_PROCESSING = "TASK_STILL_PROCESSING";
101
+ var MAX_IDEMPOTENCY_KEY = 255;
102
+ var Request = class {
103
+ constructor(method, path, params, body, idempotencyKey) {
104
+ this.method = method;
105
+ this.path = path;
106
+ this.params = params;
107
+ this.body = body;
108
+ this.idempotencyKey = idempotencyKey;
109
+ }
110
+ method;
111
+ path;
112
+ params;
113
+ body;
114
+ idempotencyKey;
115
+ /** GETs are safe by nature, writes because they carry an idempotency key. */
116
+ get retryable() {
117
+ return this.method === "GET" || this.idempotencyKey !== void 0;
118
+ }
119
+ /**
120
+ * Method and path only. The body can hold an LLM key or a cookie header, so
121
+ * neither logging nor JSON.stringify may spill it.
122
+ */
123
+ toString() {
124
+ return `<Request ${this.method} ${this.path}>`;
125
+ }
126
+ toJSON() {
127
+ return this.toString();
128
+ }
129
+ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
130
+ return this.toString();
131
+ }
132
+ };
133
+ function pathSegment(value) {
134
+ return encodeURIComponent(value);
135
+ }
136
+ function newIdempotencyKey() {
137
+ return crypto.randomUUID();
138
+ }
139
+ function headers(apiKey, userAgent, request) {
140
+ const out = {
141
+ "X-API-Key": apiKey,
142
+ Accept: "application/json",
143
+ "User-Agent": userAgent
144
+ };
145
+ if (request.body !== void 0) out["Content-Type"] = "application/json";
146
+ if (request.idempotencyKey !== void 0) out["Idempotency-Key"] = request.idempotencyKey;
147
+ return out;
148
+ }
149
+ function selector(value) {
150
+ return typeof value === "string" ? value : JSON.stringify(value);
151
+ }
152
+ function aiAttributes(ai) {
153
+ const out = { result_use_ai: true };
154
+ if (ai.prompt) out.result_ai_prompt = ai.prompt;
155
+ if (ai.format !== void 0) out.result_ai_format = ai.format;
156
+ if (ai.provider) out.ai_provider = ai.provider;
157
+ if (ai.model) out.ai_model = ai.model;
158
+ if (ai.apiKey) out.ai_api_key = ai.apiKey;
159
+ return out;
160
+ }
161
+ function validateAI(ai) {
162
+ const given = {
163
+ provider: ai.provider !== void 0,
164
+ model: ai.model !== void 0,
165
+ apiKey: ai.apiKey !== void 0
166
+ };
167
+ const present = Object.values(given).filter(Boolean).length;
168
+ if (present > 0 && present < 3) {
169
+ const missing = Object.entries(given).filter(([, ok]) => !ok).map(([name]) => name).join(", ");
170
+ throw new TypeError(`ai needs provider, model and apiKey together; missing: ${missing}`);
171
+ }
172
+ }
173
+ function scrapeAttributes(options = {}) {
174
+ const attrs = {};
175
+ if (options.format) attrs.result_format = options.format;
176
+ if (options.jsRendering) attrs.js_rendering = true;
177
+ if (options.waitFor) attrs.wait_for_selector = options.waitFor;
178
+ if (options.waitForTimeoutMs) attrs.wait_for_selector_timeout_ms = options.waitForTimeoutMs;
179
+ if (options.jsInstructions) attrs.js_instructions = options.jsInstructions;
180
+ if (options.blockResource) attrs.block_resource = options.blockResource;
181
+ if (options.mainContentOnly) attrs.main_content_only = true;
182
+ if (options.includeImages !== void 0) attrs.include_images = options.includeImages;
183
+ if (options.extract) attrs.extract_selector = selector(options.extract);
184
+ if (options.extractRegex) attrs.extract_regex = selector(options.extractRegex);
185
+ if (options.template) attrs.result_template = options.template;
186
+ if (options.method) attrs.method = options.method;
187
+ if (options.body) attrs.body = options.body;
188
+ if (options.contentType) attrs.content_type = options.contentType;
189
+ if (options.headers) attrs.headers = options.headers;
190
+ if (options.headerOrder) attrs.header_order = options.headerOrder;
191
+ if (options.cookies) attrs.cookie_string = options.cookies;
192
+ if (options.userAgent) attrs.user_agent = options.userAgent;
193
+ if (options.userAgentType) attrs.user_agent_type = options.userAgentType;
194
+ if (options.ai) {
195
+ validateAI(options.ai);
196
+ Object.assign(attrs, aiAttributes(options.ai));
197
+ }
198
+ return attrs;
199
+ }
200
+ function proxyEnvelope(proxy) {
201
+ const out = {};
202
+ if (!proxy) return out;
203
+ if (proxy.type) out.proxy_type = proxy.type;
204
+ if (proxy.country) out.proxy_country = proxy.country;
205
+ if (proxy.city) out.proxy_city = proxy.city;
206
+ if (proxy.state) out.proxy_state = proxy.state;
207
+ if (proxy.asn) out.proxy_asn = proxy.asn;
208
+ return out;
209
+ }
210
+ function proxySession(proxy) {
211
+ const out = {};
212
+ if (!proxy) return out;
213
+ if (proxy.sessionId) out.proxy_session_id = proxy.sessionId;
214
+ if (proxy.ttl) out.proxy_ttl = proxy.ttl;
215
+ return out;
216
+ }
217
+ function envelope(taskType, attributes, extra = {}) {
218
+ const body = { type: taskType, ...proxyEnvelope(extra.proxy) };
219
+ if (extra.multithreaded !== void 0) body.multithreaded = extra.multithreaded;
220
+ body.attributes = attributes;
221
+ return body;
222
+ }
223
+ function key(explicit) {
224
+ if (explicit !== void 0 && explicit.length > MAX_IDEMPOTENCY_KEY) {
225
+ throw new TypeError(`idempotencyKey is longer than ${MAX_IDEMPOTENCY_KEY} characters`);
226
+ }
227
+ return explicit ?? newIdempotencyKey();
228
+ }
229
+ function buildScrape(url, options, proxy, idempotencyKey) {
230
+ const attrs = { ...scrapeAttributes(options), url, ...proxySession(proxy) };
231
+ return new Request(
232
+ "POST",
233
+ "/task",
234
+ void 0,
235
+ envelope("unlocker", attrs, { proxy }),
236
+ idempotencyKey
237
+ );
238
+ }
239
+ function buildJob(urls, options, proxy, sequential, idempotencyKey) {
240
+ const attrs = { ...scrapeAttributes(options), urls: [...urls] };
241
+ return new Request(
242
+ "POST",
243
+ "/job",
244
+ void 0,
245
+ envelope("unlocker", attrs, { proxy, multithreaded: !sequential }),
246
+ idempotencyKey
247
+ );
248
+ }
249
+ function askAttributes(promptField, prompt, options) {
250
+ const attrs = { [promptField]: prompt, engine: options.engine };
251
+ if (options.websearch) attrs.websearch = true;
252
+ if (options.followUp) attrs.follow_up_prompt = options.followUp;
253
+ if (options.country) attrs.proxy_country = options.country;
254
+ if (options.format) attrs.result_format = options.format;
255
+ return attrs;
256
+ }
257
+ function buildAsk(prompt, options, idempotencyKey) {
258
+ const attrs = askAttributes("prompt", prompt, options);
259
+ return new Request("POST", "/task", void 0, envelope("llm_scraping", attrs), idempotencyKey);
260
+ }
261
+ function buildAskJob(prompts, options, sequential, idempotencyKey) {
262
+ const attrs = askAttributes("prompts", [...prompts], options);
263
+ return new Request(
264
+ "POST",
265
+ "/job",
266
+ void 0,
267
+ envelope("llm_scraping", attrs, { multithreaded: !sequential }),
268
+ idempotencyKey
269
+ );
270
+ }
271
+ function buildMap(url, options, proxy, idempotencyKey) {
272
+ const attrs = { url };
273
+ if (options.search) attrs.search = options.search;
274
+ if (options.sitemap) attrs.sitemap = options.sitemap;
275
+ if (options.limit) attrs.limit = options.limit;
276
+ if (options.includeSubdomains) attrs.include_subdomains = true;
277
+ if (options.ignoreSitemap) attrs.ignore_sitemap = true;
278
+ if (options.sitemapOnly) attrs.sitemap_only = true;
279
+ if (options.userAgent) attrs.user_agent = options.userAgent;
280
+ if (options.userAgentType) attrs.user_agent_type = options.userAgentType;
281
+ Object.assign(attrs, proxySession(proxy));
282
+ return new Request("POST", "/map", void 0, envelope("map", attrs, { proxy }), idempotencyKey);
283
+ }
284
+ function buildCrawl(url, options, proxy, idempotencyKey) {
285
+ if (options.ai) {
286
+ throw new TypeError("crawl does not support ai: the API rejects result_use_ai on crawls");
287
+ }
288
+ const attrs = { ...scrapeAttributes(options), url };
289
+ if (options.maxPages) attrs.max_pages = options.maxPages;
290
+ if (options.maxDepth) attrs.max_depth = options.maxDepth;
291
+ if (options.concurrency) attrs.concurrency = options.concurrency;
292
+ if (options.includePaths?.length) attrs.include_paths = [...options.includePaths];
293
+ if (options.excludePaths?.length) attrs.exclude_paths = [...options.excludePaths];
294
+ if (options.includeSubdomains) attrs.include_subdomains = true;
295
+ if (options.allowBackwardLinks) attrs.allow_backward_links = true;
296
+ return new Request(
297
+ "POST",
298
+ "/crawl",
299
+ void 0,
300
+ envelope("crawl", attrs, { proxy }),
301
+ idempotencyKey
302
+ );
303
+ }
304
+ function crawlResultsRequest(crawlId, cursor, limit) {
305
+ const params = {};
306
+ if (cursor) params.cursor = cursor;
307
+ if (limit) params.limit = String(limit);
308
+ return new Request(
309
+ "GET",
310
+ `/crawl/${pathSegment(crawlId)}/results`,
311
+ Object.keys(params).length > 0 ? params : void 0
312
+ );
313
+ }
314
+ function stillProcessing(body) {
315
+ return body !== null && typeof body === "object" && body.code === STILL_PROCESSING;
316
+ }
317
+ function shouldRetry(error) {
318
+ if (error instanceof Unavailable) {
319
+ if (error.code === "MODULE_UNAVAILABLE" || error.code === "ENGINE_UNAVAILABLE") return false;
320
+ }
321
+ if (error instanceof APIError) return [429, 502, 503, 504].includes(error.status);
322
+ return true;
323
+ }
324
+ function backoff(attempt, retryAfterSeconds = 0) {
325
+ if (retryAfterSeconds > 0) return Math.min(retryAfterSeconds * 1e3, 3e4);
326
+ const base = 500 * 2 ** Math.min(attempt, 4);
327
+ return base / 2 + Math.random() * (base / 2);
328
+ }
329
+ function pollDelay(attempt) {
330
+ return Math.max(STILL_PROCESSING_DELAY_MS, backoff(attempt));
331
+ }
332
+
333
+ // src/models.ts
334
+ var TERMINAL = /* @__PURE__ */ new Set(["completed", "completed_with_errors", "failed", "cancelled"]);
335
+ function isDone(status) {
336
+ return status !== void 0 && TERMINAL.has(status);
337
+ }
338
+ var Result = class {
339
+ id;
340
+ status;
341
+ /** HTTP status the target answered. */
342
+ statusCode;
343
+ finalUrl;
344
+ redirected;
345
+ /** Charged for this task, 0 when it failed. */
346
+ creditsUsed;
347
+ durationMs;
348
+ blocked;
349
+ /** Anti-bot vendor recognised when blocked. */
350
+ protection;
351
+ error;
352
+ /** The raw `result` object. Prefer `text`, `data` and `image`. */
353
+ payload;
354
+ /** Everything the API sent, including fields this SDK does not know yet. */
355
+ raw;
356
+ constructor(raw) {
357
+ this.raw = raw;
358
+ this.id = str(raw.id);
359
+ this.status = str(raw.status);
360
+ this.statusCode = num(raw.status_code);
361
+ this.finalUrl = str(raw.final_url);
362
+ this.redirected = raw.redirected === true;
363
+ this.creditsUsed = num(raw.credits_used) ?? 0;
364
+ this.durationMs = num(raw.duration_ms);
365
+ this.blocked = raw.blocked === true;
366
+ this.protection = str(raw.protection);
367
+ this.error = str(raw.error);
368
+ this.payload = raw.result !== null && typeof raw.result === "object" ? raw.result : void 0;
369
+ }
370
+ /** Effective state: the envelope's, else the payload's, else inferred. */
371
+ get state() {
372
+ if (this.status) return this.status;
373
+ if (this.payload?.status) return this.payload.status;
374
+ if (this.payload?.data !== void 0 && this.payload.data !== null) return "completed";
375
+ return "pending";
376
+ }
377
+ /** Whether the task has not finished yet (job and crawl results list stubs). */
378
+ get pending() {
379
+ return !isDone(this.state);
380
+ }
381
+ /** Whether the task completed and the target was not blocked. */
382
+ get ok() {
383
+ return this.state === "completed" && !this.blocked;
384
+ }
385
+ /** The structured output: format json, extract, template, AI. */
386
+ get data() {
387
+ return this.payload?.data;
388
+ }
389
+ /** Content of an html or markdown result; the raw JSON for structured ones. */
390
+ get text() {
391
+ const value = this.data;
392
+ if (value === void 0 || value === null) return "";
393
+ return typeof value === "string" ? value : JSON.stringify(value);
394
+ }
395
+ /** Bytes of a png or jpeg screenshot. Throws when the result is not one. */
396
+ get image() {
397
+ try {
398
+ return Uint8Array.from(atob(this.text), (char) => char.charCodeAt(0));
399
+ } catch (cause) {
400
+ throw new DataFuelError("result is not a png or jpeg screenshot", { cause });
401
+ }
402
+ }
403
+ /** `data`, or throw when the task carried none. */
404
+ requireData() {
405
+ if (this.data === void 0 || this.data === null) {
406
+ throw new DataFuelError(`result has no data (status ${this.state})`);
407
+ }
408
+ return this.data;
409
+ }
410
+ /** Throw TaskFailed, or Blocked, when this task failed. Refunded either way. */
411
+ raiseForStatus() {
412
+ if (this.state === "failed") {
413
+ throw this.blocked ? new Blocked(this) : new TaskFailed(this);
414
+ }
415
+ }
416
+ };
417
+ var CrawlPage = class extends Result {
418
+ url;
419
+ depth;
420
+ taskId;
421
+ constructor(raw) {
422
+ super(raw);
423
+ this.url = str(raw.url) ?? "";
424
+ this.depth = num(raw.depth) ?? 0;
425
+ this.taskId = str(raw.task_id);
426
+ }
427
+ };
428
+ var Capabilities = class {
429
+ modules;
430
+ engines;
431
+ constructor(raw) {
432
+ this.modules = Array.isArray(raw.modules) ? raw.modules : [];
433
+ this.engines = Array.isArray(raw.engines) ? raw.engines : [];
434
+ }
435
+ /** Whether a task type accepts work. Unknown names report false. */
436
+ moduleEnabled(name) {
437
+ return this.modules.some((item) => item.name === name && item.enabled);
438
+ }
439
+ /** Whether an LLM engine accepts work. Unknown names report false. */
440
+ engineEnabled(name) {
441
+ return this.engines.some((item) => item.name === name && item.enabled);
442
+ }
443
+ };
444
+ function str(value) {
445
+ return typeof value === "string" && value !== "" ? value : void 0;
446
+ }
447
+ function num(value) {
448
+ return typeof value === "number" ? value : void 0;
449
+ }
450
+
451
+ // src/client.ts
452
+ var DataFuel = class {
453
+ baseUrl;
454
+ timeoutMs;
455
+ maxRetries;
456
+ pollIntervalMs;
457
+ userAgent;
458
+ apiKey;
459
+ fetchImpl;
460
+ constructor(options = {}) {
461
+ const opts = typeof options === "string" ? { apiKey: options } : options;
462
+ this.apiKey = opts.apiKey ?? envApiKey() ?? "";
463
+ this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
464
+ this.timeoutMs = opts.timeoutMs === void 0 ? DEFAULT_TIMEOUT_MS : opts.timeoutMs;
465
+ this.maxRetries = Math.max(opts.maxRetries ?? 2, 0);
466
+ this.pollIntervalMs = opts.pollIntervalMs !== void 0 && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : 2e3;
467
+ const sdk = `datafuel-js/${VERSION}`;
468
+ this.userAgent = opts.userAgent ? `${opts.userAgent} ${sdk}` : sdk;
469
+ this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
470
+ }
471
+ // --- transport ---------------------------------------------------------
472
+ /** Pause between attempts. Overridable so tests need not wait in real time. */
473
+ sleep(ms) {
474
+ return new Promise((resolve) => setTimeout(resolve, ms));
475
+ }
476
+ async send(request, opts = {}) {
477
+ if (!this.apiKey) {
478
+ throw new NoApiKey("no API key: pass one to the client or set DATAFUEL_API_KEY");
479
+ }
480
+ const url = new URL(this.baseUrl + request.path);
481
+ for (const [name, value] of Object.entries(request.params ?? {})) {
482
+ url.searchParams.set(name, value);
483
+ }
484
+ const init = {
485
+ method: request.method,
486
+ headers: headers(this.apiKey, this.userAgent, request),
487
+ // Redirects stay off: a redirect to another host would carry X-API-Key
488
+ // to it, since fetch does not strip custom headers.
489
+ redirect: "manual"
490
+ };
491
+ if (request.body !== void 0) init.body = JSON.stringify(request.body);
492
+ for (let attempt = 0; ; attempt++) {
493
+ let error;
494
+ try {
495
+ const signal = this.signalFor(opts);
496
+ const send = this.fetchImpl;
497
+ const response = await send(url, signal ? { ...init, signal } : init);
498
+ return await parse(response);
499
+ } catch (caught) {
500
+ if (isAbort(caught)) {
501
+ throw new TransportError("the request was aborted or timed out", { cause: caught });
502
+ }
503
+ error = caught instanceof DataFuelError ? caught : new TransportError(String(caught), {
504
+ cause: caught
505
+ });
506
+ }
507
+ const retryAfter = error instanceof Object && "retryAfter" in error ? Number(error.retryAfter) : 0;
508
+ if (attempt >= this.maxRetries || !request.retryable || !shouldRetry(error)) throw error;
509
+ await this.sleep(backoff(attempt, retryAfter));
510
+ }
511
+ }
512
+ signalFor(opts) {
513
+ const budget = opts.timeoutMs ?? this.timeoutMs;
514
+ const timeout = budget === null || budget === void 0 ? void 0 : AbortSignal.timeout(budget);
515
+ if (timeout && opts.signal) return AbortSignal.any([timeout, opts.signal]);
516
+ return timeout ?? opts.signal;
517
+ }
518
+ /**
519
+ * Send a task and wait for its result.
520
+ *
521
+ * On a 202 the API is still working: the identical request goes out again
522
+ * under the identical idempotency key, so it attaches to the running task
523
+ * rather than starting a second one.
524
+ */
525
+ async runTask(request, opts) {
526
+ const budget = opts.timeoutMs ?? this.timeoutMs ?? DEFAULT_TIMEOUT_MS;
527
+ const deadline = Date.now() + budget;
528
+ let body;
529
+ for (let attempt = 0; ; attempt++) {
530
+ body = await this.send(request, opts);
531
+ if (!stillProcessing(body)) break;
532
+ const delay = pollDelay(attempt);
533
+ if (Date.now() + delay >= deadline) {
534
+ throw new WaitTimeout(
535
+ "the task is still processing; re-send under the same idempotency key to pick it up",
536
+ request.idempotencyKey
537
+ );
538
+ }
539
+ await this.sleep(delay);
540
+ }
541
+ const result = new Result(record(body));
542
+ result.raiseForStatus();
543
+ return result;
544
+ }
545
+ // --- pages -------------------------------------------------------------
546
+ /**
547
+ * Fetch one URL and wait for the result.
548
+ *
549
+ * Throws {@link Blocked} when the target refused the request and
550
+ * {@link TaskFailed} otherwise; both carry `.result`.
551
+ */
552
+ async scrape(url, options = {}) {
553
+ const request = buildScrape(url, options, options.proxy, key(options.idempotencyKey));
554
+ return this.runTask(request, options);
555
+ }
556
+ /** The one-liner: the page as LLM-ready markdown, images dropped. */
557
+ async markdown(url, options = {}) {
558
+ const result = await this.scrape(url, { ...options, format: "markdown", includeImages: false });
559
+ return result.text;
560
+ }
561
+ /**
562
+ * Return a task by id, e.g. one created by a job or a crawl.
563
+ *
564
+ * A task that is still running comes back with `pending` true.
565
+ */
566
+ async getTask(taskId, options = {}) {
567
+ const body = await this.send(
568
+ new Request("GET", `/task/${pathSegment(taskId)}`),
569
+ options
570
+ );
571
+ if (stillProcessing(body)) return new Result({ id: taskId, status: "processing" });
572
+ const result = new Result(record(body));
573
+ result.raiseForStatus();
574
+ return result;
575
+ }
576
+ /** Send a prompt to an AI engine and return its answer. */
577
+ async ask(prompt, options) {
578
+ const request = buildAsk(prompt, options, key(options.idempotencyKey));
579
+ return this.runTask(request, options);
580
+ }
581
+ /**
582
+ * List the URLs of a site without scraping them.
583
+ *
584
+ * One credit per call on a Basic proxy, however many links come back. Use it
585
+ * before a crawl to see how big a section is.
586
+ */
587
+ async map(url, options = {}) {
588
+ const request = buildMap(url, options, options.proxy, key(options.idempotencyKey));
589
+ const task = await this.runTask(request, options);
590
+ const data = record(task.requireData());
591
+ return { ...data, links: data.links ?? [], sitemaps: data.sitemaps ?? [], task };
592
+ }
593
+ // --- crawl -------------------------------------------------------------
594
+ /**
595
+ * Queue a crawl and return its id immediately.
596
+ *
597
+ * Follows links from the start URL and scrapes every page. Pages are charged
598
+ * like single scrapes when they are queued; failed and blocked pages are
599
+ * refunded. Unset limits use the API defaults: 100 pages, depth 3, 5 in flight.
600
+ */
601
+ async startCrawl(url, options = {}) {
602
+ const request = buildCrawl(url, options, options.proxy, key(options.idempotencyKey));
603
+ return strField(await this.send(request, options), "job_id");
604
+ }
605
+ /** Return the progress of a crawl. */
606
+ async getCrawl(crawlId, options = {}) {
607
+ const body = record(
608
+ await this.send(new Request("GET", `/crawl/${pathSegment(crawlId)}`), options)
609
+ );
610
+ const raw = body;
611
+ return {
612
+ ...raw,
613
+ status: raw.status,
614
+ // Counters the caller reads unconditionally must exist even on a thin answer.
615
+ pages: {
616
+ discovered: 0,
617
+ enqueued: 0,
618
+ done: 0,
619
+ failed: 0,
620
+ skipped: 0,
621
+ ...raw.pages ?? {}
622
+ },
623
+ depth_reached: raw.depth_reached ?? 0,
624
+ total_cost: raw.total_cost ?? 0,
625
+ done: isDone(raw.status)
626
+ };
627
+ }
628
+ /** One page of results, in discovery order. `limit` unset uses the API default. */
629
+ async crawlResults(crawlId, options = {}) {
630
+ const body = record(
631
+ await this.send(crawlResultsRequest(crawlId, options.cursor, options.limit), options)
632
+ );
633
+ const pages = Array.isArray(body.pages) ? body.pages : [];
634
+ const next = typeof body.next_cursor === "string" ? body.next_cursor : void 0;
635
+ return {
636
+ pages: pages.map((page) => new CrawlPage(record(page))),
637
+ ...next ? { nextCursor: next } : {}
638
+ };
639
+ }
640
+ /**
641
+ * Walk every page of a crawl, fetching result pages as needed.
642
+ *
643
+ * Readable while the crawl runs; unfinished pages report `pending`.
644
+ */
645
+ async *crawlPages(crawlId, options = {}) {
646
+ let cursor;
647
+ const seen = /* @__PURE__ */ new Set();
648
+ for (; ; ) {
649
+ const batch = await this.crawlResults(crawlId, { ...options, ...cursor ? { cursor } : {} });
650
+ yield* batch.pages;
651
+ cursor = batch.nextCursor;
652
+ if (!cursor) return;
653
+ if (seen.has(cursor)) {
654
+ throw new DataFuelError("the API repeated a crawl cursor; stopping to avoid a loop");
655
+ }
656
+ seen.add(cursor);
657
+ }
658
+ }
659
+ /** Poll until the crawl is done. Without `timeoutMs` it waits indefinitely. */
660
+ async waitCrawl(crawlId, options = {}) {
661
+ const { timeoutMs, ...perPoll } = options;
662
+ const deadline = timeoutMs === void 0 ? null : Date.now() + timeoutMs;
663
+ for (; ; ) {
664
+ const status = await this.getCrawl(crawlId, perPoll);
665
+ if (status.done) return status;
666
+ if (deadline !== null && Date.now() + this.pollIntervalMs >= deadline) {
667
+ throw new WaitTimeout(`crawl ${crawlId} is still running`, crawlId, status);
668
+ }
669
+ await this.sleep(this.pollIntervalMs);
670
+ }
671
+ }
672
+ /**
673
+ * Start a crawl, wait for it, and return every page.
674
+ *
675
+ * Check `stop_reason`: `insufficient_credits` means it ended early. For large
676
+ * crawls prefer {@link startCrawl} + {@link waitCrawl} + {@link crawlPages},
677
+ * which stream instead of holding every page in memory. On {@link WaitTimeout}
678
+ * the error carries the id: the crawl keeps running and billing.
679
+ */
680
+ async crawl(url, options = {}) {
681
+ const id = await this.startCrawl(url, options);
682
+ const status = await this.waitCrawl(id, options);
683
+ const pages = [];
684
+ for await (const page of this.crawlPages(id, options)) pages.push(page);
685
+ return { id, status, pages };
686
+ }
687
+ // --- jobs --------------------------------------------------------------
688
+ /**
689
+ * Queue a batch of known URLs and return the job id.
690
+ *
691
+ * Cheaper and more predictable than a crawl when you already have the URLs.
692
+ * `sequential` runs them one after the other; the default runs them
693
+ * concurrently, bounded by the account's concurrency limit.
694
+ */
695
+ async createJob(urls, options = {}) {
696
+ const request = buildJob(
697
+ urls,
698
+ options,
699
+ options.proxy,
700
+ options.sequential ?? false,
701
+ key(options.idempotencyKey)
702
+ );
703
+ return strField(await this.send(request, options), "id");
704
+ }
705
+ /** Queue a batch of prompts for an AI engine and return the job id. */
706
+ async createAskJob(prompts, options) {
707
+ const request = buildAskJob(
708
+ prompts,
709
+ options,
710
+ options.sequential ?? false,
711
+ key(options.idempotencyKey)
712
+ );
713
+ return strField(await this.send(request, options), "id");
714
+ }
715
+ /** Return the progress of a job. */
716
+ async getJob(jobId, options = {}) {
717
+ const body = record(
718
+ await this.send(new Request("GET", `/job/${pathSegment(jobId)}`), options)
719
+ );
720
+ const raw = body;
721
+ return {
722
+ status: raw.status,
723
+ tasks_count: raw.tasks_count ?? 0,
724
+ tasks_done: raw.tasks_done ?? 0,
725
+ tasks_remaining: raw.tasks_remaining ?? 0,
726
+ total_cost: raw.total_cost ?? 0,
727
+ done: isDone(raw.status)
728
+ };
729
+ }
730
+ /**
731
+ * Return the per-task results of a job.
732
+ *
733
+ * A job completes even when some of its tasks failed: check `task.ok` or call
734
+ * `task.raiseForStatus()` per task.
735
+ */
736
+ async jobResults(jobId, options = {}) {
737
+ const body = record(
738
+ await this.send(new Request("GET", `/job/${pathSegment(jobId)}/results`), options)
739
+ );
740
+ const tasks = Array.isArray(body.tasks_result) ? body.tasks_result : [];
741
+ return {
742
+ id: jobId,
743
+ tasks_count: Number(body.tasks_count ?? 0),
744
+ tasks_failed: Number(body.tasks_failed ?? 0),
745
+ tasks_complete: Number(body.tasks_complete ?? 0),
746
+ tasks: tasks.map((task) => new Result(record(task)))
747
+ };
748
+ }
749
+ /** Poll until the job is done. Without `timeoutMs` it waits indefinitely. */
750
+ async waitJob(jobId, options = {}) {
751
+ const { timeoutMs, ...perPoll } = options;
752
+ const deadline = timeoutMs === void 0 ? null : Date.now() + timeoutMs;
753
+ for (; ; ) {
754
+ const status = await this.getJob(jobId, perPoll);
755
+ if (status.done) return status;
756
+ if (deadline !== null && Date.now() + this.pollIntervalMs >= deadline) {
757
+ throw new WaitTimeout(`job ${jobId} is still running`, jobId, status);
758
+ }
759
+ await this.sleep(this.pollIntervalMs);
760
+ }
761
+ }
762
+ /** Create a job, wait for it, and return its results. */
763
+ async runJob(urls, options = {}) {
764
+ const id = await this.createJob(urls, options);
765
+ await this.waitJob(id, options);
766
+ return this.jobResults(id, options);
767
+ }
768
+ /** Create a prompt job, wait for it, and return its results. */
769
+ async runAskJob(prompts, options) {
770
+ const id = await this.createAskJob(prompts, options);
771
+ await this.waitJob(id, options);
772
+ return this.jobResults(id, options);
773
+ }
774
+ // --- account -----------------------------------------------------------
775
+ /** Which task types and LLM engines are switched on right now. */
776
+ async capabilities(options = {}) {
777
+ return new Capabilities(
778
+ record(await this.send(new Request("GET", "/capabilities"), options))
779
+ );
780
+ }
781
+ /** Remaining credits. */
782
+ async balance(options = {}) {
783
+ const body = await this.send(new Request("GET", "/users/@me/balance"), options);
784
+ return intField(body, "balance");
785
+ }
786
+ /** The account behind the API key. */
787
+ async me(options = {}) {
788
+ const body = await this.send(new Request("GET", "/users/@me"), options);
789
+ return record(body);
790
+ }
791
+ /**
792
+ * Revoke the current key and return the new one.
793
+ *
794
+ * This is the only time the new key is shown. The client keeps using the
795
+ * revoked one: store the new key and build a new client with it.
796
+ */
797
+ async resetApiKey(options = {}) {
798
+ const body = await this.send(new Request("POST", "/users/@me/api-key/reset"), options);
799
+ return strField(body, "api_key");
800
+ }
801
+ };
802
+ function envApiKey() {
803
+ return typeof process !== "undefined" ? process.env?.DATAFUEL_API_KEY : void 0;
804
+ }
805
+ async function parse(response) {
806
+ const text = await response.text();
807
+ let body;
808
+ if (text.length > 0) {
809
+ try {
810
+ body = JSON.parse(text);
811
+ } catch {
812
+ body = text;
813
+ }
814
+ }
815
+ if (!response.ok) {
816
+ const header = response.headers.get("Retry-After");
817
+ const retryAfter = header !== null && !Number.isNaN(Number(header)) ? Number(header) : 0;
818
+ throw apiError(response.status, body, retryAfter);
819
+ }
820
+ return body;
821
+ }
822
+ function isAbort(error) {
823
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
824
+ }
825
+ function record(body) {
826
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
827
+ throw new DataFuelError(`unexpected answer from the API: ${JSON.stringify(body) ?? "empty"}`);
828
+ }
829
+ return body;
830
+ }
831
+ function field(body, name) {
832
+ const value = record(body)[name];
833
+ if (value === void 0 || value === null) {
834
+ throw new DataFuelError(`unexpected answer from the API: no ${name} field`);
835
+ }
836
+ return value;
837
+ }
838
+ function strField(body, name) {
839
+ return String(field(body, name));
840
+ }
841
+ function intField(body, name) {
842
+ const value = Number(field(body, name));
843
+ if (Number.isNaN(value)) {
844
+ throw new DataFuelError(`unexpected answer from the API: ${name} is not a number`);
845
+ }
846
+ return value;
847
+ }
848
+ export {
849
+ APIError,
850
+ Blocked,
851
+ Capabilities,
852
+ CrawlPage,
853
+ DEFAULT_BASE_URL,
854
+ DataFuel,
855
+ DataFuelError,
856
+ EngineUnavailable,
857
+ IdempotencyKeyReused,
858
+ InsufficientCredits,
859
+ InvalidAttributes,
860
+ ModuleUnavailable,
861
+ NoApiKey,
862
+ NotFound,
863
+ RateLimited,
864
+ Result,
865
+ TaskFailed,
866
+ TransportError,
867
+ Unauthorized,
868
+ Unavailable,
869
+ VERSION,
870
+ WaitTimeout,
871
+ isDone
872
+ };
873
+ //# sourceMappingURL=index.js.map