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(/
]*>([\s\S]*?)<\/div>\s*
/); + if (!mainTweetMatch) { + throw new Error('트윗 내용을 파싱할 수 없습니다'); + } + + const container = mainTweetMatch[1]; + + // 시간 + const timeMatch = container.match(/]*>]*title="([^"]+)"/); + const time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null; + + // 텍스트 + const contentMatch = container.match(/
]*>([\s\S]*?)<\/div>/); + let text = ''; + if (contentMatch) { + text = contentMatch[1] + .replace(//g, '\n') + .replace(/]*>([^<]*)<\/a>/g, '$1') + .replace(/<[^>]+>/g, '') + .trim(); + } + + // 이미지 + const imageUrls = extractImageUrls(container); + + // 프로필 정보 + const profile = extractProfile(html); + + return { + id: postId, + time, + text, + imageUrls, + url: `https://x.com/${username}/status/${postId}`, + profile, + }; +} + /** * Nitter에서 트윗 수집 (첫 페이지만) */ diff --git a/docs/api.md b/docs/api.md index 193056d..c8aeb22 100644 --- a/docs/api.md +++ b/docs/api.md @@ -229,6 +229,50 @@ YouTube 일정 저장 --- +## 관리자 - X (인증 필요) + +### GET /admin/x/post-info +X 게시글 정보 조회 (Nitter 스크래핑) + +**Query Parameters:** +- `postId` - 게시글 ID (필수) +- `username` - 사용자명 (기본: realfromis_9) + +**응답:** +```json +{ + "postId": "1234567890", + "username": "realfromis_9", + "text": "게시글 전체 내용", + "title": "첫 문단 (자동 추출)", + "imageUrls": ["https://pbs.twimg.com/media/..."], + "date": "2026-01-19", + "time": "15:00:00", + "postUrl": "https://x.com/realfromis_9/status/1234567890", + "profile": { + "displayName": "프로미스나인 (fromis_9)", + "avatarUrl": "https://..." + } +} +``` + +### POST /admin/x/schedule +X 일정 저장 + +**Request Body:** +```json +{ + "postId": "1234567890", + "title": "게시글 제목", + "content": "게시글 내용", + "imageUrls": ["https://..."], + "date": "2026-01-19", + "time": "15:00:00" +} +``` + +--- + ## 헬스 체크 ### GET /health diff --git a/frontend/src/pages/pc/admin/schedule/form/XForm.jsx b/frontend/src/pages/pc/admin/schedule/form/XForm.jsx new file mode 100644 index 0000000..1aebf21 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedule/form/XForm.jsx @@ -0,0 +1,333 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { motion } from "framer-motion"; +import { + Twitter, + Hash, + Loader2, + Check, + AlertCircle, + Save, + Image as ImageIcon, +} from "lucide-react"; +import Toast from "../../../../../components/Toast"; +import useToast from "../../../../../hooks/useToast"; + +/** + * X(Twitter) 일정 추가 폼 + * - 게시글 ID 입력 시 자동으로 정보 조회 + */ +function XForm() { + const navigate = useNavigate(); + const { toast, setToast } = useToast(); + + const [postId, setPostId] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [postInfo, setPostInfo] = useState(null); + const [error, setError] = useState(null); + + // 게시글 ID 추출 (URL에서도 추출 가능) + const extractPostId = (input) => { + // 숫자만 있으면 그대로 반환 + if (/^\d+$/.test(input.trim())) { + return input.trim(); + } + // URL에서 추출 + const match = input.match(/status\/(\d+)/); + return match ? match[1] : null; + }; + + // X 게시글 정보 조회 + const fetchPostInfo = async () => { + const id = extractPostId(postId); + if (!id) { + setError("게시글 ID 또는 URL을 입력해주세요."); + return; + } + + setLoading(true); + setError(null); + setPostInfo(null); + + try { + const token = localStorage.getItem("adminToken"); + const response = await fetch( + `/api/admin/x/post-info?postId=${id}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "게시글 정보를 가져올 수 없습니다."); + } + + const data = await response.json(); + setPostInfo(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + // 입력 후 엔터 키 + const handleKeyDown = (e) => { + if (e.key === "Enter") { + e.preventDefault(); + fetchPostInfo(); + } + }; + + // 초기화 + const handleReset = () => { + setPostId(""); + setPostInfo(null); + setError(null); + }; + + // 폼 제출 + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!postInfo) { + setError("먼저 게시글 ID를 입력하고 조회해주세요."); + return; + } + + setSaving(true); + + try { + const token = localStorage.getItem("adminToken"); + + const response = await fetch("/api/admin/x/schedule", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + postId: postInfo.postId, + title: postInfo.title, + content: postInfo.text, + imageUrls: postInfo.imageUrls, + date: postInfo.date, + time: postInfo.time, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "일정 저장에 실패했습니다."); + } + + sessionStorage.setItem( + "scheduleToast", + JSON.stringify({ + type: "success", + message: "X 일정이 추가되었습니다.", + }) + ); + navigate("/admin/schedule"); + } catch (err) { + setToast({ + type: "error", + message: err.message, + }); + } finally { + setSaving(false); + } + }; + + return ( + <> + setToast(null)} /> + +
+ {/* 게시글 ID 입력 */} +
+
+ +

X 게시글

+
+ +
+ {/* ID 입력 필드 */} +
+ +
+
+ + setPostId(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="1234567890 또는 https://x.com/realfromis_9/status/1234567890" + className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent" + disabled={loading || postInfo} + /> +
+ {!postInfo ? ( + + ) : ( + + )} +
+
+ + {/* 에러 메시지 */} + {error && ( + + + {error} + + )} + + {/* 게시글 정보 미리보기 */} + {postInfo && ( + +
+ + + 게시글 정보를 가져왔습니다 + +
+ + {/* 프로필 */} + {postInfo.profile?.displayName && ( +
+ {postInfo.profile.avatarUrl && ( + + )} +
+

+ {postInfo.profile.displayName} +

+

@{postInfo.username}

+
+
+ )} + + {/* 제목 (첫 문단) */} +
+

제목 (자동 추출)

+

{postInfo.title}

+
+ + {/* 전체 내용 */} +
+

전체 내용

+

+ {postInfo.text} +

+
+ + {/* 이미지 */} + {postInfo.imageUrls?.length > 0 && ( +
+

+ + 이미지 ({postInfo.imageUrls.length}개) +

+
+ {postInfo.imageUrls.map((url, index) => ( +
+ {`이미지 +
+ ))} +
+
+ )} + + {/* 날짜/시간 */} +
+ 게시:{" "} + {postInfo.date} {postInfo.time} +
+
+ )} +
+
+ + {/* 버튼 */} +
+ + +
+
+ + ); +} + +export default XForm; diff --git a/frontend/src/pages/pc/admin/schedule/form/index.jsx b/frontend/src/pages/pc/admin/schedule/form/index.jsx index f0bf5ce..bee2b4b 100644 --- a/frontend/src/pages/pc/admin/schedule/form/index.jsx +++ b/frontend/src/pages/pc/admin/schedule/form/index.jsx @@ -6,6 +6,7 @@ import useAdminAuth from "../../../../../hooks/useAdminAuth"; import * as categoriesApi from "../../../../../api/admin/categories"; import CategorySelector from "./components/CategorySelector"; import YouTubeForm from "./YouTubeForm"; +import XForm from "./XForm"; // 카테고리 ID 상수 const CATEGORY_IDS = { @@ -52,6 +53,9 @@ function ScheduleFormPage() { case CATEGORY_IDS.YOUTUBE: return ; + case CATEGORY_IDS.X: + return ; + // 다른 카테고리는 기존 폼으로 리다이렉트 (추후 구현) default: return (