@crawlee/cheerio 3.0.0-alpha.2 → 3.0.0-alpha.5

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.
@@ -0,0 +1,682 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cheerioCrawlerEnqueueLinks = exports.CheerioCrawler = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const timeout_1 = require("@apify/timeout");
6
+ const utilities_1 = require("@apify/utilities");
7
+ const basic_1 = require("@crawlee/basic");
8
+ const core_1 = require("@crawlee/core");
9
+ const utils_1 = require("@crawlee/utils");
10
+ const cheerio_1 = tslib_1.__importDefault(require("cheerio"));
11
+ const content_type_1 = tslib_1.__importDefault(require("content-type"));
12
+ const got_scraping_1 = require("got-scraping");
13
+ const htmlparser2_1 = require("htmlparser2");
14
+ const WritableStream_1 = require("htmlparser2/lib/WritableStream");
15
+ const iconv_lite_1 = tslib_1.__importDefault(require("iconv-lite"));
16
+ const ow_1 = tslib_1.__importDefault(require("ow"));
17
+ const util_1 = tslib_1.__importDefault(require("util"));
18
+ /**
19
+ * Default mime types, which CheerioScraper supports.
20
+ */
21
+ const HTML_AND_XML_MIME_TYPES = ['text/html', 'text/xml', 'application/xhtml+xml', 'application/xml'];
22
+ const APPLICATION_JSON_MIME_TYPE = 'application/json';
23
+ const CHEERIO_OPTIMIZED_AUTOSCALED_POOL_OPTIONS = {
24
+ snapshotterOptions: {
25
+ eventLoopSnapshotIntervalSecs: 2,
26
+ maxBlockedMillis: 100,
27
+ },
28
+ systemStatusOptions: {
29
+ maxEventLoopOverloadedRatio: 0.7,
30
+ },
31
+ };
32
+ /**
33
+ * Provides a framework for the parallel crawling of web pages using plain HTTP requests and
34
+ * [cheerio](https://www.npmjs.com/package/cheerio) HTML parser.
35
+ * The URLs to crawl are fed either from a static list of URLs
36
+ * or from a dynamic queue of URLs enabling recursive crawling of websites.
37
+ *
38
+ * Since `CheerioCrawler` uses raw HTTP requests to download web pages,
39
+ * it is very fast and efficient on data bandwidth. However, if the target website requires JavaScript
40
+ * to display the content, you might need to use {@link PuppeteerCrawler} or {@link PlaywrightCrawler} instead,
41
+ * because it loads the pages using full-featured headless Chrome browser.
42
+ *
43
+ * `CheerioCrawler` downloads each URL using a plain HTTP request,
44
+ * parses the HTML content using [Cheerio](https://www.npmjs.com/package/cheerio)
45
+ * and then invokes the user-provided {@link CheerioCrawlerOptions.requestHandler} to extract page data
46
+ * using a [jQuery](https://jquery.com/)-like interface to the parsed HTML DOM.
47
+ *
48
+ * The source URLs are represented using {@link Request} objects that are fed from
49
+ * {@link RequestList} or {@link RequestQueue} instances provided by the {@link CheerioCrawlerOptions.requestList}
50
+ * or {@link CheerioCrawlerOptions.requestQueue} constructor options, respectively.
51
+ *
52
+ * If both {@link CheerioCrawlerOptions.requestList} and {@link CheerioCrawlerOptions.requestQueue} are used,
53
+ * the instance first processes URLs from the {@link RequestList} and automatically enqueues all of them
54
+ * to {@link RequestQueue} before it starts their processing. This ensures that a single URL is not crawled multiple times.
55
+ *
56
+ * The crawler finishes when there are no more {@link Request} objects to crawl.
57
+ *
58
+ * We can use the `preNavigationHooks` to adjust `gotOptions`:
59
+ *
60
+ * ```
61
+ * preNavigationHooks: [
62
+ * (crawlingContext, gotOptions) => {
63
+ * // ...
64
+ * },
65
+ * ]
66
+ * ```
67
+ *
68
+ * By default, `CheerioCrawler` only processes web pages with the `text/html`
69
+ * and `application/xhtml+xml` MIME content types (as reported by the `Content-Type` HTTP header),
70
+ * and skips pages with other content types. If you want the crawler to process other content types,
71
+ * use the {@link CheerioCrawlerOptions.additionalMimeTypes} constructor option.
72
+ * Beware that the parsing behavior differs for HTML, XML, JSON and other types of content.
73
+ * For details, see {@link CheerioCrawlerOptions.requestHandler}.
74
+ *
75
+ * New requests are only dispatched when there is enough free CPU and memory available,
76
+ * using the functionality provided by the {@link AutoscaledPool} class.
77
+ * All {@link AutoscaledPool} configuration options can be passed to the `autoscaledPoolOptions`
78
+ * parameter of the `CheerioCrawler` constructor. For user convenience, the `minConcurrency` and `maxConcurrency`
79
+ * {@link AutoscaledPool} options are available directly in the `CheerioCrawler` constructor.
80
+ *
81
+ * **Example usage:**
82
+ *
83
+ * ```javascript
84
+ * // Prepare a list of URLs to crawl
85
+ * const requestList = new RequestList({
86
+ * sources: [
87
+ * { url: 'http://www.example.com/page-1' },
88
+ * { url: 'http://www.example.com/page-2' },
89
+ * ],
90
+ * });
91
+ * await requestList.initialize();
92
+ *
93
+ * // Crawl the URLs
94
+ * const crawler = new CheerioCrawler({
95
+ * requestList,
96
+ * handlePageFunction: async ({ request, response, body, contentType, $ }) => {
97
+ * const data = [];
98
+ *
99
+ * // Do some data extraction from the page with Cheerio.
100
+ * $('.some-collection').each((index, el) => {
101
+ * data.push({ title: $(el).find('.some-title').text() });
102
+ * });
103
+ *
104
+ * // Save the data to dataset.
105
+ * await Actor.pushData({
106
+ * url: request.url,
107
+ * html: body,
108
+ * data,
109
+ * })
110
+ * },
111
+ * });
112
+ *
113
+ * await crawler.run();
114
+ * ```
115
+ * @category Crawlers
116
+ */
117
+ class CheerioCrawler extends basic_1.BasicCrawler {
118
+ /**
119
+ * All `CheerioCrawler` parameters are passed via an options object.
120
+ */
121
+ constructor(options) {
122
+ (0, ow_1.default)(options, 'CheerioCrawlerOptions', ow_1.default.object.exactShape(CheerioCrawler.optionsShape));
123
+ const { requestHandler, handlePageFunction, requestHandlerTimeoutSecs = 60, requestTimeoutSecs = 30, ignoreSslErrors = true, additionalMimeTypes = [], suggestResponseEncoding, forceResponseEncoding, proxyConfiguration, prepareRequestFunction, postResponseFunction, persistCookiesPerSession, preNavigationHooks = [], postNavigationHooks = [],
124
+ // Ignored
125
+ handleRequestFunction,
126
+ // BasicCrawler
127
+ autoscaledPoolOptions = CHEERIO_OPTIMIZED_AUTOSCALED_POOL_OPTIONS, ...basicCrawlerOptions } = options;
128
+ super({
129
+ ...basicCrawlerOptions,
130
+ // Will be overridden below
131
+ requestHandler: () => { },
132
+ autoscaledPoolOptions,
133
+ // We need to add some time for internal functions to finish,
134
+ // but not too much so that we would stall the crawler.
135
+ requestHandlerTimeoutSecs: requestTimeoutSecs + requestHandlerTimeoutSecs + basic_1.BASIC_CRAWLER_TIMEOUT_BUFFER_SECS,
136
+ });
137
+ /**
138
+ * A reference to the underlying {@link ProxyConfiguration} class that manages the crawler's proxies.
139
+ * Only available if used by the crawler.
140
+ */
141
+ Object.defineProperty(this, "proxyConfiguration", {
142
+ enumerable: true,
143
+ configurable: true,
144
+ writable: true,
145
+ value: void 0
146
+ });
147
+ Object.defineProperty(this, "userRequestHandlerTimeoutMillis", {
148
+ enumerable: true,
149
+ configurable: true,
150
+ writable: true,
151
+ value: void 0
152
+ });
153
+ Object.defineProperty(this, "defaultGotoOptions", {
154
+ enumerable: true,
155
+ configurable: true,
156
+ writable: true,
157
+ value: void 0
158
+ });
159
+ Object.defineProperty(this, "preNavigationHooks", {
160
+ enumerable: true,
161
+ configurable: true,
162
+ writable: true,
163
+ value: void 0
164
+ });
165
+ Object.defineProperty(this, "postNavigationHooks", {
166
+ enumerable: true,
167
+ configurable: true,
168
+ writable: true,
169
+ value: void 0
170
+ });
171
+ Object.defineProperty(this, "persistCookiesPerSession", {
172
+ enumerable: true,
173
+ configurable: true,
174
+ writable: true,
175
+ value: void 0
176
+ });
177
+ Object.defineProperty(this, "requestTimeoutMillis", {
178
+ enumerable: true,
179
+ configurable: true,
180
+ writable: true,
181
+ value: void 0
182
+ });
183
+ Object.defineProperty(this, "ignoreSslErrors", {
184
+ enumerable: true,
185
+ configurable: true,
186
+ writable: true,
187
+ value: void 0
188
+ });
189
+ Object.defineProperty(this, "suggestResponseEncoding", {
190
+ enumerable: true,
191
+ configurable: true,
192
+ writable: true,
193
+ value: void 0
194
+ });
195
+ Object.defineProperty(this, "forceResponseEncoding", {
196
+ enumerable: true,
197
+ configurable: true,
198
+ writable: true,
199
+ value: void 0
200
+ });
201
+ Object.defineProperty(this, "prepareRequestFunction", {
202
+ enumerable: true,
203
+ configurable: true,
204
+ writable: true,
205
+ value: void 0
206
+ });
207
+ Object.defineProperty(this, "postResponseFunction", {
208
+ enumerable: true,
209
+ configurable: true,
210
+ writable: true,
211
+ value: void 0
212
+ });
213
+ Object.defineProperty(this, "supportedMimeTypes", {
214
+ enumerable: true,
215
+ configurable: true,
216
+ writable: true,
217
+ value: void 0
218
+ });
219
+ /**
220
+ * @internal wraps public utility for mocking purposes
221
+ */
222
+ Object.defineProperty(this, "_requestAsBrowser", {
223
+ enumerable: true,
224
+ configurable: true,
225
+ writable: true,
226
+ value: (options) => {
227
+ return new Promise((resolve, reject) => {
228
+ const stream = (0, got_scraping_1.gotScraping)(options);
229
+ stream.on('error', reject);
230
+ stream.on('response', () => {
231
+ resolve(addResponsePropertiesToStream(stream));
232
+ });
233
+ });
234
+ }
235
+ });
236
+ this._handlePropertyNameChange({
237
+ newName: 'requestHandler',
238
+ oldName: 'handlePageFunction',
239
+ propertyKey: 'requestHandler',
240
+ newProperty: requestHandler,
241
+ oldProperty: handlePageFunction,
242
+ });
243
+ // Cookies should be persisted per session only if session pool is used
244
+ if (!this.useSessionPool && persistCookiesPerSession) {
245
+ throw new Error('You cannot use "persistCookiesPerSession" without "useSessionPool" set to true.');
246
+ }
247
+ this.supportedMimeTypes = new Set([...HTML_AND_XML_MIME_TYPES, APPLICATION_JSON_MIME_TYPE]);
248
+ if (additionalMimeTypes.length)
249
+ this._extendSupportedMimeTypes(additionalMimeTypes);
250
+ if (suggestResponseEncoding && forceResponseEncoding) {
251
+ this.log.warning('Both forceResponseEncoding and suggestResponseEncoding options are set. Using forceResponseEncoding.');
252
+ }
253
+ this.userRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
254
+ this.requestTimeoutMillis = requestTimeoutSecs * 1000;
255
+ this.ignoreSslErrors = ignoreSslErrors;
256
+ this.suggestResponseEncoding = suggestResponseEncoding;
257
+ this.forceResponseEncoding = forceResponseEncoding;
258
+ this.prepareRequestFunction = prepareRequestFunction;
259
+ this.postResponseFunction = postResponseFunction;
260
+ this.proxyConfiguration = proxyConfiguration;
261
+ this.preNavigationHooks = preNavigationHooks;
262
+ this.postNavigationHooks = [
263
+ ({ request, response }) => this._abortDownloadOfBody(request, response),
264
+ ...postNavigationHooks,
265
+ ];
266
+ if (this.useSessionPool) {
267
+ this.persistCookiesPerSession = persistCookiesPerSession ?? true;
268
+ }
269
+ else {
270
+ this.persistCookiesPerSession = false;
271
+ }
272
+ }
273
+ /**
274
+ * **EXPERIMENTAL**
275
+ * Function for attaching CrawlerExtensions such as the Unblockers.
276
+ * @param extension Crawler extension that overrides the crawler configuration.
277
+ */
278
+ use(extension) {
279
+ (0, ow_1.default)(extension, ow_1.default.object.instanceOf(core_1.CrawlerExtension));
280
+ const extensionOptions = extension.getCrawlerOptions();
281
+ for (const [key, value] of (0, utils_1.entries)(extensionOptions)) {
282
+ const isConfigurable = this.hasOwnProperty(key); // eslint-disable-line
283
+ const originalType = typeof this[key];
284
+ const extensionType = typeof value; // What if we want to null something? It is really needed?
285
+ const isSameType = originalType === extensionType || value == null; // fast track for deleting keys
286
+ const exists = this[key] != null;
287
+ if (!isConfigurable) { // Test if the property can be configured on the crawler
288
+ throw new Error(`${extension.name} tries to set property "${key}" that is not configurable on CheerioCrawler instance.`);
289
+ }
290
+ if (!isSameType && exists) { // Assuming that extensions will only add up configuration
291
+ throw new Error(`${extension.name} tries to set property of different type "${extensionType}". "CheerioCrawler.${key}: ${originalType}".`);
292
+ }
293
+ this.log.warning(`${extension.name} is overriding "CheerioCrawler.${key}: ${originalType}" with ${value}.`);
294
+ this[key] = value;
295
+ }
296
+ }
297
+ /**
298
+ * Wrapper around handlePageFunction that opens and closes pages etc.
299
+ */
300
+ async _runRequestHandler(crawlingContext) {
301
+ const { request, session } = crawlingContext;
302
+ if (this.proxyConfiguration) {
303
+ const sessionId = session ? session.id : undefined;
304
+ crawlingContext.proxyInfo = await this.proxyConfiguration.newProxyInfo(sessionId);
305
+ }
306
+ await this._handleNavigation(crawlingContext);
307
+ (0, timeout_1.tryCancel)();
308
+ const { dom, isXml, body, contentType, response } = await this._parseResponse(request, crawlingContext.response);
309
+ (0, timeout_1.tryCancel)();
310
+ if (this.useSessionPool) {
311
+ this._throwOnBlockedRequest(session, response.statusCode);
312
+ }
313
+ if (this.persistCookiesPerSession) {
314
+ session.setCookiesFromResponse(response);
315
+ }
316
+ request.loadedUrl = response.url;
317
+ const $ = dom
318
+ ? cheerio_1.default.load(dom, {
319
+ xmlMode: isXml,
320
+ // Recent versions of cheerio use parse5 as the HTML parser/serializer. It's more strict than htmlparser2
321
+ // and not good for scraping. It also does not have a great streaming interface.
322
+ // Here we tell cheerio to use htmlparser2 for serialization, otherwise the conflict produces weird errors.
323
+ _useHtmlParser2: true,
324
+ })
325
+ : null;
326
+ crawlingContext.$ = $;
327
+ crawlingContext.contentType = contentType;
328
+ crawlingContext.response = response;
329
+ crawlingContext.enqueueLinks = async (enqueueOptions) => {
330
+ return cheerioCrawlerEnqueueLinks({
331
+ options: enqueueOptions,
332
+ $,
333
+ requestQueue: await this.getRequestQueue(),
334
+ originalRequestUrl: crawlingContext.request.url,
335
+ finalRequestUrl: crawlingContext.request.loadedUrl,
336
+ });
337
+ };
338
+ Object.defineProperty(crawlingContext, 'json', {
339
+ get() {
340
+ if (contentType.type !== APPLICATION_JSON_MIME_TYPE)
341
+ return null;
342
+ const jsonString = body.toString(contentType.encoding);
343
+ return JSON.parse(jsonString);
344
+ },
345
+ });
346
+ Object.defineProperty(crawlingContext, 'body', {
347
+ get() {
348
+ // NOTE: For XML/HTML documents, we don't store the original body and only reconstruct it from Cheerio's DOM.
349
+ // This is to save memory for high-concurrency crawls. The downside is that changes
350
+ // made to DOM are reflected in the HTML, but we can live with that...
351
+ if (dom) {
352
+ return isXml ? $.xml() : $.html({ decodeEntities: false });
353
+ }
354
+ return body;
355
+ },
356
+ });
357
+ return (0, timeout_1.addTimeoutToPromise)(() => Promise.resolve(this.requestHandler(crawlingContext)), this.userRequestHandlerTimeoutMillis, `requestHandler timed out after ${this.userRequestHandlerTimeoutMillis / 1000} seconds.`);
358
+ }
359
+ async _handleNavigation(crawlingContext) {
360
+ if (this.prepareRequestFunction) {
361
+ this.log.deprecated('Option "prepareRequestFunction" is deprecated. Use "preNavigationHooks" instead.');
362
+ await this.prepareRequestFunction(crawlingContext);
363
+ (0, timeout_1.tryCancel)();
364
+ }
365
+ const gotOptions = {};
366
+ if (this.useSessionPool) {
367
+ this._applySessionCookie(crawlingContext, gotOptions);
368
+ }
369
+ const { request, session } = crawlingContext;
370
+ const cookieSnapshot = request.headers?.Cookie ?? request.headers?.cookie;
371
+ await this._executeHooks(this.preNavigationHooks, crawlingContext, gotOptions);
372
+ (0, timeout_1.tryCancel)();
373
+ const proxyUrl = crawlingContext.proxyInfo?.url;
374
+ this._mergeRequestCookieDiff(request, cookieSnapshot, gotOptions);
375
+ crawlingContext.response = await (0, timeout_1.addTimeoutToPromise)(() => this._requestFunction({ request, session, proxyUrl, gotOptions }), this.requestTimeoutMillis, `request timed out after ${this.requestTimeoutMillis / 1000} seconds.`);
376
+ (0, timeout_1.tryCancel)();
377
+ await this._executeHooks(this.postNavigationHooks, crawlingContext, gotOptions);
378
+ (0, timeout_1.tryCancel)();
379
+ if (this.postResponseFunction) {
380
+ this.log.deprecated('Option "postResponseFunction" is deprecated. Use "postNavigationHooks" instead.');
381
+ await this.postResponseFunction(crawlingContext);
382
+ (0, timeout_1.tryCancel)();
383
+ }
384
+ }
385
+ /**
386
+ * When users change `request.headers.cookie` inside preNavigationHook, the change would be ignored,
387
+ * as `request.headers` are already merged into the `gotOptions`. This method is using
388
+ * old `request.headers` snapshot (before hooks are executed), makes a diff with the cookie value
389
+ * after hooks are executed, and merges any new cookies back to `gotOptions`.
390
+ *
391
+ * This way we can still use both `gotOptions` and `context.request` in the hooks (not both).
392
+ */
393
+ _mergeRequestCookieDiff(request, cookieSnapshot, gotOptions) {
394
+ const cookieDiff = (0, core_1.diffCookies)(request.url, cookieSnapshot, request.headers?.Cookie ?? request.headers?.cookie);
395
+ if (cookieDiff.length > 0) {
396
+ gotOptions.headers ?? (gotOptions.headers = {});
397
+ gotOptions.headers.Cookie = (0, core_1.mergeCookies)(request.url, [
398
+ gotOptions.headers.Cookie,
399
+ cookieDiff,
400
+ ]);
401
+ }
402
+ }
403
+ /**
404
+ * Function to make the HTTP request. It performs optimizations
405
+ * on the request such as only downloading the request body if the
406
+ * received content type matches text/html, application/xml, application/xhtml+xml.
407
+ */
408
+ async _requestFunction({ request, session, proxyUrl, gotOptions }) {
409
+ const opts = this._getRequestOptions(request, session, proxyUrl, gotOptions);
410
+ try {
411
+ return await this._requestAsBrowser(opts);
412
+ }
413
+ catch (e) {
414
+ if (e instanceof got_scraping_1.TimeoutError) {
415
+ this._handleRequestTimeout(session);
416
+ return undefined;
417
+ }
418
+ throw e;
419
+ }
420
+ }
421
+ /**
422
+ * Sets the cookie header to `gotOptions` based on provided session and request. If some cookies were already set,
423
+ * the session cookie will be merged with them. User provided cookies on `request` object have precedence.
424
+ */
425
+ _applySessionCookie({ request, session }, gotOptions) {
426
+ const userCookie = request.headers?.Cookie ?? request.headers?.cookie;
427
+ const sessionCookie = session.getCookieString(request.url);
428
+ const mergedCookies = (0, core_1.mergeCookies)(request.url, [sessionCookie, userCookie]);
429
+ // merge cookies from all possible sources
430
+ if (mergedCookies) {
431
+ gotOptions.headers ?? (gotOptions.headers = {});
432
+ gotOptions.headers.Cookie = mergedCookies;
433
+ }
434
+ }
435
+ /**
436
+ * Encodes and parses response according to the provided content type
437
+ */
438
+ async _parseResponse(request, responseStream) {
439
+ const { statusCode } = responseStream;
440
+ const { type, charset } = (0, utils_1.parseContentTypeFromResponse)(responseStream);
441
+ const { response, encoding } = this._encodeResponse(request, responseStream, charset);
442
+ const contentType = { type, encoding };
443
+ if (statusCode >= 500) {
444
+ const body = await (0, utilities_1.readStreamToString)(response, encoding);
445
+ // Errors are often sent as JSON, so attempt to parse them,
446
+ // despite Accept header being set to text/html.
447
+ if (type === APPLICATION_JSON_MIME_TYPE) {
448
+ const errorResponse = JSON.parse(body);
449
+ let { message } = errorResponse;
450
+ if (!message)
451
+ message = util_1.default.inspect(errorResponse, { depth: 1, maxArrayLength: 10 });
452
+ throw new Error(`${statusCode} - ${message}`);
453
+ }
454
+ // It's not a JSON so it's probably some text. Get the first 100 chars of it.
455
+ throw new Error(`${statusCode} - Internal Server Error: ${body.substr(0, 100)}`);
456
+ }
457
+ else if (HTML_AND_XML_MIME_TYPES.includes(type)) {
458
+ const dom = await this._parseHtmlToDom(response);
459
+ return ({ dom, isXml: type.includes('xml'), response, contentType });
460
+ }
461
+ else {
462
+ const body = await (0, utilities_1.concatStreamToBuffer)(response);
463
+ return { body, response, contentType };
464
+ }
465
+ }
466
+ /**
467
+ * Combines the provided `requestOptions` with mandatory (non-overridable) values.
468
+ */
469
+ _getRequestOptions(request, session, proxyUrl, gotOptions) {
470
+ const requestOptions = {
471
+ url: request.url,
472
+ method: request.method,
473
+ proxyUrl,
474
+ timeout: { request: this.requestTimeoutMillis },
475
+ sessionToken: session,
476
+ ...gotOptions,
477
+ headers: { ...request.headers, ...gotOptions?.headers },
478
+ https: {
479
+ ...gotOptions?.https,
480
+ rejectUnauthorized: !this.ignoreSslErrors,
481
+ },
482
+ isStream: true,
483
+ };
484
+ // TODO this is incorrect, the check for man in the middle needs to be done
485
+ // on individual proxy level, not on the `proxyConfiguration` level,
486
+ // because users can use normal + MITM proxies in a single configuration.
487
+ // Disable SSL verification for MITM proxies
488
+ if (this.proxyConfiguration && this.proxyConfiguration.isManInTheMiddle) {
489
+ requestOptions.https = {
490
+ ...requestOptions.https,
491
+ rejectUnauthorized: false,
492
+ };
493
+ }
494
+ if (/PATCH|POST|PUT/.test(request.method))
495
+ requestOptions.body = request.payload;
496
+ return requestOptions;
497
+ }
498
+ _encodeResponse(request, response, encoding) {
499
+ if (this.forceResponseEncoding) {
500
+ encoding = this.forceResponseEncoding;
501
+ }
502
+ else if (!encoding && this.suggestResponseEncoding) {
503
+ encoding = this.suggestResponseEncoding;
504
+ }
505
+ // Fall back to utf-8 if we still don't have encoding.
506
+ const utf8 = 'utf8';
507
+ if (!encoding)
508
+ return { response, encoding: utf8 };
509
+ // This means that the encoding is one of Node.js supported
510
+ // encodings and we don't need to re-encode it.
511
+ if (Buffer.isEncoding(encoding))
512
+ return { response, encoding };
513
+ // Try to re-encode a variety of unsupported encodings to utf-8
514
+ if (iconv_lite_1.default.encodingExists(encoding)) {
515
+ const encodeStream = iconv_lite_1.default.encodeStream(utf8);
516
+ const decodeStream = iconv_lite_1.default.decodeStream(encoding).on('error', (err) => encodeStream.emit('error', err));
517
+ response.on('error', (err) => decodeStream.emit('error', err));
518
+ const encodedResponse = response.pipe(decodeStream).pipe(encodeStream);
519
+ encodedResponse.statusCode = response.statusCode;
520
+ encodedResponse.headers = response.headers;
521
+ encodedResponse.url = response.url;
522
+ return {
523
+ response: encodedResponse,
524
+ encoding: utf8,
525
+ };
526
+ }
527
+ throw new Error(`Resource ${request.url} served with unsupported charset/encoding: ${encoding}`);
528
+ }
529
+ async _parseHtmlToDom(response) {
530
+ return new Promise((resolve, reject) => {
531
+ const domHandler = new htmlparser2_1.DomHandler((err, dom) => {
532
+ if (err)
533
+ reject(err);
534
+ else
535
+ resolve(dom);
536
+ });
537
+ const parser = new WritableStream_1.WritableStream(domHandler, { decodeEntities: true });
538
+ parser.on('error', reject);
539
+ response
540
+ .on('error', reject)
541
+ .pipe(parser);
542
+ });
543
+ }
544
+ /**
545
+ * Checks and extends supported mime types
546
+ */
547
+ _extendSupportedMimeTypes(additionalMimeTypes) {
548
+ additionalMimeTypes.forEach((mimeType) => {
549
+ try {
550
+ const parsedType = content_type_1.default.parse(mimeType);
551
+ this.supportedMimeTypes.add(parsedType.type);
552
+ }
553
+ catch (err) {
554
+ throw new Error(`Can not parse mime type ${mimeType} from "options.additionalMimeTypes".`);
555
+ }
556
+ });
557
+ }
558
+ /**
559
+ * Handles blocked request
560
+ */
561
+ _throwOnBlockedRequest(session, statusCode) {
562
+ const isBlocked = session.retireOnBlockedStatusCodes(statusCode);
563
+ if (isBlocked) {
564
+ throw new Error(`Request blocked - received ${statusCode} status code`);
565
+ }
566
+ }
567
+ /**
568
+ * Handles timeout request
569
+ */
570
+ _handleRequestTimeout(session) {
571
+ session?.markBad();
572
+ throw new Error(`request timed out after ${this.requestHandlerTimeoutMillis / 1000} seconds.`);
573
+ }
574
+ _abortDownloadOfBody(request, response) {
575
+ const { statusCode } = response;
576
+ const { type } = (0, utils_1.parseContentTypeFromResponse)(response);
577
+ if (statusCode === 406) {
578
+ request.noRetry = true;
579
+ throw new Error(`Resource ${request.url} is not available in the format requested by the Accept header. Skipping resource.`);
580
+ }
581
+ if (!this.supportedMimeTypes.has(type) && statusCode < 500) {
582
+ request.noRetry = true;
583
+ throw new Error(`Resource ${request.url} served Content-Type ${type}, `
584
+ + `but only ${Array.from(this.supportedMimeTypes).join(', ')} are allowed. Skipping resource.`);
585
+ }
586
+ }
587
+ }
588
+ exports.CheerioCrawler = CheerioCrawler;
589
+ Object.defineProperty(CheerioCrawler, "optionsShape", {
590
+ enumerable: true,
591
+ configurable: true,
592
+ writable: true,
593
+ value: {
594
+ ...basic_1.BasicCrawler.optionsShape,
595
+ handlePageFunction: ow_1.default.optional.function,
596
+ requestTimeoutSecs: ow_1.default.optional.number,
597
+ ignoreSslErrors: ow_1.default.optional.boolean,
598
+ additionalMimeTypes: ow_1.default.optional.array.ofType(ow_1.default.string),
599
+ suggestResponseEncoding: ow_1.default.optional.string,
600
+ forceResponseEncoding: ow_1.default.optional.string,
601
+ proxyConfiguration: ow_1.default.optional.object.validate(core_1.validators.proxyConfiguration),
602
+ prepareRequestFunction: ow_1.default.optional.function,
603
+ postResponseFunction: ow_1.default.optional.function,
604
+ persistCookiesPerSession: ow_1.default.optional.boolean,
605
+ preNavigationHooks: ow_1.default.optional.array,
606
+ postNavigationHooks: ow_1.default.optional.array,
607
+ }
608
+ });
609
+ /** @internal */
610
+ async function cheerioCrawlerEnqueueLinks({ options, $, requestQueue, originalRequestUrl, finalRequestUrl }) {
611
+ if (!$) {
612
+ throw new Error('Cannot enqueue links because the DOM is not available.');
613
+ }
614
+ const baseUrl = (0, core_1.resolveBaseUrl)({
615
+ enqueueStrategy: options?.strategy,
616
+ finalRequestUrl,
617
+ originalRequestUrl,
618
+ userProvidedBaseUrl: options?.baseUrl,
619
+ });
620
+ const urls = extractUrlsFromCheerio($, options?.selector ?? 'a', baseUrl);
621
+ return (0, core_1.enqueueLinks)({
622
+ requestQueue,
623
+ urls,
624
+ baseUrl,
625
+ ...options,
626
+ });
627
+ }
628
+ exports.cheerioCrawlerEnqueueLinks = cheerioCrawlerEnqueueLinks;
629
+ /**
630
+ * Extracts URLs from a given Cheerio object.
631
+ * @ignore
632
+ */
633
+ function extractUrlsFromCheerio($, selector, baseUrl) {
634
+ return $(selector)
635
+ .map((_i, el) => $(el).attr('href'))
636
+ .get()
637
+ .filter((href) => !!href)
638
+ .map((href) => {
639
+ // Throw a meaningful error when only a relative URL would be extracted instead of waiting for the Request to fail later.
640
+ const isHrefAbsolute = /^[a-z][a-z0-9+.-]*:/.test(href); // Grabbed this in 'is-absolute-url' package.
641
+ if (!isHrefAbsolute && !baseUrl) {
642
+ throw new Error(`An extracted URL: ${href} is relative and options.baseUrl is not set. `
643
+ + 'Use options.baseUrl in enqueueLinks() to automatically resolve relative URLs.');
644
+ }
645
+ return baseUrl
646
+ ? (new URL(href, baseUrl)).href
647
+ : href;
648
+ });
649
+ }
650
+ /**
651
+ * The stream object returned from got does not have the below properties.
652
+ * At the same time, you can't read data directly from the response stream,
653
+ * because they won't get emitted unless you also read from the primary
654
+ * got stream. To be able to work with only one stream, we move the expected props
655
+ * from the response stream to the got stream.
656
+ * @internal
657
+ */
658
+ function addResponsePropertiesToStream(stream) {
659
+ const properties = [
660
+ 'statusCode', 'statusMessage', 'headers',
661
+ 'complete', 'httpVersion', 'rawHeaders',
662
+ 'rawTrailers', 'trailers', 'url',
663
+ 'request',
664
+ ];
665
+ const response = stream.response;
666
+ response.on('end', () => {
667
+ // @ts-expect-error
668
+ Object.assign(stream.rawTrailers, response.rawTrailers);
669
+ // @ts-expect-error
670
+ Object.assign(stream.trailers, response.trailers);
671
+ // @ts-expect-error
672
+ stream.complete = response.complete;
673
+ });
674
+ for (const prop of properties) {
675
+ if (!(prop in stream)) {
676
+ // @ts-expect-error
677
+ stream[prop] = response[prop];
678
+ }
679
+ }
680
+ return stream;
681
+ }
682
+ //# sourceMappingURL=cheerio-crawler.js.map