@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.
- package/README.md +1 -1
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/internals/dom-crawler.d.ts +131 -0
- package/internals/dom-crawler.js +93 -0
- package/internals/file-download.d.ts +11 -61
- package/internals/file-download.js +19 -117
- package/internals/http-crawler.d.ts +147 -149
- package/internals/http-crawler.js +137 -133
- package/internals/utils.js +10 -14
- package/package.json +11 -14
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|
|
123
|
-
|
|
124
|
-
additionalMimeTypes:
|
|
125
|
-
suggestResponseEncoding:
|
|
126
|
-
forceResponseEncoding:
|
|
127
|
-
saveResponseCookies:
|
|
128
|
-
preNavigationHooks:
|
|
129
|
-
postNavigationHooks:
|
|
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
|
-
|
|
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
|
|
148
|
+
this.#supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
|
|
145
149
|
if (additionalMimeTypes.length)
|
|
146
|
-
this
|
|
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
|
|
151
|
-
this
|
|
152
|
-
this
|
|
153
|
-
this
|
|
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
|
|
158
|
-
this
|
|
159
|
-
({ request, response }) => this
|
|
161
|
+
this.#preNavigationHooks = preNavigationHooks;
|
|
162
|
+
this.#postNavigationHooks = [
|
|
163
|
+
({ request, response }) => this.#abortDownloadOfBody(request, response),
|
|
160
164
|
...postNavigationHooks,
|
|
161
165
|
];
|
|
162
|
-
this
|
|
166
|
+
this.#saveResponseCookies = saveResponseCookies;
|
|
163
167
|
}
|
|
168
|
+
/** @internal */
|
|
164
169
|
getNavigationTimeoutMillis() {
|
|
165
|
-
return this
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
199
|
+
for (const hook of this.#postNavigationHooks) {
|
|
204
200
|
pipelineWithNavigation = pipelineWithNavigation.compose(windowGuard(hook));
|
|
205
201
|
}
|
|
206
202
|
return pipelineWithNavigation
|
|
207
|
-
.compose(
|
|
208
|
-
.compose(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
278
|
+
const remaining = remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis);
|
|
268
279
|
if (remaining <= 0) {
|
|
269
|
-
throw new TimeoutError(`Navigation timed out after ${this
|
|
280
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
270
281
|
}
|
|
271
|
-
const parsed = await addTimeoutToPromise(async () => this
|
|
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
|
|
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
|
|
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.
|
|
291
|
-
if (this
|
|
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.
|
|
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
|
|
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
|
|
362
|
+
async #requestFunction({ request, session, proxyUrl }) {
|
|
363
|
+
const opts = this.#getRequestOptions(request, proxyUrl);
|
|
349
364
|
try {
|
|
350
|
-
return await this
|
|
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
|
|
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.
|
|
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.
|
|
387
|
+
this.statistics.registerStatusCode(status);
|
|
375
388
|
}
|
|
376
389
|
if (this.isErrorStatusCode(status)) {
|
|
377
|
-
const
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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,
|
|
432
|
+
#getRequestOptions(request, proxyUrl) {
|
|
418
433
|
const requestOptions = {
|
|
419
434
|
url: request.url,
|
|
420
435
|
method: request.method,
|
|
421
436
|
proxyUrl,
|
|
422
|
-
timeout: this
|
|
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.
|
|
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
|
|
447
|
-
encoding = this
|
|
449
|
+
#encodeResponse(request, response, encoding) {
|
|
450
|
+
if (this.#forceResponseEncoding) {
|
|
451
|
+
encoding = this.#forceResponseEncoding;
|
|
448
452
|
}
|
|
449
|
-
else if (!encoding && this
|
|
450
|
-
encoding = this
|
|
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
|
|
488
|
+
this.#supportedMimeTypes.add(mimeType);
|
|
485
489
|
continue;
|
|
486
490
|
}
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
503
|
+
throw new Error(`Request timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
502
504
|
}
|
|
503
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
};
|
package/internals/utils.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 =
|
|
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-
|
|
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.
|
|
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.
|
|
51
|
-
"@
|
|
52
|
-
"@crawlee/
|
|
53
|
-
"@crawlee/
|
|
54
|
-
"@crawlee/
|
|
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.
|
|
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": "
|
|
70
|
+
"gitHead": "f354ca5e943bed1a5c657a1fce1974c053f9fff0"
|
|
74
71
|
}
|