feat: YouTube API 할당량 절감을 위한 playlist ID 캐싱
- uploads playlist ID를 Redis에 영구 캐싱 (불변값) - 일일 API 사용량 6,480 → 4,320 units (33% 절감) - 문서 업데이트 (컨테이너 분리 구조, X source.name 빈 문자열) - CLAUDE.md에 문서 업데이트 필수 안내 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
149e85ebd9
commit
c7b0a51924
5 changed files with 61 additions and 25 deletions
|
|
@ -29,3 +29,7 @@ DB 및 외부 서비스 접근 정보는 `.env` 파일 참조:
|
||||||
- [docs/architecture.md](docs/architecture.md) - 프로젝트 구조
|
- [docs/architecture.md](docs/architecture.md) - 프로젝트 구조
|
||||||
- [docs/api.md](docs/api.md) - API 명세
|
- [docs/api.md](docs/api.md) - API 명세
|
||||||
- [docs/development.md](docs/development.md) - 개발/배포 가이드
|
- [docs/development.md](docs/development.md) - 개발/배포 가이드
|
||||||
|
|
||||||
|
## 작업 시 주의사항
|
||||||
|
|
||||||
|
- **문서 업데이트 필수**: 작업이 완료되면 항상 `docs/` 폴더의 관련 문서를 업데이트할 것
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ function getVideoUrl(videoId, isShorts) {
|
||||||
/**
|
/**
|
||||||
* 채널의 업로드 플레이리스트 ID 조회
|
* 채널의 업로드 플레이리스트 ID 조회
|
||||||
*/
|
*/
|
||||||
async function getUploadsPlaylistId(channelId) {
|
export async function getUploadsPlaylistId(channelId) {
|
||||||
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
|
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -64,9 +64,12 @@ async function getVideoDurations(videoIds) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 N개 영상 조회
|
* 최근 N개 영상 조회
|
||||||
|
* @param {string} channelId - 채널 ID
|
||||||
|
* @param {number} maxResults - 최대 결과 수
|
||||||
|
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
|
||||||
*/
|
*/
|
||||||
export async function fetchRecentVideos(channelId, maxResults = 10) {
|
export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlaylistId = null) {
|
||||||
const uploadsId = await getUploadsPlaylistId(channelId);
|
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
|
||||||
|
|
||||||
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
|
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|
@ -102,9 +105,11 @@ export async function fetchRecentVideos(channelId, maxResults = 10) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전체 영상 조회 (페이지네이션)
|
* 전체 영상 조회 (페이지네이션)
|
||||||
|
* @param {string} channelId - 채널 ID
|
||||||
|
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
|
||||||
*/
|
*/
|
||||||
export async function fetchAllVideos(channelId) {
|
export async function fetchAllVideos(channelId, uploadsPlaylistId = null) {
|
||||||
const uploadsId = await getUploadsPlaylistId(channelId);
|
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
|
||||||
const videos = [];
|
const videos = [];
|
||||||
let pageToken = '';
|
let pageToken = '';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,30 @@
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import { fetchRecentVideos, fetchAllVideos } from './api.js';
|
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
|
||||||
import bots from '../../config/bots.js';
|
import bots from '../../config/bots.js';
|
||||||
|
|
||||||
const YOUTUBE_CATEGORY_ID = 2;
|
const YOUTUBE_CATEGORY_ID = 2;
|
||||||
|
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
||||||
|
|
||||||
async function youtubeBotPlugin(fastify, opts) {
|
async function youtubeBotPlugin(fastify, opts) {
|
||||||
|
/**
|
||||||
|
* uploads playlist ID 조회 (Redis 캐싱)
|
||||||
|
*/
|
||||||
|
async function getCachedUploadsPlaylistId(channelId) {
|
||||||
|
const cacheKey = `${PLAYLIST_CACHE_PREFIX}${channelId}`;
|
||||||
|
|
||||||
|
// Redis 캐시 확인
|
||||||
|
const cached = await fastify.redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 호출 후 캐싱 (영구 저장 - 값이 변하지 않음)
|
||||||
|
const playlistId = await getUploadsPlaylistId(channelId);
|
||||||
|
await fastify.redis.set(cacheKey, playlistId);
|
||||||
|
|
||||||
|
return playlistId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 멤버 이름 맵 조회
|
* 멤버 이름 맵 조회
|
||||||
*/
|
*/
|
||||||
|
|
@ -89,7 +109,8 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
* 최근 영상 동기화 (정기 실행)
|
* 최근 영상 동기화 (정기 실행)
|
||||||
*/
|
*/
|
||||||
async function syncNewVideos(bot) {
|
async function syncNewVideos(bot) {
|
||||||
const videos = await fetchRecentVideos(bot.channelId, 10);
|
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
|
||||||
|
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
for (const video of videos) {
|
for (const video of videos) {
|
||||||
|
|
@ -106,7 +127,8 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
* 전체 영상 동기화 (초기화)
|
* 전체 영상 동기화 (초기화)
|
||||||
*/
|
*/
|
||||||
async function syncAllVideos(bot) {
|
async function syncAllVideos(bot) {
|
||||||
const videos = await fetchAllVideos(bot.channelId);
|
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
|
||||||
|
const videos = await fetchAllVideos(bot.channelId, uploadsPlaylistId);
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
for (const video of videos) {
|
for (const video of videos) {
|
||||||
|
|
@ -137,5 +159,5 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
|
|
||||||
export default fp(youtubeBotPlugin, {
|
export default fp(youtubeBotPlugin, {
|
||||||
name: 'youtubeBot',
|
name: 'youtubeBot',
|
||||||
dependencies: ['db'],
|
dependencies: ['db', 'redis'],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ Base URL: `/api`
|
||||||
|
|
||||||
**source 객체 (카테고리별):**
|
**source 객체 (카테고리별):**
|
||||||
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
|
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
|
||||||
- X (category_id=3): `{ name: "X", url: "https://x.com/realfromis_9/status/..." }`
|
- X (category_id=3): `{ name: "", url: "https://x.com/realfromis_9/status/..." }` (name 빈 문자열)
|
||||||
- 기타 카테고리: source 없음
|
- 기타 카테고리: source 없음
|
||||||
|
|
||||||
**검색 응답:**
|
**검색 응답:**
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ fromis_9/
|
||||||
│ │ │ └── suggestions/ # 추천 검색어
|
│ │ │ └── suggestions/ # 추천 검색어
|
||||||
│ │ ├── app.js # Fastify 앱 설정
|
│ │ ├── app.js # Fastify 앱 설정
|
||||||
│ │ └── server.js # 진입점
|
│ │ └── server.js # 진입점
|
||||||
|
│ ├── Dockerfile # 백엔드 컨테이너
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
│
|
│
|
||||||
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
|
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
|
||||||
|
|
@ -47,9 +48,9 @@ fromis_9/
|
||||||
│ │ ├── stores/ # Zustand 스토어
|
│ │ ├── stores/ # Zustand 스토어
|
||||||
│ │ └── App.jsx
|
│ │ └── App.jsx
|
||||||
│ ├── vite.config.js
|
│ ├── vite.config.js
|
||||||
|
│ ├── Dockerfile # 프론트엔드 컨테이너
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
│
|
│
|
||||||
├── Dockerfile # 개발/배포 통합 (주석 전환)
|
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── .env
|
└── .env
|
||||||
```
|
```
|
||||||
|
|
@ -64,20 +65,24 @@ fromis_9/
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ fromis9-frontend (Docker) │
|
│ fromis9-frontend (:80) │
|
||||||
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
|
│ Vite 개발서버 │
|
||||||
│ │ Vite (:80) │───▶│ Fastify (:3000) │ │
|
│ (프록시: /api → backend) │
|
||||||
│ │ 프론트엔드 │ │ 백엔드 API │ │
|
└─────────────────────┬───────────────────────────────────┘
|
||||||
│ └─────────────────┘ └──────────┬──────────────────┘ │
|
|
||||||
└─────────────────────────────────────┼───────────────────┘
|
|
||||||
│
|
│
|
||||||
┌────────────────────────────┼────────────────────────────┐
|
▼
|
||||||
│ │ │
|
┌─────────────────────────────────────────────────────────┐
|
||||||
▼ ▼ ▼
|
│ fromis9-backend (:80) │
|
||||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
│ Fastify API │
|
||||||
│ MariaDB │ │ Meilisearch │ │ Redis │
|
└─────────────────────┬───────────────────────────────────┘
|
||||||
│ (외부 DB망) │ │ (검색 엔진) │ │ (캐시) │
|
│
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
┌────────────┼────────────┬────────────┐
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌───────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||||
|
│ MariaDB │ │Meilisearch│ │ Redis │ │ Nitter │
|
||||||
|
│ (외부 DB망) │ │ (검색엔진) │ │ (캐시) │ │ (X 스크랩) │
|
||||||
|
└───────────────┘ └───────────┘ └───────────┘ └───────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue