litescrape-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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -0
  3. package/dist/cjs/batch.d.ts +12 -0
  4. package/dist/cjs/batch.d.ts.map +1 -0
  5. package/dist/cjs/batch.js +131 -0
  6. package/dist/cjs/batch.js.map +1 -0
  7. package/dist/cjs/cache.d.ts +21 -0
  8. package/dist/cjs/cache.d.ts.map +1 -0
  9. package/dist/cjs/cache.js +112 -0
  10. package/dist/cjs/cache.js.map +1 -0
  11. package/dist/cjs/client.d.ts +38 -0
  12. package/dist/cjs/client.d.ts.map +1 -0
  13. package/dist/cjs/client.js +143 -0
  14. package/dist/cjs/client.js.map +1 -0
  15. package/dist/cjs/errors.d.ts +53 -0
  16. package/dist/cjs/errors.d.ts.map +1 -0
  17. package/dist/cjs/errors.js +121 -0
  18. package/dist/cjs/errors.js.map +1 -0
  19. package/dist/cjs/index.d.ts +8 -0
  20. package/dist/cjs/index.d.ts.map +1 -0
  21. package/dist/cjs/index.js +40 -0
  22. package/dist/cjs/index.js.map +1 -0
  23. package/dist/cjs/models.d.ts +1735 -0
  24. package/dist/cjs/models.d.ts.map +1 -0
  25. package/dist/cjs/models.js +389 -0
  26. package/dist/cjs/models.js.map +1 -0
  27. package/dist/cjs/package.json +1 -0
  28. package/dist/cjs/result.d.ts +23 -0
  29. package/dist/cjs/result.d.ts.map +1 -0
  30. package/dist/cjs/result.js +49 -0
  31. package/dist/cjs/result.js.map +1 -0
  32. package/dist/cjs/runtime.d.ts +53 -0
  33. package/dist/cjs/runtime.d.ts.map +1 -0
  34. package/dist/cjs/runtime.js +237 -0
  35. package/dist/cjs/runtime.js.map +1 -0
  36. package/dist/cjs/stores.d.ts +143 -0
  37. package/dist/cjs/stores.d.ts.map +1 -0
  38. package/dist/cjs/stores.js +194 -0
  39. package/dist/cjs/stores.js.map +1 -0
  40. package/dist/cjs/validation.d.ts +65 -0
  41. package/dist/cjs/validation.d.ts.map +1 -0
  42. package/dist/cjs/validation.js +122 -0
  43. package/dist/cjs/validation.js.map +1 -0
  44. package/dist/cjs/version.d.ts +2 -0
  45. package/dist/cjs/version.d.ts.map +1 -0
  46. package/dist/cjs/version.js +5 -0
  47. package/dist/cjs/version.js.map +1 -0
  48. package/dist/esm/batch.d.ts +12 -0
  49. package/dist/esm/batch.d.ts.map +1 -0
  50. package/dist/esm/batch.js +128 -0
  51. package/dist/esm/batch.js.map +1 -0
  52. package/dist/esm/cache.d.ts +21 -0
  53. package/dist/esm/cache.d.ts.map +1 -0
  54. package/dist/esm/cache.js +106 -0
  55. package/dist/esm/cache.js.map +1 -0
  56. package/dist/esm/client.d.ts +38 -0
  57. package/dist/esm/client.d.ts.map +1 -0
  58. package/dist/esm/client.js +138 -0
  59. package/dist/esm/client.js.map +1 -0
  60. package/dist/esm/errors.d.ts +53 -0
  61. package/dist/esm/errors.d.ts.map +1 -0
  62. package/dist/esm/errors.js +106 -0
  63. package/dist/esm/errors.js.map +1 -0
  64. package/dist/esm/index.d.ts +8 -0
  65. package/dist/esm/index.d.ts.map +1 -0
  66. package/dist/esm/index.js +6 -0
  67. package/dist/esm/index.js.map +1 -0
  68. package/dist/esm/models.d.ts +1735 -0
  69. package/dist/esm/models.d.ts.map +1 -0
  70. package/dist/esm/models.js +384 -0
  71. package/dist/esm/models.js.map +1 -0
  72. package/dist/esm/result.d.ts +23 -0
  73. package/dist/esm/result.d.ts.map +1 -0
  74. package/dist/esm/result.js +45 -0
  75. package/dist/esm/result.js.map +1 -0
  76. package/dist/esm/runtime.d.ts +53 -0
  77. package/dist/esm/runtime.d.ts.map +1 -0
  78. package/dist/esm/runtime.js +226 -0
  79. package/dist/esm/runtime.js.map +1 -0
  80. package/dist/esm/stores.d.ts +143 -0
  81. package/dist/esm/stores.d.ts.map +1 -0
  82. package/dist/esm/stores.js +190 -0
  83. package/dist/esm/stores.js.map +1 -0
  84. package/dist/esm/validation.d.ts +65 -0
  85. package/dist/esm/validation.d.ts.map +1 -0
  86. package/dist/esm/validation.js +109 -0
  87. package/dist/esm/validation.js.map +1 -0
  88. package/dist/esm/version.d.ts +2 -0
  89. package/dist/esm/version.d.ts.map +1 -0
  90. package/dist/esm/version.js +2 -0
  91. package/dist/esm/version.js.map +1 -0
  92. package/package.json +74 -0
  93. package/src/batch.ts +158 -0
  94. package/src/cache.ts +123 -0
  95. package/src/client.ts +199 -0
  96. package/src/errors.ts +120 -0
  97. package/src/index.ts +17 -0
  98. package/src/models.ts +570 -0
  99. package/src/package.json +3 -0
  100. package/src/result.ts +49 -0
  101. package/src/runtime.ts +255 -0
  102. package/src/stores.ts +206 -0
  103. package/src/validation.ts +140 -0
  104. package/src/version.ts +1 -0
package/src/client.ts ADDED
@@ -0,0 +1,199 @@
1
+ import {
2
+ APIError,
3
+ AuthenticationError,
4
+ PaymentRequiredError,
5
+ TransportError,
6
+ ValidationError,
7
+ } from "./errors.js";
8
+ import {
9
+ keyStatusSchema,
10
+ parseRequest,
11
+ type AnyRequest,
12
+ type KeyStatus,
13
+ type RequestItem,
14
+ } from "./models.js";
15
+ import { Result } from "./result.js";
16
+ import {
17
+ DEFAULT_BASE_URL,
18
+ Semaphore,
19
+ each,
20
+ keySemaphore,
21
+ requestWithRetries,
22
+ type Fetch,
23
+ type RuntimeOptions,
24
+ } from "./runtime.js";
25
+ import { timeoutSeconds } from "./validation.js";
26
+
27
+ export interface ClientOptions {
28
+ /** Defaults to LITESCRAPE_API_KEY. */
29
+ apiKey?: string;
30
+ /** Defaults to LITESCRAPE_API_URL or https://api.litescrape.com. */
31
+ baseUrl?: string;
32
+ /** Total transport attempts, including the first. Default: 5. */
33
+ attempts?: number;
34
+ /** HTTP transport timeout in seconds per attempt. Default: 120. */
35
+ timeout?: number;
36
+ /** Cancels local work; accepted durable jobs keep running on the server. */
37
+ signal?: AbortSignal;
38
+ /** Optional fetch implementation for custom transports and testing. */
39
+ fetch?: Fetch;
40
+ }
41
+
42
+ export interface ScrapeOptions extends ClientOptions {
43
+ /** Lowers the key's synchronous concurrency cap; batch requests default to 32. */
44
+ concurrency?: number;
45
+ /** Server deadline in seconds, >0 and <=90. An item's timeout overrides this. */
46
+ requestTimeout?: number;
47
+ batched?: boolean;
48
+ /** Resume saved durable jobs; requires batched: true. Default: false. */
49
+ useCache?: boolean;
50
+ /** Defaults to LITESCRAPE_JOB_CACHE or ~/.cache/litescrape/jobs-ts.sqlite3. */
51
+ cachePath?: string;
52
+ /** Called once per result in completion order. Returned results stay in input order. */
53
+ onProgress?: (completed: number, total: number, result: Result) => void;
54
+ }
55
+
56
+ function checkArgs(options: ScrapeOptions): void {
57
+ for (const [name, value] of [
58
+ ["attempts", options.attempts ?? 5],
59
+ ["concurrency", options.concurrency ?? 1],
60
+ ] as const)
61
+ if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`);
62
+ const timeout = options.timeout ?? 120;
63
+ if (
64
+ typeof timeout !== "number" ||
65
+ !Number.isFinite(timeout) ||
66
+ timeout <= 0 ||
67
+ timeout * 1000 > 2 ** 31 - 1
68
+ )
69
+ throw new RangeError("timeout must be a finite positive number of seconds within the timer range");
70
+ if (options.requestTimeout !== undefined && !timeoutSeconds.safeParse(options.requestTimeout).success)
71
+ throw new RangeError("requestTimeout must be a finite number greater than 0 and at most 90");
72
+ if (options.useCache && !options.batched) throw new RangeError("useCache requires batched: true");
73
+ }
74
+
75
+ function runtimeOptions(options: ClientOptions): RuntimeOptions {
76
+ const apiKey = (options.apiKey || process.env.LITESCRAPE_API_KEY || "").trim();
77
+ if (!apiKey)
78
+ throw new AuthenticationError("Pass apiKey or set the LITESCRAPE_API_KEY environment variable.", {
79
+ statusCode: null,
80
+ errorCode: "missing_api_key",
81
+ });
82
+ const url = new URL(options.baseUrl || process.env.LITESCRAPE_API_URL || DEFAULT_BASE_URL);
83
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
84
+ throw new TypeError("baseUrl must be an HTTP(S) URL without credentials, a query, or a fragment");
85
+ return {
86
+ apiKey,
87
+ baseUrl: url.toString().replace(/\/+$/, ""),
88
+ attempts: options.attempts ?? 5,
89
+ timeout: options.timeout ?? 120,
90
+ fetch: options.fetch ?? globalThis.fetch,
91
+ signal: options.signal,
92
+ };
93
+ }
94
+
95
+ async function fetchStatus(options: RuntimeOptions): Promise<KeyStatus> {
96
+ const outcome = await requestWithRetries("/api/keys/status", options);
97
+ if (outcome.error) throw outcome.error;
98
+ const status = keyStatusSchema.safeParse({ ...outcome.data, request_id: outcome.requestId });
99
+ if (!status.success)
100
+ throw new TransportError(`Unexpected key status body: ${status.error.message}`, {
101
+ retryable: false,
102
+ cause: status.error,
103
+ });
104
+ return status.data;
105
+ }
106
+
107
+ /** Read the key's balance and limits. This endpoint is not billed. */
108
+ export async function keyStatus(options: ClientOptions = {}): Promise<KeyStatus> {
109
+ checkArgs(options);
110
+ options.signal?.throwIfAborted();
111
+ return fetchStatus(runtimeOptions(options));
112
+ }
113
+
114
+ /** Validate all inputs first, then return one Result per input, in input order. */
115
+ export async function scrape(
116
+ requests: readonly RequestItem[],
117
+ options: ScrapeOptions = {},
118
+ ): Promise<Result[]> {
119
+ checkArgs(options);
120
+ options.signal?.throwIfAborted();
121
+ if (!Array.isArray(requests)) throw new TypeError("requests must be an array");
122
+ const problems: Array<[number, string]> = [];
123
+ const items: AnyRequest[] = [];
124
+ for (const [index, input] of requests.entries()) {
125
+ try {
126
+ let item = parseRequest(input);
127
+ if (item.timeout == null && options.requestTimeout !== undefined)
128
+ item = item.withTimeout(options.requestTimeout);
129
+ items.push(item);
130
+ } catch (error) {
131
+ if (!(error instanceof ValidationError)) throw error;
132
+ problems.push([index, error.problems.map(([, message]) => message).join("; ")]);
133
+ }
134
+ }
135
+ if (problems.length) throw new ValidationError(problems);
136
+ if (!items.length) return [];
137
+
138
+ const context = runtimeOptions(options);
139
+ let completed = 0;
140
+ const onResult = (result: Result) => options.onProgress?.(++completed, items.length, result);
141
+ if (options.batched) {
142
+ const { runBatch } = await import("./batch.js");
143
+ return runBatch(items, {
144
+ ...context,
145
+ concurrency: options.concurrency,
146
+ useCache: options.useCache ?? false,
147
+ cachePath: options.cachePath,
148
+ onResult,
149
+ });
150
+ }
151
+
152
+ const status = await fetchStatus(context);
153
+ if (status.remaining_calls < items.length)
154
+ throw new PaymentRequiredError(
155
+ `This batch needs ${items.length} calls but the key has ${status.remaining_calls} remaining.`,
156
+ { statusCode: 402, errorCode: "payment_required", requestId: status.request_id },
157
+ );
158
+ const keyLimit = Math.max(1, status.concurrency_limit || 25);
159
+ const limit = Math.min(keyLimit, options.concurrency ?? keyLimit);
160
+ const shared = keySemaphore(context.apiKey, context.baseUrl, keyLimit);
161
+ const semaphores = [new Semaphore(limit), shared.semaphore];
162
+ let fatal: APIError | null = null;
163
+ const results: Result[] = new Array(items.length);
164
+ const started = performance.now();
165
+ try {
166
+ await each(
167
+ items.map((_, i) => i),
168
+ limit,
169
+ async (index) => {
170
+ const request = items[index]!;
171
+ const outcome = await requestWithRetries(request.path, {
172
+ ...context,
173
+ params: request.queryParams(),
174
+ semaphores,
175
+ gate: () => fatal,
176
+ onOutcome: (outcome) => {
177
+ const error = outcome.error;
178
+ if (
179
+ error instanceof APIError &&
180
+ ([401, 402].includes(error.statusCode ?? 0) || error.errorCode === "api_key_disabled")
181
+ )
182
+ fatal = error;
183
+ },
184
+ });
185
+ const result = new Result(index, request, outcome, (performance.now() - started) / 1000);
186
+ results[index] = result;
187
+ onResult(result);
188
+ },
189
+ );
190
+ return results;
191
+ } finally {
192
+ shared.release();
193
+ }
194
+ }
195
+
196
+ // JavaScript is asynchronous throughout; these aliases ease migration from Python.
197
+ export const ascrape = scrape;
198
+ export const key_status = keyStatus;
199
+ export const akey_status = keyStatus;
package/src/errors.ts ADDED
@@ -0,0 +1,120 @@
1
+ /** Base class for every error raised by the SDK. */
2
+ export class LitescrapeError extends Error {
3
+ constructor(message: string, options?: ErrorOptions) {
4
+ super(message, options);
5
+ this.name = new.target.name;
6
+ }
7
+ }
8
+
9
+ /** All invalid input indices are reported before any HTTP request is sent. */
10
+ export class ValidationError extends LitescrapeError {
11
+ constructor(public readonly problems: ReadonlyArray<readonly [number, string]>) {
12
+ super(
13
+ `${problems.length} invalid request item(s):\n${problems.map(([i, m]) => ` [${i}] ${m}`).join("\n")}`,
14
+ );
15
+ }
16
+ }
17
+
18
+ export class TransportError extends LitescrapeError {
19
+ readonly retryable: boolean;
20
+ constructor(message: string, options: { retryable: boolean; cause?: unknown }) {
21
+ super(message, { cause: options.cause });
22
+ this.retryable = options.retryable;
23
+ }
24
+ }
25
+
26
+ export interface APIErrorOptions {
27
+ statusCode: number | null;
28
+ errorCode: string;
29
+ requestId?: string;
30
+ retryable?: boolean;
31
+ body?: Record<string, unknown>;
32
+ retryAfter?: number | null;
33
+ }
34
+
35
+ export class APIError extends LitescrapeError {
36
+ readonly statusCode: number | null;
37
+ readonly errorCode: string;
38
+ readonly requestId: string;
39
+ readonly retryable: boolean;
40
+ readonly body: Record<string, unknown>;
41
+ /** Retry-After in seconds. */
42
+ readonly retryAfter: number | null;
43
+
44
+ constructor(message: string, options: APIErrorOptions) {
45
+ super(message);
46
+ this.statusCode = options.statusCode;
47
+ this.errorCode = options.errorCode;
48
+ this.requestId = options.requestId ?? "";
49
+ this.retryable = options.retryable ?? false;
50
+ this.body = options.body ?? {};
51
+ this.retryAfter = options.retryAfter ?? null;
52
+ }
53
+
54
+ get status_code() {
55
+ return this.statusCode;
56
+ }
57
+ get error_code() {
58
+ return this.errorCode;
59
+ }
60
+ get request_id() {
61
+ return this.requestId;
62
+ }
63
+ get retry_after() {
64
+ return this.retryAfter;
65
+ }
66
+
67
+ override toString(): string {
68
+ const details = [this.errorCode];
69
+ if (this.statusCode !== null) details.push(`HTTP ${this.statusCode}`);
70
+ if (this.requestId) details.push(`request ${this.requestId}`);
71
+ return `${this.message} (${details.join(", ")})`;
72
+ }
73
+ }
74
+
75
+ export class AuthenticationError extends APIError {}
76
+ export class PaymentRequiredError extends APIError {}
77
+ export class NotFoundError extends APIError {}
78
+ export class RateLimitError extends APIError {}
79
+ export class RequestDeadlineExceededError extends APIError {}
80
+
81
+ export function isObject(value: unknown): value is Record<string, unknown> {
82
+ return typeof value === "object" && value !== null && !Array.isArray(value);
83
+ }
84
+
85
+ export function parseRetryAfter(value: string | null): number | null {
86
+ if (!value?.trim()) return null;
87
+ if (/^\d+$/.test(value.trim())) {
88
+ const seconds = Number(value);
89
+ return Number.isFinite(seconds) ? seconds : null;
90
+ }
91
+ const when = Date.parse(value);
92
+ return Number.isFinite(when) ? Math.max(0, (when - Date.now()) / 1000) : null;
93
+ }
94
+
95
+ export function apiError(status: number, data: unknown, headers = new Headers()): APIError {
96
+ const body =
97
+ isObject(data) && "error_code" in data
98
+ ? data
99
+ : {
100
+ error: isObject(data) && data.detail ? String(data.detail) : `HTTP ${status}`,
101
+ error_code: status === 404 ? "not_found" : "request_failed",
102
+ status_code: status,
103
+ request_id: headers.get("x-request-id") ?? "",
104
+ retryable: status === 429 || status >= 500,
105
+ };
106
+ const ErrorClass =
107
+ status === 503 && body.error_code === "request_deadline_exceeded"
108
+ ? RequestDeadlineExceededError
109
+ : ({ 401: AuthenticationError, 402: PaymentRequiredError, 404: NotFoundError, 429: RateLimitError }[
110
+ status
111
+ ] ?? APIError);
112
+ return new ErrorClass(String(body.error || `HTTP ${status}`), {
113
+ statusCode: status,
114
+ errorCode: String(body.error_code),
115
+ requestId: String(body.request_id || headers.get("x-request-id") || ""),
116
+ retryable: body.retryable === true,
117
+ body,
118
+ retryAfter: parseRetryAfter(headers.get("retry-after")),
119
+ });
120
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { scrape, ascrape, keyStatus, key_status, akey_status } from "./client.js";
2
+ export type { ClientOptions, ScrapeOptions } from "./client.js";
3
+ export { Result } from "./result.js";
4
+ export * from "./models.js";
5
+ export {
6
+ LitescrapeError,
7
+ ValidationError,
8
+ TransportError,
9
+ APIError,
10
+ AuthenticationError,
11
+ PaymentRequiredError,
12
+ NotFoundError,
13
+ RateLimitError,
14
+ RequestDeadlineExceededError,
15
+ } from "./errors.js";
16
+ export type { APIErrorOptions } from "./errors.js";
17
+ export { VERSION, VERSION as __version__ } from "./version.js";