@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 +1 -1
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/internals/dom-crawler.d.ts +131 -0
- package/internals/dom-crawler.js +93 -0
- package/internals/file-download.d.ts +11 -61
- package/internals/file-download.js +19 -117
- package/internals/http-crawler.d.ts +42 -65
- package/internals/http-crawler.js +72 -72
- package/internals/utils.js +6 -8
- package/package.json +10 -13
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
|
|
37
|
+
> Crawlee requires **Node.js 22.13 or higher**.
|
|
38
38
|
|
|
39
39
|
### With Crawlee CLI
|
|
40
40
|
|
package/index.d.ts
CHANGED
package/index.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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<
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
128
|
-
export declare function createFileRouter<Context extends FileDownloadCrawlingContext = FileDownloadCrawlingContext, UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(routes?: RouterRoutes<Context, UserData
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
43
|
+
contextPipelineBuilder: () => this.#buildContextPipeline(),
|
|
114
44
|
});
|
|
115
45
|
}
|
|
116
|
-
buildContextPipeline() {
|
|
117
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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?:
|
|
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<
|
|
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.
|
|
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
|
-
*
|
|
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<
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
208
|
-
.compose(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
362
|
+
async #requestFunction({ request, session, proxyUrl }) {
|
|
363
|
+
const opts = this.#getRequestOptions(request, proxyUrl);
|
|
362
364
|
try {
|
|
363
|
-
return await this
|
|
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
|
|
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
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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,
|
|
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
|
-
|
|
491
|
-
|
|
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,
|
package/internals/utils.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
51
|
-
"@
|
|
52
|
-
"@crawlee/
|
|
53
|
-
"@crawlee/
|
|
54
|
-
"@crawlee/
|
|
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.
|
|
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
|
|
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": "
|
|
70
|
+
"gitHead": "f354ca5e943bed1a5c657a1fce1974c053f9fff0"
|
|
74
71
|
}
|