mcp-youtube-intelligence 0.2.6 → 0.2.8

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/README.md CHANGED
@@ -19,13 +19,13 @@ You need API keys for three services:
19
19
  | `APIFY_TOKEN` | [Apify Console](https://console.apify.com/) → Account → Integrations → API token |
20
20
  | `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/) → Get API key |
21
21
 
22
- `GEMINI_API_KEY` is optional — if omitted, the tool falls back to word-frequency topic extraction instead of semantic analysis.
22
+ `GEMINI_API_KEY` is optional — if omitted, the tool falls back to word-frequency topic extraction instead of semantic analysis. The same fallback applies when a configured Gemini call fails (invalid key, exhausted quota, blocked content); the result's `note` says why.
23
23
 
24
24
  **Optional**
25
25
 
26
26
  | Variable | Default | Description |
27
27
  |----------|---------|-------------|
28
- | `ANALYZE_CHANNEL_OUTPUT_DIR` | `./output/` | Directory where per-channel JSON analysis artifacts are written |
28
+ | `ANALYZE_CHANNEL_OUTPUT_DIR` | `./output/` | Directory where per-channel JSON analysis artifacts are written. The default is relative to the server's working directory, which your MCP client chooses (some start servers in `/`, where the write is denied). Set an absolute path to be sure: when the write fails the analysis still succeeds, but the result has no `output_path` and the reason goes only to stderr |
29
29
 
30
30
  ## Installation
31
31
 
@@ -79,6 +79,20 @@ channel_url: YouTube channel URL or @handle. Accepts:
79
79
  max_videos: Number of recent videos to analyze (default: 5, max: 50)
80
80
  ```
81
81
 
82
+ Returns a JSON object:
83
+
84
+ | Field | Meaning |
85
+ |-------|---------|
86
+ | `channel_id`, `channel_title` | The resolved channel |
87
+ | `channel_url` | Your `channel_url` input, echoed back |
88
+ | `sample_video_ids` | The most recent uploads that were analyzed |
89
+ | `videos_analyzed` | How many videos were fetched from the channel's uploads playlist |
90
+ | `transcripts_available` | How many of those came back with caption content |
91
+ | `topics` | Up to 20 top keywords by frequency across all transcripts. Always computed. Counts plain English words only: a word with an accented or non-Latin letter is skipped, so a transcript in another language gives few or no keywords here (`topics_structured` still works for it when Gemini is configured) |
92
+ | `topics_structured` | Per video: `theme`, `entities[]`, `tags[]` from Gemini. Empty when Gemini is not configured or fails. Only the first 1,000 words of each transcript are sent, and a video Gemini blocks or returns nothing usable for is left out |
93
+ | `note` | Which analysis mode ran. When Gemini was configured but failed, or when none of the videos returned a transcript (so there was nothing to analyze), it also says why |
94
+ | `output_path` | Where the JSON artifact was written. Absent if the write failed |
95
+
82
96
  **Example prompt:** "Analyze the @fireship YouTube channel and tell me what topics they cover most."
83
97
 
84
98
  ## Development
@@ -25,6 +25,7 @@ const GEMINI_TRANSCRIPT_WORD_LIMIT = 1000;
25
25
  const DEFAULT_OUTPUT_DIR = "./output/";
26
26
  const MAX_VIDEOS = 50;
27
27
  const FETCH_TIMEOUT_MS = 30_000; // bound every outbound request so a stalled external API can't hang the tool call indefinitely
28
+ const NOTE_CAUSE_MAX_CHARS = 200; // cap on a failure cause quoted in the result's `note`
28
29
  // ---------------------------------------------------------------------------
29
30
  // Fetch helper
30
31
  // ---------------------------------------------------------------------------
@@ -411,6 +412,24 @@ const TECH_TERMS = new Set([
411
412
  "ui", "ux", "ci", "cd", "ide", "orm", "jwt", "ssh", "tcp", "dns", "cdn",
412
413
  "js", "ts", "tsx", "jsx", "php", "os",
413
414
  ]);
415
+ // Google APIs report errors as {"error": {"code", "message", "status", "details": [{"reason"}]}}.
416
+ // Returns the readable message and, when present, the machine-readable reason. A body in any
417
+ // other shape (plain text, an HTML error page from a proxy) comes back trimmed and otherwise
418
+ // untouched.
419
+ function parseGoogleError(body) {
420
+ try {
421
+ const error = JSON.parse(body)?.error;
422
+ if (typeof error?.message === "string" && error.message) {
423
+ const details = Array.isArray(error.details) ? error.details : [];
424
+ const reason = details.map((d) => d?.reason).find((r) => typeof r === "string");
425
+ return { message: error.message, reason };
426
+ }
427
+ }
428
+ catch {
429
+ // Not JSON — fall through to the raw text.
430
+ }
431
+ return { message: body.trim() };
432
+ }
414
433
  export async function extractTopicsWithLLM(transcriptItems, geminiApiKey) {
415
434
  const results = [];
416
435
  let attempted = 0;
@@ -475,38 +494,68 @@ ${truncated}`;
475
494
  }, "Gemini API");
476
495
  }
477
496
  if (!resp.ok) {
478
- const detail = await resp.text();
479
- if (resp.status === 401 || resp.status === 403) {
497
+ const { message, reason } = parseGoogleError(await resp.text());
498
+ const failure = `Gemini API error ${resp.status}: ${message}`;
499
+ // Google reports a bad or expired key as HTTP 400 with reason API_KEY_INVALID, not as
500
+ // 401/403, so the status alone missed the commonest credential failure.
501
+ const badKey = reason === "API_KEY_INVALID" || /^API key (not valid|expired)/i.test(message);
502
+ if (resp.status === 401 || resp.status === 403 || badKey) {
480
503
  // Auth failures are invariant across requests using the same key — every
481
504
  // remaining video would fail identically, so stop instead of repeating a
482
505
  // doomed call up to max_videos times.
483
- lastRequestError = `Gemini API error ${resp.status}: ${detail}`;
506
+ lastRequestError = failure;
484
507
  console.warn(`[analyze_channel] Gemini authentication failed (${resp.status}), aborting remaining videos: ${lastRequestError}`);
485
508
  break;
486
509
  }
487
- throw new Error(`Gemini API error ${resp.status}: ${detail}`);
510
+ throw new Error(failure);
488
511
  }
489
512
  const body = await parseJsonResponse(resp, "Gemini API");
490
- const text = body.candidates?.[0]?.content?.parts?.[0]?.text ?? "{}";
491
- let parsed = {};
513
+ const candidate = body.candidates?.[0];
514
+ const text = candidate?.content?.parts?.[0]?.text;
515
+ // Each of the four checks below counts toward the same "every attempted video
516
+ // produced nothing usable" total-failure check after the loop as an HTTP-level
517
+ // failure does: a Gemini that answers 200 with nothing parseable is as much an
518
+ // outage as one that answers 5xx, and must not be reported as a clean success.
519
+ if (typeof text !== "string" || !text.trim()) {
520
+ // A blocked prompt or a stopped generation is still HTTP 200. This used to fall
521
+ // through a "{}" default and be recorded as a successful, empty analysis.
522
+ const why = body.promptFeedback?.blockReason ?? candidate?.finishReason;
523
+ lastRequestError = `Gemini returned no content for video ${videoId}${why ? ` (${why})` : ""}`;
524
+ console.warn(`[analyze_channel] ${lastRequestError}, skipping`);
525
+ continue;
526
+ }
527
+ let parsed;
492
528
  try {
493
529
  parsed = JSON.parse(text);
494
530
  }
495
531
  catch {
496
- // Counts toward the same "every attempted video produced nothing usable"
497
- // check below as an HTTP-level failure — an all-malformed-JSON response is
498
- // just as much a Gemini outage as an all-5xx one, and previously only the
499
- // latter was surfaced as a thrown error instead of a silent empty success.
500
532
  lastRequestError = `Gemini returned unparseable JSON for video ${videoId}`;
501
533
  console.warn(`[analyze_channel] ${lastRequestError}, skipping`);
502
534
  continue;
503
535
  }
504
- results.push({
536
+ // Valid JSON is not necessarily an object: `null` used to throw a cryptic
537
+ // "Cannot read properties of null", and an array or a bare scalar was recorded
538
+ // as an empty entry.
539
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
540
+ lastRequestError = `Gemini returned an unexpected JSON shape for video ${videoId}`;
541
+ console.warn(`[analyze_channel] ${lastRequestError}, skipping`);
542
+ continue;
543
+ }
544
+ const fields = parsed;
545
+ const entry = {
505
546
  video_id: videoId,
506
- theme: parsed.theme ?? "",
507
- entities: parsed.entities ?? [],
508
- tags: parsed.tags ?? [],
509
- });
547
+ theme: fields.theme ?? "",
548
+ entities: fields.entities ?? [],
549
+ tags: fields.tags ?? [],
550
+ };
551
+ // Partial objects stay valid (only a theme, only tags, ...). One carrying nothing
552
+ // at all tells the caller nothing and would inflate the "semantic topics" count.
553
+ if (!entry.theme && !entry.entities.length && !entry.tags.length) {
554
+ lastRequestError = `Gemini returned no usable topics for video ${videoId}`;
555
+ console.warn(`[analyze_channel] ${lastRequestError}, skipping`);
556
+ continue;
557
+ }
558
+ results.push(entry);
510
559
  }
511
560
  catch (err) {
512
561
  // Isolate this video's failure so one bad request doesn't discard
@@ -540,7 +589,12 @@ export function extractTopics(transcriptItems, topN = 20) {
540
589
  else if (typeof snippets === "string") {
541
590
  text = snippets;
542
591
  }
543
- const words = text.toLowerCase().match(/\b[a-z]{2,}\b/g) ?? [];
592
+ // Split into maximal runs of letters, marks, digits and underscores, then keep only the runs
593
+ // that are plain ASCII words. A `\b[a-z]{2,}\b` match treats every non-ASCII letter as a word
594
+ // boundary, so "Zürich" came out as the topic "rich" and "dzień" as "dzie". For ASCII text the
595
+ // two are identical ("gpt4" and "snake_case" are still skipped); a word with an accented or
596
+ // non-Latin letter is now dropped whole instead of leaving its ASCII fragment behind.
597
+ const words = (text.toLowerCase().match(/[\p{L}\p{M}\p{N}_]+/gu) ?? []).filter((w) => /^[a-z]{2,}$/.test(w));
544
598
  for (const word of words) {
545
599
  if ((word.length >= 4 || TECH_TERMS.has(word)) && !STOPWORDS.has(word) && !CONTRACTION_STOPWORDS.has(word)) {
546
600
  freq[word] = (freq[word] ?? 0) + 1;
@@ -555,6 +609,14 @@ export function extractTopics(transcriptItems, topN = 20) {
555
609
  // ---------------------------------------------------------------------------
556
610
  // Main: analyzeChannel
557
611
  // ---------------------------------------------------------------------------
612
+ // The note is read by the MCP client's model, which cannot see this server's stderr, so a
613
+ // failure cause has to travel in the result itself. Collapse it to one short line, and scrub
614
+ // the API key: it rides in the request URL, and some runtimes echo the URL in network errors.
615
+ function summarizeFailure(message, secret) {
616
+ const scrubbed = secret ? message.split(secret).join("[redacted]") : message;
617
+ const oneLine = scrubbed.replace(/\s+/g, " ").trim() || "unknown error";
618
+ return oneLine.length > NOTE_CAUSE_MAX_CHARS ? `${oneLine.slice(0, NOTE_CAUSE_MAX_CHARS)}…` : oneLine;
619
+ }
558
620
  export async function analyzeChannel(channelInput, maxVideos) {
559
621
  if (maxVideos < 1)
560
622
  throw new Error(`max_videos must be at least 1, got ${maxVideos}`);
@@ -588,21 +650,33 @@ export async function analyzeChannel(channelInput, maxVideos) {
588
650
  let topics_structured = [];
589
651
  let geminiUsed = false;
590
652
  let geminiConfigured = false;
591
- try {
592
- const geminiKey = getGeminiApiKey();
593
- geminiConfigured = true;
594
- topics_structured = await extractTopicsWithLLM(transcriptItems, geminiKey);
595
- geminiUsed = true;
653
+ let geminiKey;
654
+ let geminiFailure = "";
655
+ // With no transcript text at all there is nothing for Gemini to read, and no key would change
656
+ // that, so skip the call (and the key lookup) rather than report "0 semantic topics via Gemini"
657
+ // or tell the user to set GEMINI_API_KEY for a result it could not improve.
658
+ const nothingToAnalyze = transcriptsAvailable === 0;
659
+ if (!nothingToAnalyze) {
660
+ try {
661
+ geminiKey = getGeminiApiKey();
662
+ geminiConfigured = true;
663
+ topics_structured = await extractTopicsWithLLM(transcriptItems, geminiKey);
664
+ geminiUsed = true;
665
+ }
666
+ catch (err) {
667
+ // Graceful degradation — topics_structured stays empty; topics is the fallback
668
+ const reason = err instanceof Error ? err.message : String(err);
669
+ geminiFailure = summarizeFailure(reason, geminiKey);
670
+ console.warn(`[analyze_channel] Gemini extraction unavailable, using keyword fallback: ${reason}`);
671
+ }
596
672
  }
597
- catch (err) {
598
- // Graceful degradation — topics_structured stays empty; topics is the fallback
599
- console.warn(`[analyze_channel] Gemini extraction unavailable, using keyword fallback: ${err instanceof Error ? err.message : String(err)}`);
600
- }
601
- const note = geminiUsed
602
- ? `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics_structured.length} semantic topics (topics_structured) via Gemini, ${topics.length} keyword topics as supplemental.`
603
- : geminiConfigured
604
- ? `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics.length} keyword topics only (Gemini API error — check logs for details).`
605
- : `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.`;
673
+ const note = nothingToAnalyze
674
+ ? `Analyzed ${videoIds.length} video(s), but none returned a transcript (captions disabled or unavailable, or the transcript scraper could not read them), so topics and topics_structured are empty.`
675
+ : geminiUsed
676
+ ? `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics_structured.length} semantic topics (topics_structured) via Gemini, ${topics.length} keyword topics as supplemental.`
677
+ : geminiConfigured
678
+ ? `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics.length} keyword topics only (Gemini extraction failed — ${geminiFailure}).`
679
+ : `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.`;
606
680
  const result = {
607
681
  channel_id: channel.channelId,
608
682
  channel_title: channel.title,
package/build/index.js CHANGED
@@ -25,7 +25,7 @@ const server = new McpServer({
25
25
  });
26
26
  server.registerTool("analyze_channel", {
27
27
  title: "Analyze YouTube Channel",
28
- 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.",
28
+ 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; plain English words only, so a non-English transcript yields few or no keywords — use topics_structured for those), topics_structured[] (per-video semantic analysis — each entry has video_id/theme/entities[]/tags[]), note (which analysis mode ran — and, when Gemini was configured but failed, or no video returned a transcript, why), 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.",
29
29
  inputSchema: {
30
30
  channel_url: z.string().describe("YouTube channel URL or @handle (e.g. @fireship, https://www.youtube.com/@fireship, UCxxxxxxx)"),
31
31
  max_videos: z.number().int().min(1).max(50).optional().default(5).describe("Number of recent videos to analyze (default 5, max 50)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-youtube-intelligence",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "MCP server for extracting structured intelligence from YouTube channels and videos — transcripts, topics, competitive signals",
5
5
  "mcpName": "io.github.dmitriusan/mcp-youtube-intelligence",
6
6
  "main": "build/index.js",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@modelcontextprotocol/sdk": "^1.27.1",
51
- "zod": "^4.0.0"
51
+ "zod": "^4.6.6"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^22.0.0",