@crawlee/basic 4.0.0-beta.145 → 4.0.0-beta.147

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.
@@ -140,8 +140,9 @@ export interface BasicCrawlerOptions<Context extends CrawlingContext = CrawlingC
140
140
  * Indicates how much time (in seconds) to wait before crawling another same domain request. Subdomains are
141
141
  * paced together with the site they belong to.
142
142
  *
143
- * Wraps the crawler's request manager in a {@link ThrottlingRequestManager}; pass one as `requestManager`
144
- * yourself to configure it further.
143
+ * Offered to the crawler's request manager as a `minIntervalEverywhere` {@link PacingSignal}; a manager that
144
+ * already paces every domain it dispatches to takes it, so no domain ends up with two clocks. Otherwise the
145
+ * crawler wraps its request manager in a {@link ThrottlingRequestManager} of its own.
145
146
  * @default 0
146
147
  */
147
148
  sameDomainDelaySecs?: number;
@@ -589,8 +590,7 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
589
590
  */
590
591
  getRequestQueue(): Promise<IRequestManager>;
591
592
  /**
592
- * Opens the default {@link RequestQueue}, applies the crawler's timeouts to it and records it as the
593
- * crawler-owned queue (so it gets purged between repeated `run()` calls).
593
+ * Opens the default {@link RequestQueue} — the crawler's own, read from when the caller supplied nothing.
594
594
  * @private
595
595
  */
596
596
  private openOwnedRequestQueue;
@@ -689,19 +689,14 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
689
689
  protected throwOnBlockedRequest(statusCode: number): void;
690
690
  private isAllowedBasedOnRobotsTxtFile;
691
691
  /**
692
- * Records an HTTP 429 against the URL's domain so the request manager can pace the retry.
692
+ * Records an HTTP 429 against the URL's domain so the request manager can hold the retry back.
693
693
  *
694
694
  * @param retryAfterHeader The raw `Retry-After` response header, if the server sent one.
695
- * @returns `true` if a manager took responsibility for the delay, in which case the caller should throw
695
+ * @returns `true` if the manager took responsibility for the delay, in which case the caller should throw
696
696
  * {@link RequestThrottledError} rather than treating the response as a blocked session.
697
697
  */
698
698
  protected recordDomainRateLimit(url: string, retryAfterHeader?: string | null): boolean;
699
- /**
700
- * Hands a robots.txt `Crawl-delay` to the request manager, warning if nothing is able to honour it.
701
- *
702
- * The warning is driven by whether the delay was actually accepted rather than by the type of the manager,
703
- * because a manager that does throttle still drops the delay for a domain missing from its `domains` list.
704
- */
699
+ /** Hands a robots.txt `Crawl-delay` to the request manager, warning if it will not be honoured. */
705
700
  private applyCrawlDelay;
706
701
  protected getRobotsTxtFileForUrl(url: string): Promise<RobotsTxtFile | undefined>;
707
702
  private pauseOnMigration;
@@ -722,13 +717,10 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
722
717
  */
723
718
  private timeoutAndRetry;
724
719
  /**
725
- * Returns true if either RequestList or RequestQueue have a request ready for processing.
720
+ * Whether the request manager has a request ready for processing. A manager that is only `waiting` also gets a
721
+ * wake-up scheduled, so a paced crawl resumes on its clock rather than on the task loop's polling interval.
726
722
  */
727
723
  private isTaskReadyFunction;
728
- /**
729
- * Returns true if both RequestList and RequestQueue have all requests finished.
730
- */
731
- private defaultIsFinishedFunction;
732
724
  /**
733
725
  * Unwraps errors thrown by the context pipeline to get the actual user error.
734
726
  * RequestHandlerError and ContextPipelineInitializationError wrap the actual error.
@@ -1,6 +1,6 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { applyRequestTransform, AutoscaledPool, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, buildEnqueueStrategyPatterns, ConcurrencySystem, Configuration, constructUrlPatternObjects, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, createRequestOptions, createStorageTransaction, Request, CriticalError, currentStorageTransaction, Dataset, EnqueueStrategy, EventManager, EventType, filterRequestOptionsByPatterns, getObjectType, KeyValueStore, log, LogLevel, mergeCookies, MissingSessionError, NavigationSkippedError, NonRetryableError, OwnedOrInjected, purgeDefaultStorages, RequestHandlerError, parseRetryAfterHeader, RequestThrottledError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, supportsDomainThrottling, Router, ServiceLocator, serviceLocator, Session, SessionError, SessionPool, Statistics, ThrottlingRequestManager, validateUserData, validators, withDirectStorageAccess, } from '@crawlee/core';
3
+ import { applyRequestTransform, AutoscaledPool, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, buildEnqueueStrategyPatterns, ConcurrencySystem, Configuration, constructUrlPatternObjects, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, createRequestOptions, createStorageTransaction, Request, CriticalError, currentStorageTransaction, Dataset, EnqueueStrategy, EventManager, EventType, filterRequestOptionsByPatterns, getObjectType, KeyValueStore, log, LogLevel, mergeCookies, MissingSessionError, NavigationSkippedError, NonRetryableError, OwnedOrInjected, PersistentRateLimitError, purgeDefaultStorages, RequestHandlerError, parseRetryAfterHeader, RequestThrottledError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, Router, ServiceLocator, serviceLocator, Session, SessionError, SessionPool, Statistics, ThrottlingRequestManager, validateUserData, validators, withDirectStorageAccess, } from '@crawlee/core';
4
4
  import { BaseHttpClient, FetchHttpClient } from '@crawlee/http-client';
5
5
  import { isAsyncIterable, isIterable, parseArgument, ROTATE_PROXY_ERRORS, schemas } from '@crawlee/utils/internal';
6
6
  import { RobotsTxtFile } from '@crawlee/utils';
@@ -108,12 +108,6 @@ export class BasicCrawler {
108
108
  get sessionPool() {
109
109
  return this.#sessionPoolDep.value;
110
110
  }
111
- /**
112
- * Tracks **only** the queue the crawler opens for itself — not the {@link RequestManagerTandem} that may wrap it
113
- * around a user-supplied `requestList` — so the owned-only purge between repeated `run()` calls never reaches
114
- * through to a borrowed loader. Filled lazily in {@link BasicCrawler.openOwnedRequestQueue|`openOwnedRequestQueue()`}.
115
- */
116
- #ownedRequestQueue = OwnedOrInjected.resolve();
117
111
  /**
118
112
  * Whether the request-processing-time hint has already been forwarded to the request manager. The hint
119
113
  * derives only from `requestHandlerTimeoutMillis` (constant for the crawler's lifetime) and is raise-only,
@@ -151,6 +145,10 @@ export class BasicCrawler {
151
145
  * {@link BasicCrawler.concurrencySystem|`concurrencySystem`}.
152
146
  */
153
147
  #autoscaledPool;
148
+ /** A pending nudge of the task loop, armed when the request manager announces when it will have work again. */
149
+ #taskLoopWakeTimer;
150
+ /** When the pending wake-up is due, so an earlier one can replace a later one. */
151
+ #taskLoopWakeAt = 0;
154
152
  /**
155
153
  * A reference to the underlying {@link IProxyConfiguration} instance that manages the crawler's proxies.
156
154
  * Only available if used by the crawler.
@@ -197,7 +195,16 @@ export class BasicCrawler {
197
195
  internalTimeoutMillis;
198
196
  #maxRequestRetries;
199
197
  #maxCrawlDepth;
200
- #sameDomainDelaySecs;
198
+ /**
199
+ * How much of {@link BasicCrawler.requestManager} the crawler may empty between repeated `run()` calls.
200
+ *
201
+ * - `all` — nothing under it came from the caller, so one `purge()` on the outside covers everything.
202
+ * - `none` — the caller supplied it and the crawler put nothing of its own inside.
203
+ * - `ambiguous` — the caller supplied it, but `sameDomainDelaySecs` put the crawler's own per-domain queues
204
+ * underneath: purging empties the caller's storage too, skipping leaves ours stale. A repeated `run()` asks
205
+ * rather than guessing.
206
+ */
207
+ #purgeableExtent;
201
208
  #maxRequestsPerCrawl;
202
209
  get handledRequestsCount() {
203
210
  return this.statistics.state.requestsFinished + this.statistics.state.requestsFailed;
@@ -335,31 +342,49 @@ export class BasicCrawler {
335
342
  serviceLocator.getConfiguration();
336
343
  const instanceIndex = BasicCrawler.instanceCount++;
337
344
  this.#identity = { instanceIndex, hasExplicitId: id !== undefined, id: id ?? String(instanceIndex) };
338
- if (requestManager !== undefined) {
339
- if (requestList !== undefined || requestQueue !== undefined) {
340
- throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
341
- }
342
- // Both would pace the same domains, from different keys and with no idea of one another.
343
- if (sameDomainDelaySecs > 0 && supportsDomainThrottling(requestManager)) {
344
- throw new Error('The `sameDomainDelaySecs` option cannot be combined with a `requestManager` that throttles ' +
345
- 'per domain on its own. Configure the delay on the manager instead, via the ' +
346
- '`minCrawlDelaySecs` option of `ThrottlingRequestManager`.');
347
- }
348
- this.requestManager = requestManager;
345
+ if (requestManager !== undefined && (requestList !== undefined || requestQueue !== undefined)) {
346
+ throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
349
347
  }
350
- else if (requestList !== undefined && requestQueue !== undefined) {
351
- // Combine the read-only list with the writable queue into a tandem.
352
- this.requestManager = new RequestManagerTandem(requestList, requestQueue);
348
+ const suppliedManager = requestManager ?? requestQueue;
349
+ // Offered before building a pacer of our own: anything that paces takes the floor - through any
350
+ // number of wrappers, since they all forward - so no domain ends up with two clocks.
351
+ const floorTaken = sameDomainDelaySecs > 0 &&
352
+ (suppliedManager?.recordPacingSignal({
353
+ reason: 'minIntervalEverywhere',
354
+ intervalMs: sameDomainDelaySecs * 1000,
355
+ // What `sameDomainDelaySecs` has always meant: one clock per site, subdomains included.
356
+ scope: 'registrableDomain',
357
+ }) ??
358
+ false);
359
+ const pacerNeeded = sameDomainDelaySecs > 0 && !floorTaken;
360
+ // Our per-domain queues under a manager the caller owns is the case with no right answer; a floor
361
+ // it took leaves nothing of ours behind. See `#purgeableExtent`.
362
+ if (suppliedManager === undefined) {
363
+ this.#purgeableExtent = 'all';
353
364
  }
354
- else if (requestQueue !== undefined) {
355
- // A RequestQueue is itself a request manager.
356
- this.requestManager = requestQueue;
365
+ else {
366
+ this.#purgeableExtent = pacerNeeded ? 'ambiguous' : 'none';
357
367
  }
358
- else if (requestList !== undefined) {
359
- // A lone read-only `requestList` (deprecated option) is combined with a lazily-opened default queue
360
- // into a tandem, so that its requests are read first and new ones can still be enqueued during the
361
- // crawl. The queue is opened on first use; the tandem also forwards `persistState()` to the loader.
362
- this.requestManager = new RequestManagerTandem(requestList, () => this.openOwnedRequestQueue());
368
+ // Built here rather than at first use so it can sit *inside* the tandem below, which is where a
369
+ // loader's transferred requests pass through it.
370
+ const writableManager = pacerNeeded
371
+ ? new ThrottlingRequestManager({
372
+ domains: 'all',
373
+ minCrawlDelaySecs: sameDomainDelaySecs,
374
+ throttleBy: 'registrableDomain',
375
+ persistStateKey: `CRAWLEE_THROTTLED_DOMAINS_${this.#identity.id}`,
376
+ // A factory, because the default queue is only opened on first use.
377
+ inner: suppliedManager ?? (() => this.openOwnedRequestQueue()),
378
+ })
379
+ : suppliedManager;
380
+ if (requestList !== undefined) {
381
+ // The list is read first, while new requests still have somewhere writable to go; the tandem also
382
+ // forwards `persistState()` to the loader.
383
+ this.requestManager = new RequestManagerTandem(requestList, writableManager ?? (() => this.openOwnedRequestQueue()));
384
+ }
385
+ else if (writableManager !== undefined) {
386
+ // A RequestQueue is itself a request manager.
387
+ this.requestManager = writableManager;
363
388
  }
364
389
  this.httpClient = httpClient ?? new LazyDefaultHttpClient({ logger: this.log });
365
390
  this.proxyConfiguration = proxyConfiguration;
@@ -390,7 +415,6 @@ export class BasicCrawler {
390
415
  Math.max(this.#requestHandlerTimeoutMillis * 2, 300e3);
391
416
  this.#maxRequestRetries = maxRequestRetries;
392
417
  this.#maxCrawlDepth = maxCrawlDepth;
393
- this.#sameDomainDelaySecs = sameDomainDelaySecs;
394
418
  this.#statisticsDep = OwnedOrInjected.resolve(statistics,
395
419
  // A crawler-built default tracks the built-in fields only. A non-empty `StatisticStateExtension` can
396
420
  // only be satisfied by an injected instance carrying the matching `state`, so this branch does
@@ -509,14 +533,17 @@ export class BasicCrawler {
509
533
  this.log.info('The crawler has finished all the remaining ongoing requests and will shut down now.');
510
534
  return true;
511
535
  }
512
- // Checked here because this runs only once nothing is in flight, which is exactly when a
513
- // crawl that cannot progress looks indistinguishable from one that is merely waiting.
514
- if (!keepAlive && supportsDomainThrottling(this.requestManager)) {
515
- await this.requestManager.assertNoStalledDomains();
536
+ // `maybeFinish()` calls this only once nothing is in flight (`autoscaled_pool.ts`) - the point
537
+ // where a crawl that cannot progress becomes distinguishable from one that is merely waiting,
538
+ // and the only place where throwing does not abandon requests mid-processing.
539
+ const state = await this.requestManager?.checkReadiness();
540
+ // Under `keepAlive`, outliving a domain that will not let us through is the whole point.
541
+ if (state?.status === 'stalled' && !keepAlive) {
542
+ throw new PersistentRateLimitError(`Giving up: ${state.reason}`);
516
543
  }
517
544
  const isFinished = isFinishedFunction
518
545
  ? await isFinishedFunction()
519
- : await this.defaultIsFinishedFunction();
546
+ : state === undefined || state.status === 'finished';
520
547
  if (isFinished) {
521
548
  const reason = isFinishedFunction
522
549
  ? "Crawler's custom isFinishedFunction() returned true, the crawler will shut down."
@@ -777,19 +804,18 @@ export class BasicCrawler {
777
804
  // we need to purge the RQ to allow processing the same requests again — this is important so users can
778
805
  // pass in failed requests back to the `crawler.run()`, otherwise they would be considered as handled and
779
806
  // ignored — as a failed request is still handled.
780
- // By default (`purgeRequestQueue` unset), only the queue we opened ourselves is purged.
781
- // When `purgeRequestQueue` is explicitly `true`, we also purge a user-supplied manager.
782
- // When `purgeRequestQueue` is explicitly `false`, nothing is purged.
783
- const shouldPurge = purgeRequestQueue !== false;
784
- const managerToPurge = this.#ownedRequestQueue.maybeValue ?? (purgeRequestQueue === true ? this.requestManager : undefined);
785
- if (shouldPurge) {
786
- await managerToPurge?.purge?.();
787
- // The per-domain queues a `sameDomainDelaySecs` wrapper created are the crawler's own, whatever
788
- // sits underneath them - so they are emptied even when the manager they wrap is spared. Purging
789
- // the wrapper itself has already covered them.
790
- if (this.requestManager instanceof ThrottlingRequestManager && managerToPurge !== this.requestManager) {
791
- await this.requestManager.purgeDomainQueues();
792
- }
807
+ // `purgeRequestQueue` unset purges only storage the crawler opened itself (see `#purgeableExtent`);
808
+ // `true` also purges a caller-supplied manager, `false` purges nothing.
809
+ if (purgeRequestQueue === undefined && this.#purgeableExtent === 'ambiguous') {
810
+ throw new Error('Cannot decide what to purge before running again: `sameDomainDelaySecs` paces the request ' +
811
+ 'manager you supplied, so the per-domain queues that have to be emptied are the ' +
812
+ "crawler's while the manager underneath them is yours. Say which you want: " +
813
+ '`run(requests, { purgeRequestQueue: true })` empties both, `false` empties neither.');
814
+ }
815
+ if (purgeRequestQueue !== false && (this.#purgeableExtent === 'all' || purgeRequestQueue === true)) {
816
+ // One call from the outside in reaches everything the manager wraps, a pacer's per-domain queues
817
+ // included.
818
+ await this.requestManager?.purge?.();
793
819
  }
794
820
  // A supplied statistics instance keeps whatever state it was handed - only wipe a default we built.
795
821
  await this.#statisticsDep.ifOwned(async (stats) => {
@@ -927,19 +953,6 @@ export class BasicCrawler {
927
953
  if (!this.requestManager) {
928
954
  this.requestManager = await this.openOwnedRequestQueue();
929
955
  }
930
- // Wrapped here rather than in the constructor, because the manager being wrapped may only be opened at
931
- // this point - and because everything that enqueues goes through here first, so nothing slips past the
932
- // wrapper into the queue it hides.
933
- if (this.#sameDomainDelaySecs > 0 && !supportsDomainThrottling(this.requestManager)) {
934
- this.requestManager = new ThrottlingRequestManager({
935
- inner: this.requestManager,
936
- domains: 'all',
937
- minCrawlDelaySecs: this.#sameDomainDelaySecs,
938
- // What `sameDomainDelaySecs` has always meant: one clock for a site, subdomains included.
939
- throttleBy: 'registrableDomain',
940
- persistStateKey: `CRAWLEE_THROTTLED_DOMAINS_${this.#identity.id}`,
941
- });
942
- }
943
956
  // Apply the processing-time hint here (an async lifecycle point) rather than in the constructor,
944
957
  // now that `setExpectedRequestProcessingTimeSecs` is async. The hint is raise-only and idempotent,
945
958
  // but guard so we do not re-issue it on every call.
@@ -957,16 +970,14 @@ export class BasicCrawler {
957
970
  return this.getRequestManager();
958
971
  }
959
972
  /**
960
- * Opens the default {@link RequestQueue}, applies the crawler's timeouts to it and records it as the
961
- * crawler-owned queue (so it gets purged between repeated `run()` calls).
973
+ * Opens the default {@link RequestQueue} — the crawler's own, read from when the caller supplied nothing.
962
974
  * @private
963
975
  */
964
976
  async openOwnedRequestQueue() {
965
977
  // The first crawler instance uses the default queue (null identifier);
966
978
  // subsequent instances get their own queue via a unique alias so they don't collide.
967
979
  const identifier = this.#identity.instanceIndex === 0 ? null : { alias: `__default_${this.#identity.id}__` };
968
- const requestQueue = await RequestQueue.open(identifier, { configuration: serviceLocator.getConfiguration() });
969
- return this.#ownedRequestQueue.set(requestQueue);
980
+ return RequestQueue.open(identifier, { configuration: serviceLocator.getConfiguration() });
970
981
  }
971
982
  /**
972
983
  * Tells a request manager how long we expect to hold a fetched request, so that one backed by a
@@ -1373,38 +1384,44 @@ export class BasicCrawler {
1373
1384
  return !robotsTxtFile || robotsTxtFile.isAllowed(url, userAgent);
1374
1385
  }
1375
1386
  /**
1376
- * Records an HTTP 429 against the URL's domain so the request manager can pace the retry.
1387
+ * Records an HTTP 429 against the URL's domain so the request manager can hold the retry back.
1377
1388
  *
1378
1389
  * @param retryAfterHeader The raw `Retry-After` response header, if the server sent one.
1379
- * @returns `true` if a manager took responsibility for the delay, in which case the caller should throw
1390
+ * @returns `true` if the manager took responsibility for the delay, in which case the caller should throw
1380
1391
  * {@link RequestThrottledError} rather than treating the response as a blocked session.
1381
1392
  */
1382
1393
  recordDomainRateLimit(url, retryAfterHeader) {
1383
- if (supportsDomainThrottling(this.requestManager) &&
1384
- this.requestManager.recordDomainDelay(url, parseRetryAfterHeader(retryAfterHeader))) {
1394
+ if (this.requestManager?.recordPacingSignal({
1395
+ reason: 'rateLimited',
1396
+ url,
1397
+ waitMs: parseRetryAfterHeader(retryAfterHeader) ?? undefined,
1398
+ })) {
1385
1399
  return true;
1386
1400
  }
1387
1401
  const domain = hostnameOrUrl(url);
1388
- this.logOncePerRun(`rateLimitNotThrottled:${domain}`, `"${domain}" responded with HTTP 429 (Too Many Requests), but nothing is set up to back off from it, ` +
1389
- 'so the response is handled like any other, with no per-domain delay. ' +
1390
- `Pass a \`ThrottlingRequestManager\` as \`requestManager\` and include "${domain}" in its \`domains\` ` +
1391
- 'option to honour `Retry-After` and apply exponential backoff instead.', 'warning');
1402
+ this.logOncePerRun(`rateLimitNotThrottled:${domain}`, `"${domain}" responded with HTTP 429 (Too Many Requests), but the crawler's request manager does not ` +
1403
+ 'pace that domain, so the response is handled like any other, with no per-domain delay. Set ' +
1404
+ `\`sameDomainDelaySecs\`, or pass a \`ThrottlingRequestManager\` covering "${domain}" as ` +
1405
+ '`requestManager`, to honour `Retry-After` and apply exponential backoff instead.', 'warning');
1392
1406
  return false;
1393
1407
  }
1394
- /**
1395
- * Hands a robots.txt `Crawl-delay` to the request manager, warning if nothing is able to honour it.
1396
- *
1397
- * The warning is driven by whether the delay was actually accepted rather than by the type of the manager,
1398
- * because a manager that does throttle still drops the delay for a domain missing from its `domains` list.
1399
- */
1408
+ /** Hands a robots.txt `Crawl-delay` to the request manager, warning if it will not be honoured. */
1400
1409
  applyCrawlDelay(url, delaySeconds) {
1401
- if (supportsDomainThrottling(this.requestManager) && this.requestManager.setCrawlDelay(url, delaySeconds)) {
1410
+ // robots.txt is per-origin; `hostname` is the closest pacing scope and errs wide (http and https to one
1411
+ // host share a clock).
1412
+ if (this.requestManager?.recordPacingSignal({
1413
+ reason: 'minInterval',
1414
+ url,
1415
+ intervalMs: delaySeconds * 1000,
1416
+ scope: 'hostname',
1417
+ })) {
1402
1418
  return;
1403
1419
  }
1404
1420
  const domain = hostnameOrUrl(url);
1405
- this.logOncePerRun(`crawlDelayIgnored:${domain}`, `robots.txt for "${domain}" defines a crawl-delay of ${delaySeconds}s, but nothing is set up to honour it, ` +
1406
- 'so requests to that domain will not be paced. Pass a `ThrottlingRequestManager` as `requestManager` ' +
1407
- `and include "${domain}" in its \`domains\` option to enforce the delay.`, 'warning');
1421
+ this.logOncePerRun(`crawlDelayIgnored:${domain}`, `robots.txt for "${domain}" defines a crawl-delay of ${delaySeconds}s, but the crawler's request ` +
1422
+ 'manager does not pace that domain, so its requests will not be paced. Set ' +
1423
+ `\`sameDomainDelaySecs\`, or pass a \`ThrottlingRequestManager\` covering "${domain}" as ` +
1424
+ '`requestManager`.', 'warning');
1408
1425
  }
1409
1426
  async getRobotsTxtFileForUrl(url) {
1410
1427
  if (!this.#respectRobotsTxtFile) {
@@ -1439,10 +1456,10 @@ export class BasicCrawler {
1439
1456
  });
1440
1457
  }
1441
1458
  const requestManagerPersistPromise = (async () => {
1442
- // The request manager persists its read-only loader's state, if it has one that supports persistence
1443
- // (e.g. a tandem wrapping a `RequestList`). For a plain `RequestQueue`, this is a no-op.
1459
+ // The request manager persists its read-only loader's state, if it has one that supports
1460
+ // persistence (e.g. a tandem wrapping a `RequestList`). For a plain `RequestQueue`, this is a no-op.
1444
1461
  if (this.requestManager?.persistState) {
1445
- if (await this.requestManager.isFinished())
1462
+ if ((await this.requestManager.checkReadiness()).status === 'finished')
1446
1463
  return;
1447
1464
  await this.requestManager.persistState().catch((err) => {
1448
1465
  if (err.message.includes('Cannot persist state.')) {
@@ -1573,16 +1590,44 @@ export class BasicCrawler {
1573
1590
  }
1574
1591
  }
1575
1592
  /**
1576
- * Returns true if either RequestList or RequestQueue have a request ready for processing.
1593
+ * Whether the request manager has a request ready for processing. A manager that is only `waiting` also gets a
1594
+ * wake-up scheduled, so a paced crawl resumes on its clock rather than on the task loop's polling interval.
1577
1595
  */
1578
1596
  async isTaskReadyFunction() {
1579
- return this.requestManager !== undefined && !(await this.requestManager.isEmpty());
1597
+ if (this.requestManager === undefined) {
1598
+ return false;
1599
+ }
1600
+ const state = await this.requestManager.checkReadiness();
1601
+ if (state.status === 'waiting' && state.readyAt !== undefined) {
1602
+ this.#scheduleTaskLoopWake(state.readyAt);
1603
+ }
1604
+ return state.status === 'ready';
1580
1605
  }
1581
1606
  /**
1582
- * Returns true if both RequestList and RequestQueue have all requests finished.
1607
+ * Nudges the task loop at `readyAt`, on a single timer that only an earlier one replaces. The pool polls anyway
1608
+ * every `maybeRunIntervalSecs` (0.5s by default), so this only shortens the wait - hence one timer rather than
1609
+ * one per probe, and `unref`'d so it never keeps the process alive.
1583
1610
  */
1584
- async defaultIsFinishedFunction() {
1585
- return !this.requestManager || (await this.requestManager.isFinished());
1611
+ #scheduleTaskLoopWake(readyAt) {
1612
+ if (this.#taskLoopWakeTimer !== undefined) {
1613
+ if (this.#taskLoopWakeAt <= readyAt) {
1614
+ return;
1615
+ }
1616
+ clearTimeout(this.#taskLoopWakeTimer);
1617
+ }
1618
+ this.#taskLoopWakeAt = readyAt;
1619
+ this.#taskLoopWakeTimer = setTimeout(() => {
1620
+ this.#taskLoopWakeTimer = undefined;
1621
+ void this.#autoscaledPool?.notify();
1622
+ }, Math.max(0, readyAt - Date.now()));
1623
+ this.#taskLoopWakeTimer.unref();
1624
+ }
1625
+ /** Drops a pending task-loop wake-up, so a finished run leaves no timer behind. */
1626
+ #clearTaskLoopWake() {
1627
+ if (this.#taskLoopWakeTimer !== undefined) {
1628
+ clearTimeout(this.#taskLoopWakeTimer);
1629
+ this.#taskLoopWakeTimer = undefined;
1630
+ }
1586
1631
  }
1587
1632
  /**
1588
1633
  * Unwraps errors thrown by the context pipeline to get the actual user error.
@@ -1726,6 +1771,7 @@ export class BasicCrawler {
1726
1771
  if (this.#closeEvents) {
1727
1772
  await serviceLocator.getEventManager().close();
1728
1773
  }
1774
+ this.#clearTaskLoopWake();
1729
1775
  await this.#autoscaledPool?.abort();
1730
1776
  await this.#concurrencySystemDep?.ifOwned((system) => system.stop());
1731
1777
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.145",
3
+ "version": "4.0.0-beta.147",
4
4
  "description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -42,10 +42,10 @@
42
42
  "@apify/datastructures": "^2.0.0",
43
43
  "@apify/timeout": "^0.4.4",
44
44
  "@apify/utilities": "^2.15.5",
45
- "@crawlee/core": "4.0.0-beta.145",
46
- "@crawlee/http-client": "4.0.0-beta.145",
47
- "@crawlee/types": "4.0.0-beta.145",
48
- "@crawlee/utils": "4.0.0-beta.145",
45
+ "@crawlee/core": "4.0.0-beta.147",
46
+ "@crawlee/http-client": "4.0.0-beta.147",
47
+ "@crawlee/types": "4.0.0-beta.147",
48
+ "@crawlee/utils": "4.0.0-beta.147",
49
49
  "csv-stringify": "^6.5.2",
50
50
  "tldts": "^7.0.6",
51
51
  "tslib": "^2.8.1",
@@ -53,7 +53,7 @@
53
53
  "zod": "^4.4.3"
54
54
  },
55
55
  "optionalDependencies": {
56
- "@crawlee/impit-client": "^4.0.0-beta.145"
56
+ "@crawlee/impit-client": "^4.0.0-beta.147"
57
57
  },
58
58
  "lerna": {
59
59
  "command": {
@@ -62,5 +62,5 @@
62
62
  }
63
63
  }
64
64
  },
65
- "gitHead": "e8b4d50265b9e433ae61c69d16cc1fee00a4d7e9"
65
+ "gitHead": "bb04462b453da49b08ea0af0038cb734eabe3d01"
66
66
  }