internal linking
This commit is contained in:
@@ -9,11 +9,11 @@ class MirrorToolLogos extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'tools:mirror-logos';
|
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
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$tools = Tool::query()->whereNotNull('logo_url')->get()->reject->hasLocalLogo();
|
$tools = Tool::query()->get()->reject->hasLocalLogo();
|
||||||
|
|
||||||
$tools->each->queueLogoMirrorIfNeeded();
|
$tools->each->queueLogoMirrorIfNeeded();
|
||||||
|
|
||||||
|
|||||||
+35
-11
@@ -3,9 +3,11 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\Tool;
|
use App\Models\Tool;
|
||||||
|
use App\Support\FaviconResolver;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
@@ -16,20 +18,25 @@ class MirrorToolLogo implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
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
|
$tool = $this->tool->fresh();
|
||||||
// already mirrored) since this job was queued; only proceed if it's
|
|
||||||
// still pointing at the URL we were asked to mirror.
|
// The tool may have been deleted, or already mirrored by an earlier
|
||||||
if ($this->tool->fresh()?->logo_url !== $this->sourceUrl) {
|
// run of this same job, since this was queued.
|
||||||
|
if (! $tool || $tool->hasLocalLogo()) {
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +45,30 @@ class MirrorToolLogo implements ShouldQueue
|
|||||||
'image/jpeg' => 'jpg',
|
'image/jpeg' => 'jpg',
|
||||||
'image/webp' => 'webp',
|
'image/webp' => 'webp',
|
||||||
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
|
'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());
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\PostStatus;
|
use App\Enums\PostStatus;
|
||||||
|
use App\Support\ToolLinker;
|
||||||
use Database\Factories\PostFactory;
|
use Database\Factories\PostFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
||||||
@@ -88,12 +89,14 @@ class Post extends Model
|
|||||||
// The page template already renders the post title as an <h1>. If the
|
// The page template already renders the post title as an <h1>. If the
|
||||||
// Markdown body also opens with a top-level heading repeating that
|
// Markdown body also opens with a top-level heading repeating that
|
||||||
// title, drop it so the page doesn't ship two identical <h1>s.
|
// title, drop it so the page doesn't ship two identical <h1>s.
|
||||||
return preg_replace_callback(
|
$html = preg_replace_callback(
|
||||||
'/^\s*<h1>(.*?)<\/h1>/is',
|
'/^\s*<h1>(.*?)<\/h1>/is',
|
||||||
fn ($matches) => trim(strip_tags($matches[1])) === trim($this->title) ? '' : $matches[0],
|
fn ($matches) => trim(strip_tags($matches[1])) === trim($this->title) ? '' : $matches[0],
|
||||||
$html,
|
$html,
|
||||||
limit: 1
|
limit: 1
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (new ToolLinker)->link($html);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -60,8 +60,8 @@ class Tool extends Model
|
|||||||
|
|
||||||
public function queueLogoMirrorIfNeeded(): void
|
public function queueLogoMirrorIfNeeded(): void
|
||||||
{
|
{
|
||||||
if ($this->logo_url && ! $this->hasLocalLogo()) {
|
if (! $this->hasLocalLogo()) {
|
||||||
MirrorToolLogo::dispatch($this, $this->logo_url);
|
MirrorToolLogo::dispatch($this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a real, working favicon/icon URL for a site. `{domain}/favicon.ico`
|
||||||
|
* is a guess, not a guarantee -- plenty of sites serve their icon from a
|
||||||
|
* hashed asset path, an SVG, or declare it only via a <link rel="icon">
|
||||||
|
* 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(
|
||||||
|
'/<link\b[^>]*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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Tool;
|
||||||
|
use DOMDocument;
|
||||||
|
use DOMText;
|
||||||
|
use DOMXPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-links the first mention of each approved tool's name in rendered
|
||||||
|
* post HTML to that tool's page, so "Top 10" style posts (and any post
|
||||||
|
* that happens to name-drop a tool we list) link back into the directory
|
||||||
|
* without editors having to hand-add the links.
|
||||||
|
*/
|
||||||
|
class ToolLinker
|
||||||
|
{
|
||||||
|
public function link(string $html): string
|
||||||
|
{
|
||||||
|
if (trim($html) === '') {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tools = Tool::approved()->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(
|
||||||
|
'<?xml encoding="utf-8"?><div>'.$html.'</div>',
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user