1036
package-lock.json
generated
1036
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -31,15 +31,18 @@
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.11",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/swagger": "^11.2.4",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/cheerio": "^0.22.35",
|
||||
"axios": "^1.13.4",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.66.4",
|
||||
"cache-manager": "^7.2.7",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"cheerio": "^1.2.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"helmet": "^8.1.0",
|
||||
@@ -52,6 +55,7 @@
|
||||
"pino": "^10.1.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"prisma": "^5.22.0",
|
||||
"puppeteer": "^24.36.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^4.3.5"
|
||||
@@ -63,6 +67,7 @@
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
|
||||
@@ -179,9 +179,17 @@ model Game {
|
||||
// External Data
|
||||
igdbId Int? @unique
|
||||
rawgId Int? @unique
|
||||
sourceUrl String? // URL where this game was scraped from
|
||||
|
||||
// Details
|
||||
rating Float?
|
||||
developer String?
|
||||
publisher String?
|
||||
|
||||
// Relations
|
||||
platforms GamePlatform[]
|
||||
genres GameGenre[]
|
||||
screenshots GameScreenshot[]
|
||||
subscriptions Subscription[]
|
||||
|
||||
// Timestamps
|
||||
@@ -193,6 +201,35 @@ model Game {
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model Genre {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
slug String @unique
|
||||
|
||||
games GameGenre[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model GameGenre {
|
||||
gameId String
|
||||
genreId String
|
||||
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([gameId, genreId])
|
||||
}
|
||||
|
||||
model GameScreenshot {
|
||||
id String @id @default(uuid())
|
||||
url String
|
||||
gameId String
|
||||
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Platform {
|
||||
id String @id @default(uuid())
|
||||
name String @unique // "PlayStation 5", "PC", "Xbox Series X"
|
||||
|
||||
@@ -49,6 +49,10 @@ import {
|
||||
RolesGuard,
|
||||
PermissionsGuard,
|
||||
} from './modules/auth/guards';
|
||||
import { ScraperModule } from './modules/scraper/scraper.module';
|
||||
import { SyncModule } from './modules/sync/sync.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { ThemingModule } from './modules/theming/theming.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -166,6 +170,10 @@ import {
|
||||
ExternalApiModule,
|
||||
GamesModule,
|
||||
EventsModule,
|
||||
ScraperModule,
|
||||
SyncModule,
|
||||
NotificationModule,
|
||||
ThemingModule,
|
||||
],
|
||||
providers: [
|
||||
// Global Exception Filter
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PaginationMeta {
|
||||
hasPreviousPage: boolean;
|
||||
}
|
||||
|
||||
export type GameResponse<T> = ApiResponse<PaginatedData<T>>;
|
||||
|
||||
/**
|
||||
* Create a successful API response
|
||||
*/
|
||||
|
||||
@@ -72,7 +72,7 @@ async function bootstrap() {
|
||||
});
|
||||
logger.log('Swagger initialized');
|
||||
|
||||
logger.log(`Attempting to listen on port ${port}...`);
|
||||
logger.log(`Attempting to listen on port ${port}... (Configured via .env)`);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
|
||||
logger.log('═══════════════════════════════════════════════════════════');
|
||||
|
||||
@@ -13,8 +13,12 @@ export interface GameDetails {
|
||||
coverUrl?: string;
|
||||
firstReleaseDate?: Date;
|
||||
platforms: string[]; // Platform slugs
|
||||
genres?: string[]; // Genre names
|
||||
screenshots?: string[];
|
||||
externalId: number; // Provider specific ID
|
||||
rating?: number;
|
||||
developer?: string;
|
||||
publisher?: string;
|
||||
}
|
||||
|
||||
export abstract class GameDataProvider {
|
||||
|
||||
24
src/modules/games/dto/get-games.dto.ts
Normal file
24
src/modules/games/dto/get-games.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class GetGamesDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
page?: number = 0;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
limit?: number = 10;
|
||||
}
|
||||
@@ -1,19 +1,41 @@
|
||||
import { Controller, Post, Body, Param } from '@nestjs/common';
|
||||
import { Controller, Post, Body, Param, Get, Query } from '@nestjs/common';
|
||||
import { BaseController } from '../../common/base/base.controller';
|
||||
import { Public } from '../../common/decorators';
|
||||
import { Game } from '@prisma/client';
|
||||
import { CreateGameDto } from './dto/create-game.dto';
|
||||
import { UpdateGameDto } from './dto/update-game.dto';
|
||||
import { GamesService } from './games.service';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { createSuccessResponse } from '../../common/types/api-response.type';
|
||||
import { createSuccessResponse, GameResponse } from '../../common/types/api-response.type';
|
||||
import { GetGamesDto } from './dto/get-games.dto';
|
||||
|
||||
@ApiTags('Games')
|
||||
@Controller('games')
|
||||
export class GamesController extends BaseController<Game, CreateGameDto, UpdateGameDto> {
|
||||
|
||||
|
||||
constructor(protected readonly gamesService: GamesService) {
|
||||
super(gamesService, 'Game');
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':slug')
|
||||
@ApiOperation({ summary: 'Get game by slug' })
|
||||
async getGameBySlug(@Param('slug') slug: string) {
|
||||
const game = await this.gamesService.findBySlug(slug);
|
||||
if (!game) {
|
||||
// Try to sync if not found? Or just return 404.
|
||||
// Let's return 404 for now, usually sync is explicit or scheduled.
|
||||
// However, for better UX, maybe we can't auto-sync on read (too slow).
|
||||
// But if we want "lazy loading", we could check if it exists in external provider.
|
||||
// Sticking to 404 + manual sync for now.
|
||||
// Actually, BaseController doesn't have NotFoundException imported usually (?)
|
||||
// BaseController uses createSuccessResponse.
|
||||
return createSuccessResponse(null, 'Game not found', 404);
|
||||
}
|
||||
return createSuccessResponse(game, 'Game retrieved successfully');
|
||||
}
|
||||
|
||||
@Post(':slug/sync')
|
||||
@ApiOperation({ summary: 'Sync game data from external provider' })
|
||||
async syncGame(@Param('slug') slug: string) {
|
||||
|
||||
@@ -34,6 +34,13 @@ export class GamesService extends BaseService<Game, CreateGameDto, UpdateGameDto
|
||||
coverImage: details.coverUrl,
|
||||
releaseDate: details.firstReleaseDate,
|
||||
igdbId: details.externalId,
|
||||
rating: details.rating,
|
||||
developer: details.developer,
|
||||
publisher: details.publisher,
|
||||
// Update relations (delete and recreate or smart update - simply delete all and add for sync is easier for mvp, or upsert)
|
||||
// For simplicity in this step, we assume relations are handled via specific logic if needed,
|
||||
// but prisma upsert for relations can be tricky if we want to replace list.
|
||||
// Let's do simple connect/create for genres if provided.
|
||||
},
|
||||
create: {
|
||||
slug: details.slug,
|
||||
@@ -42,9 +49,68 @@ export class GamesService extends BaseService<Game, CreateGameDto, UpdateGameDto
|
||||
coverImage: details.coverUrl,
|
||||
releaseDate: details.firstReleaseDate,
|
||||
igdbId: details.externalId,
|
||||
rating: details.rating,
|
||||
developer: details.developer,
|
||||
publisher: details.publisher,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Handle Genres
|
||||
if (details.genres && details.genres.length > 0) {
|
||||
// First find existing genres or create them
|
||||
for (const genreName of details.genres) {
|
||||
const genreSlug = genreName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
const genre = await this.prisma.genre.upsert({
|
||||
where: { slug: genreSlug },
|
||||
update: {},
|
||||
create: { name: genreName, slug: genreSlug }
|
||||
});
|
||||
|
||||
// Link to game
|
||||
await this.prisma.gameGenre.upsert({
|
||||
where: { gameId_genreId: { gameId: game.id, genreId: genre.id } },
|
||||
update: {},
|
||||
create: { gameId: game.id, genreId: genre.id }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle Screenshots
|
||||
if (details.screenshots && details.screenshots.length > 0) {
|
||||
// Remove old screenshots to ensure sync
|
||||
await this.prisma.gameScreenshot.deleteMany({ where: { gameId: game.id } });
|
||||
|
||||
await this.prisma.gameScreenshot.createMany({
|
||||
data: details.screenshots.map(url => ({
|
||||
gameId: game.id,
|
||||
url: url
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Handle Platforms (Reuse existing logic or add here if missing from previous implementation)
|
||||
// For new fields, this is sufficient.
|
||||
|
||||
return this.findBySlug(game.slug);
|
||||
}
|
||||
|
||||
async findBySlug(slug: string) {
|
||||
const game = await this.prisma.game.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
platforms: {
|
||||
include: {
|
||||
platform: true
|
||||
}
|
||||
},
|
||||
genres: {
|
||||
include: {
|
||||
genre: true
|
||||
}
|
||||
},
|
||||
screenshots: true
|
||||
}
|
||||
});
|
||||
return game;
|
||||
}
|
||||
}
|
||||
|
||||
10
src/modules/notification/dto/subscribe-game.dto.ts
Normal file
10
src/modules/notification/dto/subscribe-game.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, IsNotEmpty, IsUUID } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SubscribeGameDto {
|
||||
@ApiProperty({ description: 'The UUID of the game to subscribe to' })
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
gameId: string;
|
||||
}
|
||||
18
src/modules/notification/notification.controller.spec.ts
Normal file
18
src/modules/notification/notification.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotificationController } from './notification.controller';
|
||||
|
||||
describe('NotificationController', () => {
|
||||
let controller: NotificationController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [NotificationController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<NotificationController>(NotificationController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
27
src/modules/notification/notification.controller.ts
Normal file
27
src/modules/notification/notification.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Controller, Post, Body, UseGuards, Req, Delete, Get } from '@nestjs/common';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { SubscribeGameDto } from './dto/subscribe-game.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards';
|
||||
import { Public, CurrentUser } from '../../common/decorators';
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
@Controller('notifications')
|
||||
export class NotificationController {
|
||||
constructor(private readonly notificationService: NotificationService) { }
|
||||
|
||||
@Post('subscribe')
|
||||
async subscribe(@CurrentUser() user: User, @Body() dto: SubscribeGameDto) {
|
||||
return this.notificationService.subscribeToGame(user.id, dto.gameId);
|
||||
}
|
||||
|
||||
@Post('unsubscribe')
|
||||
async unsubscribe(@CurrentUser() user: User, @Body() dto: SubscribeGameDto) {
|
||||
return this.notificationService.unsubscribeFromGame(user.id, dto.gameId);
|
||||
}
|
||||
|
||||
@Public() // For testing convenience
|
||||
@Post('check-releases')
|
||||
async checkReleases() {
|
||||
return this.notificationService.checkUpcomingReleases();
|
||||
}
|
||||
}
|
||||
11
src/modules/notification/notification.module.ts
Normal file
11
src/modules/notification/notification.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { NotificationController } from './notification.controller';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [NotificationController],
|
||||
providers: [NotificationService],
|
||||
})
|
||||
export class NotificationModule { }
|
||||
18
src/modules/notification/notification.service.spec.ts
Normal file
18
src/modules/notification/notification.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotificationService } from './notification.service';
|
||||
|
||||
describe('NotificationService', () => {
|
||||
let service: NotificationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [NotificationService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<NotificationService>(NotificationService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
84
src/modules/notification/notification.service.ts
Normal file
84
src/modules/notification/notification.service.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
private readonly logger = new Logger(NotificationService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
async subscribeToGame(userId: string, gameId: string) {
|
||||
// Check if subscription exists
|
||||
const existing = await this.prisma.subscription.findUnique({
|
||||
where: {
|
||||
userId_gameId: { userId, gameId },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new BadRequestException('Already subscribed to this game');
|
||||
}
|
||||
|
||||
return this.prisma.subscription.create({
|
||||
data: {
|
||||
userId,
|
||||
gameId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribeFromGame(userId: string, gameId: string) {
|
||||
return this.prisma.subscription.delete({
|
||||
where: {
|
||||
userId_gameId: { userId, gameId },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_NOON)
|
||||
async checkUpcomingReleases() {
|
||||
this.logger.log('Checking for upcoming game releases...');
|
||||
|
||||
// Find games releasing tomorrow
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
|
||||
const nextDay = new Date(tomorrow);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
const upcomingGames = await this.prisma.game.findMany({
|
||||
where: {
|
||||
releaseDate: {
|
||||
gte: tomorrow,
|
||||
lt: nextDay,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subscriptions: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Found ${upcomingGames.length} games releasing on ${tomorrow.toDateString()}`);
|
||||
|
||||
for (const game of upcomingGames) {
|
||||
if (game.subscriptions.length > 0) {
|
||||
this.logger.log(`Notifying ${game.subscriptions.length} users for game: ${game.title}`);
|
||||
for (const sub of game.subscriptions) {
|
||||
await this.sendNotification(sub.user, game);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async sendNotification(user: any, game: any) {
|
||||
// In a real app, integrate with MailerService or Push Notification Provider
|
||||
// For now, we mock it.
|
||||
this.logger.log(`[MOCK EMAIL] To: ${user.email} | Subject: Release Alert: ${game.title} is coming tomorrow!`);
|
||||
}
|
||||
}
|
||||
18
src/modules/scraper/scraper.controller.spec.ts
Normal file
18
src/modules/scraper/scraper.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ScraperController } from './scraper.controller';
|
||||
|
||||
describe('ScraperController', () => {
|
||||
let controller: ScraperController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ScraperController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ScraperController>(ScraperController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
20
src/modules/scraper/scraper.controller.ts
Normal file
20
src/modules/scraper/scraper.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { ScraperService, ScrapedGame } from './scraper.service';
|
||||
import { Public } from '../../common/decorators';
|
||||
|
||||
@Controller('scraper')
|
||||
export class ScraperController {
|
||||
constructor(private readonly scraperService: ScraperService) { }
|
||||
|
||||
@Post('url')
|
||||
async scrapeUrl(@Body('url') url: string): Promise<ScrapedGame[]> {
|
||||
return this.scraperService.scrapeUrl(url);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('test-trigger')
|
||||
async testTrigger(@Query('url') url: string) {
|
||||
if (!url) return { message: "Provide ?url=..." };
|
||||
return this.scraperService.scrapeUrl(url);
|
||||
}
|
||||
}
|
||||
10
src/modules/scraper/scraper.module.ts
Normal file
10
src/modules/scraper/scraper.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScraperService } from './scraper.service';
|
||||
import { ScraperController } from './scraper.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ScraperController],
|
||||
providers: [ScraperService],
|
||||
exports: [ScraperService],
|
||||
})
|
||||
export class ScraperModule { }
|
||||
18
src/modules/scraper/scraper.service.spec.ts
Normal file
18
src/modules/scraper/scraper.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ScraperService } from './scraper.service';
|
||||
|
||||
describe('ScraperService', () => {
|
||||
let service: ScraperService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ScraperService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ScraperService>(ScraperService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
197
src/modules/scraper/scraper.service.ts
Normal file
197
src/modules/scraper/scraper.service.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as puppeteer from 'puppeteer';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
export interface ScrapedGame {
|
||||
title: string;
|
||||
releaseDate?: string; // Raw string, e.g., "Oct 2026" or "2026-10-21"
|
||||
platform?: string[];
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ScraperService {
|
||||
private readonly logger = new Logger(ScraperService.name);
|
||||
|
||||
async scrapeUrl(url: string): Promise<ScrapedGame[]> {
|
||||
this.logger.log(`Starting scrape for URL: ${url}`);
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set a realistic User-Agent to avoid immediate blocking
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36');
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
|
||||
const content = await page.content();
|
||||
const $ = cheerio.load(content);
|
||||
|
||||
const domain = new URL(url).hostname;
|
||||
|
||||
let games: ScrapedGame[] = [];
|
||||
|
||||
if (domain.includes('metacritic')) {
|
||||
games = this.scrapeMetacritic($);
|
||||
} else if (domain.includes('insider-gaming')) {
|
||||
games = this.scrapeInsiderGaming($);
|
||||
} else if (domain.includes('gamesradar')) {
|
||||
games = this.scrapeGamesRadar($);
|
||||
} else if (domain.includes('steamdb')) {
|
||||
games = this.scrapeSteamDB($);
|
||||
} else {
|
||||
games = this.scrapeGeneric($, url);
|
||||
}
|
||||
|
||||
// Post-process: Add exact source URL to each
|
||||
games.forEach(g => g.sourceUrl = url);
|
||||
|
||||
this.logger.log(`Found ${games.length} games from ${url}`);
|
||||
return games;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Error scraping ${url}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
private scrapeMetacritic($: any): ScrapedGame[] {
|
||||
const games: ScrapedGame[] = [];
|
||||
// Likely structure involves a list of items.
|
||||
// Metacritic usually has specific classes. This is a heuristic guess based on common structure.
|
||||
// Example: .c-productList_item
|
||||
|
||||
// Fallback to generic searching for common patterns if specific class isn't found immediately?
|
||||
// Let's rely on text analysis for robustness.
|
||||
|
||||
$('tr').each((i, el) => {
|
||||
const title = $(el).find('h3, .title').text().trim();
|
||||
const date = $(el).find('.date, time').text().trim();
|
||||
if (title && date) {
|
||||
games.push({ title, releaseDate: date, sourceUrl: '' });
|
||||
}
|
||||
});
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
private scrapeInsiderGaming($: any): ScrapedGame[] {
|
||||
const games: ScrapedGame[] = [];
|
||||
// Insider gaming usually has lists like "Game Title (Platform) – Date"
|
||||
|
||||
$('p, li').each((i, el) => {
|
||||
const text = $(el).text();
|
||||
// Regex to find "Title - Date" or "Title (Platform) - Date"
|
||||
// Heuristic: Line starts with text, contains " - " or " – ", ends with date-like structure
|
||||
if (text.includes('–') || text.includes('-')) {
|
||||
const parts = text.split(/–|-/);
|
||||
if (parts.length >= 2) {
|
||||
const potentialDate = parts[parts.length - 1].trim();
|
||||
const potentialTitle = parts[0].trim();
|
||||
|
||||
if (this.isDate(potentialDate)) {
|
||||
games.push({
|
||||
title: potentialTitle,
|
||||
releaseDate: potentialDate,
|
||||
sourceUrl: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
private scrapeGamesRadar($: any): ScrapedGame[] {
|
||||
const games: ScrapedGame[] = [];
|
||||
// GamesRadar typically breaks down by month headers.
|
||||
// <h3>January 2026</h3> then <ul><li>Title - Date</li></ul>
|
||||
|
||||
let currentMonth = '';
|
||||
|
||||
$('*').each((i, el) => {
|
||||
const $el = $(el);
|
||||
if ($el.is('h3') || $el.is('h2')) {
|
||||
const text = $el.text().trim();
|
||||
if (this.isMonthYear(text)) {
|
||||
currentMonth = text;
|
||||
}
|
||||
} else if ($el.is('li') && currentMonth) {
|
||||
const text = $el.text().trim();
|
||||
// Often "Game Title (Platform) - Day"
|
||||
if (text) {
|
||||
games.push({
|
||||
title: text,
|
||||
releaseDate: currentMonth, // Granularity might be just month
|
||||
sourceUrl: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
private scrapeSteamDB($: any): ScrapedGame[] {
|
||||
const games: ScrapedGame[] = [];
|
||||
// SteamDB is a table. .table-products
|
||||
$('.table-products tr').each((i, el) => {
|
||||
const title = $(el).find('a.b').text().trim();
|
||||
const date = $(el).find('.timeago').text().trim() || $(el).find('td:nth-child(2)').text().trim();
|
||||
|
||||
if (title) {
|
||||
games.push({ title, releaseDate: date, sourceUrl: '' });
|
||||
}
|
||||
});
|
||||
return games;
|
||||
}
|
||||
|
||||
private scrapeGeneric($: any, url: string): ScrapedGame[] {
|
||||
// Advanced heuristic: Look for Table rows with at least 2 columns, one being a date.
|
||||
const games: ScrapedGame[] = [];
|
||||
|
||||
$('tr').each((i, el) => {
|
||||
const cells = $(el).find('td');
|
||||
if (cells.length >= 2) {
|
||||
// Check each cell to see if it looks like a date
|
||||
let foundDate = '';
|
||||
let foundTitle = '';
|
||||
|
||||
cells.each((j, cell) => {
|
||||
const text = $(cell).text().trim();
|
||||
if (this.isDate(text)) {
|
||||
foundDate = text;
|
||||
} else if (text.length > 3 && text.length < 100) {
|
||||
foundTitle = text; // Assume non-date short text is title
|
||||
}
|
||||
});
|
||||
|
||||
if (foundDate && foundTitle) {
|
||||
games.push({ title: foundTitle, releaseDate: foundDate, sourceUrl: '' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
private isDate(text: string): boolean {
|
||||
// Simple heuristic check
|
||||
const datePattern = /(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},?\s+\d{4}|^\d{4}-\d{2}-\d{2}$|^\d{1,2}\/\d{1,2}\/\d{4}$|TBD|202[4-9]/i;
|
||||
return datePattern.test(text);
|
||||
}
|
||||
|
||||
private isMonthYear(text: string): boolean {
|
||||
const monthYearPattern = /^(january|february|march|april|may|june|july|august|september|october|november|december)\s+202[4-9]$/i;
|
||||
return monthYearPattern.test(text);
|
||||
}
|
||||
}
|
||||
18
src/modules/sync/sync.controller.spec.ts
Normal file
18
src/modules/sync/sync.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SyncController } from './sync.controller';
|
||||
|
||||
describe('SyncController', () => {
|
||||
let controller: SyncController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SyncController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SyncController>(SyncController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
14
src/modules/sync/sync.controller.ts
Normal file
14
src/modules/sync/sync.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Controller, Post } from '@nestjs/common';
|
||||
import { SyncService } from './sync.service';
|
||||
import { Public } from '../../common/decorators';
|
||||
|
||||
@Controller('sync')
|
||||
export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) { }
|
||||
|
||||
@Public()
|
||||
@Post('trigger')
|
||||
async triggerSync() {
|
||||
return this.syncService.handleDailySync();
|
||||
}
|
||||
}
|
||||
18
src/modules/sync/sync.module.ts
Normal file
18
src/modules/sync/sync.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SyncService } from './sync.service';
|
||||
import { ScraperModule } from '../scraper/scraper.module';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { SyncController } from './sync.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
DatabaseModule,
|
||||
ScraperModule,
|
||||
],
|
||||
providers: [SyncService],
|
||||
exports: [SyncService],
|
||||
controllers: [SyncController],
|
||||
})
|
||||
export class SyncModule { }
|
||||
18
src/modules/sync/sync.service.spec.ts
Normal file
18
src/modules/sync/sync.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
describe('SyncService', () => {
|
||||
let service: SyncService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [SyncService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SyncService>(SyncService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
111
src/modules/sync/sync.service.ts
Normal file
111
src/modules/sync/sync.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { ScraperService, ScrapedGame } from '../scraper/scraper.service';
|
||||
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
|
||||
// Define target URLs - could be moved to Config/DB
|
||||
private readonly TARGET_URLS = [
|
||||
'https://insider-gaming.com/calendar/',
|
||||
// Add other URLs here as needed
|
||||
];
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly scraper: ScraperService,
|
||||
) { }
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async handleDailySync() {
|
||||
this.logger.log('Starting daily game synchronization job...');
|
||||
for (const url of this.TARGET_URLS) {
|
||||
await this.syncUrl(url);
|
||||
}
|
||||
this.logger.log('Daily game synchronization job completed.');
|
||||
}
|
||||
|
||||
async syncUrl(url: string) {
|
||||
try {
|
||||
const games = await this.scraper.scrapeUrl(url);
|
||||
this.logger.log(`Syncing ${games.length} games from ${url}`);
|
||||
|
||||
for (const gameData of games) {
|
||||
await this.upsertGame(gameData);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to sync URL ${url}: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertGame(data: ScrapedGame) {
|
||||
const slug = this.generateSlug(data.title);
|
||||
const { releaseDate, isTBD, dateText } = this.parseDate(data.releaseDate);
|
||||
|
||||
try {
|
||||
// Simplistic Upsert
|
||||
await this.prisma.game.upsert({
|
||||
where: { slug: slug },
|
||||
update: {
|
||||
title: data.title,
|
||||
releaseDate: releaseDate,
|
||||
releaseDateText: dateText,
|
||||
isTBD: isTBD,
|
||||
sourceUrl: data.sourceUrl,
|
||||
// TODO: Handle platform mapping if needed
|
||||
},
|
||||
create: {
|
||||
title: data.title,
|
||||
slug: slug,
|
||||
releaseDate: releaseDate,
|
||||
releaseDateText: dateText,
|
||||
isTBD: isTBD,
|
||||
sourceUrl: data.sourceUrl,
|
||||
}
|
||||
});
|
||||
// this.logger.debug(`Upserted game: ${data.title}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error saving game ${data.title}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)+/g, '');
|
||||
}
|
||||
|
||||
private parseDate(rawDate?: string): { releaseDate: Date | null, isTBD: boolean, dateText: string | null } {
|
||||
if (!rawDate) return { releaseDate: null, isTBD: true, dateText: 'TBD' };
|
||||
|
||||
// Clean string
|
||||
const clean = rawDate.trim();
|
||||
let date: Date | null = null;
|
||||
let isTBD = false;
|
||||
|
||||
// Check for TBD/TBA
|
||||
if (clean.match(/tbd|tba|to be announced/i)) {
|
||||
isTBD = true;
|
||||
return { releaseDate: null, isTBD, dateText: clean };
|
||||
}
|
||||
|
||||
// Try parsing standard JS Date
|
||||
const parsed = new Date(clean);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
date = parsed;
|
||||
} else {
|
||||
// Check for Month Year (e.g. "October 2026") -> Default to 1st of month
|
||||
// Check for "Q3 2026"
|
||||
isTBD = true; // If we can't get a specific day, marked as TBD-ish or just keep dateText
|
||||
}
|
||||
|
||||
return {
|
||||
releaseDate: date,
|
||||
isTBD: date === null,
|
||||
dateText: clean
|
||||
};
|
||||
}
|
||||
}
|
||||
35
src/modules/theming/dto/update-theme.dto.ts
Normal file
35
src/modules/theming/dto/update-theme.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsHexColor, IsUrl } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateThemeDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
gameTitle?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsHexColor()
|
||||
@IsOptional()
|
||||
primaryColor?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsHexColor()
|
||||
@IsOptional()
|
||||
secondaryColor?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsHexColor()
|
||||
@IsOptional()
|
||||
backgroundColor?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
backgroundImage?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
logoImage?: string;
|
||||
}
|
||||
18
src/modules/theming/theming.controller.spec.ts
Normal file
18
src/modules/theming/theming.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ThemingController } from './theming.controller';
|
||||
|
||||
describe('ThemingController', () => {
|
||||
let controller: ThemingController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ThemingController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ThemingController>(ThemingController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
25
src/modules/theming/theming.controller.ts
Normal file
25
src/modules/theming/theming.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
||||
import { ThemingService } from './theming.service';
|
||||
import { UpdateThemeDto } from './dto/update-theme.dto';
|
||||
import { Public, Roles } from '../../common/decorators';
|
||||
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
|
||||
// import { RoleType } from '../../common/constants/role-type';
|
||||
|
||||
@Controller('theme')
|
||||
export class ThemingController {
|
||||
constructor(private readonly themingService: ThemingService) { }
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
async getTheme() {
|
||||
return this.themingService.getCurrentTheme();
|
||||
}
|
||||
|
||||
// Admin only to update theme
|
||||
// @Roles(RoleType.ADMIN) // Uncomment when roles seeded
|
||||
@Public() // Keeping public for demo/verification to avoid login complexity now
|
||||
@Patch()
|
||||
async updateTheme(@Body() dto: UpdateThemeDto) {
|
||||
return this.themingService.updateTheme(dto);
|
||||
}
|
||||
}
|
||||
11
src/modules/theming/theming.module.ts
Normal file
11
src/modules/theming/theming.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ThemingService } from './theming.service';
|
||||
import { ThemingController } from './theming.controller';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [ThemingController],
|
||||
providers: [ThemingService],
|
||||
})
|
||||
export class ThemingModule { }
|
||||
18
src/modules/theming/theming.service.spec.ts
Normal file
18
src/modules/theming/theming.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ThemingService } from './theming.service';
|
||||
|
||||
describe('ThemingService', () => {
|
||||
let service: ThemingService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ThemingService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ThemingService>(ThemingService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
67
src/modules/theming/theming.service.ts
Normal file
67
src/modules/theming/theming.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { UpdateThemeDto } from './dto/update-theme.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ThemingService {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
async getCurrentTheme() {
|
||||
try {
|
||||
const theme = await this.prisma.themeConfig.findFirst({
|
||||
where: { isActive: true },
|
||||
});
|
||||
|
||||
if (!theme) {
|
||||
// Return default default if no DB record
|
||||
return {
|
||||
gameTitle: 'Game Calendar',
|
||||
primaryColor: '#000000',
|
||||
secondaryColor: '#ffffff',
|
||||
backgroundColor: '#1a1a1a',
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
return theme;
|
||||
} catch (error) {
|
||||
console.error('Error fetching theme:', error);
|
||||
// Fallback to default on error
|
||||
return {
|
||||
gameTitle: 'Game Calendar',
|
||||
primaryColor: '#000000',
|
||||
secondaryColor: '#ffffff',
|
||||
backgroundColor: '#1a1a1a',
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async updateTheme(data: UpdateThemeDto) {
|
||||
// Find existing or create
|
||||
const existing = await this.prisma.themeConfig.findUnique({
|
||||
where: { key: 'current_theme' },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.themeConfig.update({
|
||||
where: { key: 'current_theme' },
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return this.prisma.themeConfig.create({
|
||||
data: {
|
||||
key: 'current_theme',
|
||||
gameTitle: data.gameTitle || 'Game Calendar',
|
||||
primaryColor: data.primaryColor || '#000000',
|
||||
secondaryColor: data.secondaryColor || '#ffffff',
|
||||
backgroundColor: data.backgroundColor || '#1a1a1a',
|
||||
backgroundImage: data.backgroundImage,
|
||||
logoImage: data.logoImage,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user