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:
parent
3ba27c0100
commit
134f5836b7
2 changed files with 40 additions and 11 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(/<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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue