From 6ffb24f20abb7e9e3944d5bcdd65c807b305e9c2 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 11 Aug 2026 00:06:19 +0300 Subject: [PATCH] internal linking --- app/Console/Commands/MirrorToolLogos.php | 4 +- app/Jobs/MirrorToolLogo.php | 46 +++++++--- app/Models/Post.php | 5 +- app/Models/Tool.php | 4 +- app/Support/FaviconResolver.php | 107 ++++++++++++++++++++++ app/Support/ToolLinker.php | 111 +++++++++++++++++++++++ 6 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 app/Support/FaviconResolver.php create mode 100644 app/Support/ToolLinker.php diff --git a/app/Console/Commands/MirrorToolLogos.php b/app/Console/Commands/MirrorToolLogos.php index ae6342c..2202ce7 100644 --- a/app/Console/Commands/MirrorToolLogos.php +++ b/app/Console/Commands/MirrorToolLogos.php @@ -9,11 +9,11 @@ class MirrorToolLogos extends Command { protected $signature = 'tools:mirror-logos'; - protected $description = 'Queue jobs to download externally-hosted tool logos and serve them from local storage'; + protected $description = 'Queue jobs to resolve and download tool logos (external, missing, or broken) and serve them from local storage'; public function handle(): int { - $tools = Tool::query()->whereNotNull('logo_url')->get()->reject->hasLocalLogo(); + $tools = Tool::query()->get()->reject->hasLocalLogo(); $tools->each->queueLogoMirrorIfNeeded(); diff --git a/app/Jobs/MirrorToolLogo.php b/app/Jobs/MirrorToolLogo.php index fba1d3b..0021d81 100644 --- a/app/Jobs/MirrorToolLogo.php +++ b/app/Jobs/MirrorToolLogo.php @@ -3,9 +3,11 @@ namespace App\Jobs; use App\Models\Tool; +use App\Support\FaviconResolver; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Http\Client\Response; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; @@ -16,20 +18,25 @@ class MirrorToolLogo implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public function __construct(public Tool $tool, public string $sourceUrl) {} + public function __construct(public Tool $tool) {} - public function handle(): void + public function handle(FaviconResolver $resolver): void { - // The tool may have been re-saved with a different logo_url (or one - // already mirrored) since this job was queued; only proceed if it's - // still pointing at the URL we were asked to mirror. - if ($this->tool->fresh()?->logo_url !== $this->sourceUrl) { + $tool = $this->tool->fresh(); + + // The tool may have been deleted, or already mirrored by an earlier + // run of this same job, since this was queued. + if (! $tool || $tool->hasLocalLogo()) { return; } - $response = Http::timeout(10)->get($this->sourceUrl); + // logo_url is a hint, not a guarantee -- it's often a guessed + // `{domain}/favicon.ico` that 404s or isn't actually an image. + // Fall back to properly resolving the site's real favicon. + $response = ($tool->logo_url ? $this->fetchImage($tool->logo_url) : null) + ?? $this->fetchImage($resolver->resolve($tool->url)); - if (! $response->successful() || ! str_starts_with((string) $response->header('Content-Type'), 'image/')) { + if (! $response) { return; } @@ -38,13 +45,30 @@ class MirrorToolLogo implements ShouldQueue 'image/jpeg' => 'jpg', 'image/webp' => 'webp', 'image/x-icon', 'image/vnd.microsoft.icon' => 'ico', - default => 'svg' === pathinfo(parse_url($this->sourceUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ? 'svg' : 'png', + default => 'png', }; - $path = "tools/{$this->tool->slug}-".Str::random(8).".{$extension}"; + $path = "tools/{$tool->slug}-".Str::random(8).".{$extension}"; Storage::disk('public')->put($path, $response->body()); - $this->tool->update(['logo_url' => Storage::disk('public')->url($path)]); + $tool->update(['logo_url' => Storage::disk('public')->url($path)]); + } + + private function fetchImage(?string $url): ?Response + { + if (! $url) { + return null; + } + + try { + $response = Http::timeout(10)->get($url); + } catch (\Throwable) { + return null; + } + + return $response->successful() && str_starts_with((string) $response->header('Content-Type'), 'image/') + ? $response + : null; } } diff --git a/app/Models/Post.php b/app/Models/Post.php index 06caba8..b11a595 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\PostStatus; +use App\Support\ToolLinker; use Database\Factories\PostFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\RouteKey; @@ -88,12 +89,14 @@ class Post extends Model // The page template already renders the post title as an

. If the // Markdown body also opens with a top-level heading repeating that // title, drop it so the page doesn't ship two identical

s. - return preg_replace_callback( + $html = preg_replace_callback( '/^\s*

(.*?)<\/h1>/is', fn ($matches) => trim(strip_tags($matches[1])) === trim($this->title) ? '' : $matches[0], $html, limit: 1 ); + + return (new ToolLinker)->link($html); }); } diff --git a/app/Models/Tool.php b/app/Models/Tool.php index 71608db..42415f8 100644 --- a/app/Models/Tool.php +++ b/app/Models/Tool.php @@ -60,8 +60,8 @@ class Tool extends Model public function queueLogoMirrorIfNeeded(): void { - if ($this->logo_url && ! $this->hasLocalLogo()) { - MirrorToolLogo::dispatch($this, $this->logo_url); + if (! $this->hasLocalLogo()) { + MirrorToolLogo::dispatch($this); } } diff --git a/app/Support/FaviconResolver.php b/app/Support/FaviconResolver.php new file mode 100644 index 0000000..b6e7e8f --- /dev/null +++ b/app/Support/FaviconResolver.php @@ -0,0 +1,107 @@ + + * tag. This tries progressively more reliable sources before giving up. + */ +class FaviconResolver +{ + public function resolve(string $siteUrl): ?string + { + return $this->fromWellKnownPath($siteUrl) + ?? $this->fromHtmlLinkTag($siteUrl) + ?? $this->fromGoogleFallback($siteUrl); + } + + private function fromWellKnownPath(string $siteUrl): ?string + { + $candidate = rtrim($siteUrl, '/').'/favicon.ico'; + + return $this->isImage($candidate) ? $candidate : null; + } + + private function fromHtmlLinkTag(string $siteUrl): ?string + { + $response = $this->safeGet($siteUrl); + + if (! $response || ! $response->successful()) { + return null; + } + + if (! preg_match_all( + '/]*rel=["\'](?:shortcut icon|icon|apple-touch-icon)["\'][^>]*>/i', + $response->body(), + $tags + )) { + return null; + } + + foreach ($tags[0] as $tag) { + if (! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $hrefMatch)) { + continue; + } + + $absolute = $this->toAbsoluteUrl($hrefMatch[1], $siteUrl); + + if ($absolute && $this->isImage($absolute)) { + return $absolute; + } + } + + return null; + } + + /** + * Google's favicon service resolves almost anything (it does its own + * multi-source lookup), making it a reliable last resort rather than + * leaving the tool with no logo at all. + */ + private function fromGoogleFallback(string $siteUrl): string + { + return 'https://www.google.com/s2/favicons?sz=128&domain_url='.urlencode($siteUrl); + } + + private function toAbsoluteUrl(string $href, string $base): ?string + { + if (Str::startsWith($href, ['http://', 'https://'])) { + return $href; + } + + $parsed = parse_url($base); + + if (! $parsed || ! isset($parsed['scheme'], $parsed['host'])) { + return null; + } + + if (Str::startsWith($href, '//')) { + return "{$parsed['scheme']}:{$href}"; + } + + return "{$parsed['scheme']}://{$parsed['host']}/".ltrim($href, '/'); + } + + private function isImage(string $url): bool + { + $response = $this->safeGet($url); + + return (bool) $response?->successful() + && str_starts_with((string) $response->header('Content-Type'), 'image/'); + } + + private function safeGet(string $url): ?Response + { + try { + return Http::timeout(6)->get($url); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Support/ToolLinker.php b/app/Support/ToolLinker.php new file mode 100644 index 0000000..af056b7 --- /dev/null +++ b/app/Support/ToolLinker.php @@ -0,0 +1,111 @@ +get(['id', 'name', 'slug']) + ->sortByDesc(fn (Tool $tool) => mb_strlen($tool->name)) + ->values(); + + if ($tools->isEmpty()) { + return $html; + } + + $document = new DOMDocument(); + libxml_use_internal_errors(true); + $document->loadHTML( + '
'.$html.'
', + LIBXML_NOERROR | LIBXML_NOWARNING + ); + libxml_clear_errors(); + + $xpath = new DOMXPath($document); + $textNodes = $xpath->query( + '//text()[not(ancestor::a) and not(ancestor::code) and not(ancestor::pre) '. + 'and not(ancestor::script) and not(ancestor::style)]' + ); + + $alreadyLinked = []; + + foreach ($textNodes as $node) { + /** @var DOMText $node */ + foreach ($tools as $tool) { + if (isset($alreadyLinked[$tool->id])) { + continue; + } + + $pattern = '/\b'.preg_quote($tool->name, '/').'\b/ui'; + + if (! preg_match($pattern, $node->wholeText, $match, PREG_OFFSET_CAPTURE)) { + continue; + } + + $this->wrapMatchInLink($document, $node, $match[0], $tool->slug); + $alreadyLinked[$tool->id] = true; + + // The node has been split and detached from the document; + // move on to the next original text node. + break; + } + } + + $wrapper = $document->getElementsByTagName('div')->item(0); + $result = ''; + + foreach (iterator_to_array($wrapper->childNodes) as $child) { + $result .= $document->saveHTML($child); + } + + return $result; + } + + /** + * @param array{0: string, 1: int} $match [matched text, byte offset] from PREG_OFFSET_CAPTURE + */ + private function wrapMatchInLink(DOMDocument $document, DOMText $node, array $match, string $slug): void + { + [$matchedText, $offset] = $match; + + // PREG_OFFSET_CAPTURE offsets are byte offsets, so slice with + // substr() (byte-based), not mb_substr() -- the offsets are still + // valid UTF-8 boundaries since the /u match itself respects them. + $before = substr($node->wholeText, 0, $offset); + $after = substr($node->wholeText, $offset + strlen($matchedText)); + + $link = $document->createElement('a'); + $link->setAttribute('href', "/tools/{$slug}"); + $link->appendChild($document->createTextNode($matchedText)); + + $parent = $node->parentNode; + + if ($before !== '') { + $parent->insertBefore($document->createTextNode($before), $node); + } + + $parent->insertBefore($link, $node); + + if ($after !== '') { + $parent->insertBefore($document->createTextNode($after), $node); + } + + $parent->removeChild($node); + } +}