diff --git a/back/.env.example b/back/.env.example index d0c6fef..9eeda6c 100644 --- a/back/.env.example +++ b/back/.env.example @@ -4,3 +4,4 @@ DB_USERNAME=kakao DB_PASSWORD=486251daKWON@ DB_DATABASE=kakao APP_PORT=3000 +DISCORD_WEBHOOK_URL= diff --git a/back/src/app.module.ts b/back/src/app.module.ts index 60d1179..8306c5a 100644 --- a/back/src/app.module.ts +++ b/back/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WosModule } from './wos/wos.module'; +import { CrawlerModule } from './crawler/crawler.module'; import { WosUser } from './entities/wos-user.entity'; import { CouponLog } from './entities/coupon-log.entity'; import { WosCoupon } from './entities/wos-coupon.entity'; @@ -20,6 +21,7 @@ import { WosCoupon } from './entities/wos-coupon.entity'; synchronize: true, }), WosModule, + CrawlerModule, ], }) export class AppModule {} diff --git a/back/src/crawler/crawler.controller.ts b/back/src/crawler/crawler.controller.ts new file mode 100644 index 0000000..5cf3056 --- /dev/null +++ b/back/src/crawler/crawler.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Post, HttpCode } from '@nestjs/common'; +import { CrawlerService } from './crawler.service'; + +@Controller('api/crawler') +export class CrawlerController { + constructor(private readonly crawlerService: CrawlerService) {} + + @Post('trigger') + @HttpCode(200) + async triggerCrawl() { + return this.crawlerService.crawlReddit(); + } +} diff --git a/back/src/crawler/crawler.module.ts b/back/src/crawler/crawler.module.ts new file mode 100644 index 0000000..0a7e78b --- /dev/null +++ b/back/src/crawler/crawler.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CrawlerController } from './crawler.controller'; +import { CrawlerService } from './crawler.service'; +import { WosCoupon } from '../entities/wos-coupon.entity'; +import { WosModule } from '../wos/wos.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([WosCoupon]), WosModule], + controllers: [CrawlerController], + providers: [CrawlerService], +}) +export class CrawlerModule {} diff --git a/back/src/crawler/crawler.service.ts b/back/src/crawler/crawler.service.ts new file mode 100644 index 0000000..00b6611 --- /dev/null +++ b/back/src/crawler/crawler.service.ts @@ -0,0 +1,186 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cron } from '@nestjs/schedule'; +import axios from 'axios'; +import { WosCoupon } from '../entities/wos-coupon.entity'; +import { WosService } from '../wos/wos.service'; + +const REDDIT_SEARCH_URL = + 'https://www.reddit.com/r/WhiteoutSurvival/search.json?q=gift+code+OR+redeem+code+OR+coupon&sort=new&restrict_sr=1&limit=25&t=week'; + +const COMMON_WORDS = new Set([ + 'THE', 'AND', 'FOR', 'ARE', 'BUT', 'NOT', 'YOU', 'ALL', 'ANY', 'CAN', + 'HER', 'WAS', 'ONE', 'OUR', 'OUT', 'DAY', 'GET', 'HAS', 'HIM', 'HIS', + 'HOW', 'ITS', 'NEW', 'NOW', 'OLD', 'SEE', 'TWO', 'WAY', 'WHO', 'BOY', + 'DID', 'ITS', 'LET', 'PUT', 'SAY', 'SHE', 'TOO', 'USE', 'WITH', 'FROM', + 'HAVE', 'MORE', 'WILL', 'HOME', 'EACH', 'ALSO', 'HERE', 'INTO', 'LIKE', + 'LOOK', 'MAKE', 'MANY', 'OVER', 'SUCH', 'TAKE', 'THAN', 'THEM', 'THEN', + 'THEY', 'THIS', 'TIME', 'VERY', 'WELL', 'WERE', 'WHEN', 'YOUR', 'ABOUT', + 'AFTER', 'AGAIN', 'BEING', 'EVERY', 'FIRST', 'FOUND', 'COULD', 'OTHER', + 'THEIR', 'THERE', 'THESE', 'THINK', 'THOSE', 'THREE', 'UNDER', 'WATER', + 'WHERE', 'WHICH', 'WHILE', 'WORLD', 'WOULD', 'WRITE', 'HTTPS', 'HTTP', + 'IMGUR', 'REDDIT', 'GIFT', 'CODE', 'CODES', 'FREE', 'GAME', 'PLAY', + 'JOIN', 'CLAN', 'BEAR', 'WOLF', 'LION', 'TYPE', 'JUST', 'BEEN', 'SOME', + 'MOST', 'SAME', 'ONLY', 'BOTH', 'MUCH', 'NEED', 'WANT', 'GOOD', 'WORK', + 'KNOW', 'GIVE', 'COME', 'BACK', 'HELP', 'LAST', 'LONG', 'HIGH', 'NEXT', + 'OPEN', 'SEEM', 'TURN', 'TELL', 'KEEP', 'HAND', 'BEST', 'EVEN', 'FIND', + 'REAL', 'SHOW', 'SAID', 'DOES', 'DONE', 'WENT', 'USED', 'STILL', 'NEVER', + 'ALWAYS', 'PLEASE', 'ANYONE', 'REALLY', 'THANKS', 'POSTED', 'ANYONE', +]); + +@Injectable() +export class CrawlerService { + private readonly logger = new Logger(CrawlerService.name); + private seenPostIds = new Set(); + + constructor( + @InjectRepository(WosCoupon) + private wosCouponRepo: Repository, + private readonly wosService: WosService, + ) {} + + private extractCodes(text: string): string[] { + const codePattern = /\b([A-Z][A-Z0-9]{4,19})\b/g; + const found = new Set(); + let match: RegExpExecArray | null; + + while ((match = codePattern.exec(text)) !== null) { + const code = match[1]; + if (!COMMON_WORDS.has(code) && /\d/.test(code)) { + found.add(code); + } + } + + const mixedPattern = /\b([A-Z]{2,}[0-9]{2,}[A-Z0-9]*|[A-Z0-9]*[0-9]{2,}[A-Z]{2,}[A-Z0-9]*)\b/g; + while ((match = mixedPattern.exec(text)) !== null) { + const code = match[1]; + if (code.length >= 5 && code.length <= 20) { + found.add(code); + } + } + + return Array.from(found); + } + + private getPostTitle(post: any): string { + try { + return post?.data?.title || ''; + } catch { + return ''; + } + } + + private getPostText(post: any): string { + try { + return post?.data?.selftext || ''; + } catch { + return ''; + } + } + + private isCodeRelatedPost(title: string, text: string): boolean { + const combined = (title + ' ' + text).toLowerCase(); + return ( + combined.includes('gift code') || + combined.includes('giftcode') || + combined.includes('redeem') || + combined.includes('coupon') || + combined.includes('promo code') || + combined.includes('code:') || + combined.includes('code -') + ); + } + + async crawlReddit(): Promise<{ found: number; added: string[] }> { + this.logger.log('Reddit 쿠폰 크롤링 시작...'); + + let posts: any[] = []; + try { + const resp = await axios.get(REDDIT_SEARCH_URL, { + headers: { + 'User-Agent': 'WosCouponBot/1.0', + 'Accept': 'application/json', + }, + timeout: 10000, + }); + posts = resp.data?.data?.children || []; + } catch (err: any) { + this.logger.error(`Reddit API 요청 실패: ${err.message}`); + return { found: 0, added: [] }; + } + + const existingCoupons = await this.wosCouponRepo.find(); + const existingCodes = new Set(existingCoupons.map((c) => c.code)); + + const addedCodes: string[] = []; + + for (const post of posts) { + const postId: string = post?.data?.id || ''; + if (this.seenPostIds.has(postId)) continue; + this.seenPostIds.add(postId); + + const title = this.getPostTitle(post); + const text = this.getPostText(post); + + if (!this.isCodeRelatedPost(title, text)) continue; + + const combined = `${title} ${text}`; + const codes = this.extractCodes(combined); + + for (const code of codes) { + if (existingCodes.has(code)) continue; + + this.logger.log(`새 쿠폰 발견: ${code} (출처: "${title}")`); + + try { + await this.wosService.addCoupon(`Reddit: ${title.slice(0, 50)}`, code); + existingCodes.add(code); + addedCodes.push(code); + this.logger.log(`쿠폰 추가 완료: ${code}`); + } catch (err: any) { + this.logger.error(`쿠폰 추가 실패 [${code}]: ${err.message}`); + } + } + } + + if (addedCodes.length > 0) { + await this.sendDiscordNotification(addedCodes); + } + + this.logger.log(`Reddit 크롤링 완료 - 신규 쿠폰: ${addedCodes.length}개`); + return { found: posts.length, added: addedCodes }; + } + + private async sendDiscordNotification(codes: string[]): Promise { + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + if (!webhookUrl) return; + + const codeList = codes.map((c) => `\`${c}\``).join(', '); + const payload = { + username: 'WOS 쿠폰 봇', + avatar_url: 'https://wos-giftcode.centurygame.com/favicon.ico', + embeds: [ + { + title: '🎁 새 WOS 쿠폰 발견!', + description: `Reddit에서 새로운 쿠폰 **${codes.length}개**를 찾았어요!\n\n${codeList}`, + color: 0x00b0f4, + footer: { text: 'WOS 쿠폰 자동 크롤러 | r/WhiteoutSurvival' }, + timestamp: new Date().toISOString(), + }, + ], + }; + + try { + await axios.post(webhookUrl, payload, { timeout: 5000 }); + this.logger.log(`Discord 알림 전송 완료 (${codes.length}개 쿠폰)`); + } catch (err: any) { + this.logger.error(`Discord 알림 실패: ${err.message}`); + } + } + + @Cron('0 0 * * * *') + async scheduledCrawl() { + await this.crawlReddit(); + } +} diff --git a/back/src/wos/wos.module.ts b/back/src/wos/wos.module.ts index 8d481f6..9fccadf 100644 --- a/back/src/wos/wos.module.ts +++ b/back/src/wos/wos.module.ts @@ -10,5 +10,6 @@ import { WosCoupon } from '../entities/wos-coupon.entity'; imports: [TypeOrmModule.forFeature([WosUser, CouponLog, WosCoupon])], controllers: [WosController], providers: [WosService], + exports: [WosService], }) export class WosModule {}