diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index b96e8f4..c50b637 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -58,16 +58,26 @@ async function xBotPlugin(fastify, opts) { * - 비었거나 없으면 본문 첫 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; + const nitter = tweet.card && (tweet.card.title || tweet.card.image) ? tweet.card : null; + // Nitter 카드에 이미지가 있으면 그대로 사용 + if (nitter && nitter.image) return nitter; + + // 이미지가 없으면 OG로 보강 (YouTube 등 Nitter가 카드 이미지를 안 주는 경우 복구) + const url = (nitter && nitter.url) || extractFirstUrl(tweet.text); + if (url) { + try { + const og = await fetchOgCard(url); + if (og) { + // Nitter 카드가 있으면 제목/설명은 유지하되 OG 이미지로 보강 + return nitter + ? { ...nitter, image: og.image || null, description: nitter.description || og.description } + : og; + } + } catch { + // noop + } } + return nitter; } async function saveTweet(tweet, username) { diff --git a/backend/src/services/x/scraper.js b/backend/src/services/x/scraper.js index d7d7400..b7f8ef3 100644 --- a/backend/src/services/x/scraper.js +++ b/backend/src/services/x/scraper.js @@ -27,6 +27,24 @@ async function fetchWithTimeout(url, timeout = FETCH_TIMEOUT) { } } +/** + * HTML 엔티티 디코딩 (Nitter가 / & 등으로 이스케이프한 텍스트 복원) + */ +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))) + .replace(/ /g, ' '); +} + /** * 트윗 텍스트에서 첫 문단 추출 (title용) */ @@ -77,7 +95,7 @@ export function extractCard(html) { const hrefMatch = html.match(/ (s ? s.replace(/<[^>]+>/g, '').trim() : ''); + const stripTags = s => (s ? decodeEntities(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>/); @@ -158,7 +176,7 @@ export function extractProfile(html) { * 트윗 HTML 컨텐츠에서 텍스트 추출 (링크는 원본 URL 사용) */ function extractTextFromHtml(html) { - return html + const stripped = html .replace(//g, '\n') // 태그: href에서 원본 URL 추출 (외부 링크만) .replace(/]*href="([^"]*)"[^>]*>([^<]*)<\/a>/g, (match, href, text) => { @@ -176,6 +194,7 @@ function extractTextFromHtml(html) { }) .replace(/<[^>]+>/g, '') .trim(); + return decodeEntities(stripped); } /**