scrapely 1.2.33 → 1.2.34

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/NOTICE +12 -26
  3. package/README.md +1 -2
  4. package/dist/actor.d.ts +32 -25
  5. package/dist/actor.js +39 -23
  6. package/dist/charging.d.ts +49 -4
  7. package/dist/charging.js +293 -44
  8. package/dist/configuration.js +2 -2
  9. package/dist/index.js +1 -1
  10. package/dist/node-wrapper.js +1 -1
  11. package/dist/patch-browser-pool.js +7 -3
  12. package/dist/proxy-configuration.d.ts +11 -11
  13. package/dist/proxy-configuration.js +1 -1
  14. package/dist/proxy-input-aliases.d.ts +1 -1
  15. package/dist/proxy-input-aliases.js +1 -1
  16. package/dist/request-list.d.ts +2 -2
  17. package/dist/request-list.js +1 -1
  18. package/dist/scrapely-proxy-configuration.d.ts +15 -16
  19. package/dist/scrapely-proxy-configuration.js +27 -31
  20. package/dist/storage/api-storage-client.d.ts +4 -5
  21. package/dist/storage/api-storage-client.js +17 -15
  22. package/dist/storage/billing-tracker.d.ts +1 -1
  23. package/dist/storage/billing-tracker.js +1 -1
  24. package/dist/storage/key-value-store-client.d.ts +0 -3
  25. package/dist/storage/key-value-store-client.js +2 -5
  26. package/dist/storage/kv-store-collection-client.js +6 -2
  27. package/dist/storage/kvs-iteration.d.ts +1 -1
  28. package/dist/storage/kvs-iteration.js +1 -1
  29. package/dist/storage/request-queue-client.d.ts +1 -1
  30. package/dist/storage/request-queue-client.js +5 -5
  31. package/dist/storage/rq-lock-script.d.ts +2 -2
  32. package/dist/storage/rq-lock-script.js +2 -2
  33. package/dist/storage/run-metadata.js +1 -1
  34. package/dist/storage/ttl-manager.d.ts +7 -6
  35. package/dist/storage/ttl-manager.js +5 -4
  36. package/dist/storage-identifiers.d.ts +1 -2
  37. package/dist/storage-identifiers.js +1 -2
  38. package/dist/utils.d.ts +41 -8
  39. package/dist/utils.js +49 -3
  40. package/package.json +22 -11
package/dist/charging.js CHANGED
@@ -8,6 +8,7 @@ exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
8
8
  exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
9
9
  exports.hasDefaultDatasetItemEventInPricing = hasDefaultDatasetItemEventInPricing;
10
10
  exports.mergeChargeResults = mergeChargeResults;
11
+ exports.assertItemsSerializable = assertItemsSerializable;
11
12
  exports.pushDataAndCharge = pushDataAndCharge;
12
13
  exports.validateRowCharges = validateRowCharges;
13
14
  exports.pushDataWithCharges = pushDataWithCharges;
@@ -48,7 +49,7 @@ class ChargeUncertainError extends Error {
48
49
  }
49
50
  }
50
51
  exports.ChargeUncertainError = ChargeUncertainError;
51
- /** Apify-compatible synthetic event for default-dataset items in pay-per-event actors. */
52
+ /** Synthetic event for default-dataset items in pay-per-event actors. */
52
53
  /** Definitive platform rejection (402/400/409). Do not retry with the same key. */
53
54
  class ChargeRejectedError extends Error {
54
55
  operationId;
@@ -108,7 +109,7 @@ function refuseDefaultDatasetPush(itemCount) {
108
109
  exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = 'apify-default-dataset-item';
109
110
  /** Scrapely-native synthetic event for default-dataset items. */
110
111
  exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = 'scrapely-default-dataset-item';
111
- /** Apify-compatible alias — imported actors expect this constant name. */
112
+ /** Alias — imported actors expect this constant name. */
112
113
  exports.DEFAULT_DATASET_ITEM_EVENT = exports.APIFY_DEFAULT_DATASET_ITEM_EVENT;
113
114
  function isDefaultDatasetItemEvent(eventName) {
114
115
  return eventName === exports.APIFY_DEFAULT_DATASET_ITEM_EVENT || eventName === exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT;
@@ -128,7 +129,7 @@ function hasDefaultDatasetItemEventInPricing(perEventPrices) {
128
129
  }
129
130
  /**
130
131
  * Events that must never be POSTed to the charge API (server derives them at settle).
131
- * Explicit names only — a prefix heuristic would drop real priced events like `apify-foo`.
132
+ * Explicit names only — a prefix heuristic would drop real priced events.
132
133
  */
133
134
  function isSyntheticEvent(eventName) {
134
135
  return isDefaultDatasetItemEvent(eventName);
@@ -361,8 +362,23 @@ class ChargingManager {
361
362
  lastAcceptedOperationId;
362
363
  /** Voids not yet confirmed by the platform; retried before the next push and at exit. */
363
364
  pendingVoids = [];
365
+ /**
366
+ * @internal Platform charges for results that were not stored and that the
367
+ * platform definitively refused to void (event -> count). They stay billed,
368
+ * so the next push of that event uses them instead of charging again.
369
+ */
370
+ _voidRefusedCredit = {};
364
371
  /** Unresolved platform POSTs: key = idempotency UUID (never event name). */
365
372
  pendingCharges = new Map();
373
+ /**
374
+ * Record in the run's default key-value store that mirrors pendingCharges,
375
+ * so a container crash does not lose them: settlement re-applies them under
376
+ * their own idempotency keys. One record per container, so a restarted
377
+ * container never overwrites its predecessor's.
378
+ */
379
+ pendingRecordKey = `SCRAPELY_PENDING_CHARGES-${(0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 12)}`;
380
+ /** Intent keys last written to that record ('' = nothing written / empty). */
381
+ persistedPendingSig = '';
366
382
  flushInFlight = null;
367
383
  operationTail = Promise.resolve();
368
384
  postAttempts = POST_ATTEMPTS;
@@ -735,6 +751,39 @@ class ChargingManager {
735
751
  this.lastAcceptedOperationId = undefined;
736
752
  return id;
737
753
  }
754
+ /**
755
+ * @internal Mirror the unconfirmed charges to the run's default key-value
756
+ * store (best effort, only when the set changed since the last write).
757
+ */
758
+ async _persistPendingCharges() {
759
+ if (!this.isAtHome || !this.actorRunId)
760
+ return;
761
+ const intents = [...this.pendingCharges.values()].map(({ idempotencyKey, eventName, count, createdAt }) => ({ idempotencyKey, eventName, count, createdAt }));
762
+ const sig = intents.map((i) => i.idempotencyKey).sort().join(',');
763
+ if (sig === this.persistedPendingSig)
764
+ return;
765
+ const storeId = process.env.ACTOR_DEFAULT_KEY_VALUE_STORE_ID
766
+ || process.env.APIFY_DEFAULT_KEY_VALUE_STORE_ID
767
+ || this.configuration.get('defaultKeyValueStoreId');
768
+ if (!storeId || storeId === 'default')
769
+ return;
770
+ let timer;
771
+ try {
772
+ const store = this.apifyClient.keyValueStore(storeId);
773
+ await Promise.race([
774
+ store.setRecord({ key: this.pendingRecordKey, value: { v: 1, intents } }),
775
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timed out')), 15_000); }),
776
+ ]);
777
+ this.persistedPendingSig = sig;
778
+ }
779
+ catch (err) {
780
+ console.warn(`[Scrapely] Could not persist ${intents.length} unconfirmed charge(s): ${err.message}`);
781
+ }
782
+ finally {
783
+ if (timer)
784
+ clearTimeout(timer);
785
+ }
786
+ }
738
787
  /**
739
788
  * @internal Reverse an accepted charge whose items were never stored
740
789
  * (POST /v2/actor-runs/:runId/charge/void), then unwind the local
@@ -748,7 +797,54 @@ class ChargingManager {
748
797
  */
749
798
  async _cancelUncertainCharge(eventName, operationId, reservedCount) {
750
799
  this.pendingCharges.delete(operationId);
751
- return this._voidCharge(eventName, operationId, reservedCount, true);
800
+ const voided = await this._voidCharge(eventName, operationId, reservedCount, true);
801
+ // A persisted copy is harmless either way (the void leaves a tombstone),
802
+ // but keep the record small.
803
+ await this._persistPendingCharges();
804
+ return voided;
805
+ }
806
+ /**
807
+ * @internal Void (or cancel, when unconfirmed) a whole charge and say what
808
+ * happened: 'voided' (confirmed, local reservation released), 'stands' (a
809
+ * definitive answer that it cannot be voided) or 'unknown' (queued for retry,
810
+ * reservation kept).
811
+ */
812
+ async _voidOutcome(eventName, operationId, reservedCount, uncertain) {
813
+ const queued = this.pendingVoids.length;
814
+ const ok = uncertain
815
+ ? await this._cancelUncertainCharge(eventName, operationId, reservedCount)
816
+ : await this._voidCharge(eventName, operationId, reservedCount);
817
+ if (ok)
818
+ return 'voided';
819
+ return this.pendingVoids.length > queued ? 'unknown' : 'stands';
820
+ }
821
+ /** @internal A charge for `count` unstored results stands (void refused): keep it as credit. */
822
+ _noteUnvoidable(eventName, count) {
823
+ if (count <= 0)
824
+ return;
825
+ this._voidRefusedCredit[eventName] = (this._voidRefusedCredit[eventName] ?? 0) + count;
826
+ console.warn(`[Scrapely] Charge for ${count} unstored '${eventName}' result(s) could not be voided; ` +
827
+ `the next push of '${eventName}' uses it instead of charging again`);
828
+ }
829
+ /** @internal Use credit for a whole charge of `count` events (all or nothing); returns the count used. */
830
+ _takeVoidCredit(eventName, count) {
831
+ const have = this._voidRefusedCredit[eventName] ?? 0;
832
+ if (count <= 0 || have < count)
833
+ return 0;
834
+ this._voidRefusedCredit[eventName] = have - count;
835
+ return count;
836
+ }
837
+ /** @internal */
838
+ _returnVoidCredit(eventName, count) {
839
+ if (count > 0)
840
+ this._voidRefusedCredit[eventName] = (this._voidRefusedCredit[eventName] ?? 0) + count;
841
+ }
842
+ /** @internal Void (or cancel) a charge for results that were not stored; a refused void becomes credit. */
843
+ async _voidOrCredit(eventName, operationId, reservedCount, uncertain) {
844
+ const outcome = await this._voidOutcome(eventName, operationId, reservedCount, uncertain);
845
+ if (outcome === 'stands')
846
+ this._noteUnvoidable(eventName, reservedCount);
847
+ return outcome;
752
848
  }
753
849
  async _voidCharge(eventName, operationId, reservedCount, notFoundMeansVoided = false, deadline) {
754
850
  if (!this.isAtHome || !this.actorRunId)
@@ -897,6 +993,7 @@ class ChargingManager {
897
993
  if (confirmed === 0)
898
994
  break;
899
995
  }
996
+ await this._persistPendingCharges();
900
997
  return confirmedTotal;
901
998
  })();
902
999
  try {
@@ -1020,6 +1117,9 @@ class ChargingManager {
1020
1117
  this.syncPlatformBudget();
1021
1118
  }
1022
1119
  else {
1120
+ // Not confirmed: keep a durable copy so a crash before the next
1121
+ // flush cannot lose it (settlement re-applies it).
1122
+ await this._persistPendingCharges();
1023
1123
  throw new ChargeUncertainError(`Charge for event '${normalizedEventName}' (count=${chargedCount}) was not confirmed by the platform; operationId=${intent.idempotencyKey}`, {
1024
1124
  operationId: intent.idempotencyKey,
1025
1125
  eventName: normalizedEventName,
@@ -1064,7 +1164,7 @@ function extractChargeResponseBody(response) {
1064
1164
  if (!response || typeof response !== 'object')
1065
1165
  return {};
1066
1166
  const r = response;
1067
- // apify-client httpClient.call returns { data, statusCode, ... }
1167
+ // The API client's httpClient.call returns { data, statusCode, ... }
1068
1168
  if (r.data && typeof r.data === 'object' && !Array.isArray(r.data)) {
1069
1169
  return r.data;
1070
1170
  }
@@ -1099,6 +1199,12 @@ function classifyChargeHttpError(err, intent) {
1099
1199
  reason: status === 402 ? 'charge-limit-exceeded' : `http-${status}`,
1100
1200
  });
1101
1201
  }
1202
+ // A 5xx / 408 / 429 answer is never a definitive rejection, whatever its text
1203
+ // says: the charge may have applied, and only a retry with the same
1204
+ // Idempotency-Key can tell (classifying it as rejected unwound the local
1205
+ // count and the caller charged again under a new key).
1206
+ if (status !== undefined && isTransientStatus(status))
1207
+ return null;
1102
1208
  const message = String(err?.message ?? err ?? '');
1103
1209
  if (/charge-limit-exceeded|unknown-event|ledger-sealed|invalid-input|conflict/i.test(message)) {
1104
1210
  return new ChargeRejectedError(message, {
@@ -1111,7 +1217,39 @@ function classifyChargeHttpError(err, intent) {
1111
1217
  }
1112
1218
  return null;
1113
1219
  }
1114
- async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultDataset, pushFn, }) {
1220
+ /**
1221
+ * @internal Fail before anything is charged when an item cannot be serialized
1222
+ * for the dataset (circular reference, BigInt, a bare function or undefined):
1223
+ * the push would otherwise fail after the charge, with nothing stored.
1224
+ */
1225
+ function assertItemsSerializable(items) {
1226
+ items.forEach((item, index) => {
1227
+ let json;
1228
+ try {
1229
+ json = JSON.stringify(item);
1230
+ }
1231
+ catch (err) {
1232
+ throw new Error(`Dataset item at index ${index} is not serializable to JSON: ${err.message}`);
1233
+ }
1234
+ if (json === undefined)
1235
+ throw new Error(`Dataset item at index ${index} is not serializable to JSON`);
1236
+ });
1237
+ }
1238
+ // Credit / void helpers that also work with partial managers (tests, older shims).
1239
+ const takeVoidCredit = (m, name, count) => isSyntheticEvent(name) ? 0 : (m._takeVoidCredit?.(name, count) ?? 0);
1240
+ const returnVoidCredit = (m, name, count) => m._returnVoidCredit?.(name, count);
1241
+ async function voidOrCredit(m, name, operationId, reserved, uncertain, creditIfStands = true) {
1242
+ if (creditIfStands && m._voidOrCredit)
1243
+ return m._voidOrCredit(name, operationId, reserved, uncertain);
1244
+ if (m._voidOutcome)
1245
+ return m._voidOutcome(name, operationId, reserved, uncertain);
1246
+ if (uncertain)
1247
+ await m._cancelUncertainCharge?.(name, operationId, reserved);
1248
+ else
1249
+ await m._voidCharge(name, operationId, reserved);
1250
+ return 'voided';
1251
+ }
1252
+ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultDataset, pushFn, validateItems, }) {
1115
1253
  return chargingManager._withOperationLock(async () => {
1116
1254
  if (chargingManager.isClosed()) {
1117
1255
  throw new Error('ChargingManager is closed (actor is shutting down); charge rejected');
@@ -1178,6 +1316,9 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
1178
1316
  // items in the dataset. Operation lock is held across both steps.
1179
1317
  let chargeResult;
1180
1318
  if (Object.keys(eventsToCharge).length > 0) {
1319
+ // An item the storage would refuse fails here, before it is charged.
1320
+ assertItemsSerializable(limitedItems);
1321
+ validateItems?.(limitedItems);
1181
1322
  const results = [];
1182
1323
  // Synthetic/default-dataset events first (local-only); then explicit.
1183
1324
  const ordered = Object.entries(eventsToCharge).sort(([a], [b]) => {
@@ -1185,10 +1326,35 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
1185
1326
  const bSyn = isSyntheticEvent(b) ? 0 : 1;
1186
1327
  return aSyn - bSyn;
1187
1328
  });
1329
+ // credit: events of this push paid from credit (an earlier charge that could not be voided).
1188
1330
  const reservedInOp = [];
1331
+ // Undo charge i of this operation: void a platform charge, unwind a local
1332
+ // one, give credit back. A refused void becomes credit (its results were
1333
+ // not stored) unless the caller handles that itself (creditIfStands=false).
1334
+ const undoOp = async (i, creditIfStands = true) => {
1335
+ const entry = reservedInOp[i];
1336
+ const { name, reserved, operationId, uncertain } = entry;
1337
+ if (reserved <= 0)
1338
+ return 'voided';
1339
+ if (entry.credit) {
1340
+ returnVoidCredit(chargingManager, name, entry.credit);
1341
+ entry.credit = 0;
1342
+ return 'voided';
1343
+ }
1344
+ if (operationId)
1345
+ return voidOrCredit(chargingManager, name, operationId, reserved, !!uncertain, creditIfStands);
1346
+ chargingManager.applyAcceptedCount(name, reserved, 0);
1347
+ return 'voided';
1348
+ };
1189
1349
  try {
1190
1350
  for (const [name, chargeCount] of ordered) {
1191
1351
  chargingManager._takeLastAcceptedOperationId?.();
1352
+ const credit = takeVoidCredit(chargingManager, name, chargeCount);
1353
+ if (credit > 0) {
1354
+ results.push({ eventChargeLimitReached: false, chargedCount: credit, chargeableWithinLimit: chargingManager._chargeableWithinLimit?.() ?? {} });
1355
+ reservedInOp.push({ name, reserved: credit, credit });
1356
+ continue;
1357
+ }
1192
1358
  let r;
1193
1359
  try {
1194
1360
  r = await chargingManager._chargeExclusive({
@@ -1218,14 +1384,64 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
1218
1384
  }
1219
1385
  }
1220
1386
  catch (err) {
1221
- // Uncertain/failure after partial multi-event reserve: unwind all
1222
- // prior reservations for this operation (synthetic + explicit).
1223
- for (const { name, reserved } of reservedInOp) {
1224
- if (reserved > 0) {
1225
- chargingManager.applyAcceptedCount(name, reserved, 0);
1387
+ // Failure after a partial multi-event reserve: undo every prior
1388
+ // charge of this operation, voiding the ones the platform accepted
1389
+ // (not only unwinding them locally).
1390
+ for (let i = 0; i < reservedInOp.length; i++)
1391
+ await undoOp(i);
1392
+ throw err;
1393
+ }
1394
+ // One platform event accepted more than another: only the smaller
1395
+ // count of items is stored, so the larger platform charge is voided
1396
+ // and charged again for exactly that count. A charge that cannot be
1397
+ // voided (definitive answer) stays as before; one whose void is not
1398
+ // confirmed yet fails the push so the caller retries it.
1399
+ // (Today a push posts at most one platform event; this keeps it
1400
+ // correct if that changes.)
1401
+ const stood = new Set();
1402
+ for (let round = 0; round < 3; round++) {
1403
+ const acceptedNow = Math.min(limitedItems.length, ...results.map((r) => r.chargedCount));
1404
+ const excess = results
1405
+ .map((r, i) => i)
1406
+ .filter((i) => reservedInOp[i]?.operationId && !stood.has(i) && results[i].chargedCount > acceptedNow);
1407
+ if (acceptedNow <= 0 || excess.length === 0)
1408
+ break;
1409
+ for (const i of excess) {
1410
+ const name = reservedInOp[i].name;
1411
+ // A refused void here keeps its stored share; the rest becomes credit below.
1412
+ const outcome = await undoOp(i, false);
1413
+ if (outcome === 'stands') {
1414
+ stood.add(i);
1415
+ continue;
1416
+ }
1417
+ if (outcome === 'unknown') {
1418
+ for (let j = 0; j < reservedInOp.length; j++)
1419
+ if (j !== i)
1420
+ await undoOp(j);
1421
+ throw new Error('[Scrapely] An earlier charge for a failed dataset push could not be voided yet; retry the push');
1422
+ }
1423
+ chargingManager._takeLastAcceptedOperationId?.();
1424
+ let r;
1425
+ let operationId;
1426
+ let uncertain = false;
1427
+ try {
1428
+ r = await chargingManager._chargeExclusive({ eventName: name, count: acceptedNow });
1429
+ operationId = chargingManager._takeLastAcceptedOperationId?.();
1430
+ }
1431
+ catch (chargeErr) {
1432
+ if (!(chargeErr instanceof ChargeUncertainError))
1433
+ throw chargeErr;
1434
+ r = {
1435
+ eventChargeLimitReached: false,
1436
+ chargedCount: chargeErr.count,
1437
+ chargeableWithinLimit: chargingManager._chargeableWithinLimit?.() ?? {},
1438
+ };
1439
+ operationId = chargeErr.operationId;
1440
+ uncertain = true;
1226
1441
  }
1442
+ results[i] = r;
1443
+ reservedInOp[i] = { name, reserved: r.chargedCount, operationId, uncertain };
1227
1444
  }
1228
- throw err;
1229
1445
  }
1230
1446
  // Persist only what every charged event accepted.
1231
1447
  // When multiple events are charged for the same items, use the
@@ -1243,8 +1459,22 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
1243
1459
  // count to the void below, which releases it only once the
1244
1460
  // platform confirms (never on a server without the void route).
1245
1461
  const platformOp = acceptedForItems <= 0 && reservedInOp[i]?.operationId;
1246
- if (!platformOp)
1247
- chargingManager.applyAcceptedCount(name, reserved, acceptedForItems);
1462
+ if (!platformOp) {
1463
+ const entry = reservedInOp[i];
1464
+ let over = reserved - acceptedForItems;
1465
+ const back = Math.min(over, entry.credit ?? 0);
1466
+ if (back > 0) {
1467
+ returnVoidCredit(chargingManager, name, back);
1468
+ entry.credit = (entry.credit ?? 0) - back;
1469
+ entry.reserved -= back;
1470
+ over -= back;
1471
+ }
1472
+ // The platform refused to void it: the unstored share stays billed.
1473
+ if (over > 0 && stood.has(i))
1474
+ chargingManager._noteUnvoidable?.(name, over);
1475
+ else if (over > 0)
1476
+ chargingManager.applyAcceptedCount(name, acceptedForItems + over, acceptedForItems);
1477
+ }
1248
1478
  results[i] = {
1249
1479
  ...results[i],
1250
1480
  chargedCount: acceptedForItems,
@@ -1252,7 +1482,7 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
1252
1482
  };
1253
1483
  }
1254
1484
  }
1255
- // Report rows charged, as Apify does for the pushed event: summing the
1485
+ // Report rows charged for the pushed event: summing the
1256
1486
  // explicit and default-dataset events would count every row twice.
1257
1487
  chargeResult = { ...results.reduce(mergeChargeResults), chargedCount: acceptedForItems };
1258
1488
  if (isDefaultDataset && acceptedForItems < limitedItems.length) {
@@ -1263,15 +1493,19 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
1263
1493
  // zero): undo every platform charge this operation made, so no
1264
1494
  // result is billed for items that are never saved.
1265
1495
  for (let i = 0; i < reservedInOp.length; i++) {
1266
- const { name, operationId, uncertain, reserved } = reservedInOp[i];
1496
+ const entry = reservedInOp[i];
1497
+ const { name, operationId, uncertain } = entry;
1498
+ let { reserved } = entry;
1499
+ if (entry.credit) {
1500
+ returnVoidCredit(chargingManager, name, entry.credit);
1501
+ reserved -= entry.credit;
1502
+ entry.credit = 0;
1503
+ }
1267
1504
  if (reserved <= 0 || !operationId)
1268
1505
  continue;
1269
1506
  // Original count: the local reservation is released only when
1270
1507
  // the platform confirms the void (or that it never applied).
1271
- if (uncertain)
1272
- await chargingManager._cancelUncertainCharge?.(name, operationId, reserved);
1273
- else
1274
- await chargingManager._voidCharge(name, operationId, reserved);
1508
+ await voidOrCredit(chargingManager, name, operationId, reserved, !!uncertain);
1275
1509
  }
1276
1510
  return {
1277
1511
  eventChargeLimitReached: true,
@@ -1297,16 +1531,21 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
1297
1531
  if (!pushCertainlyFailed(pushErr))
1298
1532
  throw pushErr;
1299
1533
  for (let i = 0; i < reservedInOp.length; i++) {
1300
- const { name, operationId, uncertain } = reservedInOp[i];
1301
- const reserved = results[i].chargedCount;
1534
+ const entry = reservedInOp[i];
1535
+ const { name, operationId, uncertain } = entry;
1536
+ let reserved = results[i].chargedCount;
1537
+ if (entry.credit) {
1538
+ const back = Math.min(reserved, entry.credit);
1539
+ returnVoidCredit(chargingManager, name, back);
1540
+ entry.credit -= back;
1541
+ reserved -= back;
1542
+ }
1302
1543
  if (reserved <= 0)
1303
1544
  continue;
1304
- if (operationId && uncertain) {
1305
- // Never re-post a charge for items that were not stored.
1306
- await chargingManager._cancelUncertainCharge?.(name, operationId, reserved);
1307
- }
1308
- else if (operationId) {
1309
- await chargingManager._voidCharge(name, operationId, reserved);
1545
+ if (operationId) {
1546
+ // An unconfirmed charge is cancelled (never re-posted for items
1547
+ // that were not stored); a refused void becomes credit.
1548
+ await voidOrCredit(chargingManager, name, operationId, reserved, !!uncertain);
1310
1549
  }
1311
1550
  else if (isSyntheticEvent(name)) {
1312
1551
  chargingManager.applyAcceptedCount(name, reserved, 0);
@@ -1351,7 +1590,7 @@ function validateRowCharges(charges) {
1351
1590
  * the rows that are kept, so no row is stored part-paid and no event is billed
1352
1591
  * for a row that was not stored. Unpriced events are ignored.
1353
1592
  */
1354
- async function pushDataWithCharges({ chargingManager, items, charges, isDefaultDataset, pushFn, chargePer = 'row', }) {
1593
+ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultDataset, pushFn, chargePer = 'row', validateItems, }) {
1355
1594
  const perRowIn = validateRowCharges(charges);
1356
1595
  if (chargePer !== 'row' && chargePer !== 'batch')
1357
1596
  throw new Error("chargePer must be 'row' or 'batch'");
@@ -1384,16 +1623,20 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
1384
1623
  if (!voidsConfirmed && Object.keys(perRow).some((n) => !isSyntheticEvent(n))) {
1385
1624
  throw new Error('[Scrapely] An earlier charge for a failed dataset push could not be voided yet; retry the push');
1386
1625
  }
1626
+ // An item the storage would refuse fails here, before it is charged.
1627
+ assertItemsSerializable(rowsIn);
1628
+ validateItems?.(rowsIn);
1387
1629
  const undo = async (ops) => {
1388
1630
  for (const op of ops) {
1389
1631
  if (op.charged <= 0)
1390
1632
  continue;
1391
- if (!op.operationId)
1633
+ if (op.credit)
1634
+ returnVoidCredit(chargingManager, op.name, op.charged);
1635
+ else if (!op.operationId)
1392
1636
  chargingManager.applyAcceptedCount(op.name, op.charged, 0);
1393
- else if (op.uncertain)
1394
- await chargingManager._cancelUncertainCharge(op.name, op.operationId, op.charged);
1637
+ // Cancel / void; a refused void stays billed and becomes credit.
1395
1638
  else
1396
- await chargingManager._voidCharge(op.name, op.operationId, op.charged);
1639
+ await voidOrCredit(chargingManager, op.name, op.operationId, op.charged, op.uncertain);
1397
1640
  }
1398
1641
  };
1399
1642
  const order = Object.entries(perRow).sort(([a], [b]) => Number(isSyntheticEvent(a)) - Number(isSyntheticEvent(b)) || a.localeCompare(b));
@@ -1405,19 +1648,25 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
1405
1648
  const want = n * rows;
1406
1649
  chargingManager._takeLastAcceptedOperationId?.();
1407
1650
  let got;
1408
- try {
1409
- const r = await chargingManager._chargeExclusive({ eventName: name, count: want });
1410
- got = r.chargedCount;
1411
- ops.push({ name, charged: got, operationId: chargingManager._takeLastAcceptedOperationId?.(), uncertain: false });
1651
+ if (takeVoidCredit(chargingManager, name, want) > 0) {
1652
+ got = want;
1653
+ ops.push({ name, charged: want, uncertain: false, credit: true });
1412
1654
  }
1413
- catch (err) {
1414
- if (!(err instanceof ChargeUncertainError)) {
1415
- await undo(ops);
1416
- throw err;
1655
+ else {
1656
+ try {
1657
+ const r = await chargingManager._chargeExclusive({ eventName: name, count: want });
1658
+ got = r.chargedCount;
1659
+ ops.push({ name, charged: got, operationId: chargingManager._takeLastAcceptedOperationId?.(), uncertain: false });
1660
+ }
1661
+ catch (err) {
1662
+ if (!(err instanceof ChargeUncertainError)) {
1663
+ await undo(ops);
1664
+ throw err;
1665
+ }
1666
+ // Not confirmed: stays pending and is re-posted at exit, so it counts as accepted.
1667
+ got = err.count;
1668
+ ops.push({ name, charged: got, operationId: err.operationId, uncertain: true });
1417
1669
  }
1418
- // Not confirmed: stays pending and is re-posted at exit, so it counts as accepted.
1419
- got = err.count;
1420
- ops.push({ name, charged: got, operationId: err.operationId, uncertain: true });
1421
1670
  }
1422
1671
  if (got < want) {
1423
1672
  shortTo = Math.floor(got / n);
@@ -86,8 +86,8 @@ class Configuration extends core_1.Configuration {
86
86
  defaultDatasetId: 'default',
87
87
  defaultRequestQueueId: 'default',
88
88
  inputKey: 'INPUT',
89
- apiBaseUrl: 'https://api-staging.scrape.ly',
90
- apiPublicBaseUrl: 'https://api-staging.scrape.ly',
89
+ apiBaseUrl: 'https://api.scrape.ly',
90
+ apiPublicBaseUrl: 'https://api.scrape.ly',
91
91
  proxyStatusUrl: 'http://proxy.scrape.ly',
92
92
  proxyHostname: 'proxy.scrape.ly',
93
93
  proxyPort: 8000,
package/dist/index.js CHANGED
@@ -90,7 +90,7 @@ Object.defineProperty(exports, "MissingRouteError", { enumerable: true, get: fun
90
90
  Object.defineProperty(exports, "CriticalError", { enumerable: true, get: function () { return core_2.CriticalError; } });
91
91
  Object.defineProperty(exports, "useState", { enumerable: true, get: function () { return core_2.useState; } });
92
92
  Object.defineProperty(exports, "mergeCookies", { enumerable: true, get: function () { return core_2.mergeCookies; } });
93
- // Apify v2 compatible utils namespace
93
+ // v2-style utils namespace
94
94
  var utils_js_1 = require("./utils.js");
95
95
  Object.defineProperty(exports, "utils", { enumerable: true, get: function () { return utils_js_1.utils; } });
96
96
  Object.defineProperty(exports, "enqueueLinks", { enumerable: true, get: function () { return utils_js_1.enqueueLinks; } });
@@ -57,7 +57,7 @@ const SCRIPT_CONFIG_ENV_RE = new RegExp(`^npm_config_(${[...SCRIPT_CONFIG_KEYS,
57
57
  /**
58
58
  * Whether an npmrc file sets any of SCRIPT_CONFIG_KEYS. Other settings
59
59
  * (progress=false, loglevel, registry, auth, ...) don't affect running a
60
- * script — Apify's Dockerfiles write `progress=false` to ~/.npmrc.
60
+ * script — the standard actor Dockerfiles write `progress=false` to ~/.npmrc.
61
61
  * Unreadable or unparseable files count as setting them.
62
62
  */
63
63
  function readNpmrc(file) {
@@ -1033,8 +1033,12 @@ function installEsmPuppeteerHook() {
1033
1033
  return;
1034
1034
  Module._scrapelyEsmPuppeteerHook = true;
1035
1035
  try {
1036
- if (typeof Module.registerHooks === 'function') {
1037
- // Node 22.15+ / 23.5+: synchronous, in-thread.
1036
+ // Node 22's synchronous hooks break require(esm) of CommonJS graphs that
1037
+ // ESM imports (crawlee -> linkedom: "request for 'boolbase' is not in cache");
1038
+ // Node 22 uses the off-thread loader instead.
1039
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
1040
+ if (typeof Module.registerHooks === 'function' && nodeMajor >= 24) {
1041
+ // Node 24+: synchronous, in-thread.
1038
1042
  Module.registerHooks({
1039
1043
  load(url, context, nextLoad) {
1040
1044
  const result = nextLoad(url, context);
@@ -1047,7 +1051,7 @@ function installEsmPuppeteerHook() {
1047
1051
  });
1048
1052
  }
1049
1053
  else if (typeof Module.register === 'function') {
1050
- // Node 20.6+ / 22.0-22.14: off-thread loader (esm-loader-hooks.mjs).
1054
+ // Node 20.6+ / 22: off-thread loader (esm-loader-hooks.mjs).
1051
1055
  // eslint-disable-next-line @typescript-eslint/no-var-requires
1052
1056
  const { pathToFileURL } = require('url');
1053
1057
  Module.register('./esm-loader-hooks.mjs', pathToFileURL(__filename));
@@ -1,8 +1,8 @@
1
1
  import type { ProxyConfigurationOptions as CoreProxyConfigurationOptions, ProxyInfo as CoreProxyInfo } from '@crawlee/core';
2
2
  import { ProxyConfiguration } from './scrapely-proxy-configuration.js';
3
3
  /**
4
- * Proxy info enriched with the platform-proxy (Apify-compatible) username
5
- * fields. Mirrors the Apify SDK's `ProxyInfo` extension of Crawlee's type.
4
+ * Proxy info enriched with the platform-proxy username fields
5
+ * (extends Crawlee's `ProxyInfo` type).
6
6
  */
7
7
  export interface ProxyInfo extends CoreProxyInfo {
8
8
  /** Proxy groups used by the platform proxy (e.g. `['RESIDENTIAL']`). */
@@ -18,33 +18,33 @@ export interface ProxyInfo extends CoreProxyInfo {
18
18
  * Modes supported:
19
19
  * - Platform proxy (default on Scrapely runs): when the server injects
20
20
  * APIFY_PROXY_* or SCRAPELY_PROXY_* credentials, groups/countryCode/password
21
- * build Apify-style gateway usernames automatically.
21
+ * build gateway usernames automatically.
22
22
  * - Bring-your-own-proxy (BYOP): custom proxyUrls / newUrlFunction /
23
23
  * tieredProxyUrls always win over the platform proxy.
24
24
  * - Mixed: the 'auto' (or 'http://auto') sentinel inside proxyUrls /
25
25
  * tieredProxyUrls / tieredProxyConfig[].proxyUrls reserves a slot for the
26
- * platform proxy alongside BYOP entries (Apify-compatible).
26
+ * platform proxy alongside BYOP entries.
27
27
  */
28
28
  export interface ProxyConfigurationOptions extends CoreProxyConfigurationOptions {
29
29
  /**
30
- * Multiple different ProxyConfigurationOptions stratified into tiers (Apify-compatible).
30
+ * Multiple different ProxyConfigurationOptions stratified into tiers.
31
31
  * Tiers with `proxyUrls` map directly to tieredProxyUrls; tiers without
32
32
  * URLs expand their platform options (groups/countryCode/password) to a
33
- * concrete platform proxy URL, mirroring Apify's `_generateTieredProxyUrls`.
33
+ * concrete platform proxy URL.
34
34
  */
35
35
  tieredProxyConfig?: Omit<ProxyConfigurationOptions, 'tieredProxyConfig' | 'tieredProxyUrls'>[];
36
36
  /**
37
- * Platform proxy groups (Apify-compatible). Effective in platform proxy
37
+ * Platform proxy groups. Effective in platform proxy
38
38
  * mode; ignored for BYOP configurations.
39
39
  */
40
40
  groups?: string[];
41
41
  /**
42
- * Platform proxy country code (Apify-compatible). Effective in platform
42
+ * Platform proxy country code. Effective in platform
43
43
  * proxy mode; ignored for BYOP configurations.
44
44
  */
45
45
  countryCode?: string;
46
46
  /**
47
- * Platform proxy subdivision code, e.g. US state (Apify-compatible).
47
+ * Platform proxy subdivision code, e.g. US state.
48
48
  * Requires `countryCode`. Effective in platform proxy mode; ignored for
49
49
  * BYOP configurations. Emitted as a merged `country-XX_YY` username
50
50
  * fragment (ISO 3166-2).
@@ -90,7 +90,7 @@ export interface CreateProxyConfigurationOptions extends ProxyConfigurationOptio
90
90
  /**
91
91
  * Platform proxy connection details injected into run containers by the
92
92
  * Scrapely server (backed by Evomi). SCRAPELY_* vars take precedence over the
93
- * Apify-compatible aliases so Scrapely-first deployments stay unambiguous.
93
+ * APIFY_* aliases so Scrapely-first deployments stay unambiguous.
94
94
  */
95
95
  export interface PlatformProxyEnv {
96
96
  hostname: string;
@@ -114,7 +114,7 @@ export declare function getPlatformProxyEnv(): PlatformProxyEnv | null;
114
114
  * Creates a proxy configuration for use with crawlers.
115
115
  *
116
116
  * The platform proxy (Evomi-backed) is injected into runs via the SCRAPELY_PROXY
117
- * and APIFY_PROXY environment variables. Apify Proxy options (groups, countryCode, ...)
117
+ * and APIFY_PROXY environment variables. Platform proxy options (groups, countryCode, ...)
118
118
  * build gateway usernames in platform mode; proxyUrls, newUrlFunction and
119
119
  * tieredProxyUrls configure bring-your-own proxies instead.
120
120
  */
@@ -57,7 +57,7 @@ function normalizeProxyInput(options) {
57
57
  * Creates a proxy configuration for use with crawlers.
58
58
  *
59
59
  * The platform proxy (Evomi-backed) is injected into runs via the SCRAPELY_PROXY
60
- * and APIFY_PROXY environment variables. Apify Proxy options (groups, countryCode, ...)
60
+ * and APIFY_PROXY environment variables. Platform proxy options (groups, countryCode, ...)
61
61
  * build gateway usernames in platform mode; proxyUrls, newUrlFunction and
62
62
  * tieredProxyUrls configure bring-your-own proxies instead.
63
63
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Proxy input objects with both namings. An input-schema proxy field may be
3
3
  * written with the Scrapely names (`useScrapelyProxy`, `scrapelyProxyGroups`,
4
- * `scrapelyProxyCountry`, `scrapelyProxySubdivision`) or the Apify names
4
+ * `scrapelyProxyCountry`, `scrapelyProxySubdivision`) or the legacy names
5
5
  * (`useApifyProxy`, `apifyProxy*`). Actor.getInput() and Actor.input return
6
6
  * every set option under both names, so actor code that reads either naming
7
7
  * sees the user's choice.