New task
완료! 추가된 파일 구조는 다음과 같아요:
---
## 추가된 파일
```
autoCoupon/back/src/
├── crawler/
│ ├── crawler.service.ts ← 핵심 크롤러
│ ├── crawler.controller.ts ← 수동 트리거 API
│ └── crawler.module.ts
├── wos/
│ └── wos.module.ts ← WosService export 추가
└── app.module.ts ← CrawlerModule 등록
```
---
## 동작 방식
```mermaid
flowchart LR
A[매 정시 크론] --> B[Reddit JSON API 조회]
B --> C{gift code 언급?}
C -->|No| D[스킵]
C -->|Yes| E[정규식으로 코드 추출]
E --> F{DB 중복?}
F -->|중복| D
F -->|신규| G[addCoupon 호출]
G --> H[전체 유저 자동 지급]
G --> I[Discord Webhook 알림]
```
---
## 배포 후 설정
**1. `.env`에 Discord Webhook URL 추가 (선택)**
```
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy
```
**2. 수동 크롤링 테스트**
```bash
curl -X POST http://localhost:3000/api/crawler/trigger
```
**Discord Webhook 만들기**: 디스코드 서버 → 채널 설정 → 연동 → 웹후크 → 새 웹후크 → URL 복사
This commit is contained in:
parent
15f9fabf96
commit
4514a26575
@ -4,3 +4,4 @@ DB_USERNAME=kakao
|
||||
DB_PASSWORD=486251daKWON@
|
||||
DB_DATABASE=kakao
|
||||
APP_PORT=3000
|
||||
DISCORD_WEBHOOK_URL=
|
||||
|
||||
@ -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 {}
|
||||
|
||||
13
back/src/crawler/crawler.controller.ts
Normal file
13
back/src/crawler/crawler.controller.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
13
back/src/crawler/crawler.module.ts
Normal file
13
back/src/crawler/crawler.module.ts
Normal file
@ -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 {}
|
||||
186
back/src/crawler/crawler.service.ts
Normal file
186
back/src/crawler/crawler.service.ts
Normal file
@ -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<string>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WosCoupon)
|
||||
private wosCouponRepo: Repository<WosCoupon>,
|
||||
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<string>();
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user