scrapely 1.2.3 → 1.2.4
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/README.md +119 -1
- package/dist/actor.d.ts +703 -0
- package/dist/actor.js +1441 -0
- package/dist/browser-configuration.d.ts +58 -0
- package/dist/browser-configuration.js +128 -0
- package/dist/browser-locations.d.ts +5 -0
- package/dist/browser-locations.js +5469 -0
- package/dist/charging.d.ts +153 -0
- package/dist/charging.js +782 -0
- package/dist/charging_client.d.ts +18 -0
- package/dist/charging_client.js +49 -0
- package/dist/configuration.d.ts +121 -0
- package/dist/configuration.js +128 -0
- package/dist/crypto/secrets.d.ts +44 -0
- package/dist/crypto/secrets.js +164 -0
- package/dist/events/events-client.d.ts +59 -0
- package/dist/events/events-client.js +120 -0
- package/dist/events/scrapely-event-manager.d.ts +13 -0
- package/dist/events/scrapely-event-manager.js +61 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +95 -0
- package/dist/log-env.d.ts +1 -0
- package/dist/log-env.js +12 -0
- package/dist/manifest.d.ts +30 -0
- package/dist/manifest.js +47 -0
- package/dist/patch-browser-pool.d.ts +66 -0
- package/dist/patch-browser-pool.js +827 -0
- package/dist/proxy-configuration.d.ts +118 -0
- package/dist/proxy-configuration.js +88 -0
- package/dist/request-list.d.ts +57 -0
- package/dist/request-list.js +79 -0
- package/dist/runner/docker-manager.d.ts +88 -0
- package/dist/runner/docker-manager.js +283 -0
- package/dist/runner/index.d.ts +2 -0
- package/dist/runner/index.js +7 -0
- package/dist/runner/timeout-manager.d.ts +43 -0
- package/dist/runner/timeout-manager.js +90 -0
- package/dist/scrapely-proxy-configuration.d.ts +99 -0
- package/dist/scrapely-proxy-configuration.js +559 -0
- package/dist/storage/api-storage-client.d.ts +199 -0
- package/dist/storage/api-storage-client.js +910 -0
- package/dist/storage/billing-tracker.d.ts +163 -0
- package/dist/storage/billing-tracker.js +260 -0
- package/dist/storage/dataset-client.d.ts +43 -0
- package/dist/storage/dataset-client.js +232 -0
- package/dist/storage/dataset-collection-client.d.ts +8 -0
- package/dist/storage/dataset-collection-client.js +75 -0
- package/dist/storage/dataset-push-script.d.ts +18 -0
- package/dist/storage/dataset-push-script.js +38 -0
- package/dist/storage/dragonfly-client.d.ts +94 -0
- package/dist/storage/dragonfly-client.js +167 -0
- package/dist/storage/index.d.ts +11 -0
- package/dist/storage/index.js +25 -0
- package/dist/storage/key-value-store-client.d.ts +61 -0
- package/dist/storage/key-value-store-client.js +306 -0
- package/dist/storage/kv-store-collection-client.d.ts +8 -0
- package/dist/storage/kv-store-collection-client.js +76 -0
- package/dist/storage/kvs-iteration.d.ts +17 -0
- package/dist/storage/kvs-iteration.js +126 -0
- package/dist/storage/log-manager.d.ts +114 -0
- package/dist/storage/log-manager.js +244 -0
- package/dist/storage/patch-storage.d.ts +11 -0
- package/dist/storage/patch-storage.js +177 -0
- package/dist/storage/request-queue-client.d.ts +93 -0
- package/dist/storage/request-queue-client.js +522 -0
- package/dist/storage/rq-collection-client.d.ts +8 -0
- package/dist/storage/rq-collection-client.js +89 -0
- package/dist/storage/rq-list-requests.d.ts +59 -0
- package/dist/storage/rq-list-requests.js +114 -0
- package/dist/storage/rq-lock-script.d.ts +56 -0
- package/dist/storage/rq-lock-script.js +127 -0
- package/dist/storage/run-metadata.d.ts +98 -0
- package/dist/storage/run-metadata.js +256 -0
- package/dist/storage/s3-client.d.ts +103 -0
- package/dist/storage/s3-client.js +302 -0
- package/dist/storage/ttl-manager.d.ts +70 -0
- package/dist/storage/ttl-manager.js +115 -0
- package/dist/storage/utils.d.ts +3 -0
- package/dist/storage/utils.js +19 -0
- package/dist/storage-identifiers.d.ts +20 -0
- package/dist/storage-identifiers.js +6 -0
- package/dist/utils.d.ts +54 -0
- package/dist/utils.js +63 -0
- package/dist/validator.d.ts +24 -0
- package/dist/validator.js +158 -0
- package/package.json +90 -9
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DragonflyDatasetClient = void 0;
|
|
4
|
+
const utils_js_1 = require("./utils.js");
|
|
5
|
+
const billing_tracker_js_1 = require("./billing-tracker.js");
|
|
6
|
+
const dataset_push_script_js_1 = require("./dataset-push-script.js");
|
|
7
|
+
function makeAsyncIterableResult(promiseFn) {
|
|
8
|
+
const promise = promiseFn();
|
|
9
|
+
const iterable = {
|
|
10
|
+
[Symbol.asyncIterator]: () => {
|
|
11
|
+
let items = null;
|
|
12
|
+
let index = 0;
|
|
13
|
+
return {
|
|
14
|
+
async next() {
|
|
15
|
+
if (!items)
|
|
16
|
+
items = (await promise).items;
|
|
17
|
+
if (index >= items.length)
|
|
18
|
+
return { done: true, value: undefined };
|
|
19
|
+
return { done: false, value: items[index++] };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
then: promise.then.bind(promise),
|
|
24
|
+
catch: promise.catch.bind(promise),
|
|
25
|
+
finally: promise.finally.bind(promise),
|
|
26
|
+
[Symbol.toStringTag]: 'AsyncIterablePromise',
|
|
27
|
+
};
|
|
28
|
+
return iterable;
|
|
29
|
+
}
|
|
30
|
+
class DragonflyDatasetClient {
|
|
31
|
+
client;
|
|
32
|
+
id;
|
|
33
|
+
infoKey;
|
|
34
|
+
itemsKey;
|
|
35
|
+
constructor(client, id) {
|
|
36
|
+
this.client = client;
|
|
37
|
+
this.id = id;
|
|
38
|
+
this.infoKey = client.key('dataset', id, 'info');
|
|
39
|
+
this.itemsKey = client.key('dataset', id, 'items');
|
|
40
|
+
}
|
|
41
|
+
async get() {
|
|
42
|
+
const info = await this.client.redis.hgetall(this.infoKey);
|
|
43
|
+
if (!info || !info.id)
|
|
44
|
+
return undefined;
|
|
45
|
+
return {
|
|
46
|
+
id: info.id,
|
|
47
|
+
name: info.name || undefined,
|
|
48
|
+
createdAt: new Date(info.createdAt),
|
|
49
|
+
modifiedAt: new Date(info.modifiedAt),
|
|
50
|
+
accessedAt: new Date(info.accessedAt),
|
|
51
|
+
itemCount: parseInt(info.itemCount || '0', 10),
|
|
52
|
+
storageBytes: parseInt(info.storageBytes || '0', 10),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async update(newFields) {
|
|
56
|
+
const now = (0, utils_js_1.nowISO)();
|
|
57
|
+
if (newFields.name !== undefined) {
|
|
58
|
+
await this.client.redis.hset(this.infoKey, 'name', newFields.name);
|
|
59
|
+
const indexKey = this.client.key('datasets:index');
|
|
60
|
+
const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
|
|
61
|
+
if (raw) {
|
|
62
|
+
const data = JSON.parse(raw);
|
|
63
|
+
if (data.name)
|
|
64
|
+
await this.client.redis.hdel(indexKey, `name:${data.name}`);
|
|
65
|
+
data.name = newFields.name;
|
|
66
|
+
await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
|
|
67
|
+
await this.client.redis.hset(indexKey, `name:${newFields.name}`, JSON.stringify(data));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await this.client.redis.hset(this.infoKey, 'modifiedAt', now.toISOString());
|
|
71
|
+
return { name: newFields.name };
|
|
72
|
+
}
|
|
73
|
+
async delete() {
|
|
74
|
+
const info = await this.client.redis.hgetall(this.infoKey);
|
|
75
|
+
await this.client.redis.del(this.infoKey, this.itemsKey);
|
|
76
|
+
const indexKey = this.client.key('datasets:index');
|
|
77
|
+
await this.client.redis.hdel(indexKey, `id:${this.id}`);
|
|
78
|
+
if (info?.name)
|
|
79
|
+
await this.client.redis.hdel(indexKey, `name:${info.name}`);
|
|
80
|
+
}
|
|
81
|
+
async downloadItems() {
|
|
82
|
+
const result = await this._listItems();
|
|
83
|
+
return Buffer.from(JSON.stringify(result.items));
|
|
84
|
+
}
|
|
85
|
+
listItems(options) {
|
|
86
|
+
return makeAsyncIterableResult(() => this._listItems(options));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Iterates over dataset entries (index-value pairs) using an async generator.
|
|
90
|
+
* Required by Crawlee's Dataset.entries() method.
|
|
91
|
+
*/
|
|
92
|
+
listEntries(options) {
|
|
93
|
+
return makeAsyncIterableResult(async () => {
|
|
94
|
+
const result = await this._listItems(options);
|
|
95
|
+
const entries = result.items.map((item, idx) => [
|
|
96
|
+
(result.offset ?? 0) + idx,
|
|
97
|
+
item,
|
|
98
|
+
]);
|
|
99
|
+
return {
|
|
100
|
+
total: result.total,
|
|
101
|
+
count: entries.length,
|
|
102
|
+
offset: result.offset,
|
|
103
|
+
limit: result.limit,
|
|
104
|
+
items: entries,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
async _listItems(options) {
|
|
109
|
+
const exists = await this.client.redis.exists(this.infoKey);
|
|
110
|
+
if (!exists)
|
|
111
|
+
return { total: 0, count: 0, offset: 0, limit: 0, items: [] };
|
|
112
|
+
// Item indices are dense (0..N-1: pushes append sequentially and there is
|
|
113
|
+
// no per-item delete), so pages can be addressed by index directly instead
|
|
114
|
+
// of pulling the whole hash on every request (matches the server route).
|
|
115
|
+
const totalCount = await this.client.redis.hlen(this.itemsKey);
|
|
116
|
+
const desc = options?.desc ?? false;
|
|
117
|
+
const offset = options?.offset ?? 0;
|
|
118
|
+
const limit = options?.limit ?? totalCount;
|
|
119
|
+
const pageIndices = [];
|
|
120
|
+
for (let n = 0; n < limit && offset + n < totalCount; n++) {
|
|
121
|
+
pageIndices.push(desc ? totalCount - 1 - offset - n : offset + n);
|
|
122
|
+
}
|
|
123
|
+
let items = pageIndices.length
|
|
124
|
+
? (await this.client.redis.hmget(this.itemsKey, ...pageIndices.map(String)))
|
|
125
|
+
.filter((raw) => raw !== null)
|
|
126
|
+
.map(raw => JSON.parse(raw))
|
|
127
|
+
: [];
|
|
128
|
+
// Apply filtering options
|
|
129
|
+
items = this._applyFilters(items, options);
|
|
130
|
+
// Track reads for billing - increment both user-level and info hash counters
|
|
131
|
+
if (items.length > 0 && this.client.billingContext) {
|
|
132
|
+
const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
|
|
133
|
+
await billingTracker.trackRead(this.client.billingContext.userId, items.length);
|
|
134
|
+
await this.client.redis.hincrby(this.infoKey, 'readCount', items.length);
|
|
135
|
+
// Track internal data transfer (bytes returned to actor)
|
|
136
|
+
const bytesTransferred = items.reduce((sum, item) => sum + billing_tracker_js_1.BillingTracker.estimateItemBytes(item), 0);
|
|
137
|
+
await billingTracker.trackInternalTransfer(this.client.billingContext.runId, this.client.billingContext.userId, bytesTransferred);
|
|
138
|
+
// Register this dataset as accessed for per-run aggregation
|
|
139
|
+
await this.client.redis.sadd(this.client.key('run', this.client.billingContext.runId, 'datasets_accessed'), this.id);
|
|
140
|
+
}
|
|
141
|
+
return { total: totalCount, count: items.length, offset, limit, desc: options?.desc, items };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Apply filtering options to dataset items.
|
|
145
|
+
*/
|
|
146
|
+
_applyFilters(items, options) {
|
|
147
|
+
if (!options)
|
|
148
|
+
return items;
|
|
149
|
+
return items.map(item => {
|
|
150
|
+
let filteredItem = { ...item };
|
|
151
|
+
// Skip hidden fields (fields starting with "#")
|
|
152
|
+
if (options.skipHidden || options.clean) {
|
|
153
|
+
for (const key of Object.keys(filteredItem)) {
|
|
154
|
+
if (key.startsWith('#')) {
|
|
155
|
+
delete filteredItem[key];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Filter to only specified fields
|
|
160
|
+
if (options.fields && options.fields.length > 0) {
|
|
161
|
+
const filtered = {};
|
|
162
|
+
for (const field of options.fields) {
|
|
163
|
+
if (field in filteredItem) {
|
|
164
|
+
filtered[field] = filteredItem[field];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
filteredItem = filtered;
|
|
168
|
+
}
|
|
169
|
+
return filteredItem;
|
|
170
|
+
}).filter(item => {
|
|
171
|
+
// Skip empty items
|
|
172
|
+
if (options.skipEmpty || options.clean) {
|
|
173
|
+
return Object.keys(item).length > 0;
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async pushItems(items, billingContext) {
|
|
179
|
+
const exists = await this.client.redis.exists(this.infoKey);
|
|
180
|
+
if (!exists)
|
|
181
|
+
throw new Error(`Dataset with id: ${this.id} does not exist.`);
|
|
182
|
+
let parsed;
|
|
183
|
+
if (typeof items === 'string') {
|
|
184
|
+
try {
|
|
185
|
+
parsed = JSON.parse(items);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new Error('Data items must be a JSON object, array, or JSON string.');
|
|
189
|
+
}
|
|
190
|
+
if (!Array.isArray(parsed))
|
|
191
|
+
parsed = [parsed];
|
|
192
|
+
}
|
|
193
|
+
else if (Array.isArray(items)) {
|
|
194
|
+
parsed = items.map(i => (typeof i === 'string' ? JSON.parse(i) : i));
|
|
195
|
+
}
|
|
196
|
+
else if (typeof items === 'object' && items !== null) {
|
|
197
|
+
parsed = [items];
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
throw new Error('Data items must be a JSON object, array, or JSON string.');
|
|
201
|
+
}
|
|
202
|
+
for (const item of parsed) {
|
|
203
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
204
|
+
throw new Error('Each item must be a JSON object.');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Calculate total bytes for billing
|
|
208
|
+
let totalBytes = 0;
|
|
209
|
+
const serializedItems = [];
|
|
210
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
211
|
+
const serialized = JSON.stringify(parsed[i]);
|
|
212
|
+
serializedItems.push(serialized);
|
|
213
|
+
totalBytes += billing_tracker_js_1.BillingTracker.estimateItemBytes(parsed[i]);
|
|
214
|
+
}
|
|
215
|
+
// Atomic slot allocation: the previous read-then-write of itemCount in
|
|
216
|
+
// separate round trips allowed concurrent pushes to allocate the same
|
|
217
|
+
// slot indices, silently overwriting items.
|
|
218
|
+
await (0, dataset_push_script_js_1.pushItemsAtomic)(this.client.redis, this.infoKey, this.itemsKey, serializedItems, totalBytes);
|
|
219
|
+
// Track billing using client's billing context
|
|
220
|
+
if (this.client.billingContext) {
|
|
221
|
+
const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
|
|
222
|
+
await billingTracker.trackStorageBytes(this.client.billingContext.runId, this.client.billingContext.userId, totalBytes);
|
|
223
|
+
await billingTracker.trackWrite(this.client.billingContext.userId, parsed.length);
|
|
224
|
+
await this.client.redis.hincrby(this.infoKey, 'writeCount', parsed.length);
|
|
225
|
+
// Track internal data transfer (bytes written to storage)
|
|
226
|
+
await billingTracker.trackInternalTransfer(this.client.billingContext.runId, this.client.billingContext.userId, totalBytes);
|
|
227
|
+
// Register this dataset as accessed for per-run aggregation
|
|
228
|
+
await this.client.redis.sadd(this.client.key('run', this.client.billingContext.runId, 'datasets_accessed'), this.id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
exports.DragonflyDatasetClient = DragonflyDatasetClient;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DatasetCollectionClient, DatasetCollectionData, PaginatedList, Dataset } from '@crawlee/types';
|
|
2
|
+
import type { DragonflyClient } from './dragonfly-client.js';
|
|
3
|
+
export declare class DragonflyDatasetCollectionClient implements DatasetCollectionClient {
|
|
4
|
+
private client;
|
|
5
|
+
constructor(client: DragonflyClient);
|
|
6
|
+
getOrCreate(name?: string): Promise<DatasetCollectionData>;
|
|
7
|
+
list(): Promise<PaginatedList<Dataset>>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DragonflyDatasetCollectionClient = void 0;
|
|
4
|
+
const utils_js_1 = require("./utils.js");
|
|
5
|
+
class DragonflyDatasetCollectionClient {
|
|
6
|
+
client;
|
|
7
|
+
constructor(client) {
|
|
8
|
+
this.client = client;
|
|
9
|
+
}
|
|
10
|
+
async getOrCreate(name) {
|
|
11
|
+
const indexKey = this.client.key('datasets:index');
|
|
12
|
+
if (name) {
|
|
13
|
+
const existing = await this.client.redis.hget(indexKey, `name:${name}`);
|
|
14
|
+
if (existing)
|
|
15
|
+
return JSON.parse(existing);
|
|
16
|
+
}
|
|
17
|
+
const id = (0, utils_js_1.generateId)();
|
|
18
|
+
const now = (0, utils_js_1.nowISO)();
|
|
19
|
+
const data = {
|
|
20
|
+
id,
|
|
21
|
+
name,
|
|
22
|
+
createdAt: now,
|
|
23
|
+
modifiedAt: now,
|
|
24
|
+
accessedAt: now,
|
|
25
|
+
};
|
|
26
|
+
await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
|
|
27
|
+
if (name)
|
|
28
|
+
await this.client.redis.hset(indexKey, `name:${name}`, JSON.stringify(data));
|
|
29
|
+
const infoKey = this.client.key('dataset', id, 'info');
|
|
30
|
+
const itemsKey = this.client.key('dataset', id, 'items');
|
|
31
|
+
await this.client.redis.hset(infoKey, {
|
|
32
|
+
id,
|
|
33
|
+
name: name ?? '',
|
|
34
|
+
itemCount: '0',
|
|
35
|
+
createdAt: now.toISOString(),
|
|
36
|
+
modifiedAt: now.toISOString(),
|
|
37
|
+
accessedAt: now.toISOString(),
|
|
38
|
+
});
|
|
39
|
+
// Set TTL for unnamed datasets (named datasets have indefinite retention)
|
|
40
|
+
if (!name) {
|
|
41
|
+
const ttl = this.client.ttlManager.getTtl('dataset', this.client.userPlan, false);
|
|
42
|
+
if (ttl) {
|
|
43
|
+
await this.client.ttlManager.setExpiry(this.client.redis, [infoKey, itemsKey], ttl);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
async list() {
|
|
49
|
+
const indexKey = this.client.key('datasets:index');
|
|
50
|
+
const all = await this.client.redis.hgetall(indexKey);
|
|
51
|
+
const items = [];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const [key, val] of Object.entries(all)) {
|
|
54
|
+
if (!key.startsWith('id:'))
|
|
55
|
+
continue;
|
|
56
|
+
const parsed = JSON.parse(val);
|
|
57
|
+
if (seen.has(parsed.id))
|
|
58
|
+
continue;
|
|
59
|
+
seen.add(parsed.id);
|
|
60
|
+
const infoKey = this.client.key('dataset', parsed.id, 'info');
|
|
61
|
+
// Skip and clean up stale index entries whose keys have expired via TTL
|
|
62
|
+
const exists = await this.client.redis.exists(infoKey);
|
|
63
|
+
if (!exists) {
|
|
64
|
+
await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
|
|
65
|
+
if (parsed.name)
|
|
66
|
+
await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const itemCount = parseInt(await this.client.redis.hget(infoKey, 'itemCount') || '0', 10);
|
|
70
|
+
items.push({ ...parsed, itemCount });
|
|
71
|
+
}
|
|
72
|
+
return { total: items.length, count: items.length, offset: 0, limit: items.length, items };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
exports.DragonflyDatasetCollectionClient = DragonflyDatasetCollectionClient;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type Redis from 'ioredis';
|
|
2
|
+
/**
|
|
3
|
+
* Atomic dataset push: allocates consecutive item slots starting at the current
|
|
4
|
+
* itemCount, writes the items, and updates itemCount/storageBytes/modifiedAt in
|
|
5
|
+
* a single script.
|
|
6
|
+
*
|
|
7
|
+
* Without this, pushItems did a non-atomic `HGET itemCount` followed by HSETs of
|
|
8
|
+
* `count..count+n` in a pipeline, so two concurrent pushes could read the same
|
|
9
|
+
* count and write overlapping slots — silently overwriting items and skewing
|
|
10
|
+
* itemCount away from HLEN (which also broke the dense-index pagination
|
|
11
|
+
* assumption used by both this client and the server route).
|
|
12
|
+
*/
|
|
13
|
+
export declare const PUSH_ITEMS_LUA = "\nlocal infoKey = KEYS[1]\nlocal itemsKey = KEYS[2]\nlocal now = ARGV[1]\nlocal bytes = tonumber(ARGV[2])\nlocal count = tonumber(redis.call('HGET', infoKey, 'itemCount') or '0')\nfor i = 3, #ARGV do\n redis.call('HSET', itemsKey, tostring(count), ARGV[i])\n count = count + 1\nend\nredis.call('HSET', infoKey, 'itemCount', tostring(count))\nredis.call('HINCRBY', infoKey, 'storageBytes', bytes)\nredis.call('HSET', infoKey, 'modifiedAt', now)\nreturn count\n";
|
|
14
|
+
/**
|
|
15
|
+
* Pushes serialized items into the dataset hash, allocating dense consecutive
|
|
16
|
+
* slots atomically. Returns the new itemCount.
|
|
17
|
+
*/
|
|
18
|
+
export declare function pushItemsAtomic(redis: Redis, infoKey: string, itemsKey: string, serializedItems: string[], totalBytes: number): Promise<number>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PUSH_ITEMS_LUA = void 0;
|
|
4
|
+
exports.pushItemsAtomic = pushItemsAtomic;
|
|
5
|
+
/**
|
|
6
|
+
* Atomic dataset push: allocates consecutive item slots starting at the current
|
|
7
|
+
* itemCount, writes the items, and updates itemCount/storageBytes/modifiedAt in
|
|
8
|
+
* a single script.
|
|
9
|
+
*
|
|
10
|
+
* Without this, pushItems did a non-atomic `HGET itemCount` followed by HSETs of
|
|
11
|
+
* `count..count+n` in a pipeline, so two concurrent pushes could read the same
|
|
12
|
+
* count and write overlapping slots — silently overwriting items and skewing
|
|
13
|
+
* itemCount away from HLEN (which also broke the dense-index pagination
|
|
14
|
+
* assumption used by both this client and the server route).
|
|
15
|
+
*/
|
|
16
|
+
exports.PUSH_ITEMS_LUA = `
|
|
17
|
+
local infoKey = KEYS[1]
|
|
18
|
+
local itemsKey = KEYS[2]
|
|
19
|
+
local now = ARGV[1]
|
|
20
|
+
local bytes = tonumber(ARGV[2])
|
|
21
|
+
local count = tonumber(redis.call('HGET', infoKey, 'itemCount') or '0')
|
|
22
|
+
for i = 3, #ARGV do
|
|
23
|
+
redis.call('HSET', itemsKey, tostring(count), ARGV[i])
|
|
24
|
+
count = count + 1
|
|
25
|
+
end
|
|
26
|
+
redis.call('HSET', infoKey, 'itemCount', tostring(count))
|
|
27
|
+
redis.call('HINCRBY', infoKey, 'storageBytes', bytes)
|
|
28
|
+
redis.call('HSET', infoKey, 'modifiedAt', now)
|
|
29
|
+
return count
|
|
30
|
+
`;
|
|
31
|
+
/**
|
|
32
|
+
* Pushes serialized items into the dataset hash, allocating dense consecutive
|
|
33
|
+
* slots atomically. Returns the new itemCount.
|
|
34
|
+
*/
|
|
35
|
+
async function pushItemsAtomic(redis, infoKey, itemsKey, serializedItems, totalBytes) {
|
|
36
|
+
const result = await redis.eval(exports.PUSH_ITEMS_LUA, 2, infoKey, itemsKey, new Date().toISOString(), String(totalBytes), ...serializedItems);
|
|
37
|
+
return typeof result === 'number' ? result : parseInt(String(result), 10);
|
|
38
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import Redis from 'ioredis';
|
|
2
|
+
import type { StorageClient, SetStatusMessageOptions, RequestQueueOptions } from '@crawlee/types';
|
|
3
|
+
import { DragonflyDatasetCollectionClient } from './dataset-collection-client.js';
|
|
4
|
+
import { DragonflyDatasetClient } from './dataset-client.js';
|
|
5
|
+
import { DragonflyKeyValueStoreCollectionClient } from './kv-store-collection-client.js';
|
|
6
|
+
import { DragonflyKeyValueStoreClient } from './key-value-store-client.js';
|
|
7
|
+
import { DragonflyRequestQueueCollectionClient } from './rq-collection-client.js';
|
|
8
|
+
import { DragonflyRequestQueueClient } from './request-queue-client.js';
|
|
9
|
+
import { TtlManager, TtlConfig, UserPlan } from './ttl-manager.js';
|
|
10
|
+
export interface BillingContext {
|
|
11
|
+
runId: string;
|
|
12
|
+
userId: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DragonflyClientOptions {
|
|
15
|
+
host: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
password?: string;
|
|
18
|
+
keyPrefix?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Single-tenant escape hatch: allow purge() to delete the entire default
|
|
21
|
+
* `crawlee:` key prefix even when no billingContext.runId is set. The
|
|
22
|
+
* `crawlee:` namespace is shared by every user/run on the platform, so
|
|
23
|
+
* purging it wholesale would delete other tenants' data — hence opt-in.
|
|
24
|
+
*/
|
|
25
|
+
allowUnscopedPurge?: boolean;
|
|
26
|
+
/** User plan for TTL determination */
|
|
27
|
+
userPlan?: UserPlan;
|
|
28
|
+
/** Custom TTL configuration */
|
|
29
|
+
ttlConfig?: Partial<TtlConfig>;
|
|
30
|
+
/** Billing context for tracking usage */
|
|
31
|
+
billingContext?: BillingContext;
|
|
32
|
+
}
|
|
33
|
+
export declare class DragonflyClient implements StorageClient {
|
|
34
|
+
readonly redis: Redis;
|
|
35
|
+
readonly keyPrefix: string;
|
|
36
|
+
readonly ttlManager: TtlManager;
|
|
37
|
+
readonly userPlan: UserPlan;
|
|
38
|
+
readonly billingContext?: BillingContext;
|
|
39
|
+
readonly allowUnscopedPurge: boolean;
|
|
40
|
+
stats: {
|
|
41
|
+
rateLimitErrors: number[];
|
|
42
|
+
};
|
|
43
|
+
constructor(options: DragonflyClientOptions);
|
|
44
|
+
key(...parts: string[]): string;
|
|
45
|
+
datasets(): DragonflyDatasetCollectionClient;
|
|
46
|
+
dataset(id: string): DragonflyDatasetClient;
|
|
47
|
+
keyValueStores(): DragonflyKeyValueStoreCollectionClient;
|
|
48
|
+
keyValueStore(id: string): DragonflyKeyValueStoreClient;
|
|
49
|
+
requestQueues(): DragonflyRequestQueueCollectionClient;
|
|
50
|
+
requestQueue(id: string, options?: RequestQueueOptions): DragonflyRequestQueueClient;
|
|
51
|
+
/**
|
|
52
|
+
* Purges storage, mirroring Crawlee's StorageClient.purge() contract: clear
|
|
53
|
+
* only the current run's default storages (dataset, KV store, request queue).
|
|
54
|
+
*
|
|
55
|
+
* Scoping rules — the default `crawlee:` prefix is shared by every user/run
|
|
56
|
+
* on the platform (datasets, request queues, billing, auth tokens all live
|
|
57
|
+
* under it), so purging the whole prefix would delete other tenants' data:
|
|
58
|
+
*
|
|
59
|
+
* 1. With `billingContext.runId`: resolve the run's storageIds from
|
|
60
|
+
* `{prefix}run:{runId}` and delete only those three storage namespaces.
|
|
61
|
+
* 2. Without a run ID, but with a custom (scoped) `keyPrefix`: delete the
|
|
62
|
+
* whole prefix — it is assumed dedicated to this client.
|
|
63
|
+
* 3. Without a run ID and with the shared default prefix: refuse, unless
|
|
64
|
+
* `allowUnscopedPurge` was set explicitly (single-tenant opt-in).
|
|
65
|
+
*
|
|
66
|
+
* Deletion uses SCAN + batched UNLINK (see deleteByPattern) instead of a
|
|
67
|
+
* blocking KEYS walk followed by a single huge DEL.
|
|
68
|
+
*/
|
|
69
|
+
purge(): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Delete the default storages (dataset / KV store / request queue) of a run,
|
|
72
|
+
* resolved from the run's metadata hash (`{prefix}run:{runId}` → storageIds).
|
|
73
|
+
*
|
|
74
|
+
* @returns the number of keys deleted, or null when run metadata is missing
|
|
75
|
+
* (expired or never created) and nothing could be scoped.
|
|
76
|
+
*/
|
|
77
|
+
private purgeRunDefaultStorages;
|
|
78
|
+
/**
|
|
79
|
+
* Incrementally delete every key matching `pattern` using SCAN + batched
|
|
80
|
+
* UNLINK. Unlike KEYS + DEL, this never walks the keyspace in one blocking
|
|
81
|
+
* pass, never materializes the full key list in a single reply, and never
|
|
82
|
+
* sends one gigantic delete command; UNLINK also reclaims memory
|
|
83
|
+
* asynchronously. Keys that vanish mid-scan are simply no-ops.
|
|
84
|
+
*
|
|
85
|
+
* @returns number of keys deleted (best effort).
|
|
86
|
+
*/
|
|
87
|
+
deleteByPattern(pattern: string, options?: {
|
|
88
|
+
scanCount?: number;
|
|
89
|
+
unlinkBatch?: number;
|
|
90
|
+
}): Promise<number>;
|
|
91
|
+
teardown(): Promise<void>;
|
|
92
|
+
disconnect(): Promise<void>;
|
|
93
|
+
setStatusMessage(message: string, options?: SetStatusMessageOptions): Promise<void>;
|
|
94
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DragonflyClient = void 0;
|
|
7
|
+
const ioredis_1 = __importDefault(require("ioredis"));
|
|
8
|
+
const dataset_collection_client_js_1 = require("./dataset-collection-client.js");
|
|
9
|
+
const dataset_client_js_1 = require("./dataset-client.js");
|
|
10
|
+
const kv_store_collection_client_js_1 = require("./kv-store-collection-client.js");
|
|
11
|
+
const key_value_store_client_js_1 = require("./key-value-store-client.js");
|
|
12
|
+
const rq_collection_client_js_1 = require("./rq-collection-client.js");
|
|
13
|
+
const request_queue_client_js_1 = require("./request-queue-client.js");
|
|
14
|
+
const ttl_manager_js_1 = require("./ttl-manager.js");
|
|
15
|
+
/** The platform-wide storage namespace shared by all users and runs. */
|
|
16
|
+
const PLATFORM_KEY_PREFIX = 'crawlee:';
|
|
17
|
+
/** SCAN COUNT hint for deleteByPattern(): keys examined per scan step. */
|
|
18
|
+
const DEFAULT_SCAN_COUNT = 500;
|
|
19
|
+
/** Max keys per UNLINK call in deleteByPattern(): keeps command buffers small. */
|
|
20
|
+
const DEFAULT_UNLINK_BATCH = 1000;
|
|
21
|
+
class DragonflyClient {
|
|
22
|
+
redis;
|
|
23
|
+
keyPrefix;
|
|
24
|
+
ttlManager;
|
|
25
|
+
userPlan;
|
|
26
|
+
billingContext;
|
|
27
|
+
allowUnscopedPurge;
|
|
28
|
+
stats = { rateLimitErrors: [] };
|
|
29
|
+
constructor(options) {
|
|
30
|
+
const redisOpts = {
|
|
31
|
+
host: options.host,
|
|
32
|
+
port: options.port ?? 6379,
|
|
33
|
+
lazyConnect: false,
|
|
34
|
+
retryStrategy: (times) => Math.min(times * 100, 3000),
|
|
35
|
+
keepAlive: 10000,
|
|
36
|
+
};
|
|
37
|
+
if (options.password)
|
|
38
|
+
redisOpts.password = options.password;
|
|
39
|
+
this.redis = new ioredis_1.default(redisOpts);
|
|
40
|
+
this.redis.on('error', () => { });
|
|
41
|
+
this.keyPrefix = options.keyPrefix ?? PLATFORM_KEY_PREFIX;
|
|
42
|
+
this.userPlan = options.userPlan ?? 'free';
|
|
43
|
+
this.ttlManager = new ttl_manager_js_1.TtlManager(options.ttlConfig);
|
|
44
|
+
this.billingContext = options.billingContext;
|
|
45
|
+
this.allowUnscopedPurge = options.allowUnscopedPurge ?? false;
|
|
46
|
+
}
|
|
47
|
+
key(...parts) {
|
|
48
|
+
return this.keyPrefix + parts.join(':');
|
|
49
|
+
}
|
|
50
|
+
datasets() {
|
|
51
|
+
return new dataset_collection_client_js_1.DragonflyDatasetCollectionClient(this);
|
|
52
|
+
}
|
|
53
|
+
dataset(id) {
|
|
54
|
+
return new dataset_client_js_1.DragonflyDatasetClient(this, id);
|
|
55
|
+
}
|
|
56
|
+
keyValueStores() {
|
|
57
|
+
return new kv_store_collection_client_js_1.DragonflyKeyValueStoreCollectionClient(this);
|
|
58
|
+
}
|
|
59
|
+
keyValueStore(id) {
|
|
60
|
+
return new key_value_store_client_js_1.DragonflyKeyValueStoreClient(this, id);
|
|
61
|
+
}
|
|
62
|
+
requestQueues() {
|
|
63
|
+
return new rq_collection_client_js_1.DragonflyRequestQueueCollectionClient(this);
|
|
64
|
+
}
|
|
65
|
+
requestQueue(id, options) {
|
|
66
|
+
return new request_queue_client_js_1.DragonflyRequestQueueClient(this, id, options);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Purges storage, mirroring Crawlee's StorageClient.purge() contract: clear
|
|
70
|
+
* only the current run's default storages (dataset, KV store, request queue).
|
|
71
|
+
*
|
|
72
|
+
* Scoping rules — the default `crawlee:` prefix is shared by every user/run
|
|
73
|
+
* on the platform (datasets, request queues, billing, auth tokens all live
|
|
74
|
+
* under it), so purging the whole prefix would delete other tenants' data:
|
|
75
|
+
*
|
|
76
|
+
* 1. With `billingContext.runId`: resolve the run's storageIds from
|
|
77
|
+
* `{prefix}run:{runId}` and delete only those three storage namespaces.
|
|
78
|
+
* 2. Without a run ID, but with a custom (scoped) `keyPrefix`: delete the
|
|
79
|
+
* whole prefix — it is assumed dedicated to this client.
|
|
80
|
+
* 3. Without a run ID and with the shared default prefix: refuse, unless
|
|
81
|
+
* `allowUnscopedPurge` was set explicitly (single-tenant opt-in).
|
|
82
|
+
*
|
|
83
|
+
* Deletion uses SCAN + batched UNLINK (see deleteByPattern) instead of a
|
|
84
|
+
* blocking KEYS walk followed by a single huge DEL.
|
|
85
|
+
*/
|
|
86
|
+
async purge() {
|
|
87
|
+
const runId = this.billingContext?.runId;
|
|
88
|
+
if (runId && (await this.purgeRunDefaultStorages(runId)) !== null)
|
|
89
|
+
return;
|
|
90
|
+
if (this.keyPrefix === PLATFORM_KEY_PREFIX && !this.allowUnscopedPurge) {
|
|
91
|
+
throw new Error('DragonflyClient.purge() refused: keyPrefix "crawlee:" is shared by all users/runs ' +
|
|
92
|
+
"of the platform, so purging it would delete other tenants' data. Provide a " +
|
|
93
|
+
"billingContext.runId (purges only that run's default storages), pass a scoped " +
|
|
94
|
+
'keyPrefix, or set allowUnscopedPurge: true for single-tenant deployments.');
|
|
95
|
+
}
|
|
96
|
+
await this.deleteByPattern(`${this.keyPrefix}*`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Delete the default storages (dataset / KV store / request queue) of a run,
|
|
100
|
+
* resolved from the run's metadata hash (`{prefix}run:{runId}` → storageIds).
|
|
101
|
+
*
|
|
102
|
+
* @returns the number of keys deleted, or null when run metadata is missing
|
|
103
|
+
* (expired or never created) and nothing could be scoped.
|
|
104
|
+
*/
|
|
105
|
+
async purgeRunDefaultStorages(runId) {
|
|
106
|
+
const data = await this.redis.hgetall(this.key('run', runId));
|
|
107
|
+
if (!data || !data.storageIds)
|
|
108
|
+
return null;
|
|
109
|
+
let storageIds;
|
|
110
|
+
try {
|
|
111
|
+
storageIds = JSON.parse(data.storageIds);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null; // corrupt metadata — do not guess, fall back to prefix rules
|
|
115
|
+
}
|
|
116
|
+
let deleted = 0;
|
|
117
|
+
if (storageIds.dataset)
|
|
118
|
+
deleted += await this.deleteByPattern(this.key('dataset', storageIds.dataset, '*'));
|
|
119
|
+
if (storageIds.kvStore)
|
|
120
|
+
deleted += await this.deleteByPattern(this.key('kvs', storageIds.kvStore, '*'));
|
|
121
|
+
if (storageIds.requestQueue)
|
|
122
|
+
deleted += await this.deleteByPattern(this.key('rq', storageIds.requestQueue, '*'));
|
|
123
|
+
return deleted;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Incrementally delete every key matching `pattern` using SCAN + batched
|
|
127
|
+
* UNLINK. Unlike KEYS + DEL, this never walks the keyspace in one blocking
|
|
128
|
+
* pass, never materializes the full key list in a single reply, and never
|
|
129
|
+
* sends one gigantic delete command; UNLINK also reclaims memory
|
|
130
|
+
* asynchronously. Keys that vanish mid-scan are simply no-ops.
|
|
131
|
+
*
|
|
132
|
+
* @returns number of keys deleted (best effort).
|
|
133
|
+
*/
|
|
134
|
+
async deleteByPattern(pattern, options) {
|
|
135
|
+
const scanCount = Math.max(1, options?.scanCount ?? DEFAULT_SCAN_COUNT);
|
|
136
|
+
const unlinkBatch = Math.max(1, options?.unlinkBatch ?? DEFAULT_UNLINK_BATCH);
|
|
137
|
+
let deleted = 0;
|
|
138
|
+
let cursor = '0';
|
|
139
|
+
do {
|
|
140
|
+
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount);
|
|
141
|
+
cursor = nextCursor;
|
|
142
|
+
// A single SCAN reply can exceed COUNT, so chunk the UNLINKs too.
|
|
143
|
+
for (let i = 0; i < keys.length; i += unlinkBatch) {
|
|
144
|
+
const chunk = keys.slice(i, i + unlinkBatch);
|
|
145
|
+
await this.redis.unlink(...chunk);
|
|
146
|
+
deleted += chunk.length;
|
|
147
|
+
}
|
|
148
|
+
} while (cursor !== '0');
|
|
149
|
+
return deleted;
|
|
150
|
+
}
|
|
151
|
+
async teardown() {
|
|
152
|
+
// no-op: crawlee calls this during crawler.run() shutdown
|
|
153
|
+
// we manage the Redis connection lifecycle ourselves via disconnectDragonfly()
|
|
154
|
+
}
|
|
155
|
+
async disconnect() {
|
|
156
|
+
await this.redis.quit();
|
|
157
|
+
}
|
|
158
|
+
async setStatusMessage(message, options) {
|
|
159
|
+
const statusKey = this.key('status');
|
|
160
|
+
await this.redis.hset(statusKey, {
|
|
161
|
+
message,
|
|
162
|
+
level: options?.level ?? 'INFO',
|
|
163
|
+
isTerminal: options?.isStatusMessageTerminal ? '1' : '0',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
exports.DragonflyClient = DragonflyClient;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { DragonflyClient } from './dragonfly-client.js';
|
|
2
|
+
export { TtlManager, type UserPlan, type StorageType, type TtlConfig } from './ttl-manager.js';
|
|
3
|
+
export { DragonflyDatasetClient } from './dataset-client.js';
|
|
4
|
+
export { DragonflyKeyValueStoreClient } from './key-value-store-client.js';
|
|
5
|
+
export { DragonflyRequestQueueClient } from './request-queue-client.js';
|
|
6
|
+
export { DragonflyDatasetCollectionClient } from './dataset-collection-client.js';
|
|
7
|
+
export { DragonflyKeyValueStoreCollectionClient } from './kv-store-collection-client.js';
|
|
8
|
+
export { DragonflyRequestQueueCollectionClient } from './rq-collection-client.js';
|
|
9
|
+
export { RunMetadataManager, type RunMetadata, type RunStatus } from './run-metadata.js';
|
|
10
|
+
export { LogManager, type LogConfig } from './log-manager.js';
|
|
11
|
+
export { BillingTracker, type BillingSummary } from './billing-tracker.js';
|