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:
parent
d3d3c9cf75
commit
8b8b9a7f53
6 changed files with 264 additions and 3 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
98
backend/src/services/x/og.js
Normal file
98
backend/src/services/x/og.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* URL에서 Open Graph 메타데이터 직접 추출
|
||||
* Nitter가 카드를 비워서 줄 때(주로 YouTube) fallback으로 사용.
|
||||
*/
|
||||
|
||||
const OG_TIMEOUT = 8000;
|
||||
|
||||
/**
|
||||
* HTML 엔티티 디코딩 (이미지 URL의 & 등 처리)
|
||||
*/
|
||||
function decodeEntities(str) {
|
||||
if (!str) return str;
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(///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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue