Skip to content

Commit 254174a

Browse files
committed
feat(workers): derive the crawler user agent from the connected browser
The crawler hardcoded a Chrome 124 macOS user agent regardless of the browser it drives, which contradicts the browser's real version, platform and client hints. Read the browser's own UA over CDP instead, dropping the "Headless" marker and applying Chrome's UA reduction.
1 parent ed774c1 commit 254174a

4 files changed

Lines changed: 76 additions & 3 deletions

File tree

‎apps/workers/workers/crawler/browser.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { setUrlHostnameFromResolvedAddress } from "@karakeep/shared/utils/url";
2323
import { tryCatch } from "@karakeep/shared/tryCatch";
2424

2525
import { loadAutoconsent } from "./autoconsent";
26-
import { redactUrlCredentials } from "./utils";
26+
import { normalizeBrowserUserAgent, redactUrlCredentials } from "./utils";
2727

2828
interface Cookie {
2929
name: string;
@@ -104,6 +104,36 @@ export function getPlaywrightProxyConfig(
104104
};
105105
}
106106

107+
const browserUserAgents = new WeakMap<Browser, Promise<string | undefined>>();
108+
109+
/**
110+
* Returns the user agent to use for crawl contexts, derived from the connected
111+
* browser so it always matches the browser's real version, platform, and
112+
* client hints. Resolves to undefined (keep the browser's default) if the
113+
* browser can't be queried.
114+
*/
115+
export function getBrowserUserAgent(
116+
browser: Browser,
117+
): Promise<string | undefined> {
118+
let userAgent = browserUserAgents.get(browser);
119+
if (!userAgent) {
120+
userAgent = (async () => {
121+
const session = await browser.newBrowserCDPSession();
122+
try {
123+
const version = await session.send("Browser.getVersion");
124+
return normalizeBrowserUserAgent(version.userAgent);
125+
} finally {
126+
await session.detach().catch(() => undefined);
127+
}
128+
})().catch((e: unknown) => {
129+
logger.warn(`[Crawler] Failed to read the browser's user agent: ${e}`);
130+
return undefined;
131+
});
132+
browserUserAgents.set(browser, userAgent);
133+
}
134+
return userAgent;
135+
}
136+
107137
/**
108138
* Reaps browser contexts that have been open longer than the max job timeout.
109139
* This is a safety net for cases where context.close() hangs or is never called.

‎apps/workers/workers/crawler/crawlPage.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
PAGE_CLOSE_TIMEOUT_MS,
4242
getGlobalBlocker,
4343
getGlobalBrowser,
44+
getBrowserUserAgent,
4445
getGlobalCookies,
4546
getPlaywrightProxyConfig,
4647
startBrowserInstance,
@@ -634,6 +635,7 @@ export async function crawlPage(
634635
}
635636

636637
const proxyConfig = getPlaywrightProxyConfig(runProxy);
638+
const userAgent = await getBrowserUserAgent(browser);
637639
const isRunningInProxyContext =
638640
proxyConfig !== undefined &&
639641
!matchesNoProxy(url, proxyConfig.bypass?.split(",") ?? []);
@@ -648,8 +650,7 @@ export async function crawlPage(
648650
async () =>
649651
browser.newContext({
650652
viewport: { width: 1440, height: 900 },
651-
userAgent:
652-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
653+
userAgent,
653654
proxy: proxyConfig,
654655
serviceWorkers: "block",
655656
}),
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { normalizeBrowserUserAgent } from "./utils";
4+
5+
describe("normalizeBrowserUserAgent", () => {
6+
it("drops the headless marker and reduces the version", () => {
7+
expect(
8+
normalizeBrowserUserAgent(
9+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.8037.57 Safari/537.36",
10+
),
11+
).toBe(
12+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36",
13+
);
14+
});
15+
16+
it("reduces the full version that headless-shell reports", () => {
17+
expect(
18+
normalizeBrowserUserAgent(
19+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36",
20+
),
21+
).toBe(
22+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
23+
);
24+
});
25+
26+
it("keeps an already reduced user agent and its platform unchanged", () => {
27+
const ua =
28+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36";
29+
expect(normalizeBrowserUserAgent(ua)).toBe(ua);
30+
});
31+
});

‎apps/workers/workers/crawler/utils.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,14 @@ export function shouldRetryCrawlStatusCode(statusCode: number | null): boolean {
4040
}
4141
return statusCode === 403 || statusCode === 429 || statusCode >= 500;
4242
}
43+
44+
/**
45+
* Turns the browser's own user agent into the one a regular (non-headless)
46+
* Chrome of the same version and platform would send: drops the "Headless"
47+
* marker and applies Chrome's user-agent reduction (minor version zeroed).
48+
*/
49+
export function normalizeBrowserUserAgent(userAgent: string): string {
50+
return userAgent
51+
.replace("HeadlessChrome/", "Chrome/")
52+
.replace(/Chrome\/(\d+)\.[\d.]+/, "Chrome/$1.0.0.0");
53+
}

0 commit comments

Comments
 (0)