import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js'; import { addOrUpdateSchedule } from '../../services/meilisearch/index.js'; import { formatDate, formatTime } from '../../utils/date.js'; import config, { CATEGORY_IDS } from '../../config/index.js'; const X_CATEGORY_ID = CATEGORY_IDS.X; const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080'; const DEFAULT_USERNAME = 'realfromis_9'; /** * X(Twitter) 관련 관리자 라우트 */ export default async function xRoutes(fastify) { const { db, meilisearch } = fastify; /** * GET /api/admin/x/post-info * X 게시글 정보 조회 */ fastify.get('/post-info', { schema: { tags: ['admin/x'], summary: 'X 게시글 정보 조회', security: [{ bearerAuth: [] }], querystring: { type: 'object', properties: { postId: { type: 'string', description: '게시글 ID' }, username: { type: 'string', description: '사용자명 (기본: realfromis_9)' }, }, required: ['postId'], }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { postId, username = DEFAULT_USERNAME } = request.query; // 게시글 ID 유효성 검사 if (!/^\d+$/.test(postId)) { return reply.code(400).send({ error: '유효하지 않은 게시글 ID입니다.' }); } try { const tweet = await fetchSingleTweet(NITTER_URL, username, postId); return { postId: tweet.id, username, text: tweet.text, title: extractTitle(tweet.text), imageUrls: tweet.imageUrls, date: tweet.time ? formatDate(tweet.time) : null, time: tweet.time ? formatTime(tweet.time) : null, postUrl: tweet.url, profile: tweet.profile, }; } catch (err) { fastify.log.error(`X 게시글 조회 오류: ${err.message}`); return reply.code(500).send({ error: err.message }); } }); /** * POST /api/admin/x/schedule * X 일정 저장 */ fastify.post('/schedule', { schema: { tags: ['admin/x'], summary: 'X 일정 저장', security: [{ bearerAuth: [] }], body: { type: 'object', properties: { postId: { type: 'string' }, title: { type: 'string' }, content: { type: 'string' }, imageUrls: { type: 'array', items: { type: 'string' } }, date: { type: 'string' }, time: { type: 'string' }, }, required: ['postId', 'title', 'date'], }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { postId, title, content, imageUrls, date, time } = request.body; try { // 중복 체크 const [existing] = await db.query( 'SELECT id FROM schedule_x WHERE post_id = ?', [postId] ); if (existing.length > 0) { return reply.code(409).send({ error: '이미 등록된 게시글입니다.' }); } // schedules 테이블에 저장 const [result] = await db.query( 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', [X_CATEGORY_ID, title, date, time || null] ); const scheduleId = result.insertId; // schedule_x 테이블에 저장 await db.query( 'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)', [scheduleId, postId, content || null, imageUrls?.length > 0 ? JSON.stringify(imageUrls) : null] ); // Meilisearch 동기화 const [categoryRows] = await db.query( 'SELECT name, color FROM schedule_categories WHERE id = ?', [X_CATEGORY_ID] ); const category = categoryRows[0] || {}; await addOrUpdateSchedule(meilisearch, { id: scheduleId, title, date, time: time || '', category_id: X_CATEGORY_ID, category_name: category.name || '', category_color: category.color || '', source_name: '', }); return { success: true, scheduleId }; } catch (err) { fastify.log.error(`X 일정 저장 오류: ${err.message}`); return reply.code(500).send({ error: err.message }); } }); }