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:
deepseek
2026-08-23 07:06:02 +00:00
commit fff7a4b9b9
146 changed files with 15691 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Mongo;
use App\Models\GameModel;
use CodeIgniter\Controller;
class Api extends Controller
{
public function suggest(): \CodeIgniter\HTTP\ResponseInterface
{
$q = trim((string) service('request')->getGet('q'));
if (mb_strlen($q) < 2) {
return service('response')->setJSON([]);
}
return service('response')->setJSON((new GameModel())->suggest($q));
}
/**
* Game Prices API: lowest price by Steam App ID.
* GET /api/prices?appid=1245620
*/
public function prices(): \CodeIgniter\HTTP\ResponseInterface
{
$appid = (int) service('request')->getGet('appid');
if ($appid <= 0) {
return service('response')->setStatusCode(400)->setJSON([
'error' => 'bad_request',
'message' => 'Missing or invalid "appid" query parameter.',
]);
}
// appid map is in the CrawlCovers command; reuse via a public map.
$game = (new GameModel())->findByAppId($appid);
if ($game === null) {
return service('response')->setStatusCode(404)->setJSON([
'error' => 'not_found',
'message' => "No game found for Steam App ID {$appid}.",
]);
}
return service('response')->setJSON([
'game' => [
'title' => $game['title'],
'slug' => $game['slug'],
'url' => '/game/' . $game['slug'],
'steam_appid' => $appid,
'regular_price' => (float) $game['base_price'],
'best_official' => [
'price' => (float) $game['best_official']['price'],
'store' => $game['best_official']['store'],
'cut' => (int) $game['best_official']['cut'],
],
'best_keyshop' => [
'price' => (float) $game['best_keyshop']['price'],
'store' => $game['best_keyshop']['store'],
'cut' => (int) $game['best_keyshop']['cut'],
],
'all_time_low' => [
'price' => (float) $game['all_time_low']['price'],
'store' => $game['all_time_low']['store'],
'date' => $game['all_time_low']['date'] instanceof \MongoDB\BSON\UTCDateTime
? $game['all_time_low']['date']->toDateTime()->format('Y-m-d')
: null,
],
'active_deals' => (int) $game['active_deals'],
'metacritic' => (int) $game['score'],
],
]);
}
/**
* API documentation page data (regions, endpoints).
*/
public function docs(): string
{
$model = new GameModel();
$data = [
'pageTitle' => 'GG.deals API — Game Deals, Bundles and Prices API',
'stats' => $model->stats(),
];
return view('api', $data);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
/**
* BaseController provides a convenient place for loading components
* and performing functions that are needed by all your controllers.
*
* Extend this class in any new controllers:
* ```
* class Home extends BaseController
* ```
*
* For security, be sure to declare any new methods as protected or private.
*/
abstract class BaseController extends Controller
{
/**
* Be sure to declare properties for any property fetch you initialized.
* The creation of dynamic property is deprecated in PHP 8.2.
*/
// protected $session;
/**
* @return void
*/
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
// Load here all helpers you want to be available in your controllers that extend BaseController.
// Caution: Do not put the this below the parent::initController() call below.
// $this->helpers = ['form', 'url'];
// Caution: Do not edit this line.
parent::initController($request, $response, $logger);
// Preload any models, libraries, etc, here.
// $this->session = service('session');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Mongo;
use App\Models\GameModel;
use App\Models\StoreModel;
use CodeIgniter\Controller;
class Bundle extends Controller
{
public function index(string $slug): string
{
$b = Mongo::collection('bundles')->findOne(['slug' => $slug]);
if ($b === null) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$games = [];
foreach ($b['games'] as $gslug) {
$g = (new GameModel())->findBySlug($gslug);
if ($g !== null) {
$games[] = $g;
}
}
$data = [
'pageTitle' => $b['title'] . ' Bundle — GG.deals',
'bundle' => $b,
'games' => $games,
'stores' => (new StoreModel())->map(),
];
return view('bundle', $data);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Mongo;
use App\Models\GameModel;
use CodeIgniter\Controller;
class Franchise extends Controller
{
public function index(string $slug): string
{
$fr = Mongo::collection('franchises')->findOne(['slug' => $slug]);
if ($fr === null) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$games = [];
foreach ($fr['games'] as $gslug) {
$g = (new GameModel())->findBySlug($gslug);
if ($g !== null) {
$games[] = $g;
}
}
$data = [
'pageTitle' => $fr['name'] . ' Franchise — GG.deals',
'franchise' => $fr,
'games' => $games,
'allFranchises' => Mongo::collection('franchises')->find([], ['sort' => ['name' => 1]])->toArray(),
];
return view('franchise', $data);
}
public function all(): string
{
$list = Mongo::collection('franchises')->find([], ['sort' => ['name' => 1]])->toArray();
$data = [
'pageTitle' => 'Franchises — GG.deals',
'franchises' => $list,
];
return view('franchises', $data);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Mongo;
use App\Models\GameModel;
use App\Models\StoreModel;
use CodeIgniter\Controller;
class Game extends Controller
{
public function show(string $slug): string
{
$model = new GameModel();
$game = $model->findBySlug($slug);
if ($game === null) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$deals = $model->dealsFor($slug);
$history = $model->historyFor($slug);
$similar = $model->similar($game, 8);
$bundles = $model->bundlesFor($slug);
$franchises = $model->franchisesFor($slug);
$subscriptions = Mongo::collection('subscriptions')->find([], ['sort' => ['price' => 1]])->toArray();
$data = [
'pageTitle' => $game['title'] . ' — best deals — GG.deals',
'game' => $game,
'deals' => $deals,
'history' => $history,
'stores' => (new StoreModel())->map(),
'similar' => $similar,
'bundles' => $bundles,
'franchises' => $franchises,
'subscriptions' => $subscriptions,
];
return view('game', $data);
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\GameModel;
use App\Models\StoreModel;
use CodeIgniter\Controller;
class Games extends Controller
{
public function index(string $genre = ''): string
{
$model = new GameModel();
$request = service('request');
// genre map (slug => name)
$genres = [];
foreach ($model->genres() as $g) {
$slug = url_title((string) $g['_id'], '-', true);
$genres[$slug] = ['name' => $g['_id'], 'count' => $g['count']];
}
ksort($genres);
$genreName = '';
if ($genre !== '') {
if (! isset($genres[$genre])) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$genreName = $genres[$genre]['name'];
}
// ---- filters (same shape as the deal feed) ----
$filters = [
'stores' => array_values(array_filter(array_map('strval', (array) $request->getGet('stores')))),
'kind' => (string) ($request->getGet('kind') ?? ''),
'min_price' => $request->getGet('min_price') !== null ? (string) $request->getGet('min_price') : '',
'max_price' => $request->getGet('max_price') !== null ? (string) $request->getGet('max_price') : '',
'min_cut' => (int) ($request->getGet('min_cut') ?? 0),
'hl_only' => (bool) $request->getGet('hl_only'),
'sort' => (string) ($request->getGet('sort') ?? 'trending'),
'q' => trim((string) ($request->getGet('q') ?? '')),
'price_tier' => (string) ($request->getGet('price_tier') ?? ''),
'discount_tier' => (string) ($request->getGet('discount_tier') ?? ''),
'deal_rating' => (string) ($request->getGet('deal_rating') ?? ''),
'release_range' => (string) ($request->getGet('release_range') ?? ''),
'time_range' => (string) ($request->getGet('time_range') ?? ''),
'genre' => $genreName,
'genres' => array_values(array_filter(array_map('strval', (array) $request->getGet('genres')))),
];
$page = max(1, (int) $request->getGet('page'));
$perPage = 20;
$feed = $model->feed($filters, $page, $perPage);
$data = [
'pageTitle' => ($genreName !== '' ? ucwords($genreName) . ' games — ' : '') . 'GG.deals',
'filters' => $filters,
'feed' => $feed['rows'],
'total' => $feed['total'],
'pages' => $feed['pages'],
'page' => $page,
'perPage' => $perPage,
'genres' => $genres,
'activeGenre' => $genre,
'activeGenreName' => $genreName,
'stores' => (new StoreModel())->all(),
'stats' => $model->stats(),
];
return view('games', $data);
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\GameModel;
use App\Models\StoreModel;
use App\Libraries\Mongo;
class Home extends BaseController
{
/**
* Deal presets (mirrors gg.deals /deals/{preset}/ URLs).
*/
private const PRESETS = [
'hot-new-deals' => ['title' => 'Hot New Deals', 'sort' => 'hot', 'preset' => 'hot_new'],
'new-deals' => ['title' => 'New Deals', 'sort' => 'recent', 'preset' => 'new'],
'historical-lows' => ['title' => 'Historical Lows', 'sort' => 'rating', 'preset' => 'hl'],
'ending-soon' => ['title' => 'Ending Soon', 'sort' => 'ending', 'preset' => 'ending'],
'at-least-75-off' => ['title' => 'At least 75% off', 'sort' => 'cut', 'preset' => 'cut75'],
'at-least-85-off' => ['title' => 'At least 85% off', 'sort' => 'cut', 'preset' => 'cut85'],
];
public function index(): string
{
$request = service('request');
// If any filter/search is active → regular deal feed. Otherwise → homepage dashboard.
$hasFilters = $request->getGet('stores') !== null || $request->getGet('kind') !== null
|| $request->getGet('min_price') !== null || $request->getGet('max_price') !== null
|| $request->getGet('min_cut') !== null || $request->getGet('hl_only') !== null
|| $request->getGet('sort') !== null || $request->getGet('price_tier') !== null
|| $request->getGet('discount_tier') !== null || $request->getGet('deal_rating') !== null
|| $request->getGet('release_range') !== null || $request->getGet('q') !== null;
if (! $hasFilters) {
return $this->dashboard();
}
return $this->renderFeed([]);
}
/**
* Plain deal feed (used by /deals) regardless of filters.
*/
public function feedOnly(): string
{
return $this->renderFeed([]);
}
/**
* Homepage dashboard mirroring gg.deals: multiple preset sections.
*/
private function dashboard(): string
{
$model = new GameModel();
$data = [
'pageTitle' => 'GG.deals — Best game deals, prices &amp; discounts',
'popular' => $model->dashboardDeals('best', 8),
'newDeals' => $model->dashboardDeals('new', 8),
'bestDeals' => $model->dashboardDeals('hot', 8),
'histLows' => $model->dashboardDeals('hl', 8),
'endingSoon' => $model->dashboardDeals('ending', 8),
'mostWanted' => $model->dashboardGames('wanted', 8),
'newReleases' => $model->dashboardGames('releases', 8),
'upcoming' => $model->dashboardGames('upcoming', 8),
'news' => $model->recentNews(6),
'prepaids' => Mongo::collection('prepaids')->find([], ['sort' => ['name' => 1], 'limit' => 8])->toArray(),
'stats' => $model->stats(),
'stores' => (new StoreModel())->map(),
'trending' => $model->trending(12),
];
return view('homepage', $data);
}
public function preset(string $name): string
{
if (! isset(self::PRESETS[$name])) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
return $this->renderFeed(self::PRESETS[$name]);
}
private function renderFeed(array $preset): string
{
$request = service('request');
$filters = [
'stores' => array_values(array_filter(array_map('strval', (array) $request->getGet('stores')))),
'kind' => (string) ($request->getGet('kind') ?? ''),
'min_price' => $request->getGet('min_price') !== null ? (string) $request->getGet('min_price') : '',
'max_price' => $request->getGet('max_price') !== null ? (string) $request->getGet('max_price') : '',
'min_cut' => (int) ($request->getGet('min_cut') ?? 0),
'hl_only' => (bool) $request->getGet('hl_only'),
'sort' => (string) ($request->getGet('sort') ?? ($preset['sort'] ?? 'trending')),
'q' => trim((string) ($request->getGet('q') ?? '')),
'price_tier'=> (string) ($request->getGet('price_tier') ?? ''),
'discount_tier' => (string) ($request->getGet('discount_tier') ?? ''),
'deal_rating' => (string) ($request->getGet('deal_rating') ?? ''),
'release_range' => (string) ($request->getGet('release_range') ?? ''),
'time_range' => (string) ($request->getGet('time_range') ?? ''),
'view' => (string) ($request->getGet('view') ?? 'sidebar'),
'wishlist' => array_values(array_filter(array_map('strval', (array) $request->getGet('wishlist')))),
];
$filters['preset'] = $preset['preset'] ?? '';
$filters['preset_name'] = $preset['title'] ?? '';
$page = max(1, (int) $request->getGet('page'));
$perPage = 20;
$model = new GameModel();
$feed = $model->feed($filters, $page, $perPage);
$data = [
'pageTitle' => ($filters['preset_name'] !== '' ? $filters['preset_name'] . ' — ' : '') . 'Best PC game deals — GG.deals',
'filters' => $filters,
'feed' => $feed['rows'],
'total' => $feed['total'],
'pages' => $feed['pages'],
'page' => $page,
'perPage' => $perPage,
'trending' => $model->trending(12),
'stores' => (new StoreModel())->all(),
'stats' => $model->stats(),
'presets' => self::PRESETS,
'activePreset' => $filters['preset_name'],
];
return view('home', $data);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Mongo;
use CodeIgniter\Controller;
class News extends Controller
{
private const CATEGORIES = [
'' => 'All news',
'deals' => 'Deals',
'bundles' => 'Bundles',
'freebies' => 'Freebies',
'giveaways' => 'Giveaways',
'subscriptions' => 'Subscriptions',
];
public function index(string $cat = ''): string
{
if (! isset(self::CATEGORIES[$cat])) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$filter = $cat !== '' ? ['category' => $cat] : [];
$posts = Mongo::collection('news')
->find($filter, ['sort' => ['date' => -1], 'limit' => 40])
->toArray();
$data = [
'pageTitle' => 'Video game news — GG.deals',
'posts' => $posts,
'cats' => self::CATEGORIES,
'active' => $cat,
];
return view('news', $data);
}
public function article(string $slug): string
{
$post = Mongo::collection('news')->findOne(['slug' => $slug]);
if ($post === null) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$related = Mongo::collection('news')
->find(
['slug' => ['$ne' => $slug], 'category' => $post['category']],
['sort' => ['date' => -1], 'limit' => 4]
)
->toArray();
$data = [
'pageTitle' => $post['title'] . ' — GG.deals',
'post' => $post,
'related' => $related,
'cats' => self::CATEGORIES,
'active' => $post['category'],
];
return view('news_article', $data);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use CodeIgniter\Controller;
class Pages extends Controller
{
public function faq(): string
{
$data = [
'pageTitle' => 'FAQ — GG.deals',
];
return view('faq', $data);
}
public function tutorial(string $topic): string
{
$tutorials = [
'activate-steam' => ['How to activate Steam CD key', 'steam'],
'activate-gog' => ['How to activate GOG CD key', 'gog'],
'activate-epic' => ['How to activate Epic Games CD key', 'epic'],
'activate-origin' => ['How to activate Origin CD key', 'origin'],
'activate-uplay' => ['How to activate Uplay CD key', 'uplay'],
'keyshops-risks' => ['Keyshops — what to know', 'keyshops'],
];
if (! isset($tutorials[$topic])) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$data = [
'pageTitle' => $tutorials[$topic][0] . ' — GG.deals',
'tutorial' => $tutorials[$topic],
'all' => $tutorials,
];
return view('tutorial', $data);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Mongo;
use CodeIgniter\Controller;
class Prepaids extends Controller
{
public function index(): string
{
$groups = Mongo::collection('prepaids')->find([], ['sort' => ['name' => 1]])->toArray();
$data = [
'pageTitle' => 'Gift Cards &amp; Prepaids — GG.deals',
'groups' => $groups,
];
return view('prepaids', $data);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\GameModel;
use CodeIgniter\Controller;
class Rankings extends Controller
{
public function index(): string
{
$model = new GameModel();
$data = [
'pageTitle' => 'Rankings — GG.deals',
'wishlisted' => $model->ranked('trending', 20),
'collected' => $model->ranked('rating_pct', 20),
'metacritic' => $model->ranked('score', 20),
'reviews' => $model->ranked('rating_pct', 20),
];
return view('rankings', $data);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\GameModel;
use CodeIgniter\Controller;
class Stores extends Controller
{
public function index(): string
{
$model = new GameModel();
$data = [
'pageTitle' => 'Stores — GG.deals',
'stores' => $model->storeStats(),
];
return view('stores', $data);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Mongo;
use CodeIgniter\Controller;
class Subscriptions extends Controller
{
public function index(): string
{
$subs = Mongo::collection('subscriptions')
->find([], ['sort' => ['price' => 1]])
->toArray();
$data = [
'pageTitle' => 'Subscriptions — GG.deals',
'subs' => $subs,
'stores' => (new \App\Models\StoreModel())->map(),
];
return view('subscriptions', $data);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\StoreModel;
use App\Libraries\Mongo;
use CodeIgniter\Controller;
class Vouchers extends Controller
{
public function index(): string
{
$vouchers = Mongo::collection('vouchers')->find([], ['sort' => ['store' => 1]])->toArray();
$data = [
'pageTitle' => 'Promo Codes &amp; Coupons — GG.deals',
'vouchers' => $vouchers,
'stores' => (new StoreModel())->map(),
];
return view('vouchers', $data);
}
public function store(string $code): string
{
$vouchers = Mongo::collection('vouchers')->find(['store' => $code], ['sort' => ['created' => -1]])->toArray();
$data = [
'pageTitle' => ucfirst($code) . ' promo codes — GG.deals',
'vouchers' => $vouchers,
'stores' => (new StoreModel())->map(),
'storeCode' => $code,
];
return view('vouchers', $data);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\GameModel;
use CodeIgniter\Controller;
class Wishlist extends Controller
{
public function index(): string
{
// wishlist slugs come from localStorage client-side; the page shows
// the JS-powered collection with an optional server fetch fallback.
$data = [
'pageTitle' => 'Wishlist — GG.deals',
];
return view('wishlist', $data);
}
/**
* JSON: fetch full game data for a list of slugs (used by the wishlist page).
*/
public function data(): \CodeIgniter\HTTP\ResponseInterface
{
$slugs = (array) service('request')->getGet('slugs');
$games = [];
foreach (array_unique(array_filter($slugs)) as $slug) {
$g = (new GameModel())->findBySlug($slug);
if ($g !== null) {
$games[] = $g;
}
}
return service('response')->setJSON($games);
}
}