@crawlee/basic 4.0.0-beta.123 → 4.0.0-beta.125

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.
@@ -29,7 +29,7 @@ export type StatusMessageCallback<Context extends CrawlingContext = BasicCrawlin
29
29
  export type RequireContextPipeline<DefaultContextType extends CrawlingContext, FinalContextType extends DefaultContextType> = DefaultContextType extends FinalContextType ? {} : {
30
30
  contextPipelineBuilder: () => ContextPipeline<CrawlingContext, FinalContextType>;
31
31
  };
32
- export interface BasicCrawlerOptions<Context extends CrawlingContext = CrawlingContext, ContextExtension = Dictionary<never>, ExtendedContext extends Context = Context & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>> {
32
+ export interface BasicCrawlerOptions<Context extends CrawlingContext = CrawlingContext, ContextExtension = Dictionary<never>, ExtendedContext extends Context = Context & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>, StatisticStateExtension extends object = {}> {
33
33
  /**
34
34
  * User-provided function that performs the logic of the crawler. It is called for each URL to crawl.
35
35
  *
@@ -137,7 +137,11 @@ export interface BasicCrawlerOptions<Context extends CrawlingContext = CrawlingC
137
137
  */
138
138
  maxRequestRetries?: number;
139
139
  /**
140
- * Indicates how much time (in seconds) to wait before crawling another same domain request.
140
+ * Indicates how much time (in seconds) to wait before crawling another same domain request. Subdomains are
141
+ * paced together with the site they belong to.
142
+ *
143
+ * Wraps the crawler's request manager in a {@link ThrottlingRequestManager}; pass one as `requestManager`
144
+ * yourself to configure it further.
141
145
  * @default 0
142
146
  */
143
147
  sameDomainDelaySecs?: number;
@@ -274,10 +278,28 @@ export interface BasicCrawlerOptions<Context extends CrawlingContext = CrawlingC
274
278
  onSkippedRequest?: SkippedRequestCallback;
275
279
  /**
276
280
  * A preconfigured statistics instance. When provided, the crawler records into it instead of building its own and
277
- * will not `reset()` it between `run()` calls. Accepts the built-in {@link Statistics} (subclass it to track
278
- * extra fields) or any object implementing {@link IStatistics}.
281
+ * will not `reset()` it between `run()` calls. Accepts the built-in {@link Statistics} or any object
282
+ * implementing {@link IStatistics}.
283
+ *
284
+ * Custom fields declared via {@link StatisticsOptions.stateExtension|`stateExtension`} are carried over to
285
+ * {@link BasicCrawler.stats|`crawler.stats.state`}:
286
+ *
287
+ * ```ts
288
+ * const statistics = new Statistics({ stateExtension: { defaultState: { productsFound: 0 } } });
289
+ *
290
+ * const crawler = new BasicCrawler({
291
+ * statistics,
292
+ * requestHandler: async () => {
293
+ * statistics.state.productsFound++;
294
+ * },
295
+ * });
296
+ *
297
+ * await crawler.run();
298
+ * // the custom fields are typed on `crawler.stats` too
299
+ * console.log(crawler.stats.state.productsFound);
300
+ * ```
279
301
  */
280
- statistics?: IStatistics;
302
+ statistics?: IStatistics<StatisticStateExtension>;
281
303
  /**
282
304
  * HTTP client implementation for the `sendRequest` context helper and for plain HTTP crawling.
283
305
  * Defaults to {@link ImpitHttpClient} when `@crawlee/impit-client` is installed, otherwise {@link FetchHttpClient}.
@@ -429,7 +451,7 @@ interface CrawlerIdentity {
429
451
  /** Whether `id` came from the user (as opposed to being derived from `instanceIndex`). */
430
452
  readonly hasExplicitId: boolean;
431
453
  }
432
- export declare class BasicCrawler<Context extends CrawlingContext = CrawlingContext, ContextExtension = Dictionary<never>, ExtendedContext extends Context = Context & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>> {
454
+ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingContext, ContextExtension = Dictionary<never>, ExtendedContext extends Context = Context & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>, StatisticStateExtension extends object = {}> {
433
455
  #private;
434
456
  protected static readonly CRAWLEE_STATE_KEY = "CRAWLEE_STATE";
435
457
  /**
@@ -442,7 +464,7 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
442
464
  * The statistics instance collecting the crawler's run statistics - either the injected `statistics` option or a
443
465
  * crawler-built default. Typed as {@link IStatistics} so custom implementations can be plugged in.
444
466
  */
445
- get stats(): IStatistics;
467
+ get stats(): IStatistics<StatisticStateExtension>;
446
468
  /**
447
469
  * The main request-handling component of the crawler. It manages the requests that the crawler processes,
448
470
  * combining any provided request loader and/or queue. It's initialized during the crawler startup or lazily
@@ -605,7 +627,7 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
605
627
  /**
606
628
  * All `BasicCrawler` parameters are passed via an options object.
607
629
  */
608
- constructor(options?: BasicCrawlerOptions<Context, ContextExtension, ExtendedContext, Routes> & RequireContextPipeline<CrawlingContext, Context>);
630
+ constructor(options?: BasicCrawlerOptions<Context, ContextExtension, ExtendedContext, Routes, StatisticStateExtension> & RequireContextPipeline<CrawlingContext, Context>);
609
631
  /**
610
632
  * Builds the crawler-owned default {@link ConcurrencySystem} from the resolved
611
633
  * `minConcurrency`/`maxConcurrency`/`maxRequestsPerMinute` shortcuts. Not called when a
@@ -824,12 +846,6 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
824
846
  * Fetches the next request to process from the underlying request provider.
825
847
  */
826
848
  private fetchNextRequest;
827
- /**
828
- * Delays processing of the request based on the `sameDomainDelaySecs` option,
829
- * adding it back to the queue after the timeout passes. Returns `true` if the request
830
- * should be ignored and will be reclaimed to the queue once ready.
831
- */
832
- private delayRequest;
833
849
  /** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
834
850
  private handleRequest;
835
851
  /**
@@ -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, parseArgument, purgeDefaultStorages, RequestHandlerError, parseRetryAfterHeader, RequestThrottledError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, supportsDomainThrottling, Router, schemas, ServiceLocator, serviceLocator, Session, SessionError, SessionPool, Statistics, 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, parseArgument, purgeDefaultStorages, RequestHandlerError, parseRetryAfterHeader, RequestThrottledError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, supportsDomainThrottling, Router, schemas, 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, ROTATE_PROXY_ERRORS } from '@crawlee/utils/internal';
6
6
  import { RobotsTxtFile } from '@crawlee/utils';
@@ -198,8 +198,7 @@ export class BasicCrawler {
198
198
  internalTimeoutMillis;
199
199
  maxRequestRetries;
200
200
  maxCrawlDepth;
201
- #sameDomainDelayMillis;
202
- #domainAccessedTime;
201
+ #sameDomainDelaySecs;
203
202
  maxRequestsPerCrawl;
204
203
  get handledRequestsCount() {
205
204
  return this.stats.state.requestsFinished + this.stats.state.requestsFailed;
@@ -335,6 +334,12 @@ export class BasicCrawler {
335
334
  if (requestList !== undefined || requestQueue !== undefined) {
336
335
  throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
337
336
  }
337
+ // Both would pace the same domains, from different keys and with no idea of one another.
338
+ if (sameDomainDelaySecs > 0 && supportsDomainThrottling(requestManager)) {
339
+ throw new Error('The `sameDomainDelaySecs` option cannot be combined with a `requestManager` that throttles ' +
340
+ 'per domain on its own. Configure the delay on the manager instead, via the ' +
341
+ '`minCrawlDelaySecs` option of `ThrottlingRequestManager`.');
342
+ }
338
343
  this.requestManager = requestManager;
339
344
  }
340
345
  else if (requestList !== undefined && requestQueue !== undefined) {
@@ -355,7 +360,6 @@ export class BasicCrawler {
355
360
  this.proxyConfiguration = proxyConfiguration;
356
361
  this.#statusMessageLoggingInterval = statusMessageLoggingInterval;
357
362
  this.#statusMessageCallback = statusMessageCallback;
358
- this.#domainAccessedTime = new Map();
359
363
  this.#robotsTxtFileCache = new LruCache({ maxLength: 1000 });
360
364
  this.handleSkippedRequest = this.handleSkippedRequest.bind(this);
361
365
  this.additionalHttpErrorStatusCodes = new Set([...additionalHttpErrorStatusCodes]);
@@ -382,8 +386,12 @@ export class BasicCrawler {
382
386
  Math.max(this.requestHandlerTimeoutMillis * 2, 300e3);
383
387
  this.maxRequestRetries = maxRequestRetries;
384
388
  this.maxCrawlDepth = maxCrawlDepth;
385
- this.#sameDomainDelayMillis = sameDomainDelaySecs * 1000;
386
- this.#statsDep = OwnedOrInjected.resolve(statistics, () => new Statistics({
389
+ this.#sameDomainDelaySecs = sameDomainDelaySecs;
390
+ this.#statsDep = OwnedOrInjected.resolve(statistics,
391
+ // A crawler-built default tracks the built-in fields only. A non-empty `StatisticStateExtension` can
392
+ // only be satisfied by an injected instance carrying the matching `state`, so this branch does
393
+ // not run in that case - hence the cast.
394
+ () => new Statistics({
387
395
  logMessage: `${this.constructor.name} request statistics:`,
388
396
  log: this.log,
389
397
  id: this.identity.id,
@@ -422,7 +430,7 @@ export class BasicCrawler {
422
430
  if (!source)
423
431
  throw new Error('Request provider is not initialized!');
424
432
  const request = await this.resolveRequest();
425
- if (!request || this.delayRequest(request, source)) {
433
+ if (!request) {
426
434
  return;
427
435
  }
428
436
  // Started here, rather than in `handleRequest`, so that a failure during context pipeline
@@ -767,8 +775,14 @@ export class BasicCrawler {
767
775
  // When `purgeRequestQueue` is explicitly `false`, nothing is purged.
768
776
  const shouldPurge = purgeRequestQueue !== false;
769
777
  const managerToPurge = this.#ownedRequestQueue.maybeValue ?? (purgeRequestQueue === true ? this.requestManager : undefined);
770
- if (managerToPurge?.purge && shouldPurge) {
771
- await managerToPurge.purge();
778
+ if (shouldPurge) {
779
+ await managerToPurge?.purge?.();
780
+ // The per-domain queues a `sameDomainDelaySecs` wrapper created are the crawler's own, whatever
781
+ // sits underneath them - so they are emptied even when the manager they wrap is spared. Purging
782
+ // the wrapper itself has already covered them.
783
+ if (this.requestManager instanceof ThrottlingRequestManager && managerToPurge !== this.requestManager) {
784
+ await this.requestManager.purgeDomainQueues();
785
+ }
772
786
  }
773
787
  // A supplied statistics instance keeps whatever state it was handed - only wipe a default we built.
774
788
  await this.#statsDep.ifOwned(async (stats) => {
@@ -906,6 +920,19 @@ export class BasicCrawler {
906
920
  if (!this.requestManager) {
907
921
  this.requestManager = await this.openOwnedRequestQueue();
908
922
  }
923
+ // Wrapped here rather than in the constructor, because the manager being wrapped may only be opened at
924
+ // this point - and because everything that enqueues goes through here first, so nothing slips past the
925
+ // wrapper into the queue it hides.
926
+ if (this.#sameDomainDelaySecs > 0 && !supportsDomainThrottling(this.requestManager)) {
927
+ this.requestManager = new ThrottlingRequestManager({
928
+ inner: this.requestManager,
929
+ domains: 'all',
930
+ minCrawlDelaySecs: this.#sameDomainDelaySecs,
931
+ // What `sameDomainDelaySecs` has always meant: one clock for a site, subdomains included.
932
+ throttleBy: 'registrableDomain',
933
+ persistStateKey: `CRAWLEE_THROTTLED_DOMAINS_${this.identity.id}`,
934
+ });
935
+ }
909
936
  // Apply the processing-time hint here (an async lifecycle point) rather than in the constructor,
910
937
  // now that `setExpectedRequestProcessingTimeSecs` is async. The hint is raise-only and idempotent,
911
938
  // but guard so we do not re-issue it on every call.
@@ -1431,30 +1458,6 @@ export class BasicCrawler {
1431
1458
  }
1432
1459
  return this.requestManager.fetchNextRequest();
1433
1460
  }
1434
- /**
1435
- * Delays processing of the request based on the `sameDomainDelaySecs` option,
1436
- * adding it back to the queue after the timeout passes. Returns `true` if the request
1437
- * should be ignored and will be reclaimed to the queue once ready.
1438
- */
1439
- delayRequest(request, source) {
1440
- const domain = getDomain(request.url);
1441
- if (!domain || !request) {
1442
- return false;
1443
- }
1444
- const now = Date.now();
1445
- const lastAccessTime = this.#domainAccessedTime.get(domain);
1446
- if (!lastAccessTime || now - lastAccessTime >= this.#sameDomainDelayMillis) {
1447
- this.#domainAccessedTime.set(domain, now);
1448
- return false;
1449
- }
1450
- const delay = lastAccessTime + this.#sameDomainDelayMillis - now;
1451
- this.log.debug(`Request ${request.url} (${request.id}) will be reclaimed after ${delay} milliseconds due to same domain delay`);
1452
- setTimeout(async () => {
1453
- this.log.debug(`Adding request ${request.url} (${request.id}) back to the queue`);
1454
- await source.reclaimRequest(request, { forefront: request.userData?.__crawlee?.forefront });
1455
- }, delay);
1456
- return true;
1457
- }
1458
1461
  /** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
1459
1462
  async handleRequest(crawlingContext, requestSource, request) {
1460
1463
  // An earlier phase we cannot cancel (e.g. a slow `extendContext`) may have run past the internal timeout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.123",
3
+ "version": "4.0.0-beta.125",
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.123",
46
- "@crawlee/http-client": "4.0.0-beta.123",
47
- "@crawlee/types": "4.0.0-beta.123",
48
- "@crawlee/utils": "4.0.0-beta.123",
45
+ "@crawlee/core": "4.0.0-beta.125",
46
+ "@crawlee/http-client": "4.0.0-beta.125",
47
+ "@crawlee/types": "4.0.0-beta.125",
48
+ "@crawlee/utils": "4.0.0-beta.125",
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.123"
56
+ "@crawlee/impit-client": "^4.0.0-beta.125"
57
57
  },
58
58
  "lerna": {
59
59
  "command": {
@@ -62,5 +62,5 @@
62
62
  }
63
63
  }
64
64
  },
65
- "gitHead": "f77648095c6a3f5ed8815c7620ea765db430ae44"
65
+ "gitHead": "04a7212dd4abe4a5f94f5fc9632acb6089819c8b"
66
66
  }