diff --git a/app/Console/Commands/MirrorToolLogos.php b/app/Console/Commands/MirrorToolLogos.php new file mode 100644 index 0000000..ae6342c --- /dev/null +++ b/app/Console/Commands/MirrorToolLogos.php @@ -0,0 +1,24 @@ +whereNotNull('logo_url')->get()->reject->hasLocalLogo(); + + $tools->each->queueLogoMirrorIfNeeded(); + + $this->info("Queued {$tools->count()} tool logo(s) for mirroring."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/ToolController.php b/app/Http/Controllers/ToolController.php index b087cd0..eab8476 100644 --- a/app/Http/Controllers/ToolController.php +++ b/app/Http/Controllers/ToolController.php @@ -6,6 +6,7 @@ use App\Models\Tool; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Str; class ToolController extends Controller { @@ -72,7 +73,7 @@ class ToolController extends Controller 'tool' => $tool, 'related' => $related, 'title' => $tool->name.' — AI Tools — '.config('app.name'), - 'description' => str($tool->description)->limit(155, '')->toString(), + 'description' => $this->metaDescription($tool->description), 'canonical' => route('tools.show', $tool), 'image' => $tool->logo_url, ]); @@ -86,4 +87,17 @@ class ToolController extends Controller return redirect()->away($tool->url); } + + /** + * Truncate to ~155 chars without cutting a word in half. Google appends + * its own ellipsis when it truncates in search results, so we don't add one. + */ + private function metaDescription(string $description, int $limit = 155): string + { + if (Str::length($description) <= $limit) { + return $description; + } + + return Str::of($description)->limit($limit, '')->beforeLast(' ')->toString(); + } } diff --git a/app/Jobs/MirrorToolLogo.php b/app/Jobs/MirrorToolLogo.php new file mode 100644 index 0000000..fba1d3b --- /dev/null +++ b/app/Jobs/MirrorToolLogo.php @@ -0,0 +1,50 @@ +tool->fresh()?->logo_url !== $this->sourceUrl) { + return; + } + + $response = Http::timeout(10)->get($this->sourceUrl); + + if (! $response->successful() || ! str_starts_with((string) $response->header('Content-Type'), 'image/')) { + return; + } + + $extension = match ($response->header('Content-Type')) { + 'image/png' => 'png', + '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', + }; + + $path = "tools/{$this->tool->slug}-".Str::random(8).".{$extension}"; + + Storage::disk('public')->put($path, $response->body()); + + $this->tool->update(['logo_url' => Storage::disk('public')->url($path)]); + } +} diff --git a/app/Models/Tool.php b/app/Models/Tool.php index a9c783b..71608db 100644 --- a/app/Models/Tool.php +++ b/app/Models/Tool.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Jobs\MirrorToolLogo; use Database\Factories\ToolFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\RouteKey; @@ -40,6 +41,28 @@ class Tool extends Model $tool->slug = Str::slug($tool->name); } }); + + static::created(function (Tool $tool) { + $tool->queueLogoMirrorIfNeeded(); + }); + + static::updated(function (Tool $tool) { + if ($tool->wasChanged('logo_url')) { + $tool->queueLogoMirrorIfNeeded(); + } + }); + } + + public function hasLocalLogo(): bool + { + return str_starts_with((string) $this->logo_url, rtrim(config('app.url'), '/').'/storage/tools/'); + } + + public function queueLogoMirrorIfNeeded(): void + { + if ($this->logo_url && ! $this->hasLocalLogo()) { + MirrorToolLogo::dispatch($this, $this->logo_url); + } } /** @@ -63,4 +86,29 @@ class Tool extends Model { return Attribute::get(fn () => config("tools.categories.{$this->category}", $this->category)); } + + /** + * Structured-data-safe price, parsed from the free-text `pricing` field + * (e.g. "Free", "$29/month"). schema.org's Offer.price expects a plain + * number with a currency, so values we can't parse into that (e.g. + * "Freemium", "Contact for pricing") are left out rather than guessed. + */ + protected function offerData(): Attribute + { + return Attribute::get(function () { + if (! $this->pricing) { + return null; + } + + if (strcasecmp($this->pricing, 'free') === 0) { + return ['price' => '0', 'priceCurrency' => 'USD']; + } + + if (preg_match('/^\$(\d+(?:\.\d+)?)/', $this->pricing, $matches)) { + return ['price' => $matches[1], 'priceCurrency' => 'USD']; + } + + return null; + }); + } } diff --git a/resources/views/tools/show.blade.php b/resources/views/tools/show.blade.php index 59260c6..e4c1ef7 100644 --- a/resources/views/tools/show.blade.php +++ b/resources/views/tools/show.blade.php @@ -9,7 +9,7 @@ 'url' => $tool->url, ...($tool->logo_url ? ['image' => $tool->logo_url] : []), 'applicationCategory' => $tool->category_label, - ...($tool->pricing ? ['offers' => ['@type' => 'Offer', 'price' => $tool->pricing]] : []), + ...($tool->offer_data ? ['offers' => ['@type' => 'Offer', ...$tool->offer_data]] : []), ]) !!} @endpush