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 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;
|
||||||
|
|
|
||||||
|
|
@ -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 ');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue