fix(x-bot): 리트윗 프로필/이미지 복구 및 원본 트윗 매칭
- getProfile: bot_x에 없는 계정도 Nitter에서 직접 조회 후 Redis 캐시 - refetch-retweets 스크립트: 원본 작성자 타임라인에서 매칭 트윗 찾아 이미지/내용 복구 - 기존 21건 리트윗 데이터 재수집 완료 (이미지 포함) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3ce8d7ec7d
commit
9498559f6b
2 changed files with 110 additions and 28 deletions
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue