fromis_9/backend/src/services/x/index.js
caadiq 4cfa4ffc00 fix(x-bot): 리트윗 이중 저장 방지
같은 리트윗이 타임라인에서 래퍼 id / 원본 id 두 형태로 번갈아 나타나
post_id 중복 체크를 통과해 두 번 저장되던 문제 수정.

리트윗 저장 시 동일 내용 + (원작자 또는 봇계정) username이 이미 있으면
중복으로 간주해 건너뜀. hydration으로 양쪽 모두 전체 내용을 갖추므로
내용 기반 매칭이 안정적으로 동작.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:02:22 +09:00

333 lines
10 KiB
JavaScript

import fp from 'fastify-plugin';
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';
import { syncScheduleById } from '../meilisearch/index.js';
import { logActivity } from '../../utils/log.js';
const X_CATEGORY_ID = 3;
const YOUTUBE_CATEGORY_ID = 2;
const PROFILE_CACHE_PREFIX = 'x_profile:';
const PROFILE_TTL = 604800; // 7일
async function xBotPlugin(fastify, opts) {
/**
* 관리 중인 YouTube 채널 ID 목록 (DB에서 조회)
*/
async function getManagedChannelIds() {
const [rows] = await fastify.db.query(
'SELECT channel_id FROM bot_youtube WHERE enabled = 1'
);
return rows.map(r => r.channel_id);
}
/**
* X 프로필 저장 (bot_x 테이블 + Redis 캐시)
*/
async function saveProfile(username, profile) {
if (!profile.displayName && !profile.avatarUrl) return;
// bot_x 테이블 업데이트
await fastify.db.query(`
UPDATE bot_x SET display_name = ?, avatar_url = ?
WHERE username = ?
`, [profile.displayName, profile.avatarUrl, username]);
// Redis 캐시에도 저장
const data = {
username,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
updatedAt: nowKST(),
};
await fastify.redis.setex(
`${PROFILE_CACHE_PREFIX}${username}`,
PROFILE_TTL,
JSON.stringify(data)
);
}
/**
* 트윗을 DB에 저장
*/
async function saveTweet(tweet, username) {
// 중복 체크 (post_id로) - 트랜잭션 전에 수행
const [existing] = await fastify.db.query(
'SELECT id FROM schedule_x WHERE post_id = ?',
[tweet.id]
);
if (existing.length > 0) {
return null;
}
// 리트윗 이중 저장 방지: 같은 리트윗이 타임라인에서 래퍼 id / 원본 id 두 형태로
// 번갈아 나타나 post_id가 달라도 같은 트윗인 경우가 있음.
// hydration으로 양쪽 모두 전체 내용을 갖추므로 동일 내용이면 중복 처리.
// username은 형태에 따라 원작자/봇계정으로 불일치할 수 있어 둘 다 매칭.
if (tweet.isRetweet && tweet.text) {
const [dup] = await fastify.db.query(
'SELECT id FROM schedule_x WHERE content = ? AND username IN (?, ?) LIMIT 1',
[tweet.text, tweet.originalUsername || username, username]
);
if (dup.length > 0) {
return null;
}
}
const date = formatDate(tweet.time);
const time = formatTime(tweet.time);
const title = extractTitle(tweet.text);
// 리트윗인 경우 원본 작성자를 username으로 사용
const tweetUsername = tweet.originalUsername || username;
// 트랜잭션으로 INSERT 작업 수행
return withTransaction(fastify.db, async (connection) => {
// schedules 테이블에 저장
const [result] = await connection.query(
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
[X_CATEGORY_ID, title, date, time]
);
const scheduleId = result.insertId;
// schedule_x 테이블에 저장
await connection.query(
'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls) VALUES (?, ?, ?, ?, ?)',
[
scheduleId,
tweet.id,
tweetUsername,
tweet.text,
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
]
);
return scheduleId;
});
}
/**
* YouTube 영상을 DB에 저장 (트윗에서 감지된 링크)
*/
async function saveYoutubeFromTweet(video) {
// 중복 체크 - 트랜잭션 전에 수행
const [existing] = await fastify.db.query(
'SELECT id FROM schedule_youtube WHERE video_id = ?',
[video.videoId]
);
if (existing.length > 0) {
return null;
}
// 트랜잭션으로 INSERT 작업 수행
try {
return await withTransaction(fastify.db, async (connection) => {
// schedules 테이블에 저장
const [result] = await connection.query(
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
);
const scheduleId = result.insertId;
// schedule_youtube 테이블에 저장
await connection.query(
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
[scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle]
);
return scheduleId;
});
} catch (err) {
// UNIQUE 제약 위반 (동시성 중복) → 무시
if (err.code === 'ER_DUP_ENTRY') return null;
throw err;
}
}
/**
* 트윗에서 YouTube 링크 처리
*/
async function processYoutubeLinks(tweet, { excludeManagedChannels = true } = {}) {
const videoIds = extractYoutubeVideoIds(tweet.text);
if (videoIds.length === 0) return 0;
const managedChannels = excludeManagedChannels ? await getManagedChannelIds() : [];
let addedCount = 0;
for (const videoId of videoIds) {
try {
const video = await fetchVideoInfo(videoId);
if (!video) continue;
// 옵션에 따라 관리 중인 채널 영상은 스킵
if (excludeManagedChannels && managedChannels.includes(video.channelId)) continue;
const scheduleId = await saveYoutubeFromTweet(video);
if (scheduleId) {
// Meilisearch 동기화
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
addedCount++;
}
} catch (err) {
fastify.log.error(`YouTube 영상 처리 오류 (${videoId}): ${err.message}`);
}
}
return addedCount;
}
/**
* 텍스트 필터 적용 (키워드 중 하나라도 포함되면 true)
*/
function matchesFilter(text, filters) {
if (!filters || filters.length === 0) return true;
const lowerText = text.toLowerCase();
return filters.some(filter => lowerText.includes(filter.toLowerCase()));
}
/**
* 최근 트윗 동기화 (정기 실행)
*/
async function syncNewTweets(bot) {
const options = { includeRetweets: bot.includeRetweets || false, log: fastify.log };
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username, options);
// 프로필 저장 (DB + 캐시)
await saveProfile(bot.username, profile);
let addedCount = 0;
let ytAddedCount = 0;
for (const tweet of tweets) {
// 텍스트 필터 적용
if (!matchesFilter(tweet.text, bot.textFilters)) {
continue;
}
const scheduleId = await saveTweet(tweet, bot.username);
if (scheduleId) {
// Meilisearch 동기화
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
const title = extractTitle(tweet.text);
logActivity(fastify.db, {
actor: bot.id,
action: 'create',
category: 'schedule',
targetType: 'x_schedule',
targetId: scheduleId,
summary: `X 트윗 추가: ${title}`,
});
addedCount++;
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
if (bot.extractYoutube === true) {
ytAddedCount += await processYoutubeLinks(tweet, {
excludeManagedChannels: bot.excludeManagedChannels !== false,
});
}
}
}
return { addedCount: addedCount + ytAddedCount, total: tweets.length, tweetCount: addedCount, ytCount: ytAddedCount };
}
/**
* 전체 트윗 동기화 (초기화)
*/
async function syncAllTweets(bot) {
const options = { includeRetweets: bot.includeRetweets || false };
const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log, options);
let addedCount = 0;
let ytAddedCount = 0;
for (const tweet of tweets) {
// 텍스트 필터 적용
if (!matchesFilter(tweet.text, bot.textFilters)) {
continue;
}
const scheduleId = await saveTweet(tweet, bot.username);
if (scheduleId) {
// Meilisearch 동기화
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
addedCount++;
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
if (bot.extractYoutube === true) {
ytAddedCount += await processYoutubeLinks(tweet, {
excludeManagedChannels: bot.excludeManagedChannels !== false,
});
}
}
}
return { addedCount: addedCount + ytAddedCount, total: tweets.length, tweetCount: addedCount, ytCount: ytAddedCount };
}
/**
* X 프로필 조회 (Redis 캐시 → bot_x 테이블 → Nitter 직접 조회)
*/
async function getProfile(username) {
// Redis 캐시 확인
const cached = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
if (cached) {
return JSON.parse(cached);
}
// bot_x 테이블에서 조회
const [rows] = await fastify.db.query(
'SELECT username, display_name, avatar_url FROM bot_x WHERE username = ?',
[username]
);
if (rows.length > 0) {
const row = rows[0];
const data = {
username: row.username,
displayName: row.display_name,
avatarUrl: row.avatar_url,
};
await fastify.redis.setex(
`${PROFILE_CACHE_PREFIX}${username}`,
PROFILE_TTL,
JSON.stringify(data)
);
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;
}
fastify.decorate('xBot', {
syncNewTweets,
syncAllTweets,
getProfile,
});
}
export default fp(xBotPlugin, {
name: 'xBot',
dependencies: ['db', 'redis'],
});