initial commit

This commit is contained in:
2026-05-11 16:35:26 +09:00
commit 6372c699e6
35 changed files with 8602 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=kakao
DB_PASSWORD=486251daKWON@
DB_DATABASE=kakao
APP_PORT=3000
DISCORD_WEBHOOK_URL=
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
build/
.cache/
*.log
.env
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+5303
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "auto-coupon-back",
"version": "1.0.0",
"description": "WOS 쿠폰 자동 입력 NestJS 백엔드",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/typeorm": "^10.0.0",
"axios": "^1.6.0",
"mysql2": "^3.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"typeorm": "^0.3.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@types/node": "^20.0.0",
"ts-node": "^10.9.0",
"typescript": "^5.0.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
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';
@Module({
imports: [
ScheduleModule.forRoot(),
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT) || 3306,
username: process.env.DB_USERNAME || 'kakao',
password: process.env.DB_PASSWORD || '486251daKWON@',
database: process.env.DB_DATABASE || 'kakao',
entities: [WosUser, CouponLog, WosCoupon],
synchronize: true,
}),
WosModule,
CrawlerModule,
],
})
export class AppModule {}
+13
View 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.crawlWiki();
}
}
+13
View 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 {}
+113
View File
@@ -0,0 +1,113 @@
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 WIKI_URL = 'https://www.whiteoutsurvival.wiki/giftcodes/';
@Injectable()
export class CrawlerService {
private readonly logger = new Logger(CrawlerService.name);
constructor(
@InjectRepository(WosCoupon)
private wosCouponRepo: Repository<WosCoupon>,
private readonly wosService: WosService,
) {}
private extractCodesFromHtml(html: string): string[] {
const found: string[] = [];
const pattern = /<span[^>]*class="code"[^>]*>([^<]+)<\/span>/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(html)) !== null) {
const code = match[1].trim();
if (code.length > 0) found.push(code);
}
return found;
}
async crawlWiki(): Promise<{ found: number; added: string[] }> {
this.logger.log('Wiki 쿠폰 크롤링 시작...');
let html: string;
try {
const resp = await axios.get(WIKI_URL, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html',
},
timeout: 10000,
});
html = resp.data as string;
} catch (err: any) {
this.logger.error(`Wiki 요청 실패: ${err.message}`);
return { found: 0, added: [] };
}
const wikiCodes = this.extractCodesFromHtml(html);
this.logger.log(`Wiki에서 ${wikiCodes.length}개 코드 발견`);
if (wikiCodes.length === 0) return { found: 0, added: [] };
const existingCoupons = await this.wosCouponRepo.find();
const existingCodes = new Set(existingCoupons.map((c) => c.code));
const addedCodes: string[] = [];
for (const code of wikiCodes) {
if (existingCodes.has(code)) continue;
this.logger.log(`새 쿠폰 발견: ${code}`);
try {
await this.wosService.addCoupon(`Wiki: ${code}`, 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(`Wiki 크롤링 완료 - 신규 쿠폰: ${addedCodes.length}`);
return { found: wikiCodes.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: `Wiki에서 새로운 쿠폰 **${codes.length}개**를 찾았어요!\n\n${codeList}`,
color: 0x00b0f4,
footer: { text: 'WOS 쿠폰 자동 크롤러 | whiteoutsurvival.wiki' },
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.crawlWiki();
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('coupon_logs')
export class CouponLog {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 20 })
fid: string;
@Column({ length: 50 })
coupon_code: string;
@Column({ length: 100, nullable: true })
result_msg: string;
@CreateDateColumn()
executed_at: Date;
}
+19
View File
@@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('coupons')
export class Coupon {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true, length: 100 })
code: string;
@Column({ length: 100, nullable: true })
name: string;
@Column({ default: true })
is_active: boolean;
@CreateDateColumn()
created_at: Date;
}
+22
View File
@@ -0,0 +1,22 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('wos_coupons')
export class WosCoupon {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ unique: true, length: 50 })
code: string;
@Column({ default: true })
is_active: boolean;
@Column({ default: false })
is_expired: boolean;
@CreateDateColumn()
created_at: Date;
}
+19
View File
@@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('wos_users')
export class WosUser {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true, length: 20 })
fid: string;
@Column({ length: 50, nullable: true })
nickname: string;
@Column({ type: 'text', nullable: true })
avatar_url: string;
@CreateDateColumn()
created_at: Date;
}
+17
View File
@@ -0,0 +1,17 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: '*',
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type'],
});
const port = process.env.APP_PORT || 3000;
await app.listen(port);
console.log(`Server running on port ${port}`);
}
bootstrap();
+62
View File
@@ -0,0 +1,62 @@
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, HttpCode } from '@nestjs/common';
import { WosService } from './wos.service';
@Controller('api')
export class WosController {
constructor(private readonly wosService: WosService) {}
@Post('player')
@HttpCode(200)
async getPlayer(@Body() body: { fid: string }) {
return this.wosService.getPlayer(body.fid);
}
@Post('redeem-all')
@HttpCode(200)
async redeemAll(@Body() body: { fid: string }) {
return this.wosService.redeemAll(body.fid);
}
@Post('redeem')
@HttpCode(200)
async redeem(@Body() body: { fid: string }) {
return this.wosService.redeemAll(body.fid);
}
@Get('users')
async listUsers() {
return this.wosService.listUsers();
}
@Get('users/search')
async searchUsers(@Query('q') q: string) {
if (!q || q.trim().length === 0) return [];
return this.wosService.searchUsersByNickname(q.trim());
}
@Get('history/:fid')
async getHistory(@Param('fid') fid: string) {
return this.wosService.getHistory(fid);
}
@Get('coupons')
async listCoupons() {
return this.wosService.listCoupons();
}
@Post('coupons')
@HttpCode(200)
async addCoupon(@Body() body: { name: string; code: string }) {
return this.wosService.addCoupon(body.name, body.code);
}
@Patch('coupons/:id')
async toggleCoupon(@Param('id') id: string, @Body() body: { is_active: boolean }) {
return this.wosService.toggleCoupon(Number(id), body.is_active);
}
@Delete('coupons/:id')
async deleteCoupon(@Param('id') id: string) {
return this.wosService.deleteCoupon(Number(id));
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WosController } from './wos.controller';
import { WosService } from './wos.service';
import { WosUser } from '../entities/wos-user.entity';
import { CouponLog } from '../entities/coupon-log.entity';
import { WosCoupon } from '../entities/wos-coupon.entity';
@Module({
imports: [TypeOrmModule.forFeature([WosUser, CouponLog, WosCoupon])],
controllers: [WosController],
providers: [WosService],
exports: [WosService],
})
export class WosModule {}
+304
View File
@@ -0,0 +1,304 @@
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Cron } from '@nestjs/schedule';
import axios from 'axios';
import * as crypto from 'crypto';
import { WosUser } from '../entities/wos-user.entity';
import { CouponLog } from '../entities/coupon-log.entity';
import { WosCoupon } from '../entities/wos-coupon.entity';
const SECRET = 'tB87#kPtkxqOS2';
const WOS_API_BASE = 'https://wos-giftcode-api.centurygame.com/api';
const OCR_SERVER = 'http://127.0.0.1:5001';
const COMMON_HEADERS = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Origin': 'https://wos-giftcode.centurygame.com',
'Referer': 'https://wos-giftcode.centurygame.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
};
const SUCCESS_MSG = '성공';
const ALREADY_MSG = '이미 수령';
@Injectable()
export class WosService {
private readonly logger = new Logger(WosService.name);
constructor(
@InjectRepository(WosUser)
private wosUserRepo: Repository<WosUser>,
@InjectRepository(CouponLog)
private couponLogRepo: Repository<CouponLog>,
@InjectRepository(WosCoupon)
private wosCouponRepo: Repository<WosCoupon>,
) {}
private now(): number {
return Date.now();
}
private makePlayerSign(fid: string, timestamp: number): string {
const raw = `fid=${fid}&time=${timestamp}${SECRET}`;
return crypto.createHash('md5').update(raw).digest('hex');
}
private makeCaptchaSign(fid: string, timestamp: number): string {
const raw = `fid=${fid}&init=0&time=${timestamp}${SECRET}`;
return crypto.createHash('md5').update(raw).digest('hex');
}
private makeGiftSign(fid: string, timestamp: number, cdk: string, captchaCode: string): string {
const raw = `captcha_code=${captchaCode}&cdk=${cdk}&fid=${fid}&time=${timestamp}${SECRET}`;
return crypto.createHash('md5').update(raw).digest('hex');
}
private async solveCaptcha(fid: string): Promise<string> {
const ts = this.now();
const sign = this.makeCaptchaSign(fid, ts);
const resp = await axios.post(
`${WOS_API_BASE}/captcha`,
new URLSearchParams({ fid, time: String(ts), sign, init: '0' }),
{ headers: { ...COMMON_HEADERS } },
);
if (resp.data.code !== 0) throw new Error(`Captcha 요청 실패: ${resp.data.msg}`);
const imgB64: string = resp.data.data.img;
const imgData = imgB64.includes(',') ? imgB64.split(',')[1] : imgB64;
const imgBuffer = Buffer.from(imgData, 'base64');
const ocrResp = await axios.post(OCR_SERVER, imgBuffer, {
headers: { 'Content-Type': 'application/octet-stream' },
timeout: 5000,
});
return String(ocrResp.data).trim().toLowerCase();
}
async getPlayer(fid: string) {
const timestamp = this.now();
const sign = this.makePlayerSign(fid, timestamp);
const response = await axios.post(
`${WOS_API_BASE}/player`,
new URLSearchParams({ fid, time: String(timestamp), sign }),
{ headers: { ...COMMON_HEADERS } },
);
const data = response.data;
if (data.code !== 0) {
throw new HttpException(
data.msg || `오류가 발생했습니다. (${data.err_code})`,
HttpStatus.BAD_REQUEST,
);
}
const playerInfo = data.data;
const existing = await this.wosUserRepo.findOne({ where: { fid } });
if (existing) {
existing.nickname = playerInfo.nickname;
existing.avatar_url = playerInfo.avatar_image;
await this.wosUserRepo.save(existing);
} else {
await this.wosUserRepo.save({ fid, nickname: playerInfo.nickname, avatar_url: playerInfo.avatar_image });
}
return playerInfo;
}
private async redeemOne(
fid: string,
code: string,
maxRetries = 5,
): Promise<{ success: boolean; message: string; err_code?: number }> {
const ts1 = this.now();
const playerSign = this.makePlayerSign(fid, ts1);
await axios.post(
`${WOS_API_BASE}/player`,
new URLSearchParams({ fid, time: String(ts1), sign: playerSign }),
{ headers: { ...COMMON_HEADERS } },
);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
let captchaCode: string;
try {
captchaCode = await this.solveCaptcha(fid);
} catch (err: any) {
return { success: false, message: `캡차 오류: ${err.message}` };
}
const ts2 = this.now();
const giftSign = this.makeGiftSign(fid, ts2, code, captchaCode);
const response = await axios.post(
`${WOS_API_BASE}/gift_code`,
new URLSearchParams({ fid, time: String(ts2), sign: giftSign, cdk: code, captcha_code: captchaCode }),
{ headers: { ...COMMON_HEADERS } },
);
const data = response.data;
this.logger.log(`[${fid}] ${code} attempt=${attempt} captcha=${captchaCode} → code=${data.code} err=${data.err_code} msg=${data.msg}`);
if (data.code === 0) return { success: true, message: SUCCESS_MSG };
if (data.err_code === 40103) {
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 500));
continue;
}
return { success: false, message: `캡차 인식 실패 (${maxRetries}회 시도)`, err_code: data.err_code };
}
return { success: false, message: data.msg || `실패 (${data.err_code})`, err_code: data.err_code };
}
return { success: false, message: '알 수 없는 오류' };
}
async redeemAll(fid: string) {
const activeCoupons = await this.wosCouponRepo.find({
where: { is_expired: false },
});
if (activeCoupons.length === 0) return [];
const existingLogs = await this.couponLogRepo.find({
where: { fid, result_msg: In([SUCCESS_MSG, ALREADY_MSG]) },
});
const alreadySucceeded = new Set(existingLogs.map((l) => l.coupon_code));
const results: { name: string; code: string; status: string; message: string }[] = [];
for (const coupon of activeCoupons) {
if (alreadySucceeded.has(coupon.code)) {
results.push({ name: coupon.name, code: coupon.code, status: 'skipped', message: '이미 지급 완료' });
continue;
}
let result: { success: boolean; message: string; err_code?: number };
try {
result = await this.redeemOne(fid, coupon.code);
} catch (err: any) {
result = { success: false, message: err.message || '오류 발생' };
}
if (result.err_code === 40008 || result.err_code === 40011) {
result = { success: true, message: ALREADY_MSG, err_code: result.err_code };
}
if (result.err_code === 40007) {
await this.wosCouponRepo.update(coupon.id, { is_expired: true, is_active: false });
result.message = '만료된 쿠폰';
}
const status = result.success ? 'success' : 'error';
await this.couponLogRepo.save({ fid, coupon_code: coupon.code, result_msg: result.message });
results.push({ name: coupon.name, code: coupon.code, status, message: result.message });
await new Promise((r) => setTimeout(r, 1500));
}
return results;
}
@Cron('0 0 9 * * *')
async scheduledRedeemAll() {
this.logger.log('자동 쿠폰 지급 시작...');
const users = await this.wosUserRepo.find();
for (const user of users) {
try {
await this.redeemAll(user.fid);
this.logger.log(`[${user.fid}] ${user.nickname} 지급 완료`);
} catch (err: any) {
this.logger.error(`[${user.fid}] 지급 실패: ${err.message}`);
}
await new Promise((r) => setTimeout(r, 2000));
}
this.logger.log('자동 쿠폰 지급 완료');
}
async getHistory(fid: string) {
return this.couponLogRepo.find({
where: { fid },
order: { executed_at: 'DESC' },
take: 100,
});
}
async listUsers() {
return this.wosUserRepo.find({ order: { created_at: 'DESC' } });
}
async searchUsersByNickname(nickname: string) {
return this.wosUserRepo
.createQueryBuilder('u')
.where('u.nickname LIKE :q', { q: `%${nickname}%` })
.orderBy('u.nickname', 'ASC')
.limit(20)
.getMany();
}
async listCoupons() {
return this.wosCouponRepo.find({ order: { created_at: 'DESC' } });
}
async addCoupon(name: string, code: string) {
const exists = await this.wosCouponRepo.findOne({ where: { code } });
if (exists) {
exists.name = name;
exists.is_active = true;
exists.is_expired = false;
await this.wosCouponRepo.save(exists);
} else {
await this.wosCouponRepo.save({ name, code, is_active: true, is_expired: false });
}
this.redeemCouponForAllUsers(code).catch((err) =>
this.logger.error(`신규 쿠폰 전체 지급 실패: ${err.message}`),
);
return this.wosCouponRepo.findOne({ where: { code } });
}
private async redeemCouponForAllUsers(code: string) {
const users = await this.wosUserRepo.find();
for (const user of users) {
const alreadyDone = await this.couponLogRepo.findOne({
where: { fid: user.fid, coupon_code: code, result_msg: In([SUCCESS_MSG, ALREADY_MSG]) },
});
if (alreadyDone) continue;
try {
const result = await this.redeemOne(user.fid, code);
const finalResult =
result.err_code === 40008 || result.err_code === 40011
? { success: true, message: ALREADY_MSG }
: result;
if (result.err_code === 40007) {
const coupon = await this.wosCouponRepo.findOne({ where: { code } });
if (coupon) await this.wosCouponRepo.update(coupon.id, { is_expired: true, is_active: false });
await this.couponLogRepo.save({ fid: user.fid, coupon_code: code, result_msg: '만료된 쿠폰' });
break;
}
await this.couponLogRepo.save({ fid: user.fid, coupon_code: code, result_msg: finalResult.message });
this.logger.log(`[${user.fid}] ${code}${finalResult.message}`);
} catch (err: any) {
this.logger.error(`[${user.fid}] ${code} 지급 실패: ${err.message}`);
}
await new Promise((r) => setTimeout(r, 1500));
}
}
async toggleCoupon(id: number, is_active: boolean) {
await this.wosCouponRepo.update(id, { is_active });
return this.wosCouponRepo.findOne({ where: { id } });
}
async deleteCoupon(id: number) {
await this.wosCouponRepo.delete(id);
return { success: true };
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}