fix(capture): broaden isLogo detection — structural signals catch real header logos

Verified on the heygen.com + huly.io captures from the last real-AI-test
run: the prior class-substring detectors caught 0 / 32 (heygen) and
0 / 19 (huly) SVGs as logos. Both sites' actual header logos were
captured but never got the `logo-<hash>.svg` prefix, so AI agents had
no filename signal to find them and ended up composing fakes.

Root cause: modern React / Tailwind builds rarely put "logo" / "brand"
anywhere in an ancestor className. Tailwind's class names are utility
classes ("flex items-center"), and component frameworks emit hashed
classes ("Header__inner_a1b2c3"). The detector's substring check
("[class*=\"logo\"]") matched neither.

Fix: add structural DOM signals alongside the existing class check.
Three signals catch the universal "site header logo" pattern even
without semantic class names:

1. inBanner — element sits inside <header>, <nav>, or [role="banner"].
   The canonical "logo at the top of the page" container.
2. inHomeLink — element sits inside an <a> whose href is the site root
   ("/", "#", "./", or origin-only URL). The brand-home link wrap that
   nearly every header logo lives in.
3. matchesTitleBrand — element's alt / aria-label / title contains
   the brand segment of document.title (everything before " - " /
   " | " / " — "). Catches the explicit "alt=HeyGen" pattern.

These three are independent — ANY positive signal sets isLogo. False
positives (an icon that happens to be the first <svg> in a <nav>) are
acceptable because the downstream effect is only that the file gets
named `logo-<hash>.svg` instead of `svg-<hash>.svg` — content is
identical, agents will read asset-descriptions.md to disambiguate
either way.

Files changed:

1. packages/cli/src/capture/tokenExtractor.ts — inline-DOM SVG scan
   (the path that catches <svg>...</svg> markup directly in the page).
   Existing isLogo line replaced with the broadened block that runs
   the three structural checks after the original class/label checks.

2. packages/cli/src/capture/assetCataloger.ts —
   - CatalogedAsset interface: added optional inBanner, inHomeLink,
     matchesTitleBrand booleans.
   - getElementContext: emits the three signals on every cataloged
     asset so the downloader can read them.
   - The add() function merges booleans with "any positive wins"
     semantics — if any DOM occurrence of a URL is in a banner, the
     URL is a header-context asset.
   - deduplicateSrcsetVariants merges the same way across w= variants.

3. packages/cli/src/capture/assetDownloader.ts — URL-downloaded SVG
   path (which catches the HeyGen header lockup, served via <img src=
   "...cdn.svg" alt="HeyGen">). The lone `contexts` substring check —
   which never fired because contexts hold HTML positions like
   'img[src]', not semantic labels — is now one prong among many. The
   structural signals (inBanner / inHomeLink / matchesTitleBrand)
   carry most of the recall; the substring check stays as a backstop
   against future contexts vocabulary additions.

No behavior change for projects where isLogo was already firing
correctly. The change only PROMOTES previously-undetected logos to
the logo-<hash>.svg naming. The byte content and downstream rendering
are unchanged either way.

Tied to commit e6aa7fed (the doc landmine for searching
asset-descriptions.md before composing a logo) — together these
close the heygen / huly logo discoverability gap from two angles:
better signal in the filename when detection fires, doc-rule
fallback when it doesn't.

Followup: strengthen the no-Gemini fallback in contentExtractor.ts
so asset-descriptions.md remains useful for teams without a Gemini
API key (currently falls back to just the filename hash).
This commit is contained in:
ukimsanov
2026-06-12 01:07:30 -07:00
parent e6aa7feda9
commit 02f95f9556
3 changed files with 99 additions and 3 deletions
+40 -1
View File
@@ -25,6 +25,12 @@ export interface CatalogedAsset {
sectionClasses?: string;
/** Whether the image is above the fold (visible without scrolling) */
aboveFold?: boolean;
/** Element sits inside <header>, <nav>, or [role="banner"] — logo signal */
inBanner?: boolean;
/** Element sits inside <a> with site-root href ("/", "#", origin-only) — brand-home link */
inHomeLink?: boolean;
/** alt/aria-label/title contains the brand segment of document.title */
matchesTitleBrand?: boolean;
}
/**
@@ -62,6 +68,29 @@ export async function catalogAssets(page: Page): Promise<CatalogedAsset[]> {
var rect = el.getBoundingClientRect();
ctx.aboveFold = rect.top < window.innerHeight;
} catch(e) {}
// Logo signals — surfaced explicitly so the downloader can prefix
// logo-<hash> reliably. Real-AI-test on heygen.com + huly.io showed
// the prior class-substring detector caught 0 logos; these explicit
// structural signals catch the header logo across modern React/
// Tailwind builds where "logo" isn't in any className.
// 1. inBanner: element sits inside <header>, <nav>, or [role=banner].
ctx.inBanner = el.closest('header, nav, [role="banner"]') !== null;
// 2. inHomeLink: element sits inside an <a> whose href is the site
// root ("/", "#", "./" or origin-only URL) — the brand-home link.
var homeAnchor = el.closest('a[href]');
if (homeAnchor) {
var aHref = homeAnchor.getAttribute('href') || '';
ctx.inHomeLink = aHref === '/' || aHref === '#' || aHref === './' ||
/^https?:\/\/[^/]+\/?$/.test(aHref);
}
// 3. matchesTitleBrand: alt/aria-label/title contains the brand
// segment of the page title (everything before " - " / " | " /
// " — ") — the "alt=HeyGen" / "aria-label=Huly" pattern.
var titleBrand = (document.title || '').split(/[-|—]/)[0].trim();
if (desc && titleBrand.length > 1 && titleBrand.length < 30 &&
desc.toLowerCase().indexOf(titleBrand.toLowerCase()) !== -1) {
ctx.matchesTitleBrand = true;
}
return ctx;
}
@@ -92,12 +121,18 @@ export async function catalogAssets(page: Page): Promise<CatalogedAsset[]> {
if (notes && !entry.notes) {
entry.notes = notes;
}
// Merge rich context (first one wins)
// Merge rich context. Text fields: first-occurrence wins. Boolean
// signals (inBanner / inHomeLink / matchesTitleBrand): any positive
// sample wins — if ANY DOM occurrence of this URL is in a header,
// the URL is a header-context asset.
if (richCtx) {
if (richCtx.description && !entry.description) entry.description = richCtx.description;
if (richCtx.nearestHeading && !entry.nearestHeading) entry.nearestHeading = richCtx.nearestHeading;
if (richCtx.sectionClasses && !entry.sectionClasses) entry.sectionClasses = richCtx.sectionClasses;
if (richCtx.aboveFold !== undefined && entry.aboveFold === undefined) entry.aboveFold = richCtx.aboveFold;
if (richCtx.inBanner) entry.inBanner = true;
if (richCtx.inHomeLink) entry.inHomeLink = true;
if (richCtx.matchesTitleBrand) entry.matchesTitleBrand = true;
}
}
@@ -324,6 +359,10 @@ function deduplicateSrcsetVariants(assets: CatalogedAsset[]): CatalogedAsset[] {
if (a.notes && !existing.notes) {
existing.notes = a.notes;
}
// Boolean logo signals: any positive sample wins through the merge.
if (a.inBanner) existing.inBanner = true;
if (a.inHomeLink) existing.inHomeLink = true;
if (a.matchesTitleBrand) existing.matchesTitleBrand = true;
// Keep the URL with highest w= value (largest image)
const existingW = getWidthParam(existing.url);
const newW = getWidthParam(a.url);
+21 -1
View File
@@ -160,7 +160,27 @@ export async function downloadAssets(
// human-readable slug — they were not affected by the bug.
let slug: string;
if (ext === ".svg") {
const isLogo = !!catalog?.contexts?.some((c) => /logo|brand|wordmark/i.test(c));
// isLogo signals — broadened. The original `contexts` substring
// check never fired in practice because contexts hold HTML
// positions like 'img[src]' / 'video[poster]', not semantic
// labels. Real signals come from DOM structure + alt/aria text:
// 1. The cataloger now flags inBanner (inside <header>/<nav>/
// [role=banner]), inHomeLink (inside <a href="/">), and
// matchesTitleBrand (alt/aria matches document.title's
// brand segment) — see assetCataloger.ts getElementContext.
// 2. As a backstop, also check description / nearestHeading /
// sectionClasses for "logo" / "brand" / "wordmark" text.
const c = catalog;
const brandRe = /logo|brand|wordmark/i;
const isLogo = !!(
c?.inBanner ||
c?.inHomeLink ||
c?.matchesTitleBrand ||
c?.contexts?.some((s) => brandRe.test(s)) ||
(c?.description && brandRe.test(c.description)) ||
(c?.nearestHeading && brandRe.test(c.nearestHeading)) ||
(c?.sectionClasses && brandRe.test(c.sectionClasses))
);
slug = svgContentHashSlug(buffer, isLogo);
} else {
slug = deriveAssetName(parsedUrl, catalog, isPoster, imgIdx, usedNames);
+38 -1
View File
@@ -304,6 +304,43 @@ const EXTRACT_SCRIPT = `(() => {
// Keep SVGs that have a label OR are at least 16px wide OR are inside a logo/brand context
var inLogoContext = svg.closest('[class*="logo"], [class*="brand"], [class*="partner"], [class*="customer"], [class*="marquee"]') !== null;
if (!label && !inLogoContext && (!w || parseInt(w) < 16)) return null;
// isLogo — broadened detection. Real-AI-test on heygen.com + huly.io
// showed the original class-based detector caught 0/32 and 0/19 SVGs
// respectively — modern React/Tailwind builds don't put "logo" / "brand"
// anywhere in className. Three additional cheap heuristics added below.
var isLogo = (label && label.toLowerCase().indexOf("logo") !== -1) ||
svg.closest('[class*="logo"], [class*="brand"], [class*="home"], [class*="marquee"], [class*="partner"], [class*="customer"]') !== null;
if (!isLogo) {
// (a) First SVG inside <header>, <nav>, or [role=banner] — the
// canonical "home logo at the top of the page" pattern.
var bannerEl = svg.closest('header, nav, [role="banner"]');
if (bannerEl) {
var firstSvg = bannerEl.querySelector('svg');
if (firstSvg === svg) isLogo = true;
}
}
if (!isLogo) {
// (b) SVG inside an <a> whose href is the site root — the brand-home
// link pattern (e.g. <a href="/"><svg>...</svg></a>).
var anchor = svg.closest('a[href]');
if (anchor) {
var href = anchor.getAttribute('href') || '';
if (href === '/' || href === '#' || href === './' ||
/^https?:\/\/[^/]+\/?$/.test(href)) {
isLogo = true;
}
}
}
if (!isLogo) {
// (c) SVG with aria-label that matches the document title's brand
// segment (everything before " - " / " | " / " — ").
var ariaLabel = svg.getAttribute('aria-label') || svg.getAttribute('title') || '';
var titleBrand = (document.title || '').split(/[-|—]/)[0].trim();
if (titleBrand.length > 1 && titleBrand.length < 30 &&
ariaLabel.toLowerCase().indexOf(titleBrand.toLowerCase()) !== -1) {
isLogo = true;
}
}
var rect = svg.getBoundingClientRect();
return {
label: label || undefined,
@@ -311,7 +348,7 @@ const EXTRACT_SCRIPT = `(() => {
width: Math.round(rect.width),
height: Math.round(rect.height),
outerHTML: svg.outerHTML.slice(0, 10000),
isLogo: (label && label.toLowerCase().indexOf("logo") !== -1) || svg.closest('[class*="logo"], [class*="brand"], [class*="home"], [class*="marquee"], [class*="partner"], [class*="customer"]') !== null
isLogo: isLogo
};
}).filter(Boolean).slice(0, 50);