@detiq/crawler 1.1.15 → 1.1.16
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 +236 -82
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +11 -1
- package/dist/job-queue.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +147 -17
- package/src/job-queue.ts +9 -0
package/package.json
CHANGED
package/src/crawler.ts
CHANGED
|
@@ -1288,6 +1288,17 @@ export async function crawlProject(opts: {
|
|
|
1288
1288
|
logger.info({ url: finalUrl }, '[crawler] pre-auth page has 0 elements — deferring to authenticated BFS');
|
|
1289
1289
|
continue;
|
|
1290
1290
|
}
|
|
1291
|
+
// Detect auth walls that have a tiny number of elements (login button, back link, spinner).
|
|
1292
|
+
// These pages redirect or gate on auth — main BFS must visit them with a live session.
|
|
1293
|
+
// Label prefix or exact match — "Back to Learn" matches "back to", "Sign In" matches "sign in"
|
|
1294
|
+
const AUTH_WALL_LABEL_RE = /^(sign.?in|log.?in|login|platform access|back\s+to|checking\s+auth|get started|create account|register|continue with|join now)/i;
|
|
1295
|
+
// Also treat pages with only `a`-role elements (no inputs/buttons) as likely auth walls
|
|
1296
|
+
const allAnchorOnly = preAuthElements.every(el => el.role === 'a');
|
|
1297
|
+
const looksLikeAuthWall = preAuthElements.length <= 3 && (allAnchorOnly || preAuthElements.every(el => AUTH_WALL_LABEL_RE.test(el.label)));
|
|
1298
|
+
if (looksLikeAuthWall) {
|
|
1299
|
+
logger.info({ url: finalUrl, elements: preAuthElements.length, labels: preAuthElements.map(e => e.label) }, '[crawler] pre-auth auth wall — deferring to authenticated BFS');
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1291
1302
|
const pathParts = finalPath.split('/').filter(Boolean);
|
|
1292
1303
|
const screenName = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
1293
1304
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
|
|
@@ -2026,7 +2037,16 @@ export async function crawlProject(opts: {
|
|
|
2026
2037
|
const s = window.getComputedStyle(el);
|
|
2027
2038
|
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
2028
2039
|
}
|
|
2029
|
-
|
|
2040
|
+
// Wide nav selector: covers semantic nav, ARIA, header links, sidebar (aside/[role=complementary]),
|
|
2041
|
+
// and common class-based sidebars used by admin/dashboard pages.
|
|
2042
|
+
const NAV_SEL = [
|
|
2043
|
+
'nav a', '[role="navigation"] a', 'header a', 'footer nav a',
|
|
2044
|
+
'[aria-label*="navigation" i] a', '[aria-label*="sidebar" i] a',
|
|
2045
|
+
'aside a', '[role="complementary"] a',
|
|
2046
|
+
'[class*="sidebar" i] a', '[class*="side-nav" i] a', '[class*="sidenav" i] a',
|
|
2047
|
+
'[class*="nav-menu" i] a', '[class*="navmenu" i] a', '[data-nav] a',
|
|
2048
|
+
].join(', ');
|
|
2049
|
+
document.querySelectorAll(NAV_SEL).forEach((a: any) => {
|
|
2030
2050
|
if (!isV(a)) return;
|
|
2031
2051
|
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
2032
2052
|
const href = a.href ?? '';
|
|
@@ -2036,8 +2056,10 @@ export async function crawlProject(opts: {
|
|
|
2036
2056
|
while (cur) {
|
|
2037
2057
|
const t = cur.tagName?.toLowerCase();
|
|
2038
2058
|
const r = cur.getAttribute?.('role')?.toLowerCase();
|
|
2059
|
+
const cl = (cur.className ?? '').toLowerCase();
|
|
2039
2060
|
if (t === 'header' || r === 'banner') { landmark = 'Header'; break; }
|
|
2040
2061
|
if (t === 'footer' || r === 'contentinfo') { landmark = 'Footer'; break; }
|
|
2062
|
+
if (t === 'aside' || r === 'complementary' || cl.includes('sidebar') || cl.includes('sidenav') || cl.includes('side-nav')) { landmark = 'Sidebar'; break; }
|
|
2041
2063
|
if (t === 'nav' || r === 'navigation') { landmark = cur.getAttribute('aria-label')?.trim() || 'Site Navigation'; break; }
|
|
2042
2064
|
cur = cur.parentElement;
|
|
2043
2065
|
}
|
|
@@ -2053,6 +2075,42 @@ export async function crawlProject(opts: {
|
|
|
2053
2075
|
const key = `footer|${label.toLowerCase()}|${href}`;
|
|
2054
2076
|
if (!seen.has(key)) { seen.add(key); items.push({ meaning: label, role: 'footer-link', notes: JSON.stringify({ href: href.slice(0, 150) }) }); fc++; }
|
|
2055
2077
|
});
|
|
2078
|
+
// Grandparent-grouping fallback: detects sidebar navigation where each link is
|
|
2079
|
+
// individually wrapped (e.g. <div class="flex"><a>link</a></div> × many siblings).
|
|
2080
|
+
// Runs ALWAYS (not gated on 0 results) so header links don't block sidebar detection.
|
|
2081
|
+
// Deduped against hrefs already captured by the semantic pass above.
|
|
2082
|
+
{
|
|
2083
|
+
const navHrefsSeen = new Set<string>();
|
|
2084
|
+
for (const it of items) {
|
|
2085
|
+
try { const h = JSON.parse(it.notes ?? '{}').href; if (h) navHrefsSeen.add(h); } catch { /* skip */ }
|
|
2086
|
+
}
|
|
2087
|
+
const gpGroups = new Map<Element, Array<{label: string; href: string}>>();
|
|
2088
|
+
document.querySelectorAll('a[href]').forEach((a: any) => {
|
|
2089
|
+
if (!isV(a)) return;
|
|
2090
|
+
const href = a.href ?? '';
|
|
2091
|
+
if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsSeen.has(href)) return;
|
|
2092
|
+
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
2093
|
+
if (!label || label.length > 40) return;
|
|
2094
|
+
const parent = a.parentElement;
|
|
2095
|
+
const gp = parent?.parentElement;
|
|
2096
|
+
if (!parent || !gp) return;
|
|
2097
|
+
// Only match when parent wraps exactly 1 anchor — the sidebar <div><a> pattern
|
|
2098
|
+
if (parent.querySelectorAll('a').length !== 1) return;
|
|
2099
|
+
const gpTag = gp.tagName.toLowerCase();
|
|
2100
|
+
if (['body', 'html', 'form'].includes(gpTag)) return;
|
|
2101
|
+
const arr = gpGroups.get(gp) ?? [];
|
|
2102
|
+
arr.push({ label, href });
|
|
2103
|
+
gpGroups.set(gp, arr);
|
|
2104
|
+
});
|
|
2105
|
+
for (const [, links] of gpGroups.entries()) {
|
|
2106
|
+
if (links.length >= 4) {
|
|
2107
|
+
for (const { label, href } of links) {
|
|
2108
|
+
const k = `cluster|${label.toLowerCase()}|${href}`;
|
|
2109
|
+
if (!seen.has(k)) { seen.add(k); items.push({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark: 'Sidebar' }) }); }
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2056
2114
|
return items;
|
|
2057
2115
|
}).catch(() => [] as any[]);
|
|
2058
2116
|
|
|
@@ -2807,7 +2865,7 @@ export async function crawlProject(opts: {
|
|
|
2807
2865
|
}
|
|
2808
2866
|
|
|
2809
2867
|
// Append significant query params
|
|
2810
|
-
const SIG_PARAMS = ['product_cat', 'category', 'cat', 'type', 'tag', 'brand', 'filter', 'tab', 'section', 'view'];
|
|
2868
|
+
const SIG_PARAMS = ['product_cat', 'category', 'cat', 'type', 'tag', 'brand', 'filter', 'tab', 'section', 'view', 'play', 'mode', 'id', 'page'];
|
|
2811
2869
|
const paramSuffix = SIG_PARAMS
|
|
2812
2870
|
.map((p) => { const v = u.searchParams.get(p); return v ? ` · ${v.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}` : ''; })
|
|
2813
2871
|
.find(Boolean) ?? '';
|
|
@@ -2830,11 +2888,12 @@ export async function crawlProject(opts: {
|
|
|
2830
2888
|
const candidate = parts[0].trim();
|
|
2831
2889
|
if (candidate.length > 2 && !/^\d[\d\s]*$/.test(candidate)) return candidate;
|
|
2832
2890
|
}
|
|
2833
|
-
// Single-segment title — only use if multi-word
|
|
2891
|
+
// Single-segment title — only use if multi-word, not brand-name-like, and not the generic SPA app title
|
|
2834
2892
|
const candidate = parts[0].trim();
|
|
2835
2893
|
const isBrand = candidate.split(/\s+/).length === 1 &&
|
|
2836
2894
|
(candidate.toLowerCase().includes(brandName) || candidate.toLowerCase() === brandName);
|
|
2837
|
-
|
|
2895
|
+
const isSiteGenericTitle = homeFingerprintTitle !== '' && candidate === homeFingerprintTitle;
|
|
2896
|
+
if (!isBrand && !isSiteGenericTitle && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
|
|
2838
2897
|
return candidate;
|
|
2839
2898
|
}
|
|
2840
2899
|
}
|
|
@@ -3365,10 +3424,38 @@ export async function crawlProject(opts: {
|
|
|
3365
3424
|
try {
|
|
3366
3425
|
const baseline = await AppBrain.getVisualBaselineForDiff(opts.tenantId, screen.id, 'DESKTOP', gitInfo.branch);
|
|
3367
3426
|
if (baseline && baseline.storagePath !== shotPath) {
|
|
3427
|
+
// Resolve baseline file path — local copy may have been rotated; fall back to cloud download
|
|
3428
|
+
let baselinePath: string = baseline.storagePath;
|
|
3429
|
+
let tempBaselinePath: string | null = null;
|
|
3430
|
+
const localExists = await fs.access(baseline.storagePath).then(() => true, () => false);
|
|
3431
|
+
if (!localExists) {
|
|
3432
|
+
if (baseline.cloudUrl) {
|
|
3433
|
+
try {
|
|
3434
|
+
const resp = await fetch(baseline.cloudUrl);
|
|
3435
|
+
if (resp.ok) {
|
|
3436
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
3437
|
+
tempBaselinePath = `${shotPath}.baseline-tmp.png`;
|
|
3438
|
+
await fs.writeFile(tempBaselinePath, buf);
|
|
3439
|
+
baselinePath = tempBaselinePath;
|
|
3440
|
+
logger.info({ screenId: screen.id }, '[crawler] VisualDiff baseline downloaded from cloud storage');
|
|
3441
|
+
} else {
|
|
3442
|
+
logger.warn({ screenId: screen.id, status: resp.status }, '[crawler] VisualDiff skipped — baseline cloud fetch returned non-200');
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
} catch (dlErr) {
|
|
3446
|
+
logger.warn({ screenId: screen.id, err: String(dlErr) }, '[crawler] VisualDiff skipped — baseline cloud download failed');
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
} else {
|
|
3450
|
+
logger.warn({ screenId: screen.id }, '[crawler] VisualDiff skipped — baseline not on disk and no cloudUrl available');
|
|
3451
|
+
return;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
try {
|
|
3368
3455
|
const { screenshotDiffPercent, gridLayoutDiff } = await import('./phash.js');
|
|
3369
3456
|
const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
|
|
3370
|
-
screenshotDiffPercent(
|
|
3371
|
-
gridLayoutDiff(
|
|
3457
|
+
screenshotDiffPercent(baselinePath, shotPath, baseline.ignoredRegions),
|
|
3458
|
+
gridLayoutDiff(baselinePath, shotPath, baseline.ignoredRegions),
|
|
3372
3459
|
AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, 'DESKTOP'),
|
|
3373
3460
|
]);
|
|
3374
3461
|
let verdict = pHashVerdict(pHashDistance, changeLevel, [
|
|
@@ -3389,10 +3476,10 @@ export async function crawlProject(opts: {
|
|
|
3389
3476
|
const [baselineBuffer, newBuffer] = regions.length > 0
|
|
3390
3477
|
? await (async () => {
|
|
3391
3478
|
const { applyMask } = await import('./phash.js');
|
|
3392
|
-
return Promise.all([applyMask(
|
|
3479
|
+
return Promise.all([applyMask(baselinePath, regions), applyMask(shotPath, regions)]);
|
|
3393
3480
|
})()
|
|
3394
3481
|
: await Promise.all([
|
|
3395
|
-
fs.readFile(
|
|
3482
|
+
fs.readFile(baselinePath),
|
|
3396
3483
|
fs.readFile(shotPath),
|
|
3397
3484
|
]);
|
|
3398
3485
|
const aiResult = await aiVisualCompare({
|
|
@@ -3435,7 +3522,7 @@ export async function crawlProject(opts: {
|
|
|
3435
3522
|
// R5: Generate and upload pixelmatch diff image (changed pixels in red)
|
|
3436
3523
|
try {
|
|
3437
3524
|
const { generatePixelmatchDiff } = await import('./phash.js');
|
|
3438
|
-
const diffBuffer = await generatePixelmatchDiff(
|
|
3525
|
+
const diffBuffer = await generatePixelmatchDiff(baselinePath, shotPath, baseline.ignoredRegions ?? []);
|
|
3439
3526
|
const diffTmpPath = `${shotPath}.diff.png`;
|
|
3440
3527
|
await fs.writeFile(diffTmpPath, diffBuffer);
|
|
3441
3528
|
const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, 'visual-diffs', `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
|
|
@@ -3459,14 +3546,12 @@ export async function crawlProject(opts: {
|
|
|
3459
3546
|
aiSummary,
|
|
3460
3547
|
});
|
|
3461
3548
|
}
|
|
3549
|
+
} finally {
|
|
3550
|
+
if (tempBaselinePath) fs.unlink(tempBaselinePath).catch(() => {});
|
|
3551
|
+
}
|
|
3462
3552
|
}
|
|
3463
3553
|
} catch (err: any) {
|
|
3464
|
-
|
|
3465
|
-
if (missing) {
|
|
3466
|
-
logger.warn({ screenId: screen.id, version: screen.version, err: String(err?.message ?? err) }, '[crawler] VisualDiff skipped — baseline screenshot not on disk (stored in cloud only). Run a full recrawl to regenerate baselines.');
|
|
3467
|
-
} else {
|
|
3468
|
-
console.warn('[crawler] VisualDiff pipeline failed:', err);
|
|
3469
|
-
}
|
|
3554
|
+
console.warn('[crawler] VisualDiff pipeline failed:', err);
|
|
3470
3555
|
}
|
|
3471
3556
|
})();
|
|
3472
3557
|
}
|
|
@@ -5094,19 +5179,28 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
5094
5179
|
const s = window.getComputedStyle(el);
|
|
5095
5180
|
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
5096
5181
|
};
|
|
5097
|
-
|
|
5182
|
+
const navSel = [
|
|
5183
|
+
'nav a', '[role="navigation"] a', 'header a', 'header nav a', 'footer nav a',
|
|
5184
|
+
'[aria-label*="navigation" i] a', '[aria-label*="menu" i] a', '[aria-label*="sidebar" i] a',
|
|
5185
|
+
'aside a', '[role="complementary"] a',
|
|
5186
|
+
'[class*="sidebar" i] a', '[class*="side-nav" i] a', '[class*="sidenav" i] a',
|
|
5187
|
+
'[class*="nav-menu" i] a', '[class*="navmenu" i] a', '[data-nav] a',
|
|
5188
|
+
].join(', ');
|
|
5189
|
+
const navLinksFoundCount = { n: 0 };
|
|
5190
|
+
document.querySelectorAll(navSel).forEach((a: any) => {
|
|
5098
5191
|
if (!isNavVisible(a)) return;
|
|
5099
5192
|
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
5100
5193
|
const href = a.href;
|
|
5101
5194
|
if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
|
|
5102
|
-
// Walk up to find nearest nav/header/footer ancestor to get landmark label
|
|
5103
5195
|
let landmark = 'Site Navigation';
|
|
5104
5196
|
let el: any = a.parentElement;
|
|
5105
5197
|
while (el) {
|
|
5106
5198
|
const tag = el.tagName?.toLowerCase();
|
|
5107
5199
|
const role = el.getAttribute?.('role')?.toLowerCase();
|
|
5200
|
+
const cl = (el.className ?? '').toLowerCase();
|
|
5108
5201
|
if (tag === 'header' || role === 'banner') { landmark = 'Header'; break; }
|
|
5109
5202
|
if (tag === 'footer' || role === 'contentinfo') { landmark = 'Footer'; break; }
|
|
5203
|
+
if (tag === 'aside' || role === 'complementary' || cl.includes('sidebar') || cl.includes('sidenav') || cl.includes('side-nav')) { landmark = 'Sidebar'; break; }
|
|
5110
5204
|
if (tag === 'nav' || role === 'navigation') {
|
|
5111
5205
|
const directLabel = el.getAttribute('aria-label')?.trim() ?? '';
|
|
5112
5206
|
const labelId = el.getAttribute('aria-labelledby');
|
|
@@ -5117,7 +5211,43 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
5117
5211
|
el = el.parentElement;
|
|
5118
5212
|
}
|
|
5119
5213
|
addUniq({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
|
|
5214
|
+
navLinksFoundCount.n++;
|
|
5120
5215
|
});
|
|
5216
|
+
// Grandparent-grouping fallback: detects sidebar navigation where each link is individually
|
|
5217
|
+
// wrapped (e.g. <div class="flex"><a>link</a></div> × many siblings). Runs ALWAYS so header
|
|
5218
|
+
// links don't block sidebar detection. Deduped against hrefs already in the semantic pass.
|
|
5219
|
+
{
|
|
5220
|
+
const navHrefsAlready = new Set<string>();
|
|
5221
|
+
for (const item of out) {
|
|
5222
|
+
if (item.role === 'nav-link') {
|
|
5223
|
+
try { const h = JSON.parse(item.notes ?? '{}').href; if (h) navHrefsAlready.add(h); } catch { /* skip */ }
|
|
5224
|
+
}
|
|
5225
|
+
}
|
|
5226
|
+
const gpGroups2 = new Map<Element, Array<{label: string; href: string}>>();
|
|
5227
|
+
document.querySelectorAll('a[href]').forEach((a: any) => {
|
|
5228
|
+
if (!isNavVisible(a)) return;
|
|
5229
|
+
const href = a.href ?? '';
|
|
5230
|
+
if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsAlready.has(href)) return;
|
|
5231
|
+
const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
|
|
5232
|
+
if (!label || label.length > 40) return;
|
|
5233
|
+
const parent = a.parentElement;
|
|
5234
|
+
const gp = parent?.parentElement;
|
|
5235
|
+
if (!parent || !gp) return;
|
|
5236
|
+
if (parent.querySelectorAll('a').length !== 1) return;
|
|
5237
|
+
const gpTag = gp.tagName.toLowerCase();
|
|
5238
|
+
if (['body', 'html', 'form'].includes(gpTag)) return;
|
|
5239
|
+
const arr = gpGroups2.get(gp) ?? [];
|
|
5240
|
+
arr.push({ label, href });
|
|
5241
|
+
gpGroups2.set(gp, arr);
|
|
5242
|
+
});
|
|
5243
|
+
for (const [, links] of gpGroups2.entries()) {
|
|
5244
|
+
if (links.length >= 4) {
|
|
5245
|
+
for (const { label, href } of links) {
|
|
5246
|
+
addUniq({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark: 'Sidebar' }) });
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
5121
5251
|
|
|
5122
5252
|
// 9. Breadcrumb items
|
|
5123
5253
|
document.querySelectorAll('[aria-label*="breadcrumb" i] a, [class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a').forEach((a: any) => {
|
package/src/job-queue.ts
CHANGED
|
@@ -2502,6 +2502,15 @@ jobDispatchTable = {
|
|
|
2502
2502
|
BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
|
|
2503
2503
|
CREATE_PR: processCreatePRJob,
|
|
2504
2504
|
NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
|
|
2505
|
+
// GENERATE_TESTS is processed in-process by the API (never QUEUED).
|
|
2506
|
+
// If the worker claims one, the API crashed after creating it — mark failed so it's visible.
|
|
2507
|
+
GENERATE_TESTS: async (jobId: string, _config: any) => {
|
|
2508
|
+
logger.error({ jobId }, '[job-worker] GENERATE_TESTS claimed by worker — API did not process it in-process');
|
|
2509
|
+
await prismaAdmin.platformJob.update({
|
|
2510
|
+
where: { id: jobId },
|
|
2511
|
+
data: { status: 'FAILED', error: 'Test generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2512
|
+
}).catch(() => {});
|
|
2513
|
+
},
|
|
2505
2514
|
};
|
|
2506
2515
|
|
|
2507
2516
|
async function processRepoScanJob(jobId: string, config: any) {
|