fix(x-bot): OG 카드 엔티티 디코딩 + YouTube 이미지 보강

- 스크래퍼가 Nitter HTML 엔티티(/ & 등)를 디코딩하지 않아
  본문/카드 제목에 [JP/EN]처럼 노출되던 문제 수정 (extractCard,
  extractTextFromHtml에 decodeEntities 적용)
- resolveCard가 Nitter 카드에 제목만 있고 이미지가 없을 때 OG로 이미지를
  보강하도록 변경 (YouTube 카드 이미지 누락 복구)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-06-07 09:26:54 +09:00
parent 3ba27c0100
commit 134f5836b7
2 changed files with 40 additions and 11 deletions

View file

@ -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) {

View file

@ -27,6 +27,24 @@ async function fetchWithTimeout(url, timeout = FETCH_TIMEOUT) {
}
}
/**
* HTML 엔티티 디코딩 (Nitter가 &#x2F; &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)))
.replace(/&nbsp;/g, ' ');
}
/**
* 트윗 텍스트에서 문단 추출 (title용)
*/
@ -77,7 +95,7 @@ export function extractCard(html) {
const hrefMatch = html.match(/<a class="card-container" href="([^"]+)"/);
if (!hrefMatch) return null;
const stripTags = s => (s ? s.replace(/<[^>]+>/g, '').trim() : '');
const stripTags = s => (s ? decodeEntities(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>/);
@ -158,7 +176,7 @@ export function extractProfile(html) {
* 트윗 HTML 컨텐츠에서 텍스트 추출 (링크는 원본 URL 사용)
*/
function extractTextFromHtml(html) {
return html
const stripped = html
.replace(/<br\s*\/?>/g, '\n')
// <a> 태그: href에서 원본 URL 추출 (외부 링크만)
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/g, (match, href, text) => {
@ -176,6 +194,7 @@ function extractTextFromHtml(html) {
})
.replace(/<[^>]+>/g, '')
.trim();
return decodeEntities(stripped);
}
/**