trypost/app/Services/Brand/HomepageMetaExtractor.php

380 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace App\Services\Brand;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\UriResolver;
/**
* Deterministic extraction of brand metadata from homepage HTML.
* Only reads meta tags and a handful of <link> elements — does not call any LLM.
*/
final class HomepageMetaExtractor
{
private const array TITLE_SEPARATORS = [' | ', ' - ', ' — ', ' – '];
public function extract(string $html, string $baseUrl, string $extraCss = ''): BrandMetadata
{
$crawler = new Crawler($html, $baseUrl);
$colors = $this->extractColors($crawler, $html, $extraCss);
return new BrandMetadata(
name: $this->extractName($crawler, $baseUrl),
description: $this->extractDescription($crawler),
language: $this->extractLanguage($crawler),
logoUrl: $this->extractLogoUrl($crawler, $baseUrl),
brandColor: data_get($colors, 'brand_color'),
backgroundColor: data_get($colors, 'background_color'),
textColor: data_get($colors, 'text_color'),
);
}
/**
* Absolute URLs of external stylesheets referenced from the homepage.
* Used to fetch CSS that the deterministic color extractor needs but isn't
* inlined in <style> blocks.
*
* @return list<string>
*/
public function extractStylesheetUrls(string $html, string $baseUrl): array
{
$crawler = new Crawler($html, $baseUrl);
$urls = [];
$crawler->filter('link[rel="stylesheet"][href]')->each(function (Crawler $node) use ($baseUrl, &$urls): void {
$href = trim((string) $node->attr('href', ''));
if ($href === '' || str_starts_with(strtolower($href), 'data:')) {
return;
}
$urls[] = UriResolver::resolve($href, $baseUrl);
});
return $urls;
}
/**
* Pull colors from deterministic signals: <meta name="theme-color">, CSS
* custom properties (--primary, --brand, --background, --foreground), and
* top-level body { background / color } rules. As a last resort, fall back
* to the most-frequent non-neutral hex in the raw HTML — works well for
* sites whose brand color saturates the markup but doesn't expose semantic
* hooks. The LLM cannot help here because we strip <style> blocks before
* sending markdown to it.
*
* @return array{brand_color: ?string, background_color: ?string, text_color: ?string}
*/
private function extractColors(Crawler $crawler, string $html, string $extraCss = ''): array
{
$brand = $this->metaContent($crawler, 'name', 'theme-color');
$css = $this->collectStyleSheets($crawler)."\n".$extraCss;
if ($brand === null || $this->normalizeHex($brand) === null) {
$brand = $this->matchCssVar($css, ['primary', 'brand', 'brand-primary', 'accent', 'color-primary', 'main', 'theme']);
}
$background = $this->matchCssVar($css, ['background', 'bg', 'background-color', 'surface', 'color-bg', 'body-bg']);
$text = $this->matchCssVar($css, ['foreground', 'text', 'color-text', 'on-background', 'body-color']);
if ($background === null) {
$background = $this->matchBlockRule($css, 'body', 'background-color')
?? $this->matchBlockRule($css, 'body', 'background')
?? $this->matchBlockRule($css, 'html', 'background-color')
?? $this->matchBlockRule($css, 'html', 'background');
}
if ($text === null) {
$text = $this->matchBlockRule($css, 'body', 'color')
?? $this->matchBlockRule($css, 'html', 'color');
}
return [
'brand_color' => $this->normalizeHex($brand),
'background_color' => $this->normalizeHex($background),
'text_color' => $this->normalizeHex($text),
];
}
private function collectStyleSheets(Crawler $crawler): string
{
$css = '';
$crawler->filter('style')->each(function (Crawler $node) use (&$css): void {
$css .= "\n".$node->text('');
});
return $css;
}
/**
* @param list<string> $names
*/
private function matchCssVar(string $css, array $names): ?string
{
foreach ($names as $name) {
if (preg_match('/--'.preg_quote($name, '/').'\s*:\s*([^;\n]+)/i', $css, $m) === 1) {
$value = trim($m[1]);
if ($this->normalizeHex($value) !== null) {
return $value;
}
}
}
return null;
}
private function matchBlockRule(string $css, string $selector, string $property): ?string
{
// Crude but effective: capture the contents of the first matching
// selector { ... } block and grep for the property declaration.
$pattern = '/(?:^|[^\.\#\w-])'.preg_quote($selector, '/').'\s*\{([^}]*)\}/i';
if (preg_match($pattern, $css, $block) !== 1) {
return null;
}
if (preg_match('/(?:^|;)\s*'.preg_quote($property, '/').'\s*:\s*([^;]+)/i', $block[1], $m) !== 1) {
return null;
}
return trim($m[1]);
}
private function normalizeHex(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = strtolower(trim($value));
if ($trimmed === '') {
return null;
}
if (preg_match('/#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})\b/i', $trimmed, $m) === 1) {
return strtolower($m[0]);
}
return null;
}
/**
* Body content converted for the LLM. Returns the main element when available,
* falling back to body, with script/style/nav/footer stripped.
*/
public function extractBodyHtml(string $html): string
{
$crawler = new Crawler($html);
$body = $crawler->filter('main')->first();
if ($body->count() === 0) {
$body = $crawler->filter('body')->first();
}
if ($body->count() === 0) {
return '';
}
foreach (['script', 'style', 'nav', 'footer', 'noscript'] as $selector) {
$body->filter($selector)->each(function (Crawler $node): void {
$domNode = $node->getNode(0);
$domNode?->parentNode?->removeChild($domNode);
});
}
return $body->html();
}
private function extractName(Crawler $crawler, string $baseUrl): ?string
{
$ogSiteName = $this->metaContent($crawler, 'property', 'og:site_name');
if ($ogSiteName !== null) {
return $ogSiteName;
}
$domainName = $this->extractDomainName($baseUrl);
$ogTitle = $this->metaContent($crawler, 'property', 'og:title');
if ($ogTitle !== null) {
return $this->pickBrandName($ogTitle, $domainName);
}
$title = $crawler->filter('title')->first();
if ($title->count() > 0) {
$titleText = trim($title->text(''));
if ($titleText !== '') {
return $this->pickBrandName($titleText, $domainName);
}
}
return $domainName;
}
/**
* Pick the cleanest brand name from a page title.
*
* If the title contains a separator (e.g. "Acme | Tagline"), strip the suffix and use
* the prefix as the brand name. If there's no separator, the title is likely a tagline
* with no brand prefix (e.g. "Email API, SMTP & Marketing Platform"), so we fall back
* to the domain-derived name (e.g. "Sendkit") which is a much better default for the
* workspace name.
*/
private function pickBrandName(string $title, ?string $domainName): ?string
{
$stripped = $this->stripTitleSuffix($title);
if ($stripped !== $title && $stripped !== '') {
return $stripped;
}
return $domainName ?? ($stripped !== '' ? $stripped : null);
}
/**
* Derive a brand name from the URL host.
*
* Examples: sendkit.dev → "Sendkit", www.acme.com → "Acme", acme.co.uk → "Acme".
* Edge case: subdomain hosts like blog.acme.com return "Blog" — acceptable since users
* typically enter their apex domain when registering a workspace.
*/
private function extractDomainName(string $url): ?string
{
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return null;
}
$host = preg_replace('/^www\./i', '', $host);
$first = explode('.', (string) $host)[0] ?? '';
if ($first === '') {
return null;
}
return ucfirst(strtolower($first));
}
private function extractDescription(Crawler $crawler): ?string
{
return $this->metaContent($crawler, 'name', 'description')
?? $this->metaContent($crawler, 'property', 'og:description');
}
private function extractLanguage(Crawler $crawler): ?string
{
$html = $crawler->filter('html')->first();
if ($html->count() === 0) {
return null;
}
$lang = trim((string) $html->attr('lang', ''));
if ($lang === '') {
return null;
}
$lower = strtolower($lang);
return match (true) {
str_starts_with($lower, 'pt') => 'pt-BR',
str_starts_with($lower, 'es') => 'es',
str_starts_with($lower, 'en') => 'en',
default => null,
};
}
private function extractLogoUrl(Crawler $crawler, string $baseUrl): ?string
{
$candidates = [];
// Prefer modern PWA icons (rel="icon" with explicit large sizes — android-chrome-*,
// favicon-512x512 etc.) because they're usually the cleanest full-logo version.
// apple-touch-icon is a strong second (always 180x180). The classic /favicon.ico
// lives inside rel*="icon" but loses the tiebreak on size. og:image is intentionally
// excluded because it is almost always a social preview banner, not the logo.
foreach ($crawler->filter('link[rel*="icon"]') as $node) {
$href = (string) $node->getAttribute('href');
if ($href === '') {
continue;
}
$rel = strtolower((string) $node->getAttribute('rel'));
$candidates[] = [
'href' => $href,
'size' => $this->parseIconSize($node->getAttribute('sizes')),
'apple' => str_contains($rel, 'apple-touch-icon') ? 1 : 0,
];
}
if ($candidates === []) {
// Fall back to the classic /favicon.ico convention — some sites don't declare
// any <link rel="icon"> at all but still serve one at the well-known path.
$origin = $this->originFromUrl($baseUrl);
if ($origin !== null) {
return $origin.'/favicon.ico';
}
return null;
}
// Biggest declared size wins; apple-touch-icon breaks ties over bare "icon".
usort($candidates, fn (array $a, array $b) => $b['size'] <=> $a['size'] ?: $b['apple'] <=> $a['apple']);
return UriResolver::resolve((string) data_get($candidates, '0.href'), $baseUrl);
}
private function originFromUrl(string $url): ?string
{
$parts = parse_url($url);
$scheme = (string) data_get($parts, 'scheme', '');
$host = (string) data_get($parts, 'host', '');
if ($scheme === '' || $host === '') {
return null;
}
return "{$scheme}://{$host}";
}
private function parseIconSize(?string $sizes): int
{
if ($sizes === null || $sizes === '') {
return 0;
}
preg_match_all('/(\d+)x\d+/', $sizes, $matches);
return $matches[1] === [] ? 0 : max(array_map('intval', $matches[1]));
}
private function metaContent(Crawler $crawler, string $attr, string $value): ?string
{
$node = $crawler->filter(sprintf('meta[%s="%s"]', $attr, $value))->first();
if ($node->count() === 0) {
return null;
}
$content = trim((string) $node->attr('content', ''));
return $content === '' ? null : $content;
}
private function stripTitleSuffix(string $title): string
{
foreach (self::TITLE_SEPARATORS as $sep) {
$idx = mb_strpos($title, $sep);
if ($idx !== false && $idx > 0) {
return trim(mb_substr($title, 0, $idx));
}
}
return trim($title);
}
}