@crawlee/browser 4.0.0-beta.98 → 4.0.0-rc.0
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/internals/browser-crawler.d.ts +164 -140
- package/internals/browser-crawler.js +96 -123
- package/internals/browser-launcher.d.ts +54 -17
- package/internals/browser-launcher.js +55 -10
- package/package.json +8 -8
|
@@ -1,14 +1,30 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AddRequestsBatchedResult, BasicCrawlerOptions, CrawlingContext, EnqueueLinksOptions, ErrorHandler, ExtractLinksOptions, GetUserDataFromRequest, LoadedRequest, Request, RequestHandler, RouterHandler } from '@crawlee/basic';
|
|
2
2
|
import { BasicCrawler, ContextPipeline } from '@crawlee/basic';
|
|
3
|
-
import type {
|
|
4
|
-
import type { Awaitable,
|
|
5
|
-
import
|
|
6
|
-
import type { ReadonlyDeep } from 'type-fest';
|
|
3
|
+
import type { CommonPage, CrawlerRemoteBrowserOptions } from '@crawlee/browser-pool';
|
|
4
|
+
import type { Awaitable, Dictionary, IBrowserPool } from '@crawlee/types';
|
|
5
|
+
import { z } from 'zod';
|
|
7
6
|
import type { BrowserLaunchContext } from './browser-launcher.js';
|
|
8
7
|
interface BaseResponse {
|
|
9
8
|
status(): number;
|
|
9
|
+
/** Optional because only Playwright and Puppeteer responses are guaranteed to carry it. */
|
|
10
|
+
headers?(): Record<string, string>;
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The type of a browser pool the crawler builds (and therefore owns) for itself. It's an {@link IBrowserPool} that
|
|
14
|
+
* additionally exposes `destroy()` — the crawler only ever tears down pools it created, which is why {@link IBrowserPool}
|
|
15
|
+
* itself intentionally omits `destroy`.
|
|
16
|
+
*/
|
|
17
|
+
export type OwnedBrowserPool<Page> = IBrowserPool<Page> & {
|
|
18
|
+
destroy: () => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Rejects options that exist only to configure the browser pool the crawler would have built for itself.
|
|
22
|
+
* Accepting them alongside a pre-built `browserPool` and quietly ignoring them is how `browserPoolOptions` grew
|
|
23
|
+
* into a second, half-working way of configuring the same pool.
|
|
24
|
+
*/
|
|
25
|
+
export declare function assertBrowserPoolNotConfigured(crawlerName: string, ignoredOptions: Dictionary): void;
|
|
26
|
+
export interface BrowserCrawlingContext<Page extends CommonPage = CommonPage, Response extends BaseResponse = BaseResponse, UserData extends Dictionary = any, // with default to Dictionary we cant use a typed router in untyped crawler
|
|
27
|
+
GoToOptions extends Dictionary = Dictionary> extends CrawlingContext<UserData> {
|
|
12
28
|
/**
|
|
13
29
|
* The browser page object where the web page is loaded and rendered.
|
|
14
30
|
*/
|
|
@@ -26,18 +42,30 @@ export interface BrowserCrawlingContext<Page extends CommonPage = CommonPage, Re
|
|
|
26
42
|
* object (or return `{ gotoOptions: ... }`) to influence the navigation.
|
|
27
43
|
*/
|
|
28
44
|
gotoOptions: GoToOptions;
|
|
45
|
+
/**
|
|
46
|
+
* Extracts URLs from the current page, without adding them to the request queue.
|
|
47
|
+
*/
|
|
48
|
+
extractLinks: (options?: ExtractLinksOptions) => Promise<string[]>;
|
|
29
49
|
/**
|
|
30
50
|
* Helper function for extracting URLs from the current page and adding them to the request queue.
|
|
31
51
|
*/
|
|
32
|
-
enqueueLinks: (options?: EnqueueLinksOptions) => Promise<
|
|
52
|
+
enqueueLinks: (options?: EnqueueLinksOptions) => Promise<AddRequestsBatchedResult>;
|
|
33
53
|
}
|
|
34
54
|
export type BrowserHook<Context = BrowserCrawlingContext, ContextExtension = {}> = (crawlingContext: Context & ContextExtension) => Awaitable<void | Partial<Context>>;
|
|
35
|
-
export interface BrowserCrawlerOptions<Page extends CommonPage = CommonPage, Response extends BaseResponse = BaseResponse, Context extends BrowserCrawlingContext<Page, Response
|
|
55
|
+
export interface BrowserCrawlerOptions<Page extends CommonPage = CommonPage, Response extends BaseResponse = BaseResponse, Context extends BrowserCrawlingContext<Page, Response> = BrowserCrawlingContext<Page, Response>, ContextExtension = Dictionary<never>, ExtendedContext extends Context = Context & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>, StatisticStateExtension extends object = {}> extends Omit<BasicCrawlerOptions<Context, ContextExtension, ExtendedContext, Routes, StatisticStateExtension>, 'requestHandler' | 'failedRequestHandler' | 'errorHandler'> {
|
|
36
56
|
launchContext?: BrowserLaunchContext<any, any>;
|
|
37
57
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
58
|
+
* The browser pool the crawler should serve its pages from. This is the single way to run a pool with
|
|
59
|
+
* non-default options: build one with the factory that matches your crawler
|
|
60
|
+
* ({@link playwrightBrowserPool}, {@link puppeteerBrowserPool}, {@link stagehandBrowserPool}) - it
|
|
61
|
+
* accepts every {@link BrowserPoolOptions|`BrowserPool` option} and supplies the correct browser plugin
|
|
62
|
+
* itself, so the pool can never mismatch the crawler.
|
|
63
|
+
*
|
|
64
|
+
* A pool passed in this way is borrowed, not owned: the crawler will not tear it down, which is what makes it
|
|
65
|
+
* shareable across crawlers. Since the crawler then builds nothing itself, the options that configure its own
|
|
66
|
+
* pool (`launchContext`, `headless`, `remoteBrowser`) are rejected rather than silently ignored.
|
|
67
|
+
*
|
|
68
|
+
* When omitted, the crawler builds - and tears down - a default pool for its own browser.
|
|
41
69
|
*/
|
|
42
70
|
browserPool?: IBrowserPool<Page>;
|
|
43
71
|
/**
|
|
@@ -48,8 +76,9 @@ export interface BrowserCrawlerOptions<Page extends CommonPage = CommonPage, Res
|
|
|
48
76
|
* crawler. Supply the connection details only: a static `endpoint` URL, a function returning one per launch,
|
|
49
77
|
* or a {@link RemoteBrowserProvider}.
|
|
50
78
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
79
|
+
* Cannot be combined with `browserPool`. To tune the pool wrapping the remote connection, or to share it
|
|
80
|
+
* across crawlers, build it with the remote factory for your crawler ({@link remotePlaywrightBrowserPool},
|
|
81
|
+
* {@link remotePuppeteerBrowserPool}, {@link remoteStagehandBrowserPool}) and pass it as `browserPool`.
|
|
53
82
|
*/
|
|
54
83
|
remoteBrowser?: CrawlerRemoteBrowserOptions;
|
|
55
84
|
/**
|
|
@@ -100,11 +129,6 @@ export interface BrowserCrawlerOptions<Page extends CommonPage = CommonPage, Res
|
|
|
100
129
|
* represents the last error thrown during processing of the request.
|
|
101
130
|
*/
|
|
102
131
|
failedRequestHandler?: ErrorHandler<CrawlingContext, ExtendedContext>;
|
|
103
|
-
/**
|
|
104
|
-
* Custom options passed to the underlying {@link BrowserPool} constructor.
|
|
105
|
-
* We can tweak those to fine-tune browser management.
|
|
106
|
-
*/
|
|
107
|
-
browserPoolOptions?: Partial<BrowserPoolOptions> & Partial<BrowserPoolHooks<__BrowserControllerReturn, __LaunchContextReturn>>;
|
|
108
132
|
/**
|
|
109
133
|
* Async functions that are sequentially evaluated before the navigation. Good for setting additional cookies
|
|
110
134
|
* or browser properties before navigation. The function receives the `crawlingContext`; the options object
|
|
@@ -169,11 +193,6 @@ export interface BrowserCrawlerOptions<Page extends CommonPage = CommonPage, Res
|
|
|
169
193
|
* Defines whether the cookies should be persisted for sessions. Enabled by default.
|
|
170
194
|
*/
|
|
171
195
|
saveResponseCookies?: boolean;
|
|
172
|
-
/**
|
|
173
|
-
* Whether to run browser in headless mode. Defaults to `true`.
|
|
174
|
-
* Can be also set via {@link Configuration}.
|
|
175
|
-
*/
|
|
176
|
-
headless?: boolean | 'new' | 'old';
|
|
177
196
|
/**
|
|
178
197
|
* Whether to ignore custom elements (and their #shadow-roots) when processing the page content via `parseWithCheerio` helper.
|
|
179
198
|
* By default, they are expanded automatically. Use this option to disable this behavior.
|
|
@@ -225,9 +244,8 @@ export interface BrowserCrawlerOptions<Page extends CommonPage = CommonPage, Res
|
|
|
225
244
|
*
|
|
226
245
|
* @category Crawlers
|
|
227
246
|
*/
|
|
228
|
-
export declare abstract class BrowserCrawler<Page extends CommonPage = CommonPage, Response extends BaseResponse = BaseResponse,
|
|
229
|
-
|
|
230
|
-
private browserPoolDep;
|
|
247
|
+
export declare abstract class BrowserCrawler<Page extends CommonPage = CommonPage, Response extends BaseResponse = BaseResponse, LaunchOptions extends Dictionary | undefined = Dictionary, Context extends BrowserCrawlingContext<Page, Response> = BrowserCrawlingContext<Page, Response>, ContextExtension = Dictionary<never>, ExtendedContext extends Context = Context & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>, StatisticStateExtension extends object = {}, GoToOptions extends Dictionary = Dictionary> extends BasicCrawler<Context, ContextExtension, ExtendedContext, Routes, StatisticStateExtension> {
|
|
248
|
+
#private;
|
|
231
249
|
/**
|
|
232
250
|
* A reference to the underlying browser pool that manages the crawler's browsers. Typed as
|
|
233
251
|
* {@link IBrowserPool} so custom implementations can be plugged in via the `browserPool` constructor option.
|
|
@@ -236,105 +254,131 @@ export declare abstract class BrowserCrawler<Page extends CommonPage = CommonPag
|
|
|
236
254
|
launchContext: BrowserLaunchContext<LaunchOptions, unknown>;
|
|
237
255
|
protected readonly ignoreShadowRoots: boolean;
|
|
238
256
|
protected readonly ignoreIframes: boolean;
|
|
239
|
-
private readonly navigationTimeoutMillis;
|
|
240
|
-
private readonly preNavigationHooks;
|
|
241
|
-
private readonly postNavigationHooks;
|
|
242
|
-
private readonly saveResponseCookies;
|
|
243
257
|
protected static optionsShape: {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
additionalHttpErrorStatusCodes: import("ow").ArrayPredicate<number>;
|
|
300
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
301
|
-
ignoreHttpErrorStatusCodes: import("ow").ArrayPredicate<number>;
|
|
302
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
303
|
-
blockedStatusCodes: import("ow").ArrayPredicate<number>;
|
|
304
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
305
|
-
retryOnBlocked: import("ow").BooleanPredicate & import("ow").BasePredicate<boolean | undefined>;
|
|
306
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
307
|
-
respectRobotsTxtFile: import("ow").AnyPredicate<boolean | object>;
|
|
308
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
309
|
-
onSkippedRequest: import("ow").Predicate<Function> & import("ow").BasePredicate<Function | undefined>;
|
|
310
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
311
|
-
httpClient: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
|
|
312
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
313
|
-
configuration: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
|
|
314
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
315
|
-
storageBackend: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
|
|
316
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
317
|
-
eventManager: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
|
|
318
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
319
|
-
logger: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
|
|
320
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
321
|
-
minConcurrency: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
|
|
322
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
323
|
-
maxConcurrency: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
|
|
324
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
325
|
-
maxRequestsPerMinute: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
|
|
326
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
327
|
-
keepAlive: import("ow").BooleanPredicate & import("ow").BasePredicate<boolean | undefined>;
|
|
328
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
329
|
-
statisticsOptions: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
|
|
330
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
331
|
-
id: import("ow").StringPredicate & import("ow").BasePredicate<string | undefined>;
|
|
258
|
+
navigationTimeoutSecs: z.ZodDefault<z.ZodCustom<number, number>>;
|
|
259
|
+
preNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
|
|
260
|
+
postNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
|
|
261
|
+
launchContext: z.ZodDefault<z.ZodCustom<Dictionary, Dictionary>>;
|
|
262
|
+
browserPool: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
263
|
+
browserPoolBuilder: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
264
|
+
remoteBrowser: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
265
|
+
saveResponseCookies: z.ZodDefault<z.ZodBoolean>;
|
|
266
|
+
proxyConfiguration: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
267
|
+
ignoreIframes: z.ZodDefault<z.ZodBoolean>;
|
|
268
|
+
ignoreShadowRoots: z.ZodDefault<z.ZodBoolean>;
|
|
269
|
+
contextPipelineBuilder: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
270
|
+
extendContext: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
271
|
+
requestList: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
272
|
+
requestQueue: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
273
|
+
requestManager: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
274
|
+
requestHandler: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
275
|
+
requestHandlerTimeoutSecs: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
276
|
+
errorHandler: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
277
|
+
failedRequestHandler: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
278
|
+
maxRequestRetries: z.ZodDefault<z.ZodCustom<number, number>>;
|
|
279
|
+
sameDomainDelaySecs: z.ZodDefault<z.ZodCustom<number, number>>;
|
|
280
|
+
maxRequestsPerCrawl: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
281
|
+
maxCrawlDepth: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
282
|
+
taskLoopOptions: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
283
|
+
concurrencySystem: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
284
|
+
sessionPool: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
285
|
+
statusMessageLoggingInterval: z.ZodDefault<z.ZodCustom<number, number>>;
|
|
286
|
+
statusMessageCallback: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
287
|
+
additionalHttpErrorStatusCodes: z.ZodDefault<z.ZodArray<z.ZodCustom<number, number>>>;
|
|
288
|
+
ignoreHttpErrorStatusCodes: z.ZodDefault<z.ZodArray<z.ZodCustom<number, number>>>;
|
|
289
|
+
blockedStatusCodes: z.ZodOptional<z.ZodArray<z.ZodCustom<number, number>>>;
|
|
290
|
+
retryOnBlocked: z.ZodDefault<z.ZodBoolean>;
|
|
291
|
+
respectRobotsTxtFile: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodCustom<Dictionary, Dictionary>]>>;
|
|
292
|
+
transactionalStorage: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
293
|
+
requestQueue: z.ZodOptional<z.ZodEnum<{
|
|
294
|
+
deferred: "deferred";
|
|
295
|
+
writeThrough: "writeThrough";
|
|
296
|
+
}>>;
|
|
297
|
+
}, z.core.$strict>]>>;
|
|
298
|
+
onSkippedRequest: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
299
|
+
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
300
|
+
httpClient: z.ZodOptional<z.ZodCustom<import("@crawlee/http-client").BaseHttpClient, import("@crawlee/http-client").BaseHttpClient>>;
|
|
301
|
+
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
302
|
+
configuration: z.ZodOptional<z.ZodCustom<import("@crawlee/basic").Configuration, import("@crawlee/basic").Configuration>>;
|
|
303
|
+
storageBackend: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
304
|
+
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
305
|
+
eventManager: z.ZodOptional<z.ZodCustom<import("@crawlee/basic").EventManager, import("@crawlee/basic").EventManager>>;
|
|
306
|
+
logger: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
307
|
+
minConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
308
|
+
maxConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
309
|
+
maxRequestsPerMinute: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
310
|
+
keepAlive: z.ZodOptional<z.ZodBoolean>;
|
|
311
|
+
statistics: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
312
|
+
id: z.ZodOptional<z.ZodString>;
|
|
332
313
|
};
|
|
314
|
+
protected static optionsSchema: z.ZodObject<{
|
|
315
|
+
navigationTimeoutSecs: z.ZodDefault<z.ZodCustom<number, number>>;
|
|
316
|
+
preNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
|
|
317
|
+
postNavigationHooks: z.ZodDefault<z.ZodCustom<unknown[], unknown[]>>;
|
|
318
|
+
launchContext: z.ZodDefault<z.ZodCustom<Dictionary, Dictionary>>;
|
|
319
|
+
browserPool: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
320
|
+
browserPoolBuilder: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
321
|
+
remoteBrowser: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
322
|
+
saveResponseCookies: z.ZodDefault<z.ZodBoolean>;
|
|
323
|
+
proxyConfiguration: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
324
|
+
ignoreIframes: z.ZodDefault<z.ZodBoolean>;
|
|
325
|
+
ignoreShadowRoots: z.ZodDefault<z.ZodBoolean>;
|
|
326
|
+
contextPipelineBuilder: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
327
|
+
extendContext: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
328
|
+
requestList: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
329
|
+
requestQueue: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
330
|
+
requestManager: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
331
|
+
requestHandler: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
332
|
+
requestHandlerTimeoutSecs: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
333
|
+
errorHandler: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
334
|
+
failedRequestHandler: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
335
|
+
maxRequestRetries: z.ZodDefault<z.ZodCustom<number, number>>;
|
|
336
|
+
sameDomainDelaySecs: z.ZodDefault<z.ZodCustom<number, number>>;
|
|
337
|
+
maxRequestsPerCrawl: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
338
|
+
maxCrawlDepth: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
339
|
+
taskLoopOptions: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
340
|
+
concurrencySystem: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
341
|
+
sessionPool: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
342
|
+
statusMessageLoggingInterval: z.ZodDefault<z.ZodCustom<number, number>>;
|
|
343
|
+
statusMessageCallback: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
344
|
+
additionalHttpErrorStatusCodes: z.ZodDefault<z.ZodArray<z.ZodCustom<number, number>>>;
|
|
345
|
+
ignoreHttpErrorStatusCodes: z.ZodDefault<z.ZodArray<z.ZodCustom<number, number>>>;
|
|
346
|
+
blockedStatusCodes: z.ZodOptional<z.ZodArray<z.ZodCustom<number, number>>>;
|
|
347
|
+
retryOnBlocked: z.ZodDefault<z.ZodBoolean>;
|
|
348
|
+
respectRobotsTxtFile: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodCustom<Dictionary, Dictionary>]>>;
|
|
349
|
+
transactionalStorage: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
350
|
+
requestQueue: z.ZodOptional<z.ZodEnum<{
|
|
351
|
+
deferred: "deferred";
|
|
352
|
+
writeThrough: "writeThrough";
|
|
353
|
+
}>>;
|
|
354
|
+
}, z.core.$strict>]>>;
|
|
355
|
+
onSkippedRequest: z.ZodOptional<z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>>;
|
|
356
|
+
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
357
|
+
httpClient: z.ZodOptional<z.ZodCustom<import("@crawlee/http-client").BaseHttpClient, import("@crawlee/http-client").BaseHttpClient>>;
|
|
358
|
+
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
359
|
+
configuration: z.ZodOptional<z.ZodCustom<import("@crawlee/basic").Configuration, import("@crawlee/basic").Configuration>>;
|
|
360
|
+
storageBackend: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
361
|
+
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
362
|
+
eventManager: z.ZodOptional<z.ZodCustom<import("@crawlee/basic").EventManager, import("@crawlee/basic").EventManager>>;
|
|
363
|
+
logger: z.ZodOptional<z.ZodType<Dictionary<any>, unknown, z.core.$ZodTypeInternals<Dictionary<any>, unknown>>>;
|
|
364
|
+
minConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
365
|
+
maxConcurrency: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
366
|
+
maxRequestsPerMinute: z.ZodOptional<z.ZodCustom<number, number>>;
|
|
367
|
+
keepAlive: z.ZodOptional<z.ZodBoolean>;
|
|
368
|
+
statistics: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
369
|
+
id: z.ZodOptional<z.ZodString>;
|
|
370
|
+
}, z.core.$strict>;
|
|
333
371
|
/**
|
|
334
372
|
* All `BrowserCrawler` parameters are passed via an options object.
|
|
335
373
|
*/
|
|
336
|
-
protected constructor(options: BrowserCrawlerOptions<Page, Response, Context, ContextExtension, ExtendedContext> & {
|
|
374
|
+
protected constructor(options: BrowserCrawlerOptions<Page, Response, Context, ContextExtension, ExtendedContext, Routes, StatisticStateExtension> & {
|
|
337
375
|
contextPipelineBuilder: () => ContextPipeline<CrawlingContext, Context>;
|
|
376
|
+
/**
|
|
377
|
+
* Builds the pool the crawler owns, used only when the user injected no `browserPool`. Supplied by the
|
|
378
|
+
* concrete crawler, which is the only place that knows which browser plugin to run - that is also why
|
|
379
|
+
* `remoteBrowser` is handed to it rather than acted upon here.
|
|
380
|
+
*/
|
|
381
|
+
browserPoolBuilder: (remoteBrowser?: CrawlerRemoteBrowserOptions) => OwnedBrowserPool<Page>;
|
|
338
382
|
});
|
|
339
383
|
protected getNavigationTimeoutMillis(): number;
|
|
340
384
|
protected buildContextPipeline(): ContextPipeline<CrawlingContext, BrowserCrawlingContext<Page, Response, Dictionary>>;
|
|
@@ -364,7 +408,7 @@ export declare abstract class BrowserCrawler<Page extends CommonPage = CommonPag
|
|
|
364
408
|
* Transforms proxy-related errors to `SessionError`.
|
|
365
409
|
*/
|
|
366
410
|
private throwIfProxyError;
|
|
367
|
-
protected abstract
|
|
411
|
+
protected abstract navigationHandler(crawlingContext: BrowserCrawlingContext<Page, Response>, gotoOptions: GoToOptions): Promise<Context['response'] | null | undefined>;
|
|
368
412
|
private processResponse;
|
|
369
413
|
/**
|
|
370
414
|
* Function for cleaning up after all requests are processed.
|
|
@@ -372,26 +416,6 @@ export declare abstract class BrowserCrawler<Page extends CommonPage = CommonPag
|
|
|
372
416
|
*/
|
|
373
417
|
teardown(): Promise<void>;
|
|
374
418
|
}
|
|
375
|
-
/** @internal */
|
|
376
|
-
interface EnqueueLinksInternalOptions {
|
|
377
|
-
options?: ReadonlyDeep<Omit<EnqueueLinksOptions, 'requestManager'>> & Pick<EnqueueLinksOptions, 'requestManager'>;
|
|
378
|
-
page: CommonPage;
|
|
379
|
-
requestManager: IRequestManager;
|
|
380
|
-
robotsTxtFile?: RobotsTxtFile;
|
|
381
|
-
onSkippedRequest?: SkippedRequestCallback;
|
|
382
|
-
originalRequestUrl: string;
|
|
383
|
-
finalRequestUrl?: string;
|
|
384
|
-
}
|
|
385
|
-
/** @internal */
|
|
386
|
-
interface BoundEnqueueLinksInternalOptions {
|
|
387
|
-
enqueueLinks: BasicCrawlingContext['enqueueLinks'];
|
|
388
|
-
options?: ReadonlyDeep<Omit<EnqueueLinksOptions, 'requestManager'>> & Pick<EnqueueLinksOptions, 'requestManager'>;
|
|
389
|
-
originalRequestUrl: string;
|
|
390
|
-
finalRequestUrl?: string;
|
|
391
|
-
page: CommonPage;
|
|
392
|
-
}
|
|
393
|
-
/** @internal */
|
|
394
|
-
export declare function browserCrawlerEnqueueLinks(options: EnqueueLinksInternalOptions | BoundEnqueueLinksInternalOptions): Promise<unknown>;
|
|
395
419
|
/**
|
|
396
420
|
* Extracts URLs from a given page.
|
|
397
421
|
* @ignore
|
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie, EnqueueStrategy, NavigationSkippedError, OwnedOrInjected, parseArgument, remainingNavigationWindowMillis, RequestState, RequestThrottledError, resolveBaseUrlForEnqueueLinksFiltering, schemas, SessionError, toughCookieToBrowserPoolCookie, tryAbsoluteURL, validators, } from '@crawlee/basic';
|
|
2
|
+
import { CLOUDFLARE_RETRY_CSS_SELECTORS, RETRY_CSS_SELECTORS } from '@crawlee/utils/internal';
|
|
3
|
+
import { sleep } from '@crawlee/utils';
|
|
4
|
+
import { z } from 'zod';
|
|
5
5
|
import { addTimeoutToPromise, TimeoutError, tryCancel } from '@apify/timeout';
|
|
6
|
+
/**
|
|
7
|
+
* Rejects options that exist only to configure the browser pool the crawler would have built for itself.
|
|
8
|
+
* Accepting them alongside a pre-built `browserPool` and quietly ignoring them is how `browserPoolOptions` grew
|
|
9
|
+
* into a second, half-working way of configuring the same pool.
|
|
10
|
+
*/
|
|
11
|
+
export function assertBrowserPoolNotConfigured(crawlerName, ignoredOptions) {
|
|
12
|
+
const names = Object.keys(ignoredOptions).filter((name) => ignoredOptions[name] !== undefined);
|
|
13
|
+
if (names.length === 0) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
throw new Error(`${crawlerName}: ${names.map((name) => `\`${name}\``).join(', ')} cannot be combined with \`browserPool\`, ` +
|
|
17
|
+
`${names.length > 1 ? 'they configure' : 'it configures'} the browser pool the crawler would build for ` +
|
|
18
|
+
'itself. Configure the pool you pass in instead.');
|
|
19
|
+
}
|
|
6
20
|
const COOKIES_BEFORE_HOOKS = Symbol('cookiesBeforeHooks');
|
|
7
21
|
const readContextField = (ctx, key) => ctx[key];
|
|
8
22
|
/**
|
|
@@ -58,40 +72,46 @@ function isNavigationTimeoutError(error) {
|
|
|
58
72
|
*/
|
|
59
73
|
export class BrowserCrawler extends BasicCrawler {
|
|
60
74
|
/** Backs the {@link BrowserCrawler.browserPool|`browserPool`} getter. */
|
|
61
|
-
browserPoolDep;
|
|
75
|
+
#browserPoolDep;
|
|
62
76
|
/**
|
|
63
77
|
* A reference to the underlying browser pool that manages the crawler's browsers. Typed as
|
|
64
78
|
* {@link IBrowserPool} so custom implementations can be plugged in via the `browserPool` constructor option.
|
|
65
79
|
*/
|
|
66
80
|
get browserPool() {
|
|
67
|
-
return this
|
|
81
|
+
return this.#browserPoolDep.value;
|
|
68
82
|
}
|
|
69
83
|
launchContext;
|
|
70
84
|
ignoreShadowRoots;
|
|
71
85
|
ignoreIframes;
|
|
72
|
-
navigationTimeoutMillis;
|
|
73
|
-
preNavigationHooks;
|
|
74
|
-
postNavigationHooks;
|
|
75
|
-
saveResponseCookies;
|
|
86
|
+
#navigationTimeoutMillis;
|
|
87
|
+
#preNavigationHooks;
|
|
88
|
+
#postNavigationHooks;
|
|
89
|
+
#saveResponseCookies;
|
|
76
90
|
static optionsShape = {
|
|
77
91
|
...BasicCrawler.optionsShape,
|
|
78
|
-
navigationTimeoutSecs:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
navigationTimeoutSecs: schemas.anyNumber
|
|
93
|
+
.refine((value) => value > 0, 'Expected a number greater than 0')
|
|
94
|
+
.default(60),
|
|
95
|
+
preNavigationHooks: schemas.anyArray.default(() => []),
|
|
96
|
+
postNavigationHooks: schemas.anyArray.default(() => []),
|
|
97
|
+
launchContext: schemas.anyObject.default(() => ({})),
|
|
98
|
+
browserPool: validators.browserPool.optional(),
|
|
99
|
+
browserPoolBuilder: schemas.anyFunction.optional(),
|
|
100
|
+
remoteBrowser: schemas.anyObject.optional(),
|
|
101
|
+
saveResponseCookies: z.boolean().default(true),
|
|
102
|
+
proxyConfiguration: validators.proxyConfiguration.optional(),
|
|
103
|
+
ignoreIframes: z.boolean().default(false),
|
|
104
|
+
ignoreShadowRoots: z.boolean().default(false),
|
|
88
105
|
};
|
|
106
|
+
static optionsSchema = z.strictObject(BrowserCrawler.optionsShape);
|
|
89
107
|
/**
|
|
90
108
|
* All `BrowserCrawler` parameters are passed via an options object.
|
|
91
109
|
*/
|
|
92
110
|
constructor(options) {
|
|
93
|
-
|
|
94
|
-
|
|
111
|
+
const { navigationTimeoutSecs, saveResponseCookies, launchContext, browserPool, remoteBrowser, preNavigationHooks, postNavigationHooks, ignoreIframes, ignoreShadowRoots, contextPipelineBuilder, browserPoolBuilder, extendContext, ...basicCrawlerOptions } = parseArgument(options, BrowserCrawler.optionsSchema, 'BrowserCrawlerOptions');
|
|
112
|
+
if (browserPool) {
|
|
113
|
+
assertBrowserPoolNotConfigured(new.target.name, { remoteBrowser });
|
|
114
|
+
}
|
|
95
115
|
const skipGuard = (action) => ({
|
|
96
116
|
action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
|
|
97
117
|
});
|
|
@@ -103,18 +123,18 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
103
123
|
// hook eats into the same window the navigation uses. The navigation itself is bounded by
|
|
104
124
|
// capping its `gotoOptions.timeout` to the remaining budget.
|
|
105
125
|
const windowGuard = (step) => skipGuard(async (ctx) => {
|
|
106
|
-
const remaining = remainingNavigationWindowMillis(ctx, this
|
|
126
|
+
const remaining = remainingNavigationWindowMillis(ctx, this.#navigationTimeoutMillis);
|
|
107
127
|
if (remaining <= 0) {
|
|
108
|
-
throw new TimeoutError(`Navigation timed out after ${this
|
|
128
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
109
129
|
}
|
|
110
|
-
return addTimeoutToPromise(async () => step(ctx), remaining, `Navigation timed out after ${this
|
|
130
|
+
return addTimeoutToPromise(async () => step(ctx), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
111
131
|
});
|
|
112
132
|
let pipeline = contextPipelineBuilder().compose({ action: this.prepareNavigation.bind(this) });
|
|
113
|
-
for (const hook of this
|
|
133
|
+
for (const hook of this.#preNavigationHooks) {
|
|
114
134
|
pipeline = pipeline.compose(windowGuard(hook));
|
|
115
135
|
}
|
|
116
136
|
pipeline = pipeline.compose(skipGuard(this.navigate.bind(this)));
|
|
117
|
-
for (const hook of this
|
|
137
|
+
for (const hook of this.#postNavigationHooks) {
|
|
118
138
|
pipeline = pipeline.compose(windowGuard(hook));
|
|
119
139
|
}
|
|
120
140
|
return pipeline
|
|
@@ -125,50 +145,19 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
125
145
|
extendContext,
|
|
126
146
|
});
|
|
127
147
|
this.launchContext = launchContext;
|
|
128
|
-
this
|
|
148
|
+
this.#navigationTimeoutMillis = navigationTimeoutSecs * 1000;
|
|
129
149
|
// The public option hooks are extension-aware; internal storage uses the base context type
|
|
130
150
|
// (the pipeline composes hooks against the concrete context, which does not statically carry
|
|
131
151
|
// `ContextExtension`). The extension members are present at runtime regardless.
|
|
132
|
-
this
|
|
133
|
-
this
|
|
152
|
+
this.#preNavigationHooks = preNavigationHooks;
|
|
153
|
+
this.#postNavigationHooks = postNavigationHooks;
|
|
134
154
|
this.ignoreIframes = ignoreIframes;
|
|
135
155
|
this.ignoreShadowRoots = ignoreShadowRoots;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
this.launchContext.launchOptions.headless = headless;
|
|
139
|
-
}
|
|
140
|
-
this.saveResponseCookies = saveResponseCookies;
|
|
141
|
-
// `browserPool` wins over `remoteBrowser` — a passed-in pool is used as-is (borrowed), the sugar is ignored.
|
|
142
|
-
// The default is only built when no pool was injected, so all the option/launchContext fiddling below stays
|
|
143
|
-
// inside the factory.
|
|
144
|
-
this.browserPoolDep = OwnedOrInjected.resolve(browserPool, () => {
|
|
145
|
-
const resolvedBrowserPoolOptions = browserPoolOptions ?? {};
|
|
146
|
-
if (launchContext?.userAgent) {
|
|
147
|
-
if (resolvedBrowserPoolOptions.useFingerprints)
|
|
148
|
-
this.log.info('Custom user agent provided, disabling automatic browser fingerprint injection!');
|
|
149
|
-
resolvedBrowserPoolOptions.useFingerprints = false;
|
|
150
|
-
}
|
|
151
|
-
if (remoteBrowser) {
|
|
152
|
-
// The crawler already built the right plugin for its browser — hand it to a RemoteBrowserPool so the
|
|
153
|
-
// remote connection is always for the matching browser (no plugin to construct, no way to mismatch).
|
|
154
|
-
const { browserPlugins, ...remoteBrowserPoolOptions } = resolvedBrowserPoolOptions;
|
|
155
|
-
return new RemoteBrowserPool({
|
|
156
|
-
browserPlugins: browserPlugins,
|
|
157
|
-
...remoteBrowser,
|
|
158
|
-
browserPoolOptions: remoteBrowserPoolOptions,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
// Double cast: `BrowserPool` implements `IBrowserPool<PageReturn>`, where `PageReturn` is derived from the
|
|
162
|
-
// plugin/controller generics and doesn't overlap with the crawler's free `Page` type param, so TS won't
|
|
163
|
-
// narrow it directly. The concrete pool does satisfy the `Page`/`destroy` contract at runtime — this is the
|
|
164
|
-
// long-standing `Page` variance gap, not a `destroy`-related hole.
|
|
165
|
-
return new BrowserPool({
|
|
166
|
-
...resolvedBrowserPoolOptions,
|
|
167
|
-
});
|
|
168
|
-
});
|
|
156
|
+
this.#saveResponseCookies = saveResponseCookies;
|
|
157
|
+
this.#browserPoolDep = OwnedOrInjected.resolve(browserPool, () => browserPoolBuilder(remoteBrowser));
|
|
169
158
|
}
|
|
170
159
|
getNavigationTimeoutMillis() {
|
|
171
|
-
return this
|
|
160
|
+
return this.#navigationTimeoutMillis;
|
|
172
161
|
}
|
|
173
162
|
buildContextPipeline() {
|
|
174
163
|
return ContextPipeline.create().compose({
|
|
@@ -217,7 +206,10 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
217
206
|
session: crawlingContext.session,
|
|
218
207
|
});
|
|
219
208
|
tryCancel();
|
|
220
|
-
const
|
|
209
|
+
const addRequests = crawlingContext.addRequests;
|
|
210
|
+
const extractLinks = async (options) => {
|
|
211
|
+
return extractUrlsFromPage(page, options?.selector ?? 'a', options?.baseUrl ?? crawlingContext.request.loadedUrl ?? crawlingContext.request.url);
|
|
212
|
+
};
|
|
221
213
|
return {
|
|
222
214
|
page,
|
|
223
215
|
get response() {
|
|
@@ -226,20 +218,20 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
226
218
|
get gotoOptions() {
|
|
227
219
|
throw new Error('The `gotoOptions` property is not available until `prepareNavigation` runs.');
|
|
228
220
|
},
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
limit: await this.calculateEnqueuedRequestLimit(enqueueOptions?.limit),
|
|
234
|
-
},
|
|
235
|
-
page,
|
|
236
|
-
requestManager: await this.getRequestManager(),
|
|
237
|
-
robotsTxtFile: await this.getRobotsTxtFileForUrl(crawlingContext.request.url),
|
|
238
|
-
onSkippedRequest: this.handleSkippedRequest,
|
|
239
|
-
originalRequestUrl: crawlingContext.request.url,
|
|
221
|
+
extractLinks,
|
|
222
|
+
enqueueLinks: async (options = {}) => {
|
|
223
|
+
const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
|
|
224
|
+
enqueueStrategy: options.strategy,
|
|
240
225
|
finalRequestUrl: crawlingContext.request.loadedUrl,
|
|
241
|
-
|
|
242
|
-
|
|
226
|
+
originalRequestUrl: crawlingContext.request.url,
|
|
227
|
+
userProvidedBaseUrl: options.baseUrl,
|
|
228
|
+
});
|
|
229
|
+
const urls = await extractLinks(options);
|
|
230
|
+
return addRequests(urls, {
|
|
231
|
+
...options,
|
|
232
|
+
baseUrl,
|
|
233
|
+
strategy: options.strategy ?? EnqueueStrategy.SameHostname,
|
|
234
|
+
});
|
|
243
235
|
},
|
|
244
236
|
};
|
|
245
237
|
}
|
|
@@ -263,31 +255,31 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
263
255
|
return {
|
|
264
256
|
// Default to the full navigation timeout so a pre-navigation hook can read it; `navigate` narrows it
|
|
265
257
|
// to the remaining shared window unless a hook overrode it (see there).
|
|
266
|
-
gotoOptions: { timeout: this
|
|
267
|
-
[COOKIES_BEFORE_HOOKS]: this.
|
|
258
|
+
gotoOptions: { timeout: this.#navigationTimeoutMillis },
|
|
259
|
+
[COOKIES_BEFORE_HOOKS]: this.getCookieHeaderFromRequest(crawlingContext.request),
|
|
268
260
|
};
|
|
269
261
|
}
|
|
270
262
|
async navigate(crawlingContext) {
|
|
271
263
|
tryCancel();
|
|
272
264
|
const gotoOptions = crawlingContext.gotoOptions;
|
|
273
|
-
const remaining = remainingNavigationWindowMillis(crawlingContext, this
|
|
265
|
+
const remaining = remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis);
|
|
274
266
|
if (remaining <= 0) {
|
|
275
|
-
throw new TimeoutError(`Navigation timed out after ${this
|
|
267
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
276
268
|
}
|
|
277
269
|
// If a hook left the default `navigationTimeoutMillis` in place, bound the goto to whatever is left of the
|
|
278
270
|
// shared navigation window. If it overrode the value - including `0`, Playwright's "no timeout" - honour
|
|
279
271
|
// that verbatim as the goto's own timeout. The driver enforces this natively (so a timed-out goto is
|
|
280
272
|
// aborted, not left lingering) and `handleNavigationTimeout` turns its error into our own message.
|
|
281
273
|
const gotoTimeout = gotoOptions;
|
|
282
|
-
if (gotoTimeout.timeout === this
|
|
274
|
+
if (gotoTimeout.timeout === this.#navigationTimeoutMillis) {
|
|
283
275
|
gotoTimeout.timeout = remaining;
|
|
284
276
|
}
|
|
285
277
|
const cookiesBeforeHooks = readContextField(crawlingContext, COOKIES_BEFORE_HOOKS);
|
|
286
|
-
const cookiesAfterHooks = this.
|
|
278
|
+
const cookiesAfterHooks = this.getCookieHeaderFromRequest(crawlingContext.request);
|
|
287
279
|
await this.applyCookies(crawlingContext, cookiesBeforeHooks, cookiesAfterHooks);
|
|
288
280
|
let response;
|
|
289
281
|
try {
|
|
290
|
-
response = (await this.
|
|
282
|
+
response = (await this.navigationHandler(crawlingContext, gotoOptions)) ?? undefined;
|
|
291
283
|
}
|
|
292
284
|
catch (error) {
|
|
293
285
|
await this.handleNavigationTimeout(crawlingContext, error);
|
|
@@ -320,7 +312,7 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
320
312
|
* Copies cookies from the live browser page into the session cookie jar.
|
|
321
313
|
*/
|
|
322
314
|
async persistCookiesFromPage(crawlingContext) {
|
|
323
|
-
if (!this
|
|
315
|
+
if (!this.#saveResponseCookies || !crawlingContext.session) {
|
|
324
316
|
return;
|
|
325
317
|
}
|
|
326
318
|
const { cookies } = await this.browserPool.extractPageState(crawlingContext.page);
|
|
@@ -329,7 +321,7 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
329
321
|
const url = (await crawlingContext.page.url()) || crawlingContext.request.loadedUrl || crawlingContext.request.url;
|
|
330
322
|
for (const cookie of cookies) {
|
|
331
323
|
try {
|
|
332
|
-
crawlingContext.session.cookieJar.
|
|
324
|
+
await crawlingContext.session.cookieJar.setCookie(browserPoolCookieToToughCookie(cookie), url, {
|
|
333
325
|
ignoreError: false,
|
|
334
326
|
});
|
|
335
327
|
}
|
|
@@ -370,7 +362,9 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
370
362
|
return {};
|
|
371
363
|
}
|
|
372
364
|
async applyCookies({ session, request, page }, preHooksCookies, postHooksCookies) {
|
|
373
|
-
const sessionCookie = session
|
|
365
|
+
const sessionCookie = session
|
|
366
|
+
? (await session.cookieJar.getCookies(request.url)).map(toughCookieToBrowserPoolCookie)
|
|
367
|
+
: [];
|
|
374
368
|
const parsedPreHooksCookies = preHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
|
|
375
369
|
const parsedPostHooksCookies = postHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
|
|
376
370
|
const cookies = [...sessionCookie, ...parsedPreHooksCookies, ...parsedPostHooksCookies]
|
|
@@ -390,7 +384,7 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
390
384
|
session?.markBad();
|
|
391
385
|
// The driver was handed the remaining window (usually shorter than `navigationTimeoutSecs` once the
|
|
392
386
|
// hooks have run), so it names that value in its own error; report the configured window instead.
|
|
393
|
-
throw new TimeoutError(`Navigation timed out after ${this
|
|
387
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
394
388
|
}
|
|
395
389
|
}
|
|
396
390
|
/**
|
|
@@ -398,14 +392,23 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
398
392
|
*/
|
|
399
393
|
throwIfProxyError(error) {
|
|
400
394
|
if (this.isProxyError(error)) {
|
|
401
|
-
throw new SessionError(this.
|
|
395
|
+
throw new SessionError(this.getMessageFromError(error));
|
|
402
396
|
}
|
|
403
397
|
}
|
|
404
398
|
async processResponse(response, crawlingContext) {
|
|
405
399
|
const { session, request, page } = crawlingContext;
|
|
406
400
|
if (typeof response === 'object' && typeof response.status === 'function') {
|
|
407
401
|
const status = response.status();
|
|
408
|
-
this.
|
|
402
|
+
this.statistics.registerStatusCode(status);
|
|
403
|
+
// Ahead of the error-status throw below: a 429 the user opted into treating as an error is still a
|
|
404
|
+
// rate limit the domain should back off from.
|
|
405
|
+
if (status === 429) {
|
|
406
|
+
// Both drivers lower-case header names and join duplicates, so a plain lookup is enough.
|
|
407
|
+
const retryAfter = response.headers?.()['retry-after'];
|
|
408
|
+
if (this.recordDomainRateLimit(crawlingContext.request.url, retryAfter)) {
|
|
409
|
+
throw new RequestThrottledError(`${crawlingContext.request.url} responded with 429.`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
409
412
|
if (this.isErrorStatusCode(status)) {
|
|
410
413
|
if (this.additionalHttpErrorStatusCodes.has(status)) {
|
|
411
414
|
throw new Error(`${status} - Error status code was set by user.`);
|
|
@@ -415,7 +418,7 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
415
418
|
}
|
|
416
419
|
if (this.sessionPool && response && session) {
|
|
417
420
|
if (typeof response === 'object' && typeof response.status === 'function') {
|
|
418
|
-
this.
|
|
421
|
+
this.throwOnBlockedRequest(response.status());
|
|
419
422
|
}
|
|
420
423
|
else {
|
|
421
424
|
this.log.debug('Got a malformed Browser response.', { request, response });
|
|
@@ -428,40 +431,10 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
428
431
|
* @ignore
|
|
429
432
|
*/
|
|
430
433
|
async teardown() {
|
|
431
|
-
await this
|
|
434
|
+
await this.#browserPoolDep.ifOwned((pool) => pool.destroy());
|
|
432
435
|
await super.teardown();
|
|
433
436
|
}
|
|
434
437
|
}
|
|
435
|
-
/** @internal */
|
|
436
|
-
function containsEnqueueLinks(options) {
|
|
437
|
-
return !!options.enqueueLinks;
|
|
438
|
-
}
|
|
439
|
-
/** @internal */
|
|
440
|
-
export async function browserCrawlerEnqueueLinks(options) {
|
|
441
|
-
const { options: enqueueLinksOptions, finalRequestUrl, originalRequestUrl, page } = options;
|
|
442
|
-
const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
|
|
443
|
-
enqueueStrategy: enqueueLinksOptions?.strategy,
|
|
444
|
-
finalRequestUrl,
|
|
445
|
-
originalRequestUrl,
|
|
446
|
-
userProvidedBaseUrl: enqueueLinksOptions?.baseUrl,
|
|
447
|
-
});
|
|
448
|
-
const urls = await extractUrlsFromPage(page, enqueueLinksOptions?.selector ?? 'a', enqueueLinksOptions?.baseUrl ?? finalRequestUrl ?? originalRequestUrl);
|
|
449
|
-
if (containsEnqueueLinks(options)) {
|
|
450
|
-
return options.enqueueLinks({
|
|
451
|
-
urls,
|
|
452
|
-
baseUrl,
|
|
453
|
-
...enqueueLinksOptions,
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
return enqueueLinks({
|
|
457
|
-
requestManager: options.requestManager,
|
|
458
|
-
robotsTxtFile: options.robotsTxtFile,
|
|
459
|
-
onSkippedRequest: options.onSkippedRequest,
|
|
460
|
-
urls,
|
|
461
|
-
baseUrl,
|
|
462
|
-
...enqueueLinksOptions,
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
438
|
/**
|
|
466
439
|
* Extracts URLs from a given page.
|
|
467
440
|
* @ignore
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Configuration } from '@crawlee/basic';
|
|
2
|
-
import type { BrowserPlugin, BrowserPluginOptions } from '@crawlee/browser-pool';
|
|
2
|
+
import type { BrowserPlugin, BrowserPluginOptions, BrowserPoolHooks, BrowserPoolOptions, RemoteBrowserPoolOptions } from '@crawlee/browser-pool';
|
|
3
|
+
import { BrowserPool, RemoteBrowserPool } from '@crawlee/browser-pool';
|
|
3
4
|
import type { Constructor, Dictionary } from '@crawlee/types';
|
|
5
|
+
import { z } from 'zod';
|
|
4
6
|
export interface BrowserLaunchContext<TOptions, Launcher> extends BrowserPluginOptions<TOptions> {
|
|
5
7
|
/**
|
|
6
8
|
* URL to an HTTP proxy server. It must define the port number,
|
|
@@ -64,6 +66,19 @@ export interface BrowserLaunchContext<TOptions, Launcher> extends BrowserPluginO
|
|
|
64
66
|
*/
|
|
65
67
|
launcher?: Launcher;
|
|
66
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The {@link BrowserPool} options a launcher-built pool accepts: everything the pool itself takes except
|
|
71
|
+
* `browserPlugins`, which the launcher derives from its launch context. The hooks are deliberately unconstrained -
|
|
72
|
+
* the browser they run against is only known to the concrete `*BrowserPool()` factory, which is where the
|
|
73
|
+
* caller-facing types are pinned down.
|
|
74
|
+
*/
|
|
75
|
+
export type LauncherBrowserPoolOptions = Omit<BrowserPoolOptions, 'browserPlugins'> & {
|
|
76
|
+
[Hook in keyof BrowserPoolHooks<any, any, any>]?: readonly ((...args: any[]) => unknown)[];
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* The {@link RemoteBrowserPool} counterpart of {@link LauncherBrowserPoolOptions}.
|
|
80
|
+
*/
|
|
81
|
+
export type LauncherRemoteBrowserPoolOptions = Omit<RemoteBrowserPoolOptions, 'browserPlugins'>;
|
|
67
82
|
/**
|
|
68
83
|
* Abstract class for creating browser launchers, such as `PlaywrightLauncher` and `PuppeteerLauncher`.
|
|
69
84
|
* @ignore
|
|
@@ -78,23 +93,25 @@ export declare abstract class BrowserLauncher<Plugin extends BrowserPlugin, Laun
|
|
|
78
93
|
Plugin: T;
|
|
79
94
|
userAgent?: string;
|
|
80
95
|
protected static optionsShape: {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
90
|
-
ignoreProxyCertificate: import("ow").BooleanPredicate & import("ow").BasePredicate<boolean | undefined>;
|
|
91
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
92
|
-
userDataDir: import("ow").StringPredicate & import("ow").BasePredicate<string | undefined>;
|
|
93
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
94
|
-
launchOptions: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
|
|
95
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
96
|
-
userAgent: import("ow").StringPredicate & import("ow").BasePredicate<string | undefined>;
|
|
96
|
+
proxyUrl: z.ZodOptional<z.ZodURL>;
|
|
97
|
+
useChrome: z.ZodOptional<z.ZodBoolean>;
|
|
98
|
+
useIncognitoPages: z.ZodOptional<z.ZodBoolean>;
|
|
99
|
+
browserPerProxy: z.ZodOptional<z.ZodBoolean>;
|
|
100
|
+
ignoreProxyCertificate: z.ZodOptional<z.ZodBoolean>;
|
|
101
|
+
userDataDir: z.ZodOptional<z.ZodString>;
|
|
102
|
+
launchOptions: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
103
|
+
userAgent: z.ZodOptional<z.ZodString>;
|
|
97
104
|
};
|
|
105
|
+
protected static optionsSchema: z.ZodObject<{
|
|
106
|
+
proxyUrl: z.ZodOptional<z.ZodURL>;
|
|
107
|
+
useChrome: z.ZodOptional<z.ZodBoolean>;
|
|
108
|
+
useIncognitoPages: z.ZodOptional<z.ZodBoolean>;
|
|
109
|
+
browserPerProxy: z.ZodOptional<z.ZodBoolean>;
|
|
110
|
+
ignoreProxyCertificate: z.ZodOptional<z.ZodBoolean>;
|
|
111
|
+
userDataDir: z.ZodOptional<z.ZodString>;
|
|
112
|
+
launchOptions: z.ZodOptional<z.ZodCustom<Dictionary, Dictionary>>;
|
|
113
|
+
userAgent: z.ZodOptional<z.ZodString>;
|
|
114
|
+
}, z.core.$strict>;
|
|
98
115
|
static requireLauncherOrThrow<T>(launcher: string, apifyImageName: string): T;
|
|
99
116
|
/**
|
|
100
117
|
* All `BrowserLauncher` parameters are passed via an launchContext object.
|
|
@@ -104,6 +121,26 @@ export declare abstract class BrowserLauncher<Plugin extends BrowserPlugin, Laun
|
|
|
104
121
|
* @ignore
|
|
105
122
|
*/
|
|
106
123
|
createBrowserPlugin(): Plugin;
|
|
124
|
+
/**
|
|
125
|
+
* Builds a {@link BrowserPool} running a single plugin for this launcher's browser. Shared body of the
|
|
126
|
+
* per-library `*BrowserPool()` factories, which exist so that configuring a pool never requires assembling
|
|
127
|
+
* a plugin by hand — and therefore never lets the plugin drift away from the crawler it is used with.
|
|
128
|
+
* @internal
|
|
129
|
+
*/
|
|
130
|
+
createBrowserPool(options?: LauncherBrowserPoolOptions): BrowserPool<{
|
|
131
|
+
browserPlugins: [Plugin];
|
|
132
|
+
}, [Plugin]>;
|
|
133
|
+
/**
|
|
134
|
+
* The {@link RemoteBrowserPool} counterpart of {@link BrowserLauncher.createBrowserPool}: the launcher
|
|
135
|
+
* supplies the plugin, the caller supplies the remote connection details.
|
|
136
|
+
* @internal
|
|
137
|
+
*/
|
|
138
|
+
createRemoteBrowserPool<Page>(options: LauncherRemoteBrowserPoolOptions): RemoteBrowserPool<Page>;
|
|
139
|
+
/**
|
|
140
|
+
* A custom `userAgent` and Crawlee's fingerprint injection would both write the same headers, so an
|
|
141
|
+
* explicitly requested user agent wins.
|
|
142
|
+
*/
|
|
143
|
+
private resolveFingerprinting;
|
|
107
144
|
/**
|
|
108
145
|
* Launches a browser instance based on the plugin.
|
|
109
146
|
* @returns Browser instance.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
import { Configuration } from '@crawlee/basic';
|
|
5
|
-
import
|
|
4
|
+
import { Configuration, schemas, serviceLocator } from '@crawlee/basic';
|
|
5
|
+
import { BrowserPool, RemoteBrowserPool } from '@crawlee/browser-pool';
|
|
6
|
+
import { z } from 'zod';
|
|
6
7
|
const DEFAULT_VIEWPORT = {
|
|
7
8
|
width: 1366,
|
|
8
9
|
height: 768,
|
|
@@ -23,15 +24,16 @@ export class BrowserLauncher {
|
|
|
23
24
|
Plugin;
|
|
24
25
|
userAgent;
|
|
25
26
|
static optionsShape = {
|
|
26
|
-
proxyUrl:
|
|
27
|
-
useChrome:
|
|
28
|
-
useIncognitoPages:
|
|
29
|
-
browserPerProxy:
|
|
30
|
-
ignoreProxyCertificate:
|
|
31
|
-
userDataDir:
|
|
32
|
-
launchOptions:
|
|
33
|
-
userAgent:
|
|
27
|
+
proxyUrl: z.url().optional(),
|
|
28
|
+
useChrome: z.boolean().optional(),
|
|
29
|
+
useIncognitoPages: z.boolean().optional(),
|
|
30
|
+
browserPerProxy: z.boolean().optional(),
|
|
31
|
+
ignoreProxyCertificate: z.boolean().optional(),
|
|
32
|
+
userDataDir: z.string().optional(),
|
|
33
|
+
launchOptions: schemas.anyObject.optional(),
|
|
34
|
+
userAgent: z.string().optional(),
|
|
34
35
|
};
|
|
36
|
+
static optionsSchema = z.strictObject(BrowserLauncher.optionsShape);
|
|
35
37
|
static requireLauncherOrThrow(launcher, apifyImageName) {
|
|
36
38
|
try {
|
|
37
39
|
return require(launcher); // eslint-disable-line
|
|
@@ -73,6 +75,49 @@ export class BrowserLauncher {
|
|
|
73
75
|
...this.otherLaunchContextProps,
|
|
74
76
|
});
|
|
75
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Builds a {@link BrowserPool} running a single plugin for this launcher's browser. Shared body of the
|
|
80
|
+
* per-library `*BrowserPool()` factories, which exist so that configuring a pool never requires assembling
|
|
81
|
+
* a plugin by hand — and therefore never lets the plugin drift away from the crawler it is used with.
|
|
82
|
+
* @internal
|
|
83
|
+
*/
|
|
84
|
+
createBrowserPool(options = {}) {
|
|
85
|
+
// The hook types `BrowserPool` derives from `Plugin` are unresolvable while `Plugin` is still a free type
|
|
86
|
+
// parameter, so the argument cannot be checked here. The concrete `*BrowserPool()` factories are where the
|
|
87
|
+
// caller-facing hook types get pinned down.
|
|
88
|
+
return new BrowserPool({
|
|
89
|
+
...this.resolveFingerprinting(options),
|
|
90
|
+
browserPlugins: [this.createBrowserPlugin()],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The {@link RemoteBrowserPool} counterpart of {@link BrowserLauncher.createBrowserPool}: the launcher
|
|
95
|
+
* supplies the plugin, the caller supplies the remote connection details.
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
createRemoteBrowserPool(options) {
|
|
99
|
+
return new RemoteBrowserPool({
|
|
100
|
+
...options,
|
|
101
|
+
browserPlugins: [this.createBrowserPlugin()],
|
|
102
|
+
browserPoolOptions: this.resolveFingerprinting(options.browserPoolOptions ?? {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A custom `userAgent` and Crawlee's fingerprint injection would both write the same headers, so an
|
|
107
|
+
* explicitly requested user agent wins.
|
|
108
|
+
*/
|
|
109
|
+
resolveFingerprinting(options) {
|
|
110
|
+
if (!this.userAgent) {
|
|
111
|
+
return options;
|
|
112
|
+
}
|
|
113
|
+
if (options.useFingerprints) {
|
|
114
|
+
serviceLocator
|
|
115
|
+
.getLogger()
|
|
116
|
+
.child({ prefix: 'BrowserLauncher' })
|
|
117
|
+
.info('Custom user agent provided, disabling automatic browser fingerprint injection!');
|
|
118
|
+
}
|
|
119
|
+
return { ...options, useFingerprints: false };
|
|
120
|
+
}
|
|
76
121
|
/**
|
|
77
122
|
* Launches a browser instance based on the plugin.
|
|
78
123
|
* @returns Browser instance.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/browser",
|
|
3
|
-
"version": "4.0.0-
|
|
3
|
+
"version": "4.0.0-rc.0",
|
|
4
4
|
"description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -48,13 +48,13 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@apify/timeout": "^0.4.4",
|
|
51
|
-
"@crawlee/basic": "4.0.0-
|
|
52
|
-
"@crawlee/browser-pool": "4.0.0-
|
|
53
|
-
"@crawlee/types": "4.0.0-
|
|
54
|
-
"@crawlee/utils": "4.0.0-
|
|
55
|
-
"ow": "^2.0.0",
|
|
51
|
+
"@crawlee/basic": "4.0.0-rc.0",
|
|
52
|
+
"@crawlee/browser-pool": "4.0.0-rc.0",
|
|
53
|
+
"@crawlee/types": "4.0.0-rc.0",
|
|
54
|
+
"@crawlee/utils": "4.0.0-rc.0",
|
|
56
55
|
"tslib": "^2.8.1",
|
|
57
|
-
"type-fest": "^4.41.0"
|
|
56
|
+
"type-fest": "^4.41.0",
|
|
57
|
+
"zod": "^4.4.3"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"playwright": "*",
|
|
@@ -75,5 +75,5 @@
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
},
|
|
78
|
-
"gitHead": "
|
|
78
|
+
"gitHead": "79ab33dacdacb83e0197e6516d145f3aceef80c7"
|
|
79
79
|
}
|