scrapely 1.2.34 → 1.2.36
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 +8 -0
- package/dist/actor.d.ts +10 -0
- package/dist/actor.js +32 -8
- package/dist/storage/api-storage-client.js +11 -10
- package/dist/storage/key-value-store-client.js +1 -1
- package/dist/storage/utils.d.ts +8 -0
- package/dist/storage/utils.js +17 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.2.36] - 2026-10-22
|
|
6
|
+
|
|
7
|
+
- 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`).
|
|
8
|
+
|
|
9
|
+
## [1.2.35] - 2026-10-20
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
5
13
|
## [1.2.34] - 2026-10-19
|
|
6
14
|
|
|
7
15
|
- Initial public release.
|
package/dist/actor.d.ts
CHANGED
|
@@ -459,7 +459,15 @@ export declare class Actor {
|
|
|
459
459
|
*/
|
|
460
460
|
static setValue<T>(key: string, value: T | null, options?: RecordOptions): Promise<void>;
|
|
461
461
|
private static _debugCaptureCount;
|
|
462
|
+
/**
|
|
463
|
+
* A payload up to DEBUG_CAPTURE_MAX_BYTES characters is stored as it is; a larger one gzip-compressed
|
|
464
|
+
* (base64, at most DEBUG_CAPTURE_MAX_BYTES characters again), keeping up to DEBUG_CAPTURE_MAX_CHARS
|
|
465
|
+
* characters / DEBUG_CAPTURE_MAX_ENCODED bytes of it. A plain cut at 256 KiB lost the part a check
|
|
466
|
+
* needed when a large API document starts with site navigation.
|
|
467
|
+
*/
|
|
462
468
|
static readonly DEBUG_CAPTURE_MAX_BYTES: number;
|
|
469
|
+
static readonly DEBUG_CAPTURE_MAX_CHARS: number;
|
|
470
|
+
static readonly DEBUG_CAPTURE_MAX_ENCODED: number;
|
|
463
471
|
static readonly DEBUG_CAPTURE_MAX_RECORDS = 500;
|
|
464
472
|
/** The key-value store record name for a capture key (item URL or id). */
|
|
465
473
|
static debugCaptureRecordKey(key: string): Promise<string>;
|
|
@@ -471,6 +479,8 @@ export declare class Actor {
|
|
|
471
479
|
/** debugCapture for synchronous code: fire and forget. Does nothing unless capture is enabled for the run. */
|
|
472
480
|
static debugCaptureSync(key: string, payload: unknown): void;
|
|
473
481
|
static debugCapture(key: string, payload: unknown): Promise<void>;
|
|
482
|
+
/** The record for one capture: plain when small, else gzip-base64 (plain head as the last resort). */
|
|
483
|
+
static debugCaptureRecord(key: string, text: string): Promise<Record<string, unknown>>;
|
|
474
484
|
/**
|
|
475
485
|
* Helper to resolve storage identifier to a string ID or name.
|
|
476
486
|
* Supports: string, { id: '...' }, { name: '...' }, { alias: '...' }
|
package/dist/actor.js
CHANGED
|
@@ -660,7 +660,15 @@ class Actor {
|
|
|
660
660
|
await store.setValue(key, value, options);
|
|
661
661
|
}
|
|
662
662
|
static _debugCaptureCount = 0;
|
|
663
|
+
/**
|
|
664
|
+
* A payload up to DEBUG_CAPTURE_MAX_BYTES characters is stored as it is; a larger one gzip-compressed
|
|
665
|
+
* (base64, at most DEBUG_CAPTURE_MAX_BYTES characters again), keeping up to DEBUG_CAPTURE_MAX_CHARS
|
|
666
|
+
* characters / DEBUG_CAPTURE_MAX_ENCODED bytes of it. A plain cut at 256 KiB lost the part a check
|
|
667
|
+
* needed when a large API document starts with site navigation.
|
|
668
|
+
*/
|
|
663
669
|
static DEBUG_CAPTURE_MAX_BYTES = 256 * 1024;
|
|
670
|
+
static DEBUG_CAPTURE_MAX_CHARS = 8 * 1024 * 1024;
|
|
671
|
+
static DEBUG_CAPTURE_MAX_ENCODED = 8 * 1024 * 1024;
|
|
664
672
|
static DEBUG_CAPTURE_MAX_RECORDS = 500;
|
|
665
673
|
/** The key-value store record name for a capture key (item URL or id). */
|
|
666
674
|
static async debugCaptureRecordKey(key) {
|
|
@@ -684,14 +692,32 @@ class Actor {
|
|
|
684
692
|
if (Actor._debugCaptureCount >= Actor.DEBUG_CAPTURE_MAX_RECORDS)
|
|
685
693
|
return;
|
|
686
694
|
try {
|
|
687
|
-
const text =
|
|
695
|
+
const text = typeof payload === 'string' ? payload
|
|
696
|
+
: payload instanceof Uint8Array ? Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength).toString('utf8')
|
|
697
|
+
: JSON.stringify(payload) ?? '';
|
|
698
|
+
const record = await Actor.debugCaptureRecord(String(key), text);
|
|
688
699
|
Actor._debugCaptureCount++;
|
|
689
|
-
await Actor.setValue(await Actor.debugCaptureRecordKey(key),
|
|
700
|
+
await Actor.setValue(await Actor.debugCaptureRecordKey(key), record);
|
|
690
701
|
}
|
|
691
702
|
catch {
|
|
692
703
|
// never break the run
|
|
693
704
|
}
|
|
694
705
|
}
|
|
706
|
+
/** The record for one capture: plain when small, else gzip-base64 (plain head as the last resort). */
|
|
707
|
+
static async debugCaptureRecord(key, text) {
|
|
708
|
+
const limit = Actor.DEBUG_CAPTURE_MAX_BYTES;
|
|
709
|
+
if (text.length <= limit)
|
|
710
|
+
return { key, payload: text };
|
|
711
|
+
const { gzipSync } = await import('node:zlib');
|
|
712
|
+
const raw = Buffer.from(text.slice(0, Actor.DEBUG_CAPTURE_MAX_CHARS), 'utf8');
|
|
713
|
+
const kept = raw.subarray(0, Actor.DEBUG_CAPTURE_MAX_ENCODED);
|
|
714
|
+
const packed = gzipSync(kept, { level: 6 }).toString('base64');
|
|
715
|
+
if (packed.length <= limit) {
|
|
716
|
+
const truncated = text.length > Actor.DEBUG_CAPTURE_MAX_CHARS || kept.length < raw.length;
|
|
717
|
+
return { key, payload: packed, encoding: 'gzip-base64', length: text.length, truncated };
|
|
718
|
+
}
|
|
719
|
+
return { key, payload: text.slice(0, limit), length: text.length, truncated: true };
|
|
720
|
+
}
|
|
695
721
|
/**
|
|
696
722
|
* Helper to resolve storage identifier to a string ID or name.
|
|
697
723
|
* Supports: string, { id: '...' }, { name: '...' }, { alias: '...' }
|
|
@@ -1330,12 +1356,10 @@ class Actor {
|
|
|
1330
1356
|
if (!apiUrl || !token || !runId) {
|
|
1331
1357
|
throw new Error('Metamorph requires apiBaseUrl, token, and actorRunId in configuration');
|
|
1332
1358
|
}
|
|
1333
|
-
// Wire format (server contract, Python SDK parity): the
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1337
|
-
// the contract — the old client-side KVS write made the server overwrite
|
|
1338
|
-
// our value with the stale INPUT, silently losing the new input.
|
|
1359
|
+
// Wire format (server contract, Python SDK parity): the new input (if
|
|
1360
|
+
// any) goes in the request body's `input` field; the server stores it as
|
|
1361
|
+
// INPUT-METAMORPH-<n> (INPUT keeps the original) and tracks the metamorph
|
|
1362
|
+
// count in run stats. `inputKey` is NOT part of the contract.
|
|
1339
1363
|
const body = { targetActorId };
|
|
1340
1364
|
if (options?.build) {
|
|
1341
1365
|
body.build = options.build;
|
|
@@ -11,6 +11,7 @@ exports.fetchWithRetries = fetchWithRetries;
|
|
|
11
11
|
const node_crypto_1 = require("node:crypto");
|
|
12
12
|
const charging_js_1 = require("../charging.js");
|
|
13
13
|
const kvs_iteration_js_1 = require("./kvs-iteration.js");
|
|
14
|
+
const utils_js_1 = require("./utils.js");
|
|
14
15
|
function buildRqQueryParams(baseClientKey, options) {
|
|
15
16
|
const params = new URLSearchParams();
|
|
16
17
|
params.set('clientKey', options?.clientKey ?? baseClientKey);
|
|
@@ -734,11 +735,11 @@ class ApiKeyValueStoreClient extends BaseApiClient {
|
|
|
734
735
|
await this.request('DELETE', `/key-value-stores/${this.id}`);
|
|
735
736
|
}
|
|
736
737
|
async recordExists(key) {
|
|
737
|
-
return this.exists(`/key-value-stores/${this.id}/records/${key}`);
|
|
738
|
+
return this.exists(`/key-value-stores/${this.id}/records/${(0, utils_js_1.pathSegment)(key)}`);
|
|
738
739
|
}
|
|
739
740
|
async getRecord(key, options) {
|
|
740
741
|
try {
|
|
741
|
-
const { data, contentType } = await this.requestWithHeaders('GET', `/key-value-stores/${this.id}/records/${key}`);
|
|
742
|
+
const { data, contentType } = await this.requestWithHeaders('GET', `/key-value-stores/${this.id}/records/${(0, utils_js_1.pathSegment)(key)}`);
|
|
742
743
|
if (data === undefined || data === null)
|
|
743
744
|
return undefined;
|
|
744
745
|
let value = data;
|
|
@@ -791,10 +792,10 @@ class ApiKeyValueStoreClient extends BaseApiClient {
|
|
|
791
792
|
timeoutSecs: options.timeoutSecs,
|
|
792
793
|
doNotRetryTimeouts: options.doNotRetryTimeouts,
|
|
793
794
|
} : undefined;
|
|
794
|
-
await this.request('PUT', `/key-value-stores/${this.id}/records/${key}`, body, contentType || ct, requestOptions);
|
|
795
|
+
await this.request('PUT', `/key-value-stores/${this.id}/records/${(0, utils_js_1.pathSegment)(key)}`, body, contentType || ct, requestOptions);
|
|
795
796
|
}
|
|
796
797
|
async deleteRecord(key) {
|
|
797
|
-
await this.request('DELETE', `/key-value-stores/${this.id}/records/${key}`);
|
|
798
|
+
await this.request('DELETE', `/key-value-stores/${this.id}/records/${(0, utils_js_1.pathSegment)(key)}`);
|
|
798
799
|
}
|
|
799
800
|
async listKeys(options) {
|
|
800
801
|
const params = new URLSearchParams();
|
|
@@ -853,7 +854,7 @@ class ApiKeyValueStoreClient extends BaseApiClient {
|
|
|
853
854
|
// apiUrl is the container-internal API address; links handed to users
|
|
854
855
|
// must use the public base URL the platform injects.
|
|
855
856
|
const publicBase = (process.env.APIFY_API_PUBLIC_BASE_URL || process.env.ACTOR_API_PUBLIC_BASE_URL || this.config.apiUrl).replace(/\/+$/, '');
|
|
856
|
-
return `${publicBase}/v2/key-value-stores/${this.id}/records/${key}`;
|
|
857
|
+
return `${publicBase}/v2/key-value-stores/${this.id}/records/${(0, utils_js_1.pathSegment)(key)}`;
|
|
857
858
|
}
|
|
858
859
|
}
|
|
859
860
|
exports.ApiKeyValueStoreClient = ApiKeyValueStoreClient;
|
|
@@ -920,7 +921,7 @@ class ApiRequestQueueClient extends BaseApiClient {
|
|
|
920
921
|
}
|
|
921
922
|
async getRequest(id) {
|
|
922
923
|
try {
|
|
923
|
-
return await this.request('GET', `/request-queues/${this.id}/requests/${id}`);
|
|
924
|
+
return await this.request('GET', `/request-queues/${this.id}/requests/${(0, utils_js_1.pathSegment)(id)}`);
|
|
924
925
|
}
|
|
925
926
|
catch {
|
|
926
927
|
return undefined;
|
|
@@ -928,10 +929,10 @@ class ApiRequestQueueClient extends BaseApiClient {
|
|
|
928
929
|
}
|
|
929
930
|
async updateRequest(request, options) {
|
|
930
931
|
const query = buildRqQueryParams(this.clientKey, { forefront: options?.forefront });
|
|
931
|
-
return this.request('PUT', `/request-queues/${this.id}/requests/${request.id}${query}`, request);
|
|
932
|
+
return this.request('PUT', `/request-queues/${this.id}/requests/${(0, utils_js_1.pathSegment)(request.id)}${query}`, request);
|
|
932
933
|
}
|
|
933
934
|
async deleteRequest(id) {
|
|
934
|
-
await this.request('DELETE', `/request-queues/${this.id}/requests/${id}`);
|
|
935
|
+
await this.request('DELETE', `/request-queues/${this.id}/requests/${(0, utils_js_1.pathSegment)(id)}`);
|
|
935
936
|
}
|
|
936
937
|
async listHead(options) {
|
|
937
938
|
const query = buildRqQueryParams(this.clientKey, { limit: options?.limit });
|
|
@@ -959,12 +960,12 @@ class ApiRequestQueueClient extends BaseApiClient {
|
|
|
959
960
|
lockSecs: options.lockSecs,
|
|
960
961
|
forefront: options.forefront,
|
|
961
962
|
});
|
|
962
|
-
const result = await this.request('PUT', `/request-queues/${this.id}/requests/${id}/lock${query}`);
|
|
963
|
+
const result = await this.request('PUT', `/request-queues/${this.id}/requests/${(0, utils_js_1.pathSegment)(id)}/lock${query}`);
|
|
963
964
|
return { lockExpiresAt: new Date(result.lockExpiresAt) };
|
|
964
965
|
}
|
|
965
966
|
async deleteRequestLock(id, options) {
|
|
966
967
|
const query = buildRqQueryParams(this.clientKey, { forefront: options?.forefront });
|
|
967
|
-
await this.request('DELETE', `/request-queues/${this.id}/requests/${id}/lock${query}`);
|
|
968
|
+
await this.request('DELETE', `/request-queues/${this.id}/requests/${(0, utils_js_1.pathSegment)(id)}/lock${query}`);
|
|
968
969
|
}
|
|
969
970
|
/**
|
|
970
971
|
* List requests in the queue with optional filtering and pagination.
|
|
@@ -297,7 +297,7 @@ class DragonflyKeyValueStoreClient {
|
|
|
297
297
|
getPublicUrl(key) {
|
|
298
298
|
const config = configuration_js_1.Configuration.getGlobalConfig();
|
|
299
299
|
const baseUrl = config.get('apiPublicBaseUrl') || config.get('apiBaseUrl') || 'https://api.scrape.ly';
|
|
300
|
-
return `${baseUrl}/v2/key-value-stores/${this.id}/records/${key}`;
|
|
300
|
+
return `${baseUrl}/v2/key-value-stores/${this.id}/records/${(0, utils_js_1.pathSegment)(key)}`;
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
exports.DragonflyKeyValueStoreClient = DragonflyKeyValueStoreClient;
|
package/dist/storage/utils.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
export declare function uniqueKeyToRequestId(uniqueKey: string): string;
|
|
2
2
|
export declare function generateId(): string;
|
|
3
3
|
export declare function nowISO(): Date;
|
|
4
|
+
/**
|
|
5
|
+
* Percent-encode a value so it stays one URL path segment. Request ids come
|
|
6
|
+
* from queue contents another client may have written: unencoded, an id such
|
|
7
|
+
* as `..` or `../../key-value-stores/x` would send the call to another
|
|
8
|
+
* resource. `''`, `.` and `..` are rejected because a URL parser resolves dot
|
|
9
|
+
* segments even when they are percent-encoded.
|
|
10
|
+
*/
|
|
11
|
+
export declare function pathSegment(value: string): string;
|
package/dist/storage/utils.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.uniqueKeyToRequestId = uniqueKeyToRequestId;
|
|
4
4
|
exports.generateId = generateId;
|
|
5
5
|
exports.nowISO = nowISO;
|
|
6
|
+
exports.pathSegment = pathSegment;
|
|
6
7
|
const node_crypto_1 = require("node:crypto");
|
|
7
8
|
function uniqueKeyToRequestId(uniqueKey) {
|
|
8
9
|
return (0, node_crypto_1.createHash)('sha256')
|
|
@@ -17,3 +18,19 @@ function generateId() {
|
|
|
17
18
|
function nowISO() {
|
|
18
19
|
return new Date();
|
|
19
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Percent-encode a value so it stays one URL path segment. Request ids come
|
|
23
|
+
* from queue contents another client may have written: unencoded, an id such
|
|
24
|
+
* as `..` or `../../key-value-stores/x` would send the call to another
|
|
25
|
+
* resource. `''`, `.` and `..` are rejected because a URL parser resolves dot
|
|
26
|
+
* segments even when they are percent-encoded.
|
|
27
|
+
*/
|
|
28
|
+
function pathSegment(value) {
|
|
29
|
+
if (typeof value !== 'string') {
|
|
30
|
+
throw new TypeError(`Expected a string for a URL path segment, got ${typeof value}.`);
|
|
31
|
+
}
|
|
32
|
+
if (value === '' || value === '.' || value === '..') {
|
|
33
|
+
throw new Error(`"${value}" cannot be used as a URL path segment.`);
|
|
34
|
+
}
|
|
35
|
+
return encodeURIComponent(value);
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.36",
|
|
4
4
|
"description": "Scrapely SDK for building JavaScript and TypeScript Actors",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
"test:charge-pending-persist": "npx tsx --test tests/charge-pending-persist.test.ts",
|
|
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
|
+
"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",
|
|
50
52
|
"test:push-idempotency": "npx tsx --test tests/push-idempotency.test.ts",
|
|
51
53
|
"test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
|
|
52
54
|
"test:charging-client": "npx tsx --test tests/charging-client.test.ts",
|