mcp-youtube-intelligence 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmytro Lisnichenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # mcp-youtube-intelligence
2
+
3
+ MCP server for extracting structured intelligence from YouTube channels and videos.
4
+
5
+ ## What it does
6
+
7
+ Analyzes YouTube channels to produce structured intelligence reports:
8
+ - Transcript extraction across recent videos (up to 50 videos)
9
+ - Semantic topic extraction per video via Gemini (theme, named entities, tags)
10
+ - Keyword frequency analysis across all transcripts (fallback when Gemini is unavailable)
11
+
12
+ ## Prerequisites
13
+
14
+ You need API keys for three services:
15
+
16
+ | Variable | Where to get it |
17
+ |----------|----------------|
18
+ | `YOUTUBE_API_KEY` | [Google Cloud Console](https://console.cloud.google.com/) → YouTube Data API v3 |
19
+ | `APIFY_TOKEN` | [Apify Console](https://console.apify.com/) → Account → Integrations → API token |
20
+ | `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/) → Get API key |
21
+
22
+ `GEMINI_API_KEY` is optional — if omitted, the tool falls back to word-frequency topic extraction instead of semantic analysis.
23
+
24
+ **Optional**
25
+
26
+ | Variable | Default | Description |
27
+ |----------|---------|-------------|
28
+ | `ANALYZE_CHANNEL_OUTPUT_DIR` | `./output/` | Directory where per-channel JSON analysis artifacts are written |
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install -g mcp-youtube-intelligence
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Add to your Claude Desktop / MCP client config:
39
+ - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
40
+ - **Linux**: `~/.config/Claude/claude_desktop_config.json`
41
+ - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "youtube-intelligence": {
47
+ "command": "mcp-youtube-intelligence",
48
+ "env": {
49
+ "YOUTUBE_API_KEY": "your-youtube-api-key",
50
+ "APIFY_TOKEN": "your-apify-token",
51
+ "GEMINI_API_KEY": "your-gemini-api-key"
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ### Tools
59
+
60
+ **`analyze_channel`** — Extract intelligence from a YouTube channel
61
+
62
+ ```
63
+ channel_url: YouTube channel URL or @handle (e.g. @fireship, youtube.com/@fireship)
64
+ max_videos: Number of recent videos to analyze (default: 5, max: 50)
65
+ ```
66
+
67
+ **Example prompt:** "Analyze the @fireship YouTube channel and tell me what topics they cover most."
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ npm install
73
+ npm run build
74
+ npm test
75
+ ```
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,521 @@
1
+ /**
2
+ * analyze_channel — Day 4 implementation.
3
+ *
4
+ * Channel resolution: YouTube Data API v3 (forHandle / by channel ID)
5
+ * Transcript fetch: Apify supreme_coder/youtube-transcript-scraper (vKlQCAJRI72MdyK1u)
6
+ * — same actor used by IrrationalCorp Scout/R&D signal pipeline
7
+ * Topic extraction: Gemini 2.5 Flash semantic layer (primary); word-frequency fallback
8
+ * Persistence: topics_structured + topics written to disk as JSON artifact (ANALYZE_CHANNEL_OUTPUT_DIR)
9
+ */
10
+ import { readFileSync, mkdirSync, writeFileSync } from "fs";
11
+ import { join } from "path";
12
+ // ---------------------------------------------------------------------------
13
+ // Constants
14
+ // ---------------------------------------------------------------------------
15
+ const APIFY_BASE = "https://api.apify.com/v2";
16
+ // supreme_coder/youtube-transcript-scraper — same actor as scripts/yt-captions/apify_download.py
17
+ export const TRANSCRIPT_ACTOR_ID = "vKlQCAJRI72MdyK1u";
18
+ const YT_API_BASE = "https://www.googleapis.com/youtube/v3";
19
+ const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models";
20
+ // Corp secrets path — MCP server runs on the same host as IrrationalCorp repo
21
+ const CORP_SECRETS_DIR = "/media/development/irrationals/IrrationalCorp/secrets";
22
+ const APIFY_POLL_INTERVAL_MS = 15_000;
23
+ const APIFY_MAX_WAIT_MS = 5 * 60 * 1000; // 5 minutes
24
+ const GEMINI_TRANSCRIPT_WORD_LIMIT = 1000;
25
+ const DEFAULT_OUTPUT_DIR = "./output/";
26
+ const MAX_VIDEOS = 50;
27
+ const FETCH_TIMEOUT_MS = 30_000; // bound every outbound request so a stalled external API can't hang the tool call indefinitely
28
+ // ---------------------------------------------------------------------------
29
+ // Fetch helper
30
+ // ---------------------------------------------------------------------------
31
+ async function fetchWithTimeout(url, options, label) {
32
+ try {
33
+ return await fetch(url, { ...options, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
34
+ }
35
+ catch (err) {
36
+ if (err instanceof Error && err.name === "TimeoutError") {
37
+ throw new Error(`${label} timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
38
+ }
39
+ throw err;
40
+ }
41
+ }
42
+ // A 2xx status does not guarantee a parseable (or expected-shape) JSON body —
43
+ // proxies/gateways can return HTML error pages with a 200, and flaky connections
44
+ // can truncate a response. Surface those as the same kind of descriptive error
45
+ // every other failure mode in this file produces, instead of a bare native
46
+ // "Unexpected token < in JSON at position 0" SyntaxError.
47
+ async function parseJsonResponse(resp, label) {
48
+ try {
49
+ return await resp.json();
50
+ }
51
+ catch (err) {
52
+ const detail = err instanceof Error ? err.message : String(err);
53
+ throw new Error(`${label}: response was not valid JSON (status ${resp.status}): ${detail}`);
54
+ }
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Secret loader
58
+ // ---------------------------------------------------------------------------
59
+ function loadEnvKey(filename, key) {
60
+ try {
61
+ const content = readFileSync(join(CORP_SECRETS_DIR, filename), "utf8");
62
+ for (const line of content.split("\n")) {
63
+ const trimmed = line.trim();
64
+ if (trimmed.startsWith("#") || !trimmed.includes("="))
65
+ continue;
66
+ const eqIdx = trimmed.indexOf("=");
67
+ const k = trimmed.slice(0, eqIdx).trim();
68
+ if (k === key)
69
+ return trimmed.slice(eqIdx + 1).trim().replace(/^['"]|['"]$/g, "");
70
+ }
71
+ }
72
+ catch {
73
+ // File not found or unreadable — caller throws descriptive error
74
+ }
75
+ return undefined;
76
+ }
77
+ export function getApifyToken() {
78
+ const token = process.env["APIFY_TOKEN"] ?? loadEnvKey("apify.env", "APIFY_TOKEN");
79
+ if (!token)
80
+ throw new Error("APIFY_TOKEN not configured. Set env var APIFY_TOKEN or ensure secrets/apify.env exists.");
81
+ return token;
82
+ }
83
+ export function getYouTubeApiKey() {
84
+ const key = process.env["YOUTUBE_API_KEY"] ?? loadEnvKey("google-cloud.env", "GOOGLE_CLOUD_API_KEY");
85
+ if (!key)
86
+ throw new Error("YOUTUBE_API_KEY not configured. Set env var YOUTUBE_API_KEY or ensure secrets/google-cloud.env exists.");
87
+ return key;
88
+ }
89
+ export function getGeminiApiKey() {
90
+ const key = process.env["GEMINI_API_KEY"] ?? loadEnvKey("gemini.env", "GEMINI_API_KEY");
91
+ if (!key)
92
+ throw new Error("GEMINI_API_KEY not configured. Set env var GEMINI_API_KEY or ensure secrets/gemini.env exists.");
93
+ return key;
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // Channel URL / handle parser
97
+ // ---------------------------------------------------------------------------
98
+ export function parseChannelInput(input) {
99
+ const trimmed = input.trim();
100
+ // Bare @handle — e.g. @fireship or @web.dev (periods allowed in handles)
101
+ if (/^@[\w.-]+$/.test(trimmed))
102
+ return { type: "handle", value: trimmed };
103
+ // Full URL with @handle — youtube.com/@fireship or youtube.com/@web.dev/videos
104
+ const handleFromUrl = trimmed.match(/youtube\.com\/@([\w.-]+)/);
105
+ if (handleFromUrl)
106
+ return { type: "handle", value: `@${handleFromUrl[1]}` };
107
+ // /channel/UC... URL
108
+ const channelIdFromUrl = trimmed.match(/\/channel\/(UC[\w-]{22})/);
109
+ if (channelIdFromUrl)
110
+ return { type: "channel_id", value: channelIdFromUrl[1] };
111
+ // Bare UC... channel ID (24 chars: "UC" + 22)
112
+ if (/^UC[\w-]{22}$/.test(trimmed))
113
+ return { type: "channel_id", value: trimmed };
114
+ // Legacy /c/name or /user/name URL
115
+ const customFromUrl = trimmed.match(/youtube\.com\/(?:c|user)\/([\w-]+)/);
116
+ if (customFromUrl)
117
+ return { type: "custom_url", value: customFromUrl[1] };
118
+ throw new Error(`Cannot parse YouTube channel from: "${input}". ` +
119
+ `Use @handle, youtube.com/@handle URL, or UC... channel ID.`);
120
+ }
121
+ export async function resolveChannel(input, apiKey) {
122
+ const parsed = parseChannelInput(input);
123
+ let apiUrl;
124
+ if (parsed.type === "handle") {
125
+ apiUrl = `${YT_API_BASE}/channels?part=id,contentDetails,snippet&forHandle=${encodeURIComponent(parsed.value)}&key=${apiKey}`;
126
+ }
127
+ else if (parsed.type === "channel_id") {
128
+ apiUrl = `${YT_API_BASE}/channels?part=id,contentDetails,snippet&id=${encodeURIComponent(parsed.value)}&key=${apiKey}`;
129
+ }
130
+ else {
131
+ // custom_url — fall back to search (costs 100 quota units); rare path
132
+ const searchUrl = `${YT_API_BASE}/search?part=snippet&type=channel&q=${encodeURIComponent(parsed.value)}&maxResults=1&key=${apiKey}`;
133
+ const searchResp = await fetchWithTimeout(searchUrl, {}, "YouTube search API");
134
+ if (!searchResp.ok)
135
+ throw new Error(`YouTube search API error ${searchResp.status}: ${await searchResp.text()}`);
136
+ const searchData = await parseJsonResponse(searchResp, "YouTube search API");
137
+ // Same guard as the handle/channel_id path below and getRecentVideoIds: Google APIs
138
+ // sometimes embed a quota/permission failure in a 200 body instead of the HTTP status.
139
+ // This path costs 100 quota units per call (vs 1 for the others), so it is the most
140
+ // likely of the three to actually hit that failure mode — without this check it was
141
+ // misreported as "Channel not found" instead of the real cause.
142
+ if (searchData.error)
143
+ throw new Error(`YouTube API: ${searchData.error.message}`);
144
+ const items = searchData.items ?? [];
145
+ if (!items.length)
146
+ throw new Error(`Channel not found for custom URL: ${input}`);
147
+ const channelId = items[0]["id"]?.["channelId"];
148
+ if (!channelId)
149
+ throw new Error(`Channel not found for custom URL: ${input} (search result had no channel ID)`);
150
+ return resolveChannel(channelId, apiKey);
151
+ }
152
+ const resp = await fetchWithTimeout(apiUrl, {}, "YouTube channels API");
153
+ if (!resp.ok)
154
+ throw new Error(`YouTube channels API error ${resp.status}: ${await resp.text()}`);
155
+ const data = await parseJsonResponse(resp, "YouTube channels API");
156
+ if (data.error)
157
+ throw new Error(`YouTube API: ${data.error.message}`);
158
+ const items = data.items ?? [];
159
+ if (!items.length)
160
+ throw new Error(`Channel not found: ${input}`);
161
+ const item = items[0];
162
+ const snippet = item["snippet"];
163
+ const contentDetails = item["contentDetails"];
164
+ const uploadsPlaylistId = contentDetails?.relatedPlaylists?.["uploads"];
165
+ if (!uploadsPlaylistId) {
166
+ throw new Error(`Channel "${input}" has no uploads playlist (it may be terminated, suspended, or otherwise unavailable).`);
167
+ }
168
+ return {
169
+ channelId: item["id"],
170
+ uploadsPlaylistId,
171
+ title: snippet["title"],
172
+ };
173
+ }
174
+ export async function getRecentVideoIds(uploadsPlaylistId, maxResults, apiKey) {
175
+ const apiUrl = `${YT_API_BASE}/playlistItems?part=contentDetails` +
176
+ `&playlistId=${encodeURIComponent(uploadsPlaylistId)}` +
177
+ `&maxResults=${maxResults}` +
178
+ `&key=${apiKey}`;
179
+ const resp = await fetchWithTimeout(apiUrl, {}, "YouTube playlistItems API");
180
+ if (!resp.ok)
181
+ throw new Error(`YouTube playlistItems API error ${resp.status}: ${await resp.text()}`);
182
+ const data = await parseJsonResponse(resp, "YouTube playlistItems API");
183
+ // Google APIs usually mirror an API-level error into the HTTP status, but not
184
+ // always (e.g. some quota/permission failures land in the body of a 200) —
185
+ // resolveChannel already guards this; mirror it here instead of silently
186
+ // proceeding as if the channel simply had zero videos.
187
+ if (data.error)
188
+ throw new Error(`YouTube API: ${data.error.message}`);
189
+ const items = (data.items ?? []);
190
+ return items.map((item) => item.contentDetails?.videoId).filter((id) => Boolean(id));
191
+ }
192
+ export async function runApifyTranscriptScraper(videoUrls, token) {
193
+ // Start actor run — 1GB memory is enough for a few videos
194
+ const startRun = () => fetchWithTimeout(`${APIFY_BASE}/acts/${TRANSCRIPT_ACTOR_ID}/runs?memory=1024&timeout=300`, {
195
+ method: "POST",
196
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
197
+ body: JSON.stringify({ urls: videoUrls.map((url) => ({ url })) }),
198
+ }, "Apify start run");
199
+ let runResp = await startRun();
200
+ // Retry once on a transient 5xx — the same one-shot tolerance already given to
201
+ // Gemini calls and to every status poll below; a 503 here previously failed the
202
+ // whole tool call immediately instead of getting that same retry.
203
+ if (!runResp.ok && runResp.status >= 500) {
204
+ runResp = await startRun();
205
+ }
206
+ if (!runResp.ok) {
207
+ throw new Error(`Apify start run failed ${runResp.status}: ${await runResp.text()}`);
208
+ }
209
+ const runBody = await parseJsonResponse(runResp, "Apify start run");
210
+ if (!runBody.data?.id || !runBody.data?.defaultDatasetId) {
211
+ throw new Error(`Apify start run: response missing run id or dataset id: ${JSON.stringify(runBody)}`);
212
+ }
213
+ const { id: runId, defaultDatasetId: datasetId } = runBody.data;
214
+ // Poll until SUCCEEDED or terminal status
215
+ const deadline = Date.now() + APIFY_MAX_WAIT_MS;
216
+ let lastStatus = "RUNNING";
217
+ while (Date.now() < deadline) {
218
+ await sleep(APIFY_POLL_INTERVAL_MS);
219
+ let statusResp;
220
+ try {
221
+ statusResp = await fetchWithTimeout(`${APIFY_BASE}/actor-runs/${runId}`, { headers: { Authorization: `Bearer ${token}` } }, "Apify poll");
222
+ }
223
+ catch {
224
+ // Connection error or per-request timeout — genuinely transient, same as a 5xx below.
225
+ continue;
226
+ }
227
+ if (!statusResp.ok) {
228
+ // 4xx = permanent error (invalid run ID, bad token) — fail fast instead of polling to timeout
229
+ if (statusResp.status >= 400 && statusResp.status < 500) {
230
+ throw new Error(`Apify poll error ${statusResp.status} for run ${runId}: ${await statusResp.text()}`);
231
+ }
232
+ continue; // 5xx or network transient — keep polling
233
+ }
234
+ let statusBody;
235
+ try {
236
+ statusBody = await parseJsonResponse(statusResp, "Apify poll");
237
+ }
238
+ catch {
239
+ continue; // malformed body on an otherwise-ok poll response — as transient as a 5xx
240
+ }
241
+ if (!statusBody.data?.status)
242
+ continue; // unexpected shape — same treatment
243
+ lastStatus = statusBody.data.status;
244
+ if (lastStatus === "SUCCEEDED")
245
+ break;
246
+ if (["FAILED", "ABORTED", "TIMED-OUT"].includes(lastStatus)) {
247
+ throw new Error(`Apify run ${runId} ended with status: ${lastStatus}`);
248
+ }
249
+ }
250
+ if (lastStatus !== "SUCCEEDED") {
251
+ throw new Error(`Apify run ${runId} did not finish within ${APIFY_MAX_WAIT_MS / 1000}s (last: ${lastStatus})`);
252
+ }
253
+ // Fetch dataset items
254
+ const itemsResp = await fetchWithTimeout(`${APIFY_BASE}/datasets/${datasetId}/items?limit=100`, { headers: { Authorization: `Bearer ${token}` } }, "Apify dataset fetch");
255
+ if (!itemsResp.ok)
256
+ throw new Error(`Apify dataset fetch failed ${itemsResp.status}: ${await itemsResp.text()}`);
257
+ const items = await parseJsonResponse(itemsResp, "Apify dataset fetch");
258
+ if (!Array.isArray(items)) {
259
+ throw new Error(`Apify dataset fetch: expected an array of items, got ${typeof items}`);
260
+ }
261
+ return items;
262
+ }
263
+ function sleep(ms) {
264
+ return new Promise((resolve) => setTimeout(resolve, ms));
265
+ }
266
+ // ---------------------------------------------------------------------------
267
+ // Disk persistence — write topics_structured artifact as JSON (Day 4)
268
+ // ---------------------------------------------------------------------------
269
+ export function persistAnalysisResult(result, outputDir = process.env["ANALYZE_CHANNEL_OUTPUT_DIR"] ?? DEFAULT_OUTPUT_DIR) {
270
+ try {
271
+ mkdirSync(outputDir, { recursive: true });
272
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
273
+ const filename = `analyze_channel-${result.channel_id}-${timestamp}.json`;
274
+ const filepath = join(outputDir, filename);
275
+ const payload = {
276
+ channel_id: result.channel_id,
277
+ channel_title: result.channel_title,
278
+ channel_url: result.channel_url,
279
+ sample_video_ids: result.sample_video_ids,
280
+ video_count: result.videos_analyzed,
281
+ topics_structured: result.topics_structured,
282
+ topics: result.topics,
283
+ generated_at: new Date().toISOString(),
284
+ };
285
+ writeFileSync(filepath, JSON.stringify(payload, null, 2), "utf8");
286
+ return filepath;
287
+ }
288
+ catch (err) {
289
+ console.warn(`[analyze_channel] Failed to persist output: ${err.message}`);
290
+ return null;
291
+ }
292
+ }
293
+ // ---------------------------------------------------------------------------
294
+ // Topic extraction — word frequency (Day 3 replaces with LLM semantic layer)
295
+ // ---------------------------------------------------------------------------
296
+ // Common English stopwords + YouTube filler words to suppress from topics
297
+ const STOPWORDS = new Set([
298
+ "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
299
+ "from", "as", "is", "was", "are", "were", "be", "been", "being", "have", "has", "had",
300
+ "do", "does", "did", "will", "would", "could", "should", "may", "might", "can", "it",
301
+ "its", "this", "that", "these", "those", "i", "you", "he", "she", "we", "they", "me",
302
+ "him", "her", "us", "them", "what", "which", "who", "when", "where", "why", "how",
303
+ "all", "each", "every", "both", "few", "more", "most", "other", "some", "such", "no",
304
+ "not", "only", "own", "same", "so", "than", "too", "very", "just", "about", "up", "out",
305
+ "if", "also", "like", "into", "over", "after", "get", "know", "think", "want", "go",
306
+ "see", "one", "two", "time", "new", "year", "way", "going", "really", "actually",
307
+ "basically", "literally", "right", "okay", "yeah", "well", "kind", "sort", "much",
308
+ "many", "here", "there", "then", "now", "even", "back", "come", "came", "good", "great",
309
+ "thing", "things", "make", "made", "take", "took", "look", "put", "use", "used", "need",
310
+ "let", "say", "said", "gonna", "wanna", "gotta",
311
+ ]);
312
+ // The word regex below splits on the apostrophe in negation contractions (e.g.
313
+ // "wasn't" tokenizes as "wasn", dropping the "t"), and several of those prefixes
314
+ // are 4+ characters — long enough to pass the length filter — and were not caught
315
+ // by the modal-verb stopwords above, so they leaked into topics as noise on any
316
+ // transcript with normal spoken-English contraction density.
317
+ const CONTRACTION_STOPWORDS = new Set([
318
+ "wasn", "aren", "weren", "hasn", "haven", "hadn", "doesn", "didn", "couldn", "wouldn", "shouldn",
319
+ ]);
320
+ // Short technical abbreviations exempt from the 4-char minimum in extractTopics.
321
+ // The base regex only matches [a-z]{4,} to suppress noise, but these 2-3 char
322
+ // terms are high-signal in tech content (channels discussing AI, web dev, cloud, etc.).
323
+ const TECH_TERMS = new Set([
324
+ "ai", "ml", "vr", "ar", "xr",
325
+ "api", "sdk", "npm", "css", "gpu", "llm", "cli", "sql", "git", "aws", "gcp", "ios",
326
+ "ui", "ux", "ci", "cd", "ide", "orm", "jwt", "ssh", "tcp", "dns", "cdn",
327
+ "js", "ts", "tsx", "jsx", "php", "os",
328
+ ]);
329
+ export async function extractTopicsWithLLM(transcriptItems, geminiApiKey) {
330
+ const results = [];
331
+ let attempted = 0;
332
+ let lastRequestError;
333
+ for (const item of transcriptItems) {
334
+ const record = item;
335
+ const videoDetails = record["videoDetails"];
336
+ const videoId = videoDetails?.["videoId"] ?? "unknown";
337
+ const snippets = record["transcript"];
338
+ let rawText = "";
339
+ if (Array.isArray(snippets)) {
340
+ rawText = snippets.map((s) => s["text"] ?? "").join(" ");
341
+ }
342
+ else if (typeof snippets === "string") {
343
+ rawText = snippets;
344
+ }
345
+ if (!rawText.trim())
346
+ continue;
347
+ // Truncate to limit Gemini input tokens
348
+ const words = rawText.split(/\s+/);
349
+ const truncated = words.slice(0, GEMINI_TRANSCRIPT_WORD_LIMIT).join(" ");
350
+ const prompt = `Analyze this YouTube video transcript excerpt and return a JSON object with fields:
351
+ - "theme": one short phrase describing the main topic (max 8 words)
352
+ - "entities": array of up to 6 specific named technologies, products, companies, or people mentioned
353
+ - "tags": array of up to 8 topical keywords relevant for content classification
354
+
355
+ Transcript (video_id: ${videoId}):
356
+ ${truncated}`;
357
+ const requestBody = {
358
+ contents: [{ parts: [{ text: prompt }] }],
359
+ generationConfig: {
360
+ responseMimeType: "application/json",
361
+ responseSchema: {
362
+ type: "OBJECT",
363
+ properties: {
364
+ theme: { type: "STRING" },
365
+ entities: { type: "ARRAY", items: { type: "STRING" } },
366
+ tags: { type: "ARRAY", items: { type: "STRING" } },
367
+ },
368
+ required: ["theme", "entities", "tags"],
369
+ },
370
+ },
371
+ };
372
+ attempted++;
373
+ try {
374
+ let resp = await fetchWithTimeout(`${GEMINI_API_BASE}/gemini-2.5-flash:generateContent?key=${geminiApiKey}`, {
375
+ method: "POST",
376
+ headers: { "Content-Type": "application/json" },
377
+ body: JSON.stringify(requestBody),
378
+ }, "Gemini API");
379
+ // Retry once on transient failures: 5xx (Gemini 503 overloaded is common) or 429
380
+ // (rate limit) — both are worth a single backoff-free retry rather than discarding
381
+ // this video's topics outright.
382
+ if (!resp.ok && (resp.status >= 500 || resp.status === 429)) {
383
+ resp = await fetchWithTimeout(`${GEMINI_API_BASE}/gemini-2.5-flash:generateContent?key=${geminiApiKey}`, {
384
+ method: "POST",
385
+ headers: { "Content-Type": "application/json" },
386
+ body: JSON.stringify(requestBody),
387
+ }, "Gemini API");
388
+ }
389
+ if (!resp.ok) {
390
+ throw new Error(`Gemini API error ${resp.status}: ${await resp.text()}`);
391
+ }
392
+ const body = await parseJsonResponse(resp, "Gemini API");
393
+ const text = body.candidates?.[0]?.content?.parts?.[0]?.text ?? "{}";
394
+ let parsed = {};
395
+ try {
396
+ parsed = JSON.parse(text);
397
+ }
398
+ catch {
399
+ console.warn(`[analyze_channel] Gemini returned unparseable JSON for video ${videoId}, skipping`);
400
+ continue;
401
+ }
402
+ results.push({
403
+ video_id: videoId,
404
+ theme: parsed.theme ?? "",
405
+ entities: parsed.entities ?? [],
406
+ tags: parsed.tags ?? [],
407
+ });
408
+ }
409
+ catch (err) {
410
+ // Isolate this video's failure so one bad request doesn't discard
411
+ // structured topics already extracted for the rest of the channel's videos.
412
+ lastRequestError = err instanceof Error ? err.message : String(err);
413
+ console.warn(`[analyze_channel] Gemini request failed for video ${videoId}, skipping: ${lastRequestError}`);
414
+ }
415
+ }
416
+ // Every attempted video failed at the HTTP level — surface it as a total
417
+ // failure so analyzeChannel's note reports a Gemini outage instead of
418
+ // silently claiming success with zero structured topics.
419
+ if (attempted > 0 && results.length === 0 && lastRequestError) {
420
+ throw new Error(lastRequestError);
421
+ }
422
+ return results;
423
+ }
424
+ // ---------------------------------------------------------------------------
425
+ // Topic extraction — word frequency (kept as fallback when LLM unavailable)
426
+ // ---------------------------------------------------------------------------
427
+ export function extractTopics(transcriptItems, topN = 20) {
428
+ const freq = {};
429
+ for (const item of transcriptItems) {
430
+ const record = item;
431
+ const snippets = record["transcript"];
432
+ let text = "";
433
+ if (Array.isArray(snippets)) {
434
+ text = snippets
435
+ .map((s) => s["text"] ?? "")
436
+ .join(" ");
437
+ }
438
+ else if (typeof snippets === "string") {
439
+ text = snippets;
440
+ }
441
+ const words = text.toLowerCase().match(/\b[a-z]{2,}\b/g) ?? [];
442
+ for (const word of words) {
443
+ if ((word.length >= 4 || TECH_TERMS.has(word)) && !STOPWORDS.has(word) && !CONTRACTION_STOPWORDS.has(word)) {
444
+ freq[word] = (freq[word] ?? 0) + 1;
445
+ }
446
+ }
447
+ }
448
+ return Object.entries(freq)
449
+ .sort((a, b) => b[1] - a[1])
450
+ .slice(0, topN)
451
+ .map(([word]) => word);
452
+ }
453
+ // ---------------------------------------------------------------------------
454
+ // Main: analyzeChannel
455
+ // ---------------------------------------------------------------------------
456
+ export async function analyzeChannel(channelInput, maxVideos) {
457
+ if (maxVideos < 1)
458
+ throw new Error(`max_videos must be at least 1, got ${maxVideos}`);
459
+ const clampedMaxVideos = Math.min(maxVideos, MAX_VIDEOS);
460
+ const apifyToken = getApifyToken();
461
+ const youtubeApiKey = getYouTubeApiKey();
462
+ // Step 1: Resolve channel URL/handle → ID + uploads playlist
463
+ const channel = await resolveChannel(channelInput, youtubeApiKey);
464
+ // Step 2: Get recent video IDs from uploads playlist
465
+ const videoIds = await getRecentVideoIds(channel.uploadsPlaylistId, clampedMaxVideos, youtubeApiKey);
466
+ if (!videoIds.length)
467
+ throw new Error(`No videos found for channel: ${channelInput}`);
468
+ // Step 3: Fetch transcripts via Apify actor (same actor as Scout/R&D pipeline)
469
+ const videoUrls = videoIds.map((id) => `https://www.youtube.com/watch?v=${id}`);
470
+ const transcriptItems = await runApifyTranscriptScraper(videoUrls, apifyToken);
471
+ // Step 4a: Word-frequency topics (always computed — fallback if LLM fails)
472
+ const topics = extractTopics(transcriptItems);
473
+ // Count only items where the transcript field contains actual content.
474
+ // Apify returns one item per video even when captions are unavailable,
475
+ // so raw transcriptItems.length overstates how many videos were actually transcribed.
476
+ const transcriptsAvailable = transcriptItems.filter((item) => {
477
+ const record = item;
478
+ const snippets = record["transcript"];
479
+ if (Array.isArray(snippets))
480
+ return snippets.length > 0;
481
+ if (typeof snippets === "string")
482
+ return snippets.trim().length > 0;
483
+ return false;
484
+ }).length;
485
+ // Step 4b: LLM semantic extraction via Gemini 2.5 Flash (primary route)
486
+ let topics_structured = [];
487
+ let geminiUsed = false;
488
+ let geminiConfigured = false;
489
+ try {
490
+ const geminiKey = getGeminiApiKey();
491
+ geminiConfigured = true;
492
+ topics_structured = await extractTopicsWithLLM(transcriptItems, geminiKey);
493
+ geminiUsed = true;
494
+ }
495
+ catch (err) {
496
+ // Graceful degradation — topics_structured stays empty; topics is the fallback
497
+ console.warn(`[analyze_channel] Gemini extraction unavailable, using keyword fallback: ${err instanceof Error ? err.message : String(err)}`);
498
+ }
499
+ const note = geminiUsed
500
+ ? `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics_structured.length} semantic topics (topics_structured) via Gemini, ${topics.length} keyword topics as supplemental.`
501
+ : geminiConfigured
502
+ ? `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics.length} keyword topics only (Gemini API error — check logs for details).`
503
+ : `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics.length} keyword topics only (GEMINI_API_KEY not configured). Set GEMINI_API_KEY for richer structured analysis.`;
504
+ const result = {
505
+ channel_id: channel.channelId,
506
+ channel_title: channel.title,
507
+ channel_url: channelInput,
508
+ sample_video_ids: videoIds,
509
+ videos_analyzed: videoIds.length,
510
+ transcripts_available: transcriptsAvailable,
511
+ topics,
512
+ topics_structured,
513
+ note,
514
+ };
515
+ // Side-effect: persist artifact to disk (configurable via ANALYZE_CHANNEL_OUTPUT_DIR)
516
+ const outputPath = persistAnalysisResult(result);
517
+ if (outputPath)
518
+ result.output_path = outputPath;
519
+ return result;
520
+ }
521
+ //# sourceMappingURL=analyze_channel.js.map
package/build/index.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP YouTube Intelligence — MCP server for extracting structured intelligence
4
+ * from YouTube channels and videos.
5
+ *
6
+ * Tools:
7
+ * analyze_channel — Extract transcripts, topics, and competitive signals from a channel
8
+ */
9
+ import { createRequire } from "module";
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { z } from "zod";
13
+ import { analyzeChannel } from "./analyze_channel.js";
14
+ const require = createRequire(import.meta.url);
15
+ const { version: packageVersion } = require("../package.json");
16
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
17
+ console.log(`mcp-youtube-intelligence v${packageVersion} — MCP server for YouTube channel intelligence
18
+
19
+ Usage:
20
+ mcp-youtube-intelligence [options]
21
+
22
+ Options:
23
+ --help, -h Show this help message
24
+
25
+ Tools provided:
26
+ analyze_channel Extract transcripts, topics, and competitive signals from a YouTube channel`);
27
+ process.exit(0);
28
+ }
29
+ const server = new McpServer({
30
+ name: "mcp-youtube-intelligence",
31
+ version: packageVersion,
32
+ });
33
+ server.registerTool("analyze_channel", {
34
+ title: "Analyze YouTube Channel",
35
+ description: "Analyze a YouTube channel and return a JSON object with: channel_id, channel_title, channel_url, sample_video_ids[], videos_analyzed (count of videos fetched from playlist), transcripts_available (count with actual caption content), topics[] (top keyword frequencies across all transcripts), topics_structured[] (per-video semantic analysis — each entry has video_id/theme/entities[]/tags[]), note (which analysis mode ran), and optional output_path (local artifact path). Requires YOUTUBE_API_KEY and APIFY_TOKEN; set GEMINI_API_KEY for topics_structured semantic analysis (falls back to keyword-only when absent). Supported channel inputs: @handle (e.g. @fireship), youtube.com/@handle URL, /channel/UC... URL, bare 24-char UCxxxxxx ID, or legacy /c/ and /user/ URLs.",
36
+ inputSchema: {
37
+ channel_url: z.string().describe("YouTube channel URL or @handle (e.g. @fireship, https://www.youtube.com/@fireship, UCxxxxxxx)"),
38
+ max_videos: z.number().int().min(1).max(50).optional().default(5).describe("Number of recent videos to analyze (default 5, max 50)"),
39
+ },
40
+ annotations: {
41
+ // Writes a local JSON artifact as a side effect, so not strictly read-only —
42
+ // but that write is purely additive (a new timestamped file) and every run
43
+ // hits live, rate-limited external APIs, so results are not idempotent.
44
+ readOnlyHint: false,
45
+ destructiveHint: false,
46
+ idempotentHint: false,
47
+ openWorldHint: true,
48
+ },
49
+ }, async ({ channel_url, max_videos }) => {
50
+ try {
51
+ const result = await analyzeChannel(channel_url, max_videos);
52
+ return {
53
+ content: [
54
+ {
55
+ type: "text",
56
+ text: JSON.stringify(result, null, 2),
57
+ },
58
+ ],
59
+ };
60
+ }
61
+ catch (err) {
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ return {
64
+ content: [
65
+ {
66
+ type: "text",
67
+ text: `Error analyzing channel "${channel_url}": ${message}`,
68
+ },
69
+ ],
70
+ isError: true,
71
+ };
72
+ }
73
+ });
74
+ const transport = new StdioServerTransport();
75
+ await server.connect(transport);
76
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "mcp-youtube-intelligence",
3
+ "version": "0.2.0",
4
+ "description": "MCP server for extracting structured intelligence from YouTube channels and videos — transcripts, topics, competitive signals",
5
+ "mcpName": "io.github.dmitriusan/mcp-youtube-intelligence",
6
+ "main": "build/index.js",
7
+ "bin": {
8
+ "mcp-youtube-intelligence": "build/index.js"
9
+ },
10
+ "type": "module",
11
+ "scripts": {
12
+ "build": "tsc && chmod 755 build/index.js",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
15
+ "prepublishOnly": "npm run build && npm test"
16
+ },
17
+ "files": [
18
+ "build/**/*.js",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "keywords": [
23
+ "mcp",
24
+ "mcp-server",
25
+ "model-context-protocol",
26
+ "ai",
27
+ "claude",
28
+ "anthropic",
29
+ "youtube",
30
+ "transcript",
31
+ "intelligence",
32
+ "nlp",
33
+ "content-analysis",
34
+ "competitive-intelligence",
35
+ "captions",
36
+ "youtube-intelligence",
37
+ "youtube-api"
38
+ ],
39
+ "author": "Dmytro Lisnichenko",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/Dmitriusan/mcp-youtube-intelligence.git"
44
+ },
45
+ "homepage": "https://github.com/Dmitriusan/mcp-youtube-intelligence#readme",
46
+ "engines": {
47
+ "node": ">=20.0.0"
48
+ },
49
+ "dependencies": {
50
+ "@modelcontextprotocol/sdk": "^1.27.1",
51
+ "zod": "^4.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^22.0.0",
55
+ "typescript": "^5.0.0",
56
+ "vitest": "^2.0.0"
57
+ }
58
+ }