@crawlee/http 4.0.0-beta.15 → 4.0.0-beta.151
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 +14 -14
- package/index.d.ts +0 -1
- package/index.js +0 -1
- package/internals/file-download.d.ts +13 -11
- package/internals/file-download.js +47 -43
- package/internals/http-crawler.d.ts +212 -270
- package/internals/http-crawler.js +259 -248
- package/internals/utils.d.ts +11 -1
- package/internals/utils.js +50 -6
- package/package.json +12 -11
- package/index.d.ts.map +0 -1
- package/index.js.map +0 -1
- package/internals/file-download.d.ts.map +0 -1
- package/internals/file-download.js.map +0 -1
- package/internals/http-crawler.d.ts.map +0 -1
- package/internals/http-crawler.js.map +0 -1
- package/internals/utils.d.ts.map +0 -1
- package/internals/utils.js.map +0 -1
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
import { Readable } from 'node:stream';
|
|
2
2
|
import util from 'node:util';
|
|
3
|
-
import { BasicCrawler,
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
3
|
+
import { BasicCrawler, ContextPipeline, NavigationSkippedError, remainingNavigationWindowMillis, RequestState, Router, SessionError, } from '@crawlee/basic';
|
|
4
|
+
import { RequestThrottledError, getCookiesFromResponse } from '@crawlee/core';
|
|
5
|
+
import { ResponseWithUrl } from '@crawlee/http-client';
|
|
6
|
+
import { parseArgument, RETRY_CSS_SELECTORS, schemas } from '@crawlee/utils/internal';
|
|
6
7
|
import contentTypeParser from 'content-type';
|
|
7
8
|
import iconv from 'iconv-lite';
|
|
8
|
-
import
|
|
9
|
-
import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
|
|
10
|
-
import { parseContentTypeFromResponse } from './utils.js';
|
|
11
|
-
let TimeoutError;
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { addTimeoutToPromise, storage, TimeoutError, tryCancel } from '@apify/timeout';
|
|
11
|
+
import { extractCharsetFromHtmlBytes, parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
|
|
12
12
|
/**
|
|
13
13
|
* Default mime types, which HttpScraper supports.
|
|
14
14
|
*/
|
|
15
15
|
const HTML_AND_XML_MIME_TYPES = ['text/html', 'text/xml', 'application/xhtml+xml', 'application/xml'];
|
|
16
16
|
const APPLICATION_JSON_MIME_TYPE = 'application/json';
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* 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.
|
|
20
|
+
*
|
|
21
|
+
* A {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} you supply yourself replaces that default
|
|
22
|
+
* wholesale, tuning included, so spread these options in if you want to keep it:
|
|
23
|
+
*
|
|
24
|
+
* ```typescript
|
|
25
|
+
* new ConcurrencySystem({ ...HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS, maxConcurrency: 50 });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export const HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS = {
|
|
18
29
|
desiredConcurrency: 10,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
loadSignals: {
|
|
31
|
+
eventLoop: {
|
|
32
|
+
snapshotIntervalSecs: 2,
|
|
33
|
+
maxBlockedMillis: 100,
|
|
34
|
+
overloadedRatio: 0.7,
|
|
35
|
+
},
|
|
25
36
|
},
|
|
26
37
|
};
|
|
27
38
|
/**
|
|
@@ -35,38 +46,40 @@ const HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
|
|
|
35
46
|
*
|
|
36
47
|
* This crawler downloads each URL using a plain HTTP request and doesn't do any HTML parsing.
|
|
37
48
|
*
|
|
38
|
-
* The source URLs are represented using {@link Request} objects that are fed from
|
|
39
|
-
* {@link
|
|
40
|
-
*
|
|
49
|
+
* The source URLs are represented using {@link Request} objects that are fed from the
|
|
50
|
+
* {@link IRequestManager|request manager} provided via the {@link HttpCrawlerOptions.requestManager|`requestManager`}
|
|
51
|
+
* constructor option (a {@link RequestQueue} is itself a request manager). To read from a read-only source such
|
|
52
|
+
* as a {@link RequestList} while still being able to enqueue new requests, combine it with a queue into a
|
|
53
|
+
* {@link RequestManagerTandem} via {@link IRequestLoader.toTandem|`requestLoader.toTandem()`} and pass the
|
|
54
|
+
* result as `requestManager`.
|
|
41
55
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* to {@link RequestQueue} before it starts their processing. This ensures that a single URL is not crawled multiple times.
|
|
56
|
+
* > The {@link HttpCrawlerOptions.requestList|`requestList`} and {@link HttpCrawlerOptions.requestQueue|`requestQueue`}
|
|
57
|
+
* > options are deprecated; they are still accepted and folded into a single `requestManager` for back-compat.
|
|
45
58
|
*
|
|
46
59
|
* The crawler finishes when there are no more {@link Request} objects to crawl.
|
|
47
60
|
*
|
|
48
|
-
* We can use the `preNavigationHooks` to adjust
|
|
61
|
+
* We can use the `preNavigationHooks` to adjust the crawling context before the request is made:
|
|
49
62
|
*
|
|
50
63
|
* ```javascript
|
|
51
64
|
* preNavigationHooks: [
|
|
52
|
-
* (crawlingContext
|
|
65
|
+
* (crawlingContext) => {
|
|
53
66
|
* // ...
|
|
54
67
|
* },
|
|
55
68
|
* ]
|
|
56
69
|
* ```
|
|
57
70
|
*
|
|
58
|
-
* By default, this crawler only processes web pages with the `text/html`
|
|
59
|
-
* and `application/
|
|
71
|
+
* By default, this crawler only processes web pages with the `text/html`, `application/xhtml+xml`, `text/xml`, `application/xml`,
|
|
72
|
+
* and `application/json` MIME content types (as reported by the `Content-Type` HTTP header),
|
|
60
73
|
* and skips pages with other content types. If you want the crawler to process other content types,
|
|
61
74
|
* use the {@link HttpCrawlerOptions.additionalMimeTypes} constructor option.
|
|
62
75
|
* Beware that the parsing behavior differs for HTML, XML, JSON and other types of content.
|
|
63
76
|
* For details, see {@link HttpCrawlerOptions.requestHandler}.
|
|
64
77
|
*
|
|
65
|
-
* New requests are only dispatched when there is enough free CPU and memory available,
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* {@link
|
|
78
|
+
* New requests are only dispatched when there is enough free CPU and memory available, as judged by the crawler's
|
|
79
|
+
* {@link ConcurrencySystem}.
|
|
80
|
+
* Concurrency is tuned via the `minConcurrency`, `maxConcurrency` and `maxRequestsPerMinute` options of the
|
|
81
|
+
* constructor, or, for finer control, by injecting a pre-configured
|
|
82
|
+
* {@link ConcurrencySystem|`concurrencySystem`}.
|
|
70
83
|
*
|
|
71
84
|
* **Example usage:**
|
|
72
85
|
*
|
|
@@ -92,109 +105,140 @@ const HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
|
|
|
92
105
|
* @category Crawlers
|
|
93
106
|
*/
|
|
94
107
|
export class HttpCrawler extends BasicCrawler {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
// Internal storage uses the base (non-extended) context types. The public option types are
|
|
109
|
+
// extension-aware for consumer DX, but internally the pipeline composes hooks against the
|
|
110
|
+
// concrete crawling context, which does not statically carry `ContextExtension`. The members
|
|
111
|
+
// added by `extendContext` are present at runtime regardless.
|
|
112
|
+
#preNavigationHooks;
|
|
113
|
+
#postNavigationHooks;
|
|
114
|
+
#saveResponseCookies;
|
|
115
|
+
#navigationTimeoutMillis;
|
|
116
|
+
#ignoreTlsErrors;
|
|
117
|
+
#suggestResponseEncoding;
|
|
118
|
+
#forceResponseEncoding;
|
|
119
|
+
#supportedMimeTypes;
|
|
120
|
+
/**
|
|
121
|
+
* @internal
|
|
122
|
+
*/
|
|
106
123
|
static optionsShape = {
|
|
107
124
|
...BasicCrawler.optionsShape,
|
|
108
|
-
navigationTimeoutSecs:
|
|
109
|
-
|
|
110
|
-
additionalMimeTypes:
|
|
111
|
-
suggestResponseEncoding:
|
|
112
|
-
forceResponseEncoding:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
preNavigationHooks: ow.optional.array,
|
|
117
|
-
postNavigationHooks: ow.optional.array,
|
|
125
|
+
navigationTimeoutSecs: schemas.anyNumber.default(30),
|
|
126
|
+
ignoreTlsErrors: z.boolean().default(true),
|
|
127
|
+
additionalMimeTypes: schemas.arrayOf(z.string(), 'strings').default(() => []),
|
|
128
|
+
suggestResponseEncoding: z.string().optional(),
|
|
129
|
+
forceResponseEncoding: z.string().optional(),
|
|
130
|
+
saveResponseCookies: z.boolean().default(true),
|
|
131
|
+
preNavigationHooks: schemas.anyArray.default(() => []),
|
|
132
|
+
postNavigationHooks: schemas.anyArray.default(() => []),
|
|
118
133
|
};
|
|
134
|
+
/** @internal */
|
|
135
|
+
static optionsSchema = z.strictObject(HttpCrawler.optionsShape);
|
|
119
136
|
/**
|
|
120
137
|
* All `HttpCrawlerOptions` parameters are passed via an options object.
|
|
121
138
|
*/
|
|
122
|
-
constructor(options = {}
|
|
123
|
-
|
|
124
|
-
const { navigationTimeoutSecs = 30, ignoreSslErrors = true, additionalMimeTypes = [], suggestResponseEncoding, forceResponseEncoding, persistCookiesPerSession, preNavigationHooks = [], postNavigationHooks = [], additionalHttpErrorStatusCodes = [], ignoreHttpErrorStatusCodes = [],
|
|
139
|
+
constructor(options = {}) {
|
|
140
|
+
const { navigationTimeoutSecs, ignoreTlsErrors, additionalMimeTypes, suggestResponseEncoding, forceResponseEncoding, saveResponseCookies, preNavigationHooks, postNavigationHooks,
|
|
125
141
|
// BasicCrawler
|
|
126
|
-
|
|
142
|
+
contextPipelineBuilder, ...basicCrawlerOptions } = parseArgument(options, HttpCrawler.optionsSchema, 'HttpCrawlerOptions');
|
|
127
143
|
super({
|
|
128
144
|
...basicCrawlerOptions,
|
|
129
|
-
autoscaledPoolOptions,
|
|
130
145
|
contextPipelineBuilder: contextPipelineBuilder ??
|
|
131
146
|
(() => this.buildContextPipeline()),
|
|
132
|
-
}
|
|
133
|
-
this
|
|
134
|
-
// Cookies should be persisted per session only if session pool is used
|
|
135
|
-
if (!this.useSessionPool && persistCookiesPerSession) {
|
|
136
|
-
throw new Error('You cannot use "persistCookiesPerSession" without "useSessionPool" set to true.');
|
|
137
|
-
}
|
|
138
|
-
this.supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
|
|
147
|
+
});
|
|
148
|
+
this.#supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
|
|
139
149
|
if (additionalMimeTypes.length)
|
|
140
|
-
this.
|
|
150
|
+
this.extendSupportedMimeTypes(additionalMimeTypes);
|
|
141
151
|
if (suggestResponseEncoding && forceResponseEncoding) {
|
|
142
152
|
this.log.warning('Both forceResponseEncoding and suggestResponseEncoding options are set. Using forceResponseEncoding.');
|
|
143
153
|
}
|
|
144
|
-
this
|
|
145
|
-
this
|
|
146
|
-
this
|
|
147
|
-
this
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
this
|
|
152
|
-
|
|
154
|
+
this.#navigationTimeoutMillis = navigationTimeoutSecs * 1000;
|
|
155
|
+
this.#ignoreTlsErrors = ignoreTlsErrors;
|
|
156
|
+
this.#suggestResponseEncoding = suggestResponseEncoding;
|
|
157
|
+
this.#forceResponseEncoding = forceResponseEncoding;
|
|
158
|
+
// Cast away the extension-aware option types to the base internal storage types (see the field
|
|
159
|
+
// declarations above). This is sound - the hooks only ever receive the base context plus the
|
|
160
|
+
// members `extendContext` added at runtime.
|
|
161
|
+
this.#preNavigationHooks = preNavigationHooks;
|
|
162
|
+
this.#postNavigationHooks = [
|
|
163
|
+
({ request, response }) => this.abortDownloadOfBody(request, response),
|
|
153
164
|
...postNavigationHooks,
|
|
154
165
|
];
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
this.#saveResponseCookies = saveResponseCookies;
|
|
167
|
+
}
|
|
168
|
+
getNavigationTimeoutMillis() {
|
|
169
|
+
return this.#navigationTimeoutMillis;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
|
|
173
|
+
* concurrency shortcuts on top. Not called for a supplied
|
|
174
|
+
* {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} — spread the constant into it yourself to
|
|
175
|
+
* keep the tuning.
|
|
176
|
+
*/
|
|
177
|
+
createDefaultConcurrencySystem(options) {
|
|
178
|
+
return super.createDefaultConcurrencySystem({
|
|
179
|
+
...HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS,
|
|
180
|
+
...options,
|
|
181
|
+
});
|
|
161
182
|
}
|
|
162
183
|
buildContextPipeline() {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
184
|
+
// When navigation is skipped, `prepareHttpRequest` has already installed throwing getters for
|
|
185
|
+
// the response-derived members, so the guarded action is bypassed and the context left untouched.
|
|
186
|
+
const skipGuard = (action) => ({
|
|
187
|
+
action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
|
|
188
|
+
});
|
|
189
|
+
// A single navigation window covers the pre-navigation hooks, the navigation, and the post-navigation
|
|
190
|
+
// hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow hook eats into the same
|
|
191
|
+
// window the navigation uses instead of each step being timed on its own.
|
|
192
|
+
const navigationTimedOut = `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`;
|
|
193
|
+
const windowGuard = (step) => skipGuard(async (ctx) => {
|
|
194
|
+
const remaining = remainingNavigationWindowMillis(ctx, this.#navigationTimeoutMillis);
|
|
195
|
+
if (remaining <= 0) {
|
|
196
|
+
throw new TimeoutError(navigationTimedOut);
|
|
197
|
+
}
|
|
198
|
+
return addTimeoutToPromise(async () => step(ctx), remaining, navigationTimedOut);
|
|
199
|
+
});
|
|
200
|
+
let pipeline = ContextPipeline.create().compose({
|
|
201
|
+
action: this.prepareHttpRequest.bind(this),
|
|
202
|
+
});
|
|
203
|
+
for (const hook of this.#preNavigationHooks) {
|
|
204
|
+
pipeline = pipeline.compose(windowGuard(hook));
|
|
205
|
+
}
|
|
206
|
+
let pipelineWithNavigation = pipeline.compose(skipGuard(this.makeHttpRequest.bind(this)));
|
|
207
|
+
for (const hook of this.#postNavigationHooks) {
|
|
208
|
+
pipelineWithNavigation = pipelineWithNavigation.compose(windowGuard(hook));
|
|
209
|
+
}
|
|
210
|
+
return pipelineWithNavigation
|
|
167
211
|
.compose({ action: this.processHttpResponse.bind(this) })
|
|
168
212
|
.compose({ action: this.handleBlockedRequestByContent.bind(this) });
|
|
169
213
|
}
|
|
170
|
-
async
|
|
171
|
-
const { request
|
|
214
|
+
async prepareHttpRequest(crawlingContext) {
|
|
215
|
+
const { request } = crawlingContext;
|
|
172
216
|
if (request.skipNavigation) {
|
|
173
217
|
return {
|
|
174
218
|
request: new Proxy(request, {
|
|
175
219
|
get(target, propertyName, receiver) {
|
|
176
220
|
if (propertyName === 'loadedUrl') {
|
|
177
|
-
throw new
|
|
221
|
+
throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
|
|
178
222
|
}
|
|
179
223
|
return Reflect.get(target, propertyName, receiver);
|
|
180
224
|
},
|
|
181
225
|
}),
|
|
182
226
|
get response() {
|
|
183
|
-
throw new
|
|
227
|
+
throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
|
|
184
228
|
},
|
|
185
229
|
};
|
|
186
230
|
}
|
|
187
|
-
const gotOptions = {};
|
|
188
|
-
const preNavigationHooksCookies = this._getCookieHeaderFromRequest(request);
|
|
189
231
|
request.state = RequestState.BEFORE_NAV;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
async makeHttpRequest(crawlingContext) {
|
|
193
235
|
tryCancel();
|
|
194
|
-
const
|
|
195
|
-
this._applyCookies(crawlingContext, gotOptions, preNavigationHooksCookies, postNavigationHooksCookies);
|
|
236
|
+
const { request, session } = crawlingContext;
|
|
196
237
|
const proxyUrl = crawlingContext.proxyInfo?.url;
|
|
197
|
-
|
|
238
|
+
// Bound the request by whatever is left of the shared navigation window (the pre-navigation hooks may
|
|
239
|
+
// have already spent part of it), so it produces a clean navigation-timeout error rather than the raw
|
|
240
|
+
// client abort.
|
|
241
|
+
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.`);
|
|
198
242
|
tryCancel();
|
|
199
243
|
request.loadedUrl = httpResponse?.url;
|
|
200
244
|
request.state = RequestState.AFTER_NAV;
|
|
@@ -204,46 +248,79 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
204
248
|
if (crawlingContext.request.skipNavigation) {
|
|
205
249
|
return {
|
|
206
250
|
get contentType() {
|
|
207
|
-
throw new
|
|
251
|
+
throw new NavigationSkippedError('The `contentType` property is not available - `skipNavigation` was used');
|
|
208
252
|
},
|
|
209
253
|
get body() {
|
|
210
|
-
throw new
|
|
254
|
+
throw new NavigationSkippedError('The `body` property is not available - `skipNavigation` was used');
|
|
211
255
|
},
|
|
212
256
|
get json() {
|
|
213
|
-
throw new
|
|
257
|
+
throw new NavigationSkippedError('The `json` property is not available - `skipNavigation` was used');
|
|
214
258
|
},
|
|
215
259
|
get waitForSelector() {
|
|
216
|
-
throw new
|
|
260
|
+
throw new NavigationSkippedError('The `waitForSelector` method is not available - `skipNavigation` was used');
|
|
217
261
|
},
|
|
218
262
|
get parseWithCheerio() {
|
|
219
|
-
throw new
|
|
263
|
+
throw new NavigationSkippedError('The `parseWithCheerio` method is not available - `skipNavigation` was used');
|
|
220
264
|
},
|
|
221
265
|
};
|
|
222
266
|
}
|
|
223
|
-
await this._executeHooks(this.postNavigationHooks, crawlingContext);
|
|
224
267
|
tryCancel();
|
|
225
|
-
|
|
268
|
+
// Before `parseResponse`, which throws for error status codes - a 429 the user opted into treating as an
|
|
269
|
+
// error is still a rate limit the domain should back off from.
|
|
270
|
+
if (crawlingContext.response.status === 429) {
|
|
271
|
+
const retryAfter = crawlingContext.response.headers.get('retry-after');
|
|
272
|
+
if (this.recordDomainRateLimit(crawlingContext.request.url, retryAfter)) {
|
|
273
|
+
// This is the one path that never reads the body, so cancel it to release the connection
|
|
274
|
+
// rather than leaving it to the garbage collector.
|
|
275
|
+
await crawlingContext.response.body?.cancel().catch(() => { });
|
|
276
|
+
throw new RequestThrottledError(`${crawlingContext.request.url} responded with 429.`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Reading the body is still part of the navigation, so it draws from the same shared window: on a server
|
|
280
|
+
// that streams the body slowly the request completes (headers arrive) but the body read would otherwise
|
|
281
|
+
// run unbounded. `extendTimeout` from a post-navigation hook has already pushed this deadline out if asked.
|
|
282
|
+
const remaining = remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis);
|
|
283
|
+
if (remaining <= 0) {
|
|
284
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
285
|
+
}
|
|
286
|
+
const parsed = await addTimeoutToPromise(async () => this.parseResponse(crawlingContext.request, crawlingContext.response), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
226
287
|
tryCancel();
|
|
227
288
|
const response = parsed.response;
|
|
228
289
|
const contentType = parsed.contentType;
|
|
229
290
|
const waitForSelector = async (selector, _timeoutMs) => {
|
|
291
|
+
const cheerio = await import('cheerio');
|
|
230
292
|
const $ = cheerio.load(parsed.body.toString());
|
|
231
293
|
if ($(selector).get().length === 0) {
|
|
232
294
|
throw new Error(`Selector '${selector}' not found.`);
|
|
233
295
|
}
|
|
234
296
|
};
|
|
235
297
|
const parseWithCheerio = async (selector, timeoutMs) => {
|
|
298
|
+
const cheerio = await import('cheerio');
|
|
236
299
|
const $ = cheerio.load(parsed.body.toString());
|
|
237
300
|
if (selector) {
|
|
238
301
|
await crawlingContext.waitForSelector(selector, timeoutMs);
|
|
239
302
|
}
|
|
240
303
|
return $;
|
|
241
304
|
};
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
305
|
+
this.throwOnBlockedRequest(response.status);
|
|
306
|
+
if (this.#saveResponseCookies) {
|
|
307
|
+
try {
|
|
308
|
+
for (const cookie of getCookiesFromResponse(response)) {
|
|
309
|
+
if (!cookie)
|
|
310
|
+
continue;
|
|
311
|
+
try {
|
|
312
|
+
await crawlingContext.session.cookieJar.setCookie(cookie, response.url, {
|
|
313
|
+
ignoreError: false,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
this.log.debug(`Could not set cookie: ${e.message}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
this.log.exception(e, 'Could not get cookies from response');
|
|
323
|
+
}
|
|
247
324
|
}
|
|
248
325
|
return {
|
|
249
326
|
get json() {
|
|
@@ -274,78 +351,28 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
274
351
|
return `Found selectors: ${foundSelectors.join(', ')}`;
|
|
275
352
|
}
|
|
276
353
|
}
|
|
277
|
-
|
|
278
|
-
// eslint-disable-next-line dot-notation
|
|
279
|
-
(this.sessionPool?.['blockedStatusCodes'].length ?? 0) > 0
|
|
280
|
-
? // eslint-disable-next-line dot-notation
|
|
281
|
-
this.sessionPool['blockedStatusCodes']
|
|
282
|
-
: BLOCKED_STATUS_CODES;
|
|
283
|
-
if (blockedStatusCodes.includes(crawlingContext.response.status)) {
|
|
354
|
+
if (this.blockedStatusCodes.has(crawlingContext.response.status)) {
|
|
284
355
|
return `Blocked by status code ${crawlingContext.response.status}`;
|
|
285
356
|
}
|
|
286
357
|
return false;
|
|
287
358
|
}
|
|
288
|
-
/**
|
|
289
|
-
* Sets the cookie header to `gotOptions` based on the provided request and session headers, as well as any changes that occurred due to hooks.
|
|
290
|
-
*/
|
|
291
|
-
_applyCookies({ session, request }, gotOptions, preHookCookies, postHookCookies) {
|
|
292
|
-
const sessionCookie = session?.getCookieString(request.url) ?? '';
|
|
293
|
-
let alteredGotOptionsCookies = gotOptions.headers?.Cookie || gotOptions.headers?.cookie || '';
|
|
294
|
-
if (gotOptions.headers?.Cookie && gotOptions.headers?.cookie) {
|
|
295
|
-
const { Cookie: upperCaseHeader, cookie: lowerCaseHeader } = gotOptions.headers;
|
|
296
|
-
this.log.warning(`Encountered mixed casing for the cookie headers in the got options for request ${request.url} (${request.id}). Their values will be merged`);
|
|
297
|
-
const sourceCookies = [];
|
|
298
|
-
if (Array.isArray(lowerCaseHeader)) {
|
|
299
|
-
sourceCookies.push(...lowerCaseHeader);
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
sourceCookies.push(lowerCaseHeader);
|
|
303
|
-
}
|
|
304
|
-
if (Array.isArray(upperCaseHeader)) {
|
|
305
|
-
sourceCookies.push(...upperCaseHeader);
|
|
306
|
-
}
|
|
307
|
-
else {
|
|
308
|
-
sourceCookies.push(upperCaseHeader);
|
|
309
|
-
}
|
|
310
|
-
alteredGotOptionsCookies = mergeCookies(request.url, sourceCookies);
|
|
311
|
-
}
|
|
312
|
-
const sourceCookies = [sessionCookie, preHookCookies];
|
|
313
|
-
if (Array.isArray(alteredGotOptionsCookies)) {
|
|
314
|
-
sourceCookies.push(...alteredGotOptionsCookies);
|
|
315
|
-
}
|
|
316
|
-
else {
|
|
317
|
-
sourceCookies.push(alteredGotOptionsCookies);
|
|
318
|
-
}
|
|
319
|
-
sourceCookies.push(postHookCookies);
|
|
320
|
-
const mergedCookie = mergeCookies(request.url, sourceCookies);
|
|
321
|
-
gotOptions.headers ??= {};
|
|
322
|
-
Reflect.deleteProperty(gotOptions.headers, 'Cookie');
|
|
323
|
-
Reflect.deleteProperty(gotOptions.headers, 'cookie');
|
|
324
|
-
if (mergedCookie !== '') {
|
|
325
|
-
gotOptions.headers.Cookie = mergedCookie;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
359
|
/**
|
|
329
360
|
* Function to make the HTTP request. It performs optimizations
|
|
330
361
|
* on the request such as only downloading the request body if the
|
|
331
362
|
* received content type matches text/html, application/xml, application/xhtml+xml.
|
|
332
363
|
*/
|
|
333
|
-
async
|
|
334
|
-
|
|
335
|
-
// @ts-ignore
|
|
336
|
-
({ TimeoutError } = await import('got-scraping'));
|
|
337
|
-
}
|
|
338
|
-
const opts = this._getRequestOptions(request, session, proxyUrl, gotOptions);
|
|
364
|
+
async requestFunction({ request, session, proxyUrl }) {
|
|
365
|
+
const opts = this.getRequestOptions(request, session, proxyUrl);
|
|
339
366
|
try {
|
|
340
|
-
return await this.
|
|
367
|
+
return await this.requestAsBrowser(opts, session);
|
|
341
368
|
}
|
|
342
369
|
catch (e) {
|
|
343
|
-
if (e instanceof TimeoutError) {
|
|
344
|
-
this.
|
|
345
|
-
return new Response(); // this will never happen, as
|
|
370
|
+
if (e instanceof Error && e.constructor.name === 'TimeoutError') {
|
|
371
|
+
this.handleRequestTimeout(session);
|
|
372
|
+
return new Response(); // this will never happen, as handleRequestTimeout always throws
|
|
346
373
|
}
|
|
347
374
|
if (this.isProxyError(e)) {
|
|
348
|
-
throw new SessionError(this.
|
|
375
|
+
throw new SessionError(this.getMessageFromError(e));
|
|
349
376
|
}
|
|
350
377
|
else {
|
|
351
378
|
throw e;
|
|
@@ -355,17 +382,15 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
355
382
|
/**
|
|
356
383
|
* Encodes and parses response according to the provided content type
|
|
357
384
|
*/
|
|
358
|
-
async
|
|
385
|
+
async parseResponse(request, response) {
|
|
359
386
|
const { status } = response;
|
|
360
387
|
const { type, charset } = parseContentTypeFromResponse(response);
|
|
361
|
-
const { response: reencodedResponse, encoding } = this.
|
|
388
|
+
const { response: reencodedResponse, encoding } = this.encodeResponse(request, response, charset);
|
|
362
389
|
const contentType = { type, encoding };
|
|
363
390
|
if (status >= 400 && status <= 599) {
|
|
364
|
-
this.
|
|
391
|
+
this.statistics.registerStatusCode(status);
|
|
365
392
|
}
|
|
366
|
-
|
|
367
|
-
const includeError = this.additionalHttpErrorStatusCodes.has(status);
|
|
368
|
-
if ((status >= 500 && !excludeError) || includeError) {
|
|
393
|
+
if (this.isErrorStatusCode(status)) {
|
|
369
394
|
const body = await reencodedResponse.text(); // TODO - this always uses UTF-8 (see https://developer.mozilla.org/en-US/docs/Web/API/Request/text)
|
|
370
395
|
// Errors are often sent as JSON, so attempt to parse them,
|
|
371
396
|
// despite Accept header being set to text/html.
|
|
@@ -376,17 +401,26 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
376
401
|
message = util.inspect(errorResponse, { depth: 1, maxArrayLength: 10 });
|
|
377
402
|
throw new Error(`${status} - ${message}`);
|
|
378
403
|
}
|
|
379
|
-
if (
|
|
404
|
+
if (this.additionalHttpErrorStatusCodes.has(status)) {
|
|
380
405
|
throw new Error(`${status} - Error status code was set by user.`);
|
|
381
406
|
}
|
|
382
407
|
// It's not a JSON, so it's probably some text. Get the first 100 chars of it.
|
|
383
408
|
throw new Error(`${status} - Internal Server Error: ${body.slice(0, 100)}`);
|
|
384
409
|
}
|
|
385
410
|
else if (HTML_AND_XML_MIME_TYPES.includes(type)) {
|
|
386
|
-
|
|
411
|
+
if (!charset && !this.#forceResponseEncoding) {
|
|
412
|
+
const rawBytes = Buffer.from(await response.arrayBuffer());
|
|
413
|
+
const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
|
|
414
|
+
const charsetToUse = metaCharset ?? this.#suggestResponseEncoding ?? 'utf-8';
|
|
415
|
+
const body = iconv.encodingExists(charsetToUse)
|
|
416
|
+
? iconv.decode(rawBytes, charsetToUse)
|
|
417
|
+
: rawBytes.toString('utf8');
|
|
418
|
+
return { response, contentType: { type, encoding: 'utf-8' }, body };
|
|
419
|
+
}
|
|
420
|
+
return { response, contentType, body: await reencodedResponse.text() };
|
|
387
421
|
}
|
|
388
422
|
else {
|
|
389
|
-
const body = Buffer.from(await
|
|
423
|
+
const body = Buffer.from(await reencodedResponse.bytes());
|
|
390
424
|
return {
|
|
391
425
|
body,
|
|
392
426
|
response,
|
|
@@ -397,41 +431,30 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
397
431
|
/**
|
|
398
432
|
* Combines the provided `requestOptions` with mandatory (non-overridable) values.
|
|
399
433
|
*/
|
|
400
|
-
|
|
434
|
+
getRequestOptions(request, session, proxyUrl) {
|
|
401
435
|
const requestOptions = {
|
|
402
436
|
url: request.url,
|
|
403
437
|
method: request.method,
|
|
404
438
|
proxyUrl,
|
|
405
|
-
timeout:
|
|
406
|
-
cookieJar: this.persistCookiesPerSession ? session?.cookieJar : undefined,
|
|
439
|
+
timeout: this.#navigationTimeoutMillis,
|
|
407
440
|
sessionToken: session,
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
https: {
|
|
411
|
-
...gotOptions?.https,
|
|
412
|
-
rejectUnauthorized: !this.ignoreSslErrors,
|
|
413
|
-
},
|
|
414
|
-
isStream: true,
|
|
441
|
+
headers: request.headers,
|
|
442
|
+
body: undefined,
|
|
415
443
|
};
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (session?.proxyInfo?.ignoreTlsErrors) {
|
|
420
|
-
requestOptions.https = {
|
|
421
|
-
...requestOptions.https,
|
|
422
|
-
rejectUnauthorized: false,
|
|
423
|
-
};
|
|
444
|
+
if (requestOptions.headers?.cookie || requestOptions.headers?.Cookie) {
|
|
445
|
+
requestOptions.headers.Cookie = this.getCookieHeaderFromRequest(request);
|
|
446
|
+
delete requestOptions.headers.cookie;
|
|
424
447
|
}
|
|
425
448
|
if (/PATCH|POST|PUT/.test(request.method))
|
|
426
449
|
requestOptions.body = request.payload ?? '';
|
|
427
450
|
return requestOptions;
|
|
428
451
|
}
|
|
429
|
-
|
|
430
|
-
if (this
|
|
431
|
-
encoding = this
|
|
452
|
+
encodeResponse(request, response, encoding) {
|
|
453
|
+
if (this.#forceResponseEncoding) {
|
|
454
|
+
encoding = this.#forceResponseEncoding;
|
|
432
455
|
}
|
|
433
|
-
else if (!encoding && this
|
|
434
|
-
encoding = this
|
|
456
|
+
else if (!encoding && this.#suggestResponseEncoding) {
|
|
457
|
+
encoding = this.#suggestResponseEncoding;
|
|
435
458
|
}
|
|
436
459
|
// Fall back to utf-8 if we still don't have encoding.
|
|
437
460
|
const utf8 = 'utf8';
|
|
@@ -444,7 +467,9 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
444
467
|
// Try to re-encode a variety of unsupported encodings to utf-8
|
|
445
468
|
if (iconv.encodingExists(encoding)) {
|
|
446
469
|
const encodeStream = iconv.encodeStream(utf8);
|
|
447
|
-
const decodeStream = iconv
|
|
470
|
+
const decodeStream = iconv
|
|
471
|
+
.decodeStream(encoding)
|
|
472
|
+
.on('error', (err) => encodeStream.emit('error', err));
|
|
448
473
|
const reencodedBody = response.body
|
|
449
474
|
? Readable.toWeb(Readable.from(Readable.fromWeb(response.body)
|
|
450
475
|
.pipe(decodeStream)
|
|
@@ -460,15 +485,15 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
460
485
|
/**
|
|
461
486
|
* Checks and extends supported mime types
|
|
462
487
|
*/
|
|
463
|
-
|
|
488
|
+
extendSupportedMimeTypes(additionalMimeTypes) {
|
|
464
489
|
for (const mimeType of additionalMimeTypes) {
|
|
465
490
|
if (mimeType === '*/*') {
|
|
466
|
-
this
|
|
491
|
+
this.#supportedMimeTypes.add(mimeType);
|
|
467
492
|
continue;
|
|
468
493
|
}
|
|
469
494
|
try {
|
|
470
495
|
const parsedType = contentTypeParser.parse(mimeType);
|
|
471
|
-
this
|
|
496
|
+
this.#supportedMimeTypes.add(parsedType.type);
|
|
472
497
|
}
|
|
473
498
|
catch (err) {
|
|
474
499
|
throw new Error(`Can not parse mime type ${mimeType} from "options.additionalMimeTypes".`);
|
|
@@ -478,68 +503,54 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
478
503
|
/**
|
|
479
504
|
* Handles timeout request
|
|
480
505
|
*/
|
|
481
|
-
|
|
482
|
-
session
|
|
483
|
-
throw new Error(`
|
|
506
|
+
handleRequestTimeout(session) {
|
|
507
|
+
session.markBad();
|
|
508
|
+
throw new Error(`Request timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
484
509
|
}
|
|
485
|
-
|
|
510
|
+
abortDownloadOfBody(request, response) {
|
|
486
511
|
const { status } = response;
|
|
487
512
|
const { type } = parseContentTypeFromResponse(response);
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
// if we retry the request, can the Content-Type change?
|
|
491
|
-
const isTransientContentType = status >= 500 || blockedStatusCodes.includes(status);
|
|
492
|
-
if (!this.supportedMimeTypes.has(type) && !this.supportedMimeTypes.has('*/*') && !isTransientContentType) {
|
|
513
|
+
const isTransientContentType = status >= 500 || this.blockedStatusCodes.has(status);
|
|
514
|
+
if (!this.#supportedMimeTypes.has(type) && !this.#supportedMimeTypes.has('*/*') && !isTransientContentType) {
|
|
493
515
|
request.noRetry = true;
|
|
494
516
|
throw new Error(`Resource ${request.url} served Content-Type ${type}, ` +
|
|
495
|
-
`but only ${Array.from(this
|
|
517
|
+
`but only ${Array.from(this.#supportedMimeTypes).join(', ')} are allowed. Skipping resource.`);
|
|
496
518
|
}
|
|
497
519
|
}
|
|
498
520
|
/**
|
|
499
521
|
* @internal wraps public utility for mocking purposes
|
|
500
522
|
*/
|
|
501
|
-
|
|
502
|
-
const
|
|
523
|
+
requestAsBrowser = async (options, session) => {
|
|
524
|
+
const opts = processHttpRequestOptions({
|
|
503
525
|
...options,
|
|
504
|
-
cookieJar: options.cookieJar,
|
|
505
526
|
responseType: 'text',
|
|
506
|
-
})
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
527
|
+
});
|
|
528
|
+
// When saveResponseCookies is false, the response cookies must not mutate the
|
|
529
|
+
// session jar. Reads still go through the session (so session.setCookie() in pre-nav
|
|
530
|
+
// hooks keeps working) but a per-request clone is passed in so writes are discarded.
|
|
531
|
+
const cookieJar = this.#saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
|
|
532
|
+
// Bind the request to the shared navigation window instead of a fixed per-request timeout, so
|
|
533
|
+
// `extendTimeout()` can push the deadline and a fixed `AbortSignal.timeout` won't fire on its own and
|
|
534
|
+
// kill a lazily-read body mid-extension. This aborts the socket only during the header phase; the body
|
|
535
|
+
// read is bounded separately at the promise level (see `processHttpResponse`), so a slow-streaming body
|
|
536
|
+
// still fails cleanly with a navigation timeout, though the socket is left to close on its own.
|
|
537
|
+
const cancelSignal = storage.getStore()?.cancelTask.signal;
|
|
538
|
+
const response = await this.httpClient.sendRequest(new Request(opts.url, {
|
|
539
|
+
body: opts.body ? Readable.toWeb(opts.body) : undefined,
|
|
540
|
+
headers: new Headers(opts.headers),
|
|
541
|
+
method: opts.method,
|
|
542
|
+
// Node-specific option to make the request body work with streams
|
|
543
|
+
duplex: 'half',
|
|
544
|
+
}), {
|
|
545
|
+
session,
|
|
546
|
+
cookieJar,
|
|
547
|
+
signal: cancelSignal,
|
|
548
|
+
timeoutMillis: cancelSignal ? undefined : opts.timeout,
|
|
549
|
+
ignoreTlsErrors: this.#ignoreTlsErrors,
|
|
514
550
|
});
|
|
515
551
|
return response;
|
|
516
552
|
};
|
|
517
553
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
* This instance can then serve as a `requestHandler` of your {@link HttpCrawler}.
|
|
521
|
-
* Defaults to the {@link HttpCrawlingContext}.
|
|
522
|
-
*
|
|
523
|
-
* > Serves as a shortcut for using `Router.create<HttpCrawlingContext>()`.
|
|
524
|
-
*
|
|
525
|
-
* ```ts
|
|
526
|
-
* import { HttpCrawler, createHttpRouter } from 'crawlee';
|
|
527
|
-
*
|
|
528
|
-
* const router = createHttpRouter();
|
|
529
|
-
* router.addHandler('label-a', async (ctx) => {
|
|
530
|
-
* ctx.log.info('...');
|
|
531
|
-
* });
|
|
532
|
-
* router.addDefaultHandler(async (ctx) => {
|
|
533
|
-
* ctx.log.info('...');
|
|
534
|
-
* });
|
|
535
|
-
*
|
|
536
|
-
* const crawler = new HttpCrawler({
|
|
537
|
-
* requestHandler: router,
|
|
538
|
-
* });
|
|
539
|
-
* await crawler.run();
|
|
540
|
-
* ```
|
|
541
|
-
*/
|
|
542
|
-
export function createHttpRouter(routes) {
|
|
543
|
-
return Router.create(routes);
|
|
554
|
+
export function createHttpRouter(routesOrSchemas) {
|
|
555
|
+
return Router.create(routesOrSchemas);
|
|
544
556
|
}
|
|
545
|
-
//# sourceMappingURL=http-crawler.js.map
|