@crawlee/http 4.0.0-beta.8 → 4.0.0-beta.80

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.
@@ -1,15 +1,14 @@
1
- import { extname } from 'node:path';
1
+ import { Readable } from 'node:stream';
2
2
  import util from 'node:util';
3
- import { BASIC_CRAWLER_TIMEOUT_BUFFER_SECS, BasicCrawler, Configuration, CrawlerExtension, mergeCookies, processHttpRequestOptions, RequestState, Router, SessionError, validators, } from '@crawlee/basic';
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 mime from 'mime-types';
9
- import ow, { ObjectPredicate } from 'ow';
9
+ import ow from 'ow';
10
10
  import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
11
- import { concatStreamToBuffer, readStreamToString } from '@apify/utilities';
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 RequestList} or {@link RequestQueue} instances provided by the {@link HttpCrawlerOptions.requestList}
41
- * or {@link HttpCrawlerOptions.requestQueue} constructor options, respectively.
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
- * If both {@link HttpCrawlerOptions.requestList} and {@link HttpCrawlerOptions.requestQueue} are used,
44
- * the instance first processes URLs from the {@link RequestList} and automatically enqueues all of them
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 `gotOptions`:
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, gotOptions) => {
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/xhtml+xml` MIME content types (as reported by the `Content-Type` HTTP header),
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
- persistCookiesPerSession;
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
- proxyConfiguration: ow.optional.object.validate(validators.proxyConfiguration),
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 = {}, config = Configuration.getGlobalConfig()) {
119
+ constructor(options = {}) {
131
120
  ow(options, 'HttpCrawlerOptions', ow.object.exactShape(HttpCrawler.optionsShape));
132
- const { requestHandler, requestHandlerTimeoutSecs = 60, navigationTimeoutSecs = 30, ignoreSslErrors = true, additionalMimeTypes = [], suggestResponseEncoding, forceResponseEncoding, proxyConfiguration, persistCookiesPerSession, preNavigationHooks = [], postNavigationHooks = [], additionalHttpErrorStatusCodes = [], ignoreHttpErrorStatusCodes = [],
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
- // We need to add some time for internal functions to finish,
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
- }
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
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
- if (this.useSessionPool) {
169
- this.persistCookiesPerSession = persistCookiesPerSession ?? true;
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
- else {
172
- this.persistCookiesPerSession = false;
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
- * **EXPERIMENTAL**
177
- * Function for attaching CrawlerExtensions such as the Unblockers.
178
- * @param extension Crawler extension that overrides the crawler configuration.
179
- */
180
- use(extension) {
181
- ow(extension, ow.object.instanceOf(CrawlerExtension));
182
- const className = this.constructor.name;
183
- const extensionOptions = extension.getCrawlerOptions();
184
- for (const [key, value] of Object.entries(extensionOptions)) {
185
- const isConfigurable = Object.hasOwn(this, key);
186
- const originalType = typeof this[key];
187
- const extensionType = typeof value; // What if we want to null something? It is really needed?
188
- const isSameType = originalType === extensionType || value == null; // fast track for deleting keys
189
- const exists = this[key] != null;
190
- if (!isConfigurable) {
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
- * Wrapper around requestHandler that opens and closes pages etc.
204
- */
205
- async _runRequestHandler(crawlingContext) {
187
+ async makeHttpRequest(crawlingContext) {
188
+ tryCancel();
206
189
  const { request, session } = crawlingContext;
207
- if (this.proxyConfiguration) {
208
- const sessionId = session ? session.id : undefined;
209
- crawlingContext.proxyInfo = await this.proxyConfiguration.newProxyInfo(sessionId, { request });
210
- }
211
- if (!request.skipNavigation) {
212
- await this._handleNavigation(crawlingContext);
213
- tryCancel();
214
- const parsed = await this._parseResponse(request, crawlingContext.response, crawlingContext);
215
- const response = parsed.response;
216
- const contentType = parsed.contentType;
217
- tryCancel();
218
- // `??=` because descendant classes may already set optimized version
219
- crawlingContext.waitForSelector ??= async (selector, _timeoutMs) => {
220
- const $ = cheerio.load(parsed.body.toString());
221
- if ($(selector).get().length === 0) {
222
- throw new Error(`Selector '${selector}' not found.`);
223
- }
224
- };
225
- crawlingContext.parseWithCheerio ??= async (selector, timeoutMs) => {
226
- const $ = cheerio.load(parsed.body.toString());
227
- if (selector) {
228
- await crawlingContext.waitForSelector(selector, timeoutMs);
229
- }
230
- return $;
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
- if (this.useSessionPool) {
233
- this._throwOnBlockedRequest(crawlingContext.session, response.statusCode);
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
- if (this.persistCookiesPerSession) {
236
- crawlingContext.session.setCookiesFromResponse(response);
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
- request.loadedUrl = response.url;
239
- if (!this.requestMatchesEnqueueStrategy(request)) {
240
- this.log.debug(
241
- // eslint-disable-next-line dot-notation
242
- `Skipping request ${request.id} (starting url: ${request.url} -> loaded url: ${request.loadedUrl}) because it does not match the enqueue strategy (${request['enqueueStrategy']}).`);
243
- request.noRetry = true;
244
- request.state = RequestState.SKIPPED;
245
- return;
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
- request.state = RequestState.REQUEST_HANDLER;
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
- return false;
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 _requestFunction({ request, session, proxyUrl, gotOptions, }) {
346
- if (!TimeoutError) {
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) {
300
+ if (e instanceof Error && e.constructor.name === 'TimeoutError') {
356
301
  this._handleRequestTimeout(session);
357
- return undefined;
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 _parseResponse(request, responseStream, crawlingContext) {
371
- const { statusCode } = responseStream;
372
- const { type, charset } = parseContentTypeFromResponse(responseStream);
373
- const { response, encoding } = this._encodeResponse(request, responseStream, charset);
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 (statusCode >= 400 && statusCode <= 599) {
376
- this.stats.registerStatusCode(statusCode);
320
+ if (status >= 400 && status <= 599) {
321
+ this.stats.registerStatusCode(status);
377
322
  }
378
- const excludeError = this.ignoreHttpErrorStatusCodes.has(statusCode);
379
- const includeError = this.additionalHttpErrorStatusCodes.has(statusCode);
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(`${statusCode} - ${message}`);
332
+ throw new Error(`${status} - ${message}`);
390
333
  }
391
- if (includeError) {
392
- throw new Error(`${statusCode} - Error status code was set by user.`);
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(`${statusCode} - Internal Server Error: ${body.slice(0, 100)}`);
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
- const isXml = type.includes('xml');
399
- const parsed = await this._parseHTML(response, isXml, crawlingContext);
400
- return { ...parsed, isXml, response, contentType };
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 concatStreamToBuffer(response);
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
- _getRequestOptions(request, session, proxyUrl, gotOptions) {
364
+ _getRequestOptions(request, session, proxyUrl) {
421
365
  const requestOptions = {
422
366
  url: request.url,
423
367
  method: request.method,
424
368
  proxyUrl,
425
- timeout: { request: this.navigationTimeoutMillis },
369
+ timeout: this.navigationTimeoutMillis,
426
370
  sessionToken: session,
427
- ...gotOptions,
428
- headers: { ...request.headers, ...gotOptions?.headers },
371
+ headers: request.headers,
429
372
  https: {
430
- ...gotOptions?.https,
431
373
  rejectUnauthorized: !this.ignoreSslErrors,
432
374
  },
433
- isStream: true,
375
+ body: undefined,
434
376
  };
435
- // Delete any possible lowercased header for cookie as they are merged in _applyCookies under the uppercase Cookie header
436
- Reflect.deleteProperty(requestOptions.headers, 'cookie');
437
- // TODO this is incorrect, the check for man in the middle needs to be done
438
- // on individual proxy level, not on the `proxyConfiguration` level,
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 (this.proxyConfiguration && this.proxyConfiguration.isManInTheMiddle) {
382
+ if (session.proxyInfo?.ignoreTlsErrors) {
442
383
  requestOptions.https = {
443
384
  ...requestOptions.https,
444
385
  rejectUnauthorized: false,
@@ -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.decodeStream(encoding).on('error', (err) => encodeStream.emit('error', err));
470
- response.on('error', (err) => decodeStream.emit('error', err));
471
- const encodedResponse = response.pipe(decodeStream).pipe(encodeStream);
472
- encodedResponse.statusCode = response.statusCode;
473
- encodedResponse.headers = response.headers;
474
- encodedResponse.url = response.url;
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: encodedResponse,
419
+ response: new ResponseWithUrl(reencodedBody, response),
477
420
  encoding: utf8,
478
421
  };
479
422
  }
@@ -501,16 +444,13 @@ export class HttpCrawler extends BasicCrawler {
501
444
  * Handles timeout request
502
445
  */
503
446
  _handleRequestTimeout(session) {
504
- session?.markBad();
505
- throw new Error(`request timed out after ${this.requestHandlerTimeoutMillis / 1000} seconds.`);
447
+ session.markBad();
448
+ throw new Error(`request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
506
449
  }
507
450
  _abortDownloadOfBody(request, response) {
508
- const { statusCode } = response;
451
+ const { status } = response;
509
452
  const { type } = parseContentTypeFromResponse(response);
510
- // eslint-disable-next-line dot-notation -- accessing private property
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 response = await this.httpClient.stream(processHttpRequestOptions({
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
- return addResponsePropertiesToStream(response.stream, response);
538
- };
539
- }
540
- /**
541
- * The stream object returned from got does not have the below properties.
542
- * At the same time, you can't read data directly from the response stream,
543
- * because they won't get emitted unless you also read from the primary
544
- * got stream. To be able to work with only one stream, we move the expected props
545
- * from the response stream to the got stream.
546
- * @internal
547
- */
548
- function addResponsePropertiesToStream(stream, response) {
549
- const properties = [
550
- 'statusCode',
551
- 'statusMessage',
552
- 'headers',
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
- * Creates new {@link Router} instance that works based on request labels.
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