@detiq/crawler 1.1.22 → 1.1.24
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 +173 -28
- package/dist/crawler.js.map +2 -2
- package/dist/extractors/branding-extractor.js +104 -0
- package/dist/extractors/branding-extractor.js.map +7 -0
- package/dist/extractors/summary-extractor.js +36 -0
- package/dist/extractors/summary-extractor.js.map +7 -0
- package/dist/frontier.js +20 -2
- package/dist/frontier.js.map +2 -2
- package/dist/incremental-diff.js +76 -0
- package/dist/incremental-diff.js.map +2 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +2 -2
- package/dist/interactive-probe.js +108 -17
- package/dist/interactive-probe.js.map +2 -2
- package/dist/job-queue.js +430 -67
- package/dist/job-queue.js.map +2 -2
- package/dist/sqs-queue.js +78 -10
- package/dist/sqs-queue.js.map +2 -2
- package/dist/worker.js +7 -7
- package/dist/worker.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +208 -30
- package/src/extractors/branding-extractor.ts +159 -0
- package/src/extractors/summary-extractor.ts +49 -0
- package/src/frontier.ts +31 -1
- package/src/incremental-diff.ts +138 -0
- package/src/index.ts +1 -1
- package/src/interactive-probe.ts +129 -20
- package/src/job-queue.ts +525 -69
- package/src/sqs-queue.ts +92 -12
- package/src/worker.ts +13 -11
- package/types/crawler.d.ts +20 -0
- package/types/extractors/branding-extractor.d.ts +46 -0
- package/types/extractors/summary-extractor.d.ts +10 -0
- package/types/frontier.d.ts +9 -1
- package/types/incremental-diff.d.ts +57 -0
- package/types/index.d.ts +1 -1
- package/types/interactive-probe.d.ts +5 -1
- package/types/job-queue.d.ts +53 -0
- package/types/sqs-queue.d.ts +12 -1
package/src/crawler.ts
CHANGED
|
@@ -79,7 +79,7 @@ import { probeCrudFlows } from './crud-interaction.js';
|
|
|
79
79
|
import { crawlFiltersAndPagination } from './filter-pagination-crawler.js';
|
|
80
80
|
import { probeMultiContext } from './multi-context-handler.js';
|
|
81
81
|
import { probeAdvancedUI } from './advanced-ui-probes.js';
|
|
82
|
-
import { ScreenTransitionGraph } from './state-graph.js';
|
|
82
|
+
import { ScreenTransitionGraph, simHash32 } from './state-graph.js';
|
|
83
83
|
import { crawlNestedContexts } from './nested-window-crawler.js';
|
|
84
84
|
import { crawlShadowDom } from './shadow-dom-crawler.js';
|
|
85
85
|
import { crawlAccessibilityTree } from './accessibility-tree-crawler.js';
|
|
@@ -125,7 +125,18 @@ interface PerformanceMetric {
|
|
|
125
125
|
cls?: number;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
export interface DiscoveredLinkMeta {
|
|
129
|
+
url: string;
|
|
130
|
+
text?: string;
|
|
131
|
+
landmark?: string;
|
|
132
|
+
source: 'DOM' | 'AI' | 'INTERACTIVE';
|
|
133
|
+
}
|
|
134
|
+
|
|
128
135
|
const ALLOWED_NUMERIC_PATHS = new Set(['/2fa', '/404', '/500', '/403', '/401']);
|
|
136
|
+
|
|
137
|
+
// Seed path patterns compiled for the active crawl. Set by crawlProject() at the
|
|
138
|
+
// start of each run. All isUsefulNavigationUrl() calls share this for the duration.
|
|
139
|
+
let _activeSeedPatterns: RegExp[] = [];
|
|
129
140
|
const UUID_PATH_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
130
141
|
const CUID_PATH_SEGMENT_RE = /^c[a-z0-9]{20,}$/i;
|
|
131
142
|
// Match 4+ digit pure numeric IDs only — aligns with canonicalize()'s /\d{4,}/ threshold.
|
|
@@ -171,7 +182,9 @@ function isUsefulNavigationUrl(href: string, siteOrigin: string): boolean {
|
|
|
171
182
|
const u = new URL(href);
|
|
172
183
|
if (!sameSite(u.toString(), siteOrigin)) return false;
|
|
173
184
|
if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
|
|
174
|
-
|
|
185
|
+
// Bypass CUID/UUID guard for seed path patterns declared by the caller.
|
|
186
|
+
// _activeSeedPatterns is set per-crawl by crawlProject.
|
|
187
|
+
if (isGeneratedDynamicPath(u.pathname) && !_activeSeedPatterns.some(re => re.test(u.pathname))) return false;
|
|
175
188
|
if (DOWNLOAD_EXT_RE.test(u.pathname)) return false;
|
|
176
189
|
if (API_PATH_RE.test(u.pathname)) return false;
|
|
177
190
|
if (EXPORT_PATH_RE.test(u.pathname)) return false;
|
|
@@ -181,6 +194,19 @@ function isUsefulNavigationUrl(href: string, siteOrigin: string): boolean {
|
|
|
181
194
|
}
|
|
182
195
|
}
|
|
183
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Compile seedPathPatterns (e.g., ['/projects/{id}/tests']) into RegExp matchers.
|
|
199
|
+
* {id} matches any single path segment (CUID, UUID, numeric, slug, etc.).
|
|
200
|
+
* Example: '/projects/{id}/tests' → /^\/projects\/[^/]+\/tests(\/.*)?$/i
|
|
201
|
+
*/
|
|
202
|
+
function buildSeedPatternRegexes(patterns: string[]): RegExp[] {
|
|
203
|
+
return patterns.map((p) => {
|
|
204
|
+
const parts = p.split('{id}').map(part => part.replace(/[.+^${}()|[\]\\]/g, '\\$&'));
|
|
205
|
+
const body = parts.join('[^/]+');
|
|
206
|
+
return new RegExp(`^${body}(/.*)?$`, 'i');
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
184
210
|
type AuthFlowType = 'public' | 'login' | 'register' | 'password_reset' | 'verification' | 'invite' | 'session_recovery';
|
|
185
211
|
type AuthDiscoverySource = 'entry' | 'seed_path' | 'page_link' | 'auth_click';
|
|
186
212
|
type AuthDiscoveryProgress = (pct: number, step: string) => void | Promise<void>;
|
|
@@ -327,11 +353,15 @@ async function launchChromeCDP(
|
|
|
327
353
|
execPath: string,
|
|
328
354
|
extraArgs: string[],
|
|
329
355
|
proxy?: { server: string; username?: string; password?: string },
|
|
356
|
+
useStealthPlugin = true,
|
|
330
357
|
): Promise<{ wsUrl: string; proc: any }> {
|
|
331
358
|
// Use a random port in 9200-9900 range to avoid clashes between concurrent crawls.
|
|
332
359
|
const port = 9200 + Math.floor(Math.random() * 700);
|
|
333
360
|
|
|
334
|
-
|
|
361
|
+
// Use stealthChromium (playwright-extra + puppeteer-extra-plugin-stealth) by default.
|
|
362
|
+
// Pass useStealthPlugin=false to opt out for a specific crawl job (opts.stealth === false).
|
|
363
|
+
const launcher = useStealthPlugin ? stealthChromium : chromium;
|
|
364
|
+
const browserServer = await launcher.launchServer({
|
|
335
365
|
headless: true,
|
|
336
366
|
executablePath: execPath,
|
|
337
367
|
// Use Playwright's native proxy option — it handles 407 auth challenges automatically.
|
|
@@ -503,6 +533,13 @@ export async function crawlProject(opts: {
|
|
|
503
533
|
startUrls?: string[];
|
|
504
534
|
/** When provided, crawl ONLY these exact URLs (no link-following, no full screen clear). Used for selected-screen recrawl. */
|
|
505
535
|
selectiveUrls?: string[];
|
|
536
|
+
/**
|
|
537
|
+
* URL path patterns that bypass the CUID/UUID generated-ID filter during BFS link discovery.
|
|
538
|
+
* Use {id} as a placeholder for any generated path segment (CUID, UUID, numeric ID, etc.).
|
|
539
|
+
* Example: ['/projects/{id}/tests', '/workspaces/{id}/settings']
|
|
540
|
+
* Without this, all URLs containing generated IDs are silently skipped by the BFS.
|
|
541
|
+
*/
|
|
542
|
+
seedPathPatterns?: string[];
|
|
506
543
|
/** When true, run the interactive probe after static crawl to capture real validation errors, toasts, and modals. */
|
|
507
544
|
interactive?: boolean;
|
|
508
545
|
/** Optional abort signal — abort() cancels the crawl between page navigations. */
|
|
@@ -586,10 +623,17 @@ export async function crawlProject(opts: {
|
|
|
586
623
|
autoThrottle?: boolean;
|
|
587
624
|
/** Regex pattern — only crawl URLs whose path+query match this pattern. */
|
|
588
625
|
urlFilter?: string;
|
|
626
|
+
/** Glob patterns — only enqueue URLs whose pathname matches at least one (supports * and **). */
|
|
627
|
+
includePatterns?: string[];
|
|
628
|
+
/** Glob patterns — skip URLs whose pathname matches any of these (supports * and **). */
|
|
629
|
+
excludePatterns?: string[];
|
|
589
630
|
/** Already-captured URLs from a previous failed job — pre-seeded into visited map so they're never re-crawled. */
|
|
590
631
|
skipUrls?: string[];
|
|
591
632
|
/** DOM hashes collected in a prior segment — prevents re-screenshotting structurally identical pages across segments. */
|
|
592
633
|
knownDomHashes?: string[];
|
|
634
|
+
/** When false, disables the puppeteer-extra stealth plugin (playwright-extra) for this crawl.
|
|
635
|
+
* Default true — stealth is applied unless explicitly set to false. */
|
|
636
|
+
stealth?: boolean;
|
|
593
637
|
/** Called every 10 successful screen captures with current visited URLs and remaining queue — used for checkpoint persistence. */
|
|
594
638
|
onCheckpoint?: (visitedUrls: string[], pendingQueue: string[]) => Promise<void>;
|
|
595
639
|
/** Discovery mode only: called after each page with the current set of discovered original URLs — enables streaming partial results to callers. */
|
|
@@ -597,6 +641,10 @@ export async function crawlProject(opts: {
|
|
|
597
641
|
}, onProgress?: (pct: number, step: string) => Promise<void>) {
|
|
598
642
|
const tuning = await getCrawlTuning();
|
|
599
643
|
|
|
644
|
+
// Compile seed path patterns for this crawl run. Applied by isUsefulNavigationUrl()
|
|
645
|
+
// to bypass the CUID guard for explicitly declared URL shapes.
|
|
646
|
+
_activeSeedPatterns = buildSeedPatternRegexes(opts.seedPathPatterns ?? []);
|
|
647
|
+
|
|
600
648
|
if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
|
|
601
649
|
|
|
602
650
|
// Upload screenshot to cloud storage if configured, returns cloudUrl or undefined
|
|
@@ -908,7 +956,7 @@ export async function crawlProject(opts: {
|
|
|
908
956
|
}
|
|
909
957
|
}
|
|
910
958
|
|
|
911
|
-
const { wsUrl: cdpUrl, proc: chromeProc } = await launchChromeCDP(executablePath, chromeArgs, playwrightProxy);
|
|
959
|
+
const { wsUrl: cdpUrl, proc: chromeProc } = await launchChromeCDP(executablePath, chromeArgs, playwrightProxy, opts.stealth !== false);
|
|
912
960
|
|
|
913
961
|
// Accumulate token usage from Stagehand's internal LLM calls (extract/act/observe).
|
|
914
962
|
// Stagehand doesn't expose per-call usage externally; we parse it from its logger.
|
|
@@ -1354,6 +1402,7 @@ export async function crawlProject(opts: {
|
|
|
1354
1402
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
|
|
1355
1403
|
if (!screen) continue;
|
|
1356
1404
|
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
|
|
1405
|
+
// TODO GAP-09: pass opts.screenshotConfig (width/height/quality) here when screenshotConfig is wired into the opts type.
|
|
1357
1406
|
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {});
|
|
1358
1407
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => undefined);
|
|
1359
1408
|
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl).catch(() => {});
|
|
@@ -1787,7 +1836,7 @@ export async function crawlProject(opts: {
|
|
|
1787
1836
|
...sitemapUrls.filter((u) => canonicalize(u) !== canonicalize(startUrl)),
|
|
1788
1837
|
];
|
|
1789
1838
|
const queued = new Set<string>(seedUrls.map(canonicalize));
|
|
1790
|
-
const queue = new CrawlFrontier(opts.traversalStrategy ?? 'BFS', seedUrls, opts.bestFirstKeywords, opts.urlFilter);
|
|
1839
|
+
const queue = new CrawlFrontier(opts.traversalStrategy ?? 'BFS', seedUrls, opts.bestFirstKeywords, opts.urlFilter, opts.includePatterns, opts.excludePatterns);
|
|
1791
1840
|
// Selective recrawl: block all link-discovery pushes so BFS only visits the requested URLs.
|
|
1792
1841
|
// Constructor seeding already ran above — this no-op override fires only for BFS-loop discoveries.
|
|
1793
1842
|
if (selective) {
|
|
@@ -1824,7 +1873,8 @@ export async function crawlProject(opts: {
|
|
|
1824
1873
|
const stripWww = (h: string) => h.replace(/^www\./i, '').toLowerCase();
|
|
1825
1874
|
if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
|
|
1826
1875
|
// Don't re-seed generated dynamic routes — they produce duplicate templates.
|
|
1827
|
-
|
|
1876
|
+
// Exception: seed path patterns explicitly declared by the caller.
|
|
1877
|
+
if (isGeneratedDynamicPath(pathname) && !_activeSeedPatterns.some(re => re.test(pathname))) return false;
|
|
1828
1878
|
// Don't re-seed API/export/download paths — they were misidentified as screens.
|
|
1829
1879
|
if (!isUsefulNavigationUrl(u, startUrl)) return false;
|
|
1830
1880
|
// Canonical dedup: mark seen immediately during filter so duplicates are caught.
|
|
@@ -2018,7 +2068,7 @@ export async function crawlProject(opts: {
|
|
|
2018
2068
|
|
|
2019
2069
|
try {
|
|
2020
2070
|
const { pathname } = new URL(url);
|
|
2021
|
-
if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
|
|
2071
|
+
if (SKIP_PATH.test(pathname) || (isGeneratedDynamicPath(pathname) && !_activeSeedPatterns.some(re => re.test(pathname)))) continue;
|
|
2022
2072
|
// Skip API/export paths — these return data files, not navigable pages
|
|
2023
2073
|
if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
|
|
2024
2074
|
} catch { continue; }
|
|
@@ -2376,7 +2426,8 @@ export async function crawlProject(opts: {
|
|
|
2376
2426
|
// Generated ID-like path segments are usually fallback IDs or entity
|
|
2377
2427
|
// records. They flood the catalog with duplicate page templates, so
|
|
2378
2428
|
// skip them before screenshots or screen creation.
|
|
2379
|
-
|
|
2429
|
+
// Exception: seed path patterns declared by the caller bypass this guard.
|
|
2430
|
+
if (isGeneratedDynamicPath(pathname) && !_activeSeedPatterns.some(re => re.test(pathname))) {
|
|
2380
2431
|
console.log(`[crawler] skip generated dynamic route: ${url}`);
|
|
2381
2432
|
continue;
|
|
2382
2433
|
}
|
|
@@ -3231,10 +3282,20 @@ export async function crawlProject(opts: {
|
|
|
3231
3282
|
// Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
|
|
3232
3283
|
// Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
|
|
3233
3284
|
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined)
|
|
3285
|
+
.then((metas) => {
|
|
3286
|
+
// Persist nav-graph edges fire-and-forget
|
|
3287
|
+
if (metas.length > 0) {
|
|
3288
|
+
AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, screen.id, metas.map((m) => ({
|
|
3289
|
+
href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
|
|
3290
|
+
}))).catch(() => {});
|
|
3291
|
+
}
|
|
3292
|
+
return metas.map(m => m.url);
|
|
3293
|
+
})
|
|
3234
3294
|
.then((freshLinks) => {
|
|
3235
3295
|
if (aiSkipCount > 0) aiSkipCount--;
|
|
3236
3296
|
aiErrorStreak = 0;
|
|
3237
|
-
const
|
|
3297
|
+
const freshUrls = freshLinks;
|
|
3298
|
+
const merged = (cachedLinks ? Array.from(new Set([...cachedLinks, ...freshUrls])) : freshUrls)
|
|
3238
3299
|
.filter((href) => isUsefulNavigationUrl(href, origin));
|
|
3239
3300
|
logger.info({ url, domLinks: freshLinks.length, cached: cachedLinks?.length ?? 0, merged: merged.length }, '[crawler] link-discovery: result');
|
|
3240
3301
|
// Always persist fresh recipe so stale cache gets overwritten with new AI discoveries
|
|
@@ -3250,7 +3311,7 @@ export async function crawlProject(opts: {
|
|
|
3250
3311
|
return merged;
|
|
3251
3312
|
})
|
|
3252
3313
|
.catch((err: any) => {
|
|
3253
|
-
const fallback: string[] = err?.domLinks ?? [];
|
|
3314
|
+
const fallback: string[] = (err?.domLinks ?? []).map((l: DiscoveredLinkMeta) => l.url);
|
|
3254
3315
|
if (err?.isRateLimit) {
|
|
3255
3316
|
aiErrorStreak++;
|
|
3256
3317
|
if (aiErrorStreak >= 5) { aiSkipCount = 3; aiErrorStreak = 0; logger.warn('[crawler] 5 consecutive AI rate limits — pausing AI link discovery for next 3 screens then retrying'); }
|
|
@@ -3823,7 +3884,7 @@ export async function crawlProject(opts: {
|
|
|
3823
3884
|
// Don't flood the queue with generated ID-like route segments.
|
|
3824
3885
|
try {
|
|
3825
3886
|
const hrefPath = new URL(href).pathname;
|
|
3826
|
-
if (isGeneratedDynamicPath(hrefPath)) continue;
|
|
3887
|
+
if (isGeneratedDynamicPath(hrefPath) && !_activeSeedPatterns.some(re => re.test(hrefPath))) continue;
|
|
3827
3888
|
} catch { /* ignore invalid URLs */ }
|
|
3828
3889
|
if (robotsCache && !await robotsCache.isAllowed(href)) continue;
|
|
3829
3890
|
queued.add(canonHref);
|
|
@@ -4062,7 +4123,14 @@ export async function crawlProject(opts: {
|
|
|
4062
4123
|
}
|
|
4063
4124
|
const [elems, links, linkMappings] = await Promise.all([
|
|
4064
4125
|
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
|
|
4065
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined)
|
|
4126
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined).then((metas) => {
|
|
4127
|
+
if (metas.length > 0) {
|
|
4128
|
+
AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, screen.id, metas.map((m) => ({
|
|
4129
|
+
href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
|
|
4130
|
+
}))).catch(() => {});
|
|
4131
|
+
}
|
|
4132
|
+
return metas.map(m => m.url);
|
|
4133
|
+
}),
|
|
4066
4134
|
extractLinkMappings(page, origin),
|
|
4067
4135
|
]);
|
|
4068
4136
|
for (const el of elems) {
|
|
@@ -4077,7 +4145,8 @@ export async function crawlProject(opts: {
|
|
|
4077
4145
|
pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
|
|
4078
4146
|
}
|
|
4079
4147
|
// Also enqueue links Skyvern page found for further ZeTa crawling
|
|
4080
|
-
for (const
|
|
4148
|
+
for (const link of links) {
|
|
4149
|
+
const href = link;
|
|
4081
4150
|
const c = canonicalize(href);
|
|
4082
4151
|
if (isUsefulNavigationUrl(href, origin) && !queued.has(c) && !visited.has(c)) { queued.add(c); queue.push(href); }
|
|
4083
4152
|
}
|
|
@@ -4233,7 +4302,16 @@ export async function crawlProject(opts: {
|
|
|
4233
4302
|
}
|
|
4234
4303
|
}
|
|
4235
4304
|
// Harvest any new links revealed by async content
|
|
4236
|
-
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined)
|
|
4305
|
+
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined)
|
|
4306
|
+
.then((metas) => {
|
|
4307
|
+
if (metas.length > 0) {
|
|
4308
|
+
AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, existingScreenId, metas.map((m) => ({
|
|
4309
|
+
href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
|
|
4310
|
+
}))).catch(() => {});
|
|
4311
|
+
}
|
|
4312
|
+
return metas.map(m => m.url);
|
|
4313
|
+
})
|
|
4314
|
+
.catch(() => []);
|
|
4237
4315
|
for (const href of revisitLinks) {
|
|
4238
4316
|
const canon = canonicalize(href);
|
|
4239
4317
|
if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 3) {
|
|
@@ -4285,7 +4363,16 @@ export async function crawlProject(opts: {
|
|
|
4285
4363
|
}
|
|
4286
4364
|
const [p3bElems, p3bLinks] = await Promise.all([
|
|
4287
4365
|
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
|
|
4288
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined)
|
|
4366
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined)
|
|
4367
|
+
.then((metas) => {
|
|
4368
|
+
if (metas.length > 0) {
|
|
4369
|
+
AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, screen.id, metas.map((m) => ({
|
|
4370
|
+
href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
|
|
4371
|
+
}))).catch(() => {});
|
|
4372
|
+
}
|
|
4373
|
+
return metas.map(m => m.url);
|
|
4374
|
+
})
|
|
4375
|
+
.catch(() => []),
|
|
4289
4376
|
]);
|
|
4290
4377
|
if (p3bElems.length > 0) {
|
|
4291
4378
|
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
@@ -4369,7 +4456,16 @@ export async function crawlProject(opts: {
|
|
|
4369
4456
|
}
|
|
4370
4457
|
const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
|
|
4371
4458
|
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
|
|
4372
|
-
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined)
|
|
4459
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined)
|
|
4460
|
+
.then((metas) => {
|
|
4461
|
+
if (metas.length > 0) {
|
|
4462
|
+
AppBrain.saveDiscoveredLinks(opts.tenantId, opts.projectId, gapScreen.id, metas.map((m) => ({
|
|
4463
|
+
href: m.url, text: m.text, landmark: m.landmark, linkType: 'NAV_LINK', source: m.source, jobId: opts.jobId,
|
|
4464
|
+
}))).catch(() => {});
|
|
4465
|
+
}
|
|
4466
|
+
return metas.map(m => m.url);
|
|
4467
|
+
})
|
|
4468
|
+
.catch(() => []),
|
|
4373
4469
|
extractLinkMappings(page, origin).catch(() => []),
|
|
4374
4470
|
]);
|
|
4375
4471
|
for (const el of gapElems) {
|
|
@@ -4494,6 +4590,26 @@ export async function crawlProject(opts: {
|
|
|
4494
4590
|
} catch { return false; }
|
|
4495
4591
|
}).length;
|
|
4496
4592
|
logger.info({ transitionCount }, 'KAG: wired element→screen transitions');
|
|
4593
|
+
|
|
4594
|
+
// Navigation graph: resolve DiscoveredLink.toScreenId by matching href → visited screen IDs.
|
|
4595
|
+
// Build a raw-href → screenId map using both canonical and pathname-based lookups.
|
|
4596
|
+
if (opts.jobId) {
|
|
4597
|
+
const hrefToScreenId = new Map<string, string>();
|
|
4598
|
+
for (const [canonUrl, screenId] of visited) {
|
|
4599
|
+
if (screenId && screenId !== '_pending_' && screenId !== '_error_') {
|
|
4600
|
+
hrefToScreenId.set(canonUrl, screenId);
|
|
4601
|
+
try {
|
|
4602
|
+
const u = new URL(canonUrl);
|
|
4603
|
+
hrefToScreenId.set(u.href, screenId);
|
|
4604
|
+
// Also index by origin+pathname so links with extra query params still resolve
|
|
4605
|
+
hrefToScreenId.set(u.origin + u.pathname, screenId);
|
|
4606
|
+
hrefToScreenId.set(u.origin + u.pathname.replace(/\/$/, ''), screenId);
|
|
4607
|
+
} catch { /* skip */ }
|
|
4608
|
+
}
|
|
4609
|
+
}
|
|
4610
|
+
AppBrain.resolveDiscoveredLinkTargets(opts.tenantId, opts.projectId, opts.jobId, hrefToScreenId).catch(() => {});
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4497
4613
|
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
4498
4614
|
jobId: opts.jobId,
|
|
4499
4615
|
type: 'WORKFLOW_GRAPH_SUMMARY',
|
|
@@ -4672,7 +4788,7 @@ export async function crawlProject(opts: {
|
|
|
4672
4788
|
const probeInputs = discovered
|
|
4673
4789
|
.filter((d) => d.screenId)
|
|
4674
4790
|
.map((d) => ({ url: d.url, screenId: d.screenId!, elements: d.elementFacts ?? [] }));
|
|
4675
|
-
await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
|
|
4791
|
+
await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress, opts.jobId);
|
|
4676
4792
|
}
|
|
4677
4793
|
|
|
4678
4794
|
// Step 7: End-of-crawl state graph coverage report.
|
|
@@ -4681,7 +4797,32 @@ export async function crawlProject(opts: {
|
|
|
4681
4797
|
// loop above and are saved as PER_SCREEN_PROBES evidence.
|
|
4682
4798
|
try {
|
|
4683
4799
|
await onProgress?.(90, 'Generating state graph coverage report');
|
|
4800
|
+
// Populate stateGraph from discovered screens + BFS edges (previously always empty).
|
|
4684
4801
|
const stateGraph = new ScreenTransitionGraph();
|
|
4802
|
+
const now = new Date().toISOString();
|
|
4803
|
+
for (const d of discovered) {
|
|
4804
|
+
if (!d.screenId || !d.url) continue;
|
|
4805
|
+
const hashVal = simHash32(d.url);
|
|
4806
|
+
stateGraph.addState({
|
|
4807
|
+
id: d.screenId,
|
|
4808
|
+
url: d.url,
|
|
4809
|
+
normalizedUrl: d.url,
|
|
4810
|
+
title: d.url,
|
|
4811
|
+
interactiveElementCount: d.elements ?? 0,
|
|
4812
|
+
domFingerprint: hashVal.toString(16).padStart(8, '0'),
|
|
4813
|
+
simHashValue: hashVal,
|
|
4814
|
+
visitedAt: now,
|
|
4815
|
+
});
|
|
4816
|
+
}
|
|
4817
|
+
// Wire transitions from BFS edges
|
|
4818
|
+
for (const edge of edges) {
|
|
4819
|
+
const fromId = visited.get(canonicalize(edge.fromUrl));
|
|
4820
|
+
const toId = visited.get(canonicalize(edge.toUrl));
|
|
4821
|
+
if (fromId && toId && fromId !== '_pending_' && fromId !== '_error_' &&
|
|
4822
|
+
toId !== '_pending_' && toId !== '_error_') {
|
|
4823
|
+
stateGraph.addTransition(fromId, toId, 'navigate', edge.toUrl, now);
|
|
4824
|
+
}
|
|
4825
|
+
}
|
|
4685
4826
|
const coveragePayload: Record<string, unknown> = {
|
|
4686
4827
|
stateGraph: stateGraph.getCoverageReport(),
|
|
4687
4828
|
totalScreensCrawled: discovered.filter(d => !!d.screenId).length,
|
|
@@ -4961,7 +5102,7 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
|
|
|
4961
5102
|
}
|
|
4962
5103
|
}
|
|
4963
5104
|
|
|
4964
|
-
async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false, rawPage?: any): Promise<
|
|
5105
|
+
async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false, rawPage?: any): Promise<DiscoveredLinkMeta[]> {
|
|
4965
5106
|
// Expand collapsed nav menus (hamburger, aria-haspopup, Radix closed triggers) so hidden routes
|
|
4966
5107
|
// become part of the DOM before querying. This catches SPA nav dropdowns that only render on click.
|
|
4967
5108
|
const navExpanded = await expandNavMenus(page);
|
|
@@ -4971,7 +5112,9 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
|
|
|
4971
5112
|
const domLinks = await domFallbackLinks(page, origin, rawPage);
|
|
4972
5113
|
|
|
4973
5114
|
// Interact with tabs, carousels, and accordions to reveal hidden content and discover more links.
|
|
4974
|
-
|
|
5115
|
+
// interactAndCollectLinks returns string[] — convert to DiscoveredLinkMeta[] with source INTERACTIVE.
|
|
5116
|
+
const interactiveRaw = await interactAndCollectLinks(page, origin);
|
|
5117
|
+
const interactiveLinks: DiscoveredLinkMeta[] = interactiveRaw.map((url) => ({ url, source: 'INTERACTIVE' as const }));
|
|
4975
5118
|
|
|
4976
5119
|
// Dismiss any opened menus so page is clean for screenshot/element extraction after this call.
|
|
4977
5120
|
if (navExpanded) {
|
|
@@ -4984,7 +5127,15 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
|
|
|
4984
5127
|
}).catch(() => { });
|
|
4985
5128
|
}
|
|
4986
5129
|
|
|
4987
|
-
|
|
5130
|
+
// Deduplicate by URL — DOM wins over INTERACTIVE for metadata richness.
|
|
5131
|
+
const seenUrls = new Set<string>();
|
|
5132
|
+
const allDomLinks: DiscoveredLinkMeta[] = [];
|
|
5133
|
+
for (const link of [...domLinks, ...interactiveLinks]) {
|
|
5134
|
+
if (!seenUrls.has(link.url)) {
|
|
5135
|
+
seenUrls.add(link.url);
|
|
5136
|
+
allDomLinks.push(link);
|
|
5137
|
+
}
|
|
5138
|
+
}
|
|
4988
5139
|
|
|
4989
5140
|
if (allDomLinks.length >= 50 || skipAi) return allDomLinks;
|
|
4990
5141
|
|
|
@@ -5004,7 +5155,7 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
|
|
|
5004
5155
|
if (!extracted) return allDomLinks;
|
|
5005
5156
|
const { links } = extracted as any;
|
|
5006
5157
|
const currentUrl: string = page.url?.() ?? '';
|
|
5007
|
-
const aiLinks = links
|
|
5158
|
+
const aiLinks: DiscoveredLinkMeta[] = links
|
|
5008
5159
|
.map((href: string) => {
|
|
5009
5160
|
try { const u = new URL(href, currentUrl); if (!/^#!?\//.test(u.hash)) u.hash = ''; return u.toString(); }
|
|
5010
5161
|
catch { return null; }
|
|
@@ -5012,9 +5163,19 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
|
|
|
5012
5163
|
.filter((u: string | null): u is string => {
|
|
5013
5164
|
if (!u) return false;
|
|
5014
5165
|
return isUsefulNavigationUrl(u, origin);
|
|
5015
|
-
})
|
|
5016
|
-
|
|
5017
|
-
|
|
5166
|
+
})
|
|
5167
|
+
.map((url: string) => ({ url, source: 'AI' as const }));
|
|
5168
|
+
|
|
5169
|
+
// Always merge DOM + AI — AI complements, never replaces. Dedup by URL.
|
|
5170
|
+
const merged: DiscoveredLinkMeta[] = [...allDomLinks];
|
|
5171
|
+
const mergedUrls = new Set(merged.map((l) => l.url));
|
|
5172
|
+
for (const link of aiLinks) {
|
|
5173
|
+
if (!mergedUrls.has(link.url) && isUsefulNavigationUrl(link.url, origin)) {
|
|
5174
|
+
mergedUrls.add(link.url);
|
|
5175
|
+
merged.push(link);
|
|
5176
|
+
}
|
|
5177
|
+
}
|
|
5178
|
+
return merged.filter((l) => isUsefulNavigationUrl(l.url, origin));
|
|
5018
5179
|
} catch (err: any) {
|
|
5019
5180
|
// Detect OpenAI rate-limit errors — signal caller via thrown special error so circuit breaker can trip
|
|
5020
5181
|
const is429 = err?.status === 429 || String(err?.message ?? '').includes('429') || String(err?.message ?? '').includes('rate limit');
|
|
@@ -6225,14 +6386,16 @@ async function expandNavMenus(page: any): Promise<boolean> {
|
|
|
6225
6386
|
} catch { return false; }
|
|
6226
6387
|
}
|
|
6227
6388
|
|
|
6228
|
-
async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promise<
|
|
6389
|
+
async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promise<DiscoveredLinkMeta[]> {
|
|
6229
6390
|
// Named function so .toString() gives the raw function body for CDP evaluation.
|
|
6230
6391
|
// esbuild wraps nested named arrow-function constants with __name() — prepend a
|
|
6231
6392
|
// tiny polyfill so the browser has it when we serialize this function as a string.
|
|
6393
|
+
// IMPORTANT: _linkEval is serialized to a string via .toString() and evaluated in the
|
|
6394
|
+
// browser via CDP. It CANNOT contain TypeScript types or Node.js imports.
|
|
6232
6395
|
function _linkEval(originStr: string) {
|
|
6233
6396
|
const logout = /logout|log out|sign out|delete/i;
|
|
6234
6397
|
const isRouteHash = (hash: string) => /^#!?\//.test(hash);
|
|
6235
|
-
const seen = new
|
|
6398
|
+
const seen = new Map();
|
|
6236
6399
|
const add = (rawHref: string, textContent: string) => {
|
|
6237
6400
|
if (logout.test(textContent)) return;
|
|
6238
6401
|
if (!rawHref) return;
|
|
@@ -6241,7 +6404,7 @@ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promi
|
|
|
6241
6404
|
try {
|
|
6242
6405
|
const u = new URL(rawHref, location.href);
|
|
6243
6406
|
if (!isRouteHash(u.hash)) u.hash = '';
|
|
6244
|
-
if (new URL(u).origin === originStr) seen.
|
|
6407
|
+
if (new URL(u).origin === originStr) seen.set(u.toString(), (textContent || '').trim().slice(0, 120));
|
|
6245
6408
|
} catch { /* ignore */ }
|
|
6246
6409
|
};
|
|
6247
6410
|
const allRoots: Array<Document | ShadowRoot> = [document];
|
|
@@ -6273,7 +6436,22 @@ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promi
|
|
|
6273
6436
|
});
|
|
6274
6437
|
} catch (_) { /* element threw — skip root */ }
|
|
6275
6438
|
}
|
|
6276
|
-
return Array.from(seen);
|
|
6439
|
+
return Array.from(seen.entries()).map(([url, text]) => ({ url, text }));
|
|
6440
|
+
}
|
|
6441
|
+
|
|
6442
|
+
function inferLandmark(url: string): string | undefined {
|
|
6443
|
+
const p = url.toLowerCase();
|
|
6444
|
+
if (p.includes('/auth') || p.includes('/login') || p.includes('/register')) return 'nav';
|
|
6445
|
+
return undefined;
|
|
6446
|
+
}
|
|
6447
|
+
|
|
6448
|
+
function toMeta(raw: Array<{ url: string; text: string }>): DiscoveredLinkMeta[] {
|
|
6449
|
+
return raw.map(({ url, text }) => ({
|
|
6450
|
+
url,
|
|
6451
|
+
text: text || undefined,
|
|
6452
|
+
landmark: inferLandmark(url),
|
|
6453
|
+
source: 'DOM' as const,
|
|
6454
|
+
}));
|
|
6277
6455
|
}
|
|
6278
6456
|
|
|
6279
6457
|
const _linkEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_linkEval.toString()})(${JSON.stringify(origin)})})();`;
|
|
@@ -6291,7 +6469,7 @@ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promi
|
|
|
6291
6469
|
awaitPromise: false,
|
|
6292
6470
|
});
|
|
6293
6471
|
if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
|
|
6294
|
-
return evalResult.result.value as string
|
|
6472
|
+
return toMeta(evalResult.result.value as Array<{ url: string; text: string }>);
|
|
6295
6473
|
}
|
|
6296
6474
|
if (evalResult?.exceptionDetails) {
|
|
6297
6475
|
console.warn('[crawler] domFallbackLinks CDP error:', evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
|
|
@@ -6307,7 +6485,7 @@ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promi
|
|
|
6307
6485
|
|
|
6308
6486
|
// Fallback: pass string expression so esbuild __name wrapper never reaches browser
|
|
6309
6487
|
const evalPage = rawPage ?? page;
|
|
6310
|
-
return (evalPage.evaluate(_linkEvalStr as any) as Promise<string
|
|
6488
|
+
return (evalPage.evaluate(_linkEvalStr as any) as Promise<Array<{ url: string; text: string }>>).then(toMeta).catch((err: any) => {
|
|
6311
6489
|
console.warn('[crawler] domFallbackLinks page.evaluate threw:', err?.message ?? String(err));
|
|
6312
6490
|
return [];
|
|
6313
6491
|
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
|
|
3
|
+
export interface BrandingColors {
|
|
4
|
+
primary?: string;
|
|
5
|
+
accent?: string;
|
|
6
|
+
background?: string;
|
|
7
|
+
textPrimary?: string;
|
|
8
|
+
link?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BrandingFont {
|
|
12
|
+
family: string;
|
|
13
|
+
role: 'heading' | 'body';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BrandingImages {
|
|
17
|
+
logo?: string;
|
|
18
|
+
favicon?: string;
|
|
19
|
+
ogImage?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BrandingPersonality {
|
|
23
|
+
tone?: string;
|
|
24
|
+
energy?: 'high' | 'medium' | 'low';
|
|
25
|
+
targetAudience?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BrandingResult {
|
|
29
|
+
colorScheme: 'light' | 'dark';
|
|
30
|
+
colors: BrandingColors;
|
|
31
|
+
fonts: BrandingFont[];
|
|
32
|
+
images: BrandingImages;
|
|
33
|
+
personality?: BrandingPersonality;
|
|
34
|
+
designSystem?: { framework?: string; componentLibrary?: string };
|
|
35
|
+
metadata?: { title?: string; description?: string; themeColor?: string; language?: string };
|
|
36
|
+
confidence: { colors: number; fonts: number; images: number; overall: number };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function extractBranding(page: Page): Promise<BrandingResult> {
|
|
40
|
+
try {
|
|
41
|
+
const result = await page.evaluate(() => {
|
|
42
|
+
const bodyStyle = window.getComputedStyle(document.body);
|
|
43
|
+
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches ||
|
|
44
|
+
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
|
45
|
+
document.body.classList.contains('dark');
|
|
46
|
+
|
|
47
|
+
// Extract colors from computed styles
|
|
48
|
+
const bg = bodyStyle.backgroundColor;
|
|
49
|
+
const text = bodyStyle.color;
|
|
50
|
+
|
|
51
|
+
// Find primary button color
|
|
52
|
+
const primaryBtn = document.querySelector(
|
|
53
|
+
'button[class*="primary"], .btn-primary, [class*="btn-primary"], [class*="button-primary"]'
|
|
54
|
+
);
|
|
55
|
+
const primaryColor = primaryBtn ? window.getComputedStyle(primaryBtn).backgroundColor : undefined;
|
|
56
|
+
|
|
57
|
+
// Find link color
|
|
58
|
+
const firstLink = document.querySelector('a');
|
|
59
|
+
const linkColor = firstLink ? window.getComputedStyle(firstLink).color : undefined;
|
|
60
|
+
|
|
61
|
+
// Extract font families
|
|
62
|
+
const headingEl = document.querySelector('h1, h2');
|
|
63
|
+
const bodyEl = document.body;
|
|
64
|
+
const headingFont = headingEl ? window.getComputedStyle(headingEl).fontFamily : bodyStyle.fontFamily;
|
|
65
|
+
const bodyFont = bodyStyle.fontFamily;
|
|
66
|
+
|
|
67
|
+
// Extract images
|
|
68
|
+
const logoEl = document.querySelector(
|
|
69
|
+
'header img, img[alt*="logo" i], img[class*="logo" i], [class*="logo"] img, [id*="logo"] img'
|
|
70
|
+
) as HTMLImageElement | null;
|
|
71
|
+
const faviconEl = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]') as HTMLLinkElement | null;
|
|
72
|
+
const ogImageEl = document.querySelector('meta[property="og:image"]') as HTMLMetaElement | null;
|
|
73
|
+
|
|
74
|
+
// Extract metadata
|
|
75
|
+
const titleEl = document.querySelector('title');
|
|
76
|
+
const descEl = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
|
|
77
|
+
const themeColorEl = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null;
|
|
78
|
+
|
|
79
|
+
// Detect design framework
|
|
80
|
+
const hasBootstrap = !!document.querySelector('[class*="btn btn-"], .container-fluid, .navbar-expand');
|
|
81
|
+
const hasTailwind = !!document.querySelector('[class*="flex "], [class*="text-"], [class*="bg-"]');
|
|
82
|
+
const hasChakra = !!(document as any).__chakra_factory__ || !!document.querySelector('[class*="chakra-"]');
|
|
83
|
+
const hasMUI = !!document.querySelector('.MuiButton-root, .MuiTypography-root');
|
|
84
|
+
|
|
85
|
+
const framework = hasBootstrap ? 'Bootstrap' : hasTailwind ? 'Tailwind CSS' : hasChakra ? 'Chakra UI' : hasMUI ? 'Material UI' : null;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
isDark,
|
|
89
|
+
bg,
|
|
90
|
+
text,
|
|
91
|
+
primaryColor,
|
|
92
|
+
linkColor,
|
|
93
|
+
headingFont,
|
|
94
|
+
bodyFont,
|
|
95
|
+
logo: logoEl?.src || logoEl?.getAttribute('src') || undefined,
|
|
96
|
+
favicon: faviconEl?.href || undefined,
|
|
97
|
+
ogImage: ogImageEl?.content || undefined,
|
|
98
|
+
title: titleEl?.textContent || undefined,
|
|
99
|
+
description: descEl?.content || undefined,
|
|
100
|
+
themeColor: themeColorEl?.content || undefined,
|
|
101
|
+
language: document.documentElement.lang || undefined,
|
|
102
|
+
framework,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const fonts: BrandingFont[] = [];
|
|
107
|
+
if (result.headingFont && result.headingFont !== result.bodyFont) {
|
|
108
|
+
fonts.push({ family: result.headingFont.split(',')[0].replace(/['"]/g, '').trim(), role: 'heading' });
|
|
109
|
+
}
|
|
110
|
+
if (result.bodyFont) {
|
|
111
|
+
fonts.push({ family: result.bodyFont.split(',')[0].replace(/['"]/g, '').trim(), role: 'body' });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const colorConfidence = (result.bg || result.text || result.primaryColor) ? 0.8 : 0.3;
|
|
115
|
+
const fontConfidence = fonts.length > 0 ? 0.7 : 0.2;
|
|
116
|
+
const imageConfidence = (result.logo || result.ogImage) ? 0.9 : 0.2;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
colorScheme: result.isDark ? 'dark' : 'light',
|
|
120
|
+
colors: {
|
|
121
|
+
background: result.bg || undefined,
|
|
122
|
+
textPrimary: result.text || undefined,
|
|
123
|
+
primary: result.primaryColor || undefined,
|
|
124
|
+
link: result.linkColor || undefined,
|
|
125
|
+
},
|
|
126
|
+
fonts,
|
|
127
|
+
images: {
|
|
128
|
+
logo: result.logo,
|
|
129
|
+
favicon: result.favicon,
|
|
130
|
+
ogImage: result.ogImage,
|
|
131
|
+
},
|
|
132
|
+
designSystem: {
|
|
133
|
+
framework: result.framework || undefined,
|
|
134
|
+
componentLibrary: undefined,
|
|
135
|
+
},
|
|
136
|
+
metadata: {
|
|
137
|
+
title: result.title,
|
|
138
|
+
description: result.description,
|
|
139
|
+
themeColor: result.themeColor,
|
|
140
|
+
language: result.language,
|
|
141
|
+
},
|
|
142
|
+
confidence: {
|
|
143
|
+
colors: colorConfidence,
|
|
144
|
+
fonts: fontConfidence,
|
|
145
|
+
images: imageConfidence,
|
|
146
|
+
overall: (colorConfidence + fontConfidence + imageConfidence) / 3,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.warn('[branding-extractor] Extraction failed:', err);
|
|
151
|
+
return {
|
|
152
|
+
colorScheme: 'light',
|
|
153
|
+
colors: {},
|
|
154
|
+
fonts: [],
|
|
155
|
+
images: {},
|
|
156
|
+
confidence: { colors: 0, fonts: 0, images: 0, overall: 0 },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|