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