133 lines
4.4 KiB
JavaScript
133 lines
4.4 KiB
JavaScript
|
|
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;
|
||
|
|
}
|