@datafuel/sdk 0.2.0 → 0.4.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/src/client.ts CHANGED
@@ -4,14 +4,17 @@ import * as core from "./core.js";
4
4
  import type { AskOptions, CrawlOptions, MapOptions, SearchOptions } from "./core.js";
5
5
  import { apiError, DataFuelError, NoApiKey, TransportError, WaitTimeout } from "./errors.js";
6
6
  import type {
7
+ AIProvider,
7
8
  Analytics,
8
9
  AnalyticsOptions,
10
+ BalanceSplit,
9
11
  CallOptions,
10
12
  CancelResult,
11
13
  Capability,
12
14
  CrawlResult,
13
15
  CrawlResultsPage,
14
16
  CrawlStatus,
17
+ Health,
15
18
  JobResults,
16
19
  JobsPage,
17
20
  JobStatus,
@@ -20,6 +23,7 @@ import type {
20
23
  ListOptions,
21
24
  ListTasksOptions,
22
25
  Profile,
26
+ ProtectionCheck,
23
27
  ProxyCountry,
24
28
  ProxyLocation,
25
29
  ScrapeOptions,
@@ -124,7 +128,7 @@ export class DataFuel {
124
128
  // none) throws "Illegal invocation".
125
129
  const send = this.fetchImpl;
126
130
  const response = await send(url, signal ? { ...init, signal } : init);
127
- return await parse(response);
131
+ return await parse(response, request.degradedOk);
128
132
  } catch (caught) {
129
133
  if (isAbort(caught)) {
130
134
  throw new TransportError("the request was aborted or timed out", { cause: caught });
@@ -551,6 +555,40 @@ export class DataFuel {
551
555
  return Array.isArray(body.instructions) ? (body.instructions as JsInstruction[]) : [];
552
556
  }
553
557
 
558
+ /** The LLM providers and models `ai` accepts. Needs no key. */
559
+ async aiProviders(options: CallOptions = {}): Promise<AIProvider[]> {
560
+ const request = new core.Request(
561
+ "GET",
562
+ "/config/ai-providers",
563
+ undefined,
564
+ undefined,
565
+ undefined,
566
+ false,
567
+ );
568
+ const body = record(await this.send(request, options));
569
+ return Array.isArray(body.providers) ? (body.providers as AIProvider[]) : [];
570
+ }
571
+
572
+ /**
573
+ * Whether the API is up. `deep` also checks the dependencies it needs to
574
+ * serve scrapes. A degraded report is returned, not thrown: read `ok`.
575
+ * Needs no key.
576
+ */
577
+ async health(options: CallOptions & { deep?: boolean } = {}): Promise<Health> {
578
+ const body = record(await this.send(core.healthRequest(options.deep ?? false), options));
579
+ return { ...(body as unknown as Health), ok: body.status === "ok" };
580
+ }
581
+
582
+ /**
583
+ * Which anti-bot protection sits in front of each URL. Nothing is scraped
584
+ * and nothing is charged; invalid URLs are skipped.
585
+ */
586
+ async checkProtection(urls: string[], options: CallOptions = {}): Promise<ProtectionCheck[]> {
587
+ return list(
588
+ await this.send(new core.Request("POST", "/filter/check", undefined, [...urls]), options),
589
+ ) as ProtectionCheck[];
590
+ }
591
+
554
592
  /** Countries, regions and cities a proxy type can exit from. */
555
593
  async proxyLocations(
556
594
  options: CallOptions & { proxyType?: string } = {},
@@ -573,14 +611,24 @@ export class DataFuel {
573
611
  ) as ProxyLocation[];
574
612
  }
575
613
 
576
- /** Remaining credits. */
614
+ /** Remaining credits: plan and pay-as-you-go together. */
577
615
  async balance(options: CallOptions = {}): Promise<number> {
578
616
  const body = await this.send(new core.Request("GET", "/users/@me/balance"), options);
579
617
  return intField(body, "balance");
580
618
  }
581
619
 
620
+ /** Remaining credits by pool: plan credits (spent first) and pay-as-you-go credits. */
621
+ async balanceSplit(options: CallOptions = {}): Promise<BalanceSplit> {
622
+ const body = await this.send(new core.Request("GET", "/users/@me/balance"), options);
623
+ return {
624
+ balance: intField(body, "balance"),
625
+ plan_balance: intField(body, "plan_balance"),
626
+ payg_balance: intField(body, "payg_balance"),
627
+ };
628
+ }
629
+
582
630
  /**
583
- * Credit movements, newest first: purchases, usage, refunds, expiry.
631
+ * Credit movements, newest first: plan assignments, credit pack purchases, usage, refunds, expiry.
584
632
  * `sums` totals each operation over the whole range, not just this page.
585
633
  */
586
634
  async transactions(options: TransactionsOptions & CallOptions = {}): Promise<TransactionsPage> {
@@ -629,7 +677,7 @@ function envApiKey(): string | undefined {
629
677
  return typeof process !== "undefined" ? process.env?.DATAFUEL_API_KEY : undefined;
630
678
  }
631
679
 
632
- async function parse(response: Response): Promise<unknown> {
680
+ async function parse(response: Response, degradedOk = false): Promise<unknown> {
633
681
  const text = await response.text();
634
682
  let body: unknown;
635
683
  if (text.length > 0) {
@@ -639,6 +687,7 @@ async function parse(response: Response): Promise<unknown> {
639
687
  body = text;
640
688
  }
641
689
  }
690
+ if (degradedOk && response.status === 503 && isHealthReport(body)) return body;
642
691
  if (!response.ok) {
643
692
  const header = response.headers.get("Retry-After");
644
693
  const retryAfter = header !== null && !Number.isNaN(Number(header)) ? Number(header) : 0;
@@ -647,6 +696,10 @@ async function parse(response: Response): Promise<unknown> {
647
696
  return body;
648
697
  }
649
698
 
699
+ function isHealthReport(body: unknown): boolean {
700
+ return body !== null && typeof body === "object" && "status" in body;
701
+ }
702
+
650
703
  function isAbort(error: unknown): boolean {
651
704
  return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
652
705
  }
package/src/core.ts CHANGED
@@ -21,7 +21,7 @@ import type {
21
21
  export const DEFAULT_BASE_URL = "https://scraping-api.datafuel.ai/api/v1";
22
22
 
23
23
  /** Kept in step with package.json by a test; see test/hardening.test.ts. */
24
- export const VERSION = "0.2.0";
24
+ export const VERSION = "0.4.0";
25
25
 
26
26
  /** How long a synchronous call waits for a result before giving up. */
27
27
  export const DEFAULT_TIMEOUT_MS = 180_000;
@@ -37,9 +37,11 @@ export class Request {
37
37
  readonly method: string,
38
38
  readonly path: string,
39
39
  readonly params?: Record<string, string>,
40
- readonly body?: Record<string, unknown>,
40
+ readonly body?: Record<string, unknown> | unknown[],
41
41
  readonly idempotencyKey?: string,
42
42
  readonly auth: boolean = true,
43
+ /** A 503 that carries a health report is an answer, not an error. */
44
+ readonly degradedOk: boolean = false,
43
45
  ) {}
44
46
 
45
47
  /** GETs are safe by nature, writes because they carry an idempotency key. */
@@ -109,23 +111,10 @@ function aiAttributes(ai: AI): Record<string, unknown> {
109
111
  return out;
110
112
  }
111
113
 
112
- /**
113
- * Catch a half-filled AI key before it costs a request. The API does not check
114
- * these, so a missing one would be spent on a scrape whose AI step then fails.
115
- */
114
+ /** The API answers 400 INVALID_ATTRIBUTES without a provider; say so before sending. */
116
115
  export function validateAI(ai: AI): void {
117
- const given = {
118
- provider: ai.provider !== undefined,
119
- model: ai.model !== undefined,
120
- apiKey: ai.apiKey !== undefined,
121
- };
122
- const present = Object.values(given).filter(Boolean).length;
123
- if (present > 0 && present < 3) {
124
- const missing = Object.entries(given)
125
- .filter(([, ok]) => !ok)
126
- .map(([name]) => name)
127
- .join(", ");
128
- throw new TypeError(`ai needs provider, model and apiKey together; missing: ${missing}`);
116
+ if (!ai.provider) {
117
+ throw new TypeError("ai needs a provider; aiProviders() lists the ones the API supports");
129
118
  }
130
119
  }
131
120
 
@@ -141,7 +130,12 @@ export function scrapeAttributes(options: ScrapeOptions = {}): Record<string, un
141
130
  if (options.waitFor) attrs.wait_for_selector = options.waitFor;
142
131
  if (options.waitForTimeoutMs) attrs.wait_for_selector_timeout_ms = options.waitForTimeoutMs;
143
132
  if (options.jsInstructions) attrs.js_instructions = options.jsInstructions;
144
- if (options.blockResource) attrs.block_resource = options.blockResource;
133
+ if (options.blockResource?.length) {
134
+ attrs.block_resource =
135
+ typeof options.blockResource === "string"
136
+ ? options.blockResource
137
+ : [...options.blockResource];
138
+ }
145
139
  if (options.mainContentOnly) attrs.main_content_only = true;
146
140
  if (options.includeImages !== undefined) attrs.include_images = options.includeImages;
147
141
  if (options.extract) attrs.extract_selector = selector(options.extract);
@@ -386,7 +380,10 @@ export interface SearchOptions {
386
380
  ludocid?: string;
387
381
  lsig?: string;
388
382
  ibp?: string;
389
- /** Exit country of the request. */
383
+ /**
384
+ * Accepted but not used yet: searches leave through DataFuel's own pool.
385
+ * Target a market with `country` and `language`.
386
+ */
390
387
  proxyCountry?: string;
391
388
  /** Default json. */
392
389
  format?: "json" | "html" | "markdown";
@@ -465,6 +462,18 @@ export function crawlResultsRequest(crawlId: string, cursor?: string, limit?: nu
465
462
  );
466
463
  }
467
464
 
465
+ export function healthRequest(deep: boolean): Request {
466
+ return new Request(
467
+ "GET",
468
+ "/healthz",
469
+ deep ? { deep: "1" } : undefined,
470
+ undefined,
471
+ undefined,
472
+ false,
473
+ true,
474
+ );
475
+ }
476
+
468
477
  /** A Date as its UTC day, which is what the API's date filters take. */
469
478
  export function day(value: string | Date): string {
470
479
  return typeof value === "string" ? value : value.toISOString().slice(0, 10);
package/src/errors.ts CHANGED
@@ -45,6 +45,10 @@ export type ErrorCode =
45
45
  | "JOB_NOT_FOUND"
46
46
  | "CRAWL_NOT_FOUND"
47
47
  | "JOB_NOT_CANCELLABLE"
48
+ | "TASK_ALREADY_EXISTS"
49
+ | "JOB_ALREADY_EXISTS"
50
+ | "URL_LENGTH_CANNOT_BE_ZERO"
51
+ | "ANALYTICS_FETCH_FAILED"
48
52
  | "INVALID_CRAWL_PATTERN"
49
53
  | "CRAWL_UNSUPPORTED_OPTION"
50
54
  | "INVALID_CURSOR"
@@ -92,6 +96,12 @@ export class InvalidAttributes extends APIError {}
92
96
  export class IdempotencyKeyReused extends APIError {}
93
97
  /** 409 JOB_NOT_CANCELLABLE: the job or crawl already finished. */
94
98
  export class JobNotCancellable extends APIError {}
99
+ /**
100
+ * 409 TASK_ALREADY_EXISTS / JOB_ALREADY_EXISTS: the create collided with an
101
+ * existing task or job and is not an idempotent replay. Nothing was charged;
102
+ * send the request again.
103
+ */
104
+ export class AlreadyExists extends APIError {}
95
105
  /** 503: an operator switched something off, or a dependency is down. The message carries the reason. */
96
106
  export class Unavailable extends APIError {}
97
107
  /** 503 MODULE_UNAVAILABLE: this task type is switched off. Nothing was charged. */
@@ -152,6 +162,8 @@ const BY_CODE: Record<string, new (s: number, c: string, m: string, r?: number)
152
162
  INVALID_API_KEY: Unauthorized,
153
163
  FORBIDDEN: Forbidden,
154
164
  JOB_NOT_CANCELLABLE: JobNotCancellable,
165
+ TASK_ALREADY_EXISTS: AlreadyExists,
166
+ JOB_ALREADY_EXISTS: AlreadyExists,
155
167
  };
156
168
 
157
169
  const BY_STATUS: Record<number, new (s: number, c: string, m: string, r?: number) => APIError> = {
@@ -159,7 +171,6 @@ const BY_STATUS: Record<number, new (s: number, c: string, m: string, r?: number
159
171
  402: InsufficientCredits,
160
172
  403: Forbidden,
161
173
  404: NotFound,
162
- 409: JobNotCancellable,
163
174
  422: IdempotencyKeyReused,
164
175
  429: RateLimited,
165
176
  503: Unavailable,
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export type { ClientOptions } from "./client.js";
32
32
  export { DEFAULT_BASE_URL, VERSION } from "./core.js";
33
33
  export type { AskOptions, CrawlOptions, MapOptions, SearchOptions } from "./core.js";
34
34
  export {
35
+ AlreadyExists,
35
36
  APIError,
36
37
  Blocked,
37
38
  DataFuelError,
@@ -55,9 +56,11 @@ export type { ErrorCode } from "./errors.js";
55
56
  export { Capabilities, CrawlPage, isDone, Result } from "./models.js";
56
57
  export type {
57
58
  AI,
59
+ AIProvider,
58
60
  Analytics,
59
61
  AnalyticsCounts,
60
62
  AnalyticsOptions,
63
+ BalanceSplit,
61
64
  CallOptions,
62
65
  CancelResult,
63
66
  Capability,
@@ -66,6 +69,7 @@ export type {
66
69
  CrawlStatus,
67
70
  Engine,
68
71
  Format,
72
+ Health,
69
73
  JobResults,
70
74
  JobsPage,
71
75
  JobStatus,
@@ -77,6 +81,7 @@ export type {
77
81
  ListTasksOptions,
78
82
  Payload,
79
83
  Profile,
84
+ ProtectionCheck,
80
85
  Proxy,
81
86
  ProxyCountry,
82
87
  ProxyLocation,
package/src/models.ts CHANGED
@@ -60,7 +60,9 @@ export interface AI {
60
60
  prompt?: string;
61
61
  /** Example JSON object the output must follow. */
62
62
  format?: unknown;
63
- provider?: "openai" | "anthropic" | "google";
63
+ /** Required. One of the providers `df.aiProviders()` lists; the API answers 400 INVALID_ATTRIBUTES without it. */
64
+ provider?: string;
65
+ /** One of the models `df.aiProviders()` lists for `provider`. Omit for the provider's default. */
64
66
  model?: string;
65
67
  apiKey?: string;
66
68
  }
@@ -75,9 +77,15 @@ export interface ScrapeOptions {
75
77
  jsRendering?: boolean;
76
78
  waitFor?: string;
77
79
  waitForTimeoutMs?: number;
78
- /** An object keyed by action, e.g. `{ click: "#more" }`. `df.jsInstructions()` lists them. */
80
+ /**
81
+ * Browser actions run after load: an array of single-action objects, run in
82
+ * order, e.g. `[{ click: "#more" }, { wait_ms: 1000 }, { click: "#more" }]`.
83
+ * An object keyed by action is still accepted, but its order is not
84
+ * guaranteed and an action cannot repeat. `df.jsInstructions()` lists them.
85
+ */
79
86
  jsInstructions?: unknown;
80
- blockResource?: string;
87
+ /** Resource types the browser must not load: one, e.g. `"Image"`, or several. */
88
+ blockResource?: string | string[];
81
89
  /** Markdown only: always render just the `<main>` / `<article>` container. */
82
90
  mainContentOnly?: boolean;
83
91
  /** Markdown only: `false` drops images and saves tokens. */
@@ -414,6 +422,8 @@ export interface TransactionsOptions {
414
422
  export interface Transaction {
415
423
  id: number;
416
424
  amount: number;
425
+ /** The part of `amount` that moved plan credits, same sign; the rest moved pay-as-you-go credits. */
426
+ plan_amount?: number;
417
427
  operation: TransactionOperation;
418
428
  /** What `reference_id` points to, e.g. `task_id` or `job_id`. */
419
429
  reference_type: string;
@@ -508,13 +518,30 @@ export interface Analytics {
508
518
  by_status_code: StatusCodeBreakdown[];
509
519
  }
510
520
 
521
+ /**
522
+ * Credits by pool. Plan credits are spent first, roll over when the plan renews and
523
+ * expire if it is not renewed. Pay-as-you-go credits come from credit packs, are spent
524
+ * after plan credits and never expire.
525
+ */
526
+ export interface BalanceSplit {
527
+ /** Total spendable credits: `plan_balance` plus `payg_balance`. */
528
+ balance: number;
529
+ plan_balance: number;
530
+ payg_balance: number;
531
+ }
532
+
511
533
  /** The account behind the API key. */
512
534
  export interface Profile {
513
535
  email: string;
514
536
  username: string;
515
537
  current_concurrency: number;
516
538
  concurrency_limit: number;
539
+ /** Total spendable credits: `plan_credit_balance` plus `payg_credit_balance`. */
517
540
  credit_balance: number;
541
+ /** Spent first; expire if the plan is not renewed. */
542
+ plan_credit_balance?: number;
543
+ /** Spent after plan credits; never expire. */
544
+ payg_credit_balance?: number;
518
545
  monthly_credit_limit: number;
519
546
  }
520
547
 
@@ -538,6 +565,28 @@ export interface JsInstruction {
538
565
  example: unknown;
539
566
  }
540
567
 
568
+ /** An LLM provider `ai.provider` accepts, with the models `ai.model` accepts for it. */
569
+ export interface AIProvider {
570
+ name: string;
571
+ models: string[];
572
+ }
573
+
574
+ /** The anti-bot protection in front of one host and path, from `checkProtection`. */
575
+ export interface ProtectionCheck {
576
+ host: string;
577
+ path: string;
578
+ /** cloudflare, cloudflare_5sec, akamai, imperva, perimeterx or unprotected. */
579
+ protection_type: string;
580
+ }
581
+
582
+ /** The state of the API. `checks` is filled by a deep check only, keyed by dependency. */
583
+ export interface Health {
584
+ status: "ok" | "degraded" | (string & {});
585
+ checks?: Record<string, { status: "ok" | "fail" | (string & {}); latency_ms: number }>;
586
+ /** Whether the API can serve requests. */
587
+ ok: boolean;
588
+ }
589
+
541
590
  /** A named proxy location: a city, or an ASN. */
542
591
  export interface ProxyLocation {
543
592
  code: string;