@crawlee/basic 4.0.0-beta.122 → 4.0.0-beta.124

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.
@@ -1,9 +1,9 @@
1
- import type { AddRequestsBatchedOptions, AddRequestsBatchedResult, ConcurrencySystemOptions, CrawleeLogger, CrawlingContext, DatasetExportOptions, EnqueueLinksOptions, FinalStatistics, GetUserDataFromRequest, IConcurrencySystem, IProxyConfiguration, IRequestLoader, IRequestManager, IStatistics, Request, RequestsLike, RouterHandler, RouterRoutes, SkippedRequestCallback, Source, StatisticState, StorageIdentifier, StorageWritePolicy, TaskLoopPredicates, TypedRequestsLike } from '@crawlee/core';
2
- import { ConcurrencySystem, Configuration, ContextPipeline, Dataset, EventManager, RequestQueue } from '@crawlee/core';
1
+ import type { AddRequestsBatchedOptions, AddRequestsBatchedResult, ConcurrencySystemOptions, CrawleeLogger, CrawlingContext, DatasetExportOptions, EnqueueUrlsOptions, FinalStatistics, GetUserDataFromRequest, IConcurrencySystem, IProxyConfiguration, IRequestLoader, IRequestManager, IStatistics, RequestsLike, RouterHandler, RouterRoutes, SkippedRequestCallback, Source, StatisticState, StorageIdentifier, StorageWritePolicy, TaskLoopPredicates, TypedRequestsLike } from '@crawlee/core';
2
+ import { ConcurrencySystem, Configuration, ContextPipeline, Request, Dataset, EventManager, RequestQueue } from '@crawlee/core';
3
3
  import { BaseHttpClient } from '@crawlee/http-client';
4
- import type { Awaitable, BatchAddRequestsResult, Dictionary, ISession, ISessionPool, ProxyInfo, SetStatusMessageOptions, StorageBackend } from '@crawlee/types';
4
+ import type { Awaitable, Dictionary, ISession, ISessionPool, ProxyInfo, SetStatusMessageOptions, StorageBackend } from '@crawlee/types';
5
5
  import { RobotsTxtFile } from '@crawlee/utils';
6
- import type { ReadonlyDeep, SetRequired } from 'type-fest';
6
+ import type { ReadonlyDeep } from 'type-fest';
7
7
  import { z } from 'zod';
8
8
  import { TimeoutError } from '@apify/timeout';
9
9
  export interface BasicCrawlingContext<UserData extends Dictionary = Dictionary> extends CrawlingContext<UserData> {
@@ -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;
@@ -733,6 +737,11 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
733
737
  * the batches via `waitBetweenBatchesMillis`. If you want to wait for all batches to be added to the queue, you can use
734
738
  * the `waitForAllRequestsToBeAdded` promise you get in the response object.
735
739
  *
740
+ * Optionally, the requests can be filtered using `include`/`exclude` glob or regexp patterns and an
741
+ * enqueue `strategy` (both AND-ed together, same as {@link CrawlingContext.enqueueLinks|`enqueueLinks`}),
742
+ * relative to `baseUrl`. Unlike `enqueueLinks`, there is no implicit "current page" to anchor the strategy
743
+ * to, so `strategy` defaults to {@link EnqueueStrategy.All|`all`} here.
744
+ *
736
745
  * This is an alias for calling `addRequestsBatched()` on the implicit `RequestQueue` for this crawler instance.
737
746
  *
738
747
  * @param requests The requests to add
@@ -819,22 +828,8 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
819
828
  * Fetches the next request to process from the underlying request provider.
820
829
  */
821
830
  private fetchNextRequest;
822
- /**
823
- * Delays processing of the request based on the `sameDomainDelaySecs` option,
824
- * adding it back to the queue after the timeout passes. Returns `true` if the request
825
- * should be ignored and will be reclaimed to the queue once ready.
826
- */
827
- private delayRequest;
828
831
  /** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
829
832
  private handleRequest;
830
- /**
831
- * Wrapper around the crawling context's `enqueueLinks` method:
832
- * - Injects `crawlDepth` to each request being added based on the crawling context request.
833
- * - Provides defaults for the `enqueueLinks` options based on the crawler configuration.
834
- * - These options can be overridden by the user.
835
- * @internal
836
- */
837
- protected enqueueLinksWithCrawlDepth(options: SetRequired<EnqueueLinksOptions, 'urls'>, request: Request<Dictionary>, requestManager: IRequestManager): Promise<BatchAddRequestsResult>;
838
833
  /**
839
834
  * Generator function that yields requests injected with the given crawl depth.
840
835
  * @internal
@@ -893,7 +888,7 @@ export interface CreateContextOptions {
893
888
  session: ISession;
894
889
  proxyInfo?: ProxyInfo;
895
890
  }
896
- export interface CrawlerAddRequestsOptions extends AddRequestsBatchedOptions {
891
+ export interface CrawlerAddRequestsOptions extends AddRequestsBatchedOptions, EnqueueUrlsOptions {
897
892
  }
898
893
  export interface CrawlerAddRequestsResult extends AddRequestsBatchedResult {
899
894
  }
@@ -1,6 +1,6 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { AutoscaledPool, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, ConcurrencySystem, Configuration, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, createStorageTransaction, CriticalError, currentStorageTransaction, Dataset, enqueueLinks, EnqueueStrategy, EventManager, EventType, 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';
@@ -43,6 +43,33 @@ const SAFE_MIGRATION_WAIT_MILLIS = 20000;
43
43
  const deferredCleanupKey = Symbol('deferredCleanup');
44
44
  // The request timeout plumbing (the window helper, the context symbols, and the race) lives in its own module.
45
45
  export { navigationDeadlineKey, remainingNavigationWindowMillis } from './request-timeout.js';
46
+ const urlPatternSchema = z.union([
47
+ z.string(),
48
+ z.instanceof(RegExp),
49
+ schemas.objectWithKeys(['glob']),
50
+ schemas.objectWithKeys(['regexp']),
51
+ ]);
52
+ // `looseObject` (rather than `strictObject`) lets subclasses forward their own extraction-only options
53
+ // (e.g. `selector`) straight through without having to strip them out first.
54
+ const addRequestsOptionsSchema = z.looseObject({
55
+ forefront: z.boolean().optional(),
56
+ cache: z.boolean().optional(),
57
+ waitForAllRequestsToBeAdded: z.boolean().optional(),
58
+ batchSize: schemas.anyNumber.optional(),
59
+ waitBetweenBatchesMillis: schemas.anyNumber.optional(),
60
+ maxNewRequests: schemas.anyNumber.optional(),
61
+ limit: schemas.anyNumber.optional(),
62
+ baseUrl: z.string().optional(),
63
+ userData: schemas.anyObject.optional(),
64
+ label: z.string().optional(),
65
+ sessionId: z.string().optional(),
66
+ skipNavigation: z.boolean().optional(),
67
+ include: schemas.arrayOf(urlPatternSchema, 'URL patterns').min(1).optional(),
68
+ exclude: schemas.arrayOf(urlPatternSchema, 'URL patterns').optional(),
69
+ transformRequestFunction: schemas.anyFunction.optional(),
70
+ strategy: z.enum(EnqueueStrategy).optional(),
71
+ onSkippedRequest: schemas.anyFunction.optional(),
72
+ });
46
73
  export class BasicCrawler {
47
74
  static CRAWLEE_STATE_KEY = 'CRAWLEE_STATE';
48
75
  /**
@@ -171,8 +198,7 @@ export class BasicCrawler {
171
198
  internalTimeoutMillis;
172
199
  maxRequestRetries;
173
200
  maxCrawlDepth;
174
- #sameDomainDelayMillis;
175
- #domainAccessedTime;
201
+ #sameDomainDelaySecs;
176
202
  maxRequestsPerCrawl;
177
203
  get handledRequestsCount() {
178
204
  return this.stats.state.requestsFinished + this.stats.state.requestsFailed;
@@ -308,6 +334,12 @@ export class BasicCrawler {
308
334
  if (requestList !== undefined || requestQueue !== undefined) {
309
335
  throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
310
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
+ }
311
343
  this.requestManager = requestManager;
312
344
  }
313
345
  else if (requestList !== undefined && requestQueue !== undefined) {
@@ -328,7 +360,6 @@ export class BasicCrawler {
328
360
  this.proxyConfiguration = proxyConfiguration;
329
361
  this.#statusMessageLoggingInterval = statusMessageLoggingInterval;
330
362
  this.#statusMessageCallback = statusMessageCallback;
331
- this.#domainAccessedTime = new Map();
332
363
  this.#robotsTxtFileCache = new LruCache({ maxLength: 1000 });
333
364
  this.handleSkippedRequest = this.handleSkippedRequest.bind(this);
334
365
  this.additionalHttpErrorStatusCodes = new Set([...additionalHttpErrorStatusCodes]);
@@ -355,7 +386,7 @@ export class BasicCrawler {
355
386
  Math.max(this.requestHandlerTimeoutMillis * 2, 300e3);
356
387
  this.maxRequestRetries = maxRequestRetries;
357
388
  this.maxCrawlDepth = maxCrawlDepth;
358
- this.#sameDomainDelayMillis = sameDomainDelaySecs * 1000;
389
+ this.#sameDomainDelaySecs = sameDomainDelaySecs;
359
390
  this.#statsDep = OwnedOrInjected.resolve(statistics, () => new Statistics({
360
391
  logMessage: `${this.constructor.name} request statistics:`,
361
392
  log: this.log,
@@ -395,7 +426,7 @@ export class BasicCrawler {
395
426
  if (!source)
396
427
  throw new Error('Request provider is not initialized!');
397
428
  const request = await this.resolveRequest();
398
- if (!request || this.delayRequest(request, source)) {
429
+ if (!request) {
399
430
  return;
400
431
  }
401
432
  // Started here, rather than in `handleRequest`, so that a failure during context pipeline
@@ -598,17 +629,13 @@ export class BasicCrawler {
598
629
  return { session, proxyInfo: session?.proxyInfo };
599
630
  }
600
631
  async createContextHelpers({ request, session }) {
601
- const enqueueLinksWrapper = async (options) => {
602
- const requestManager = await this.getRequestManager();
603
- return await this.enqueueLinksWithCrawlDepth(options, request, requestManager);
604
- };
605
632
  const addRequests = async (requests, options = {}) => {
606
633
  const newCrawlDepth = request.crawlDepth + 1;
607
634
  const requestsGenerator = this.addCrawlDepthRequestGenerator(requests, newCrawlDepth);
608
- await this.addRequests(requestsGenerator, options);
635
+ return await this.addRequests(requestsGenerator, options);
609
636
  };
610
637
  const sendRequest = createSendRequest(this.httpClient, request, session);
611
- return { enqueueLinks: enqueueLinksWrapper, addRequests, sendRequest };
638
+ return { addRequests, sendRequest };
612
639
  }
613
640
  buildFinalContextPipeline() {
614
641
  const subclassPipeline = (this.#contextPipelineOptions.contextPipelineBuilder?.() ??
@@ -744,8 +771,14 @@ export class BasicCrawler {
744
771
  // When `purgeRequestQueue` is explicitly `false`, nothing is purged.
745
772
  const shouldPurge = purgeRequestQueue !== false;
746
773
  const managerToPurge = this.#ownedRequestQueue.maybeValue ?? (purgeRequestQueue === true ? this.requestManager : undefined);
747
- if (managerToPurge?.purge && shouldPurge) {
748
- await managerToPurge.purge();
774
+ if (shouldPurge) {
775
+ await managerToPurge?.purge?.();
776
+ // The per-domain queues a `sameDomainDelaySecs` wrapper created are the crawler's own, whatever
777
+ // sits underneath them - so they are emptied even when the manager they wrap is spared. Purging
778
+ // the wrapper itself has already covered them.
779
+ if (this.requestManager instanceof ThrottlingRequestManager && managerToPurge !== this.requestManager) {
780
+ await this.requestManager.purgeDomainQueues();
781
+ }
749
782
  }
750
783
  // A supplied statistics instance keeps whatever state it was handed - only wipe a default we built.
751
784
  await this.#statsDep.ifOwned(async (stats) => {
@@ -883,6 +916,19 @@ export class BasicCrawler {
883
916
  if (!this.requestManager) {
884
917
  this.requestManager = await this.openOwnedRequestQueue();
885
918
  }
919
+ // Wrapped here rather than in the constructor, because the manager being wrapped may only be opened at
920
+ // this point - and because everything that enqueues goes through here first, so nothing slips past the
921
+ // wrapper into the queue it hides.
922
+ if (this.#sameDomainDelaySecs > 0 && !supportsDomainThrottling(this.requestManager)) {
923
+ this.requestManager = new ThrottlingRequestManager({
924
+ inner: this.requestManager,
925
+ domains: 'all',
926
+ minCrawlDelaySecs: this.#sameDomainDelaySecs,
927
+ // What `sameDomainDelaySecs` has always meant: one clock for a site, subdomains included.
928
+ throttleBy: 'registrableDomain',
929
+ persistStateKey: `CRAWLEE_THROTTLED_DOMAINS_${this.identity.id}`,
930
+ });
931
+ }
886
932
  // Apply the processing-time hint here (an async lifecycle point) rather than in the constructor,
887
933
  // now that `setExpectedRequestProcessingTimeSecs` is async. The hint is raise-only and idempotent,
888
934
  // but guard so we do not re-issue it on every call.
@@ -1005,6 +1051,11 @@ export class BasicCrawler {
1005
1051
  * the batches via `waitBetweenBatchesMillis`. If you want to wait for all batches to be added to the queue, you can use
1006
1052
  * the `waitForAllRequestsToBeAdded` promise you get in the response object.
1007
1053
  *
1054
+ * Optionally, the requests can be filtered using `include`/`exclude` glob or regexp patterns and an
1055
+ * enqueue `strategy` (both AND-ed together, same as {@link CrawlingContext.enqueueLinks|`enqueueLinks`}),
1056
+ * relative to `baseUrl`. Unlike `enqueueLinks`, there is no implicit "current page" to anchor the strategy
1057
+ * to, so `strategy` defaults to {@link EnqueueStrategy.All|`all`} here.
1058
+ *
1008
1059
  * This is an alias for calling `addRequestsBatched()` on the implicit `RequestQueue` for this crawler instance.
1009
1060
  *
1010
1061
  * @param requests The requests to add
@@ -1012,55 +1063,95 @@ export class BasicCrawler {
1012
1063
  */
1013
1064
  async addRequests(requests, options = {}) {
1014
1065
  await this.getRequestManager();
1015
- const requestLimit = await this.calculateEnqueuedRequestLimit();
1016
- const skippedBecauseOfRobots = new Set();
1017
- const skippedBecauseOfMaxCrawlDepth = new Set();
1018
- const isAllowedBasedOnRobotsTxtFile = this.isAllowedBasedOnRobotsTxtFile.bind(this);
1019
- const maxCrawlDepth = this.maxCrawlDepth;
1020
- const validateRequestUserData = this.validateRequestUserData.bind(this);
1021
1066
  if (!isIterable(requests) && !isAsyncIterable(requests)) {
1022
1067
  throw new Error(`Expected an iterable or async iterable, got ${getObjectType(requests)}`);
1023
1068
  }
1069
+ parseArgument(options, addRequestsOptionsSchema, 'EnqueueUrlsOptions');
1070
+ // `label`/`userData` apply to every request this call produces, so a single upfront validation
1071
+ // against the label's schema covers them all and fails the whole call fast, rather than failing
1072
+ // lazily once the generator below is drained. Skipped when neither is set - each item still gets
1073
+ // its own per-item validation below, and validating an absent label/userData here would spuriously
1074
+ // check them against a registered default-route schema.
1075
+ if (options.label !== undefined || options.userData !== undefined) {
1076
+ await this.validateRequestUserData({ label: options.label, userData: options.userData });
1077
+ }
1078
+ const requestLimit = await this.calculateEnqueuedRequestLimit(options.limit);
1079
+ const strategy = options.strategy ?? EnqueueStrategy.All;
1080
+ const urlExcludePatternObjects = options.exclude?.length
1081
+ ? constructUrlPatternObjects(options.exclude)
1082
+ : [];
1083
+ const urlPatternObjects = options.include?.length
1084
+ ? constructUrlPatternObjects(options.include)
1085
+ : [];
1086
+ // The strategy always applies, even when `include` patterns are provided - the two are AND-ed together
1087
+ // (a URL must match an `include` pattern *and* satisfy the strategy). This mirrors crawlee-python.
1088
+ const enqueueStrategyPatterns = options.baseUrl
1089
+ ? buildEnqueueStrategyPatterns(options.baseUrl, strategy)
1090
+ : [];
1091
+ const isAllowedBasedOnRobotsTxtFile = this.isAllowedBasedOnRobotsTxtFile.bind(this);
1092
+ const maxCrawlDepth = this.maxCrawlDepth;
1093
+ const validateRequestUserData = this.validateRequestUserData.bind(this);
1094
+ const allSkipped = [];
1024
1095
  async function* filteredRequests() {
1025
1096
  for await (const request of requests) {
1026
- const url = typeof request === 'string' ? request : request.url;
1027
- if (maxCrawlDepth !== undefined && request.crawlDepth > maxCrawlDepth) {
1028
- skippedBecauseOfMaxCrawlDepth.add(url);
1097
+ const [requestOptions] = createRequestOptions([typeof request === 'string' ? request : request], { ...options, strategy });
1098
+ if (!requestOptions) {
1099
+ continue; // invalid URL, silently dropped (matches `createRequestOptions`'s own filtering)
1100
+ }
1101
+ if (maxCrawlDepth !== undefined && requestOptions.crawlDepth > maxCrawlDepth) {
1102
+ allSkipped.push({ url: requestOptions.url, reason: 'depth' });
1029
1103
  continue;
1030
1104
  }
1031
- if (await isAllowedBasedOnRobotsTxtFile(url)) {
1032
- await validateRequestUserData(request);
1033
- yield request;
1105
+ if (!(await isAllowedBasedOnRobotsTxtFile(requestOptions.url))) {
1106
+ allSkipped.push({ url: requestOptions.url, reason: 'robotsTxt' });
1107
+ continue;
1034
1108
  }
1035
- else {
1036
- skippedBecauseOfRobots.add(url);
1109
+ const onSkippedFilterUrl = (url) => allSkipped.push({ url, reason: 'filters' });
1110
+ // Filter by user patterns first (with exclude)...
1111
+ let filtered = filterRequestOptionsByPatterns([requestOptions], urlPatternObjects.length > 0 ? urlPatternObjects : undefined, urlExcludePatternObjects, strategy, onSkippedFilterUrl);
1112
+ // ...then filter by the enqueue strategy (making this an AND check)
1113
+ filtered = filterRequestOptionsByPatterns(filtered, enqueueStrategyPatterns.length > 0 ? enqueueStrategyPatterns : undefined, [], strategy, onSkippedFilterUrl);
1114
+ if (filtered.length === 0) {
1115
+ continue;
1037
1116
  }
1117
+ let [finalOptions] = filtered;
1118
+ if (options.transformRequestFunction) {
1119
+ const transformed = applyRequestTransform([finalOptions], options.transformRequestFunction, (r) => allSkipped.push({ url: r.url, reason: r.skippedReason ?? 'transform' }));
1120
+ if (transformed.length === 0) {
1121
+ continue;
1122
+ }
1123
+ [finalOptions] = transformed;
1124
+ }
1125
+ await validateRequestUserData(finalOptions);
1126
+ yield new Request(finalOptions);
1038
1127
  }
1039
1128
  }
1040
1129
  const result = await this.requestManager.addRequestsBatched(filteredRequests(), {
1041
- ...options,
1130
+ forefront: options.forefront,
1131
+ waitForAllRequestsToBeAdded: options.waitForAllRequestsToBeAdded,
1132
+ batchSize: options.batchSize,
1133
+ waitBetweenBatchesMillis: options.waitBetweenBatchesMillis,
1042
1134
  maxNewRequests: requestLimit,
1043
1135
  });
1044
- // Report requests skipped due to the maxNewRequests budget (i.e. maxRequestsPerCrawl limit)
1045
- const skippedBecauseOfLimit = result.requestsOverLimit ?? [];
1046
- if (skippedBecauseOfRobots.size > 0) {
1047
- this.log.warning(`Some requests were skipped because they were disallowed based on the robots.txt file`, {
1048
- skipped: [...skippedBecauseOfRobots],
1049
- });
1050
- }
1051
- if (skippedBecauseOfRobots.size > 0 ||
1052
- skippedBecauseOfLimit.length > 0 ||
1053
- skippedBecauseOfMaxCrawlDepth.size > 0) {
1054
- await Promise.all([...skippedBecauseOfRobots]
1055
- .map((url) => {
1056
- return this.handleSkippedRequest({ url, reason: 'robotsTxt' });
1057
- })
1058
- .concat(skippedBecauseOfLimit.map((request) => {
1059
- const url = typeof request === 'string' ? request : request.url;
1060
- return this.handleSkippedRequest({ url, reason: 'limit' });
1061
- }), [...skippedBecauseOfMaxCrawlDepth].map((url) => {
1062
- return this.handleSkippedRequest({ url, reason: 'depth' });
1063
- })));
1136
+ // Report requests skipped due to the maxNewRequests budget (i.e. maxRequestsPerCrawl limit, or an
1137
+ // explicit `limit` option)
1138
+ for (const request of result.requestsOverLimit ?? []) {
1139
+ allSkipped.push({ url: typeof request === 'string' ? request : request.url, reason: 'limit' });
1140
+ }
1141
+ if (allSkipped.length > 0) {
1142
+ const skippedRobotsUrls = allSkipped.filter((s) => s.reason === 'robotsTxt').map((s) => s.url);
1143
+ if (skippedRobotsUrls.length > 0) {
1144
+ this.log.warning(`Some requests were skipped because they were disallowed based on the robots.txt file`, { skipped: skippedRobotsUrls });
1145
+ }
1146
+ // Only log the limit message when an explicit `limit` was passed (not the internal
1147
+ // `maxRequestsPerCrawl`-derived one), and only once per call.
1148
+ if (options.limit !== undefined && allSkipped.some((s) => s.reason === 'limit')) {
1149
+ this.log.info(`Skipping requests in this call due to the enqueueLinks limit of ${options.limit}.`);
1150
+ }
1151
+ await Promise.all(allSkipped.map(async ({ url, reason }) => {
1152
+ await this.handleSkippedRequest({ url, reason });
1153
+ await options.onSkippedRequest?.({ url, reason });
1154
+ }));
1064
1155
  }
1065
1156
  return result;
1066
1157
  }
@@ -1363,30 +1454,6 @@ export class BasicCrawler {
1363
1454
  }
1364
1455
  return this.requestManager.fetchNextRequest();
1365
1456
  }
1366
- /**
1367
- * Delays processing of the request based on the `sameDomainDelaySecs` option,
1368
- * adding it back to the queue after the timeout passes. Returns `true` if the request
1369
- * should be ignored and will be reclaimed to the queue once ready.
1370
- */
1371
- delayRequest(request, source) {
1372
- const domain = getDomain(request.url);
1373
- if (!domain || !request) {
1374
- return false;
1375
- }
1376
- const now = Date.now();
1377
- const lastAccessTime = this.#domainAccessedTime.get(domain);
1378
- if (!lastAccessTime || now - lastAccessTime >= this.#sameDomainDelayMillis) {
1379
- this.#domainAccessedTime.set(domain, now);
1380
- return false;
1381
- }
1382
- const delay = lastAccessTime + this.#sameDomainDelayMillis - now;
1383
- this.log.debug(`Request ${request.url} (${request.id}) will be reclaimed after ${delay} milliseconds due to same domain delay`);
1384
- setTimeout(async () => {
1385
- this.log.debug(`Adding request ${request.url} (${request.id}) back to the queue`);
1386
- await source.reclaimRequest(request, { forefront: request.userData?.__crawlee?.forefront });
1387
- }, delay);
1388
- return true;
1389
- }
1390
1457
  /** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
1391
1458
  async handleRequest(crawlingContext, requestSource, request) {
1392
1459
  // An earlier phase we cannot cancel (e.g. a slow `extendContext`) may have run past the internal timeout,
@@ -1459,52 +1526,6 @@ export class BasicCrawler {
1459
1526
  }
1460
1527
  }
1461
1528
  }
1462
- /**
1463
- * Wrapper around the crawling context's `enqueueLinks` method:
1464
- * - Injects `crawlDepth` to each request being added based on the crawling context request.
1465
- * - Provides defaults for the `enqueueLinks` options based on the crawler configuration.
1466
- * - These options can be overridden by the user.
1467
- * @internal
1468
- */
1469
- async enqueueLinksWithCrawlDepth(options, request, requestManager) {
1470
- const transformRequestFunctionWrapper = (requestOptions) => {
1471
- requestOptions.crawlDepth = request.crawlDepth + 1;
1472
- if (this.maxCrawlDepth !== undefined && requestOptions.crawlDepth > this.maxCrawlDepth) {
1473
- // Setting `skippedReason` before returning `false` ensures that `reportSkippedRequests`
1474
- // reports `'depth'` as the reason (via `request.skippedReason ?? reason` fallback),
1475
- // rather than the generic `'transform'` reason.
1476
- requestOptions.skippedReason = 'depth';
1477
- return false;
1478
- }
1479
- // After injecting the crawlDepth, we call the user-provided transform function, if there is one.
1480
- return options.transformRequestFunction?.(requestOptions) ?? requestOptions;
1481
- };
1482
- // Create a request-scoped callback that logs enqueueLimit once per request handler call
1483
- // Only log if an explicit limit was passed to enqueueLinks (not the internal maxRequestsPerCrawl-derived limit)
1484
- let loggedEnqueueLimitForThisRequest = false;
1485
- const onSkippedRequest = async (skippedOptions) => {
1486
- if (skippedOptions.reason === 'enqueueLimit') {
1487
- if (!loggedEnqueueLimitForThisRequest && options.limit !== undefined) {
1488
- this.log.info(`Skipping URLs in the handler for ${request.url} due to the enqueueLinks limit of ${options.limit}.`);
1489
- loggedEnqueueLimitForThisRequest = true;
1490
- }
1491
- }
1492
- await this.handleSkippedRequest(skippedOptions);
1493
- };
1494
- // `enqueueLinks` applies `options.label`/`options.userData` to every newly enqueued request, so a single
1495
- // validation against the label's schema covers them all (a no-op unless the router declares a schema).
1496
- await this.validateRequestUserData({ label: options.label, userData: options.userData });
1497
- return await enqueueLinks({
1498
- requestManager,
1499
- robotsTxtFile: await this.getRobotsTxtFileForUrl(request.url),
1500
- respectRobotsTxtFile: this.#respectRobotsTxtFile,
1501
- onSkippedRequest,
1502
- limit: await this.calculateEnqueuedRequestLimit(options.limit),
1503
- // Allow user options to override defaults set above ⤴
1504
- ...options,
1505
- transformRequestFunction: transformRequestFunctionWrapper,
1506
- });
1507
- }
1508
1529
  /**
1509
1530
  * Generator function that yields requests injected with the given crawl depth.
1510
1531
  * @internal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.122",
3
+ "version": "4.0.0-beta.124",
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.122",
46
- "@crawlee/http-client": "4.0.0-beta.122",
47
- "@crawlee/types": "4.0.0-beta.122",
48
- "@crawlee/utils": "4.0.0-beta.122",
45
+ "@crawlee/core": "4.0.0-beta.124",
46
+ "@crawlee/http-client": "4.0.0-beta.124",
47
+ "@crawlee/types": "4.0.0-beta.124",
48
+ "@crawlee/utils": "4.0.0-beta.124",
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.122"
56
+ "@crawlee/impit-client": "^4.0.0-beta.124"
57
57
  },
58
58
  "lerna": {
59
59
  "command": {
@@ -62,5 +62,5 @@
62
62
  }
63
63
  }
64
64
  },
65
- "gitHead": "2c3e87fefdb9e1fca4c144f03167d8524cfdc2e5"
65
+ "gitHead": "0694ee1b94c755b98141671baa93cc363f2bf8e3"
66
66
  }