- Full gg.deals-style UI: deal feed with filters, game pages with price history charts, rankings, vouchers, news, prepaids, subscriptions, bundles, franchises, wishlist, API docs - Real data crawlers: * gg:crawl (Steam Store API + GOG Catalog + Epic free games) * gg:catalog (252K game slugs from gg.deals sitemaps) * gg:ggdeals (per-game prices via FlareSolverr session, JSON-LD parsing) * gg:seed / gg:crawl-covers (demo seed + Steam CDN cover art) - 24/7 self-updating scheduler (scripts/scheduler_loop.php, lockfile-protected) - Tailwind v4 dark/light theme, Alpine.js, Chart.js
185 lines
6.9 KiB
PHP
185 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Commands;
|
|
|
|
use App\Libraries\Mongo;
|
|
use CodeIgniter\CLI\BaseCommand;
|
|
use CodeIgniter\CLI\CLI;
|
|
|
|
/**
|
|
* Crawls real cover art from the Steam CDN and updates the games collection.
|
|
*
|
|
* Usage: php spark gg:crawl-covers
|
|
*
|
|
* Cover sources (in order of preference):
|
|
* 1. Steam store header (460x215) — https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/header.jpg
|
|
* 2. Steam capsule (616x353) — https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/capsule_616x353.jpg
|
|
* 3. Existing SVG placeholder (kept)
|
|
*/
|
|
class CrawlCovers extends BaseCommand
|
|
{
|
|
protected $group = 'GGDeals';
|
|
protected $name = 'gg:crawl-covers';
|
|
protected $description = 'Crawls real cover art from the Steam CDN and updates games.';
|
|
protected $usage = 'gg:crawl-covers';
|
|
|
|
/** slug => steam appid */
|
|
public const APP_IDS = [
|
|
'age-of-empires-iv' => 1466860,
|
|
'alan-wake-2' => 2379780,
|
|
'balan-wonderworld' => 1336480,
|
|
'baldurs-gate-3' => 1086940,
|
|
'cities-skylines-ii' => 949230,
|
|
'control' => 870780,
|
|
'counter-strike-2' => 730,
|
|
'cuphead' => 268910,
|
|
'cyberpunk-2077' => 1091500,
|
|
'dark-souls-iii' => 374320,
|
|
'dead-cells' => 588650,
|
|
'death-stranding' => 1190460,
|
|
'deep-rock-galactic' => 548430,
|
|
'diablo-iv' => 2344520,
|
|
'disco-elysium' => 632470,
|
|
'dishonored-2' => 403640,
|
|
'divinity-original-sin-2' => 435150,
|
|
'doom-eternal' => 782330,
|
|
'ea-sports-fc-24' => 2195250,
|
|
'efootball-2024' => 2349380,
|
|
'elden-ring' => 1245620,
|
|
'enshrouded' => 1203620,
|
|
'factorio' => 427520,
|
|
'forza-horizon-5' => 1551360,
|
|
'god-of-war' => 1593500,
|
|
'grand-theft-auto-v' => 271590,
|
|
'hades-ii' => 1145350,
|
|
'helldivers-2' => 553850,
|
|
'hogwarts-legacy' => 990080,
|
|
'hollow-knight' => 367520,
|
|
'horizon-forbidden-west' => 2420110,
|
|
'it-takes-two' => 1426210,
|
|
'kingdom-come-deliverance' => 379430,
|
|
'lethal-company' => 1966720,
|
|
'manor-lords' => 1363080,
|
|
'marvels-spider-man-2' => 2651290,
|
|
'marvels-spider-man-remastered' => 1817070,
|
|
'metro-exodus' => 412020,
|
|
'microsoft-flight-simulator' => 1250410,
|
|
'monster-hunter-rise' => 1446780,
|
|
'nier-automata' => 524220,
|
|
'ori-and-the-will-of-the-wisps' => 1057090,
|
|
'outer-wilds' => 753640,
|
|
'palworld' => 1623730,
|
|
'persona-5-royal' => 1687950,
|
|
'ready-or-not' => 1144200,
|
|
'red-dead-redemption-2' => 1174180,
|
|
'resident-evil-4' => 2050650,
|
|
'rimworld' => 294100,
|
|
'sekiro-shadows-die-twice' => 814380,
|
|
'slay-the-spire' => 646570,
|
|
'sonic-frontiers' => 1237320,
|
|
'stardew-valley' => 413150,
|
|
'starfield' => 1716740,
|
|
'street-fighter-6' => 1364780,
|
|
'terraria' => 105600,
|
|
'the-last-of-us-part-i' => 1888930,
|
|
'the-witcher-3-wild-hunt' => 292030,
|
|
'total-war-warhammer-iii' => 1142710,
|
|
'valheim' => 892970,
|
|
'warhammer-40000-darktide' => 1361210,
|
|
];
|
|
|
|
private const CDN = 'https://cdn.cloudflare.steamstatic.com/steam/apps/%d/';
|
|
private const OUT_DIR = WRITEPATH . '../public/img/covers/';
|
|
|
|
private const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
|
|
|
|
public function run(array $params): void
|
|
{
|
|
$games = Mongo::collection('games')->find()->toArray();
|
|
|
|
if (! is_dir(self::OUT_DIR)) {
|
|
mkdir(self::OUT_DIR, 0777, true);
|
|
}
|
|
|
|
$ok = 0;
|
|
$fail = [];
|
|
$skipped = 0;
|
|
|
|
foreach ($games as $game) {
|
|
$slug = $game['slug'];
|
|
$appid = self::APP_IDS[$slug] ?? null;
|
|
|
|
if ($appid === null) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
$filename = $slug . '.jpg';
|
|
$dest = self::OUT_DIR . $filename;
|
|
|
|
$downloaded = $this->download(
|
|
sprintf(self::CDN, $appid) . 'header.jpg',
|
|
$dest
|
|
);
|
|
|
|
if (! $downloaded) {
|
|
// try the capsule variant as a fallback
|
|
$downloaded = $this->download(
|
|
sprintf(self::CDN, $appid) . 'capsule_616x353.jpg',
|
|
$dest
|
|
);
|
|
}
|
|
|
|
if ($downloaded) {
|
|
Mongo::collection('games')->updateOne(
|
|
['slug' => $slug],
|
|
['$set' => ['cover' => ['file' => '/img/covers/' . $filename, 'from' => '#000000', 'to' => '#000000']]]
|
|
);
|
|
$ok++;
|
|
CLI::write(' ✓ ' . $slug . ' (' . $appid . ')', 'green');
|
|
} else {
|
|
$fail[] = $slug;
|
|
CLI::write(' ✗ ' . $slug . ' (' . $appid . ')', 'red');
|
|
}
|
|
|
|
usleep(120000); // be gentle with the CDN
|
|
}
|
|
|
|
CLI::write('');
|
|
CLI::write(sprintf('Downloaded %d covers, skipped %d (no appid), failed %d.', $ok, $skipped, count($fail)), 'cyan');
|
|
if ($fail !== []) {
|
|
CLI::write('Failed: ' . implode(', ', $fail), 'yellow');
|
|
}
|
|
}
|
|
|
|
private function download(string $url, string $dest): bool
|
|
{
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 20,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_USERAGENT => self::UA,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
]);
|
|
|
|
$body = curl_exec($ch);
|
|
$code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
|
curl_close($ch);
|
|
|
|
if ($code !== 200 || $body === false || strlen($body) < 1000) {
|
|
return false;
|
|
}
|
|
|
|
// ensure it's an image
|
|
if (! str_starts_with($type, 'image/')) {
|
|
return false;
|
|
}
|
|
|
|
return file_put_contents($dest, $body) !== false;
|
|
}
|
|
}
|