@crawlee/browser 4.0.0-beta.13 → 4.0.0-beta.130
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/browser-crawler.d.ts +249 -157
- package/internals/browser-crawler.js +271 -220
- package/internals/browser-launcher.d.ts +66 -23
- package/internals/browser-launcher.js +68 -23
- package/package.json +10 -10
- package/index.d.ts.map +0 -1
- package/index.js.map +0 -1
- package/internals/browser-crawler.d.ts.map +0 -1
- package/internals/browser-crawler.js.map +0 -1
- package/internals/browser-launcher.d.ts.map +0 -1
- package/internals/browser-launcher.js.map +0 -1
- package/tsconfig.build.tsbuildinfo +0 -1
|
@@ -1,8 +1,35 @@
|
|
|
1
|
-
import { BasicCrawler,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import { tryCancel } from '@apify/timeout';
|
|
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
|
+
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
|
+
}
|
|
20
|
+
const COOKIES_BEFORE_HOOKS = Symbol('cookiesBeforeHooks');
|
|
21
|
+
const readContextField = (ctx, key) => ctx[key];
|
|
22
|
+
/**
|
|
23
|
+
* Whether an error thrown by `page.goto()` is a navigation timeout - either our own {@link TimeoutError}
|
|
24
|
+
* or the driver's, which Playwright/Puppeteer report with their own class and a `Timeout ... exceeded` message
|
|
25
|
+
* naming the raw millisecond value rather than the configured window.
|
|
26
|
+
*/
|
|
27
|
+
function isNavigationTimeoutError(error) {
|
|
28
|
+
return (error instanceof TimeoutError ||
|
|
29
|
+
error?.name === 'TimeoutError' ||
|
|
30
|
+
error?.constructor?.name === 'TimeoutError' ||
|
|
31
|
+
/timeout.*exceeded/i.test(error?.message ?? ''));
|
|
32
|
+
}
|
|
6
33
|
/**
|
|
7
34
|
* Provides a simple framework for parallel crawling of web pages
|
|
8
35
|
* using headless browsers with [Puppeteer](https://github.com/puppeteer/puppeteer)
|
|
@@ -15,115 +42,134 @@ import { tryCancel } from '@apify/timeout';
|
|
|
15
42
|
* If the target website doesn't need JavaScript, we should consider using the {@link CheerioCrawler},
|
|
16
43
|
* which downloads the pages using raw HTTP requests and is about 10x faster.
|
|
17
44
|
*
|
|
18
|
-
* The source URLs are represented by the {@link Request} objects that are fed from the
|
|
19
|
-
*
|
|
20
|
-
* constructor
|
|
45
|
+
* The source URLs are represented by the {@link Request} objects that are fed from the
|
|
46
|
+
* {@link IRequestManager|request manager} provided via the {@link BrowserCrawlerOptions.requestManager|`requestManager`}
|
|
47
|
+
* constructor option (a {@link RequestQueue} is itself a request manager). If no `requestManager` is provided,
|
|
21
48
|
* the crawler will open the default request queue either when the {@link BrowserCrawler.addRequests|`crawler.addRequests()`} function is called,
|
|
22
49
|
* or if `requests` parameter (representing the initial requests) of the {@link BrowserCrawler.run|`crawler.run()`} function is provided.
|
|
23
50
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
51
|
+
* To read from a read-only source such as a {@link RequestList} while still being able to enqueue new requests,
|
|
52
|
+
* combine it with a queue into a {@link RequestManagerTandem} via {@link IRequestLoader.toTandem|`requestLoader.toTandem()`}
|
|
53
|
+
* and pass the result as `requestManager`.
|
|
54
|
+
*
|
|
55
|
+
* > The {@link BrowserCrawlerOptions.requestList|`requestList`} and {@link BrowserCrawlerOptions.requestQueue|`requestQueue`}
|
|
56
|
+
* > options are deprecated; they are still accepted and folded into a single `requestManager` for back-compat.
|
|
27
57
|
*
|
|
28
58
|
* The crawler finishes when there are no more {@link Request} objects to crawl.
|
|
29
59
|
*
|
|
30
60
|
* `BrowserCrawler` opens a new browser page (i.e. tab or window) for each {@link Request} object to crawl
|
|
31
61
|
* and then calls the function provided by user as the {@link BrowserCrawlerOptions.requestHandler|`requestHandler`} option.
|
|
32
62
|
*
|
|
33
|
-
* New pages are only opened when there is enough free CPU and memory available,
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* {@link AutoscaledPoolOptions.maxConcurrency|`maxConcurrency`} options of the
|
|
39
|
-
* underlying {@link AutoscaledPool} constructor are available directly in the `BrowserCrawler` constructor.
|
|
63
|
+
* New pages are only opened when there is enough free CPU and memory available, as judged by the crawler's
|
|
64
|
+
* {@link ConcurrencySystem}.
|
|
65
|
+
* Concurrency is tuned via the `minConcurrency`, `maxConcurrency` and `maxRequestsPerMinute` options of the
|
|
66
|
+
* `BrowserCrawler` constructor, or, for finer control, by injecting a pre-configured
|
|
67
|
+
* {@link ConcurrencySystem|`concurrencySystem`}.
|
|
40
68
|
*
|
|
41
69
|
* > *NOTE:* the pool of browser instances is internally managed by the {@link BrowserPool} class.
|
|
42
70
|
*
|
|
43
71
|
* @category Crawlers
|
|
44
72
|
*/
|
|
45
73
|
export class BrowserCrawler extends BasicCrawler {
|
|
46
|
-
|
|
74
|
+
/** Backs the {@link BrowserCrawler.browserPool|`browserPool`} getter. */
|
|
75
|
+
#browserPoolDep;
|
|
47
76
|
/**
|
|
48
|
-
* A reference to the underlying
|
|
77
|
+
* A reference to the underlying browser pool that manages the crawler's browsers. Typed as
|
|
78
|
+
* {@link IBrowserPool} so custom implementations can be plugged in via the `browserPool` constructor option.
|
|
49
79
|
*/
|
|
50
|
-
browserPool
|
|
80
|
+
get browserPool() {
|
|
81
|
+
return this.#browserPoolDep.value;
|
|
82
|
+
}
|
|
51
83
|
launchContext;
|
|
52
84
|
ignoreShadowRoots;
|
|
53
85
|
ignoreIframes;
|
|
54
|
-
navigationTimeoutMillis;
|
|
55
|
-
preNavigationHooks;
|
|
56
|
-
postNavigationHooks;
|
|
57
|
-
|
|
86
|
+
#navigationTimeoutMillis;
|
|
87
|
+
#preNavigationHooks;
|
|
88
|
+
#postNavigationHooks;
|
|
89
|
+
#saveResponseCookies;
|
|
58
90
|
static optionsShape = {
|
|
59
91
|
...BasicCrawler.optionsShape,
|
|
60
|
-
navigationTimeoutSecs:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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),
|
|
70
105
|
};
|
|
106
|
+
static optionsSchema = z.strictObject(BrowserCrawler.optionsShape);
|
|
71
107
|
/**
|
|
72
108
|
* All `BrowserCrawler` parameters are passed via an options object.
|
|
73
109
|
*/
|
|
74
|
-
constructor(options
|
|
75
|
-
|
|
76
|
-
|
|
110
|
+
constructor(options) {
|
|
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
|
+
}
|
|
115
|
+
const skipGuard = (action) => ({
|
|
116
|
+
action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
|
|
117
|
+
});
|
|
77
118
|
super({
|
|
78
119
|
...basicCrawlerOptions,
|
|
79
|
-
contextPipelineBuilder: () =>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
120
|
+
contextPipelineBuilder: () => {
|
|
121
|
+
// A single navigation window covers the pre-navigation hooks, the navigation, and the
|
|
122
|
+
// post-navigation hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow
|
|
123
|
+
// hook eats into the same window the navigation uses. The navigation itself is bounded by
|
|
124
|
+
// capping its `gotoOptions.timeout` to the remaining budget.
|
|
125
|
+
const windowGuard = (step) => skipGuard(async (ctx) => {
|
|
126
|
+
const remaining = remainingNavigationWindowMillis(ctx, this.#navigationTimeoutMillis);
|
|
127
|
+
if (remaining <= 0) {
|
|
128
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
129
|
+
}
|
|
130
|
+
return addTimeoutToPromise(async () => step(ctx), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
131
|
+
});
|
|
132
|
+
let pipeline = contextPipelineBuilder().compose({ action: this.prepareNavigation.bind(this) });
|
|
133
|
+
for (const hook of this.#preNavigationHooks) {
|
|
134
|
+
pipeline = pipeline.compose(windowGuard(hook));
|
|
135
|
+
}
|
|
136
|
+
pipeline = pipeline.compose(skipGuard(this.navigate.bind(this)));
|
|
137
|
+
for (const hook of this.#postNavigationHooks) {
|
|
138
|
+
pipeline = pipeline.compose(windowGuard(hook));
|
|
139
|
+
}
|
|
140
|
+
return pipeline
|
|
141
|
+
.compose(skipGuard(this.finalizeNavigation.bind(this)))
|
|
142
|
+
.compose({ action: this.handleBlockedRequestByContent.bind(this) })
|
|
143
|
+
.compose({ action: this.restoreRequestState.bind(this) });
|
|
144
|
+
},
|
|
145
|
+
extendContext,
|
|
146
|
+
});
|
|
90
147
|
this.launchContext = launchContext;
|
|
91
|
-
this
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
148
|
+
this.#navigationTimeoutMillis = navigationTimeoutSecs * 1000;
|
|
149
|
+
// The public option hooks are extension-aware; internal storage uses the base context type
|
|
150
|
+
// (the pipeline composes hooks against the concrete context, which does not statically carry
|
|
151
|
+
// `ContextExtension`). The extension members are present at runtime regardless.
|
|
152
|
+
this.#preNavigationHooks = preNavigationHooks;
|
|
153
|
+
this.#postNavigationHooks = postNavigationHooks;
|
|
95
154
|
this.ignoreIframes = ignoreIframes;
|
|
96
155
|
this.ignoreShadowRoots = ignoreShadowRoots;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
this.persistCookiesPerSession = persistCookiesPerSession !== undefined ? persistCookiesPerSession : true;
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
this.persistCookiesPerSession = false;
|
|
106
|
-
}
|
|
107
|
-
if (launchContext?.userAgent) {
|
|
108
|
-
if (browserPoolOptions.useFingerprints)
|
|
109
|
-
this.log.info('Custom user agent provided, disabling automatic browser fingerprint injection!');
|
|
110
|
-
browserPoolOptions.useFingerprints = false;
|
|
111
|
-
}
|
|
112
|
-
const { preLaunchHooks = [], postLaunchHooks = [], ...rest } = browserPoolOptions;
|
|
113
|
-
this.browserPool = new BrowserPool({
|
|
114
|
-
...rest,
|
|
115
|
-
preLaunchHooks: [this._extendLaunchContext.bind(this), ...preLaunchHooks],
|
|
116
|
-
postLaunchHooks: [this._maybeAddSessionRetiredListener.bind(this), ...postLaunchHooks],
|
|
117
|
-
});
|
|
156
|
+
this.#saveResponseCookies = saveResponseCookies;
|
|
157
|
+
this.#browserPoolDep = OwnedOrInjected.resolve(browserPool, () => browserPoolBuilder(remoteBrowser));
|
|
158
|
+
}
|
|
159
|
+
getNavigationTimeoutMillis() {
|
|
160
|
+
return this.#navigationTimeoutMillis;
|
|
118
161
|
}
|
|
119
162
|
buildContextPipeline() {
|
|
120
163
|
return ContextPipeline.create().compose({
|
|
121
164
|
action: this.preparePage.bind(this),
|
|
122
165
|
cleanup: async (context) => {
|
|
123
166
|
context.registerDeferredCleanup(async () => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
167
|
+
const error = !context.session.isUsable()
|
|
168
|
+
? new SessionError('Session is no longer usable')
|
|
169
|
+
: undefined;
|
|
170
|
+
await this.browserPool
|
|
171
|
+
.closePage(context.page, { error })
|
|
172
|
+
.catch((closeError) => this.log.debug('Error while closing page', { error: closeError }));
|
|
127
173
|
});
|
|
128
174
|
},
|
|
129
175
|
});
|
|
@@ -137,12 +183,6 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
137
183
|
}
|
|
138
184
|
async isRequestBlocked(crawlingContext) {
|
|
139
185
|
const { page, response } = crawlingContext;
|
|
140
|
-
const blockedStatusCodes =
|
|
141
|
-
// eslint-disable-next-line dot-notation
|
|
142
|
-
(this.sessionPool?.['blockedStatusCodes'].length ?? 0) > 0
|
|
143
|
-
? // eslint-disable-next-line dot-notation
|
|
144
|
-
this.sessionPool['blockedStatusCodes']
|
|
145
|
-
: DEFAULT_BLOCKED_STATUS_CODES;
|
|
146
186
|
// Cloudflare specific heuristic - wait 5 seconds if we get a 403 for the JS challenge to load / resolve.
|
|
147
187
|
if ((await this.containsSelectors(page, CLOUDFLARE_RETRY_CSS_SELECTORS)) && response?.status() === 403) {
|
|
148
188
|
await sleep(5000);
|
|
@@ -153,115 +193,161 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
153
193
|
return `Cloudflare challenge failed, found selectors: ${foundSelectors.join(', ')}`;
|
|
154
194
|
}
|
|
155
195
|
const foundSelectors = await this.containsSelectors(page, RETRY_CSS_SELECTORS);
|
|
156
|
-
const
|
|
196
|
+
const statusCode = response?.status() ?? 0;
|
|
157
197
|
if (foundSelectors)
|
|
158
198
|
return `Found selectors: ${foundSelectors.join(', ')}`;
|
|
159
|
-
if (
|
|
160
|
-
return `Received blocked status code: ${
|
|
199
|
+
if (this.blockedStatusCodes.has(statusCode))
|
|
200
|
+
return `Received blocked status code: ${statusCode}`;
|
|
161
201
|
return false;
|
|
162
202
|
}
|
|
163
203
|
async preparePage(crawlingContext) {
|
|
164
|
-
const
|
|
204
|
+
const page = await this.browserPool.newPage({
|
|
165
205
|
id: crawlingContext.id,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (crawlingContext.session?.proxyInfo) {
|
|
169
|
-
const proxyInfo = crawlingContext.session.proxyInfo;
|
|
170
|
-
crawlingContext.proxyInfo = proxyInfo;
|
|
171
|
-
newPageOptions.proxyUrl = proxyInfo?.url;
|
|
172
|
-
newPageOptions.proxyTier = proxyInfo?.proxyTier;
|
|
173
|
-
if (proxyInfo?.ignoreTlsErrors) {
|
|
174
|
-
/**
|
|
175
|
-
* @see https://playwright.dev/docs/api/class-browser/#browser-new-context
|
|
176
|
-
* @see https://github.com/puppeteer/puppeteer/blob/main/docs/api.md
|
|
177
|
-
*/
|
|
178
|
-
newPageOptions.pageOptions = {
|
|
179
|
-
ignoreHTTPSErrors: true,
|
|
180
|
-
acceptInsecureCerts: true,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const page = (await this.browserPool.newPage(newPageOptions));
|
|
206
|
+
session: crawlingContext.session,
|
|
207
|
+
});
|
|
185
208
|
tryCancel();
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
};
|
|
190
213
|
return {
|
|
191
214
|
page,
|
|
192
215
|
get response() {
|
|
193
216
|
throw new Error("The `response` property is not available. This might mean that you're trying to access it before navigation or that navigation resulted in `null` (this should only happen with `about:` URLs)");
|
|
194
217
|
},
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
requestQueue: await this.getRequestQueue(),
|
|
203
|
-
robotsTxtFile: await this.getRobotsTxtFileForUrl(crawlingContext.request.url),
|
|
204
|
-
onSkippedRequest: this.onSkippedRequest,
|
|
205
|
-
originalRequestUrl: crawlingContext.request.url,
|
|
218
|
+
get gotoOptions() {
|
|
219
|
+
throw new Error('The `gotoOptions` property is not available until `prepareNavigation` runs.');
|
|
220
|
+
},
|
|
221
|
+
extractLinks,
|
|
222
|
+
enqueueLinks: async (options = {}) => {
|
|
223
|
+
const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
|
|
224
|
+
enqueueStrategy: options.strategy,
|
|
206
225
|
finalRequestUrl: crawlingContext.request.loadedUrl,
|
|
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,
|
|
207
234
|
});
|
|
208
235
|
},
|
|
209
236
|
};
|
|
210
237
|
}
|
|
211
|
-
async
|
|
238
|
+
async prepareNavigation(crawlingContext) {
|
|
212
239
|
if (crawlingContext.request.skipNavigation) {
|
|
213
240
|
return {
|
|
214
241
|
request: new Proxy(crawlingContext.request, {
|
|
215
242
|
get(target, propertyName, receiver) {
|
|
216
243
|
if (propertyName === 'loadedUrl') {
|
|
217
|
-
throw new
|
|
244
|
+
throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
|
|
218
245
|
}
|
|
219
246
|
return Reflect.get(target, propertyName, receiver);
|
|
220
247
|
},
|
|
221
248
|
}),
|
|
222
249
|
get response() {
|
|
223
|
-
throw new
|
|
250
|
+
throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
|
|
224
251
|
},
|
|
225
252
|
};
|
|
226
253
|
}
|
|
227
|
-
const gotoOptions = { timeout: this.navigationTimeoutMillis };
|
|
228
|
-
const preNavigationHooksCookies = this._getCookieHeaderFromRequest(crawlingContext.request);
|
|
229
254
|
crawlingContext.request.state = RequestState.BEFORE_NAV;
|
|
230
|
-
|
|
255
|
+
return {
|
|
256
|
+
// Default to the full navigation timeout so a pre-navigation hook can read it; `navigate` narrows it
|
|
257
|
+
// to the remaining shared window unless a hook overrode it (see there).
|
|
258
|
+
gotoOptions: { timeout: this.#navigationTimeoutMillis },
|
|
259
|
+
[COOKIES_BEFORE_HOOKS]: this.getCookieHeaderFromRequest(crawlingContext.request),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
async navigate(crawlingContext) {
|
|
231
263
|
tryCancel();
|
|
232
|
-
const
|
|
233
|
-
|
|
264
|
+
const gotoOptions = crawlingContext.gotoOptions;
|
|
265
|
+
const remaining = remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis);
|
|
266
|
+
if (remaining <= 0) {
|
|
267
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
268
|
+
}
|
|
269
|
+
// If a hook left the default `navigationTimeoutMillis` in place, bound the goto to whatever is left of the
|
|
270
|
+
// shared navigation window. If it overrode the value - including `0`, Playwright's "no timeout" - honour
|
|
271
|
+
// that verbatim as the goto's own timeout. The driver enforces this natively (so a timed-out goto is
|
|
272
|
+
// aborted, not left lingering) and `handleNavigationTimeout` turns its error into our own message.
|
|
273
|
+
const gotoTimeout = gotoOptions;
|
|
274
|
+
if (gotoTimeout.timeout === this.#navigationTimeoutMillis) {
|
|
275
|
+
gotoTimeout.timeout = remaining;
|
|
276
|
+
}
|
|
277
|
+
const cookiesBeforeHooks = readContextField(crawlingContext, COOKIES_BEFORE_HOOKS);
|
|
278
|
+
const cookiesAfterHooks = this.getCookieHeaderFromRequest(crawlingContext.request);
|
|
279
|
+
await this.applyCookies(crawlingContext, cookiesBeforeHooks, cookiesAfterHooks);
|
|
234
280
|
let response;
|
|
235
281
|
try {
|
|
236
|
-
response = (await this.
|
|
282
|
+
response = (await this.navigationHandler(crawlingContext, gotoOptions)) ?? undefined;
|
|
237
283
|
}
|
|
238
284
|
catch (error) {
|
|
239
|
-
await this.
|
|
285
|
+
await this.handleNavigationTimeout(crawlingContext, error);
|
|
240
286
|
crawlingContext.request.state = RequestState.ERROR;
|
|
241
|
-
this.
|
|
287
|
+
this.throwIfProxyError(error);
|
|
242
288
|
throw error;
|
|
243
289
|
}
|
|
244
290
|
tryCancel();
|
|
245
291
|
crawlingContext.request.state = RequestState.AFTER_NAV;
|
|
246
|
-
|
|
292
|
+
return { response };
|
|
293
|
+
}
|
|
294
|
+
async finalizeNavigation(crawlingContext) {
|
|
295
|
+
tryCancel();
|
|
296
|
+
let response;
|
|
297
|
+
try {
|
|
298
|
+
response = crawlingContext.response;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// `preparePage` installs a throwing getter for `response`; reaching this branch means
|
|
302
|
+
// navigation produced no response and no hook overrode it. Treat as undefined.
|
|
303
|
+
}
|
|
247
304
|
await this.processResponse(response, crawlingContext);
|
|
248
305
|
tryCancel();
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
306
|
+
// Persist cookies from the navigation response before the user handler runs.
|
|
307
|
+
// Cookies set during `requestHandler` are saved again afterwards.
|
|
308
|
+
await this.persistCookiesFromPage(crawlingContext);
|
|
309
|
+
return { request: crawlingContext.request };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Copies cookies from the live browser page into the session cookie jar.
|
|
313
|
+
*/
|
|
314
|
+
async persistCookiesFromPage(crawlingContext) {
|
|
315
|
+
if (!this.#saveResponseCookies || !crawlingContext.session) {
|
|
316
|
+
return;
|
|
255
317
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
318
|
+
const { cookies } = await this.browserPool.extractPageState(crawlingContext.page);
|
|
319
|
+
tryCancel();
|
|
320
|
+
// Prefer the live page URL — the handler may have navigated after the initial load.
|
|
321
|
+
const url = (await crawlingContext.page.url()) || crawlingContext.request.loadedUrl || crawlingContext.request.url;
|
|
322
|
+
for (const cookie of cookies) {
|
|
323
|
+
try {
|
|
324
|
+
await crawlingContext.session.cookieJar.setCookie(browserPoolCookieToToughCookie(cookie), url, {
|
|
325
|
+
ignoreError: false,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
this.log.debug(`Could not set cookie: ${e.message}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Runs the user request handler, then re-reads browser cookies so login flows /
|
|
335
|
+
* `page.setCookie` / XHR `Set-Cookie` updates are stored for later requests.
|
|
336
|
+
*/
|
|
337
|
+
async runRequestHandler(crawlingContext) {
|
|
338
|
+
try {
|
|
339
|
+
await super.runRequestHandler(crawlingContext);
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
if (!crawlingContext.request.skipNavigation) {
|
|
343
|
+
try {
|
|
344
|
+
await this.persistCookiesFromPage(crawlingContext);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// Page may already be closed on some failure paths; ignore.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
261
350
|
}
|
|
262
|
-
return {
|
|
263
|
-
request: crawlingContext.request,
|
|
264
|
-
};
|
|
265
351
|
}
|
|
266
352
|
async handleBlockedRequestByContent(crawlingContext) {
|
|
267
353
|
if (this.retryOnBlocked) {
|
|
@@ -275,41 +361,64 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
275
361
|
crawlingContext.request.state = RequestState.REQUEST_HANDLER;
|
|
276
362
|
return {};
|
|
277
363
|
}
|
|
278
|
-
async
|
|
279
|
-
const sessionCookie = session
|
|
364
|
+
async applyCookies({ session, request, page }, preHooksCookies, postHooksCookies) {
|
|
365
|
+
const sessionCookie = session
|
|
366
|
+
? (await session.cookieJar.getCookies(request.url)).map(toughCookieToBrowserPoolCookie)
|
|
367
|
+
: [];
|
|
280
368
|
const parsedPreHooksCookies = preHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
|
|
281
369
|
const parsedPostHooksCookies = postHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
|
|
282
|
-
|
|
370
|
+
const cookies = [...sessionCookie, ...parsedPreHooksCookies, ...parsedPostHooksCookies]
|
|
283
371
|
.filter((c) => typeof c !== 'undefined' && c !== null)
|
|
284
|
-
.map((c) => ({ ...c, url: c.domain ? undefined : request.url }))
|
|
372
|
+
.map((c) => ({ ...c, url: c.domain ? undefined : request.url }));
|
|
373
|
+
await this.browserPool.injectPageState(page, { cookies });
|
|
285
374
|
}
|
|
286
375
|
/**
|
|
287
|
-
* Marks session bad in
|
|
376
|
+
* Marks session bad on navigation timeout, and stops in-flight page loading on any navigation error.
|
|
288
377
|
*/
|
|
289
|
-
async
|
|
290
|
-
const { session } = crawlingContext;
|
|
291
|
-
|
|
292
|
-
|
|
378
|
+
async handleNavigationTimeout(crawlingContext, error) {
|
|
379
|
+
const { session, page } = crawlingContext;
|
|
380
|
+
// Fire-and-forget: no user code will run on this page after a failed navigation.
|
|
381
|
+
// Swallow rejections: the page may already be detached.
|
|
382
|
+
void page.evaluate(() => window.stop()).catch(() => { });
|
|
383
|
+
if (isNavigationTimeoutError(error)) {
|
|
384
|
+
session?.markBad();
|
|
385
|
+
// The driver was handed the remaining window (usually shorter than `navigationTimeoutSecs` once the
|
|
386
|
+
// hooks have run), so it names that value in its own error; report the configured window instead.
|
|
387
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
293
388
|
}
|
|
294
|
-
await crawlingContext.page.close();
|
|
295
389
|
}
|
|
296
390
|
/**
|
|
297
391
|
* Transforms proxy-related errors to `SessionError`.
|
|
298
392
|
*/
|
|
299
|
-
|
|
393
|
+
throwIfProxyError(error) {
|
|
300
394
|
if (this.isProxyError(error)) {
|
|
301
|
-
throw new SessionError(this.
|
|
395
|
+
throw new SessionError(this.getMessageFromError(error));
|
|
302
396
|
}
|
|
303
397
|
}
|
|
304
398
|
async processResponse(response, crawlingContext) {
|
|
305
399
|
const { session, request, page } = crawlingContext;
|
|
306
400
|
if (typeof response === 'object' && typeof response.status === 'function') {
|
|
307
401
|
const status = response.status();
|
|
308
|
-
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
|
+
}
|
|
412
|
+
if (this.isErrorStatusCode(status)) {
|
|
413
|
+
if (this.additionalHttpErrorStatusCodes.has(status)) {
|
|
414
|
+
throw new Error(`${status} - Error status code was set by user.`);
|
|
415
|
+
}
|
|
416
|
+
throw new Error(`${status} - Internal Server Error`);
|
|
417
|
+
}
|
|
309
418
|
}
|
|
310
419
|
if (this.sessionPool && response && session) {
|
|
311
420
|
if (typeof response === 'object' && typeof response.status === 'function') {
|
|
312
|
-
this.
|
|
421
|
+
this.throwOnBlockedRequest(response.status());
|
|
313
422
|
}
|
|
314
423
|
else {
|
|
315
424
|
this.log.debug('Got a malformed Browser response.', { request, response });
|
|
@@ -317,72 +426,15 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
317
426
|
}
|
|
318
427
|
request.loadedUrl = await page.url();
|
|
319
428
|
}
|
|
320
|
-
async _extendLaunchContext(_pageId, launchContext) {
|
|
321
|
-
const launchContextExtends = {};
|
|
322
|
-
if (this.sessionPool) {
|
|
323
|
-
launchContextExtends.session = await this.sessionPool.newSession({
|
|
324
|
-
proxyInfo: await this.proxyConfiguration?.newProxyInfo({
|
|
325
|
-
// cannot pass a request here, since session is created on browser launch
|
|
326
|
-
}),
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
if (!launchContext.proxyUrl && launchContextExtends.session?.proxyInfo) {
|
|
330
|
-
const proxyInfo = launchContextExtends.session.proxyInfo;
|
|
331
|
-
launchContext.proxyUrl = proxyInfo?.url;
|
|
332
|
-
launchContextExtends.proxyInfo = proxyInfo;
|
|
333
|
-
// Disable SSL verification for MITM proxies
|
|
334
|
-
if (proxyInfo?.ignoreTlsErrors) {
|
|
335
|
-
/**
|
|
336
|
-
* @see https://playwright.dev/docs/api/class-browser/#browser-new-context
|
|
337
|
-
* @see https://github.com/puppeteer/puppeteer/blob/main/docs/api.md
|
|
338
|
-
*/
|
|
339
|
-
launchContext.launchOptions.ignoreHTTPSErrors = true;
|
|
340
|
-
launchContext.launchOptions.acceptInsecureCerts = true;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
launchContext.extend(launchContextExtends);
|
|
344
|
-
}
|
|
345
|
-
_maybeAddSessionRetiredListener(_pageId, browserController) {
|
|
346
|
-
if (this.sessionPool) {
|
|
347
|
-
const listener = (session) => {
|
|
348
|
-
const { launchContext } = browserController;
|
|
349
|
-
if (session.id === launchContext.session.id) {
|
|
350
|
-
this.browserPool.retireBrowserController(browserController);
|
|
351
|
-
}
|
|
352
|
-
};
|
|
353
|
-
this.sessionPool.on(EVENT_SESSION_RETIRED, listener);
|
|
354
|
-
browserController.on("browserClosed" /* BROWSER_CONTROLLER_EVENTS.BROWSER_CLOSED */, () => {
|
|
355
|
-
return this.sessionPool.removeListener(EVENT_SESSION_RETIRED, listener);
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
429
|
/**
|
|
360
430
|
* Function for cleaning up after all requests are processed.
|
|
361
431
|
* @ignore
|
|
362
432
|
*/
|
|
363
433
|
async teardown() {
|
|
364
|
-
await this.
|
|
434
|
+
await this.#browserPoolDep.ifOwned((pool) => pool.destroy());
|
|
365
435
|
await super.teardown();
|
|
366
436
|
}
|
|
367
437
|
}
|
|
368
|
-
/** @internal */
|
|
369
|
-
export async function browserCrawlerEnqueueLinks({ options, page, requestQueue, robotsTxtFile, onSkippedRequest, originalRequestUrl, finalRequestUrl, }) {
|
|
370
|
-
const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
|
|
371
|
-
enqueueStrategy: options?.strategy,
|
|
372
|
-
finalRequestUrl,
|
|
373
|
-
originalRequestUrl,
|
|
374
|
-
userProvidedBaseUrl: options?.baseUrl,
|
|
375
|
-
});
|
|
376
|
-
const urls = await extractUrlsFromPage(page, options?.selector ?? 'a', options?.baseUrl ?? finalRequestUrl ?? originalRequestUrl);
|
|
377
|
-
return enqueueLinks({
|
|
378
|
-
requestQueue,
|
|
379
|
-
robotsTxtFile,
|
|
380
|
-
onSkippedRequest,
|
|
381
|
-
urls,
|
|
382
|
-
baseUrl,
|
|
383
|
-
...options,
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
438
|
/**
|
|
387
439
|
* Extracts URLs from a given page.
|
|
388
440
|
* @ignore
|
|
@@ -408,4 +460,3 @@ page, selector, baseUrl) {
|
|
|
408
460
|
})
|
|
409
461
|
.filter((href) => !!href);
|
|
410
462
|
}
|
|
411
|
-
//# sourceMappingURL=browser-crawler.js.map
|