Build an AI mentor from your favorite YouTube creator: pull every transcript as timestamped Markdown for RAG (Brian Jenney / Parsity) Brian Jenney, creator of the Develop Yourself podcast, published a Python script that pulls every transcript from a YouTube channel, playlist, or list of videos and writes them as timestamped Markdown files for retrieval-augmented generation. The tool groups caption lines into roughly 60-second passages so RAG answers can cite the exact episode and moment, and emits a manifest of what succeeded or was skipped. The output folder is designed to be dropped into Hippocampus or any other RAG tool. | | /usr/bin/env python3 | | | """ | | | Build an AI mentor from your favorite creator. | | | | | | Pulls every transcript from a YouTube channel, playlist, or list of videos and | | | writes them as clean, timestamped Markdown files. Drop the output folder into | | | Hippocampus or any RAG tool and ask it questions. Every answer can cite the | | | episode and the exact moment it came from. | | | | | | By Brian Jenney youtube.com/@brianjenney , from the video where he turned | | | every Develop Yourself podcast interview into an AI career mentor. | | | | | | Learn to build RAG systems like this yourself: https://parsity.io | | | Try Hippocampus 20% off for 12 months, code BrianJenney : | | | https://www.thalius.ai/?utm source=youtube&utm medium=influeri&utm campaign=thalius hippocampus developer pilot&utm content=brianjenney | | | | | | Setup a virtual environment avoids "externally-managed-environment" errors on Mac : | | | python3 -m venv .venv && source .venv/bin/activate | | | pip install yt-dlp youtube-transcript-api | | | | | | Usage: | | | python build mentor corpus.py https://www.youtube.com/@brianjenney | | | python build mentor corpus.py https://www.youtube.com/@brianjenney --limit 50 | | | python build mentor corpus.py "https://www.youtube.com/playlist?list=PLxxxx" --out my mentor | | | python build mentor corpus.py https://youtu.be/VIDEO ID https://youtu.be/OTHER ID | | | | | | Output: | | | mentor corpus/ | | | 2026-10-28 - Episode title - VIDEO ID.md one file per video | | | ... | | | manifest.json what worked, what got skipped | | | """ | | | | | | import argparse | | | import json | | | import re | | | import sys | | | import time | | | from pathlib import Path | | | | | | try: | | | from yt dlp import YoutubeDL | | | from youtube transcript api import YouTubeTranscriptApi | | | except ImportError: | | | sys.exit | | | "Missing dependencies. Run:\n" | | | " python3 -m venv .venv && source .venv/bin/activate\n" | | | " pip install yt-dlp youtube-transcript-api" | | | | | | | | | | | | Group caption lines into ~60-second passages. Long enough to carry a full | | | thought, short enough that a citation points somewhere useful. | | | PASSAGE SECONDS = 60 | | | | | | | | | def list videos urls, limit : | | | """Expand channels/playlists/single videos into a flat list of video metadata.""" | | | opts = {"quiet": True, "extract flat": "in playlist", "skip download": True} | | | videos = | | | with YoutubeDL opts as ydl: | | | for url in urls: | | | A channel URL lists tabs videos, shorts, live . Ask for long-form videos. | | | if re.search r"youtube\.com/ @\|c/\|channel/\|user/ ", url and not url.rstrip "/" .endswith | | | "/videos", "/streams", "/shorts" | | | : | | | url = url.rstrip "/" + "/videos" | | | info = ydl.extract info url, download=False | | | entries = info.get "entries" or info | | | for e in entries: | | | if not e or not e.get "id" : | | | continue | | | videos.append {"id": e "id" , "title": e.get "title" or e "id" } | | | if limit and len videos = limit: | | | return videos | | | return videos | | | | | | | | | def video details video id : | | | """Grab title, channel and upload date for one video cheap, no download .""" | | | opts = {"quiet": True, "skip download": True, "ignore no formats error": True, "no warnings": True} | | | with YoutubeDL opts as ydl: | | | info = ydl.extract info f"https://www.youtube.com/watch?v={video id}", download=False | | | date = info.get "upload date" or "" | | | if len date == 8: | | | date = f"{date :4 }-{date 4:6 }-{date 6: }" | | | return { | | | "title": info.get "title" or video id, | | | "channel": info.get "channel" or info.get "uploader" or "", | | | "date": date, | | | "description": info.get "description" or "" .strip , | | | } | | | | | | | | | def fetch transcript video id, languages : | | | """Return a list of {start, text} caption lines, or None if the video has no captions.""" | | | api = YouTubeTranscriptApi | | | try: | | | fetched = api.fetch video id, languages=languages | | | except Exception: | | | Fall back to whatever transcript exists auto-generated, other language . | | | try: | | | transcript = next iter api.list video id | | | fetched = transcript.fetch | | | except Exception: | | | return None | | | return {"start": s.start, "text": s.text} for s in fetched | | | | | | | | | def clock seconds : | | | seconds = int seconds | | | h, rem = divmod seconds, 3600 | | | m, s = divmod rem, 60 | | | return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}" | | | | | | | | | def to passages lines : | | | """Merge caption fragments into ~60-second passages with clean text.""" | | | passages, current, start = , , None | | | for line in lines: | | | text = re.sub r"\ music\|applause\|laughter \ ", "", line "text" , flags=re.I | | | text = re.sub r"\s+", " ", text.replace "\n", " " .strip | | | if not text: | | | continue | | | if start is None: | | | start = line "start" | | | current.append text | | | if line "start" - start = PASSAGE SECONDS: | | | passages.append start, " ".join current | | | current, start = , None | | | if current: | | | passages.append start, " ".join current | | | return passages | | | | | | | | | def to markdown video id, meta, passages : | | | url = f"https://www.youtube.com/watch?v={video id}" | | | out = | | | f" {meta 'title' }", | | | "", | | | f"- Source: {url}", | | | f"- Creator: {meta 'channel' }" if meta "channel" else None, | | | f"- Published: {meta 'date' }" if meta "date" else None, | | | "", | | | | | | if meta "description" : | | | The first lines of a description usually name the guest and topic, | | | which helps retrieval a lot. Skip the link dump below it. | | | summary = meta "description" .split "\n\n" 0 :600 | | | out += " About this episode", "", summary, "" | | | out += " Transcript", "" | | | for start, text in passages: | | | stamp = clock start | | | out += f" {stamp} {url}&t={int start }s ", "", text, "" | | | return "\n".join line for line in out if line is not None | | | | | | | | | def safe filename name : | | | name = re.sub r' < :"/\\\|? \x00-\x1f ', "", name | | | return re.sub r"\s+", " ", name .strip :90 | | | | | | | | | def main : | | | parser = argparse.ArgumentParser | | | description="Turn a YouTube creator's videos into a timestamped corpus for RAG." | | | | | | parser.add argument "urls", nargs="+", help="Channel, playlist, or video URLs" | | | parser.add argument "--out", default="mentor corpus", help="Output folder default: mentor corpus " | | | parser.add argument "--limit", type=int, default=0, help="Max videos to pull default: all " | | | parser.add argument "--lang", default="en", help="Preferred caption languages, comma separated default: en " | | | parser.add argument "--delay", type=float, default=1.0, help="Seconds between videos, be polite default: 1 " | | | args = parser.parse args | | | | | | out dir = Path args.out | | | out dir.mkdir parents=True, exist ok=True | | | languages = l.strip for l in args.lang.split "," if l.strip | | | | | | print "Finding videos..." | | | videos = list videos args.urls, args.limit | | | print f"Found {len videos } videos. Pulling transcripts.\n" | | | | | | manifest = {"saved": , "skipped": } | | | for i, v in enumerate videos, 1 : | | | vid = v "id" | | | existing = list out dir.glob f" {vid}.md" | | | if existing: | | | print f" {i}/{len videos } already have {v 'title' }" | | | manifest "saved" .append {"id": vid, "file": existing 0 .name} | | | continue | | | | | | lines = fetch transcript vid, languages | | | if not lines: | | | print f" {i}/{len videos } no captions, skipping: {v 'title' }" | | | manifest "skipped" .append {"id": vid, "title": v "title" , "reason": "no captions"} | | | continue | | | | | | try: | | | meta = video details vid | | | except Exception: | | | meta = {"title": v "title" , "channel": "", "date": "", "description": ""} | | | | | | md = to markdown vid, meta, to passages lines | | | prefix = f"{meta 'date' } - " if meta "date" else "" | | | path = out dir / f"{prefix}{safe filename meta 'title' } - {vid}.md" | | | path.write text md, encoding="utf-8" | | | manifest "saved" .append {"id": vid, "file": path.name} | | | print f" {i}/{len videos } saved {path.name}" | | | time.sleep args.delay | | | | | | out dir / " manifest.json" .write text json.dumps manifest, indent=2 , encoding="utf-8" | | | | | | print | | | f"\nDone. {len manifest 'saved' } transcripts in ./{out dir}/, " | | | f"{len manifest 'skipped' } skipped see manifest.json ." | | | "\n\nNext: upload the folder to Hippocampus or your own RAG pipeline and start asking questions." | | | "\nWant to learn how to build the retrieval side yourself? https://parsity.io" | | | | | | | | | | | | if name == " main ": | | | main |