@crawlee/http 4.0.0-beta.99 → 4.0.0-rc.1

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,12 +1,11 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import util from 'node:util';
3
- import { BasicCrawler, ContextPipeline, NavigationSkippedError, remainingNavigationWindowMillis, RequestState, Router, SessionError, } from '@crawlee/basic';
4
- import { getCookiesFromResponse } from '@crawlee/core';
3
+ import { BasicCrawler, ContextPipeline, getCookiesFromResponse, NavigationSkippedError, remainingNavigationWindowMillis, RequestState, RequestThrottledError, Router, SessionError, } from '@crawlee/basic';
5
4
  import { ResponseWithUrl } from '@crawlee/http-client';
6
- import { RETRY_CSS_SELECTORS } from '@crawlee/utils';
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';
8
+ import { z } from 'zod';
10
9
  import { addTimeoutToPromise, storage, TimeoutError, tryCancel } from '@apify/timeout';
11
10
  import { extractCharsetFromHtmlBytes, parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
12
11
  /**
@@ -16,7 +15,8 @@ const HTML_AND_XML_MIME_TYPES = ['text/html', 'text/xml', 'application/xhtml+xml
16
15
  const APPLICATION_JSON_MIME_TYPE = 'application/json';
17
16
  /**
18
17
  * 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.
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
20
  *
21
21
  * A {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} you supply yourself replaces that default
22
22
  * wholesale, tuning included, so spread these options in if you want to keep it:
@@ -109,67 +109,67 @@ export class HttpCrawler extends BasicCrawler {
109
109
  // extension-aware for consumer DX, but internally the pipeline composes hooks against the
110
110
  // concrete crawling context, which does not statically carry `ContextExtension`. The members
111
111
  // added by `extendContext` are present at runtime regardless.
112
- preNavigationHooks;
113
- postNavigationHooks;
114
- saveResponseCookies;
115
- navigationTimeoutMillis;
116
- ignoreSslErrors;
117
- suggestResponseEncoding;
118
- forceResponseEncoding;
119
- supportedMimeTypes;
112
+ #preNavigationHooks;
113
+ #postNavigationHooks;
114
+ #saveResponseCookies;
115
+ #navigationTimeoutMillis;
116
+ #ignoreTlsErrors;
117
+ #suggestResponseEncoding;
118
+ #forceResponseEncoding;
119
+ #supportedMimeTypes;
120
+ /**
121
+ * @internal
122
+ */
120
123
  static optionsShape = {
121
124
  ...BasicCrawler.optionsShape,
122
- navigationTimeoutSecs: ow.optional.number,
123
- ignoreSslErrors: ow.optional.boolean,
124
- additionalMimeTypes: ow.optional.array.ofType(ow.string),
125
- suggestResponseEncoding: ow.optional.string,
126
- forceResponseEncoding: ow.optional.string,
127
- saveResponseCookies: ow.optional.boolean,
128
- preNavigationHooks: ow.optional.array,
129
- 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(() => []),
130
133
  };
134
+ /** @internal */
135
+ static optionsSchema = z.strictObject(HttpCrawler.optionsShape);
131
136
  /**
132
137
  * All `HttpCrawlerOptions` parameters are passed via an options object.
133
138
  */
134
139
  constructor(options = {}) {
135
- ow(options, 'HttpCrawlerOptions', ow.object.exactShape(HttpCrawler.optionsShape));
136
- const { navigationTimeoutSecs = 30, ignoreSslErrors = true, additionalMimeTypes = [], suggestResponseEncoding, forceResponseEncoding, saveResponseCookies = true, preNavigationHooks = [], postNavigationHooks = [],
140
+ const { navigationTimeoutSecs, ignoreTlsErrors, additionalMimeTypes, suggestResponseEncoding, forceResponseEncoding, saveResponseCookies, preNavigationHooks, postNavigationHooks,
137
141
  // BasicCrawler
138
- contextPipelineBuilder, ...basicCrawlerOptions } = options;
142
+ contextPipelineBuilder, ...basicCrawlerOptions } = parseArgument(options, HttpCrawler.optionsSchema, 'HttpCrawlerOptions');
139
143
  super({
140
144
  ...basicCrawlerOptions,
141
145
  contextPipelineBuilder: contextPipelineBuilder ??
142
146
  (() => this.buildContextPipeline()),
143
147
  });
144
- this.supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
148
+ this.#supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
145
149
  if (additionalMimeTypes.length)
146
- this.extendSupportedMimeTypes(additionalMimeTypes);
150
+ this.#extendSupportedMimeTypes(additionalMimeTypes);
147
151
  if (suggestResponseEncoding && forceResponseEncoding) {
148
152
  this.log.warning('Both forceResponseEncoding and suggestResponseEncoding options are set. Using forceResponseEncoding.');
149
153
  }
150
- this.navigationTimeoutMillis = navigationTimeoutSecs * 1000;
151
- this.ignoreSslErrors = ignoreSslErrors;
152
- this.suggestResponseEncoding = suggestResponseEncoding;
153
- this.forceResponseEncoding = forceResponseEncoding;
154
+ this.#navigationTimeoutMillis = navigationTimeoutSecs * 1000;
155
+ this.#ignoreTlsErrors = ignoreTlsErrors;
156
+ this.#suggestResponseEncoding = suggestResponseEncoding;
157
+ this.#forceResponseEncoding = forceResponseEncoding;
154
158
  // Cast away the extension-aware option types to the base internal storage types (see the field
155
159
  // declarations above). This is sound - the hooks only ever receive the base context plus the
156
160
  // members `extendContext` added at runtime.
157
- this.preNavigationHooks = preNavigationHooks;
158
- this.postNavigationHooks = [
159
- ({ request, response }) => this._abortDownloadOfBody(request, response),
161
+ this.#preNavigationHooks = preNavigationHooks;
162
+ this.#postNavigationHooks = [
163
+ ({ request, response }) => this.#abortDownloadOfBody(request, response),
160
164
  ...postNavigationHooks,
161
165
  ];
162
- this.saveResponseCookies = saveResponseCookies;
166
+ this.#saveResponseCookies = saveResponseCookies;
163
167
  }
168
+ /** @internal */
164
169
  getNavigationTimeoutMillis() {
165
- return this.navigationTimeoutMillis;
170
+ return this.#navigationTimeoutMillis;
166
171
  }
167
- /**
168
- * Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
169
- * concurrency shortcuts on top. Not called for a supplied
170
- * {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} — spread the constant into it yourself to
171
- * keep the tuning.
172
- */
172
+ /** @internal */
173
173
  createDefaultConcurrencySystem(options) {
174
174
  return super.createDefaultConcurrencySystem({
175
175
  ...HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS,
@@ -179,45 +179,44 @@ export class HttpCrawler extends BasicCrawler {
179
179
  buildContextPipeline() {
180
180
  // When navigation is skipped, `prepareHttpRequest` has already installed throwing getters for
181
181
  // the response-derived members, so the guarded action is bypassed and the context left untouched.
182
- const skipGuard = (action) => ({
183
- action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
184
- });
182
+ const skipGuard = (action) => async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {}));
185
183
  // A single navigation window covers the pre-navigation hooks, the navigation, and the post-navigation
186
184
  // hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow hook eats into the same
187
185
  // window the navigation uses instead of each step being timed on its own.
188
- const navigationTimedOut = `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`;
186
+ const navigationTimedOut = `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`;
189
187
  const windowGuard = (step) => skipGuard(async (ctx) => {
190
- const remaining = remainingNavigationWindowMillis(ctx, this.navigationTimeoutMillis);
188
+ const remaining = remainingNavigationWindowMillis(ctx, this.#navigationTimeoutMillis);
191
189
  if (remaining <= 0) {
192
190
  throw new TimeoutError(navigationTimedOut);
193
191
  }
194
192
  return addTimeoutToPromise(async () => step(ctx), remaining, navigationTimedOut);
195
193
  });
196
- let pipeline = ContextPipeline.create().compose({
197
- action: this.prepareHttpRequest.bind(this),
198
- });
199
- for (const hook of this.preNavigationHooks) {
194
+ let pipeline = ContextPipeline.create().compose(this.#prepareHttpRequest.bind(this));
195
+ for (const hook of this.#preNavigationHooks) {
200
196
  pipeline = pipeline.compose(windowGuard(hook));
201
197
  }
202
198
  let pipelineWithNavigation = pipeline.compose(skipGuard(this.makeHttpRequest.bind(this)));
203
- for (const hook of this.postNavigationHooks) {
199
+ for (const hook of this.#postNavigationHooks) {
204
200
  pipelineWithNavigation = pipelineWithNavigation.compose(windowGuard(hook));
205
201
  }
206
202
  return pipelineWithNavigation
207
- .compose({ action: this.processHttpResponse.bind(this) })
208
- .compose({ action: this.handleBlockedRequestByContent.bind(this) });
203
+ .compose(this.#processHttpResponse.bind(this))
204
+ .compose(this.#handleBlockedRequestByContent.bind(this));
209
205
  }
210
- async prepareHttpRequest(crawlingContext) {
206
+ async #prepareHttpRequest(crawlingContext) {
211
207
  const { request } = crawlingContext;
212
208
  if (request.skipNavigation) {
213
209
  return {
214
210
  request: new Proxy(request, {
215
- get(target, propertyName, receiver) {
211
+ get(target, propertyName) {
216
212
  if (propertyName === 'loadedUrl') {
217
213
  throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
218
214
  }
219
- 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;
220
218
  },
219
+ set: (target, propertyName, value) => Reflect.set(target, propertyName, value, target),
221
220
  }),
222
221
  get response() {
223
222
  throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
@@ -227,6 +226,7 @@ export class HttpCrawler extends BasicCrawler {
227
226
  request.state = RequestState.BEFORE_NAV;
228
227
  return {};
229
228
  }
229
+ // oxlint-disable-next-line crawlee/prefer-private-fields -- patched by @crawlee/otel
230
230
  async makeHttpRequest(crawlingContext) {
231
231
  tryCancel();
232
232
  const { request, session } = crawlingContext;
@@ -234,13 +234,13 @@ export class HttpCrawler extends BasicCrawler {
234
234
  // Bound the request by whatever is left of the shared navigation window (the pre-navigation hooks may
235
235
  // have already spent part of it), so it produces a clean navigation-timeout error rather than the raw
236
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.`);
237
+ const httpResponse = await addTimeoutToPromise(async () => this.#requestFunction({ request, session, proxyUrl }), Math.max(1, remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis)), `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
238
238
  tryCancel();
239
239
  request.loadedUrl = httpResponse?.url;
240
240
  request.state = RequestState.AFTER_NAV;
241
241
  return { request: request, response: httpResponse };
242
242
  }
243
- async processHttpResponse(crawlingContext) {
243
+ async #processHttpResponse(crawlingContext) {
244
244
  if (crawlingContext.request.skipNavigation) {
245
245
  return {
246
246
  get contentType() {
@@ -261,40 +261,55 @@ export class HttpCrawler extends BasicCrawler {
261
261
  };
262
262
  }
263
263
  tryCancel();
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
+ }
264
275
  // Reading the body is still part of the navigation, so it draws from the same shared window: on a server
265
276
  // that streams the body slowly the request completes (headers arrive) but the body read would otherwise
266
277
  // run unbounded. `extendTimeout` from a post-navigation hook has already pushed this deadline out if asked.
267
- const remaining = remainingNavigationWindowMillis(crawlingContext, this.navigationTimeoutMillis);
278
+ const remaining = remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis);
268
279
  if (remaining <= 0) {
269
- throw new TimeoutError(`Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
280
+ throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
270
281
  }
271
- const parsed = await addTimeoutToPromise(async () => this.parseResponse(crawlingContext.request, crawlingContext.response), remaining, `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
282
+ const parsed = await addTimeoutToPromise(async () => this.#parseResponse(crawlingContext.request, crawlingContext.response), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
272
283
  tryCancel();
273
284
  const response = parsed.response;
274
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
+ };
275
290
  const waitForSelector = async (selector, _timeoutMs) => {
276
- const cheerio = await import('cheerio');
277
- const $ = cheerio.load(parsed.body.toString());
291
+ const $ = await loadBody();
278
292
  if ($(selector).get().length === 0) {
279
293
  throw new Error(`Selector '${selector}' not found.`);
280
294
  }
281
295
  };
282
296
  const parseWithCheerio = async (selector, timeoutMs) => {
283
- const cheerio = await import('cheerio');
284
- const $ = cheerio.load(parsed.body.toString());
297
+ const $ = await loadBody();
285
298
  if (selector) {
286
299
  await crawlingContext.waitForSelector(selector, timeoutMs);
287
300
  }
288
301
  return $;
289
302
  };
290
- this._throwOnBlockedRequest(response.status);
291
- if (this.saveResponseCookies) {
303
+ this.throwOnBlockedRequest(response.status);
304
+ if (this.#saveResponseCookies) {
292
305
  try {
293
306
  for (const cookie of getCookiesFromResponse(response)) {
294
307
  if (!cookie)
295
308
  continue;
296
309
  try {
297
- crawlingContext.session.cookieJar.setCookieSync(cookie, response.url, { ignoreError: false });
310
+ await crawlingContext.session.cookieJar.setCookie(cookie, response.url, {
311
+ ignoreError: false,
312
+ });
298
313
  }
299
314
  catch (e) {
300
315
  this.log.debug(`Could not set cookie: ${e.message}`);
@@ -318,15 +333,15 @@ export class HttpCrawler extends BasicCrawler {
318
333
  body: parsed.body,
319
334
  };
320
335
  }
321
- async handleBlockedRequestByContent(crawlingContext) {
336
+ async #handleBlockedRequestByContent(crawlingContext) {
322
337
  if (this.retryOnBlocked) {
323
- const error = await this.isRequestBlocked(crawlingContext);
338
+ const error = await this.#isRequestBlocked(crawlingContext);
324
339
  if (error)
325
340
  throw new SessionError(error);
326
341
  }
327
342
  return {};
328
343
  }
329
- async isRequestBlocked(crawlingContext) {
344
+ async #isRequestBlocked(crawlingContext) {
330
345
  if (HTML_AND_XML_MIME_TYPES.includes(crawlingContext.contentType.type)) {
331
346
  const $ = await crawlingContext.parseWithCheerio();
332
347
  const foundSelectors = RETRY_CSS_SELECTORS.filter((selector) => $(selector).length > 0);
@@ -344,18 +359,18 @@ export class HttpCrawler extends BasicCrawler {
344
359
  * on the request such as only downloading the request body if the
345
360
  * received content type matches text/html, application/xml, application/xhtml+xml.
346
361
  */
347
- async requestFunction({ request, session, proxyUrl }) {
348
- const opts = this.getRequestOptions(request, session, proxyUrl);
362
+ async #requestFunction({ request, session, proxyUrl }) {
363
+ const opts = this.#getRequestOptions(request, proxyUrl);
349
364
  try {
350
- return await this._requestAsBrowser(opts, session);
365
+ return await this.#requestAsBrowser(opts, session);
351
366
  }
352
367
  catch (e) {
353
368
  if (e instanceof Error && e.constructor.name === 'TimeoutError') {
354
- this.handleRequestTimeout(session);
369
+ this.#handleRequestTimeout(session);
355
370
  return new Response(); // this will never happen, as handleRequestTimeout always throws
356
371
  }
357
372
  if (this.isProxyError(e)) {
358
- throw new SessionError(this._getMessageFromError(e));
373
+ throw new SessionError(this.getMessageFromError(e));
359
374
  }
360
375
  else {
361
376
  throw e;
@@ -365,16 +380,15 @@ export class HttpCrawler extends BasicCrawler {
365
380
  /**
366
381
  * Encodes and parses response according to the provided content type
367
382
  */
368
- async parseResponse(request, response) {
383
+ async #parseResponse(request, response) {
369
384
  const { status } = response;
370
385
  const { type, charset } = parseContentTypeFromResponse(response);
371
- const { response: reencodedResponse, encoding } = this.encodeResponse(request, response, charset);
372
- const contentType = { type, encoding };
373
386
  if (status >= 400 && status <= 599) {
374
- this.stats.registerStatusCode(status);
387
+ this.statistics.registerStatusCode(status);
375
388
  }
376
389
  if (this.isErrorStatusCode(status)) {
377
- const body = await reencodedResponse.text(); // TODO - this always uses UTF-8 (see https://developer.mozilla.org/en-US/docs/Web/API/Request/text)
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)
378
392
  // Errors are often sent as JSON, so attempt to parse them,
379
393
  // despite Accept header being set to text/html.
380
394
  if (type === APPLICATION_JSON_MIME_TYPE) {
@@ -390,64 +404,54 @@ export class HttpCrawler extends BasicCrawler {
390
404
  // It's not a JSON, so it's probably some text. Get the first 100 chars of it.
391
405
  throw new Error(`${status} - Internal Server Error: ${body.slice(0, 100)}`);
392
406
  }
393
- else if (HTML_AND_XML_MIME_TYPES.includes(type)) {
394
- if (!charset && !this.forceResponseEncoding) {
395
- const rawBytes = Buffer.from(await response.arrayBuffer());
396
- const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
397
- const charsetToUse = metaCharset ?? this.suggestResponseEncoding ?? 'utf-8';
398
- const body = iconv.encodingExists(charsetToUse)
399
- ? iconv.decode(rawBytes, charsetToUse)
400
- : rawBytes.toString('utf8');
401
- return { response, contentType: { type, encoding: 'utf-8' }, body };
402
- }
403
- return { response, contentType, body: await reencodedResponse.text() };
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 };
404
417
  }
405
- else {
406
- const body = Buffer.from(await reencodedResponse.bytes());
407
- return {
408
- body,
409
- response,
410
- contentType,
411
- };
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() };
412
422
  }
423
+ return {
424
+ body: Buffer.from(await reencodedResponse.bytes()),
425
+ response,
426
+ contentType,
427
+ };
413
428
  }
414
429
  /**
415
430
  * Combines the provided `requestOptions` with mandatory (non-overridable) values.
416
431
  */
417
- getRequestOptions(request, session, proxyUrl) {
432
+ #getRequestOptions(request, proxyUrl) {
418
433
  const requestOptions = {
419
434
  url: request.url,
420
435
  method: request.method,
421
436
  proxyUrl,
422
- timeout: this.navigationTimeoutMillis,
423
- sessionToken: session,
437
+ timeout: this.#navigationTimeoutMillis,
424
438
  headers: request.headers,
425
- https: {
426
- rejectUnauthorized: !this.ignoreSslErrors,
427
- },
428
439
  body: undefined,
429
440
  };
430
441
  if (requestOptions.headers?.cookie || requestOptions.headers?.Cookie) {
431
- requestOptions.headers.Cookie = this._getCookieHeaderFromRequest(request);
442
+ requestOptions.headers.Cookie = this.getCookieHeaderFromRequest(request);
432
443
  delete requestOptions.headers.cookie;
433
444
  }
434
- // Disable SSL verification for MITM proxies
435
- if (session.proxyInfo?.ignoreTlsErrors) {
436
- requestOptions.https = {
437
- ...requestOptions.https,
438
- rejectUnauthorized: false,
439
- };
440
- }
441
445
  if (/PATCH|POST|PUT/.test(request.method))
442
446
  requestOptions.body = request.payload ?? '';
443
447
  return requestOptions;
444
448
  }
445
- encodeResponse(request, response, encoding) {
446
- if (this.forceResponseEncoding) {
447
- encoding = this.forceResponseEncoding;
449
+ #encodeResponse(request, response, encoding) {
450
+ if (this.#forceResponseEncoding) {
451
+ encoding = this.#forceResponseEncoding;
448
452
  }
449
- else if (!encoding && this.suggestResponseEncoding) {
450
- encoding = this.suggestResponseEncoding;
453
+ else if (!encoding && this.#suggestResponseEncoding) {
454
+ encoding = this.#suggestResponseEncoding;
451
455
  }
452
456
  // Fall back to utf-8 if we still don't have encoding.
453
457
  const utf8 = 'utf8';
@@ -478,42 +482,40 @@ export class HttpCrawler extends BasicCrawler {
478
482
  /**
479
483
  * Checks and extends supported mime types
480
484
  */
481
- extendSupportedMimeTypes(additionalMimeTypes) {
485
+ #extendSupportedMimeTypes(additionalMimeTypes) {
482
486
  for (const mimeType of additionalMimeTypes) {
483
487
  if (mimeType === '*/*') {
484
- this.supportedMimeTypes.add(mimeType);
488
+ this.#supportedMimeTypes.add(mimeType);
485
489
  continue;
486
490
  }
487
- try {
488
- const parsedType = contentTypeParser.parse(mimeType);
489
- this.supportedMimeTypes.add(parsedType.type);
490
- }
491
- catch (err) {
491
+ const parsedType = parseContentType(mimeType);
492
+ if (!isTypeValid(parsedType.type)) {
492
493
  throw new Error(`Can not parse mime type ${mimeType} from "options.additionalMimeTypes".`);
493
494
  }
495
+ this.#supportedMimeTypes.add(parsedType.type);
494
496
  }
495
497
  }
496
498
  /**
497
499
  * Handles timeout request
498
500
  */
499
- handleRequestTimeout(session) {
501
+ #handleRequestTimeout(session) {
500
502
  session.markBad();
501
- throw new Error(`Request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
503
+ throw new Error(`Request timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
502
504
  }
503
- _abortDownloadOfBody(request, response) {
505
+ #abortDownloadOfBody(request, response) {
504
506
  const { status } = response;
505
507
  const { type } = parseContentTypeFromResponse(response);
506
508
  const isTransientContentType = status >= 500 || this.blockedStatusCodes.has(status);
507
- if (!this.supportedMimeTypes.has(type) && !this.supportedMimeTypes.has('*/*') && !isTransientContentType) {
509
+ if (!this.#supportedMimeTypes.has(type) && !this.#supportedMimeTypes.has('*/*') && !isTransientContentType) {
508
510
  request.noRetry = true;
509
511
  throw new Error(`Resource ${request.url} served Content-Type ${type}, ` +
510
- `but only ${Array.from(this.supportedMimeTypes).join(', ')} are allowed. Skipping resource.`);
512
+ `but only ${Array.from(this.#supportedMimeTypes).join(', ')} are allowed. Skipping resource.`);
511
513
  }
512
514
  }
513
515
  /**
514
516
  * @internal wraps public utility for mocking purposes
515
517
  */
516
- _requestAsBrowser = async (options, session) => {
518
+ #requestAsBrowser = async (options, session) => {
517
519
  const opts = processHttpRequestOptions({
518
520
  ...options,
519
521
  responseType: 'text',
@@ -521,7 +523,7 @@ export class HttpCrawler extends BasicCrawler {
521
523
  // When saveResponseCookies is false, the response cookies must not mutate the
522
524
  // session jar. Reads still go through the session (so session.setCookie() in pre-nav
523
525
  // hooks keeps working) but a per-request clone is passed in so writes are discarded.
524
- const cookieJar = this.saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
526
+ const cookieJar = this.#saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
525
527
  // Bind the request to the shared navigation window instead of a fixed per-request timeout, so
526
528
  // `extendTimeout()` can push the deadline and a fixed `AbortSignal.timeout` won't fire on its own and
527
529
  // kill a lazily-read body mid-extension. This aborts the socket only during the header phase; the body
@@ -537,8 +539,10 @@ export class HttpCrawler extends BasicCrawler {
537
539
  }), {
538
540
  session,
539
541
  cookieJar,
542
+ proxyUrl: opts.proxyUrl,
540
543
  signal: cancelSignal,
541
544
  timeoutMillis: cancelSignal ? undefined : opts.timeout,
545
+ ignoreTlsErrors: this.#ignoreTlsErrors,
542
546
  });
543
547
  return response;
544
548
  };
@@ -1,9 +1,10 @@
1
1
  import { extname } from 'node:path';
2
2
  import { Readable } from 'node:stream';
3
- import { applySearchParams } from '@crawlee/utils';
4
- import contentTypeParser from 'content-type';
3
+ import { applySearchParams, parseArgument } from '@crawlee/utils/internal';
4
+ import { isTypeValid, parse as parseContentType } from 'content-type';
5
5
  import mime from 'mime-types';
6
- import ow, { ObjectPredicate } from 'ow';
6
+ import { z } from 'zod';
7
+ const responseWithUrlSchema = z.looseObject({ url: z.url(), headers: z.looseObject({}) });
7
8
  /**
8
9
  * Converts {@link HttpRequestOptions} to a {@link HttpRequest}.
9
10
  */
@@ -54,25 +55,20 @@ export function extractCharsetFromHtmlBytes(bytes) {
54
55
  * @param response HTTP response object
55
56
  */
56
57
  export function parseContentTypeFromResponse(response) {
57
- ow(response, ow.object.partialShape({
58
- url: ow.string.url,
59
- headers: new ObjectPredicate(),
60
- }));
58
+ parseArgument(response, responseWithUrlSchema);
61
59
  const { url, headers } = response;
62
60
  let parsedContentType;
63
61
  if (headers.get('content-type')) {
64
- try {
65
- parsedContentType = contentTypeParser.parse(headers.get('content-type'));
66
- }
67
- catch {
68
- // Can not parse content type from Content-Type header. Try to parse it from file extension.
69
- }
62
+ const parsed = parseContentType(headers.get('content-type'));
63
+ // Can not parse content type from Content-Type header. Try to parse it from file extension.
64
+ if (isTypeValid(parsed.type))
65
+ parsedContentType = parsed;
70
66
  }
71
67
  // Parse content type from file extension as fallback
72
68
  if (!parsedContentType) {
73
69
  const parsedUrl = new URL(url);
74
70
  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
75
- parsedContentType = contentTypeParser.parse(contentTypeFromExtname);
71
+ parsedContentType = parseContentType(contentTypeFromExtname);
76
72
  }
77
73
  return {
78
74
  type: parsedContentType.type,
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@crawlee/http",
3
- "version": "4.0.0-beta.99",
3
+ "version": "4.0.0-rc.1",
4
4
  "description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
5
5
  "engines": {
6
- "node": ">=22.0.0"
6
+ "node": ">=22.13.0"
7
7
  },
8
8
  "type": "module",
9
9
  "exports": {
@@ -47,21 +47,18 @@
47
47
  "access": "public"
48
48
  },
49
49
  "dependencies": {
50
- "@apify/timeout": "^0.4.4",
51
- "@apify/utilities": "^2.15.5",
52
- "@crawlee/basic": "4.0.0-beta.99",
53
- "@crawlee/core": "4.0.0-beta.99",
54
- "@crawlee/http-client": "4.0.0-beta.99",
55
- "@crawlee/types": "4.0.0-beta.99",
56
- "@crawlee/utils": "4.0.0-beta.99",
57
- "@types/content-type": "^1.1.8",
50
+ "@apify/timeout": "^1.0.1",
51
+ "@crawlee/basic": "4.0.0-rc.1",
52
+ "@crawlee/http-client": "4.0.0-rc.1",
53
+ "@crawlee/types": "4.0.0-rc.1",
54
+ "@crawlee/utils": "4.0.0-rc.1",
58
55
  "cheerio": "^1.0.0",
59
- "content-type": "^1.0.5",
56
+ "content-type": "^3.1.1",
60
57
  "iconv-lite": "^0.7.2",
61
58
  "mime-types": "^3.0.1",
62
- "ow": "^2.0.0",
63
59
  "tslib": "^2.8.1",
64
- "type-fest": "^4.41.0"
60
+ "type-fest": "^4.41.0",
61
+ "zod": "^4.5.4"
65
62
  },
66
63
  "lerna": {
67
64
  "command": {
@@ -70,5 +67,5 @@
70
67
  }
71
68
  }
72
69
  },
73
- "gitHead": "ad2748380941842bb10cff100f4b4caad92049e3"
70
+ "gitHead": "f354ca5e943bed1a5c657a1fce1974c053f9fff0"
74
71
  }