@detiq/crawler 1.1.1 → 1.1.5
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/package.json +1 -1
- package/src/crawler.ts +112 -7
- package/src/job-queue.ts +5 -2
- package/src/sitemap.ts +5 -3
package/package.json
CHANGED
package/src/crawler.ts
CHANGED
|
@@ -1638,6 +1638,92 @@ export async function crawlProject(opts: {
|
|
|
1638
1638
|
|
|
1639
1639
|
let pendingSessionRestore: (() => Promise<void>) | null = null;
|
|
1640
1640
|
|
|
1641
|
+
// F10: Parallel lite-worker browser tabs — pull from shared queue while main loop runs.
|
|
1642
|
+
// Each worker uses a fresh Playwright page from the same browser context so it shares
|
|
1643
|
+
// cookies/session with the main page. DOM-only extraction (no Stagehand AI).
|
|
1644
|
+
// Default 5 = 3-5× crawl throughput on a single browser context. Set CRAWL_PAGE_PARALLELISM=1 to disable.
|
|
1645
|
+
const CRAWL_PAGE_PARALLELISM = Math.max(1, parseInt(process.env.CRAWL_PAGE_PARALLELISM ?? '5', 10));
|
|
1646
|
+
|
|
1647
|
+
async function runLiteWorker(workerPage: any): Promise<void> {
|
|
1648
|
+
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
1649
|
+
if (opts.abortSignal?.aborted) break;
|
|
1650
|
+
if (++totalAttemptsThisSegment > maxAttempts) break;
|
|
1651
|
+
|
|
1652
|
+
const url = queue.shift();
|
|
1653
|
+
if (!url) break;
|
|
1654
|
+
|
|
1655
|
+
const canonUrl = canonicalize(url);
|
|
1656
|
+
if (visited.has(canonUrl)) continue;
|
|
1657
|
+
|
|
1658
|
+
try {
|
|
1659
|
+
const { pathname } = new URL(url);
|
|
1660
|
+
if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
|
|
1661
|
+
} catch { continue; }
|
|
1662
|
+
|
|
1663
|
+
// Claim URL before first await — prevents duplicate processing across parallel workers
|
|
1664
|
+
visited.set(canonUrl, '_pending_');
|
|
1665
|
+
|
|
1666
|
+
try {
|
|
1667
|
+
const navErr = await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).then(() => null as null, (e: any) => e);
|
|
1668
|
+
if (navErr && /timeout/i.test(String((navErr as any)?.message ?? navErr))) {
|
|
1669
|
+
await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const pathParts = new URL(url).pathname.split('/').filter(Boolean);
|
|
1673
|
+
const name = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
1674
|
+
|
|
1675
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
|
|
1676
|
+
visited.set(canonUrl, screen.id);
|
|
1677
|
+
|
|
1678
|
+
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
|
|
1679
|
+
|
|
1680
|
+
const [elements] = await Promise.all([
|
|
1681
|
+
domFallbackElements(workerPage).catch(() => [] as Awaited<ReturnType<typeof domFallbackElements>>),
|
|
1682
|
+
workerPage.screenshot({ path: shotPath, fullPage: true })
|
|
1683
|
+
.then(async () => {
|
|
1684
|
+
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
1685
|
+
return (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl);
|
|
1686
|
+
})
|
|
1687
|
+
.catch(() => {}),
|
|
1688
|
+
]);
|
|
1689
|
+
|
|
1690
|
+
const nextLinks: string[] = await (workerPage.evaluate as any)((originStr: string) => {
|
|
1691
|
+
return Array.from(document.querySelectorAll('a[href]'))
|
|
1692
|
+
.map((a: any) => {
|
|
1693
|
+
try { return new URL(a.getAttribute('href')!, window.location.href).href; }
|
|
1694
|
+
catch { return ''; }
|
|
1695
|
+
})
|
|
1696
|
+
.filter((h: string) => h && new URL(h).origin === originStr && !h.includes('#'))
|
|
1697
|
+
.slice(0, 200);
|
|
1698
|
+
}, origin).catch(() => []);
|
|
1699
|
+
|
|
1700
|
+
for (const link of nextLinks) {
|
|
1701
|
+
const cLink = canonicalize(link);
|
|
1702
|
+
if (!visited.has(cLink)) {
|
|
1703
|
+
queue.push(link);
|
|
1704
|
+
edges.push({ fromUrl: url, toUrl: link });
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
|
|
1709
|
+
} catch (err: any) {
|
|
1710
|
+
logger.warn({ url, err: err?.message ?? String(err) }, '[lite-worker] URL processing failed');
|
|
1711
|
+
discovered.push({ url, error: err?.message ?? String(err) });
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
const liteWorkerPromises: Promise<void>[] = [];
|
|
1717
|
+
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
|
|
1718
|
+
const pwCtx = playwrightBrowser.contexts()[0];
|
|
1719
|
+
for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
|
|
1720
|
+
const wp = await pwCtx.newPage();
|
|
1721
|
+
await wp.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); }).catch(() => {});
|
|
1722
|
+
wp.setDefaultTimeout(45_000);
|
|
1723
|
+
liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {})));
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1641
1727
|
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
1642
1728
|
if (pendingSessionRestore) {
|
|
1643
1729
|
await pendingSessionRestore();
|
|
@@ -1767,9 +1853,17 @@ export async function crawlProject(opts: {
|
|
|
1767
1853
|
}
|
|
1768
1854
|
}
|
|
1769
1855
|
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1856
|
+
// Try fast 15 s first; only escalate to 30 s on timeout so happy-path pages don't wait
|
|
1857
|
+
const navErr15 = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).then(() => null, (e: any) => e);
|
|
1858
|
+
if (navErr15) {
|
|
1859
|
+
if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
|
|
1860
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
|
|
1861
|
+
logger.warn({ url, err: navErr?.message ?? String(navErr) }, '[crawler] navigation failed — skipping');
|
|
1862
|
+
});
|
|
1863
|
+
} else {
|
|
1864
|
+
logger.warn({ url, err: navErr15?.message ?? String(navErr15) }, '[crawler] navigation failed — skipping');
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1773
1867
|
rawPage.off('response', onRedirectResponse);
|
|
1774
1868
|
if (redirectHops.length > 0) {
|
|
1775
1869
|
redirectRegistry.register({
|
|
@@ -2350,7 +2444,8 @@ export async function crawlProject(opts: {
|
|
|
2350
2444
|
}),
|
|
2351
2445
|
// Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
|
|
2352
2446
|
// Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
|
|
2353
|
-
|
|
2447
|
+
// Skip AI on simple pages (< 5 interactive elements) — DOM-only is sufficient and saves ~12s per page.
|
|
2448
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0 || preDomCount < 5)
|
|
2354
2449
|
.then((freshLinks) => {
|
|
2355
2450
|
if (aiSkipCount > 0) aiSkipCount--;
|
|
2356
2451
|
aiErrorStreak = 0;
|
|
@@ -2909,6 +3004,9 @@ export async function crawlProject(opts: {
|
|
|
2909
3004
|
}
|
|
2910
3005
|
}
|
|
2911
3006
|
|
|
3007
|
+
// Wait for any lite workers still processing URLs from the shared queue
|
|
3008
|
+
if (liteWorkerPromises.length > 0) await Promise.all(liteWorkerPromises);
|
|
3009
|
+
|
|
2912
3010
|
// Discovery mode: return collected URLs and skip all post-crawl analysis phases.
|
|
2913
3011
|
// Use visited.values() (original URLs) not .keys() (canonical forms with /{id} placeholders).
|
|
2914
3012
|
if (opts.discoveryMode) {
|
|
@@ -5052,7 +5150,7 @@ async function hasMeaningfulAuthOrPublicState(page: any, requestedUrl: string):
|
|
|
5052
5150
|
}
|
|
5053
5151
|
})();
|
|
5054
5152
|
|
|
5055
|
-
const
|
|
5153
|
+
const domState = await page.evaluate(() => {
|
|
5056
5154
|
const text = (document.body?.innerText ?? '').toLowerCase();
|
|
5057
5155
|
const inputs = Array.from(document.querySelectorAll('input')) as HTMLInputElement[];
|
|
5058
5156
|
const hasPassword = inputs.some((i) => i.type === 'password');
|
|
@@ -5062,9 +5160,16 @@ async function hasMeaningfulAuthOrPublicState(page: any, requestedUrl: string):
|
|
|
5062
5160
|
});
|
|
5063
5161
|
const hasAuthCopy = /(sign in|log in|login|sign up|create account|register|forgot password|reset password|verify email|one-time|otp|continue with email)/i.test(text);
|
|
5064
5162
|
const hasPublicNav = /(pricing|contact|features|documentation|docs|about|privacy|terms)/i.test(text);
|
|
5065
|
-
|
|
5066
|
-
|
|
5163
|
+
// Soft-404: page says "not found" / "404" with no real auth form
|
|
5164
|
+
const isSoft404 = !hasPassword && !hasIdentityInput &&
|
|
5165
|
+
/(page not found|404|this page (could not|doesn'?t) (be found|exist)|we couldn'?t find|no longer exists|been moved|doesn'?t exist)/i.test(text);
|
|
5166
|
+
return { hasPassword, hasIdentityInput, hasAuthCopy, hasPublicNav, isSoft404 };
|
|
5167
|
+
}).catch(() => ({ hasPassword: false, hasIdentityInput: false, hasAuthCopy: false, hasPublicNav: false, isSoft404: false }));
|
|
5168
|
+
|
|
5169
|
+
// Soft-404 with no auth form — path match alone is not enough
|
|
5170
|
+
if (domState.isSoft404) return false;
|
|
5067
5171
|
|
|
5172
|
+
const domLooksRelevant = domState.hasPassword || domState.hasIdentityInput || domState.hasAuthCopy || domState.hasPublicNav;
|
|
5068
5173
|
return pathLooksRelevant || domLooksRelevant;
|
|
5069
5174
|
}
|
|
5070
5175
|
|
package/src/job-queue.ts
CHANGED
|
@@ -191,7 +191,7 @@ const runningControllers = new Map<string, AbortController>();
|
|
|
191
191
|
const WORKER_ID = `${os.hostname()}:${process.pid}`;
|
|
192
192
|
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
|
|
193
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) ||
|
|
194
|
+
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
|
|
195
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
|
|
@@ -199,7 +199,7 @@ const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS)
|
|
|
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) || 5;
|
|
203
203
|
|
|
204
204
|
type ClaimedJob = { id: string; tenantId: string; projectId: string; type: string; config: any };
|
|
205
205
|
|
|
@@ -1689,6 +1689,7 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1689
1689
|
const maxDiscoverUrls: number = config.maxDiscoverUrls ?? 2000;
|
|
1690
1690
|
const proxyUrl: string | undefined = config.proxyUrl ?? crawlCfg.proxyUrl;
|
|
1691
1691
|
const screenshotDir = os.tmpdir();
|
|
1692
|
+
const discoverStorageConfig = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
1692
1693
|
|
|
1693
1694
|
await onProgress(3, 'Seeding from sitemap/robots.txt…');
|
|
1694
1695
|
|
|
@@ -1714,6 +1715,7 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1714
1715
|
maxScreens: maxDiscoverUrls,
|
|
1715
1716
|
proxyUrl,
|
|
1716
1717
|
useFirefox: crawlCfg.useFirefox,
|
|
1718
|
+
storageConfig: discoverStorageConfig,
|
|
1717
1719
|
abortSignal: controller.signal,
|
|
1718
1720
|
jobId,
|
|
1719
1721
|
onUrlsDiscovered: async (urls: string[]) => {
|
|
@@ -1794,6 +1796,7 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1794
1796
|
proxyUrl,
|
|
1795
1797
|
chromeProfilePath: crawlCfg.chromeProfilePath,
|
|
1796
1798
|
useFirefox: crawlCfg.useFirefox,
|
|
1799
|
+
storageConfig: discoverStorageConfig,
|
|
1797
1800
|
captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
|
|
1798
1801
|
captchaSolverProvider: crawlCfg.captchaSolverProvider,
|
|
1799
1802
|
mailslurpApiKey: crawlCfg.mailslurpApiKey,
|
package/src/sitemap.ts
CHANGED
|
@@ -111,9 +111,11 @@ export async function fetchSitemapUrls(sitemapUrl: string, origin: string, depth
|
|
|
111
111
|
const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parsed, origin);
|
|
112
112
|
const collected = [...pageUrls];
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
if (subSitemapUrls.length > 0 && collected.length < MAX_URLS) {
|
|
115
|
+
const subResults = await Promise.all(
|
|
116
|
+
subSitemapUrls.slice(0, 10).map(subUrl => fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher))
|
|
117
|
+
);
|
|
118
|
+
for (const sub of subResults) collected.push(...sub);
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
return [...new Set(collected)].slice(0, MAX_URLS);
|