2026-01-16 21:11:02 +09:00
|
|
|
import fp from 'fastify-plugin';
|
|
|
|
|
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
|
|
|
|
import { fetchVideoInfo } from '../youtube/api.js';
|
|
|
|
|
import { formatDate, formatTime } from '../../utils/date.js';
|
|
|
|
|
import bots from '../../config/bots.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 목록
|
|
|
|
|
*/
|
|
|
|
|
function getManagedChannelIds() {
|
|
|
|
|
return bots
|
|
|
|
|
.filter(b => b.type === 'youtube')
|
|
|
|
|
.map(b => b.channelId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-19 10:19:32 +09:00
|
|
|
* X 프로필 저장 (DB + Redis 캐시)
|
2026-01-16 21:11:02 +09:00
|
|
|
*/
|
2026-01-19 10:19:32 +09:00
|
|
|
async function saveProfile(username, profile) {
|
2026-01-16 21:11:02 +09:00
|
|
|
if (!profile.displayName && !profile.avatarUrl) return;
|
|
|
|
|
|
2026-01-19 10:19:32 +09:00
|
|
|
// DB에 저장 (upsert)
|
|
|
|
|
await fastify.db.query(`
|
|
|
|
|
INSERT INTO x_profiles (username, display_name, avatar_url)
|
|
|
|
|
VALUES (?, ?, ?)
|
|
|
|
|
ON DUPLICATE KEY UPDATE
|
|
|
|
|
display_name = VALUES(display_name),
|
|
|
|
|
avatar_url = VALUES(avatar_url),
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
`, [username, profile.displayName, profile.avatarUrl]);
|
|
|
|
|
|
|
|
|
|
// Redis 캐시에도 저장
|
2026-01-16 21:11:02 +09:00
|
|
|
const data = {
|
|
|
|
|
username,
|
|
|
|
|
displayName: profile.displayName,
|
|
|
|
|
avatarUrl: profile.avatarUrl,
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
await fastify.redis.setex(
|
|
|
|
|
`${PROFILE_CACHE_PREFIX}${username}`,
|
|
|
|
|
PROFILE_TTL,
|
|
|
|
|
JSON.stringify(data)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 트윗을 DB에 저장
|
|
|
|
|
*/
|
|
|
|
|
async function saveTweet(tweet) {
|
|
|
|
|
// 중복 체크 (post_id로)
|
|
|
|
|
const [existing] = await fastify.db.query(
|
|
|
|
|
'SELECT id FROM schedule_x WHERE post_id = ?',
|
|
|
|
|
[tweet.id]
|
|
|
|
|
);
|
|
|
|
|
if (existing.length > 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const date = formatDate(tweet.time);
|
|
|
|
|
const time = formatTime(tweet.time);
|
|
|
|
|
const title = extractTitle(tweet.text);
|
|
|
|
|
|
|
|
|
|
// schedules 테이블에 저장
|
|
|
|
|
const [result] = await fastify.db.query(
|
|
|
|
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
|
|
|
|
[X_CATEGORY_ID, title, date, time]
|
|
|
|
|
);
|
|
|
|
|
const scheduleId = result.insertId;
|
|
|
|
|
|
|
|
|
|
// schedule_x 테이블에 저장
|
|
|
|
|
await fastify.db.query(
|
|
|
|
|
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
|
|
|
|
[
|
|
|
|
|
scheduleId,
|
|
|
|
|
tweet.id,
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// schedules 테이블에 저장
|
|
|
|
|
const [result] = await fastify.db.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 fastify.db.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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 트윗에서 YouTube 링크 처리
|
|
|
|
|
*/
|
|
|
|
|
async function processYoutubeLinks(tweet) {
|
|
|
|
|
const videoIds = extractYoutubeVideoIds(tweet.text);
|
|
|
|
|
if (videoIds.length === 0) return 0;
|
|
|
|
|
|
|
|
|
|
const managedChannels = getManagedChannelIds();
|
|
|
|
|
let addedCount = 0;
|
|
|
|
|
|
|
|
|
|
for (const videoId of videoIds) {
|
|
|
|
|
try {
|
|
|
|
|
const video = await fetchVideoInfo(videoId);
|
|
|
|
|
if (!video) continue;
|
|
|
|
|
|
|
|
|
|
// 관리 중인 채널이면 스킵
|
|
|
|
|
if (managedChannels.includes(video.channelId)) continue;
|
|
|
|
|
|
|
|
|
|
const saved = await saveYoutubeFromTweet(video);
|
|
|
|
|
if (saved) addedCount++;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
fastify.log.error(`YouTube 영상 처리 오류 (${videoId}): ${err.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return addedCount;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 최근 트윗 동기화 (정기 실행)
|
|
|
|
|
*/
|
|
|
|
|
async function syncNewTweets(bot) {
|
|
|
|
|
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username);
|
|
|
|
|
|
2026-01-19 10:19:32 +09:00
|
|
|
// 프로필 저장 (DB + 캐시)
|
|
|
|
|
await saveProfile(bot.username, profile);
|
2026-01-16 21:11:02 +09:00
|
|
|
|
|
|
|
|
let addedCount = 0;
|
|
|
|
|
let ytAddedCount = 0;
|
|
|
|
|
|
|
|
|
|
for (const tweet of tweets) {
|
|
|
|
|
const scheduleId = await saveTweet(tweet);
|
|
|
|
|
if (scheduleId) {
|
|
|
|
|
addedCount++;
|
|
|
|
|
// YouTube 링크 처리
|
|
|
|
|
ytAddedCount += await processYoutubeLinks(tweet);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { addedCount: addedCount + ytAddedCount, tweetCount: addedCount, ytCount: ytAddedCount };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 전체 트윗 동기화 (초기화)
|
|
|
|
|
*/
|
|
|
|
|
async function syncAllTweets(bot) {
|
|
|
|
|
const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log);
|
|
|
|
|
|
|
|
|
|
let addedCount = 0;
|
|
|
|
|
let ytAddedCount = 0;
|
|
|
|
|
|
|
|
|
|
for (const tweet of tweets) {
|
|
|
|
|
const scheduleId = await saveTweet(tweet);
|
|
|
|
|
if (scheduleId) {
|
|
|
|
|
addedCount++;
|
|
|
|
|
ytAddedCount += await processYoutubeLinks(tweet);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { addedCount: addedCount + ytAddedCount, tweetCount: addedCount, ytCount: ytAddedCount };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-19 10:19:32 +09:00
|
|
|
* X 프로필 조회 (Redis 캐시 → DB)
|
2026-01-16 21:11:02 +09:00
|
|
|
*/
|
|
|
|
|
async function getProfile(username) {
|
2026-01-19 10:19:32 +09:00
|
|
|
// Redis 캐시 확인
|
|
|
|
|
const cached = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
|
|
|
|
|
if (cached) {
|
|
|
|
|
return JSON.parse(cached);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DB에서 조회
|
|
|
|
|
const [rows] = await fastify.db.query(
|
|
|
|
|
'SELECT username, display_name, avatar_url, updated_at FROM x_profiles WHERE username = ?',
|
|
|
|
|
[username]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (rows.length > 0) {
|
|
|
|
|
const row = rows[0];
|
|
|
|
|
const data = {
|
|
|
|
|
username: row.username,
|
|
|
|
|
displayName: row.display_name,
|
|
|
|
|
avatarUrl: row.avatar_url,
|
|
|
|
|
updatedAt: row.updated_at?.toISOString(),
|
|
|
|
|
};
|
|
|
|
|
// Redis 캐시에 저장
|
|
|
|
|
await fastify.redis.setex(
|
|
|
|
|
`${PROFILE_CACHE_PREFIX}${username}`,
|
|
|
|
|
PROFILE_TTL,
|
|
|
|
|
JSON.stringify(data)
|
|
|
|
|
);
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
2026-01-16 21:11:02 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fastify.decorate('xBot', {
|
|
|
|
|
syncNewTweets,
|
|
|
|
|
syncAllTweets,
|
|
|
|
|
getProfile,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default fp(xBotPlugin, {
|
|
|
|
|
name: 'xBot',
|
|
|
|
|
dependencies: ['db', 'redis'],
|
|
|
|
|
});
|