@detiq/crawler 0.2.14 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crawler.js +9 -6
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +15 -8
- package/dist/job-queue.js.map +2 -2
- package/dist/session-capture.d.ts +1 -0
- package/dist/session-capture.js +2 -1
- package/dist/session-capture.js.map +2 -2
- package/dist/tls-fingerprint.js +1 -1
- package/dist/tls-fingerprint.js.map +2 -2
- package/package.json +2 -1
- package/src/crawler.ts +62 -22
- package/src/job-queue.ts +23 -20
- package/src/phash.ts +33 -0
- package/src/session-capture.ts +5 -2
- package/src/stealth/human-behavior.ts +13 -11
- package/src/vendor.d.ts +20 -0
- package/types/session-capture.d.ts +1 -0
package/dist/session-capture.js
CHANGED
|
@@ -243,6 +243,7 @@ async function validateAndCaptureSession(appUrl, creds, llmConfig, captchaOpts)
|
|
|
243
243
|
}
|
|
244
244
|
await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
245
245
|
});
|
|
246
|
+
const finalUrl = page.url();
|
|
246
247
|
await exploreAppAfterLogin(page);
|
|
247
248
|
const state = await context.storageState();
|
|
248
249
|
const cookies = state.cookies?.length ?? 0;
|
|
@@ -253,7 +254,7 @@ async function validateAndCaptureSession(appUrl, creds, llmConfig, captchaOpts)
|
|
|
253
254
|
if (cookies === 0 && localStorage === 0) {
|
|
254
255
|
throw new Error("Login appeared to succeed but no session data was captured \u2014 try manual session capture instead");
|
|
255
256
|
}
|
|
256
|
-
return { storageState: JSON.stringify(state), cookies, localStorage };
|
|
257
|
+
return { storageState: JSON.stringify(state), cookies, localStorage, finalUrl };
|
|
257
258
|
} finally {
|
|
258
259
|
await browser.close().catch(() => {
|
|
259
260
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/session-capture.ts"],
|
|
4
|
-
"sourcesContent": ["import { chromium } from 'playwright';\nimport { randomBytes } from 'node:crypto';\nimport { login } from './auth.js';\nimport { generateTOTP } from './job-queue.js';\n\ninterface CaptureSession {\n browser: any;\n context: any;\n timer: ReturnType<typeof setTimeout>;\n}\n\nconst activeSessions = new Map<string, CaptureSession>();\n\n/**\n * Opens a headed (visible) browser at appUrl and returns a captureId.\n * The user logs in manually, then calls completeSessionCapture() to harvest storageState.\n * Sessions expire after 10 minutes if not completed.\n *\n * Throws 'HEADLESS_ENV' if running in Docker/Linux without a display server.\n * The API route surfaces this as a 503 so the frontend can switch to manual JSON paste.\n */\nexport async function startSessionCapture(appUrl: string): Promise<string> {\n const inDocker = process.env.PLAYWRIGHT_IN_DOCKER === 'true';\n const noDisplay = process.platform === 'linux' && !process.env.DISPLAY;\n if (inDocker || noDisplay) {\n throw new Error('HEADLESS_ENV');\n }\n\n const id = randomBytes(16).toString('hex');\n const browser = await chromium.launch({ headless: false });\n const context = await browser.newContext();\n const page = await context.newPage();\n await page.goto(appUrl, { waitUntil: 'domcontentloaded' }).catch(() => {});\n const timer = setTimeout(() => cancelSessionCapture(id).catch(() => {}), 10 * 60 * 1000);\n activeSessions.set(id, { browser, context, timer });\n return id;\n}\n\n/** Captures storageState from the open browser, closes it, and returns the JSON string. */\nexport async function completeSessionCapture(captureId: string): Promise<string> {\n const session = activeSessions.get(captureId);\n if (!session) throw new Error('Capture session not found or expired.');\n clearTimeout(session.timer);\n const state = await session.context.storageState();\n await session.browser.close().catch(() => {});\n activeSessions.delete(captureId);\n return JSON.stringify(state);\n}\n\n/** Closes the browser without capturing \u2014 called on Cancel. */\nexport async function cancelSessionCapture(captureId: string): Promise<void> {\n const session = activeSessions.get(captureId);\n if (!session) return;\n clearTimeout(session.timer);\n await session.browser.close().catch(() => {});\n activeSessions.delete(captureId);\n}\n\n/**\n * Post-login exploration: navigates nav links, cycles tabs, advances carousels,\n * opens accordions, scrolls \u2014 so the browser accumulates full session state before capture.\n * All best-effort: never throws, never fails the capture.\n */\nasync function exploreAppAfterLogin(page: any): Promise<void> {\n const sleep = (ms: number) => page.waitForTimeout(ms).catch(() => {});\n\n const isVisible = `\n function isVisible(el) {\n const s = window.getComputedStyle(el);\n if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) < 0.1) return false;\n const r = el.getBoundingClientRect();\n return r.width > 4 && r.height > 4;\n }\n `;\n\n try {\n const origin = await page.evaluate(() => location.origin).catch(() => '');\n const startUrl = await page.evaluate(() => location.href).catch(() => '');\n\n // \u2500\u2500 1. Expand collapsed nav menus / hamburgers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await page.evaluate(new Function(`\n ${isVisible}\n const inBadCtx = el => {\n let p = el.parentElement;\n while (p) {\n const role = p.getAttribute('role') ?? '';\n if (['dialog','alertdialog','combobox','listbox'].includes(role)) return true;\n p = p.parentElement;\n }\n return false;\n };\n const targets = new Set();\n document.querySelectorAll('[aria-haspopup]:not([aria-expanded=\"true\"])').forEach(el => {\n if (isVisible(el) && !inBadCtx(el)) targets.add(el);\n });\n document.querySelectorAll('[aria-expanded=\"false\"]').forEach(el => {\n if ((el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') && isVisible(el) && !inBadCtx(el)) targets.add(el);\n });\n document.querySelectorAll('[data-state=\"closed\"],[data-headlessui-state=\"closed\"]').forEach(el => {\n if ((el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') && isVisible(el) && !inBadCtx(el)) targets.add(el);\n });\n targets.forEach(el => { try { el.click(); } catch {} });\n `) as () => void).catch(() => {});\n await sleep(600);\n\n // \u2500\u2500 2. Collect nav links from the expanded DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const navLinks: string[] = await page.evaluate(new Function(`\n ${isVisible}\n const links = new Set();\n const navEls = document.querySelectorAll('nav a, [role=\"navigation\"] a, aside a, header a');\n navEls.forEach(a => {\n const href = a.getAttribute('href');\n if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;\n if (!isVisible(a)) return;\n try {\n const url = new URL(href, location.href);\n if (url.origin === location.origin) links.add(url.href);\n } catch {}\n });\n return [...links].slice(0, 10); // cap at 10 nav pages\n `) as () => string[]).catch(() => []);\n\n // Dismiss opened menus before navigating\n await page.keyboard?.press('Escape').catch(() => {});\n await sleep(200);\n\n // \u2500\u2500 3. Visit each nav link, interact, return \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n for (const link of navLinks) {\n try {\n if (link === startUrl) continue;\n await page.goto(link, { waitUntil: 'networkidle', timeout: 15_000 }).catch(() => {});\n await sleep(400);\n await interactWithCurrentPage(page, isVisible, sleep);\n } catch { /* skip broken page */ }\n }\n\n // Return to start page\n if (navLinks.length > 0) {\n await page.goto(startUrl, { waitUntil: 'networkidle', timeout: 15_000 }).catch(() => {});\n await sleep(400);\n }\n\n // \u2500\u2500 4. Final interaction pass on landing page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await interactWithCurrentPage(page, isVisible, sleep);\n\n await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});\n } catch {\n // Exploration is best-effort\n }\n}\n\n/** Interacts with tabs, carousels, accordions, and scrolls on the current page. */\nasync function interactWithCurrentPage(page: any, isVisible: string, sleep: (ms: number) => Promise<void>): Promise<void> {\n // Cycle through tabs\n await page.evaluate(new Function(`\n ${isVisible}\n const tabs = [...document.querySelectorAll('[role=\"tab\"]')].filter(isVisible);\n tabs.forEach(tab => { try { tab.click(); } catch {} });\n `) as () => void).catch(() => {});\n await sleep(400);\n\n // Advance carousels (next buttons)\n await page.evaluate(new Function(`\n ${isVisible}\n const nextBtns = [...document.querySelectorAll(\n 'button[aria-label*=\"next\" i], button[aria-label*=\"forward\" i], button[aria-label*=\"right\" i], ' +\n '[class*=\"carousel\" i] button, [class*=\"slider\" i] button, [data-direction=\"next\"]'\n )].filter(isVisible);\n // Click next up to 3 times per carousel\n for (let i = 0; i < 3; i++) {\n nextBtns.forEach(btn => { try { btn.click(); } catch {} });\n }\n `) as () => void).catch(() => {});\n await sleep(400);\n\n // Open closed accordions / disclosure panels\n await page.evaluate(new Function(`\n ${isVisible}\n document.querySelectorAll(\n '[data-state=\"closed\"] button, [aria-expanded=\"false\"], ' +\n 'details:not([open]) > summary, [class*=\"accordion\" i] button'\n ).forEach(el => {\n if (isVisible(el)) { try { el.click(); } catch {} }\n });\n `) as () => void).catch(() => {});\n await sleep(300);\n\n // Scroll to bottom then back to top\n await page.evaluate(() => { window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }); }).catch(() => {});\n await sleep(300);\n await page.evaluate(() => { window.scrollTo({ top: 0, behavior: 'instant' }); }).catch(() => {});\n}\n\n/**\n * Headless login + session capture \u2014 works on Docker/cloud (no display needed).\n * Tries DOM-based login first (fast, free). If it fails and llmConfig is provided,\n * falls back to Stagehand AI-guided login (handles modals, multi-step, marketing pages).\n *\n * Returns { storageState, cookies, localStorage } on success.\n * Throws a human-readable error on failure.\n */\nexport async function validateAndCaptureSession(\n appUrl: string,\n creds: {\n username: string; password: string; totpSecret?: string;\n httpBasicUsername?: string; httpBasicPassword?: string;\n },\n llmConfig?: { modelName: string; apiKey: string },\n captchaOpts?: { apiKey?: string; provider?: '2captcha' | 'capsolver' },\n): Promise<{ storageState: string; cookies: number; localStorage: number }> {\n const browser = await chromium.launch({ headless: true });\n try {\n const contextOpts: any = {};\n if (creds.httpBasicUsername && creds.httpBasicPassword) {\n contextOpts.httpCredentials = { username: creds.httpBasicUsername, password: creds.httpBasicPassword };\n }\n const context = await browser.newContext(contextOpts);\n const page = await context.newPage();\n\n const getOneTimeCode = creds.totpSecret\n ? async () => generateTOTP(creds.totpSecret!)\n : undefined;\n\n let loggedIn: boolean;\n try {\n loggedIn = await login(page, appUrl, {\n username: creds.username,\n password: creds.password,\n getOneTimeCode,\n });\n } catch (loginErr: any) {\n // login() throws when it can read a real error message from the page\n const shotBuf = await page.screenshot({ type: 'jpeg', quality: 60, fullPage: false }).catch(() => null);\n throw Object.assign(loginErr, {\n failureScreenshot: shotBuf ? shotBuf.toString('base64') : null,\n });\n }\n\n // Stagehand AI fallback when DOM login fails and LLM config is available\n if (!loggedIn && llmConfig) {\n let sh: any = null;\n try {\n const { Stagehand } = await import('@browserbasehq/stagehand');\n const { loginWithStagehand } = await import('./ai-login.js');\n sh = new Stagehand({\n env: 'LOCAL',\n model: { modelName: llmConfig.modelName as any, apiKey: llmConfig.apiKey },\n serverCache: false,\n verbose: 0,\n localBrowserLaunchOptions: { headless: true },\n });\n await sh.init();\n const shPage = await (sh as any).resolvePage();\n await shPage.goto(appUrl, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => {});\n const shLoggedIn = await loginWithStagehand(\n sh,\n { username: creds.username, password: creds.password, getOneTimeCode },\n appUrl,\n captchaOpts,\n );\n if (shLoggedIn) {\n const shCtx = (sh as any).context ?? (sh as any).browserContext;\n if (shCtx) {\n const shState = await shCtx.storageState();\n const shCookies = shState.cookies?.length ?? 0;\n const shLocalStorage = shState.origins?.reduce((s: number, o: any) => s + (o.localStorage?.length ?? 0), 0) ?? 0;\n if (shCookies > 0 || shLocalStorage > 0) {\n await sh.close().catch(() => {});\n sh = null;\n return { storageState: JSON.stringify(shState), cookies: shCookies, localStorage: shLocalStorage };\n }\n }\n loggedIn = true; // Stagehand succeeded but we'll capture from the plain browser context below\n }\n } catch (shErr: any) {\n const msg = String(shErr?.message ?? shErr);\n if (msg.includes('CAPTCHA') || shErr?.isSSOError) throw shErr;\n // Stagehand failed silently \u2014 fall through to \"credentials rejected\"\n } finally {\n if (sh) await sh.close().catch(() => {});\n }\n }\n\n if (!loggedIn) {\n const shotBuf = await page.screenshot({ type: 'jpeg', quality: 60, fullPage: false }).catch(() => null);\n const challenge = await page.evaluate(() => {\n const text = (document.body?.innerText ?? '').toLowerCase();\n const hasCloudflare = /verify you are human|checking your browser|cloudflare|cf-challenge|turnstile/.test(text) ||\n !!document.querySelector('iframe[src*=\"challenges.cloudflare.com\"], .cf-turnstile, [data-sitekey]');\n const hasCaptcha = hasCloudflare || /captcha|recaptcha|hcaptcha/.test(text) ||\n !!document.querySelector('iframe[src*=\"recaptcha\"], iframe[src*=\"hcaptcha\"], #captcha, .captcha, [id*=\"captcha\"], [class*=\"captcha\"]');\n return { hasCloudflare, hasCaptcha };\n }).catch(() => ({ hasCloudflare: false, hasCaptcha: false }));\n const message = challenge.hasCloudflare\n ? 'Cloudflare human verification blocked headless validation. This cannot be bypassed reliably in code; use Live Browser or manual session capture, configure a CAPTCHA solver, or allowlist the crawler IP.'\n : challenge.hasCaptcha\n ? 'A CAPTCHA blocked headless validation. Configure CAPTCHA Auto-Solve or use Live Browser/manual session capture.'\n : 'Login failed \u2014 credentials rejected or the login flow requires a manual/SSO step. Check credentials, saved login instructions, or use Live Browser session capture.';\n const err = Object.assign(new Error(message), {\n failureScreenshot: shotBuf ? shotBuf.toString('base64') : null,\n reason: challenge.hasCloudflare ? 'CLOUDFLARE_CHALLENGE' : challenge.hasCaptcha ? 'CAPTCHA_CHALLENGE' : 'LOGIN_NOT_CONFIRMED',\n });\n throw err;\n }\n\n // Wait for post-login navigation to settle before capturing session\n await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});\n\n // Post-login exploration: navigate tabs, expand menus, scroll to trigger lazy-loaded state\n await exploreAppAfterLogin(page);\n\n const state = await context.storageState();\n const cookies = state.cookies?.length ?? 0;\n const localStorage = state.origins?.reduce(\n (sum, o) => sum + (o.localStorage?.length ?? 0), 0\n ) ?? 0;\n\n if (cookies === 0 && localStorage === 0) {\n throw new Error('Login appeared to succeed but no session data was captured \u2014 try manual session capture instead');\n }\n\n return { storageState: JSON.stringify(state), cookies, localStorage };\n } finally {\n await browser.close().catch(() => {});\n }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { chromium } from 'playwright';\nimport { randomBytes } from 'node:crypto';\nimport { login } from './auth.js';\nimport { generateTOTP } from './job-queue.js';\n\ninterface CaptureSession {\n browser: any;\n context: any;\n timer: ReturnType<typeof setTimeout>;\n}\n\nconst activeSessions = new Map<string, CaptureSession>();\n\n/**\n * Opens a headed (visible) browser at appUrl and returns a captureId.\n * The user logs in manually, then calls completeSessionCapture() to harvest storageState.\n * Sessions expire after 10 minutes if not completed.\n *\n * Throws 'HEADLESS_ENV' if running in Docker/Linux without a display server.\n * The API route surfaces this as a 503 so the frontend can switch to manual JSON paste.\n */\nexport async function startSessionCapture(appUrl: string): Promise<string> {\n const inDocker = process.env.PLAYWRIGHT_IN_DOCKER === 'true';\n const noDisplay = process.platform === 'linux' && !process.env.DISPLAY;\n if (inDocker || noDisplay) {\n throw new Error('HEADLESS_ENV');\n }\n\n const id = randomBytes(16).toString('hex');\n const browser = await chromium.launch({ headless: false });\n const context = await browser.newContext();\n const page = await context.newPage();\n await page.goto(appUrl, { waitUntil: 'domcontentloaded' }).catch(() => {});\n const timer = setTimeout(() => cancelSessionCapture(id).catch(() => {}), 10 * 60 * 1000);\n activeSessions.set(id, { browser, context, timer });\n return id;\n}\n\n/** Captures storageState from the open browser, closes it, and returns the JSON string. */\nexport async function completeSessionCapture(captureId: string): Promise<string> {\n const session = activeSessions.get(captureId);\n if (!session) throw new Error('Capture session not found or expired.');\n clearTimeout(session.timer);\n const state = await session.context.storageState();\n await session.browser.close().catch(() => {});\n activeSessions.delete(captureId);\n return JSON.stringify(state);\n}\n\n/** Closes the browser without capturing \u2014 called on Cancel. */\nexport async function cancelSessionCapture(captureId: string): Promise<void> {\n const session = activeSessions.get(captureId);\n if (!session) return;\n clearTimeout(session.timer);\n await session.browser.close().catch(() => {});\n activeSessions.delete(captureId);\n}\n\n/**\n * Post-login exploration: navigates nav links, cycles tabs, advances carousels,\n * opens accordions, scrolls \u2014 so the browser accumulates full session state before capture.\n * All best-effort: never throws, never fails the capture.\n */\nasync function exploreAppAfterLogin(page: any): Promise<void> {\n const sleep = (ms: number) => page.waitForTimeout(ms).catch(() => {});\n\n const isVisible = `\n function isVisible(el) {\n const s = window.getComputedStyle(el);\n if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) < 0.1) return false;\n const r = el.getBoundingClientRect();\n return r.width > 4 && r.height > 4;\n }\n `;\n\n try {\n const origin = await page.evaluate(() => location.origin).catch(() => '');\n const startUrl = await page.evaluate(() => location.href).catch(() => '');\n\n // \u2500\u2500 1. Expand collapsed nav menus / hamburgers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await page.evaluate(new Function(`\n ${isVisible}\n const inBadCtx = el => {\n let p = el.parentElement;\n while (p) {\n const role = p.getAttribute('role') ?? '';\n if (['dialog','alertdialog','combobox','listbox'].includes(role)) return true;\n p = p.parentElement;\n }\n return false;\n };\n const targets = new Set();\n document.querySelectorAll('[aria-haspopup]:not([aria-expanded=\"true\"])').forEach(el => {\n if (isVisible(el) && !inBadCtx(el)) targets.add(el);\n });\n document.querySelectorAll('[aria-expanded=\"false\"]').forEach(el => {\n if ((el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') && isVisible(el) && !inBadCtx(el)) targets.add(el);\n });\n document.querySelectorAll('[data-state=\"closed\"],[data-headlessui-state=\"closed\"]').forEach(el => {\n if ((el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') && isVisible(el) && !inBadCtx(el)) targets.add(el);\n });\n targets.forEach(el => { try { el.click(); } catch {} });\n `) as () => void).catch(() => {});\n await sleep(600);\n\n // \u2500\u2500 2. Collect nav links from the expanded DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const navLinks: string[] = await page.evaluate(new Function(`\n ${isVisible}\n const links = new Set();\n const navEls = document.querySelectorAll('nav a, [role=\"navigation\"] a, aside a, header a');\n navEls.forEach(a => {\n const href = a.getAttribute('href');\n if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;\n if (!isVisible(a)) return;\n try {\n const url = new URL(href, location.href);\n if (url.origin === location.origin) links.add(url.href);\n } catch {}\n });\n return [...links].slice(0, 10); // cap at 10 nav pages\n `) as () => string[]).catch(() => []);\n\n // Dismiss opened menus before navigating\n await page.keyboard?.press('Escape').catch(() => {});\n await sleep(200);\n\n // \u2500\u2500 3. Visit each nav link, interact, return \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n for (const link of navLinks) {\n try {\n if (link === startUrl) continue;\n await page.goto(link, { waitUntil: 'networkidle', timeout: 15_000 }).catch(() => {});\n await sleep(400);\n await interactWithCurrentPage(page, isVisible, sleep);\n } catch { /* skip broken page */ }\n }\n\n // Return to start page\n if (navLinks.length > 0) {\n await page.goto(startUrl, { waitUntil: 'networkidle', timeout: 15_000 }).catch(() => {});\n await sleep(400);\n }\n\n // \u2500\u2500 4. Final interaction pass on landing page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await interactWithCurrentPage(page, isVisible, sleep);\n\n await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});\n } catch {\n // Exploration is best-effort\n }\n}\n\n/** Interacts with tabs, carousels, accordions, and scrolls on the current page. */\nasync function interactWithCurrentPage(page: any, isVisible: string, sleep: (ms: number) => Promise<void>): Promise<void> {\n // Cycle through tabs\n await page.evaluate(new Function(`\n ${isVisible}\n const tabs = [...document.querySelectorAll('[role=\"tab\"]')].filter(isVisible);\n tabs.forEach(tab => { try { tab.click(); } catch {} });\n `) as () => void).catch(() => {});\n await sleep(400);\n\n // Advance carousels (next buttons)\n await page.evaluate(new Function(`\n ${isVisible}\n const nextBtns = [...document.querySelectorAll(\n 'button[aria-label*=\"next\" i], button[aria-label*=\"forward\" i], button[aria-label*=\"right\" i], ' +\n '[class*=\"carousel\" i] button, [class*=\"slider\" i] button, [data-direction=\"next\"]'\n )].filter(isVisible);\n // Click next up to 3 times per carousel\n for (let i = 0; i < 3; i++) {\n nextBtns.forEach(btn => { try { btn.click(); } catch {} });\n }\n `) as () => void).catch(() => {});\n await sleep(400);\n\n // Open closed accordions / disclosure panels\n await page.evaluate(new Function(`\n ${isVisible}\n document.querySelectorAll(\n '[data-state=\"closed\"] button, [aria-expanded=\"false\"], ' +\n 'details:not([open]) > summary, [class*=\"accordion\" i] button'\n ).forEach(el => {\n if (isVisible(el)) { try { el.click(); } catch {} }\n });\n `) as () => void).catch(() => {});\n await sleep(300);\n\n // Scroll to bottom then back to top\n await page.evaluate(() => { window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }); }).catch(() => {});\n await sleep(300);\n await page.evaluate(() => { window.scrollTo({ top: 0, behavior: 'instant' }); }).catch(() => {});\n}\n\n/**\n * Headless login + session capture \u2014 works on Docker/cloud (no display needed).\n * Tries DOM-based login first (fast, free). If it fails and llmConfig is provided,\n * falls back to Stagehand AI-guided login (handles modals, multi-step, marketing pages).\n *\n * Returns { storageState, cookies, localStorage } on success.\n * Throws a human-readable error on failure.\n */\nexport async function validateAndCaptureSession(\n appUrl: string,\n creds: {\n username: string; password: string; totpSecret?: string;\n httpBasicUsername?: string; httpBasicPassword?: string;\n },\n llmConfig?: { modelName: string; apiKey: string },\n captchaOpts?: { apiKey?: string; provider?: '2captcha' | 'capsolver' },\n): Promise<{ storageState: string; cookies: number; localStorage: number; finalUrl?: string }> {\n const browser = await chromium.launch({ headless: true });\n try {\n const contextOpts: any = {};\n if (creds.httpBasicUsername && creds.httpBasicPassword) {\n contextOpts.httpCredentials = { username: creds.httpBasicUsername, password: creds.httpBasicPassword };\n }\n const context = await browser.newContext(contextOpts);\n const page = await context.newPage();\n\n const getOneTimeCode = creds.totpSecret\n ? async () => generateTOTP(creds.totpSecret!)\n : undefined;\n\n let loggedIn: boolean;\n try {\n loggedIn = await login(page, appUrl, {\n username: creds.username,\n password: creds.password,\n getOneTimeCode,\n });\n } catch (loginErr: any) {\n // login() throws when it can read a real error message from the page\n const shotBuf = await page.screenshot({ type: 'jpeg', quality: 60, fullPage: false }).catch(() => null);\n throw Object.assign(loginErr, {\n failureScreenshot: shotBuf ? shotBuf.toString('base64') : null,\n });\n }\n\n // Stagehand AI fallback when DOM login fails and LLM config is available\n if (!loggedIn && llmConfig) {\n let sh: any = null;\n try {\n const { Stagehand } = await import('@browserbasehq/stagehand');\n const { loginWithStagehand } = await import('./ai-login.js');\n sh = new Stagehand({\n env: 'LOCAL',\n model: { modelName: llmConfig.modelName as any, apiKey: llmConfig.apiKey },\n serverCache: false,\n verbose: 0,\n localBrowserLaunchOptions: { headless: true },\n });\n await sh.init();\n const shPage = await (sh as any).resolvePage();\n await shPage.goto(appUrl, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => {});\n const shLoggedIn = await loginWithStagehand(\n sh,\n { username: creds.username, password: creds.password, getOneTimeCode },\n appUrl,\n captchaOpts,\n );\n if (shLoggedIn) {\n const shCtx = (sh as any).context ?? (sh as any).browserContext;\n if (shCtx) {\n const shState = await shCtx.storageState();\n const shCookies = shState.cookies?.length ?? 0;\n const shLocalStorage = shState.origins?.reduce((s: number, o: any) => s + (o.localStorage?.length ?? 0), 0) ?? 0;\n if (shCookies > 0 || shLocalStorage > 0) {\n await sh.close().catch(() => {});\n sh = null;\n return { storageState: JSON.stringify(shState), cookies: shCookies, localStorage: shLocalStorage };\n }\n }\n loggedIn = true; // Stagehand succeeded but we'll capture from the plain browser context below\n }\n } catch (shErr: any) {\n const msg = String(shErr?.message ?? shErr);\n if (msg.includes('CAPTCHA') || shErr?.isSSOError) throw shErr;\n // Stagehand failed silently \u2014 fall through to \"credentials rejected\"\n } finally {\n if (sh) await sh.close().catch(() => {});\n }\n }\n\n if (!loggedIn) {\n const shotBuf = await page.screenshot({ type: 'jpeg', quality: 60, fullPage: false }).catch(() => null);\n const challenge = await page.evaluate(() => {\n const text = (document.body?.innerText ?? '').toLowerCase();\n const hasCloudflare = /verify you are human|checking your browser|cloudflare|cf-challenge|turnstile/.test(text) ||\n !!document.querySelector('iframe[src*=\"challenges.cloudflare.com\"], .cf-turnstile, [data-sitekey]');\n const hasCaptcha = hasCloudflare || /captcha|recaptcha|hcaptcha/.test(text) ||\n !!document.querySelector('iframe[src*=\"recaptcha\"], iframe[src*=\"hcaptcha\"], #captcha, .captcha, [id*=\"captcha\"], [class*=\"captcha\"]');\n return { hasCloudflare, hasCaptcha };\n }).catch(() => ({ hasCloudflare: false, hasCaptcha: false }));\n const message = challenge.hasCloudflare\n ? 'Cloudflare human verification blocked headless validation. This cannot be bypassed reliably in code; use Live Browser or manual session capture, configure a CAPTCHA solver, or allowlist the crawler IP.'\n : challenge.hasCaptcha\n ? 'A CAPTCHA blocked headless validation. Configure CAPTCHA Auto-Solve or use Live Browser/manual session capture.'\n : 'Login failed \u2014 credentials rejected or the login flow requires a manual/SSO step. Check credentials, saved login instructions, or use Live Browser session capture.';\n const err = Object.assign(new Error(message), {\n failureScreenshot: shotBuf ? shotBuf.toString('base64') : null,\n reason: challenge.hasCloudflare ? 'CLOUDFLARE_CHALLENGE' : challenge.hasCaptcha ? 'CAPTCHA_CHALLENGE' : 'LOGIN_NOT_CONFIRMED',\n });\n throw err;\n }\n\n // Wait for post-login navigation to settle before capturing session\n await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});\n\n // Capture post-login landing URL before exploration navigates away\n const finalUrl = page.url();\n\n // Post-login exploration: navigate tabs, expand menus, scroll to trigger lazy-loaded state\n await exploreAppAfterLogin(page);\n\n const state = await context.storageState();\n const cookies = state.cookies?.length ?? 0;\n const localStorage = state.origins?.reduce(\n (sum, o) => sum + (o.localStorage?.length ?? 0), 0\n ) ?? 0;\n\n if (cookies === 0 && localStorage === 0) {\n throw new Error('Login appeared to succeed but no session data was captured \u2014 try manual session capture instead');\n }\n\n return { storageState: JSON.stringify(state), cookies, localStorage, finalUrl };\n } finally {\n await browser.close().catch(() => {});\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/tls-fingerprint.js
CHANGED
|
@@ -46,7 +46,7 @@ async function probeTlsFingerprint(host, port = 443) {
|
|
|
46
46
|
protocol: socket.getProtocol() ?? "unknown",
|
|
47
47
|
cipher: socket.getCipher()?.name ?? "unknown",
|
|
48
48
|
authorized: socket.authorized,
|
|
49
|
-
subjectCN: cert?.subject?.CN ?? null
|
|
49
|
+
subjectCN: cert?.subject?.CN?.toString() ?? null
|
|
50
50
|
});
|
|
51
51
|
socket.destroy();
|
|
52
52
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/tls-fingerprint.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * TLS fingerprint spoofing \u2014 Chrome JA3 cipher suite ordering.\n *\n * Node.js TLS doesn't expose JA3 hash control directly, but ordering\n * cipher suites to match Chrome's TLS ClientHello reduces fingerprint\n * detectability vs the default OpenSSL order.\n *\n * For full JA3 bypass, pair with the browser-stealth engine (Camoufox).\n */\n\nimport { Agent as HttpsAgent } from 'node:https';\nimport { connect as tlsConnect } from 'node:tls';\n\n// Chrome 120+ TLS 1.3 + 1.2 cipher suite order\nexport const CHROME_CIPHERS = [\n 'TLS_AES_128_GCM_SHA256',\n 'TLS_AES_256_GCM_SHA384',\n 'TLS_CHACHA20_POLY1305_SHA256',\n 'ECDHE-ECDSA-AES128-GCM-SHA256',\n 'ECDHE-RSA-AES128-GCM-SHA256',\n 'ECDHE-ECDSA-AES256-GCM-SHA384',\n 'ECDHE-RSA-AES256-GCM-SHA384',\n 'ECDHE-ECDSA-CHACHA20-POLY1305',\n 'ECDHE-RSA-CHACHA20-POLY1305',\n 'ECDHE-RSA-AES128-SHA',\n 'ECDHE-RSA-AES256-SHA',\n 'AES128-GCM-SHA256',\n 'AES256-GCM-SHA384',\n 'AES128-SHA',\n 'AES256-SHA',\n].join(':');\n\n// Chrome's ECDH curve preference order\nexport const CHROME_CURVES = 'X25519:P-256:P-384';\n\nexport interface TlsFingerprintOptions {\n rejectUnauthorized?: boolean;\n keepAlive?: boolean;\n keepAliveMsecs?: number;\n maxSockets?: number;\n}\n\n/**\n * Returns an HTTPS agent configured with Chrome-like TLS settings.\n * Drop-in replacement for the default agent in fetch/got/axios.\n */\nexport function createChromeTlsAgent(opts: TlsFingerprintOptions = {}): HttpsAgent {\n return new HttpsAgent({\n ciphers: CHROME_CIPHERS,\n ecdhCurve: CHROME_CURVES,\n minVersion: 'TLSv1.2',\n maxVersion: 'TLSv1.3',\n rejectUnauthorized: opts.rejectUnauthorized ?? true,\n keepAlive: opts.keepAlive ?? true,\n keepAliveMsecs: opts.keepAliveMsecs ?? 10_000,\n maxSockets: opts.maxSockets ?? 50,\n });\n}\n\n/**\n * Probe a host and return its JA3-relevant TLS parameters.\n * Useful for verifying that the fingerprint changed vs default.\n */\nexport async function probeTlsFingerprint(host: string, port = 443): Promise<{\n protocol: string;\n cipher: string;\n authorized: boolean;\n subjectCN: string | null;\n}> {\n return new Promise((resolve, reject) => {\n const socket = tlsConnect({\n host,\n port,\n servername: host,\n ciphers: CHROME_CIPHERS,\n ecdhCurve: CHROME_CURVES,\n minVersion: 'TLSv1.2',\n rejectUnauthorized: false,\n }, () => {\n const cert = socket.getPeerCertificate();\n resolve({\n protocol: socket.getProtocol() ?? 'unknown',\n cipher: socket.getCipher()?.name ?? 'unknown',\n authorized: socket.authorized,\n subjectCN: cert?.subject?.CN ?? null,\n });\n socket.destroy();\n });\n socket.on('error', reject);\n setTimeout(() => { socket.destroy(); reject(new Error('TLS probe timeout')); }, 5_000);\n });\n}\n\n/**\n * Node.js fetch dispatcher options with Chrome TLS agent.\n * Pass as the `dispatcher` option to undici or as `agent` to node-fetch.\n */\nexport const chromeTlsAgent = createChromeTlsAgent();\n\nexport const CHROME_TLS_HEADERS = {\n 'Accept-Encoding': 'gzip, deflate, br, zstd',\n 'Accept-Language': 'en-US,en;q=0.9',\n 'sec-ch-ua': '\"Google Chrome\";v=\"120\", \"Chromium\";v=\"120\", \"Not-A.Brand\";v=\"99\"',\n 'sec-ch-ua-mobile': '?0',\n 'sec-ch-ua-platform': '\"Windows\"',\n 'Sec-Fetch-Dest': 'document',\n 'Sec-Fetch-Mode': 'navigate',\n 'Sec-Fetch-Site': 'none',\n 'Sec-Fetch-User': '?1',\n 'Upgrade-Insecure-Requests': '1',\n};\n"],
|
|
5
|
-
"mappings": "AAUA,SAAS,SAAS,kBAAkB;AACpC,SAAS,WAAW,kBAAkB;AAG/B,MAAM,iBAAiB;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,GAAG;AAGH,MAAM,gBAAgB;AAatB,SAAS,qBAAqB,OAA8B,CAAC,GAAe;AACjF,SAAO,IAAI,WAAW;AAAA,IACpB,SAAS;AAAA,IACT,WAAW;AAAA,IACX,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ,oBAAoB,KAAK,sBAAsB;AAAA,IAC/C,WAAW,KAAK,aAAa;AAAA,IAC7B,gBAAgB,KAAK,kBAAkB;AAAA,IACvC,YAAY,KAAK,cAAc;AAAA,EACjC,CAAC;AACH;AAMA,eAAsB,oBAAoB,MAAc,OAAO,KAK5D;AACD,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,SAAS,WAAW;AAAA,MACxB;AAAA,MACA;AAAA,MACA,YAAY;AAAA,MACZ,SAAS;AAAA,MACT,WAAW;AAAA,MACX,YAAY;AAAA,MACZ,oBAAoB;AAAA,IACtB,GAAG,MAAM;AACP,YAAM,OAAO,OAAO,mBAAmB;AACvC,cAAQ;AAAA,QACN,UAAU,OAAO,YAAY,KAAK;AAAA,QAClC,QAAQ,OAAO,UAAU,GAAG,QAAQ;AAAA,QACpC,YAAY,OAAO;AAAA,QACnB,WAAW,MAAM,SAAS,
|
|
4
|
+
"sourcesContent": ["/**\n * TLS fingerprint spoofing \u2014 Chrome JA3 cipher suite ordering.\n *\n * Node.js TLS doesn't expose JA3 hash control directly, but ordering\n * cipher suites to match Chrome's TLS ClientHello reduces fingerprint\n * detectability vs the default OpenSSL order.\n *\n * For full JA3 bypass, pair with the browser-stealth engine (Camoufox).\n */\n\nimport { Agent as HttpsAgent } from 'node:https';\nimport { connect as tlsConnect } from 'node:tls';\n\n// Chrome 120+ TLS 1.3 + 1.2 cipher suite order\nexport const CHROME_CIPHERS = [\n 'TLS_AES_128_GCM_SHA256',\n 'TLS_AES_256_GCM_SHA384',\n 'TLS_CHACHA20_POLY1305_SHA256',\n 'ECDHE-ECDSA-AES128-GCM-SHA256',\n 'ECDHE-RSA-AES128-GCM-SHA256',\n 'ECDHE-ECDSA-AES256-GCM-SHA384',\n 'ECDHE-RSA-AES256-GCM-SHA384',\n 'ECDHE-ECDSA-CHACHA20-POLY1305',\n 'ECDHE-RSA-CHACHA20-POLY1305',\n 'ECDHE-RSA-AES128-SHA',\n 'ECDHE-RSA-AES256-SHA',\n 'AES128-GCM-SHA256',\n 'AES256-GCM-SHA384',\n 'AES128-SHA',\n 'AES256-SHA',\n].join(':');\n\n// Chrome's ECDH curve preference order\nexport const CHROME_CURVES = 'X25519:P-256:P-384';\n\nexport interface TlsFingerprintOptions {\n rejectUnauthorized?: boolean;\n keepAlive?: boolean;\n keepAliveMsecs?: number;\n maxSockets?: number;\n}\n\n/**\n * Returns an HTTPS agent configured with Chrome-like TLS settings.\n * Drop-in replacement for the default agent in fetch/got/axios.\n */\nexport function createChromeTlsAgent(opts: TlsFingerprintOptions = {}): HttpsAgent {\n return new HttpsAgent({\n ciphers: CHROME_CIPHERS,\n ecdhCurve: CHROME_CURVES,\n minVersion: 'TLSv1.2',\n maxVersion: 'TLSv1.3',\n rejectUnauthorized: opts.rejectUnauthorized ?? true,\n keepAlive: opts.keepAlive ?? true,\n keepAliveMsecs: opts.keepAliveMsecs ?? 10_000,\n maxSockets: opts.maxSockets ?? 50,\n });\n}\n\n/**\n * Probe a host and return its JA3-relevant TLS parameters.\n * Useful for verifying that the fingerprint changed vs default.\n */\nexport async function probeTlsFingerprint(host: string, port = 443): Promise<{\n protocol: string;\n cipher: string;\n authorized: boolean;\n subjectCN: string | null;\n}> {\n return new Promise((resolve, reject) => {\n const socket = tlsConnect({\n host,\n port,\n servername: host,\n ciphers: CHROME_CIPHERS,\n ecdhCurve: CHROME_CURVES,\n minVersion: 'TLSv1.2',\n rejectUnauthorized: false,\n }, () => {\n const cert = socket.getPeerCertificate();\n resolve({\n protocol: socket.getProtocol() ?? 'unknown',\n cipher: socket.getCipher()?.name ?? 'unknown',\n authorized: socket.authorized,\n subjectCN: cert?.subject?.CN?.toString() ?? null,\n });\n socket.destroy();\n });\n socket.on('error', reject);\n setTimeout(() => { socket.destroy(); reject(new Error('TLS probe timeout')); }, 5_000);\n });\n}\n\n/**\n * Node.js fetch dispatcher options with Chrome TLS agent.\n * Pass as the `dispatcher` option to undici or as `agent` to node-fetch.\n */\nexport const chromeTlsAgent = createChromeTlsAgent();\n\nexport const CHROME_TLS_HEADERS = {\n 'Accept-Encoding': 'gzip, deflate, br, zstd',\n 'Accept-Language': 'en-US,en;q=0.9',\n 'sec-ch-ua': '\"Google Chrome\";v=\"120\", \"Chromium\";v=\"120\", \"Not-A.Brand\";v=\"99\"',\n 'sec-ch-ua-mobile': '?0',\n 'sec-ch-ua-platform': '\"Windows\"',\n 'Sec-Fetch-Dest': 'document',\n 'Sec-Fetch-Mode': 'navigate',\n 'Sec-Fetch-Site': 'none',\n 'Sec-Fetch-User': '?1',\n 'Upgrade-Insecure-Requests': '1',\n};\n"],
|
|
5
|
+
"mappings": "AAUA,SAAS,SAAS,kBAAkB;AACpC,SAAS,WAAW,kBAAkB;AAG/B,MAAM,iBAAiB;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,GAAG;AAGH,MAAM,gBAAgB;AAatB,SAAS,qBAAqB,OAA8B,CAAC,GAAe;AACjF,SAAO,IAAI,WAAW;AAAA,IACpB,SAAS;AAAA,IACT,WAAW;AAAA,IACX,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ,oBAAoB,KAAK,sBAAsB;AAAA,IAC/C,WAAW,KAAK,aAAa;AAAA,IAC7B,gBAAgB,KAAK,kBAAkB;AAAA,IACvC,YAAY,KAAK,cAAc;AAAA,EACjC,CAAC;AACH;AAMA,eAAsB,oBAAoB,MAAc,OAAO,KAK5D;AACD,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,SAAS,WAAW;AAAA,MACxB;AAAA,MACA;AAAA,MACA,YAAY;AAAA,MACZ,SAAS;AAAA,MACT,WAAW;AAAA,MACX,YAAY;AAAA,MACZ,oBAAoB;AAAA,IACtB,GAAG,MAAM;AACP,YAAM,OAAO,OAAO,mBAAmB;AACvC,cAAQ;AAAA,QACN,UAAU,OAAO,YAAY,KAAK;AAAA,QAClC,QAAQ,OAAO,UAAU,GAAG,QAAQ;AAAA,QACpC,YAAY,OAAO;AAAA,QACnB,WAAW,MAAM,SAAS,IAAI,SAAS,KAAK;AAAA,MAC9C,CAAC;AACD,aAAO,QAAQ;AAAA,IACjB,CAAC;AACD,WAAO,GAAG,SAAS,MAAM;AACzB,eAAW,MAAM;AAAE,aAAO,QAAQ;AAAG,aAAO,IAAI,MAAM,mBAAmB,CAAC;AAAA,IAAG,GAAG,GAAK;AAAA,EACvF,CAAC;AACH;AAMO,MAAM,iBAAiB,qBAAqB;AAE5C,MAAM,qBAAqB;AAAA,EAChC,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,aAAa;AAAA,EACb,oBAAoB;AAAA,EACpB,sBAAsB;AAAA,EACtB,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,6BAA6B;AAC/B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@detiq/crawler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"mailparser": "^3.9.12",
|
|
32
32
|
"pino": "^10.3.1",
|
|
33
33
|
"pino-pretty": "^13.1.3",
|
|
34
|
+
"pixelmatch": "^6.0.0",
|
|
34
35
|
"playwright": "^1.55.0",
|
|
35
36
|
"playwright-extra": "^4.3.6",
|
|
36
37
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
package/src/crawler.ts
CHANGED
|
@@ -74,6 +74,16 @@ import { generateOpenApiSpec, specToYaml } from './openapi-generator.js';
|
|
|
74
74
|
import { WebhookNotifier } from './webhook-notifier.js';
|
|
75
75
|
import { generatePlaywrightTest } from './playwright-recorder.js';
|
|
76
76
|
|
|
77
|
+
// Module-level cache for per-crawl LLM config — avoids redundant DB round-trips inside per-page loops.
|
|
78
|
+
// Keyed by tenantId so multi-tenant workers don't share configs across tenants.
|
|
79
|
+
const _crawlLLMCache = new Map<string, any>();
|
|
80
|
+
async function getCachedCrawlLLM(tenantId: string): Promise<any> {
|
|
81
|
+
if (_crawlLLMCache.has(tenantId)) return _crawlLLMCache.get(tenantId);
|
|
82
|
+
const cfg = await getTaskAgentLLMConfig(tenantId, 'crawl').catch(() => null);
|
|
83
|
+
_crawlLLMCache.set(tenantId, cfg);
|
|
84
|
+
return cfg;
|
|
85
|
+
}
|
|
86
|
+
|
|
77
87
|
function getGitInfo(): { branch: string; commit: string } {
|
|
78
88
|
try {
|
|
79
89
|
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', timeout: 3000 }).trim();
|
|
@@ -1707,12 +1717,12 @@ export async function crawlProject(opts: {
|
|
|
1707
1717
|
continue;
|
|
1708
1718
|
}
|
|
1709
1719
|
// Full page-ready sequence:
|
|
1710
|
-
// 1.
|
|
1720
|
+
// 1. load — waits for initial page load (faster than networkidle; XHR handled by waitForStableDOM)
|
|
1711
1721
|
// 2. DOM stability — waits for React/Vue hydration to finish mutating the DOM
|
|
1712
1722
|
// 3. Modal capture — screenshot app dialogs BEFORE dismissal (they are testable UI states)
|
|
1713
1723
|
// 4. Overlay dismissal — removes cookie banners/modals before element extraction
|
|
1714
1724
|
// 5. Lazy-load scroll — triggers IntersectionObserver for off-screen images/components
|
|
1715
|
-
await page.waitForLoadState('
|
|
1725
|
+
await page.waitForLoadState('load', { timeout: 8_000 }).catch(() => { });
|
|
1716
1726
|
await waitForStableDOM(page);
|
|
1717
1727
|
|
|
1718
1728
|
if (!opts.discoveryMode) {
|
|
@@ -1806,7 +1816,7 @@ export async function crawlProject(opts: {
|
|
|
1806
1816
|
throw new Error('[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.');
|
|
1807
1817
|
}
|
|
1808
1818
|
} else {
|
|
1809
|
-
logger.warn('[crawler] Session expired mid-crawl, no credentials to recover —
|
|
1819
|
+
logger.warn('[crawler] Session expired mid-crawl, no credentials to recover — skipping URL');
|
|
1810
1820
|
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
1811
1821
|
jobId: opts.jobId,
|
|
1812
1822
|
type: 'AUTH_FAILURE',
|
|
@@ -2203,8 +2213,8 @@ export async function crawlProject(opts: {
|
|
|
2203
2213
|
const linkRecipe = await AppBrain.getActionRecipe(
|
|
2204
2214
|
opts.tenantId, opts.projectId, new URL(url).hostname, `link-discovery:${canonUrl}`
|
|
2205
2215
|
).catch(() => null);
|
|
2206
|
-
const cachedLinks: string[] | null = (linkRecipe && linkRecipe.successRate >= 0.7)
|
|
2207
|
-
? ((linkRecipe.steps as any[])
|
|
2216
|
+
const cachedLinks: string[] | null = (linkRecipe && (linkRecipe as any).successRate >= 0.7)
|
|
2217
|
+
? (((linkRecipe as any).steps as any[])
|
|
2208
2218
|
.map((s: any) => s.href)
|
|
2209
2219
|
.filter((href: string | undefined): href is string => !!href && isUsefulNavigationUrl(href, origin)))
|
|
2210
2220
|
: null;
|
|
@@ -2326,7 +2336,7 @@ export async function crawlProject(opts: {
|
|
|
2326
2336
|
// Generate a one-sentence AI summary from the markdown — fire-and-forget
|
|
2327
2337
|
const summaryPromise = (async () => {
|
|
2328
2338
|
try {
|
|
2329
|
-
const llmCfg = await
|
|
2339
|
+
const llmCfg = await getCachedCrawlLLM(opts.tenantId);
|
|
2330
2340
|
const snippet = extracted.markdown.slice(0, 1200);
|
|
2331
2341
|
const { text, usage } = await askTracked(
|
|
2332
2342
|
'You are a QA analyst. Given page content, write ONE crisp sentence describing what this screen does or shows. Be specific. No preamble.',
|
|
@@ -2486,21 +2496,48 @@ export async function crawlProject(opts: {
|
|
|
2486
2496
|
}
|
|
2487
2497
|
}
|
|
2488
2498
|
|
|
2489
|
-
|
|
2499
|
+
let { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
|
|
2500
|
+
|
|
2501
|
+
// R7: Apply per-project visual diff threshold — suppress diffs below the threshold
|
|
2502
|
+
if (changeType !== 'NONE') {
|
|
2503
|
+
try {
|
|
2504
|
+
const project = await AppBrain.getProject(opts.tenantId, opts.projectId);
|
|
2505
|
+
const threshold = (project as any)?.visualDiffThreshold ?? null;
|
|
2506
|
+
if (threshold !== null && pHashDistance < threshold) {
|
|
2507
|
+
changeType = 'NONE';
|
|
2508
|
+
}
|
|
2509
|
+
} catch { /* non-fatal — use verdict as-is */ }
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2490
2512
|
if (changeType !== 'NONE') {
|
|
2491
2513
|
let newScreenshotCloudUrl: string | undefined;
|
|
2514
|
+
let diffImageCloudUrl: string | undefined;
|
|
2492
2515
|
try {
|
|
2493
2516
|
const { getTenantStorageConfig, uploadScreenshotToTenantStorage, buildArtifactKey } = await import('@detiq/app-brain');
|
|
2494
2517
|
const storageCfg = await getTenantStorageConfig(opts.tenantId).catch(() => null);
|
|
2495
2518
|
if (storageCfg) {
|
|
2496
2519
|
const key = buildArtifactKey(opts.tenantId, opts.projectId, 'visual-diffs', `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
|
|
2497
2520
|
newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
|
|
2521
|
+
|
|
2522
|
+
// R5: Generate and upload pixelmatch diff image (changed pixels in red)
|
|
2523
|
+
try {
|
|
2524
|
+
const { generatePixelmatchDiff } = await import('./phash.js');
|
|
2525
|
+
const diffBuffer = await generatePixelmatchDiff(baseline.storagePath, shotPath, baseline.ignoredRegions ?? []);
|
|
2526
|
+
const diffTmpPath = `${shotPath}.diff.png`;
|
|
2527
|
+
await fs.writeFile(diffTmpPath, diffBuffer);
|
|
2528
|
+
const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, 'visual-diffs', `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
|
|
2529
|
+
diffImageCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, diffTmpPath, diffKey);
|
|
2530
|
+
fs.unlink(diffTmpPath).catch(() => {});
|
|
2531
|
+
} catch (diffErr) {
|
|
2532
|
+
logger.warn({ screenId: screen.id, err: String(diffErr) }, '[crawler] pixelmatch diff generation failed, skipping');
|
|
2533
|
+
}
|
|
2498
2534
|
}
|
|
2499
2535
|
} catch { /* non-fatal — falls back to local path */ }
|
|
2500
2536
|
await AppBrain.saveVisualDiff(opts.tenantId, {
|
|
2501
2537
|
baselineId: baseline.id,
|
|
2502
2538
|
newScreenshotPath: shotPath,
|
|
2503
2539
|
newScreenshotCloudUrl,
|
|
2540
|
+
diffImageCloudUrl,
|
|
2504
2541
|
diffScore: pHashDistance,
|
|
2505
2542
|
changeType,
|
|
2506
2543
|
changes: allChanges,
|
|
@@ -2522,18 +2559,22 @@ export async function crawlProject(opts: {
|
|
|
2522
2559
|
}
|
|
2523
2560
|
|
|
2524
2561
|
const linkMappings = await extractLinkMappings(page, origin);
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2562
|
+
// Batch element saves: 4 concurrent DB writes instead of serial round-trips
|
|
2563
|
+
for (let _ei = 0; _ei < elements.length; _ei += 4) {
|
|
2564
|
+
const _batch = elements.slice(_ei, _ei + 4);
|
|
2565
|
+
await Promise.all(_batch.map(async (el: any) => {
|
|
2566
|
+
const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark);
|
|
2567
|
+
// Populate ElementFingerprint — fire-and-forget, failures are non-fatal
|
|
2568
|
+
const label = el.meaning;
|
|
2569
|
+
const role = el.role ?? 'unknown';
|
|
2570
|
+
const selectorHint = atByRole.get(role) ?? role;
|
|
2571
|
+
const selectors = [
|
|
2572
|
+
{ type: 'role-label', value: `${role}:${label}`, confidence: 0.9 },
|
|
2573
|
+
{ type: 'aria-label', value: label, confidence: 0.85 },
|
|
2574
|
+
{ type: 'tag-hint', value: selectorHint, confidence: 0.6 },
|
|
2575
|
+
];
|
|
2576
|
+
AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, (saved as any).id, selectors).catch(console.warn);
|
|
2577
|
+
}));
|
|
2537
2578
|
}
|
|
2538
2579
|
for (const lm of linkMappings) {
|
|
2539
2580
|
const safety = assessActionSafety(lm.label, domainProfile);
|
|
@@ -2876,7 +2917,7 @@ export async function crawlProject(opts: {
|
|
|
2876
2917
|
// Saves URL_CANDIDATES evidence so the ActivityDrawer shows what was found.
|
|
2877
2918
|
try {
|
|
2878
2919
|
const { runUrlPatternCompletion } = await import('./url-pattern-completer.js');
|
|
2879
|
-
const llmCfgForPatterns = await
|
|
2920
|
+
const llmCfgForPatterns = await getCachedCrawlLLM(opts.tenantId);
|
|
2880
2921
|
const urlPatternCandidates = await runUrlPatternCompletion(
|
|
2881
2922
|
Array.from(visited.keys()),
|
|
2882
2923
|
opts.appUrl,
|
|
@@ -2944,7 +2985,7 @@ export async function crawlProject(opts: {
|
|
|
2944
2985
|
if (visited.size >= 3) {
|
|
2945
2986
|
try {
|
|
2946
2987
|
await onProgress?.(80, 'Running gap analysis…');
|
|
2947
|
-
const llmCfgForGap = await
|
|
2988
|
+
const llmCfgForGap = await getCachedCrawlLLM(opts.tenantId);
|
|
2948
2989
|
const discoveredForGap = Array.from(visited.entries()).map(([url, screenId]) => {
|
|
2949
2990
|
const d = discovered.find(dd => dd.screenId === screenId);
|
|
2950
2991
|
return { url, name: d ? (new URL(url).pathname.split('/').filter(Boolean).pop() ?? 'screen') : undefined, elements: d?.elements };
|
|
@@ -4168,7 +4209,6 @@ async function waitForScreenshotReady(page: any): Promise<void> {
|
|
|
4168
4209
|
]);
|
|
4169
4210
|
}).catch(() => { }));
|
|
4170
4211
|
await withHardCap(waitForStableDOM(page, 2_000, 500));
|
|
4171
|
-
await withHardCap(page.waitForTimeout(250).catch(() => { }));
|
|
4172
4212
|
} catch { /* readiness is best-effort; never fail the crawl for a continuously mutating page */ }
|
|
4173
4213
|
}
|
|
4174
4214
|
|
package/src/job-queue.ts
CHANGED
|
@@ -32,7 +32,7 @@ async function notifyAdmins(tenantId: string, data: {
|
|
|
32
32
|
where: { tenantId, role: { in: ['OWNER', 'ADMIN'] } },
|
|
33
33
|
select: { userId: true },
|
|
34
34
|
})
|
|
35
|
-
);
|
|
35
|
+
) as any[];
|
|
36
36
|
await withTenant(tenantId, (tx) =>
|
|
37
37
|
tx.notification.createMany({
|
|
38
38
|
data: members.map((m: { userId: string }) => ({
|
|
@@ -189,17 +189,17 @@ const runningControllers = new Map<string, AbortController>();
|
|
|
189
189
|
// double-processed. See startJobWorkerLoop() below.
|
|
190
190
|
|
|
191
191
|
const WORKER_ID = `${os.hostname()}:${process.pid}`;
|
|
192
|
-
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES
|
|
193
|
-
const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS
|
|
194
|
-
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY
|
|
195
|
-
const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS
|
|
192
|
+
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
|
|
193
|
+
const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15_000;
|
|
194
|
+
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 3;
|
|
195
|
+
const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS) || 15_000;
|
|
196
196
|
// How many headless browsers a single RUN_TEST_SUITE job launches at once —
|
|
197
197
|
// distinct from WORKER_CONCURRENCY (how many *jobs* the poll loop claims at
|
|
198
198
|
// once). Bounded by default to avoid exhausting a container's memory; the
|
|
199
199
|
// primary scale-out path for running more tests at once is still horizontal
|
|
200
200
|
// (more worker containers / SQS), not raising this past what one container
|
|
201
201
|
// can safely hold — see startJobWorkerLoop's doc comment.
|
|
202
|
-
const TEST_EXEC_CONCURRENCY = Number(process.env.TEST_EXEC_CONCURRENCY
|
|
202
|
+
const TEST_EXEC_CONCURRENCY = Number(process.env.TEST_EXEC_CONCURRENCY) || 3;
|
|
203
203
|
|
|
204
204
|
type ClaimedJob = { id: string; tenantId: string; projectId: string; type: string; config: any };
|
|
205
205
|
|
|
@@ -254,7 +254,7 @@ export async function requeueStaleRunningJobs(): Promise<void> {
|
|
|
254
254
|
RETURNING id
|
|
255
255
|
`;
|
|
256
256
|
if (rows.length > 0) {
|
|
257
|
-
logger.warn({ jobIds: rows.map((r) => r.id), staleTimeoutMinutes: STALE_JOB_TIMEOUT_MINUTES }, '[job-worker] requeued stale RUNNING job(s)');
|
|
257
|
+
logger.warn({ jobIds: rows.map((r: any) => r.id), staleTimeoutMinutes: STALE_JOB_TIMEOUT_MINUTES }, '[job-worker] requeued stale RUNNING job(s)');
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
|
|
@@ -567,14 +567,15 @@ export interface ProcessJobOpts {
|
|
|
567
567
|
export async function processJob(jobId: string, config: any, opts?: ProcessJobOpts) {
|
|
568
568
|
const { tenantId, projectId } = config;
|
|
569
569
|
|
|
570
|
-
// Mark as RUNNING.
|
|
570
|
+
// Mark as RUNNING — skip if already CANCELLED (race: user cancelled before worker picked it up).
|
|
571
571
|
const jobStartedAt = new Date();
|
|
572
|
-
await withTenant(tenantId, (tx) =>
|
|
573
|
-
tx.platformJob.
|
|
574
|
-
where: { id: jobId },
|
|
572
|
+
const claimed = await withTenant(tenantId, (tx) =>
|
|
573
|
+
tx.platformJob.updateMany({
|
|
574
|
+
where: { id: jobId, status: { not: 'CANCELLED' } },
|
|
575
575
|
data: { status: 'RUNNING', startedAt: jobStartedAt, heartbeatAt: jobStartedAt, workerId: WORKER_ID },
|
|
576
576
|
})
|
|
577
577
|
);
|
|
578
|
+
if (claimed.count === 0) return;
|
|
578
579
|
|
|
579
580
|
const controller = new AbortController();
|
|
580
581
|
runningControllers.set(jobId, controller);
|
|
@@ -1469,7 +1470,7 @@ async function processGenerateSkillFilesJob(jobId: string, config: any) {
|
|
|
1469
1470
|
select: { result: true },
|
|
1470
1471
|
})
|
|
1471
1472
|
);
|
|
1472
|
-
const lastResult = (lastCrawl?.result ?? {}) as any;
|
|
1473
|
+
const lastResult = ((lastCrawl as any)?.result ?? {}) as any;
|
|
1473
1474
|
await updateJobProgress(tenantId, jobId, 25, `Generating ${screens.length} app intelligence skill files…`);
|
|
1474
1475
|
await generateTaxonomySkillFiles(tenantId, projectId, screens, lastResult.apiEndpoints, lastResult.performanceMetrics);
|
|
1475
1476
|
await updateJobProgress(tenantId, jobId, 85, 'Refreshing testing intelligence skill files…');
|
|
@@ -1702,7 +1703,9 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1702
1703
|
authToken: crawlCfg.authToken,
|
|
1703
1704
|
authTokenHeader: crawlCfg.authTokenHeader,
|
|
1704
1705
|
authTokenPrefix: crawlCfg.authTokenPrefix,
|
|
1705
|
-
startUrls:
|
|
1706
|
+
startUrls: crawlCfg.postLoginUrl
|
|
1707
|
+
? [crawlCfg.postLoginUrl, ...(sitemapUrls.length > 0 ? sitemapUrls : [])]
|
|
1708
|
+
: (sitemapUrls.length > 0 ? sitemapUrls : undefined),
|
|
1706
1709
|
screenshotDir,
|
|
1707
1710
|
maxScreens: maxDiscoverUrls,
|
|
1708
1711
|
proxyUrl,
|
|
@@ -1820,7 +1823,7 @@ async function processMonitorJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
1820
1823
|
const projectScreens: Array<{ url: string | null; elementHash: string | null }> =
|
|
1821
1824
|
await withTenant(tenantId, (tx) =>
|
|
1822
1825
|
tx.screen.findMany({ where: { projectId }, select: { url: true, elementHash: true } })
|
|
1823
|
-
).catch(() => []);
|
|
1826
|
+
).catch(() => []) as any;
|
|
1824
1827
|
const screenHashMap: Record<string, string> = {};
|
|
1825
1828
|
for (const s of projectScreens) {
|
|
1826
1829
|
if (s.url && s.elementHash) screenHashMap[s.url] = s.elementHash;
|
|
@@ -2155,7 +2158,7 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
2155
2158
|
|
|
2156
2159
|
const conn = await withTenant(tenantId, (tx) =>
|
|
2157
2160
|
tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
2158
|
-
);
|
|
2161
|
+
) as any;
|
|
2159
2162
|
if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
|
|
2160
2163
|
|
|
2161
2164
|
// Decrypt token
|
|
@@ -2288,7 +2291,7 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
2288
2291
|
const { buildCoverageMap } = await import('@detiq/agents');
|
|
2289
2292
|
const testCases = await withTenant(tenantId, (tx) =>
|
|
2290
2293
|
tx.testCase.findMany({ where: { projectId, tenantId, status: { not: 'RETIRED' } }, select: { id: true, title: true, ref: true } })
|
|
2291
|
-
);
|
|
2294
|
+
) as any[];
|
|
2292
2295
|
|
|
2293
2296
|
const mappings = await buildCoverageMap(testCases, allExtracted, tenantId);
|
|
2294
2297
|
|
|
@@ -2296,7 +2299,7 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
2296
2299
|
for (const m of mappings) {
|
|
2297
2300
|
const existing = await withTenant(tenantId, (tx) =>
|
|
2298
2301
|
tx.coverageMapping.findUnique({ where: { testCaseId: m.testCaseId } })
|
|
2299
|
-
);
|
|
2302
|
+
) as any;
|
|
2300
2303
|
if (existing?.manualOverride) continue;
|
|
2301
2304
|
|
|
2302
2305
|
await withTenant(tenantId, (tx) =>
|
|
@@ -2425,8 +2428,8 @@ async function processBatchGenerateAlignedJob(jobId: string, config: any) {
|
|
|
2425
2428
|
where: { tenantId, projectId, status: { not: 'RETIRED' } },
|
|
2426
2429
|
select: { id: true },
|
|
2427
2430
|
})
|
|
2428
|
-
);
|
|
2429
|
-
caseIds = cases.map((c) => c.id);
|
|
2431
|
+
) as any[];
|
|
2432
|
+
caseIds = cases.map((c: any) => c.id);
|
|
2430
2433
|
}
|
|
2431
2434
|
|
|
2432
2435
|
await updateJobProgress(tenantId, jobId, 15, `Generating scripts for ${caseIds.length} test cases`);
|
|
@@ -2674,7 +2677,7 @@ async function processNewPagesPipelineJob(jobId: string, config: any) {
|
|
|
2674
2677
|
select: { id: true, url: true, name: true },
|
|
2675
2678
|
take: 100,
|
|
2676
2679
|
})
|
|
2677
|
-
);
|
|
2680
|
+
) as any[];
|
|
2678
2681
|
|
|
2679
2682
|
if (newScreens.length === 0) {
|
|
2680
2683
|
await updateJobProgress(tenantId, jobId, 100, 'No new screens to process');
|
package/src/phash.ts
CHANGED
|
@@ -57,6 +57,39 @@ function hammingDistance(a: bigint, b: bigint): number {
|
|
|
57
57
|
return count;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Generates a pixelmatch diff image highlighting changed pixels in red.
|
|
62
|
+
* Applies the same ignoredRegions mask as screenshotDiffPercent so masked
|
|
63
|
+
* zones don't show as "changed" in the visual output.
|
|
64
|
+
* Returns a PNG Buffer suitable for upload to S3.
|
|
65
|
+
*/
|
|
66
|
+
export async function generatePixelmatchDiff(
|
|
67
|
+
pathA: string,
|
|
68
|
+
pathB: string,
|
|
69
|
+
ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [],
|
|
70
|
+
): Promise<Buffer> {
|
|
71
|
+
const metaA = await sharp(pathA).metadata();
|
|
72
|
+
const W = metaA.width ?? 1200;
|
|
73
|
+
const H = metaA.height ?? 800;
|
|
74
|
+
|
|
75
|
+
const toRawRGBA = async (path: string): Promise<Buffer> => {
|
|
76
|
+
const base = ignoredRegions.length > 0 ? sharp(await applyMask(path, ignoredRegions)) : sharp(path);
|
|
77
|
+
const { data } = await base.resize(W, H, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
78
|
+
return data;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const [rawA, rawB] = await Promise.all([toRawRGBA(pathA), toRawRGBA(pathB)]);
|
|
82
|
+
const { default: pixelmatch } = await import('pixelmatch');
|
|
83
|
+
const diffData = Buffer.alloc(W * H * 4);
|
|
84
|
+
pixelmatch(rawA, rawB, diffData, W, H, {
|
|
85
|
+
threshold: 0.1,
|
|
86
|
+
alpha: 0.3,
|
|
87
|
+
diffColor: [255, 0, 0],
|
|
88
|
+
aaColor: [255, 255, 0],
|
|
89
|
+
});
|
|
90
|
+
return sharp(diffData, { raw: { width: W, height: H, channels: 4 } }).png().toBuffer();
|
|
91
|
+
}
|
|
92
|
+
|
|
60
93
|
export async function screenshotDiffPercent(
|
|
61
94
|
pathA: string,
|
|
62
95
|
pathB: string,
|
package/src/session-capture.ts
CHANGED
|
@@ -207,7 +207,7 @@ export async function validateAndCaptureSession(
|
|
|
207
207
|
},
|
|
208
208
|
llmConfig?: { modelName: string; apiKey: string },
|
|
209
209
|
captchaOpts?: { apiKey?: string; provider?: '2captcha' | 'capsolver' },
|
|
210
|
-
): Promise<{ storageState: string; cookies: number; localStorage: number }> {
|
|
210
|
+
): Promise<{ storageState: string; cookies: number; localStorage: number; finalUrl?: string }> {
|
|
211
211
|
const browser = await chromium.launch({ headless: true });
|
|
212
212
|
try {
|
|
213
213
|
const contextOpts: any = {};
|
|
@@ -306,6 +306,9 @@ export async function validateAndCaptureSession(
|
|
|
306
306
|
// Wait for post-login navigation to settle before capturing session
|
|
307
307
|
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
|
|
308
308
|
|
|
309
|
+
// Capture post-login landing URL before exploration navigates away
|
|
310
|
+
const finalUrl = page.url();
|
|
311
|
+
|
|
309
312
|
// Post-login exploration: navigate tabs, expand menus, scroll to trigger lazy-loaded state
|
|
310
313
|
await exploreAppAfterLogin(page);
|
|
311
314
|
|
|
@@ -319,7 +322,7 @@ export async function validateAndCaptureSession(
|
|
|
319
322
|
throw new Error('Login appeared to succeed but no session data was captured — try manual session capture instead');
|
|
320
323
|
}
|
|
321
324
|
|
|
322
|
-
return { storageState: JSON.stringify(state), cookies, localStorage };
|
|
325
|
+
return { storageState: JSON.stringify(state), cookies, localStorage, finalUrl };
|
|
323
326
|
} finally {
|
|
324
327
|
await browser.close().catch(() => {});
|
|
325
328
|
}
|
|
@@ -108,19 +108,12 @@ export async function infiniteScrollToBottom(
|
|
|
108
108
|
): Promise<{ scrollCount: number; heightReached: number }> {
|
|
109
109
|
const { maxScrolls = 50, scrollDelay = 400, stabilizeMs = 600 } = opts;
|
|
110
110
|
let scrollCount = 0;
|
|
111
|
-
let
|
|
112
|
-
let
|
|
111
|
+
let stableScrollCount = 0;
|
|
112
|
+
let previousHeight = 0;
|
|
113
113
|
|
|
114
114
|
try {
|
|
115
115
|
while (scrollCount < maxScrolls) {
|
|
116
|
-
|
|
117
|
-
if (currentHeight === lastHeight) {
|
|
118
|
-
sameHeightCount++;
|
|
119
|
-
if (sameHeightCount >= 3) break; // no new content
|
|
120
|
-
} else {
|
|
121
|
-
sameHeightCount = 0;
|
|
122
|
-
lastHeight = currentHeight;
|
|
123
|
-
}
|
|
116
|
+
previousHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
|
|
124
117
|
|
|
125
118
|
const scrollBy = 500 + Math.floor(Math.random() * 300);
|
|
126
119
|
await page.evaluate((by: number) => window.scrollBy(0, by), scrollBy).catch(() => {});
|
|
@@ -129,10 +122,19 @@ export async function infiniteScrollToBottom(
|
|
|
129
122
|
// Wait for potential lazy-loaded content
|
|
130
123
|
await page.waitForTimeout(stabilizeMs).catch(() => {});
|
|
131
124
|
scrollCount++;
|
|
125
|
+
|
|
126
|
+
const newHeight: number = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
|
|
127
|
+
if (newHeight - previousHeight < 10) {
|
|
128
|
+
stableScrollCount++;
|
|
129
|
+
if (stableScrollCount >= 3) break; // no meaningful new content after 3 consecutive non-growing scrolls
|
|
130
|
+
} else {
|
|
131
|
+
stableScrollCount = 0;
|
|
132
|
+
}
|
|
133
|
+
previousHeight = newHeight;
|
|
132
134
|
}
|
|
133
135
|
} catch { /* non-fatal */ }
|
|
134
136
|
|
|
135
|
-
return { scrollCount, heightReached:
|
|
137
|
+
return { scrollCount, heightReached: previousHeight };
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
export async function extractIframeContent(page: Page): Promise<Array<{ src: string; content: string; markdown: string }>> {
|