Skip to content

Commit 79ceb4e

Browse files
authored
feat: allow navigation hooks to override crawling context members (#3672)
`preNavigationHooks` and `postNavigationHooks` may now return a `Partial<Context>`; returned own properties are merged into the crawling context. Closes #3660
1 parent 4721cda commit 79ceb4e

14 files changed

Lines changed: 378 additions & 231 deletions

File tree

docs/guides/avoid_blocking.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ On the contrary, sometimes we want to entirely disable the usage of browser fing
5858

5959
## Camoufox
6060

61-
For some protections, using our integrated solutions is not enough, one example could be the Cloudflare challenge. For such pages, you can try [Camoufox](https://camoufox.com/), a custom stealthy build of Firefox for web scraping. It might not get you through the challenge automatically, but with our `handleCloudflareChallenge` helper, it should be able to successfully mimic the required user action and get you through it.
61+
For some protections, using our integrated solutions is not enough, one example could be the Cloudflare challenge. For such pages, you can try [Camoufox](https://camoufox.com/), a custom stealthy build of Firefox for web scraping. It might not get you through the challenge automatically, but with our `handleCloudflareChallengeHook` post-navigation hook, it should be able to successfully mimic the required user action and get you through it. The hook also reloads the page after the challenge clears and propagates the fresh response back into the crawling context.
6262

6363
<CodeBlock language="ts">
6464
{PlaywrightCamoufox}

docs/guides/avoid_blocking_camoufox.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
import { PlaywrightCrawler } from 'crawlee';
1+
import { PlaywrightCrawler, handleCloudflareChallengeHook } from 'crawlee';
22
import { launchOptions } from 'camoufox-js';
33
import { firefox } from 'playwright';
44

55
const crawler = new PlaywrightCrawler({
6-
postNavigationHooks: [
7-
async ({ handleCloudflareChallenge }) => {
8-
await handleCloudflareChallenge();
9-
},
10-
],
6+
postNavigationHooks: [handleCloudflareChallengeHook()],
117
browserPoolOptions: {
128
// Disable the default fingerprint spoofing to avoid conflicts with Camoufox.
139
useFingerprints: false,

packages/basic-crawler/src/internals/basic-crawler.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2239,17 +2239,6 @@ export class BasicCrawler<
22392239
}
22402240
}
22412241

2242-
protected async _executeHooks<HookLike extends (...args: any[]) => Awaitable<void>>(
2243-
hooks: HookLike[],
2244-
...args: Parameters<HookLike>
2245-
) {
2246-
if (Array.isArray(hooks) && hooks.length) {
2247-
for (const hook of hooks) {
2248-
await hook(...args);
2249-
}
2250-
}
2251-
}
2252-
22532242
/**
22542243
* Stops the crawler immediately.
22552244
*

packages/browser-crawler/src/internals/browser-crawler.ts

Lines changed: 81 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
Awaitable,
33
BasicCrawlerOptions,
44
BasicCrawlingContext,
5+
ContextMiddleware,
56
CrawlingContext,
67
Dictionary,
78
EnqueueLinksOptions,
@@ -58,6 +59,7 @@ export interface BrowserCrawlingContext<
5859
Response extends BaseResponse = BaseResponse,
5960
ProvidedController = BrowserController,
6061
UserData extends Dictionary = Dictionary,
62+
GoToOptions extends Dictionary = Dictionary,
6163
> extends CrawlingContext<UserData> {
6264
/**
6365
* An instance of the {@apilink BrowserController} that manages the browser instance and provides access to its API.
@@ -79,16 +81,25 @@ export interface BrowserCrawlingContext<
7981
*/
8082
response: Response;
8183

84+
/**
85+
* Options object passed to the underlying `page.goto()` call. `preNavigationHooks` can mutate this
86+
* object (or return `{ gotoOptions: ... }`) to influence the navigation.
87+
*/
88+
gotoOptions: GoToOptions;
89+
8290
/**
8391
* Helper function for extracting URLs from the current page and adding them to the request queue.
8492
*/
8593
enqueueLinks: (options?: EnqueueLinksOptions) => Promise<BatchAddRequestsResult>;
8694
}
8795

88-
export type BrowserHook<Context = BrowserCrawlingContext, GoToOptions extends Dictionary | undefined = Dictionary> = (
96+
export type BrowserHook<Context = BrowserCrawlingContext> = (
8997
crawlingContext: Context,
90-
gotoOptions: GoToOptions,
91-
) => Awaitable<void>;
98+
) => Awaitable<void | Partial<Context>>;
99+
100+
const COOKIES_BEFORE_HOOKS = Symbol('cookiesBeforeHooks');
101+
102+
const readContextField = <T>(ctx: object, key: symbol): T => (ctx as Record<symbol, unknown>)[key] as T;
92103

93104
export interface BrowserCrawlerOptions<
94105
Page extends CommonPage = CommonPage,
@@ -174,31 +185,33 @@ export interface BrowserCrawlerOptions<
174185

175186
/**
176187
* Async functions that are sequentially evaluated before the navigation. Good for setting additional cookies
177-
* or browser properties before navigation. The function accepts two parameters, `crawlingContext` and `gotoOptions`,
178-
* which are passed to the `page.goto()` function the crawler calls to navigate.
188+
* or browser properties before navigation. The function receives the `crawlingContext`; the options object
189+
* forwarded to `page.goto()` is available as `crawlingContext.gotoOptions` and can be mutated in place.
179190
*
180191
* **Example:**
181192
*
182193
* ```js
183194
* preNavigationHooks: [
184-
* async (crawlingContext, gotoOptions) => {
185-
* const { page } = crawlingContext;
195+
* async ({ page, gotoOptions }) => {
186196
* await page.evaluate((attr) => { window.foo = attr; }, 'bar');
187197
* gotoOptions.timeout = 60_000;
188198
* gotoOptions.waitUntil = 'domcontentloaded';
189199
* },
190200
* ]
191201
* ```
192202
*
193-
* Modyfing `pageOptions` is supported only in Playwright incognito.
194-
* See {@apilink PrePageCreateHook}
203+
* A hook may optionally return a partial object whose properties are merged into the crawling context,
204+
* allowing the hook to override context members for subsequent hooks and pipeline stages.
195205
*/
196206
preNavigationHooks?: BrowserHook<Context>[];
197207

198208
/**
199209
* Async functions that are sequentially evaluated after the navigation. Good for checking if the navigation was successful.
200210
* The function accepts `crawlingContext` as the only parameter.
201211
*
212+
* A hook may optionally return a partial object whose properties are merged into the crawling context.
213+
* This is useful for overriding context members (e.g. `response`) after solving a challenge.
214+
*
202215
* **Example:**
203216
*
204217
* ```js
@@ -209,6 +222,11 @@ export interface BrowserCrawlerOptions<
209222
* await solveCaptcha(page);
210223
* }
211224
* },
225+
* async (crawlingContext) => {
226+
* if (await needsRevalidation(crawlingContext)) {
227+
* return { response: await crawlingContext.page.reload() };
228+
* }
229+
* },
212230
* ]
213231
* ```
214232
*/
@@ -358,13 +376,32 @@ export abstract class BrowserCrawler<
358376
...basicCrawlerOptions
359377
} = options;
360378

379+
const skipGuard = <Ctx extends Context>(
380+
action: (ctx: Ctx) => Awaitable<void | Partial<Ctx>>,
381+
): ContextMiddleware<Ctx, Partial<Ctx>> => ({
382+
action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
383+
});
384+
361385
super({
362386
...basicCrawlerOptions,
363-
contextPipelineBuilder: () =>
364-
contextPipelineBuilder()
365-
.compose({ action: this.performNavigation.bind(this) })
387+
contextPipelineBuilder: () => {
388+
let pipeline = contextPipelineBuilder().compose({ action: this.prepareNavigation.bind(this) });
389+
390+
for (const hook of this.preNavigationHooks) {
391+
pipeline = pipeline.compose(skipGuard(hook));
392+
}
393+
394+
pipeline = pipeline.compose(skipGuard(this.navigate.bind(this)));
395+
396+
for (const hook of this.postNavigationHooks) {
397+
pipeline = pipeline.compose(skipGuard(hook));
398+
}
399+
400+
return pipeline
401+
.compose(skipGuard(this.finalizeNavigation.bind(this)))
366402
.compose({ action: this.handleBlockedRequestByContent.bind(this) })
367-
.compose({ action: this.restoreRequestState.bind(this) }),
403+
.compose({ action: this.restoreRequestState.bind(this) });
404+
},
368405
extendContext: extendContext as (context: Context) => Awaitable<ContextExtension>,
369406
});
370407

@@ -490,6 +527,9 @@ export abstract class BrowserCrawler<
490527
"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)",
491528
);
492529
},
530+
get gotoOptions(): Dictionary {
531+
throw new Error('The `gotoOptions` property is not available until `prepareNavigation` runs.');
532+
},
493533
browserController: browserControllerInstance,
494534
enqueueLinks: async (enqueueOptions: EnqueueLinksOptions = {}) => {
495535
return (await browserCrawlerEnqueueLinks({
@@ -506,10 +546,7 @@ export abstract class BrowserCrawler<
506546
};
507547
}
508548

509-
private async performNavigation(crawlingContext: Context): Promise<{
510-
request: LoadedRequest<Request>;
511-
response?: Response;
512-
}> {
549+
private async prepareNavigation(crawlingContext: Context): Promise<Partial<Context>> {
513550
if (crawlingContext.request.skipNavigation) {
514551
return {
515552
request: new Proxy(crawlingContext.request, {
@@ -527,42 +564,56 @@ export abstract class BrowserCrawler<
527564
'The `response` property is not available - `skipNavigation` was used',
528565
);
529566
},
530-
};
567+
} as Partial<Context>;
531568
}
532569

533-
const gotoOptions = { timeout: this.navigationTimeoutMillis } as unknown as GoToOptions;
570+
crawlingContext.request.state = RequestState.BEFORE_NAV;
534571

535-
const preNavigationHooksCookies = this._getCookieHeaderFromRequest(crawlingContext.request);
572+
return {
573+
gotoOptions: { timeout: this.navigationTimeoutMillis } as unknown as GoToOptions,
574+
[COOKIES_BEFORE_HOOKS]: this._getCookieHeaderFromRequest(crawlingContext.request),
575+
} as unknown as Partial<Context>;
576+
}
536577

537-
crawlingContext.request.state = RequestState.BEFORE_NAV;
538-
await this._executeHooks(this.preNavigationHooks, crawlingContext, gotoOptions);
578+
private async navigate(crawlingContext: Context): Promise<Partial<Context>> {
539579
tryCancel();
540580

541-
const postNavigationHooksCookies = this._getCookieHeaderFromRequest(crawlingContext.request);
581+
const gotoOptions = crawlingContext.gotoOptions as GoToOptions;
582+
const cookiesBeforeHooks = readContextField<string>(crawlingContext, COOKIES_BEFORE_HOOKS);
583+
const cookiesAfterHooks = this._getCookieHeaderFromRequest(crawlingContext.request);
542584

543-
await this._applyCookies(crawlingContext, preNavigationHooksCookies, postNavigationHooksCookies);
585+
await this._applyCookies(crawlingContext, cookiesBeforeHooks, cookiesAfterHooks);
544586

545587
let response: Response | undefined;
546-
547588
try {
548589
response = (await this._navigationHandler(crawlingContext, gotoOptions)) ?? undefined;
549590
} catch (error) {
550591
await this._handleNavigationTimeout(crawlingContext, error as Error);
551-
552592
crawlingContext.request.state = RequestState.ERROR;
553-
554593
this._throwIfProxyError(error as Error);
555594
throw error;
556595
}
557596
tryCancel();
558597

559598
crawlingContext.request.state = RequestState.AFTER_NAV;
560-
await this._executeHooks(this.postNavigationHooks, crawlingContext, gotoOptions);
599+
600+
return { response } as Partial<Context>;
601+
}
602+
603+
private async finalizeNavigation(crawlingContext: Context): Promise<Partial<Context>> {
604+
tryCancel();
605+
606+
let response: Response | undefined;
607+
try {
608+
response = crawlingContext.response;
609+
} catch {
610+
// `preparePage` installs a throwing getter for `response`; reaching this branch means
611+
// navigation produced no response and no hook overrode it. Treat as undefined.
612+
}
561613

562614
await this.processResponse(response, crawlingContext);
563615
tryCancel();
564616

565-
// save cookies
566617
// TODO: Should we save the cookies also after/only the handle page?
567618
if (this.saveResponseCookies && crawlingContext.session) {
568619
const cookies = await crawlingContext.browserController.getCookies(crawlingContext.page);
@@ -579,16 +630,7 @@ export abstract class BrowserCrawler<
579630
}
580631
}
581632

582-
if (response !== undefined) {
583-
return {
584-
request: crawlingContext.request as LoadedRequest<Request>,
585-
response,
586-
};
587-
}
588-
589-
return {
590-
request: crawlingContext.request as LoadedRequest<Request>,
591-
};
633+
return { request: crawlingContext.request as LoadedRequest<Request> } as Partial<Context>;
592634
}
593635

594636
private async handleBlockedRequestByContent(

0 commit comments

Comments
 (0)