From 9498559f6b46d219506063a533a45b4e2db29fc8 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 29 Mar 2026 14:07:43 +0900 Subject: [PATCH] =?UTF-8?q?fix(x-bot):=20=EB=A6=AC=ED=8A=B8=EC=9C=97=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84/=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EB=B0=8F=20=EC=9B=90=EB=B3=B8=20=ED=8A=B8?= =?UTF-8?q?=EC=9C=97=20=EB=A7=A4=EC=B9=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getProfile: bot_x에 없는 계정도 Nitter에서 직접 조회 후 Redis 캐시 - refetch-retweets 스크립트: 원본 작성자 타임라인에서 매칭 트윗 찾아 이미지/내용 복구 - 기존 21건 리트윗 데이터 재수집 완료 (이미지 포함) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/scripts/refetch-retweets.js | 111 +++++++++++++++++++++------- backend/src/services/x/index.js | 27 ++++++- 2 files changed, 110 insertions(+), 28 deletions(-) diff --git a/backend/scripts/refetch-retweets.js b/backend/scripts/refetch-retweets.js index 7ecc190..dab5968 100644 --- a/backend/scripts/refetch-retweets.js +++ b/backend/scripts/refetch-retweets.js @@ -1,11 +1,11 @@ /** * 리트윗 데이터 재수집 스크립트 - * 잘못 저장된 리트윗 일정을 Nitter에서 다시 가져와 수정합니다. + * 원본 작성자의 타임라인에서 매칭되는 트윗을 찾아 이미지와 내용을 복구합니다. * * 사용법: node scripts/refetch-retweets.js [scheduleId1,scheduleId2,...] */ import mysql from 'mysql2/promise'; -import { fetchSingleTweet, extractTitle } from '../src/services/x/scraper.js'; +import { fetchAllTweets, fetchSingleTweet, extractTitle } from '../src/services/x/scraper.js'; const NITTER_URL = process.env.NITTER_URL || 'http://nitter:8080'; @@ -17,26 +17,75 @@ const pool = mysql.createPool({ database: process.env.DB_NAME || 'fromis9', }); +// 간단한 로거 +const log = { + info: (msg) => console.log(msg), + error: (msg) => console.error(msg), +}; + +// 타임라인 캐시 (같은 작성자의 반복 조회 방지) +const timelineCache = new Map(); + +/** + * 원본 작성자의 타임라인에서 매칭되는 트윗 찾기 + */ +async function findOriginalTweet(username, content, date) { + // 캐시 확인 + if (!timelineCache.has(username)) { + console.log(` @${username} 타임라인 수집 중...`); + const tweets = await fetchAllTweets(NITTER_URL, username, log, { includeRetweets: false }); + timelineCache.set(username, tweets); + console.log(` -> ${tweets.length}개 트윗 수집 완료`); + } + + const tweets = timelineCache.get(username); + + // 내용의 첫 30자로 매칭 (완전 일치는 포맷 차이로 어려움) + const contentStart = content.substring(0, 30).trim(); + + for (const tweet of tweets) { + if (tweet.text.startsWith(contentStart)) { + return tweet; + } + } + + // 날짜 기반 유사 매칭 시도 + const targetDate = date?.split('T')[0]; + if (targetDate) { + for (const tweet of tweets) { + const tweetDate = tweet.time?.toISOString().split('T')[0]; + if (tweetDate === targetDate && tweet.text.includes(contentStart.substring(0, 15))) { + return tweet; + } + } + } + + return null; +} + async function main() { - // CLI에서 특정 ID 지정 가능 const argIds = process.argv[2]?.split(',').map(Number).filter(Boolean); let rows; if (argIds && argIds.length > 0) { [rows] = await pool.query( - `SELECT sx.schedule_id, sx.post_id, sx.username, sx.content - FROM schedule_x sx WHERE sx.schedule_id IN (?)`, + `SELECT sx.schedule_id, sx.post_id, sx.username, sx.content, sx.image_urls, s.date + FROM schedule_x sx JOIN schedules s ON sx.schedule_id = s.id + WHERE sx.schedule_id IN (?)`, [argIds] ); } else { + // 이미지가 없는 리트윗 또는 content에 문제가 있는 것 [rows] = await pool.query( - `SELECT sx.schedule_id, sx.post_id, sx.username, sx.content - FROM schedule_x sx - WHERE sx.content LIKE 'RT @%' OR sx.content LIKE '%nitter%t.co%'` + `SELECT sx.schedule_id, sx.post_id, sx.username, sx.content, sx.image_urls, s.date + FROM schedule_x sx JOIN schedules s ON sx.schedule_id = s.id + WHERE sx.content LIKE 'RT @%' + OR sx.content LIKE '%nitter%t.co%' + OR (sx.image_urls IS NULL AND sx.username != 'realfromis_9')` ); } - console.log(`대상: ${rows.length}건`); + console.log(`대상: ${rows.length}건\n`); if (rows.length === 0) { await pool.end(); return; @@ -47,38 +96,50 @@ async function main() { for (const row of rows) { try { - // RT @username: 에서 원본 작성자 추출 - const rtMatch = row.content?.match(/^RT @(\w+):/); - const fetchUsername = rtMatch ? rtMatch[1] : (row.username || 'realfromis_9'); + const username = row.username || 'realfromis_9'; + console.log(`[${row.schedule_id}] @${username} post_id=${row.post_id}`); - console.log(`[${row.schedule_id}] post_id=${row.post_id}, from=@${fetchUsername}`); + // 1단계: 먼저 개별 페이지에서 시도 (RT prefix 제거) + let newContent = row.content || ''; + let newImageUrls = null; + let newPostId = row.post_id; - const tweet = await fetchSingleTweet(NITTER_URL, fetchUsername, row.post_id); - - // RT @ 프리픽스 제거 - let newContent = tweet.text; + // RT @ 프리픽스가 있으면 제거 const rtPrefixMatch = newContent.match(/^RT @\w+:\s*/); if (rtPrefixMatch) { newContent = newContent.slice(rtPrefixMatch[0].length); } - // 끝의 … 제거 newContent = newContent.replace(/…$/, '').trim(); + // nitter t.co 링크 수정 + newContent = newContent.replace(/https?:\/\/nitter[^/]*\/t\.co\/(\S+)/g, 'https://t.co/$1'); + + // 2단계: 이미지가 없으면 원본 작성자 타임라인에서 찾기 + if (!row.image_urls) { + const original = await findOriginalTweet(username, newContent, row.date); + if (original) { + newContent = original.text; + newPostId = original.id; + if (original.imageUrls.length > 0) { + newImageUrls = JSON.stringify(original.imageUrls); + } + console.log(` -> 원본 발견! id=${original.id}, images=${original.imageUrls.length}`); + } else { + console.log(` -> 원본 미발견, 텍스트만 수정`); + } + } + const newTitle = extractTitle(newContent); - const newImageUrls = tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null; // DB 업데이트 await pool.query('UPDATE schedules SET title = ? WHERE id = ?', [newTitle, row.schedule_id]); await pool.query( - 'UPDATE schedule_x SET username = ?, content = ?, image_urls = ? WHERE schedule_id = ?', - [fetchUsername, newContent, newImageUrls, row.schedule_id] + 'UPDATE schedule_x SET post_id = ?, username = ?, content = ?, image_urls = ? WHERE schedule_id = ?', + [newPostId, username, newContent, newImageUrls, row.schedule_id] ); - console.log(` -> title: ${newTitle.substring(0, 60)} | images: ${tweet.imageUrls.length}`); + console.log(` -> title: ${newTitle.substring(0, 60)} | images: ${newImageUrls ? JSON.parse(newImageUrls).length : 0}`); updated++; - - // Nitter 부하 방지 - await new Promise(r => setTimeout(r, 500)); } catch (err) { console.error(` -> 실패: ${err.message}`); failed++; diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index 6d6e95d..822c5b5 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -1,5 +1,5 @@ import fp from 'fastify-plugin'; -import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js'; +import { fetchTweets, fetchAllTweets, fetchProfile as fetchNitterProfile, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js'; import { fetchVideoInfo } from '../youtube/api.js'; import { formatDate, formatTime, nowKST } from '../../utils/date.js'; import { withTransaction } from '../../utils/transaction.js'; @@ -247,7 +247,7 @@ async function xBotPlugin(fastify, opts) { } /** - * X 프로필 조회 (Redis 캐시 → bot_x 테이블) + * X 프로필 조회 (Redis 캐시 → bot_x 테이블 → Nitter 직접 조회) */ async function getProfile(username) { // Redis 캐시 확인 @@ -269,7 +269,6 @@ async function xBotPlugin(fastify, opts) { displayName: row.display_name, avatarUrl: row.avatar_url, }; - // Redis 캐시에 저장 await fastify.redis.setex( `${PROFILE_CACHE_PREFIX}${username}`, PROFILE_TTL, @@ -278,6 +277,28 @@ async function xBotPlugin(fastify, opts) { return data; } + // bot_x에 없으면 Nitter에서 직접 조회 (리트윗 원본 작성자 등) + try { + const nitterUrl = fastify.config?.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080'; + const profile = await fetchNitterProfile(nitterUrl, username); + if (profile) { + const data = { + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }; + // Redis 캐시에 저장 + await fastify.redis.setex( + `${PROFILE_CACHE_PREFIX}${username}`, + PROFILE_TTL, + JSON.stringify(data) + ); + return data; + } + } catch (err) { + fastify.log.error(`Nitter 프로필 조회 실패 (${username}): ${err.message}`); + } + return null; }