Skip to content

Commit 1800346

Browse files
committed
feat(workers): wait for self-clearing bot challenges before capturing
Cloudflare's "Just a moment..." page and DataDome's device check reload into the real page on their own when the browser passes, but only after several seconds; the crawler captured them right after load. When a challenge is detected, wait up to CRAWLER_CHALLENGE_WAIT_SEC (default 15) for it to clear, skipping DataDome captchas that need a human. Crawls now report the final main-document status instead of the challenge's.
1 parent af8b0eb commit 1800346

7 files changed

Lines changed: 190 additions & 3 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Waits out non-interactive bot-protection challenges (e.g. Cloudflare's
2+
// "Just a moment..." page or DataDome's device check). These interstitials run
3+
// some JS and then reload into the real page on their own when the browser
4+
// passes. Capturing right after the initial load would store the challenge
5+
// page instead, so when one is detected we give it a bounded amount of time to
6+
// clear.
7+
import type { Page } from "patchright";
8+
import { abortRace, raceWith } from "utils";
9+
10+
import { setSpanAttributes } from "@karakeep/shared-server";
11+
import serverConfig from "@karakeep/shared/config";
12+
import logger from "@karakeep/shared/logger";
13+
14+
import {
15+
isLikelyChallengePage,
16+
isWaitableChallenge,
17+
} from "../utils/metadataResolver";
18+
19+
// Statuses bot-protection vendors serve their interstitials with.
20+
const CHALLENGE_STATUS_CODES = new Set([401, 403, 429, 503]);
21+
22+
const POLL_INTERVAL_MS = 500;
23+
const SETTLE_TIMEOUT_MS = 5000;
24+
25+
async function pageIsWaitableChallenge(page: Page): Promise<boolean> {
26+
try {
27+
const [title, htmlContent] = await Promise.all([
28+
page.title(),
29+
page.content(),
30+
]);
31+
return isWaitableChallenge(title, htmlContent);
32+
} catch {
33+
// The page is navigating (typically the challenge reloading into the real
34+
// page); treat it as still pending and check again on the next poll.
35+
return true;
36+
}
37+
}
38+
39+
/**
40+
* If the page looks like a self-clearing bot challenge, waits (up to
41+
* CRAWLER_CHALLENGE_WAIT_SEC) for it to go away and for the resulting page to
42+
* settle. Returns without waiting for regular pages.
43+
*/
44+
export async function waitForChallengeToClear(
45+
page: Page,
46+
statusCode: number,
47+
jobId: string,
48+
abortSignal: AbortSignal,
49+
): Promise<void> {
50+
const waitMs = serverConfig.crawler.challengeWaitSec * 1000;
51+
if (waitMs <= 0) {
52+
return;
53+
}
54+
// Cheap gate so regular pages don't pay for serializing their DOM.
55+
const title = await page.title().catch(() => "");
56+
if (
57+
!CHALLENGE_STATUS_CODES.has(statusCode) &&
58+
!isLikelyChallengePage({ title })
59+
) {
60+
return;
61+
}
62+
if (!(await pageIsWaitableChallenge(page))) {
63+
return;
64+
}
65+
66+
logger.info(
67+
`[Crawler][${jobId}] The page looks like a bot challenge, waiting up to ${serverConfig.crawler.challengeWaitSec}s for it to clear`,
68+
);
69+
const startedAt = Date.now();
70+
const deadline = startedAt + waitMs;
71+
let cleared = false;
72+
while (Date.now() < deadline) {
73+
await raceWith<unknown>(
74+
new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)),
75+
abortRace(abortSignal),
76+
);
77+
if (!(await pageIsWaitableChallenge(page))) {
78+
cleared = true;
79+
break;
80+
}
81+
}
82+
83+
if (cleared) {
84+
await page
85+
.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS })
86+
.catch(() => undefined);
87+
}
88+
abortSignal.throwIfAborted();
89+
const waitedMs = Date.now() - startedAt;
90+
setSpanAttributes({
91+
"crawler.challenge.cleared": cleared,
92+
"crawler.challenge.waitMs": waitedMs,
93+
});
94+
logger.info(
95+
`[Crawler][${jobId}] Bot challenge ${cleared ? "cleared" : "did not clear"} after ${waitedMs}ms`,
96+
);
97+
}

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
trackContext,
4848
untrackContext,
4949
} from "./browser";
50+
import { waitForChallengeToClear } from "./challenge";
5051
import { truncateUrl } from "./utils";
5152

5253
const tracer = getTracer("@karakeep/workers");
@@ -695,6 +696,18 @@ export async function crawlPage(
695696
);
696697
}
697698
const targetUrl = navigationValidation.url.toString();
699+
// Tracks the status of the latest main-frame document, which differs
700+
// from the initial navigation's when a bot challenge clears and
701+
// reloads into the real page.
702+
let mainDocumentStatus: number | undefined;
703+
activePage.on("response", (res) => {
704+
if (
705+
res.request().isNavigationRequest() &&
706+
res.frame() === activePage.mainFrame()
707+
) {
708+
mainDocumentStatus = res.status();
709+
}
710+
});
698711
logger.info(`[Crawler][${jobId}] Navigating to "${targetUrl}"`);
699712
const response = await withSpan(
700713
tracer,
@@ -751,9 +764,21 @@ export async function crawlPage(
751764
setup.autoconsentEnabled,
752765
abortSignal,
753766
);
754-
755767
abortSignal.throwIfAborted();
756768

769+
await withSpan(
770+
tracer,
771+
"crawlerWorker.crawlPage.waitForChallenge",
772+
{ attributes: { "job.id": jobId } },
773+
() =>
774+
waitForChallengeToClear(
775+
activePage,
776+
mainDocumentStatus ?? response?.status() ?? 0,
777+
jobId,
778+
abortSignal,
779+
),
780+
);
781+
757782
logger.info(
758783
`[Crawler][${jobId}] Finished waiting for the page to load.`,
759784
);
@@ -767,7 +792,7 @@ export async function crawlPage(
767792

768793
return {
769794
htmlContent,
770-
statusCode: response?.status() ?? 0,
795+
statusCode: mainDocumentStatus ?? response?.status() ?? 0,
771796
screenshot,
772797
pdf,
773798
url: activePage.url(),

‎apps/workers/workers/utils/metadataResolver.test.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { isLikelyChallengePage, resolveMetadata } from "./metadataResolver";
3+
import {
4+
isLikelyChallengePage,
5+
isWaitableChallenge,
6+
resolveMetadata,
7+
} from "./metadataResolver";
48

59
function meta(fields: Record<string, string | null | undefined>) {
610
return {
@@ -139,3 +143,41 @@ describe("isLikelyChallengePage", () => {
139143
);
140144
});
141145
});
146+
147+
describe("isWaitableChallenge", () => {
148+
it("waits on Cloudflare's interstitial", () => {
149+
expect(
150+
isWaitableChallenge(
151+
"Just a moment...",
152+
"<html><head><title>Just a moment...</title></head><body><script>window._cf_chl_opt={}</script></body></html>",
153+
),
154+
).toBe(true);
155+
});
156+
157+
it("waits on DataDome's device check", () => {
158+
expect(
159+
isWaitableChallenge(
160+
"wsj.com",
161+
"<html><body><script>var dd={'rt':'i','cid':'x','host':'geo.captcha-delivery.com'}</script></body></html>",
162+
),
163+
).toBe(true);
164+
});
165+
166+
it("doesn't wait on DataDome's captcha, which needs a human", () => {
167+
expect(
168+
isWaitableChallenge(
169+
"wsj.com",
170+
"<html><body><script>var dd={'rt':'c','cid':'x','host':'geo.captcha-delivery.com'}</script></body></html>",
171+
),
172+
).toBe(false);
173+
});
174+
175+
it("doesn't wait on regular pages", () => {
176+
expect(
177+
isWaitableChallenge(
178+
"Example Domain",
179+
"<html><head><title>Example Domain</title></head><body><p>Hello</p></body></html>",
180+
),
181+
).toBe(false);
182+
});
183+
});

‎apps/workers/workers/utils/metadataResolver.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,24 @@ export function isLikelyChallengePage({
8282
return CHALLENGE_PAGE_BODY_MARKERS.some((marker) => lowered.includes(marker));
8383
}
8484

85+
// DataDome's captcha page (as opposed to its self-clearing device check) needs
86+
// a human to solve it, so there's no point waiting for it to clear.
87+
const DATADOME_HARD_CAPTCHA = /['"]rt['"]\s*:\s*['"]c['"]/;
88+
89+
/**
90+
* Whether the page is a bot challenge that may clear by itself (and reload
91+
* into the real page) if the browser keeps waiting.
92+
*/
93+
export function isWaitableChallenge(
94+
title: string,
95+
htmlContent: string,
96+
): boolean {
97+
return (
98+
isLikelyChallengePage({ title, htmlContent }) &&
99+
!DATADOME_HARD_CAPTCHA.test(htmlContent)
100+
);
101+
}
102+
85103
/**
86104
* Merges the browser render's metadata with the preflight probe's, per field.
87105
* The render normally wins (it sees the post-JS DOM), with the probe filling

‎docs/docs/03-configuration/01-environment-variables.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ Either `OPENAI_API_KEY` or `OLLAMA_BASE_URL` need to be set for automatic taggin
149149
| CRAWLER_FULL_PAGE_ARCHIVE_MAX_SIZE_MB | No | 0 | Maximum size in MB for a stored full-page archive. Archives larger than this are discarded instead of saved, since media-rich pages can produce 1GB+ files that never render. 0 disables the limit. |
150150
| CRAWLER_JOB_TIMEOUT_SEC | No | 60 | How long to wait for the crawler job to finish before timing out. If you have a slow internet connection or a low powered device, you might want to bump this up a bit |
151151
| CRAWLER_NAVIGATE_TIMEOUT_SEC | No | 30 | How long to spend navigating to the page (along with its redirects). Increase this if you have a slow internet connection |
152+
| CRAWLER_CHALLENGE_WAIT_SEC | No | 15 | When a page turns out to be a bot-protection challenge that clears by itself (e.g. Cloudflare's "Just a moment..." page), how long to wait for it to clear before capturing the page. Set to `0` to disable. |
152153
| CRAWLER_BROWSER_TIMEZONE | No | Not set | IANA timezone (e.g. `America/New_York`) that the crawler's browser reports to websites. Bot-protection services flag browsers whose timezone doesn't match their IP's location, so if you crawl through a proxy, set this to the proxy's location. When not set, the browser uses its own timezone (usually UTC in containers). |
153154
| CRAWLER_PREFLIGHT_USER_AGENT | No | Not set | The `User-Agent` header to send on the crawler's preflight request (the plain HTTP request that determines a link's content type and extracts early preview metadata before the full crawl). If not set, no `User-Agent` header is sent. |
154155
| CRAWLER_PARSE_TIMEOUT_SEC | No | 60 | How long to wait for the HTML parsing subprocess (metadata extraction + readability) to finish before timing out. |

‎packages/shared-server/src/tracingTypes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export type TracingAttributeKey =
3333
| "crawler.cleanup.hasPage"
3434
| "crawler.cleanup.pageClosed"
3535
| "crawler.cleanup.contextClosed"
36+
| "crawler.challenge.cleared"
37+
| "crawler.challenge.waitMs"
3638
// Database attributes
3739
| "db.system"
3840
| "db.statement"

‎packages/shared/config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ const allEnv = z.object({
126126
BROWSER_COOKIE_PATH: z.string().optional(),
127127
CRAWLER_JOB_TIMEOUT_SEC: z.coerce.number().default(60),
128128
CRAWLER_NAVIGATE_TIMEOUT_SEC: z.coerce.number().default(30),
129+
CRAWLER_CHALLENGE_WAIT_SEC: z.coerce.number().default(15),
129130
CRAWLER_BROWSER_TIMEZONE: z
130131
.string()
131132
.refine(
@@ -420,6 +421,7 @@ const serverConfigSchema = allEnv.transform((val, ctx) => {
420421
browserCookiePath: val.BROWSER_COOKIE_PATH,
421422
jobTimeoutSec: val.CRAWLER_JOB_TIMEOUT_SEC,
422423
navigateTimeoutSec: val.CRAWLER_NAVIGATE_TIMEOUT_SEC,
424+
challengeWaitSec: val.CRAWLER_CHALLENGE_WAIT_SEC,
423425
browserTimezone: val.CRAWLER_BROWSER_TIMEZONE,
424426
downloadBannerImage: val.CRAWLER_DOWNLOAD_BANNER_IMAGE,
425427
storeScreenshot: val.CRAWLER_STORE_SCREENSHOT,

0 commit comments

Comments
 (0)