UNPKG

youtube-data-mcp

Version:

Extract YouTube transcripts and comments for analysis

560 lines (559 loc) 19.7 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import * as dotenv from "dotenv"; import { z } from "zod"; // 환경 변수 로드 dotenv.config(); // 유틸리티 함수 가져오기 import { getVideoId, fetchBasicVideoInfo } from "./utils/helpers.js"; // YouTube transcript 관련 함수 import { YoutubeTranscript } from "youtube-transcript"; // SerpAPI 관련 설정 const SERPAPI_KEY = process.env.SERPAPI_KEY; const SERPAPI_BASE_URL = "https://serpapi.com/search.json"; // YouTube 기본 비디오 정보 가져오기 함수 async function getVideoInfoData({ url, }) { try { // YouTube 동영상 ID 추출 const videoId = getVideoId(url); if (!videoId) { throw new Error("Invalid YouTube URL or video ID"); } if (!SERPAPI_KEY) { throw new Error("SERPAPI_KEY is not set in environment variables"); } // SerpAPI 요청 준비 const requestUrl = new URL(SERPAPI_BASE_URL); const params = { api_key: SERPAPI_KEY, engine: "youtube_video", v: videoId, }; // 디버깅 정보 - 요청 URL과 파라미터 console.error("Debug - Video Info Request:", { url: requestUrl.toString(), params, }); // URL 파라미터 설정 Object.entries(params).forEach(([key, value]) => { requestUrl.searchParams.append(key, value); }); // 요청 보내기 const headers = { "Content-Type": "application/json", }; const response = await fetch(requestUrl.toString(), { headers }); // 디버깅 정보 - 응답 상태 console.error("Debug - Response status:", response.status); if (!response.ok) { // 에러 응답의 본문 가져오기 const errorText = await response.text(); console.error("Debug - Error response:", errorText); throw new Error(`SerpAPI error: ${response.status} - ${errorText}`); } const data = await response.json(); // 디버깅 정보 - 응답 데이터의 키 목록 console.error("Debug - Response keys:", Object.keys(data)); // 비디오 정보 추출 return { videoId: videoId, title: data.title, viewCount: data.views, publishDate: data.published_date, channelName: data.channel?.name, commentCount: data.extracted_comment_count, commentsNextPageToken: data.comments_next_page_token, commentsSortingTokens: data.comments_sorting_token, }; } catch (error) { console.error("Error fetching video info:", error); throw error; } } // YouTube 트랜스크립트 가져오기 함수 async function getTranscriptData({ url, lang = "en", includeTranscript = false, }) { try { // YouTube 동영상 ID 추출 const videoId = getVideoId(url); if (!videoId) { throw new Error("Invalid YouTube URL or video ID"); } // 비디오 기본 정보 가져오기 const videoInfo = await fetchBasicVideoInfo(videoId); // 트랜스크립트 가져오기 const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang, }); // 전체 텍스트 구성 const fullText = transcript.map((segment) => segment.text).join(" "); // 결과 객체 구성 const result = { videoInfo: { id: videoId, title: videoInfo.title, channelName: videoInfo.channelName, publishedAt: videoInfo.publishedAt, viewCount: videoInfo.viewCount, }, fullText, language: lang, }; if (includeTranscript) { result.transcript = transcript; } return result; } catch (error) { console.error("Error fetching transcript:", error); throw error; } } // YouTube 댓글 가져오기 함수 async function getCommentsData({ url, limit = 100, sort = "relevance", pageToken, }) { try { if (!SERPAPI_KEY) { throw new Error("SERPAPI_KEY is not set in environment variables"); } let requestUrl; let params = {}; let extractedVideoId = ""; // 디버깅 정보 console.error("Debug - CommentsParams:", { url, limit, sort, pageToken }); if (pageToken) { // 페이지 토큰이 제공된 경우 - 다음 페이지 요청 requestUrl = new URL(SERPAPI_BASE_URL); params = { api_key: SERPAPI_KEY, engine: "youtube_video", next_page_token: pageToken, }; } else { // URL로 먼저 비디오 정보 요청 const possibleVideoId = getVideoId(url); if (!possibleVideoId) { throw new Error("Invalid YouTube URL or video ID"); } extractedVideoId = possibleVideoId; // 비디오 정보를 가져와서 기본 댓글 토큰 획득 const videoInfo = await getVideoInfoData({ url }); // 정렬 방식에 따라 적절한 토큰 선택 let tokenToUse = videoInfo.commentsNextPageToken; if (sort === "time" && videoInfo.commentsSortingTokens) { // 'Newest first' 토큰 찾기 const newestFirstToken = videoInfo.commentsSortingTokens.find((token) => token.title.toLowerCase().includes("newest")); if (newestFirstToken) { tokenToUse = newestFirstToken.token; } } if (!tokenToUse) { throw new Error("Could not find valid comments token"); } requestUrl = new URL(SERPAPI_BASE_URL); params = { api_key: SERPAPI_KEY, engine: "youtube_video", next_page_token: tokenToUse, }; // 추가 정보 저장 extractedVideoId = videoInfo.videoId; } // 디버깅 정보 - 요청 URL과 파라미터 console.error("Debug - Request:", { url: requestUrl.toString(), params, }); // URL 파라미터 설정 Object.entries(params).forEach(([key, value]) => { requestUrl.searchParams.append(key, value); }); // API 요청 준비 const headers = { "Content-Type": "application/json", }; // 요청 보내기 const response = await fetch(requestUrl.toString(), { headers }); // 디버깅 정보 - 응답 상태 console.error("Debug - Response status:", response.status); if (!response.ok) { // 에러 응답의 본문 가져오기 (상세 오류 메시지 확인용) const errorText = await response.text(); console.error("Debug - Error response:", errorText); throw new Error(`SerpAPI error: ${response.status} - ${errorText}`); } const data = await response.json(); // 디버깅 정보 - 응답 데이터의 키 목록 console.error("Debug - Response keys:", Object.keys(data)); // 비디오 정보 추출 const videoId = data.video_id || extractedVideoId; const videoTitle = data.title; // 다음 페이지 토큰 const nextPageToken = data.comments_next_page_token || undefined; // 댓글 목록이 없는 경우 if (!data.comments || !Array.isArray(data.comments)) { return { videoId, videoTitle, comments: [], commentCount: 0, nextPageToken, }; } // 댓글 파싱 const comments = data.comments.slice(0, limit).map((comment) => ({ commentId: comment.comment_id || "", author: comment.channel?.name || "Anonymous", text: comment.content || "", time: comment.published_date || "", likes: comment.extracted_vote_count || 0, replies: comment.replies_count || 0, repliesToken: comment.replies_next_page_token || null, })); return { videoId, videoTitle, comments, commentCount: comments.length, nextPageToken, }; } catch (error) { console.error("Error fetching comments:", error); throw error; } } // YouTube 검색 함수 구현 async function searchYouTube({ query, limit = 10, gl, hl, sp, pageToken, }) { try { if (!SERPAPI_KEY) { throw new Error("SERPAPI_KEY is not set in environment variables"); } // SerpAPI 요청 URL 및 기본 파라미터 설정 const requestUrl = new URL(SERPAPI_BASE_URL); const params = { api_key: SERPAPI_KEY, engine: "youtube", search_query: query, }; // 선택적 파라미터 추가 if (gl) params.gl = gl; // 국가 코드 if (hl) params.hl = hl; // 언어 코드 if (sp) params.sp = sp; // 필터링 및 페이지네이션 if (pageToken) params.sp = pageToken; // 다음 페이지 토큰 // 디버깅 정보 출력 console.error("Debug - YouTube Search Request:", { url: requestUrl.toString(), params, }); // URL 파라미터 설정 Object.entries(params).forEach(([key, value]) => { requestUrl.searchParams.append(key, value); }); // 요청 보내기 const headers = { "Content-Type": "application/json", }; const response = await fetch(requestUrl.toString(), { headers }); // 디버깅 정보 - 응답 상태 console.error("Debug - Response status:", response.status); if (!response.ok) { // 에러 응답의 본문 가져오기 const errorText = await response.text(); console.error("Debug - Error response:", errorText); throw new Error(`SerpAPI error: ${response.status} - ${errorText}`); } const data = await response.json(); // 디버깅 정보 - 응답 데이터의 키 목록 console.error("Debug - Response keys:", Object.keys(data)); // 결과 처리 및 구조화 return { search_metadata: data.search_metadata, search_parameters: data.search_parameters, search_information: data.search_information, video_results: data.video_results?.slice(0, limit) || [], channel_results: data.channel_results || [], playlist_results: data.playlist_results || [], movie_results: data.movie_results || [], related_searches: data.searches_related_to_star_wars?.searches || [], pagination: { current: data.pagination?.current, next: data.pagination?.next, next_page_token: data.pagination?.next_page_token, }, }; } catch (error) { console.error("Error searching YouTube:", error); throw error; } } // 특정 댓글의 답글을 가져오는 함수 async function getRepliesData({ pageToken }) { try { if (!SERPAPI_KEY) { throw new Error("SERPAPI_KEY is not set in environment variables"); } // 디버깅 정보 console.error("Debug - RepliesParams:", { pageToken }); const requestUrl = new URL(SERPAPI_BASE_URL); requestUrl.searchParams.append("api_key", SERPAPI_KEY); requestUrl.searchParams.append("engine", "youtube_video"); requestUrl.searchParams.append("next_page_token", pageToken); // 디버깅 정보 - 요청 URL console.error("Debug - Request URL:", requestUrl.toString()); // 답글 요청 const headers = { "Content-Type": "application/json", }; const response = await fetch(requestUrl.toString(), { headers }); // 디버깅 정보 - 응답 상태 console.error("Debug - Response status:", response.status); if (!response.ok) { // 에러 응답의 본문 가져오기 const errorText = await response.text(); console.error("Debug - Error response:", errorText); throw new Error(`SerpAPI error: ${response.status} - ${errorText}`); } const data = await response.json(); // 디버깅 정보 - 응답 데이터의 키 목록 console.error("Debug - Response keys:", Object.keys(data)); // 부모 댓글 ID const parentCommentId = data.comment_parent_id || ""; // 페이지네이션 토큰 const nextPageToken = data.replies_next_page_token || undefined; // 답글 목록이 없는 경우 if (!data.replies || !Array.isArray(data.replies)) { return { parentCommentId, replies: [], replyCount: 0, nextPageToken, }; } // 답글 파싱 const replies = data.replies.map((reply) => ({ commentId: reply.comment_id || "", author: reply.channel?.name || "Anonymous", text: reply.content || "", time: reply.published_date || "", likes: reply.extracted_vote_count || 0, })); return { parentCommentId, replies, replyCount: replies.length, nextPageToken, }; } catch (error) { console.error("Error fetching replies:", error); throw error; } } // MCP 서버 생성 const server = new McpServer({ name: "YouTube Data MCP", version: "1.0.0", description: "Extract YouTube transcripts and comments for analysis", }); // 트랜스크립트 도구 등록 server.tool("getTranscript", "Get transcript/subtitles from a YouTube video", { url: z.string().describe("YouTube video URL or video ID"), lang: z .string() .default("en") .describe('Language code for transcript (e.g., "en", "ko", "ja")'), }, async ({ url, lang }) => { try { const result = await getTranscriptData({ url, lang }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message || "Unknown error"}`, }, ], }; } }); // 비디오 정보 도구 등록 server.tool("getVideoInfo", "Get basic information about a YouTube video", { url: z.string().describe("YouTube video URL or video ID"), }, async ({ url }) => { try { const result = await getVideoInfoData({ url }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message || "Unknown error"}`, }, ], }; } }); // 댓글 도구 등록 server.tool("getReplies", "Get comments/replies from a YouTube video using SerpAPI", { url: z.string().optional().describe("YouTube video URL or video ID"), limit: z .number() .default(100) .describe("Maximum number of comments to retrieve"), sort: z .enum(["relevance", "time"]) .default("relevance") .describe("Sort order for comments"), pageToken: z .string() .optional() .describe("Token for pagination (from previous response)"), }, async ({ url, limit, sort, pageToken }) => { try { // url 또는 pageToken 중 하나는 필수 if (!url && !pageToken) { throw new Error("Either url or pageToken must be provided"); } const result = await getCommentsData({ url: url || "", limit, sort, pageToken, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message || "Unknown error"}`, }, ], }; } }); // 답글 도구 등록 server.tool("getCommentReplies", "Get replies for a specific YouTube comment using SerpAPI", { pageToken: z .string() .describe("Reply token from a comment to get its replies"), }, async ({ pageToken }) => { try { const result = await getRepliesData({ pageToken }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message || "Unknown error"}`, }, ], }; } }); // YouTube 검색 도구 등록 server.tool("searchYoutube", "Search YouTube videos, channels, and playlists", { query: z.string().describe("Search query for YouTube"), limit: z .number() .optional() .default(10) .describe("Maximum number of results to return (default: 10)"), gl: z .string() .optional() .describe("Country code for search results (e.g., 'us', 'kr', 'jp')"), hl: z .string() .optional() .describe("Language code for search results (e.g., 'en', 'ko', 'ja')"), sp: z .string() .optional() .describe("Special parameter for filtering or pagination"), pageToken: z .string() .optional() .describe("Token for pagination from previous response"), }, async ({ query, limit, gl, hl, sp, pageToken }) => { try { const result = await searchYouTube({ query, limit, gl, hl, sp, pageToken, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message || "Unknown error"}`, }, ], }; } }); // 서버 시작 async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("YouTube Data MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });