@crawlee/browser 4.0.0-beta.99 → 4.0.0-rc.1
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 +1 -1
- package/internals/browser-crawler.d.ts +172 -168
- package/internals/browser-crawler.js +149 -170
- package/internals/browser-launcher.d.ts +55 -23
- package/internals/browser-launcher.js +66 -16
- package/package.json +9 -10
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie, EnqueueStrategy, NavigationSkippedError, OwnedOrInjected, remainingNavigationWindowMillis, RequestState, ContextPipelineInitializationError, RequestHandlerError, RequestThrottledError, resolveBaseUrlForEnqueueLinksFiltering, SessionError, SessionRetiredError, toughCookieToBrowserPoolCookie, validators, } from '@crawlee/basic';
|
|
2
|
+
import { assertBrowserPoolNotConfigured, CLOUDFLARE_RETRY_CSS_SELECTORS, parseArgument, RETRY_CSS_SELECTORS, schemas, tryAbsoluteURL, } from '@crawlee/utils/internal';
|
|
3
|
+
import { sleep } from '@crawlee/utils';
|
|
4
|
+
import { z } from 'zod';
|
|
5
5
|
import { addTimeoutToPromise, TimeoutError, tryCancel } from '@apify/timeout';
|
|
6
6
|
const COOKIES_BEFORE_HOOKS = Symbol('cookiesBeforeHooks');
|
|
7
7
|
const readContextField = (ctx, key) => ctx[key];
|
|
@@ -58,43 +58,50 @@ function isNavigationTimeoutError(error) {
|
|
|
58
58
|
*/
|
|
59
59
|
export class BrowserCrawler extends BasicCrawler {
|
|
60
60
|
/** Backs the {@link BrowserCrawler.browserPool|`browserPool`} getter. */
|
|
61
|
-
browserPoolDep;
|
|
61
|
+
#browserPoolDep;
|
|
62
62
|
/**
|
|
63
63
|
* A reference to the underlying browser pool that manages the crawler's browsers. Typed as
|
|
64
64
|
* {@link IBrowserPool} so custom implementations can be plugged in via the `browserPool` constructor option.
|
|
65
65
|
*/
|
|
66
66
|
get browserPool() {
|
|
67
|
-
return this
|
|
67
|
+
return this.#browserPoolDep.value;
|
|
68
68
|
}
|
|
69
|
-
launchContext;
|
|
70
69
|
ignoreShadowRoots;
|
|
71
70
|
ignoreIframes;
|
|
72
|
-
navigationTimeoutMillis;
|
|
73
|
-
preNavigationHooks;
|
|
74
|
-
postNavigationHooks;
|
|
75
|
-
saveResponseCookies;
|
|
71
|
+
#navigationTimeoutMillis;
|
|
72
|
+
#preNavigationHooks;
|
|
73
|
+
#postNavigationHooks;
|
|
74
|
+
#saveResponseCookies;
|
|
75
|
+
/**
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
76
78
|
static optionsShape = {
|
|
77
79
|
...BasicCrawler.optionsShape,
|
|
78
|
-
navigationTimeoutSecs:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
browserPool:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
saveResponseCookies:
|
|
87
|
-
proxyConfiguration:
|
|
80
|
+
navigationTimeoutSecs: schemas.anyNumber
|
|
81
|
+
.refine((value) => value > 0, 'Expected a number greater than 0')
|
|
82
|
+
.default(60),
|
|
83
|
+
preNavigationHooks: schemas.anyArray.default(() => []),
|
|
84
|
+
postNavigationHooks: schemas.anyArray.default(() => []),
|
|
85
|
+
browserPool: validators.browserPool.optional(),
|
|
86
|
+
browserPoolBuilder: schemas.anyFunction.optional(),
|
|
87
|
+
remoteBrowser: schemas.anyObject.optional(),
|
|
88
|
+
saveResponseCookies: z.boolean().default(true),
|
|
89
|
+
proxyConfiguration: validators.proxyConfiguration.optional(),
|
|
90
|
+
ignoreIframes: z.boolean().default(false),
|
|
91
|
+
ignoreShadowRoots: z.boolean().default(false),
|
|
88
92
|
};
|
|
93
|
+
/** @internal */
|
|
94
|
+
static optionsSchema = z.strictObject(BrowserCrawler.optionsShape);
|
|
89
95
|
/**
|
|
90
96
|
* All `BrowserCrawler` parameters are passed via an options object.
|
|
97
|
+
* @internal
|
|
91
98
|
*/
|
|
92
99
|
constructor(options) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
});
|
|
100
|
+
const { navigationTimeoutSecs, saveResponseCookies, browserPool, remoteBrowser, preNavigationHooks, postNavigationHooks, ignoreIframes, ignoreShadowRoots, contextPipelineBuilder, browserPoolBuilder, extendContext, ...basicCrawlerOptions } = parseArgument(options, BrowserCrawler.optionsSchema, 'BrowserCrawlerOptions');
|
|
101
|
+
if (browserPool) {
|
|
102
|
+
assertBrowserPoolNotConfigured(new.target.name, { remoteBrowser });
|
|
103
|
+
}
|
|
104
|
+
const skipGuard = (action) => async (ctx) => ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {});
|
|
98
105
|
super({
|
|
99
106
|
...basicCrawlerOptions,
|
|
100
107
|
contextPipelineBuilder: () => {
|
|
@@ -103,107 +110,64 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
103
110
|
// hook eats into the same window the navigation uses. The navigation itself is bounded by
|
|
104
111
|
// capping its `gotoOptions.timeout` to the remaining budget.
|
|
105
112
|
const windowGuard = (step) => skipGuard(async (ctx) => {
|
|
106
|
-
const remaining = remainingNavigationWindowMillis(ctx, this
|
|
113
|
+
const remaining = remainingNavigationWindowMillis(ctx, this.#navigationTimeoutMillis);
|
|
107
114
|
if (remaining <= 0) {
|
|
108
|
-
throw new TimeoutError(`Navigation timed out after ${this
|
|
115
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
109
116
|
}
|
|
110
|
-
return addTimeoutToPromise(async () => step(ctx), remaining, `Navigation timed out after ${this
|
|
117
|
+
return addTimeoutToPromise(async () => step(ctx), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
111
118
|
});
|
|
112
|
-
let pipeline = contextPipelineBuilder().compose(
|
|
113
|
-
for (const hook of this
|
|
119
|
+
let pipeline = contextPipelineBuilder().compose(this.#prepareNavigation.bind(this));
|
|
120
|
+
for (const hook of this.#preNavigationHooks) {
|
|
114
121
|
pipeline = pipeline.compose(windowGuard(hook));
|
|
115
122
|
}
|
|
116
123
|
pipeline = pipeline.compose(skipGuard(this.navigate.bind(this)));
|
|
117
|
-
for (const hook of this
|
|
124
|
+
for (const hook of this.#postNavigationHooks) {
|
|
118
125
|
pipeline = pipeline.compose(windowGuard(hook));
|
|
119
126
|
}
|
|
120
127
|
return pipeline
|
|
121
|
-
.compose(skipGuard(this
|
|
122
|
-
.compose(
|
|
123
|
-
.compose(
|
|
128
|
+
.compose(skipGuard(this.#finalizeNavigation.bind(this)))
|
|
129
|
+
.compose(this.#handleBlockedRequestByContent.bind(this))
|
|
130
|
+
.compose(this.#restoreRequestState.bind(this));
|
|
124
131
|
},
|
|
125
132
|
extendContext,
|
|
126
133
|
});
|
|
127
|
-
this
|
|
128
|
-
this.navigationTimeoutMillis = navigationTimeoutSecs * 1000;
|
|
134
|
+
this.#navigationTimeoutMillis = navigationTimeoutSecs * 1000;
|
|
129
135
|
// The public option hooks are extension-aware; internal storage uses the base context type
|
|
130
136
|
// (the pipeline composes hooks against the concrete context, which does not statically carry
|
|
131
137
|
// `ContextExtension`). The extension members are present at runtime regardless.
|
|
132
|
-
this
|
|
133
|
-
this
|
|
138
|
+
this.#preNavigationHooks = preNavigationHooks;
|
|
139
|
+
this.#postNavigationHooks = postNavigationHooks;
|
|
134
140
|
this.ignoreIframes = ignoreIframes;
|
|
135
141
|
this.ignoreShadowRoots = ignoreShadowRoots;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
this.launchContext.launchOptions.headless = headless;
|
|
139
|
-
}
|
|
140
|
-
this.saveResponseCookies = saveResponseCookies;
|
|
141
|
-
// `browserPool` wins over `remoteBrowser` — a passed-in pool is used as-is (borrowed), the sugar is ignored.
|
|
142
|
-
// The default is only built when no pool was injected, so all the option/launchContext fiddling below stays
|
|
143
|
-
// inside the factory.
|
|
144
|
-
this.browserPoolDep = OwnedOrInjected.resolve(browserPool, () => {
|
|
145
|
-
const resolvedBrowserPoolOptions = browserPoolOptions ?? {};
|
|
146
|
-
if (launchContext?.userAgent) {
|
|
147
|
-
if (resolvedBrowserPoolOptions.useFingerprints)
|
|
148
|
-
this.log.info('Custom user agent provided, disabling automatic browser fingerprint injection!');
|
|
149
|
-
resolvedBrowserPoolOptions.useFingerprints = false;
|
|
150
|
-
}
|
|
151
|
-
if (remoteBrowser) {
|
|
152
|
-
// The crawler already built the right plugin for its browser — hand it to a RemoteBrowserPool so the
|
|
153
|
-
// remote connection is always for the matching browser (no plugin to construct, no way to mismatch).
|
|
154
|
-
const { browserPlugins, ...remoteBrowserPoolOptions } = resolvedBrowserPoolOptions;
|
|
155
|
-
return new RemoteBrowserPool({
|
|
156
|
-
browserPlugins: browserPlugins,
|
|
157
|
-
...remoteBrowser,
|
|
158
|
-
browserPoolOptions: remoteBrowserPoolOptions,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
// Double cast: `BrowserPool` implements `IBrowserPool<PageReturn>`, where `PageReturn` is derived from the
|
|
162
|
-
// plugin/controller generics and doesn't overlap with the crawler's free `Page` type param, so TS won't
|
|
163
|
-
// narrow it directly. The concrete pool does satisfy the `Page`/`destroy` contract at runtime — this is the
|
|
164
|
-
// long-standing `Page` variance gap, not a `destroy`-related hole.
|
|
165
|
-
return new BrowserPool({
|
|
166
|
-
...resolvedBrowserPoolOptions,
|
|
167
|
-
});
|
|
168
|
-
});
|
|
142
|
+
this.#saveResponseCookies = saveResponseCookies;
|
|
143
|
+
this.#browserPoolDep = OwnedOrInjected.resolve(browserPool, () => browserPoolBuilder(remoteBrowser));
|
|
169
144
|
}
|
|
145
|
+
/** @internal */
|
|
170
146
|
getNavigationTimeoutMillis() {
|
|
171
|
-
return this
|
|
147
|
+
return this.#navigationTimeoutMillis;
|
|
172
148
|
}
|
|
173
149
|
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
|
-
});
|
|
150
|
+
return ContextPipeline.create().compose(this.#preparePage.bind(this));
|
|
187
151
|
}
|
|
188
|
-
async containsSelectors(page, selectors) {
|
|
152
|
+
async #containsSelectors(page, selectors) {
|
|
189
153
|
const foundSelectors = (await Promise.all(selectors.map((selector) => page.$(selector))))
|
|
190
154
|
.map((x, i) => [x, selectors[i]])
|
|
191
155
|
.filter(([x]) => x !== null)
|
|
192
156
|
.map(([, selector]) => selector);
|
|
193
157
|
return foundSelectors.length > 0 ? foundSelectors : null;
|
|
194
158
|
}
|
|
195
|
-
async isRequestBlocked(crawlingContext) {
|
|
159
|
+
async #isRequestBlocked(crawlingContext) {
|
|
196
160
|
const { page, response } = crawlingContext;
|
|
197
161
|
// Cloudflare specific heuristic - wait 5 seconds if we get a 403 for the JS challenge to load / resolve.
|
|
198
|
-
if ((await this
|
|
162
|
+
if ((await this.#containsSelectors(page, CLOUDFLARE_RETRY_CSS_SELECTORS)) && response?.status() === 403) {
|
|
199
163
|
await sleep(5000);
|
|
200
164
|
// here we cannot test for response code, because we only have the original response, not the possible Cloudflare redirect on passed challenge.
|
|
201
|
-
const foundSelectors = await this
|
|
165
|
+
const foundSelectors = await this.#containsSelectors(page, RETRY_CSS_SELECTORS);
|
|
202
166
|
if (!foundSelectors)
|
|
203
167
|
return false;
|
|
204
168
|
return `Cloudflare challenge failed, found selectors: ${foundSelectors.join(', ')}`;
|
|
205
169
|
}
|
|
206
|
-
const foundSelectors = await this
|
|
170
|
+
const foundSelectors = await this.#containsSelectors(page, RETRY_CSS_SELECTORS);
|
|
207
171
|
const statusCode = response?.status() ?? 0;
|
|
208
172
|
if (foundSelectors)
|
|
209
173
|
return `Found selectors: ${foundSelectors.join(', ')}`;
|
|
@@ -211,13 +175,38 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
211
175
|
return `Received blocked status code: ${statusCode}`;
|
|
212
176
|
return false;
|
|
213
177
|
}
|
|
214
|
-
async preparePage(crawlingContext) {
|
|
178
|
+
async #preparePage(crawlingContext, onCleanup) {
|
|
215
179
|
const page = await this.browserPool.newPage({
|
|
216
180
|
id: crawlingContext.id,
|
|
217
181
|
session: crawlingContext.session,
|
|
218
182
|
});
|
|
219
183
|
tryCancel();
|
|
220
|
-
|
|
184
|
+
onCleanup((failure) => {
|
|
185
|
+
crawlingContext.registerDeferredCleanup(async () => {
|
|
186
|
+
const cause = failure instanceof RequestHandlerError || failure instanceof ContextPipelineInitializationError
|
|
187
|
+
? failure.cause
|
|
188
|
+
: failure;
|
|
189
|
+
// Only tells the pool what to do with the page's browser state - the request's failure, if any, has
|
|
190
|
+
// already been handled. A thrown `SessionError` or a blocked session counts as a block; a session
|
|
191
|
+
// unusable for any other reason is reported as retired. `retire()`/`markBad()` also bump the usage
|
|
192
|
+
// count, so the block check must come first.
|
|
193
|
+
const { session } = crawlingContext;
|
|
194
|
+
const closeReason = cause instanceof SessionError
|
|
195
|
+
? cause
|
|
196
|
+
: session.isBlocked()
|
|
197
|
+
? new SessionError()
|
|
198
|
+
: session.isUsable()
|
|
199
|
+
? undefined
|
|
200
|
+
: new SessionRetiredError();
|
|
201
|
+
await this.browserPool
|
|
202
|
+
.closePage(page, { error: closeReason })
|
|
203
|
+
.catch((closeError) => this.log.debug('Error while closing page', { error: closeError }));
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
const addRequests = crawlingContext.addRequests;
|
|
207
|
+
const extractLinks = async (options) => {
|
|
208
|
+
return extractUrlsFromPage(page, options?.selector ?? 'a', options?.baseUrl ?? crawlingContext.request.loadedUrl ?? crawlingContext.request.url);
|
|
209
|
+
};
|
|
221
210
|
return {
|
|
222
211
|
page,
|
|
223
212
|
get response() {
|
|
@@ -226,33 +215,36 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
226
215
|
get gotoOptions() {
|
|
227
216
|
throw new Error('The `gotoOptions` property is not available until `prepareNavigation` runs.');
|
|
228
217
|
},
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
limit: await this.calculateEnqueuedRequestLimit(enqueueOptions?.limit),
|
|
234
|
-
},
|
|
235
|
-
page,
|
|
236
|
-
requestManager: await this.getRequestManager(),
|
|
237
|
-
robotsTxtFile: await this.getRobotsTxtFileForUrl(crawlingContext.request.url),
|
|
238
|
-
onSkippedRequest: this.handleSkippedRequest,
|
|
239
|
-
originalRequestUrl: crawlingContext.request.url,
|
|
218
|
+
extractLinks,
|
|
219
|
+
enqueueLinks: async (options = {}) => {
|
|
220
|
+
const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
|
|
221
|
+
enqueueStrategy: options.strategy,
|
|
240
222
|
finalRequestUrl: crawlingContext.request.loadedUrl,
|
|
241
|
-
|
|
242
|
-
|
|
223
|
+
originalRequestUrl: crawlingContext.request.url,
|
|
224
|
+
userProvidedBaseUrl: options.baseUrl,
|
|
225
|
+
});
|
|
226
|
+
const urls = await extractLinks(options);
|
|
227
|
+
return addRequests(urls, {
|
|
228
|
+
...options,
|
|
229
|
+
baseUrl,
|
|
230
|
+
strategy: options.strategy ?? EnqueueStrategy.SameHostname,
|
|
231
|
+
});
|
|
243
232
|
},
|
|
244
233
|
};
|
|
245
234
|
}
|
|
246
|
-
async prepareNavigation(crawlingContext) {
|
|
235
|
+
async #prepareNavigation(crawlingContext) {
|
|
247
236
|
if (crawlingContext.request.skipNavigation) {
|
|
248
237
|
return {
|
|
249
238
|
request: new Proxy(crawlingContext.request, {
|
|
250
|
-
get(target, propertyName
|
|
239
|
+
get(target, propertyName) {
|
|
251
240
|
if (propertyName === 'loadedUrl') {
|
|
252
241
|
throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
|
|
253
242
|
}
|
|
254
|
-
|
|
243
|
+
// `target` as receiver and bound methods, or `#` members throw on the proxy
|
|
244
|
+
const value = Reflect.get(target, propertyName, target);
|
|
245
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
255
246
|
},
|
|
247
|
+
set: (target, propertyName, value) => Reflect.set(target, propertyName, value, target),
|
|
256
248
|
}),
|
|
257
249
|
get response() {
|
|
258
250
|
throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
|
|
@@ -263,43 +255,44 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
263
255
|
return {
|
|
264
256
|
// Default to the full navigation timeout so a pre-navigation hook can read it; `navigate` narrows it
|
|
265
257
|
// to the remaining shared window unless a hook overrode it (see there).
|
|
266
|
-
gotoOptions: { timeout: this
|
|
267
|
-
[COOKIES_BEFORE_HOOKS]: this.
|
|
258
|
+
gotoOptions: { timeout: this.#navigationTimeoutMillis },
|
|
259
|
+
[COOKIES_BEFORE_HOOKS]: this.getCookieHeaderFromRequest(crawlingContext.request),
|
|
268
260
|
};
|
|
269
261
|
}
|
|
262
|
+
// oxlint-disable-next-line crawlee/prefer-private-fields -- patched by @crawlee/otel
|
|
270
263
|
async navigate(crawlingContext) {
|
|
271
264
|
tryCancel();
|
|
272
265
|
const gotoOptions = crawlingContext.gotoOptions;
|
|
273
|
-
const remaining = remainingNavigationWindowMillis(crawlingContext, this
|
|
266
|
+
const remaining = remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis);
|
|
274
267
|
if (remaining <= 0) {
|
|
275
|
-
throw new TimeoutError(`Navigation timed out after ${this
|
|
268
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
276
269
|
}
|
|
277
270
|
// If a hook left the default `navigationTimeoutMillis` in place, bound the goto to whatever is left of the
|
|
278
271
|
// shared navigation window. If it overrode the value - including `0`, Playwright's "no timeout" - honour
|
|
279
272
|
// that verbatim as the goto's own timeout. The driver enforces this natively (so a timed-out goto is
|
|
280
273
|
// aborted, not left lingering) and `handleNavigationTimeout` turns its error into our own message.
|
|
281
274
|
const gotoTimeout = gotoOptions;
|
|
282
|
-
if (gotoTimeout.timeout === this
|
|
275
|
+
if (gotoTimeout.timeout === this.#navigationTimeoutMillis) {
|
|
283
276
|
gotoTimeout.timeout = remaining;
|
|
284
277
|
}
|
|
285
278
|
const cookiesBeforeHooks = readContextField(crawlingContext, COOKIES_BEFORE_HOOKS);
|
|
286
|
-
const cookiesAfterHooks = this.
|
|
287
|
-
await this
|
|
279
|
+
const cookiesAfterHooks = this.getCookieHeaderFromRequest(crawlingContext.request);
|
|
280
|
+
await this.#applyCookies(crawlingContext, cookiesBeforeHooks, cookiesAfterHooks);
|
|
288
281
|
let response;
|
|
289
282
|
try {
|
|
290
|
-
response = (await this.
|
|
283
|
+
response = (await this.navigationHandler(crawlingContext, gotoOptions)) ?? undefined;
|
|
291
284
|
}
|
|
292
285
|
catch (error) {
|
|
293
|
-
await this
|
|
286
|
+
await this.#handleNavigationTimeout(crawlingContext, error);
|
|
294
287
|
crawlingContext.request.state = RequestState.ERROR;
|
|
295
|
-
this
|
|
288
|
+
this.#throwIfProxyError(error);
|
|
296
289
|
throw error;
|
|
297
290
|
}
|
|
298
291
|
tryCancel();
|
|
299
292
|
crawlingContext.request.state = RequestState.AFTER_NAV;
|
|
300
293
|
return { response };
|
|
301
294
|
}
|
|
302
|
-
async finalizeNavigation(crawlingContext) {
|
|
295
|
+
async #finalizeNavigation(crawlingContext) {
|
|
303
296
|
tryCancel();
|
|
304
297
|
let response;
|
|
305
298
|
try {
|
|
@@ -309,18 +302,18 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
309
302
|
// `preparePage` installs a throwing getter for `response`; reaching this branch means
|
|
310
303
|
// navigation produced no response and no hook overrode it. Treat as undefined.
|
|
311
304
|
}
|
|
312
|
-
await this
|
|
305
|
+
await this.#processResponse(response, crawlingContext);
|
|
313
306
|
tryCancel();
|
|
314
307
|
// Persist cookies from the navigation response before the user handler runs.
|
|
315
308
|
// Cookies set during `requestHandler` are saved again afterwards.
|
|
316
|
-
await this
|
|
309
|
+
await this.#persistCookiesFromPage(crawlingContext);
|
|
317
310
|
return { request: crawlingContext.request };
|
|
318
311
|
}
|
|
319
312
|
/**
|
|
320
313
|
* Copies cookies from the live browser page into the session cookie jar.
|
|
321
314
|
*/
|
|
322
|
-
async persistCookiesFromPage(crawlingContext) {
|
|
323
|
-
if (!this
|
|
315
|
+
async #persistCookiesFromPage(crawlingContext) {
|
|
316
|
+
if (!this.#saveResponseCookies || !crawlingContext.session) {
|
|
324
317
|
return;
|
|
325
318
|
}
|
|
326
319
|
const { cookies } = await this.browserPool.extractPageState(crawlingContext.page);
|
|
@@ -329,7 +322,7 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
329
322
|
const url = (await crawlingContext.page.url()) || crawlingContext.request.loadedUrl || crawlingContext.request.url;
|
|
330
323
|
for (const cookie of cookies) {
|
|
331
324
|
try {
|
|
332
|
-
crawlingContext.session.cookieJar.
|
|
325
|
+
await crawlingContext.session.cookieJar.setCookie(browserPoolCookieToToughCookie(cookie), url, {
|
|
333
326
|
ignoreError: false,
|
|
334
327
|
});
|
|
335
328
|
}
|
|
@@ -341,6 +334,7 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
341
334
|
/**
|
|
342
335
|
* Runs the user request handler, then re-reads browser cookies so login flows /
|
|
343
336
|
* `page.setCookie` / XHR `Set-Cookie` updates are stored for later requests.
|
|
337
|
+
* @internal
|
|
344
338
|
*/
|
|
345
339
|
async runRequestHandler(crawlingContext) {
|
|
346
340
|
try {
|
|
@@ -349,7 +343,7 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
349
343
|
finally {
|
|
350
344
|
if (!crawlingContext.request.skipNavigation) {
|
|
351
345
|
try {
|
|
352
|
-
await this
|
|
346
|
+
await this.#persistCookiesFromPage(crawlingContext);
|
|
353
347
|
}
|
|
354
348
|
catch {
|
|
355
349
|
// Page may already be closed on some failure paths; ignore.
|
|
@@ -357,20 +351,22 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
357
351
|
}
|
|
358
352
|
}
|
|
359
353
|
}
|
|
360
|
-
async handleBlockedRequestByContent(crawlingContext) {
|
|
354
|
+
async #handleBlockedRequestByContent(crawlingContext) {
|
|
361
355
|
if (this.retryOnBlocked) {
|
|
362
|
-
const error = await this
|
|
356
|
+
const error = await this.#isRequestBlocked(crawlingContext);
|
|
363
357
|
if (error)
|
|
364
358
|
throw new SessionError(error);
|
|
365
359
|
}
|
|
366
360
|
return {};
|
|
367
361
|
}
|
|
368
|
-
async restoreRequestState(crawlingContext) {
|
|
362
|
+
async #restoreRequestState(crawlingContext) {
|
|
369
363
|
crawlingContext.request.state = RequestState.REQUEST_HANDLER;
|
|
370
364
|
return {};
|
|
371
365
|
}
|
|
372
|
-
async applyCookies({ session, request, page }, preHooksCookies, postHooksCookies) {
|
|
373
|
-
const sessionCookie = session
|
|
366
|
+
async #applyCookies({ session, request, page }, preHooksCookies, postHooksCookies) {
|
|
367
|
+
const sessionCookie = session
|
|
368
|
+
? (await session.cookieJar.getCookies(request.url)).map(toughCookieToBrowserPoolCookie)
|
|
369
|
+
: [];
|
|
374
370
|
const parsedPreHooksCookies = preHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
|
|
375
371
|
const parsedPostHooksCookies = postHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
|
|
376
372
|
const cookies = [...sessionCookie, ...parsedPreHooksCookies, ...parsedPostHooksCookies]
|
|
@@ -381,7 +377,7 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
381
377
|
/**
|
|
382
378
|
* Marks session bad on navigation timeout, and stops in-flight page loading on any navigation error.
|
|
383
379
|
*/
|
|
384
|
-
async handleNavigationTimeout(crawlingContext, error) {
|
|
380
|
+
async #handleNavigationTimeout(crawlingContext, error) {
|
|
385
381
|
const { session, page } = crawlingContext;
|
|
386
382
|
// Fire-and-forget: no user code will run on this page after a failed navigation.
|
|
387
383
|
// Swallow rejections: the page may already be detached.
|
|
@@ -390,22 +386,31 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
390
386
|
session?.markBad();
|
|
391
387
|
// The driver was handed the remaining window (usually shorter than `navigationTimeoutSecs` once the
|
|
392
388
|
// 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
|
|
389
|
+
throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
|
|
394
390
|
}
|
|
395
391
|
}
|
|
396
392
|
/**
|
|
397
393
|
* Transforms proxy-related errors to `SessionError`.
|
|
398
394
|
*/
|
|
399
|
-
throwIfProxyError(error) {
|
|
395
|
+
#throwIfProxyError(error) {
|
|
400
396
|
if (this.isProxyError(error)) {
|
|
401
|
-
throw new SessionError(this.
|
|
397
|
+
throw new SessionError(this.getMessageFromError(error));
|
|
402
398
|
}
|
|
403
399
|
}
|
|
404
|
-
async processResponse(response, crawlingContext) {
|
|
400
|
+
async #processResponse(response, crawlingContext) {
|
|
405
401
|
const { session, request, page } = crawlingContext;
|
|
406
402
|
if (typeof response === 'object' && typeof response.status === 'function') {
|
|
407
403
|
const status = response.status();
|
|
408
|
-
this.
|
|
404
|
+
this.statistics.registerStatusCode(status);
|
|
405
|
+
// Ahead of the error-status throw below: a 429 the user opted into treating as an error is still a
|
|
406
|
+
// rate limit the domain should back off from.
|
|
407
|
+
if (status === 429) {
|
|
408
|
+
// Both drivers lower-case header names and join duplicates, so a plain lookup is enough.
|
|
409
|
+
const retryAfter = response.headers?.()['retry-after'];
|
|
410
|
+
if (this.recordDomainRateLimit(crawlingContext.request.url, retryAfter)) {
|
|
411
|
+
throw new RequestThrottledError(`${crawlingContext.request.url} responded with 429.`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
409
414
|
if (this.isErrorStatusCode(status)) {
|
|
410
415
|
if (this.additionalHttpErrorStatusCodes.has(status)) {
|
|
411
416
|
throw new Error(`${status} - Error status code was set by user.`);
|
|
@@ -415,7 +420,7 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
415
420
|
}
|
|
416
421
|
if (this.sessionPool && response && session) {
|
|
417
422
|
if (typeof response === 'object' && typeof response.status === 'function') {
|
|
418
|
-
this.
|
|
423
|
+
this.throwOnBlockedRequest(response.status());
|
|
419
424
|
}
|
|
420
425
|
else {
|
|
421
426
|
this.log.debug('Got a malformed Browser response.', { request, response });
|
|
@@ -424,43 +429,17 @@ export class BrowserCrawler extends BasicCrawler {
|
|
|
424
429
|
request.loadedUrl = await page.url();
|
|
425
430
|
}
|
|
426
431
|
/**
|
|
427
|
-
*
|
|
428
|
-
*
|
|
432
|
+
* Closes the browsers of a pool the crawler owns, so a finished run leaves none behind. The pool itself is
|
|
433
|
+
* crawler-lifetime and survives — destroying it here would hand a repeated `run()` a dead pool.
|
|
429
434
|
*/
|
|
430
435
|
async teardown() {
|
|
431
|
-
await this
|
|
436
|
+
await this.#browserPoolDep.ifOwned((pool) => pool.releaseAllBrowsers());
|
|
432
437
|
await super.teardown();
|
|
433
438
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
return !!options.enqueueLinks;
|
|
438
|
-
}
|
|
439
|
-
/** @internal */
|
|
440
|
-
export async function browserCrawlerEnqueueLinks(options) {
|
|
441
|
-
const { options: enqueueLinksOptions, finalRequestUrl, originalRequestUrl, page } = options;
|
|
442
|
-
const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
|
|
443
|
-
enqueueStrategy: enqueueLinksOptions?.strategy,
|
|
444
|
-
finalRequestUrl,
|
|
445
|
-
originalRequestUrl,
|
|
446
|
-
userProvidedBaseUrl: enqueueLinksOptions?.baseUrl,
|
|
447
|
-
});
|
|
448
|
-
const urls = await extractUrlsFromPage(page, enqueueLinksOptions?.selector ?? 'a', enqueueLinksOptions?.baseUrl ?? finalRequestUrl ?? originalRequestUrl);
|
|
449
|
-
if (containsEnqueueLinks(options)) {
|
|
450
|
-
return options.enqueueLinks({
|
|
451
|
-
urls,
|
|
452
|
-
baseUrl,
|
|
453
|
-
...enqueueLinksOptions,
|
|
454
|
-
});
|
|
439
|
+
async destroy() {
|
|
440
|
+
await super.destroy();
|
|
441
|
+
await this.#browserPoolDep.ifOwned((pool) => pool.destroy());
|
|
455
442
|
}
|
|
456
|
-
return enqueueLinks({
|
|
457
|
-
requestManager: options.requestManager,
|
|
458
|
-
robotsTxtFile: options.robotsTxtFile,
|
|
459
|
-
onSkippedRequest: options.onSkippedRequest,
|
|
460
|
-
urls,
|
|
461
|
-
baseUrl,
|
|
462
|
-
...enqueueLinksOptions,
|
|
463
|
-
});
|
|
464
443
|
}
|
|
465
444
|
/**
|
|
466
445
|
* Extracts URLs from a given page.
|