@crawlee/basic 4.0.0-beta.165 → 4.0.0-beta.167

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.
@@ -398,13 +398,15 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
398
398
  * {@link ISessionPool} so custom implementations can be plugged in via the `sessionPool` constructor option.
399
399
  */
400
400
  get sessionPool(): ISessionPool;
401
+ /** Whether a {@link BasicCrawler.run|`run()`} is in progress on this instance. */
402
+ get running(): boolean;
401
403
  /**
402
- * The concurrency governor this run is booking its requests against — either the
404
+ * The concurrency governor the run in progress is booking its requests against — either the
403
405
  * {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} that was injected, or the default the
404
406
  * crawler built for itself. Read it for telemetry: `desiredConcurrency`, `currentConcurrency`, `isRunning`.
405
407
  *
406
- * > *NOTE:* `undefined` until {@link BasicCrawler.run|`crawler.run()`} has resolved it. A crawler-owned default
407
- * is also rebuilt for every run, so the instance is not stable across runs.
408
+ * > *NOTE:* `undefined` outside a run, and a crawler-owned default is rebuilt for every run — so read it
409
+ * during a run rather than caching it across runs.
408
410
  *
409
411
  * {@link IConcurrencySystem} is deliberately read-only. Tuning concurrency *while a crawl is running* means
410
412
  * owning the instance: build a {@link ConcurrencySystem} yourself and inject it, then set
@@ -433,7 +435,6 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
433
435
  request: Request;
434
436
  }, CrawlingContext>;
435
437
  get contextPipeline(): ContextPipeline<CrawlingContext, ExtendedContext>;
436
- running: boolean;
437
438
  /** Whether a `run()` on this instance has already finished - a repeated one continues where it left off. */
438
439
  get hasFinishedBefore(): boolean;
439
440
  get log(): CrawleeLogger;
@@ -616,7 +617,6 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
616
617
  */
617
618
  private validateRequestUserData;
618
619
  useState<State extends Dictionary = Dictionary>(defaultValue?: State): Promise<State>;
619
- private logOncePerRun;
620
620
  /**
621
621
  * Adds requests to the queue in batches. By default, it will resolve after the initial batch is added, and continue
622
622
  * adding the rest in background. You can configure the batch size via `batchSize` option and the sleep time in between
@@ -751,13 +751,22 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
751
751
  private errorAbsolvesSession;
752
752
  private canRequestBeRetried;
753
753
  /**
754
- * Stops the crawler immediately.
754
+ * Ends the run in progress immediately, without waiting for the requests already in flight.
755
755
  *
756
- * This method doesn't wait for currently active requests to finish.
756
+ * This runs at the end of every {@link BasicCrawler.run|`run()`}, so what it releases is per-run only, and
757
+ * a repeated `run()` has to find everything it needs either intact or re-establishable. What outlives a run is
758
+ * released by {@link BasicCrawler.destroy|`destroy()`} instead.
757
759
  *
758
760
  * To stop the crawler gracefully (waiting for all running requests to finish), use {@link BasicCrawler.stop|`crawler.stop()`} instead.
759
761
  */
760
762
  teardown(): Promise<void>;
763
+ /**
764
+ * Releases what the crawler owns beyond a single run — in the browser crawlers, the browser pool. A finished
765
+ * `run()` has already released everything a run owns, so this is only needed before dropping a crawler you
766
+ * could otherwise have run again.
767
+ */
768
+ destroy(): Promise<void>;
769
+ [Symbol.asyncDispose](): Promise<void>;
761
770
  protected getCookieHeaderFromRequest(request: Request): string;
762
771
  private requestMatchesEnqueueStrategy;
763
772
  }
@@ -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, createSkippedRequestArgs, createStorageTransaction, Request, CriticalError, currentStorageTransaction, Dataset, EnqueueStrategy, EventManager, EventType, filterRequestOptionsByPatterns, getObjectType, KeyValueStore, log, 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';
3
+ import { applyRequestTransform, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, buildEnqueueStrategyPatterns, ConcurrencySystem, Configuration, constructUrlPatternObjects, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, createRequestOptions, createSkippedRequestArgs, createStorageTransaction, Request, CriticalError, currentStorageTransaction, Dataset, EnqueueStrategy, EventManager, EventType, filterRequestOptionsByPatterns, getObjectType, KeyValueStore, log, 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';
@@ -9,6 +9,7 @@ import { z } from 'zod';
9
9
  import { LruCache } from '@apify/datastructures';
10
10
  import { addTimeoutToPromise, extendTimeout, storage as timeoutStorage, TimeoutError, tryCancel } from '@apify/timeout';
11
11
  import { cryptoRandomObjectId } from '@apify/utilities';
12
+ import { CrawlerRun } from './crawler-run.js';
12
13
  import { extendTimeoutKey, navigationDeadlineKey, raceWithTimeout, timeoutExpiredKey, } from './request-timeout.js';
13
14
  import { createSendRequest } from './send-request.js';
14
15
  class LazyDefaultHttpClient extends BaseHttpClient {
@@ -120,35 +121,36 @@ export class BasicCrawler {
120
121
  * the concurrency shortcuts folded in (owned, so the crawler starts and stops it).
121
122
  */
122
123
  #resolveConcurrencySystem;
123
- /** As resolved by `init()`. Absent until the first run, so a `teardown()` before it is a no-op. */
124
- #concurrencySystemDep;
124
+ /** The last run started on this instance. It outlives the run, so anything that drives one goes through `#liveRun`. */
125
+ #run;
126
+ #concurrencySystem;
127
+ /** The run in progress, if any — a finished run is history and cannot be driven. */
128
+ get #liveRun() {
129
+ return this.#run?.isLive === true ? this.#run : undefined;
130
+ }
131
+ /** The run whose task loop is dispatching, if any — only that one can be paused and resumed. */
132
+ get #dispatchingRun() {
133
+ return this.#liveRun?.isDispatching === true ? this.#liveRun : undefined;
134
+ }
135
+ /** Whether a {@link BasicCrawler.run|`run()`} is in progress on this instance. */
136
+ get running() {
137
+ return this.#liveRun !== undefined;
138
+ }
125
139
  /**
126
- * The concurrency governor this run is booking its requests against — either the
140
+ * The concurrency governor the run in progress is booking its requests against — either the
127
141
  * {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} that was injected, or the default the
128
142
  * crawler built for itself. Read it for telemetry: `desiredConcurrency`, `currentConcurrency`, `isRunning`.
129
143
  *
130
- * > *NOTE:* `undefined` until {@link BasicCrawler.run|`crawler.run()`} has resolved it. A crawler-owned default
131
- * is also rebuilt for every run, so the instance is not stable across runs.
144
+ * > *NOTE:* `undefined` outside a run, and a crawler-owned default is rebuilt for every run — so read it
145
+ * during a run rather than caching it across runs.
132
146
  *
133
147
  * {@link IConcurrencySystem} is deliberately read-only. Tuning concurrency *while a crawl is running* means
134
148
  * owning the instance: build a {@link ConcurrencySystem} yourself and inject it, then set
135
149
  * `minConcurrency`/`maxConcurrency`/`desiredConcurrency` on your own reference.
136
150
  */
137
151
  get concurrencySystem() {
138
- return this.#concurrencySystemDep?.maybeValue;
152
+ return this.#concurrencySystem?.value;
139
153
  }
140
- /**
141
- * The task loop that dispatches this run's requests. Private on purpose — it is a bare parallel task runner with
142
- * no configuration left of its own (see {@link ConcurrencySystem}), and everything a caller legitimately did
143
- * with it now has a crawler-level counterpart: {@link BasicCrawler.pause|`pause()`},
144
- * {@link BasicCrawler.resume|`resume()`}, {@link BasicCrawler.teardown|`teardown()`} and
145
- * {@link BasicCrawler.concurrencySystem|`concurrencySystem`}.
146
- */
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;
152
154
  /**
153
155
  * A reference to the underlying {@link IProxyConfiguration} instance that manages the crawler's proxies.
154
156
  * Only available if used by the crawler.
@@ -181,9 +183,7 @@ export class BasicCrawler {
181
183
  }
182
184
  return this.#contextPipeline;
183
185
  }
184
- running = false;
185
186
  #hasFinishedBefore = false;
186
- #unexpectedStop = false;
187
187
  /** Whether a `run()` on this instance has already finished - a repeated one continues where it left off. */
188
188
  get hasFinishedBefore() {
189
189
  return this.#hasFinishedBefore;
@@ -223,8 +223,6 @@ export class BasicCrawler {
223
223
  /** The resolved per-storage-type write policy overrides forwarded to each request's transaction. */
224
224
  #storageWritePolicy;
225
225
  #onSkippedRequest;
226
- #closeEvents;
227
- #loggedPerRun = new Set();
228
226
  #robotsTxtFileCache;
229
227
  #identity;
230
228
  #contextPipelineOptions;
@@ -497,12 +495,12 @@ export class BasicCrawler {
497
495
  },
498
496
  isTaskReadyFunction: async () => {
499
497
  if (isMaxPagesExceeded()) {
500
- this.logOncePerRun('shuttingDown', 'Crawler reached the maxRequestsPerCrawl limit of ' +
498
+ this.#liveRun?.logOnce('shuttingDown', 'Crawler reached the maxRequestsPerCrawl limit of ' +
501
499
  `${this.#maxRequestsPerCrawl} requests and will shut down soon. Requests that are in progress will be allowed to finish.`);
502
500
  return false;
503
501
  }
504
- if (this.#unexpectedStop) {
505
- this.logOncePerRun('shuttingDown', 'No new requests are allowed because the `stop()` method has been called. ' +
502
+ if (this.#run?.stopRequested) {
503
+ this.#liveRun?.logOnce('shuttingDown', 'No new requests are allowed because the `stop()` method has been called. ' +
506
504
  'Ongoing requests will be allowed to complete.');
507
505
  return false;
508
506
  }
@@ -515,7 +513,7 @@ export class BasicCrawler {
515
513
  `In total, the crawler processed ${this.handledRequestsCount} requests and will shut down.`);
516
514
  return true;
517
515
  }
518
- if (this.#unexpectedStop) {
516
+ if (this.#run?.stopRequested) {
519
517
  this.log.info('The crawler has finished all the remaining ongoing requests and will shut down now.');
520
518
  return true;
521
519
  }
@@ -619,6 +617,15 @@ export class BasicCrawler {
619
617
  registerDeferredCleanup: (cleanup) => {
620
618
  deferredCleanup.push(cleanup);
621
619
  },
620
+ afterStorageCommit: (callback) => {
621
+ const transaction = currentStorageTransaction();
622
+ if (transaction === undefined) {
623
+ throw new Error('afterStorageCommit() needs an active storage transaction, and there is none. ' +
624
+ "With `transactionalStorage: false` the request's writes are applied as they are " +
625
+ 'made, so they throw at the call site instead of at commit time.');
626
+ }
627
+ transaction.afterCommit(callback);
628
+ },
622
629
  extendTimeout: (secs) => {
623
630
  const extraMillis = secs * 1000;
624
631
  // the current `addTimeoutToPromise` window (the request handler, or a navigation hook)...
@@ -804,18 +811,29 @@ export class BasicCrawler {
804
811
  await pool.resetStore();
805
812
  });
806
813
  }
807
- this.#unexpectedStop = false;
808
- this.running = true;
809
- this.#loggedPerRun.clear();
810
- await purgeDefaultStorages({
811
- onlyPurgeOnce: true,
812
- storageBackend: serviceLocator.getStorageBackend(),
813
- configuration: serviceLocator.getConfiguration(),
814
+ // An owned governor is rebuilt for every run, so it always starts from a clean slate — stale
815
+ // resource snapshots or a previous run's scaled desired concurrency would otherwise distort this
816
+ // run's scaling. An injected one is long-lived and its lifecycle belongs to the caller.
817
+ this.#concurrencySystem = this.#resolveConcurrencySystem();
818
+ const run = new CrawlerRun({
819
+ log: this.log,
820
+ concurrencySystem: this.#concurrencySystem.value,
821
+ taskLoopOptions: this.#taskLoopOptions,
822
+ consumer: this.#identity,
814
823
  });
815
- if (requests) {
816
- await this.addRequests(requests, options);
817
- }
824
+ this.#run = run;
818
825
  try {
826
+ await purgeDefaultStorages({
827
+ onlyPurgeOnce: true,
828
+ storageBackend: serviceLocator.getStorageBackend(),
829
+ configuration: serviceLocator.getConfiguration(),
830
+ });
831
+ if (requests) {
832
+ await this.addRequests(requests, options);
833
+ }
834
+ // An injected governor is the caller's to run; an owned one is started here and stopped again
835
+ // below if the startup fails. Either way the task loop rejects one that is not running.
836
+ await this.#concurrencySystem.ifOwned((system) => system.start());
819
837
  await this.init();
820
838
  await this.statistics.startCapturing();
821
839
  }
@@ -825,7 +843,8 @@ export class BasicCrawler {
825
843
  this.log.exception(teardownError, 'Cleaning up after a failed crawler startup failed.');
826
844
  });
827
845
  // The run never began, so let the instance be run again instead of leaving it wedged as `running`.
828
- this.running = false;
846
+ await run.finish();
847
+ await this.#concurrencySystem.ifOwned((system) => system.stop());
829
848
  throw error;
830
849
  }
831
850
  const periodicLogger = this.getPeriodicLogger();
@@ -833,7 +852,7 @@ export class BasicCrawler {
833
852
  const sigintHandler = async () => {
834
853
  this.log.warning('Pausing... Press CTRL+C again to force exit. To resume, do: CRAWLEE_PURGE_ON_START=0 npm start');
835
854
  await this.pauseOnMigration();
836
- await this.#autoscaledPool.abort();
855
+ await run.abort();
837
856
  };
838
857
  // Attach a listener to handle migration and aborting events gracefully.
839
858
  const boundPauseOnMigration = this.pauseOnMigration.bind(this);
@@ -843,7 +862,7 @@ export class BasicCrawler {
843
862
  eventManager.on(EventType.ABORTING, boundPauseOnMigration);
844
863
  let stats = {};
845
864
  try {
846
- await this.#autoscaledPool.run();
865
+ await run.dispatchRequests();
847
866
  }
848
867
  finally {
849
868
  await this.statistics.stopCapturing();
@@ -896,7 +915,8 @@ export class BasicCrawler {
896
915
  }
897
916
  periodicLogger.stop();
898
917
  this.setStatusMessage(`Finished! Total ${this.statistics.state.requestsFinished + this.statistics.state.requestsFailed} requests: ${this.statistics.state.requestsFinished} succeeded, ${this.statistics.state.requestsFailed} failed.`, { isStatusMessageTerminal: true, level: 'INFO' });
899
- this.running = false;
918
+ await run.finish();
919
+ await this.#concurrencySystem.ifOwned((system) => system.stop());
900
920
  this.#hasFinishedBefore = true;
901
921
  }
902
922
  return stats;
@@ -910,11 +930,12 @@ export class BasicCrawler {
910
930
  * To stop the crawler immediately, use {@link BasicCrawler.teardown|`crawler.teardown()`} instead.
911
931
  */
912
932
  stop(reason = 'The crawler has been gracefully stopped.') {
913
- if (this.#unexpectedStop) {
933
+ const run = this.#liveRun;
934
+ if (run === undefined) {
935
+ this.log.warning('Cannot stop a crawler that is not running.');
914
936
  return;
915
937
  }
916
- this.log.info(reason);
917
- this.#unexpectedStop = true;
938
+ run.stop(reason);
918
939
  }
919
940
  /**
920
941
  * Stops dispatching new requests, letting the in-progress ones finish. Resolves once they have settled, or rejects
@@ -925,22 +946,24 @@ export class BasicCrawler {
925
946
  * throughout, since a shared one may still be serving other crawlers.
926
947
  */
927
948
  async pause(timeoutSecs) {
928
- if (!this.#autoscaledPool) {
949
+ const run = this.#dispatchingRun;
950
+ if (run === undefined) {
929
951
  this.log.warning('Cannot pause a crawler that is not running.');
930
952
  return;
931
953
  }
932
- await this.#autoscaledPool.pause(timeoutSecs);
954
+ await run.pause(timeoutSecs);
933
955
  }
934
956
  /**
935
957
  * Resumes a run suspended with {@link BasicCrawler.pause|`pause()`}, letting the crawler dispatch requests
936
958
  * again. A no-op on a crawler that is not paused.
937
959
  */
938
960
  resume() {
939
- if (!this.#autoscaledPool) {
961
+ const run = this.#dispatchingRun;
962
+ if (run === undefined) {
940
963
  this.log.warning('Cannot resume a crawler that is not running.');
941
964
  return;
942
965
  }
943
- this.#autoscaledPool.resume();
966
+ run.resume();
944
967
  }
945
968
  /**
946
969
  * Returns the crawler's {@link IRequestManager|request manager}, opening the default {@link RequestQueue}
@@ -1048,22 +1071,18 @@ export class BasicCrawler {
1048
1071
  // A skipped request is a *successful* outcome, but the interrupt still unwinds through the
1049
1072
  // transaction scope, which rolls back - so the skip bookkeeping must write directly.
1050
1073
  await withDirectStorageAccess(async () => {
1074
+ // Enqueueing happens outside a run too - `addRequests()` before the first `run()` - so these dedupe
1075
+ // against the logger for the crawler's lifetime rather than per run.
1051
1076
  if (options.reason === 'limit') {
1052
- this.logOncePerRun('maxRequestsPerCrawl', 'The number of requests enqueued by the crawler reached the maxRequestsPerCrawl limit of ' +
1053
- `${this.#maxRequestsPerCrawl} requests and no further requests will be added.`);
1077
+ this.log.info('The number of requests enqueued by the crawler reached the maxRequestsPerCrawl limit of ' +
1078
+ `${this.#maxRequestsPerCrawl} requests and no further requests will be added.`, undefined, { once: true });
1054
1079
  }
1055
1080
  if (options.reason === 'depth') {
1056
- this.logOncePerRun('maxCrawlDepth', `The crawler reached the maxCrawlDepth limit of ${this.#maxCrawlDepth} and no further requests will be enqueued.`);
1081
+ this.log.info(`The crawler reached the maxCrawlDepth limit of ${this.#maxCrawlDepth} and no further requests will be enqueued.`, undefined, { once: true });
1057
1082
  }
1058
1083
  await this.#onSkippedRequest?.(options);
1059
1084
  });
1060
1085
  }
1061
- logOncePerRun(key, message, level = 'info') {
1062
- if (!this.#loggedPerRun.has(key)) {
1063
- this.log[level](message);
1064
- this.#loggedPerRun.add(key);
1065
- }
1066
- }
1067
1086
  /**
1068
1087
  * Adds requests to the queue in batches. By default, it will resolve after the initial batch is added, and continue
1069
1088
  * adding the rest in background. You can configure the batch size via `batchSize` option and the sleep time in between
@@ -1268,10 +1287,14 @@ export class BasicCrawler {
1268
1287
  * Initializes the crawler.
1269
1288
  */
1270
1289
  async init() {
1290
+ const run = this.#liveRun;
1291
+ if (run === undefined) {
1292
+ throw new Error('There is no crawl in progress - `run()` is the only thing that starts one.');
1293
+ }
1271
1294
  const eventManager = serviceLocator.getEventManager();
1272
1295
  if (!eventManager.isInitialized()) {
1273
1296
  await eventManager.init();
1274
- this.#closeEvents = true;
1297
+ run.ownsEventManager = true;
1275
1298
  }
1276
1299
  // Warn once at startup if the internal timeout is shorter than the phases it is meant to outlast. It is
1277
1300
  // floored per request so it will not actually cut them short, but the configured value is then effectively
@@ -1283,16 +1306,6 @@ export class BasicCrawler {
1283
1306
  `and request handler timeouts combined (${phasesMillis / 1000}s); it will be raised per request ` +
1284
1307
  `so it does not cut them short.`);
1285
1308
  }
1286
- // An owned governor is rebuilt (and started) for every run, so it always starts from a clean slate — stale
1287
- // resource snapshots or a previous run's scaled desired concurrency would otherwise distort this run's
1288
- // scaling. An injected one is long-lived and its lifecycle belongs to the caller.
1289
- this.#concurrencySystemDep = this.#resolveConcurrencySystem();
1290
- await this.#concurrencySystemDep.ifOwned((system) => system.start());
1291
- this.#autoscaledPool = new AutoscaledPool({
1292
- ...this.#taskLoopOptions,
1293
- concurrencySystem: this.#concurrencySystemDep.value,
1294
- consumer: this.#identity,
1295
- });
1296
1309
  await this.getRequestManager();
1297
1310
  }
1298
1311
  /**
@@ -1415,10 +1428,10 @@ export class BasicCrawler {
1415
1428
  return true;
1416
1429
  }
1417
1430
  const domain = hostnameOrUrl(url);
1418
- this.logOncePerRun(`rateLimitNotThrottled:${domain}`, `"${domain}" responded with HTTP 429 (Too Many Requests), but the crawler's request manager does not ` +
1431
+ this.log.warningOnce(`"${domain}" responded with HTTP 429 (Too Many Requests), but the crawler's request manager does not ` +
1419
1432
  'pace that domain, so the response is handled like any other, with no per-domain delay. Set ' +
1420
1433
  `\`sameDomainDelaySecs\`, or pass a \`ThrottlingRequestManager\` covering "${domain}" as ` +
1421
- '`requestManager`, to honour `Retry-After` and apply exponential backoff instead.', 'warning');
1434
+ '`requestManager`, to honour `Retry-After` and apply exponential backoff instead.');
1422
1435
  return false;
1423
1436
  }
1424
1437
  /** Hands a robots.txt `Crawl-delay` to the request manager, warning if it will not be honoured. */
@@ -1434,10 +1447,10 @@ export class BasicCrawler {
1434
1447
  return;
1435
1448
  }
1436
1449
  const domain = hostnameOrUrl(url);
1437
- this.logOncePerRun(`crawlDelayIgnored:${domain}`, `robots.txt for "${domain}" defines a crawl-delay of ${delaySeconds}s, but the crawler's request ` +
1450
+ this.log.warningOnce(`robots.txt for "${domain}" defines a crawl-delay of ${delaySeconds}s, but the crawler's request ` +
1438
1451
  'manager does not pace that domain, so its requests will not be paced. Set ' +
1439
1452
  `\`sameDomainDelaySecs\`, or pass a \`ThrottlingRequestManager\` covering "${domain}" as ` +
1440
- '`requestManager`.', 'warning');
1453
+ '`requestManager`.');
1441
1454
  }
1442
1455
  async getRobotsTxtFileForUrl(url) {
1443
1456
  if (!this.#respectRobotsTxtFile) {
@@ -1459,9 +1472,9 @@ export class BasicCrawler {
1459
1472
  }
1460
1473
  }
1461
1474
  async pauseOnMigration() {
1462
- if (this.#autoscaledPool) {
1463
- // if run wasn't called, this is going to crash
1464
- await this.#autoscaledPool.pause(SAFE_MIGRATION_WAIT_MILLIS).catch((err) => {
1475
+ const run = this.#dispatchingRun;
1476
+ if (run) {
1477
+ await run.pause(SAFE_MIGRATION_WAIT_MILLIS).catch((err) => {
1465
1478
  if (err.message.includes('running tasks did not finish')) {
1466
1479
  this.log.error('The crawler was paused due to migration to another host, ' +
1467
1480
  "but some requests did not finish in time. Those requests' results may be duplicated.");
@@ -1615,36 +1628,10 @@ export class BasicCrawler {
1615
1628
  }
1616
1629
  const state = await this.requestManager.checkReadiness();
1617
1630
  if (state.status === 'waiting' && state.readyAt !== undefined) {
1618
- this.#scheduleTaskLoopWake(state.readyAt);
1631
+ this.#run?.scheduleWake(state.readyAt);
1619
1632
  }
1620
1633
  return state.status === 'ready';
1621
1634
  }
1622
- /**
1623
- * Nudges the task loop at `readyAt`, on a single timer that only an earlier one replaces. The pool polls anyway
1624
- * every `maybeRunIntervalSecs` (0.5s by default), so this only shortens the wait - hence one timer rather than
1625
- * one per probe, and `unref`'d so it never keeps the process alive.
1626
- */
1627
- #scheduleTaskLoopWake(readyAt) {
1628
- if (this.#taskLoopWakeTimer !== undefined) {
1629
- if (this.#taskLoopWakeAt <= readyAt) {
1630
- return;
1631
- }
1632
- clearTimeout(this.#taskLoopWakeTimer);
1633
- }
1634
- this.#taskLoopWakeAt = readyAt;
1635
- this.#taskLoopWakeTimer = setTimeout(() => {
1636
- this.#taskLoopWakeTimer = undefined;
1637
- void this.#autoscaledPool?.notify();
1638
- }, Math.max(0, readyAt - Date.now()));
1639
- this.#taskLoopWakeTimer.unref();
1640
- }
1641
- /** Drops a pending task-loop wake-up, so a finished run leaves no timer behind. */
1642
- #clearTaskLoopWake() {
1643
- if (this.#taskLoopWakeTimer !== undefined) {
1644
- clearTimeout(this.#taskLoopWakeTimer);
1645
- this.#taskLoopWakeTimer = undefined;
1646
- }
1647
- }
1648
1635
  /**
1649
1636
  * Unwraps errors thrown by the context pipeline to get the actual user error.
1650
1637
  * RequestHandlerError and ContextPipelineInitializationError wrap the actual error.
@@ -1770,28 +1757,42 @@ export class BasicCrawler {
1770
1757
  return request.retryCount < maxRequestRetries;
1771
1758
  }
1772
1759
  /**
1773
- * Stops the crawler immediately.
1760
+ * Ends the run in progress immediately, without waiting for the requests already in flight.
1774
1761
  *
1775
- * This method doesn't wait for currently active requests to finish.
1762
+ * This runs at the end of every {@link BasicCrawler.run|`run()`}, so what it releases is per-run only, and
1763
+ * a repeated `run()` has to find everything it needs either intact or re-establishable. What outlives a run is
1764
+ * released by {@link BasicCrawler.destroy|`destroy()`} instead.
1776
1765
  *
1777
1766
  * To stop the crawler gracefully (waiting for all running requests to finish), use {@link BasicCrawler.stop|`crawler.stop()`} instead.
1778
1767
  */
1779
1768
  async teardown() {
1769
+ const eventManager = serviceLocator.getEventManager();
1770
+ const ownsEventManager = this.#run?.ownsEventManager === true;
1780
1771
  // First, so that the pool writes its final state once - its teardown stops it listening before the
1781
1772
  // persistence event below goes out.
1782
1773
  await this.#sessionPoolDep.ifOwned(async (pool) => pool.teardown());
1783
1774
  // When this crawler initialized the event manager, its close() call emits
1784
1775
  // the final persistence event after the crawler-specific state has been
1785
1776
  // saved. External event managers still need an explicit event here.
1786
- if (!this.#closeEvents) {
1787
- serviceLocator.getEventManager().emit(EventType.PERSIST_STATE, { isMigrating: false });
1777
+ if (!ownsEventManager) {
1778
+ eventManager.emit(EventType.PERSIST_STATE, { isMigrating: false });
1788
1779
  }
1789
- if (this.#closeEvents) {
1790
- await serviceLocator.getEventManager().close();
1780
+ if (ownsEventManager) {
1781
+ await eventManager.close();
1791
1782
  }
1792
- this.#clearTaskLoopWake();
1793
- await this.#autoscaledPool?.abort();
1794
- await this.#concurrencySystemDep?.ifOwned((system) => system.stop());
1783
+ await this.#run?.end();
1784
+ }
1785
+ /**
1786
+ * Releases what the crawler owns beyond a single run — in the browser crawlers, the browser pool. A finished
1787
+ * `run()` has already released everything a run owns, so this is only needed before dropping a crawler you
1788
+ * could otherwise have run again.
1789
+ */
1790
+ async destroy() {
1791
+ // An abandoned run - one whose `run()` never returned - would otherwise keep dispatching requests.
1792
+ await this.#run?.end();
1793
+ }
1794
+ async [Symbol.asyncDispose]() {
1795
+ await this.destroy();
1795
1796
  }
1796
1797
  getCookieHeaderFromRequest(request) {
1797
1798
  if (request.headers?.Cookie && request.headers?.cookie) {
@@ -0,0 +1,74 @@
1
+ import type { AutoscaledPoolOptions, CrawleeLogger, IConcurrencySystem } from '@crawlee/core';
2
+ /** Everything a run needs to exist. Whatever a crawler resolves per run is resolved before the run is built. */
3
+ export interface CrawlerRunSetup {
4
+ log: CrawleeLogger;
5
+ /** The governor this run books its requests against, started and stopped by whoever owns it. */
6
+ concurrencySystem: IConcurrencySystem;
7
+ taskLoopOptions: Omit<AutoscaledPoolOptions, 'concurrencySystem' | 'consumer'>;
8
+ consumer: AutoscaledPoolOptions['consumer'];
9
+ }
10
+ /**
11
+ * Everything that belongs to a single {@link BasicCrawler.run|`crawler.run()`} — the task loop dispatching its
12
+ * requests, the concurrency governor those requests are booked against, and the bookkeeping that must not survive
13
+ * into a later run.
14
+ *
15
+ * The crawler builds a fresh one for every run, which is what keeps per-run state from leaking across runs: there
16
+ * is no reset checklist to keep in sync. A finished run stays readable as the history of the last crawl, so
17
+ * liveness is asked about explicitly (`isLive`) rather than inferred from presence.
18
+ *
19
+ * The lifecycle is `dispatchRequests()` and `finish()`. Everything a run owns exists from construction, so there
20
+ * is no half-built state to guard against in between.
21
+ *
22
+ * @internal
23
+ */
24
+ export declare class CrawlerRun {
25
+ #private;
26
+ /** Whether this run initialized the ambient event manager, and is therefore the one that closes it. */
27
+ ownsEventManager: boolean;
28
+ constructor(setup: CrawlerRunSetup);
29
+ /**
30
+ * Runs the task loop until the crawl is over or {@link CrawlerRun.abort|aborted}. The governor has to be
31
+ * running by now — the task loop refuses to book anything against one that is not.
32
+ */
33
+ dispatchRequests(): Promise<void>;
34
+ /** Whether the `run()` call this belongs to is still executing. */
35
+ get isLive(): boolean;
36
+ /**
37
+ * Whether the task loop is dispatching requests. Pausing one that has not started would not suspend a crawl,
38
+ * it would keep it from ever starting.
39
+ */
40
+ get isDispatching(): boolean;
41
+ /** Whether a graceful shutdown of this run has been requested. */
42
+ get stopRequested(): boolean;
43
+ /** Asks the run to stop taking new requests. Only the first call logs `reason`. */
44
+ stop(reason: string): void;
45
+ /** Stops dispatching new requests, resolving once the ones in flight have settled. */
46
+ pause(timeoutSecs?: number): Promise<void>;
47
+ /** Resumes dispatching after a {@link CrawlerRun.pause|`pause()`}. */
48
+ resume(): void;
49
+ /** Ends the crawl without waiting for the requests in flight, so a pending `crawl()` resolves. */
50
+ abort(): Promise<void>;
51
+ /**
52
+ * Logs `message` the first time this run asks for it under `key`. Keyed rather than deduped by text, so that
53
+ * two messages describing the same shutdown say it once between them.
54
+ */
55
+ logOnce(key: string, message: string): void;
56
+ /**
57
+ * Nudges the task loop at `readyAt`, when the request manager says it will have work again. The loop polls on
58
+ * its own anyway, so this only shortens the wait — hence a single timer that only an earlier wake-up replaces,
59
+ * `unref`'d so it never keeps the process alive. A run that has ended arms nothing.
60
+ */
61
+ scheduleWake(readyAt: number): void;
62
+ /**
63
+ * Ends the run: drops a pending wake-up and aborts the task loop. Idempotent, so aborting a run through
64
+ * `teardown()` and then finishing it does the work once. The governor is left alone — it belongs to whoever
65
+ * started it.
66
+ */
67
+ end(): Promise<void>;
68
+ /**
69
+ * Ends the run and marks it as history: the crawler stops reporting itself as running, and what is left is
70
+ * readable but no longer drivable. Ending here too means a subclass `teardown()` that forgets `super` cannot
71
+ * leave the task loop dispatching.
72
+ */
73
+ finish(): Promise<void>;
74
+ }
@@ -0,0 +1,142 @@
1
+ import { AutoscaledPool } from '@crawlee/core';
2
+ /**
3
+ * Everything that belongs to a single {@link BasicCrawler.run|`crawler.run()`} — the task loop dispatching its
4
+ * requests, the concurrency governor those requests are booked against, and the bookkeeping that must not survive
5
+ * into a later run.
6
+ *
7
+ * The crawler builds a fresh one for every run, which is what keeps per-run state from leaking across runs: there
8
+ * is no reset checklist to keep in sync. A finished run stays readable as the history of the last crawl, so
9
+ * liveness is asked about explicitly (`isLive`) rather than inferred from presence.
10
+ *
11
+ * The lifecycle is `dispatchRequests()` and `finish()`. Everything a run owns exists from construction, so there
12
+ * is no half-built state to guard against in between.
13
+ *
14
+ * @internal
15
+ */
16
+ export class CrawlerRun {
17
+ #log;
18
+ #autoscaledPool;
19
+ #loggedOnce = new Set();
20
+ #stopRequested = false;
21
+ #dispatching = false;
22
+ #ended = false;
23
+ #finished = false;
24
+ #wakeTimer;
25
+ #wakeAt = 0;
26
+ /** Whether this run initialized the ambient event manager, and is therefore the one that closes it. */
27
+ ownsEventManager = false;
28
+ constructor(setup) {
29
+ this.#log = setup.log;
30
+ this.#autoscaledPool = new AutoscaledPool({
31
+ ...setup.taskLoopOptions,
32
+ concurrencySystem: setup.concurrencySystem,
33
+ consumer: setup.consumer,
34
+ });
35
+ }
36
+ /**
37
+ * Runs the task loop until the crawl is over or {@link CrawlerRun.abort|aborted}. The governor has to be
38
+ * running by now — the task loop refuses to book anything against one that is not.
39
+ */
40
+ async dispatchRequests() {
41
+ this.#dispatching = true;
42
+ try {
43
+ await this.#autoscaledPool.run();
44
+ }
45
+ finally {
46
+ this.#dispatching = false;
47
+ }
48
+ }
49
+ /** Whether the `run()` call this belongs to is still executing. */
50
+ get isLive() {
51
+ return !this.#finished;
52
+ }
53
+ /**
54
+ * Whether the task loop is dispatching requests. Pausing one that has not started would not suspend a crawl,
55
+ * it would keep it from ever starting.
56
+ */
57
+ get isDispatching() {
58
+ return this.#dispatching;
59
+ }
60
+ /** Whether a graceful shutdown of this run has been requested. */
61
+ get stopRequested() {
62
+ return this.#stopRequested;
63
+ }
64
+ /** Asks the run to stop taking new requests. Only the first call logs `reason`. */
65
+ stop(reason) {
66
+ if (this.#stopRequested) {
67
+ return;
68
+ }
69
+ this.#log.info(reason);
70
+ this.#stopRequested = true;
71
+ }
72
+ /** Stops dispatching new requests, resolving once the ones in flight have settled. */
73
+ async pause(timeoutSecs) {
74
+ await this.#autoscaledPool.pause(timeoutSecs);
75
+ }
76
+ /** Resumes dispatching after a {@link CrawlerRun.pause|`pause()`}. */
77
+ resume() {
78
+ this.#autoscaledPool.resume();
79
+ }
80
+ /** Ends the crawl without waiting for the requests in flight, so a pending `crawl()` resolves. */
81
+ async abort() {
82
+ await this.#autoscaledPool.abort();
83
+ }
84
+ /**
85
+ * Logs `message` the first time this run asks for it under `key`. Keyed rather than deduped by text, so that
86
+ * two messages describing the same shutdown say it once between them.
87
+ */
88
+ logOnce(key, message) {
89
+ if (this.#loggedOnce.has(key)) {
90
+ return;
91
+ }
92
+ this.#log.info(message);
93
+ this.#loggedOnce.add(key);
94
+ }
95
+ /**
96
+ * Nudges the task loop at `readyAt`, when the request manager says it will have work again. The loop polls on
97
+ * its own anyway, so this only shortens the wait — hence a single timer that only an earlier wake-up replaces,
98
+ * `unref`'d so it never keeps the process alive. A run that has ended arms nothing.
99
+ */
100
+ scheduleWake(readyAt) {
101
+ if (this.#ended) {
102
+ return;
103
+ }
104
+ if (this.#wakeTimer !== undefined) {
105
+ if (this.#wakeAt <= readyAt) {
106
+ return;
107
+ }
108
+ clearTimeout(this.#wakeTimer);
109
+ }
110
+ this.#wakeAt = readyAt;
111
+ this.#wakeTimer = setTimeout(() => {
112
+ this.#wakeTimer = undefined;
113
+ void this.#autoscaledPool.notify();
114
+ }, Math.max(0, readyAt - Date.now()));
115
+ this.#wakeTimer.unref();
116
+ }
117
+ /**
118
+ * Ends the run: drops a pending wake-up and aborts the task loop. Idempotent, so aborting a run through
119
+ * `teardown()` and then finishing it does the work once. The governor is left alone — it belongs to whoever
120
+ * started it.
121
+ */
122
+ async end() {
123
+ if (this.#ended) {
124
+ return;
125
+ }
126
+ this.#ended = true;
127
+ if (this.#wakeTimer !== undefined) {
128
+ clearTimeout(this.#wakeTimer);
129
+ this.#wakeTimer = undefined;
130
+ }
131
+ await this.#autoscaledPool.abort();
132
+ }
133
+ /**
134
+ * Ends the run and marks it as history: the crawler stops reporting itself as running, and what is left is
135
+ * readable but no longer drivable. Ending here too means a subclass `teardown()` that forgets `super` cannot
136
+ * leave the task loop dispatching.
137
+ */
138
+ async finish() {
139
+ await this.end();
140
+ this.#finished = true;
141
+ }
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.165",
3
+ "version": "4.0.0-beta.167",
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": "^3.0.1",
43
43
  "@apify/timeout": "^1.0.1",
44
44
  "@apify/utilities": "^3.0.1",
45
- "@crawlee/core": "4.0.0-beta.165",
46
- "@crawlee/http-client": "4.0.0-beta.165",
47
- "@crawlee/types": "4.0.0-beta.165",
48
- "@crawlee/utils": "4.0.0-beta.165",
45
+ "@crawlee/core": "4.0.0-beta.167",
46
+ "@crawlee/http-client": "4.0.0-beta.167",
47
+ "@crawlee/types": "4.0.0-beta.167",
48
+ "@crawlee/utils": "4.0.0-beta.167",
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.5.4"
54
54
  },
55
55
  "optionalDependencies": {
56
- "@crawlee/impit-client": "4.0.0-beta.165"
56
+ "@crawlee/impit-client": "4.0.0-beta.167"
57
57
  },
58
58
  "lerna": {
59
59
  "command": {
@@ -62,5 +62,5 @@
62
62
  }
63
63
  }
64
64
  },
65
- "gitHead": "e41d837b93d16b5e5c726d4dc00aa6f3f1282b51"
65
+ "gitHead": "8b26080bf07dc602aa0970e12fb66e7e9cba509b"
66
66
  }