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