Files
ggdeals/app/Models/GameModel.php
T
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

588 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models;
use App\Libraries\Mongo;
use MongoDB\BSON\UTCDateTime;
/**
* Game data access layer built on MongoDB aggregations.
*/
class GameModel
{
/**
* Deal feed: best current deal per game, filtered + sorted.
*/
public function feed(array $filters, int $page, int $perPage): array
{
$match = [];
$stores = $filters['stores'] ?? [];
if ($stores !== []) {
$match['store_code'] = ['$in' => $stores];
}
if (! empty($filters['kind']) && in_array($filters['kind'], ['official', 'keyshop'], true)) {
$match['kind'] = $filters['kind'];
}
$priceCond = [];
if ($filters['min_price'] !== null && $filters['min_price'] !== '') {
$priceCond['$gte'] = (float) $filters['min_price'];
}
if ($filters['max_price'] !== null && $filters['max_price'] !== '') {
$priceCond['$lte'] = (float) $filters['max_price'];
}
if ($priceCond !== []) {
$match['price'] = $priceCond;
}
// price tier presets (Under $5 / $10 / $15 / $20 / $30)
$priceTier = (int) ($filters['price_tier'] ?? 0);
if ($priceTier > 0) {
$match['price'] = array_merge($match['price'] ?? [], ['$lte' => (float) $priceTier]);
}
$minCut = (int) ($filters['min_cut'] ?? 0);
if ($minCut > 0) {
$match['cut'] = ['$gte' => $minCut];
}
// discount tier presets (At least 50/66/75/80/85/90 %)
$discountTier = (int) ($filters['discount_tier'] ?? 0);
if ($discountTier > 0) {
$match['cut'] = ['$gte' => $discountTier];
}
// preset discount thresholds
$preset = (string) ($filters['preset'] ?? '');
if ($preset === 'cut75') {
$match['cut'] = ['$gte' => 75];
}
if ($preset === 'cut85') {
$match['cut'] = ['$gte' => 85];
}
// deal age window (within 24h/48h/1w/2w) — deals have no created_at,
// so approximate with the expiry window (ending soon) or leave empty.
$timeRange = (string) ($filters['time_range'] ?? '');
if ($timeRange !== '' && $timeRange !== 'any') {
// apply on game.release_date is not right; instead ignore for now
// (see post-group $match below for ending-soon handling)
}
$basePipeline = [];
if ($match !== []) {
$basePipeline[] = ['$match' => $match];
}
$basePipeline[] = ['$sort' => ['price' => 1]];
$basePipeline[] = [
'$group' => [
'_id' => '$game_slug',
'best_price' => ['$first' => '$price'],
'best_store' => ['$first' => '$store_code'],
'best_kind' => ['$first' => '$kind'],
'best_cut' => ['$first' => '$cut'],
'best_hl' => ['$first' => '$historical_low'],
'best_expiry' => ['$first' => '$expiry'],
'best_deal_doc' => ['$first' => '$$ROOT'],
],
];
$basePipeline[] = [
'$lookup' => [
'from' => 'games',
'localField' => '_id',
'foreignField' => 'slug',
'as' => 'game',
],
];
$basePipeline[] = ['$unwind' => '$game'];
$basePipeline[] = [
'$addFields' => [
'deal_rating' => [
'$cond' => [
'if' => ['$gt' => ['$best_price', 0]],
'then' => ['$divide' => ['$game.all_time_low.price', '$best_price']],
'else' => 0,
],
],
'ends_within' => [
'$cond' => [
'if' => ['$ne' => ['$best_expiry', null]],
'then' => ['$subtract' => ['$best_expiry', '$$NOW']],
'else' => 86400000 * 365,
],
],
],
];
if (! empty($filters['q'])) {
$regex = new \MongoDB\BSON\Regex(preg_quote((string) $filters['q'], '/'), 'i');
$basePipeline[] = ['$match' => ['game.title' => $regex]];
}
// genre filter (e.g. /games/{genre})
if (! empty($filters['genre'])) {
$basePipeline[] = ['$match' => ['game.genres' => $filters['genre']]];
}
// multi-genre filter from the sidebar (matches any selected genre)
$genresFilter = $filters['genres'] ?? [];
if ($genresFilter !== []) {
$basePipeline[] = ['$match' => ['game.genres' => ['$in' => $genresFilter]]];
}
if (! empty($filters['hl_only'])) {
$basePipeline[] = ['$match' => ['best_hl' => true]];
}
// deal rating filter (hot/good/fair = ratio thresholds)
$ratingFilter = (string) ($filters['deal_rating'] ?? '');
if ($ratingFilter !== '' && $ratingFilter !== 'any') {
$ratios = [
'hot' => ['$gte' => 0.85],
'good' => ['$gte' => 0.6],
'fair' => ['$gte' => 0.35],
];
if (isset($ratios[$ratingFilter])) {
$basePipeline[] = ['$match' => ['deal_rating' => $ratios[$ratingFilter]]];
}
}
// release year range
$releaseRange = (string) ($filters['release_range'] ?? '');
if ($releaseRange !== '' && $releaseRange !== 'any') {
$ranges = [
'pre2000' => ['$lte' => mktime(0, 0, 0, 1, 1, 2000) * 1000],
'2000s' => ['$gte' => mktime(0, 0, 0, 1, 1, 2000) * 1000, '$lte' => mktime(0, 0, 0, 1, 1, 2010) * 1000],
'2010s' => ['$gte' => mktime(0, 0, 0, 1, 1, 2010) * 1000, '$lte' => mktime(0, 0, 0, 1, 1, 2020) * 1000],
'2020s' => ['$gte' => mktime(0, 0, 0, 1, 1, 2020) * 1000],
];
if (isset($ranges[$releaseRange])) {
$basePipeline[] = ['$match' => ['game.release_date' => $ranges[$releaseRange]]];
}
}
// preset handling
if ($preset === 'hl') {
$basePipeline[] = ['$match' => ['best_hl' => true]];
}
if ($preset === 'ending') {
$basePipeline[] = ['$match' => ['ends_within' => ['$lte' => 7 * 86400000]]];
}
// wishlist-only filter: match slugs passed from the client
$wishlist = $filters['wishlist'] ?? [];
if ($wishlist !== []) {
$basePipeline[] = ['$match' => ['game.slug' => ['$in' => $wishlist]]];
}
// ---- count total (run same pipeline up to filters, then count)
$countPipeline = $basePipeline;
$countPipeline[] = ['$count' => 'total'];
$countRes = Mongo::collection('deals')->aggregate($countPipeline)->toArray();
$total = $countRes[0]['total'] ?? 0;
// ---- sort
$sortStage = match ($filters['sort'] ?? 'trending') {
'price' => ['best_price' => 1],
'cut' => ['best_cut' => -1],
'recent' => ['game.release_date' => -1],
'hl' => ['game.all_time_low.price' => 1],
'rating' => ['deal_rating' => -1],
'title' => ['game.title' => 1],
'metascore' => ['game.score' => -1],
'length' => ['game.release_date' => 1],
'ending' => ['ends_within' => 1],
'hot' => ['best_cut' => -1, 'deal_rating' => -1],
default => ['game.trending' => -1],
};
$pipeline = $basePipeline;
$pipeline[] = ['$sort' => $sortStage];
$pipeline[] = ['$skip' => ($page - 1) * $perPage];
$pipeline[] = ['$limit' => $perPage];
$rows = Mongo::collection('deals')->aggregate($pipeline)->toArray();
return [
'rows' => $rows,
'total' => $total,
'pages' => (int) max(1, ceil($total / $perPage)),
];
}
public function findBySlug(string $slug): ?array
{
$res = Mongo::collection('games')->findOne(['slug' => $slug]);
if ($res === null) {
return null;
}
$res['_id'] = (string) $res['_id'];
return $res;
}
/**
* Steam AppID → game lookup for the public Prices API.
*/
public function findByAppId(int $appid): ?array
{
$slug = array_search($appid, \App\Commands\CrawlCovers::APP_IDS, true);
if ($slug === false) {
return null;
}
return $this->findBySlug((string) $slug);
}
/**
* All active deals for a game, sorted by price.
*/
public function dealsFor(string $slug): array
{
$rows = Mongo::collection('deals')
->find(['game_slug' => $slug], ['sort' => ['price' => 1]])
->toArray();
$official = [];
$keyshop = [];
foreach ($rows as $r) {
unset($r['_id']);
if ($r['kind'] === 'official') {
$official[] = $r;
} else {
$keyshop[] = $r;
}
}
return ['official' => $official, 'keyshop' => $keyshop];
}
/**
* Daily min price per store kind for the history chart.
*/
public function historyFor(string $slug): array
{
$pipeline = [
['$match' => ['game_slug' => $slug]],
[
'$group' => [
'_id' => [
'day' => ['$dateToString' => ['date' => '$ts', 'format' => '%Y-%m-%d']],
'kind' => '$kind',
],
'price' => ['$min' => '$price'],
],
],
['$sort' => ['_id.day' => 1]],
];
$rows = Mongo::collection('price_history')->aggregate($pipeline)->toArray();
$series = ['official' => [], 'keyshop' => []];
foreach ($rows as $r) {
$series[$r['_id']['kind']][] = ['day' => $r['_id']['day'], 'price' => round($r['price'], 2)];
}
return $series;
}
public function trending(int $limit = 12): array
{
$rows = Mongo::collection('games')
->find([], ['sort' => ['trending' => -1], 'limit' => $limit])
->toArray();
foreach ($rows as &$r) {
$r['_id'] = (string) $r['_id'];
}
return $rows;
}
/**
* Ranking list by a numeric field (score, rating_pct, trending, etc).
*/
public function ranked(string $field, int $limit = 20): array
{
$sort = in_array($field, ['score', 'rating_pct', 'trending'], true)
? [$field => -1]
: ['trending' => -1];
$rows = Mongo::collection('games')
->find([], ['sort' => $sort, 'limit' => $limit])
->toArray();
foreach ($rows as &$r) {
$r['_id'] = (string) $r['_id'];
}
return $rows;
}
/**
* Genre counts for the games browser.
*/
public function genres(): array
{
$rows = Mongo::collection('games')->aggregate([
['$unwind' => '$genres'],
['$group' => ['_id' => '$genres', 'count' => ['$sum' => 1]]],
['$sort' => ['count' => -1]],
])->toArray();
return $rows;
}
public function byGenre(string $genre, int $limit = 40): array
{
$rows = Mongo::collection('games')
->find(['genres' => $genre], ['sort' => ['trending' => -1], 'limit' => $limit])
->toArray();
foreach ($rows as &$r) {
$r['_id'] = (string) $r['_id'];
}
return $rows;
}
/**
* Similar games: same genre, excluding self.
*/
public function similar(array $game, int $limit = 8): array
{
$genres = array_map('strval', $game['genres'] ?? []);
if ($genres === []) {
return [];
}
$rows = Mongo::collection('games')
->find(
['slug' => ['$ne' => $game['slug']], 'genres' => ['$in' => $genres]],
['sort' => ['trending' => -1], 'limit' => $limit]
)
->toArray();
foreach ($rows as &$r) {
$r['_id'] = (string) $r['_id'];
}
return $rows;
}
public function suggest(string $q, int $limit = 8): array
{
$regex = new \MongoDB\BSON\Regex(preg_quote($q, '/'), 'i');
$rows = Mongo::collection('games')
->find(['title' => $regex], [
'sort' => ['trending' => -1],
'limit' => $limit,
'projection' => ['title' => 1, 'slug' => 1, 'cover' => 1, 'all_time_low' => 1, 'best_official' => 1, 'best_keyshop' => 1, 'active_deals' => 1],
])
->toArray();
$out = [];
foreach ($rows as $r) {
$out[] = [
'title' => $r['title'],
'slug' => $r['slug'],
'cover' => $r['cover']['file'] ?? '',
'hl' => $r['all_time_low']['price'] ?? null,
'deals' => $r['active_deals'] ?? 0,
];
}
return $out;
}
public function storeStats(): array
{
$pipeline = [
[
'$group' => [
'_id' => '$store_code',
'deals' => ['$sum' => 1],
'min_price' => ['$min' => '$price'],
'avg_cut' => ['$avg' => '$cut'],
],
],
];
$rows = Mongo::collection('deals')->aggregate($pipeline)->toArray();
$stores = [];
foreach (Mongo::collection('stores')->find()->toArray() as $s) {
$stores[$s['code']] = [
'code' => $s['code'],
'name' => $s['name'],
'kind' => $s['kind'],
'color' => $s['color'],
'textcolor' => $s['textcolor'],
'logo' => $s['logo'] ?? '/img/stores/' . $s['code'] . '.png',
'deals' => 0,
'min_price' => null,
'avg_cut' => 0,
];
}
foreach ($rows as $r) {
if (isset($stores[$r['_id']])) {
$stores[$r['_id']]['deals'] = $r['deals'];
$stores[$r['_id']]['min_price'] = $r['min_price'];
$stores[$r['_id']]['avg_cut'] = round($r['avg_cut'], 1);
}
}
usort($stores, static fn ($a, $b) => $b['deals'] <=> $a['deals']);
return array_values($stores);
}
public function stats(): array
{
$games = Mongo::collection('games')->countDocuments();
$deals = Mongo::collection('deals')->countDocuments();
return [
'games' => $games,
'deals' => $deals,
'stores' => Mongo::collection('stores')->countDocuments(),
'hl_now' => Mongo::collection('deals')->countDocuments(['historical_low' => true]),
];
}
/**
* Compact deal rows for the homepage dashboard sections.
* $mode: best | new | hl | ending | hot | wanted | releases
*/
public function dashboardDeals(string $mode, int $limit = 12): array
{
$sort = match ($mode) {
'new' => ['game.release_date' => -1, 'deal_rating' => -1],
'hl' => ['deal_rating' => -1],
'ending' => ['ends_within' => 1],
'hot' => ['best_cut' => -1, 'deal_rating' => -1],
'wanted' => ['game.trending' => -1],
default => ['deal_rating' => -1, 'best_cut' => -1],
};
$pipeline = [
['$sort' => ['price' => 1]],
[
'$group' => [
'_id' => '$game_slug',
'best_price' => ['$first' => '$price'],
'best_store' => ['$first' => '$store_code'],
'best_kind' => ['$first' => '$kind'],
'best_cut' => ['$first' => '$cut'],
'best_hl' => ['$first' => '$historical_low'],
'best_expiry' => ['$first' => '$expiry'],
],
],
[
'$lookup' => [
'from' => 'games',
'localField' => '_id',
'foreignField' => 'slug',
'as' => 'game',
],
],
['$unwind' => '$game'],
[
'$addFields' => [
'deal_rating' => [
'$cond' => [
'if' => ['$gt' => ['$best_price', 0]],
'then' => ['$divide' => ['$game.all_time_low.price', '$best_price']],
'else' => 0,
],
],
'ends_within' => [
'$cond' => [
'if' => ['$ne' => ['$best_expiry', null]],
'then' => ['$subtract' => ['$best_expiry', '$$NOW']],
'else' => 86400000 * 365,
],
],
],
],
];
if ($mode === 'hl') {
$pipeline[] = ['$match' => ['best_hl' => true]];
}
if ($mode === 'ending') {
$pipeline[] = ['$match' => ['ends_within' => ['$lte' => 7 * 86400000]]];
}
$pipeline[] = ['$sort' => $sort];
$pipeline[] = ['$limit' => $limit];
return Mongo::collection('deals')->aggregate($pipeline)->toArray();
}
/**
* Most wanted / new releases / upcoming games (game-only lists).
*/
public function dashboardGames(string $mode, int $limit = 12): array
{
$sort = match ($mode) {
'releases' => ['release_date' => -1],
'upcoming' => ['release_date' => 1],
default => ['trending' => -1],
};
$filter = [];
if ($mode === 'upcoming') {
// treat games released after 2023 as "upcoming" in our demo data
$filter = ['release_date' => ['$gt' => new UTCDateTime(mktime(0, 0, 0, 1, 1, 2024) * 1000)]];
}
$rows = Mongo::collection('games')
->find($filter, ['sort' => $sort, 'limit' => $limit])
->toArray();
foreach ($rows as &$r) {
$r['_id'] = (string) $r['_id'];
}
return $rows;
}
/**
* Latest news posts for the homepage feed.
*/
public function recentNews(int $limit = 6): array
{
return Mongo::collection('news')
->find([], ['sort' => ['date' => -1], 'limit' => $limit])
->toArray();
}
/**
* Bundles that include this game.
*/
public function bundlesFor(string $slug): array
{
return Mongo::collection('bundles')
->find(['games' => $slug], ['sort' => ['price' => 1]])
->toArray();
}
/**
* Franchises containing this game.
*/
public function franchisesFor(string $slug): array
{
return Mongo::collection('franchises')
->find(['games' => $slug])
->toArray();
}
}