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
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.2.41] - 2026-11-08
|
|
8
|
+
|
|
9
|
+
- An atomic multi-event push (`Actor.pushData(rows, { charges })`) whose charge the limit covers only in part no longer logs "Charging 5 instances of 'audio-minute' event would exceed the end of the account balance - only 3 events were charged". A row is saved only when all of its events are charged, so the SDK gives those 3 back and nothing is charged: the line was untrue. It now says what happened: "Saving this item needs 5 'audio-minute' event(s), but the account balance covers only 3: it was not saved and nothing was charged for it." When some rows still fit, it names how many were saved and charged in full, and when giving a charge back is not confirmed it says so instead of "nothing was charged". A plain `Actor.charge()` that comes back short keeps its line: those events do stay charged.
|
|
10
|
+
- `ChargeResult.coveredCounts` (from `pushData(rows, { charges })`): for each event whose charge came back short, how many events the limit covered (given back, not charged), e.g. `{ 'audio-minute': 3 }`. Actors can cut a row to what the limit covers without reading the log. What is charged and saved does not change.
|
|
11
|
+
- A spending limit of $0 (`maxTotalChargeUsd: 0`) is a real limit, the same as on the platform. It used to be read as "no limit". A missing or non-numeric value still means no limit.
|
|
12
|
+
- A plain `Actor.pushData(items)` to the default dataset of a pay-per-event Actor whose dataset item event is priced goes through the charged push: rows past the spending limit are not saved, and it returns a `ChargeResult`. Before, all rows were saved and only the ones within the limit were paid.
|
|
13
|
+
- A dataset push that fails with a `503 push-outcome-unknown` (the server could not tell whether it was stored) keeps its charge instead of voiding it.
|
|
14
|
+
- `utils.enqueueLinks({ limit })`: enqueue at most `limit` links per page; links are added to the queue in batches of 100.
|
|
15
|
+
- Dragonfly storage clients: named datasets, key-value stores and request queues are looked up per owner (`name:{userId}:{name}`, as the server stores them), a storage is created only with a known owner, and keys created later (dataset items, records, the log truncation marker) take their storage's expiry. Actor runs on the platform use the API storage client and are not affected.
|
|
16
|
+
|
|
17
|
+
## [1.2.40] - 2026-10-30
|
|
18
|
+
|
|
19
|
+
- Actors on puppeteer 25 (ESM-only) are sent to the remote browser when puppeteer is loaded with `require()` before the SDK on a run without the preload. The SDK only recognised puppeteer's old `lib/cjs` build, so such a `puppeteer.launch()` tried to start a local browser; the named `launch` export (`const { launch } = require('puppeteer')`) is covered too. puppeteer 24 and older behave as before.
|
|
20
|
+
|
|
7
21
|
## [1.2.39] - 2026-10-29
|
|
8
22
|
|
|
9
23
|
- A refused charge names the limit that refused it. When a charge is refused or cut short, the platform now says why (HTTP 402 `error.data.reason`, and `limitedBy` on a partial acceptance): the run's item limit (`maxItems`), its maximum total charge (`maxTotalChargeUsd`) or the account balance. The SDK used to call all of them the spending limit. Now its log lines name the real limit; `ChargingManager.getChargeLimitReason()` returns `'max-items'`, `'max-charge'` or `'insufficient-funds'` and `getChargeLimitMessage()` the stop message ("Stopped: reached the item limit you set (800 items)", "Stopped: reached the maximum charge you set ($3.00)", "Stopped: your account has no credit left for this run"); and once a limit was reached, a status message (`Actor.setStatusMessage`, `Actor.exit`, `Actor.fail`) whose clause blames the maximum charge gets that clause replaced by the real stop message. Platforms that do not send the reason yet: inferred from the 402 message and `ACTOR_MAX_PAID_DATASET_ITEMS`. What is charged and saved does not change.
|
package/dist/actor.js
CHANGED
|
@@ -603,6 +603,17 @@ class Actor {
|
|
|
603
603
|
}
|
|
604
604
|
const { Dataset } = await import('@crawlee/core');
|
|
605
605
|
const dataset = await Dataset.open();
|
|
606
|
+
const isDefault = dataset.id === this.config.get('defaultDatasetId');
|
|
607
|
+
// A priced default-dataset item event: rows are trimmed to the budget and
|
|
608
|
+
// counted like a charged push, so the spending limit is seen locally.
|
|
609
|
+
if (eventName === undefined
|
|
610
|
+
&& !this.usesPushDataInterception(dataset)
|
|
611
|
+
&& this.initialized
|
|
612
|
+
&& isDefault
|
|
613
|
+
&& this.chargingManager._isPayPerEvent()
|
|
614
|
+
&& this.chargingManager._isPaidPerItem(undefined, true)) {
|
|
615
|
+
return this.pushDataWithExplicitCharging(dataset, item, undefined);
|
|
616
|
+
}
|
|
606
617
|
if (eventName === undefined && !this.usesPushDataInterception(dataset)) {
|
|
607
618
|
// Charge-limit safety net (also for local storage): this push is not
|
|
608
619
|
// charged, so past the run's spending limit it is not stored.
|
package/dist/charging.d.ts
CHANGED
|
@@ -84,7 +84,24 @@ export interface ChargeResult {
|
|
|
84
84
|
eventChargeLimitReached: boolean;
|
|
85
85
|
chargedCount: number;
|
|
86
86
|
chargeableWithinLimit: Record<string, number>;
|
|
87
|
+
/**
|
|
88
|
+
* `pushData(rows, { charges })` only: for each event whose charge came back
|
|
89
|
+
* short, how many events the limit (account balance, maxTotalChargeUsd,
|
|
90
|
+
* maxItems) covered. A row is saved only when all of its events are charged,
|
|
91
|
+
* so those events were given back, not charged. Empty when no charge came
|
|
92
|
+
* back short.
|
|
93
|
+
*/
|
|
94
|
+
coveredCounts?: Record<string, number>;
|
|
95
|
+
}
|
|
96
|
+
/** @internal A charge of an atomic push that the limit accepted only in part (then given back). */
|
|
97
|
+
export interface ShortCharge {
|
|
98
|
+
eventName: string;
|
|
99
|
+
requested: number;
|
|
100
|
+
covered: number;
|
|
101
|
+
limit: string;
|
|
87
102
|
}
|
|
103
|
+
/** @internal Log what an atomic push whose charge came back short really did. */
|
|
104
|
+
export declare function logAtomicRefusal(shorts: ShortCharge[], rowsPushed: number, rowsSaved: number, givenBack: boolean): void;
|
|
88
105
|
export interface ActorPricingInfo {
|
|
89
106
|
pricingModel?: ActorRunPricingInfo['pricingModel'];
|
|
90
107
|
maxTotalChargeUsd: number;
|
|
@@ -110,6 +127,12 @@ export declare class ChargingManager {
|
|
|
110
127
|
private lastLimitMaxItems;
|
|
111
128
|
/** Default-dataset rows refused after the limit (one warning, then a count). */
|
|
112
129
|
private refusedDefaultDatasetItems;
|
|
130
|
+
/**
|
|
131
|
+
* @internal Set by pushDataWithCharges while it runs (it holds the operation
|
|
132
|
+
* lock): charges that come back short are collected here instead of being
|
|
133
|
+
* logged as "only N events were charged", because the atomic push gives them back.
|
|
134
|
+
*/
|
|
135
|
+
_shortCharges: ShortCharge[] | undefined;
|
|
113
136
|
/**
|
|
114
137
|
* Prepaid-row balance: priced events charged through charge() minus rows
|
|
115
138
|
* pushed to the default dataset without a per-row charge. An Actor that
|
package/dist/charging.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.refuseDefaultDatasetPush = refuseDefaultDatasetPush;
|
|
|
10
10
|
exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
|
|
11
11
|
exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
|
|
12
12
|
exports.hasDefaultDatasetItemEventInPricing = hasDefaultDatasetItemEventInPricing;
|
|
13
|
+
exports.logAtomicRefusal = logAtomicRefusal;
|
|
13
14
|
exports.mergeChargeResults = mergeChargeResults;
|
|
14
15
|
exports.assertItemsSerializable = assertItemsSerializable;
|
|
15
16
|
exports.pushDataAndCharge = pushDataAndCharge;
|
|
@@ -234,6 +235,32 @@ function hasDefaultDatasetItemEventInPricing(perEventPrices) {
|
|
|
234
235
|
function isSyntheticEvent(eventName) {
|
|
235
236
|
return isDefaultDatasetItemEvent(eventName);
|
|
236
237
|
}
|
|
238
|
+
function itemsPhrase(count) {
|
|
239
|
+
return count === 1
|
|
240
|
+
? { what: 'this item', were: 'it was', them: 'it' }
|
|
241
|
+
: { what: `these ${count} items`, were: 'they were', them: 'them' };
|
|
242
|
+
}
|
|
243
|
+
/** @internal Log what an atomic push whose charge came back short really did. */
|
|
244
|
+
function logAtomicRefusal(shorts, rowsPushed, rowsSaved, givenBack) {
|
|
245
|
+
const first = shorts[0];
|
|
246
|
+
if (!first)
|
|
247
|
+
return;
|
|
248
|
+
const limit = first.limit.replace(/^the end of /, '');
|
|
249
|
+
const { what, were, them } = itemsPhrase(rowsPushed);
|
|
250
|
+
const head = `[Scrapely] Saving ${what} needs ${first.requested} '${first.eventName}' event(s), but ${limit} covers only ${first.covered}`;
|
|
251
|
+
if (rowsSaved <= 0) {
|
|
252
|
+
console.log(givenBack
|
|
253
|
+
? `${head}: ${were} not saved and nothing was charged for ${them}.`
|
|
254
|
+
: `${head}: ${were} not saved, but the ${first.covered} event(s) charged for ${them} could not be given back yet (see the warning about it).`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const left = rowsPushed - rowsSaved;
|
|
258
|
+
const rest = itemsPhrase(left);
|
|
259
|
+
const tail = givenBack
|
|
260
|
+
? `nothing was charged for ${rest.them}`
|
|
261
|
+
: `what was charged for ${rest.them} could not be given back yet (see the warning about it)`;
|
|
262
|
+
console.log(`${head}: saved the first ${rowsSaved} item(s), charged in full; the other ${left} ${rest.were.split(' ').pop()} not saved and ${tail}.`);
|
|
263
|
+
}
|
|
237
264
|
function mergeChargeResults(a, b) {
|
|
238
265
|
return {
|
|
239
266
|
eventChargeLimitReached: a.eventChargeLimitReached || b.eventChargeLimitReached,
|
|
@@ -244,6 +271,17 @@ function mergeChargeResults(a, b) {
|
|
|
244
271
|
])),
|
|
245
272
|
};
|
|
246
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* The run's spending limit (maxTotalChargeUsd), or Infinity when uncapped.
|
|
276
|
+
* 0 is a real limit (no fees), the same as on the platform; only a missing
|
|
277
|
+
* or non-numeric value means uncapped.
|
|
278
|
+
*/
|
|
279
|
+
function spendingLimitUsd(value) {
|
|
280
|
+
if (value == null || value === '')
|
|
281
|
+
return Infinity;
|
|
282
|
+
const usd = Number(value);
|
|
283
|
+
return Number.isFinite(usd) && usd >= 0 ? usd : Infinity;
|
|
284
|
+
}
|
|
247
285
|
/**
|
|
248
286
|
* Pay-per-event charging for platform runs.
|
|
249
287
|
* Reports charges via POST /v2/actor-runs/:runId/charge (Redis → worker billing).
|
|
@@ -262,6 +300,12 @@ class ChargingManager {
|
|
|
262
300
|
lastLimitMaxItems;
|
|
263
301
|
/** Default-dataset rows refused after the limit (one warning, then a count). */
|
|
264
302
|
refusedDefaultDatasetItems = 0;
|
|
303
|
+
/**
|
|
304
|
+
* @internal Set by pushDataWithCharges while it runs (it holds the operation
|
|
305
|
+
* lock): charges that come back short are collected here instead of being
|
|
306
|
+
* logged as "only N events were charged", because the atomic push gives them back.
|
|
307
|
+
*/
|
|
308
|
+
_shortCharges;
|
|
265
309
|
/**
|
|
266
310
|
* Prepaid-row balance: priced events charged through charge() minus rows
|
|
267
311
|
* pushed to the default dataset without a per-row charge. An Actor that
|
|
@@ -577,7 +621,7 @@ class ChargingManager {
|
|
|
577
621
|
constructor(configuration, apifyClient) {
|
|
578
622
|
this.configuration = configuration;
|
|
579
623
|
this.apifyClientSource = apifyClient;
|
|
580
|
-
this.maxTotalChargeUsd = configuration.get('maxTotalChargeUsd')
|
|
624
|
+
this.maxTotalChargeUsd = spendingLimitUsd(configuration.get('maxTotalChargeUsd'));
|
|
581
625
|
this.isAtHome = !!configuration.get('isAtHome');
|
|
582
626
|
this.actorRunId = configuration.get('actorRunId') || '';
|
|
583
627
|
}
|
|
@@ -619,7 +663,7 @@ class ChargingManager {
|
|
|
619
663
|
return {
|
|
620
664
|
pricingInfo: actorPricingInfo ? JSON.parse(actorPricingInfo) : undefined,
|
|
621
665
|
chargedEventCounts: JSON.parse(chargedEventCounts),
|
|
622
|
-
maxTotalChargeUsd: this.configuration.get('maxTotalChargeUsd')
|
|
666
|
+
maxTotalChargeUsd: spendingLimitUsd(this.configuration.get('maxTotalChargeUsd')),
|
|
623
667
|
};
|
|
624
668
|
}
|
|
625
669
|
if (this.isAtHome) {
|
|
@@ -633,13 +677,13 @@ class ChargingManager {
|
|
|
633
677
|
return {
|
|
634
678
|
pricingInfo: run.pricingInfo,
|
|
635
679
|
chargedEventCounts: run.chargedEventCounts,
|
|
636
|
-
maxTotalChargeUsd: run.options?.maxTotalChargeUsd
|
|
680
|
+
maxTotalChargeUsd: spendingLimitUsd(run.options?.maxTotalChargeUsd),
|
|
637
681
|
};
|
|
638
682
|
}
|
|
639
683
|
return {
|
|
640
684
|
pricingInfo: undefined,
|
|
641
685
|
chargedEventCounts: {},
|
|
642
|
-
maxTotalChargeUsd: this.configuration.get('maxTotalChargeUsd')
|
|
686
|
+
maxTotalChargeUsd: spendingLimitUsd(this.configuration.get('maxTotalChargeUsd')),
|
|
643
687
|
};
|
|
644
688
|
}
|
|
645
689
|
/**
|
|
@@ -1328,8 +1372,16 @@ class ChargingManager {
|
|
|
1328
1372
|
});
|
|
1329
1373
|
}
|
|
1330
1374
|
if (chargedCount < count) {
|
|
1331
|
-
|
|
1332
|
-
|
|
1375
|
+
if (this._shortCharges) {
|
|
1376
|
+
// Inside an atomic push: this charge is given back unless the row is
|
|
1377
|
+
// saved, so "only N events were charged" would not be true there.
|
|
1378
|
+
// pushDataWithCharges logs what really happened instead.
|
|
1379
|
+
this._shortCharges.push({ eventName: normalizedEventName, requested: count, covered: chargedCount, limit: this.limitName() });
|
|
1380
|
+
}
|
|
1381
|
+
else {
|
|
1382
|
+
const subject = count === 1 ? 'instance' : 'instances';
|
|
1383
|
+
console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed ${this.limitName()} - only ${chargedCount} events were charged`);
|
|
1384
|
+
}
|
|
1333
1385
|
}
|
|
1334
1386
|
return {
|
|
1335
1387
|
eventChargeLimitReached: this.calculateMaxEventChargeCountWithinLimit(normalizedEventName) <= 0,
|
|
@@ -1814,6 +1866,10 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
|
|
|
1814
1866
|
// An item the storage would refuse fails here, before it is charged.
|
|
1815
1867
|
assertItemsSerializable(rowsIn);
|
|
1816
1868
|
validateItems?.(rowsIn);
|
|
1869
|
+
// Charges of this push that came back short: given back, and logged once the
|
|
1870
|
+
// outcome is known (logAtomicRefusal), not as "only N events were charged".
|
|
1871
|
+
const shorts = [];
|
|
1872
|
+
let givenBack = true;
|
|
1817
1873
|
const undo = async (ops) => {
|
|
1818
1874
|
for (const op of ops) {
|
|
1819
1875
|
if (op.charged <= 0)
|
|
@@ -1823,61 +1879,75 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
|
|
|
1823
1879
|
else if (!op.operationId)
|
|
1824
1880
|
chargingManager.applyAcceptedCount(op.name, op.charged, 0);
|
|
1825
1881
|
// Cancel / void; a refused void stays billed and becomes credit.
|
|
1826
|
-
else
|
|
1827
|
-
|
|
1882
|
+
else if ((await voidOrCredit(chargingManager, op.name, op.operationId, op.charged, op.uncertain)) !== 'voided')
|
|
1883
|
+
givenBack = false;
|
|
1828
1884
|
}
|
|
1829
1885
|
};
|
|
1886
|
+
// Per event: how many events the limit covered in its last short answer.
|
|
1887
|
+
const coveredCounts = () => Object.fromEntries(shorts.map((sc) => [sc.eventName, sc.covered]));
|
|
1830
1888
|
const order = Object.entries(perRow).sort(([a], [b]) => Number(isSyntheticEvent(a)) - Number(isSyntheticEvent(b)) || a.localeCompare(b));
|
|
1831
1889
|
let ops = [];
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
let
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
got
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
try {
|
|
1845
|
-
const r = await chargingManager._chargeExclusive({ eventName: name, count: want });
|
|
1846
|
-
got = r.chargedCount;
|
|
1847
|
-
ops.push({ name, charged: got, operationId: chargingManager._takeLastAcceptedOperationId?.(), uncertain: false });
|
|
1890
|
+
chargingManager._shortCharges = shorts;
|
|
1891
|
+
try {
|
|
1892
|
+
for (let attempt = 0; attempt < 6 && rows > 0; attempt++) {
|
|
1893
|
+
ops = [];
|
|
1894
|
+
let shortTo = null;
|
|
1895
|
+
for (const [name, n] of order) {
|
|
1896
|
+
const want = n * rows;
|
|
1897
|
+
chargingManager._takeLastAcceptedOperationId?.();
|
|
1898
|
+
let got;
|
|
1899
|
+
if (takeVoidCredit(chargingManager, name, want) > 0) {
|
|
1900
|
+
got = want;
|
|
1901
|
+
ops.push({ name, charged: want, uncertain: false, credit: true });
|
|
1848
1902
|
}
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
await
|
|
1852
|
-
|
|
1903
|
+
else {
|
|
1904
|
+
try {
|
|
1905
|
+
const r = await chargingManager._chargeExclusive({ eventName: name, count: want });
|
|
1906
|
+
got = r.chargedCount;
|
|
1907
|
+
ops.push({ name, charged: got, operationId: chargingManager._takeLastAcceptedOperationId?.(), uncertain: false });
|
|
1908
|
+
}
|
|
1909
|
+
catch (err) {
|
|
1910
|
+
if (!(err instanceof ChargeUncertainError)) {
|
|
1911
|
+
await undo(ops);
|
|
1912
|
+
throw err;
|
|
1913
|
+
}
|
|
1914
|
+
// Not confirmed: stays pending and is re-posted at exit, so it counts as accepted.
|
|
1915
|
+
got = err.count;
|
|
1916
|
+
ops.push({ name, charged: got, operationId: err.operationId, uncertain: true });
|
|
1853
1917
|
}
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1918
|
+
}
|
|
1919
|
+
if (got < want) {
|
|
1920
|
+
shortTo = Math.floor(got / n);
|
|
1921
|
+
break;
|
|
1857
1922
|
}
|
|
1858
1923
|
}
|
|
1859
|
-
if (
|
|
1860
|
-
shortTo = Math.floor(got / n);
|
|
1924
|
+
if (shortTo === null)
|
|
1861
1925
|
break;
|
|
1862
|
-
|
|
1926
|
+
// An event came back short: void this attempt and retry with the whole
|
|
1927
|
+
// rows the budget covers at the FULL per-row cost (sizing from the short
|
|
1928
|
+
// event alone came back short again: appstore 64df0a3608d74af88, 0 saved).
|
|
1929
|
+
// At least one single-row try: the last affordable row may still fit.
|
|
1930
|
+
const attemptOps = ops.map((op) => ({ name: op.name, charged: op.charged }));
|
|
1931
|
+
await undo(ops);
|
|
1932
|
+
ops = [];
|
|
1933
|
+
rows = Math.min(rows - 1, Math.max(chargingManager._rowsAfterShort(attemptOps, perRow), 1));
|
|
1863
1934
|
}
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
// rows the budget covers at the FULL per-row cost (sizing from the short
|
|
1868
|
-
// event alone came back short again: appstore 64df0a3608d74af88, 0 saved).
|
|
1869
|
-
// At least one single-row try: the last affordable row may still fit.
|
|
1870
|
-
const attemptOps = ops.map((op) => ({ name: op.name, charged: op.charged }));
|
|
1871
|
-
await undo(ops);
|
|
1872
|
-
ops = [];
|
|
1873
|
-
rows = Math.min(rows - 1, Math.max(chargingManager._rowsAfterShort(attemptOps, perRow), 1));
|
|
1935
|
+
}
|
|
1936
|
+
finally {
|
|
1937
|
+
chargingManager._shortCharges = undefined;
|
|
1874
1938
|
}
|
|
1875
1939
|
if (rows <= 0 || ops.length === 0) {
|
|
1876
1940
|
await undo(ops);
|
|
1941
|
+
logAtomicRefusal(shorts, rowsIn.length, 0, givenBack);
|
|
1877
1942
|
chargingManager._markChargeLimitHit();
|
|
1878
1943
|
if (isDefaultDataset)
|
|
1879
1944
|
chargingManager._noteRefusedDefaultDatasetItems(rowsIn.length);
|
|
1880
|
-
return {
|
|
1945
|
+
return {
|
|
1946
|
+
eventChargeLimitReached: true,
|
|
1947
|
+
chargedCount: 0,
|
|
1948
|
+
chargeableWithinLimit: chargingManager._chargeableWithinLimit(),
|
|
1949
|
+
coveredCounts: coveredCounts(),
|
|
1950
|
+
};
|
|
1881
1951
|
}
|
|
1882
1952
|
const kept = batch ? rowsIn : rowsIn.slice(0, rows);
|
|
1883
1953
|
if (kept.length < rowsIn.length) {
|
|
@@ -1894,10 +1964,12 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
|
|
|
1894
1964
|
await undo(ops);
|
|
1895
1965
|
throw pushErr;
|
|
1896
1966
|
}
|
|
1967
|
+
logAtomicRefusal(shorts, rowsIn.length, kept.length, givenBack);
|
|
1897
1968
|
return {
|
|
1898
1969
|
eventChargeLimitReached: kept.length < rowsIn.length || chargingManager.isChargeLimitReached(),
|
|
1899
1970
|
chargedCount: kept.length,
|
|
1900
1971
|
chargeableWithinLimit: chargingManager._chargeableWithinLimit(),
|
|
1972
|
+
coveredCounts: coveredCounts(),
|
|
1901
1973
|
};
|
|
1902
1974
|
});
|
|
1903
1975
|
}
|
|
@@ -1919,6 +1991,9 @@ function pushCertainlyFailed(err) {
|
|
|
1919
1991
|
for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
|
|
1920
1992
|
if (e.outcomeUnknown === true)
|
|
1921
1993
|
return false;
|
|
1994
|
+
// The server could not tell whether the push was stored (a 503 with this type).
|
|
1995
|
+
if (apiErrorTypeOf(e) === 'push-outcome-unknown')
|
|
1996
|
+
return false;
|
|
1922
1997
|
}
|
|
1923
1998
|
if ((0, api_storage_client_js_1.isConnectFailure)(err))
|
|
1924
1999
|
return true;
|
package/dist/crypto/secrets.js
CHANGED
|
@@ -20,6 +20,7 @@ const PBKDF2_ITERATIONS = 100_000;
|
|
|
20
20
|
const PBKDF2_HASH = 'sha256';
|
|
21
21
|
const SALT_LENGTH = 32;
|
|
22
22
|
const KEY_LENGTH = 32;
|
|
23
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
23
24
|
// ─── Private Key Decryption ───────────────────────────────────────────────────
|
|
24
25
|
/**
|
|
25
26
|
* Read the private key file from disk and decrypt it with the run passphrase.
|
|
@@ -101,6 +102,9 @@ function decryptAllSecrets(encryptedSecrets, privateKeyPem) {
|
|
|
101
102
|
function mergeDecryptedInput(input, decryptedSecrets) {
|
|
102
103
|
const merged = { ...input };
|
|
103
104
|
for (const [fieldName, value] of Object.entries(decryptedSecrets)) {
|
|
105
|
+
// Never walk into or write onto an object's prototype chain.
|
|
106
|
+
if (fieldName.split('.').some((part) => UNSAFE_KEYS.has(part)))
|
|
107
|
+
continue;
|
|
104
108
|
if (fieldName.includes('.')) {
|
|
105
109
|
// Handle nested paths
|
|
106
110
|
const parts = fieldName.split('.');
|
|
@@ -791,8 +791,31 @@ function patchRawPuppeteerModule(mod, moduleName) {
|
|
|
791
791
|
// read-only export binding — the default export is still patched
|
|
792
792
|
}
|
|
793
793
|
}
|
|
794
|
+
// ESM-only puppeteer (v25+) loaded through require(): the named
|
|
795
|
+
// `launch` is a read-only binding to the original method, which hands
|
|
796
|
+
// off to a BrowserLauncher. Patch that class so it redirects too.
|
|
797
|
+
if (mod.launch === origBoundLaunch)
|
|
798
|
+
patchPuppeteerBrowserLauncherClass(mod.BrowserLauncher, moduleName);
|
|
794
799
|
}
|
|
795
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* Patch puppeteer's BrowserLauncher class (base of ChromeLauncher and
|
|
803
|
+
* FirefoxLauncher), which every PuppeteerNode launch goes through. Calls that
|
|
804
|
+
* already went through a patched launch decide the same way again (no CDP URL
|
|
805
|
+
* → local launch). Idempotent per class.
|
|
806
|
+
*/
|
|
807
|
+
function patchPuppeteerBrowserLauncherClass(BrowserLauncher, moduleName) {
|
|
808
|
+
const proto = BrowserLauncher?.prototype;
|
|
809
|
+
if (!proto || typeof proto.launch !== 'function')
|
|
810
|
+
return;
|
|
811
|
+
if (Object.prototype.hasOwnProperty.call(proto, '_scrapelyRawCdpPatched'))
|
|
812
|
+
return;
|
|
813
|
+
const origLaunch = proto.launch;
|
|
814
|
+
proto.launch = function browserLauncherLaunchPatched(launchOptions) {
|
|
815
|
+
return puppeteerLaunchPatched(this.puppeteer, (options) => origLaunch.call(this, options), moduleName, launchOptions);
|
|
816
|
+
};
|
|
817
|
+
proto._scrapelyRawCdpPatched = true;
|
|
818
|
+
}
|
|
796
819
|
/**
|
|
797
820
|
* Requests that load (a part of) some copy of @crawlee/browser-pool:
|
|
798
821
|
* - the bare package name (require from @crawlee/playwright etc.),
|
|
@@ -804,8 +827,11 @@ const BROWSER_POOL_REQUEST_RE = /^@crawlee\/browser-pool$|[/\\]@crawlee[/\\]brow
|
|
|
804
827
|
const BROWSER_POOL_FILENAME_RE = /[/\\]@crawlee[/\\]browser-pool[/\\](index|playwright[/\\]playwright-plugin|puppeteer[/\\]puppeteer-plugin)\.js$/;
|
|
805
828
|
/** playwright / playwright-core package entry, by filename (ESM imports and require.cache). */
|
|
806
829
|
const PLAYWRIGHT_ENTRY_FILENAME_RE = /[/\\]node_modules[/\\](playwright|playwright-core)[/\\]index\.js$/;
|
|
807
|
-
/**
|
|
808
|
-
|
|
830
|
+
/**
|
|
831
|
+
* puppeteer / puppeteer-core package entry (default instance), by filename:
|
|
832
|
+
* `lib/cjs/puppeteer/…` up to v24, `lib/puppeteer/…` in the ESM-only v25+.
|
|
833
|
+
*/
|
|
834
|
+
const PUPPETEER_ENTRY_FILENAME_RE = /[/\\]node_modules[/\\](puppeteer|puppeteer-core)[/\\]lib[/\\](?:cjs[/\\])?puppeteer[/\\](puppeteer|puppeteer-core)\.js$/;
|
|
809
835
|
/** puppeteer-core's PuppeteerNode class file (CJS), as requested by node/node.js or cached. */
|
|
810
836
|
const PUPPETEER_NODE_REQUEST_RE = /(^|[/\\])PuppeteerNode(\.js)?$/;
|
|
811
837
|
/**
|
|
@@ -4,6 +4,7 @@ exports.DragonflyDatasetClient = void 0;
|
|
|
4
4
|
const utils_js_1 = require("./utils.js");
|
|
5
5
|
const billing_tracker_js_1 = require("./billing-tracker.js");
|
|
6
6
|
const dataset_push_script_js_1 = require("./dataset-push-script.js");
|
|
7
|
+
const ttl_manager_js_1 = require("./ttl-manager.js");
|
|
7
8
|
function makeAsyncIterableResult(promiseFn) {
|
|
8
9
|
const promise = promiseFn();
|
|
9
10
|
const iterable = {
|
|
@@ -60,11 +61,13 @@ class DragonflyDatasetClient {
|
|
|
60
61
|
const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
|
|
61
62
|
if (raw) {
|
|
62
63
|
const data = JSON.parse(raw);
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
// Name pointers are scoped by owner (name:{userId}:{name}).
|
|
65
|
+
const ownerId = data.userId ?? this.client.billingContext?.userId;
|
|
66
|
+
await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, ownerId, data.name, this.id);
|
|
65
67
|
data.name = newFields.name;
|
|
66
68
|
await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
|
|
67
|
-
|
|
69
|
+
if (ownerId && newFields.name)
|
|
70
|
+
await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(ownerId, newFields.name), JSON.stringify(data));
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
await this.client.redis.hset(this.infoKey, 'modifiedAt', now.toISOString());
|
|
@@ -75,8 +78,7 @@ class DragonflyDatasetClient {
|
|
|
75
78
|
await this.client.redis.del(this.infoKey, this.itemsKey);
|
|
76
79
|
const indexKey = this.client.key('datasets:index');
|
|
77
80
|
await this.client.redis.hdel(indexKey, `id:${this.id}`);
|
|
78
|
-
|
|
79
|
-
await this.client.redis.hdel(indexKey, `name:${info.name}`);
|
|
81
|
+
await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, info?.userId || this.client.billingContext?.userId, info?.name, this.id);
|
|
80
82
|
}
|
|
81
83
|
async downloadItems() {
|
|
82
84
|
const result = await this._listItems();
|
|
@@ -216,6 +218,10 @@ class DragonflyDatasetClient {
|
|
|
216
218
|
// separate round trips allowed concurrent pushes to allocate the same
|
|
217
219
|
// slot indices, silently overwriting items.
|
|
218
220
|
await (0, dataset_push_script_js_1.pushItemsAtomic)(this.client.redis, this.infoKey, this.itemsKey, serializedItems, totalBytes);
|
|
221
|
+
// The items hash is created by the first push: give it the dataset's expiry.
|
|
222
|
+
const expiry = this.client.redis.pipeline();
|
|
223
|
+
(0, ttl_manager_js_1.queueInheritExpiry)(expiry, this.infoKey, [this.itemsKey]);
|
|
224
|
+
await expiry.exec();
|
|
219
225
|
// Track billing using client's billing context
|
|
220
226
|
if (this.client.billingContext) {
|
|
221
227
|
const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
|
|
@@ -9,8 +9,10 @@ class DragonflyDatasetCollectionClient {
|
|
|
9
9
|
}
|
|
10
10
|
async getOrCreate(name) {
|
|
11
11
|
const indexKey = this.client.key('datasets: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, 'dataset');
|
|
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
|
}
|
|
@@ -25,7 +27,7 @@ class DragonflyDatasetCollectionClient {
|
|
|
25
27
|
};
|
|
26
28
|
await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
|
|
27
29
|
if (name)
|
|
28
|
-
await this.client.redis.hset(indexKey,
|
|
30
|
+
await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(userId, name), JSON.stringify(data));
|
|
29
31
|
const infoKey = this.client.key('dataset', id, 'info');
|
|
30
32
|
const itemsKey = this.client.key('dataset', id, 'items');
|
|
31
33
|
await this.client.redis.hset(infoKey, {
|
|
@@ -62,8 +64,7 @@ class DragonflyDatasetCollectionClient {
|
|
|
62
64
|
const exists = await this.client.redis.exists(infoKey);
|
|
63
65
|
if (!exists) {
|
|
64
66
|
await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
|
|
65
|
-
|
|
66
|
-
await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
|
|
67
|
+
await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, parsed.userId ?? this.client.billingContext?.userId, parsed.name, parsed.id);
|
|
67
68
|
continue;
|
|
68
69
|
}
|
|
69
70
|
const itemCount = parseInt(await this.client.redis.hget(infoKey, 'itemCount') || '0', 10);
|
|
@@ -4,6 +4,7 @@ exports.DragonflyKeyValueStoreClient = void 0;
|
|
|
4
4
|
const node_stream_1 = require("node:stream");
|
|
5
5
|
const utils_js_1 = require("./utils.js");
|
|
6
6
|
const billing_tracker_js_1 = require("./billing-tracker.js");
|
|
7
|
+
const ttl_manager_js_1 = require("./ttl-manager.js");
|
|
7
8
|
const configuration_js_1 = require("../configuration.js");
|
|
8
9
|
const kvs_iteration_js_1 = require("./kvs-iteration.js");
|
|
9
10
|
function makeAsyncIterableListKeys(promiseFn) {
|
|
@@ -70,11 +71,13 @@ class DragonflyKeyValueStoreClient {
|
|
|
70
71
|
const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
|
|
71
72
|
if (raw) {
|
|
72
73
|
const data = JSON.parse(raw);
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
// Name pointers are scoped by owner (name:{userId}:{name}).
|
|
75
|
+
const ownerId = data.userId ?? this.client.billingContext?.userId;
|
|
76
|
+
await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, ownerId, data.name, this.id);
|
|
75
77
|
data.name = newFields.name;
|
|
76
78
|
await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
|
|
77
|
-
|
|
79
|
+
if (ownerId && newFields.name)
|
|
80
|
+
await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(ownerId, newFields.name), JSON.stringify(data));
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
await this.client.redis.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
|
|
@@ -85,8 +88,7 @@ class DragonflyKeyValueStoreClient {
|
|
|
85
88
|
await this.client.redis.del(this.infoKey, this.recordsKey, this.metaKey);
|
|
86
89
|
const indexKey = this.client.key('kvs:index');
|
|
87
90
|
await this.client.redis.hdel(indexKey, `id:${this.id}`);
|
|
88
|
-
|
|
89
|
-
await this.client.redis.hdel(indexKey, `name:${info.name}`);
|
|
91
|
+
await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, info?.userId || this.client.billingContext?.userId, info?.name, this.id);
|
|
90
92
|
}
|
|
91
93
|
async recordExists(key) {
|
|
92
94
|
return (await this.client.redis.hexists(this.recordsKey, key)) === 1;
|
|
@@ -168,6 +170,8 @@ class DragonflyKeyValueStoreClient {
|
|
|
168
170
|
pipeline.hset(this.metaKey, key, JSON.stringify({ contentType }));
|
|
169
171
|
}
|
|
170
172
|
pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
|
|
173
|
+
// Records/meta are created by the first write: give them the store's expiry.
|
|
174
|
+
(0, ttl_manager_js_1.queueInheritExpiry)(pipeline, this.infoKey, [this.recordsKey, this.metaKey]);
|
|
171
175
|
await pipeline.exec();
|
|
172
176
|
// Track billing using client's billing context
|
|
173
177
|
if (this.client.billingContext) {
|
|
@@ -9,17 +9,15 @@ class DragonflyKeyValueStoreCollectionClient {
|
|
|
9
9
|
}
|
|
10
10
|
async getOrCreate(name) {
|
|
11
11
|
const indexKey = this.client.key('kvs: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, 'key-value store');
|
|
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
|
-
// Derive ownership from the active billing context (as the request-queue
|
|
20
|
-
// client does); the server authorizes against this persisted field.
|
|
21
|
-
// The legacy '1' placeholder applies only when no context is present.
|
|
22
|
-
const userId = this.client.billingContext?.userId ?? '1';
|
|
23
21
|
const data = {
|
|
24
22
|
id,
|
|
25
23
|
name,
|
|
@@ -30,7 +28,7 @@ class DragonflyKeyValueStoreCollectionClient {
|
|
|
30
28
|
};
|
|
31
29
|
await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
|
|
32
30
|
if (name)
|
|
33
|
-
await this.client.redis.hset(indexKey,
|
|
31
|
+
await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(userId, name), JSON.stringify(data));
|
|
34
32
|
const infoKey = this.client.key('kvs', id, 'info');
|
|
35
33
|
const recordsKey = this.client.key('kvs', id, 'records');
|
|
36
34
|
const metaKey = this.client.key('kvs', id, 'meta');
|
|
@@ -68,8 +66,7 @@ class DragonflyKeyValueStoreCollectionClient {
|
|
|
68
66
|
const exists = await this.client.redis.exists(infoKey);
|
|
69
67
|
if (!exists) {
|
|
70
68
|
await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
|
|
71
|
-
|
|
72
|
-
await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
|
|
69
|
+
await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, parsed.userId, parsed.name, parsed.id);
|
|
73
70
|
continue;
|
|
74
71
|
}
|
|
75
72
|
items.push(parsed);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.LogManager = void 0;
|
|
4
|
+
const ttl_manager_js_1 = require("./ttl-manager.js");
|
|
4
5
|
const run_metadata_js_1 = require("./run-metadata.js");
|
|
5
6
|
const DEFAULT_LOG_CONFIG = {
|
|
6
7
|
maxLogSize: 5 * 1024 * 1024, // 5MB
|
|
@@ -89,10 +90,14 @@ class LogManager {
|
|
|
89
90
|
// a valid code-point boundary and never store a broken sequence.
|
|
90
91
|
const alignedOffset = LogManager.alignUtf8Boundary(trailingBuf, 0);
|
|
91
92
|
const safeTrailing = trailingBuf.subarray(alignedOffset);
|
|
92
|
-
// Overwrite the key with only the safe trailing portion
|
|
93
|
-
await this.client.redis.set(key, safeTrailing);
|
|
94
|
-
// Mark log as truncated (NX — only set once; ignore if already set)
|
|
93
|
+
// Overwrite the key with only the safe trailing portion (keeping the log's expiry)
|
|
94
|
+
await this.client.redis.set(key, safeTrailing, 'KEEPTTL');
|
|
95
|
+
// Mark log as truncated (NX — only set once; ignore if already set). The marker
|
|
96
|
+
// is created here, after setLogTtl ran: it takes the log's expiry.
|
|
95
97
|
await this.client.redis.set(this.getTruncatedKey(runId), '1', 'NX');
|
|
98
|
+
const expiry = this.client.redis.pipeline();
|
|
99
|
+
(0, ttl_manager_js_1.queueInheritExpiry)(expiry, key, [this.getTruncatedKey(runId)]);
|
|
100
|
+
await expiry.exec();
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
/**
|
|
@@ -118,7 +123,7 @@ class LogManager {
|
|
|
118
123
|
const key = this.getLogKey(runId);
|
|
119
124
|
const ttl = this.client.ttlManager.getTtl('logs', plan);
|
|
120
125
|
if (ttl !== null && ttl > 0) {
|
|
121
|
-
await this.client.ttlManager.setExpiry(this.client.redis, [key], ttl);
|
|
126
|
+
await this.client.ttlManager.setExpiry(this.client.redis, [key, this.getTruncatedKey(runId)], ttl);
|
|
122
127
|
}
|
|
123
128
|
}
|
|
124
129
|
/**
|