Files
deepseek fff7a4b9b9 GG.deals clone: CodeIgniter 4 + MongoDB game price tracker
- 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
2026-08-23 07:06:02 +00:00

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;
}
}