@crawlee/http 4.0.0-beta.23 → 4.0.0-beta.231

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