@crawlee/http 4.0.0-rc.0 → 4.0.0-rc.1

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/README.md CHANGED
@@ -34,7 +34,7 @@ Crawlee is available as the [`crawlee`](https://www.npmjs.com/package/crawlee) N
34
34
 
35
35
  We recommend visiting the [Introduction tutorial](https://crawlee.dev/js/docs/introduction) in Crawlee documentation for more information.
36
36
 
37
- > Crawlee requires **Node.js 16 or higher**.
37
+ > Crawlee requires **Node.js 22.13 or higher**.
38
38
 
39
39
  ### With Crawlee CLI
40
40
 
package/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from '@crawlee/basic';
2
2
  export * from './internals/http-crawler.js';
3
+ export * from './internals/dom-crawler.js';
3
4
  export * from './internals/file-download.js';
package/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from '@crawlee/basic';
2
2
  export * from './internals/http-crawler.js';
3
+ export * from './internals/dom-crawler.js';
3
4
  export * from './internals/file-download.js';
@@ -0,0 +1,131 @@
1
+ import type { AddRequestsBatchedResult, ContextPipeline, CrawlingContext, EnqueueLinksOptions, ExtractLinksOptions, GetUserDataFromRequest } from '@crawlee/basic';
2
+ import type { Awaitable, Dictionary } from '@crawlee/types';
3
+ import type { CheerioAPI } from 'cheerio';
4
+ import type { HttpCrawlerOptions, InternalHttpCrawlingContext } from './http-crawler.js';
5
+ import { HttpCrawler } from './http-crawler.js';
6
+ /**
7
+ * The minimum a {@link DOMParser} has to contribute to the crawling context - the serialized document, used by
8
+ * the {@link DOMCrawlingContext.parseWithCheerio|`parseWithCheerio`} helper.
9
+ */
10
+ export interface DOMParseResult {
11
+ body: string;
12
+ }
13
+ /**
14
+ * Turns a response body into a DOM representation and knows how to query it. Passing one to {@link DOMCrawler}
15
+ * is what makes the crawler jsdom-based, linkedom-based, or based on a DOM implementation of your own.
16
+ *
17
+ * **Example usage:**
18
+ * ```ts
19
+ * import { DOMCrawler } from 'crawlee';
20
+ * import type { DOMParser } from 'crawlee';
21
+ *
22
+ * const myParser: DOMParser<{ body: string }> = {
23
+ * // ...
24
+ * };
25
+ *
26
+ * const crawler = new DOMCrawler({
27
+ * parser: myParser,
28
+ * async requestHandler({ body }) {
29
+ * // ...
30
+ * },
31
+ * });
32
+ * ```
33
+ */
34
+ export interface DOMParser<Parsed extends DOMParseResult> {
35
+ /**
36
+ * The context members {@link DOMParser.parse|`parse`} contributes, mapped to `true`. Used to build the
37
+ * placeholders that report a helpful error when the members are accessed after `skipNavigation` - the `Record`
38
+ * type forces every key of `Parsed` to be listed, so the compiler catches an omission that would otherwise
39
+ * yield `undefined` (rather than throwing) after `skipNavigation`.
40
+ */
41
+ readonly placeholderMembers: Record<keyof Parsed & string, true>;
42
+ parse(context: InternalHttpCrawlingContext): Awaitable<Parsed>;
43
+ /**
44
+ * Returns the URLs the `selector` matches, resolved against `baseUrl`.
45
+ */
46
+ extractLinks(parsed: Parsed, selector: string, baseUrl: string): Awaitable<string[]>;
47
+ /**
48
+ * Returns the current matches of `selector`. Only the count is used, by
49
+ * {@link DOMCrawlingContext.waitForSelector|`waitForSelector`}.
50
+ */
51
+ select(parsed: Parsed, selector: string): Awaitable<ArrayLike<unknown>>;
52
+ /**
53
+ * Whether the parse result can change after {@link DOMParser.parse|`parse`} returned - the case when the DOM
54
+ * implementation runs the page scripts. If it can, {@link DOMCrawlingContext.waitForSelector|`waitForSelector`}
55
+ * polls until the timeout elapses; otherwise it fails as soon as the selector does not match.
56
+ */
57
+ readonly mutable?: boolean;
58
+ /**
59
+ * Returns a Cheerio handle over the parse result, for parsers that are backed by Cheerio anyway. Without it,
60
+ * {@link DOMCrawlingContext.parseWithCheerio|`parseWithCheerio`} parses
61
+ * {@link DOMParseResult.body|`body`} again.
62
+ */
63
+ toCheerio?(parsed: Parsed): Awaitable<CheerioAPI>;
64
+ /**
65
+ * Releases whatever {@link DOMParser.parse|`parse`} allocated. Called after the request handler finishes or
66
+ * fails, and skipped entirely when navigation was skipped.
67
+ */
68
+ cleanup?(parsed: Parsed): Awaitable<void>;
69
+ }
70
+ export interface DOMCrawlingHelpers {
71
+ /**
72
+ * Extracts URLs from the parsed DOM, without adding them to the request queue.
73
+ */
74
+ extractLinks(options?: ExtractLinksOptions): Promise<string[]>;
75
+ /**
76
+ * Helper function for extracting URLs from the parsed DOM and adding them to the request queue.
77
+ */
78
+ enqueueLinks(options?: EnqueueLinksOptions): Promise<AddRequestsBatchedResult>;
79
+ /**
80
+ * Wait for an element matching the selector to appear. The `timeoutMs` only has an effect when the parser is
81
+ * {@link DOMParser.mutable|`mutable`} (e.g. {@link JSDOMCrawler} with `runScripts: true`); otherwise the
82
+ * selector is checked once and the call resolves or throws immediately.
83
+ * Timeout defaults to 5s.
84
+ *
85
+ * **Example usage:**
86
+ * ```ts
87
+ * async requestHandler({ waitForSelector, parseWithCheerio }) {
88
+ * await waitForSelector('article h1');
89
+ * const $ = await parseWithCheerio();
90
+ * const title = $('title').text();
91
+ * });
92
+ * ```
93
+ */
94
+ waitForSelector(selector: string, timeoutMs?: number): Promise<void>;
95
+ /**
96
+ * Returns Cheerio handle, allowing to work with the data same way as with {@link CheerioCrawler}.
97
+ * When provided with the `selector` argument, it will throw if it's not available.
98
+ *
99
+ * **Example usage:**
100
+ * ```javascript
101
+ * async requestHandler({ parseWithCheerio }) {
102
+ * const $ = await parseWithCheerio();
103
+ * const title = $('title').text();
104
+ * });
105
+ * ```
106
+ */
107
+ parseWithCheerio(selector?: string, timeoutMs?: number): Promise<CheerioAPI>;
108
+ }
109
+ export type DOMCrawlingContext<Parsed extends DOMParseResult = DOMParseResult, UserData extends Dictionary = any, // with default to Dictionary we cant use a typed router in untyped crawler
110
+ JSONData extends Dictionary = any> = InternalHttpCrawlingContext<UserData, JSONData> & Parsed & DOMCrawlingHelpers;
111
+ export interface DOMCrawlerOptions<Parsed extends DOMParseResult = DOMParseResult, ContextExtension = Dictionary<never>, ExtendedContext extends DOMCrawlingContext<Parsed> = DOMCrawlingContext<Parsed> & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, any>, StatisticStateExtension extends object = {}> extends Omit<HttpCrawlerOptions<DOMCrawlingContext<Parsed>, ContextExtension, ExtendedContext, Routes, StatisticStateExtension>, 'contextPipelineBuilder'> {
112
+ /**
113
+ * The DOM implementation to parse the response bodies with. Its parse result becomes part of the crawling
114
+ * context, so the members the request handler receives follow from the parser you pass.
115
+ */
116
+ parser: DOMParser<Parsed>;
117
+ }
118
+ /**
119
+ * An {@link HttpCrawler} that parses each response into a DOM using the {@link DOMCrawlerOptions.parser|`parser`}
120
+ * it is given, and exposes the parse result plus the {@link DOMCrawlingContext.enqueueLinks|`enqueueLinks`} and
121
+ * {@link DOMCrawlingContext.extractLinks|`extractLinks`} helpers on the crawling context.
122
+ *
123
+ * {@link JSDOMCrawler} and {@link LinkeDOMCrawler} are this crawler with a parser already chosen.
124
+ *
125
+ * @category Crawlers
126
+ */
127
+ export declare class DOMCrawler<Parsed extends DOMParseResult = DOMParseResult, ContextExtension = Dictionary<never>, ExtendedContext extends DOMCrawlingContext<Parsed> = DOMCrawlingContext<Parsed> & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<DOMCrawlingContext<Parsed>['request']>>, StatisticStateExtension extends object = {}> extends HttpCrawler<DOMCrawlingContext<Parsed>, ContextExtension, ExtendedContext, Routes, StatisticStateExtension> {
128
+ #private;
129
+ constructor(options: DOMCrawlerOptions<Parsed, ContextExtension, ExtendedContext, Routes, StatisticStateExtension>);
130
+ protected buildContextPipeline(): ContextPipeline<CrawlingContext, DOMCrawlingContext<Parsed>>;
131
+ }
@@ -0,0 +1,93 @@
1
+ import { EnqueueStrategy, NavigationSkippedError, resolveBaseUrlForEnqueueLinksFiltering } from '@crawlee/basic';
2
+ import { sleep } from '@crawlee/utils';
3
+ import { HttpCrawler } from './http-crawler.js';
4
+ /**
5
+ * An {@link HttpCrawler} that parses each response into a DOM using the {@link DOMCrawlerOptions.parser|`parser`}
6
+ * it is given, and exposes the parse result plus the {@link DOMCrawlingContext.enqueueLinks|`enqueueLinks`} and
7
+ * {@link DOMCrawlingContext.extractLinks|`extractLinks`} helpers on the crawling context.
8
+ *
9
+ * {@link JSDOMCrawler} and {@link LinkeDOMCrawler} are this crawler with a parser already chosen.
10
+ *
11
+ * @category Crawlers
12
+ */
13
+ export class DOMCrawler extends HttpCrawler {
14
+ #parser;
15
+ constructor(options) {
16
+ const { parser, ...rest } = options;
17
+ super({ ...rest, contextPipelineBuilder: () => this.buildContextPipeline() });
18
+ this.#parser = parser;
19
+ }
20
+ buildContextPipeline() {
21
+ return super
22
+ .buildContextPipeline()
23
+ .compose(this.#parseContent.bind(this))
24
+ .compose(async (context) => this.#addHelpers(context));
25
+ }
26
+ async #parseContent(context, onCleanup) {
27
+ try {
28
+ const parsed = await this.#parser.parse(context);
29
+ onCleanup(() => this.#parser.cleanup?.(parsed));
30
+ return parsed;
31
+ }
32
+ catch (err) {
33
+ // The `skipNavigation` placeholders below throw on access, so there is nothing to clean up.
34
+ if (err instanceof NavigationSkippedError) {
35
+ return Object.defineProperties({}, Object.fromEntries(Object.keys(this.#parser.placeholderMembers).map((member) => [
36
+ member,
37
+ {
38
+ configurable: true,
39
+ enumerable: true,
40
+ get() {
41
+ throw new NavigationSkippedError(`The \`${member}\` property is not available - \`skipNavigation\` was used`, { cause: err });
42
+ },
43
+ },
44
+ ])));
45
+ }
46
+ throw err;
47
+ }
48
+ }
49
+ async #addHelpers(context) {
50
+ const { addRequests } = context;
51
+ const parser = this.#parser;
52
+ const extractLinks = async (options) => parser.extractLinks(context, options?.selector ?? 'a', options?.baseUrl ?? context.request.loadedUrl ?? context.request.url);
53
+ const waitForSelector = async (selector, timeoutMs = 5_000) => {
54
+ let remaining = parser.mutable ? timeoutMs : 0;
55
+ while ((await parser.select(context, selector)).length === 0) {
56
+ if (remaining <= 0) {
57
+ throw new Error(`Selector '${selector}' not found.`);
58
+ }
59
+ await sleep(50);
60
+ remaining -= 50;
61
+ }
62
+ };
63
+ return {
64
+ extractLinks,
65
+ waitForSelector,
66
+ enqueueLinks: async (options = {}) => {
67
+ const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
68
+ enqueueStrategy: options.strategy,
69
+ finalRequestUrl: context.request.loadedUrl,
70
+ originalRequestUrl: context.request.url,
71
+ userProvidedBaseUrl: options.baseUrl,
72
+ });
73
+ const urls = await extractLinks(options);
74
+ return addRequests(urls, {
75
+ ...options,
76
+ baseUrl,
77
+ strategy: options.strategy ?? EnqueueStrategy.SameHostname,
78
+ });
79
+ },
80
+ async parseWithCheerio(selector, _timeoutMs = 5_000) {
81
+ // Import full cheerio (not cheerio/slim) to be browser-compliant for DOM Crawlers (not CheerioCrawler).
82
+ const $ = (await parser.toCheerio?.(context)) ??
83
+ (await import('cheerio')).load(context.body, {
84
+ xmlMode: context.contentType.type.includes('xml'),
85
+ });
86
+ if (selector && $(selector).get().length === 0) {
87
+ throw new Error(`Selector '${selector}' not found.`);
88
+ }
89
+ return $;
90
+ },
91
+ };
92
+ }
93
+ }
@@ -1,16 +1,11 @@
1
- import { Transform } from 'node:stream';
2
- import type { BasicCrawlerOptions } from '@crawlee/basic';
1
+ import type { BasicCrawlerOptions, CrawlingContext, CrawlingRequest, LoadedRequest } from '@crawlee/basic';
3
2
  import { BasicCrawler } from '@crawlee/basic';
4
- import type { CrawlingContext, LoadedRequest, Request } from '@crawlee/core';
5
- import { ResponseWithUrl } from '@crawlee/http-client';
6
3
  import type { Dictionary } from '@crawlee/types';
7
- import type { ErrorHandler, GetUserDataFromRequest, InternalHttpHook, RequestHandler, RouterRoutes } from '../index.js';
8
- declare const kBodyDrained: unique symbol;
4
+ import type { ErrorHandler, GetUserDataFromRequest, RequestHandler, RouterHandler, RouterRoutes, RouteSchemas, RoutesFromSchemas } from '../index.js';
9
5
  export type FileDownloadErrorHandler<UserData extends Dictionary = any, // with default to Dictionary we cant use a typed router in untyped crawler
10
6
  ContextExtension = Dictionary<never>> = ErrorHandler<CrawlingContext, FileDownloadCrawlingContext<UserData> & ContextExtension>;
11
- export type FileDownloadHook<UserData extends Dictionary = any> = InternalHttpHook<FileDownloadCrawlingContext<UserData>>;
12
7
  export interface FileDownloadCrawlingContext<UserData extends Dictionary = any> extends CrawlingContext<UserData> {
13
- request: LoadedRequest<Request<UserData>>;
8
+ request: LoadedRequest<CrawlingRequest<UserData>>;
14
9
  response: Response;
15
10
  contentType: {
16
11
  type: string;
@@ -18,31 +13,6 @@ export interface FileDownloadCrawlingContext<UserData extends Dictionary = any>
18
13
  };
19
14
  }
20
15
  export type FileDownloadRequestHandler<UserData extends Dictionary = any> = RequestHandler<FileDownloadCrawlingContext<UserData>>;
21
- /**
22
- * Creates a transform stream that throws an error if the source data speed is below the specified minimum speed.
23
- * This `Transform` checks the amount of data every `checkProgressInterval` milliseconds.
24
- * If the stream has received less than `minSpeedKbps * historyLengthMs / 1000` bytes in the last `historyLengthMs` milliseconds,
25
- * it will throw an error.
26
- *
27
- * Can be used e.g. to abort a download if the network speed is too slow.
28
- * @returns Transform stream that monitors the speed of the incoming data.
29
- */
30
- export declare function MinimumSpeedStream({ minSpeedKbps, historyLengthMs, checkProgressInterval: checkProgressIntervalMs, }: {
31
- minSpeedKbps: number;
32
- historyLengthMs?: number;
33
- checkProgressInterval?: number;
34
- }): Transform;
35
- /**
36
- * Creates a transform stream that logs the progress of the incoming data.
37
- * This `Transform` calls the `logProgress` function every `loggingInterval` milliseconds with the number of bytes received so far.
38
- *
39
- * Can be used e.g. to log the progress of a download.
40
- * @returns Transform stream logging the progress of the incoming data.
41
- */
42
- export declare function ByteCounterStream({ logTransferredBytes, loggingInterval, }: {
43
- logTransferredBytes: (transferredBytes: number) => void;
44
- loggingInterval?: number;
45
- }): Transform;
46
16
  /**
47
17
  * Provides a framework for downloading files in parallel using plain HTTP requests. The URLs to download are fed either from a static list of URLs or they can be added on the fly from another crawler.
48
18
  *
@@ -50,24 +20,14 @@ export declare function ByteCounterStream({ logTransferredBytes, loggingInterval
50
20
  * However, it doesn't parse the content - if you need to e.g. extract data from the downloaded files,
51
21
  * you might need to use {@link CheerioCrawler}, {@link PuppeteerCrawler} or {@link PlaywrightCrawler} instead.
52
22
  *
53
- * `FileCrawler` downloads each URL using a plain HTTP request and then invokes the user-provided {@link FileDownloadOptions.requestHandler} where the user can specify what to do with the downloaded data.
23
+ * `FileCrawler` downloads each URL using a plain HTTP request and then invokes the user-provided {@link BasicCrawlerOptions.requestHandler} where the user can specify what to do with the downloaded data.
54
24
  *
55
- * The source URLs are represented using {@link Request} objects that are fed from the {@link IRequestManager|request manager} provided via the {@link FileDownloadOptions.requestManager|`requestManager`} constructor option (a {@link RequestQueue} is itself a request manager). To read from a read-only source such as a {@link RequestList} while still being able to enqueue new requests, combine it with a queue into a {@link RequestManagerTandem} via {@link IRequestLoader.toTandem|`requestLoader.toTandem()`} and pass the result as `requestManager`.
25
+ * The source URLs are represented using {@link Request} objects that are fed from the {@link IRequestManager|request manager} provided via the {@link BasicCrawlerOptions.requestManager|`requestManager`} constructor option (a {@link RequestQueue} is itself a request manager). To read from a read-only source such as a {@link RequestList} while still being able to enqueue new requests, combine it with a queue into a {@link RequestManagerTandem} via {@link IRequestLoader.toTandem|`requestLoader.toTandem()`} and pass the result as `requestManager`.
56
26
  *
57
- * > The {@link FileDownloadOptions.requestList|`requestList`} and {@link FileDownloadOptions.requestQueue|`requestQueue`} options are deprecated; they are still accepted and folded into a single `requestManager` for back-compat.
27
+ * > The {@link BasicCrawlerOptions.requestList|`requestList`} and {@link BasicCrawlerOptions.requestQueue|`requestQueue`} options are deprecated; they are still accepted and folded into a single `requestManager` for back-compat.
58
28
  *
59
29
  * The crawler finishes when there are no more {@link Request} objects to crawl.
60
30
  *
61
- * We can use the `preNavigationHooks` to adjust the crawling context before the request is made:
62
- *
63
- * ```
64
- * preNavigationHooks: [
65
- * (crawlingContext) => {
66
- * // ...
67
- * },
68
- * ]
69
- * ```
70
- *
71
31
  * New requests are only dispatched when there is enough free CPU and memory available, as judged by the crawler's {@link ConcurrencySystem}. Concurrency is tuned via the `minConcurrency`, `maxConcurrency` and `maxRequestsPerMinute` options of the `FileCrawler` constructor, or, for finer control, by injecting a pre-configured {@link ConcurrencySystem|`concurrencySystem`}.
72
32
  *
73
33
  * ## Example usage
@@ -87,18 +47,8 @@ export declare function ByteCounterStream({ logTransferredBytes, loggingInterval
87
47
  * ```
88
48
  */
89
49
  export declare class FileDownload extends BasicCrawler<FileDownloadCrawlingContext> {
90
- constructor(options?: BasicCrawlerOptions<FileDownloadCrawlingContext>);
91
- // @ts-ignore optional peer dependency or compatibility with es2022
92
- protected buildContextPipeline(): import("@crawlee/basic").ContextPipeline<CrawlingContext<Dictionary>, CrawlingContext<Dictionary> & {
93
- request: LoadedRequest<Request>;
94
- response: ResponseWithUrl;
95
- contentType: {
96
- type: string;
97
- encoding: BufferEncoding;
98
- };
99
- [kBodyDrained]: Promise<void>;
100
- }>;
101
- private initiateDownload;
50
+ #private;
51
+ constructor(options?: Omit<BasicCrawlerOptions<FileDownloadCrawlingContext>, 'contextPipelineBuilder'>);
102
52
  }
103
53
  /**
104
54
  * Creates new {@link Router} instance that works based on request labels.
@@ -124,6 +74,6 @@ export declare class FileDownload extends BasicCrawler<FileDownloadCrawlingConte
124
74
  * await crawler.run();
125
75
  * ```
126
76
  */
127
- // @ts-ignore optional peer dependency or compatibility with es2022
128
- export declare function createFileRouter<Context extends FileDownloadCrawlingContext = FileDownloadCrawlingContext, UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(routes?: RouterRoutes<Context, UserData>): import("@crawlee/basic").RouterHandler<Context, Record<string, GetUserDataFromRequest<Context["request"]>>>;
129
- export {};
77
+ export declare function createFileRouter<Context extends FileDownloadCrawlingContext = FileDownloadCrawlingContext, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>>(routes?: RouterRoutes<Context, Routes>): RouterHandler<Context, Routes>;
78
+ export declare function createFileRouter<Context extends FileDownloadCrawlingContext = FileDownloadCrawlingContext, UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(routes?: RouterRoutes<Context, Record<string, UserData>>): RouterHandler<Context, Record<string, UserData>>;
79
+ export declare function createFileRouter<Context extends FileDownloadCrawlingContext = FileDownloadCrawlingContext, const Schemas extends RouteSchemas = RouteSchemas>(schemas: Schemas): RouterHandler<Context, RoutesFromSchemas<Schemas>>;
@@ -1,67 +1,7 @@
1
- import { Transform } from 'node:stream';
2
- import { BasicCrawler } from '@crawlee/basic';
1
+ import { BasicCrawler, ContextPipeline } from '@crawlee/basic';
3
2
  import { ResponseWithUrl } from '@crawlee/http-client';
4
3
  import { Router } from '../index.js';
5
4
  import { parseContentTypeFromResponse } from './utils.js';
6
- const kBodyDrained = Symbol('bodyDrained');
7
- /**
8
- * Creates a transform stream that throws an error if the source data speed is below the specified minimum speed.
9
- * This `Transform` checks the amount of data every `checkProgressInterval` milliseconds.
10
- * If the stream has received less than `minSpeedKbps * historyLengthMs / 1000` bytes in the last `historyLengthMs` milliseconds,
11
- * it will throw an error.
12
- *
13
- * Can be used e.g. to abort a download if the network speed is too slow.
14
- * @returns Transform stream that monitors the speed of the incoming data.
15
- */
16
- export function MinimumSpeedStream({ minSpeedKbps, historyLengthMs = 10e3, checkProgressInterval: checkProgressIntervalMs = 5e3, }) {
17
- let snapshots = [];
18
- const checkInterval = setInterval(() => {
19
- const now = Date.now();
20
- snapshots = snapshots.filter((snapshot) => now - snapshot.timestamp < historyLengthMs);
21
- const totalBytes = snapshots.reduce((acc, snapshot) => acc + snapshot.bytes, 0);
22
- const elapsed = (now - (snapshots[0]?.timestamp ?? 0)) / 1000;
23
- if (totalBytes / 1024 / elapsed < minSpeedKbps) {
24
- clearInterval(checkInterval);
25
- stream.emit('error', new Error(`Stream speed too slow, aborting...`));
26
- }
27
- }, checkProgressIntervalMs);
28
- const stream = new Transform({
29
- transform: (chunk, _, callback) => {
30
- snapshots.push({ timestamp: Date.now(), bytes: chunk.length });
31
- callback(null, chunk);
32
- },
33
- final: (callback) => {
34
- clearInterval(checkInterval);
35
- callback();
36
- },
37
- });
38
- return stream;
39
- }
40
- /**
41
- * Creates a transform stream that logs the progress of the incoming data.
42
- * This `Transform` calls the `logProgress` function every `loggingInterval` milliseconds with the number of bytes received so far.
43
- *
44
- * Can be used e.g. to log the progress of a download.
45
- * @returns Transform stream logging the progress of the incoming data.
46
- */
47
- export function ByteCounterStream({ logTransferredBytes, loggingInterval = 5000, }) {
48
- let transferredBytes = 0;
49
- let lastLogTime = Date.now();
50
- return new Transform({
51
- transform: (chunk, _, callback) => {
52
- transferredBytes += chunk.length;
53
- if (Date.now() - lastLogTime > loggingInterval) {
54
- lastLogTime = Date.now();
55
- logTransferredBytes(transferredBytes);
56
- }
57
- callback(null, chunk);
58
- },
59
- flush: (callback) => {
60
- logTransferredBytes(transferredBytes);
61
- callback();
62
- },
63
- });
64
- }
65
5
  /**
66
6
  * Provides a framework for downloading files in parallel using plain HTTP requests. The URLs to download are fed either from a static list of URLs or they can be added on the fly from another crawler.
67
7
  *
@@ -69,24 +9,14 @@ export function ByteCounterStream({ logTransferredBytes, loggingInterval = 5000,
69
9
  * However, it doesn't parse the content - if you need to e.g. extract data from the downloaded files,
70
10
  * you might need to use {@link CheerioCrawler}, {@link PuppeteerCrawler} or {@link PlaywrightCrawler} instead.
71
11
  *
72
- * `FileCrawler` downloads each URL using a plain HTTP request and then invokes the user-provided {@link FileDownloadOptions.requestHandler} where the user can specify what to do with the downloaded data.
12
+ * `FileCrawler` downloads each URL using a plain HTTP request and then invokes the user-provided {@link BasicCrawlerOptions.requestHandler} where the user can specify what to do with the downloaded data.
73
13
  *
74
- * The source URLs are represented using {@link Request} objects that are fed from the {@link IRequestManager|request manager} provided via the {@link FileDownloadOptions.requestManager|`requestManager`} constructor option (a {@link RequestQueue} is itself a request manager). To read from a read-only source such as a {@link RequestList} while still being able to enqueue new requests, combine it with a queue into a {@link RequestManagerTandem} via {@link IRequestLoader.toTandem|`requestLoader.toTandem()`} and pass the result as `requestManager`.
14
+ * The source URLs are represented using {@link Request} objects that are fed from the {@link IRequestManager|request manager} provided via the {@link BasicCrawlerOptions.requestManager|`requestManager`} constructor option (a {@link RequestQueue} is itself a request manager). To read from a read-only source such as a {@link RequestList} while still being able to enqueue new requests, combine it with a queue into a {@link RequestManagerTandem} via {@link IRequestLoader.toTandem|`requestLoader.toTandem()`} and pass the result as `requestManager`.
75
15
  *
76
- * > The {@link FileDownloadOptions.requestList|`requestList`} and {@link FileDownloadOptions.requestQueue|`requestQueue`} options are deprecated; they are still accepted and folded into a single `requestManager` for back-compat.
16
+ * > The {@link BasicCrawlerOptions.requestList|`requestList`} and {@link BasicCrawlerOptions.requestQueue|`requestQueue`} options are deprecated; they are still accepted and folded into a single `requestManager` for back-compat.
77
17
  *
78
18
  * The crawler finishes when there are no more {@link Request} objects to crawl.
79
19
  *
80
- * We can use the `preNavigationHooks` to adjust the crawling context before the request is made:
81
- *
82
- * ```
83
- * preNavigationHooks: [
84
- * (crawlingContext) => {
85
- * // ...
86
- * },
87
- * ]
88
- * ```
89
- *
90
20
  * New requests are only dispatched when there is enough free CPU and memory available, as judged by the crawler's {@link ConcurrencySystem}. Concurrency is tuned via the `minConcurrency`, `maxConcurrency` and `maxRequestsPerMinute` options of the `FileCrawler` constructor, or, for finer control, by injecting a pre-configured {@link ConcurrencySystem|`concurrencySystem`}.
91
21
  *
92
22
  * ## Example usage
@@ -110,36 +40,32 @@ export class FileDownload extends BasicCrawler {
110
40
  constructor(options = {}) {
111
41
  super({
112
42
  ...options,
113
- contextPipelineBuilder: () => this.buildContextPipeline(),
43
+ contextPipelineBuilder: () => this.#buildContextPipeline(),
114
44
  });
115
45
  }
116
- buildContextPipeline() {
117
- return super.buildContextPipeline().compose({
118
- action: async (context) => this.initiateDownload(context),
119
- cleanup: async (context) => {
120
- if (!context.response.bodyUsed) {
121
- // Nobody consumed the body — cancel it so the
122
- // underlying connection can be released.
123
- await context.response.body?.cancel();
124
- }
125
- await context[kBodyDrained];
126
- },
127
- });
46
+ #buildContextPipeline() {
47
+ return ContextPipeline.create().compose(this.#initiateDownload.bind(this));
128
48
  }
129
- async initiateDownload(context) {
49
+ async #initiateDownload(context, onCleanup) {
130
50
  const response = await this.httpClient.sendRequest(context.request.intoFetchAPIRequest(), {
131
51
  session: context.session,
132
52
  });
133
53
  const { type, charset: encoding } = parseContentTypeFromResponse(response);
134
54
  context.request.url = response.url;
135
55
  const { response: trackedResponse, bodyDrained } = trackBodyConsumption(response);
136
- const contextExtension = {
56
+ onCleanup(async () => {
57
+ if (!trackedResponse.bodyUsed) {
58
+ // Nobody consumed the body — cancel it so the
59
+ // underlying connection can be released.
60
+ await trackedResponse.body?.cancel();
61
+ }
62
+ await bodyDrained;
63
+ });
64
+ return {
137
65
  request: context.request,
138
66
  response: trackedResponse,
139
67
  contentType: { type, encoding },
140
- [kBodyDrained]: bodyDrained,
141
68
  };
142
- return contextExtension;
143
69
  }
144
70
  }
145
71
  /**
@@ -162,30 +88,6 @@ function trackBodyConsumption(response) {
162
88
  });
163
89
  return { response: trackedResponse, bodyDrained };
164
90
  }
165
- /**
166
- * Creates new {@link Router} instance that works based on request labels.
167
- * This instance can then serve as a `requestHandler` of your {@link FileDownload}.
168
- * Defaults to the {@link FileDownloadCrawlingContext}.
169
- *
170
- * > Serves as a shortcut for using `Router.create<FileDownloadCrawlingContext>()`.
171
- *
172
- * ```ts
173
- * import { FileDownload, createFileRouter } from 'crawlee';
174
- *
175
- * const router = createFileRouter();
176
- * router.addHandler('label-a', async (ctx) => {
177
- * ctx.log.info('...');
178
- * });
179
- * router.addDefaultHandler(async (ctx) => {
180
- * ctx.log.info('...');
181
- * });
182
- *
183
- * const crawler = new FileDownload({
184
- * requestHandler: router,
185
- * });
186
- * await crawler.run();
187
- * ```
188
- */
189
- export function createFileRouter(routes) {
190
- return Router.create(routes);
91
+ export function createFileRouter(routesOrSchemas) {
92
+ return Router.create(routesOrSchemas);
191
93
  }
@@ -1,13 +1,13 @@
1
- import type { BasicCrawlerOptions, ConcurrencySystem, ConcurrencySystemOptions, CrawlingContext, ErrorHandler, GetUserDataFromRequest, Request as CrawleeRequest, RequestHandler, RequireContextPipeline, RouterHandler, RouterRoutes, RouteSchemas, RoutesFromSchemas } from '@crawlee/basic';
1
+ import type { BasicCrawlerOptions, ConcurrencySystem, ConcurrencySystemOptions, CrawlingContext, ErrorHandler, GetUserDataFromRequest, LoadedRequest, CrawlingRequest, RequireContextPipeline, RequestHandler, RouterHandler, RouterRoutes, RouteSchemas, RoutesFromSchemas } from '@crawlee/basic';
2
2
  import { BasicCrawler, ContextPipeline } from '@crawlee/basic';
3
- import { type LoadedRequest } from '@crawlee/core';
4
3
  import type { Awaitable, Dictionary } from '@crawlee/types';
5
- import { type CheerioRoot } from '@crawlee/utils/internal';
4
+ import type { CheerioAPI } from 'cheerio';
6
5
  import type { JsonValue } from 'type-fest';
7
6
  import { z } from 'zod';
8
7
  /**
9
8
  * A higher starting concurrency and a relaxed event loop signal, since HTTP-only crawling barely touches the event
10
- * loop. {@link HttpCrawler} folds these into the {@link ConcurrencySystem} it builds by default.
9
+ * loop. {@link HttpCrawler} folds these into the {@link ConcurrencySystem} it builds by default, with your own
10
+ * concurrency shortcuts (`minConcurrency`, `maxConcurrency`, `maxRequestsPerMinute`) kept on top.
11
11
  *
12
12
  * A {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} you supply yourself replaces that default
13
13
  * wholesale, tuning included, so spread these options in if you want to keep it:
@@ -77,7 +77,7 @@ export interface HttpCrawlerOptions<Context extends InternalHttpCrawlingContext
77
77
  * ]
78
78
  * ```
79
79
  */
80
- postNavigationHooks?: ((crawlingContext: CrawlingContextWithResponse & ContextExtension) => Awaitable<void | Partial<CrawlingContextWithResponse>>)[];
80
+ postNavigationHooks?: InternalHttpHook<CrawlingContextWithResponse, ContextExtension>[];
81
81
  /**
82
82
  * An array of [MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Complete_list_of_MIME_types)
83
83
  * you want the crawler to load and process. By default, only `text/html`, `application/xhtml+xml`, `text/xml`, `application/xml`,
@@ -115,13 +115,11 @@ export interface HttpCrawlerOptions<Context extends InternalHttpCrawlingContext
115
115
  saveResponseCookies?: boolean;
116
116
  }
117
117
  export type InternalHttpHook<Context, ContextExtension = {}> = (crawlingContext: Context & ContextExtension) => Awaitable<void | Partial<Context>>;
118
- export type HttpHook<UserData extends Dictionary = any, // with default to Dictionary we cant use a typed router in untyped crawler
119
- JSONData extends JsonValue = any> = InternalHttpHook<HttpCrawlingContext<UserData, JSONData>>;
120
118
  interface CrawlingContextWithResponse<UserData extends Dictionary = any> extends CrawlingContext<UserData> {
121
119
  /**
122
120
  * The request object that was successfully loaded and navigated to, including the {@link Request.loadedUrl|`loadedUrl`} property.
123
121
  */
124
- request: LoadedRequest<CrawleeRequest<UserData>>;
122
+ request: LoadedRequest<CrawlingRequest<UserData>>;
125
123
  /**
126
124
  * The HTTP response object containing status code, headers, and other response metadata.
127
125
  */
@@ -148,7 +146,11 @@ JSONData extends JsonValue = any> extends CrawlingContextWithResponse<UserData>
148
146
  encoding: BufferEncoding;
149
147
  };
150
148
  /**
151
- * Wait for an element matching the selector to appear. Timeout is ignored.
149
+ * Wait for an element matching the selector to appear.
150
+ *
151
+ * `HttpCrawler` and {@link CheerioCrawler} parse a response that is already fully downloaded, so there is
152
+ * nothing to wait for and `timeoutMs` is ignored. {@link JSDOMCrawler} and {@link LinkeDOMCrawler} poll for
153
+ * the selector instead, with `timeoutMs` defaulting to 5s.
152
154
  *
153
155
  * **Example usage:**
154
156
  * ```ts
@@ -162,7 +164,12 @@ JSONData extends JsonValue = any> extends CrawlingContextWithResponse<UserData>
162
164
  waitForSelector(selector: string, timeoutMs?: number): Promise<void>;
163
165
  /**
164
166
  * Returns Cheerio handle for `page.content()`, allowing to work with the data same way as with {@link CheerioCrawler}.
165
- * When provided with the `selector` argument, it will throw if it's not available.
167
+ * This is here to unify the crawler API, so they all have this handy method - in {@link CheerioCrawler} it has
168
+ * the same return type as the `$` context property, so use it only if you are abstracting your workflow to
169
+ * support different context types in one handler.
170
+ *
171
+ * When provided with the `selector` argument, it will throw if it's not available. {@link JSDOMCrawler} and
172
+ * {@link LinkeDOMCrawler} wait for the selector first, with `timeoutMs` defaulting to 5s.
166
173
  *
167
174
  * **Example usage:**
168
175
  * ```ts
@@ -172,7 +179,7 @@ JSONData extends JsonValue = any> extends CrawlingContextWithResponse<UserData>
172
179
  * });
173
180
  * ```
174
181
  */
175
- parseWithCheerio(selector?: string, timeoutMs?: number): Promise<CheerioRoot>;
182
+ parseWithCheerio(selector?: string, timeoutMs?: number): Promise<CheerioAPI>;
176
183
  }
177
184
  export interface HttpCrawlingContext<UserData extends Dictionary = any, JSONData extends JsonValue = any> extends InternalHttpCrawlingContext<UserData, JSONData> {
178
185
  }
@@ -249,15 +256,10 @@ JSONData extends JsonValue = any> = RequestHandler<HttpCrawlingContext<UserData,
249
256
  */
250
257
  export declare class HttpCrawler<Context extends InternalHttpCrawlingContext<any, any> = InternalHttpCrawlingContext, ContextExtension = Dictionary<never>, ExtendedContext extends Context = Context & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>, StatisticStateExtension extends object = {}> extends BasicCrawler<Context, ContextExtension, ExtendedContext, Routes, StatisticStateExtension> {
251
258
  #private;
259
+ /**
260
+ * @internal
261
+ */
252
262
  protected static optionsShape: {
253
- navigationTimeoutSecs: z.ZodDefault<z.ZodCustom<number, number>>;
254
- ignoreTlsErrors: z.ZodDefault<z.ZodBoolean>;
255
- additionalMimeTypes: z.ZodDefault<z.ZodArray<z.ZodString>>;
256
- suggestResponseEncoding: z.ZodOptional<z.ZodString>;
257
- forceResponseEncoding: z.ZodOptional<z.ZodString>;
258
- saveResponseCookies: z.ZodDefault<z.ZodBoolean>;
259
- preNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
260
- postNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
261
263
  contextPipelineBuilder: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
262
264
  extendContext: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
263
265
  requestList: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
@@ -290,21 +292,20 @@ export declare class HttpCrawler<Context extends InternalHttpCrawlingContext<any
290
292
  }, z.core.$strict>]>>;
291
293
  onSkippedRequest: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
292
294
  // @ts-ignore optional peer dependency or compatibility with es2022
293
- httpClient: z.ZodOptional<z.ZodCustom<import("@crawlee/http-client").BaseHttpClient, import("@crawlee/http-client").BaseHttpClient>>;
295
+ httpClient: z.ZodOptional<z.ZodInstanceOf<import("@crawlee/http-client").BaseHttpClient>>;
294
296
  // @ts-ignore optional peer dependency or compatibility with es2022
295
- configuration: z.ZodOptional<z.ZodCustom<import("@crawlee/basic").Configuration, import("@crawlee/basic").Configuration>>;
297
+ configuration: z.ZodOptional<z.ZodInstanceOf<import("@crawlee/basic").Configuration>>;
296
298
  storageBackend: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
297
299
  // @ts-ignore optional peer dependency or compatibility with es2022
298
- eventManager: z.ZodOptional<z.ZodCustom<import("@crawlee/basic").EventManager, import("@crawlee/basic").EventManager>>;
300
+ eventManager: z.ZodOptional<z.ZodInstanceOf<import("@crawlee/basic").EventManager>>;
299
301
  logger: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
300
302
  minConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
301
303
  maxConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
304
+ initialConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
302
305
  maxRequestsPerMinute: z.ZodOptional<z.ZodCustom<number, number>>;
303
306
  keepAlive: z.ZodOptional<z.ZodBoolean>;
304
307
  statistics: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
305
308
  id: z.ZodOptional<z.ZodString>;
306
- };
307
- protected static optionsSchema: z.ZodObject<{
308
309
  navigationTimeoutSecs: z.ZodDefault<z.ZodCustom<number, number>>;
309
310
  ignoreTlsErrors: z.ZodDefault<z.ZodBoolean>;
310
311
  additionalMimeTypes: z.ZodDefault<z.ZodArray<z.ZodString>>;
@@ -313,6 +314,9 @@ export declare class HttpCrawler<Context extends InternalHttpCrawlingContext<any
313
314
  saveResponseCookies: z.ZodDefault<z.ZodBoolean>;
314
315
  preNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
315
316
  postNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
317
+ };
318
+ /** @internal */
319
+ protected static optionsSchema: z.ZodObject<{
316
320
  contextPipelineBuilder: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
317
321
  extendContext: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
318
322
  requestList: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
@@ -345,66 +349,39 @@ export declare class HttpCrawler<Context extends InternalHttpCrawlingContext<any
345
349
  }, z.core.$strict>]>>;
346
350
  onSkippedRequest: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
347
351
  // @ts-ignore optional peer dependency or compatibility with es2022
348
- httpClient: z.ZodOptional<z.ZodCustom<import("@crawlee/http-client").BaseHttpClient, import("@crawlee/http-client").BaseHttpClient>>;
352
+ httpClient: z.ZodOptional<z.ZodInstanceOf<import("@crawlee/http-client").BaseHttpClient>>;
349
353
  // @ts-ignore optional peer dependency or compatibility with es2022
350
- configuration: z.ZodOptional<z.ZodCustom<import("@crawlee/basic").Configuration, import("@crawlee/basic").Configuration>>;
354
+ configuration: z.ZodOptional<z.ZodInstanceOf<import("@crawlee/basic").Configuration>>;
351
355
  storageBackend: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
352
356
  // @ts-ignore optional peer dependency or compatibility with es2022
353
- eventManager: z.ZodOptional<z.ZodCustom<import("@crawlee/basic").EventManager, import("@crawlee/basic").EventManager>>;
357
+ eventManager: z.ZodOptional<z.ZodInstanceOf<import("@crawlee/basic").EventManager>>;
354
358
  logger: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
355
359
  minConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
356
360
  maxConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
361
+ initialConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
357
362
  maxRequestsPerMinute: z.ZodOptional<z.ZodCustom<number, number>>;
358
363
  keepAlive: z.ZodOptional<z.ZodBoolean>;
359
364
  statistics: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
360
365
  id: z.ZodOptional<z.ZodString>;
366
+ navigationTimeoutSecs: z.ZodDefault<z.ZodCustom<number, number>>;
367
+ ignoreTlsErrors: z.ZodDefault<z.ZodBoolean>;
368
+ additionalMimeTypes: z.ZodDefault<z.ZodArray<z.ZodString>>;
369
+ suggestResponseEncoding: z.ZodOptional<z.ZodString>;
370
+ forceResponseEncoding: z.ZodOptional<z.ZodString>;
371
+ saveResponseCookies: z.ZodDefault<z.ZodBoolean>;
372
+ preNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
373
+ postNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
361
374
  }, z.core.$strict>;
362
375
  /**
363
376
  * All `HttpCrawlerOptions` parameters are passed via an options object.
364
377
  */
365
378
  constructor(options?: HttpCrawlerOptions<Context, ContextExtension, ExtendedContext, Routes, StatisticStateExtension> & RequireContextPipeline<InternalHttpCrawlingContext, Context>);
379
+ /** @internal */
366
380
  protected getNavigationTimeoutMillis(): number;
367
- /**
368
- * Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
369
- * concurrency shortcuts on top. Not called for a supplied
370
- * {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} — spread the constant into it yourself to
371
- * keep the tuning.
372
- */
381
+ /** @internal */
373
382
  protected createDefaultConcurrencySystem(options: ConcurrencySystemOptions): ConcurrencySystem;
374
383
  protected buildContextPipeline(): ContextPipeline<CrawlingContext, InternalHttpCrawlingContext>;
375
- private prepareHttpRequest;
376
384
  private makeHttpRequest;
377
- private processHttpResponse;
378
- private handleBlockedRequestByContent;
379
- protected isRequestBlocked(crawlingContext: InternalHttpCrawlingContext): Promise<string | false>;
380
- /**
381
- * Function to make the HTTP request. It performs optimizations
382
- * on the request such as only downloading the request body if the
383
- * received content type matches text/html, application/xml, application/xhtml+xml.
384
- */
385
- private requestFunction;
386
- /**
387
- * Encodes and parses response according to the provided content type
388
- */
389
- private parseResponse;
390
- /**
391
- * Combines the provided `requestOptions` with mandatory (non-overridable) values.
392
- */
393
- private getRequestOptions;
394
- private encodeResponse;
395
- /**
396
- * Checks and extends supported mime types
397
- */
398
- private extendSupportedMimeTypes;
399
- /**
400
- * Handles timeout request
401
- */
402
- private handleRequestTimeout;
403
- private abortDownloadOfBody;
404
- /**
405
- * @internal wraps public utility for mocking purposes
406
- */
407
- private requestAsBrowser;
408
385
  }
409
386
  /**
410
387
  * Creates new {@link Router} instance that works based on request labels.
@@ -1,10 +1,9 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import util from 'node:util';
3
- import { BasicCrawler, ContextPipeline, NavigationSkippedError, remainingNavigationWindowMillis, RequestState, Router, SessionError, } from '@crawlee/basic';
4
- import { RequestThrottledError, getCookiesFromResponse, parseArgument, schemas, } from '@crawlee/core';
3
+ import { BasicCrawler, ContextPipeline, getCookiesFromResponse, NavigationSkippedError, remainingNavigationWindowMillis, RequestState, RequestThrottledError, Router, SessionError, } from '@crawlee/basic';
5
4
  import { ResponseWithUrl } from '@crawlee/http-client';
6
- import { RETRY_CSS_SELECTORS } from '@crawlee/utils/internal';
7
- import contentTypeParser from 'content-type';
5
+ import { parseArgument, RETRY_CSS_SELECTORS, schemas } from '@crawlee/utils/internal';
6
+ import { isTypeValid, parse as parseContentType } from 'content-type';
8
7
  import iconv from 'iconv-lite';
9
8
  import { z } from 'zod';
10
9
  import { addTimeoutToPromise, storage, TimeoutError, tryCancel } from '@apify/timeout';
@@ -16,7 +15,8 @@ const HTML_AND_XML_MIME_TYPES = ['text/html', 'text/xml', 'application/xhtml+xml
16
15
  const APPLICATION_JSON_MIME_TYPE = 'application/json';
17
16
  /**
18
17
  * A higher starting concurrency and a relaxed event loop signal, since HTTP-only crawling barely touches the event
19
- * loop. {@link HttpCrawler} folds these into the {@link ConcurrencySystem} it builds by default.
18
+ * loop. {@link HttpCrawler} folds these into the {@link ConcurrencySystem} it builds by default, with your own
19
+ * concurrency shortcuts (`minConcurrency`, `maxConcurrency`, `maxRequestsPerMinute`) kept on top.
20
20
  *
21
21
  * A {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} you supply yourself replaces that default
22
22
  * wholesale, tuning included, so spread these options in if you want to keep it:
@@ -117,6 +117,9 @@ export class HttpCrawler extends BasicCrawler {
117
117
  #suggestResponseEncoding;
118
118
  #forceResponseEncoding;
119
119
  #supportedMimeTypes;
120
+ /**
121
+ * @internal
122
+ */
120
123
  static optionsShape = {
121
124
  ...BasicCrawler.optionsShape,
122
125
  navigationTimeoutSecs: schemas.anyNumber.default(30),
@@ -128,6 +131,7 @@ export class HttpCrawler extends BasicCrawler {
128
131
  preNavigationHooks: schemas.anyArray.default(() => []),
129
132
  postNavigationHooks: schemas.anyArray.default(() => []),
130
133
  };
134
+ /** @internal */
131
135
  static optionsSchema = z.strictObject(HttpCrawler.optionsShape);
132
136
  /**
133
137
  * All `HttpCrawlerOptions` parameters are passed via an options object.
@@ -143,7 +147,7 @@ export class HttpCrawler extends BasicCrawler {
143
147
  });
144
148
  this.#supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
145
149
  if (additionalMimeTypes.length)
146
- this.extendSupportedMimeTypes(additionalMimeTypes);
150
+ this.#extendSupportedMimeTypes(additionalMimeTypes);
147
151
  if (suggestResponseEncoding && forceResponseEncoding) {
148
152
  this.log.warning('Both forceResponseEncoding and suggestResponseEncoding options are set. Using forceResponseEncoding.');
149
153
  }
@@ -156,20 +160,16 @@ export class HttpCrawler extends BasicCrawler {
156
160
  // members `extendContext` added at runtime.
157
161
  this.#preNavigationHooks = preNavigationHooks;
158
162
  this.#postNavigationHooks = [
159
- ({ request, response }) => this.abortDownloadOfBody(request, response),
163
+ ({ request, response }) => this.#abortDownloadOfBody(request, response),
160
164
  ...postNavigationHooks,
161
165
  ];
162
166
  this.#saveResponseCookies = saveResponseCookies;
163
167
  }
168
+ /** @internal */
164
169
  getNavigationTimeoutMillis() {
165
170
  return this.#navigationTimeoutMillis;
166
171
  }
167
- /**
168
- * Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
169
- * concurrency shortcuts on top. Not called for a supplied
170
- * {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} — spread the constant into it yourself to
171
- * keep the tuning.
172
- */
172
+ /** @internal */
173
173
  createDefaultConcurrencySystem(options) {
174
174
  return super.createDefaultConcurrencySystem({
175
175
  ...HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS,
@@ -179,9 +179,7 @@ export class HttpCrawler extends BasicCrawler {
179
179
  buildContextPipeline() {
180
180
  // When navigation is skipped, `prepareHttpRequest` has already installed throwing getters for
181
181
  // the response-derived members, so the guarded action is bypassed and the context left untouched.
182
- const skipGuard = (action) => ({
183
- action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
184
- });
182
+ const skipGuard = (action) => async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {}));
185
183
  // A single navigation window covers the pre-navigation hooks, the navigation, and the post-navigation
186
184
  // hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow hook eats into the same
187
185
  // window the navigation uses instead of each step being timed on its own.
@@ -193,9 +191,7 @@ export class HttpCrawler extends BasicCrawler {
193
191
  }
194
192
  return addTimeoutToPromise(async () => step(ctx), remaining, navigationTimedOut);
195
193
  });
196
- let pipeline = ContextPipeline.create().compose({
197
- action: this.prepareHttpRequest.bind(this),
198
- });
194
+ let pipeline = ContextPipeline.create().compose(this.#prepareHttpRequest.bind(this));
199
195
  for (const hook of this.#preNavigationHooks) {
200
196
  pipeline = pipeline.compose(windowGuard(hook));
201
197
  }
@@ -204,20 +200,23 @@ export class HttpCrawler extends BasicCrawler {
204
200
  pipelineWithNavigation = pipelineWithNavigation.compose(windowGuard(hook));
205
201
  }
206
202
  return pipelineWithNavigation
207
- .compose({ action: this.processHttpResponse.bind(this) })
208
- .compose({ action: this.handleBlockedRequestByContent.bind(this) });
203
+ .compose(this.#processHttpResponse.bind(this))
204
+ .compose(this.#handleBlockedRequestByContent.bind(this));
209
205
  }
210
- async prepareHttpRequest(crawlingContext) {
206
+ async #prepareHttpRequest(crawlingContext) {
211
207
  const { request } = crawlingContext;
212
208
  if (request.skipNavigation) {
213
209
  return {
214
210
  request: new Proxy(request, {
215
- get(target, propertyName, receiver) {
211
+ get(target, propertyName) {
216
212
  if (propertyName === 'loadedUrl') {
217
213
  throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
218
214
  }
219
- return Reflect.get(target, propertyName, receiver);
215
+ // `target` as receiver and bound methods, or `#` members throw on the proxy
216
+ const value = Reflect.get(target, propertyName, target);
217
+ return typeof value === 'function' ? value.bind(target) : value;
220
218
  },
219
+ set: (target, propertyName, value) => Reflect.set(target, propertyName, value, target),
221
220
  }),
222
221
  get response() {
223
222
  throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
@@ -227,6 +226,7 @@ export class HttpCrawler extends BasicCrawler {
227
226
  request.state = RequestState.BEFORE_NAV;
228
227
  return {};
229
228
  }
229
+ // oxlint-disable-next-line crawlee/prefer-private-fields -- patched by @crawlee/otel
230
230
  async makeHttpRequest(crawlingContext) {
231
231
  tryCancel();
232
232
  const { request, session } = crawlingContext;
@@ -234,13 +234,13 @@ export class HttpCrawler extends BasicCrawler {
234
234
  // Bound the request by whatever is left of the shared navigation window (the pre-navigation hooks may
235
235
  // have already spent part of it), so it produces a clean navigation-timeout error rather than the raw
236
236
  // client abort.
237
- const httpResponse = await addTimeoutToPromise(async () => this.requestFunction({ request, session, proxyUrl }), Math.max(1, remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis)), `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
237
+ const httpResponse = await addTimeoutToPromise(async () => this.#requestFunction({ request, session, proxyUrl }), Math.max(1, remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis)), `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
238
238
  tryCancel();
239
239
  request.loadedUrl = httpResponse?.url;
240
240
  request.state = RequestState.AFTER_NAV;
241
241
  return { request: request, response: httpResponse };
242
242
  }
243
- async processHttpResponse(crawlingContext) {
243
+ async #processHttpResponse(crawlingContext) {
244
244
  if (crawlingContext.request.skipNavigation) {
245
245
  return {
246
246
  get contentType() {
@@ -279,20 +279,22 @@ export class HttpCrawler extends BasicCrawler {
279
279
  if (remaining <= 0) {
280
280
  throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
281
281
  }
282
- const parsed = await addTimeoutToPromise(async () => this.parseResponse(crawlingContext.request, crawlingContext.response), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
282
+ const parsed = await addTimeoutToPromise(async () => this.#parseResponse(crawlingContext.request, crawlingContext.response), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
283
283
  tryCancel();
284
284
  const response = parsed.response;
285
285
  const contentType = parsed.contentType;
286
+ const loadBody = async () => {
287
+ const { load } = await import('cheerio/slim');
288
+ return load(parsed.body.toString(), { xmlMode: contentType.type.includes('xml') });
289
+ };
286
290
  const waitForSelector = async (selector, _timeoutMs) => {
287
- const cheerio = await import('cheerio');
288
- const $ = cheerio.load(parsed.body.toString());
291
+ const $ = await loadBody();
289
292
  if ($(selector).get().length === 0) {
290
293
  throw new Error(`Selector '${selector}' not found.`);
291
294
  }
292
295
  };
293
296
  const parseWithCheerio = async (selector, timeoutMs) => {
294
- const cheerio = await import('cheerio');
295
- const $ = cheerio.load(parsed.body.toString());
297
+ const $ = await loadBody();
296
298
  if (selector) {
297
299
  await crawlingContext.waitForSelector(selector, timeoutMs);
298
300
  }
@@ -331,15 +333,15 @@ export class HttpCrawler extends BasicCrawler {
331
333
  body: parsed.body,
332
334
  };
333
335
  }
334
- async handleBlockedRequestByContent(crawlingContext) {
336
+ async #handleBlockedRequestByContent(crawlingContext) {
335
337
  if (this.retryOnBlocked) {
336
- const error = await this.isRequestBlocked(crawlingContext);
338
+ const error = await this.#isRequestBlocked(crawlingContext);
337
339
  if (error)
338
340
  throw new SessionError(error);
339
341
  }
340
342
  return {};
341
343
  }
342
- async isRequestBlocked(crawlingContext) {
344
+ async #isRequestBlocked(crawlingContext) {
343
345
  if (HTML_AND_XML_MIME_TYPES.includes(crawlingContext.contentType.type)) {
344
346
  const $ = await crawlingContext.parseWithCheerio();
345
347
  const foundSelectors = RETRY_CSS_SELECTORS.filter((selector) => $(selector).length > 0);
@@ -357,14 +359,14 @@ export class HttpCrawler extends BasicCrawler {
357
359
  * on the request such as only downloading the request body if the
358
360
  * received content type matches text/html, application/xml, application/xhtml+xml.
359
361
  */
360
- async requestFunction({ request, session, proxyUrl }) {
361
- const opts = this.getRequestOptions(request, session, proxyUrl);
362
+ async #requestFunction({ request, session, proxyUrl }) {
363
+ const opts = this.#getRequestOptions(request, proxyUrl);
362
364
  try {
363
- return await this.requestAsBrowser(opts, session);
365
+ return await this.#requestAsBrowser(opts, session);
364
366
  }
365
367
  catch (e) {
366
368
  if (e instanceof Error && e.constructor.name === 'TimeoutError') {
367
- this.handleRequestTimeout(session);
369
+ this.#handleRequestTimeout(session);
368
370
  return new Response(); // this will never happen, as handleRequestTimeout always throws
369
371
  }
370
372
  if (this.isProxyError(e)) {
@@ -378,16 +380,15 @@ export class HttpCrawler extends BasicCrawler {
378
380
  /**
379
381
  * Encodes and parses response according to the provided content type
380
382
  */
381
- async parseResponse(request, response) {
383
+ async #parseResponse(request, response) {
382
384
  const { status } = response;
383
385
  const { type, charset } = parseContentTypeFromResponse(response);
384
- const { response: reencodedResponse, encoding } = this.encodeResponse(request, response, charset);
385
- const contentType = { type, encoding };
386
386
  if (status >= 400 && status <= 599) {
387
387
  this.statistics.registerStatusCode(status);
388
388
  }
389
389
  if (this.isErrorStatusCode(status)) {
390
- const body = await reencodedResponse.text(); // TODO - this always uses UTF-8 (see https://developer.mozilla.org/en-US/docs/Web/API/Request/text)
390
+ const { response: decoded } = this.#encodeResponse(request, response, charset);
391
+ const body = await decoded.text(); // TODO - this always uses UTF-8 (see https://developer.mozilla.org/en-US/docs/Web/API/Request/text)
391
392
  // Errors are often sent as JSON, so attempt to parse them,
392
393
  // despite Accept header being set to text/html.
393
394
  if (type === APPLICATION_JSON_MIME_TYPE) {
@@ -403,37 +404,37 @@ export class HttpCrawler extends BasicCrawler {
403
404
  // It's not a JSON, so it's probably some text. Get the first 100 chars of it.
404
405
  throw new Error(`${status} - Internal Server Error: ${body.slice(0, 100)}`);
405
406
  }
406
- else if (HTML_AND_XML_MIME_TYPES.includes(type)) {
407
- if (!charset && !this.#forceResponseEncoding) {
408
- const rawBytes = Buffer.from(await response.arrayBuffer());
409
- const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
410
- const charsetToUse = metaCharset ?? this.#suggestResponseEncoding ?? 'utf-8';
411
- const body = iconv.encodingExists(charsetToUse)
412
- ? iconv.decode(rawBytes, charsetToUse)
413
- : rawBytes.toString('utf8');
414
- return { response, contentType: { type, encoding: 'utf-8' }, body };
415
- }
416
- return { response, contentType, body: await reencodedResponse.text() };
407
+ if (HTML_AND_XML_MIME_TYPES.includes(type) && !charset && !this.#forceResponseEncoding) {
408
+ // The charset comes from the document itself, so the raw bytes are what we need -
409
+ // decoding them through `encodeResponse` first would consume the body for nothing.
410
+ const rawBytes = Buffer.from(await response.arrayBuffer());
411
+ const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
412
+ const charsetToUse = metaCharset ?? this.#suggestResponseEncoding ?? 'utf-8';
413
+ const body = iconv.encodingExists(charsetToUse)
414
+ ? iconv.decode(rawBytes, charsetToUse)
415
+ : rawBytes.toString('utf8');
416
+ return { response, contentType: { type, encoding: 'utf-8' }, body };
417
417
  }
418
- else {
419
- const body = Buffer.from(await reencodedResponse.bytes());
420
- return {
421
- body,
422
- response,
423
- contentType,
424
- };
418
+ const { response: reencodedResponse, encoding } = this.#encodeResponse(request, response, charset);
419
+ const contentType = { type, encoding };
420
+ if (HTML_AND_XML_MIME_TYPES.includes(type)) {
421
+ return { response, contentType, body: await reencodedResponse.text() };
425
422
  }
423
+ return {
424
+ body: Buffer.from(await reencodedResponse.bytes()),
425
+ response,
426
+ contentType,
427
+ };
426
428
  }
427
429
  /**
428
430
  * Combines the provided `requestOptions` with mandatory (non-overridable) values.
429
431
  */
430
- getRequestOptions(request, session, proxyUrl) {
432
+ #getRequestOptions(request, proxyUrl) {
431
433
  const requestOptions = {
432
434
  url: request.url,
433
435
  method: request.method,
434
436
  proxyUrl,
435
437
  timeout: this.#navigationTimeoutMillis,
436
- sessionToken: session,
437
438
  headers: request.headers,
438
439
  body: undefined,
439
440
  };
@@ -445,7 +446,7 @@ export class HttpCrawler extends BasicCrawler {
445
446
  requestOptions.body = request.payload ?? '';
446
447
  return requestOptions;
447
448
  }
448
- encodeResponse(request, response, encoding) {
449
+ #encodeResponse(request, response, encoding) {
449
450
  if (this.#forceResponseEncoding) {
450
451
  encoding = this.#forceResponseEncoding;
451
452
  }
@@ -481,29 +482,27 @@ export class HttpCrawler extends BasicCrawler {
481
482
  /**
482
483
  * Checks and extends supported mime types
483
484
  */
484
- extendSupportedMimeTypes(additionalMimeTypes) {
485
+ #extendSupportedMimeTypes(additionalMimeTypes) {
485
486
  for (const mimeType of additionalMimeTypes) {
486
487
  if (mimeType === '*/*') {
487
488
  this.#supportedMimeTypes.add(mimeType);
488
489
  continue;
489
490
  }
490
- try {
491
- const parsedType = contentTypeParser.parse(mimeType);
492
- this.#supportedMimeTypes.add(parsedType.type);
493
- }
494
- catch (err) {
491
+ const parsedType = parseContentType(mimeType);
492
+ if (!isTypeValid(parsedType.type)) {
495
493
  throw new Error(`Can not parse mime type ${mimeType} from "options.additionalMimeTypes".`);
496
494
  }
495
+ this.#supportedMimeTypes.add(parsedType.type);
497
496
  }
498
497
  }
499
498
  /**
500
499
  * Handles timeout request
501
500
  */
502
- handleRequestTimeout(session) {
501
+ #handleRequestTimeout(session) {
503
502
  session.markBad();
504
503
  throw new Error(`Request timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
505
504
  }
506
- abortDownloadOfBody(request, response) {
505
+ #abortDownloadOfBody(request, response) {
507
506
  const { status } = response;
508
507
  const { type } = parseContentTypeFromResponse(response);
509
508
  const isTransientContentType = status >= 500 || this.blockedStatusCodes.has(status);
@@ -516,7 +515,7 @@ export class HttpCrawler extends BasicCrawler {
516
515
  /**
517
516
  * @internal wraps public utility for mocking purposes
518
517
  */
519
- requestAsBrowser = async (options, session) => {
518
+ #requestAsBrowser = async (options, session) => {
520
519
  const opts = processHttpRequestOptions({
521
520
  ...options,
522
521
  responseType: 'text',
@@ -540,6 +539,7 @@ export class HttpCrawler extends BasicCrawler {
540
539
  }), {
541
540
  session,
542
541
  cookieJar,
542
+ proxyUrl: opts.proxyUrl,
543
543
  signal: cancelSignal,
544
544
  timeoutMillis: cancelSignal ? undefined : opts.timeout,
545
545
  ignoreTlsErrors: this.#ignoreTlsErrors,
@@ -1,7 +1,7 @@
1
1
  import { extname } from 'node:path';
2
2
  import { Readable } from 'node:stream';
3
3
  import { applySearchParams, parseArgument } from '@crawlee/utils/internal';
4
- import contentTypeParser from 'content-type';
4
+ import { isTypeValid, parse as parseContentType } from 'content-type';
5
5
  import mime from 'mime-types';
6
6
  import { z } from 'zod';
7
7
  const responseWithUrlSchema = z.looseObject({ url: z.url(), headers: z.looseObject({}) });
@@ -59,18 +59,16 @@ export function parseContentTypeFromResponse(response) {
59
59
  const { url, headers } = response;
60
60
  let parsedContentType;
61
61
  if (headers.get('content-type')) {
62
- try {
63
- parsedContentType = contentTypeParser.parse(headers.get('content-type'));
64
- }
65
- catch {
66
- // Can not parse content type from Content-Type header. Try to parse it from file extension.
67
- }
62
+ const parsed = parseContentType(headers.get('content-type'));
63
+ // Can not parse content type from Content-Type header. Try to parse it from file extension.
64
+ if (isTypeValid(parsed.type))
65
+ parsedContentType = parsed;
68
66
  }
69
67
  // Parse content type from file extension as fallback
70
68
  if (!parsedContentType) {
71
69
  const parsedUrl = new URL(url);
72
70
  const contentTypeFromExtname = mime.contentType(extname(parsedUrl.pathname)) || 'application/octet-stream; charset=utf-8'; // Fallback content type, specified in https://tools.ietf.org/html/rfc7231#section-3.1.1.5
73
- parsedContentType = contentTypeParser.parse(contentTypeFromExtname);
71
+ parsedContentType = parseContentType(contentTypeFromExtname);
74
72
  }
75
73
  return {
76
74
  type: parsedContentType.type,
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@crawlee/http",
3
- "version": "4.0.0-rc.0",
3
+ "version": "4.0.0-rc.1",
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
- "node": ">=22.0.0"
6
+ "node": ">=22.13.0"
7
7
  },
8
8
  "type": "module",
9
9
  "exports": {
@@ -47,21 +47,18 @@
47
47
  "access": "public"
48
48
  },
49
49
  "dependencies": {
50
- "@apify/timeout": "^0.4.4",
51
- "@apify/utilities": "^2.15.5",
52
- "@crawlee/basic": "4.0.0-rc.0",
53
- "@crawlee/core": "4.0.0-rc.0",
54
- "@crawlee/http-client": "4.0.0-rc.0",
55
- "@crawlee/types": "4.0.0-rc.0",
56
- "@crawlee/utils": "4.0.0-rc.0",
57
- "@types/content-type": "^1.1.8",
50
+ "@apify/timeout": "^1.0.1",
51
+ "@crawlee/basic": "4.0.0-rc.1",
52
+ "@crawlee/http-client": "4.0.0-rc.1",
53
+ "@crawlee/types": "4.0.0-rc.1",
54
+ "@crawlee/utils": "4.0.0-rc.1",
58
55
  "cheerio": "^1.0.0",
59
- "content-type": "^1.0.5",
56
+ "content-type": "^3.1.1",
60
57
  "iconv-lite": "^0.7.2",
61
58
  "mime-types": "^3.0.1",
62
59
  "tslib": "^2.8.1",
63
60
  "type-fest": "^4.41.0",
64
- "zod": "^4.4.3"
61
+ "zod": "^4.5.4"
65
62
  },
66
63
  "lerna": {
67
64
  "command": {
@@ -70,5 +67,5 @@
70
67
  }
71
68
  }
72
69
  },
73
- "gitHead": "79ab33dacdacb83e0197e6516d145f3aceef80c7"
70
+ "gitHead": "f354ca5e943bed1a5c657a1fce1974c053f9fff0"
74
71
  }