@crawlee/basic 4.0.0-beta.145 → 4.0.0-beta.147
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/internals/basic-crawler.d.ts +9 -17
- package/internals/basic-crawler.js +138 -92
- package/package.json +7 -7
|
@@ -140,8 +140,9 @@ export interface BasicCrawlerOptions<Context extends CrawlingContext = CrawlingC
|
|
|
140
140
|
* Indicates how much time (in seconds) to wait before crawling another same domain request. Subdomains are
|
|
141
141
|
* paced together with the site they belong to.
|
|
142
142
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
143
|
+
* Offered to the crawler's request manager as a `minIntervalEverywhere` {@link PacingSignal}; a manager that
|
|
144
|
+
* already paces every domain it dispatches to takes it, so no domain ends up with two clocks. Otherwise the
|
|
145
|
+
* crawler wraps its request manager in a {@link ThrottlingRequestManager} of its own.
|
|
145
146
|
* @default 0
|
|
146
147
|
*/
|
|
147
148
|
sameDomainDelaySecs?: number;
|
|
@@ -589,8 +590,7 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
|
|
|
589
590
|
*/
|
|
590
591
|
getRequestQueue(): Promise<IRequestManager>;
|
|
591
592
|
/**
|
|
592
|
-
* Opens the default {@link RequestQueue}
|
|
593
|
-
* crawler-owned queue (so it gets purged between repeated `run()` calls).
|
|
593
|
+
* Opens the default {@link RequestQueue} — the crawler's own, read from when the caller supplied nothing.
|
|
594
594
|
* @private
|
|
595
595
|
*/
|
|
596
596
|
private openOwnedRequestQueue;
|
|
@@ -689,19 +689,14 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
|
|
|
689
689
|
protected throwOnBlockedRequest(statusCode: number): void;
|
|
690
690
|
private isAllowedBasedOnRobotsTxtFile;
|
|
691
691
|
/**
|
|
692
|
-
* Records an HTTP 429 against the URL's domain so the request manager can
|
|
692
|
+
* Records an HTTP 429 against the URL's domain so the request manager can hold the retry back.
|
|
693
693
|
*
|
|
694
694
|
* @param retryAfterHeader The raw `Retry-After` response header, if the server sent one.
|
|
695
|
-
* @returns `true` if
|
|
695
|
+
* @returns `true` if the manager took responsibility for the delay, in which case the caller should throw
|
|
696
696
|
* {@link RequestThrottledError} rather than treating the response as a blocked session.
|
|
697
697
|
*/
|
|
698
698
|
protected recordDomainRateLimit(url: string, retryAfterHeader?: string | null): boolean;
|
|
699
|
-
/**
|
|
700
|
-
* Hands a robots.txt `Crawl-delay` to the request manager, warning if nothing is able to honour it.
|
|
701
|
-
*
|
|
702
|
-
* The warning is driven by whether the delay was actually accepted rather than by the type of the manager,
|
|
703
|
-
* because a manager that does throttle still drops the delay for a domain missing from its `domains` list.
|
|
704
|
-
*/
|
|
699
|
+
/** Hands a robots.txt `Crawl-delay` to the request manager, warning if it will not be honoured. */
|
|
705
700
|
private applyCrawlDelay;
|
|
706
701
|
protected getRobotsTxtFileForUrl(url: string): Promise<RobotsTxtFile | undefined>;
|
|
707
702
|
private pauseOnMigration;
|
|
@@ -722,13 +717,10 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
|
|
|
722
717
|
*/
|
|
723
718
|
private timeoutAndRetry;
|
|
724
719
|
/**
|
|
725
|
-
*
|
|
720
|
+
* Whether the request manager has a request ready for processing. A manager that is only `waiting` also gets a
|
|
721
|
+
* wake-up scheduled, so a paced crawl resumes on its clock rather than on the task loop's polling interval.
|
|
726
722
|
*/
|
|
727
723
|
private isTaskReadyFunction;
|
|
728
|
-
/**
|
|
729
|
-
* Returns true if both RequestList and RequestQueue have all requests finished.
|
|
730
|
-
*/
|
|
731
|
-
private defaultIsFinishedFunction;
|
|
732
724
|
/**
|
|
733
725
|
* Unwraps errors thrown by the context pipeline to get the actual user error.
|
|
734
726
|
* RequestHandlerError and ContextPipelineInitializationError wrap the actual error.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
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, purgeDefaultStorages, RequestHandlerError, parseRetryAfterHeader, RequestThrottledError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError,
|
|
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, PersistentRateLimitError, purgeDefaultStorages, RequestHandlerError, parseRetryAfterHeader, RequestThrottledError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, Router, 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, parseArgument, ROTATE_PROXY_ERRORS, schemas } from '@crawlee/utils/internal';
|
|
6
6
|
import { RobotsTxtFile } from '@crawlee/utils';
|
|
@@ -108,12 +108,6 @@ export class BasicCrawler {
|
|
|
108
108
|
get sessionPool() {
|
|
109
109
|
return this.#sessionPoolDep.value;
|
|
110
110
|
}
|
|
111
|
-
/**
|
|
112
|
-
* Tracks **only** the queue the crawler opens for itself — not the {@link RequestManagerTandem} that may wrap it
|
|
113
|
-
* around a user-supplied `requestList` — so the owned-only purge between repeated `run()` calls never reaches
|
|
114
|
-
* through to a borrowed loader. Filled lazily in {@link BasicCrawler.openOwnedRequestQueue|`openOwnedRequestQueue()`}.
|
|
115
|
-
*/
|
|
116
|
-
#ownedRequestQueue = OwnedOrInjected.resolve();
|
|
117
111
|
/**
|
|
118
112
|
* Whether the request-processing-time hint has already been forwarded to the request manager. The hint
|
|
119
113
|
* derives only from `requestHandlerTimeoutMillis` (constant for the crawler's lifetime) and is raise-only,
|
|
@@ -151,6 +145,10 @@ export class BasicCrawler {
|
|
|
151
145
|
* {@link BasicCrawler.concurrencySystem|`concurrencySystem`}.
|
|
152
146
|
*/
|
|
153
147
|
#autoscaledPool;
|
|
148
|
+
/** A pending nudge of the task loop, armed when the request manager announces when it will have work again. */
|
|
149
|
+
#taskLoopWakeTimer;
|
|
150
|
+
/** When the pending wake-up is due, so an earlier one can replace a later one. */
|
|
151
|
+
#taskLoopWakeAt = 0;
|
|
154
152
|
/**
|
|
155
153
|
* A reference to the underlying {@link IProxyConfiguration} instance that manages the crawler's proxies.
|
|
156
154
|
* Only available if used by the crawler.
|
|
@@ -197,7 +195,16 @@ export class BasicCrawler {
|
|
|
197
195
|
internalTimeoutMillis;
|
|
198
196
|
#maxRequestRetries;
|
|
199
197
|
#maxCrawlDepth;
|
|
200
|
-
|
|
198
|
+
/**
|
|
199
|
+
* How much of {@link BasicCrawler.requestManager} the crawler may empty between repeated `run()` calls.
|
|
200
|
+
*
|
|
201
|
+
* - `all` — nothing under it came from the caller, so one `purge()` on the outside covers everything.
|
|
202
|
+
* - `none` — the caller supplied it and the crawler put nothing of its own inside.
|
|
203
|
+
* - `ambiguous` — the caller supplied it, but `sameDomainDelaySecs` put the crawler's own per-domain queues
|
|
204
|
+
* underneath: purging empties the caller's storage too, skipping leaves ours stale. A repeated `run()` asks
|
|
205
|
+
* rather than guessing.
|
|
206
|
+
*/
|
|
207
|
+
#purgeableExtent;
|
|
201
208
|
#maxRequestsPerCrawl;
|
|
202
209
|
get handledRequestsCount() {
|
|
203
210
|
return this.statistics.state.requestsFinished + this.statistics.state.requestsFailed;
|
|
@@ -335,31 +342,49 @@ export class BasicCrawler {
|
|
|
335
342
|
serviceLocator.getConfiguration();
|
|
336
343
|
const instanceIndex = BasicCrawler.instanceCount++;
|
|
337
344
|
this.#identity = { instanceIndex, hasExplicitId: id !== undefined, id: id ?? String(instanceIndex) };
|
|
338
|
-
if (requestManager !== undefined) {
|
|
339
|
-
|
|
340
|
-
throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
|
|
341
|
-
}
|
|
342
|
-
// Both would pace the same domains, from different keys and with no idea of one another.
|
|
343
|
-
if (sameDomainDelaySecs > 0 && supportsDomainThrottling(requestManager)) {
|
|
344
|
-
throw new Error('The `sameDomainDelaySecs` option cannot be combined with a `requestManager` that throttles ' +
|
|
345
|
-
'per domain on its own. Configure the delay on the manager instead, via the ' +
|
|
346
|
-
'`minCrawlDelaySecs` option of `ThrottlingRequestManager`.');
|
|
347
|
-
}
|
|
348
|
-
this.requestManager = requestManager;
|
|
345
|
+
if (requestManager !== undefined && (requestList !== undefined || requestQueue !== undefined)) {
|
|
346
|
+
throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
|
|
349
347
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
348
|
+
const suppliedManager = requestManager ?? requestQueue;
|
|
349
|
+
// Offered before building a pacer of our own: anything that paces takes the floor - through any
|
|
350
|
+
// number of wrappers, since they all forward - so no domain ends up with two clocks.
|
|
351
|
+
const floorTaken = sameDomainDelaySecs > 0 &&
|
|
352
|
+
(suppliedManager?.recordPacingSignal({
|
|
353
|
+
reason: 'minIntervalEverywhere',
|
|
354
|
+
intervalMs: sameDomainDelaySecs * 1000,
|
|
355
|
+
// What `sameDomainDelaySecs` has always meant: one clock per site, subdomains included.
|
|
356
|
+
scope: 'registrableDomain',
|
|
357
|
+
}) ??
|
|
358
|
+
false);
|
|
359
|
+
const pacerNeeded = sameDomainDelaySecs > 0 && !floorTaken;
|
|
360
|
+
// Our per-domain queues under a manager the caller owns is the case with no right answer; a floor
|
|
361
|
+
// it took leaves nothing of ours behind. See `#purgeableExtent`.
|
|
362
|
+
if (suppliedManager === undefined) {
|
|
363
|
+
this.#purgeableExtent = 'all';
|
|
353
364
|
}
|
|
354
|
-
else
|
|
355
|
-
|
|
356
|
-
this.requestManager = requestQueue;
|
|
365
|
+
else {
|
|
366
|
+
this.#purgeableExtent = pacerNeeded ? 'ambiguous' : 'none';
|
|
357
367
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
368
|
+
// Built here rather than at first use so it can sit *inside* the tandem below, which is where a
|
|
369
|
+
// loader's transferred requests pass through it.
|
|
370
|
+
const writableManager = pacerNeeded
|
|
371
|
+
? new ThrottlingRequestManager({
|
|
372
|
+
domains: 'all',
|
|
373
|
+
minCrawlDelaySecs: sameDomainDelaySecs,
|
|
374
|
+
throttleBy: 'registrableDomain',
|
|
375
|
+
persistStateKey: `CRAWLEE_THROTTLED_DOMAINS_${this.#identity.id}`,
|
|
376
|
+
// A factory, because the default queue is only opened on first use.
|
|
377
|
+
inner: suppliedManager ?? (() => this.openOwnedRequestQueue()),
|
|
378
|
+
})
|
|
379
|
+
: suppliedManager;
|
|
380
|
+
if (requestList !== undefined) {
|
|
381
|
+
// The list is read first, while new requests still have somewhere writable to go; the tandem also
|
|
382
|
+
// forwards `persistState()` to the loader.
|
|
383
|
+
this.requestManager = new RequestManagerTandem(requestList, writableManager ?? (() => this.openOwnedRequestQueue()));
|
|
384
|
+
}
|
|
385
|
+
else if (writableManager !== undefined) {
|
|
386
|
+
// A RequestQueue is itself a request manager.
|
|
387
|
+
this.requestManager = writableManager;
|
|
363
388
|
}
|
|
364
389
|
this.httpClient = httpClient ?? new LazyDefaultHttpClient({ logger: this.log });
|
|
365
390
|
this.proxyConfiguration = proxyConfiguration;
|
|
@@ -390,7 +415,6 @@ export class BasicCrawler {
|
|
|
390
415
|
Math.max(this.#requestHandlerTimeoutMillis * 2, 300e3);
|
|
391
416
|
this.#maxRequestRetries = maxRequestRetries;
|
|
392
417
|
this.#maxCrawlDepth = maxCrawlDepth;
|
|
393
|
-
this.#sameDomainDelaySecs = sameDomainDelaySecs;
|
|
394
418
|
this.#statisticsDep = OwnedOrInjected.resolve(statistics,
|
|
395
419
|
// A crawler-built default tracks the built-in fields only. A non-empty `StatisticStateExtension` can
|
|
396
420
|
// only be satisfied by an injected instance carrying the matching `state`, so this branch does
|
|
@@ -509,14 +533,17 @@ export class BasicCrawler {
|
|
|
509
533
|
this.log.info('The crawler has finished all the remaining ongoing requests and will shut down now.');
|
|
510
534
|
return true;
|
|
511
535
|
}
|
|
512
|
-
//
|
|
513
|
-
// crawl that cannot progress
|
|
514
|
-
|
|
515
|
-
|
|
536
|
+
// `maybeFinish()` calls this only once nothing is in flight (`autoscaled_pool.ts`) - the point
|
|
537
|
+
// where a crawl that cannot progress becomes distinguishable from one that is merely waiting,
|
|
538
|
+
// and the only place where throwing does not abandon requests mid-processing.
|
|
539
|
+
const state = await this.requestManager?.checkReadiness();
|
|
540
|
+
// Under `keepAlive`, outliving a domain that will not let us through is the whole point.
|
|
541
|
+
if (state?.status === 'stalled' && !keepAlive) {
|
|
542
|
+
throw new PersistentRateLimitError(`Giving up: ${state.reason}`);
|
|
516
543
|
}
|
|
517
544
|
const isFinished = isFinishedFunction
|
|
518
545
|
? await isFinishedFunction()
|
|
519
|
-
:
|
|
546
|
+
: state === undefined || state.status === 'finished';
|
|
520
547
|
if (isFinished) {
|
|
521
548
|
const reason = isFinishedFunction
|
|
522
549
|
? "Crawler's custom isFinishedFunction() returned true, the crawler will shut down."
|
|
@@ -777,19 +804,18 @@ export class BasicCrawler {
|
|
|
777
804
|
// we need to purge the RQ to allow processing the same requests again — this is important so users can
|
|
778
805
|
// pass in failed requests back to the `crawler.run()`, otherwise they would be considered as handled and
|
|
779
806
|
// ignored — as a failed request is still handled.
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
// the
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
}
|
|
807
|
+
// `purgeRequestQueue` unset purges only storage the crawler opened itself (see `#purgeableExtent`);
|
|
808
|
+
// `true` also purges a caller-supplied manager, `false` purges nothing.
|
|
809
|
+
if (purgeRequestQueue === undefined && this.#purgeableExtent === 'ambiguous') {
|
|
810
|
+
throw new Error('Cannot decide what to purge before running again: `sameDomainDelaySecs` paces the request ' +
|
|
811
|
+
'manager you supplied, so the per-domain queues that have to be emptied are the ' +
|
|
812
|
+
"crawler's while the manager underneath them is yours. Say which you want: " +
|
|
813
|
+
'`run(requests, { purgeRequestQueue: true })` empties both, `false` empties neither.');
|
|
814
|
+
}
|
|
815
|
+
if (purgeRequestQueue !== false && (this.#purgeableExtent === 'all' || purgeRequestQueue === true)) {
|
|
816
|
+
// One call from the outside in reaches everything the manager wraps, a pacer's per-domain queues
|
|
817
|
+
// included.
|
|
818
|
+
await this.requestManager?.purge?.();
|
|
793
819
|
}
|
|
794
820
|
// A supplied statistics instance keeps whatever state it was handed - only wipe a default we built.
|
|
795
821
|
await this.#statisticsDep.ifOwned(async (stats) => {
|
|
@@ -927,19 +953,6 @@ export class BasicCrawler {
|
|
|
927
953
|
if (!this.requestManager) {
|
|
928
954
|
this.requestManager = await this.openOwnedRequestQueue();
|
|
929
955
|
}
|
|
930
|
-
// Wrapped here rather than in the constructor, because the manager being wrapped may only be opened at
|
|
931
|
-
// this point - and because everything that enqueues goes through here first, so nothing slips past the
|
|
932
|
-
// wrapper into the queue it hides.
|
|
933
|
-
if (this.#sameDomainDelaySecs > 0 && !supportsDomainThrottling(this.requestManager)) {
|
|
934
|
-
this.requestManager = new ThrottlingRequestManager({
|
|
935
|
-
inner: this.requestManager,
|
|
936
|
-
domains: 'all',
|
|
937
|
-
minCrawlDelaySecs: this.#sameDomainDelaySecs,
|
|
938
|
-
// What `sameDomainDelaySecs` has always meant: one clock for a site, subdomains included.
|
|
939
|
-
throttleBy: 'registrableDomain',
|
|
940
|
-
persistStateKey: `CRAWLEE_THROTTLED_DOMAINS_${this.#identity.id}`,
|
|
941
|
-
});
|
|
942
|
-
}
|
|
943
956
|
// Apply the processing-time hint here (an async lifecycle point) rather than in the constructor,
|
|
944
957
|
// now that `setExpectedRequestProcessingTimeSecs` is async. The hint is raise-only and idempotent,
|
|
945
958
|
// but guard so we do not re-issue it on every call.
|
|
@@ -957,16 +970,14 @@ export class BasicCrawler {
|
|
|
957
970
|
return this.getRequestManager();
|
|
958
971
|
}
|
|
959
972
|
/**
|
|
960
|
-
* Opens the default {@link RequestQueue}
|
|
961
|
-
* crawler-owned queue (so it gets purged between repeated `run()` calls).
|
|
973
|
+
* Opens the default {@link RequestQueue} — the crawler's own, read from when the caller supplied nothing.
|
|
962
974
|
* @private
|
|
963
975
|
*/
|
|
964
976
|
async openOwnedRequestQueue() {
|
|
965
977
|
// The first crawler instance uses the default queue (null identifier);
|
|
966
978
|
// subsequent instances get their own queue via a unique alias so they don't collide.
|
|
967
979
|
const identifier = this.#identity.instanceIndex === 0 ? null : { alias: `__default_${this.#identity.id}__` };
|
|
968
|
-
|
|
969
|
-
return this.#ownedRequestQueue.set(requestQueue);
|
|
980
|
+
return RequestQueue.open(identifier, { configuration: serviceLocator.getConfiguration() });
|
|
970
981
|
}
|
|
971
982
|
/**
|
|
972
983
|
* Tells a request manager how long we expect to hold a fetched request, so that one backed by a
|
|
@@ -1373,38 +1384,44 @@ export class BasicCrawler {
|
|
|
1373
1384
|
return !robotsTxtFile || robotsTxtFile.isAllowed(url, userAgent);
|
|
1374
1385
|
}
|
|
1375
1386
|
/**
|
|
1376
|
-
* Records an HTTP 429 against the URL's domain so the request manager can
|
|
1387
|
+
* Records an HTTP 429 against the URL's domain so the request manager can hold the retry back.
|
|
1377
1388
|
*
|
|
1378
1389
|
* @param retryAfterHeader The raw `Retry-After` response header, if the server sent one.
|
|
1379
|
-
* @returns `true` if
|
|
1390
|
+
* @returns `true` if the manager took responsibility for the delay, in which case the caller should throw
|
|
1380
1391
|
* {@link RequestThrottledError} rather than treating the response as a blocked session.
|
|
1381
1392
|
*/
|
|
1382
1393
|
recordDomainRateLimit(url, retryAfterHeader) {
|
|
1383
|
-
if (
|
|
1384
|
-
|
|
1394
|
+
if (this.requestManager?.recordPacingSignal({
|
|
1395
|
+
reason: 'rateLimited',
|
|
1396
|
+
url,
|
|
1397
|
+
waitMs: parseRetryAfterHeader(retryAfterHeader) ?? undefined,
|
|
1398
|
+
})) {
|
|
1385
1399
|
return true;
|
|
1386
1400
|
}
|
|
1387
1401
|
const domain = hostnameOrUrl(url);
|
|
1388
|
-
this.logOncePerRun(`rateLimitNotThrottled:${domain}`, `"${domain}" responded with HTTP 429 (Too Many Requests), but
|
|
1389
|
-
'so the response is handled like any other, with no per-domain delay. ' +
|
|
1390
|
-
|
|
1391
|
-
'
|
|
1402
|
+
this.logOncePerRun(`rateLimitNotThrottled:${domain}`, `"${domain}" responded with HTTP 429 (Too Many Requests), but the crawler's request manager does not ` +
|
|
1403
|
+
'pace that domain, so the response is handled like any other, with no per-domain delay. Set ' +
|
|
1404
|
+
`\`sameDomainDelaySecs\`, or pass a \`ThrottlingRequestManager\` covering "${domain}" as ` +
|
|
1405
|
+
'`requestManager`, to honour `Retry-After` and apply exponential backoff instead.', 'warning');
|
|
1392
1406
|
return false;
|
|
1393
1407
|
}
|
|
1394
|
-
/**
|
|
1395
|
-
* Hands a robots.txt `Crawl-delay` to the request manager, warning if nothing is able to honour it.
|
|
1396
|
-
*
|
|
1397
|
-
* The warning is driven by whether the delay was actually accepted rather than by the type of the manager,
|
|
1398
|
-
* because a manager that does throttle still drops the delay for a domain missing from its `domains` list.
|
|
1399
|
-
*/
|
|
1408
|
+
/** Hands a robots.txt `Crawl-delay` to the request manager, warning if it will not be honoured. */
|
|
1400
1409
|
applyCrawlDelay(url, delaySeconds) {
|
|
1401
|
-
|
|
1410
|
+
// robots.txt is per-origin; `hostname` is the closest pacing scope and errs wide (http and https to one
|
|
1411
|
+
// host share a clock).
|
|
1412
|
+
if (this.requestManager?.recordPacingSignal({
|
|
1413
|
+
reason: 'minInterval',
|
|
1414
|
+
url,
|
|
1415
|
+
intervalMs: delaySeconds * 1000,
|
|
1416
|
+
scope: 'hostname',
|
|
1417
|
+
})) {
|
|
1402
1418
|
return;
|
|
1403
1419
|
}
|
|
1404
1420
|
const domain = hostnameOrUrl(url);
|
|
1405
|
-
this.logOncePerRun(`crawlDelayIgnored:${domain}`, `robots.txt for "${domain}" defines a crawl-delay of ${delaySeconds}s, but
|
|
1406
|
-
'
|
|
1407
|
-
|
|
1421
|
+
this.logOncePerRun(`crawlDelayIgnored:${domain}`, `robots.txt for "${domain}" defines a crawl-delay of ${delaySeconds}s, but the crawler's request ` +
|
|
1422
|
+
'manager does not pace that domain, so its requests will not be paced. Set ' +
|
|
1423
|
+
`\`sameDomainDelaySecs\`, or pass a \`ThrottlingRequestManager\` covering "${domain}" as ` +
|
|
1424
|
+
'`requestManager`.', 'warning');
|
|
1408
1425
|
}
|
|
1409
1426
|
async getRobotsTxtFileForUrl(url) {
|
|
1410
1427
|
if (!this.#respectRobotsTxtFile) {
|
|
@@ -1439,10 +1456,10 @@ export class BasicCrawler {
|
|
|
1439
1456
|
});
|
|
1440
1457
|
}
|
|
1441
1458
|
const requestManagerPersistPromise = (async () => {
|
|
1442
|
-
// The request manager persists its read-only loader's state, if it has one that supports
|
|
1443
|
-
// (e.g. a tandem wrapping a `RequestList`). For a plain `RequestQueue`, this is a no-op.
|
|
1459
|
+
// The request manager persists its read-only loader's state, if it has one that supports
|
|
1460
|
+
// persistence (e.g. a tandem wrapping a `RequestList`). For a plain `RequestQueue`, this is a no-op.
|
|
1444
1461
|
if (this.requestManager?.persistState) {
|
|
1445
|
-
if (await this.requestManager.
|
|
1462
|
+
if ((await this.requestManager.checkReadiness()).status === 'finished')
|
|
1446
1463
|
return;
|
|
1447
1464
|
await this.requestManager.persistState().catch((err) => {
|
|
1448
1465
|
if (err.message.includes('Cannot persist state.')) {
|
|
@@ -1573,16 +1590,44 @@ export class BasicCrawler {
|
|
|
1573
1590
|
}
|
|
1574
1591
|
}
|
|
1575
1592
|
/**
|
|
1576
|
-
*
|
|
1593
|
+
* Whether the request manager has a request ready for processing. A manager that is only `waiting` also gets a
|
|
1594
|
+
* wake-up scheduled, so a paced crawl resumes on its clock rather than on the task loop's polling interval.
|
|
1577
1595
|
*/
|
|
1578
1596
|
async isTaskReadyFunction() {
|
|
1579
|
-
|
|
1597
|
+
if (this.requestManager === undefined) {
|
|
1598
|
+
return false;
|
|
1599
|
+
}
|
|
1600
|
+
const state = await this.requestManager.checkReadiness();
|
|
1601
|
+
if (state.status === 'waiting' && state.readyAt !== undefined) {
|
|
1602
|
+
this.#scheduleTaskLoopWake(state.readyAt);
|
|
1603
|
+
}
|
|
1604
|
+
return state.status === 'ready';
|
|
1580
1605
|
}
|
|
1581
1606
|
/**
|
|
1582
|
-
*
|
|
1607
|
+
* Nudges the task loop at `readyAt`, on a single timer that only an earlier one replaces. The pool polls anyway
|
|
1608
|
+
* every `maybeRunIntervalSecs` (0.5s by default), so this only shortens the wait - hence one timer rather than
|
|
1609
|
+
* one per probe, and `unref`'d so it never keeps the process alive.
|
|
1583
1610
|
*/
|
|
1584
|
-
|
|
1585
|
-
|
|
1611
|
+
#scheduleTaskLoopWake(readyAt) {
|
|
1612
|
+
if (this.#taskLoopWakeTimer !== undefined) {
|
|
1613
|
+
if (this.#taskLoopWakeAt <= readyAt) {
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
clearTimeout(this.#taskLoopWakeTimer);
|
|
1617
|
+
}
|
|
1618
|
+
this.#taskLoopWakeAt = readyAt;
|
|
1619
|
+
this.#taskLoopWakeTimer = setTimeout(() => {
|
|
1620
|
+
this.#taskLoopWakeTimer = undefined;
|
|
1621
|
+
void this.#autoscaledPool?.notify();
|
|
1622
|
+
}, Math.max(0, readyAt - Date.now()));
|
|
1623
|
+
this.#taskLoopWakeTimer.unref();
|
|
1624
|
+
}
|
|
1625
|
+
/** Drops a pending task-loop wake-up, so a finished run leaves no timer behind. */
|
|
1626
|
+
#clearTaskLoopWake() {
|
|
1627
|
+
if (this.#taskLoopWakeTimer !== undefined) {
|
|
1628
|
+
clearTimeout(this.#taskLoopWakeTimer);
|
|
1629
|
+
this.#taskLoopWakeTimer = undefined;
|
|
1630
|
+
}
|
|
1586
1631
|
}
|
|
1587
1632
|
/**
|
|
1588
1633
|
* Unwraps errors thrown by the context pipeline to get the actual user error.
|
|
@@ -1726,6 +1771,7 @@ export class BasicCrawler {
|
|
|
1726
1771
|
if (this.#closeEvents) {
|
|
1727
1772
|
await serviceLocator.getEventManager().close();
|
|
1728
1773
|
}
|
|
1774
|
+
this.#clearTaskLoopWake();
|
|
1729
1775
|
await this.#autoscaledPool?.abort();
|
|
1730
1776
|
await this.#concurrencySystemDep?.ifOwned((system) => system.stop());
|
|
1731
1777
|
}
|
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.147",
|
|
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.147",
|
|
46
|
+
"@crawlee/http-client": "4.0.0-beta.147",
|
|
47
|
+
"@crawlee/types": "4.0.0-beta.147",
|
|
48
|
+
"@crawlee/utils": "4.0.0-beta.147",
|
|
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.
|
|
56
|
+
"@crawlee/impit-client": "^4.0.0-beta.147"
|
|
57
57
|
},
|
|
58
58
|
"lerna": {
|
|
59
59
|
"command": {
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
|
-
"gitHead": "
|
|
65
|
+
"gitHead": "bb04462b453da49b08ea0af0038cb734eabe3d01"
|
|
66
66
|
}
|