298 lines
11 KiB
PHP
298 lines
11 KiB
PHP
<?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)));
|
||
}
|
||
} |