@crawlee/http 4.0.0-beta.9 → 4.0.0-beta.91

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,28 +1,38 @@
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
  */
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
- const HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
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
- snapshotterOptions: {
21
- eventLoopSnapshotIntervalSecs: 2,
22
- maxBlockedMillis: 100,
23
- },
24
- systemStatusOptions: {
25
- maxEventLoopOverloadedRatio: 0.7,
30
+ loadSignals: {
31
+ eventLoop: {
32
+ snapshotIntervalSecs: 2,
33
+ maxBlockedMillis: 100,
34
+ overloadedRatio: 0.7,
35
+ },
26
36
  },
27
37
  };
28
38
  /**
@@ -36,28 +46,30 @@ 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 RequestList} or {@link RequestQueue} instances provided by the {@link HttpCrawlerOptions.requestList}
41
- * or {@link HttpCrawlerOptions.requestQueue} constructor options, respectively.
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
- * 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.
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 `gotOptions`:
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, gotOptions) => {
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/xhtml+xml` MIME content types (as reported by the `Content-Type` HTTP header),
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.
@@ -65,9 +77,9 @@ const HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
65
77
  *
66
78
  * New requests are only dispatched when there is enough free CPU and memory available,
67
79
  * using the functionality provided by the {@link AutoscaledPool} class.
68
- * All {@link AutoscaledPool} configuration options can be passed to the `autoscaledPoolOptions`
69
- * parameter of the constructor. For user convenience, the `minConcurrency` and `maxConcurrency`
70
- * {@link AutoscaledPool} options are available directly in the constructor.
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
- 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;
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
- persistCookiesPerSession;
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,183 @@ 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
- 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),
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 = {}, config = Configuration.getGlobalConfig()) {
134
+ constructor(options = {}) {
131
135
  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 = [],
136
+ const { navigationTimeoutSecs = 30, ignoreSslErrors = true, additionalMimeTypes = [], suggestResponseEncoding, forceResponseEncoding, saveResponseCookies = true, preNavigationHooks = [], postNavigationHooks = [],
133
137
  // BasicCrawler
134
- autoscaledPoolOptions = HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS, ...basicCrawlerOptions } = options;
138
+ contextPipelineBuilder, ...basicCrawlerOptions } = options;
135
139
  super({
136
140
  ...basicCrawlerOptions,
137
- requestHandler,
138
- 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
- }
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._extendSupportedMimeTypes(additionalMimeTypes);
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
- this.additionalHttpErrorStatusCodes = new Set([...additionalHttpErrorStatusCodes]);
161
- this.ignoreHttpErrorStatusCodes = new Set([...ignoreHttpErrorStatusCodes]);
162
- this.proxyConfiguration = proxyConfiguration;
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
- if (this.useSessionPool) {
169
- this.persistCookiesPerSession = persistCookiesPerSession ?? true;
170
- }
171
- else {
172
- this.persistCookiesPerSession = false;
173
- }
162
+ this.saveResponseCookies = saveResponseCookies;
174
163
  }
175
164
  /**
176
- * **EXPERIMENTAL**
177
- * Function for attaching CrawlerExtensions such as the Unblockers.
178
- * @param extension Crawler extension that overrides the crawler configuration.
165
+ * Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
166
+ * concurrency shortcuts on top. Not called for a supplied
167
+ * {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} spread the constant into it yourself to
168
+ * keep the tuning.
179
169
  */
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;
200
- }
170
+ createDefaultConcurrencySystem(options) {
171
+ return super.createDefaultConcurrencySystem({
172
+ ...HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS,
173
+ ...options,
174
+ });
201
175
  }
202
- /**
203
- * Wrapper around requestHandler that opens and closes pages etc.
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 });
176
+ buildContextPipeline() {
177
+ // When navigation is skipped, `prepareHttpRequest` has already installed throwing getters for
178
+ // the response-derived members, so the guarded action is bypassed and the context left untouched.
179
+ const skipGuard = (action) => ({
180
+ action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
181
+ });
182
+ let pipeline = ContextPipeline.create().compose({
183
+ action: this.prepareHttpRequest.bind(this),
184
+ });
185
+ for (const hook of this.preNavigationHooks) {
186
+ pipeline = pipeline.compose(skipGuard(hook));
210
187
  }
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
- }
188
+ let pipelineWithNavigation = pipeline.compose(skipGuard(this.makeHttpRequest.bind(this)));
189
+ for (const hook of this.postNavigationHooks) {
190
+ pipelineWithNavigation = pipelineWithNavigation.compose(skipGuard(hook));
191
+ }
192
+ return pipelineWithNavigation
193
+ .compose({ action: this.processHttpResponse.bind(this) })
194
+ .compose({ action: this.handleBlockedRequestByContent.bind(this) });
195
+ }
196
+ async prepareHttpRequest(crawlingContext) {
197
+ const { request } = crawlingContext;
198
+ if (request.skipNavigation) {
199
+ return {
200
+ request: new Proxy(request, {
201
+ get(target, propertyName, receiver) {
202
+ if (propertyName === 'loadedUrl') {
203
+ throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
204
+ }
205
+ return Reflect.get(target, propertyName, receiver);
206
+ },
207
+ }),
208
+ get response() {
209
+ throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
210
+ },
224
211
  };
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 $;
212
+ }
213
+ request.state = RequestState.BEFORE_NAV;
214
+ return {};
215
+ }
216
+ async makeHttpRequest(crawlingContext) {
217
+ tryCancel();
218
+ const { request, session } = crawlingContext;
219
+ const proxyUrl = crawlingContext.proxyInfo?.url;
220
+ const httpResponse = await addTimeoutToPromise(async () => this.requestFunction({ request, session, proxyUrl }), this.navigationTimeoutMillis, `request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
221
+ tryCancel();
222
+ request.loadedUrl = httpResponse?.url;
223
+ request.state = RequestState.AFTER_NAV;
224
+ return { request: request, response: httpResponse };
225
+ }
226
+ async processHttpResponse(crawlingContext) {
227
+ if (crawlingContext.request.skipNavigation) {
228
+ return {
229
+ get contentType() {
230
+ throw new NavigationSkippedError('The `contentType` property is not available - `skipNavigation` was used');
231
+ },
232
+ get body() {
233
+ throw new NavigationSkippedError('The `body` property is not available - `skipNavigation` was used');
234
+ },
235
+ get json() {
236
+ throw new NavigationSkippedError('The `json` property is not available - `skipNavigation` was used');
237
+ },
238
+ get waitForSelector() {
239
+ throw new NavigationSkippedError('The `waitForSelector` method is not available - `skipNavigation` was used');
240
+ },
241
+ get parseWithCheerio() {
242
+ throw new NavigationSkippedError('The `parseWithCheerio` method is not available - `skipNavigation` was used');
243
+ },
231
244
  };
232
- if (this.useSessionPool) {
233
- this._throwOnBlockedRequest(crawlingContext.session, response.statusCode);
245
+ }
246
+ tryCancel();
247
+ const parsed = await this.parseResponse(crawlingContext.request, crawlingContext.response);
248
+ tryCancel();
249
+ const response = parsed.response;
250
+ const contentType = parsed.contentType;
251
+ const waitForSelector = async (selector, _timeoutMs) => {
252
+ const cheerio = await import('cheerio');
253
+ const $ = cheerio.load(parsed.body.toString());
254
+ if ($(selector).get().length === 0) {
255
+ throw new Error(`Selector '${selector}' not found.`);
234
256
  }
235
- if (this.persistCookiesPerSession) {
236
- crawlingContext.session.setCookiesFromResponse(response);
257
+ };
258
+ const parseWithCheerio = async (selector, timeoutMs) => {
259
+ const cheerio = await import('cheerio');
260
+ const $ = cheerio.load(parsed.body.toString());
261
+ if (selector) {
262
+ await crawlingContext.waitForSelector(selector, timeoutMs);
237
263
  }
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;
264
+ return $;
265
+ };
266
+ this._throwOnBlockedRequest(response.status);
267
+ if (this.saveResponseCookies) {
268
+ try {
269
+ for (const cookie of getCookiesFromResponse(response)) {
270
+ if (!cookie)
271
+ continue;
272
+ try {
273
+ crawlingContext.session.cookieJar.setCookieSync(cookie, response.url, { ignoreError: false });
274
+ }
275
+ catch (e) {
276
+ this.log.debug(`Could not set cookie: ${e.message}`);
277
+ }
278
+ }
279
+ }
280
+ catch (e) {
281
+ this.log.exception(e, 'Could not get cookies from response');
246
282
  }
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
283
  }
284
+ return {
285
+ get json() {
286
+ if (contentType.type !== APPLICATION_JSON_MIME_TYPE)
287
+ return null;
288
+ const jsonString = parsed.body.toString(contentType.encoding);
289
+ return JSON.parse(jsonString);
290
+ },
291
+ waitForSelector,
292
+ parseWithCheerio,
293
+ contentType,
294
+ body: parsed.body,
295
+ };
296
+ }
297
+ async handleBlockedRequestByContent(crawlingContext) {
257
298
  if (this.retryOnBlocked) {
258
299
  const error = await this.isRequestBlocked(crawlingContext);
259
300
  if (error)
260
301
  throw new SessionError(error);
261
302
  }
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
- }
303
+ return {};
271
304
  }
272
305
  async isRequestBlocked(crawlingContext) {
273
306
  if (HTML_AND_XML_MIME_TYPES.includes(crawlingContext.contentType.type)) {
@@ -277,84 +310,25 @@ export class HttpCrawler extends BasicCrawler {
277
310
  return `Found selectors: ${foundSelectors.join(', ')}`;
278
311
  }
279
312
  }
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;
313
+ if (this.blockedStatusCodes.has(crawlingContext.response.status)) {
314
+ return `Blocked by status code ${crawlingContext.response.status}`;
338
315
  }
316
+ return false;
339
317
  }
340
318
  /**
341
319
  * Function to make the HTTP request. It performs optimizations
342
320
  * on the request such as only downloading the request body if the
343
321
  * received content type matches text/html, application/xml, application/xhtml+xml.
344
322
  */
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);
323
+ async requestFunction({ request, session, proxyUrl }) {
324
+ const opts = this.getRequestOptions(request, session, proxyUrl);
351
325
  try {
352
326
  return await this._requestAsBrowser(opts, session);
353
327
  }
354
328
  catch (e) {
355
- if (e instanceof TimeoutError) {
356
- this._handleRequestTimeout(session);
357
- return undefined;
329
+ if (e instanceof Error && e.constructor.name === 'TimeoutError') {
330
+ this.handleRequestTimeout(session);
331
+ return new Response(); // this will never happen, as handleRequestTimeout always throws
358
332
  }
359
333
  if (this.isProxyError(e)) {
360
334
  throw new SessionError(this._getMessageFromError(e));
@@ -367,18 +341,16 @@ export class HttpCrawler extends BasicCrawler {
367
341
  /**
368
342
  * Encodes and parses response according to the provided content type
369
343
  */
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);
344
+ async parseResponse(request, response) {
345
+ const { status } = response;
346
+ const { type, charset } = parseContentTypeFromResponse(response);
347
+ const { response: reencodedResponse, encoding } = this.encodeResponse(request, response, charset);
374
348
  const contentType = { type, encoding };
375
- if (statusCode >= 400 && statusCode <= 599) {
376
- this.stats.registerStatusCode(statusCode);
349
+ if (status >= 400 && status <= 599) {
350
+ this.stats.registerStatusCode(status);
377
351
  }
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);
352
+ if (this.isErrorStatusCode(status)) {
353
+ const body = await reencodedResponse.text(); // TODO - this always uses UTF-8 (see https://developer.mozilla.org/en-US/docs/Web/API/Request/text)
382
354
  // Errors are often sent as JSON, so attempt to parse them,
383
355
  // despite Accept header being set to text/html.
384
356
  if (type === APPLICATION_JSON_MIME_TYPE) {
@@ -386,59 +358,57 @@ export class HttpCrawler extends BasicCrawler {
386
358
  let { message } = errorResponse;
387
359
  if (!message)
388
360
  message = util.inspect(errorResponse, { depth: 1, maxArrayLength: 10 });
389
- throw new Error(`${statusCode} - ${message}`);
361
+ throw new Error(`${status} - ${message}`);
390
362
  }
391
- if (includeError) {
392
- throw new Error(`${statusCode} - Error status code was set by user.`);
363
+ if (this.additionalHttpErrorStatusCodes.has(status)) {
364
+ throw new Error(`${status} - Error status code was set by user.`);
393
365
  }
394
366
  // 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)}`);
367
+ throw new Error(`${status} - Internal Server Error: ${body.slice(0, 100)}`);
396
368
  }
397
369
  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 };
370
+ if (!charset && !this.forceResponseEncoding) {
371
+ const rawBytes = Buffer.from(await response.arrayBuffer());
372
+ const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
373
+ const charsetToUse = metaCharset ?? this.suggestResponseEncoding ?? 'utf-8';
374
+ const body = iconv.encodingExists(charsetToUse)
375
+ ? iconv.decode(rawBytes, charsetToUse)
376
+ : rawBytes.toString('utf8');
377
+ return { response, contentType: { type, encoding: 'utf-8' }, body };
378
+ }
379
+ return { response, contentType, body: await reencodedResponse.text() };
401
380
  }
402
381
  else {
403
- const body = await concatStreamToBuffer(response);
382
+ const body = Buffer.from(await reencodedResponse.bytes());
404
383
  return {
405
384
  body,
406
385
  response,
407
386
  contentType,
408
- enqueueLinks: async () => Promise.resolve({ processedRequests: [], unprocessedRequests: [] }),
409
387
  };
410
388
  }
411
389
  }
412
- async _parseHTML(response, _isXml, _crawlingContext) {
413
- return {
414
- body: await concatStreamToBuffer(response),
415
- };
416
- }
417
390
  /**
418
391
  * Combines the provided `requestOptions` with mandatory (non-overridable) values.
419
392
  */
420
- _getRequestOptions(request, session, proxyUrl, gotOptions) {
393
+ getRequestOptions(request, session, proxyUrl) {
421
394
  const requestOptions = {
422
395
  url: request.url,
423
396
  method: request.method,
424
397
  proxyUrl,
425
- timeout: { request: this.navigationTimeoutMillis },
398
+ timeout: this.navigationTimeoutMillis,
426
399
  sessionToken: session,
427
- ...gotOptions,
428
- headers: { ...request.headers, ...gotOptions?.headers },
400
+ headers: request.headers,
429
401
  https: {
430
- ...gotOptions?.https,
431
402
  rejectUnauthorized: !this.ignoreSslErrors,
432
403
  },
433
- isStream: true,
404
+ body: undefined,
434
405
  };
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.
406
+ if (requestOptions.headers?.cookie || requestOptions.headers?.Cookie) {
407
+ requestOptions.headers.Cookie = this._getCookieHeaderFromRequest(request);
408
+ delete requestOptions.headers.cookie;
409
+ }
440
410
  // Disable SSL verification for MITM proxies
441
- if (this.proxyConfiguration && this.proxyConfiguration.isManInTheMiddle) {
411
+ if (session.proxyInfo?.ignoreTlsErrors) {
442
412
  requestOptions.https = {
443
413
  ...requestOptions.https,
444
414
  rejectUnauthorized: false,
@@ -448,7 +418,7 @@ export class HttpCrawler extends BasicCrawler {
448
418
  requestOptions.body = request.payload ?? '';
449
419
  return requestOptions;
450
420
  }
451
- _encodeResponse(request, response, encoding) {
421
+ encodeResponse(request, response, encoding) {
452
422
  if (this.forceResponseEncoding) {
453
423
  encoding = this.forceResponseEncoding;
454
424
  }
@@ -466,14 +436,16 @@ export class HttpCrawler extends BasicCrawler {
466
436
  // Try to re-encode a variety of unsupported encodings to utf-8
467
437
  if (iconv.encodingExists(encoding)) {
468
438
  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;
439
+ const decodeStream = iconv
440
+ .decodeStream(encoding)
441
+ .on('error', (err) => encodeStream.emit('error', err));
442
+ const reencodedBody = response.body
443
+ ? Readable.toWeb(Readable.from(Readable.fromWeb(response.body)
444
+ .pipe(decodeStream)
445
+ .pipe(encodeStream)))
446
+ : null;
475
447
  return {
476
- response: encodedResponse,
448
+ response: new ResponseWithUrl(reencodedBody, response),
477
449
  encoding: utf8,
478
450
  };
479
451
  }
@@ -482,7 +454,7 @@ export class HttpCrawler extends BasicCrawler {
482
454
  /**
483
455
  * Checks and extends supported mime types
484
456
  */
485
- _extendSupportedMimeTypes(additionalMimeTypes) {
457
+ extendSupportedMimeTypes(additionalMimeTypes) {
486
458
  for (const mimeType of additionalMimeTypes) {
487
459
  if (mimeType === '*/*') {
488
460
  this.supportedMimeTypes.add(mimeType);
@@ -500,17 +472,14 @@ export class HttpCrawler extends BasicCrawler {
500
472
  /**
501
473
  * Handles timeout request
502
474
  */
503
- _handleRequestTimeout(session) {
504
- session?.markBad();
505
- throw new Error(`request timed out after ${this.requestHandlerTimeoutMillis / 1000} seconds.`);
475
+ handleRequestTimeout(session) {
476
+ session.markBad();
477
+ throw new Error(`request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
506
478
  }
507
479
  _abortDownloadOfBody(request, response) {
508
- const { statusCode } = response;
480
+ const { status } = response;
509
481
  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);
482
+ const isTransientContentType = status >= 500 || this.blockedStatusCodes.has(status);
514
483
  if (!this.supportedMimeTypes.has(type) && !this.supportedMimeTypes.has('*/*') && !isTransientContentType) {
515
484
  request.noRetry = true;
516
485
  throw new Error(`Resource ${request.url} served Content-Type ${type}, ` +
@@ -521,115 +490,28 @@ export class HttpCrawler extends BasicCrawler {
521
490
  * @internal wraps public utility for mocking purposes
522
491
  */
523
492
  _requestAsBrowser = async (options, session) => {
524
- const response = await this.httpClient.stream(processHttpRequestOptions({
493
+ const opts = processHttpRequestOptions({
525
494
  ...options,
526
- cookieJar: options.cookieJar, // HACK - the type of ToughCookieJar in got is wrong
527
495
  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
496
  });
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,
497
+ // When saveResponseCookies is false, the response cookies must not mutate the
498
+ // session jar. Reads still go through the session (so session.setCookie() in pre-nav
499
+ // hooks keeps working) but a per-request clone is passed in so writes are discarded.
500
+ const cookieJar = this.saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
501
+ const response = await this.httpClient.sendRequest(new Request(opts.url, {
502
+ body: opts.body ? Readable.toWeb(opts.body) : undefined,
503
+ headers: new Headers(opts.headers),
504
+ method: opts.method,
505
+ // Node-specific option to make the request body work with streams
506
+ duplex: 'half',
507
+ }), {
508
+ session,
509
+ cookieJar,
510
+ timeoutMillis: opts.timeout,
511
+ });
512
+ return response;
606
513
  };
607
514
  }
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);
515
+ export function createHttpRouter(routesOrSchemas) {
516
+ return Router.create(routesOrSchemas);
634
517
  }
635
- //# sourceMappingURL=http-crawler.js.map