@crawlee/http 4.0.0-beta.11 → 4.0.0-beta.110
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 +17 -13
- package/index.d.ts +0 -1
- package/index.js +0 -1
- package/internals/file-download.d.ts +49 -15
- package/internals/file-download.js +105 -46
- package/internals/http-crawler.d.ts +117 -202
- package/internals/http-crawler.js +253 -295
- package/internals/utils.d.ts +12 -2
- package/internals/utils.js +49 -3
- package/package.json +10 -9
- 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
- package/tsconfig.build.tsbuildinfo +0 -1
|
@@ -1,27 +1,38 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
1
2
|
import util from 'node:util';
|
|
2
|
-
import { BasicCrawler,
|
|
3
|
+
import { BasicCrawler, ContextPipeline, NavigationSkippedError, remainingNavigationWindowMillis, RequestState, Router, SessionError, } from '@crawlee/basic';
|
|
4
|
+
import { getCookiesFromResponse } from '@crawlee/core';
|
|
5
|
+
import { ResponseWithUrl } from '@crawlee/http-client';
|
|
3
6
|
import { RETRY_CSS_SELECTORS } from '@crawlee/utils';
|
|
4
|
-
import * as cheerio from 'cheerio';
|
|
5
7
|
import contentTypeParser from 'content-type';
|
|
6
8
|
import iconv from 'iconv-lite';
|
|
7
9
|
import ow from 'ow';
|
|
8
|
-
import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
|
|
9
|
-
import {
|
|
10
|
-
import { parseContentTypeFromResponse } from './utils.js';
|
|
11
|
-
let TimeoutError;
|
|
10
|
+
import { addTimeoutToPromise, storage, TimeoutError, tryCancel } from '@apify/timeout';
|
|
11
|
+
import { extractCharsetFromHtmlBytes, parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
|
|
12
12
|
/**
|
|
13
13
|
* Default mime types, which HttpScraper supports.
|
|
14
14
|
*/
|
|
15
15
|
const HTML_AND_XML_MIME_TYPES = ['text/html', 'text/xml', 'application/xhtml+xml', 'application/xml'];
|
|
16
16
|
const APPLICATION_JSON_MIME_TYPE = 'application/json';
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* A higher starting concurrency and a relaxed event loop signal, since HTTP-only crawling barely touches the event
|
|
19
|
+
* loop. {@link HttpCrawler} folds these into the {@link ConcurrencySystem} it builds by default.
|
|
20
|
+
*
|
|
21
|
+
* A {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} you supply yourself replaces that default
|
|
22
|
+
* wholesale, tuning included, so spread these options in if you want to keep it:
|
|
23
|
+
*
|
|
24
|
+
* ```typescript
|
|
25
|
+
* new ConcurrencySystem({ ...HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS, maxConcurrency: 50 });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export const HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS = {
|
|
18
29
|
desiredConcurrency: 10,
|
|
19
|
-
|
|
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,38 +46,40 @@ const HTTP_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
|
|
|
35
46
|
*
|
|
36
47
|
* This crawler downloads each URL using a plain HTTP request and doesn't do any HTML parsing.
|
|
37
48
|
*
|
|
38
|
-
* The source URLs are represented using {@link Request} objects that are fed from
|
|
39
|
-
* {@link
|
|
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
|
*
|
|
48
|
-
* We can use the `preNavigationHooks` to adjust
|
|
61
|
+
* We can use the `preNavigationHooks` to adjust the crawling context before the request is made:
|
|
49
62
|
*
|
|
50
63
|
* ```javascript
|
|
51
64
|
* preNavigationHooks: [
|
|
52
|
-
* (crawlingContext
|
|
65
|
+
* (crawlingContext) => {
|
|
53
66
|
* // ...
|
|
54
67
|
* },
|
|
55
68
|
* ]
|
|
56
69
|
* ```
|
|
57
70
|
*
|
|
58
|
-
* By default, this crawler only processes web pages with the `text/html`
|
|
59
|
-
* and `application/
|
|
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,22 +105,19 @@ 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
|
-
navigationTimeoutMillis;
|
|
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
|
+
// kept as TS-private: tests read it at runtime
|
|
105
117
|
ignoreSslErrors;
|
|
106
|
-
suggestResponseEncoding;
|
|
107
|
-
forceResponseEncoding;
|
|
108
|
-
|
|
109
|
-
ignoreHttpErrorStatusCodes;
|
|
110
|
-
supportedMimeTypes;
|
|
118
|
+
#suggestResponseEncoding;
|
|
119
|
+
#forceResponseEncoding;
|
|
120
|
+
#supportedMimeTypes;
|
|
111
121
|
static optionsShape = {
|
|
112
122
|
...BasicCrawler.optionsShape,
|
|
113
123
|
navigationTimeoutSecs: ow.optional.number,
|
|
@@ -115,105 +125,119 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
115
125
|
additionalMimeTypes: ow.optional.array.ofType(ow.string),
|
|
116
126
|
suggestResponseEncoding: ow.optional.string,
|
|
117
127
|
forceResponseEncoding: ow.optional.string,
|
|
118
|
-
|
|
119
|
-
persistCookiesPerSession: ow.optional.boolean,
|
|
120
|
-
additionalHttpErrorStatusCodes: ow.optional.array.ofType(ow.number),
|
|
121
|
-
ignoreHttpErrorStatusCodes: ow.optional.array.ofType(ow.number),
|
|
128
|
+
saveResponseCookies: ow.optional.boolean,
|
|
122
129
|
preNavigationHooks: ow.optional.array,
|
|
123
130
|
postNavigationHooks: ow.optional.array,
|
|
124
131
|
};
|
|
125
132
|
/**
|
|
126
133
|
* All `HttpCrawlerOptions` parameters are passed via an options object.
|
|
127
134
|
*/
|
|
128
|
-
constructor(options = {}
|
|
135
|
+
constructor(options = {}) {
|
|
129
136
|
ow(options, 'HttpCrawlerOptions', ow.object.exactShape(HttpCrawler.optionsShape));
|
|
130
|
-
const { navigationTimeoutSecs = 30, ignoreSslErrors = true, additionalMimeTypes = [], suggestResponseEncoding, forceResponseEncoding,
|
|
137
|
+
const { navigationTimeoutSecs = 30, ignoreSslErrors = true, additionalMimeTypes = [], suggestResponseEncoding, forceResponseEncoding, saveResponseCookies = true, preNavigationHooks = [], postNavigationHooks = [],
|
|
131
138
|
// BasicCrawler
|
|
132
|
-
|
|
139
|
+
contextPipelineBuilder, ...basicCrawlerOptions } = options;
|
|
133
140
|
super({
|
|
134
141
|
...basicCrawlerOptions,
|
|
135
|
-
autoscaledPoolOptions,
|
|
136
142
|
contextPipelineBuilder: contextPipelineBuilder ??
|
|
137
143
|
(() => this.buildContextPipeline()),
|
|
138
|
-
}
|
|
139
|
-
this
|
|
140
|
-
// Cookies should be persisted per session only if session pool is used
|
|
141
|
-
if (!this.useSessionPool && persistCookiesPerSession) {
|
|
142
|
-
throw new Error('You cannot use "persistCookiesPerSession" without "useSessionPool" set to true.');
|
|
143
|
-
}
|
|
144
|
-
this.supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
|
|
144
|
+
});
|
|
145
|
+
this.#supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
|
|
145
146
|
if (additionalMimeTypes.length)
|
|
146
|
-
this.
|
|
147
|
+
this.extendSupportedMimeTypes(additionalMimeTypes);
|
|
147
148
|
if (suggestResponseEncoding && forceResponseEncoding) {
|
|
148
149
|
this.log.warning('Both forceResponseEncoding and suggestResponseEncoding options are set. Using forceResponseEncoding.');
|
|
149
150
|
}
|
|
150
|
-
this
|
|
151
|
+
this.#navigationTimeoutMillis = navigationTimeoutSecs * 1000;
|
|
151
152
|
this.ignoreSslErrors = ignoreSslErrors;
|
|
152
|
-
this
|
|
153
|
-
this
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this
|
|
158
|
-
this
|
|
159
|
-
({ request, response }) => this.
|
|
153
|
+
this.#suggestResponseEncoding = suggestResponseEncoding;
|
|
154
|
+
this.#forceResponseEncoding = forceResponseEncoding;
|
|
155
|
+
// Cast away the extension-aware option types to the base internal storage types (see the field
|
|
156
|
+
// declarations above). This is sound - the hooks only ever receive the base context plus the
|
|
157
|
+
// members `extendContext` added at runtime.
|
|
158
|
+
this.#preNavigationHooks = preNavigationHooks;
|
|
159
|
+
this.#postNavigationHooks = [
|
|
160
|
+
({ request, response }) => this.abortDownloadOfBody(request, response),
|
|
160
161
|
...postNavigationHooks,
|
|
161
162
|
];
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
this.#saveResponseCookies = saveResponseCookies;
|
|
164
|
+
}
|
|
165
|
+
getNavigationTimeoutMillis() {
|
|
166
|
+
return this.#navigationTimeoutMillis;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
|
|
170
|
+
* concurrency shortcuts on top. Not called for a supplied
|
|
171
|
+
* {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} — spread the constant into it yourself to
|
|
172
|
+
* keep the tuning.
|
|
173
|
+
*/
|
|
174
|
+
createDefaultConcurrencySystem(options) {
|
|
175
|
+
return super.createDefaultConcurrencySystem({
|
|
176
|
+
...HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS,
|
|
177
|
+
...options,
|
|
178
|
+
});
|
|
168
179
|
}
|
|
169
180
|
buildContextPipeline() {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
action:
|
|
174
|
-
})
|
|
181
|
+
// When navigation is skipped, `prepareHttpRequest` has already installed throwing getters for
|
|
182
|
+
// the response-derived members, so the guarded action is bypassed and the context left untouched.
|
|
183
|
+
const skipGuard = (action) => ({
|
|
184
|
+
action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
|
|
185
|
+
});
|
|
186
|
+
// A single navigation window covers the pre-navigation hooks, the navigation, and the post-navigation
|
|
187
|
+
// hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow hook eats into the same
|
|
188
|
+
// window the navigation uses instead of each step being timed on its own.
|
|
189
|
+
const navigationTimedOut = `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`;
|
|
190
|
+
const windowGuard = (step) => skipGuard(async (ctx) => {
|
|
191
|
+
const remaining = remainingNavigationWindowMillis(ctx, this.#navigationTimeoutMillis);
|
|
192
|
+
if (remaining <= 0) {
|
|
193
|
+
throw new TimeoutError(navigationTimedOut);
|
|
194
|
+
}
|
|
195
|
+
return addTimeoutToPromise(async () => step(ctx), remaining, navigationTimedOut);
|
|
196
|
+
});
|
|
197
|
+
let pipeline = ContextPipeline.create().compose({
|
|
198
|
+
action: this.prepareHttpRequest.bind(this),
|
|
199
|
+
});
|
|
200
|
+
for (const hook of this.#preNavigationHooks) {
|
|
201
|
+
pipeline = pipeline.compose(windowGuard(hook));
|
|
202
|
+
}
|
|
203
|
+
let pipelineWithNavigation = pipeline.compose(skipGuard(this.makeHttpRequest.bind(this)));
|
|
204
|
+
for (const hook of this.#postNavigationHooks) {
|
|
205
|
+
pipelineWithNavigation = pipelineWithNavigation.compose(windowGuard(hook));
|
|
206
|
+
}
|
|
207
|
+
return pipelineWithNavigation
|
|
175
208
|
.compose({ action: this.processHttpResponse.bind(this) })
|
|
176
209
|
.compose({ action: this.handleBlockedRequestByContent.bind(this) });
|
|
177
210
|
}
|
|
178
|
-
async
|
|
179
|
-
const { request
|
|
180
|
-
let proxyInfo;
|
|
181
|
-
if (this.proxyConfiguration) {
|
|
182
|
-
const sessionId = session ? session.id : undefined;
|
|
183
|
-
proxyInfo = await this.proxyConfiguration.newProxyInfo(sessionId, { request });
|
|
184
|
-
}
|
|
185
|
-
return { proxyInfo };
|
|
186
|
-
}
|
|
187
|
-
async makeHttpRequest(crawlingContext) {
|
|
188
|
-
const { request, session } = crawlingContext;
|
|
211
|
+
async prepareHttpRequest(crawlingContext) {
|
|
212
|
+
const { request } = crawlingContext;
|
|
189
213
|
if (request.skipNavigation) {
|
|
190
214
|
return {
|
|
191
215
|
request: new Proxy(request, {
|
|
192
216
|
get(target, propertyName, receiver) {
|
|
193
217
|
if (propertyName === 'loadedUrl') {
|
|
194
|
-
throw new
|
|
218
|
+
throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
|
|
195
219
|
}
|
|
196
220
|
return Reflect.get(target, propertyName, receiver);
|
|
197
221
|
},
|
|
198
222
|
}),
|
|
199
223
|
get response() {
|
|
200
|
-
throw new
|
|
224
|
+
throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
|
|
201
225
|
},
|
|
202
226
|
};
|
|
203
227
|
}
|
|
204
|
-
const gotOptions = {};
|
|
205
|
-
const preNavigationHooksCookies = this._getCookieHeaderFromRequest(request);
|
|
206
228
|
request.state = RequestState.BEFORE_NAV;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
async makeHttpRequest(crawlingContext) {
|
|
210
232
|
tryCancel();
|
|
211
|
-
const
|
|
212
|
-
this._applyCookies(crawlingContext, gotOptions, preNavigationHooksCookies, postNavigationHooksCookies);
|
|
233
|
+
const { request, session } = crawlingContext;
|
|
213
234
|
const proxyUrl = crawlingContext.proxyInfo?.url;
|
|
214
|
-
|
|
235
|
+
// Bound the request by whatever is left of the shared navigation window (the pre-navigation hooks may
|
|
236
|
+
// have already spent part of it), so it produces a clean navigation-timeout error rather than the raw
|
|
237
|
+
// client abort.
|
|
238
|
+
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.`);
|
|
215
239
|
tryCancel();
|
|
216
|
-
request.loadedUrl = httpResponse
|
|
240
|
+
request.loadedUrl = httpResponse?.url;
|
|
217
241
|
request.state = RequestState.AFTER_NAV;
|
|
218
242
|
return { request: request, response: httpResponse };
|
|
219
243
|
}
|
|
@@ -221,46 +245,68 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
221
245
|
if (crawlingContext.request.skipNavigation) {
|
|
222
246
|
return {
|
|
223
247
|
get contentType() {
|
|
224
|
-
throw new
|
|
248
|
+
throw new NavigationSkippedError('The `contentType` property is not available - `skipNavigation` was used');
|
|
225
249
|
},
|
|
226
250
|
get body() {
|
|
227
|
-
throw new
|
|
251
|
+
throw new NavigationSkippedError('The `body` property is not available - `skipNavigation` was used');
|
|
228
252
|
},
|
|
229
253
|
get json() {
|
|
230
|
-
throw new
|
|
254
|
+
throw new NavigationSkippedError('The `json` property is not available - `skipNavigation` was used');
|
|
231
255
|
},
|
|
232
256
|
get waitForSelector() {
|
|
233
|
-
throw new
|
|
257
|
+
throw new NavigationSkippedError('The `waitForSelector` method is not available - `skipNavigation` was used');
|
|
234
258
|
},
|
|
235
259
|
get parseWithCheerio() {
|
|
236
|
-
throw new
|
|
260
|
+
throw new NavigationSkippedError('The `parseWithCheerio` method is not available - `skipNavigation` was used');
|
|
237
261
|
},
|
|
238
262
|
};
|
|
239
263
|
}
|
|
240
|
-
await this._executeHooks(this.postNavigationHooks, crawlingContext);
|
|
241
264
|
tryCancel();
|
|
242
|
-
|
|
265
|
+
// Reading the body is still part of the navigation, so it draws from the same shared window: on a server
|
|
266
|
+
// that streams the body slowly the request completes (headers arrive) but the body read would otherwise
|
|
267
|
+
// run unbounded. `extendTimeout` from a post-navigation hook has already pushed this deadline out if asked.
|
|
268
|
+
const remaining = remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis);
|
|
269
|
+
if (remaining <= 0) {
|
|
270
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
271
|
+
}
|
|
272
|
+
const parsed = await addTimeoutToPromise(async () => this.parseResponse(crawlingContext.request, crawlingContext.response), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
243
273
|
tryCancel();
|
|
244
274
|
const response = parsed.response;
|
|
245
275
|
const contentType = parsed.contentType;
|
|
246
276
|
const waitForSelector = async (selector, _timeoutMs) => {
|
|
277
|
+
const cheerio = await import('cheerio');
|
|
247
278
|
const $ = cheerio.load(parsed.body.toString());
|
|
248
279
|
if ($(selector).get().length === 0) {
|
|
249
280
|
throw new Error(`Selector '${selector}' not found.`);
|
|
250
281
|
}
|
|
251
282
|
};
|
|
252
283
|
const parseWithCheerio = async (selector, timeoutMs) => {
|
|
284
|
+
const cheerio = await import('cheerio');
|
|
253
285
|
const $ = cheerio.load(parsed.body.toString());
|
|
254
286
|
if (selector) {
|
|
255
287
|
await crawlingContext.waitForSelector(selector, timeoutMs);
|
|
256
288
|
}
|
|
257
289
|
return $;
|
|
258
290
|
};
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
291
|
+
this.throwOnBlockedRequest(response.status);
|
|
292
|
+
if (this.#saveResponseCookies) {
|
|
293
|
+
try {
|
|
294
|
+
for (const cookie of getCookiesFromResponse(response)) {
|
|
295
|
+
if (!cookie)
|
|
296
|
+
continue;
|
|
297
|
+
try {
|
|
298
|
+
await crawlingContext.session.cookieJar.setCookie(cookie, response.url, {
|
|
299
|
+
ignoreError: false,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
this.log.debug(`Could not set cookie: ${e.message}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
this.log.exception(e, 'Could not get cookies from response');
|
|
309
|
+
}
|
|
264
310
|
}
|
|
265
311
|
return {
|
|
266
312
|
get json() {
|
|
@@ -291,69 +337,28 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
291
337
|
return `Found selectors: ${foundSelectors.join(', ')}`;
|
|
292
338
|
}
|
|
293
339
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Sets the cookie header to `gotOptions` based on the provided request and session headers, as well as any changes that occurred due to hooks.
|
|
298
|
-
*/
|
|
299
|
-
_applyCookies({ session, request }, gotOptions, preHookCookies, postHookCookies) {
|
|
300
|
-
const sessionCookie = session?.getCookieString(request.url) ?? '';
|
|
301
|
-
let alteredGotOptionsCookies = gotOptions.headers?.Cookie || gotOptions.headers?.cookie || '';
|
|
302
|
-
if (gotOptions.headers?.Cookie && gotOptions.headers?.cookie) {
|
|
303
|
-
const { Cookie: upperCaseHeader, cookie: lowerCaseHeader } = gotOptions.headers;
|
|
304
|
-
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`);
|
|
305
|
-
const sourceCookies = [];
|
|
306
|
-
if (Array.isArray(lowerCaseHeader)) {
|
|
307
|
-
sourceCookies.push(...lowerCaseHeader);
|
|
308
|
-
}
|
|
309
|
-
else {
|
|
310
|
-
sourceCookies.push(lowerCaseHeader);
|
|
311
|
-
}
|
|
312
|
-
if (Array.isArray(upperCaseHeader)) {
|
|
313
|
-
sourceCookies.push(...upperCaseHeader);
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
sourceCookies.push(upperCaseHeader);
|
|
317
|
-
}
|
|
318
|
-
alteredGotOptionsCookies = mergeCookies(request.url, sourceCookies);
|
|
319
|
-
}
|
|
320
|
-
const sourceCookies = [sessionCookie, preHookCookies];
|
|
321
|
-
if (Array.isArray(alteredGotOptionsCookies)) {
|
|
322
|
-
sourceCookies.push(...alteredGotOptionsCookies);
|
|
323
|
-
}
|
|
324
|
-
else {
|
|
325
|
-
sourceCookies.push(alteredGotOptionsCookies);
|
|
326
|
-
}
|
|
327
|
-
sourceCookies.push(postHookCookies);
|
|
328
|
-
const mergedCookie = mergeCookies(request.url, sourceCookies);
|
|
329
|
-
gotOptions.headers ??= {};
|
|
330
|
-
Reflect.deleteProperty(gotOptions.headers, 'Cookie');
|
|
331
|
-
Reflect.deleteProperty(gotOptions.headers, 'cookie');
|
|
332
|
-
if (mergedCookie !== '') {
|
|
333
|
-
gotOptions.headers.Cookie = mergedCookie;
|
|
340
|
+
if (this.blockedStatusCodes.has(crawlingContext.response.status)) {
|
|
341
|
+
return `Blocked by status code ${crawlingContext.response.status}`;
|
|
334
342
|
}
|
|
343
|
+
return false;
|
|
335
344
|
}
|
|
336
345
|
/**
|
|
337
346
|
* Function to make the HTTP request. It performs optimizations
|
|
338
347
|
* on the request such as only downloading the request body if the
|
|
339
348
|
* received content type matches text/html, application/xml, application/xhtml+xml.
|
|
340
349
|
*/
|
|
341
|
-
async
|
|
342
|
-
|
|
343
|
-
// @ts-ignore
|
|
344
|
-
({ TimeoutError } = await import('got-scraping'));
|
|
345
|
-
}
|
|
346
|
-
const opts = this._getRequestOptions(request, session, proxyUrl, gotOptions);
|
|
350
|
+
async requestFunction({ request, session, proxyUrl }) {
|
|
351
|
+
const opts = this.getRequestOptions(request, session, proxyUrl);
|
|
347
352
|
try {
|
|
348
|
-
return await this.
|
|
353
|
+
return await this.requestAsBrowser(opts, session);
|
|
349
354
|
}
|
|
350
355
|
catch (e) {
|
|
351
|
-
if (e instanceof TimeoutError) {
|
|
352
|
-
this.
|
|
353
|
-
return
|
|
356
|
+
if (e instanceof Error && e.constructor.name === 'TimeoutError') {
|
|
357
|
+
this.handleRequestTimeout(session);
|
|
358
|
+
return new Response(); // this will never happen, as handleRequestTimeout always throws
|
|
354
359
|
}
|
|
355
360
|
if (this.isProxyError(e)) {
|
|
356
|
-
throw new SessionError(this.
|
|
361
|
+
throw new SessionError(this.getMessageFromError(e));
|
|
357
362
|
}
|
|
358
363
|
else {
|
|
359
364
|
throw e;
|
|
@@ -363,18 +368,16 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
363
368
|
/**
|
|
364
369
|
* Encodes and parses response according to the provided content type
|
|
365
370
|
*/
|
|
366
|
-
async
|
|
367
|
-
const {
|
|
368
|
-
const { type, charset } = parseContentTypeFromResponse(
|
|
369
|
-
const { response, encoding } = this.
|
|
371
|
+
async parseResponse(request, response) {
|
|
372
|
+
const { status } = response;
|
|
373
|
+
const { type, charset } = parseContentTypeFromResponse(response);
|
|
374
|
+
const { response: reencodedResponse, encoding } = this.encodeResponse(request, response, charset);
|
|
370
375
|
const contentType = { type, encoding };
|
|
371
|
-
if (
|
|
372
|
-
this.stats.registerStatusCode(
|
|
376
|
+
if (status >= 400 && status <= 599) {
|
|
377
|
+
this.stats.registerStatusCode(status);
|
|
373
378
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if ((statusCode >= 500 && !excludeError) || includeError) {
|
|
377
|
-
const body = await readStreamToString(response, encoding);
|
|
379
|
+
if (this.isErrorStatusCode(status)) {
|
|
380
|
+
const body = await reencodedResponse.text(); // TODO - this always uses UTF-8 (see https://developer.mozilla.org/en-US/docs/Web/API/Request/text)
|
|
378
381
|
// Errors are often sent as JSON, so attempt to parse them,
|
|
379
382
|
// despite Accept header being set to text/html.
|
|
380
383
|
if (type === APPLICATION_JSON_MIME_TYPE) {
|
|
@@ -382,19 +385,28 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
382
385
|
let { message } = errorResponse;
|
|
383
386
|
if (!message)
|
|
384
387
|
message = util.inspect(errorResponse, { depth: 1, maxArrayLength: 10 });
|
|
385
|
-
throw new Error(`${
|
|
388
|
+
throw new Error(`${status} - ${message}`);
|
|
386
389
|
}
|
|
387
|
-
if (
|
|
388
|
-
throw new Error(`${
|
|
390
|
+
if (this.additionalHttpErrorStatusCodes.has(status)) {
|
|
391
|
+
throw new Error(`${status} - Error status code was set by user.`);
|
|
389
392
|
}
|
|
390
393
|
// It's not a JSON, so it's probably some text. Get the first 100 chars of it.
|
|
391
|
-
throw new Error(`${
|
|
394
|
+
throw new Error(`${status} - Internal Server Error: ${body.slice(0, 100)}`);
|
|
392
395
|
}
|
|
393
396
|
else if (HTML_AND_XML_MIME_TYPES.includes(type)) {
|
|
394
|
-
|
|
397
|
+
if (!charset && !this.#forceResponseEncoding) {
|
|
398
|
+
const rawBytes = Buffer.from(await response.arrayBuffer());
|
|
399
|
+
const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
|
|
400
|
+
const charsetToUse = metaCharset ?? this.#suggestResponseEncoding ?? 'utf-8';
|
|
401
|
+
const body = iconv.encodingExists(charsetToUse)
|
|
402
|
+
? iconv.decode(rawBytes, charsetToUse)
|
|
403
|
+
: rawBytes.toString('utf8');
|
|
404
|
+
return { response, contentType: { type, encoding: 'utf-8' }, body };
|
|
405
|
+
}
|
|
406
|
+
return { response, contentType, body: await reencodedResponse.text() };
|
|
395
407
|
}
|
|
396
408
|
else {
|
|
397
|
-
const body = await
|
|
409
|
+
const body = Buffer.from(await reencodedResponse.bytes());
|
|
398
410
|
return {
|
|
399
411
|
body,
|
|
400
412
|
response,
|
|
@@ -405,28 +417,25 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
405
417
|
/**
|
|
406
418
|
* Combines the provided `requestOptions` with mandatory (non-overridable) values.
|
|
407
419
|
*/
|
|
408
|
-
|
|
420
|
+
getRequestOptions(request, session, proxyUrl) {
|
|
409
421
|
const requestOptions = {
|
|
410
422
|
url: request.url,
|
|
411
423
|
method: request.method,
|
|
412
424
|
proxyUrl,
|
|
413
|
-
timeout:
|
|
425
|
+
timeout: this.#navigationTimeoutMillis,
|
|
414
426
|
sessionToken: session,
|
|
415
|
-
|
|
416
|
-
headers: { ...request.headers, ...gotOptions?.headers },
|
|
427
|
+
headers: request.headers,
|
|
417
428
|
https: {
|
|
418
|
-
...gotOptions?.https,
|
|
419
429
|
rejectUnauthorized: !this.ignoreSslErrors,
|
|
420
430
|
},
|
|
421
|
-
|
|
431
|
+
body: undefined,
|
|
422
432
|
};
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
// because users can use normal + MITM proxies in a single configuration.
|
|
433
|
+
if (requestOptions.headers?.cookie || requestOptions.headers?.Cookie) {
|
|
434
|
+
requestOptions.headers.Cookie = this.getCookieHeaderFromRequest(request);
|
|
435
|
+
delete requestOptions.headers.cookie;
|
|
436
|
+
}
|
|
428
437
|
// Disable SSL verification for MITM proxies
|
|
429
|
-
if (
|
|
438
|
+
if (session.proxyInfo?.ignoreTlsErrors) {
|
|
430
439
|
requestOptions.https = {
|
|
431
440
|
...requestOptions.https,
|
|
432
441
|
rejectUnauthorized: false,
|
|
@@ -436,12 +445,12 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
436
445
|
requestOptions.body = request.payload ?? '';
|
|
437
446
|
return requestOptions;
|
|
438
447
|
}
|
|
439
|
-
|
|
440
|
-
if (this
|
|
441
|
-
encoding = this
|
|
448
|
+
encodeResponse(request, response, encoding) {
|
|
449
|
+
if (this.#forceResponseEncoding) {
|
|
450
|
+
encoding = this.#forceResponseEncoding;
|
|
442
451
|
}
|
|
443
|
-
else if (!encoding && this
|
|
444
|
-
encoding = this
|
|
452
|
+
else if (!encoding && this.#suggestResponseEncoding) {
|
|
453
|
+
encoding = this.#suggestResponseEncoding;
|
|
445
454
|
}
|
|
446
455
|
// Fall back to utf-8 if we still don't have encoding.
|
|
447
456
|
const utf8 = 'utf8';
|
|
@@ -454,14 +463,16 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
454
463
|
// Try to re-encode a variety of unsupported encodings to utf-8
|
|
455
464
|
if (iconv.encodingExists(encoding)) {
|
|
456
465
|
const encodeStream = iconv.encodeStream(utf8);
|
|
457
|
-
const decodeStream = iconv
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
466
|
+
const decodeStream = iconv
|
|
467
|
+
.decodeStream(encoding)
|
|
468
|
+
.on('error', (err) => encodeStream.emit('error', err));
|
|
469
|
+
const reencodedBody = response.body
|
|
470
|
+
? Readable.toWeb(Readable.from(Readable.fromWeb(response.body)
|
|
471
|
+
.pipe(decodeStream)
|
|
472
|
+
.pipe(encodeStream)))
|
|
473
|
+
: null;
|
|
463
474
|
return {
|
|
464
|
-
response:
|
|
475
|
+
response: new ResponseWithUrl(reencodedBody, response),
|
|
465
476
|
encoding: utf8,
|
|
466
477
|
};
|
|
467
478
|
}
|
|
@@ -470,15 +481,15 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
470
481
|
/**
|
|
471
482
|
* Checks and extends supported mime types
|
|
472
483
|
*/
|
|
473
|
-
|
|
484
|
+
extendSupportedMimeTypes(additionalMimeTypes) {
|
|
474
485
|
for (const mimeType of additionalMimeTypes) {
|
|
475
486
|
if (mimeType === '*/*') {
|
|
476
|
-
this
|
|
487
|
+
this.#supportedMimeTypes.add(mimeType);
|
|
477
488
|
continue;
|
|
478
489
|
}
|
|
479
490
|
try {
|
|
480
491
|
const parsedType = contentTypeParser.parse(mimeType);
|
|
481
|
-
this
|
|
492
|
+
this.#supportedMimeTypes.add(parsedType.type);
|
|
482
493
|
}
|
|
483
494
|
catch (err) {
|
|
484
495
|
throw new Error(`Can not parse mime type ${mimeType} from "options.additionalMimeTypes".`);
|
|
@@ -488,106 +499,53 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
488
499
|
/**
|
|
489
500
|
* Handles timeout request
|
|
490
501
|
*/
|
|
491
|
-
|
|
492
|
-
session
|
|
493
|
-
throw new Error(`
|
|
502
|
+
handleRequestTimeout(session) {
|
|
503
|
+
session.markBad();
|
|
504
|
+
throw new Error(`Request timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
494
505
|
}
|
|
495
|
-
|
|
496
|
-
const {
|
|
506
|
+
abortDownloadOfBody(request, response) {
|
|
507
|
+
const { status } = response;
|
|
497
508
|
const { type } = parseContentTypeFromResponse(response);
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
// if we retry the request, can the Content-Type change?
|
|
501
|
-
const isTransientContentType = statusCode >= 500 || blockedStatusCodes.includes(statusCode);
|
|
502
|
-
if (!this.supportedMimeTypes.has(type) && !this.supportedMimeTypes.has('*/*') && !isTransientContentType) {
|
|
509
|
+
const isTransientContentType = status >= 500 || this.blockedStatusCodes.has(status);
|
|
510
|
+
if (!this.#supportedMimeTypes.has(type) && !this.#supportedMimeTypes.has('*/*') && !isTransientContentType) {
|
|
503
511
|
request.noRetry = true;
|
|
504
512
|
throw new Error(`Resource ${request.url} served Content-Type ${type}, ` +
|
|
505
|
-
`but only ${Array.from(this
|
|
513
|
+
`but only ${Array.from(this.#supportedMimeTypes).join(', ')} are allowed. Skipping resource.`);
|
|
506
514
|
}
|
|
507
515
|
}
|
|
508
516
|
/**
|
|
509
517
|
* @internal wraps public utility for mocking purposes
|
|
510
518
|
*/
|
|
511
|
-
|
|
512
|
-
const
|
|
519
|
+
requestAsBrowser = async (options, session) => {
|
|
520
|
+
const opts = processHttpRequestOptions({
|
|
513
521
|
...options,
|
|
514
|
-
cookieJar: options.cookieJar, // HACK - the type of ToughCookieJar in got is wrong
|
|
515
522
|
responseType: 'text',
|
|
516
|
-
}), (redirectResponse, updatedRequest) => {
|
|
517
|
-
if (this.persistCookiesPerSession) {
|
|
518
|
-
session.setCookiesFromResponse(redirectResponse);
|
|
519
|
-
const cookieString = session.getCookieString(updatedRequest.url.toString());
|
|
520
|
-
if (cookieString !== '') {
|
|
521
|
-
updatedRequest.headers.Cookie = cookieString;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
523
|
});
|
|
525
|
-
|
|
524
|
+
// When saveResponseCookies is false, the response cookies must not mutate the
|
|
525
|
+
// session jar. Reads still go through the session (so session.setCookie() in pre-nav
|
|
526
|
+
// hooks keeps working) but a per-request clone is passed in so writes are discarded.
|
|
527
|
+
const cookieJar = this.#saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
|
|
528
|
+
// Bind the request to the shared navigation window instead of a fixed per-request timeout, so
|
|
529
|
+
// `extendTimeout()` can push the deadline and a fixed `AbortSignal.timeout` won't fire on its own and
|
|
530
|
+
// kill a lazily-read body mid-extension. This aborts the socket only during the header phase; the body
|
|
531
|
+
// read is bounded separately at the promise level (see `processHttpResponse`), so a slow-streaming body
|
|
532
|
+
// still fails cleanly with a navigation timeout, though the socket is left to close on its own.
|
|
533
|
+
const cancelSignal = storage.getStore()?.cancelTask.signal;
|
|
534
|
+
const response = await this.httpClient.sendRequest(new Request(opts.url, {
|
|
535
|
+
body: opts.body ? Readable.toWeb(opts.body) : undefined,
|
|
536
|
+
headers: new Headers(opts.headers),
|
|
537
|
+
method: opts.method,
|
|
538
|
+
// Node-specific option to make the request body work with streams
|
|
539
|
+
duplex: 'half',
|
|
540
|
+
}), {
|
|
541
|
+
session,
|
|
542
|
+
cookieJar,
|
|
543
|
+
signal: cancelSignal,
|
|
544
|
+
timeoutMillis: cancelSignal ? undefined : opts.timeout,
|
|
545
|
+
});
|
|
546
|
+
return response;
|
|
526
547
|
};
|
|
527
548
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
* At the same time, you can't read data directly from the response stream,
|
|
531
|
-
* because they won't get emitted unless you also read from the primary
|
|
532
|
-
* got stream. To be able to work with only one stream, we move the expected props
|
|
533
|
-
* from the response stream to the got stream.
|
|
534
|
-
* @internal
|
|
535
|
-
*/
|
|
536
|
-
function addResponsePropertiesToStream(stream, response) {
|
|
537
|
-
const properties = [
|
|
538
|
-
'statusCode',
|
|
539
|
-
'statusMessage',
|
|
540
|
-
'headers',
|
|
541
|
-
'complete',
|
|
542
|
-
'httpVersion',
|
|
543
|
-
'rawHeaders',
|
|
544
|
-
'rawTrailers',
|
|
545
|
-
'trailers',
|
|
546
|
-
'url',
|
|
547
|
-
'request',
|
|
548
|
-
];
|
|
549
|
-
stream.on('end', () => {
|
|
550
|
-
// @ts-expect-error
|
|
551
|
-
if (stream.rawTrailers)
|
|
552
|
-
stream.rawTrailers = response.rawTrailers; // TODO BC with got - remove in 4.0
|
|
553
|
-
// @ts-expect-error
|
|
554
|
-
if (stream.trailers)
|
|
555
|
-
stream.trailers = response.trailers;
|
|
556
|
-
// @ts-expect-error
|
|
557
|
-
stream.complete = response.complete;
|
|
558
|
-
});
|
|
559
|
-
for (const prop of properties) {
|
|
560
|
-
if (!(prop in stream)) {
|
|
561
|
-
stream[prop] = response[prop];
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
return stream;
|
|
565
|
-
}
|
|
566
|
-
/**
|
|
567
|
-
* Creates new {@link Router} instance that works based on request labels.
|
|
568
|
-
* This instance can then serve as a `requestHandler` of your {@link HttpCrawler}.
|
|
569
|
-
* Defaults to the {@link HttpCrawlingContext}.
|
|
570
|
-
*
|
|
571
|
-
* > Serves as a shortcut for using `Router.create<HttpCrawlingContext>()`.
|
|
572
|
-
*
|
|
573
|
-
* ```ts
|
|
574
|
-
* import { HttpCrawler, createHttpRouter } from 'crawlee';
|
|
575
|
-
*
|
|
576
|
-
* const router = createHttpRouter();
|
|
577
|
-
* router.addHandler('label-a', async (ctx) => {
|
|
578
|
-
* ctx.log.info('...');
|
|
579
|
-
* });
|
|
580
|
-
* router.addDefaultHandler(async (ctx) => {
|
|
581
|
-
* ctx.log.info('...');
|
|
582
|
-
* });
|
|
583
|
-
*
|
|
584
|
-
* const crawler = new HttpCrawler({
|
|
585
|
-
* requestHandler: router,
|
|
586
|
-
* });
|
|
587
|
-
* await crawler.run();
|
|
588
|
-
* ```
|
|
589
|
-
*/
|
|
590
|
-
export function createHttpRouter(routes) {
|
|
591
|
-
return Router.create(routes);
|
|
549
|
+
export function createHttpRouter(routesOrSchemas) {
|
|
550
|
+
return Router.create(routesOrSchemas);
|
|
592
551
|
}
|
|
593
|
-
//# sourceMappingURL=http-crawler.js.map
|