From 2cfc580283f55217f1b87ec5c0bf965147ce50bb Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 31 May 2026 18:00:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(x-bot):=20=EC=9E=98=EB=A6=B0=20=EA=B8=B4=20?= =?UTF-8?q?=EB=A6=AC=ED=8A=B8=EC=9C=97=20=EC=A0=84=EC=B2=B4=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EB=B3=B5=EC=9B=90=20(hydration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X long tweet(280자 초과)을 Nitter 타임라인이 간헐적으로 …로 잘라서 주는 경우, 개별 상태 페이지에서 재요청해 전체 내용으로 교체. - parseTweets: …로 끝나는 트윗에 truncated 플래그 부여 - hydrateTruncatedTweets: 잘린 트윗 status 페이지 재요청 후 교체 (best-effort) - fetchTweets/fetchAllTweets에 적용 - fetchSingleTweet을 timeout-safe하게 변경 Co-Authored-By: Claude Opus 4.7 --- backend/src/services/x/index.js | 2 +- backend/src/services/x/scraper.js | 45 ++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index 090faf2..2c2d254 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -176,7 +176,7 @@ async function xBotPlugin(fastify, opts) { * 최근 트윗 동기화 (정기 실행) */ async function syncNewTweets(bot) { - const options = { includeRetweets: bot.includeRetweets || false }; + const options = { includeRetweets: bot.includeRetweets || false, log: fastify.log }; const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username, options); // 프로필 저장 (DB + 캐시) diff --git a/backend/src/services/x/scraper.js b/backend/src/services/x/scraper.js index 0f69439..6030720 100644 --- a/backend/src/services/x/scraper.js +++ b/backend/src/services/x/scraper.js @@ -194,6 +194,8 @@ export function parseTweets(html, username, options = {}) { imageUrls, isRetweet, originalUsername, + // 긴 트윗(280자 초과)이 …로 잘렸는지 여부 (hydrate 대상 판별용) + truncated: /…\s*$/.test(text), url: isRetweet && originalUsername ? `https://x.com/${originalUsername}/status/${id}` : `https://x.com/${username}/status/${id}`, @@ -208,12 +210,7 @@ export function parseTweets(html, username, options = {}) { */ export async function fetchSingleTweet(nitterUrl, username, postId) { const url = `${nitterUrl}/${username}/status/${postId}`; - const res = await fetch(url); - - if (!res.ok) { - throw new Error(`트윗을 찾을 수 없습니다 (${res.status})`); - } - + const res = await fetchWithTimeout(url); const html = await res.text(); // 메인 트윗 파싱 (main-tweet ~ replies 사이) @@ -277,12 +274,43 @@ export async function fetchProfile(nitterUrl, username) { }; } +/** + * 잘린 트윗(…로 끝나는 긴 트윗)을 개별 상태 페이지에서 재요청해 전체 내용으로 교체 + * - 타임라인이 long tweet을 간헐적으로 잘라서 주는 경우 대비 + * - 재요청 결과가 더 길고 잘리지 않았을 때만 교체 (best-effort) + * @param {string} nitterUrl - Nitter URL + * @param {Array} tweets - parseTweets 결과 + * @param {string} username - 타임라인 사용자명 (리트윗 아닌 경우 fallback) + * @param {object} log - 로거 (선택) + */ +async function hydrateTruncatedTweets(nitterUrl, tweets, username, log) { + for (const tweet of tweets) { + if (!tweet.truncated) continue; + try { + // status id는 전역 유일 → username 경로는 resolve에 영향 없음 + const author = tweet.originalUsername || username; + const full = await fetchSingleTweet(nitterUrl, author, tweet.id); + if (full?.text && full.text.length > tweet.text.length && !/…\s*$/.test(full.text)) { + tweet.text = full.text; + if (full.imageUrls?.length > 0) tweet.imageUrls = full.imageUrls; + tweet.truncated = false; + } + } catch (err) { + log?.warn?.(`[hydrate] 트윗 ${tweet.id} 재요청 실패: ${err.message}`); + } + // Nitter 부하 완화 + await new Promise(r => setTimeout(r, 300)); + } + return tweets; +} + /** * Nitter에서 트윗 수집 (첫 페이지만) * @param {string} nitterUrl - Nitter URL * @param {string} username - 사용자명 * @param {object} options - 옵션 * @param {boolean} options.includeRetweets - 리트윗 포함 여부 + * @param {object} options.log - 로거 (선택) */ export async function fetchTweets(nitterUrl, username, options = {}) { const url = `${nitterUrl}/${username}`; @@ -295,6 +323,9 @@ export async function fetchTweets(nitterUrl, username, options = {}) { // 트윗 파싱 const tweets = parseTweets(html, username, options); + // 잘린 긴 트윗 전체 내용 복원 + await hydrateTruncatedTweets(nitterUrl, tweets, username, options.log); + return { tweets, profile }; } @@ -329,6 +360,8 @@ export async function fetchAllTweets(nitterUrl, username, log, options = {}) { if (emptyCount >= 3) break; } else { emptyCount = 0; + // 잘린 긴 트윗 전체 내용 복원 + await hydrateTruncatedTweets(nitterUrl, tweets, username, log); allTweets.push(...tweets); log?.info(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`); }