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:
parent
c94849d42a
commit
4f0cf724d0
5 changed files with 230 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 추출: <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에서 트윗 수집 (첫 페이지만)
|
||||
*/
|
||||
|
|
@ -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 ');
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 }) {
|
||||
return (
|
||||
|
|
@ -267,12 +381,16 @@ function ScheduleDetail() {
|
|||
switch (schedule.category_id) {
|
||||
case CATEGORY_ID.YOUTUBE:
|
||||
return <YoutubeSection schedule={schedule} />;
|
||||
case CATEGORY_ID.X:
|
||||
return <XSection schedule={schedule} />;
|
||||
default:
|
||||
return <DefaultSection schedule={schedule} />;
|
||||
}
|
||||
};
|
||||
|
||||
const isYoutube = schedule.category_id === CATEGORY_ID.YOUTUBE;
|
||||
const isX = schedule.category_id === CATEGORY_ID.X;
|
||||
const hasCustomLayout = isYoutube || isX;
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
|
||||
|
|
@ -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()}
|
||||
</motion.div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue