seo fixes
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Models\Tool;
|
|||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ToolController extends Controller
|
class ToolController extends Controller
|
||||||
{
|
{
|
||||||
@@ -72,7 +73,7 @@ class ToolController extends Controller
|
|||||||
'tool' => $tool,
|
'tool' => $tool,
|
||||||
'related' => $related,
|
'related' => $related,
|
||||||
'title' => $tool->name.' — AI Tools — '.config('app.name'),
|
'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),
|
'canonical' => route('tools.show', $tool),
|
||||||
'image' => $tool->logo_url,
|
'image' => $tool->logo_url,
|
||||||
]);
|
]);
|
||||||
@@ -86,4 +87,17 @@ class ToolController extends Controller
|
|||||||
|
|
||||||
return redirect()->away($tool->url);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Jobs\MirrorToolLogo;
|
||||||
use Database\Factories\ToolFactory;
|
use Database\Factories\ToolFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
||||||
@@ -40,6 +41,28 @@ class Tool extends Model
|
|||||||
$tool->slug = Str::slug($tool->name);
|
$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));
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
'url' => $tool->url,
|
'url' => $tool->url,
|
||||||
...($tool->logo_url ? ['image' => $tool->logo_url] : []),
|
...($tool->logo_url ? ['image' => $tool->logo_url] : []),
|
||||||
'applicationCategory' => $tool->category_label,
|
'applicationCategory' => $tool->category_label,
|
||||||
...($tool->pricing ? ['offers' => ['@type' => 'Offer', 'price' => $tool->pricing]] : []),
|
...($tool->offer_data ? ['offers' => ['@type' => 'Offer', ...$tool->offer_data]] : []),
|
||||||
]) !!}
|
]) !!}
|
||||||
</script>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
|
|||||||
Reference in New Issue
Block a user