@crawlee/http 4.0.0-beta.18 → 4.0.0-beta.180

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