@crawlee/basic 4.0.0-beta.111 → 4.0.0-beta.112

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.
@@ -237,6 +237,10 @@ export interface BasicCrawlerOptions<Context extends CrawlingContext = CrawlingC
237
237
  statusMessageCallback?: StatusMessageCallback;
238
238
  /**
239
239
  * HTTP status codes that indicate the session should be retired.
240
+ *
241
+ * A 429 from a domain covered by a {@link ThrottlingRequestManager} is handled as a rate limit before
242
+ * this is consulted, so removing 429 here only affects domains that manager does not cover.
243
+ *
240
244
  * @default [401, 403, 429]
241
245
  */
242
246
  blockedStatusCodes?: number[];
@@ -518,6 +522,8 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
518
522
  requestList: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
519
523
  // @ts-ignore optional peer dependency or compatibility with es2022
520
524
  requestQueue: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
525
+ // @ts-ignore optional peer dependency or compatibility with es2022
526
+ requestManager: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
521
527
  // @ts-ignore optional peer dependency or compatibility with es2022
522
528
  requestHandler: import("ow").Predicate<Function> & BasePredicate<Function | undefined>;
523
529
  // @ts-ignore optional peer dependency or compatibility with es2022
@@ -778,6 +784,21 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
778
784
  */
779
785
  protected throwOnBlockedRequest(statusCode: number): void;
780
786
  private isAllowedBasedOnRobotsTxtFile;
787
+ /**
788
+ * Records an HTTP 429 against the URL's domain so the request manager can pace the retry.
789
+ *
790
+ * @param retryAfterHeader The raw `Retry-After` response header, if the server sent one.
791
+ * @returns `true` if a manager took responsibility for the delay, in which case the caller should throw
792
+ * {@link RequestThrottledError} rather than treating the response as a blocked session.
793
+ */
794
+ protected recordDomainRateLimit(url: string, retryAfterHeader?: string | null): boolean;
795
+ /**
796
+ * Hands a robots.txt `Crawl-delay` to the request manager, warning if nothing is able to honour it.
797
+ *
798
+ * The warning is driven by whether the delay was actually accepted rather than by the type of the manager,
799
+ * because a manager that does throttle still drops the delay for a domain missing from its `domains` list.
800
+ */
801
+ private applyCrawlDelay;
781
802
  protected getRobotsTxtFileForUrl(url: string): Promise<RobotsTxtFile | undefined>;
782
803
  private pauseOnMigration;
783
804
  /**
@@ -836,6 +857,11 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
836
857
  * @returns The message to be logged
837
858
  */
838
859
  protected getMessageFromError(error: Error, forceStack?: boolean): string | TimeoutError | undefined;
860
+ /**
861
+ * Whether the session should be spared for this error - either because it was already retired, or because the
862
+ * failure says nothing about the session (a rate limit is a property of the domain).
863
+ */
864
+ private errorAbsolvesSession;
839
865
  private canRequestBeRetried;
840
866
  /**
841
867
  * Stops the crawler immediately.
@@ -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, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, createStorageTransaction, CriticalError, currentStorageTransaction, Dataset, enqueueLinks, EnqueueStrategy, getObjectType, KeyValueStore, log, LogLevel, mergeCookies, MissingSessionError, NavigationSkippedError, NonRetryableError, OwnedOrInjected, purgeDefaultStorages, RequestHandlerError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, Router, ServiceLocator, serviceLocator, Session, SessionError, SessionPool, Statistics, validateUserData, validators, withDirectStorageAccess, } from '@crawlee/core';
3
+ import { AutoscaledPool, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, ConcurrencySystem, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, createStorageTransaction, CriticalError, currentStorageTransaction, Dataset, enqueueLinks, EnqueueStrategy, 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, validateUserData, validators, withDirectStorageAccess, } from '@crawlee/core';
4
4
  import { FetchHttpClient } from '@crawlee/http-client';
5
5
  import { isAsyncIterable, isIterable, ROTATE_PROXY_ERRORS } from '@crawlee/utils/internal';
6
6
  import { RobotsTxtFile } from '@crawlee/utils';
@@ -204,6 +204,7 @@ export class BasicCrawler {
204
204
  extendContext: ow.optional.function,
205
205
  requestList: ow.optional.object.validate(validators.requestList),
206
206
  requestQueue: ow.optional.object.validate(validators.requestQueue),
207
+ requestManager: ow.optional.object,
207
208
  // Subclasses override this function instead of passing it
208
209
  // in constructor, so this validation needs to apply only
209
210
  // if the user creates an instance of BasicCrawler directly.
@@ -419,7 +420,7 @@ export class BasicCrawler {
419
420
  await this.requestFunctionErrorHandler(unwrappedError, crawlingContext, request, this.requestManager);
420
421
  // SessionError already retired the session in `requestFunctionErrorHandler`;
421
422
  // skip `markBad` to avoid double-counting usage/error score.
422
- if (!(unwrappedError instanceof SessionError)) {
423
+ if (!this.errorAbsolvesSession(unwrappedError)) {
423
424
  crawlingContext.session?.markBad();
424
425
  }
425
426
  return;
@@ -456,6 +457,11 @@ export class BasicCrawler {
456
457
  this.log.info('The crawler has finished all the remaining ongoing requests and will shut down now.');
457
458
  return true;
458
459
  }
460
+ // Checked here because this runs only once nothing is in flight, which is exactly when a
461
+ // crawl that cannot progress looks indistinguishable from one that is merely waiting.
462
+ if (!keepAlive && supportsDomainThrottling(this.requestManager)) {
463
+ await this.requestManager.assertNoStalledDomains();
464
+ }
459
465
  const isFinished = isFinishedFunction
460
466
  ? await isFinishedFunction()
461
467
  : await this.defaultIsFinishedFunction();
@@ -974,9 +980,9 @@ export class BasicCrawler {
974
980
  await this.onSkippedRequest?.(options);
975
981
  });
976
982
  }
977
- logOncePerRun(key, message) {
983
+ logOncePerRun(key, message, level = 'info') {
978
984
  if (!this.#loggedPerRun.has(key)) {
979
- this.log.info(message);
985
+ this.log[level](message);
980
986
  this.#loggedPerRun.add(key);
981
987
  }
982
988
  }
@@ -1240,8 +1246,48 @@ export class BasicCrawler {
1240
1246
  }
1241
1247
  const robotsTxtFile = await this.getRobotsTxtFileForUrl(url);
1242
1248
  const userAgent = typeof this.#respectRobotsTxtFile === 'object' ? this.#respectRobotsTxtFile?.userAgent : '*';
1249
+ if (robotsTxtFile) {
1250
+ const crawlDelay = robotsTxtFile.getCrawlDelay(userAgent);
1251
+ if (crawlDelay !== undefined) {
1252
+ this.applyCrawlDelay(url, crawlDelay);
1253
+ }
1254
+ }
1243
1255
  return !robotsTxtFile || robotsTxtFile.isAllowed(url, userAgent);
1244
1256
  }
1257
+ /**
1258
+ * Records an HTTP 429 against the URL's domain so the request manager can pace the retry.
1259
+ *
1260
+ * @param retryAfterHeader The raw `Retry-After` response header, if the server sent one.
1261
+ * @returns `true` if a manager took responsibility for the delay, in which case the caller should throw
1262
+ * {@link RequestThrottledError} rather than treating the response as a blocked session.
1263
+ */
1264
+ recordDomainRateLimit(url, retryAfterHeader) {
1265
+ if (supportsDomainThrottling(this.requestManager) &&
1266
+ this.requestManager.recordDomainDelay(url, parseRetryAfterHeader(retryAfterHeader))) {
1267
+ return true;
1268
+ }
1269
+ const domain = hostnameOrUrl(url);
1270
+ this.logOncePerRun(`rateLimitNotThrottled:${domain}`, `"${domain}" responded with HTTP 429 (Too Many Requests), but nothing is set up to back off from it, ` +
1271
+ 'so the response is handled like any other, with no per-domain delay. ' +
1272
+ `Pass a \`ThrottlingRequestManager\` as \`requestManager\` and include "${domain}" in its \`domains\` ` +
1273
+ 'option to honour `Retry-After` and apply exponential backoff instead.', 'warning');
1274
+ return false;
1275
+ }
1276
+ /**
1277
+ * Hands a robots.txt `Crawl-delay` to the request manager, warning if nothing is able to honour it.
1278
+ *
1279
+ * The warning is driven by whether the delay was actually accepted rather than by the type of the manager,
1280
+ * because a manager that does throttle still drops the delay for a domain missing from its `domains` list.
1281
+ */
1282
+ applyCrawlDelay(url, delaySeconds) {
1283
+ if (supportsDomainThrottling(this.requestManager) && this.requestManager.setCrawlDelay(url, delaySeconds)) {
1284
+ return;
1285
+ }
1286
+ const domain = hostnameOrUrl(url);
1287
+ this.logOncePerRun(`crawlDelayIgnored:${domain}`, `robots.txt for "${domain}" defines a crawl-delay of ${delaySeconds}s, but nothing is set up to honour it, ` +
1288
+ 'so requests to that domain will not be paced. Pass a `ThrottlingRequestManager` as `requestManager` ' +
1289
+ `and include "${domain}" in its \`domains\` option to enforce the delay.`, 'warning');
1290
+ }
1245
1291
  async getRobotsTxtFileForUrl(url) {
1246
1292
  if (!this.#respectRobotsTxtFile) {
1247
1293
  return undefined;
@@ -1382,7 +1428,7 @@ export class BasicCrawler {
1382
1428
  }
1383
1429
  // decrease the session score if the request fails (but the error handler did not throw);
1384
1430
  // skip when the error is a SessionError, which already retired the session
1385
- if (!(err instanceof SessionError)) {
1431
+ if (!this.errorAbsolvesSession(err)) {
1386
1432
  crawlingContext.session.markBad();
1387
1433
  }
1388
1434
  }
@@ -1508,6 +1554,16 @@ export class BasicCrawler {
1508
1554
  * @param request The request object, passed separately to circumvent potential dynamic logic in crawlingContext.request
1509
1555
  */
1510
1556
  async requestFunctionErrorHandler(error, crawlingContext, request, source) {
1557
+ if (error instanceof RequestThrottledError) {
1558
+ // The domain told us to come back later, so the request was never really attempted. Put it back
1559
+ // without recording a failure - it costs neither a retry nor session reputation.
1560
+ this.log.debug(`Deferring request because its domain is rate-limiting us. ${error.message}`, {
1561
+ id: request.id,
1562
+ url: request.url,
1563
+ });
1564
+ await source.reclaimRequest(request, { forefront: request.userData?.__crawlee?.forefront });
1565
+ return;
1566
+ }
1511
1567
  request.pushErrorMessage(error);
1512
1568
  if (error instanceof CriticalError) {
1513
1569
  throw error;
@@ -1584,6 +1640,13 @@ export class BasicCrawler {
1584
1640
  ? (error.stack ?? [error.message || error, ...stackLines].join('\n'))
1585
1641
  : [error.message || error, userLine].join('\n');
1586
1642
  }
1643
+ /**
1644
+ * Whether the session should be spared for this error - either because it was already retired, or because the
1645
+ * failure says nothing about the session (a rate limit is a property of the domain).
1646
+ */
1647
+ errorAbsolvesSession(error) {
1648
+ return error instanceof SessionError || error instanceof RequestThrottledError;
1649
+ }
1587
1650
  canRequestBeRetried(request, error) {
1588
1651
  // Request should never be retried, or the error encountered makes it not able to be retried.
1589
1652
  if (request.noRetry || error instanceof NonRetryableError) {
@@ -1669,6 +1732,10 @@ export class BasicCrawler {
1669
1732
  }
1670
1733
  }
1671
1734
  }
1735
+ /** The hostname of `url`, falling back to the whole string when it is not parseable - for log messages only. */
1736
+ function hostnameOrUrl(url) {
1737
+ return URL.canParse(url) ? new URL(url).hostname : url;
1738
+ }
1672
1739
  export function createBasicRouter(routes) {
1673
1740
  return Router.create(routes);
1674
1741
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.111",
3
+ "version": "4.0.0-beta.112",
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.111",
46
- "@crawlee/http-client": "4.0.0-beta.111",
47
- "@crawlee/types": "4.0.0-beta.111",
48
- "@crawlee/utils": "4.0.0-beta.111",
45
+ "@crawlee/core": "4.0.0-beta.112",
46
+ "@crawlee/http-client": "4.0.0-beta.112",
47
+ "@crawlee/types": "4.0.0-beta.112",
48
+ "@crawlee/utils": "4.0.0-beta.112",
49
49
  "csv-stringify": "^6.5.2",
50
50
  "ow": "^2.0.0",
51
51
  "tldts": "^7.0.6",
@@ -53,7 +53,7 @@
53
53
  "type-fest": "^4.41.0"
54
54
  },
55
55
  "optionalDependencies": {
56
- "@crawlee/impit-client": "^4.0.0-beta.111"
56
+ "@crawlee/impit-client": "^4.0.0-beta.112"
57
57
  },
58
58
  "lerna": {
59
59
  "command": {
@@ -62,5 +62,5 @@
62
62
  }
63
63
  }
64
64
  },
65
- "gitHead": "a6b7dec651fa7d81c61689e0a3cd0a8d06fdb586"
65
+ "gitHead": "5d1e107d4dc4ffd21f1c6cc5b1a1c10b59ca2b47"
66
66
  }