diff --git a/backend/models/SundayMaple.js b/backend/models/SundayMaple.js new file mode 100644 index 0000000..80c237f --- /dev/null +++ b/backend/models/SundayMaple.js @@ -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, +}); diff --git a/backend/models/index.js b/backend/models/index.js index 1422c4b..bea4918 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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 }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 8b398f0..bb1d3b7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 7872b0f..36295b9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/routes/sunday-maple.js b/backend/routes/sunday-maple.js new file mode 100644 index 0000000..90920a5 --- /dev/null +++ b/backend/routes/sunday-maple.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 538e876..e50a017 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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}`); }); diff --git a/backend/services/sundayMaple.js b/backend/services/sundayMaple.js new file mode 100644 index 0000000..c977702 --- /dev/null +++ b/backend/services/sundayMaple.js @@ -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 = /]+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; +} diff --git a/backend/services/sundayMapleCron.js b/backend/services/sundayMapleCron.js new file mode 100644 index 0000000..70aaa8b --- /dev/null +++ b/backend/services/sundayMapleCron.js @@ -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 스케줄 등록'); +}