@crawlee/basic 4.0.0-beta.110 → 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.
package/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export * from '@crawlee/core';
2
2
  export * from './internals/basic-crawler.js';
3
- export type { CheerioRoot, CheerioAPI, Cheerio, Element } from '@crawlee/utils';
3
+ export type { CheerioRoot, CheerioAPI, Cheerio, Element } from '@crawlee/utils/internal';
@@ -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,8 +1,9 @@
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
- import { isAsyncIterable, isIterable, RobotsTxtFile, ROTATE_PROXY_ERRORS } from '@crawlee/utils';
5
+ import { isAsyncIterable, isIterable, ROTATE_PROXY_ERRORS } from '@crawlee/utils/internal';
6
+ import { RobotsTxtFile } from '@crawlee/utils';
6
7
  import ow, { ArgumentError } from 'ow';
7
8
  import { getDomain } from 'tldts';
8
9
  import { LruCache } from '@apify/datastructures';
@@ -203,6 +204,7 @@ export class BasicCrawler {
203
204
  extendContext: ow.optional.function,
204
205
  requestList: ow.optional.object.validate(validators.requestList),
205
206
  requestQueue: ow.optional.object.validate(validators.requestQueue),
207
+ requestManager: ow.optional.object,
206
208
  // Subclasses override this function instead of passing it
207
209
  // in constructor, so this validation needs to apply only
208
210
  // if the user creates an instance of BasicCrawler directly.
@@ -418,7 +420,7 @@ export class BasicCrawler {
418
420
  await this.requestFunctionErrorHandler(unwrappedError, crawlingContext, request, this.requestManager);
419
421
  // SessionError already retired the session in `requestFunctionErrorHandler`;
420
422
  // skip `markBad` to avoid double-counting usage/error score.
421
- if (!(unwrappedError instanceof SessionError)) {
423
+ if (!this.errorAbsolvesSession(unwrappedError)) {
422
424
  crawlingContext.session?.markBad();
423
425
  }
424
426
  return;
@@ -455,6 +457,11 @@ export class BasicCrawler {
455
457
  this.log.info('The crawler has finished all the remaining ongoing requests and will shut down now.');
456
458
  return true;
457
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
+ }
458
465
  const isFinished = isFinishedFunction
459
466
  ? await isFinishedFunction()
460
467
  : await this.defaultIsFinishedFunction();
@@ -973,9 +980,9 @@ export class BasicCrawler {
973
980
  await this.onSkippedRequest?.(options);
974
981
  });
975
982
  }
976
- logOncePerRun(key, message) {
983
+ logOncePerRun(key, message, level = 'info') {
977
984
  if (!this.#loggedPerRun.has(key)) {
978
- this.log.info(message);
985
+ this.log[level](message);
979
986
  this.#loggedPerRun.add(key);
980
987
  }
981
988
  }
@@ -1239,8 +1246,48 @@ export class BasicCrawler {
1239
1246
  }
1240
1247
  const robotsTxtFile = await this.getRobotsTxtFileForUrl(url);
1241
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
+ }
1242
1255
  return !robotsTxtFile || robotsTxtFile.isAllowed(url, userAgent);
1243
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
+ }
1244
1291
  async getRobotsTxtFileForUrl(url) {
1245
1292
  if (!this.#respectRobotsTxtFile) {
1246
1293
  return undefined;
@@ -1381,7 +1428,7 @@ export class BasicCrawler {
1381
1428
  }
1382
1429
  // decrease the session score if the request fails (but the error handler did not throw);
1383
1430
  // skip when the error is a SessionError, which already retired the session
1384
- if (!(err instanceof SessionError)) {
1431
+ if (!this.errorAbsolvesSession(err)) {
1385
1432
  crawlingContext.session.markBad();
1386
1433
  }
1387
1434
  }
@@ -1507,6 +1554,16 @@ export class BasicCrawler {
1507
1554
  * @param request The request object, passed separately to circumvent potential dynamic logic in crawlingContext.request
1508
1555
  */
1509
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
+ }
1510
1567
  request.pushErrorMessage(error);
1511
1568
  if (error instanceof CriticalError) {
1512
1569
  throw error;
@@ -1583,6 +1640,13 @@ export class BasicCrawler {
1583
1640
  ? (error.stack ?? [error.message || error, ...stackLines].join('\n'))
1584
1641
  : [error.message || error, userLine].join('\n');
1585
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
+ }
1586
1650
  canRequestBeRetried(request, error) {
1587
1651
  // Request should never be retried, or the error encountered makes it not able to be retried.
1588
1652
  if (request.noRetry || error instanceof NonRetryableError) {
@@ -1668,6 +1732,10 @@ export class BasicCrawler {
1668
1732
  }
1669
1733
  }
1670
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
+ }
1671
1739
  export function createBasicRouter(routes) {
1672
1740
  return Router.create(routes);
1673
1741
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.110",
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.110",
46
- "@crawlee/http-client": "4.0.0-beta.110",
47
- "@crawlee/types": "4.0.0-beta.110",
48
- "@crawlee/utils": "4.0.0-beta.110",
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.110"
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": "71f42c48f431ca46b04491eca65b191691e75033"
65
+ "gitHead": "5d1e107d4dc4ffd21f1c6cc5b1a1c10b59ca2b47"
66
66
  }