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 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':
@@ -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 the spending limit was reached were not saved`);
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
- this.chargeLimitHit = true;
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 limit = Number.isFinite(this.maxTotalChargeUsd) ? `$${this.maxTotalChargeUsd}` : 'the limit';
255
- console.warn(`[Scrapely] The spending limit of this run (maxTotalChargeUsd = ${limit}) has been reached: ` +
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.markChargeLimitHit();
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
- if (isChargeLimitRejection(err))
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
- if (isChargeLimitRejection(err))
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.markChargeLimitHit();
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 maxTotalChargeUsd - only ${chargedCount} events were charged`);
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) {
@@ -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
- const url = `${eventsWsUrl}?token=${encodeURIComponent(token)}&runId=${encodeURIComponent(runId)}`;
35
- ws = new ws_1.default(url);
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
- /** puppeteer / puppeteer-core CJS package entry (default instance), by filename. */
808
- const PUPPETEER_ENTRY_FILENAME_RE = /[/\\]node_modules[/\\](puppeteer|puppeteer-core)[/\\]lib[/\\]cjs[/\\]puppeteer[/\\](puppeteer|puppeteer-core)\.js$/;
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.38",
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.3.1",
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.18.0"
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": "^24.43.1",
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
  }