Files
SkinbaseNova/app/Console/Commands/BuildSitemapsCommand.php

298 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\SitemapImage;
use App\Services\Sitemaps\SitemapIndexItem;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\ThumbnailPresenter;
use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class BuildSitemapsCommand extends Command
{
protected $signature = 'skinbase:sitemaps:build
{--only=* : Limit to specific sitemap families (comma or space separated)}
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}
{--progress : Show a progress bar tracking processed URLs for each family}';
protected $description = 'Build all sitemaps and write them as static .xml files to public/.';
public function handle(SitemapBuildService $build): int
{
$totalStart = microtime(true);
$families = $this->selectedFamilies($build);
if ($families === []) {
$this->error('No valid sitemap families selected.');
return self::INVALID;
}
$diskName = (string) ($this->option('disk') ?: config('sitemaps.static_publish.disk', 'sitemaps_public'));
$disk = Storage::disk($diskName);
$written = 0;
$failed = 0;
$this->info('Disk: ' . $diskName);
$this->info('Families: ' . implode(', ', $families));
$this->newLine();
// ── Root sitemap index ────────────────────────────────────────────
$t = microtime(true);
$this->line(' Building sitemap index…');
$index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
$index['url_count'],
microtime(true) - $t,
));
// ── Per-family documents ──────────────────────────────────────────
foreach ($families as $family) {
$familyStart = microtime(true);
$this->newLine();
if ($family === 'artworks') {
// Direct MySQL path — no cursor-scan shard window computation
[$shardNames, $fw, $ff] = $this->buildArtworksDirect($disk, (bool) $this->option('progress'));
$written += $fw;
$failed += $ff;
$this->line(sprintf(
' <fg=cyan>artworks</> done %d file(s) <comment>%.3fs</comment>',
$fw,
microtime(true) - $familyStart,
));
continue;
}
$names = $build->canonicalDocumentNamesForFamily($family);
$this->line(sprintf(' <fg=cyan>%s</> (%d document(s))', $family, count($names)));
foreach ($names as $documentName) {
$t = microtime(true);
$this->line(sprintf(' Building %s…', $documentName));
$built = $build->buildNamed($documentName, force: true, persist: false);
if ($built === null) {
$this->line(sprintf(' <comment></comment> %s <fg=red>SKIPPED</> (builder returned null)', $documentName));
$failed++;
continue;
}
$disk->put('sitemaps/' . $documentName . '.xml', $built['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
$documentName . '.xml',
$built['url_count'] ?? 0,
microtime(true) - $t,
));
}
$this->line(sprintf(
' <fg=cyan>%s</> done <comment>%.3fs</comment>',
$family,
microtime(true) - $familyStart,
));
}
// ── Summary ───────────────────────────────────────────────────────
$this->newLine();
$this->info(sprintf(
'Done: %d file(s) written, %d failed total <comment>%.3fs</comment>',
$written,
$failed,
microtime(true) - $totalStart,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Stream artworks directly from MySQL using chunkById — avoids cursor-scan shard windows.
*
* @return array{0: list<string>, 1: int, 2: int} [shardNames, written, failed]
*/
private function buildArtworksDirect(Filesystem $disk, bool $showProgress = false): array
{
$chunkSize = max(1, (int) config('sitemaps.shards.artworks.size', 10_000));
$padLen = max(1, (int) config('sitemaps.shards.zero_pad_length', 4));
$shardNum = 0;
$shardNames = [];
$written = 0;
$failed = 0;
$baseQuery = Artwork::query()
->public()
->published();
$total = $showProgress ? (clone $baseQuery)->count() : null;
$this->line(sprintf(
' <fg=cyan>artworks</> (chunk size: %d%s)',
$chunkSize,
$total !== null ? ', total: ' . number_format($total) : '',
));
$bar = null;
if ($total !== null) {
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% shard %message% elapsed: %elapsed:6s% mem: %memory:6s%');
$bar->setMessage('—');
$bar->start();
$this->newLine();
}
$baseQuery
->select(['id', 'slug', 'title', 'updated_at', 'published_at', 'created_at', 'hash', 'file_path', 'file_name'])
->orderBy('id')
->chunkById($chunkSize, function ($artworks) use ($disk, $padLen, $bar, &$shardNum, &$shardNames, &$written, &$failed): void {
$shardNum++;
$t = microtime(true);
$name = 'artworks-' . str_pad((string) $shardNum, $padLen, '0', STR_PAD_LEFT);
if ($bar !== null) {
$bar->setMessage($name);
} else {
$this->line(sprintf(' Building %s (%d rows)…', $name, $artworks->count()));
}
/** @var list<SitemapUrl> $items */
$items = $artworks
->map(fn (Artwork $artwork): ?SitemapUrl => $this->artworkSitemapUrl($artwork))
->filter()
->values()
->all();
$xml = view('sitemaps.urlset', [
'items' => $items,
'hasImages' => collect($items)->contains(fn (SitemapUrl $item): bool => $item->images !== []),
])->render();
if (! $disk->put('sitemaps/' . $name . '.xml', $xml)) {
if ($bar !== null) {
$bar->advance($artworks->count());
$this->newLine();
}
$this->line(sprintf(' <comment></comment> %s <fg=red>WRITE FAILED</>', $name . '.xml'));
$failed++;
return;
}
$shardNames[] = $name;
$written++;
if ($bar !== null) {
$bar->advance($artworks->count());
} else {
$this->line(sprintf(
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
$name . '.xml',
count($items),
microtime(true) - $t,
));
}
});
if ($bar !== null) {
$bar->setMessage('done');
$bar->finish();
$this->newLine();
}
if ($shardNames === []) {
return [[], 0, $failed];
}
// Write artworks-index.xml when there are multiple shards (matches SitemapShardService behaviour)
if (count($shardNames) > 1) {
$t = microtime(true);
$indexItems = array_map(
fn (string $n): SitemapIndexItem => new SitemapIndexItem(url('/sitemaps/' . $n . '.xml')),
$shardNames,
);
$indexXml = view('sitemaps.index', ['items' => $indexItems])->render();
$disk->put('sitemaps/artworks-index.xml', $indexXml);
$written++;
$this->line(sprintf(
' <info>✔</info> artworks-index.xml %d shards <comment>%.3fs</comment>',
count($shardNames),
microtime(true) - $t,
));
}
return [$shardNames, $written, $failed];
}
private function artworkSitemapUrl(Artwork $artwork): ?SitemapUrl
{
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($slug === '') {
$slug = (string) $artwork->id;
}
$preview = ThumbnailPresenter::present($artwork, 'xl');
$images = [];
if (! empty($preview['url'])) {
$images[] = new SitemapImage((string) $preview['url'], $artwork->title ?: null);
}
$timestamps = array_filter(array_map(
static fn (mixed $v): ?Carbon => $v instanceof Carbon ? $v : (is_string($v) ? Carbon::parse($v) : null),
[$artwork->updated_at, $artwork->published_at, $artwork->created_at],
));
usort($timestamps, static fn (Carbon $a, Carbon $b): int => $b->timestamp <=> $a->timestamp);
return new SitemapUrl(
route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
$timestamps[0] ?? null,
$images,
);
}
/**
* @return list<string>
*/
private function selectedFamilies(SitemapBuildService $build): array
{
$only = [];
foreach ((array) $this->option('only') as $value) {
foreach (explode(',', (string) $value) as $family) {
$normalized = trim($family);
if ($normalized !== '') {
$only[] = $normalized;
}
}
}
$enabled = $build->enabledFamilies();
if ($only === []) {
return $enabled;
}
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
}
}