썬데이 메이플 자동 수집 백엔드
- sequelize 모델: sunday_maple (week_start/variant/image_url 등)
- services/sundayMaple.js: 이벤트 API 조회 + HTML 스크래핑 + rustfs 업로드 + DB 저장 공용 함수
- services/sundayMapleCron.js: 금요일 09:00 KST에 10초 간격 폴링 (최대 5분)
- routes/sunday-maple.js: GET /api/sunday-maple/current
* 금/토/일만 available
* DB 없으면 lazy fetch 시도 (cron miss 대비)
- 제목 파싱으로 normal/special variant 판별
- rustfs 경로: maplestory/sunday/{week_start}.png
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc0c2b22f0
commit
a94137bd4d
8 changed files with 264 additions and 6 deletions
21
backend/models/SundayMaple.js
Normal file
21
backend/models/SundayMaple.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../lib/db.js';
|
||||
|
||||
export const SundayMaple = sequelize.define('SundayMaple', {
|
||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||
// 해당 주의 금요일 (KST, YYYY-MM-DD)
|
||||
week_start: { type: DataTypes.DATEONLY, allowNull: false, unique: true },
|
||||
variant: {
|
||||
type: DataTypes.ENUM('normal', 'special'),
|
||||
allowNull: false,
|
||||
defaultValue: 'normal',
|
||||
},
|
||||
event_post_id: { type: DataTypes.STRING(50) },
|
||||
event_post_url: { type: DataTypes.STRING(500) },
|
||||
source_image_url: { type: DataTypes.STRING(500) },
|
||||
image_url: { type: DataTypes.STRING(500), allowNull: false },
|
||||
fetched_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
|
||||
}, {
|
||||
tableName: 'sunday_maple',
|
||||
underscored: true,
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Image } from './Image.js';
|
||||
import { Menu } from './Menu.js';
|
||||
import { SundayMaple } from './SundayMaple.js';
|
||||
import { BossCrystalBoss } from './boss-crystal/Boss.js';
|
||||
import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js';
|
||||
import { Symbol } from './symbol/Symbol.js';
|
||||
|
|
@ -24,4 +25,4 @@ Symbol.hasMany(SymbolLevel, {
|
|||
});
|
||||
SymbolLevel.belongsTo(Symbol, { foreignKey: 'symbol_id', as: 'symbol' });
|
||||
|
||||
export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty, Symbol, SymbolLevel };
|
||||
export { Image, Menu, SundayMaple, BossCrystalBoss, BossCrystalBossDifficulty, Symbol, SymbolLevel };
|
||||
|
|
|
|||
17
backend/package-lock.json
generated
17
backend/package-lock.json
generated
|
|
@ -11,10 +11,12 @@
|
|||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"axios": "^1.9.0",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.20",
|
||||
"express": "^5.1.0",
|
||||
"mariadb": "^3.4.0",
|
||||
"multer": "^2.0.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"sequelize": "^6.37.5",
|
||||
"sharp": "^0.34.1"
|
||||
}
|
||||
|
|
@ -2314,6 +2316,12 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.20",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -3051,6 +3059,15 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@
|
|||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"sequelize": "^6.37.5",
|
||||
"mysql2": "^3.14.1",
|
||||
"mariadb": "^3.4.0",
|
||||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"axios": "^1.9.0",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.20",
|
||||
"express": "^5.1.0",
|
||||
"mariadb": "^3.4.0",
|
||||
"multer": "^2.0.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"sequelize": "^6.37.5",
|
||||
"sharp": "^0.34.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
backend/routes/sunday-maple.js
Normal file
45
backend/routes/sunday-maple.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Router } from 'express';
|
||||
import { SundayMaple } from '../models/index.js';
|
||||
import {
|
||||
currentWeekFriday,
|
||||
isInSundayWindow,
|
||||
fetchAndSaveSundayMaple,
|
||||
} from '../services/sundayMaple.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 이번 주 썬데이 메이플 조회 (금~일만 available)
|
||||
router.get('/current', async (req, res) => {
|
||||
try {
|
||||
if (!isInSundayWindow()) {
|
||||
return res.json({ available: false });
|
||||
}
|
||||
|
||||
const weekStart = currentWeekFriday();
|
||||
let row = await SundayMaple.findOne({ where: { week_start: weekStart } });
|
||||
|
||||
// DB에 없으면 lazy fetch 시도 (cron이 놓친 경우 대비)
|
||||
if (!row) {
|
||||
try {
|
||||
row = await fetchAndSaveSundayMaple();
|
||||
} catch (err) {
|
||||
console.error('[sunday-maple] lazy fetch 실패:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!row) return res.json({ available: false });
|
||||
|
||||
res.json({
|
||||
available: true,
|
||||
variant: row.variant,
|
||||
week_start: row.week_start,
|
||||
image_url: row.image_url,
|
||||
event_post_url: row.event_post_url,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[sunday-maple/current] 오류:', err);
|
||||
res.status(500).json({ error: '조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -7,8 +7,10 @@ import bossCrystalRoutes from './routes/boss-crystal.js';
|
|||
import characterRoutes from './routes/character.js';
|
||||
import imageRoutes from './routes/images.js';
|
||||
import symbolRoutes from './routes/symbol.js';
|
||||
import sundayMapleRoutes from './routes/sunday-maple.js';
|
||||
import { sequelize } from './lib/db.js';
|
||||
import './models/index.js';
|
||||
import { scheduleSundayMapleCron } from './services/sundayMapleCron.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
|
@ -27,6 +29,7 @@ app.use('/api/boss-crystal', bossCrystalRoutes);
|
|||
app.use('/api/character', characterRoutes);
|
||||
app.use('/api/images', imageRoutes);
|
||||
app.use('/api/symbols', symbolRoutes);
|
||||
app.use('/api/sunday-maple', sundayMapleRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
|
|
@ -40,6 +43,8 @@ async function start() {
|
|||
await sequelize.sync();
|
||||
console.log('테이블 동기화 완료');
|
||||
|
||||
scheduleSundayMapleCron();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`서버 시작: http://localhost:${PORT}`);
|
||||
});
|
||||
|
|
|
|||
132
backend/services/sundayMaple.js
Normal file
132
backend/services/sundayMaple.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import timezone from 'dayjs/plugin/timezone.js';
|
||||
import { SundayMaple } from '../models/index.js';
|
||||
import { uploadObject, getPublicUrl } from '../lib/s3.js';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
const KST = 'Asia/Seoul';
|
||||
|
||||
const NEXON_API_BASE = 'https://open.api.nexon.com';
|
||||
const RUSTFS_PREFIX = 'sunday';
|
||||
|
||||
/**
|
||||
* 제목을 파싱하여 썬데이 메이플 변형을 반환
|
||||
* @returns {'normal'|'special'|null}
|
||||
*/
|
||||
export function detectVariant(title) {
|
||||
if (!title) return null;
|
||||
const t = title.trim();
|
||||
if (!t.includes('썬데이') || !t.includes('메이플')) return null;
|
||||
return t.includes('스페셜') ? 'special' : 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* 금요일 기준의 이번 주차 시작일 (YYYY-MM-DD, KST) 반환
|
||||
* 금/토/일 → 직전 금요일
|
||||
* 월/화/수/목 → 이전 주 금요일
|
||||
*/
|
||||
export function currentWeekFriday(now = dayjs().tz(KST)) {
|
||||
const dow = now.day(); // 0=일 ... 5=금 6=토
|
||||
// 금요일 기준 diff: 금(5)이면 0, 토(6)이면 -1, 일(0)이면 -2, 월(1)이면 -3 ...
|
||||
const diff = dow >= 5 ? dow - 5 : dow + 2;
|
||||
return now.startOf('day').subtract(diff, 'day').format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/**
|
||||
* 금~일요일인지
|
||||
*/
|
||||
export function isInSundayWindow(now = dayjs().tz(KST)) {
|
||||
const dow = now.day();
|
||||
return dow === 5 || dow === 6 || dow === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nexon 이벤트 API에서 이번 주 썬데이 메이플 항목 찾기
|
||||
*/
|
||||
async function findSundayPost() {
|
||||
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/notice-event`, {
|
||||
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
|
||||
});
|
||||
const list = data.event_notice || [];
|
||||
const weekStart = currentWeekFriday();
|
||||
// 제목 매칭 + 이번 주 시작
|
||||
return list.find((n) => {
|
||||
if (detectVariant(n.title) === null) return null;
|
||||
const start = n.date_event_start ? dayjs(n.date_event_start).tz(KST).format('YYYY-MM-DD') : null;
|
||||
// date_event_start가 금요일 아닐 수 있음 → 시작일이 해당 주 금요일과 같은 주인지 체크
|
||||
if (!start) return false;
|
||||
const sameWeek = dayjs(start).tz(KST).isSame(dayjs(weekStart).tz(KST), 'week')
|
||||
// dayjs isSame 'week' 는 일요일 기준이라 금/토 확인 필요 → 직접 비교
|
||||
|| Math.abs(dayjs(start).diff(dayjs(weekStart), 'day')) <= 2;
|
||||
return sameWeek;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 HTML에서 첫 번째 컨텐츠 PNG URL 추출
|
||||
*/
|
||||
async function scrapeImageUrl(postUrl) {
|
||||
const { data: html } = await axios.get(postUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
});
|
||||
const re = /<img[^>]+src=["'](https:\/\/lwi\.nexon\.com\/maplestory\/\d{4}\/\d{4}_board\/[^"']+\.png)["']/i;
|
||||
const m = html.match(re);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nexon CDN에서 이미지 다운로드 → rustfs 업로드 → URL 반환
|
||||
*/
|
||||
async function downloadAndUpload(sourceUrl, weekStart) {
|
||||
const { data: buffer } = await axios.get(sourceUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
});
|
||||
const key = `${RUSTFS_PREFIX}/${weekStart}.png`;
|
||||
await uploadObject(key, Buffer.from(buffer), 'image/png');
|
||||
return { key, url: getPublicUrl(key) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 핵심: 이번 주 썬데이 메이플을 Nexon에서 찾아 rustfs에 업로드하고 DB 저장.
|
||||
* 이미 DB에 있으면 그대로 반환 (noop).
|
||||
*
|
||||
* @returns SundayMaple | null (게시글 아직 없음)
|
||||
*/
|
||||
export async function fetchAndSaveSundayMaple() {
|
||||
const weekStart = currentWeekFriday();
|
||||
|
||||
// 이미 저장되어 있으면 스킵
|
||||
const existing = await SundayMaple.findOne({ where: { week_start: weekStart } });
|
||||
if (existing) return existing;
|
||||
|
||||
const post = await findSundayPost();
|
||||
if (!post) return null;
|
||||
|
||||
const variant = detectVariant(post.title);
|
||||
const sourceUrl = await scrapeImageUrl(post.url);
|
||||
if (!sourceUrl) {
|
||||
console.warn('[sunday-maple] 이미지 URL 못찾음:', post.url);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { url: uploadedUrl } = await downloadAndUpload(sourceUrl, weekStart);
|
||||
|
||||
// 업로드 완료 후 DB insert (동시성 대비 findOrCreate)
|
||||
const [row] = await SundayMaple.findOrCreate({
|
||||
where: { week_start: weekStart },
|
||||
defaults: {
|
||||
week_start: weekStart,
|
||||
variant,
|
||||
event_post_id: String(post.notice_id || ''),
|
||||
event_post_url: post.url,
|
||||
source_image_url: sourceUrl,
|
||||
image_url: uploadedUrl,
|
||||
},
|
||||
});
|
||||
console.log('[sunday-maple] 저장 완료:', weekStart, variant, uploadedUrl);
|
||||
return row;
|
||||
}
|
||||
35
backend/services/sundayMapleCron.js
Normal file
35
backend/services/sundayMapleCron.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import cron from 'node-cron';
|
||||
import { fetchAndSaveSundayMaple } from './sundayMaple.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 10_000; // 10초 간격
|
||||
const MAX_DURATION_MS = 5 * 60 * 1000; // 최대 5분
|
||||
|
||||
/**
|
||||
* 금요일 9시부터 10초 간격 폴링 → 찾으면 저장 후 종료, 5분 타임아웃.
|
||||
*/
|
||||
async function runPolling() {
|
||||
const started = Date.now();
|
||||
console.log('[sunday-maple cron] 폴링 시작');
|
||||
|
||||
while (Date.now() - started < MAX_DURATION_MS) {
|
||||
try {
|
||||
const row = await fetchAndSaveSundayMaple();
|
||||
if (row) {
|
||||
console.log('[sunday-maple cron] 저장 완료 → 종료');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[sunday-maple cron] 폴링 중 오류:', err.message);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
console.log('[sunday-maple cron] 5분 타임아웃 → 종료 (lazy fallback이 커버)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 매주 금요일 09:00 KST 실행
|
||||
*/
|
||||
export function scheduleSundayMapleCron() {
|
||||
cron.schedule('0 9 * * 5', runPolling, { timezone: 'Asia/Seoul' });
|
||||
console.log('[sunday-maple cron] 매주 금요일 09:00 KST 스케줄 등록');
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue