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
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user