scrapely 1.2.39 → 1.2.41
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 +14 -0
- package/dist/actor.js +11 -0
- package/dist/charging.d.ts +23 -0
- package/dist/charging.js +120 -45
- package/dist/crypto/secrets.js +4 -0
- package/dist/patch-browser-pool.js +28 -2
- package/dist/storage/dataset-client.js +11 -5
- package/dist/storage/dataset-collection-client.js +5 -4
- package/dist/storage/key-value-store-client.js +9 -5
- package/dist/storage/kv-store-collection-client.js +5 -8
- package/dist/storage/log-manager.d.ts +1 -1
- package/dist/storage/log-manager.js +9 -4
- package/dist/storage/request-queue-client.d.ts +11 -0
- package/dist/storage/request-queue-client.js +59 -14
- package/dist/storage/rq-collection-client.js +5 -9
- package/dist/storage/rq-lock-script.d.ts +13 -7
- package/dist/storage/rq-lock-script.js +48 -26
- package/dist/storage/run-metadata.js +6 -0
- package/dist/storage/ttl-manager.d.ts +3 -0
- package/dist/storage/ttl-manager.js +20 -0
- package/dist/storage/utils.d.ts +19 -0
- package/dist/storage/utils.js +39 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +12 -5
- package/package.json +7 -4
|
@@ -20,6 +20,15 @@ export interface ListRequestsResult {
|
|
|
20
20
|
import type { DragonflyClient } from './dragonfly-client.js';
|
|
21
21
|
import type { RequestQueueOptions } from '@crawlee/types';
|
|
22
22
|
import { type RequestQueueRequest } from './rq-list-requests.js';
|
|
23
|
+
/** Longest lock, in seconds (same as the server's MAX_LOCK_SECS). */
|
|
24
|
+
export declare const MAX_LOCK_SECS = 86400;
|
|
25
|
+
/** lockSecs as a whole number of seconds from 1 to MAX_LOCK_SECS. */
|
|
26
|
+
export declare function clampLockSecs(lockSecs: number): number;
|
|
27
|
+
/**
|
|
28
|
+
* Largest stored request a head read loads (the server's MAX_READABLE_STORED_REQUEST_BYTES):
|
|
29
|
+
* a larger one is left out of the head instead of being read into memory.
|
|
30
|
+
*/
|
|
31
|
+
export declare const MAX_HEAD_REQUEST_BYTES: number;
|
|
23
32
|
export declare class DragonflyRequestQueueClient implements RequestQueueClient {
|
|
24
33
|
private client;
|
|
25
34
|
private id;
|
|
@@ -71,6 +80,8 @@ export declare class DragonflyRequestQueueClient implements RequestQueueClient {
|
|
|
71
80
|
* @param options.clientKey - Client identifier for hadMultipleClients tracking
|
|
72
81
|
*/
|
|
73
82
|
listRequests(options?: ListRequestsOptions): Promise<ListRequestsResult>;
|
|
83
|
+
/** The ids whose stored request is at most MAX_HEAD_REQUEST_BYTES (HSTRLEN, nothing loaded). */
|
|
84
|
+
private withinHeadSize;
|
|
74
85
|
listAndLockHead(options: ListAndLockOptions & {
|
|
75
86
|
clientKey?: string;
|
|
76
87
|
}): Promise<ListAndLockHeadResult>;
|
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DragonflyRequestQueueClient = void 0;
|
|
3
|
+
exports.DragonflyRequestQueueClient = exports.MAX_HEAD_REQUEST_BYTES = exports.MAX_LOCK_SECS = void 0;
|
|
4
|
+
exports.clampLockSecs = clampLockSecs;
|
|
4
5
|
const utils_js_1 = require("./utils.js");
|
|
5
6
|
const billing_tracker_js_1 = require("./billing-tracker.js");
|
|
6
7
|
const rq_lock_script_js_1 = require("./rq-lock-script.js");
|
|
7
8
|
const rq_list_requests_js_1 = require("./rq-list-requests.js");
|
|
9
|
+
const ttl_manager_js_1 = require("./ttl-manager.js");
|
|
8
10
|
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
/**
|
|
12
|
+
* Lock owners are stored as "clientKey:runId", so a clientKey holding ':'
|
|
13
|
+
* could present another run's owner value. Same rule as the server.
|
|
14
|
+
*/
|
|
15
|
+
function assertClientKey(clientKey) {
|
|
16
|
+
if (clientKey.includes(':'))
|
|
17
|
+
throw new Error("clientKey must not contain ':'");
|
|
18
|
+
return clientKey;
|
|
19
|
+
}
|
|
20
|
+
/** Longest lock, in seconds (same as the server's MAX_LOCK_SECS). */
|
|
21
|
+
exports.MAX_LOCK_SECS = 86_400;
|
|
22
|
+
/** lockSecs as a whole number of seconds from 1 to MAX_LOCK_SECS. */
|
|
23
|
+
function clampLockSecs(lockSecs) {
|
|
24
|
+
const n = Math.floor(Number(lockSecs));
|
|
25
|
+
return Number.isFinite(n) ? Math.min(Math.max(n, 1), exports.MAX_LOCK_SECS) : 1;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Largest stored request a head read loads (the server's MAX_READABLE_STORED_REQUEST_BYTES):
|
|
29
|
+
* a larger one is left out of the head instead of being read into memory.
|
|
30
|
+
*/
|
|
31
|
+
exports.MAX_HEAD_REQUEST_BYTES = 10 * 1024 * 1024 + 64 * 1024;
|
|
9
32
|
class DragonflyRequestQueueClient {
|
|
10
33
|
client;
|
|
11
34
|
id;
|
|
@@ -18,7 +41,7 @@ class DragonflyRequestQueueClient {
|
|
|
18
41
|
constructor(client, id, options) {
|
|
19
42
|
this.client = client;
|
|
20
43
|
this.id = id;
|
|
21
|
-
this.clientKey = options?.clientKey || (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 16);
|
|
44
|
+
this.clientKey = assertClientKey(options?.clientKey || (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 16));
|
|
22
45
|
this.infoKey = client.key('rq', id, 'info');
|
|
23
46
|
this.requestsKey = client.key('rq', id, 'requests');
|
|
24
47
|
this.pendingKey = client.key('rq', id, 'pending');
|
|
@@ -119,11 +142,13 @@ class DragonflyRequestQueueClient {
|
|
|
119
142
|
const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
|
|
120
143
|
if (raw) {
|
|
121
144
|
const data = JSON.parse(raw);
|
|
122
|
-
|
|
123
|
-
|
|
145
|
+
// Name pointers are scoped by owner (name:{userId}:{name}).
|
|
146
|
+
const ownerId = data.userId ?? this.client.billingContext?.userId;
|
|
147
|
+
await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, ownerId, data.name, this.id);
|
|
124
148
|
data.name = newFields.name;
|
|
125
149
|
await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
|
|
126
|
-
|
|
150
|
+
if (ownerId && newFields.name)
|
|
151
|
+
await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(ownerId, newFields.name), JSON.stringify(data));
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
154
|
await this.client.redis.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
|
|
@@ -134,8 +159,7 @@ class DragonflyRequestQueueClient {
|
|
|
134
159
|
await this.client.redis.del(this.infoKey, this.requestsKey, this.pendingKey, this.lockedKey, this.lockOwnersKey);
|
|
135
160
|
const indexKey = this.client.key('rq:index');
|
|
136
161
|
await this.client.redis.hdel(indexKey, `id:${this.id}`);
|
|
137
|
-
|
|
138
|
-
await this.client.redis.hdel(indexKey, `name:${info.name}`);
|
|
162
|
+
await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, info?.userId || this.client.billingContext?.userId, info?.name, this.id);
|
|
139
163
|
}
|
|
140
164
|
async addRequest(request, options) {
|
|
141
165
|
const requestId = request.id || (0, utils_js_1.uniqueKeyToRequestId)(request.uniqueKey);
|
|
@@ -156,6 +180,8 @@ class DragonflyRequestQueueClient {
|
|
|
156
180
|
pipeline.hincrby(this.infoKey, 'totalRequestCount', 1);
|
|
157
181
|
pipeline.hincrby(this.infoKey, 'pendingRequestCount', 1);
|
|
158
182
|
pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
|
|
183
|
+
// Requests/pending are created by the first add: give them the queue's expiry.
|
|
184
|
+
(0, ttl_manager_js_1.queueInheritExpiry)(pipeline, this.infoKey, [this.requestsKey, this.pendingKey]);
|
|
159
185
|
await pipeline.exec();
|
|
160
186
|
// Track write and storage bytes for billing
|
|
161
187
|
const bytes = billing_tracker_js_1.BillingTracker.estimateRequestBytes(stored);
|
|
@@ -289,7 +315,7 @@ class DragonflyRequestQueueClient {
|
|
|
289
315
|
const activeLocks = new Set(at(1));
|
|
290
316
|
// Filter out locked items
|
|
291
317
|
const items = [];
|
|
292
|
-
const candidates = ids.filter((id) => !activeLocks.has(id)).slice(0, limit);
|
|
318
|
+
const candidates = await this.withinHeadSize(ids.filter((id) => !activeLocks.has(id)).slice(0, limit));
|
|
293
319
|
if (candidates.length > 0) {
|
|
294
320
|
const raws = await this.client.redis.hmget(this.requestsKey, ...candidates);
|
|
295
321
|
for (const raw of raws) {
|
|
@@ -408,11 +434,22 @@ class DragonflyRequestQueueClient {
|
|
|
408
434
|
}
|
|
409
435
|
return result;
|
|
410
436
|
}
|
|
437
|
+
/** The ids whose stored request is at most MAX_HEAD_REQUEST_BYTES (HSTRLEN, nothing loaded). */
|
|
438
|
+
async withinHeadSize(ids) {
|
|
439
|
+
if (ids.length === 0)
|
|
440
|
+
return ids;
|
|
441
|
+
const pipeline = this.client.redis.pipeline();
|
|
442
|
+
for (const id of ids)
|
|
443
|
+
pipeline.hstrlen(this.requestsKey, id);
|
|
444
|
+
const sizes = (await pipeline.exec()) ?? [];
|
|
445
|
+
return ids.filter((_, i) => Number(sizes[i]?.[1] ?? 0) <= exports.MAX_HEAD_REQUEST_BYTES);
|
|
446
|
+
}
|
|
411
447
|
async listAndLockHead(options) {
|
|
412
448
|
const limit = Math.min(options.limit ?? 25, 25);
|
|
413
449
|
const now = Date.now();
|
|
414
|
-
const
|
|
415
|
-
const
|
|
450
|
+
const lockSecs = clampLockSecs(options.lockSecs);
|
|
451
|
+
const lockUntil = now + lockSecs * 1000;
|
|
452
|
+
const clientKey = assertClientKey(options.clientKey || this.clientKey);
|
|
416
453
|
const runId = this.client.billingContext?.runId || null;
|
|
417
454
|
const ownerValue = runId ? `${clientKey}:${runId}` : clientKey;
|
|
418
455
|
const { lockedIds, activeLockCount } = await (0, rq_lock_script_js_1.listAndLockHeadAtomic)(this.client.redis, {
|
|
@@ -425,11 +462,19 @@ class DragonflyRequestQueueClient {
|
|
|
425
462
|
lockUntil,
|
|
426
463
|
limit,
|
|
427
464
|
ownerValue,
|
|
465
|
+
maxRequestBytes: exports.MAX_HEAD_REQUEST_BYTES,
|
|
428
466
|
});
|
|
429
467
|
const items = [];
|
|
430
468
|
if (lockedIds.length > 0) {
|
|
469
|
+
// The locked set and owners hash are created by the first lock: give them the queue's expiry.
|
|
470
|
+
const expiry = this.client.redis.pipeline();
|
|
471
|
+
(0, ttl_manager_js_1.queueInheritExpiry)(expiry, this.infoKey, [this.lockedKey, this.lockOwnersKey]);
|
|
472
|
+
await expiry.exec();
|
|
473
|
+
}
|
|
474
|
+
const readable = await this.withinHeadSize(lockedIds);
|
|
475
|
+
if (readable.length > 0) {
|
|
431
476
|
// Single HMGET instead of one HGET round-trip per locked ID
|
|
432
|
-
const raws = await this.client.redis.hmget(this.requestsKey, ...
|
|
477
|
+
const raws = await this.client.redis.hmget(this.requestsKey, ...readable);
|
|
433
478
|
for (const raw of raws) {
|
|
434
479
|
if (!raw)
|
|
435
480
|
continue;
|
|
@@ -453,7 +498,7 @@ class DragonflyRequestQueueClient {
|
|
|
453
498
|
limit,
|
|
454
499
|
queueModifiedAt: modifiedAt ? new Date(modifiedAt) : (0, utils_js_1.nowISO)(),
|
|
455
500
|
hadMultipleClients,
|
|
456
|
-
lockSecs
|
|
501
|
+
lockSecs,
|
|
457
502
|
items,
|
|
458
503
|
// From the atomic lock script: active locks held by ANY client
|
|
459
504
|
// (including this call's own).
|
|
@@ -485,7 +530,7 @@ class DragonflyRequestQueueClient {
|
|
|
485
530
|
if (owner && !(0, rq_lock_script_js_1.matchesLockOwner)(owner, this.clientKey, runId)) {
|
|
486
531
|
throw new Error(`Request with ID ${id} is not locked by this client in queue ${this.id}`);
|
|
487
532
|
}
|
|
488
|
-
const lockUntil = now + options.lockSecs * 1000;
|
|
533
|
+
const lockUntil = now + clampLockSecs(options.lockSecs) * 1000;
|
|
489
534
|
await this.client.redis.zadd(this.lockedKey, String(lockUntil), id);
|
|
490
535
|
return { lockExpiresAt: new Date(lockUntil) };
|
|
491
536
|
}
|
|
@@ -512,7 +557,7 @@ class DragonflyRequestQueueClient {
|
|
|
512
557
|
lockOwnersKey: this.lockOwnersKey,
|
|
513
558
|
pendingKey: this.pendingKey,
|
|
514
559
|
}, {
|
|
515
|
-
clientKey: options.clientKey,
|
|
560
|
+
clientKey: assertClientKey(options.clientKey),
|
|
516
561
|
forefront: options.forefront,
|
|
517
562
|
runId: options.runId,
|
|
518
563
|
});
|
|
@@ -9,18 +9,15 @@ class DragonflyRequestQueueCollectionClient {
|
|
|
9
9
|
}
|
|
10
10
|
async getOrCreate(name) {
|
|
11
11
|
const indexKey = this.client.key('rq:index');
|
|
12
|
+
// Every storage belongs to the run's user; the name pointer is scoped by that user.
|
|
13
|
+
const userId = (0, utils_js_1.requireStorageOwner)(this.client.billingContext, 'request queue');
|
|
12
14
|
if (name) {
|
|
13
|
-
const existing = await this.client.redis.hget(indexKey,
|
|
15
|
+
const existing = await this.client.redis.hget(indexKey, (0, utils_js_1.ownerNameField)(userId, name));
|
|
14
16
|
if (existing)
|
|
15
17
|
return JSON.parse(existing);
|
|
16
18
|
}
|
|
17
19
|
const id = (0, utils_js_1.generateId)();
|
|
18
20
|
const now = (0, utils_js_1.nowISO)();
|
|
19
|
-
// Real userId when billing context is active; legacy placeholder otherwise.
|
|
20
|
-
// Matches the KVS collection client convention — the server's storage usage
|
|
21
|
-
// and TTL jobs match on this field to attribute storages to users, so an
|
|
22
|
-
// empty value would orphan the queue from per-user accounting.
|
|
23
|
-
const userId = this.client.billingContext?.userId ?? '1';
|
|
24
21
|
const data = {
|
|
25
22
|
id,
|
|
26
23
|
name,
|
|
@@ -36,7 +33,7 @@ class DragonflyRequestQueueCollectionClient {
|
|
|
36
33
|
};
|
|
37
34
|
await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
|
|
38
35
|
if (name)
|
|
39
|
-
await this.client.redis.hset(indexKey,
|
|
36
|
+
await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(userId, name), JSON.stringify(data));
|
|
40
37
|
const infoKey = this.client.key('rq', id, 'info');
|
|
41
38
|
const requestsKey = this.client.key('rq', id, 'requests');
|
|
42
39
|
const pendingKey = this.client.key('rq', id, 'pending');
|
|
@@ -77,8 +74,7 @@ class DragonflyRequestQueueCollectionClient {
|
|
|
77
74
|
const exists = await this.client.redis.exists(infoKey);
|
|
78
75
|
if (!exists) {
|
|
79
76
|
await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
|
|
80
|
-
|
|
81
|
-
await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
|
|
77
|
+
await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, parsed.userId, parsed.name, parsed.id);
|
|
82
78
|
continue;
|
|
83
79
|
}
|
|
84
80
|
items.push(parsed);
|
|
@@ -9,7 +9,7 @@ import type Redis from 'ioredis';
|
|
|
9
9
|
* the one calling the endpoint or a different one"). Callers get the flag from
|
|
10
10
|
* this same atomic reply instead of re-reading the whole locked + pending sets.
|
|
11
11
|
*/
|
|
12
|
-
export declare const LIST_AND_LOCK_HEAD_LUA = "\nlocal pendingKey = KEYS[1]\nlocal lockedKey = KEYS[2]\nlocal lockOwnersKey = KEYS[3]\nlocal requestsKey = KEYS[4]\nlocal now = tonumber(ARGV[1])\nlocal lockUntil = tonumber(ARGV[2])\nlocal limit = tonumber(ARGV[3])\nlocal ownerValue = ARGV[4]\nlocal scanLimit = tonumber(ARGV[5])\n\nredis.call('ZREMRANGEBYSCORE', lockedKey, '-inf', now)\n\nlocal pendingIds = redis.call('ZRANGE', pendingKey, 0, scanLimit - 1)\nlocal lockedIds = {}\n\nfor _, id in ipairs(pendingIds) do\n if #lockedIds >= limit then break end\n local lockScore = redis.call('ZSCORE', lockedKey, id)\n if not lockScore or tonumber(lockScore) <= now then\n if redis.call('HEXISTS', requestsKey, id) == 1 then\n redis.call('ZADD', lockedKey, lockUntil, id)\n redis.call('HSET', lockOwnersKey, id, ownerValue)\n table.insert(lockedIds, id)\n end\n end\nend\n\n-- Everything left in lockedKey is an active lock (score > now): the purge\n-- above removed the expired ones and we just added ours. An O(1) ZCARD here\n-- replaces the callers' old tail of ZRANGEBYSCORE(locked) + full\n-- ZRANGE(pending, 0, -1) scans on every head fetch.\nreturn { lockedIds, redis.call('ZCARD', lockedKey) }\n";
|
|
12
|
+
export declare const LIST_AND_LOCK_HEAD_LUA = "\nlocal pendingKey = KEYS[1]\nlocal lockedKey = KEYS[2]\nlocal lockOwnersKey = KEYS[3]\nlocal requestsKey = KEYS[4]\nlocal now = tonumber(ARGV[1])\nlocal lockUntil = tonumber(ARGV[2])\nlocal limit = tonumber(ARGV[3])\nlocal ownerValue = ARGV[4]\nlocal scanLimit = tonumber(ARGV[5])\n-- Requests larger than this are never returned by the head, so they are not locked either.\nlocal maxRequestBytes = tonumber(ARGV[6])\n\n-- Expired locks leave lockedKey here, and their owner entries go with them:\n-- otherwise lockOwners keeps one entry per request ever locked.\nlocal expiredIds = redis.call('ZRANGEBYSCORE', lockedKey, '-inf', now)\nfor i = 1, #expiredIds, 1000 do\n redis.call('HDEL', lockOwnersKey, unpack(expiredIds, i, math.min(i + 999, #expiredIds)))\nend\nredis.call('ZREMRANGEBYSCORE', lockedKey, '-inf', now)\n\nlocal pendingIds = redis.call('ZRANGE', pendingKey, 0, scanLimit - 1)\nlocal lockedIds = {}\n\nfor _, id in ipairs(pendingIds) do\n if #lockedIds >= limit then break end\n local lockScore = redis.call('ZSCORE', lockedKey, id)\n if not lockScore or tonumber(lockScore) <= now then\n if redis.call('HEXISTS', requestsKey, id) == 1\n and (not maxRequestBytes or redis.call('HSTRLEN', requestsKey, id) <= maxRequestBytes) then\n redis.call('ZADD', lockedKey, lockUntil, id)\n redis.call('HSET', lockOwnersKey, id, ownerValue)\n table.insert(lockedIds, id)\n end\n end\nend\n\n-- Everything left in lockedKey is an active lock (score > now): the purge\n-- above removed the expired ones and we just added ours. An O(1) ZCARD here\n-- replaces the callers' old tail of ZRANGEBYSCORE(locked) + full\n-- ZRANGE(pending, 0, -1) scans on every head fetch.\nreturn { lockedIds, redis.call('ZCARD', lockedKey) }\n";
|
|
13
13
|
export interface ListAndLockHeadKeys {
|
|
14
14
|
pendingKey: string;
|
|
15
15
|
lockedKey: string;
|
|
@@ -23,6 +23,8 @@ export interface ListAndLockHeadParams {
|
|
|
23
23
|
ownerValue: string;
|
|
24
24
|
/** How many pending IDs to scan (should be >= limit). */
|
|
25
25
|
scanLimit?: number;
|
|
26
|
+
/** Skip (don't lock) requests stored larger than this many bytes. */
|
|
27
|
+
maxRequestBytes?: number;
|
|
26
28
|
}
|
|
27
29
|
export interface ListAndLockHeadAtomicResult {
|
|
28
30
|
/** IDs locked by this call, in queue-head order. */
|
|
@@ -36,14 +38,18 @@ export interface ListAndLockHeadAtomicResult {
|
|
|
36
38
|
export declare function listAndLockHeadAtomic(redis: Redis, keys: ListAndLockHeadKeys, params: ListAndLockHeadParams): Promise<ListAndLockHeadAtomicResult>;
|
|
37
39
|
/** Returns true if lockOwners entry matches the caller's clientKey (and optional runId). */
|
|
38
40
|
export declare function matchesLockOwner(owner: string, clientKey: string, runId?: string | null): boolean;
|
|
41
|
+
/** Locked requests UNLOCK_REQUESTS_LUA reads per page. */
|
|
42
|
+
export declare const UNLOCK_SCAN_PAGE = 1000;
|
|
39
43
|
/**
|
|
40
|
-
* Atomic unlock-by-owner:
|
|
41
|
-
* matches the clientKey (and optional
|
|
42
|
-
* per-request loop, optionally
|
|
43
|
-
*
|
|
44
|
-
*
|
|
44
|
+
* Atomic unlock-by-owner: walks the locked set a page at a time by rank,
|
|
45
|
+
* unlocks every request whose owner matches the clientKey (and optional
|
|
46
|
+
* runId) using the same rules as the old per-request loop, optionally
|
|
47
|
+
* re-queueing unlocked requests at the forefront. Returns the number of
|
|
48
|
+
* unlocked requests. One round-trip, no race window between ZREM and HDEL,
|
|
49
|
+
* and neither the owners hash nor the locked set is read whole (pages of
|
|
50
|
+
* UNLOCK_SCAN_PAGE, ARGV[4]).
|
|
45
51
|
*/
|
|
46
|
-
export declare const UNLOCK_REQUESTS_LUA = "\nlocal lockedKey = KEYS[1]\nlocal lockOwnersKey = KEYS[2]\nlocal pendingKey = KEYS[3]\nlocal clientKey = ARGV[1]\nlocal runId = ARGV[2]\nlocal forefront = ARGV[3]\n\nlocal expectedOwner = clientKey\nif runId ~= '' then\n expectedOwner = clientKey .. ':' .. runId\nend\n\nlocal
|
|
52
|
+
export declare const UNLOCK_REQUESTS_LUA = "\nlocal lockedKey = KEYS[1]\nlocal lockOwnersKey = KEYS[2]\nlocal pendingKey = KEYS[3]\nlocal clientKey = ARGV[1]\nlocal runId = ARGV[2]\nlocal forefront = ARGV[3]\nlocal pageSize = tonumber(ARGV[4])\n\nlocal expectedOwner = clientKey\nif runId ~= '' then\n expectedOwner = clientKey .. ':' .. runId\nend\n\nlocal unlockedCount = 0\nlocal start = 0\n\nwhile true do\n local ids = redis.call('ZRANGE', lockedKey, start, start + pageSize - 1)\n if #ids == 0 then break end\n local owners = redis.call('HMGET', lockOwnersKey, unpack(ids))\n local removed = 0\n for i, requestId in ipairs(ids) do\n local owner = owners[i]\n -- In an Actor run, unlock both (1) requests locked by this\n -- specific run (clientKey:runId) and (2) requests locked by the same\n -- clientKey without run context (clientKey), but NOT requests locked by the\n -- same clientKey in DIFFERENT runs (clientKey:otherRunId).\n -- Exact clientKey, or this run's clientKey:runId. An empty runId must not\n -- unlock every owner that merely starts with \"clientKey:\".\n if owner and (owner == expectedOwner or owner == clientKey) then\n redis.call('ZREM', lockedKey, requestId)\n redis.call('HDEL', lockOwnersKey, requestId)\n if forefront == '1' then\n redis.call('ZADD', pendingKey, 0, requestId)\n end\n unlockedCount = unlockedCount + 1\n removed = removed + 1\n end\n end\n if #ids < pageSize then break end\n -- Removed locks were in this page, so the next unread lock moved up by that many.\n start = start + #ids - removed\nend\n\nreturn unlockedCount\n";
|
|
47
53
|
export interface UnlockRequestsKeys {
|
|
48
54
|
lockedKey: string;
|
|
49
55
|
lockOwnersKey: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.UNLOCK_REQUESTS_LUA = exports.LIST_AND_LOCK_HEAD_LUA = void 0;
|
|
3
|
+
exports.UNLOCK_REQUESTS_LUA = exports.UNLOCK_SCAN_PAGE = exports.LIST_AND_LOCK_HEAD_LUA = void 0;
|
|
4
4
|
exports.listAndLockHeadAtomic = listAndLockHeadAtomic;
|
|
5
5
|
exports.matchesLockOwner = matchesLockOwner;
|
|
6
6
|
exports.unlockRequestsAtomic = unlockRequestsAtomic;
|
|
@@ -24,7 +24,15 @@ local lockUntil = tonumber(ARGV[2])
|
|
|
24
24
|
local limit = tonumber(ARGV[3])
|
|
25
25
|
local ownerValue = ARGV[4]
|
|
26
26
|
local scanLimit = tonumber(ARGV[5])
|
|
27
|
+
-- Requests larger than this are never returned by the head, so they are not locked either.
|
|
28
|
+
local maxRequestBytes = tonumber(ARGV[6])
|
|
27
29
|
|
|
30
|
+
-- Expired locks leave lockedKey here, and their owner entries go with them:
|
|
31
|
+
-- otherwise lockOwners keeps one entry per request ever locked.
|
|
32
|
+
local expiredIds = redis.call('ZRANGEBYSCORE', lockedKey, '-inf', now)
|
|
33
|
+
for i = 1, #expiredIds, 1000 do
|
|
34
|
+
redis.call('HDEL', lockOwnersKey, unpack(expiredIds, i, math.min(i + 999, #expiredIds)))
|
|
35
|
+
end
|
|
28
36
|
redis.call('ZREMRANGEBYSCORE', lockedKey, '-inf', now)
|
|
29
37
|
|
|
30
38
|
local pendingIds = redis.call('ZRANGE', pendingKey, 0, scanLimit - 1)
|
|
@@ -34,7 +42,8 @@ for _, id in ipairs(pendingIds) do
|
|
|
34
42
|
if #lockedIds >= limit then break end
|
|
35
43
|
local lockScore = redis.call('ZSCORE', lockedKey, id)
|
|
36
44
|
if not lockScore or tonumber(lockScore) <= now then
|
|
37
|
-
if redis.call('HEXISTS', requestsKey, id) == 1
|
|
45
|
+
if redis.call('HEXISTS', requestsKey, id) == 1
|
|
46
|
+
and (not maxRequestBytes or redis.call('HSTRLEN', requestsKey, id) <= maxRequestBytes) then
|
|
38
47
|
redis.call('ZADD', lockedKey, lockUntil, id)
|
|
39
48
|
redis.call('HSET', lockOwnersKey, id, ownerValue)
|
|
40
49
|
table.insert(lockedIds, id)
|
|
@@ -50,7 +59,7 @@ return { lockedIds, redis.call('ZCARD', lockedKey) }
|
|
|
50
59
|
`;
|
|
51
60
|
async function listAndLockHeadAtomic(redis, keys, params) {
|
|
52
61
|
const scanLimit = params.scanLimit ?? Math.max(params.limit * 4, 100);
|
|
53
|
-
const result = await redis.eval(exports.LIST_AND_LOCK_HEAD_LUA, 4, keys.pendingKey, keys.lockedKey, keys.lockOwnersKey, keys.requestsKey, String(params.now), String(params.lockUntil), String(params.limit), params.ownerValue, String(scanLimit));
|
|
62
|
+
const result = await redis.eval(exports.LIST_AND_LOCK_HEAD_LUA, 4, keys.pendingKey, keys.lockedKey, keys.lockOwnersKey, keys.requestsKey, String(params.now), String(params.lockUntil), String(params.limit), params.ownerValue, String(scanLimit), ...(params.maxRequestBytes !== undefined ? [String(params.maxRequestBytes)] : []));
|
|
54
63
|
// Expected reply shape: [string[], number]. Tolerate degraded replies the
|
|
55
64
|
// same way the old string[] version did (null / JSON-stringified table).
|
|
56
65
|
if (!Array.isArray(result) || result.length < 2) {
|
|
@@ -76,12 +85,16 @@ function matchesLockOwner(owner, clientKey, runId) {
|
|
|
76
85
|
const expectedOwner = runId ? `${clientKey}:${runId}` : clientKey;
|
|
77
86
|
return owner === expectedOwner || owner === clientKey;
|
|
78
87
|
}
|
|
88
|
+
/** Locked requests UNLOCK_REQUESTS_LUA reads per page. */
|
|
89
|
+
exports.UNLOCK_SCAN_PAGE = 1000;
|
|
79
90
|
/**
|
|
80
|
-
* Atomic unlock-by-owner:
|
|
81
|
-
* matches the clientKey (and optional
|
|
82
|
-
* per-request loop, optionally
|
|
83
|
-
*
|
|
84
|
-
*
|
|
91
|
+
* Atomic unlock-by-owner: walks the locked set a page at a time by rank,
|
|
92
|
+
* unlocks every request whose owner matches the clientKey (and optional
|
|
93
|
+
* runId) using the same rules as the old per-request loop, optionally
|
|
94
|
+
* re-queueing unlocked requests at the forefront. Returns the number of
|
|
95
|
+
* unlocked requests. One round-trip, no race window between ZREM and HDEL,
|
|
96
|
+
* and neither the owners hash nor the locked set is read whole (pages of
|
|
97
|
+
* UNLOCK_SCAN_PAGE, ARGV[4]).
|
|
85
98
|
*/
|
|
86
99
|
exports.UNLOCK_REQUESTS_LUA = `
|
|
87
100
|
local lockedKey = KEYS[1]
|
|
@@ -90,38 +103,47 @@ local pendingKey = KEYS[3]
|
|
|
90
103
|
local clientKey = ARGV[1]
|
|
91
104
|
local runId = ARGV[2]
|
|
92
105
|
local forefront = ARGV[3]
|
|
106
|
+
local pageSize = tonumber(ARGV[4])
|
|
93
107
|
|
|
94
108
|
local expectedOwner = clientKey
|
|
95
109
|
if runId ~= '' then
|
|
96
110
|
expectedOwner = clientKey .. ':' .. runId
|
|
97
111
|
end
|
|
98
112
|
|
|
99
|
-
local clientKeyPrefix = clientKey .. ':'
|
|
100
|
-
|
|
101
|
-
local flat = redis.call('HGETALL', lockOwnersKey)
|
|
102
113
|
local unlockedCount = 0
|
|
114
|
+
local start = 0
|
|
103
115
|
|
|
104
|
-
|
|
105
|
-
local
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
while true do
|
|
117
|
+
local ids = redis.call('ZRANGE', lockedKey, start, start + pageSize - 1)
|
|
118
|
+
if #ids == 0 then break end
|
|
119
|
+
local owners = redis.call('HMGET', lockOwnersKey, unpack(ids))
|
|
120
|
+
local removed = 0
|
|
121
|
+
for i, requestId in ipairs(ids) do
|
|
122
|
+
local owner = owners[i]
|
|
123
|
+
-- In an Actor run, unlock both (1) requests locked by this
|
|
124
|
+
-- specific run (clientKey:runId) and (2) requests locked by the same
|
|
125
|
+
-- clientKey without run context (clientKey), but NOT requests locked by the
|
|
126
|
+
-- same clientKey in DIFFERENT runs (clientKey:otherRunId).
|
|
127
|
+
-- Exact clientKey, or this run's clientKey:runId. An empty runId must not
|
|
128
|
+
-- unlock every owner that merely starts with "clientKey:".
|
|
129
|
+
if owner and (owner == expectedOwner or owner == clientKey) then
|
|
130
|
+
redis.call('ZREM', lockedKey, requestId)
|
|
131
|
+
redis.call('HDEL', lockOwnersKey, requestId)
|
|
132
|
+
if forefront == '1' then
|
|
133
|
+
redis.call('ZADD', pendingKey, 0, requestId)
|
|
134
|
+
end
|
|
135
|
+
unlockedCount = unlockedCount + 1
|
|
136
|
+
removed = removed + 1
|
|
117
137
|
end
|
|
118
|
-
unlockedCount = unlockedCount + 1
|
|
119
138
|
end
|
|
139
|
+
if #ids < pageSize then break end
|
|
140
|
+
-- Removed locks were in this page, so the next unread lock moved up by that many.
|
|
141
|
+
start = start + #ids - removed
|
|
120
142
|
end
|
|
121
143
|
|
|
122
144
|
return unlockedCount
|
|
123
145
|
`;
|
|
124
146
|
async function unlockRequestsAtomic(redis, keys, params) {
|
|
125
|
-
const result = await redis.eval(exports.UNLOCK_REQUESTS_LUA, 3, keys.lockedKey, keys.lockOwnersKey, keys.pendingKey, params.clientKey, params.runId ?? '', params.forefront ? '1' : '0');
|
|
147
|
+
const result = await redis.eval(exports.UNLOCK_REQUESTS_LUA, 3, keys.lockedKey, keys.lockOwnersKey, keys.pendingKey, params.clientKey, params.runId ?? '', params.forefront ? '1' : '0', String(exports.UNLOCK_SCAN_PAGE));
|
|
126
148
|
return typeof result === 'number' ? result : parseInt(String(result), 10) || 0;
|
|
127
149
|
}
|
|
@@ -208,6 +208,12 @@ class RunMetadataManager {
|
|
|
208
208
|
ttlPipeline.ttl(this.getRunKey(runId));
|
|
209
209
|
}
|
|
210
210
|
const ttlResults = await ttlPipeline.exec();
|
|
211
|
+
// Runs whose hash is gone (TTL = -2: expired or deleted outside delete()) leave the
|
|
212
|
+
// sorted set here, so it doesn't keep one entry per run ever created.
|
|
213
|
+
const staleIds = olderIds.filter((_, i) => ttlResults?.[i]?.[1] === -2);
|
|
214
|
+
if (staleIds.length > 0) {
|
|
215
|
+
await this.client.redis.zrem(userRunsKey, ...staleIds);
|
|
216
|
+
}
|
|
211
217
|
// Collect only the runs that were demoted (exist but have no expiry, TTL = -1)
|
|
212
218
|
const demotedIds = olderIds.filter((_, i) => ttlResults?.[i]?.[1] === -1);
|
|
213
219
|
if (demotedIds.length === 0) {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type Redis from 'ioredis';
|
|
2
|
+
import type { ChainableCommander } from 'ioredis';
|
|
3
|
+
/** Queue INHERIT_EXPIRY_LUA: the lazily created `keys` expire with `infoKey` (nothing when it has no expiry). */
|
|
4
|
+
export declare function queueInheritExpiry(pipeline: ChainableCommander, infoKey: string, keys: string[]): void;
|
|
2
5
|
/**
|
|
3
6
|
* TTL configuration in seconds
|
|
4
7
|
*/
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.TtlManager = exports.DEFAULT_TTL_CONFIG = void 0;
|
|
4
|
+
exports.queueInheritExpiry = queueInheritExpiry;
|
|
5
|
+
/**
|
|
6
|
+
* KEYS[1]: a storage's info key; KEYS[2..]: its data keys. Gives each data key the info key's
|
|
7
|
+
* remaining expiry (NX: one that has an expiry keeps it). An expiry set at creation is a no-op
|
|
8
|
+
* on data keys that don't exist yet, so a key created lazily on the first write never expired.
|
|
9
|
+
*/
|
|
10
|
+
const INHERIT_EXPIRY_LUA = `
|
|
11
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
12
|
+
if ttl <= 0 then return 0 end
|
|
13
|
+
for i = 2, #KEYS do
|
|
14
|
+
redis.call('PEXPIRE', KEYS[i], ttl, 'NX')
|
|
15
|
+
end
|
|
16
|
+
return 1
|
|
17
|
+
`;
|
|
18
|
+
/** Queue INHERIT_EXPIRY_LUA: the lazily created `keys` expire with `infoKey` (nothing when it has no expiry). */
|
|
19
|
+
function queueInheritExpiry(pipeline, infoKey, keys) {
|
|
20
|
+
if (keys.length === 0)
|
|
21
|
+
return;
|
|
22
|
+
pipeline.eval(INHERIT_EXPIRY_LUA, keys.length + 1, infoKey, ...keys);
|
|
23
|
+
}
|
|
4
24
|
/**
|
|
5
25
|
* Default TTL configuration
|
|
6
26
|
*/
|
package/dist/storage/utils.d.ts
CHANGED
|
@@ -9,3 +9,22 @@ export declare function nowISO(): Date;
|
|
|
9
9
|
* segments even when they are percent-encoded.
|
|
10
10
|
*/
|
|
11
11
|
export declare function pathSegment(value: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Field of a named storage in the global `{prefix}datasets:index` /
|
|
14
|
+
* `kvs:index` / `rq:index` hash: scoped by owner (`name:{userId}:{name}`, the
|
|
15
|
+
* same field the server writes), so two accounts using one name never share
|
|
16
|
+
* or overwrite each other's pointer.
|
|
17
|
+
*/
|
|
18
|
+
export declare function ownerNameField(userId: string, name: string): string;
|
|
19
|
+
/** The owner a new storage is created for: the active billing context's user. */
|
|
20
|
+
export declare function requireStorageOwner(billingContext: {
|
|
21
|
+
userId?: string;
|
|
22
|
+
} | undefined, kind: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Remove an owner's name pointer, but only while it still points at `storageId`
|
|
25
|
+
* (a pointer the owner has since moved to another storage is left alone).
|
|
26
|
+
*/
|
|
27
|
+
export declare function releaseOwnerName(redis: {
|
|
28
|
+
hget(key: string, field: string): Promise<string | null>;
|
|
29
|
+
hdel(key: string, ...fields: string[]): Promise<number>;
|
|
30
|
+
}, indexKey: string, userId: string | null | undefined, name: string | null | undefined, storageId: string): Promise<void>;
|
package/dist/storage/utils.js
CHANGED
|
@@ -4,6 +4,9 @@ exports.uniqueKeyToRequestId = uniqueKeyToRequestId;
|
|
|
4
4
|
exports.generateId = generateId;
|
|
5
5
|
exports.nowISO = nowISO;
|
|
6
6
|
exports.pathSegment = pathSegment;
|
|
7
|
+
exports.ownerNameField = ownerNameField;
|
|
8
|
+
exports.requireStorageOwner = requireStorageOwner;
|
|
9
|
+
exports.releaseOwnerName = releaseOwnerName;
|
|
7
10
|
const node_crypto_1 = require("node:crypto");
|
|
8
11
|
function uniqueKeyToRequestId(uniqueKey) {
|
|
9
12
|
return (0, node_crypto_1.createHash)('sha256')
|
|
@@ -34,3 +37,39 @@ function pathSegment(value) {
|
|
|
34
37
|
}
|
|
35
38
|
return encodeURIComponent(value);
|
|
36
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Field of a named storage in the global `{prefix}datasets:index` /
|
|
42
|
+
* `kvs:index` / `rq:index` hash: scoped by owner (`name:{userId}:{name}`, the
|
|
43
|
+
* same field the server writes), so two accounts using one name never share
|
|
44
|
+
* or overwrite each other's pointer.
|
|
45
|
+
*/
|
|
46
|
+
function ownerNameField(userId, name) {
|
|
47
|
+
return `name:${userId}:${name}`;
|
|
48
|
+
}
|
|
49
|
+
/** The owner a new storage is created for: the active billing context's user. */
|
|
50
|
+
function requireStorageOwner(billingContext, kind) {
|
|
51
|
+
const userId = billingContext?.userId;
|
|
52
|
+
if (!userId)
|
|
53
|
+
throw new Error(`Cannot create a ${kind} without a billing context (no owner user id).`);
|
|
54
|
+
return userId;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Remove an owner's name pointer, but only while it still points at `storageId`
|
|
58
|
+
* (a pointer the owner has since moved to another storage is left alone).
|
|
59
|
+
*/
|
|
60
|
+
async function releaseOwnerName(redis, indexKey, userId, name, storageId) {
|
|
61
|
+
if (!userId || !name)
|
|
62
|
+
return;
|
|
63
|
+
const field = ownerNameField(userId, name);
|
|
64
|
+
const raw = await redis.hget(indexKey, field);
|
|
65
|
+
if (!raw)
|
|
66
|
+
return;
|
|
67
|
+
try {
|
|
68
|
+
if (JSON.parse(raw)?.id !== storageId)
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await redis.hdel(indexKey, field);
|
|
75
|
+
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface EnqueueLinksOptions {
|
|
|
26
26
|
url: string;
|
|
27
27
|
userData?: Record<string, any>;
|
|
28
28
|
} | null;
|
|
29
|
+
/** Most requests to enqueue from this page. Default: no limit */
|
|
30
|
+
limit?: number;
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
31
33
|
* Enqueues links found on the page using a CSS selector.
|
package/dist/utils.js
CHANGED
|
@@ -4,6 +4,8 @@ exports.utils = void 0;
|
|
|
4
4
|
exports.enqueueLinks = enqueueLinks;
|
|
5
5
|
exports.requestAsBrowser = requestAsBrowser;
|
|
6
6
|
exports.getRandomUserAgent = getRandomUserAgent;
|
|
7
|
+
/** Requests sent to the queue per addRequests call. */
|
|
8
|
+
const ENQUEUE_BATCH_SIZE = 100;
|
|
7
9
|
/**
|
|
8
10
|
* Enqueues links found on the page using a CSS selector.
|
|
9
11
|
*
|
|
@@ -22,7 +24,7 @@ exports.getRandomUserAgent = getRandomUserAgent;
|
|
|
22
24
|
* ```
|
|
23
25
|
*/
|
|
24
26
|
async function enqueueLinks(options) {
|
|
25
|
-
const { $, requestQueue, selector = 'a[href]', baseUrl, userData = {}, transformRequestFunction } = options;
|
|
27
|
+
const { $, requestQueue, selector = 'a[href]', baseUrl, userData = {}, transformRequestFunction, limit = Infinity, } = options;
|
|
26
28
|
// Collect all matching URLs
|
|
27
29
|
const urls = [];
|
|
28
30
|
$(selector).each((_, element) => {
|
|
@@ -41,8 +43,11 @@ async function enqueueLinks(options) {
|
|
|
41
43
|
});
|
|
42
44
|
// Deduplicate URLs
|
|
43
45
|
const uniqueUrls = [...new Set(urls)];
|
|
44
|
-
//
|
|
46
|
+
// Build the requests (up to `limit`), then add them to the queue in batches
|
|
47
|
+
const requests = [];
|
|
45
48
|
for (const url of uniqueUrls) {
|
|
49
|
+
if (requests.length >= limit)
|
|
50
|
+
break;
|
|
46
51
|
const request = {
|
|
47
52
|
url,
|
|
48
53
|
userData: { ...userData },
|
|
@@ -51,9 +56,11 @@ async function enqueueLinks(options) {
|
|
|
51
56
|
const transformed = transformRequestFunction
|
|
52
57
|
? transformRequestFunction(request)
|
|
53
58
|
: request;
|
|
54
|
-
if (transformed)
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
if (transformed)
|
|
60
|
+
requests.push(transformed);
|
|
61
|
+
}
|
|
62
|
+
for (let i = 0; i < requests.length; i += ENQUEUE_BATCH_SIZE) {
|
|
63
|
+
await requestQueue.addRequests(requests.slice(i, i + ENQUEUE_BATCH_SIZE));
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.41",
|
|
4
4
|
"description": "Scrapely SDK for building JavaScript and TypeScript Actors",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"test:charge-multi-event": "npx tsx --test tests/charge-multi-event.test.ts",
|
|
45
45
|
"test:charge-multi-event-void": "npx tsx --test tests/charge-multi-event-void.test.ts",
|
|
46
46
|
"test:charge-edge-cases": "npx tsx --test tests/charge-edge-cases.test.ts",
|
|
47
|
+
"test:charge-atomic-refusal-message": "npx tsx --test tests/charge-atomic-refusal-message.test.ts",
|
|
47
48
|
"test:charge-pending-persist": "npx tsx --test tests/charge-pending-persist.test.ts",
|
|
48
49
|
"test:charge-platform-budget": "npx tsx --test tests/charge-platform-budget.test.ts",
|
|
49
50
|
"test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
|
|
@@ -89,20 +90,20 @@
|
|
|
89
90
|
"crawlee": "^3.16.0",
|
|
90
91
|
"dockerode": "^4.0.9",
|
|
91
92
|
"ioredis": "^6.0.0",
|
|
92
|
-
"ip-address": "10.
|
|
93
|
+
"ip-address": "10.6.0",
|
|
93
94
|
"lodash": "^4.18.1",
|
|
94
95
|
"mysql2": "^3.23.1",
|
|
95
96
|
"playwright": "^1.58.2",
|
|
96
97
|
"scrapely-client": "^2.25.9",
|
|
97
98
|
"tar-fs": "2.1.5",
|
|
98
|
-
"ws": "^8.
|
|
99
|
+
"ws": "^8.21.0"
|
|
99
100
|
},
|
|
100
101
|
"devDependencies": {
|
|
101
102
|
"@types/dockerode": "^4.0.1",
|
|
102
103
|
"@types/node": "^25.5.0",
|
|
103
104
|
"@types/ws": "^8.5.10",
|
|
104
105
|
"dotenv": "^17.3.1",
|
|
105
|
-
"puppeteer-core": "^
|
|
106
|
+
"puppeteer-core": "^25.16.0",
|
|
106
107
|
"tsx": "^4.21.0",
|
|
107
108
|
"typescript": "^5.7.0"
|
|
108
109
|
},
|
|
@@ -113,8 +114,10 @@
|
|
|
113
114
|
"axios": "^1.20.0",
|
|
114
115
|
"brace-expansion": "5.0.9",
|
|
115
116
|
"fast-xml-builder": "^1.2.1",
|
|
117
|
+
"strnum": "^2.4.2",
|
|
116
118
|
"file-type": "^21.3.3",
|
|
117
119
|
"follow-redirects": "^1.16.1",
|
|
120
|
+
"protobufjs": "^7.6.6",
|
|
118
121
|
"@crawlee/core": {
|
|
119
122
|
"ow": "0.28.2"
|
|
120
123
|
},
|