seo fixes

This commit is contained in:
2026-08-09 12:43:21 +03:00
parent bed4c48900
commit 443ff374c3
5 changed files with 138 additions and 2 deletions
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use App\Models\Tool;
use Illuminate\Console\Command;
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';
public function handle(): int
{
$tools = Tool::query()->whereNotNull('logo_url')->get()->reject->hasLocalLogo();
$tools->each->queueLogoMirrorIfNeeded();
$this->info("Queued {$tools->count()} tool logo(s) for mirroring.");
return self::SUCCESS;
}
}
+15 -1
View File
@@ -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();
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Jobs;
use App\Models\Tool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MirrorToolLogo implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public Tool $tool, public string $sourceUrl) {}
public function handle(): 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) {
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)]);
}
}
+48
View File
@@ -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;
});
}
}