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:
parent
51030e3aba
commit
19ba8bcddf
34 changed files with 2015 additions and 8860 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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
4734
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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
47
backend/src/app.js
Normal 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;
|
||||||
|
}
|
||||||
37
backend/src/config/bots.js
Normal file
37
backend/src/config/bots.js
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
22
backend/src/config/index.js
Normal file
22
backend/src/config/index.js
Normal 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
25
backend/src/plugins/db.js
Normal 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' });
|
||||||
27
backend/src/plugins/redis.js
Normal file
27
backend/src/plugins/redis.js
Normal 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' });
|
||||||
170
backend/src/plugins/scheduler.js
Normal file
170
backend/src/plugins/scheduler.js
Normal 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
24
backend/src/server.js
Normal 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();
|
||||||
198
backend/src/services/x/index.js
Normal file
198
backend/src/services/x/index.js
Normal 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'],
|
||||||
|
});
|
||||||
195
backend/src/services/x/scraper.js
Normal file
195
backend/src/services/x/scraper.js
Normal 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;
|
||||||
|
}
|
||||||
181
backend/src/services/youtube/api.js
Normal file
181
backend/src/services/youtube/api.js
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
141
backend/src/services/youtube/index.js
Normal file
141
backend/src/services/youtube/index.js
Normal 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
40
backend/src/utils/date.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da
|
VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da
|
||||||
VITE_KAKAO_REST_KEY=e7a5516bf6cb1b398857789ee2ea6eea
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue