feat(x-schedule): 링크 미리보기(OG) 카드 추가 - 하이브리드

트윗의 외부 링크에 대해 미리보기 카드 표시.
- Nitter가 렌더링한 카드 우선 사용 (extractCard)
- Nitter 카드가 비어있으면 본문 URL로 OG 직접 추출 (og.js)
  - YouTube/Instagram 등 복구, HTML 엔티티 디코딩 포함
  - TikTok 등 봇 차단 사이트는 Nitter 카드로 커버
- schedule_x.card_data 컬럼 + getScheduleDetail 응답에 card 포함
- 가로 레이아웃 카드 (왼쪽 이미지 + 오른쪽 텍스트)
- CardImage: 이미지 로드 실패 시 fallback 아이콘 (인스타 CDN 만료 대비)
- 자체 영상/이미지가 있으면 OG 카드 숨김 (중복 방지)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-05-31 19:34:42 +09:00
parent d3d3c9cf75
commit 8b8b9a7f53
6 changed files with 264 additions and 3 deletions

View file

@ -215,6 +215,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
sx.content as x_content,
sx.image_urls as x_image_urls,
sx.video_thumbnails as x_video_thumbnails,
sx.card_data as x_card_data,
sv.broadcaster as variety_broadcaster,
sv.replay_url as variety_replay_url,
svi.medium_url as variety_thumbnail_url,
@ -299,6 +300,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
result.content = s.x_content || null;
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
result.videoThumbnails = s.x_video_thumbnails ? JSON.parse(s.x_video_thumbnails) : [];
result.card = s.x_card_data ? JSON.parse(s.x_card_data) : null;
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
if (getXProfile) {

View file

@ -1,5 +1,6 @@
import fp from 'fastify-plugin';
import { fetchTweets, fetchAllTweets, fetchProfile as fetchNitterProfile, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
import { fetchOgCard, extractFirstUrl } from './og.js';
import { fetchVideoInfo } from '../youtube/api.js';
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
import { withTransaction } from '../../utils/transaction.js';
@ -51,6 +52,24 @@ async function xBotPlugin(fastify, opts) {
/**
* 트윗을 DB에 저장
*/
/**
* 트윗 카드 확정
* - Nitter가 카드가 유효(제목/이미지 보유)하면 그대로 사용
* - 비었거나 없으면 본문 URL로 OG 직접 추출 (YouTube 복구)
*/
async function resolveCard(tweet) {
if (tweet.card && (tweet.card.title || tweet.card.image)) {
return tweet.card;
}
const url = extractFirstUrl(tweet.text);
if (!url) return null;
try {
return await fetchOgCard(url);
} catch {
return null;
}
}
async function saveTweet(tweet, username) {
// 중복 체크 (post_id로) - 트랜잭션 전에 수행
const [existing] = await fastify.db.query(
@ -82,6 +101,9 @@ async function xBotPlugin(fastify, opts) {
// 리트윗인 경우 원본 작성자를 username으로 사용
const tweetUsername = tweet.originalUsername || username;
// 카드 확정: Nitter 카드 우선, 비어있으면 본문 URL로 OG 직접 추출 (fallback)
const card = await resolveCard(tweet);
// 트랜잭션으로 INSERT 작업 수행
return withTransaction(fastify.db, async (connection) => {
// schedules 테이블에 저장
@ -93,7 +115,7 @@ async function xBotPlugin(fastify, opts) {
// schedule_x 테이블에 저장
await connection.query(
'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls, video_thumbnails) VALUES (?, ?, ?, ?, ?, ?)',
'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls, video_thumbnails, card_data) VALUES (?, ?, ?, ?, ?, ?, ?)',
[
scheduleId,
tweet.id,
@ -101,6 +123,7 @@ async function xBotPlugin(fastify, opts) {
tweet.text,
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
tweet.videoThumbnails?.length > 0 ? JSON.stringify(tweet.videoThumbnails) : null,
card ? JSON.stringify(card) : null,
]
);

View file

@ -0,0 +1,98 @@
/**
* URL에서 Open Graph 메타데이터 직접 추출
* Nitter가 카드를 비워서 (주로 YouTube) fallback으로 사용.
*/
const OG_TIMEOUT = 8000;
/**
* HTML 엔티티 디코딩 (이미지 URL의 &amp; 처리)
*/
function decodeEntities(str) {
if (!str) return str;
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&#x27;/gi, "'")
.replace(/&#x2F;/gi, '/')
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)));
}
/**
* HTML에서 og:/twitter: ( )
*/
function pickMeta(html, prop) {
const patterns = [
new RegExp(`<meta[^>]+(?:property|name)="${prop}"[^>]+content="([^"]*)"`, 'i'),
new RegExp(`<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="${prop}"`, 'i'),
];
for (const re of patterns) {
const m = html.match(re);
if (m) return decodeEntities(m[1]);
}
return null;
}
/**
* URL의 OG 메타데이터를 가져와 카드 형식으로 반환
* @param {string} url - 대상 URL
* @returns {Promise<object|null>} { url, title, description, destination, image } 또는 null
*/
export async function fetchOgCard(url) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), OG_TIMEOUT);
try {
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; fromis9-bot)' },
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) return null;
const html = await res.text();
const title = pickMeta(html, 'og:title') || pickMeta(html, 'twitter:title');
const image = pickMeta(html, 'og:image') || pickMeta(html, 'twitter:image');
// 제목이나 이미지가 없으면 의미 있는 카드가 아님
if (!title && !image) return null;
const description = pickMeta(html, 'og:description') || pickMeta(html, 'twitter:description') || '';
const siteName = pickMeta(html, 'og:site_name');
// 최종 리다이렉트된 호스트를 destination으로
let destination = siteName;
if (!destination) {
try { destination = new URL(res.url).hostname.replace(/^www\./, ''); } catch { /* noop */ }
}
return {
url,
title: title || '',
description: description.slice(0, 200),
destination: destination || '',
image: image || null,
};
} catch {
clearTimeout(timeoutId);
return null;
}
}
/**
* 트윗 본문 텍스트에서 외부 URL 추출 (OG fetch 대상)
* t.co / 단축 URL / 일반 http(s) 모두 대상, x.com 자기 링크는 제외
*/
export function extractFirstUrl(text) {
if (!text) return null;
const matches = text.match(/https?:\/\/[^\s]+/g);
if (!matches) return null;
for (const u of matches) {
const clean = u.replace(/[)\]}.,]+$/, '');
if (/(?:^https?:\/\/)?(?:www\.)?(?:x\.com|twitter\.com)\//i.test(clean)) continue;
return clean;
}
return null;
}

View file

@ -67,6 +67,40 @@ export function extractVideoThumbnails(html) {
return [...new Set(urls)];
}
/**
* HTML에서 링크 미리보기 카드(Open Graph) 추출
* Nitter가 트윗의 외부 링크에 대해 카드를 직접 렌더링하므로 그대로 파싱.
* 트윗당 최대 1. 카드가 없으면 null.
*/
export function extractCard(html) {
if (!html.includes('card-container')) return null;
const hrefMatch = html.match(/<a class="card-container" href="([^"]+)"/);
if (!hrefMatch) return null;
const stripTags = s => (s ? s.replace(/<[^>]+>/g, '').trim() : '');
const titleMatch = html.match(/<h2 class="card-title">([\s\S]*?)<\/h2>/);
const descMatch = html.match(/<p class="card-description">([\s\S]*?)<\/p>/);
const destMatch = html.match(/<span class="card-destination">([\s\S]*?)<\/span>/);
const imgMatch = html.match(/<img src="(\/pic\/card_img[^"]+)"/);
let image = null;
if (imgMatch) {
image = `https://pbs.twimg.com/${decodeURIComponent(imgMatch[1].replace(/^\/pic\//, ''))}`;
}
const title = stripTags(titleMatch?.[1]);
// 제목이나 이미지 중 하나는 있어야 유효한 카드
if (!title && !image) return null;
return {
url: hrefMatch[1],
title,
description: stripTags(descMatch?.[1]),
destination: stripTags(destMatch?.[1]),
image,
};
}
/**
* 텍스트에서 유튜브 videoId 추출
*/
@ -200,9 +234,10 @@ export function parseTweets(html, username, options = {}) {
text = extractTextFromHtml(contentMatch[1]);
}
// 이미지 / 영상 썸네일
// 이미지 / 영상 썸네일 / 링크 카드
const imageUrls = extractImageUrls(container);
const videoThumbnails = extractVideoThumbnails(container);
const card = extractCard(container);
tweets.push({
id,
@ -210,6 +245,7 @@ export function parseTweets(html, username, options = {}) {
text,
imageUrls,
videoThumbnails,
card,
isRetweet,
originalUsername,
// 긴 트윗(280자 초과)이 …로 잘렸는지 여부 (hydrate 대상 판별용)
@ -250,9 +286,10 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
text = extractTextFromHtml(contentMatch[1]);
}
// 이미지 / 영상 썸네일
// 이미지 / 영상 썸네일 / 링크 카드
const imageUrls = extractImageUrls(container);
const videoThumbnails = extractVideoThumbnails(container);
const card = extractCard(container);
// 프로필 정보
const profile = extractProfile(html);
@ -263,6 +300,7 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
text,
imageUrls,
videoThumbnails,
card,
url: `https://x.com/${username}/status/${postId}`,
profile,
};

View file

@ -255,6 +255,25 @@ function MobileYoutubeSection({ schedule }) {
);
}
/**
* 카드 이미지 (로드 실패 fallback 아이콘 인스타 CDN 만료 대비)
*/
function CardImage({ src, className }) {
const [error, setError] = useState(false);
if (!src || error) {
return (
<div className={`${className} flex items-center justify-center bg-gray-100 text-gray-300`}>
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
</div>
);
}
return <img src={src} alt="" className={`${className} object-cover bg-gray-100`} onError={() => setError(true)} />;
}
/**
* Mobile X(트위터) 섹션
*/
@ -418,6 +437,37 @@ function MobileXSection({ schedule }) {
</div>
)}
{/* 링크 미리보기 카드 (Open Graph) — 자체 이미지/영상이 있으면 숨김 */}
{schedule.card?.url && !(schedule.videoThumbnails?.length > 0) && !(schedule.imageUrls?.length > 0) && (
<div className="px-4 pb-3">
<a
href={schedule.card.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-stretch rounded-xl border border-gray-200 overflow-hidden active:bg-gray-50 transition-colors"
>
{schedule.card.image && (
<CardImage src={schedule.card.image} className="w-24 flex-shrink-0" />
)}
<div className="flex-1 min-w-0 p-2.5 flex flex-col justify-center">
{schedule.card.destination && (
<p className="text-[11px] text-gray-400 mb-0.5">{schedule.card.destination}</p>
)}
{schedule.card.title && (
<p className="text-sm font-medium text-gray-900 line-clamp-2">
{decodeHtmlEntities(schedule.card.title)}
</p>
)}
{schedule.card.description && (
<p className="text-xs text-gray-500 line-clamp-2 mt-0.5">
{decodeHtmlEntities(schedule.card.description)}
</p>
)}
</div>
</a>
</div>
)}
{/* 날짜/시간 */}
<div className="px-4 py-3 border-t border-gray-100">
<span className="text-gray-500 text-sm">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>

View file

@ -49,6 +49,25 @@ function linkifyText(text) {
return parts.length > 0 ? parts : text;
}
/**
* 카드 이미지 (로드 실패 fallback 아이콘 인스타 CDN 만료 대비)
*/
function CardImage({ src, className }) {
const [error, setError] = useState(false);
if (!src || error) {
return (
<div className={`${className} flex items-center justify-center bg-gray-100 text-gray-300`}>
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
</div>
);
}
return <img src={src} alt="" className={`${className} object-cover bg-gray-100`} onError={() => setError(true)} />;
}
/**
* PC X(트위터) 섹션 컴포넌트
*/
@ -197,6 +216,37 @@ function XSection({ schedule }) {
</div>
)}
{/* 링크 미리보기 카드 (Open Graph) — 자체 이미지/영상이 있으면 숨김 */}
{schedule.card?.url && !(schedule.videoThumbnails?.length > 0) && !(schedule.imageUrls?.length > 0) && (
<div className="px-5 pb-3">
<a
href={schedule.card.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-stretch rounded-2xl border border-gray-200 overflow-hidden hover:bg-gray-50 transition-colors"
>
{schedule.card.image && (
<CardImage src={schedule.card.image} className="w-32 flex-shrink-0" />
)}
<div className="flex-1 min-w-0 p-3 flex flex-col justify-center">
{schedule.card.destination && (
<p className="text-xs text-gray-400 mb-0.5">{schedule.card.destination}</p>
)}
{schedule.card.title && (
<p className="text-[15px] font-medium text-gray-900 line-clamp-2">
{decodeHtmlEntities(schedule.card.title)}
</p>
)}
{schedule.card.description && (
<p className="text-sm text-gray-500 line-clamp-2 mt-0.5">
{decodeHtmlEntities(schedule.card.description)}
</p>
)}
</div>
</a>
</div>
)}
{/* 날짜/시간 */}
<div className="px-5 py-4 border-t border-gray-100">
<span className="text-gray-500 text-[15px]">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>