@crawlee/http 4.0.0-beta.8 → 4.0.0-beta.81
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 +17 -13
- package/index.d.ts +0 -1
- package/index.js +0 -1
- package/internals/file-download.d.ts +60 -34
- package/internals/file-download.js +118 -76
- package/internals/http-crawler.d.ts +100 -221
- package/internals/http-crawler.js +216 -363
- package/internals/utils.d.ts +19 -0
- package/internals/utils.js +81 -0
- package/package.json +9 -8
- 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/tsconfig.build.tsbuildinfo +0 -1
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
2
|
import util from 'node:util';
|
|
3
|
-
import {
|
|
3
|
+
import { BasicCrawler, ContextPipeline, NavigationSkippedError, RequestState, Router, SessionError, } from '@crawlee/basic';
|
|
4
|
+
import { getCookiesFromResponse } from '@crawlee/core';
|
|
5
|
+
import { ResponseWithUrl } from '@crawlee/http-client';
|
|
4
6
|
import { RETRY_CSS_SELECTORS } from '@crawlee/utils';
|
|
5
|
-
import * as cheerio from 'cheerio';
|
|
6
7
|
import contentTypeParser from 'content-type';
|
|
7
8
|
import iconv from 'iconv-lite';
|
|
8
|
-
import
|
|
9
|
-
import ow, { ObjectPredicate } from 'ow';
|
|
9
|
+
import ow from 'ow';
|
|
10
10
|
import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
|
|
11
|
-
import {
|
|
12
|
-
let TimeoutError;
|
|
11
|
+
import { extractCharsetFromHtmlBytes, parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
|
|
13
12
|
/**
|
|
14
13
|
* Default mime types, which HttpScraper supports.
|
|
15
14
|
*/
|
|
@@ -36,28 +35,30 @@ const HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
|
|
|
36
35
|
*
|
|
37
36
|
* This crawler downloads each URL using a plain HTTP request and doesn't do any HTML parsing.
|
|
38
37
|
*
|
|
39
|
-
* The source URLs are represented using {@link Request} objects that are fed from
|
|
40
|
-
* {@link
|
|
41
|
-
*
|
|
38
|
+
* The source URLs are represented using {@link Request} objects that are fed from the
|
|
39
|
+
* {@link IRequestManager|request manager} provided via the {@link HttpCrawlerOptions.requestManager|`requestManager`}
|
|
40
|
+
* constructor option (a {@link RequestQueue} is itself a request manager). To read from a read-only source such
|
|
41
|
+
* as a {@link RequestList} while still being able to enqueue new requests, combine it with a queue into a
|
|
42
|
+
* {@link RequestManagerTandem} via {@link IRequestLoader.toTandem|`requestLoader.toTandem()`} and pass the
|
|
43
|
+
* result as `requestManager`.
|
|
42
44
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* to {@link RequestQueue} before it starts their processing. This ensures that a single URL is not crawled multiple times.
|
|
45
|
+
* > The {@link HttpCrawlerOptions.requestList|`requestList`} and {@link HttpCrawlerOptions.requestQueue|`requestQueue`}
|
|
46
|
+
* > options are deprecated; they are still accepted and folded into a single `requestManager` for back-compat.
|
|
46
47
|
*
|
|
47
48
|
* The crawler finishes when there are no more {@link Request} objects to crawl.
|
|
48
49
|
*
|
|
49
|
-
* We can use the `preNavigationHooks` to adjust
|
|
50
|
+
* We can use the `preNavigationHooks` to adjust the crawling context before the request is made:
|
|
50
51
|
*
|
|
51
52
|
* ```javascript
|
|
52
53
|
* preNavigationHooks: [
|
|
53
|
-
* (crawlingContext
|
|
54
|
+
* (crawlingContext) => {
|
|
54
55
|
* // ...
|
|
55
56
|
* },
|
|
56
57
|
* ]
|
|
57
58
|
* ```
|
|
58
59
|
*
|
|
59
|
-
* By default, this crawler only processes web pages with the `text/html`
|
|
60
|
-
* and `application/
|
|
60
|
+
* By default, this crawler only processes web pages with the `text/html`, `application/xhtml+xml`, `text/xml`, `application/xml`,
|
|
61
|
+
* and `application/json` MIME content types (as reported by the `Content-Type` HTTP header),
|
|
61
62
|
* and skips pages with other content types. If you want the crawler to process other content types,
|
|
62
63
|
* use the {@link HttpCrawlerOptions.additionalMimeTypes} constructor option.
|
|
63
64
|
* Beware that the parsing behavior differs for HTML, XML, JSON and other types of content.
|
|
@@ -93,22 +94,13 @@ const HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
|
|
|
93
94
|
* @category Crawlers
|
|
94
95
|
*/
|
|
95
96
|
export class HttpCrawler extends BasicCrawler {
|
|
96
|
-
config;
|
|
97
|
-
/**
|
|
98
|
-
* A reference to the underlying {@link ProxyConfiguration} class that manages the crawler's proxies.
|
|
99
|
-
* Only available if used by the crawler.
|
|
100
|
-
*/
|
|
101
|
-
proxyConfiguration;
|
|
102
|
-
userRequestHandlerTimeoutMillis;
|
|
103
97
|
preNavigationHooks;
|
|
104
98
|
postNavigationHooks;
|
|
105
|
-
|
|
99
|
+
saveResponseCookies;
|
|
106
100
|
navigationTimeoutMillis;
|
|
107
101
|
ignoreSslErrors;
|
|
108
102
|
suggestResponseEncoding;
|
|
109
103
|
forceResponseEncoding;
|
|
110
|
-
additionalHttpErrorStatusCodes;
|
|
111
|
-
ignoreHttpErrorStatusCodes;
|
|
112
104
|
supportedMimeTypes;
|
|
113
105
|
static optionsShape = {
|
|
114
106
|
...BasicCrawler.optionsShape,
|
|
@@ -117,157 +109,169 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
117
109
|
additionalMimeTypes: ow.optional.array.ofType(ow.string),
|
|
118
110
|
suggestResponseEncoding: ow.optional.string,
|
|
119
111
|
forceResponseEncoding: ow.optional.string,
|
|
120
|
-
|
|
121
|
-
persistCookiesPerSession: ow.optional.boolean,
|
|
122
|
-
additionalHttpErrorStatusCodes: ow.optional.array.ofType(ow.number),
|
|
123
|
-
ignoreHttpErrorStatusCodes: ow.optional.array.ofType(ow.number),
|
|
112
|
+
saveResponseCookies: ow.optional.boolean,
|
|
124
113
|
preNavigationHooks: ow.optional.array,
|
|
125
114
|
postNavigationHooks: ow.optional.array,
|
|
126
115
|
};
|
|
127
116
|
/**
|
|
128
117
|
* All `HttpCrawlerOptions` parameters are passed via an options object.
|
|
129
118
|
*/
|
|
130
|
-
constructor(options = {}
|
|
119
|
+
constructor(options = {}) {
|
|
131
120
|
ow(options, 'HttpCrawlerOptions', ow.object.exactShape(HttpCrawler.optionsShape));
|
|
132
|
-
const {
|
|
121
|
+
const { navigationTimeoutSecs = 30, ignoreSslErrors = true, additionalMimeTypes = [], suggestResponseEncoding, forceResponseEncoding, saveResponseCookies = true, preNavigationHooks = [], postNavigationHooks = [],
|
|
133
122
|
// BasicCrawler
|
|
134
|
-
autoscaledPoolOptions = HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS, ...basicCrawlerOptions } = options;
|
|
123
|
+
autoscaledPoolOptions = HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS, contextPipelineBuilder, ...basicCrawlerOptions } = options;
|
|
135
124
|
super({
|
|
136
125
|
...basicCrawlerOptions,
|
|
137
|
-
requestHandler,
|
|
138
126
|
autoscaledPoolOptions,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}, config);
|
|
143
|
-
this.config = config;
|
|
144
|
-
this.requestHandler = requestHandler ?? this.router;
|
|
145
|
-
// Cookies should be persisted per session only if session pool is used
|
|
146
|
-
if (!this.useSessionPool && persistCookiesPerSession) {
|
|
147
|
-
throw new Error('You cannot use "persistCookiesPerSession" without "useSessionPool" set to true.');
|
|
148
|
-
}
|
|
127
|
+
contextPipelineBuilder: contextPipelineBuilder ??
|
|
128
|
+
(() => this.buildContextPipeline()),
|
|
129
|
+
});
|
|
149
130
|
this.supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
|
|
150
131
|
if (additionalMimeTypes.length)
|
|
151
|
-
this.
|
|
132
|
+
this.extendSupportedMimeTypes(additionalMimeTypes);
|
|
152
133
|
if (suggestResponseEncoding && forceResponseEncoding) {
|
|
153
134
|
this.log.warning('Both forceResponseEncoding and suggestResponseEncoding options are set. Using forceResponseEncoding.');
|
|
154
135
|
}
|
|
155
|
-
this.userRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
|
|
156
136
|
this.navigationTimeoutMillis = navigationTimeoutSecs * 1000;
|
|
157
137
|
this.ignoreSslErrors = ignoreSslErrors;
|
|
158
138
|
this.suggestResponseEncoding = suggestResponseEncoding;
|
|
159
139
|
this.forceResponseEncoding = forceResponseEncoding;
|
|
160
|
-
this.additionalHttpErrorStatusCodes = new Set([...additionalHttpErrorStatusCodes]);
|
|
161
|
-
this.ignoreHttpErrorStatusCodes = new Set([...ignoreHttpErrorStatusCodes]);
|
|
162
|
-
this.proxyConfiguration = proxyConfiguration;
|
|
163
140
|
this.preNavigationHooks = preNavigationHooks;
|
|
164
141
|
this.postNavigationHooks = [
|
|
165
142
|
({ request, response }) => this._abortDownloadOfBody(request, response),
|
|
166
143
|
...postNavigationHooks,
|
|
167
144
|
];
|
|
168
|
-
|
|
169
|
-
|
|
145
|
+
this.saveResponseCookies = saveResponseCookies;
|
|
146
|
+
}
|
|
147
|
+
buildContextPipeline() {
|
|
148
|
+
// When navigation is skipped, `prepareHttpRequest` has already installed throwing getters for
|
|
149
|
+
// the response-derived members, so the guarded action is bypassed and the context left untouched.
|
|
150
|
+
const skipGuard = (action) => ({
|
|
151
|
+
action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
|
|
152
|
+
});
|
|
153
|
+
let pipeline = ContextPipeline.create().compose({
|
|
154
|
+
action: this.prepareHttpRequest.bind(this),
|
|
155
|
+
});
|
|
156
|
+
for (const hook of this.preNavigationHooks) {
|
|
157
|
+
pipeline = pipeline.compose(skipGuard(hook));
|
|
170
158
|
}
|
|
171
|
-
|
|
172
|
-
|
|
159
|
+
let pipelineWithNavigation = pipeline.compose(skipGuard(this.makeHttpRequest.bind(this)));
|
|
160
|
+
for (const hook of this.postNavigationHooks) {
|
|
161
|
+
pipelineWithNavigation = pipelineWithNavigation.compose(skipGuard(hook));
|
|
173
162
|
}
|
|
163
|
+
return pipelineWithNavigation
|
|
164
|
+
.compose({ action: this.processHttpResponse.bind(this) })
|
|
165
|
+
.compose({ action: this.handleBlockedRequestByContent.bind(this) });
|
|
174
166
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// Test if the property can be configured on the crawler
|
|
192
|
-
throw new Error(`${extension.name} tries to set property "${key}" that is not configurable on ${className} instance.`);
|
|
193
|
-
}
|
|
194
|
-
if (!isSameType && exists) {
|
|
195
|
-
// Assuming that extensions will only add up configuration
|
|
196
|
-
throw new Error(`${extension.name} tries to set property of different type "${extensionType}". "${className}.${key}: ${originalType}".`);
|
|
197
|
-
}
|
|
198
|
-
this.log.warning(`${extension.name} is overriding "${className}.${key}: ${originalType}" with ${value}.`);
|
|
199
|
-
this[key] = value;
|
|
167
|
+
async prepareHttpRequest(crawlingContext) {
|
|
168
|
+
const { request } = crawlingContext;
|
|
169
|
+
if (request.skipNavigation) {
|
|
170
|
+
return {
|
|
171
|
+
request: new Proxy(request, {
|
|
172
|
+
get(target, propertyName, receiver) {
|
|
173
|
+
if (propertyName === 'loadedUrl') {
|
|
174
|
+
throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
|
|
175
|
+
}
|
|
176
|
+
return Reflect.get(target, propertyName, receiver);
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
get response() {
|
|
180
|
+
throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
|
|
181
|
+
},
|
|
182
|
+
};
|
|
200
183
|
}
|
|
184
|
+
request.state = RequestState.BEFORE_NAV;
|
|
185
|
+
return {};
|
|
201
186
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
*/
|
|
205
|
-
async _runRequestHandler(crawlingContext) {
|
|
187
|
+
async makeHttpRequest(crawlingContext) {
|
|
188
|
+
tryCancel();
|
|
206
189
|
const { request, session } = crawlingContext;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
190
|
+
const proxyUrl = crawlingContext.proxyInfo?.url;
|
|
191
|
+
const httpResponse = await addTimeoutToPromise(async () => this.requestFunction({ request, session, proxyUrl }), this.navigationTimeoutMillis, `request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
192
|
+
tryCancel();
|
|
193
|
+
request.loadedUrl = httpResponse?.url;
|
|
194
|
+
request.state = RequestState.AFTER_NAV;
|
|
195
|
+
return { request: request, response: httpResponse };
|
|
196
|
+
}
|
|
197
|
+
async processHttpResponse(crawlingContext) {
|
|
198
|
+
if (crawlingContext.request.skipNavigation) {
|
|
199
|
+
return {
|
|
200
|
+
get contentType() {
|
|
201
|
+
throw new NavigationSkippedError('The `contentType` property is not available - `skipNavigation` was used');
|
|
202
|
+
},
|
|
203
|
+
get body() {
|
|
204
|
+
throw new NavigationSkippedError('The `body` property is not available - `skipNavigation` was used');
|
|
205
|
+
},
|
|
206
|
+
get json() {
|
|
207
|
+
throw new NavigationSkippedError('The `json` property is not available - `skipNavigation` was used');
|
|
208
|
+
},
|
|
209
|
+
get waitForSelector() {
|
|
210
|
+
throw new NavigationSkippedError('The `waitForSelector` method is not available - `skipNavigation` was used');
|
|
211
|
+
},
|
|
212
|
+
get parseWithCheerio() {
|
|
213
|
+
throw new NavigationSkippedError('The `parseWithCheerio` method is not available - `skipNavigation` was used');
|
|
214
|
+
},
|
|
231
215
|
};
|
|
232
|
-
|
|
233
|
-
|
|
216
|
+
}
|
|
217
|
+
tryCancel();
|
|
218
|
+
const parsed = await this.parseResponse(crawlingContext.request, crawlingContext.response);
|
|
219
|
+
tryCancel();
|
|
220
|
+
const response = parsed.response;
|
|
221
|
+
const contentType = parsed.contentType;
|
|
222
|
+
const waitForSelector = async (selector, _timeoutMs) => {
|
|
223
|
+
const cheerio = await import('cheerio');
|
|
224
|
+
const $ = cheerio.load(parsed.body.toString());
|
|
225
|
+
if ($(selector).get().length === 0) {
|
|
226
|
+
throw new Error(`Selector '${selector}' not found.`);
|
|
234
227
|
}
|
|
235
|
-
|
|
236
|
-
|
|
228
|
+
};
|
|
229
|
+
const parseWithCheerio = async (selector, timeoutMs) => {
|
|
230
|
+
const cheerio = await import('cheerio');
|
|
231
|
+
const $ = cheerio.load(parsed.body.toString());
|
|
232
|
+
if (selector) {
|
|
233
|
+
await crawlingContext.waitForSelector(selector, timeoutMs);
|
|
237
234
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
235
|
+
return $;
|
|
236
|
+
};
|
|
237
|
+
this._throwOnBlockedRequest(response.status);
|
|
238
|
+
if (this.saveResponseCookies) {
|
|
239
|
+
try {
|
|
240
|
+
for (const cookie of getCookiesFromResponse(response)) {
|
|
241
|
+
if (!cookie)
|
|
242
|
+
continue;
|
|
243
|
+
try {
|
|
244
|
+
crawlingContext.session.cookieJar.setCookieSync(cookie, response.url, { ignoreError: false });
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
this.log.debug(`Could not set cookie: ${e.message}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
this.log.exception(e, 'Could not get cookies from response');
|
|
246
253
|
}
|
|
247
|
-
Object.assign(crawlingContext, parsed);
|
|
248
|
-
Object.defineProperty(crawlingContext, 'json', {
|
|
249
|
-
get() {
|
|
250
|
-
if (contentType.type !== APPLICATION_JSON_MIME_TYPE)
|
|
251
|
-
return null;
|
|
252
|
-
const jsonString = parsed.body.toString(contentType.encoding);
|
|
253
|
-
return JSON.parse(jsonString);
|
|
254
|
-
},
|
|
255
|
-
});
|
|
256
254
|
}
|
|
255
|
+
return {
|
|
256
|
+
get json() {
|
|
257
|
+
if (contentType.type !== APPLICATION_JSON_MIME_TYPE)
|
|
258
|
+
return null;
|
|
259
|
+
const jsonString = parsed.body.toString(contentType.encoding);
|
|
260
|
+
return JSON.parse(jsonString);
|
|
261
|
+
},
|
|
262
|
+
waitForSelector,
|
|
263
|
+
parseWithCheerio,
|
|
264
|
+
contentType,
|
|
265
|
+
body: parsed.body,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async handleBlockedRequestByContent(crawlingContext) {
|
|
257
269
|
if (this.retryOnBlocked) {
|
|
258
270
|
const error = await this.isRequestBlocked(crawlingContext);
|
|
259
271
|
if (error)
|
|
260
272
|
throw new SessionError(error);
|
|
261
273
|
}
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
await addTimeoutToPromise(async () => Promise.resolve(this.requestHandler(crawlingContext)), this.userRequestHandlerTimeoutMillis, `requestHandler timed out after ${this.userRequestHandlerTimeoutMillis / 1000} seconds.`);
|
|
265
|
-
request.state = RequestState.DONE;
|
|
266
|
-
}
|
|
267
|
-
catch (e) {
|
|
268
|
-
request.state = RequestState.ERROR;
|
|
269
|
-
throw e;
|
|
270
|
-
}
|
|
274
|
+
return {};
|
|
271
275
|
}
|
|
272
276
|
async isRequestBlocked(crawlingContext) {
|
|
273
277
|
if (HTML_AND_XML_MIME_TYPES.includes(crawlingContext.contentType.type)) {
|
|
@@ -277,84 +281,25 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
277
281
|
return `Found selectors: ${foundSelectors.join(', ')}`;
|
|
278
282
|
}
|
|
279
283
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
async _handleNavigation(crawlingContext) {
|
|
283
|
-
const gotOptions = {};
|
|
284
|
-
const { request, session } = crawlingContext;
|
|
285
|
-
const preNavigationHooksCookies = this._getCookieHeaderFromRequest(request);
|
|
286
|
-
request.state = RequestState.BEFORE_NAV;
|
|
287
|
-
// Execute pre navigation hooks before applying session pool cookies,
|
|
288
|
-
// as they may also set cookies in the session
|
|
289
|
-
await this._executeHooks(this.preNavigationHooks, crawlingContext, gotOptions);
|
|
290
|
-
tryCancel();
|
|
291
|
-
const postNavigationHooksCookies = this._getCookieHeaderFromRequest(request);
|
|
292
|
-
this._applyCookies(crawlingContext, gotOptions, preNavigationHooksCookies, postNavigationHooksCookies);
|
|
293
|
-
const proxyUrl = crawlingContext.proxyInfo?.url;
|
|
294
|
-
crawlingContext.response = await addTimeoutToPromise(async () => this._requestFunction({ request, session, proxyUrl, gotOptions }), this.navigationTimeoutMillis, `request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
295
|
-
tryCancel();
|
|
296
|
-
request.state = RequestState.AFTER_NAV;
|
|
297
|
-
await this._executeHooks(this.postNavigationHooks, crawlingContext, gotOptions);
|
|
298
|
-
tryCancel();
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Sets the cookie header to `gotOptions` based on the provided request and session headers, as well as any changes that occurred due to hooks.
|
|
302
|
-
*/
|
|
303
|
-
_applyCookies({ session, request }, gotOptions, preHookCookies, postHookCookies) {
|
|
304
|
-
const sessionCookie = session?.getCookieString(request.url) ?? '';
|
|
305
|
-
let alteredGotOptionsCookies = gotOptions.headers?.Cookie || gotOptions.headers?.cookie || '';
|
|
306
|
-
if (gotOptions.headers?.Cookie && gotOptions.headers?.cookie) {
|
|
307
|
-
const { Cookie: upperCaseHeader, cookie: lowerCaseHeader } = gotOptions.headers;
|
|
308
|
-
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`);
|
|
309
|
-
const sourceCookies = [];
|
|
310
|
-
if (Array.isArray(lowerCaseHeader)) {
|
|
311
|
-
sourceCookies.push(...lowerCaseHeader);
|
|
312
|
-
}
|
|
313
|
-
else {
|
|
314
|
-
sourceCookies.push(lowerCaseHeader);
|
|
315
|
-
}
|
|
316
|
-
if (Array.isArray(upperCaseHeader)) {
|
|
317
|
-
sourceCookies.push(...upperCaseHeader);
|
|
318
|
-
}
|
|
319
|
-
else {
|
|
320
|
-
sourceCookies.push(upperCaseHeader);
|
|
321
|
-
}
|
|
322
|
-
alteredGotOptionsCookies = mergeCookies(request.url, sourceCookies);
|
|
323
|
-
}
|
|
324
|
-
const sourceCookies = [sessionCookie, preHookCookies];
|
|
325
|
-
if (Array.isArray(alteredGotOptionsCookies)) {
|
|
326
|
-
sourceCookies.push(...alteredGotOptionsCookies);
|
|
327
|
-
}
|
|
328
|
-
else {
|
|
329
|
-
sourceCookies.push(alteredGotOptionsCookies);
|
|
330
|
-
}
|
|
331
|
-
sourceCookies.push(postHookCookies);
|
|
332
|
-
const mergedCookie = mergeCookies(request.url, sourceCookies);
|
|
333
|
-
gotOptions.headers ??= {};
|
|
334
|
-
Reflect.deleteProperty(gotOptions.headers, 'Cookie');
|
|
335
|
-
Reflect.deleteProperty(gotOptions.headers, 'cookie');
|
|
336
|
-
if (mergedCookie !== '') {
|
|
337
|
-
gotOptions.headers.Cookie = mergedCookie;
|
|
284
|
+
if (this.blockedStatusCodes.has(crawlingContext.response.status)) {
|
|
285
|
+
return `Blocked by status code ${crawlingContext.response.status}`;
|
|
338
286
|
}
|
|
287
|
+
return false;
|
|
339
288
|
}
|
|
340
289
|
/**
|
|
341
290
|
* Function to make the HTTP request. It performs optimizations
|
|
342
291
|
* on the request such as only downloading the request body if the
|
|
343
292
|
* received content type matches text/html, application/xml, application/xhtml+xml.
|
|
344
293
|
*/
|
|
345
|
-
async
|
|
346
|
-
|
|
347
|
-
// @ts-ignore
|
|
348
|
-
({ TimeoutError } = await import('got-scraping'));
|
|
349
|
-
}
|
|
350
|
-
const opts = this._getRequestOptions(request, session, proxyUrl, gotOptions);
|
|
294
|
+
async requestFunction({ request, session, proxyUrl }) {
|
|
295
|
+
const opts = this.getRequestOptions(request, session, proxyUrl);
|
|
351
296
|
try {
|
|
352
297
|
return await this._requestAsBrowser(opts, session);
|
|
353
298
|
}
|
|
354
299
|
catch (e) {
|
|
355
|
-
if (e instanceof TimeoutError) {
|
|
356
|
-
this.
|
|
357
|
-
return
|
|
300
|
+
if (e instanceof Error && e.constructor.name === 'TimeoutError') {
|
|
301
|
+
this.handleRequestTimeout(session);
|
|
302
|
+
return new Response(); // this will never happen, as handleRequestTimeout always throws
|
|
358
303
|
}
|
|
359
304
|
if (this.isProxyError(e)) {
|
|
360
305
|
throw new SessionError(this._getMessageFromError(e));
|
|
@@ -367,18 +312,16 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
367
312
|
/**
|
|
368
313
|
* Encodes and parses response according to the provided content type
|
|
369
314
|
*/
|
|
370
|
-
async
|
|
371
|
-
const {
|
|
372
|
-
const { type, charset } = parseContentTypeFromResponse(
|
|
373
|
-
const { response, encoding } = this.
|
|
315
|
+
async parseResponse(request, response) {
|
|
316
|
+
const { status } = response;
|
|
317
|
+
const { type, charset } = parseContentTypeFromResponse(response);
|
|
318
|
+
const { response: reencodedResponse, encoding } = this.encodeResponse(request, response, charset);
|
|
374
319
|
const contentType = { type, encoding };
|
|
375
|
-
if (
|
|
376
|
-
this.stats.registerStatusCode(
|
|
320
|
+
if (status >= 400 && status <= 599) {
|
|
321
|
+
this.stats.registerStatusCode(status);
|
|
377
322
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if ((statusCode >= 500 && !excludeError) || includeError) {
|
|
381
|
-
const body = await readStreamToString(response, encoding);
|
|
323
|
+
if (this.isErrorStatusCode(status)) {
|
|
324
|
+
const body = await reencodedResponse.text(); // TODO - this always uses UTF-8 (see https://developer.mozilla.org/en-US/docs/Web/API/Request/text)
|
|
382
325
|
// Errors are often sent as JSON, so attempt to parse them,
|
|
383
326
|
// despite Accept header being set to text/html.
|
|
384
327
|
if (type === APPLICATION_JSON_MIME_TYPE) {
|
|
@@ -386,59 +329,57 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
386
329
|
let { message } = errorResponse;
|
|
387
330
|
if (!message)
|
|
388
331
|
message = util.inspect(errorResponse, { depth: 1, maxArrayLength: 10 });
|
|
389
|
-
throw new Error(`${
|
|
332
|
+
throw new Error(`${status} - ${message}`);
|
|
390
333
|
}
|
|
391
|
-
if (
|
|
392
|
-
throw new Error(`${
|
|
334
|
+
if (this.additionalHttpErrorStatusCodes.has(status)) {
|
|
335
|
+
throw new Error(`${status} - Error status code was set by user.`);
|
|
393
336
|
}
|
|
394
337
|
// It's not a JSON, so it's probably some text. Get the first 100 chars of it.
|
|
395
|
-
throw new Error(`${
|
|
338
|
+
throw new Error(`${status} - Internal Server Error: ${body.slice(0, 100)}`);
|
|
396
339
|
}
|
|
397
340
|
else if (HTML_AND_XML_MIME_TYPES.includes(type)) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
341
|
+
if (!charset && !this.forceResponseEncoding) {
|
|
342
|
+
const rawBytes = Buffer.from(await response.arrayBuffer());
|
|
343
|
+
const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
|
|
344
|
+
const charsetToUse = metaCharset ?? this.suggestResponseEncoding ?? 'utf-8';
|
|
345
|
+
const body = iconv.encodingExists(charsetToUse)
|
|
346
|
+
? iconv.decode(rawBytes, charsetToUse)
|
|
347
|
+
: rawBytes.toString('utf8');
|
|
348
|
+
return { response, contentType: { type, encoding: 'utf-8' }, body };
|
|
349
|
+
}
|
|
350
|
+
return { response, contentType, body: await reencodedResponse.text() };
|
|
401
351
|
}
|
|
402
352
|
else {
|
|
403
|
-
const body = await
|
|
353
|
+
const body = Buffer.from(await reencodedResponse.bytes());
|
|
404
354
|
return {
|
|
405
355
|
body,
|
|
406
356
|
response,
|
|
407
357
|
contentType,
|
|
408
|
-
enqueueLinks: async () => Promise.resolve({ processedRequests: [], unprocessedRequests: [] }),
|
|
409
358
|
};
|
|
410
359
|
}
|
|
411
360
|
}
|
|
412
|
-
async _parseHTML(response, _isXml, _crawlingContext) {
|
|
413
|
-
return {
|
|
414
|
-
body: await concatStreamToBuffer(response),
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
361
|
/**
|
|
418
362
|
* Combines the provided `requestOptions` with mandatory (non-overridable) values.
|
|
419
363
|
*/
|
|
420
|
-
|
|
364
|
+
getRequestOptions(request, session, proxyUrl) {
|
|
421
365
|
const requestOptions = {
|
|
422
366
|
url: request.url,
|
|
423
367
|
method: request.method,
|
|
424
368
|
proxyUrl,
|
|
425
|
-
timeout:
|
|
369
|
+
timeout: this.navigationTimeoutMillis,
|
|
426
370
|
sessionToken: session,
|
|
427
|
-
|
|
428
|
-
headers: { ...request.headers, ...gotOptions?.headers },
|
|
371
|
+
headers: request.headers,
|
|
429
372
|
https: {
|
|
430
|
-
...gotOptions?.https,
|
|
431
373
|
rejectUnauthorized: !this.ignoreSslErrors,
|
|
432
374
|
},
|
|
433
|
-
|
|
375
|
+
body: undefined,
|
|
434
376
|
};
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
// because users can use normal + MITM proxies in a single configuration.
|
|
377
|
+
if (requestOptions.headers?.cookie || requestOptions.headers?.Cookie) {
|
|
378
|
+
requestOptions.headers.Cookie = this._getCookieHeaderFromRequest(request);
|
|
379
|
+
delete requestOptions.headers.cookie;
|
|
380
|
+
}
|
|
440
381
|
// Disable SSL verification for MITM proxies
|
|
441
|
-
if (
|
|
382
|
+
if (session.proxyInfo?.ignoreTlsErrors) {
|
|
442
383
|
requestOptions.https = {
|
|
443
384
|
...requestOptions.https,
|
|
444
385
|
rejectUnauthorized: false,
|
|
@@ -448,7 +389,7 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
448
389
|
requestOptions.body = request.payload ?? '';
|
|
449
390
|
return requestOptions;
|
|
450
391
|
}
|
|
451
|
-
|
|
392
|
+
encodeResponse(request, response, encoding) {
|
|
452
393
|
if (this.forceResponseEncoding) {
|
|
453
394
|
encoding = this.forceResponseEncoding;
|
|
454
395
|
}
|
|
@@ -466,14 +407,16 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
466
407
|
// Try to re-encode a variety of unsupported encodings to utf-8
|
|
467
408
|
if (iconv.encodingExists(encoding)) {
|
|
468
409
|
const encodeStream = iconv.encodeStream(utf8);
|
|
469
|
-
const decodeStream = iconv
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
410
|
+
const decodeStream = iconv
|
|
411
|
+
.decodeStream(encoding)
|
|
412
|
+
.on('error', (err) => encodeStream.emit('error', err));
|
|
413
|
+
const reencodedBody = response.body
|
|
414
|
+
? Readable.toWeb(Readable.from(Readable.fromWeb(response.body)
|
|
415
|
+
.pipe(decodeStream)
|
|
416
|
+
.pipe(encodeStream)))
|
|
417
|
+
: null;
|
|
475
418
|
return {
|
|
476
|
-
response:
|
|
419
|
+
response: new ResponseWithUrl(reencodedBody, response),
|
|
477
420
|
encoding: utf8,
|
|
478
421
|
};
|
|
479
422
|
}
|
|
@@ -482,7 +425,7 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
482
425
|
/**
|
|
483
426
|
* Checks and extends supported mime types
|
|
484
427
|
*/
|
|
485
|
-
|
|
428
|
+
extendSupportedMimeTypes(additionalMimeTypes) {
|
|
486
429
|
for (const mimeType of additionalMimeTypes) {
|
|
487
430
|
if (mimeType === '*/*') {
|
|
488
431
|
this.supportedMimeTypes.add(mimeType);
|
|
@@ -500,17 +443,14 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
500
443
|
/**
|
|
501
444
|
* Handles timeout request
|
|
502
445
|
*/
|
|
503
|
-
|
|
504
|
-
session
|
|
505
|
-
throw new Error(`request timed out after ${this.
|
|
446
|
+
handleRequestTimeout(session) {
|
|
447
|
+
session.markBad();
|
|
448
|
+
throw new Error(`request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
506
449
|
}
|
|
507
450
|
_abortDownloadOfBody(request, response) {
|
|
508
|
-
const {
|
|
451
|
+
const { status } = response;
|
|
509
452
|
const { type } = parseContentTypeFromResponse(response);
|
|
510
|
-
|
|
511
|
-
const blockedStatusCodes = this.sessionPool ? this.sessionPool['blockedStatusCodes'] : [];
|
|
512
|
-
// if we retry the request, can the Content-Type change?
|
|
513
|
-
const isTransientContentType = statusCode >= 500 || blockedStatusCodes.includes(statusCode);
|
|
453
|
+
const isTransientContentType = status >= 500 || this.blockedStatusCodes.has(status);
|
|
514
454
|
if (!this.supportedMimeTypes.has(type) && !this.supportedMimeTypes.has('*/*') && !isTransientContentType) {
|
|
515
455
|
request.noRetry = true;
|
|
516
456
|
throw new Error(`Resource ${request.url} served Content-Type ${type}, ` +
|
|
@@ -521,115 +461,28 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
521
461
|
* @internal wraps public utility for mocking purposes
|
|
522
462
|
*/
|
|
523
463
|
_requestAsBrowser = async (options, session) => {
|
|
524
|
-
const
|
|
464
|
+
const opts = processHttpRequestOptions({
|
|
525
465
|
...options,
|
|
526
|
-
cookieJar: options.cookieJar, // HACK - the type of ToughCookieJar in got is wrong
|
|
527
466
|
responseType: 'text',
|
|
528
|
-
}), (redirectResponse, updatedRequest) => {
|
|
529
|
-
if (this.persistCookiesPerSession) {
|
|
530
|
-
session.setCookiesFromResponse(redirectResponse);
|
|
531
|
-
const cookieString = session.getCookieString(updatedRequest.url.toString());
|
|
532
|
-
if (cookieString !== '') {
|
|
533
|
-
updatedRequest.headers.Cookie = cookieString;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
467
|
});
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
'complete',
|
|
554
|
-
'httpVersion',
|
|
555
|
-
'rawHeaders',
|
|
556
|
-
'rawTrailers',
|
|
557
|
-
'trailers',
|
|
558
|
-
'url',
|
|
559
|
-
'request',
|
|
560
|
-
];
|
|
561
|
-
stream.on('end', () => {
|
|
562
|
-
// @ts-expect-error
|
|
563
|
-
if (stream.rawTrailers)
|
|
564
|
-
stream.rawTrailers = response.rawTrailers; // TODO BC with got - remove in 4.0
|
|
565
|
-
// @ts-expect-error
|
|
566
|
-
if (stream.trailers)
|
|
567
|
-
stream.trailers = response.trailers;
|
|
568
|
-
// @ts-expect-error
|
|
569
|
-
stream.complete = response.complete;
|
|
570
|
-
});
|
|
571
|
-
for (const prop of properties) {
|
|
572
|
-
if (!(prop in stream)) {
|
|
573
|
-
stream[prop] = response[prop];
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
return stream;
|
|
577
|
-
}
|
|
578
|
-
/**
|
|
579
|
-
* Gets parsed content type from response object
|
|
580
|
-
* @param response HTTP response object
|
|
581
|
-
*/
|
|
582
|
-
function parseContentTypeFromResponse(response) {
|
|
583
|
-
ow(response, ow.object.partialShape({
|
|
584
|
-
url: ow.string.url,
|
|
585
|
-
headers: new ObjectPredicate(),
|
|
586
|
-
}));
|
|
587
|
-
const { url, headers } = response;
|
|
588
|
-
let parsedContentType;
|
|
589
|
-
if (headers['content-type']) {
|
|
590
|
-
try {
|
|
591
|
-
parsedContentType = contentTypeParser.parse(headers['content-type']);
|
|
592
|
-
}
|
|
593
|
-
catch {
|
|
594
|
-
// Can not parse content type from Content-Type header. Try to parse it from file extension.
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
// Parse content type from file extension as fallback
|
|
598
|
-
if (!parsedContentType) {
|
|
599
|
-
const parsedUrl = new URL(url);
|
|
600
|
-
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
|
|
601
|
-
parsedContentType = contentTypeParser.parse(contentTypeFromExtname);
|
|
602
|
-
}
|
|
603
|
-
return {
|
|
604
|
-
type: parsedContentType.type,
|
|
605
|
-
charset: parsedContentType.parameters.charset,
|
|
468
|
+
// When saveResponseCookies is false, the response cookies must not mutate the
|
|
469
|
+
// session jar. Reads still go through the session (so session.setCookie() in pre-nav
|
|
470
|
+
// hooks keeps working) but a per-request clone is passed in so writes are discarded.
|
|
471
|
+
const cookieJar = this.saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
|
|
472
|
+
const response = await this.httpClient.sendRequest(new Request(opts.url, {
|
|
473
|
+
body: opts.body ? Readable.toWeb(opts.body) : undefined,
|
|
474
|
+
headers: new Headers(opts.headers),
|
|
475
|
+
method: opts.method,
|
|
476
|
+
// Node-specific option to make the request body work with streams
|
|
477
|
+
duplex: 'half',
|
|
478
|
+
}), {
|
|
479
|
+
session,
|
|
480
|
+
cookieJar,
|
|
481
|
+
timeoutMillis: opts.timeout,
|
|
482
|
+
});
|
|
483
|
+
return response;
|
|
606
484
|
};
|
|
607
485
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
* This instance can then serve as a `requestHandler` of your {@link HttpCrawler}.
|
|
611
|
-
* Defaults to the {@link HttpCrawlingContext}.
|
|
612
|
-
*
|
|
613
|
-
* > Serves as a shortcut for using `Router.create<HttpCrawlingContext>()`.
|
|
614
|
-
*
|
|
615
|
-
* ```ts
|
|
616
|
-
* import { HttpCrawler, createHttpRouter } from 'crawlee';
|
|
617
|
-
*
|
|
618
|
-
* const router = createHttpRouter();
|
|
619
|
-
* router.addHandler('label-a', async (ctx) => {
|
|
620
|
-
* ctx.log.info('...');
|
|
621
|
-
* });
|
|
622
|
-
* router.addDefaultHandler(async (ctx) => {
|
|
623
|
-
* ctx.log.info('...');
|
|
624
|
-
* });
|
|
625
|
-
*
|
|
626
|
-
* const crawler = new HttpCrawler({
|
|
627
|
-
* requestHandler: router,
|
|
628
|
-
* });
|
|
629
|
-
* await crawler.run();
|
|
630
|
-
* ```
|
|
631
|
-
*/
|
|
632
|
-
export function createHttpRouter(routes) {
|
|
633
|
-
return Router.create(routes);
|
|
486
|
+
export function createHttpRouter(routesOrSchemas) {
|
|
487
|
+
return Router.create(routesOrSchemas);
|
|
634
488
|
}
|
|
635
|
-
//# sourceMappingURL=http-crawler.js.map
|