scrapely 1.2.38 → 1.2.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/actor.js +3 -0
- package/dist/charging.d.ts +59 -2
- package/dist/charging.js +206 -18
- package/dist/crypto/secrets.js +4 -0
- package/dist/events/events-client.js +3 -2
- package/dist/patch-browser-pool.js +28 -2
- package/package.json +8 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.2.40] - 2026-10-30
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
## [1.2.39] - 2026-10-29
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
7
15
|
## [1.2.38] - 2026-10-27
|
|
8
16
|
|
|
9
17
|
- A key-value store record write (`Actor.setValue`, `KeyValueStore.setValue`) is no longer lost to one failed answer. Each write now sends an `Idempotency-Key`, and when its answer is lost (a `502`/`504` page from the edge or load balancer, a dropped connection, a timeout) it is sent again with the same key; the API applies and counts it once. Before, the write was never re-sent, so one such page on the final `OUTPUT` write failed the whole run. An API that answers a keyed write without `X-Scrapely-Idempotency` (an older server) gets no re-sends, as before.
|
package/dist/actor.js
CHANGED
|
@@ -1157,6 +1157,9 @@ class Actor {
|
|
|
1157
1157
|
static async setStatusMessage(message, options) {
|
|
1158
1158
|
const { isStatusMessageTerminal, level } = options || {};
|
|
1159
1159
|
Actor._ensureActorInit('setStatusMessage');
|
|
1160
|
+
// Actors word a refused charge as "the maximum charge for this run was
|
|
1161
|
+
// reached" whatever refused it; name the limit that really stopped the run.
|
|
1162
|
+
message = (0, charging_js_2.truthfulStatusMessage)(message);
|
|
1160
1163
|
const loggedStatusMessage = `[Status message]: ${message}`;
|
|
1161
1164
|
switch (level) {
|
|
1162
1165
|
case 'DEBUG':
|
package/dist/charging.d.ts
CHANGED
|
@@ -21,14 +21,40 @@ export declare class ChargeRejectedError extends Error {
|
|
|
21
21
|
readonly count: number;
|
|
22
22
|
readonly statusCode?: number;
|
|
23
23
|
readonly reason: string;
|
|
24
|
+
/** Which run limit refused the charge, when the platform said (see ChargeLimitReason). */
|
|
25
|
+
readonly limitReason?: ChargeLimitReason;
|
|
26
|
+
readonly maxItems?: number;
|
|
24
27
|
constructor(message: string, opts: {
|
|
25
28
|
operationId: string;
|
|
26
29
|
eventName: string;
|
|
27
30
|
count: number;
|
|
28
31
|
statusCode?: number;
|
|
29
32
|
reason: string;
|
|
33
|
+
limitReason?: ChargeLimitReason;
|
|
34
|
+
maxItems?: number;
|
|
30
35
|
});
|
|
31
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Which run limit refused or cut down a charge: the platform's `error.reason`
|
|
39
|
+
* on HTTP 402 and `limitedBy` on a partial acceptance. 'max-items' is the item
|
|
40
|
+
* limit the user set (maxItems), 'max-charge' the maximum total charge
|
|
41
|
+
* (maxTotalChargeUsd), 'insufficient-funds' the account balance. The run's
|
|
42
|
+
* stop message names it, so an item limit is never called a spending limit.
|
|
43
|
+
*/
|
|
44
|
+
export type ChargeLimitReason = 'max-items' | 'max-charge' | 'insufficient-funds';
|
|
45
|
+
/** The run's stop message for a charge limit, or undefined when the reason is unknown. */
|
|
46
|
+
export declare function chargeLimitStopMessage(reason: ChargeLimitReason | undefined, opts?: {
|
|
47
|
+
maxItems?: number | null;
|
|
48
|
+
maxTotalChargeUsd?: number | null;
|
|
49
|
+
}): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Replace the clause of `message` that blames the spending limit with
|
|
52
|
+
* `stopMessage`: "Saved 800 results; stopped because the maximum charge for
|
|
53
|
+
* this run was reached" becomes "Saved 800 results. Stopped: reached the item
|
|
54
|
+
* limit you set (800 items)". Only the first such clause is replaced (later
|
|
55
|
+
* ones are dropped); a message without one is returned as is.
|
|
56
|
+
*/
|
|
57
|
+
export declare function rewriteChargeLimitClaims(message: string, stopMessage: string | undefined): string;
|
|
32
58
|
/** @internal Register the manager whose limit guards default-dataset writes. */
|
|
33
59
|
export declare function setActiveChargingManager(manager: ChargingManager | undefined): void;
|
|
34
60
|
/** @internal The run's default dataset ID, as the platform passes it. */
|
|
@@ -39,6 +65,8 @@ export declare function defaultDatasetIdFromEnv(): string | undefined;
|
|
|
39
65
|
* clients for writes that did not go through `pushDataAndCharge`
|
|
40
66
|
* (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
|
|
41
67
|
*/
|
|
68
|
+
/** `message` naming the limit that really stopped the run (Actor.setStatusMessage). */
|
|
69
|
+
export declare function truthfulStatusMessage(message: string): string;
|
|
42
70
|
export declare function refuseDefaultDatasetPush(itemCount: number): boolean;
|
|
43
71
|
export declare const APIFY_DEFAULT_DATASET_ITEM_EVENT = "apify-default-dataset-item";
|
|
44
72
|
/** Scrapely-native synthetic event for default-dataset items. */
|
|
@@ -74,6 +102,12 @@ export declare class ChargingManager {
|
|
|
74
102
|
private _closed;
|
|
75
103
|
/** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
|
|
76
104
|
private chargeLimitHit;
|
|
105
|
+
/** Which limit that was (the first one known wins) and the item limit the platform reported. */
|
|
106
|
+
private chargeLimitReason;
|
|
107
|
+
private chargeLimitMaxItems;
|
|
108
|
+
/** limitedBy / maxItems of the last partially accepted charge response. */
|
|
109
|
+
private lastLimitedBy;
|
|
110
|
+
private lastLimitMaxItems;
|
|
77
111
|
/** Default-dataset rows refused after the limit (one warning, then a count). */
|
|
78
112
|
private refusedDefaultDatasetItems;
|
|
79
113
|
/**
|
|
@@ -124,10 +158,33 @@ export declare class ChargingManager {
|
|
|
124
158
|
*/
|
|
125
159
|
isChargeLimitReached(): boolean;
|
|
126
160
|
private markChargeLimitHit;
|
|
161
|
+
private markRejectedCharge;
|
|
162
|
+
/** The platform accepted fewer events than asked: record which limit cut it. */
|
|
163
|
+
private markPartialCharge;
|
|
164
|
+
/** The run's item limit: as the platform reported it, else ACTOR_MAX_PAID_DATASET_ITEMS. */
|
|
165
|
+
private maxItemsSetting;
|
|
166
|
+
/**
|
|
167
|
+
* Why this run's charges stopped being accepted, or undefined while they
|
|
168
|
+
* still are: 'max-items' (the item limit the user set), 'max-charge' (the
|
|
169
|
+
* maximum total charge) or 'insufficient-funds' (the account balance).
|
|
170
|
+
* Platforms that do not say which limit it was: inferred from the run's settings.
|
|
171
|
+
*/
|
|
172
|
+
getChargeLimitReason(): ChargeLimitReason | undefined;
|
|
173
|
+
/**
|
|
174
|
+
* The run's stop message for the limit that was reached, or undefined:
|
|
175
|
+
* "Stopped: reached the item limit you set (800 items)", "Stopped: reached
|
|
176
|
+
* the maximum charge you set ($3.00)", ... Use it as (part of) the final
|
|
177
|
+
* status message when the Actor stops because a charge was refused.
|
|
178
|
+
*/
|
|
179
|
+
getChargeLimitMessage(): string | undefined;
|
|
180
|
+
/** `message` with a clause blaming the spending limit replaced by the real reason (once a limit was reached). */
|
|
181
|
+
rewriteStatusMessage(message: string): string;
|
|
182
|
+
/** 'the item limit of this run (maxItems = 800)', ... for log lines. */
|
|
183
|
+
private limitName;
|
|
127
184
|
/** @internal Count rows dropped because of the limit; warn on the first one only. */
|
|
128
185
|
_noteRefusedDefaultDatasetItems(count: number): void;
|
|
129
|
-
/** @internal */
|
|
130
|
-
_markChargeLimitHit(): void;
|
|
186
|
+
/** @internal `reason`: the limit that trimmed the push, when known. */
|
|
187
|
+
_markChargeLimitHit(reason?: ChargeLimitReason): void;
|
|
131
188
|
/** @internal Whether every row of a push with these events is charged a positive price. */
|
|
132
189
|
_isPaidPerItem(eventName: string | undefined, isDefaultDataset: boolean): boolean;
|
|
133
190
|
/** @internal */
|
package/dist/charging.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ChargingManager = exports.DEFAULT_DATASET_ITEM_EVENT = exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = exports.ChargeRejectedError = exports.ChargeUncertainError = exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = void 0;
|
|
4
|
+
exports.chargeLimitStopMessage = chargeLimitStopMessage;
|
|
5
|
+
exports.rewriteChargeLimitClaims = rewriteChargeLimitClaims;
|
|
4
6
|
exports.setActiveChargingManager = setActiveChargingManager;
|
|
5
7
|
exports.defaultDatasetIdFromEnv = defaultDatasetIdFromEnv;
|
|
8
|
+
exports.truthfulStatusMessage = truthfulStatusMessage;
|
|
6
9
|
exports.refuseDefaultDatasetPush = refuseDefaultDatasetPush;
|
|
7
10
|
exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
|
|
8
11
|
exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
|
|
@@ -57,6 +60,9 @@ class ChargeRejectedError extends Error {
|
|
|
57
60
|
count;
|
|
58
61
|
statusCode;
|
|
59
62
|
reason;
|
|
63
|
+
/** Which run limit refused the charge, when the platform said (see ChargeLimitReason). */
|
|
64
|
+
limitReason;
|
|
65
|
+
maxItems;
|
|
60
66
|
constructor(message, opts) {
|
|
61
67
|
super(message);
|
|
62
68
|
this.name = 'ChargeRejectedError';
|
|
@@ -65,9 +71,99 @@ class ChargeRejectedError extends Error {
|
|
|
65
71
|
this.count = opts.count;
|
|
66
72
|
this.statusCode = opts.statusCode;
|
|
67
73
|
this.reason = opts.reason;
|
|
74
|
+
this.limitReason = opts.limitReason;
|
|
75
|
+
this.maxItems = opts.maxItems;
|
|
68
76
|
}
|
|
69
77
|
}
|
|
70
78
|
exports.ChargeRejectedError = ChargeRejectedError;
|
|
79
|
+
const CHARGE_LIMIT_REASONS = new Set(['max-items', 'max-charge', 'insufficient-funds']);
|
|
80
|
+
function asChargeLimitReason(raw) {
|
|
81
|
+
return typeof raw === 'string' && CHARGE_LIMIT_REASONS.has(raw) ? raw : undefined;
|
|
82
|
+
}
|
|
83
|
+
function positiveInt(raw) {
|
|
84
|
+
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** $3.00, $0.005: two decimals unless the amount needs more. */
|
|
87
|
+
function formatUsd(usd) {
|
|
88
|
+
return Math.abs(usd * 100 - Math.round(usd * 100)) < 1e-9 ? `$${usd.toFixed(2)}` : `$${Number(usd.toPrecision(6))}`;
|
|
89
|
+
}
|
|
90
|
+
/** The run's stop message for a charge limit, or undefined when the reason is unknown. */
|
|
91
|
+
function chargeLimitStopMessage(reason, opts = {}) {
|
|
92
|
+
if (reason === 'max-items') {
|
|
93
|
+
const n = opts.maxItems;
|
|
94
|
+
return n && n > 0
|
|
95
|
+
? `Stopped: reached the item limit you set (${n} ${n === 1 ? 'item' : 'items'})`
|
|
96
|
+
: 'Stopped: reached the item limit you set';
|
|
97
|
+
}
|
|
98
|
+
if (reason === 'max-charge') {
|
|
99
|
+
const usd = opts.maxTotalChargeUsd;
|
|
100
|
+
return typeof usd === 'number' && Number.isFinite(usd)
|
|
101
|
+
? `Stopped: reached the maximum charge you set (${formatUsd(usd)})`
|
|
102
|
+
: 'Stopped: reached the maximum charge you set';
|
|
103
|
+
}
|
|
104
|
+
if (reason === 'insufficient-funds')
|
|
105
|
+
return 'Stopped: your account has no credit left for this run';
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
// A status-message clause that blames the spending limit ("stopped because the
|
|
109
|
+
// maximum charge for this run was reached", "The run reached its maximum total
|
|
110
|
+
// charge. Stopping.", ...). Actors written before the platform said which limit
|
|
111
|
+
// was hit use such texts for every refused charge.
|
|
112
|
+
const CHARGE_CLAIM_RE = /\bmax(?:imum)?\s+(?:total\s+)?charge|\bspending\s+limit/i;
|
|
113
|
+
const CLAUSE_SEPARATOR_RE = /(;\s*|[.!?](?:\s+|$))/;
|
|
114
|
+
/**
|
|
115
|
+
* Replace the clause of `message` that blames the spending limit with
|
|
116
|
+
* `stopMessage`: "Saved 800 results; stopped because the maximum charge for
|
|
117
|
+
* this run was reached" becomes "Saved 800 results. Stopped: reached the item
|
|
118
|
+
* limit you set (800 items)". Only the first such clause is replaced (later
|
|
119
|
+
* ones are dropped); a message without one is returned as is.
|
|
120
|
+
*/
|
|
121
|
+
function rewriteChargeLimitClaims(message, stopMessage) {
|
|
122
|
+
if (!message || !stopMessage || message.includes(stopMessage) || !CHARGE_CLAIM_RE.test(message))
|
|
123
|
+
return message;
|
|
124
|
+
const parts = message.split(CLAUSE_SEPARATOR_RE);
|
|
125
|
+
const out = [];
|
|
126
|
+
let replaced = false;
|
|
127
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
128
|
+
const clause = parts[i] ?? '';
|
|
129
|
+
const sep = parts[i + 1] ?? '';
|
|
130
|
+
if (!CHARGE_CLAIM_RE.test(clause)) {
|
|
131
|
+
out.push(clause + sep);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (replaced)
|
|
135
|
+
continue;
|
|
136
|
+
replaced = true;
|
|
137
|
+
const prev = out.length ? out[out.length - 1].trimEnd() : '';
|
|
138
|
+
if (prev.endsWith(';'))
|
|
139
|
+
out[out.length - 1] = `${prev.slice(0, -1).trimEnd()}. `;
|
|
140
|
+
const lead = clause.slice(0, clause.length - clause.trimStart().length);
|
|
141
|
+
let tail = '';
|
|
142
|
+
if (sep)
|
|
143
|
+
tail = [';', '.', '!', '?'].includes(sep.trim()) ? `.${sep.slice(sep.trimEnd().length)}` : sep;
|
|
144
|
+
out.push(lead + stopMessage + tail);
|
|
145
|
+
}
|
|
146
|
+
let result = out.join('').trimEnd();
|
|
147
|
+
if (message.trimEnd().endsWith('.') && !result.endsWith('.'))
|
|
148
|
+
result += '.';
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
/** Which limit a 402 names: error.reason / error.data.reason, else its message (older platforms). */
|
|
152
|
+
function rejectionLimit(err, message) {
|
|
153
|
+
const e = err;
|
|
154
|
+
const data = e?.data && typeof e.data === 'object' ? e.data : undefined;
|
|
155
|
+
let limitReason = asChargeLimitReason(data?.reason) ?? asChargeLimitReason(e?.body?.error?.reason);
|
|
156
|
+
const maxItems = positiveInt(data?.maxItems) ?? positiveInt(e?.body?.maxItems);
|
|
157
|
+
if (!limitReason) {
|
|
158
|
+
if (/maxitems|maximum number of results/i.test(message))
|
|
159
|
+
limitReason = 'max-items';
|
|
160
|
+
else if (/insufficient balance/i.test(message))
|
|
161
|
+
limitReason = 'insufficient-funds';
|
|
162
|
+
else if (/maximum total charge/i.test(message))
|
|
163
|
+
limitReason = 'max-charge';
|
|
164
|
+
}
|
|
165
|
+
return { limitReason, maxItems };
|
|
166
|
+
}
|
|
71
167
|
/** True when a ChargeRejectedError means the run's spending limit is used up. */
|
|
72
168
|
function isChargeLimitRejection(err) {
|
|
73
169
|
return (err.statusCode === 402 ||
|
|
@@ -101,6 +197,10 @@ function defaultDatasetIdFromEnv() {
|
|
|
101
197
|
* clients for writes that did not go through `pushDataAndCharge`
|
|
102
198
|
* (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
|
|
103
199
|
*/
|
|
200
|
+
/** `message` naming the limit that really stopped the run (Actor.setStatusMessage). */
|
|
201
|
+
function truthfulStatusMessage(message) {
|
|
202
|
+
return activeChargingManager ? activeChargingManager.rewriteStatusMessage(message) : message;
|
|
203
|
+
}
|
|
104
204
|
function refuseDefaultDatasetPush(itemCount) {
|
|
105
205
|
if (chargeGatedPush.getStore())
|
|
106
206
|
return false;
|
|
@@ -154,6 +254,12 @@ class ChargingManager {
|
|
|
154
254
|
_closed = false;
|
|
155
255
|
/** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
|
|
156
256
|
chargeLimitHit = false;
|
|
257
|
+
/** Which limit that was (the first one known wins) and the item limit the platform reported. */
|
|
258
|
+
chargeLimitReason;
|
|
259
|
+
chargeLimitMaxItems;
|
|
260
|
+
/** limitedBy / maxItems of the last partially accepted charge response. */
|
|
261
|
+
lastLimitedBy;
|
|
262
|
+
lastLimitMaxItems;
|
|
157
263
|
/** Default-dataset rows refused after the limit (one warning, then a count). */
|
|
158
264
|
refusedDefaultDatasetItems = 0;
|
|
159
265
|
/**
|
|
@@ -214,7 +320,7 @@ class ChargingManager {
|
|
|
214
320
|
close() {
|
|
215
321
|
this._closed = true;
|
|
216
322
|
if (this.refusedDefaultDatasetItems > 0) {
|
|
217
|
-
console.warn(`[Scrapely] ${this.refusedDefaultDatasetItems} result(s) pushed to the default dataset after
|
|
323
|
+
console.warn(`[Scrapely] ${this.refusedDefaultDatasetItems} result(s) pushed to the default dataset after ${this.limitName()} was reached were not saved`);
|
|
218
324
|
}
|
|
219
325
|
}
|
|
220
326
|
/**
|
|
@@ -242,25 +348,104 @@ class ChargingManager {
|
|
|
242
348
|
// Same rounding as the budget gate, so 0.03 - 0.02 still pays a $0.01 event.
|
|
243
349
|
return this.calculateMaxChargesByPrice(Math.min(...prices)) <= 0;
|
|
244
350
|
}
|
|
245
|
-
markChargeLimitHit() {
|
|
246
|
-
if (this.isPayPerEvent)
|
|
247
|
-
|
|
351
|
+
markChargeLimitHit(reason, maxItems) {
|
|
352
|
+
if (!this.isPayPerEvent)
|
|
353
|
+
return;
|
|
354
|
+
this.chargeLimitHit = true;
|
|
355
|
+
if (reason && this.chargeLimitReason === undefined)
|
|
356
|
+
this.chargeLimitReason = reason;
|
|
357
|
+
if (reason === 'max-items' && maxItems !== undefined && maxItems > 0)
|
|
358
|
+
this.chargeLimitMaxItems = maxItems;
|
|
359
|
+
}
|
|
360
|
+
markRejectedCharge(err) {
|
|
361
|
+
if (isChargeLimitRejection(err))
|
|
362
|
+
this.markChargeLimitHit(err.limitReason, err.maxItems);
|
|
363
|
+
}
|
|
364
|
+
/** The platform accepted fewer events than asked: record which limit cut it. */
|
|
365
|
+
markPartialCharge() {
|
|
366
|
+
this.markChargeLimitHit(this.lastLimitedBy, this.lastLimitMaxItems);
|
|
367
|
+
}
|
|
368
|
+
/** The run's item limit: as the platform reported it, else ACTOR_MAX_PAID_DATASET_ITEMS. */
|
|
369
|
+
maxItemsSetting() {
|
|
370
|
+
if (this.chargeLimitMaxItems !== undefined)
|
|
371
|
+
return this.chargeLimitMaxItems;
|
|
372
|
+
const raw = Number(process.env.ACTOR_MAX_PAID_DATASET_ITEMS ?? process.env.APIFY_ACTOR_MAX_PAID_DATASET_ITEMS);
|
|
373
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : undefined;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Why this run's charges stopped being accepted, or undefined while they
|
|
377
|
+
* still are: 'max-items' (the item limit the user set), 'max-charge' (the
|
|
378
|
+
* maximum total charge) or 'insufficient-funds' (the account balance).
|
|
379
|
+
* Platforms that do not say which limit it was: inferred from the run's settings.
|
|
380
|
+
*/
|
|
381
|
+
getChargeLimitReason() {
|
|
382
|
+
if (!this.isChargeLimitReached())
|
|
383
|
+
return undefined;
|
|
384
|
+
if (this.chargeLimitReason)
|
|
385
|
+
return this.chargeLimitReason;
|
|
386
|
+
const maxItems = this.maxItemsSetting();
|
|
387
|
+
if (maxItems !== undefined &&
|
|
388
|
+
Object.entries(this.chargingState ?? {}).some(([name, state]) => !isSyntheticEvent(name) && (this.pricingInfo[name]?.price ?? 0) > 0 && state.chargeCount >= maxItems)) {
|
|
389
|
+
return 'max-items';
|
|
390
|
+
}
|
|
391
|
+
if (Number.isFinite(this.maxTotalChargeUsd)) {
|
|
392
|
+
const prices = Object.values(this.pricingInfo).map(({ price }) => price).filter((price) => price > 0);
|
|
393
|
+
if (prices.length > 0 && this.calculateMaxChargesByPrice(Math.min(...prices)) <= 0)
|
|
394
|
+
return 'max-charge';
|
|
395
|
+
}
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* The run's stop message for the limit that was reached, or undefined:
|
|
400
|
+
* "Stopped: reached the item limit you set (800 items)", "Stopped: reached
|
|
401
|
+
* the maximum charge you set ($3.00)", ... Use it as (part of) the final
|
|
402
|
+
* status message when the Actor stops because a charge was refused.
|
|
403
|
+
*/
|
|
404
|
+
getChargeLimitMessage() {
|
|
405
|
+
return chargeLimitStopMessage(this.getChargeLimitReason(), {
|
|
406
|
+
maxItems: this.maxItemsSetting(),
|
|
407
|
+
maxTotalChargeUsd: Number.isFinite(this.maxTotalChargeUsd) ? this.maxTotalChargeUsd : null,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** `message` with a clause blaming the spending limit replaced by the real reason (once a limit was reached). */
|
|
411
|
+
rewriteStatusMessage(message) {
|
|
412
|
+
try {
|
|
413
|
+
return rewriteChargeLimitClaims(message, this.getChargeLimitMessage());
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return message;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/** 'the item limit of this run (maxItems = 800)', ... for log lines. */
|
|
420
|
+
limitName() {
|
|
421
|
+
const reason = this.getChargeLimitReason();
|
|
422
|
+
if (reason === 'max-items') {
|
|
423
|
+
const n = this.maxItemsSetting();
|
|
424
|
+
return n ? `the item limit of this run (maxItems = ${n})` : 'the item limit of this run (maxItems)';
|
|
425
|
+
}
|
|
426
|
+
if (reason === 'insufficient-funds')
|
|
427
|
+
return 'the end of the account balance';
|
|
428
|
+
if (reason === 'max-charge' || Number.isFinite(this.maxTotalChargeUsd)) {
|
|
429
|
+
const limit = Number.isFinite(this.maxTotalChargeUsd) ? `$${this.maxTotalChargeUsd}` : 'the limit';
|
|
430
|
+
return `the spending limit of this run (maxTotalChargeUsd = ${limit})`;
|
|
431
|
+
}
|
|
432
|
+
return 'the limit of this run';
|
|
248
433
|
}
|
|
249
434
|
/** @internal Count rows dropped because of the limit; warn on the first one only. */
|
|
250
435
|
_noteRefusedDefaultDatasetItems(count) {
|
|
251
436
|
if (count <= 0)
|
|
252
437
|
return;
|
|
253
438
|
if (this.refusedDefaultDatasetItems === 0) {
|
|
254
|
-
const
|
|
255
|
-
console.warn(`[Scrapely]
|
|
439
|
+
const name = this.limitName();
|
|
440
|
+
console.warn(`[Scrapely] ${name[0].toUpperCase()}${name.slice(1)} has been reached: ` +
|
|
256
441
|
'results pushed to the default dataset are no longer saved. The Actor should stop now ' +
|
|
257
442
|
'(check ChargeResult.eventChargeLimitReached or Actor.getChargingManager().isChargeLimitReached()).');
|
|
258
443
|
}
|
|
259
444
|
this.refusedDefaultDatasetItems += count;
|
|
260
445
|
}
|
|
261
|
-
/** @internal */
|
|
262
|
-
_markChargeLimitHit() {
|
|
263
|
-
this.markChargeLimitHit();
|
|
446
|
+
/** @internal `reason`: the limit that trimmed the push, when known. */
|
|
447
|
+
_markChargeLimitHit(reason) {
|
|
448
|
+
this.markChargeLimitHit(reason);
|
|
264
449
|
}
|
|
265
450
|
/** @internal Whether every row of a push with these events is charged a positive price. */
|
|
266
451
|
_isPaidPerItem(eventName, isDefaultDataset) {
|
|
@@ -728,6 +913,8 @@ class ChargingManager {
|
|
|
728
913
|
const accepted = readAcceptedCount(body, intent.count);
|
|
729
914
|
const remaining = body.remainingBudgetNd;
|
|
730
915
|
this.lastRemainingBudgetNd = typeof remaining === 'number' && Number.isFinite(remaining) && remaining >= 0 ? remaining : null;
|
|
916
|
+
this.lastLimitedBy = asChargeLimitReason(body.limitedBy);
|
|
917
|
+
this.lastLimitMaxItems = positiveInt(body.maxItems);
|
|
731
918
|
return accepted;
|
|
732
919
|
}
|
|
733
920
|
catch (err) {
|
|
@@ -966,7 +1153,7 @@ class ChargingManager {
|
|
|
966
1153
|
const accepted = await this.postChargeWithRetries(intent, deadline);
|
|
967
1154
|
if (accepted !== null) {
|
|
968
1155
|
if (accepted < intent.count)
|
|
969
|
-
this.
|
|
1156
|
+
this.markPartialCharge();
|
|
970
1157
|
const eventPrice = this.getEventPricing(intent.eventName).price;
|
|
971
1158
|
this.applyAcceptedCount(intent.eventName, intent.count, accepted, eventPrice);
|
|
972
1159
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
@@ -975,8 +1162,7 @@ class ChargingManager {
|
|
|
975
1162
|
}
|
|
976
1163
|
catch (err) {
|
|
977
1164
|
if (err instanceof ChargeRejectedError) {
|
|
978
|
-
|
|
979
|
-
this.markChargeLimitHit();
|
|
1165
|
+
this.markRejectedCharge(err);
|
|
980
1166
|
const eventPrice = this.getEventPricing(intent.eventName).price;
|
|
981
1167
|
this.applyAcceptedCount(intent.eventName, intent.count, 0, eventPrice);
|
|
982
1168
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
@@ -1049,8 +1235,9 @@ class ChargingManager {
|
|
|
1049
1235
|
}
|
|
1050
1236
|
return 0;
|
|
1051
1237
|
})();
|
|
1238
|
+
// The local budget gate: only maxTotalChargeUsd trims here.
|
|
1052
1239
|
if (chargedCount < count)
|
|
1053
|
-
this.markChargeLimitHit();
|
|
1240
|
+
this.markChargeLimitHit('max-charge');
|
|
1054
1241
|
if (chargedCount === 0) {
|
|
1055
1242
|
return {
|
|
1056
1243
|
eventChargeLimitReached: count > 0,
|
|
@@ -1093,8 +1280,7 @@ class ChargingManager {
|
|
|
1093
1280
|
}
|
|
1094
1281
|
catch (err) {
|
|
1095
1282
|
if (err instanceof ChargeRejectedError) {
|
|
1096
|
-
|
|
1097
|
-
this.markChargeLimitHit();
|
|
1283
|
+
this.markRejectedCharge(err);
|
|
1098
1284
|
// Server definitively rejected: unwind local reservation.
|
|
1099
1285
|
this.pendingCharges.delete(intent.idempotencyKey);
|
|
1100
1286
|
chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, 0, eventPricing.price);
|
|
@@ -1112,7 +1298,7 @@ class ChargingManager {
|
|
|
1112
1298
|
this.lastAcceptedOperationId = intent.idempotencyKey;
|
|
1113
1299
|
// The platform capped the charge at the run's limit.
|
|
1114
1300
|
if (accepted < chargedCount)
|
|
1115
|
-
this.
|
|
1301
|
+
this.markPartialCharge();
|
|
1116
1302
|
chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, accepted, eventPricing.price);
|
|
1117
1303
|
this.syncPlatformBudget();
|
|
1118
1304
|
}
|
|
@@ -1143,7 +1329,7 @@ class ChargingManager {
|
|
|
1143
1329
|
}
|
|
1144
1330
|
if (chargedCount < count) {
|
|
1145
1331
|
const subject = count === 1 ? 'instance' : 'instances';
|
|
1146
|
-
console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed
|
|
1332
|
+
console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed ${this.limitName()} - only ${chargedCount} events were charged`);
|
|
1147
1333
|
}
|
|
1148
1334
|
return {
|
|
1149
1335
|
eventChargeLimitReached: this.calculateMaxEventChargeCountWithinLimit(normalizedEventName) <= 0,
|
|
@@ -1197,6 +1383,7 @@ function classifyChargeHttpError(err, intent) {
|
|
|
1197
1383
|
count: intent.count,
|
|
1198
1384
|
statusCode: status,
|
|
1199
1385
|
reason: status === 402 ? 'charge-limit-exceeded' : `http-${status}`,
|
|
1386
|
+
...(status === 402 ? rejectionLimit(err, String(err?.message ?? '')) : {}),
|
|
1200
1387
|
});
|
|
1201
1388
|
}
|
|
1202
1389
|
// A 5xx / 408 / 429 answer is never a definitive rejection, whatever its text
|
|
@@ -1213,6 +1400,7 @@ function classifyChargeHttpError(err, intent) {
|
|
|
1213
1400
|
count: intent.count,
|
|
1214
1401
|
statusCode: status,
|
|
1215
1402
|
reason: 'message-match',
|
|
1403
|
+
...(/charge-limit-exceeded/i.test(message) ? rejectionLimit(err, message) : {}),
|
|
1216
1404
|
});
|
|
1217
1405
|
}
|
|
1218
1406
|
return null;
|
|
@@ -1302,7 +1490,7 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
1302
1490
|
const allItemsTrimmed = itemsArray.length > 0 && limitedItems.length === 0;
|
|
1303
1491
|
if (isDefaultDataset && limitedItems.length < itemsArray.length) {
|
|
1304
1492
|
// Rows beyond what the budget can pay for are dropped.
|
|
1305
|
-
chargingManager._markChargeLimitHit?.();
|
|
1493
|
+
chargingManager._markChargeLimitHit?.('max-charge');
|
|
1306
1494
|
chargingManager._noteRefusedDefaultDatasetItems?.(itemsArray.length - limitedItems.length);
|
|
1307
1495
|
}
|
|
1308
1496
|
if (limitedItems.length === 0) {
|
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('.');
|
|
@@ -31,8 +31,9 @@ function connectEvents(eventsWsUrl, token, runId) {
|
|
|
31
31
|
return;
|
|
32
32
|
shouldReconnect = true;
|
|
33
33
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// The token goes in a header, not the URL: URLs end up in access logs.
|
|
35
|
+
const url = `${eventsWsUrl}?runId=${encodeURIComponent(runId)}`;
|
|
36
|
+
ws = new ws_1.default(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
36
37
|
ws.onopen = () => {
|
|
37
38
|
connected = true;
|
|
38
39
|
console.log(`[Scrapely Events] Connected to ${eventsWsUrl}`);
|
|
@@ -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
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.40",
|
|
4
4
|
"description": "Scrapely SDK for building JavaScript and TypeScript Actors",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -89,20 +89,20 @@
|
|
|
89
89
|
"crawlee": "^3.16.0",
|
|
90
90
|
"dockerode": "^4.0.9",
|
|
91
91
|
"ioredis": "^6.0.0",
|
|
92
|
-
"ip-address": "10.
|
|
92
|
+
"ip-address": "10.6.0",
|
|
93
93
|
"lodash": "^4.18.1",
|
|
94
94
|
"mysql2": "^3.23.1",
|
|
95
95
|
"playwright": "^1.58.2",
|
|
96
96
|
"scrapely-client": "^2.25.9",
|
|
97
97
|
"tar-fs": "2.1.5",
|
|
98
|
-
"ws": "^8.
|
|
98
|
+
"ws": "^8.21.0"
|
|
99
99
|
},
|
|
100
100
|
"devDependencies": {
|
|
101
101
|
"@types/dockerode": "^4.0.1",
|
|
102
102
|
"@types/node": "^25.5.0",
|
|
103
103
|
"@types/ws": "^8.5.10",
|
|
104
104
|
"dotenv": "^17.3.1",
|
|
105
|
-
"puppeteer-core": "^
|
|
105
|
+
"puppeteer-core": "^25.16.0",
|
|
106
106
|
"tsx": "^4.21.0",
|
|
107
107
|
"typescript": "^5.7.0"
|
|
108
108
|
},
|
|
@@ -113,8 +113,10 @@
|
|
|
113
113
|
"axios": "^1.20.0",
|
|
114
114
|
"brace-expansion": "5.0.9",
|
|
115
115
|
"fast-xml-builder": "^1.2.1",
|
|
116
|
+
"strnum": "^2.4.2",
|
|
116
117
|
"file-type": "^21.3.3",
|
|
117
118
|
"follow-redirects": "^1.16.1",
|
|
119
|
+
"protobufjs": "^7.6.6",
|
|
118
120
|
"@crawlee/core": {
|
|
119
121
|
"ow": "0.28.2"
|
|
120
122
|
},
|
|
@@ -131,6 +133,7 @@
|
|
|
131
133
|
"@inquirer/external-editor": "3.0.3",
|
|
132
134
|
"nanoid": "5.1.16",
|
|
133
135
|
"browserslist": "4.28.7",
|
|
134
|
-
"basic-ftp": "^6.2.3"
|
|
136
|
+
"basic-ftp": "^6.2.3",
|
|
137
|
+
"@asamuzakjp/css-color": "^7.0.1"
|
|
135
138
|
}
|
|
136
139
|
}
|