fix(x-bot): 잘린 긴 리트윗 전체 내용 복원 (hydration)
X long tweet(280자 초과)을 Nitter 타임라인이 간헐적으로 …로 잘라서 주는 경우, 개별 상태 페이지에서 재요청해 전체 내용으로 교체. - parseTweets: …로 끝나는 트윗에 truncated 플래그 부여 - hydrateTruncatedTweets: 잘린 트윗 status 페이지 재요청 후 교체 (best-effort) - fetchTweets/fetchAllTweets에 적용 - fetchSingleTweet을 timeout-safe하게 변경 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6a24b997dd
commit
2cfc580283
2 changed files with 40 additions and 7 deletions
|
|
@ -176,7 +176,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
* 최근 트윗 동기화 (정기 실행)
|
* 최근 트윗 동기화 (정기 실행)
|
||||||
*/
|
*/
|
||||||
async function syncNewTweets(bot) {
|
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);
|
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username, options);
|
||||||
|
|
||||||
// 프로필 저장 (DB + 캐시)
|
// 프로필 저장 (DB + 캐시)
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,8 @@ export function parseTweets(html, username, options = {}) {
|
||||||
imageUrls,
|
imageUrls,
|
||||||
isRetweet,
|
isRetweet,
|
||||||
originalUsername,
|
originalUsername,
|
||||||
|
// 긴 트윗(280자 초과)이 …로 잘렸는지 여부 (hydrate 대상 판별용)
|
||||||
|
truncated: /…\s*$/.test(text),
|
||||||
url: isRetweet && originalUsername
|
url: isRetweet && originalUsername
|
||||||
? `https://x.com/${originalUsername}/status/${id}`
|
? `https://x.com/${originalUsername}/status/${id}`
|
||||||
: `https://x.com/${username}/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) {
|
export async function fetchSingleTweet(nitterUrl, username, postId) {
|
||||||
const url = `${nitterUrl}/${username}/status/${postId}`;
|
const url = `${nitterUrl}/${username}/status/${postId}`;
|
||||||
const res = await fetch(url);
|
const res = await fetchWithTimeout(url);
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`트윗을 찾을 수 없습니다 (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
|
|
||||||
// 메인 트윗 파싱 (main-tweet ~ replies 사이)
|
// 메인 트윗 파싱 (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에서 트윗 수집 (첫 페이지만)
|
* Nitter에서 트윗 수집 (첫 페이지만)
|
||||||
* @param {string} nitterUrl - Nitter URL
|
* @param {string} nitterUrl - Nitter URL
|
||||||
* @param {string} username - 사용자명
|
* @param {string} username - 사용자명
|
||||||
* @param {object} options - 옵션
|
* @param {object} options - 옵션
|
||||||
* @param {boolean} options.includeRetweets - 리트윗 포함 여부
|
* @param {boolean} options.includeRetweets - 리트윗 포함 여부
|
||||||
|
* @param {object} options.log - 로거 (선택)
|
||||||
*/
|
*/
|
||||||
export async function fetchTweets(nitterUrl, username, options = {}) {
|
export async function fetchTweets(nitterUrl, username, options = {}) {
|
||||||
const url = `${nitterUrl}/${username}`;
|
const url = `${nitterUrl}/${username}`;
|
||||||
|
|
@ -295,6 +323,9 @@ export async function fetchTweets(nitterUrl, username, options = {}) {
|
||||||
// 트윗 파싱
|
// 트윗 파싱
|
||||||
const tweets = parseTweets(html, username, options);
|
const tweets = parseTweets(html, username, options);
|
||||||
|
|
||||||
|
// 잘린 긴 트윗 전체 내용 복원
|
||||||
|
await hydrateTruncatedTweets(nitterUrl, tweets, username, options.log);
|
||||||
|
|
||||||
return { tweets, profile };
|
return { tweets, profile };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,6 +360,8 @@ export async function fetchAllTweets(nitterUrl, username, log, options = {}) {
|
||||||
if (emptyCount >= 3) break;
|
if (emptyCount >= 3) break;
|
||||||
} else {
|
} else {
|
||||||
emptyCount = 0;
|
emptyCount = 0;
|
||||||
|
// 잘린 긴 트윗 전체 내용 복원
|
||||||
|
await hydrateTruncatedTweets(nitterUrl, tweets, username, log);
|
||||||
allTweets.push(...tweets);
|
allTweets.push(...tweets);
|
||||||
log?.info(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`);
|
log?.info(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue