mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user