@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 +1 -1
- package/internals/basic-crawler.d.ts +26 -0
- package/internals/basic-crawler.js +74 -6
- package/package.json +7 -7
package/index.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
46
|
-
"@crawlee/http-client": "4.0.0-beta.
|
|
47
|
-
"@crawlee/types": "4.0.0-beta.
|
|
48
|
-
"@crawlee/utils": "4.0.0-beta.
|
|
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.
|
|
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": "
|
|
65
|
+
"gitHead": "5d1e107d4dc4ffd21f1c6cc5b1a1c10b59ca2b47"
|
|
66
66
|
}
|