@crawlee/basic 4.0.0-beta.111 → 4.0.0-beta.113
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "4.0.0-beta.113",
|
|
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.113",
|
|
46
|
+
"@crawlee/http-client": "4.0.0-beta.113",
|
|
47
|
+
"@crawlee/types": "4.0.0-beta.113",
|
|
48
|
+
"@crawlee/utils": "4.0.0-beta.113",
|
|
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.113"
|
|
57
57
|
},
|
|
58
58
|
"lerna": {
|
|
59
59
|
"command": {
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
|
-
"gitHead": "
|
|
65
|
+
"gitHead": "a80d4b05a84ecc651dcd68fafd7a1ff35e6e7532"
|
|
66
66
|
}
|