@crawlora-org/sdk 1.4.0-sdk.1 → 1.5.0-sdk.3
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/CHANGELOG.md +9 -0
- package/docs/recipes.md +26 -0
- package/examples/paginate.mjs +22 -0
- package/package.json +1 -1
- package/src/client.js +101 -8
- package/src/index.d.ts +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.5.0-sdk.1
|
|
4
|
+
|
|
5
|
+
- Added `beforeRequest`/`afterResponse` middleware hooks (mutate the outgoing
|
|
6
|
+
request context, transform the parsed response).
|
|
7
|
+
- Added opt-in `idempotencyKeys` (stable `Idempotency-Key` on POST/PATCH, reused
|
|
8
|
+
across retries) and per-request `retries`/`isRetryable` overrides.
|
|
9
|
+
- Added client-side `rateLimit` (requests/sec) and `maxConcurrency` throttling.
|
|
10
|
+
- Errors now carry `requestId`.
|
|
11
|
+
|
|
3
12
|
## v1.4.0-sdk.1
|
|
4
13
|
|
|
5
14
|
- Configurable retries: `maxRetryDelay`, `retryStatuses`, and an `isRetryable`
|
package/docs/recipes.md
CHANGED
|
@@ -119,6 +119,32 @@ for await (const chunk of res.body) { /* ... */ }
|
|
|
119
119
|
`CRAWLORA_API_KEY` and `CRAWLORA_BASE_URL` are used when not set explicitly
|
|
120
120
|
(precedence: option > env > default).
|
|
121
121
|
|
|
122
|
+
## Middleware
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
const crawlora = new CrawloraClient({
|
|
126
|
+
beforeRequest: (ctx) => { ctx.headers["x-signature"] = sign(ctx); },
|
|
127
|
+
afterResponse: (operationId, status, headers, body) => body // return a value to transform
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Idempotency And Per-Request Retries
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
const crawlora = new CrawloraClient({ idempotencyKeys: true }); // stable key on POST/PATCH retries
|
|
135
|
+
|
|
136
|
+
await crawlora.request("bing-search", { q: "coffee" }, {
|
|
137
|
+
retries: 5,
|
|
138
|
+
isRetryable: (status) => status >= 500
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Rate Limiting
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
const crawlora = new CrawloraClient({ rateLimit: 10, maxConcurrency: 4 });
|
|
146
|
+
```
|
|
147
|
+
|
|
122
148
|
## Optional Live Smoke Tests
|
|
123
149
|
|
|
124
150
|
```sh
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { CrawloraClient } from "../src/index.js";
|
|
2
|
+
|
|
3
|
+
const client = newClient();
|
|
4
|
+
if (client) {
|
|
5
|
+
const seller = process.env.CRAWLORA_EBAY_SELLER || "garlandcomputer";
|
|
6
|
+
let count = 0;
|
|
7
|
+
for await (const item of client.paginateItems("ebay-seller-feedback", { seller }, { maxPages: 3 })) {
|
|
8
|
+
count += 1;
|
|
9
|
+
}
|
|
10
|
+
console.log(`collected ${count} feedback items across up to 3 pages`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function newClient() {
|
|
14
|
+
if (!process.env.CRAWLORA_API_KEY) {
|
|
15
|
+
console.error("set CRAWLORA_API_KEY to run this live example");
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return new CrawloraClient({
|
|
19
|
+
apiKey: process.env.CRAWLORA_API_KEY,
|
|
20
|
+
baseUrl: process.env.CRAWLORA_BASE_URL
|
|
21
|
+
});
|
|
22
|
+
}
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { operations, groups } from "./operations.js";
|
|
2
2
|
|
|
3
3
|
const DEFAULT_BASE_URL = "https://api.crawlora.net/api/v1";
|
|
4
|
-
export const VERSION = "1.
|
|
4
|
+
export const VERSION = "1.5.0-sdk.3";
|
|
5
5
|
const DEFAULT_USER_AGENT = `crawlora-js-sdk/${VERSION}`;
|
|
6
6
|
|
|
7
7
|
export class CrawloraError extends Error {
|
|
@@ -48,6 +48,16 @@ function apiErrorClass(status) {
|
|
|
48
48
|
return status >= 500 ? CrawloraServerError : CrawloraClientError;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Client for the Crawlora API.
|
|
53
|
+
*
|
|
54
|
+
* Call operations via grouped helpers (`client.bing.search({ q })`) or
|
|
55
|
+
* dynamically (`client.request("bing-search", { q })`). Supports configurable
|
|
56
|
+
* retries, an `onRetry` hook, opt-in `requestId` and `idempotencyKeys`,
|
|
57
|
+
* `beforeRequest`/`afterResponse` middleware, client-side `rateLimit` /
|
|
58
|
+
* `maxConcurrency`, pagination (`paginate`/`paginateItems`), and
|
|
59
|
+
* `responseType: "stream"`.
|
|
60
|
+
*/
|
|
51
61
|
export class CrawloraClient {
|
|
52
62
|
constructor(options = {}) {
|
|
53
63
|
// Precedence: explicit option > environment variable > default.
|
|
@@ -62,7 +72,13 @@ export class CrawloraClient {
|
|
|
62
72
|
this.isRetryable = typeof options.isRetryable === "function" ? options.isRetryable : null;
|
|
63
73
|
this.onRetry = typeof options.onRetry === "function" ? options.onRetry : null;
|
|
64
74
|
this.requestId = options.requestId === true;
|
|
75
|
+
this.idempotencyKeys = options.idempotencyKeys === true;
|
|
65
76
|
this.logger = typeof options.logger === "function" ? options.logger : null;
|
|
77
|
+
this.beforeRequest = asHookList(options.beforeRequest);
|
|
78
|
+
this.afterResponse = asHookList(options.afterResponse);
|
|
79
|
+
this.limiter = (options.rateLimit > 0 || options.maxConcurrency > 0)
|
|
80
|
+
? new RateLimiter(options.rateLimit || 0, options.maxConcurrency || 0)
|
|
81
|
+
: null;
|
|
66
82
|
this.headers = { ...(options.headers || {}) };
|
|
67
83
|
this.userAgent = options.userAgent === false ? "" : (options.userAgent || DEFAULT_USER_AGENT);
|
|
68
84
|
this.fetch = options.fetch || globalThis.fetch;
|
|
@@ -93,13 +109,19 @@ export class CrawloraClient {
|
|
|
93
109
|
options = options ?? {};
|
|
94
110
|
const responseType = validateResponseType(options.responseType || "auto");
|
|
95
111
|
this.#log({ event: "request", operation: operationId });
|
|
112
|
+
const maxRetries = options.retries ?? this.retries;
|
|
113
|
+
const isRetryable = typeof options.isRetryable === "function" ? options.isRetryable : null;
|
|
114
|
+
const idempotencyKey = (this.idempotencyKeys && (operation.method === "POST" || operation.method === "PATCH"))
|
|
115
|
+
? generateId() : undefined;
|
|
96
116
|
|
|
97
117
|
let attempt = 0;
|
|
98
118
|
for (;;) {
|
|
99
119
|
try {
|
|
100
|
-
|
|
120
|
+
const run = () => this.#send(operation, params, options, responseType, idempotencyKey);
|
|
121
|
+
return await (this.limiter ? this.limiter.run(run, options.signal) : run());
|
|
101
122
|
} catch (error) {
|
|
102
|
-
|
|
123
|
+
const retryable = isRetryable ? !!isRetryable(error.status, error) : this.#isRetryable(error.status, error);
|
|
124
|
+
if (!(error instanceof CrawloraError) || error.retryable === false || attempt >= maxRetries || !retryable) {
|
|
103
125
|
throw error;
|
|
104
126
|
}
|
|
105
127
|
attempt++;
|
|
@@ -126,7 +148,7 @@ export class CrawloraClient {
|
|
|
126
148
|
if (this.logger) this.logger(event);
|
|
127
149
|
}
|
|
128
150
|
|
|
129
|
-
async #send(operation, params, options, responseType) {
|
|
151
|
+
async #send(operation, params, options, responseType, idempotencyKey) {
|
|
130
152
|
const { url, body, bodyHeaders } = buildRequest(operation, this.baseUrl, params);
|
|
131
153
|
const headers = mergeHeaders(
|
|
132
154
|
this.headers,
|
|
@@ -136,6 +158,18 @@ export class CrawloraClient {
|
|
|
136
158
|
options.headers
|
|
137
159
|
);
|
|
138
160
|
const requestId = this.requestId ? ensureRequestId(headers) : (headerValue(headers, "x-request-id") || undefined);
|
|
161
|
+
if (idempotencyKey && !headerValue(headers, "idempotency-key")) {
|
|
162
|
+
headers["Idempotency-Key"] = idempotencyKey;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let requestUrl = url;
|
|
166
|
+
let requestHeaders = headers;
|
|
167
|
+
if (this.beforeRequest.length) {
|
|
168
|
+
const ctx = { operationId: operation.id, method: operation.method, url: requestUrl, headers: requestHeaders };
|
|
169
|
+
for (const hook of this.beforeRequest) await hook(ctx);
|
|
170
|
+
requestUrl = ctx.url;
|
|
171
|
+
requestHeaders = ctx.headers;
|
|
172
|
+
}
|
|
139
173
|
|
|
140
174
|
const controller = new AbortController();
|
|
141
175
|
const timeoutMs = options.timeout ?? this.timeout;
|
|
@@ -147,9 +181,9 @@ export class CrawloraClient {
|
|
|
147
181
|
const signal = composeSignal(controller, options.signal);
|
|
148
182
|
let response;
|
|
149
183
|
try {
|
|
150
|
-
response = await this.fetch(
|
|
184
|
+
response = await this.fetch(requestUrl, {
|
|
151
185
|
method: operation.method,
|
|
152
|
-
headers,
|
|
186
|
+
headers: requestHeaders,
|
|
153
187
|
body,
|
|
154
188
|
signal
|
|
155
189
|
});
|
|
@@ -170,7 +204,7 @@ export class CrawloraClient {
|
|
|
170
204
|
if (responseType === "stream" && response.ok) {
|
|
171
205
|
return response;
|
|
172
206
|
}
|
|
173
|
-
|
|
207
|
+
let parsed = await parseResponse(response, responseType === "stream" ? "auto" : responseType, headersObject, requestId);
|
|
174
208
|
if (!response.ok) {
|
|
175
209
|
const code = parsed && typeof parsed === "object" ? parsed.code : undefined;
|
|
176
210
|
const message = parsed && typeof parsed === "object" && parsed.msg ? parsed.msg : response.statusText;
|
|
@@ -184,6 +218,12 @@ export class CrawloraClient {
|
|
|
184
218
|
requestId
|
|
185
219
|
});
|
|
186
220
|
}
|
|
221
|
+
if (this.afterResponse.length) {
|
|
222
|
+
for (const hook of this.afterResponse) {
|
|
223
|
+
const result = await hook(operation.id, response.status, headersObject, parsed);
|
|
224
|
+
if (result !== undefined) parsed = result;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
187
227
|
return parsed;
|
|
188
228
|
}
|
|
189
229
|
|
|
@@ -438,14 +478,67 @@ function parseRetryAfter(headers, cap = 30000) {
|
|
|
438
478
|
return undefined;
|
|
439
479
|
}
|
|
440
480
|
|
|
481
|
+
function asHookList(value) {
|
|
482
|
+
if (!value) return [];
|
|
483
|
+
return typeof value === "function" ? [value] : Array.from(value);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Optional client-side throttle: caps concurrency and spaces requests to a
|
|
487
|
+
// maximum rate (requests per second).
|
|
488
|
+
class RateLimiter {
|
|
489
|
+
constructor(rps, concurrency) {
|
|
490
|
+
this.interval = rps > 0 ? 1000 / rps : 0;
|
|
491
|
+
this.limit = concurrency > 0 ? concurrency : Infinity;
|
|
492
|
+
this.active = 0;
|
|
493
|
+
this.waiters = [];
|
|
494
|
+
this.nextAllowed = 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async run(fn, signal) {
|
|
498
|
+
await this.#acquire();
|
|
499
|
+
try {
|
|
500
|
+
await this.#rateWait(signal);
|
|
501
|
+
return await fn();
|
|
502
|
+
} finally {
|
|
503
|
+
const next = this.waiters.shift();
|
|
504
|
+
if (next) {
|
|
505
|
+
next(); // transfer the slot to the next waiter (active unchanged)
|
|
506
|
+
} else {
|
|
507
|
+
this.active--;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#acquire() {
|
|
513
|
+
if (this.active < this.limit) {
|
|
514
|
+
this.active++;
|
|
515
|
+
return Promise.resolve();
|
|
516
|
+
}
|
|
517
|
+
// Wait for a slot to be transferred to us (active stays at the limit).
|
|
518
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async #rateWait(signal) {
|
|
522
|
+
if (!this.interval) return;
|
|
523
|
+
const now = Date.now();
|
|
524
|
+
const wait = Math.max(0, this.nextAllowed - now);
|
|
525
|
+
this.nextAllowed = Math.max(now, this.nextAllowed) + this.interval;
|
|
526
|
+
if (wait > 0) await sleep(wait, signal);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
441
530
|
function envVar(name) {
|
|
442
531
|
return (typeof process !== "undefined" && process.env && process.env[name]) || undefined;
|
|
443
532
|
}
|
|
444
533
|
|
|
534
|
+
function generateId() {
|
|
535
|
+
return (typeof crypto !== "undefined" && crypto.randomUUID) ? crypto.randomUUID() : `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
536
|
+
}
|
|
537
|
+
|
|
445
538
|
function ensureRequestId(headers) {
|
|
446
539
|
const existing = headerValue(headers, "x-request-id");
|
|
447
540
|
if (existing) return existing;
|
|
448
|
-
const id = (
|
|
541
|
+
const id = generateId();
|
|
449
542
|
headers["x-request-id"] = id;
|
|
450
543
|
return id;
|
|
451
544
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -10,6 +10,20 @@ export type CrawloraParams = Record<string, unknown>;
|
|
|
10
10
|
|
|
11
11
|
export type CrawloraLogEvent = { event: string; [key: string]: unknown };
|
|
12
12
|
|
|
13
|
+
export interface CrawloraRequestContext {
|
|
14
|
+
operationId: string;
|
|
15
|
+
method: string;
|
|
16
|
+
url: string;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
export type CrawloraBeforeRequest = (ctx: CrawloraRequestContext) => void | Promise<void>;
|
|
20
|
+
export type CrawloraAfterResponse = (
|
|
21
|
+
operationId: string,
|
|
22
|
+
status: number,
|
|
23
|
+
headers: Record<string, string>,
|
|
24
|
+
body: unknown
|
|
25
|
+
) => unknown;
|
|
26
|
+
|
|
13
27
|
export interface CrawloraClientOptions {
|
|
14
28
|
apiKey?: string;
|
|
15
29
|
jwtToken?: string;
|
|
@@ -27,8 +41,18 @@ export interface CrawloraClientOptions {
|
|
|
27
41
|
onRetry?: (attempt: number, error: CrawloraError, delay: number) => void;
|
|
28
42
|
/** Generate an x-request-id header when absent. */
|
|
29
43
|
requestId?: boolean;
|
|
44
|
+
/** Attach a stable Idempotency-Key on POST/PATCH, reused across retries. */
|
|
45
|
+
idempotencyKeys?: boolean;
|
|
46
|
+
/** Cap outgoing requests to at most this many per second. */
|
|
47
|
+
rateLimit?: number;
|
|
48
|
+
/** Cap the number of in-flight requests. */
|
|
49
|
+
maxConcurrency?: number;
|
|
30
50
|
/** Structured event sink (request/retry); never logs on its own. */
|
|
31
51
|
logger?: (event: CrawloraLogEvent) => void;
|
|
52
|
+
/** Hook(s) run before each request; mutate ctx.headers / ctx.url. */
|
|
53
|
+
beforeRequest?: CrawloraBeforeRequest | Iterable<CrawloraBeforeRequest>;
|
|
54
|
+
/** Hook(s) run on the parsed success body; return a value to replace it. */
|
|
55
|
+
afterResponse?: CrawloraAfterResponse | Iterable<CrawloraAfterResponse>;
|
|
32
56
|
headers?: Record<string, string>;
|
|
33
57
|
userAgent?: string | false;
|
|
34
58
|
fetch?: typeof globalThis.fetch;
|
|
@@ -39,6 +63,10 @@ export interface CrawloraRequestOptions {
|
|
|
39
63
|
responseType?: "auto" | "json" | "text" | "stream";
|
|
40
64
|
timeout?: number;
|
|
41
65
|
signal?: AbortSignal;
|
|
66
|
+
/** Per-request retry count, overriding the client default. */
|
|
67
|
+
retries?: number;
|
|
68
|
+
/** Per-request retry predicate, overriding the client default. */
|
|
69
|
+
isRetryable?: (status: number, error: CrawloraError) => boolean;
|
|
42
70
|
}
|
|
43
71
|
|
|
44
72
|
export type OperationMethod = <T = unknown>(
|