scrapely 1.2.35 → 1.2.37
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 +11 -0
- package/dist/actor.d.ts +17 -0
- package/dist/actor.js +57 -5
- package/dist/storage/api-storage-client.d.ts +7 -6
- package/dist/storage/api-storage-client.js +12 -8
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [1.2.37] - 2026-10-26
|
|
8
|
+
|
|
9
|
+
- Opening a named dataset, key-value store or request queue (`Actor.openDataset('name')` and the like) retries when the API answers `503 storage-name-busy`. The API sends it when another request kept creating or renaming a storage of that name for over 15 s; nothing was changed, so the open is simply repeated. It used to fail the open.
|
|
10
|
+
- Once `Actor.fail()` has run, the run stays failed: a later `Actor.exit()`, or the end of `Actor.main()`, keeps the failure's exit code and status message. `fail()` normally ends the process at once, so this only changed runs that called `fail({ exit: false })` and then `exit()` or returned from `Actor.main()`; they used to exit with 0. A second `fail()` keeps the first failure. `fail({ exitCode: undefined })` exits with 1.
|
|
11
|
+
|
|
12
|
+
## [1.2.36] - 2026-10-22
|
|
13
|
+
|
|
14
|
+
- Debug captures (`Actor.debugCapture`, `Actor.debugCaptureSync`) keep payloads over 256 KiB whole. They are stored gzip-compressed and base64-encoded as `{ key, payload, encoding: 'gzip-base64', length, truncated }` instead of being cut at 262,144 characters; up to 8 Mi characters are kept. A payload that does not compress enough keeps its first 256 KiB, now marked `truncated: true` with its full `length`. `Uint8Array` / `Buffer` payloads are stored as UTF-8 text. Captures are only active on the platform's own diagnostic runs (`SCRAPELY_DEBUG_CAPTURE=1`).
|
|
15
|
+
|
|
5
16
|
## [1.2.35] - 2026-10-20
|
|
6
17
|
|
|
7
18
|
- The storage client percent-encodes request ids and key-value store record keys as one URL path segment and refuses `''`, `.` and `..`. A request id such as `..` read from a queue used to send the call to another resource.
|
package/dist/actor.d.ts
CHANGED
|
@@ -332,6 +332,12 @@ export declare class Actor {
|
|
|
332
332
|
* succeeds.
|
|
333
333
|
*/
|
|
334
334
|
private _lifecycleStarted;
|
|
335
|
+
/**
|
|
336
|
+
* Set by {@link Actor.fail} for the rest of the run: a later {@link Actor.exit}
|
|
337
|
+
* keeps the failure's exit code and status message, so a failed run never
|
|
338
|
+
* ends as SUCCEEDED (e.g. `fail({ exit: false })` followed by `exit()`).
|
|
339
|
+
*/
|
|
340
|
+
private static _failure;
|
|
335
341
|
/** Created on first use: scrapely-client is only loaded when something needs the API client. */
|
|
336
342
|
private _apifyClient;
|
|
337
343
|
private readonly chargingManager;
|
|
@@ -388,6 +394,7 @@ export declare class Actor {
|
|
|
388
394
|
* @param options - Exit options
|
|
389
395
|
*/
|
|
390
396
|
static exit(messageOrOptions?: string | ExitOptions, options?: ExitOptions): Promise<void>;
|
|
397
|
+
private static _exitWith;
|
|
391
398
|
static fail(messageOrOptions?: string | ExitOptions, options?: ExitOptions): Promise<void>;
|
|
392
399
|
static getInput<T = ActorInput>(): Promise<T | null>;
|
|
393
400
|
static getInputOrThrow<T = ActorInput>(): Promise<T>;
|
|
@@ -459,7 +466,15 @@ export declare class Actor {
|
|
|
459
466
|
*/
|
|
460
467
|
static setValue<T>(key: string, value: T | null, options?: RecordOptions): Promise<void>;
|
|
461
468
|
private static _debugCaptureCount;
|
|
469
|
+
/**
|
|
470
|
+
* A payload up to DEBUG_CAPTURE_MAX_BYTES characters is stored as it is; a larger one gzip-compressed
|
|
471
|
+
* (base64, at most DEBUG_CAPTURE_MAX_BYTES characters again), keeping up to DEBUG_CAPTURE_MAX_CHARS
|
|
472
|
+
* characters / DEBUG_CAPTURE_MAX_ENCODED bytes of it. A plain cut at 256 KiB lost the part a check
|
|
473
|
+
* needed when a large API document starts with site navigation.
|
|
474
|
+
*/
|
|
462
475
|
static readonly DEBUG_CAPTURE_MAX_BYTES: number;
|
|
476
|
+
static readonly DEBUG_CAPTURE_MAX_CHARS: number;
|
|
477
|
+
static readonly DEBUG_CAPTURE_MAX_ENCODED: number;
|
|
463
478
|
static readonly DEBUG_CAPTURE_MAX_RECORDS = 500;
|
|
464
479
|
/** The key-value store record name for a capture key (item URL or id). */
|
|
465
480
|
static debugCaptureRecordKey(key: string): Promise<string>;
|
|
@@ -471,6 +486,8 @@ export declare class Actor {
|
|
|
471
486
|
/** debugCapture for synchronous code: fire and forget. Does nothing unless capture is enabled for the run. */
|
|
472
487
|
static debugCaptureSync(key: string, payload: unknown): void;
|
|
473
488
|
static debugCapture(key: string, payload: unknown): Promise<void>;
|
|
489
|
+
/** The record for one capture: plain when small, else gzip-base64 (plain head as the last resort). */
|
|
490
|
+
static debugCaptureRecord(key: string, text: string): Promise<Record<string, unknown>>;
|
|
474
491
|
/**
|
|
475
492
|
* Helper to resolve storage identifier to a string ID or name.
|
|
476
493
|
* Supports: string, { id: '...' }, { name: '...' }, { alias: '...' }
|
package/dist/actor.js
CHANGED
|
@@ -112,6 +112,12 @@ class Actor {
|
|
|
112
112
|
* succeeds.
|
|
113
113
|
*/
|
|
114
114
|
_lifecycleStarted = false;
|
|
115
|
+
/**
|
|
116
|
+
* Set by {@link Actor.fail} for the rest of the run: a later {@link Actor.exit}
|
|
117
|
+
* keeps the failure's exit code and status message, so a failed run never
|
|
118
|
+
* ends as SUCCEEDED (e.g. `fail({ exit: false })` followed by `exit()`).
|
|
119
|
+
*/
|
|
120
|
+
static _failure;
|
|
115
121
|
/** Created on first use: scrapely-client is only loaded when something needs the API client. */
|
|
116
122
|
_apifyClient;
|
|
117
123
|
chargingManager;
|
|
@@ -189,6 +195,7 @@ class Actor {
|
|
|
189
195
|
if (this.initialized)
|
|
190
196
|
return;
|
|
191
197
|
cachedInput = undefined; // a fresh init reads this run's input again
|
|
198
|
+
Actor._failure = undefined; // a new lifecycle starts unfailed
|
|
192
199
|
// NOTE: `initialized` is set at the END of init — a failed platform init
|
|
193
200
|
// must stay retryable instead of leaving a half-initialized Actor.
|
|
194
201
|
Actor.installFatalChargeHandlers();
|
|
@@ -375,7 +382,8 @@ class Actor {
|
|
|
375
382
|
const result = await userFunc();
|
|
376
383
|
await Actor._shutdownActor(options?.timeoutSecs ?? 30);
|
|
377
384
|
await new Promise((resolve) => process.stdout.write('', () => resolve()));
|
|
378
|
-
|
|
385
|
+
// A run that called fail({ exit: false }) stays failed.
|
|
386
|
+
process.exit(Actor._failure?.exitCode ?? options?.exitCode ?? 0);
|
|
379
387
|
return result;
|
|
380
388
|
}
|
|
381
389
|
catch (err) {
|
|
@@ -403,10 +411,22 @@ class Actor {
|
|
|
403
411
|
const opts = typeof messageOrOptions === 'string'
|
|
404
412
|
? { ...options, statusMessage: messageOrOptions }
|
|
405
413
|
: { ...messageOrOptions, ...options };
|
|
414
|
+
Actor._ensureActorInit('exit');
|
|
415
|
+
const failure = Actor._failure;
|
|
416
|
+
if (failure) {
|
|
417
|
+
// Actor.fail() already ran: the run stays failed. Keep its exit code and
|
|
418
|
+
// status message; this exit() only finishes the shutdown.
|
|
419
|
+
if (opts.statusMessage)
|
|
420
|
+
console.log(`[Scrapely] ${opts.statusMessage}`);
|
|
421
|
+
console.log(`[Scrapely] Actor.exit() after Actor.fail(): the run stays failed (exit code ${failure.exitCode}).`);
|
|
422
|
+
return Actor._exitWith({ ...opts, exitCode: failure.exitCode, statusMessage: undefined });
|
|
423
|
+
}
|
|
424
|
+
return Actor._exitWith(opts);
|
|
425
|
+
}
|
|
426
|
+
static async _exitWith(opts) {
|
|
406
427
|
const code = opts.exitCode ?? 0;
|
|
407
428
|
const timeoutSecs = opts.timeoutSecs ?? 30;
|
|
408
429
|
const shouldExit = opts.exit ?? true;
|
|
409
|
-
Actor._ensureActorInit('exit');
|
|
410
430
|
// The exit/fail message becomes the run's terminal status
|
|
411
431
|
// message. Stored before teardown, while the run and process are alive.
|
|
412
432
|
if (opts.statusMessage)
|
|
@@ -423,7 +443,13 @@ class Actor {
|
|
|
423
443
|
: { exitCode: 1, ...messageOrOptions, ...options };
|
|
424
444
|
console.error(`[Scrapely] Actor failed: ${opts.statusMessage ?? 'Unknown error'}`);
|
|
425
445
|
await new Promise((resolve) => process.stderr.write('', () => resolve()));
|
|
426
|
-
|
|
446
|
+
Actor._ensureActorInit('fail');
|
|
447
|
+
if (Actor._failure) {
|
|
448
|
+
// Failed already: the first failure's code and message stay.
|
|
449
|
+
return Actor._exitWith({ ...opts, exitCode: Actor._failure.exitCode, statusMessage: undefined });
|
|
450
|
+
}
|
|
451
|
+
Actor._failure = { exitCode: opts.exitCode ?? 1, statusMessage: opts.statusMessage };
|
|
452
|
+
await Actor._exitWith({ ...opts, exitCode: opts.exitCode ?? 1 });
|
|
427
453
|
}
|
|
428
454
|
static async getInput() {
|
|
429
455
|
Actor._ensureActorInit('getInput');
|
|
@@ -660,7 +686,15 @@ class Actor {
|
|
|
660
686
|
await store.setValue(key, value, options);
|
|
661
687
|
}
|
|
662
688
|
static _debugCaptureCount = 0;
|
|
689
|
+
/**
|
|
690
|
+
* A payload up to DEBUG_CAPTURE_MAX_BYTES characters is stored as it is; a larger one gzip-compressed
|
|
691
|
+
* (base64, at most DEBUG_CAPTURE_MAX_BYTES characters again), keeping up to DEBUG_CAPTURE_MAX_CHARS
|
|
692
|
+
* characters / DEBUG_CAPTURE_MAX_ENCODED bytes of it. A plain cut at 256 KiB lost the part a check
|
|
693
|
+
* needed when a large API document starts with site navigation.
|
|
694
|
+
*/
|
|
663
695
|
static DEBUG_CAPTURE_MAX_BYTES = 256 * 1024;
|
|
696
|
+
static DEBUG_CAPTURE_MAX_CHARS = 8 * 1024 * 1024;
|
|
697
|
+
static DEBUG_CAPTURE_MAX_ENCODED = 8 * 1024 * 1024;
|
|
664
698
|
static DEBUG_CAPTURE_MAX_RECORDS = 500;
|
|
665
699
|
/** The key-value store record name for a capture key (item URL or id). */
|
|
666
700
|
static async debugCaptureRecordKey(key) {
|
|
@@ -684,14 +718,32 @@ class Actor {
|
|
|
684
718
|
if (Actor._debugCaptureCount >= Actor.DEBUG_CAPTURE_MAX_RECORDS)
|
|
685
719
|
return;
|
|
686
720
|
try {
|
|
687
|
-
const text =
|
|
721
|
+
const text = typeof payload === 'string' ? payload
|
|
722
|
+
: payload instanceof Uint8Array ? Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength).toString('utf8')
|
|
723
|
+
: JSON.stringify(payload) ?? '';
|
|
724
|
+
const record = await Actor.debugCaptureRecord(String(key), text);
|
|
688
725
|
Actor._debugCaptureCount++;
|
|
689
|
-
await Actor.setValue(await Actor.debugCaptureRecordKey(key),
|
|
726
|
+
await Actor.setValue(await Actor.debugCaptureRecordKey(key), record);
|
|
690
727
|
}
|
|
691
728
|
catch {
|
|
692
729
|
// never break the run
|
|
693
730
|
}
|
|
694
731
|
}
|
|
732
|
+
/** The record for one capture: plain when small, else gzip-base64 (plain head as the last resort). */
|
|
733
|
+
static async debugCaptureRecord(key, text) {
|
|
734
|
+
const limit = Actor.DEBUG_CAPTURE_MAX_BYTES;
|
|
735
|
+
if (text.length <= limit)
|
|
736
|
+
return { key, payload: text };
|
|
737
|
+
const { gzipSync } = await import('node:zlib');
|
|
738
|
+
const raw = Buffer.from(text.slice(0, Actor.DEBUG_CAPTURE_MAX_CHARS), 'utf8');
|
|
739
|
+
const kept = raw.subarray(0, Actor.DEBUG_CAPTURE_MAX_ENCODED);
|
|
740
|
+
const packed = gzipSync(kept, { level: 6 }).toString('base64');
|
|
741
|
+
if (packed.length <= limit) {
|
|
742
|
+
const truncated = text.length > Actor.DEBUG_CAPTURE_MAX_CHARS || kept.length < raw.length;
|
|
743
|
+
return { key, payload: packed, encoding: 'gzip-base64', length: text.length, truncated };
|
|
744
|
+
}
|
|
745
|
+
return { key, payload: text.slice(0, limit), length: text.length, truncated: true };
|
|
746
|
+
}
|
|
695
747
|
/**
|
|
696
748
|
* Helper to resolve storage identifier to a string ID or name.
|
|
697
749
|
* Supports: string, { id: '...' }, { name: '...' }, { alias: '...' }
|
|
@@ -15,12 +15,13 @@ export declare class ApiStorageClientError extends Error {
|
|
|
15
15
|
/**
|
|
16
16
|
* Whether a non-2xx answer is retried. 429 always: the run API limiter and
|
|
17
17
|
* dataset backpressure refuse it before anything is written. Idempotent
|
|
18
|
-
* methods: also 500/502/503/504. Others (dataset push, KV PUT
|
|
19
|
-
* that certainly was not processed: the rate limiter
|
|
20
|
-
* ("rate-limit-unavailable", before the handler),
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* methods: also 500/502/503/504. Others (dataset push, KV PUT, named storage
|
|
19
|
+
* create) only on a 503 that certainly was not processed: the rate limiter
|
|
20
|
+
* refused it ("rate-limit-unavailable", before the handler), another request
|
|
21
|
+
* held the storage name ("storage-name-busy", nothing changed), or HAProxy had
|
|
22
|
+
* no backend (its own page, nothing forwarded). Any other 503 may mean
|
|
23
|
+
* "outcome unknown" (e.g. a direct dataset write whose commit was not
|
|
24
|
+
* confirmed), and re-sending could store the items twice.
|
|
24
25
|
*/
|
|
25
26
|
export declare function isRetryableResponse(status: number, idempotent: boolean, errorType?: string, bodyText?: string): boolean;
|
|
26
27
|
/** Default time budget for retrying one API call (429, 503, network errors). */
|
|
@@ -147,12 +147,13 @@ const CONNECT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN',
|
|
|
147
147
|
/**
|
|
148
148
|
* Whether a non-2xx answer is retried. 429 always: the run API limiter and
|
|
149
149
|
* dataset backpressure refuse it before anything is written. Idempotent
|
|
150
|
-
* methods: also 500/502/503/504. Others (dataset push, KV PUT
|
|
151
|
-
* that certainly was not processed: the rate limiter
|
|
152
|
-
* ("rate-limit-unavailable", before the handler),
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
150
|
+
* methods: also 500/502/503/504. Others (dataset push, KV PUT, named storage
|
|
151
|
+
* create) only on a 503 that certainly was not processed: the rate limiter
|
|
152
|
+
* refused it ("rate-limit-unavailable", before the handler), another request
|
|
153
|
+
* held the storage name ("storage-name-busy", nothing changed), or HAProxy had
|
|
154
|
+
* no backend (its own page, nothing forwarded). Any other 503 may mean
|
|
155
|
+
* "outcome unknown" (e.g. a direct dataset write whose commit was not
|
|
156
|
+
* confirmed), and re-sending could store the items twice.
|
|
156
157
|
*/
|
|
157
158
|
function isRetryableResponse(status, idempotent, errorType, bodyText) {
|
|
158
159
|
if (status === 429)
|
|
@@ -161,7 +162,9 @@ function isRetryableResponse(status, idempotent, errorType, bodyText) {
|
|
|
161
162
|
return status === 500 || status === 502 || status === 503 || status === 504;
|
|
162
163
|
if (status !== 503)
|
|
163
164
|
return false;
|
|
164
|
-
return errorType === 'rate-limit-unavailable'
|
|
165
|
+
return errorType === 'rate-limit-unavailable'
|
|
166
|
+
|| errorType === 'storage-name-busy'
|
|
167
|
+
|| (bodyText ?? '').includes(NO_BACKEND_MARKER);
|
|
165
168
|
}
|
|
166
169
|
/** Default time budget for retrying one API call (429, 503, network errors). */
|
|
167
170
|
exports.DEFAULT_API_RETRY_WINDOW_MS = 300_000;
|
|
@@ -450,7 +453,8 @@ async function postStorageCollection(config, path, name) {
|
|
|
450
453
|
// Not marked repeatable even by name: a 500 after the create (e.g. scoped
|
|
451
454
|
// token registration failed) must surface, since a re-send would take the
|
|
452
455
|
// existing-name path and hide it. POST rules apply (limiter/HAProxy 503,
|
|
453
|
-
// refused
|
|
456
|
+
// "storage-name-busy" 503 while a parallel create holds the name, refused
|
|
457
|
+
// connection).
|
|
454
458
|
return new CollectionApiClient(config).call('POST', `${path}${query}`);
|
|
455
459
|
}
|
|
456
460
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.37",
|
|
4
4
|
"description": "Scrapely SDK for building JavaScript and TypeScript Actors",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"test:charge-platform-budget": "npx tsx --test tests/charge-platform-budget.test.ts",
|
|
49
49
|
"test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
|
|
50
50
|
"test:storage-path-segments": "npx tsx --test tests/storage-path-segments.test.ts",
|
|
51
|
+
"test:storage-record-keys": "npx tsx --test tests/storage-record-keys.test.ts",
|
|
51
52
|
"test:push-idempotency": "npx tsx --test tests/push-idempotency.test.ts",
|
|
52
53
|
"test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
|
|
53
54
|
"test:charging-client": "npx tsx --test tests/charging-client.test.ts",
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
"test:run-control": "npx tsx --test tests/run-control.test.ts",
|
|
69
70
|
"test:rq-list": "npx tsx --test tests/rq-list-requests.test.ts",
|
|
70
71
|
"test:terminal-status": "npx tsx --test tests/terminal-status-message.test.ts",
|
|
72
|
+
"test:fail-then-exit": "npx tsx --test tests/fail-then-exit.test.ts",
|
|
71
73
|
"test:cdp-retry": "npx tsx --test tests/cdp-retry.test.ts",
|
|
72
74
|
"test:proxy-input-aliases": "npx tsx --test tests/proxy-input-aliases.test.ts",
|
|
73
75
|
"test:charge-retries": "npx tsx --test tests/charge-retries.test.ts",
|