feat: X 카테고리 상세 페이지 및 프로필 캐싱 기능 추가

- X 봇에서 Nitter 프로필 정보(이름, 아바타) 추출 및 Redis 캐싱
- X 프로필 조회 API 추가 (/api/schedules/x-profile/:username)
- X 상세 페이지 UI 구현 (트위터 카드 스타일)
- X 카테고리 클릭 시 상세 페이지로 이동하도록 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-15 14:51:59 +09:00
parent c94849d42a
commit 4f0cf724d0
5 changed files with 230 additions and 4 deletions

View file

@ -2,6 +2,7 @@ import express from "express";
import pool from "../lib/db.js"; import pool from "../lib/db.js";
import { searchSchedules } from "../services/meilisearch.js"; import { searchSchedules } from "../services/meilisearch.js";
import { saveSearchQuery, getSuggestions } from "../services/suggestions.js"; import { saveSearchQuery, getSuggestions } from "../services/suggestions.js";
import { getXProfile } from "../services/x-bot.js";
const router = express.Router(); 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; export default router;

View file

@ -7,6 +7,7 @@
*/ */
import pool from "../lib/db.js"; import pool from "../lib/db.js";
import redis from "../lib/redis.js";
import { addOrUpdateSchedule } from "./meilisearch.js"; import { addOrUpdateSchedule } from "./meilisearch.js";
import { import {
toKST, toKST,
@ -15,6 +16,9 @@ import {
parseNitterDateTime, parseNitterDateTime,
} from "../lib/date.js"; } from "../lib/date.js";
// X 프로필 캐시 키 prefix
const X_PROFILE_CACHE_PREFIX = "x_profile:";
// YouTube API 키 // YouTube API 키
const YOUTUBE_API_KEY = const YOUTUBE_API_KEY =
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; 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 추출: <a class="profile-card-fullname" ...>이름</a>
const nameMatch = html.match(
/<a[^>]*class="profile-card-fullname"[^>]*>([^<]+)<\/a>/
);
if (nameMatch) {
profile.displayName = nameMatch[1].trim();
}
// Avatar URL 추출: <a class="profile-card-avatar" ...><img src="...">
const avatarMatch = html.match(
/<a[^>]*class="profile-card-avatar"[^>]*>[\s\S]*?<img[^>]*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에서 트윗 수집 ( 페이지만) * Nitter에서 트윗 수집 ( 페이지만)
*/ */
@ -146,6 +225,12 @@ async function fetchTweetsFromNitter(nitterUrl, username) {
const response = await fetch(url); const response = await fetch(url);
const html = await response.text(); const html = await response.text();
// 프로필 정보 추출 및 캐싱
const profile = extractProfileFromHtml(html);
if (profile.displayName || profile.avatarUrl) {
await cacheXProfile(username, profile, nitterUrl);
}
const tweets = []; const tweets = [];
const tweetContainers = html.split('class="timeline-item '); const tweetContainers = html.split('class="timeline-item ');

View file

@ -33,3 +33,8 @@ export async function getSchedule(id) {
export async function getCategories() { export async function getCategories() {
return fetchApi("/api/schedule-categories"); return fetchApi("/api/schedule-categories");
} }
// X 프로필 정보 조회
export async function getXProfile(username) {
return fetchApi(`/api/schedules/x-profile/${encodeURIComponent(username)}`);
}

View file

@ -354,8 +354,8 @@ function Schedule() {
// //
const handleScheduleClick = (schedule) => { const handleScheduleClick = (schedule) => {
// (id=2) // (id=2), X(id=3)
if (schedule.category_id === 2) { if (schedule.category_id === 2 || schedule.category_id === 3) {
navigate(`/schedule/${schedule.id}`); navigate(`/schedule/${schedule.id}`);
return; return;
} }

View file

@ -2,7 +2,7 @@ import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Clock, Calendar, ExternalLink, ChevronRight, Link2 } from 'lucide-react'; import { Clock, Calendar, ExternalLink, ChevronRight, Link2 } from 'lucide-react';
import { getSchedule } from '../../../api/public/schedules'; import { getSchedule, getXProfile } from '../../../api/public/schedules';
// ID // ID
const CATEGORY_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 (
<div className="max-w-2xl mx-auto">
{/* X 스타일 카드 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-2xl border border-gray-200 overflow-hidden"
>
{/* 헤더 */}
<div className="p-5 pb-0">
<div className="flex items-center gap-3">
{/* 프로필 이미지 */}
{avatarUrl ? (
<img
src={avatarUrl}
alt={displayName}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center">
<span className="text-white font-bold text-lg">
{displayName.charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900">
{displayName}
</span>
<svg className="w-5 h-5 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/>
</svg>
</div>
{username && (
<span className="text-sm text-gray-500">@{username}</span>
)}
</div>
{/* X 로고 */}
<svg className="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</div>
</div>
{/* 본문 */}
<div className="p-5">
<p className="text-gray-900 text-[17px] leading-relaxed whitespace-pre-wrap">
{decodeHtmlEntities(schedule.title)}
</p>
</div>
{/* 이미지 */}
{schedule.image_url && (
<div className="px-5 pb-3">
<img
src={schedule.image_url}
alt=""
className="w-full rounded-2xl border border-gray-100"
/>
</div>
)}
{/* 날짜/시간 */}
<div className="px-5 py-4 border-t border-gray-100">
<div className="flex items-center gap-2 text-gray-500 text-[15px]">
<span>{formatTime(schedule.time)}</span>
{schedule.time && <span>·</span>}
<span>{formatFullDate(schedule.date)}</span>
</div>
</div>
{/* X에서 보기 버튼 */}
<div className="px-5 py-4 border-t border-gray-100 bg-gray-50/50">
<a
href={schedule.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-900 hover:bg-black text-white rounded-full font-medium transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
X에서 보기
</a>
</div>
</motion.div>
</div>
);
}
// ( ) // ( )
function DefaultSection({ schedule }) { function DefaultSection({ schedule }) {
return ( return (
@ -267,12 +381,16 @@ function ScheduleDetail() {
switch (schedule.category_id) { switch (schedule.category_id) {
case CATEGORY_ID.YOUTUBE: case CATEGORY_ID.YOUTUBE:
return <YoutubeSection schedule={schedule} />; return <YoutubeSection schedule={schedule} />;
case CATEGORY_ID.X:
return <XSection schedule={schedule} />;
default: default:
return <DefaultSection schedule={schedule} />; return <DefaultSection schedule={schedule} />;
} }
}; };
const isYoutube = schedule.category_id === CATEGORY_ID.YOUTUBE; const isYoutube = schedule.category_id === CATEGORY_ID.YOUTUBE;
const isX = schedule.category_id === CATEGORY_ID.X;
const hasCustomLayout = isYoutube || isX;
return ( return (
<div className="min-h-[calc(100vh-64px)] bg-gray-50"> <div className="min-h-[calc(100vh-64px)] bg-gray-50">
@ -304,7 +422,7 @@ function ScheduleDetail() {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }} 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()} {renderCategorySection()}
</motion.div> </motion.div>