diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 39f1c43..94db54a 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -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) { diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index 07be8c3..b96e8f4 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -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, ] ); diff --git a/backend/src/services/x/og.js b/backend/src/services/x/og.js new file mode 100644 index 0000000..36a02bd --- /dev/null +++ b/backend/src/services/x/og.js @@ -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(`]+(?:property|name)="${prop}"[^>]+content="([^"]*)"`, 'i'), + new RegExp(`]+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} { 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; +} diff --git a/backend/src/services/x/scraper.js b/backend/src/services/x/scraper.js index 77d85b3..d7d7400 100644 --- a/backend/src/services/x/scraper.js +++ b/backend/src/services/x/scraper.js @@ -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(/ (s ? s.replace(/<[^>]+>/g, '').trim() : ''); + const titleMatch = html.match(/

([\s\S]*?)<\/h2>/); + const descMatch = html.match(/

([\s\S]*?)<\/p>/); + const destMatch = html.match(/([\s\S]*?)<\/span>/); + const imgMatch = html.match(/ + + + + + + + ); + } + return setError(true)} />; +} + /** * Mobile X(트위터) 섹션 */ @@ -418,6 +437,37 @@ function MobileXSection({ schedule }) { )} + {/* 링크 미리보기 카드 (Open Graph) — 자체 이미지/영상이 있으면 숨김 */} + {schedule.card?.url && !(schedule.videoThumbnails?.length > 0) && !(schedule.imageUrls?.length > 0) && ( +

+ + {schedule.card.image && ( + + )} +
+ {schedule.card.destination && ( +

{schedule.card.destination}

+ )} + {schedule.card.title && ( +

+ {decodeHtmlEntities(schedule.card.title)} +

+ )} + {schedule.card.description && ( +

+ {decodeHtmlEntities(schedule.card.description)} +

+ )} +
+
+
+ )} + {/* 날짜/시간 */}
{formatXDateTimeWithTime(schedule.date, schedule.time)} diff --git a/frontend/src/pages/pc/public/schedule/sections/XSection.jsx b/frontend/src/pages/pc/public/schedule/sections/XSection.jsx index c46ab5c..6d77d58 100644 --- a/frontend/src/pages/pc/public/schedule/sections/XSection.jsx +++ b/frontend/src/pages/pc/public/schedule/sections/XSection.jsx @@ -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 ( +
+ + + + + +
+ ); + } + return setError(true)} />; +} + /** * PC X(트위터) 섹션 컴포넌트 */ @@ -197,6 +216,37 @@ function XSection({ schedule }) {
)} + {/* 링크 미리보기 카드 (Open Graph) — 자체 이미지/영상이 있으면 숨김 */} + {schedule.card?.url && !(schedule.videoThumbnails?.length > 0) && !(schedule.imageUrls?.length > 0) && ( +
+ + {schedule.card.image && ( + + )} +
+ {schedule.card.destination && ( +

{schedule.card.destination}

+ )} + {schedule.card.title && ( +

+ {decodeHtmlEntities(schedule.card.title)} +

+ )} + {schedule.card.description && ( +

+ {decodeHtmlEntities(schedule.card.description)} +

+ )} +
+
+
+ )} + {/* 날짜/시간 */}
{formatXDateTimeWithTime(schedule.date, schedule.time)}