diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js index d355bde..3261eef 100644 --- a/backend/routes/schedules.js +++ b/backend/routes/schedules.js @@ -2,6 +2,7 @@ import express from "express"; import pool from "../lib/db.js"; import { searchSchedules } from "../services/meilisearch.js"; import { saveSearchQuery, getSuggestions } from "../services/suggestions.js"; +import { getXProfile } from "../services/x-bot.js"; const router = express.Router(); @@ -195,4 +196,21 @@ router.post("/sync-search", async (req, res) => { } }); +// X 프로필 정보 조회 +router.get("/x-profile/:username", async (req, res) => { + try { + const { username } = req.params; + const profile = await getXProfile(username); + + if (!profile) { + return res.status(404).json({ error: "프로필을 찾을 수 없습니다." }); + } + + res.json(profile); + } catch (error) { + console.error("X 프로필 조회 오류:", error); + res.status(500).json({ error: "프로필 조회 중 오류가 발생했습니다." }); + } +}); + export default router; diff --git a/backend/services/x-bot.js b/backend/services/x-bot.js index 7b226ac..0822ae7 100644 --- a/backend/services/x-bot.js +++ b/backend/services/x-bot.js @@ -7,6 +7,7 @@ */ import pool from "../lib/db.js"; +import redis from "../lib/redis.js"; import { addOrUpdateSchedule } from "./meilisearch.js"; import { toKST, @@ -15,6 +16,9 @@ import { parseNitterDateTime, } from "../lib/date.js"; +// X 프로필 캐시 키 prefix +const X_PROFILE_CACHE_PREFIX = "x_profile:"; + // YouTube API 키 const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; @@ -137,6 +141,81 @@ async function fetchVideoInfo(videoId) { } } +/** + * Nitter HTML에서 프로필 정보 추출 + */ +function extractProfileFromHtml(html) { + const profile = { + displayName: null, + avatarUrl: null, + }; + + // Display name 추출: 이름 + const nameMatch = html.match( + /]*class="profile-card-fullname"[^>]*>([^<]+)<\/a>/ + ); + if (nameMatch) { + profile.displayName = nameMatch[1].trim(); + } + + // Avatar URL 추출: + const avatarMatch = html.match( + /]*class="profile-card-avatar"[^>]*>[\s\S]*?]*src="([^"]+)"/ + ); + if (avatarMatch) { + profile.avatarUrl = avatarMatch[1]; + } + + return profile; +} + +/** + * X 프로필 정보 캐시에 저장 + */ +async function cacheXProfile(username, profile, nitterUrl) { + try { + // Nitter URL이 상대 경로인 경우 절대 경로로 변환 + let avatarUrl = profile.avatarUrl; + if (avatarUrl && avatarUrl.startsWith("/")) { + avatarUrl = `${nitterUrl}${avatarUrl}`; + } + + const data = { + username, + displayName: profile.displayName, + avatarUrl, + updatedAt: new Date().toISOString(), + }; + + // 7일간 캐시 (604800초) + await redis.setex( + `${X_PROFILE_CACHE_PREFIX}${username}`, + 604800, + JSON.stringify(data) + ); + + console.log(`[X 프로필] ${username} 캐시 저장 완료`); + } catch (error) { + console.error(`[X 프로필] 캐시 저장 실패:`, error.message); + } +} + +/** + * X 프로필 정보 조회 + */ +export async function getXProfile(username) { + try { + const cached = await redis.get(`${X_PROFILE_CACHE_PREFIX}${username}`); + if (cached) { + return JSON.parse(cached); + } + return null; + } catch (error) { + console.error(`[X 프로필] 캐시 조회 실패:`, error.message); + return null; + } +} + /** * Nitter에서 트윗 수집 (첫 페이지만) */ @@ -146,6 +225,12 @@ async function fetchTweetsFromNitter(nitterUrl, username) { const response = await fetch(url); const html = await response.text(); + // 프로필 정보 추출 및 캐싱 + const profile = extractProfileFromHtml(html); + if (profile.displayName || profile.avatarUrl) { + await cacheXProfile(username, profile, nitterUrl); + } + const tweets = []; const tweetContainers = html.split('class="timeline-item '); diff --git a/frontend/src/api/public/schedules.js b/frontend/src/api/public/schedules.js index 6581d54..372e85e 100644 --- a/frontend/src/api/public/schedules.js +++ b/frontend/src/api/public/schedules.js @@ -33,3 +33,8 @@ export async function getSchedule(id) { export async function getCategories() { return fetchApi("/api/schedule-categories"); } + +// X 프로필 정보 조회 +export async function getXProfile(username) { + return fetchApi(`/api/schedules/x-profile/${encodeURIComponent(username)}`); +} diff --git a/frontend/src/pages/pc/public/Schedule.jsx b/frontend/src/pages/pc/public/Schedule.jsx index a664ac3..9585bf8 100644 --- a/frontend/src/pages/pc/public/Schedule.jsx +++ b/frontend/src/pages/pc/public/Schedule.jsx @@ -354,8 +354,8 @@ function Schedule() { // 일정 클릭 핸들러 const handleScheduleClick = (schedule) => { - // 유튜브 카테고리(id=2)는 상세 페이지로 이동 - if (schedule.category_id === 2) { + // 유튜브(id=2), X(id=3) 카테고리는 상세 페이지로 이동 + if (schedule.category_id === 2 || schedule.category_id === 3) { navigate(`/schedule/${schedule.id}`); return; } diff --git a/frontend/src/pages/pc/public/ScheduleDetail.jsx b/frontend/src/pages/pc/public/ScheduleDetail.jsx index b998b5a..a244b6b 100644 --- a/frontend/src/pages/pc/public/ScheduleDetail.jsx +++ b/frontend/src/pages/pc/public/ScheduleDetail.jsx @@ -2,7 +2,7 @@ import { useParams, Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { Clock, Calendar, ExternalLink, ChevronRight, Link2 } from 'lucide-react'; -import { getSchedule } from '../../../api/public/schedules'; +import { getSchedule, getXProfile } from '../../../api/public/schedules'; // 카테고리 ID 상수 const CATEGORY_ID = { @@ -181,6 +181,120 @@ function YoutubeSection({ schedule }) { ); } +// X URL에서 username 추출 +const extractXUsername = (url) => { + if (!url) return null; + const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/); + return match ? match[1] : null; +}; + +// X(트위터) 섹션 컴포넌트 +function XSection({ schedule }) { + const username = extractXUsername(schedule.source_url); + + // 프로필 정보 조회 + const { data: profile } = useQuery({ + queryKey: ['x-profile', username], + queryFn: () => getXProfile(username), + enabled: !!username, + staleTime: 1000 * 60 * 60, // 1시간 + }); + + const displayName = profile?.displayName || schedule.source_name || username || 'Unknown'; + const avatarUrl = profile?.avatarUrl; + + return ( +
+ {/* X 스타일 카드 */} + + {/* 헤더 */} +
+
+ {/* 프로필 이미지 */} + {avatarUrl ? ( + {displayName} + ) : ( +
+ + {displayName.charAt(0).toUpperCase()} + +
+ )} +
+
+ + {displayName} + + + + +
+ {username && ( + @{username} + )} +
+ {/* X 로고 */} + + + +
+
+ + {/* 본문 */} +
+

+ {decodeHtmlEntities(schedule.title)} +

+
+ + {/* 이미지 */} + {schedule.image_url && ( +
+ +
+ )} + + {/* 날짜/시간 */} +
+
+ {formatTime(schedule.time)} + {schedule.time && ·} + {formatFullDate(schedule.date)} +
+
+ + {/* X에서 보기 버튼 */} +
+ + + + + X에서 보기 + +
+ +
+ ); +} + // 기본 섹션 컴포넌트 (다른 카테고리용) function DefaultSection({ schedule }) { return ( @@ -267,12 +381,16 @@ function ScheduleDetail() { switch (schedule.category_id) { case CATEGORY_ID.YOUTUBE: return ; + case CATEGORY_ID.X: + return ; default: return ; } }; const isYoutube = schedule.category_id === CATEGORY_ID.YOUTUBE; + const isX = schedule.category_id === CATEGORY_ID.X; + const hasCustomLayout = isYoutube || isX; return (
@@ -304,7 +422,7 @@ function ScheduleDetail() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }} - className={`${isYoutube ? '' : 'bg-white rounded-3xl shadow-sm p-8'}`} + className={`${hasCustomLayout ? '' : 'bg-white rounded-3xl shadow-sm p-8'}`} > {renderCategorySection()}