diff --git a/packages/playwright-crawler/src/internals/utils/playwright-utils.ts b/packages/playwright-crawler/src/internals/utils/playwright-utils.ts index c7295524b6b2..5fdb3bb89c81 100644 --- a/packages/playwright-crawler/src/internals/utils/playwright-utils.ts +++ b/packages/playwright-crawler/src/internals/utils/playwright-utils.ts @@ -755,9 +755,9 @@ async function handleCloudflareChallenge( options.isChallengeCallback ??= async () => { return await page.evaluate(async () => { - // Cloudflare nests the ray ID under varying wrapper elements, so we match by descendants - // instead of a direct-child chain (e.g. a `.footer-wrapper` was inserted in between). - return !!document.querySelector('.footer .footer-inner .diagnostic-wrapper .ray-id'); + // Cloudflare keeps reshuffling the wrapper elements between `.footer-inner` and `.ray-id`, + // so only the stable outer classes are matched. + return !!document.querySelector('.footer .footer-inner .ray-id'); }); }; diff --git a/test/core/playwright_utils.test.ts b/test/core/playwright_utils.test.ts index b765e5462739..83ab2a8a5e55 100644 --- a/test/core/playwright_utils.test.ts +++ b/test/core/playwright_utils.test.ts @@ -433,4 +433,70 @@ describe('playwrightUtils', () => { await browser.close(); } }); + + describe('handleCloudflareChallenge() challenge detection', () => { + // not a named export, only reachable via the internal `playwrightUtils` object + const { handleCloudflareChallenge } = playwrightUtils.playwrightUtils; + let browser: Browser; + let page: Page; + + // no clicking or waiting, so an unsolved challenge fails fast instead of spending ~20s in the click loop + const fastOptions = () => ({ + sleepSecs: 0, + preChallengeSleepSecs: 0, + clickCallback: async () => {}, + }); + + // markup as rendered by the current (2026) Cloudflare challenge template + const currentChallengeBody = ` +