refactor: Express에서 Fastify로 백엔드 마이그레이션

- Express → Fastify 5 프레임워크 전환
- 플러그인 기반 아키텍처로 재구성
  - plugins/db.js: MariaDB 연결 풀
  - plugins/redis.js: Redis 클라이언트
  - plugins/scheduler.js: 봇 스케줄러 (node-cron)
- 봇 설정 방식 변경: DB 테이블 → 설정 파일 (config/bots.js)
- 봇 상태 저장: DB → Redis
- YouTube/X 봇 서비스 분리 및 개선
- 날짜 유틸리티 KST 변환 수정
- 미사용 환경변수 정리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-16 21:11:02 +09:00
parent 51030e3aba
commit 19ba8bcddf
34 changed files with 2015 additions and 8860 deletions

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ redis_data/
backend/scrape_*.cjs backend/scrape_*.cjs
backend/scrape_*.js backend/scrape_*.js
backend/scrape_*.txt backend/scrape_*.txt
# Backup
backend-backup/

View file

@ -24,4 +24,4 @@ COPY backend/ ./
COPY --from=frontend-builder /frontend/dist ./dist COPY --from=frontend-builder /frontend/dist ./dist
EXPOSE 80 EXPOSE 80
CMD ["node", "server.js"] CMD ["npm", "start"]

View file

@ -1,85 +0,0 @@
/**
* 날짜/시간 유틸리티 (dayjs 기반)
* 백엔드 전체에서 공통으로 사용
*/
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc.js";
import timezone from "dayjs/plugin/timezone.js";
import customParseFormat from "dayjs/plugin/customParseFormat.js";
// 플러그인 등록
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
// 기본 시간대: KST
const KST = "Asia/Seoul";
/**
* UTC 시간을 KST로 변환
* @param {Date|string} utcDate - UTC 시간
* @returns {dayjs.Dayjs} KST 시간
*/
export function toKST(utcDate) {
return dayjs(utcDate).tz(KST);
}
/**
* 날짜를 YYYY-MM-DD 형식으로 포맷
* @param {Date|string|dayjs.Dayjs} date - 날짜
* @returns {string} YYYY-MM-DD
*/
export function formatDate(date) {
return dayjs(date).format("YYYY-MM-DD");
}
/**
* 시간을 HH:mm:ss 형식으로 포맷
* @param {Date|string|dayjs.Dayjs} date - 시간
* @returns {string} HH:mm:ss
*/
export function formatTime(date) {
return dayjs(date).format("HH:mm:ss");
}
/**
* UTC 시간을 KST로 변환 날짜/시간 분리
* @param {Date|string} utcDate - UTC 시간
* @returns {{date: string, time: string}} KST 날짜/시간
*/
export function utcToKSTDateTime(utcDate) {
const kst = toKST(utcDate);
return {
date: kst.format("YYYY-MM-DD"),
time: kst.format("HH:mm:ss"),
};
}
/**
* 현재 KST 시간 반환
* @returns {dayjs.Dayjs} 현재 KST 시간
*/
export function nowKST() {
return dayjs().tz(KST);
}
/**
* Nitter 날짜 문자열 파싱 (UTC 반환)
* : "Jan 9, 2026 · 4:00 PM UTC" Date 객체
* @param {string} timeStr - Nitter 날짜 문자열
* @returns {dayjs.Dayjs|null} UTC 시간
*/
export function parseNitterDateTime(timeStr) {
if (!timeStr) return null;
try {
const cleaned = timeStr.replace(" · ", " ").replace(" UTC", "");
const date = dayjs.utc(cleaned, "MMM D, YYYY h:mm A");
if (!date.isValid()) return null;
return date;
} catch (e) {
return null;
}
}
export default dayjs;

View file

@ -1,15 +0,0 @@
import mysql from "mysql2/promise";
// MariaDB 연결 풀 생성
const pool = mysql.createPool({
host: process.env.DB_HOST || "mariadb",
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || "fromis9",
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || "fromis9",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
export default pool;

View file

@ -1,19 +0,0 @@
import Redis from "ioredis";
// Redis 클라이언트 초기화
const redis = new Redis({
host: "fromis9-redis",
port: 6379,
retryDelayOnFailover: 100,
maxRetriesPerRequest: 3,
});
redis.on("connect", () => {
console.log("[Redis] 연결 성공");
});
redis.on("error", (err) => {
console.error("[Redis] 연결 오류:", err.message);
});
export default redis;

4734
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,17 @@
{ {
"name": "fromis9-backend", "name": "fromis9-backend",
"private": true, "version": "2.0.0",
"version": "1.0.0", "type": "module",
"type": "module", "scripts": {
"scripts": { "start": "node src/server.js",
"start": "node server.js" "dev": "node --watch src/server.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.700.0", "fastify": "^5.2.1",
"bcrypt": "^6.0.0", "fastify-plugin": "^5.0.1",
"dayjs": "^1.11.19", "mysql2": "^3.12.0",
"express": "^4.18.2", "ioredis": "^5.4.2",
"inko": "^1.1.1", "node-cron": "^3.0.3",
"ioredis": "^5.4.0", "dayjs": "^1.11.13"
"jsonwebtoken": "^9.0.3", }
"meilisearch": "^0.55.0", }
"multer": "^1.4.5-lts.1",
"mysql2": "^3.11.0",
"node-cron": "^4.2.1",
"rss-parser": "^3.13.0",
"sharp": "^0.33.5",
"fluent-ffmpeg": "^2.1.3"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,180 +0,0 @@
import express from "express";
import pool from "../lib/db.js";
const router = express.Router();
// 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함)
async function getAlbumDetails(album) {
// 트랙 정보 조회 (가사 포함)
const [tracks] = await pool.query(
"SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number",
[album.id]
);
album.tracks = tracks;
// 티저 이미지/비디오 조회 (3개 해상도 URL + video_url + media_type 포함)
const [teasers] = await pool.query(
"SELECT original_url, medium_url, thumb_url, video_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order",
[album.id]
);
album.teasers = teasers;
// 컨셉 포토 조회 (멤버 정보 + 3개 해상도 URL + 크기 정보 포함)
const [photos] = await pool.query(
`SELECT
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order,
p.width, p.height,
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
FROM album_photos p
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
LEFT JOIN members m ON pm.member_id = m.id
WHERE p.album_id = ?
GROUP BY p.id
ORDER BY p.sort_order`,
[album.id]
);
// 컨셉별로 그룹화
const conceptPhotos = {};
for (const photo of photos) {
const concept = photo.concept_name || "Default";
if (!conceptPhotos[concept]) {
conceptPhotos[concept] = [];
}
conceptPhotos[concept].push({
id: photo.id,
original_url: photo.original_url,
medium_url: photo.medium_url,
thumb_url: photo.thumb_url,
width: photo.width,
height: photo.height,
type: photo.photo_type,
members: photo.members,
sortOrder: photo.sort_order,
});
}
album.conceptPhotos = conceptPhotos;
return album;
}
// 전체 앨범 조회 (트랙 포함)
router.get("/", async (req, res) => {
try {
const [albums] = await pool.query(
"SELECT id, title, folder_name, album_type, album_type_short, release_date, cover_original_url, cover_medium_url, cover_thumb_url FROM albums ORDER BY release_date DESC"
);
// 각 앨범에 트랙 정보 추가
for (const album of albums) {
const [tracks] = await pool.query(
"SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger FROM tracks WHERE album_id = ? ORDER BY track_number",
[album.id]
);
album.tracks = tracks;
}
res.json(albums);
} catch (error) {
console.error("앨범 조회 오류:", error);
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
}
});
// 앨범명과 트랙명으로 트랙 상세 조회 (더 구체적인 경로이므로 /by-name/:name보다 앞에 배치)
router.get("/by-name/:albumName/track/:trackTitle", async (req, res) => {
try {
const albumName = decodeURIComponent(req.params.albumName);
const trackTitle = decodeURIComponent(req.params.trackTitle);
// 앨범 조회
const [albums] = await pool.query(
"SELECT * FROM albums WHERE folder_name = ? OR title = ?",
[albumName, albumName]
);
if (albums.length === 0) {
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
}
const album = albums[0];
// 해당 앨범의 트랙 조회
const [tracks] = await pool.query(
"SELECT * FROM tracks WHERE album_id = ? AND title = ?",
[album.id, trackTitle]
);
if (tracks.length === 0) {
return res.status(404).json({ error: "트랙을 찾을 수 없습니다." });
}
const track = tracks[0];
// 앨범의 다른 트랙 목록 조회
const [otherTracks] = await pool.query(
"SELECT id, track_number, title, is_title_track, duration FROM tracks WHERE album_id = ? ORDER BY track_number",
[album.id]
);
res.json({
...track,
album: {
id: album.id,
title: album.title,
folder_name: album.folder_name,
cover_thumb_url: album.cover_thumb_url,
cover_medium_url: album.cover_medium_url,
release_date: album.release_date,
album_type: album.album_type,
},
otherTracks,
});
} catch (error) {
console.error("트랙 조회 오류:", error);
res.status(500).json({ error: "트랙 정보를 가져오는데 실패했습니다." });
}
});
// 앨범 folder_name 또는 title로 조회
router.get("/by-name/:name", async (req, res) => {
try {
const name = decodeURIComponent(req.params.name);
// folder_name 또는 title로 검색 (PC는 title, 모바일은 folder_name 사용)
const [albums] = await pool.query(
"SELECT * FROM albums WHERE folder_name = ? OR title = ?",
[name, name]
);
if (albums.length === 0) {
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
}
const album = await getAlbumDetails(albums[0]);
res.json(album);
} catch (error) {
console.error("앨범 조회 오류:", error);
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
}
});
// ID로 앨범 조회
router.get("/:id", async (req, res) => {
try {
const [albums] = await pool.query("SELECT * FROM albums WHERE id = ?", [
req.params.id,
]);
if (albums.length === 0) {
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
}
const album = await getAlbumDetails(albums[0]);
res.json(album);
} catch (error) {
console.error("앨범 조회 오류:", error);
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
}
});
export default router;

View file

@ -1,35 +0,0 @@
import express from "express";
import pool from "../lib/db.js";
const router = express.Router();
// 전체 멤버 조회
router.get("/", async (req, res) => {
try {
const [rows] = await pool.query(
"SELECT id, name, birth_date, position, image_url, instagram, is_former FROM members ORDER BY is_former, birth_date"
);
res.json(rows);
} catch (error) {
console.error("멤버 조회 오류:", error);
res.status(500).json({ error: "멤버 정보를 가져오는데 실패했습니다." });
}
});
// 특정 멤버 조회
router.get("/:id", async (req, res) => {
try {
const [rows] = await pool.query("SELECT * FROM members WHERE id = ?", [
req.params.id,
]);
if (rows.length === 0) {
return res.status(404).json({ error: "멤버를 찾을 수 없습니다." });
}
res.json(rows[0]);
} catch (error) {
console.error("멤버 조회 오류:", error);
res.status(500).json({ error: "멤버 정보를 가져오는데 실패했습니다." });
}
});
export default router;

View file

@ -1,292 +0,0 @@
import express from "express";
import pool from "../lib/db.js";
import { searchSchedules } from "../services/meilisearch.js";
import { saveSearchQuery, getSuggestions } from "../services/suggestions.js";
import { getXProfile } from "../services/x-bot.js";
const router = express.Router();
// 검색어 추천 API (Bi-gram 기반)
router.get("/suggestions", async (req, res) => {
try {
const { q, limit } = req.query;
if (!q || q.trim().length === 0) {
return res.json({ suggestions: [] });
}
const suggestions = await getSuggestions(q, parseInt(limit) || 10);
res.json({ suggestions });
} catch (error) {
console.error("추천 검색어 오류:", error);
res.status(500).json({ error: "추천 검색어 조회 중 오류가 발생했습니다." });
}
});
// 공개 일정 목록 조회 (검색 포함)
router.get("/", async (req, res) => {
try {
const { search, startDate, endDate, limit, year, month } = req.query;
// 검색어가 있으면 Meilisearch 사용
if (search && search.trim()) {
const offset = parseInt(req.query.offset) || 0;
const pageLimit = parseInt(req.query.limit) || 100;
// 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습)
if (offset === 0) {
saveSearchQuery(search.trim()).catch((err) =>
console.error("검색어 저장 실패:", err.message)
);
}
// Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징)
const results = await searchSchedules(search.trim(), {
limit: 1000, // 내부적으로 1000개까지 검색
});
// 페이징 적용
const paginatedHits = results.hits.slice(offset, offset + pageLimit);
return res.json({
schedules: paginatedHits,
total: results.total,
offset: offset,
limit: pageLimit,
hasMore: offset + paginatedHits.length < results.total,
});
}
// 날짜 필터 및 제한 조건 구성
let whereClause = "WHERE 1=1";
const params = [];
// 년/월 필터링 (월별 데이터 로딩용)
if (year && month) {
whereClause += " AND YEAR(s.date) = ? AND MONTH(s.date) = ?";
params.push(parseInt(year), parseInt(month));
} else if (year) {
whereClause += " AND YEAR(s.date) = ?";
params.push(parseInt(year));
}
if (startDate) {
whereClause += " AND s.date >= ?";
params.push(startDate);
}
if (endDate) {
whereClause += " AND s.date <= ?";
params.push(endDate);
}
// limit 파라미터 처리
const limitClause = limit ? `LIMIT ${parseInt(limit)}` : "";
// 검색어 없으면 DB에서 전체 조회
const [schedules] = await pool.query(
`
SELECT
s.id,
s.title,
s.description,
s.date,
s.time,
s.category_id,
s.source_url,
s.source_name,
s.location_name,
c.name as category_name,
c.color as category_color,
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
LEFT JOIN members m ON sm.member_id = m.id
${whereClause}
GROUP BY s.id
ORDER BY s.date ASC, s.time ASC
${limitClause}
`,
params
);
// 년/월 필터가 있으면 해당 월의 현재 멤버 생일을 가상 일정으로 추가
if (year && month) {
const [birthdays] = await pool.query(
`SELECT id, name, name_en, birth_date, image_url
FROM members
WHERE is_former = 0 AND MONTH(birth_date) = ?`,
[parseInt(month)]
);
const birthdaySchedules = birthdays.map((member) => {
const birthDate = new Date(member.birth_date);
const birthdayThisYear = new Date(
parseInt(year),
birthDate.getMonth(),
birthDate.getDate()
);
return {
id: `birthday-${member.id}`,
title: `HAPPY ${member.name_en} DAY`,
description: null,
date: birthdayThisYear,
time: null,
category_id: 8,
source_url: null,
source_name: null,
location_name: null,
category_name: "생일",
category_color: "#f472b6",
member_names: member.name,
is_birthday: true,
member_image: member.image_url,
};
});
// 일정과 생일을 합쳐서 날짜순 정렬
const allSchedules = [...schedules, ...birthdaySchedules].sort(
(a, b) => new Date(a.date) - new Date(b.date)
);
return res.json(allSchedules);
}
res.json(schedules);
} catch (error) {
console.error("일정 목록 조회 오류:", error);
res.status(500).json({ error: "일정 목록 조회 중 오류가 발생했습니다." });
}
});
// 카테고리 목록 조회
router.get("/categories", async (req, res) => {
try {
const [categories] = await pool.query(`
SELECT id, name, color, sort_order
FROM schedule_categories
ORDER BY sort_order ASC
`);
res.json(categories);
} catch (error) {
console.error("카테고리 조회 오류:", error);
res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." });
}
});
// 개별 일정 조회
router.get("/:id", async (req, res) => {
try {
const { id } = req.params;
const [schedules] = await pool.query(
`
SELECT
s.*,
c.name as category_name,
c.color as category_color
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
WHERE s.id = ?
`,
[id]
);
if (schedules.length === 0) {
return res.status(404).json({ error: "일정을 찾을 수 없습니다." });
}
const schedule = schedules[0];
// 이미지 조회
const [images] = await pool.query(
`SELECT image_url FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC`,
[id]
);
schedule.images = images.map((img) => img.image_url);
// 멤버 조회
const [members] = await pool.query(
`SELECT m.id, m.name FROM members m
JOIN schedule_members sm ON m.id = sm.member_id
WHERE sm.schedule_id = ?
ORDER BY m.id`,
[id]
);
schedule.members = members;
// 콘서트 카테고리(id=6)인 경우 같은 제목의 관련 일정들도 조회
if (schedule.category_id === 6) {
const [relatedSchedules] = await pool.query(
`
SELECT id, date, time
FROM schedules
WHERE title = ? AND category_id = 6
ORDER BY date ASC, time ASC
`,
[schedule.title]
);
schedule.related_dates = relatedSchedules;
}
res.json(schedule);
} catch (error) {
console.error("일정 조회 오류:", error);
res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." });
}
});
// Meilisearch 동기화 API
router.post("/sync-search", async (req, res) => {
try {
const { syncAllSchedules } = await import("../services/meilisearch.js");
// DB에서 모든 일정 조회
const [schedules] = await pool.query(`
SELECT
s.id,
s.title,
s.description,
s.date,
s.time,
s.category_id,
s.source_url,
s.source_name,
c.name as category_name,
c.color as category_color,
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
LEFT JOIN members m ON sm.member_id = m.id
GROUP BY s.id
`);
const count = await syncAllSchedules(schedules);
res.json({ success: true, synced: count });
} catch (error) {
console.error("Meilisearch 동기화 오류:", error);
res.status(500).json({ error: "동기화 중 오류가 발생했습니다." });
}
});
// X 프로필 정보 조회
router.get("/x-profile/:username", async (req, res) => {
try {
const { username } = req.params;
const profile = await getXProfile(username);
if (!profile) {
return res.status(404).json({ error: "프로필을 찾을 수 없습니다." });
}
res.json(profile);
} catch (error) {
console.error("X 프로필 조회 오류:", error);
res.status(500).json({ error: "프로필 조회 중 오류가 발생했습니다." });
}
});
export default router;

View file

@ -1,28 +0,0 @@
import express from "express";
import pool from "../lib/db.js";
const router = express.Router();
// 통계 조회 (멤버 수, 앨범 수)
router.get("/", async (req, res) => {
try {
const [memberCount] = await pool.query(
"SELECT COUNT(*) as count FROM members"
);
const [albumCount] = await pool.query(
"SELECT COUNT(*) as count FROM albums"
);
res.json({
memberCount: memberCount[0].count,
albumCount: albumCount[0].count,
debutYear: 2018,
fandomName: "flover",
});
} catch (error) {
console.error("통계 조회 오류:", error);
res.status(500).json({ error: "통계 정보를 가져오는데 실패했습니다." });
}
});
export default router;

View file

@ -1,81 +0,0 @@
import express from "express";
import path from "path";
import { fileURLToPath } from "url";
import membersRouter from "./routes/members.js";
import albumsRouter from "./routes/albums.js";
import statsRouter from "./routes/stats.js";
import adminRouter from "./routes/admin.js";
import schedulesRouter from "./routes/schedules.js";
import { initScheduler } from "./services/youtube-scheduler.js";
import { initMeilisearch } from "./services/meilisearch.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 80;
// JSON 파싱
app.use(express.json());
// 정적 파일 서빙 (프론트엔드 빌드 결과물)
app.use(express.static(path.join(__dirname, "dist")));
// API 라우트
app.get("/api/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.use("/api/members", membersRouter);
app.use("/api/albums", albumsRouter);
app.use("/api/stats", statsRouter);
app.use("/api/admin", adminRouter);
app.use("/api/schedules", schedulesRouter);
app.use("/api/schedule-categories", (req, res, next) => {
// /api/schedule-categories -> /api/schedules/categories로 리다이렉트
req.url = "/categories";
schedulesRouter(req, res, next);
});
// SPA 폴백 - 모든 요청을 index.html로
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "dist", "index.html"));
});
app.listen(PORT, async () => {
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
// Meilisearch 초기화 및 동기화
try {
await initMeilisearch();
console.log("🔍 Meilisearch 초기화 완료");
// 서버 시작 시 일정 데이터 자동 동기화
const { syncAllSchedules } = await import("./services/meilisearch.js");
const [schedules] = await (
await import("./lib/db.js")
).default.query(`
SELECT
s.id, s.title, s.description, s.date, s.time, s.category_id, s.source_url, s.source_name,
c.name as category_name, c.color as category_color,
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
LEFT JOIN members m ON sm.member_id = m.id
GROUP BY s.id
`);
const syncedCount = await syncAllSchedules(schedules);
console.log(`🔍 Meilisearch ${syncedCount}개 일정 동기화 완료`);
} catch (error) {
console.error("Meilisearch 초기화/동기화 오류:", error);
}
// YouTube 봇 스케줄러 초기화
try {
await initScheduler();
console.log("📺 YouTube 봇 스케줄러 초기화 완료");
} catch (error) {
console.error("YouTube 스케줄러 초기화 오류:", error);
}
});

View file

@ -1,84 +0,0 @@
/**
* Meilisearch 동기화 서비스
* 모든 일정을 Meilisearch에 동기화
*/
import pool from "../lib/db.js";
import { addOrUpdateSchedule } from "./meilisearch.js";
/**
* 전체 일정 Meilisearch 동기화
*/
export async function syncAllSchedules(botId) {
try {
const startTime = Date.now();
// 모든 일정 조회
const [schedules] = await pool.query(`
SELECT s.id, s.title, s.description, s.date, s.time,
s.category_id, s.source_url, s.source_name,
c.name as category_name, c.color as category_color
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
`);
let synced = 0;
for (const s of schedules) {
// 멤버 조회
const [members] = await pool.query(
"SELECT m.id, m.name FROM schedule_members sm JOIN members m ON sm.member_id = m.id WHERE sm.schedule_id = ?",
[s.id]
);
// Meilisearch 동기화
await addOrUpdateSchedule({
id: s.id,
title: s.title,
description: s.description || "",
date: s.date,
time: s.time,
category_id: s.category_id,
category_name: s.category_name || "",
category_color: s.category_color || "",
source_name: s.source_name,
source_url: s.source_url,
members: members,
});
synced++;
}
const elapsedMs = Date.now() - startTime;
const elapsedSec = (elapsedMs / 1000).toFixed(2);
// 봇 상태 업데이트 (schedules_added = 동기화 수, last_added_count = 소요시간 ms)
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
schedules_added = ?,
last_added_count = ?,
error_message = NULL
WHERE id = ?`,
[synced, elapsedMs, botId]
);
console.log(`[Meilisearch Bot] ${synced}개 동기화 완료 (${elapsedSec}초)`);
return { synced, elapsed: elapsedSec };
} catch (error) {
// 오류 상태 업데이트
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
status = 'error',
error_message = ?
WHERE id = ?`,
[error.message, botId]
);
throw error;
}
}
export default {
syncAllSchedules,
};

View file

@ -1,227 +0,0 @@
import { MeiliSearch } from "meilisearch";
// Meilisearch 클라이언트 초기화
const client = new MeiliSearch({
host: "http://fromis9-meilisearch:7700",
apiKey: process.env.MEILI_MASTER_KEY,
});
const SCHEDULE_INDEX = "schedules";
/**
* 인덱스 초기화 설정
*/
export async function initMeilisearch() {
try {
// 인덱스 생성 (이미 존재하면 무시)
await client.createIndex(SCHEDULE_INDEX, { primaryKey: "id" });
// 인덱스 설정
const index = client.index(SCHEDULE_INDEX);
// 검색 가능한 필드 설정 (순서가 우선순위 결정)
await index.updateSearchableAttributes([
"title",
"member_names",
"description",
"source_name",
"category_name",
]);
// 필터링 가능한 필드 설정
await index.updateFilterableAttributes(["category_id", "date"]);
// 정렬 가능한 필드 설정
await index.updateSortableAttributes(["date", "time"]);
// 랭킹 규칙 설정 (동일 유사도일 때 최신 날짜 우선)
await index.updateRankingRules([
"words", // 검색어 포함 개수
"typo", // 오타 수
"proximity", // 검색어 간 거리
"attribute", // 필드 우선순위
"exactness", // 정확도
"date:desc", // 동일 유사도 시 최신 날짜 우선
]);
// 오타 허용 설정 (typo tolerance)
await index.updateTypoTolerance({
enabled: true,
minWordSizeForTypos: {
oneTypo: 2,
twoTypos: 4,
},
});
// 페이징 설정 (기본 1000개 제한 해제)
await index.updatePagination({
maxTotalHits: 10000, // 최대 10000개까지 조회 가능
});
console.log("[Meilisearch] 인덱스 초기화 완료");
} catch (error) {
console.error("[Meilisearch] 초기화 오류:", error.message);
}
}
/**
* 일정 문서 추가/업데이트
*/
export async function addOrUpdateSchedule(schedule) {
try {
const index = client.index(SCHEDULE_INDEX);
// 멤버 이름을 쉼표로 구분하여 저장
const memberNames = schedule.members
? schedule.members.map((m) => m.name).join(",")
: "";
const document = {
id: schedule.id,
title: schedule.title,
description: schedule.description || "",
date: schedule.date,
time: schedule.time || "",
category_id: schedule.category_id,
category_name: schedule.category_name || "",
category_color: schedule.category_color || "",
source_name: schedule.source_name || "",
source_url: schedule.source_url || "",
member_names: memberNames,
};
await index.addDocuments([document]);
console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`);
} catch (error) {
console.error("[Meilisearch] 문서 추가 오류:", error.message);
}
}
/**
* 일정 문서 삭제
*/
export async function deleteSchedule(scheduleId) {
try {
const index = client.index(SCHEDULE_INDEX);
await index.deleteDocument(scheduleId);
console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`);
} catch (error) {
console.error("[Meilisearch] 문서 삭제 오류:", error.message);
}
}
import Inko from "inko";
const inko = new Inko();
/**
* 영문 자판으로 입력된 검색어인지 확인 (대부분 영문으로만 구성)
*/
function isEnglishKeyboard(text) {
const englishChars = text.match(/[a-zA-Z]/g) || [];
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
// 영문이 50% 이상이고 한글이 없으면 영문 자판 입력으로 간주
return englishChars.length > 0 && koreanChars.length === 0;
}
/**
* 일정 검색 (페이징 지원)
*/
export async function searchSchedules(query, options = {}) {
try {
const index = client.index(SCHEDULE_INDEX);
const searchOptions = {
limit: options.limit || 1000, // 기본 1000개 (Meilisearch 최대)
offset: options.offset || 0, // 페이징용 offset
attributesToRetrieve: ["*"],
showRankingScore: true, // 유사도 점수 포함
};
// 카테고리 필터
if (options.categoryId) {
searchOptions.filter = `category_id = ${options.categoryId}`;
}
// 정렬 지정 시에만 적용 (기본은 유사도순)
if (options.sort) {
searchOptions.sort = options.sort;
}
// 원본 검색어로 검색
const results = await index.search(query, searchOptions);
let allHits = [...results.hits];
// 영문 자판 입력인 경우 한글로 변환하여 추가 검색
if (isEnglishKeyboard(query)) {
const koreanQuery = inko.en2ko(query);
if (koreanQuery !== query) {
const koreanResults = await index.search(koreanQuery, searchOptions);
// 중복 제거하며 병합 (id 기준)
const existingIds = new Set(allHits.map((h) => h.id));
for (const hit of koreanResults.hits) {
if (!existingIds.has(hit.id)) {
allHits.push(hit);
existingIds.add(hit.id);
}
}
}
}
// 유사도 0.5 미만인 결과 필터링
const filteredHits = allHits.filter((hit) => hit._rankingScore >= 0.5);
// 유사도 순으로 정렬
filteredHits.sort(
(a, b) => (b._rankingScore || 0) - (a._rankingScore || 0)
);
// 페이징 정보 포함 반환
return {
hits: filteredHits,
total: filteredHits.length, // 필터링 후 결과 수
offset: searchOptions.offset,
limit: searchOptions.limit,
};
} catch (error) {
console.error("[Meilisearch] 검색 오류:", error.message);
return { hits: [], total: 0, offset: 0, limit: 0 };
}
}
/**
* 모든 일정 동기화 (초기 데이터 로드용)
*/
export async function syncAllSchedules(schedules) {
try {
const index = client.index(SCHEDULE_INDEX);
// 기존 문서 모두 삭제
await index.deleteAllDocuments();
// 문서 변환
const documents = schedules.map((schedule) => ({
id: schedule.id,
title: schedule.title,
description: schedule.description || "",
date: schedule.date,
time: schedule.time || "",
category_id: schedule.category_id,
category_name: schedule.category_name || "",
category_color: schedule.category_color || "",
source_name: schedule.source_name || "",
source_url: schedule.source_url || "",
member_names: schedule.member_names || "",
}));
// 일괄 추가
await index.addDocuments(documents);
console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`);
return documents.length;
} catch (error) {
console.error("[Meilisearch] 동기화 오류:", error.message);
return 0;
}
}
export { client };

View file

@ -1,248 +0,0 @@
import pool from "../lib/db.js";
import redis from "../lib/redis.js";
import Inko from "inko";
import { searchSchedules } from "./meilisearch.js";
const inko = new Inko();
// Redis 키 prefix
const SUGGESTION_PREFIX = "suggestions:";
const CACHE_TTL = 86400; // 24시간
// 추천 검색어로 노출되기 위한 최소 비율 (최대 검색 횟수 대비)
// 예: 0.01 = 최대 검색 횟수의 1% 이상만 노출
const MIN_COUNT_RATIO = 0.01;
// 최소 임계값 (데이터가 적을 때 오타 방지)
const MIN_COUNT_FLOOR = 10;
/**
* 영문만 포함된 검색어인지 확인
*/
function isEnglishOnly(text) {
const englishChars = text.match(/[a-zA-Z]/g) || [];
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
return englishChars.length > 0 && koreanChars.length === 0;
}
/**
* 일정 검색 결과가 있는지 확인 (Meilisearch)
*/
async function hasScheduleResults(query) {
try {
const result = await searchSchedules(query, { limit: 1 });
return result.hits.length > 0;
} catch (error) {
console.error("[SearchSuggestion] 검색 확인 오류:", error.message);
return false;
}
}
/**
* 영어 입력을 분석하여 실제 영어인지 한글 오타인지 판단
* 1. 영어로 일정 검색 결과 있으면 영어
* 2. 한글 변환 일정 검색 결과 있으면 한글
* 3. 없으면 원본 유지
*/
async function resolveEnglishInput(query) {
const koreanQuery = inko.en2ko(query);
// 변환 결과가 같으면 변환 의미 없음
if (koreanQuery === query) {
return { resolved: query, type: "english" };
}
// 1. 영어로 검색
const hasEnglishResult = await hasScheduleResults(query);
if (hasEnglishResult) {
return { resolved: query, type: "english" };
}
// 2. 한글로 검색
const hasKoreanResult = await hasScheduleResults(koreanQuery);
if (hasKoreanResult) {
return { resolved: koreanQuery, type: "korean_typo" };
}
// 3. 둘 다 없으면 원본 유지
return { resolved: query, type: "unknown" };
}
/**
* 검색어 저장 (검색 실행 호출)
* - search_queries 테이블에 Unigram 저장
* - word_pairs 테이블에 Bi-gram 저장
* - Redis 캐시 업데이트
* - 영어 입력 일정 검색으로 언어 판단
*/
export async function saveSearchQuery(query) {
if (!query || query.trim().length === 0) return;
let normalizedQuery = query.trim().toLowerCase();
// 영문만 있는 경우 일정 검색으로 언어 판단
if (isEnglishOnly(normalizedQuery)) {
const { resolved, type } = await resolveEnglishInput(normalizedQuery);
if (type === "korean_typo") {
console.log(
`[SearchSuggestion] 한글 오타 감지: "${normalizedQuery}" → "${resolved}"`
);
}
normalizedQuery = resolved;
}
try {
// 1. Unigram 저장 (인기도)
await pool.query(
`INSERT INTO search_queries (query, count)
VALUES (?, 1)
ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`,
[normalizedQuery]
);
// 2. Bi-gram 저장 (다음 단어 예측)
const words = normalizedQuery.split(/\s+/).filter((w) => w.length > 0);
for (let i = 0; i < words.length - 1; i++) {
const word1 = words[i];
const word2 = words[i + 1];
// DB 저장
await pool.query(
`INSERT INTO word_pairs (word1, word2, count)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE count = count + 1`,
[word1, word2]
);
// Redis 캐시 업데이트 (Sorted Set)
await redis.zincrby(`${SUGGESTION_PREFIX}${word1}`, 1, word2);
}
console.log(`[SearchSuggestion] 검색어 저장: "${normalizedQuery}"`);
} catch (error) {
console.error("[SearchSuggestion] 검색어 저장 오류:", error.message);
}
}
/**
* 추천 검색어 조회
* - 입력이 공백으로 끝나면: 마지막 단어 기반 다음 단어 예측 (Bi-gram)
* - : prefix 매칭 (인기순)
* - 영어 입력 : 일정 검색으로 영어/한글 판단
*/
export async function getSuggestions(query, limit = 10) {
if (!query || query.trim().length === 0) {
return [];
}
let searchQuery = query.toLowerCase();
let koreanQuery = null;
// 영문만 있는 경우, 한글 변환도 같이 검색
if (isEnglishOnly(searchQuery)) {
const converted = inko.en2ko(searchQuery);
if (converted !== searchQuery) {
koreanQuery = converted;
}
}
try {
const endsWithSpace = query.endsWith(" ");
const words = searchQuery
.trim()
.split(/\s+/)
.filter((w) => w.length > 0);
if (endsWithSpace && words.length > 0) {
// 다음 단어 예측 (Bi-gram)
const lastWord = words[words.length - 1];
return await getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
} else {
// prefix 매칭 (인기순) - 영어 원본 + 한글 변환 둘 다
return await getPrefixSuggestions(
searchQuery.trim(),
koreanQuery?.trim(),
limit
);
}
} catch (error) {
console.error("[SearchSuggestion] 추천 조회 오류:", error.message);
return [];
}
}
/**
* 다음 단어 예측 (Bi-gram 기반)
* 동적 임계값을 사용하므로 Redis 캐시를 사용하지 않음
*/
async function getNextWordSuggestions(lastWord, prefix, limit) {
try {
const [rows] = await pool.query(
`SELECT word2, count FROM word_pairs
WHERE word1 = ?
AND count >= GREATEST((SELECT MAX(count) * ? FROM word_pairs), ?)
ORDER BY count DESC
LIMIT ?`,
[lastWord, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
);
// prefix + 다음 단어 조합으로 반환
return rows.map((r) => `${prefix} ${r.word2}`);
} catch (error) {
console.error("[SearchSuggestion] Bi-gram 조회 오류:", error.message);
return [];
}
}
/**
* Prefix 매칭 (인기순)
* @param {string} prefix - 원본 검색어 (영어 또는 한글)
* @param {string|null} koreanPrefix - 한글 변환된 검색어 (영어 입력의 경우)
* @param {number} limit - 결과 개수
*/
async function getPrefixSuggestions(prefix, koreanPrefix, limit) {
try {
let rows;
if (koreanPrefix) {
// 영어 원본과 한글 변환 둘 다 검색
[rows] = await pool.query(
`SELECT query FROM search_queries
WHERE (query LIKE ? OR query LIKE ?)
AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?)
ORDER BY count DESC, last_searched_at DESC
LIMIT ?`,
[`${prefix}%`, `${koreanPrefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
);
} else {
// 단일 검색
[rows] = await pool.query(
`SELECT query FROM search_queries
WHERE query LIKE ?
AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?)
ORDER BY count DESC, last_searched_at DESC
LIMIT ?`,
[`${prefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
);
}
return rows.map((r) => r.query);
} catch (error) {
console.error("[SearchSuggestion] Prefix 조회 오류:", error.message);
return [];
}
}
/**
* Redis 캐시 초기화 (필요시)
*/
export async function clearSuggestionCache() {
try {
const keys = await redis.keys(`${SUGGESTION_PREFIX}*`);
if (keys.length > 0) {
await redis.del(...keys);
console.log(`[SearchSuggestion] ${keys.length}개 캐시 삭제`);
}
} catch (error) {
console.error("[SearchSuggestion] 캐시 초기화 오류:", error.message);
}
}

View file

@ -1,687 +0,0 @@
/**
* X 서비스
*
* - Nitter를 통해 @realfromis_9 트윗 수집
* - 트윗을 schedules 테이블에 저장
* - 유튜브 링크 감지 별도 일정 추가
*/
import pool from "../lib/db.js";
import redis from "../lib/redis.js";
import { addOrUpdateSchedule } from "./meilisearch.js";
import {
toKST,
formatDate,
formatTime,
parseNitterDateTime,
} from "../lib/date.js";
// X 프로필 캐시 키 prefix
const X_PROFILE_CACHE_PREFIX = "x_profile:";
// YouTube API 키
const YOUTUBE_API_KEY =
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM";
// X 카테고리 ID
const X_CATEGORY_ID = 3;
// 유튜브 카테고리 ID
const YOUTUBE_CATEGORY_ID = 2;
/**
* 트윗 텍스트에서 문단 추출 (title용)
*/
export function extractTitle(text) {
if (!text) return "";
// 빈 줄(\n\n)로 분리하여 첫 문단 추출
const paragraphs = text.split(/\n\n+/);
const firstParagraph = paragraphs[0]?.trim() || "";
return firstParagraph;
}
/**
* 텍스트에서 유튜브 videoId 추출
*/
export function extractYoutubeVideoIds(text) {
if (!text) return [];
const videoIds = [];
// youtu.be/{videoId} 형식
const shortMatches = text.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/g);
if (shortMatches) {
shortMatches.forEach((m) => {
const id = m.replace("youtu.be/", "");
if (id && id.length === 11) videoIds.push(id);
});
}
// youtube.com/watch?v={videoId} 형식
const watchMatches = text.match(
/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/g
);
if (watchMatches) {
watchMatches.forEach((m) => {
const id = m.match(/v=([a-zA-Z0-9_-]{11})/)?.[1];
if (id) videoIds.push(id);
});
}
// youtube.com/shorts/{videoId} 형식
const shortsMatches = text.match(
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/g
);
if (shortsMatches) {
shortsMatches.forEach((m) => {
const id = m.match(/shorts\/([a-zA-Z0-9_-]{11})/)?.[1];
if (id) videoIds.push(id);
});
}
return [...new Set(videoIds)];
}
/**
* 관리 중인 채널 ID 목록 조회
*/
export async function getManagedChannelIds() {
const [configs] = await pool.query(
"SELECT channel_id FROM bot_youtube_config"
);
return configs.map((c) => c.channel_id);
}
/**
* YouTube API로 영상 정보 조회
*/
async function fetchVideoInfo(videoId) {
try {
const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${videoId}&key=${YOUTUBE_API_KEY}`;
const response = await fetch(url);
const data = await response.json();
if (!data.items || data.items.length === 0) {
return null;
}
const video = data.items[0];
const snippet = video.snippet;
const duration = video.contentDetails?.duration || "";
// duration 파싱
const durationMatch = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
let seconds = 0;
if (durationMatch) {
seconds =
parseInt(durationMatch[1] || 0) * 3600 +
parseInt(durationMatch[2] || 0) * 60 +
parseInt(durationMatch[3] || 0);
}
const isShorts = seconds > 0 && seconds <= 60;
return {
videoId,
title: snippet.title,
description: snippet.description || "",
channelId: snippet.channelId,
channelTitle: snippet.channelTitle,
publishedAt: new Date(snippet.publishedAt),
isShorts,
videoUrl: isShorts
? `https://www.youtube.com/shorts/${videoId}`
: `https://www.youtube.com/watch?v=${videoId}`,
};
} catch (error) {
console.error(`영상 정보 조회 오류 (${videoId}):`, error.message);
return null;
}
}
/**
* Nitter HTML에서 프로필 정보 추출
*/
function extractProfileFromHtml(html) {
const profile = {
displayName: null,
avatarUrl: null,
};
// Display name 추출: <a class="profile-card-fullname" ... title="이름">이름</a>
const nameMatch = html.match(
/class="profile-card-fullname"[^>]*title="([^"]+)"/
);
if (nameMatch) {
profile.displayName = nameMatch[1].trim();
}
// Avatar URL 추출: <a class="profile-card-avatar" ...><img src="/pic/...">
const avatarMatch = html.match(
/class="profile-card-avatar"[^>]*>[\s\S]*?<img[^>]*src="([^"]+)"/
);
if (avatarMatch) {
profile.avatarUrl = avatarMatch[1];
}
return profile;
}
/**
* X 프로필 정보 캐시에 저장
*/
async function cacheXProfile(username, profile, nitterUrl) {
try {
// Nitter 프록시 URL에서 원본 Twitter 이미지 URL 추출
let avatarUrl = profile.avatarUrl;
if (avatarUrl) {
// /pic/https%3A%2F%2Fpbs.twimg.com%2F... 형식에서 원본 URL 추출
const encodedMatch = avatarUrl.match(/\/pic\/(.+)/);
if (encodedMatch) {
avatarUrl = decodeURIComponent(encodedMatch[1]);
} else if (avatarUrl.startsWith("/")) {
// 상대 경로인 경우 Nitter URL 추가
avatarUrl = `${nitterUrl}${avatarUrl}`;
}
}
const data = {
username,
displayName: profile.displayName,
avatarUrl,
updatedAt: new Date().toISOString(),
};
// 7일간 캐시 (604800초)
await redis.setex(
`${X_PROFILE_CACHE_PREFIX}${username}`,
604800,
JSON.stringify(data)
);
console.log(`[X 프로필] ${username} 캐시 저장 완료`);
} catch (error) {
console.error(`[X 프로필] 캐시 저장 실패:`, error.message);
}
}
/**
* X 프로필 정보 조회
*/
export async function getXProfile(username) {
try {
const cached = await redis.get(`${X_PROFILE_CACHE_PREFIX}${username}`);
if (cached) {
return JSON.parse(cached);
}
return null;
} catch (error) {
console.error(`[X 프로필] 캐시 조회 실패:`, error.message);
return null;
}
}
/**
* Nitter에서 트윗 수집 ( 페이지만)
*/
async function fetchTweetsFromNitter(nitterUrl, username) {
const url = `${nitterUrl}/${username}`;
const response = await fetch(url);
const html = await response.text();
// 프로필 정보 추출 및 캐싱
const profile = extractProfileFromHtml(html);
if (profile.displayName || profile.avatarUrl) {
await cacheXProfile(username, profile, nitterUrl);
}
const tweets = [];
const tweetContainers = html.split('class="timeline-item ');
for (let i = 1; i < tweetContainers.length; i++) {
const container = tweetContainers[i];
const tweet = {};
// 고정 트윗 체크 - 현재 컨테이너 내에 pinned 클래스가 있는지 확인
tweet.isPinned = container.includes('class="pinned"');
// 리트윗 체크
tweet.isRetweet = container.includes('class="retweet-header"');
// 트윗 ID 추출
const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
tweet.id = linkMatch ? linkMatch[1] : null;
// 시간 추출
const timeMatch = container.match(
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
);
tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
// 텍스트 내용 추출
const contentMatch = container.match(
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
);
if (contentMatch) {
tweet.text = contentMatch[1]
.replace(/<br\s*\/?>/g, "\n")
.replace(/<a[^>]*>([^<]*)<\/a>/g, "$1")
.replace(/<[^>]+>/g, "")
.trim();
}
// URL 생성
tweet.url = tweet.id
? `https://x.com/${username}/status/${tweet.id}`
: null;
if (tweet.id && !tweet.isRetweet && !tweet.isPinned) {
tweets.push(tweet);
}
}
return tweets;
}
/**
* Nitter에서 전체 트윗 수집 (페이지네이션)
*/
async function fetchAllTweetsFromNitter(nitterUrl, username) {
const allTweets = [];
let cursor = null;
let pageNum = 1;
let consecutiveEmpty = 0;
const DELAY_MS = 1000;
while (true) {
const url = cursor
? `${nitterUrl}/${username}?cursor=${cursor}`
: `${nitterUrl}/${username}`;
console.log(`[페이지 ${pageNum}] 스크래핑 중...`);
try {
const response = await fetch(url);
const html = await response.text();
const tweets = [];
const tweetContainers = html.split('class="timeline-item ');
for (let i = 1; i < tweetContainers.length; i++) {
const container = tweetContainers[i];
const tweet = {};
// 고정 트윗 체크 - 현재 컨테이너 내에 pinned 클래스가 있는지 확인
tweet.isPinned = container.includes('class="pinned"');
tweet.isRetweet = container.includes('class="retweet-header"');
const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
tweet.id = linkMatch ? linkMatch[1] : null;
const timeMatch = container.match(
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
);
tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
const contentMatch = container.match(
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
);
if (contentMatch) {
tweet.text = contentMatch[1]
.replace(/<br\s*\/?>/g, "\n")
.replace(/<a[^>]*>([^<]*)<\/a>/g, "$1")
.replace(/<[^>]+>/g, "")
.trim();
}
tweet.url = tweet.id
? `https://x.com/${username}/status/${tweet.id}`
: null;
if (tweet.id && !tweet.isRetweet && !tweet.isPinned) {
tweets.push(tweet);
}
}
if (tweets.length === 0) {
consecutiveEmpty++;
console.log(` -> 트윗 없음 (연속 ${consecutiveEmpty}회)`);
if (consecutiveEmpty >= 3) break;
} else {
consecutiveEmpty = 0;
allTweets.push(...tweets);
console.log(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`);
}
// 다음 페이지 cursor 추출
const cursorMatch = html.match(
/class="show-more"[^>]*>\s*<a href="\?cursor=([^"]+)"/
);
if (!cursorMatch) {
console.log("\n다음 페이지 없음. 스크래핑 완료.");
break;
}
cursor = cursorMatch[1];
pageNum++;
await new Promise((r) => setTimeout(r, DELAY_MS));
} catch (error) {
console.error(` -> 오류: ${error.message}`);
consecutiveEmpty++;
if (consecutiveEmpty >= 5) break;
await new Promise((r) => setTimeout(r, DELAY_MS * 3));
}
}
return allTweets;
}
/**
* 트윗을 일정으로 저장
*/
async function createScheduleFromTweet(tweet) {
// source_url로 중복 체크
const [existing] = await pool.query(
"SELECT id FROM schedules WHERE source_url = ?",
[tweet.url]
);
if (existing.length > 0) {
return null; // 이미 존재
}
const kstDate = toKST(tweet.time);
const date = formatDate(kstDate);
const time = formatTime(kstDate);
const title = extractTitle(tweet.text);
const description = tweet.text;
// 일정 생성
const [result] = await pool.query(
`INSERT INTO schedules (title, description, date, time, category_id, source_url, source_name)
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
[title, description, date, time, X_CATEGORY_ID, tweet.url]
);
const scheduleId = result.insertId;
// Meilisearch 동기화
try {
const [categoryInfo] = await pool.query(
"SELECT name, color FROM schedule_categories WHERE id = ?",
[X_CATEGORY_ID]
);
await addOrUpdateSchedule({
id: scheduleId,
title,
description,
date,
time,
category_id: X_CATEGORY_ID,
category_name: categoryInfo[0]?.name || "",
category_color: categoryInfo[0]?.color || "",
source_name: null,
source_url: tweet.url,
members: [],
});
} catch (searchError) {
console.error("Meilisearch 동기화 오류:", searchError.message);
}
return scheduleId;
}
/**
* 유튜브 영상을 일정으로 저장
*/
async function createScheduleFromYoutube(video) {
// source_url로 중복 체크
const [existing] = await pool.query(
"SELECT id FROM schedules WHERE source_url = ?",
[video.videoUrl]
);
if (existing.length > 0) {
return null; // 이미 존재
}
const kstDate = toKST(video.publishedAt);
const date = formatDate(kstDate);
const time = formatTime(kstDate);
// 일정 생성 (source_name에 채널명 저장)
const [result] = await pool.query(
`INSERT INTO schedules (title, date, time, category_id, source_url, source_name)
VALUES (?, ?, ?, ?, ?, ?)`,
[
video.title,
date,
time,
YOUTUBE_CATEGORY_ID,
video.videoUrl,
video.channelTitle || null,
]
);
const scheduleId = result.insertId;
// Meilisearch 동기화
try {
const [categoryInfo] = await pool.query(
"SELECT name, color FROM schedule_categories WHERE id = ?",
[YOUTUBE_CATEGORY_ID]
);
await addOrUpdateSchedule({
id: scheduleId,
title: video.title,
description: "",
date,
time,
category_id: YOUTUBE_CATEGORY_ID,
category_name: categoryInfo[0]?.name || "",
category_color: categoryInfo[0]?.color || "",
source_name: video.channelTitle || null,
source_url: video.videoUrl,
members: [],
});
} catch (searchError) {
console.error("Meilisearch 동기화 오류:", searchError.message);
}
return scheduleId;
}
/**
* 트윗 동기화 ( 페이지만 - 1 간격 실행용)
*/
export async function syncNewTweets(botId) {
try {
// 봇 정보 조회
const [bots] = await pool.query(
`SELECT b.*, c.username, c.nitter_url
FROM bots b
LEFT JOIN bot_x_config c ON b.id = c.bot_id
WHERE b.id = ?`,
[botId]
);
if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다.");
}
const bot = bots[0];
if (!bot.username) {
throw new Error("Username이 설정되지 않았습니다.");
}
const nitterUrl = bot.nitter_url || "http://nitter:8080";
// 관리 중인 채널 목록 조회
const managedChannelIds = await getManagedChannelIds();
// Nitter에서 트윗 수집 (첫 페이지만)
const tweets = await fetchTweetsFromNitter(nitterUrl, bot.username);
let addedCount = 0;
let ytAddedCount = 0;
for (const tweet of tweets) {
// 트윗 저장
const scheduleId = await createScheduleFromTweet(tweet);
if (scheduleId) addedCount++;
// 유튜브 링크 처리
const videoIds = extractYoutubeVideoIds(tweet.text);
for (const videoId of videoIds) {
const video = await fetchVideoInfo(videoId);
if (!video) continue;
// 관리 중인 채널이면 스킵
if (managedChannelIds.includes(video.channelId)) continue;
// 유튜브 일정 저장
const ytScheduleId = await createScheduleFromYoutube(video);
if (ytScheduleId) ytAddedCount++;
}
}
// 봇 상태 업데이트
// 추가된 항목이 있을 때만 last_added_count 업데이트 (0이면 이전 값 유지)
const totalAdded = addedCount + ytAddedCount;
if (totalAdded > 0) {
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
schedules_added = schedules_added + ?,
last_added_count = ?,
error_message = NULL
WHERE id = ?`,
[totalAdded, totalAdded, botId]
);
} else {
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
error_message = NULL
WHERE id = ?`,
[botId]
);
}
return { addedCount, ytAddedCount, total: tweets.length };
} catch (error) {
// 오류 상태 업데이트
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
status = 'error',
error_message = ?
WHERE id = ?`,
[error.message, botId]
);
throw error;
}
}
/**
* 전체 트윗 동기화 (전체 페이지 - 초기화용)
*/
export async function syncAllTweets(botId) {
try {
// 봇 정보 조회
const [bots] = await pool.query(
`SELECT b.*, c.username, c.nitter_url
FROM bots b
LEFT JOIN bot_x_config c ON b.id = c.bot_id
WHERE b.id = ?`,
[botId]
);
if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다.");
}
const bot = bots[0];
if (!bot.username) {
throw new Error("Username이 설정되지 않았습니다.");
}
const nitterUrl = bot.nitter_url || "http://nitter:8080";
// 관리 중인 채널 목록 조회
const managedChannelIds = await getManagedChannelIds();
// Nitter에서 전체 트윗 수집
const tweets = await fetchAllTweetsFromNitter(nitterUrl, bot.username);
let addedCount = 0;
let ytAddedCount = 0;
for (const tweet of tweets) {
// 트윗 저장
const scheduleId = await createScheduleFromTweet(tweet);
if (scheduleId) addedCount++;
// 유튜브 링크 처리
const videoIds = extractYoutubeVideoIds(tweet.text);
for (const videoId of videoIds) {
const video = await fetchVideoInfo(videoId);
if (!video) continue;
// 관리 중인 채널이면 스킵
if (managedChannelIds.includes(video.channelId)) continue;
// 유튜브 일정 저장
const ytScheduleId = await createScheduleFromYoutube(video);
if (ytScheduleId) ytAddedCount++;
}
}
// 봇 상태 업데이트
// 추가된 항목이 있을 때만 last_added_count 업데이트 (0이면 이전 값 유지)
const totalAdded = addedCount + ytAddedCount;
if (totalAdded > 0) {
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
schedules_added = schedules_added + ?,
last_added_count = ?,
error_message = NULL
WHERE id = ?`,
[totalAdded, totalAdded, botId]
);
} else {
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
error_message = NULL
WHERE id = ?`,
[botId]
);
}
return { addedCount, ytAddedCount, total: tweets.length };
} catch (error) {
await pool.query(
`UPDATE bots SET
status = 'error',
error_message = ?
WHERE id = ?`,
[error.message, botId]
);
throw error;
}
}
export default {
syncNewTweets,
syncAllTweets,
extractTitle,
extractYoutubeVideoIds,
};

View file

@ -1,648 +0,0 @@
import Parser from "rss-parser";
import pool from "../lib/db.js";
import { addOrUpdateSchedule } from "./meilisearch.js";
import { toKST, formatDate, formatTime } from "../lib/date.js";
// YouTube API 키
const YOUTUBE_API_KEY =
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM";
// 봇별 커스텀 설정 (DB 대신 코드에서 관리)
// botId를 키로 사용
const BOT_CUSTOM_CONFIG = {
// MUSINSA TV: 제목에 '성수기' 포함된 영상만, 이채영 기본 멤버, description에서 멤버 추출
3: {
titleFilter: "성수기",
defaultMemberId: 7, // 이채영
extractMembersFromDesc: true,
},
};
/**
* 커스텀 설정 조회
*/
function getBotCustomConfig(botId) {
return (
BOT_CUSTOM_CONFIG[botId] || {
titleFilter: null,
defaultMemberId: null,
extractMembersFromDesc: false,
}
);
}
// RSS 파서 설정 (media:description 포함)
const rssParser = new Parser({
customFields: {
item: [
["yt:videoId", "videoId"],
["yt:channelId", "channelId"],
["media:group", "mediaGroup"],
],
},
});
/**
* '유튜브' 카테고리 ID 조회 (없으면 생성)
*/
export async function getYoutubeCategory() {
const [rows] = await pool.query(
"SELECT id FROM schedule_categories WHERE name = '유튜브'"
);
if (rows.length > 0) {
return rows[0].id;
}
// 없으면 생성
const [result] = await pool.query(
"INSERT INTO schedule_categories (name, color, sort_order) VALUES ('유튜브', '#ff0033', 99)"
);
return result.insertId;
}
/**
* 영상 URL에서 유형 판별 (video/shorts)
*/
export function getVideoType(url) {
if (url.includes("/shorts/")) {
return "shorts";
}
return "video";
}
/**
* 영상 URL 생성
*/
export function getVideoUrl(videoId, videoType) {
if (videoType === "shorts") {
return `https://www.youtube.com/shorts/${videoId}`;
}
return `https://www.youtube.com/watch?v=${videoId}`;
}
/**
* RSS 피드 파싱하여 영상 목록 반환
*/
export async function parseRSSFeed(rssUrl) {
try {
const feed = await rssParser.parseURL(rssUrl);
return feed.items.map((item) => {
const videoId = item.videoId;
const link = item.link || "";
const videoType = getVideoType(link);
const publishedAt = toKST(new Date(item.pubDate));
// media:group에서 description 추출
let description = "";
if (item.mediaGroup && item.mediaGroup["media:description"]) {
description = item.mediaGroup["media:description"][0] || "";
}
return {
videoId,
title: item.title,
description,
publishedAt,
date: formatDate(publishedAt),
time: formatTime(publishedAt),
videoUrl: link || getVideoUrl(videoId, videoType),
videoType,
};
});
} catch (error) {
console.error("RSS 파싱 오류:", error);
throw error;
}
}
/**
* ISO 8601 duration (PT1M30S) 변환
*/
function parseDuration(duration) {
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return 0;
const hours = parseInt(match[1] || 0);
const minutes = parseInt(match[2] || 0);
const seconds = parseInt(match[3] || 0);
return hours * 3600 + minutes * 60 + seconds;
}
/**
* YouTube API로 최근 N개 영상 수집 (정기 동기화용)
* @param {string} channelId - 채널 ID
* @param {number} maxResults - 조회할 영상 (기본 10)
*/
export async function fetchRecentVideosFromAPI(channelId, maxResults = 10) {
const videos = [];
try {
// 채널의 업로드 플레이리스트 ID 조회
const channelResponse = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}`
);
const channelData = await channelResponse.json();
if (!channelData.items || channelData.items.length === 0) {
throw new Error("채널을 찾을 수 없습니다.");
}
const uploadsPlaylistId =
channelData.items[0].contentDetails.relatedPlaylists.uploads;
// 플레이리스트 아이템 조회 (최근 N개만)
const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=${maxResults}&key=${YOUTUBE_API_KEY}`;
const response = await fetch(url);
const data = await response.json();
if (data.error) {
throw new Error(data.error.message);
}
// 영상 ID 목록 추출
const videoIds = data.items.map((item) => item.snippet.resourceId.videoId);
// videos API로 duration 조회 (Shorts 판별용)
const videosResponse = await fetch(
`https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join(
","
)}&key=${YOUTUBE_API_KEY}`
);
const videosData = await videosResponse.json();
// duration으로 Shorts 판별 맵 생성
const durationMap = {};
if (videosData.items) {
for (const v of videosData.items) {
const duration = v.contentDetails.duration;
const seconds = parseDuration(duration);
durationMap[v.id] = seconds <= 60 ? "shorts" : "video";
}
}
for (const item of data.items) {
const snippet = item.snippet;
const videoId = snippet.resourceId.videoId;
const publishedAt = toKST(new Date(snippet.publishedAt));
const videoType = durationMap[videoId] || "video";
videos.push({
videoId,
title: snippet.title,
description: snippet.description || "",
publishedAt,
date: formatDate(publishedAt),
time: formatTime(publishedAt),
videoUrl: getVideoUrl(videoId, videoType),
videoType,
});
}
return videos;
} catch (error) {
console.error("YouTube API 오류:", error);
throw error;
}
}
/**
* YouTube API로 전체 영상 수집 (초기 동기화용)
* Shorts 판별: duration이 60 이하이면 Shorts
*/
export async function fetchAllVideosFromAPI(channelId) {
const videos = [];
let pageToken = "";
try {
// 채널의 업로드 플레이리스트 ID 조회
const channelResponse = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}`
);
const channelData = await channelResponse.json();
if (!channelData.items || channelData.items.length === 0) {
throw new Error("채널을 찾을 수 없습니다.");
}
const uploadsPlaylistId =
channelData.items[0].contentDetails.relatedPlaylists.uploads;
// 플레이리스트 아이템 조회 (페이징)
do {
const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=50&key=${YOUTUBE_API_KEY}${
pageToken ? `&pageToken=${pageToken}` : ""
}`;
const response = await fetch(url);
const data = await response.json();
if (data.error) {
throw new Error(data.error.message);
}
// 영상 ID 목록 추출
const videoIds = data.items.map(
(item) => item.snippet.resourceId.videoId
);
// videos API로 duration 조회 (50개씩 배치)
const videosResponse = await fetch(
`https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join(
","
)}&key=${YOUTUBE_API_KEY}`
);
const videosData = await videosResponse.json();
// duration으로 Shorts 판별 맵 생성
const durationMap = {};
if (videosData.items) {
for (const v of videosData.items) {
const duration = v.contentDetails.duration;
const seconds = parseDuration(duration);
durationMap[v.id] = seconds <= 60 ? "shorts" : "video";
}
}
for (const item of data.items) {
const snippet = item.snippet;
const videoId = snippet.resourceId.videoId;
const publishedAt = toKST(new Date(snippet.publishedAt));
const videoType = durationMap[videoId] || "video";
videos.push({
videoId,
title: snippet.title,
description: snippet.description || "",
publishedAt,
date: formatDate(publishedAt),
time: formatTime(publishedAt),
videoUrl: getVideoUrl(videoId, videoType),
videoType,
});
}
pageToken = data.nextPageToken || "";
} while (pageToken);
// 과거순 정렬 (오래된 영상부터 추가)
videos.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
return videos;
} catch (error) {
console.error("YouTube API 오류:", error);
throw error;
}
}
/**
* 영상을 일정으로 추가 (source_url로 중복 체크)
* @param {Object} video - 영상 정보
* @param {number} categoryId - 카테고리 ID
* @param {number[]} memberIds - 연결할 멤버 ID 배열 (선택)
* @param {string} sourceName - 출처 이름 (선택)
*/
export async function createScheduleFromVideo(
video,
categoryId,
memberIds = [],
sourceName = null
) {
try {
// source_url로 중복 체크
const [existing] = await pool.query(
"SELECT id FROM schedules WHERE source_url = ?",
[video.videoUrl]
);
if (existing.length > 0) {
return null; // 이미 존재
}
// 일정 생성
const [result] = await pool.query(
`INSERT INTO schedules (title, date, time, category_id, source_url, source_name)
VALUES (?, ?, ?, ?, ?, ?)`,
[
video.title,
video.date,
video.time,
categoryId,
video.videoUrl,
sourceName,
]
);
const scheduleId = result.insertId;
// 멤버 연결
if (memberIds.length > 0) {
const uniqueMemberIds = [...new Set(memberIds)]; // 중복 제거
const memberValues = uniqueMemberIds.map((memberId) => [
scheduleId,
memberId,
]);
await pool.query(
`INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`,
[memberValues]
);
}
// Meilisearch에 동기화
try {
const [categoryInfo] = await pool.query(
"SELECT name, color FROM schedule_categories WHERE id = ?",
[categoryId]
);
const [memberInfo] = await pool.query(
"SELECT id, name FROM members WHERE id IN (?)",
[memberIds.length > 0 ? [...new Set(memberIds)] : [0]]
);
await addOrUpdateSchedule({
id: scheduleId,
title: video.title,
description: "",
date: video.date,
time: video.time,
category_id: categoryId,
category_name: categoryInfo[0]?.name || "",
category_color: categoryInfo[0]?.color || "",
source_name: sourceName,
source_url: video.videoUrl,
members: memberInfo,
});
} catch (searchError) {
console.error("Meilisearch 동기화 오류:", searchError.message);
}
return scheduleId;
} catch (error) {
console.error("일정 생성 오류:", error);
throw error;
}
}
/**
* 멤버 이름 목록 조회
*/
async function getMemberNameMap() {
const [members] = await pool.query("SELECT id, name FROM members");
const nameMap = {};
for (const m of members) {
nameMap[m.name] = m.id;
}
return nameMap;
}
/**
* description에서 멤버 이름 추출
*/
function extractMemberIdsFromDescription(description, memberNameMap) {
if (!description) return [];
const memberIds = [];
for (const [name, id] of Object.entries(memberNameMap)) {
if (description.includes(name)) {
memberIds.push(id);
}
}
return memberIds;
}
/**
* 봇의 영상 동기화 (YouTube API 기반)
*/
export async function syncNewVideos(botId) {
try {
// 봇 정보 조회 (bot_youtube_config 조인)
const [bots] = await pool.query(
`
SELECT b.*, c.channel_id
FROM bots b
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
WHERE b.id = ?
`,
[botId]
);
if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다.");
}
const bot = bots[0];
if (!bot.channel_id) {
throw new Error("Channel ID가 설정되지 않았습니다.");
}
// 봇별 커스텀 설정 조회
const customConfig = getBotCustomConfig(botId);
const categoryId = await getYoutubeCategory();
// YouTube API로 최근 10개 영상 조회
const videos = await fetchRecentVideosFromAPI(bot.channel_id, 10);
let addedCount = 0;
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
let memberNameMap = null;
if (customConfig.extractMembersFromDesc) {
memberNameMap = await getMemberNameMap();
}
for (const video of videos) {
// 제목 필터 적용 (설정된 경우)
if (
customConfig.titleFilter &&
!video.title.includes(customConfig.titleFilter)
) {
continue; // 필터에 맞지 않으면 스킵
}
// 멤버 ID 수집
const memberIds = [];
// 기본 멤버 추가
if (customConfig.defaultMemberId) {
memberIds.push(customConfig.defaultMemberId);
}
// description에서 멤버 추출 (설정된 경우)
if (customConfig.extractMembersFromDesc && memberNameMap) {
const extractedIds = extractMemberIdsFromDescription(
video.description,
memberNameMap
);
memberIds.push(...extractedIds);
}
const scheduleId = await createScheduleFromVideo(
video,
categoryId,
memberIds,
bot.name
);
if (scheduleId) {
addedCount++;
}
}
// 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수)
// addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지)
if (addedCount > 0) {
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
schedules_added = schedules_added + ?,
last_added_count = ?,
error_message = NULL
WHERE id = ?`,
[addedCount, addedCount, botId]
);
} else {
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
error_message = NULL
WHERE id = ?`,
[botId]
);
}
return { addedCount, total: videos.length };
} catch (error) {
// 오류 상태 업데이트
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
status = 'error',
error_message = ?
WHERE id = ?`,
[error.message, botId]
);
throw error;
}
}
/**
* 전체 영상 동기화 (API 기반, 초기화용)
*/
export async function syncAllVideos(botId) {
try {
// 봇 정보 조회 (bot_youtube_config 조인)
const [bots] = await pool.query(
`
SELECT b.*, c.channel_id
FROM bots b
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
WHERE b.id = ?
`,
[botId]
);
if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다.");
}
const bot = bots[0];
if (!bot.channel_id) {
throw new Error("Channel ID가 설정되지 않았습니다.");
}
// 봇별 커스텀 설정 조회
const customConfig = getBotCustomConfig(botId);
const categoryId = await getYoutubeCategory();
// API로 전체 영상 수집
const videos = await fetchAllVideosFromAPI(bot.channel_id);
let addedCount = 0;
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
let memberNameMap = null;
if (customConfig.extractMembersFromDesc) {
memberNameMap = await getMemberNameMap();
}
for (const video of videos) {
// 제목 필터 적용 (설정된 경우)
if (
customConfig.titleFilter &&
!video.title.includes(customConfig.titleFilter)
) {
continue; // 필터에 맞지 않으면 스킵
}
// 멤버 ID 수집
const memberIds = [];
// 기본 멤버 추가
if (customConfig.defaultMemberId) {
memberIds.push(customConfig.defaultMemberId);
}
// description에서 멤버 추출 (설정된 경우)
if (customConfig.extractMembersFromDesc && memberNameMap) {
const extractedIds = extractMemberIdsFromDescription(
video.description,
memberNameMap
);
memberIds.push(...extractedIds);
}
const scheduleId = await createScheduleFromVideo(
video,
categoryId,
memberIds,
bot.name
);
if (scheduleId) {
addedCount++;
}
}
// 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수)
// addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지)
if (addedCount > 0) {
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
schedules_added = schedules_added + ?,
last_added_count = ?,
error_message = NULL
WHERE id = ?`,
[addedCount, addedCount, botId]
);
} else {
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
error_message = NULL
WHERE id = ?`,
[botId]
);
}
return { addedCount, total: videos.length };
} catch (error) {
await pool.query(
`UPDATE bots SET
status = 'error',
error_message = ?
WHERE id = ?`,
[error.message, botId]
);
throw error;
}
}
export default {
parseRSSFeed,
fetchAllVideosFromAPI,
syncNewVideos,
syncAllVideos,
getYoutubeCategory,
};

View file

@ -1,201 +0,0 @@
import cron from "node-cron";
import pool from "../lib/db.js";
import { syncNewVideos } from "./youtube-bot.js";
import { syncNewTweets } from "./x-bot.js";
import { syncAllSchedules } from "./meilisearch-bot.js";
// 봇별 스케줄러 인스턴스 저장
const schedulers = new Map();
/**
* 타입에 따라 적절한 동기화 함수 호출
*/
async function syncBot(botId) {
const [bots] = await pool.query("SELECT type FROM bots WHERE id = ?", [
botId,
]);
if (bots.length === 0) throw new Error("봇을 찾을 수 없습니다.");
const botType = bots[0].type;
if (botType === "youtube") {
return await syncNewVideos(botId);
} else if (botType === "x") {
return await syncNewTweets(botId);
} else if (botType === "meilisearch") {
return await syncAllSchedules(botId);
} else {
throw new Error(`지원하지 않는 봇 타입: ${botType}`);
}
}
/**
* 봇이 메모리에서 실행 중인지 확인
*/
export function isBotRunning(botId) {
const id = parseInt(botId);
return schedulers.has(id);
}
/**
* 개별 스케줄 등록
*/
export function registerBot(botId, intervalMinutes = 2, cronExpression = null) {
const id = parseInt(botId);
// 기존 스케줄이 있으면 제거
unregisterBot(id);
// cron 표현식: 지정된 표현식 사용, 없으면 기본값 생성
const expression = cronExpression || `1-59/${intervalMinutes} * * * *`;
const task = cron.schedule(expression, async () => {
console.log(`[Bot ${id}] 동기화 시작...`);
try {
const result = await syncBot(id);
console.log(`[Bot ${id}] 동기화 완료: ${result.addedCount}개 추가`);
} catch (error) {
console.error(`[Bot ${id}] 동기화 오류:`, error.message);
}
});
schedulers.set(id, task);
console.log(`[Bot ${id}] 스케줄 등록됨 (cron: ${expression})`);
}
/**
* 개별 스케줄 해제
*/
export function unregisterBot(botId) {
const id = parseInt(botId);
if (schedulers.has(id)) {
schedulers.get(id).stop();
schedulers.delete(id);
console.log(`[Bot ${id}] 스케줄 해제됨`);
}
}
/**
* 10 간격으로 메모리 상태와 DB status 동기화
*/
async function syncBotStatuses() {
try {
const [bots] = await pool.query("SELECT id, status FROM bots");
for (const bot of bots) {
const botId = parseInt(bot.id);
const isRunningInMemory = schedulers.has(botId);
const isRunningInDB = bot.status === "running";
// 메모리에 없는데 DB가 running이면 → 서버 크래시 등으로 불일치
// 이 경우 DB를 stopped로 변경하는 대신, 메모리에 봇을 다시 등록
if (!isRunningInMemory && isRunningInDB) {
console.log(`[Scheduler] Bot ${botId} 메모리에 없음, 재등록 시도...`);
try {
const [botInfo] = await pool.query(
"SELECT check_interval, cron_expression FROM bots WHERE id = ?",
[botId]
);
if (botInfo.length > 0) {
const { check_interval, cron_expression } = botInfo[0];
// 직접 registerBot 함수 호출 (import 순환 방지를 위해 내부 로직 사용)
const expression =
cron_expression || `1-59/${check_interval} * * * *`;
const task = cron.schedule(expression, async () => {
console.log(`[Bot ${botId}] 동기화 시작...`);
try {
const result = await syncBot(botId);
console.log(
`[Bot ${botId}] 동기화 완료: ${result.addedCount}개 추가`
);
} catch (error) {
console.error(`[Bot ${botId}] 동기화 오류:`, error.message);
}
});
schedulers.set(botId, task);
console.log(
`[Scheduler] Bot ${botId} 재등록 완료 (cron: ${expression})`
);
}
} catch (error) {
console.error(`[Scheduler] Bot ${botId} 재등록 오류:`, error.message);
// 재등록 실패 시에만 stopped로 변경
await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [
botId,
]);
console.log(`[Scheduler] Bot ${botId} 상태 동기화: stopped`);
}
}
}
} catch (error) {
console.error("[Scheduler] 상태 동기화 오류:", error.message);
}
}
/**
* 서버 시작 실행 중인 봇들 스케줄 등록
*/
export async function initScheduler() {
try {
const [bots] = await pool.query(
"SELECT id, check_interval, cron_expression FROM bots WHERE status = 'running'"
);
for (const bot of bots) {
registerBot(bot.id, bot.check_interval, bot.cron_expression);
}
console.log(`[Scheduler] ${bots.length}개 봇 스케줄 등록됨`);
// 10초 간격으로 상태 동기화 (DB status와 메모리 상태 일치 유지)
setInterval(syncBotStatuses, 10000);
console.log(`[Scheduler] 10초 간격 상태 동기화 시작`);
} catch (error) {
console.error("[Scheduler] 초기화 오류:", error);
}
}
/**
* 시작
*/
export async function startBot(botId) {
const [bots] = await pool.query("SELECT * FROM bots WHERE id = ?", [botId]);
if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다.");
}
const bot = bots[0];
// 스케줄 등록 (cron_expression 우선 사용)
registerBot(botId, bot.check_interval, bot.cron_expression);
// 상태 업데이트
await pool.query(
"UPDATE bots SET status = 'running', error_message = NULL WHERE id = ?",
[botId]
);
// 즉시 1회 실행
try {
await syncBot(botId);
} catch (error) {
console.error(`[Bot ${botId}] 초기 동기화 오류:`, error.message);
}
}
/**
* 정지
*/
export async function stopBot(botId) {
unregisterBot(botId);
await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [botId]);
}
export default {
initScheduler,
registerBot,
unregisterBot,
startBot,
stopBot,
isBotRunning,
};

47
backend/src/app.js Normal file
View file

@ -0,0 +1,47 @@
import Fastify from 'fastify';
import config from './config/index.js';
// 플러그인
import dbPlugin from './plugins/db.js';
import redisPlugin from './plugins/redis.js';
import youtubeBotPlugin from './services/youtube/index.js';
import xBotPlugin from './services/x/index.js';
import schedulerPlugin from './plugins/scheduler.js';
export async function buildApp(opts = {}) {
const fastify = Fastify({
logger: {
level: opts.logLevel || 'info',
},
...opts,
});
// config 데코레이터 등록
fastify.decorate('config', config);
// 플러그인 등록 (순서 중요)
await fastify.register(dbPlugin);
await fastify.register(redisPlugin);
await fastify.register(youtubeBotPlugin);
await fastify.register(xBotPlugin);
await fastify.register(schedulerPlugin);
// 헬스 체크 엔드포인트
fastify.get('/api/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// 봇 상태 조회 엔드포인트
fastify.get('/api/bots', async () => {
const bots = fastify.scheduler.getBots();
const statuses = await Promise.all(
bots.map(async bot => {
const status = await fastify.scheduler.getStatus(bot.id);
return { ...bot, ...status };
})
);
return statuses;
});
return fastify;
}

View file

@ -0,0 +1,37 @@
export default [
{
id: 'youtube-fromis9',
type: 'youtube',
channelId: 'UCXbRURMKT3H_w8dT-DWLIxA',
channelName: 'fromis_9',
cron: '*/2 * * * *',
enabled: true,
},
{
id: 'youtube-studio',
type: 'youtube',
channelId: 'UCeUJ8B3krxw8zuDi19AlhaA',
channelName: '스프 : 스튜디오 프로미스나인',
cron: '*/2 * * * *',
enabled: true,
},
{
id: 'youtube-musinsa',
type: 'youtube',
channelId: 'UCtfyAiqf095_0_ux8ruwGfA',
channelName: 'MUSINSA TV',
cron: '*/2 * * * *',
enabled: true,
titleFilter: '성수기',
defaultMemberId: 7,
extractMembersFromDesc: true,
},
{
id: 'x-fromis9',
type: 'x',
username: 'realfromis_9',
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
cron: '*/1 * * * *',
enabled: true,
},
];

View file

@ -0,0 +1,22 @@
export default {
server: {
port: parseInt(process.env.PORT) || 80,
host: '0.0.0.0',
},
db: {
host: process.env.DB_HOST || 'mariadb',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'fromis9',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'fromis9',
connectionLimit: 10,
waitForConnections: true,
},
redis: {
host: process.env.REDIS_HOST || 'fromis9-redis',
port: parseInt(process.env.REDIS_PORT) || 6379,
},
youtube: {
apiKey: process.env.YOUTUBE_API_KEY,
},
};

25
backend/src/plugins/db.js Normal file
View file

@ -0,0 +1,25 @@
import fp from 'fastify-plugin';
import mysql from 'mysql2/promise';
async function dbPlugin(fastify, opts) {
const pool = mysql.createPool(fastify.config.db);
// 연결 테스트
try {
const conn = await pool.getConnection();
fastify.log.info('MariaDB 연결 성공');
conn.release();
} catch (err) {
fastify.log.error('MariaDB 연결 실패:', err.message);
throw err;
}
fastify.decorate('db', pool);
fastify.addHook('onClose', async () => {
await pool.end();
fastify.log.info('MariaDB 연결 종료');
});
}
export default fp(dbPlugin, { name: 'db' });

View file

@ -0,0 +1,27 @@
import fp from 'fastify-plugin';
import Redis from 'ioredis';
async function redisPlugin(fastify, opts) {
const redis = new Redis({
host: fastify.config.redis.host,
port: fastify.config.redis.port,
lazyConnect: true,
});
try {
await redis.connect();
fastify.log.info('Redis 연결 성공');
} catch (err) {
fastify.log.error('Redis 연결 실패:', err.message);
throw err;
}
fastify.decorate('redis', redis);
fastify.addHook('onClose', async () => {
await redis.quit();
fastify.log.info('Redis 연결 종료');
});
}
export default fp(redisPlugin, { name: 'redis' });

View file

@ -0,0 +1,170 @@
import fp from 'fastify-plugin';
import cron from 'node-cron';
import bots from '../config/bots.js';
const REDIS_PREFIX = 'bot:status:';
async function schedulerPlugin(fastify, opts) {
const tasks = new Map();
/**
* 상태 Redis에 저장
*/
async function updateStatus(botId, status) {
const current = await getStatus(botId);
const updated = { ...current, ...status, updatedAt: new Date().toISOString() };
await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated));
return updated;
}
/**
* 상태 Redis에서 조회
*/
async function getStatus(botId) {
const data = await fastify.redis.get(`${REDIS_PREFIX}${botId}`);
if (data) {
return JSON.parse(data);
}
return {
status: 'stopped',
lastCheckAt: null,
lastAddedCount: 0,
totalAdded: 0,
errorMessage: null,
};
}
/**
* 동기화 함수 가져오기
*/
function getSyncFunction(bot) {
if (bot.type === 'youtube') {
return fastify.youtubeBot.syncNewVideos;
} else if (bot.type === 'x') {
return fastify.xBot.syncNewTweets;
}
return null;
}
/**
* 시작
*/
async function startBot(botId) {
const bot = bots.find(b => b.id === botId);
if (!bot) {
throw new Error(`봇을 찾을 수 없습니다: ${botId}`);
}
// 기존 태스크가 있으면 정지
if (tasks.has(botId)) {
tasks.get(botId).stop();
tasks.delete(botId);
}
const syncFn = getSyncFunction(bot);
if (!syncFn) {
throw new Error(`지원하지 않는 봇 타입: ${bot.type}`);
}
// cron 태스크 등록
const task = cron.schedule(bot.cron, async () => {
fastify.log.info(`[${botId}] 동기화 시작`);
try {
const result = await syncFn(bot);
const status = await getStatus(botId);
await updateStatus(botId, {
status: 'running',
lastCheckAt: new Date().toISOString(),
lastAddedCount: result.addedCount,
totalAdded: (status.totalAdded || 0) + result.addedCount,
errorMessage: null,
});
fastify.log.info(`[${botId}] 동기화 완료: ${result.addedCount}개 추가`);
} catch (err) {
await updateStatus(botId, {
status: 'error',
lastCheckAt: new Date().toISOString(),
errorMessage: err.message,
});
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
}
});
tasks.set(botId, task);
await updateStatus(botId, { status: 'running' });
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
// 즉시 1회 실행
try {
const result = await syncFn(bot);
const status = await getStatus(botId);
await updateStatus(botId, {
lastCheckAt: new Date().toISOString(),
lastAddedCount: result.addedCount,
totalAdded: (status.totalAdded || 0) + result.addedCount,
});
fastify.log.info(`[${botId}] 초기 동기화 완료: ${result.addedCount}개 추가`);
} catch (err) {
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
}
}
/**
* 정지
*/
async function stopBot(botId) {
if (tasks.has(botId)) {
tasks.get(botId).stop();
tasks.delete(botId);
}
await updateStatus(botId, { status: 'stopped' });
fastify.log.info(`[${botId}] 스케줄 정지`);
}
/**
* 모든 활성 시작
*/
async function startAll() {
for (const bot of bots) {
if (bot.enabled) {
try {
await startBot(bot.id);
} catch (err) {
fastify.log.error(`[${bot.id}] 시작 실패: ${err.message}`);
}
}
}
}
/**
* 모든 정지
*/
async function stopAll() {
for (const [botId, task] of tasks) {
task.stop();
await updateStatus(botId, { status: 'stopped' });
}
tasks.clear();
}
// 데코레이터 등록
fastify.decorate('scheduler', {
startBot,
stopBot,
startAll,
stopAll,
getStatus,
getBots: () => bots,
});
// 앱 종료 시 모든 봇 정지
fastify.addHook('onClose', async () => {
await stopAll();
fastify.log.info('모든 봇 스케줄 정지');
});
}
export default fp(schedulerPlugin, {
name: 'scheduler',
dependencies: ['db', 'redis', 'youtubeBot', 'xBot'],
});

24
backend/src/server.js Normal file
View file

@ -0,0 +1,24 @@
import { buildApp } from './app.js';
import config from './config/index.js';
async function start() {
const app = await buildApp();
try {
// 서버 시작
await app.listen({
port: config.server.port,
host: config.server.host,
});
// 모든 봇 스케줄 시작
await app.scheduler.startAll();
app.log.info(`서버 시작: http://${config.server.host}:${config.server.port}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
start();

View file

@ -0,0 +1,198 @@
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);
}
/**
* X 프로필 캐시 저장
*/
async function cacheProfile(username, profile) {
if (!profile.displayName && !profile.avatarUrl) return;
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);
// 프로필 캐시 업데이트
await cacheProfile(bot.username, profile);
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 };
}
/**
* X 프로필 조회
*/
async function getProfile(username) {
const data = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
return data ? JSON.parse(data) : null;
}
fastify.decorate('xBot', {
syncNewTweets,
syncAllTweets,
getProfile,
});
}
export default fp(xBotPlugin, {
name: 'xBot',
dependencies: ['db', 'redis'],
});

View file

@ -0,0 +1,195 @@
import { parseNitterDateTime } from '../../utils/date.js';
/**
* 트윗 텍스트에서 문단 추출 (title용)
*/
export function extractTitle(text) {
if (!text) return '';
const paragraphs = text.split(/\n\n+/);
return paragraphs[0]?.trim() || '';
}
/**
* HTML에서 이미지 URL 추출
*/
export function extractImageUrls(html) {
const urls = [];
const regex = /href="\/pic\/(orig\/)?media%2F([^"]+)"/g;
let match;
while ((match = regex.exec(html)) !== null) {
const mediaPath = decodeURIComponent(match[2]);
const cleanPath = mediaPath.split('%3F')[0].split('?')[0];
urls.push(`https://pbs.twimg.com/media/${cleanPath}`);
}
return [...new Set(urls)];
}
/**
* 텍스트에서 유튜브 videoId 추출
*/
export function extractYoutubeVideoIds(text) {
if (!text) return [];
const ids = new Set();
// youtu.be/{id}
const shortRegex = /youtu\.be\/([a-zA-Z0-9_-]{11})/g;
let m;
while ((m = shortRegex.exec(text)) !== null) {
ids.add(m[1]);
}
// youtube.com/watch?v={id}
const watchRegex = /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/g;
while ((m = watchRegex.exec(text)) !== null) {
ids.add(m[1]);
}
// youtube.com/shorts/{id}
const shortsRegex = /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/g;
while ((m = shortsRegex.exec(text)) !== null) {
ids.add(m[1]);
}
return [...ids];
}
/**
* HTML에서 프로필 정보 추출
*/
export function extractProfile(html) {
const profile = { displayName: null, avatarUrl: null };
const nameMatch = html.match(/class="profile-card-fullname"[^>]*title="([^"]+)"/);
if (nameMatch) {
profile.displayName = nameMatch[1].trim();
}
const avatarMatch = html.match(/class="profile-card-avatar"[^>]*>[\s\S]*?<img[^>]*src="([^"]+)"/);
if (avatarMatch) {
let url = avatarMatch[1];
const encodedMatch = url.match(/\/pic\/(.+)/);
if (encodedMatch) {
url = decodeURIComponent(encodedMatch[1]);
}
profile.avatarUrl = url;
}
return profile;
}
/**
* HTML에서 트윗 목록 파싱
*/
export function parseTweets(html, username) {
const tweets = [];
const containers = html.split('class="timeline-item ');
for (let i = 1; i < containers.length; i++) {
const container = containers[i];
// 고정/리트윗 제외
const isPinned = container.includes('class="pinned"');
const isRetweet = container.includes('class="retweet-header"');
if (isPinned || isRetweet) continue;
// 트윗 ID
const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
if (!idMatch) continue;
const id = idMatch[1];
// 시간
const timeMatch = container.match(/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/);
const time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
if (!time) continue;
// 텍스트
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
let text = '';
if (contentMatch) {
text = contentMatch[1]
.replace(/<br\s*\/?>/g, '\n')
.replace(/<a[^>]*>([^<]*)<\/a>/g, '$1')
.replace(/<[^>]+>/g, '')
.trim();
}
// 이미지
const imageUrls = extractImageUrls(container);
tweets.push({
id,
time,
text,
imageUrls,
url: `https://x.com/${username}/status/${id}`,
});
}
return tweets;
}
/**
* Nitter에서 트윗 수집 ( 페이지만)
*/
export async function fetchTweets(nitterUrl, username) {
const url = `${nitterUrl}/${username}`;
const res = await fetch(url);
const html = await res.text();
// 프로필 정보
const profile = extractProfile(html);
// 트윗 파싱
const tweets = parseTweets(html, username);
return { tweets, profile };
}
/**
* Nitter에서 전체 트윗 수집 (페이지네이션)
*/
export async function fetchAllTweets(nitterUrl, username, log) {
const allTweets = [];
let cursor = null;
let pageNum = 1;
let emptyCount = 0;
while (true) {
const url = cursor
? `${nitterUrl}/${username}?cursor=${cursor}`
: `${nitterUrl}/${username}`;
log?.info(`[페이지 ${pageNum}] 스크래핑 중...`);
try {
const res = await fetch(url);
const html = await res.text();
const tweets = parseTweets(html, username);
if (tweets.length === 0) {
emptyCount++;
if (emptyCount >= 3) break;
} else {
emptyCount = 0;
allTweets.push(...tweets);
log?.info(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`);
}
// 다음 페이지 cursor
const cursorMatch = html.match(/class="show-more"[^>]*>\s*<a href="\?cursor=([^"]+)"/);
if (!cursorMatch) break;
cursor = cursorMatch[1];
pageNum++;
await new Promise(r => setTimeout(r, 1000));
} catch (err) {
log?.error(` -> 오류: ${err.message}`);
emptyCount++;
if (emptyCount >= 5) break;
await new Promise(r => setTimeout(r, 3000));
}
}
return allTweets;
}

View file

@ -0,0 +1,181 @@
import config from '../../config/index.js';
import { formatDate, formatTime } from '../../utils/date.js';
const API_KEY = config.youtube.apiKey;
const API_BASE = 'https://www.googleapis.com/youtube/v3';
/**
* ISO 8601 duration (PT1M30S) 변환
*/
function parseDuration(duration) {
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return 0;
return (
parseInt(match[1] || 0) * 3600 +
parseInt(match[2] || 0) * 60 +
parseInt(match[3] || 0)
);
}
/**
* 영상 URL 생성
*/
function getVideoUrl(videoId, isShorts) {
return isShorts
? `https://www.youtube.com/shorts/${videoId}`
: `https://www.youtube.com/watch?v=${videoId}`;
}
/**
* 채널의 업로드 플레이리스트 ID 조회
*/
async function getUploadsPlaylistId(channelId) {
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
throw new Error(data.error.message);
}
if (!data.items?.length) {
throw new Error('채널을 찾을 수 없습니다');
}
return data.items[0].contentDetails.relatedPlaylists.uploads;
}
/**
* 영상 ID 목록으로 duration 조회 (Shorts 판별용)
*/
async function getVideoDurations(videoIds) {
const url = `${API_BASE}/videos?part=contentDetails&id=${videoIds.join(',')}&key=${API_KEY}`;
const res = await fetch(url);
const data = await res.json();
const durations = {};
if (data.items) {
for (const v of data.items) {
const seconds = parseDuration(v.contentDetails.duration);
durations[v.id] = seconds <= 60;
}
}
return durations;
}
/**
* 최근 N개 영상 조회
*/
export async function fetchRecentVideos(channelId, maxResults = 10) {
const uploadsId = await getUploadsPlaylistId(channelId);
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
throw new Error(data.error.message);
}
const videoIds = data.items.map(item => item.snippet.resourceId.videoId);
const shortsMap = await getVideoDurations(videoIds);
return data.items.map(item => {
const { snippet } = item;
const videoId = snippet.resourceId.videoId;
const isShorts = shortsMap[videoId] || false;
const publishedAt = new Date(snippet.publishedAt);
return {
videoId,
title: snippet.title,
description: snippet.description || '',
channelId: snippet.channelId,
channelTitle: snippet.channelTitle,
publishedAt,
date: formatDate(publishedAt),
time: formatTime(publishedAt),
videoType: isShorts ? 'shorts' : 'video',
videoUrl: getVideoUrl(videoId, isShorts),
};
});
}
/**
* 전체 영상 조회 (페이지네이션)
*/
export async function fetchAllVideos(channelId) {
const uploadsId = await getUploadsPlaylistId(channelId);
const videos = [];
let pageToken = '';
do {
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=50&key=${API_KEY}${pageToken ? `&pageToken=${pageToken}` : ''}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
throw new Error(data.error.message);
}
const videoIds = data.items.map(item => item.snippet.resourceId.videoId);
const shortsMap = await getVideoDurations(videoIds);
for (const item of data.items) {
const { snippet } = item;
const videoId = snippet.resourceId.videoId;
const isShorts = shortsMap[videoId] || false;
const publishedAt = new Date(snippet.publishedAt);
videos.push({
videoId,
title: snippet.title,
description: snippet.description || '',
channelId: snippet.channelId,
channelTitle: snippet.channelTitle,
publishedAt,
date: formatDate(publishedAt),
time: formatTime(publishedAt),
videoType: isShorts ? 'shorts' : 'video',
videoUrl: getVideoUrl(videoId, isShorts),
});
}
pageToken = data.nextPageToken || '';
} while (pageToken);
// 과거순 정렬
videos.sort((a, b) => a.publishedAt - b.publishedAt);
return videos;
}
/**
* 단일 영상 정보 조회
*/
export async function fetchVideoInfo(videoId) {
const url = `${API_BASE}/videos?part=snippet,contentDetails&id=${videoId}&key=${API_KEY}`;
const res = await fetch(url);
const data = await res.json();
if (!data.items?.length) {
return null;
}
const video = data.items[0];
const { snippet, contentDetails } = video;
const seconds = parseDuration(contentDetails.duration);
const isShorts = seconds > 0 && seconds <= 60;
const publishedAt = new Date(snippet.publishedAt);
return {
videoId,
title: snippet.title,
description: snippet.description || '',
channelId: snippet.channelId,
channelTitle: snippet.channelTitle,
publishedAt,
date: formatDate(publishedAt),
time: formatTime(publishedAt),
videoType: isShorts ? 'shorts' : 'video',
videoUrl: getVideoUrl(videoId, isShorts),
};
}

View file

@ -0,0 +1,141 @@
import fp from 'fastify-plugin';
import { fetchRecentVideos, fetchAllVideos } from './api.js';
import bots from '../../config/bots.js';
const YOUTUBE_CATEGORY_ID = 2;
async function youtubeBotPlugin(fastify, opts) {
/**
* 멤버 이름 조회
*/
async function getMemberNameMap() {
const [rows] = await fastify.db.query('SELECT id, name FROM members');
const map = {};
for (const r of rows) {
map[r.name] = r.id;
}
return map;
}
/**
* description에서 멤버 추출
*/
function extractMemberIds(description, memberNameMap) {
if (!description) return [];
const ids = [];
for (const [name, id] of Object.entries(memberNameMap)) {
if (description.includes(name)) {
ids.push(id);
}
}
return ids;
}
/**
* 영상을 DB에 저장
*/
async function saveVideo(video, bot) {
// 중복 체크 (video_id로)
const [existing] = await fastify.db.query(
'SELECT id FROM schedule_youtube WHERE video_id = ?',
[video.videoId]
);
if (existing.length > 0) {
return null;
}
// 커스텀 설정 적용
if (bot.titleFilter && !video.title.includes(bot.titleFilter)) {
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, bot.channelName]
);
// 멤버 연결 (커스텀 설정)
if (bot.defaultMemberId || bot.extractMembersFromDesc) {
const memberIds = [];
if (bot.defaultMemberId) {
memberIds.push(bot.defaultMemberId);
}
if (bot.extractMembersFromDesc) {
const nameMap = await getMemberNameMap();
memberIds.push(...extractMemberIds(video.description, nameMap));
}
if (memberIds.length > 0) {
const uniqueIds = [...new Set(memberIds)];
const values = uniqueIds.map(id => [scheduleId, id]);
await fastify.db.query(
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
[values]
);
}
}
return scheduleId;
}
/**
* 최근 영상 동기화 (정기 실행)
*/
async function syncNewVideos(bot) {
const videos = await fetchRecentVideos(bot.channelId, 10);
let addedCount = 0;
for (const video of videos) {
const scheduleId = await saveVideo(video, bot);
if (scheduleId) {
addedCount++;
}
}
return { addedCount, total: videos.length };
}
/**
* 전체 영상 동기화 (초기화)
*/
async function syncAllVideos(bot) {
const videos = await fetchAllVideos(bot.channelId);
let addedCount = 0;
for (const video of videos) {
const scheduleId = await saveVideo(video, bot);
if (scheduleId) {
addedCount++;
}
}
return { addedCount, total: videos.length };
}
/**
* 관리 중인 채널 ID 목록
*/
function getManagedChannelIds() {
return bots
.filter(b => b.type === 'youtube')
.map(b => b.channelId);
}
fastify.decorate('youtubeBot', {
syncNewVideos,
syncAllVideos,
getManagedChannelIds,
});
}
export default fp(youtubeBotPlugin, {
name: 'youtubeBot',
dependencies: ['db'],
});

40
backend/src/utils/date.js Normal file
View file

@ -0,0 +1,40 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import timezone from 'dayjs/plugin/timezone.js';
dayjs.extend(utc);
dayjs.extend(timezone);
const KST = 'Asia/Seoul';
/**
* UTC Date를 KST dayjs 객체로 변환
*/
export function toKST(date) {
return dayjs(date).tz(KST);
}
/**
* 날짜를 YYYY-MM-DD 형식으로 포맷 (KST)
*/
export function formatDate(date) {
return dayjs(date).tz(KST).format('YYYY-MM-DD');
}
/**
* 시간을 HH:mm:ss 형식으로 포맷 (KST)
*/
export function formatTime(date) {
return dayjs(date).tz(KST).format('HH:mm:ss');
}
/**
* Nitter 날짜 문자열 파싱
* : "Jan 15, 2026 · 10:30 PM UTC"
*/
export function parseNitterDateTime(timeStr) {
if (!timeStr) return null;
const cleaned = timeStr.replace(' · ', ' ').replace(' UTC', '');
const date = new Date(cleaned + ' UTC');
return isNaN(date.getTime()) ? null : date;
}

View file

@ -14,12 +14,12 @@ services:
- db - db
restart: unless-stopped restart: unless-stopped
# 백엔드 - Express API 서버 # 백엔드 - Fastify API 서버
fromis9-backend: fromis9-backend:
image: node:20-alpine image: node:20-alpine
container_name: fromis9-backend container_name: fromis9-backend
working_dir: /app working_dir: /app
command: sh -c "apk add --no-cache ffmpeg && npm install && node server.js" command: sh -c "apk add --no-cache ffmpeg && npm install && npm run dev"
env_file: env_file:
- .env - .env
environment: environment:

View file

@ -22,6 +22,13 @@ services:
- app - app
restart: unless-stopped restart: unless-stopped
redis:
image: redis:7-alpine
container_name: fromis9-redis
networks:
- app
restart: unless-stopped
networks: networks:
app: app:
external: true external: true

View file

@ -1,2 +1 @@
VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da
VITE_KAKAO_REST_KEY=e7a5516bf6cb1b398857789ee2ea6eea