@crawlee/http 4.0.0-beta.13 → 4.0.0-beta.130

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, Configuration, ContextPipeline, mergeCookies, processHttpRequestOptions, RequestState, ResponseWithUrl, Router, SessionError, } from '@crawlee/basic';
4
- import { RETRY_CSS_SELECTORS } from '@crawlee/utils';
5
- import * as cheerio from 'cheerio';
3
+ import { BasicCrawler, ContextPipeline, NavigationSkippedError, remainingNavigationWindowMillis, RequestState, Router, SessionError, } from '@crawlee/basic';
4
+ import { RequestThrottledError, getCookiesFromResponse, parseArgument, schemas, } from '@crawlee/core';
5
+ import { ResponseWithUrl } from '@crawlee/http-client';
6
+ import { RETRY_CSS_SELECTORS } from '@crawlee/utils/internal';
6
7
  import contentTypeParser from 'content-type';
7
8
  import iconv from 'iconv-lite';
8
- import ow from 'ow';
9
- import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
10
- import { parseContentTypeFromResponse } from './utils.js';
11
- let TimeoutError;
9
+ import { z } from 'zod';
10
+ import { addTimeoutToPromise, storage, TimeoutError, tryCancel } from '@apify/timeout';
11
+ import { extractCharsetFromHtmlBytes, parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
12
12
  /**
13
13
  * Default mime types, which HttpScraper supports.
14
14
  */
15
15
  const HTML_AND_XML_MIME_TYPES = ['text/html', 'text/xml', 'application/xhtml+xml', 'application/xml'];
16
16
  const APPLICATION_JSON_MIME_TYPE = 'application/json';
17
- 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 = {
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,38 +46,40 @@ 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
  *
48
- * We can use the `preNavigationHooks` to adjust `gotOptions`:
61
+ * We can use the `preNavigationHooks` to adjust the crawling context before the request is made:
49
62
  *
50
63
  * ```javascript
51
64
  * preNavigationHooks: [
52
- * (crawlingContext, gotOptions) => {
65
+ * (crawlingContext) => {
53
66
  * // ...
54
67
  * },
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,109 +105,136 @@ 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;
106
120
  static optionsShape = {
107
121
  ...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,
122
+ navigationTimeoutSecs: schemas.anyNumber.default(30),
123
+ ignoreTlsErrors: z.boolean().default(true),
124
+ additionalMimeTypes: schemas.arrayOf(z.string(), 'strings').default(() => []),
125
+ suggestResponseEncoding: z.string().optional(),
126
+ forceResponseEncoding: z.string().optional(),
127
+ saveResponseCookies: z.boolean().default(true),
128
+ preNavigationHooks: schemas.anyArray.default(() => []),
129
+ postNavigationHooks: schemas.anyArray.default(() => []),
118
130
  };
131
+ static optionsSchema = z.strictObject(HttpCrawler.optionsShape);
119
132
  /**
120
133
  * All `HttpCrawlerOptions` parameters are passed via an options object.
121
134
  */
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 = [],
135
+ constructor(options = {}) {
136
+ const { navigationTimeoutSecs, ignoreTlsErrors, additionalMimeTypes, suggestResponseEncoding, forceResponseEncoding, saveResponseCookies, preNavigationHooks, postNavigationHooks,
125
137
  // BasicCrawler
126
- autoscaledPoolOptions = HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS, contextPipelineBuilder, ...basicCrawlerOptions } = options;
138
+ contextPipelineBuilder, ...basicCrawlerOptions } = parseArgument(options, HttpCrawler.optionsSchema, 'HttpCrawlerOptions');
127
139
  super({
128
140
  ...basicCrawlerOptions,
129
- autoscaledPoolOptions,
130
141
  contextPipelineBuilder: contextPipelineBuilder ??
131
142
  (() => 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]);
143
+ });
144
+ this.#supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
139
145
  if (additionalMimeTypes.length)
140
- this._extendSupportedMimeTypes(additionalMimeTypes);
146
+ this.extendSupportedMimeTypes(additionalMimeTypes);
141
147
  if (suggestResponseEncoding && forceResponseEncoding) {
142
148
  this.log.warning('Both forceResponseEncoding and suggestResponseEncoding options are set. Using forceResponseEncoding.');
143
149
  }
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),
150
+ this.#navigationTimeoutMillis = navigationTimeoutSecs * 1000;
151
+ this.#ignoreTlsErrors = ignoreTlsErrors;
152
+ this.#suggestResponseEncoding = suggestResponseEncoding;
153
+ this.#forceResponseEncoding = forceResponseEncoding;
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.
157
+ this.#preNavigationHooks = preNavigationHooks;
158
+ this.#postNavigationHooks = [
159
+ ({ request, response }) => this.abortDownloadOfBody(request, response),
153
160
  ...postNavigationHooks,
154
161
  ];
155
- if (this.useSessionPool) {
156
- this.persistCookiesPerSession = persistCookiesPerSession ?? true;
157
- }
158
- else {
159
- this.persistCookiesPerSession = false;
160
- }
162
+ this.#saveResponseCookies = saveResponseCookies;
163
+ }
164
+ getNavigationTimeoutMillis() {
165
+ return this.#navigationTimeoutMillis;
166
+ }
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
+ */
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
- })
180
+ // When navigation is skipped, `prepareHttpRequest` has already installed throwing getters for
181
+ // the response-derived members, so the guarded action is bypassed and the context left untouched.
182
+ const skipGuard = (action) => ({
183
+ action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
184
+ });
185
+ // A single navigation window covers the pre-navigation hooks, the navigation, and the post-navigation
186
+ // hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow hook eats into the same
187
+ // window the navigation uses instead of each step being timed on its own.
188
+ const navigationTimedOut = `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`;
189
+ const windowGuard = (step) => skipGuard(async (ctx) => {
190
+ const remaining = remainingNavigationWindowMillis(ctx, this.#navigationTimeoutMillis);
191
+ if (remaining <= 0) {
192
+ throw new TimeoutError(navigationTimedOut);
193
+ }
194
+ return addTimeoutToPromise(async () => step(ctx), remaining, navigationTimedOut);
195
+ });
196
+ let pipeline = ContextPipeline.create().compose({
197
+ action: this.prepareHttpRequest.bind(this),
198
+ });
199
+ for (const hook of this.#preNavigationHooks) {
200
+ pipeline = pipeline.compose(windowGuard(hook));
201
+ }
202
+ let pipelineWithNavigation = pipeline.compose(skipGuard(this.makeHttpRequest.bind(this)));
203
+ for (const hook of this.#postNavigationHooks) {
204
+ pipelineWithNavigation = pipelineWithNavigation.compose(windowGuard(hook));
205
+ }
206
+ return pipelineWithNavigation
167
207
  .compose({ action: this.processHttpResponse.bind(this) })
168
208
  .compose({ action: this.handleBlockedRequestByContent.bind(this) });
169
209
  }
170
- async makeHttpRequest(crawlingContext) {
171
- const { request, session } = crawlingContext;
210
+ async prepareHttpRequest(crawlingContext) {
211
+ const { request } = crawlingContext;
172
212
  if (request.skipNavigation) {
173
213
  return {
174
214
  request: new Proxy(request, {
175
215
  get(target, propertyName, receiver) {
176
216
  if (propertyName === 'loadedUrl') {
177
- throw new Error('The `request.loadedUrl` property is not available - `skipNavigation` was used');
217
+ throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
178
218
  }
179
219
  return Reflect.get(target, propertyName, receiver);
180
220
  },
181
221
  }),
182
222
  get response() {
183
- throw new Error('The `response` property is not available - `skipNavigation` was used');
223
+ throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
184
224
  },
185
225
  };
186
226
  }
187
- const gotOptions = {};
188
- const preNavigationHooksCookies = this._getCookieHeaderFromRequest(request);
189
227
  request.state = RequestState.BEFORE_NAV;
190
- // Execute pre navigation hooks before applying session pool cookies,
191
- // as they may also set cookies in the session
192
- await this._executeHooks(this.preNavigationHooks, crawlingContext, gotOptions);
228
+ return {};
229
+ }
230
+ async makeHttpRequest(crawlingContext) {
193
231
  tryCancel();
194
- const postNavigationHooksCookies = this._getCookieHeaderFromRequest(request);
195
- this._applyCookies(crawlingContext, gotOptions, preNavigationHooksCookies, postNavigationHooksCookies);
232
+ const { request, session } = crawlingContext;
196
233
  const proxyUrl = crawlingContext.proxyInfo?.url;
197
- const httpResponse = await addTimeoutToPromise(async () => this._requestFunction({ request, session, proxyUrl, gotOptions }), 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.`);
198
238
  tryCancel();
199
239
  request.loadedUrl = httpResponse?.url;
200
240
  request.state = RequestState.AFTER_NAV;
@@ -204,46 +244,79 @@ export class HttpCrawler extends BasicCrawler {
204
244
  if (crawlingContext.request.skipNavigation) {
205
245
  return {
206
246
  get contentType() {
207
- 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');
208
248
  },
209
249
  get body() {
210
- 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');
211
251
  },
212
252
  get json() {
213
- 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');
214
254
  },
215
255
  get waitForSelector() {
216
- 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');
217
257
  },
218
258
  get parseWithCheerio() {
219
- 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');
220
260
  },
221
261
  };
222
262
  }
223
- await this._executeHooks(this.postNavigationHooks, crawlingContext);
224
263
  tryCancel();
225
- 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.`);
226
283
  tryCancel();
227
284
  const response = parsed.response;
228
285
  const contentType = parsed.contentType;
229
286
  const waitForSelector = async (selector, _timeoutMs) => {
287
+ const cheerio = await import('cheerio');
230
288
  const $ = cheerio.load(parsed.body.toString());
231
289
  if ($(selector).get().length === 0) {
232
290
  throw new Error(`Selector '${selector}' not found.`);
233
291
  }
234
292
  };
235
293
  const parseWithCheerio = async (selector, timeoutMs) => {
294
+ const cheerio = await import('cheerio');
236
295
  const $ = cheerio.load(parsed.body.toString());
237
296
  if (selector) {
238
297
  await crawlingContext.waitForSelector(selector, timeoutMs);
239
298
  }
240
299
  return $;
241
300
  };
242
- if (this.useSessionPool) {
243
- this._throwOnBlockedRequest(crawlingContext.session, response.status);
244
- }
245
- if (this.persistCookiesPerSession) {
246
- crawlingContext.session.setCookiesFromResponse(response);
301
+ this.throwOnBlockedRequest(response.status);
302
+ if (this.#saveResponseCookies) {
303
+ try {
304
+ for (const cookie of getCookiesFromResponse(response)) {
305
+ if (!cookie)
306
+ continue;
307
+ try {
308
+ await crawlingContext.session.cookieJar.setCookie(cookie, response.url, {
309
+ ignoreError: false,
310
+ });
311
+ }
312
+ catch (e) {
313
+ this.log.debug(`Could not set cookie: ${e.message}`);
314
+ }
315
+ }
316
+ }
317
+ catch (e) {
318
+ this.log.exception(e, 'Could not get cookies from response');
319
+ }
247
320
  }
248
321
  return {
249
322
  get json() {
@@ -274,69 +347,28 @@ export class HttpCrawler extends BasicCrawler {
274
347
  return `Found selectors: ${foundSelectors.join(', ')}`;
275
348
  }
276
349
  }
277
- return false;
278
- }
279
- /**
280
- * Sets the cookie header to `gotOptions` based on the provided request and session headers, as well as any changes that occurred due to hooks.
281
- */
282
- _applyCookies({ session, request }, gotOptions, preHookCookies, postHookCookies) {
283
- const sessionCookie = session?.getCookieString(request.url) ?? '';
284
- let alteredGotOptionsCookies = gotOptions.headers?.Cookie || gotOptions.headers?.cookie || '';
285
- if (gotOptions.headers?.Cookie && gotOptions.headers?.cookie) {
286
- const { Cookie: upperCaseHeader, cookie: lowerCaseHeader } = gotOptions.headers;
287
- 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`);
288
- const sourceCookies = [];
289
- if (Array.isArray(lowerCaseHeader)) {
290
- sourceCookies.push(...lowerCaseHeader);
291
- }
292
- else {
293
- sourceCookies.push(lowerCaseHeader);
294
- }
295
- if (Array.isArray(upperCaseHeader)) {
296
- sourceCookies.push(...upperCaseHeader);
297
- }
298
- else {
299
- sourceCookies.push(upperCaseHeader);
300
- }
301
- alteredGotOptionsCookies = mergeCookies(request.url, sourceCookies);
302
- }
303
- const sourceCookies = [sessionCookie, preHookCookies];
304
- if (Array.isArray(alteredGotOptionsCookies)) {
305
- sourceCookies.push(...alteredGotOptionsCookies);
306
- }
307
- else {
308
- sourceCookies.push(alteredGotOptionsCookies);
309
- }
310
- sourceCookies.push(postHookCookies);
311
- const mergedCookie = mergeCookies(request.url, sourceCookies);
312
- gotOptions.headers ??= {};
313
- Reflect.deleteProperty(gotOptions.headers, 'Cookie');
314
- Reflect.deleteProperty(gotOptions.headers, 'cookie');
315
- if (mergedCookie !== '') {
316
- gotOptions.headers.Cookie = mergedCookie;
350
+ if (this.blockedStatusCodes.has(crawlingContext.response.status)) {
351
+ return `Blocked by status code ${crawlingContext.response.status}`;
317
352
  }
353
+ return false;
318
354
  }
319
355
  /**
320
356
  * Function to make the HTTP request. It performs optimizations
321
357
  * on the request such as only downloading the request body if the
322
358
  * received content type matches text/html, application/xml, application/xhtml+xml.
323
359
  */
324
- async _requestFunction({ request, session, proxyUrl, gotOptions, }) {
325
- if (!TimeoutError) {
326
- // @ts-ignore
327
- ({ TimeoutError } = await import('got-scraping'));
328
- }
329
- const opts = this._getRequestOptions(request, session, proxyUrl, gotOptions);
360
+ async requestFunction({ request, session, proxyUrl }) {
361
+ const opts = this.getRequestOptions(request, session, proxyUrl);
330
362
  try {
331
- return await this._requestAsBrowser(opts, session);
363
+ return await this.requestAsBrowser(opts, session);
332
364
  }
333
365
  catch (e) {
334
- if (e instanceof TimeoutError) {
335
- this._handleRequestTimeout(session);
336
- return new Response(); // this will never happen, as _handleRequestTimeout always throws
366
+ if (e instanceof Error && e.constructor.name === 'TimeoutError') {
367
+ this.handleRequestTimeout(session);
368
+ return new Response(); // this will never happen, as handleRequestTimeout always throws
337
369
  }
338
370
  if (this.isProxyError(e)) {
339
- throw new SessionError(this._getMessageFromError(e));
371
+ throw new SessionError(this.getMessageFromError(e));
340
372
  }
341
373
  else {
342
374
  throw e;
@@ -346,17 +378,15 @@ export class HttpCrawler extends BasicCrawler {
346
378
  /**
347
379
  * Encodes and parses response according to the provided content type
348
380
  */
349
- async _parseResponse(request, response) {
381
+ async parseResponse(request, response) {
350
382
  const { status } = response;
351
383
  const { type, charset } = parseContentTypeFromResponse(response);
352
- const { response: reencodedResponse, encoding } = this._encodeResponse(request, response, charset);
384
+ const { response: reencodedResponse, encoding } = this.encodeResponse(request, response, charset);
353
385
  const contentType = { type, encoding };
354
386
  if (status >= 400 && status <= 599) {
355
- this.stats.registerStatusCode(status);
387
+ this.statistics.registerStatusCode(status);
356
388
  }
357
- const excludeError = this.ignoreHttpErrorStatusCodes.has(status);
358
- const includeError = this.additionalHttpErrorStatusCodes.has(status);
359
- if ((status >= 500 && !excludeError) || includeError) {
389
+ if (this.isErrorStatusCode(status)) {
360
390
  const body = await reencodedResponse.text(); // TODO - this always uses UTF-8 (see https://developer.mozilla.org/en-US/docs/Web/API/Request/text)
361
391
  // Errors are often sent as JSON, so attempt to parse them,
362
392
  // despite Accept header being set to text/html.
@@ -367,17 +397,26 @@ export class HttpCrawler extends BasicCrawler {
367
397
  message = util.inspect(errorResponse, { depth: 1, maxArrayLength: 10 });
368
398
  throw new Error(`${status} - ${message}`);
369
399
  }
370
- if (includeError) {
400
+ if (this.additionalHttpErrorStatusCodes.has(status)) {
371
401
  throw new Error(`${status} - Error status code was set by user.`);
372
402
  }
373
403
  // It's not a JSON, so it's probably some text. Get the first 100 chars of it.
374
404
  throw new Error(`${status} - Internal Server Error: ${body.slice(0, 100)}`);
375
405
  }
376
406
  else if (HTML_AND_XML_MIME_TYPES.includes(type)) {
377
- return { response, contentType, body: await response.text() };
407
+ if (!charset && !this.#forceResponseEncoding) {
408
+ const rawBytes = Buffer.from(await response.arrayBuffer());
409
+ const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
410
+ const charsetToUse = metaCharset ?? this.#suggestResponseEncoding ?? 'utf-8';
411
+ const body = iconv.encodingExists(charsetToUse)
412
+ ? iconv.decode(rawBytes, charsetToUse)
413
+ : rawBytes.toString('utf8');
414
+ return { response, contentType: { type, encoding: 'utf-8' }, body };
415
+ }
416
+ return { response, contentType, body: await reencodedResponse.text() };
378
417
  }
379
418
  else {
380
- const body = Buffer.from(await response.bytes());
419
+ const body = Buffer.from(await reencodedResponse.bytes());
381
420
  return {
382
421
  body,
383
422
  response,
@@ -388,40 +427,30 @@ export class HttpCrawler extends BasicCrawler {
388
427
  /**
389
428
  * Combines the provided `requestOptions` with mandatory (non-overridable) values.
390
429
  */
391
- _getRequestOptions(request, session, proxyUrl, gotOptions) {
430
+ getRequestOptions(request, session, proxyUrl) {
392
431
  const requestOptions = {
393
432
  url: request.url,
394
433
  method: request.method,
395
434
  proxyUrl,
396
- timeout: { request: this.navigationTimeoutMillis },
435
+ timeout: this.#navigationTimeoutMillis,
397
436
  sessionToken: session,
398
- ...gotOptions,
399
- headers: { ...request.headers, ...gotOptions?.headers },
400
- https: {
401
- ...gotOptions?.https,
402
- rejectUnauthorized: !this.ignoreSslErrors,
403
- },
404
- isStream: true,
437
+ headers: request.headers,
438
+ body: undefined,
405
439
  };
406
- // Delete any possible lowercased header for cookie as they are merged in _applyCookies under the uppercase Cookie header
407
- Reflect.deleteProperty(requestOptions.headers, 'cookie');
408
- // Disable SSL verification for MITM proxies
409
- if (session?.proxyInfo?.ignoreTlsErrors) {
410
- requestOptions.https = {
411
- ...requestOptions.https,
412
- rejectUnauthorized: false,
413
- };
440
+ if (requestOptions.headers?.cookie || requestOptions.headers?.Cookie) {
441
+ requestOptions.headers.Cookie = this.getCookieHeaderFromRequest(request);
442
+ delete requestOptions.headers.cookie;
414
443
  }
415
444
  if (/PATCH|POST|PUT/.test(request.method))
416
445
  requestOptions.body = request.payload ?? '';
417
446
  return requestOptions;
418
447
  }
419
- _encodeResponse(request, response, encoding) {
420
- if (this.forceResponseEncoding) {
421
- encoding = this.forceResponseEncoding;
448
+ encodeResponse(request, response, encoding) {
449
+ if (this.#forceResponseEncoding) {
450
+ encoding = this.#forceResponseEncoding;
422
451
  }
423
- else if (!encoding && this.suggestResponseEncoding) {
424
- encoding = this.suggestResponseEncoding;
452
+ else if (!encoding && this.#suggestResponseEncoding) {
453
+ encoding = this.#suggestResponseEncoding;
425
454
  }
426
455
  // Fall back to utf-8 if we still don't have encoding.
427
456
  const utf8 = 'utf8';
@@ -434,7 +463,9 @@ export class HttpCrawler extends BasicCrawler {
434
463
  // Try to re-encode a variety of unsupported encodings to utf-8
435
464
  if (iconv.encodingExists(encoding)) {
436
465
  const encodeStream = iconv.encodeStream(utf8);
437
- const decodeStream = iconv.decodeStream(encoding).on('error', (err) => encodeStream.emit('error', err));
466
+ const decodeStream = iconv
467
+ .decodeStream(encoding)
468
+ .on('error', (err) => encodeStream.emit('error', err));
438
469
  const reencodedBody = response.body
439
470
  ? Readable.toWeb(Readable.from(Readable.fromWeb(response.body)
440
471
  .pipe(decodeStream)
@@ -450,15 +481,15 @@ export class HttpCrawler extends BasicCrawler {
450
481
  /**
451
482
  * Checks and extends supported mime types
452
483
  */
453
- _extendSupportedMimeTypes(additionalMimeTypes) {
484
+ extendSupportedMimeTypes(additionalMimeTypes) {
454
485
  for (const mimeType of additionalMimeTypes) {
455
486
  if (mimeType === '*/*') {
456
- this.supportedMimeTypes.add(mimeType);
487
+ this.#supportedMimeTypes.add(mimeType);
457
488
  continue;
458
489
  }
459
490
  try {
460
491
  const parsedType = contentTypeParser.parse(mimeType);
461
- this.supportedMimeTypes.add(parsedType.type);
492
+ this.#supportedMimeTypes.add(parsedType.type);
462
493
  }
463
494
  catch (err) {
464
495
  throw new Error(`Can not parse mime type ${mimeType} from "options.additionalMimeTypes".`);
@@ -468,68 +499,54 @@ export class HttpCrawler extends BasicCrawler {
468
499
  /**
469
500
  * Handles timeout request
470
501
  */
471
- _handleRequestTimeout(session) {
472
- session?.markBad();
473
- throw new Error(`request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
502
+ handleRequestTimeout(session) {
503
+ session.markBad();
504
+ throw new Error(`Request timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
474
505
  }
475
- _abortDownloadOfBody(request, response) {
506
+ abortDownloadOfBody(request, response) {
476
507
  const { status } = response;
477
508
  const { type } = parseContentTypeFromResponse(response);
478
- // eslint-disable-next-line dot-notation -- accessing private property
479
- const blockedStatusCodes = this.sessionPool ? this.sessionPool['blockedStatusCodes'] : [];
480
- // if we retry the request, can the Content-Type change?
481
- const isTransientContentType = status >= 500 || blockedStatusCodes.includes(status);
482
- if (!this.supportedMimeTypes.has(type) && !this.supportedMimeTypes.has('*/*') && !isTransientContentType) {
509
+ const isTransientContentType = status >= 500 || this.blockedStatusCodes.has(status);
510
+ if (!this.#supportedMimeTypes.has(type) && !this.#supportedMimeTypes.has('*/*') && !isTransientContentType) {
483
511
  request.noRetry = true;
484
512
  throw new Error(`Resource ${request.url} served Content-Type ${type}, ` +
485
- `but only ${Array.from(this.supportedMimeTypes).join(', ')} are allowed. Skipping resource.`);
513
+ `but only ${Array.from(this.#supportedMimeTypes).join(', ')} are allowed. Skipping resource.`);
486
514
  }
487
515
  }
488
516
  /**
489
517
  * @internal wraps public utility for mocking purposes
490
518
  */
491
- _requestAsBrowser = async (options, session) => {
492
- const response = await this.httpClient.stream(processHttpRequestOptions({
519
+ requestAsBrowser = async (options, session) => {
520
+ const opts = processHttpRequestOptions({
493
521
  ...options,
494
- cookieJar: options.cookieJar, // HACK - the type of ToughCookieJar in got is wrong
495
522
  responseType: 'text',
496
- }), (redirectResponse, updatedRequest) => {
497
- if (this.persistCookiesPerSession) {
498
- session.setCookiesFromResponse(redirectResponse);
499
- const cookieString = session.getCookieString(updatedRequest.url.toString());
500
- if (cookieString !== '') {
501
- updatedRequest.headers.Cookie = cookieString;
502
- }
503
- }
523
+ });
524
+ // When saveResponseCookies is false, the response cookies must not mutate the
525
+ // session jar. Reads still go through the session (so session.setCookie() in pre-nav
526
+ // hooks keeps working) but a per-request clone is passed in so writes are discarded.
527
+ const cookieJar = this.#saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
528
+ // Bind the request to the shared navigation window instead of a fixed per-request timeout, so
529
+ // `extendTimeout()` can push the deadline and a fixed `AbortSignal.timeout` won't fire on its own and
530
+ // kill a lazily-read body mid-extension. This aborts the socket only during the header phase; the body
531
+ // read is bounded separately at the promise level (see `processHttpResponse`), so a slow-streaming body
532
+ // still fails cleanly with a navigation timeout, though the socket is left to close on its own.
533
+ const cancelSignal = storage.getStore()?.cancelTask.signal;
534
+ const response = await this.httpClient.sendRequest(new Request(opts.url, {
535
+ body: opts.body ? Readable.toWeb(opts.body) : undefined,
536
+ headers: new Headers(opts.headers),
537
+ method: opts.method,
538
+ // Node-specific option to make the request body work with streams
539
+ duplex: 'half',
540
+ }), {
541
+ session,
542
+ cookieJar,
543
+ signal: cancelSignal,
544
+ timeoutMillis: cancelSignal ? undefined : opts.timeout,
545
+ ignoreTlsErrors: this.#ignoreTlsErrors,
504
546
  });
505
547
  return response;
506
548
  };
507
549
  }
508
- /**
509
- * Creates new {@link Router} instance that works based on request labels.
510
- * This instance can then serve as a `requestHandler` of your {@link HttpCrawler}.
511
- * Defaults to the {@link HttpCrawlingContext}.
512
- *
513
- * > Serves as a shortcut for using `Router.create<HttpCrawlingContext>()`.
514
- *
515
- * ```ts
516
- * import { HttpCrawler, createHttpRouter } from 'crawlee';
517
- *
518
- * const router = createHttpRouter();
519
- * router.addHandler('label-a', async (ctx) => {
520
- * ctx.log.info('...');
521
- * });
522
- * router.addDefaultHandler(async (ctx) => {
523
- * ctx.log.info('...');
524
- * });
525
- *
526
- * const crawler = new HttpCrawler({
527
- * requestHandler: router,
528
- * });
529
- * await crawler.run();
530
- * ```
531
- */
532
- export function createHttpRouter(routes) {
533
- return Router.create(routes);
550
+ export function createHttpRouter(routesOrSchemas) {
551
+ return Router.create(routesOrSchemas);
534
552
  }
535
- //# sourceMappingURL=http-crawler.js.map