diff --git a/backend/src/routes/admin/x.js b/backend/src/routes/admin/x.js new file mode 100644 index 0000000..0cabe8b --- /dev/null +++ b/backend/src/routes/admin/x.js @@ -0,0 +1,136 @@ +import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js'; +import { addOrUpdateSchedule } from '../../services/meilisearch/index.js'; +import { formatDate, formatTime } from '../../utils/date.js'; +import config from '../../config/index.js'; + +const X_CATEGORY_ID = 3; +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 }); + } + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index a986f71..5b9f977 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -5,6 +5,7 @@ import schedulesRoutes from './schedules/index.js'; import statsRoutes from './stats/index.js'; import botsRoutes from './admin/bots.js'; import youtubeAdminRoutes from './admin/youtube.js'; +import xAdminRoutes from './admin/x.js'; /** * 라우트 통합 @@ -31,4 +32,7 @@ export default async function routes(fastify) { // 관리자 - YouTube 라우트 fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' }); + + // 관리자 - X 라우트 + fastify.register(xAdminRoutes, { prefix: '/admin/x' }); } diff --git a/backend/src/services/x/scraper.js b/backend/src/services/x/scraper.js index 47d5295..9020812 100644 --- a/backend/src/services/x/scraper.js +++ b/backend/src/services/x/scraper.js @@ -128,6 +128,58 @@ export function parseTweets(html, username) { return tweets; } +/** + * Nitter에서 단일 트윗 조회 + */ +export async function fetchSingleTweet(nitterUrl, username, postId) { + const url = `${nitterUrl}/${username}/status/${postId}`; + const res = await fetch(url); + + if (!res.ok) { + throw new Error(`트윗을 찾을 수 없습니다 (${res.status})`); + } + + const html = await res.text(); + + // 메인 트윗 파싱 (main-tweet 클래스) + const mainTweetMatch = html.match(/