Repository navigation
Conversation
An HTTP client that sends requests with `fetch()` inside a logged-in Playwright page, so HTTP crawlers can use the browser's session. Closes apify#4145
The client needs a Playwright page to work, and only Playwright's `Page` satisfies the structural `BrowserFetchPage` interface, so it belongs next to the other Playwright code rather than in the dependency-free `@crawlee/http-client`. It is now also exported from `crawlee`.
The client now takes a Playwright `BrowserContext` instead of a page, and opens one page per origin on an empty document served by a route. Any origin can be fetched with the cookies of the context without loading the site, and a login in another page of the context no longer destroys the requests in flight. The `login` callback is gone. A 401 already throws a `SessionError` and `errorHandler` runs before the request is retried, so that is the place to log in again.
…ient The example passes a browser context and logs in again from `errorHandler` on a 401, and the same-origin limit is gone from the guide. It also mentions that the browser sends only a few HTTP/1.1 requests to one host at a time.
Close a page that crashes instead of leaving it open next to the one that replaces it. The docs now say that redirects to another origin fail, as the browser treats them as cross-origin requests, and that the 401 handling relies on the default `blockedStatusCodes`.
Since Playwright 1.52, `evaluate` serializes typed arrays in both directions, so the client no longer base64-encodes request and response bodies itself. The `playwright` peer dependency of `@crawlee/playwright` is raised to `^1.52.0` accordingly, as older versions would mangle the bodies.
The new `maxOpenPages` option (20 by default, like `maxOpenPagesPerBrowser`) caps the pages the client keeps open. Once there are more, the least recently used ones are closed. Pages with requests in flight are never closed, as that would fail the requests, so the limit can be exceeded while more origins are fetched at once.
`#fetchInPage` now takes the `Request` and returns the `Response`, which drops the intermediate request and response shapes. The abort and the in-flight page tests wait for the held request to reach the server instead of relying on fixed delays.
BrowserFetchHttpClientBrowserFetchHttpClient
|
cc @janbuchar I hijacked this PR and shaped it in a slightly different direction. Would you mind reviewing this, as I am biased now? 🙏 |
|
Thanks @noahg9 and @barjin! Two initial observations:
|
|
@barjin Another high-level question — how does this integrate with |
janbuchar
left a comment
There was a problem hiding this comment.
I like the general shape of this, but we should think about the important details of how this fits in with the rest of the framework.
|
|
||
| <CodeBlock language="ts">{CheerioBrowserFetchExample}</CodeBlock> | ||
|
|
||
| The client opens one page per origin on an empty document of that origin, without loading the site, so URLs of any origin can be fetched with the cookies of the context. It keeps up to `maxOpenPages` of them open (20 by default) and closes the least recently used idle ones. With the default `blockedStatusCodes`, a `401` response fails the request, and `errorHandler` runs before it is retried, so log in again there, in a new page of the context. Keep in mind that: |
There was a problem hiding this comment.
one page per origin
What exactly does "origin" mean in this context?
| - the browser uses its own cookies and hides the `Cookie` and `Set-Cookie` headers, so the session's cookie jar is not used, | ||
| - the `proxyUrl`, `fingerprint` and `ignoreTlsErrors` options are ignored, as the browser context decides them, |
There was a problem hiding this comment.
I understand why that happens, but can I beat the browser into the shape dictated by my session? The cookie jar discrepancy sounds like a real footgun...
| - the browser uses its own cookies and hides the `Cookie` and `Set-Cookie` headers, so the session's cookie jar is not used, | ||
| - the `proxyUrl`, `fingerprint` and `ignoreTlsErrors` options are ignored, as the browser context decides them, |
There was a problem hiding this comment.
Worth mentioning you probably want to adjust your concurrency settings to match that? Or add a load signal to your concurrency system so that you don't try to scale past the limit?
| let server: Server; | ||
| let otherPortServer: Server; | ||
| let baseUrl: string; | ||
| let otherUrl: string; | ||
| let otherPortUrl: string; | ||
| let browser: Browser; | ||
| let context: BrowserContext; | ||
| let logLevel: number; | ||
|
|
||
| const sessions = new Set<string>(); | ||
| let sessionCounter = 0; | ||
| let logoutAfterHits = Infinity; | ||
| let protectedHits = 0; | ||
| let inFlight = 0; | ||
| let maxInFlight = 0; | ||
| let flakyHits = 0; | ||
| let hangReached: PromiseWithResolvers<void>; | ||
| let hangReleased: PromiseWithResolvers<void>; | ||
| let hangClosed: boolean; | ||
| const requestedPaths: string[] = []; | ||
| const allBytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i)); |
There was a problem hiding this comment.
This would benefit from 1. structure and 2. documentation
| test('CheerioCrawler crawls pages that need the session of the context', async () => { | ||
| const titles: string[] = []; | ||
| const crawler = new CheerioCrawler({ | ||
| httpClient: new BrowserFetchHttpClient({ context }), | ||
| maxRequestRetries: 1, | ||
| requestHandler: async ({ $ }) => { | ||
| titles.push($('title').text()); | ||
| }, | ||
| }); | ||
|
|
||
| const stats = await crawler.run([ | ||
| ...Array.from({ length: 8 }, (_, i) => `${baseUrl}/private/${i}`), | ||
| `${baseUrl}/flaky`, | ||
| ]); | ||
|
|
||
| expect(titles.sort()).toEqual(['Flaky', ...Array.from({ length: 8 }, (_, i) => `Private ${i}`)].sort()); | ||
| expect(stats.requestsSucceeded).toBe(9); | ||
| expect(stats.requestsFailed).toBe(0); | ||
| expect(stats.retryHistogram).toEqual([8, 1]); | ||
| }); | ||
|
|
||
| test('runs requests concurrently on one page', async () => { | ||
| const crawler = new CheerioCrawler({ | ||
| httpClient: new BrowserFetchHttpClient({ context }), | ||
| minConcurrency: 4, | ||
| maxConcurrency: 4, | ||
| requestHandler: async () => {}, | ||
| }); | ||
|
|
||
| const stats = await crawler.run(Array.from({ length: 8 }, (_, i) => `${baseUrl}/private/${i}?delay=300`)); | ||
|
|
||
| expect(maxInFlight).toBe(4); | ||
| expect(stats.requestsSucceeded).toBe(8); | ||
| expect(stats.requestsFailed).toBe(0); | ||
| }); | ||
|
|
||
| test('lets errorHandler log in again when the session is lost while requests are in flight', async () => { | ||
| let logins = 0; | ||
| let relogin: Promise<void> | undefined; | ||
| const crawler = new CheerioCrawler({ | ||
| httpClient: new BrowserFetchHttpClient({ context }), | ||
| minConcurrency: 4, | ||
| maxConcurrency: 4, | ||
| maxRequestRetries: 1, | ||
| errorHandler: async ({ response }) => { | ||
| if (response?.status !== 401) return; | ||
| if (!relogin) { | ||
| logins++; | ||
| relogin = login().finally(() => { | ||
| relogin = undefined; | ||
| }); | ||
| } | ||
| await relogin; | ||
| }, | ||
| requestHandler: async () => {}, | ||
| }); | ||
|
|
||
| logoutAfterHits = 3; | ||
| const stats = await crawler.run(Array.from({ length: 12 }, (_, i) => `${baseUrl}/private/${i}?delay=200`)); | ||
|
|
||
| expect(logins).toBe(1); | ||
| expect(stats.requestsSucceeded).toBe(12); | ||
| expect(stats.requestsFailed).toBe(0); | ||
| }); |
There was a problem hiding this comment.
I have mixed feelings about using CheerioCrawler as a test harness for the http client, basically. Some of the tests are not even that relevant to the client itself.
Some sites block HTTP clients but not a logged-in browser, so people currently hand-write a crawler loop inside a
PlaywrightCrawlerhandler to reuse the browser's session. This adds an HTTP client that sends each request withfetch()from pages of a Playwright browser context you have already set up. Used as thehttpClientofCheerioCrawler, requests get the browser's cookies and TLS fingerprint, and the crawler keeps its queue, retries and statistics.@crawlee/playwrightand takes aBrowserContext, so@crawlee/http-clientdoesn't depend on Playwright; theplaywrightpeer dependency is now>=1.52.0maxOpenPages(20 by default) stay open, and the least recently used idle ones are closed401fails the request like other blocked statuses, soerrorHandlercan log in again once for all of them before they are retried (the docs example shows how)proxyUrl,fingerprintandignoreTlsErrorsare ignored, since the browser decides them, and redirects to another origin fail, as the browser treats them as cross-origin requestsCloses #4145