feat: X 카테고리 일정 추가 폼 구현

- 백엔드: /api/admin/x/post-info, /api/admin/x/schedule API 추가
- scraper.js에 fetchSingleTweet 함수 추가 (Nitter로 단일 트윗 조회)
- 프론트엔드: XForm 컴포넌트 생성 (게시글 ID 입력 → 미리보기 → 저장)
- 일정 추가 폼에서 X 카테고리 분기 추가
- API 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-19 12:57:06 +09:00
parent 0a73149849
commit bc3f536ec7
6 changed files with 573 additions and 0 deletions

View file

@ -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 });
}
});
}

View file

@ -5,6 +5,7 @@ import schedulesRoutes from './schedules/index.js';
import statsRoutes from './stats/index.js'; import statsRoutes from './stats/index.js';
import botsRoutes from './admin/bots.js'; import botsRoutes from './admin/bots.js';
import youtubeAdminRoutes from './admin/youtube.js'; import youtubeAdminRoutes from './admin/youtube.js';
import xAdminRoutes from './admin/x.js';
/** /**
* 라우트 통합 * 라우트 통합
@ -31,4 +32,7 @@ export default async function routes(fastify) {
// 관리자 - YouTube 라우트 // 관리자 - YouTube 라우트
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' }); fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });
// 관리자 - X 라우트
fastify.register(xAdminRoutes, { prefix: '/admin/x' });
} }

View file

@ -128,6 +128,58 @@ export function parseTweets(html, username) {
return tweets; 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(/<div class="main-tweet"[^>]*>([\s\S]*?)<\/div>\s*<div class="replies">/);
if (!mainTweetMatch) {
throw new Error('트윗 내용을 파싱할 수 없습니다');
}
const container = mainTweetMatch[1];
// 시간
const timeMatch = container.match(/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/);
const time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
// 텍스트
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
let text = '';
if (contentMatch) {
text = contentMatch[1]
.replace(/<br\s*\/?>/g, '\n')
.replace(/<a[^>]*>([^<]*)<\/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에서 트윗 수집 ( 페이지만) * Nitter에서 트윗 수집 ( 페이지만)
*/ */

View file

@ -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 ### GET /health

View file

@ -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 (
<>
<Toast toast={toast} onClose={() => setToast(null)} />
<form onSubmit={handleSubmit} className="space-y-6">
{/* 게시글 ID 입력 */}
<div className="bg-white rounded-2xl shadow-sm p-8">
<div className="flex items-center gap-2 mb-6">
<Twitter size={24} className="text-black" />
<h2 className="text-lg font-bold text-gray-900">X 게시글</h2>
</div>
<div className="space-y-4">
{/* ID 입력 필드 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
게시글 ID 또는 URL *
</label>
<div className="flex gap-2">
<div className="flex-1 relative">
<Hash
size={18}
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
value={postId}
onChange={(e) => 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}
/>
</div>
{!postInfo ? (
<button
type="button"
onClick={fetchPostInfo}
disabled={loading || !postId.trim()}
className="px-6 py-3 bg-black text-white rounded-xl hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
>
{loading ? (
<>
<Loader2 size={18} className="animate-spin" />
조회 ...
</>
) : (
"조회"
)}
</button>
) : (
<button
type="button"
onClick={handleReset}
className="px-6 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors whitespace-nowrap"
>
다시 입력
</button>
)}
</div>
</div>
{/* 에러 메시지 */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 p-4 bg-red-50 text-red-600 rounded-xl"
>
<AlertCircle size={18} />
<span>{error}</span>
</motion.div>
)}
{/* 게시글 정보 미리보기 */}
{postInfo && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="border border-green-200 bg-green-50 rounded-xl p-6"
>
<div className="flex items-center gap-2 mb-4">
<Check size={18} className="text-green-500" />
<span className="text-sm font-medium text-green-600">
게시글 정보를 가져왔습니다
</span>
</div>
{/* 프로필 */}
{postInfo.profile?.displayName && (
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-green-200">
{postInfo.profile.avatarUrl && (
<img
src={postInfo.profile.avatarUrl}
alt=""
className="w-10 h-10 rounded-full"
/>
)}
<div>
<p className="font-bold text-gray-900">
{postInfo.profile.displayName}
</p>
<p className="text-sm text-gray-500">@{postInfo.username}</p>
</div>
</div>
)}
{/* 제목 (첫 문단) */}
<div className="mb-4">
<p className="text-xs text-gray-400 mb-1">제목 (자동 추출)</p>
<p className="text-lg font-bold text-gray-900">{postInfo.title}</p>
</div>
{/* 전체 내용 */}
<div className="mb-4">
<p className="text-xs text-gray-400 mb-1">전체 내용</p>
<p className="text-gray-700 whitespace-pre-wrap text-sm">
{postInfo.text}
</p>
</div>
{/* 이미지 */}
{postInfo.imageUrls?.length > 0 && (
<div className="mb-4">
<p className="text-xs text-gray-400 mb-2 flex items-center gap-1">
<ImageIcon size={12} />
이미지 ({postInfo.imageUrls.length})
</p>
<div className="grid grid-cols-4 gap-2">
{postInfo.imageUrls.map((url, index) => (
<div
key={index}
className="aspect-square bg-gray-200 rounded-lg overflow-hidden"
>
<img
src={url}
alt={`이미지 ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
)}
{/* 날짜/시간 */}
<div className="text-sm text-gray-500">
<span className="text-gray-400">게시:</span>{" "}
{postInfo.date} {postInfo.time}
</div>
</motion.div>
)}
</div>
</div>
{/* 버튼 */}
<div className="flex items-center justify-end gap-4">
<button
type="button"
onClick={() => navigate("/admin/schedule")}
className="px-6 py-3 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors font-medium"
>
취소
</button>
<button
type="submit"
disabled={!postInfo || saving}
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<>
<Loader2 size={18} className="animate-spin" />
저장 ...
</>
) : (
<>
<Save size={18} />
추가하기
</>
)}
</button>
</div>
</form>
</>
);
}
export default XForm;

View file

@ -6,6 +6,7 @@ import useAdminAuth from "../../../../../hooks/useAdminAuth";
import * as categoriesApi from "../../../../../api/admin/categories"; import * as categoriesApi from "../../../../../api/admin/categories";
import CategorySelector from "./components/CategorySelector"; import CategorySelector from "./components/CategorySelector";
import YouTubeForm from "./YouTubeForm"; import YouTubeForm from "./YouTubeForm";
import XForm from "./XForm";
// ID // ID
const CATEGORY_IDS = { const CATEGORY_IDS = {
@ -52,6 +53,9 @@ function ScheduleFormPage() {
case CATEGORY_IDS.YOUTUBE: case CATEGORY_IDS.YOUTUBE:
return <YouTubeForm />; return <YouTubeForm />;
case CATEGORY_IDS.X:
return <XForm />;
// ( ) // ( )
default: default:
return ( return (