@crawlee/http 4.0.0-beta.10 → 4.0.0-beta.100
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 +61 -35
- package/internals/file-download.js +119 -77
- package/internals/http-crawler.d.ts +132 -228
- package/internals/http-crawler.js +289 -376
- package/internals/utils.d.ts +19 -0
- package/internals/utils.js +81 -0
- package/package.json +10 -9
- 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,28 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
2
|
import util from 'node:util';
|
|
3
|
-
import {
|
|
3
|
+
import { BasicCrawler, ContextPipeline, NavigationSkippedError, remainingNavigationWindowMillis, 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
|
|
10
|
-
import {
|
|
11
|
-
import { concatStreamToBuffer, readStreamToString } from '@apify/utilities';
|
|
12
|
-
let TimeoutError;
|
|
9
|
+
import ow from 'ow';
|
|
10
|
+
import { addTimeoutToPromise, storage, TimeoutError, tryCancel } from '@apify/timeout';
|
|
11
|
+
import { extractCharsetFromHtmlBytes, parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
|
|
13
12
|
/**
|
|
14
13
|
* Default mime types, which HttpScraper supports.
|
|
15
14
|
*/
|
|
16
15
|
const HTML_AND_XML_MIME_TYPES = ['text/html', 'text/xml', 'application/xhtml+xml', 'application/xml'];
|
|
17
16
|
const APPLICATION_JSON_MIME_TYPE = 'application/json';
|
|
18
|
-
|
|
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 = {
|
|
19
29
|
desiredConcurrency: 10,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
loadSignals: {
|
|
31
|
+
eventLoop: {
|
|
32
|
+
snapshotIntervalSecs: 2,
|
|
33
|
+
maxBlockedMillis: 100,
|
|
34
|
+
overloadedRatio: 0.7,
|
|
35
|
+
},
|
|
26
36
|
},
|
|
27
37
|
};
|
|
28
38
|
/**
|
|
@@ -36,38 +46,40 @@ const HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
|
|
|
36
46
|
*
|
|
37
47
|
* This crawler downloads each URL using a plain HTTP request and doesn't do any HTML parsing.
|
|
38
48
|
*
|
|
39
|
-
* The source URLs are represented using {@link Request} objects that are fed from
|
|
40
|
-
* {@link
|
|
41
|
-
*
|
|
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`.
|
|
42
55
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* 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.
|
|
46
58
|
*
|
|
47
59
|
* The crawler finishes when there are no more {@link Request} objects to crawl.
|
|
48
60
|
*
|
|
49
|
-
* We can use the `preNavigationHooks` to adjust
|
|
61
|
+
* We can use the `preNavigationHooks` to adjust the crawling context before the request is made:
|
|
50
62
|
*
|
|
51
63
|
* ```javascript
|
|
52
64
|
* preNavigationHooks: [
|
|
53
|
-
* (crawlingContext
|
|
65
|
+
* (crawlingContext) => {
|
|
54
66
|
* // ...
|
|
55
67
|
* },
|
|
56
68
|
* ]
|
|
57
69
|
* ```
|
|
58
70
|
*
|
|
59
|
-
* By default, this crawler only processes web pages with the `text/html`
|
|
60
|
-
* 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),
|
|
61
73
|
* and skips pages with other content types. If you want the crawler to process other content types,
|
|
62
74
|
* use the {@link HttpCrawlerOptions.additionalMimeTypes} constructor option.
|
|
63
75
|
* Beware that the parsing behavior differs for HTML, XML, JSON and other types of content.
|
|
64
76
|
* For details, see {@link HttpCrawlerOptions.requestHandler}.
|
|
65
77
|
*
|
|
66
|
-
* New requests are only dispatched when there is enough free CPU and memory available,
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* {@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`}.
|
|
71
83
|
*
|
|
72
84
|
* **Example usage:**
|
|
73
85
|
*
|
|
@@ -93,22 +105,17 @@ const HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
|
|
|
93
105
|
* @category Crawlers
|
|
94
106
|
*/
|
|
95
107
|
export class HttpCrawler extends BasicCrawler {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
*/
|
|
101
|
-
proxyConfiguration;
|
|
102
|
-
userRequestHandlerTimeoutMillis;
|
|
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.
|
|
103
112
|
preNavigationHooks;
|
|
104
113
|
postNavigationHooks;
|
|
105
|
-
|
|
114
|
+
saveResponseCookies;
|
|
106
115
|
navigationTimeoutMillis;
|
|
107
116
|
ignoreSslErrors;
|
|
108
117
|
suggestResponseEncoding;
|
|
109
118
|
forceResponseEncoding;
|
|
110
|
-
additionalHttpErrorStatusCodes;
|
|
111
|
-
ignoreHttpErrorStatusCodes;
|
|
112
119
|
supportedMimeTypes;
|
|
113
120
|
static optionsShape = {
|
|
114
121
|
...BasicCrawler.optionsShape,
|
|
@@ -117,157 +124,207 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
117
124
|
additionalMimeTypes: ow.optional.array.ofType(ow.string),
|
|
118
125
|
suggestResponseEncoding: ow.optional.string,
|
|
119
126
|
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),
|
|
127
|
+
saveResponseCookies: ow.optional.boolean,
|
|
124
128
|
preNavigationHooks: ow.optional.array,
|
|
125
129
|
postNavigationHooks: ow.optional.array,
|
|
126
130
|
};
|
|
127
131
|
/**
|
|
128
132
|
* All `HttpCrawlerOptions` parameters are passed via an options object.
|
|
129
133
|
*/
|
|
130
|
-
constructor(options = {}
|
|
134
|
+
constructor(options = {}) {
|
|
131
135
|
ow(options, 'HttpCrawlerOptions', ow.object.exactShape(HttpCrawler.optionsShape));
|
|
132
|
-
const {
|
|
136
|
+
const { navigationTimeoutSecs = 30, ignoreSslErrors = true, additionalMimeTypes = [], suggestResponseEncoding, forceResponseEncoding, saveResponseCookies = true, preNavigationHooks = [], postNavigationHooks = [],
|
|
133
137
|
// BasicCrawler
|
|
134
|
-
|
|
138
|
+
contextPipelineBuilder, ...basicCrawlerOptions } = options;
|
|
135
139
|
super({
|
|
136
140
|
...basicCrawlerOptions,
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// but not too much so that we would stall the crawler.
|
|
141
|
-
requestHandlerTimeoutSecs: navigationTimeoutSecs + requestHandlerTimeoutSecs + BASIC_CRAWLER_TIMEOUT_BUFFER_SECS,
|
|
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
|
-
}
|
|
141
|
+
contextPipelineBuilder: contextPipelineBuilder ??
|
|
142
|
+
(() => this.buildContextPipeline()),
|
|
143
|
+
});
|
|
149
144
|
this.supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
|
|
150
145
|
if (additionalMimeTypes.length)
|
|
151
|
-
this.
|
|
146
|
+
this.extendSupportedMimeTypes(additionalMimeTypes);
|
|
152
147
|
if (suggestResponseEncoding && forceResponseEncoding) {
|
|
153
148
|
this.log.warning('Both forceResponseEncoding and suggestResponseEncoding options are set. Using forceResponseEncoding.');
|
|
154
149
|
}
|
|
155
|
-
this.userRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
|
|
156
150
|
this.navigationTimeoutMillis = navigationTimeoutSecs * 1000;
|
|
157
151
|
this.ignoreSslErrors = ignoreSslErrors;
|
|
158
152
|
this.suggestResponseEncoding = suggestResponseEncoding;
|
|
159
153
|
this.forceResponseEncoding = forceResponseEncoding;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
154
|
+
// Cast away the extension-aware option types to the base internal storage types (see the field
|
|
155
|
+
// declarations above). This is sound - the hooks only ever receive the base context plus the
|
|
156
|
+
// members `extendContext` added at runtime.
|
|
163
157
|
this.preNavigationHooks = preNavigationHooks;
|
|
164
158
|
this.postNavigationHooks = [
|
|
165
159
|
({ request, response }) => this._abortDownloadOfBody(request, response),
|
|
166
160
|
...postNavigationHooks,
|
|
167
161
|
];
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
this.persistCookiesPerSession = false;
|
|
173
|
-
}
|
|
162
|
+
this.saveResponseCookies = saveResponseCookies;
|
|
163
|
+
}
|
|
164
|
+
getNavigationTimeoutMillis() {
|
|
165
|
+
return this.navigationTimeoutMillis;
|
|
174
166
|
}
|
|
175
167
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* @
|
|
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.
|
|
179
172
|
*/
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
173
|
+
createDefaultConcurrencySystem(options) {
|
|
174
|
+
return super.createDefaultConcurrencySystem({
|
|
175
|
+
...HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS,
|
|
176
|
+
...options,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
buildContextPipeline() {
|
|
180
|
+
// When navigation is skipped, `prepareHttpRequest` has already installed throwing getters for
|
|
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
|
+
});
|
|
185
|
+
// A single navigation window covers the pre-navigation hooks, the navigation, and the post-navigation
|
|
186
|
+
// hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow hook eats into the same
|
|
187
|
+
// window the navigation uses instead of each step being timed on its own.
|
|
188
|
+
const navigationTimedOut = `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`;
|
|
189
|
+
const windowGuard = (step) => skipGuard(async (ctx) => {
|
|
190
|
+
const remaining = remainingNavigationWindowMillis(ctx, this.navigationTimeoutMillis);
|
|
191
|
+
if (remaining <= 0) {
|
|
192
|
+
throw new TimeoutError(navigationTimedOut);
|
|
197
193
|
}
|
|
198
|
-
|
|
199
|
-
|
|
194
|
+
return addTimeoutToPromise(async () => step(ctx), remaining, navigationTimedOut);
|
|
195
|
+
});
|
|
196
|
+
let pipeline = ContextPipeline.create().compose({
|
|
197
|
+
action: this.prepareHttpRequest.bind(this),
|
|
198
|
+
});
|
|
199
|
+
for (const hook of this.preNavigationHooks) {
|
|
200
|
+
pipeline = pipeline.compose(windowGuard(hook));
|
|
200
201
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
*/
|
|
205
|
-
async _runRequestHandler(crawlingContext) {
|
|
206
|
-
const { request, session } = crawlingContext;
|
|
207
|
-
if (this.proxyConfiguration) {
|
|
208
|
-
const sessionId = session ? session.id : undefined;
|
|
209
|
-
crawlingContext.proxyInfo = await this.proxyConfiguration.newProxyInfo(sessionId, { request });
|
|
202
|
+
let pipelineWithNavigation = pipeline.compose(skipGuard(this.makeHttpRequest.bind(this)));
|
|
203
|
+
for (const hook of this.postNavigationHooks) {
|
|
204
|
+
pipelineWithNavigation = pipelineWithNavigation.compose(windowGuard(hook));
|
|
210
205
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
206
|
+
return pipelineWithNavigation
|
|
207
|
+
.compose({ action: this.processHttpResponse.bind(this) })
|
|
208
|
+
.compose({ action: this.handleBlockedRequestByContent.bind(this) });
|
|
209
|
+
}
|
|
210
|
+
async prepareHttpRequest(crawlingContext) {
|
|
211
|
+
const { request } = crawlingContext;
|
|
212
|
+
if (request.skipNavigation) {
|
|
213
|
+
return {
|
|
214
|
+
request: new Proxy(request, {
|
|
215
|
+
get(target, propertyName, receiver) {
|
|
216
|
+
if (propertyName === 'loadedUrl') {
|
|
217
|
+
throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
|
|
218
|
+
}
|
|
219
|
+
return Reflect.get(target, propertyName, receiver);
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
get response() {
|
|
223
|
+
throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
|
|
224
|
+
},
|
|
224
225
|
};
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
}
|
|
227
|
+
request.state = RequestState.BEFORE_NAV;
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
async makeHttpRequest(crawlingContext) {
|
|
231
|
+
tryCancel();
|
|
232
|
+
const { request, session } = crawlingContext;
|
|
233
|
+
const proxyUrl = crawlingContext.proxyInfo?.url;
|
|
234
|
+
// Bound the request by whatever is left of the shared navigation window (the pre-navigation hooks may
|
|
235
|
+
// have already spent part of it), so it produces a clean navigation-timeout error rather than the raw
|
|
236
|
+
// client abort.
|
|
237
|
+
const httpResponse = await addTimeoutToPromise(async () => this.requestFunction({ request, session, proxyUrl }), Math.max(1, remainingNavigationWindowMillis(crawlingContext, this.navigationTimeoutMillis)), `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
238
|
+
tryCancel();
|
|
239
|
+
request.loadedUrl = httpResponse?.url;
|
|
240
|
+
request.state = RequestState.AFTER_NAV;
|
|
241
|
+
return { request: request, response: httpResponse };
|
|
242
|
+
}
|
|
243
|
+
async processHttpResponse(crawlingContext) {
|
|
244
|
+
if (crawlingContext.request.skipNavigation) {
|
|
245
|
+
return {
|
|
246
|
+
get contentType() {
|
|
247
|
+
throw new NavigationSkippedError('The `contentType` property is not available - `skipNavigation` was used');
|
|
248
|
+
},
|
|
249
|
+
get body() {
|
|
250
|
+
throw new NavigationSkippedError('The `body` property is not available - `skipNavigation` was used');
|
|
251
|
+
},
|
|
252
|
+
get json() {
|
|
253
|
+
throw new NavigationSkippedError('The `json` property is not available - `skipNavigation` was used');
|
|
254
|
+
},
|
|
255
|
+
get waitForSelector() {
|
|
256
|
+
throw new NavigationSkippedError('The `waitForSelector` method is not available - `skipNavigation` was used');
|
|
257
|
+
},
|
|
258
|
+
get parseWithCheerio() {
|
|
259
|
+
throw new NavigationSkippedError('The `parseWithCheerio` method is not available - `skipNavigation` was used');
|
|
260
|
+
},
|
|
231
261
|
};
|
|
232
|
-
|
|
233
|
-
|
|
262
|
+
}
|
|
263
|
+
tryCancel();
|
|
264
|
+
// Reading the body is still part of the navigation, so it draws from the same shared window: on a server
|
|
265
|
+
// that streams the body slowly the request completes (headers arrive) but the body read would otherwise
|
|
266
|
+
// run unbounded. `extendTimeout` from a post-navigation hook has already pushed this deadline out if asked.
|
|
267
|
+
const remaining = remainingNavigationWindowMillis(crawlingContext, this.navigationTimeoutMillis);
|
|
268
|
+
if (remaining <= 0) {
|
|
269
|
+
throw new TimeoutError(`Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
270
|
+
}
|
|
271
|
+
const parsed = await addTimeoutToPromise(async () => this.parseResponse(crawlingContext.request, crawlingContext.response), remaining, `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
272
|
+
tryCancel();
|
|
273
|
+
const response = parsed.response;
|
|
274
|
+
const contentType = parsed.contentType;
|
|
275
|
+
const waitForSelector = async (selector, _timeoutMs) => {
|
|
276
|
+
const cheerio = await import('cheerio');
|
|
277
|
+
const $ = cheerio.load(parsed.body.toString());
|
|
278
|
+
if ($(selector).get().length === 0) {
|
|
279
|
+
throw new Error(`Selector '${selector}' not found.`);
|
|
234
280
|
}
|
|
235
|
-
|
|
236
|
-
|
|
281
|
+
};
|
|
282
|
+
const parseWithCheerio = async (selector, timeoutMs) => {
|
|
283
|
+
const cheerio = await import('cheerio');
|
|
284
|
+
const $ = cheerio.load(parsed.body.toString());
|
|
285
|
+
if (selector) {
|
|
286
|
+
await crawlingContext.waitForSelector(selector, timeoutMs);
|
|
237
287
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
288
|
+
return $;
|
|
289
|
+
};
|
|
290
|
+
this._throwOnBlockedRequest(response.status);
|
|
291
|
+
if (this.saveResponseCookies) {
|
|
292
|
+
try {
|
|
293
|
+
for (const cookie of getCookiesFromResponse(response)) {
|
|
294
|
+
if (!cookie)
|
|
295
|
+
continue;
|
|
296
|
+
try {
|
|
297
|
+
crawlingContext.session.cookieJar.setCookieSync(cookie, response.url, { ignoreError: false });
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
this.log.debug(`Could not set cookie: ${e.message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
this.log.exception(e, 'Could not get cookies from response');
|
|
246
306
|
}
|
|
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
307
|
}
|
|
308
|
+
return {
|
|
309
|
+
get json() {
|
|
310
|
+
if (contentType.type !== APPLICATION_JSON_MIME_TYPE)
|
|
311
|
+
return null;
|
|
312
|
+
const jsonString = parsed.body.toString(contentType.encoding);
|
|
313
|
+
return JSON.parse(jsonString);
|
|
314
|
+
},
|
|
315
|
+
waitForSelector,
|
|
316
|
+
parseWithCheerio,
|
|
317
|
+
contentType,
|
|
318
|
+
body: parsed.body,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
async handleBlockedRequestByContent(crawlingContext) {
|
|
257
322
|
if (this.retryOnBlocked) {
|
|
258
323
|
const error = await this.isRequestBlocked(crawlingContext);
|
|
259
324
|
if (error)
|
|
260
325
|
throw new SessionError(error);
|
|
261
326
|
}
|
|
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
|
-
}
|
|
327
|
+
return {};
|
|
271
328
|
}
|
|
272
329
|
async isRequestBlocked(crawlingContext) {
|
|
273
330
|
if (HTML_AND_XML_MIME_TYPES.includes(crawlingContext.contentType.type)) {
|
|
@@ -277,84 +334,25 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
277
334
|
return `Found selectors: ${foundSelectors.join(', ')}`;
|
|
278
335
|
}
|
|
279
336
|
}
|
|
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;
|
|
337
|
+
if (this.blockedStatusCodes.has(crawlingContext.response.status)) {
|
|
338
|
+
return `Blocked by status code ${crawlingContext.response.status}`;
|
|
338
339
|
}
|
|
340
|
+
return false;
|
|
339
341
|
}
|
|
340
342
|
/**
|
|
341
343
|
* Function to make the HTTP request. It performs optimizations
|
|
342
344
|
* on the request such as only downloading the request body if the
|
|
343
345
|
* received content type matches text/html, application/xml, application/xhtml+xml.
|
|
344
346
|
*/
|
|
345
|
-
async
|
|
346
|
-
|
|
347
|
-
// @ts-ignore
|
|
348
|
-
({ TimeoutError } = await import('got-scraping'));
|
|
349
|
-
}
|
|
350
|
-
const opts = this._getRequestOptions(request, session, proxyUrl, gotOptions);
|
|
347
|
+
async requestFunction({ request, session, proxyUrl }) {
|
|
348
|
+
const opts = this.getRequestOptions(request, session, proxyUrl);
|
|
351
349
|
try {
|
|
352
350
|
return await this._requestAsBrowser(opts, session);
|
|
353
351
|
}
|
|
354
352
|
catch (e) {
|
|
355
|
-
if (e instanceof TimeoutError) {
|
|
356
|
-
this.
|
|
357
|
-
return
|
|
353
|
+
if (e instanceof Error && e.constructor.name === 'TimeoutError') {
|
|
354
|
+
this.handleRequestTimeout(session);
|
|
355
|
+
return new Response(); // this will never happen, as handleRequestTimeout always throws
|
|
358
356
|
}
|
|
359
357
|
if (this.isProxyError(e)) {
|
|
360
358
|
throw new SessionError(this._getMessageFromError(e));
|
|
@@ -367,18 +365,16 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
367
365
|
/**
|
|
368
366
|
* Encodes and parses response according to the provided content type
|
|
369
367
|
*/
|
|
370
|
-
async
|
|
371
|
-
const {
|
|
372
|
-
const { type, charset } = parseContentTypeFromResponse(
|
|
373
|
-
const { response, encoding } = this.
|
|
368
|
+
async parseResponse(request, response) {
|
|
369
|
+
const { status } = response;
|
|
370
|
+
const { type, charset } = parseContentTypeFromResponse(response);
|
|
371
|
+
const { response: reencodedResponse, encoding } = this.encodeResponse(request, response, charset);
|
|
374
372
|
const contentType = { type, encoding };
|
|
375
|
-
if (
|
|
376
|
-
this.stats.registerStatusCode(
|
|
373
|
+
if (status >= 400 && status <= 599) {
|
|
374
|
+
this.stats.registerStatusCode(status);
|
|
377
375
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if ((statusCode >= 500 && !excludeError) || includeError) {
|
|
381
|
-
const body = await readStreamToString(response, encoding);
|
|
376
|
+
if (this.isErrorStatusCode(status)) {
|
|
377
|
+
const body = await reencodedResponse.text(); // TODO - this always uses UTF-8 (see https://developer.mozilla.org/en-US/docs/Web/API/Request/text)
|
|
382
378
|
// Errors are often sent as JSON, so attempt to parse them,
|
|
383
379
|
// despite Accept header being set to text/html.
|
|
384
380
|
if (type === APPLICATION_JSON_MIME_TYPE) {
|
|
@@ -386,59 +382,57 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
386
382
|
let { message } = errorResponse;
|
|
387
383
|
if (!message)
|
|
388
384
|
message = util.inspect(errorResponse, { depth: 1, maxArrayLength: 10 });
|
|
389
|
-
throw new Error(`${
|
|
385
|
+
throw new Error(`${status} - ${message}`);
|
|
390
386
|
}
|
|
391
|
-
if (
|
|
392
|
-
throw new Error(`${
|
|
387
|
+
if (this.additionalHttpErrorStatusCodes.has(status)) {
|
|
388
|
+
throw new Error(`${status} - Error status code was set by user.`);
|
|
393
389
|
}
|
|
394
390
|
// It's not a JSON, so it's probably some text. Get the first 100 chars of it.
|
|
395
|
-
throw new Error(`${
|
|
391
|
+
throw new Error(`${status} - Internal Server Error: ${body.slice(0, 100)}`);
|
|
396
392
|
}
|
|
397
393
|
else if (HTML_AND_XML_MIME_TYPES.includes(type)) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
394
|
+
if (!charset && !this.forceResponseEncoding) {
|
|
395
|
+
const rawBytes = Buffer.from(await response.arrayBuffer());
|
|
396
|
+
const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
|
|
397
|
+
const charsetToUse = metaCharset ?? this.suggestResponseEncoding ?? 'utf-8';
|
|
398
|
+
const body = iconv.encodingExists(charsetToUse)
|
|
399
|
+
? iconv.decode(rawBytes, charsetToUse)
|
|
400
|
+
: rawBytes.toString('utf8');
|
|
401
|
+
return { response, contentType: { type, encoding: 'utf-8' }, body };
|
|
402
|
+
}
|
|
403
|
+
return { response, contentType, body: await reencodedResponse.text() };
|
|
401
404
|
}
|
|
402
405
|
else {
|
|
403
|
-
const body = await
|
|
406
|
+
const body = Buffer.from(await reencodedResponse.bytes());
|
|
404
407
|
return {
|
|
405
408
|
body,
|
|
406
409
|
response,
|
|
407
410
|
contentType,
|
|
408
|
-
enqueueLinks: async () => Promise.resolve({ processedRequests: [], unprocessedRequests: [] }),
|
|
409
411
|
};
|
|
410
412
|
}
|
|
411
413
|
}
|
|
412
|
-
async _parseHTML(response, _isXml, _crawlingContext) {
|
|
413
|
-
return {
|
|
414
|
-
body: await concatStreamToBuffer(response),
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
414
|
/**
|
|
418
415
|
* Combines the provided `requestOptions` with mandatory (non-overridable) values.
|
|
419
416
|
*/
|
|
420
|
-
|
|
417
|
+
getRequestOptions(request, session, proxyUrl) {
|
|
421
418
|
const requestOptions = {
|
|
422
419
|
url: request.url,
|
|
423
420
|
method: request.method,
|
|
424
421
|
proxyUrl,
|
|
425
|
-
timeout:
|
|
422
|
+
timeout: this.navigationTimeoutMillis,
|
|
426
423
|
sessionToken: session,
|
|
427
|
-
|
|
428
|
-
headers: { ...request.headers, ...gotOptions?.headers },
|
|
424
|
+
headers: request.headers,
|
|
429
425
|
https: {
|
|
430
|
-
...gotOptions?.https,
|
|
431
426
|
rejectUnauthorized: !this.ignoreSslErrors,
|
|
432
427
|
},
|
|
433
|
-
|
|
428
|
+
body: undefined,
|
|
434
429
|
};
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
// because users can use normal + MITM proxies in a single configuration.
|
|
430
|
+
if (requestOptions.headers?.cookie || requestOptions.headers?.Cookie) {
|
|
431
|
+
requestOptions.headers.Cookie = this._getCookieHeaderFromRequest(request);
|
|
432
|
+
delete requestOptions.headers.cookie;
|
|
433
|
+
}
|
|
440
434
|
// Disable SSL verification for MITM proxies
|
|
441
|
-
if (
|
|
435
|
+
if (session.proxyInfo?.ignoreTlsErrors) {
|
|
442
436
|
requestOptions.https = {
|
|
443
437
|
...requestOptions.https,
|
|
444
438
|
rejectUnauthorized: false,
|
|
@@ -448,7 +442,7 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
448
442
|
requestOptions.body = request.payload ?? '';
|
|
449
443
|
return requestOptions;
|
|
450
444
|
}
|
|
451
|
-
|
|
445
|
+
encodeResponse(request, response, encoding) {
|
|
452
446
|
if (this.forceResponseEncoding) {
|
|
453
447
|
encoding = this.forceResponseEncoding;
|
|
454
448
|
}
|
|
@@ -466,14 +460,16 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
466
460
|
// Try to re-encode a variety of unsupported encodings to utf-8
|
|
467
461
|
if (iconv.encodingExists(encoding)) {
|
|
468
462
|
const encodeStream = iconv.encodeStream(utf8);
|
|
469
|
-
const decodeStream = iconv
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
463
|
+
const decodeStream = iconv
|
|
464
|
+
.decodeStream(encoding)
|
|
465
|
+
.on('error', (err) => encodeStream.emit('error', err));
|
|
466
|
+
const reencodedBody = response.body
|
|
467
|
+
? Readable.toWeb(Readable.from(Readable.fromWeb(response.body)
|
|
468
|
+
.pipe(decodeStream)
|
|
469
|
+
.pipe(encodeStream)))
|
|
470
|
+
: null;
|
|
475
471
|
return {
|
|
476
|
-
response:
|
|
472
|
+
response: new ResponseWithUrl(reencodedBody, response),
|
|
477
473
|
encoding: utf8,
|
|
478
474
|
};
|
|
479
475
|
}
|
|
@@ -482,7 +478,7 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
482
478
|
/**
|
|
483
479
|
* Checks and extends supported mime types
|
|
484
480
|
*/
|
|
485
|
-
|
|
481
|
+
extendSupportedMimeTypes(additionalMimeTypes) {
|
|
486
482
|
for (const mimeType of additionalMimeTypes) {
|
|
487
483
|
if (mimeType === '*/*') {
|
|
488
484
|
this.supportedMimeTypes.add(mimeType);
|
|
@@ -500,17 +496,14 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
500
496
|
/**
|
|
501
497
|
* Handles timeout request
|
|
502
498
|
*/
|
|
503
|
-
|
|
504
|
-
session
|
|
505
|
-
throw new Error(`
|
|
499
|
+
handleRequestTimeout(session) {
|
|
500
|
+
session.markBad();
|
|
501
|
+
throw new Error(`Request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
506
502
|
}
|
|
507
503
|
_abortDownloadOfBody(request, response) {
|
|
508
|
-
const {
|
|
504
|
+
const { status } = response;
|
|
509
505
|
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);
|
|
506
|
+
const isTransientContentType = status >= 500 || this.blockedStatusCodes.has(status);
|
|
514
507
|
if (!this.supportedMimeTypes.has(type) && !this.supportedMimeTypes.has('*/*') && !isTransientContentType) {
|
|
515
508
|
request.noRetry = true;
|
|
516
509
|
throw new Error(`Resource ${request.url} served Content-Type ${type}, ` +
|
|
@@ -521,115 +514,35 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
521
514
|
* @internal wraps public utility for mocking purposes
|
|
522
515
|
*/
|
|
523
516
|
_requestAsBrowser = async (options, session) => {
|
|
524
|
-
const
|
|
517
|
+
const opts = processHttpRequestOptions({
|
|
525
518
|
...options,
|
|
526
|
-
cookieJar: options.cookieJar, // HACK - the type of ToughCookieJar in got is wrong
|
|
527
519
|
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
520
|
});
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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,
|
|
521
|
+
// When saveResponseCookies is false, the response cookies must not mutate the
|
|
522
|
+
// session jar. Reads still go through the session (so session.setCookie() in pre-nav
|
|
523
|
+
// hooks keeps working) but a per-request clone is passed in so writes are discarded.
|
|
524
|
+
const cookieJar = this.saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
|
|
525
|
+
// Bind the request to the shared navigation window instead of a fixed per-request timeout, so
|
|
526
|
+
// `extendTimeout()` can push the deadline and a fixed `AbortSignal.timeout` won't fire on its own and
|
|
527
|
+
// kill a lazily-read body mid-extension. This aborts the socket only during the header phase; the body
|
|
528
|
+
// read is bounded separately at the promise level (see `processHttpResponse`), so a slow-streaming body
|
|
529
|
+
// still fails cleanly with a navigation timeout, though the socket is left to close on its own.
|
|
530
|
+
const cancelSignal = storage.getStore()?.cancelTask.signal;
|
|
531
|
+
const response = await this.httpClient.sendRequest(new Request(opts.url, {
|
|
532
|
+
body: opts.body ? Readable.toWeb(opts.body) : undefined,
|
|
533
|
+
headers: new Headers(opts.headers),
|
|
534
|
+
method: opts.method,
|
|
535
|
+
// Node-specific option to make the request body work with streams
|
|
536
|
+
duplex: 'half',
|
|
537
|
+
}), {
|
|
538
|
+
session,
|
|
539
|
+
cookieJar,
|
|
540
|
+
signal: cancelSignal,
|
|
541
|
+
timeoutMillis: cancelSignal ? undefined : opts.timeout,
|
|
542
|
+
});
|
|
543
|
+
return response;
|
|
606
544
|
};
|
|
607
545
|
}
|
|
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);
|
|
546
|
+
export function createHttpRouter(routesOrSchemas) {
|
|
547
|
+
return Router.create(routesOrSchemas);
|
|
634
548
|
}
|
|
635
|
-
//# sourceMappingURL=http-crawler.js.map
|