scrapely 1.2.7 → 1.2.9

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/dist/actor.d.ts CHANGED
@@ -371,7 +371,9 @@ export declare class Actor {
371
371
  static getInputOrThrow<T = ActorInput>(): Promise<T>;
372
372
  /**
373
373
  * Synchronously get the actor input (cached from first access).
374
- * This matches Apify's Actor.input behavior.
374
+ * The platform sets ACTOR_INPUT only for small inputs; for large ones this
375
+ * returns the input once `await Actor.getInput()` has loaded it from the
376
+ * default key-value store.
375
377
  */
376
378
  static get input(): Record<string, any> | undefined;
377
379
  /**
@@ -546,8 +548,8 @@ export declare class Actor {
546
548
  * });
547
549
  * ```
548
550
  *
549
- * For compatibility with existing Actor Input UI (Input Schema), this function returns
550
- * `undefined` when `{ useApifyProxy: false }` is passed.
551
+ * Pass the proxy input field (`editor: "proxy"`) as is: this function returns
552
+ * `undefined` when `{ useScrapelyProxy: false }` is passed without custom proxies.
551
553
  *
552
554
  * @param options - Proxy configuration options
553
555
  * @returns Promise resolving to ProxyConfiguration instance, or undefined
@@ -701,3 +703,5 @@ export declare class Actor {
701
703
  enqueueLinks: typeof import("./utils.js").enqueueLinks;
702
704
  };
703
705
  }
706
+ /** Resolves once stdout and stderr have flushed their buffers, or after `maxMs`. */
707
+ export declare function waitForStdioDrain(maxMs: number, streams?: NodeJS.WritableStream[]): Promise<void>;
package/dist/actor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Actor = exports.EXIT_CODES = exports.ACTOR_PERMISSION_LEVEL = void 0;
4
+ exports.waitForStdioDrain = waitForStdioDrain;
4
5
  require("./log-env.js");
5
6
  const node_events_1 = require("node:events");
6
7
  /** Actor permission level - matches Apify's ACTOR_PERMISSION_LEVEL enum */
@@ -172,6 +173,7 @@ class Actor {
172
173
  async init(options = {}) {
173
174
  if (this.initialized)
174
175
  return;
176
+ cachedInput = undefined; // a fresh init reads this run's input again
175
177
  // NOTE: `initialized` is set at the END of init — a failed platform init
176
178
  // must stay retryable instead of leaving a half-initialized Actor.
177
179
  Actor.installFatalChargeHandlers();
@@ -410,6 +412,13 @@ class Actor {
410
412
  const metamorphKey = `INPUT-METAMORPH-${count}`;
411
413
  const metamorphInput = await store.getValue(metamorphKey);
412
414
  if (metamorphInput !== null && metamorphInput !== undefined) {
415
+ // Same treatment as the regular input: decrypt secret fields
416
+ // and let the synchronous Actor.input getter return it.
417
+ if (typeof metamorphInput === 'object' && !Array.isArray(metamorphInput)) {
418
+ const decrypted = (0, secrets_js_1.decryptSecretInputs)(metamorphInput);
419
+ cachedInput = decrypted;
420
+ return decrypted;
421
+ }
413
422
  return metamorphInput;
414
423
  }
415
424
  // Fall through to regular INPUT if metamorph key not found
@@ -437,7 +446,11 @@ class Actor {
437
446
  // Decrypt secret inputs after loading (must happen here, not in init(),
438
447
  // because the input needs to be available first)
439
448
  if (input && typeof input === 'object' && !Array.isArray(input)) {
440
- return (0, secrets_js_1.decryptSecretInputs)(input);
449
+ const decrypted = (0, secrets_js_1.decryptSecretInputs)(input);
450
+ // Large inputs are only in the INPUT record (no ACTOR_INPUT env):
451
+ // let the synchronous Actor.input getter return what was loaded here.
452
+ cachedInput = decrypted;
453
+ return decrypted;
441
454
  }
442
455
  return input;
443
456
  }
@@ -450,14 +463,16 @@ class Actor {
450
463
  }
451
464
  /**
452
465
  * Synchronously get the actor input (cached from first access).
453
- * This matches Apify's Actor.input behavior.
466
+ * The platform sets ACTOR_INPUT only for small inputs; for large ones this
467
+ * returns the input once `await Actor.getInput()` has loaded it from the
468
+ * default key-value store.
454
469
  */
455
470
  static get input() {
456
471
  if (cachedInput !== undefined)
457
472
  return cachedInput ?? undefined;
458
473
  const raw = process.env.ACTOR_INPUT;
459
474
  if (!raw) {
460
- cachedInput = null;
475
+ // Not cached: a later Actor.getInput() fills it from the INPUT record.
461
476
  return undefined;
462
477
  }
463
478
  try {
@@ -928,10 +943,11 @@ class Actor {
928
943
  await eventManager.close();
929
944
  (0, events_client_js_1.disconnectEvents)();
930
945
  (0, patch_storage_js_1.disconnectStorage)();
931
- // Brief grace for in-flight I/O after flush (bounded).
932
- await new Promise((resolve) => {
933
- setTimeout(resolve, Math.min(1000, flushDeadlineMs));
934
- });
946
+ // Let buffered stdout/stderr reach the log pipe before the process exits
947
+ // (pipe writes are async in Node). Storage was flushed above, so this
948
+ // replaces the old fixed 1 s sleep: it resolves as soon as both streams
949
+ // drain, usually within milliseconds, and is still bounded.
950
+ await waitForStdioDrain(Math.min(1000, flushDeadlineMs));
935
951
  }
936
952
  /**
937
953
  * Sets the status message for the current Actor run.
@@ -1015,8 +1031,8 @@ class Actor {
1015
1031
  * });
1016
1032
  * ```
1017
1033
  *
1018
- * For compatibility with existing Actor Input UI (Input Schema), this function returns
1019
- * `undefined` when `{ useApifyProxy: false }` is passed.
1034
+ * Pass the proxy input field (`editor: "proxy"`) as is: this function returns
1035
+ * `undefined` when `{ useScrapelyProxy: false }` is passed without custom proxies.
1020
1036
  *
1021
1037
  * @param options - Proxy configuration options
1022
1038
  * @returns Promise resolving to ProxyConfiguration instance, or undefined
@@ -1439,3 +1455,22 @@ class Actor {
1439
1455
  static utils = utils_js_1.utils;
1440
1456
  }
1441
1457
  exports.Actor = Actor;
1458
+ /** Resolves once stdout and stderr have flushed their buffers, or after `maxMs`. */
1459
+ async function waitForStdioDrain(maxMs, streams = [process.stdout, process.stderr]) {
1460
+ const drained = Promise.all(streams.map((stream) => new Promise((resolve) => {
1461
+ const s = stream;
1462
+ if (!s.writableLength || s.writableFinished)
1463
+ return resolve();
1464
+ // An ended stream accepts no more writes; wait for it to finish flushing.
1465
+ if (s.writableEnded) {
1466
+ s.once('finish', () => resolve());
1467
+ return;
1468
+ }
1469
+ // A zero-length write's callback runs after everything queued before it.
1470
+ s.write('', () => resolve());
1471
+ })));
1472
+ let timer;
1473
+ await Promise.race([drained, new Promise((resolve) => { timer = setTimeout(resolve, maxMs); })]);
1474
+ if (timer)
1475
+ clearTimeout(timer);
1476
+ }
@@ -76,6 +76,10 @@ export declare class ChargingManager {
76
76
  private chargingState?;
77
77
  private chargingLogDataset?;
78
78
  private notPpeWarningPrinted;
79
+ /** Idempotency key of the last charge the platform accepted (pushDataAndCharge void-on-failure). */
80
+ private lastAcceptedOperationId;
81
+ /** Voids not yet confirmed by the platform; retried before the next push and at exit. */
82
+ private pendingVoids;
79
83
  /** Unresolved platform POSTs: key = idempotency UUID (never event name). */
80
84
  private readonly pendingCharges;
81
85
  private flushInFlight;
@@ -126,6 +130,19 @@ export declare class ChargingManager {
126
130
  * Returns null when the outcome is uncertain after retries.
127
131
  */
128
132
  private postChargeWithRetries;
133
+ /** @internal Idempotency key of the charge accepted by the last _chargeExclusive call, if any. */
134
+ _takeLastAcceptedOperationId(): string | undefined;
135
+ /**
136
+ * @internal Reverse an accepted charge whose items were never stored
137
+ * (POST /v2/actor-runs/:runId/charge/void), then unwind the local
138
+ * reservation. Returns true when the platform voided it.
139
+ */
140
+ _voidCharge(eventName: string, operationId: string, reservedCount: number): Promise<boolean>;
141
+ /**
142
+ * @internal Retry unconfirmed voids (caller holds the operation lock).
143
+ * Returns false while any earlier void is still unconfirmed.
144
+ */
145
+ _retryPendingVoids(): Promise<boolean>;
129
146
  /** Clamp local reservation down when the server accepted fewer events. */
130
147
  /** @internal Used by pushDataAndCharge dual-event reconciliation. */
131
148
  applyAcceptedCount(eventName: string, reservedCount: number, acceptedCount: number, eventPrice?: number): number;
@@ -151,3 +168,9 @@ export declare function pushDataAndCharge<T>({ chargingManager, items, eventName
151
168
  isDefaultDataset: boolean;
152
169
  pushFn: (limitedItems: T | T[]) => Promise<void>;
153
170
  }): Promise<ChargeResult>;
171
+ /**
172
+ * @internal True when a dataset push certainly stored nothing: the storage API
173
+ * answered with an error status, or the request never reached it. Timeouts and
174
+ * resets after the request was sent are not proof (the write may have landed).
175
+ */
176
+ export declare function pushCertainlyFailed(err: unknown): boolean;
package/dist/charging.js CHANGED
@@ -6,6 +6,7 @@ exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
6
6
  exports.hasDefaultDatasetItemEventInPricing = hasDefaultDatasetItemEventInPricing;
7
7
  exports.mergeChargeResults = mergeChargeResults;
8
8
  exports.pushDataAndCharge = pushDataAndCharge;
9
+ exports.pushCertainlyFailed = pushCertainlyFailed;
9
10
  const node_crypto_1 = require("node:crypto");
10
11
  /** Client safe-replay window; server TTL is longer (35d). */
11
12
  exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -108,6 +109,10 @@ class ChargingManager {
108
109
  chargingState;
109
110
  chargingLogDataset;
110
111
  notPpeWarningPrinted = false;
112
+ /** Idempotency key of the last charge the platform accepted (pushDataAndCharge void-on-failure). */
113
+ lastAcceptedOperationId;
114
+ /** Voids not yet confirmed by the platform; retried before the next push and at exit. */
115
+ pendingVoids = [];
111
116
  /** Unresolved platform POSTs: key = idempotency UUID (never event name). */
112
117
  pendingCharges = new Map();
113
118
  flushInFlight = null;
@@ -398,6 +403,59 @@ class ChargingManager {
398
403
  `${lastError?.message ?? lastError}`);
399
404
  return null;
400
405
  }
406
+ /** @internal Idempotency key of the charge accepted by the last _chargeExclusive call, if any. */
407
+ _takeLastAcceptedOperationId() {
408
+ const id = this.lastAcceptedOperationId;
409
+ this.lastAcceptedOperationId = undefined;
410
+ return id;
411
+ }
412
+ /**
413
+ * @internal Reverse an accepted charge whose items were never stored
414
+ * (POST /v2/actor-runs/:runId/charge/void), then unwind the local
415
+ * reservation. Returns true when the platform voided it.
416
+ */
417
+ async _voidCharge(eventName, operationId, reservedCount) {
418
+ if (!this.isAtHome || !this.actorRunId)
419
+ return false;
420
+ const runClient = this.apifyClient.run(this.actorRunId);
421
+ for (let attempt = 0; attempt < this.postAttempts; attempt++) {
422
+ try {
423
+ await runClient.httpClient.call({
424
+ url: runClient._url('charge/void'),
425
+ method: 'POST',
426
+ headers: { 'idempotency-key': operationId },
427
+ });
428
+ // Confirmed (voided or already voided): only now give the budget back.
429
+ this.applyAcceptedCount(eventName, reservedCount, 0);
430
+ return true;
431
+ }
432
+ catch (err) {
433
+ const status = httpStatusOf(err);
434
+ // 404: nothing to void; 409: already settled; 4xx auth: permanent.
435
+ // The charge stands; keep the local reservation that matches it.
436
+ if (status !== undefined && status >= 400 && status < 500)
437
+ return false;
438
+ if (attempt + 1 < this.postAttempts)
439
+ await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
440
+ }
441
+ }
442
+ // Outcome unknown: keep the reservation and retry before the next push / at exit.
443
+ this.pendingVoids.push({ eventName, operationId, reservedCount });
444
+ console.error(`[Scrapely] Void of charge ${operationId} ('${eventName}') not confirmed yet; will retry`);
445
+ return false;
446
+ }
447
+ /**
448
+ * @internal Retry unconfirmed voids (caller holds the operation lock).
449
+ * Returns false while any earlier void is still unconfirmed.
450
+ */
451
+ async _retryPendingVoids() {
452
+ const pending = this.pendingVoids;
453
+ this.pendingVoids = [];
454
+ for (const v of pending) {
455
+ await this._voidCharge(v.eventName, v.operationId, v.reservedCount);
456
+ }
457
+ return this.pendingVoids.length === 0;
458
+ }
401
459
  /** Clamp local reservation down when the server accepted fewer events. */
402
460
  /** @internal Used by pushDataAndCharge dual-event reconciliation. */
403
461
  applyAcceptedCount(eventName, reservedCount, acceptedCount, eventPrice) {
@@ -431,6 +489,7 @@ class ChargingManager {
431
489
  async _flushPendingExclusive(maxIntents = 32, deadlineMs) {
432
490
  if (!this.actorRunId)
433
491
  return 0;
492
+ await this._retryPendingVoids();
434
493
  if (this.flushInFlight) {
435
494
  return this.flushInFlight;
436
495
  }
@@ -584,6 +643,8 @@ class ChargingManager {
584
643
  }
585
644
  if (accepted !== null) {
586
645
  this.pendingCharges.delete(intent.idempotencyKey);
646
+ if (accepted > 0)
647
+ this.lastAcceptedOperationId = intent.idempotencyKey;
587
648
  chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, accepted, eventPricing.price);
588
649
  }
589
650
  else {
@@ -683,11 +744,22 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
683
744
  if (chargingManager.isClosed()) {
684
745
  throw new Error('ChargingManager is closed (actor is shutting down); charge rejected');
685
746
  }
747
+ // A void that did not go through for an earlier failed push must land
748
+ // before this push charges again, or one unsaved result is billed twice.
749
+ const voidsConfirmed = (await chargingManager._retryPendingVoids?.()) !== false;
686
750
  const { limitedItems, eventsToCharge } = chargingManager.calculatePushDataLimits({
687
751
  items,
688
752
  eventName,
689
753
  isDefaultDataset,
690
754
  });
755
+ // Only events sent to the platform count: the synthetic default-dataset
756
+ // event is local bookkeeping and never charged through the API.
757
+ if (!voidsConfirmed && Object.keys(eventsToCharge).some((name) => !isSyntheticEvent(name))) {
758
+ // The charge for an earlier, unsaved push may still stand: charging
759
+ // this push now could bill one result twice. Let the caller retry.
760
+ // Pushes that charge nothing go through.
761
+ throw new Error('[Scrapely] An earlier charge for a failed dataset push could not be voided yet; retry the push');
762
+ }
691
763
  const itemsArray = Array.isArray(items) ? items : [items];
692
764
  const allItemsTrimmed = itemsArray.length > 0 && limitedItems.length === 0;
693
765
  if (limitedItems.length === 0) {
@@ -711,12 +783,13 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
711
783
  const reservedInOp = [];
712
784
  try {
713
785
  for (const [name, chargeCount] of ordered) {
786
+ chargingManager._takeLastAcceptedOperationId?.();
714
787
  const r = await chargingManager._chargeExclusive({
715
788
  eventName: name,
716
789
  count: chargeCount,
717
790
  });
718
791
  results.push(r);
719
- reservedInOp.push({ name, reserved: r.chargedCount });
792
+ reservedInOp.push({ name, reserved: r.chargedCount, operationId: chargingManager._takeLastAcceptedOperationId?.() });
720
793
  }
721
794
  }
722
795
  catch (err) {
@@ -762,11 +835,30 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
762
835
  await pushFn(Array.isArray(items) ? toPersist : toPersist[0]);
763
836
  }
764
837
  catch (pushErr) {
765
- // Charge already accepted server-side; storage failed. Do not unwind
766
- // server ledger (no reverse API). Surface error so caller can retry
767
- // storage; local counters already match accepted charges.
838
+ // Storage failed after the charge was accepted: a result is billed
839
+ // only if it was saved, so void this operation's platform charges
840
+ // and unwind the local (synthetic) reservations, then surface the
841
+ // error so the caller can retry the push (which charges again).
768
842
  console.error(`[Scrapely] Dataset push failed after charge accepted ` +
769
- `(items=${acceptedForItems}): ${pushErr.message}`);
843
+ `(items=${acceptedForItems}): ${pushErr.message}; voiding the charge`);
844
+ // Only when the push certainly stored nothing: the storage API
845
+ // answered with an error, or the connection was never made. A
846
+ // timeout or reset after sending may have stored the items, so
847
+ // the charge stands for those (they may be in the dataset).
848
+ if (!pushCertainlyFailed(pushErr))
849
+ throw pushErr;
850
+ for (let i = 0; i < reservedInOp.length; i++) {
851
+ const { name, operationId } = reservedInOp[i];
852
+ const reserved = results[i].chargedCount;
853
+ if (reserved <= 0)
854
+ continue;
855
+ if (operationId) {
856
+ await chargingManager._voidCharge(name, operationId, reserved);
857
+ }
858
+ else if (isSyntheticEvent(name)) {
859
+ chargingManager.applyAcceptedCount(name, reserved, 0);
860
+ }
861
+ }
770
862
  throw pushErr;
771
863
  }
772
864
  return chargeResult;
@@ -780,3 +872,26 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
780
872
  };
781
873
  });
782
874
  }
875
+ /** HTTP status of an API/client error, if the server answered. */
876
+ function httpStatusOf(err) {
877
+ const e = err;
878
+ const raw = e?.statusCode ?? e?.status ?? e?.response?.status ?? e?.response?.statusCode;
879
+ return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
880
+ }
881
+ const NOT_SENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH']);
882
+ /**
883
+ * @internal True when a dataset push certainly stored nothing: the storage API
884
+ * answered with an error status, or the request never reached it. Timeouts and
885
+ * resets after the request was sent are not proof (the write may have landed).
886
+ */
887
+ function pushCertainlyFailed(err) {
888
+ for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
889
+ const status = httpStatusOf(e);
890
+ if (status !== undefined)
891
+ return status >= 400;
892
+ const code = e.code;
893
+ if (typeof code === 'string' && NOT_SENT_CODES.has(code))
894
+ return true;
895
+ }
896
+ return false;
897
+ }
@@ -53,14 +53,17 @@ export interface ProxyConfigurationOptions extends CoreProxyConfigurationOptions
53
53
  /**
54
54
  * Input-schema alias for groups. Normalized to groups.
55
55
  */
56
- apifyProxyGroups?: string[];
56
+ scrapelyProxyGroups?: string[];
57
57
  /**
58
58
  * Input-schema alias for subdivisionCode. Normalized to subdivisionCode.
59
59
  */
60
- apifyProxySubdivision?: string;
60
+ scrapelyProxySubdivision?: string;
61
61
  /**
62
62
  * Input-schema alias for countryCode. Normalized to countryCode.
63
63
  */
64
+ scrapelyProxyCountry?: string;
65
+ apifyProxyGroups?: string[];
66
+ apifyProxySubdivision?: string;
64
67
  apifyProxyCountry?: string;
65
68
  /**
66
69
  * Platform proxy password. Defaults to the password injected by the
@@ -73,9 +76,10 @@ export interface ProxyConfigurationOptions extends CoreProxyConfigurationOptions
73
76
  */
74
77
  export interface CreateProxyConfigurationOptions extends ProxyConfigurationOptions {
75
78
  /**
76
- * For compatibility with existing Actor Input UI (Input Schema).
77
- * When set to `false`, this method returns `undefined`.
79
+ * The proxy input field's switch (input schema `editor: "proxy"`). When
80
+ * `false` and no custom proxies are given, this method returns `undefined`.
78
81
  */
82
+ useScrapelyProxy?: boolean;
79
83
  useApifyProxy?: boolean;
80
84
  /**
81
85
  * Whether to verify the proxy configuration by testing a connection.
@@ -39,14 +39,17 @@ function hasCustomProxyOptions(options) {
39
39
  }
40
40
  function normalizeProxyInput(options) {
41
41
  const normalized = { ...options };
42
- if (normalized.apifyProxyGroups && !normalized.groups) {
43
- normalized.groups = normalized.apifyProxyGroups;
42
+ const groups = normalized.scrapelyProxyGroups ?? normalized.apifyProxyGroups;
43
+ if (groups && !normalized.groups) {
44
+ normalized.groups = groups;
44
45
  }
45
- if (normalized.apifyProxyCountry && !normalized.countryCode) {
46
- normalized.countryCode = normalized.apifyProxyCountry;
46
+ const countryCode = normalized.scrapelyProxyCountry ?? normalized.apifyProxyCountry;
47
+ if (countryCode && !normalized.countryCode) {
48
+ normalized.countryCode = countryCode;
47
49
  }
48
- if (normalized.apifyProxySubdivision && !normalized.subdivisionCode) {
49
- normalized.subdivisionCode = normalized.apifyProxySubdivision;
50
+ const subdivisionCode = normalized.scrapelyProxySubdivision ?? normalized.apifyProxySubdivision;
51
+ if (subdivisionCode && !normalized.subdivisionCode) {
52
+ normalized.subdivisionCode = subdivisionCode;
50
53
  }
51
54
  return normalized;
52
55
  }
@@ -60,17 +63,19 @@ function normalizeProxyInput(options) {
60
63
  */
61
64
  async function createProxyConfiguration(options = {}) {
62
65
  const normalized = normalizeProxyInput(options);
63
- const { useApifyProxy, checkAccess = true, ...proxyOptions } = normalized;
64
- // Apify parity: `useApifyProxy: false` explicitly disables the PLATFORM proxy
66
+ const { useScrapelyProxy, useApifyProxy, checkAccess = true, ...proxyOptions } = normalized;
67
+ // `useScrapelyProxy: false` (or the legacy `useApifyProxy: false`; a boolean
68
+ // useScrapelyProxy wins) explicitly disables the PLATFORM proxy
65
69
  // only — custom proxies from the same input must still be used. The standard
66
- // "Custom proxy URLs" input shape is `{ useApifyProxy: false, proxyUrls: [...] }`;
70
+ // "Custom proxy URLs" input shape is `{ useScrapelyProxy: false, proxyUrls: [...] }`;
67
71
  // returning undefined for it would silently drop the user's own proxies and
68
72
  // crawl on their real IPs. We only bail when NO custom proxy mode is given —
69
73
  // proxyUrls, tieredProxyUrls, tieredProxyConfig and newUrlFunction all count
70
74
  // (Python SDK Actor.create_proxy_configuration parity).
71
- const dontUseApifyProxy = useApifyProxy === false;
75
+ const usePlatformProxy = typeof useScrapelyProxy === 'boolean' ? useScrapelyProxy : useApifyProxy;
76
+ const dontUsePlatformProxy = usePlatformProxy === false;
72
77
  const dontUseCustomProxies = !hasCustomProxyOptions(proxyOptions);
73
- if (dontUseApifyProxy && dontUseCustomProxies) {
78
+ if (dontUsePlatformProxy && dontUseCustomProxies) {
74
79
  return undefined;
75
80
  }
76
81
  if (!hasCustomProxyOptions(proxyOptions)) {
@@ -89,7 +89,10 @@ export declare class ProxyConfiguration extends CrawleeProxyConfiguration {
89
89
  /**
90
90
  * Verifies the proxy configuration. For platform proxy mode with an
91
91
  * injected status URL, performs the Apify-format access check through the
92
- * gateway (`GET {statusUrl}/?format=json`, best-effort, never fatal);
92
+ * gateway (`GET {statusUrl}/?format=json`). When the gateway answers
93
+ * `connected: false` (e.g. an unknown proxy group), it throws with the
94
+ * gateway's `connectionError` on the platform and warns locally, like
95
+ * Apify's `_checkAccess`; timeouts and HTTP errors only warn.
93
96
  * BYOP mode only verifies that a test URL can be generated.
94
97
  */
95
98
  initialize(options?: {
@@ -73,6 +73,12 @@ function validateProxySessionId(sessionId) {
73
73
  }
74
74
  return sessionId;
75
75
  }
76
+ /** `groups`, else the input-schema alias `scrapelyProxyGroups`, else the legacy `apifyProxyGroups`. */
77
+ function platformGroups(options) {
78
+ if (options.groups?.length)
79
+ return options.groups;
80
+ return options.scrapelyProxyGroups ?? options.apifyProxyGroups;
81
+ }
76
82
  /**
77
83
  * Resolves and validates the country/subdivision options (Apify parity).
78
84
  * Country must be a two-letter ISO code; subdivision an ISO 3166-2 code
@@ -80,8 +86,8 @@ function validateProxySessionId(sessionId) {
80
86
  * the normalized values used by the username grammar.
81
87
  */
82
88
  function resolveCountryOptions(options) {
83
- const countryCode = options.countryCode ?? options.apifyProxyCountry;
84
- const subdivisionCode = options.subdivisionCode ?? options.apifyProxySubdivision;
89
+ const countryCode = options.countryCode ?? options.scrapelyProxyCountry ?? options.apifyProxyCountry;
90
+ const subdivisionCode = options.subdivisionCode ?? options.scrapelyProxySubdivision ?? options.apifyProxySubdivision;
85
91
  if (countryCode != null && !COUNTRY_CODE_REGEX.test(countryCode)) {
86
92
  throw new Error(`ProxyConfiguration: "countryCode" must be a two-letter ISO country code (got "${countryCode}").`);
87
93
  }
@@ -225,7 +231,7 @@ async function requestThroughProxy(targetUrl, proxyUrl, timeoutMs) {
225
231
  * server.
226
232
  */
227
233
  function buildPlatformProxyUrl(options, env, sessionId) {
228
- const groups = options.groups?.length ? options.groups : options.apifyProxyGroups;
234
+ const groups = platformGroups(options);
229
235
  const { countryCode, subdivisionCode } = resolveCountryOptions(options);
230
236
  const password = options.password ?? env.password;
231
237
  // Apify username grammar: groups, then session, then country — with the
@@ -334,7 +340,7 @@ function toCrawleeOptions(options, env, placeholderOptions) {
334
340
  return crawleeOptions;
335
341
  }
336
342
  function hasApifyOnlyOptions(options) {
337
- const groups = options.groups?.length ? options.groups : options.apifyProxyGroups;
343
+ const groups = platformGroups(options);
338
344
  const { countryCode, subdivisionCode } = resolveCountryOptions(options);
339
345
  return !!(groups?.length || countryCode || subdivisionCode || options.password);
340
346
  }
@@ -382,10 +388,10 @@ class ProxyConfiguration extends core_1.ProxyConfiguration {
382
388
  // values throw at construction, not on the first newUrl() call).
383
389
  resolveCountryOptions(options);
384
390
  if (hasRealCustomProxyOptions(options) && hasApifyOnlyOptions(options)) {
385
- throw new Error('Cannot combine custom proxies with Apify Proxy! ' +
391
+ throw new Error('Cannot combine custom proxies with Scrapely Proxy! ' +
386
392
  'It is not allowed to set "options.proxyUrls" or "options.newUrlFunction" combined with ' +
387
- '"options.groups" or "options.apifyProxyGroups" and "options.countryCode" or ' +
388
- '"options.apifyProxyCountry", "options.subdivisionCode" or "options.apifyProxySubdivision".');
393
+ '"options.groups" or "options.scrapelyProxyGroups" and "options.countryCode" or ' +
394
+ '"options.scrapelyProxyCountry", "options.subdivisionCode" or "options.scrapelyProxySubdivision".');
389
395
  }
390
396
  if (needsPlatformProxyUrls(options) && !platformEnv) {
391
397
  throw new Error('[Scrapely] The platform proxy was requested (via the "auto" sentinel, tieredProxyConfig tiers without ' +
@@ -445,9 +451,7 @@ class ProxyConfiguration extends core_1.ProxyConfiguration {
445
451
  const sid = sessionId != null ? validateProxySessionId(String(sessionId)) : undefined;
446
452
  const info = await super.newProxyInfo(sid, options);
447
453
  if (info && this.platformEnv && info.hostname === this.platformEnv.hostname) {
448
- const groups = this.platformOptions.groups?.length
449
- ? this.platformOptions.groups
450
- : this.platformOptions.apifyProxyGroups;
454
+ const groups = platformGroups(this.platformOptions);
451
455
  const { countryCode, subdivisionCode } = resolveCountryOptions(this.platformOptions);
452
456
  return {
453
457
  ...info,
@@ -483,6 +487,7 @@ class ProxyConfiguration extends core_1.ProxyConfiguration {
483
487
  connectionError: 'Platform proxy authentication failed (HTTP 407). Check the injected ' +
484
488
  'SCRAPELY_PROXY_*/APIFY_PROXY_* credentials.',
485
489
  isManInTheMiddle: false,
490
+ fromPayload: false,
486
491
  };
487
492
  }
488
493
  if (statusCode >= 590 && statusCode <= 599) {
@@ -491,6 +496,7 @@ class ProxyConfiguration extends core_1.ProxyConfiguration {
491
496
  connected: false,
492
497
  connectionError: `Platform proxy upstream error (HTTP ${statusCode}: ${description}).`,
493
498
  isManInTheMiddle: false,
499
+ fromPayload: false,
494
500
  };
495
501
  }
496
502
  if (statusCode >= 200 && statusCode < 300) {
@@ -500,6 +506,7 @@ class ProxyConfiguration extends core_1.ProxyConfiguration {
500
506
  connected: parsed.connected === true,
501
507
  connectionError: parsed.connectionError ?? '',
502
508
  isManInTheMiddle: parsed.isManInTheMiddle === true,
509
+ fromPayload: true,
503
510
  };
504
511
  }
505
512
  catch {
@@ -516,7 +523,10 @@ class ProxyConfiguration extends core_1.ProxyConfiguration {
516
523
  /**
517
524
  * Verifies the proxy configuration. For platform proxy mode with an
518
525
  * injected status URL, performs the Apify-format access check through the
519
- * gateway (`GET {statusUrl}/?format=json`, best-effort, never fatal);
526
+ * gateway (`GET {statusUrl}/?format=json`). When the gateway answers
527
+ * `connected: false` (e.g. an unknown proxy group), it throws with the
528
+ * gateway's `connectionError` on the platform and warns locally, like
529
+ * Apify's `_checkAccess`; timeouts and HTTP errors only warn.
520
530
  * BYOP mode only verifies that a test URL can be generated.
521
531
  */
522
532
  async initialize(options = {}) {
@@ -526,7 +536,7 @@ class ProxyConfiguration extends core_1.ProxyConfiguration {
526
536
  }
527
537
  // Apify-parity access check: GET {statusUrl}/?format=json *through the
528
538
  // platform gateway* so the proxy handshake itself authenticates the
529
- // run's credentials. Best-effort — never hard fails the run.
539
+ // run's credentials.
530
540
  if (checkAccess && this.platformEnv?.statusUrl) {
531
541
  const status = await this.fetchProxyStatus();
532
542
  if (!status) {
@@ -535,6 +545,9 @@ class ProxyConfiguration extends core_1.ProxyConfiguration {
535
545
  }
536
546
  else {
537
547
  this.isManInTheMiddle = status.isManInTheMiddle;
548
+ if (!status.connected && status.fromPayload && this.config.get('isAtHome')) {
549
+ throw new Error(status.connectionError);
550
+ }
538
551
  if (!status.connected) {
539
552
  console.warn(`[Scrapely] Platform proxy access check failed: ${status.connectionError}`);
540
553
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrapely",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "Scrapely SDK for building JavaScript and TypeScript Actors",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -19,6 +19,7 @@
19
19
  "scripts": {
20
20
  "test": "npx tsx tests/test-mvp.ts",
21
21
  "test:charging": "npx tsx --test tests/charging-pending.test.ts",
22
+ "test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
22
23
  "test:charging-client": "npx tsx --test tests/charging-client.test.ts",
23
24
  "test:proxy": "npx tsx --test tests/proxy-configuration.test.ts",
24
25
  "test:browser-pool": "npx tsx --test tests/patch-browser-pool.test.ts",
@@ -30,6 +31,7 @@
30
31
  "prod:update": "bash scripts/deploy-prod.sh --update-only",
31
32
  "migrate": "npx tsx scripts/run-migration.ts",
32
33
  "test:local-run": "npx tsx --test tests/local-run.test.ts",
34
+ "test:input-from-kv": "npx tsx --test tests/input-from-kv.test.ts",
33
35
  "test:init-rollback": "npx tsx --test tests/init-rollback.test.ts",
34
36
  "test:apify-parity": "npx tsx --test tests/apify-parity.test.ts",
35
37
  "test:run-control": "npx tsx --test tests/run-control.test.ts",