@crawlee/browser 4.0.0-beta.9 → 4.0.0-beta.91
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 +154 -95
- package/internals/browser-crawler.js +245 -241
- package/internals/browser-launcher.d.ts +14 -8
- package/internals/browser-launcher.js +14 -14
- package/package.json +7 -7
- 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,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { BrowserPool } from '@crawlee/browser-pool';
|
|
1
|
+
import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie, enqueueLinks, handleRequestTimeout, NavigationSkippedError, OwnedOrInjected, 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 {
|
|
5
|
+
import { tryCancel } from '@apify/timeout';
|
|
6
|
+
const COOKIES_BEFORE_HOOKS = Symbol('cookiesBeforeHooks');
|
|
7
|
+
const readContextField = (ctx, key) => ctx[key];
|
|
6
8
|
/**
|
|
7
9
|
* Provides a simple framework for parallel crawling of web pages
|
|
8
10
|
* using headless browsers with [Puppeteer](https://github.com/puppeteer/puppeteer)
|
|
@@ -15,15 +17,18 @@ import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
|
|
|
15
17
|
* If the target website doesn't need JavaScript, we should consider using the {@link CheerioCrawler},
|
|
16
18
|
* which downloads the pages using raw HTTP requests and is about 10x faster.
|
|
17
19
|
*
|
|
18
|
-
* The source URLs are represented by the {@link Request} objects that are fed from the
|
|
19
|
-
*
|
|
20
|
-
* constructor
|
|
20
|
+
* The source URLs are represented by the {@link Request} objects that are fed from the
|
|
21
|
+
* {@link IRequestManager|request manager} provided via the {@link BrowserCrawlerOptions.requestManager|`requestManager`}
|
|
22
|
+
* constructor option (a {@link RequestQueue} is itself a request manager). If no `requestManager` is provided,
|
|
21
23
|
* the crawler will open the default request queue either when the {@link BrowserCrawler.addRequests|`crawler.addRequests()`} function is called,
|
|
22
24
|
* or if `requests` parameter (representing the initial requests) of the {@link BrowserCrawler.run|`crawler.run()`} function is provided.
|
|
23
25
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
26
|
+
* To read from a read-only source such as a {@link RequestList} while still being able to enqueue new requests,
|
|
27
|
+
* combine it with a queue into a {@link RequestManagerTandem} via {@link IRequestLoader.toTandem|`requestLoader.toTandem()`}
|
|
28
|
+
* and pass the result as `requestManager`.
|
|
29
|
+
*
|
|
30
|
+
* > The {@link BrowserCrawlerOptions.requestList|`requestList`} and {@link BrowserCrawlerOptions.requestQueue|`requestQueue`}
|
|
31
|
+
* > options are deprecated; they are still accepted and folded into a single `requestManager` for back-compat.
|
|
27
32
|
*
|
|
28
33
|
* The crawler finishes when there are no more {@link Request} objects to crawl.
|
|
29
34
|
*
|
|
@@ -32,34 +37,31 @@ import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
|
|
|
32
37
|
*
|
|
33
38
|
* New pages are only opened when there is enough free CPU and memory available,
|
|
34
39
|
* using the functionality provided by the {@link AutoscaledPool} class.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* {@link AutoscaledPoolOptions.maxConcurrency|`maxConcurrency`} options of the
|
|
39
|
-
* underlying {@link AutoscaledPool} constructor are available directly in the `BrowserCrawler` constructor.
|
|
40
|
+
* Concurrency is tuned via the `minConcurrency`, `maxConcurrency` and `maxRequestsPerMinute` options of the
|
|
41
|
+
* `BrowserCrawler` constructor, or, for finer control, by injecting a pre-configured
|
|
42
|
+
* {@link ConcurrencySystem|`concurrencySystem`}.
|
|
40
43
|
*
|
|
41
44
|
* > *NOTE:* the pool of browser instances is internally managed by the {@link BrowserPool} class.
|
|
42
45
|
*
|
|
43
46
|
* @category Crawlers
|
|
44
47
|
*/
|
|
45
48
|
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;
|
|
49
|
+
/** Backs the {@link BrowserCrawler.browserPool|`browserPool`} getter. */
|
|
50
|
+
browserPoolDep;
|
|
52
51
|
/**
|
|
53
|
-
* A reference to the underlying
|
|
52
|
+
* A reference to the underlying browser pool that manages the crawler's browsers. Typed as
|
|
53
|
+
* {@link IBrowserPool} so custom implementations can be plugged in via the `browserPool` constructor option.
|
|
54
54
|
*/
|
|
55
|
-
browserPool
|
|
55
|
+
get browserPool() {
|
|
56
|
+
return this.browserPoolDep.value;
|
|
57
|
+
}
|
|
56
58
|
launchContext;
|
|
57
|
-
|
|
59
|
+
ignoreShadowRoots;
|
|
60
|
+
ignoreIframes;
|
|
58
61
|
navigationTimeoutMillis;
|
|
59
|
-
requestHandlerTimeoutInnerMillis;
|
|
60
62
|
preNavigationHooks;
|
|
61
63
|
postNavigationHooks;
|
|
62
|
-
|
|
64
|
+
saveResponseCookies;
|
|
63
65
|
static optionsShape = {
|
|
64
66
|
...BasicCrawler.optionsShape,
|
|
65
67
|
navigationTimeoutSecs: ow.optional.number.greaterThan(0),
|
|
@@ -67,65 +69,96 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
67
69
|
postNavigationHooks: ow.optional.array,
|
|
68
70
|
launchContext: ow.optional.object,
|
|
69
71
|
headless: ow.optional.any(ow.boolean, ow.string),
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
browserPool: ow.optional.object.validate(validators.browserPool),
|
|
73
|
+
remoteBrowser: ow.optional.object,
|
|
74
|
+
browserPoolOptions: ow.optional.object,
|
|
75
|
+
saveResponseCookies: ow.optional.boolean,
|
|
74
76
|
proxyConfiguration: ow.optional.object.validate(validators.proxyConfiguration),
|
|
75
|
-
ignoreShadowRoots: ow.optional.boolean,
|
|
76
|
-
ignoreIframes: ow.optional.boolean,
|
|
77
77
|
};
|
|
78
78
|
/**
|
|
79
79
|
* All `BrowserCrawler` parameters are passed via an options object.
|
|
80
80
|
*/
|
|
81
|
-
constructor(options
|
|
81
|
+
constructor(options) {
|
|
82
82
|
ow(options, 'BrowserCrawlerOptions', ow.object.exactShape(BrowserCrawler.optionsShape));
|
|
83
|
-
const { navigationTimeoutSecs = 60,
|
|
83
|
+
const { navigationTimeoutSecs = 60, saveResponseCookies = true, launchContext = {}, browserPool, remoteBrowser, browserPoolOptions, preNavigationHooks = [], postNavigationHooks = [], headless, ignoreIframes = false, ignoreShadowRoots = false, contextPipelineBuilder, extendContext, ...basicCrawlerOptions } = options;
|
|
84
|
+
const skipGuard = (action) => ({
|
|
85
|
+
action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
|
|
86
|
+
});
|
|
84
87
|
super({
|
|
85
88
|
...basicCrawlerOptions,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
contextPipelineBuilder: () => {
|
|
90
|
+
let pipeline = contextPipelineBuilder().compose({ action: this.prepareNavigation.bind(this) });
|
|
91
|
+
for (const hook of this.preNavigationHooks) {
|
|
92
|
+
pipeline = pipeline.compose(skipGuard(hook));
|
|
93
|
+
}
|
|
94
|
+
pipeline = pipeline.compose(skipGuard(this.navigate.bind(this)));
|
|
95
|
+
for (const hook of this.postNavigationHooks) {
|
|
96
|
+
pipeline = pipeline.compose(skipGuard(hook));
|
|
97
|
+
}
|
|
98
|
+
return pipeline
|
|
99
|
+
.compose(skipGuard(this.finalizeNavigation.bind(this)))
|
|
100
|
+
.compose({ action: this.handleBlockedRequestByContent.bind(this) })
|
|
101
|
+
.compose({ action: this.restoreRequestState.bind(this) });
|
|
102
|
+
},
|
|
103
|
+
extendContext,
|
|
104
|
+
});
|
|
95
105
|
this.launchContext = launchContext;
|
|
96
106
|
this.navigationTimeoutMillis = navigationTimeoutSecs * 1000;
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
// The public option hooks are extension-aware; internal storage uses the base context type
|
|
108
|
+
// (the pipeline composes hooks against the concrete context, which does not statically carry
|
|
109
|
+
// `ContextExtension`). The extension members are present at runtime regardless.
|
|
99
110
|
this.preNavigationHooks = preNavigationHooks;
|
|
100
111
|
this.postNavigationHooks = postNavigationHooks;
|
|
112
|
+
this.ignoreIframes = ignoreIframes;
|
|
113
|
+
this.ignoreShadowRoots = ignoreShadowRoots;
|
|
101
114
|
if (headless != null) {
|
|
102
115
|
this.launchContext.launchOptions ??= {};
|
|
103
116
|
this.launchContext.launchOptions.headless = headless;
|
|
104
117
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
this.saveResponseCookies = saveResponseCookies;
|
|
119
|
+
// `browserPool` wins over `remoteBrowser` — a passed-in pool is used as-is (borrowed), the sugar is ignored.
|
|
120
|
+
// The default is only built when no pool was injected, so all the option/launchContext fiddling below stays
|
|
121
|
+
// inside the factory.
|
|
122
|
+
this.browserPoolDep = OwnedOrInjected.resolve(browserPool, () => {
|
|
123
|
+
const resolvedBrowserPoolOptions = browserPoolOptions ?? {};
|
|
124
|
+
if (launchContext?.userAgent) {
|
|
125
|
+
if (resolvedBrowserPoolOptions.useFingerprints)
|
|
126
|
+
this.log.info('Custom user agent provided, disabling automatic browser fingerprint injection!');
|
|
127
|
+
resolvedBrowserPoolOptions.useFingerprints = false;
|
|
128
|
+
}
|
|
129
|
+
if (remoteBrowser) {
|
|
130
|
+
// The crawler already built the right plugin for its browser — hand it to a RemoteBrowserPool so the
|
|
131
|
+
// remote connection is always for the matching browser (no plugin to construct, no way to mismatch).
|
|
132
|
+
const { browserPlugins, ...remoteBrowserPoolOptions } = resolvedBrowserPoolOptions;
|
|
133
|
+
return new RemoteBrowserPool({
|
|
134
|
+
browserPlugins: browserPlugins,
|
|
135
|
+
...remoteBrowser,
|
|
136
|
+
browserPoolOptions: remoteBrowserPoolOptions,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// Double cast: `BrowserPool` implements `IBrowserPool<PageReturn>`, where `PageReturn` is derived from the
|
|
140
|
+
// plugin/controller generics and doesn't overlap with the crawler's free `Page` type param, so TS won't
|
|
141
|
+
// narrow it directly. The concrete pool does satisfy the `Page`/`destroy` contract at runtime — this is the
|
|
142
|
+
// long-standing `Page` variance gap, not a `destroy`-related hole.
|
|
143
|
+
return new BrowserPool({
|
|
144
|
+
...resolvedBrowserPoolOptions,
|
|
145
|
+
});
|
|
121
146
|
});
|
|
122
147
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
148
|
+
buildContextPipeline() {
|
|
149
|
+
return ContextPipeline.create().compose({
|
|
150
|
+
action: this.preparePage.bind(this),
|
|
151
|
+
cleanup: async (context) => {
|
|
152
|
+
context.registerDeferredCleanup(async () => {
|
|
153
|
+
const error = !context.session.isUsable()
|
|
154
|
+
? new SessionError('Session is no longer usable')
|
|
155
|
+
: undefined;
|
|
156
|
+
await this.browserPool
|
|
157
|
+
.closePage(context.page, { error })
|
|
158
|
+
.catch((closeError) => this.log.debug('Error while closing page', { error: closeError }));
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
});
|
|
129
162
|
}
|
|
130
163
|
async containsSelectors(page, selectors) {
|
|
131
164
|
const foundSelectors = (await Promise.all(selectors.map((selector) => page.$(selector))))
|
|
@@ -136,12 +169,6 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
136
169
|
}
|
|
137
170
|
async isRequestBlocked(crawlingContext) {
|
|
138
171
|
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
172
|
// Cloudflare specific heuristic - wait 5 seconds if we get a 403 for the JS challenge to load / resolve.
|
|
146
173
|
if ((await this.containsSelectors(page, CLOUDFLARE_RETRY_CSS_SELECTORS)) && response?.status() === 403) {
|
|
147
174
|
await sleep(5000);
|
|
@@ -152,170 +179,173 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
152
179
|
return `Cloudflare challenge failed, found selectors: ${foundSelectors.join(', ')}`;
|
|
153
180
|
}
|
|
154
181
|
const foundSelectors = await this.containsSelectors(page, RETRY_CSS_SELECTORS);
|
|
155
|
-
const
|
|
182
|
+
const statusCode = response?.status() ?? 0;
|
|
156
183
|
if (foundSelectors)
|
|
157
184
|
return `Found selectors: ${foundSelectors.join(', ')}`;
|
|
158
|
-
if (
|
|
159
|
-
return `Received blocked status code: ${
|
|
185
|
+
if (this.blockedStatusCodes.has(statusCode))
|
|
186
|
+
return `Received blocked status code: ${statusCode}`;
|
|
160
187
|
return false;
|
|
161
188
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
*/
|
|
165
|
-
async _runRequestHandler(crawlingContext) {
|
|
166
|
-
const newPageOptions = {
|
|
189
|
+
async preparePage(crawlingContext) {
|
|
190
|
+
const page = await this.browserPool.newPage({
|
|
167
191
|
id: crawlingContext.id,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (this.proxyConfiguration) {
|
|
171
|
-
const { session } = crawlingContext;
|
|
172
|
-
const proxyInfo = await this.proxyConfiguration.newProxyInfo(session?.id, {
|
|
173
|
-
request: crawlingContext.request,
|
|
174
|
-
});
|
|
175
|
-
crawlingContext.proxyInfo = proxyInfo;
|
|
176
|
-
newPageOptions.proxyUrl = proxyInfo?.url;
|
|
177
|
-
newPageOptions.proxyTier = proxyInfo?.proxyTier;
|
|
178
|
-
if (this.proxyConfiguration.isManInTheMiddle) {
|
|
179
|
-
/**
|
|
180
|
-
* @see https://playwright.dev/docs/api/class-browser/#browser-new-context
|
|
181
|
-
* @see https://github.com/puppeteer/puppeteer/blob/main/docs/api.md
|
|
182
|
-
*/
|
|
183
|
-
newPageOptions.pageOptions = {
|
|
184
|
-
ignoreHTTPSErrors: true,
|
|
185
|
-
acceptInsecureCerts: true,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
const page = (await this.browserPool.newPage(newPageOptions));
|
|
190
|
-
tryCancel();
|
|
191
|
-
this._enhanceCrawlingContextWithPageInfo(crawlingContext, page, useIncognitoPages);
|
|
192
|
-
// DO NOT MOVE THIS LINE ABOVE!
|
|
193
|
-
// `enhanceCrawlingContextWithPageInfo` gives us a valid session.
|
|
194
|
-
// For example, `sessionPoolOptions.sessionOptions.maxUsageCount` can be `1`.
|
|
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
|
-
}
|
|
209
|
-
}
|
|
210
|
-
if (!this.requestMatchesEnqueueStrategy(request)) {
|
|
211
|
-
this.log.debug(
|
|
212
|
-
// eslint-disable-next-line dot-notation
|
|
213
|
-
`Skipping request ${request.id} (starting url: ${request.url} -> loaded url: ${request.loadedUrl}) because it does not match the enqueue strategy (${request['enqueueStrategy']}).`);
|
|
214
|
-
request.noRetry = true;
|
|
215
|
-
request.state = RequestState.SKIPPED;
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (this.retryOnBlocked) {
|
|
219
|
-
const error = await this.isRequestBlocked(crawlingContext);
|
|
220
|
-
if (error)
|
|
221
|
-
throw new SessionError(error);
|
|
222
|
-
}
|
|
223
|
-
request.state = RequestState.REQUEST_HANDLER;
|
|
224
|
-
try {
|
|
225
|
-
await addTimeoutToPromise(async () => Promise.resolve(this.userProvidedRequestHandler(crawlingContext)), this.requestHandlerTimeoutInnerMillis, `requestHandler timed out after ${this.requestHandlerTimeoutInnerMillis / 1000} seconds.`);
|
|
226
|
-
request.state = RequestState.DONE;
|
|
227
|
-
}
|
|
228
|
-
catch (e) {
|
|
229
|
-
request.state = RequestState.ERROR;
|
|
230
|
-
throw e;
|
|
231
|
-
}
|
|
192
|
+
session: crawlingContext.session,
|
|
193
|
+
});
|
|
232
194
|
tryCancel();
|
|
195
|
+
const contextEnqueueLinks = crawlingContext.enqueueLinks;
|
|
196
|
+
return {
|
|
197
|
+
page,
|
|
198
|
+
get response() {
|
|
199
|
+
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)");
|
|
200
|
+
},
|
|
201
|
+
get gotoOptions() {
|
|
202
|
+
throw new Error('The `gotoOptions` property is not available until `prepareNavigation` runs.');
|
|
203
|
+
},
|
|
204
|
+
enqueueLinks: async (enqueueOptions = {}) => {
|
|
205
|
+
return (await browserCrawlerEnqueueLinks({
|
|
206
|
+
options: {
|
|
207
|
+
...enqueueOptions,
|
|
208
|
+
limit: await this.calculateEnqueuedRequestLimit(enqueueOptions?.limit),
|
|
209
|
+
},
|
|
210
|
+
page,
|
|
211
|
+
requestManager: await this.getRequestManager(),
|
|
212
|
+
robotsTxtFile: await this.getRobotsTxtFileForUrl(crawlingContext.request.url),
|
|
213
|
+
onSkippedRequest: this.handleSkippedRequest,
|
|
214
|
+
originalRequestUrl: crawlingContext.request.url,
|
|
215
|
+
finalRequestUrl: crawlingContext.request.loadedUrl,
|
|
216
|
+
enqueueLinks: contextEnqueueLinks,
|
|
217
|
+
})); // TODO make this type safe
|
|
218
|
+
},
|
|
219
|
+
};
|
|
233
220
|
}
|
|
234
|
-
|
|
235
|
-
crawlingContext.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
221
|
+
async prepareNavigation(crawlingContext) {
|
|
222
|
+
if (crawlingContext.request.skipNavigation) {
|
|
223
|
+
return {
|
|
224
|
+
request: new Proxy(crawlingContext.request, {
|
|
225
|
+
get(target, propertyName, receiver) {
|
|
226
|
+
if (propertyName === 'loadedUrl') {
|
|
227
|
+
throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
|
|
228
|
+
}
|
|
229
|
+
return Reflect.get(target, propertyName, receiver);
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
get response() {
|
|
233
|
+
throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
|
|
234
|
+
},
|
|
235
|
+
};
|
|
247
236
|
}
|
|
248
|
-
crawlingContext.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
requestQueue: await this.getRequestQueue(),
|
|
253
|
-
robotsTxtFile: await this.getRobotsTxtFileForUrl(crawlingContext.request.url),
|
|
254
|
-
onSkippedRequest: this.onSkippedRequest,
|
|
255
|
-
originalRequestUrl: crawlingContext.request.url,
|
|
256
|
-
finalRequestUrl: crawlingContext.request.loadedUrl,
|
|
257
|
-
});
|
|
237
|
+
crawlingContext.request.state = RequestState.BEFORE_NAV;
|
|
238
|
+
return {
|
|
239
|
+
gotoOptions: { timeout: this.navigationTimeoutMillis },
|
|
240
|
+
[COOKIES_BEFORE_HOOKS]: this._getCookieHeaderFromRequest(crawlingContext.request),
|
|
258
241
|
};
|
|
259
242
|
}
|
|
260
|
-
async
|
|
261
|
-
const gotoOptions = { timeout: this.navigationTimeoutMillis };
|
|
262
|
-
const preNavigationHooksCookies = this._getCookieHeaderFromRequest(crawlingContext.request);
|
|
263
|
-
crawlingContext.request.state = RequestState.BEFORE_NAV;
|
|
264
|
-
await this._executeHooks(this.preNavigationHooks, crawlingContext, gotoOptions);
|
|
243
|
+
async navigate(crawlingContext) {
|
|
265
244
|
tryCancel();
|
|
266
|
-
const
|
|
267
|
-
|
|
245
|
+
const gotoOptions = crawlingContext.gotoOptions;
|
|
246
|
+
const cookiesBeforeHooks = readContextField(crawlingContext, COOKIES_BEFORE_HOOKS);
|
|
247
|
+
const cookiesAfterHooks = this._getCookieHeaderFromRequest(crawlingContext.request);
|
|
248
|
+
await this.applyCookies(crawlingContext, cookiesBeforeHooks, cookiesAfterHooks);
|
|
249
|
+
let response;
|
|
268
250
|
try {
|
|
269
|
-
|
|
251
|
+
response = (await this._navigationHandler(crawlingContext, gotoOptions)) ?? undefined;
|
|
270
252
|
}
|
|
271
253
|
catch (error) {
|
|
272
|
-
await this.
|
|
254
|
+
await this.handleNavigationTimeout(crawlingContext, error);
|
|
273
255
|
crawlingContext.request.state = RequestState.ERROR;
|
|
274
|
-
this.
|
|
256
|
+
this.throwIfProxyError(error);
|
|
275
257
|
throw error;
|
|
276
258
|
}
|
|
277
259
|
tryCancel();
|
|
278
260
|
crawlingContext.request.state = RequestState.AFTER_NAV;
|
|
279
|
-
|
|
261
|
+
return { response };
|
|
262
|
+
}
|
|
263
|
+
async finalizeNavigation(crawlingContext) {
|
|
264
|
+
tryCancel();
|
|
265
|
+
let response;
|
|
266
|
+
try {
|
|
267
|
+
response = crawlingContext.response;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// `preparePage` installs a throwing getter for `response`; reaching this branch means
|
|
271
|
+
// navigation produced no response and no hook overrode it. Treat as undefined.
|
|
272
|
+
}
|
|
273
|
+
await this.processResponse(response, crawlingContext);
|
|
274
|
+
tryCancel();
|
|
275
|
+
// TODO: Should we save the cookies also after/only the handle page?
|
|
276
|
+
if (this.saveResponseCookies && crawlingContext.session) {
|
|
277
|
+
const { cookies } = await this.browserPool.extractPageState(crawlingContext.page);
|
|
278
|
+
tryCancel();
|
|
279
|
+
const url = crawlingContext.request.loadedUrl;
|
|
280
|
+
for (const cookie of cookies) {
|
|
281
|
+
try {
|
|
282
|
+
crawlingContext.session.cookieJar.setCookieSync(browserPoolCookieToToughCookie(cookie), url, {
|
|
283
|
+
ignoreError: false,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
this.log.debug(`Could not set cookie: ${e.message}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { request: crawlingContext.request };
|
|
292
|
+
}
|
|
293
|
+
async handleBlockedRequestByContent(crawlingContext) {
|
|
294
|
+
if (this.retryOnBlocked) {
|
|
295
|
+
const error = await this.isRequestBlocked(crawlingContext);
|
|
296
|
+
if (error)
|
|
297
|
+
throw new SessionError(error);
|
|
298
|
+
}
|
|
299
|
+
return {};
|
|
300
|
+
}
|
|
301
|
+
async restoreRequestState(crawlingContext) {
|
|
302
|
+
crawlingContext.request.state = RequestState.REQUEST_HANDLER;
|
|
303
|
+
return {};
|
|
280
304
|
}
|
|
281
|
-
async
|
|
282
|
-
const sessionCookie = session?.
|
|
305
|
+
async applyCookies({ session, request, page }, preHooksCookies, postHooksCookies) {
|
|
306
|
+
const sessionCookie = session?.cookieJar.getCookiesSync(request.url).map(toughCookieToBrowserPoolCookie) ?? [];
|
|
283
307
|
const parsedPreHooksCookies = preHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
|
|
284
308
|
const parsedPostHooksCookies = postHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
|
|
285
|
-
|
|
309
|
+
const cookies = [...sessionCookie, ...parsedPreHooksCookies, ...parsedPostHooksCookies]
|
|
286
310
|
.filter((c) => typeof c !== 'undefined' && c !== null)
|
|
287
|
-
.map((c) => ({ ...c, url: c.domain ? undefined : request.url }))
|
|
311
|
+
.map((c) => ({ ...c, url: c.domain ? undefined : request.url }));
|
|
312
|
+
await this.browserPool.injectPageState(page, { cookies });
|
|
288
313
|
}
|
|
289
314
|
/**
|
|
290
|
-
* Marks session bad in
|
|
315
|
+
* Marks session bad on navigation timeout, and stops in-flight page loading on any navigation error.
|
|
291
316
|
*/
|
|
292
|
-
async
|
|
293
|
-
const { session } = crawlingContext;
|
|
294
|
-
if (error
|
|
317
|
+
async handleNavigationTimeout(crawlingContext, error) {
|
|
318
|
+
const { session, page } = crawlingContext;
|
|
319
|
+
if (error?.constructor.name === 'TimeoutError') {
|
|
295
320
|
handleRequestTimeout({ session, errorMessage: error.message });
|
|
296
321
|
}
|
|
297
|
-
|
|
322
|
+
// Fire-and-forget: no user code will run on this page after a failed navigation.
|
|
323
|
+
// Swallow rejections: the page may already be detached.
|
|
324
|
+
void page.evaluate(() => window.stop()).catch(() => { });
|
|
298
325
|
}
|
|
299
326
|
/**
|
|
300
327
|
* Transforms proxy-related errors to `SessionError`.
|
|
301
328
|
*/
|
|
302
|
-
|
|
329
|
+
throwIfProxyError(error) {
|
|
303
330
|
if (this.isProxyError(error)) {
|
|
304
331
|
throw new SessionError(this._getMessageFromError(error));
|
|
305
332
|
}
|
|
306
333
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
*/
|
|
310
|
-
async _responseHandler(crawlingContext) {
|
|
311
|
-
const { response, session, request, page } = crawlingContext;
|
|
334
|
+
async processResponse(response, crawlingContext) {
|
|
335
|
+
const { session, request, page } = crawlingContext;
|
|
312
336
|
if (typeof response === 'object' && typeof response.status === 'function') {
|
|
313
337
|
const status = response.status();
|
|
314
338
|
this.stats.registerStatusCode(status);
|
|
339
|
+
if (this.isErrorStatusCode(status)) {
|
|
340
|
+
if (this.additionalHttpErrorStatusCodes.has(status)) {
|
|
341
|
+
throw new Error(`${status} - Error status code was set by user.`);
|
|
342
|
+
}
|
|
343
|
+
throw new Error(`${status} - Internal Server Error`);
|
|
344
|
+
}
|
|
315
345
|
}
|
|
316
346
|
if (this.sessionPool && response && session) {
|
|
317
347
|
if (typeof response === 'object' && typeof response.status === 'function') {
|
|
318
|
-
this._throwOnBlockedRequest(
|
|
348
|
+
this._throwOnBlockedRequest(response.status());
|
|
319
349
|
}
|
|
320
350
|
else {
|
|
321
351
|
this.log.debug('Got a malformed Browser response.', { request, response });
|
|
@@ -323,68 +353,43 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
323
353
|
}
|
|
324
354
|
request.loadedUrl = await page.url();
|
|
325
355
|
}
|
|
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
356
|
/**
|
|
364
357
|
* Function for cleaning up after all requests are processed.
|
|
365
358
|
* @ignore
|
|
366
359
|
*/
|
|
367
360
|
async teardown() {
|
|
368
|
-
await this.
|
|
361
|
+
await this.browserPoolDep.ifOwned((pool) => pool.destroy());
|
|
369
362
|
await super.teardown();
|
|
370
363
|
}
|
|
371
364
|
}
|
|
372
365
|
/** @internal */
|
|
373
|
-
|
|
366
|
+
function containsEnqueueLinks(options) {
|
|
367
|
+
return !!options.enqueueLinks;
|
|
368
|
+
}
|
|
369
|
+
/** @internal */
|
|
370
|
+
export async function browserCrawlerEnqueueLinks(options) {
|
|
371
|
+
const { options: enqueueLinksOptions, finalRequestUrl, originalRequestUrl, page } = options;
|
|
374
372
|
const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
|
|
375
|
-
enqueueStrategy:
|
|
373
|
+
enqueueStrategy: enqueueLinksOptions?.strategy,
|
|
376
374
|
finalRequestUrl,
|
|
377
375
|
originalRequestUrl,
|
|
378
|
-
userProvidedBaseUrl:
|
|
376
|
+
userProvidedBaseUrl: enqueueLinksOptions?.baseUrl,
|
|
379
377
|
});
|
|
380
|
-
const urls = await extractUrlsFromPage(page,
|
|
378
|
+
const urls = await extractUrlsFromPage(page, enqueueLinksOptions?.selector ?? 'a', enqueueLinksOptions?.baseUrl ?? finalRequestUrl ?? originalRequestUrl);
|
|
379
|
+
if (containsEnqueueLinks(options)) {
|
|
380
|
+
return options.enqueueLinks({
|
|
381
|
+
urls,
|
|
382
|
+
baseUrl,
|
|
383
|
+
...enqueueLinksOptions,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
381
386
|
return enqueueLinks({
|
|
382
|
-
|
|
383
|
-
robotsTxtFile,
|
|
384
|
-
onSkippedRequest,
|
|
387
|
+
requestManager: options.requestManager,
|
|
388
|
+
robotsTxtFile: options.robotsTxtFile,
|
|
389
|
+
onSkippedRequest: options.onSkippedRequest,
|
|
385
390
|
urls,
|
|
386
391
|
baseUrl,
|
|
387
|
-
...
|
|
392
|
+
...enqueueLinksOptions,
|
|
388
393
|
});
|
|
389
394
|
}
|
|
390
395
|
/**
|
|
@@ -412,4 +417,3 @@ page, selector, baseUrl) {
|
|
|
412
417
|
})
|
|
413
418
|
.filter((href) => !!href);
|
|
414
419
|
}
|
|
415
|
-
//# sourceMappingURL=browser-crawler.js.map
|