By the end of this guide you will have a single Python file that takes a YouTube URL, gets a transcript one of two ways, and prints a short list of takeaways where every bullet links straight to the second of the video it came from. On a video that already has captions the whole thing runs in a few seconds and costs a fraction of a cent; on a video without captions it takes as long as the audio download plus one transcription call. Budget about thirty minutes to build it the first time.
This guide sits inside AI Transcription and Content Repurposing with Python, the section covering how to get spoken words into text and then reshape them into something useful.
Prerequisites
You need Python 3.10 or newer inside a virtual environment — an isolated folder of packages that keeps this project's libraries away from the rest of your system. If that is new to you, work through Create a Python Virtual Environment for AI first, then come back.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "youtube-transcript-api>=0.6.2,<1.0" "yt-dlp>=2024.8.6" "python-dotenv>=1.0"
pip freeze > requirements.txt
Two notes on those pins. The youtube-transcript-api version is capped below 1.0 because the 1.0 rewrite moved the entry point from a class method to an instance method; pinning keeps the code below working exactly as printed. And yt-dlp needs FFmpeg, a free command-line tool that converts audio and video formats, installed separately — brew install ffmpeg on Mac, sudo apt install ffmpeg on Debian or Ubuntu, or the official Windows build on your PATH.
You only need one API key, for the second route. Put it in a file called .env beside your script:
OPENAI_API_KEY=sk-your-key-here
Then keep it out of version control before you write another line:
echo ".env" >> .gitignore
A key that reaches a public repository gets scraped and spent by strangers, often within the hour, so this is not a formality.
Two routes to a transcript
Most videos of any length already carry a caption track — either typed by the uploader or generated automatically by YouTube. When that track exists you can simply ask for it: no download, no transcription bill, no waiting. When it does not exist, or the uploader has switched captions off, you have to do the work yourself by pulling the audio and running speech-to-text over it.
Writing your script so it tries the cheap route first and quietly falls back to the expensive one is the single most useful design decision here. It means one function signature covers every video you throw at it, and you never pay for transcription you did not need.
Both routes hand you the same thing: a list of short lines, each with a start value in seconds. That shared shape is what lets the summarising code later in this guide stay simple.
Step 1 — Turn any YouTube URL into a video ID
Every YouTube link contains an 11-character video ID, but it hides in a different place depending on the link. A normal watch link puts it in the v query parameter, a share link puts it in the path, and Shorts and embeds use their own path prefixes. Parse it once, properly, and the rest of the script only ever deals with the ID.
from urllib.parse import urlparse, parse_qs
def video_id_from_url(url: str) -> str:
"""Extract the 11-character video id from any common YouTube link."""
parsed = urlparse(url)
host = parsed.netloc.lower().removeprefix("www.")
if host == "youtu.be":
return parsed.path.lstrip("/").split("/")[0]
if host in {"youtube.com", "m.youtube.com", "music.youtube.com"}:
if parsed.path == "/watch":
found = parse_qs(parsed.query).get("v")
if found:
return found[0]
for prefix in ("/embed/", "/shorts/", "/live/"):
if parsed.path.startswith(prefix):
return parsed.path[len(prefix):].split("/")[0]
raise ValueError(f"No video id found in: {url}")
if __name__ == "__main__":
for link in [
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s",
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/shorts/dQw4w9WgXcQ",
]:
print(video_id_from_url(link))
urlparse is part of Python's standard library, so there is nothing to install. Raising a clear ValueError on an unrecognised link is deliberate: when you later feed this a list of 200 URLs from a spreadsheet, you want the bad row named rather than a confusing crash three functions deeper.
Step 2 — Fetch the published caption track
This is the fast route. youtube-transcript-api asks YouTube for the caption track that is already attached to the video and returns a list of dictionaries — each with the spoken text, the start time in seconds, and how long that line stays on screen.
Three things can go wrong, and each has its own exception. TranscriptsDisabled means the uploader turned captions off. NoTranscriptFound means captions exist but not in a language you asked for. VideoUnavailable means the ID is wrong, private, or region-blocked — that one is worth letting through, because no fallback will rescue it.
from youtube_transcript_api import (
YouTubeTranscriptApi,
NoTranscriptFound,
TranscriptsDisabled,
VideoUnavailable,
)
def fetch_caption_track(video_id: str, languages=("en", "en-US", "en-GB")):
"""Return a list of {text, start, duration} dicts, or None if captions are off."""
try:
return YouTubeTranscriptApi.get_transcript(video_id, languages=list(languages))
except (TranscriptsDisabled, NoTranscriptFound):
return None
except VideoUnavailable:
raise RuntimeError(f"Video {video_id} is private, deleted, or region-blocked")
if __name__ == "__main__":
lines = fetch_caption_track("dQw4w9WgXcQ")
if lines is None:
print("No captions published — use the audio fallback.")
else:
print(f"{len(lines)} caption lines")
print(lines[0]) # {'text': '...', 'start': 0.0, 'duration': 4.2}
Returning None rather than raising on a missing caption track is what makes the fallback in the next step readable. Auto-generated captions have no punctuation and occasional mishearings, but for summarising that barely matters — a language model reads through the noise without complaint.
Step 3 — Fall back to downloading and transcribing the audio
When fetch_caption_track returns None, pull the audio instead. yt-dlp downloads only the audio stream and hands it to FFmpeg to re-encode as a small MP3, which keeps the upload well under the transcription endpoint's file-size limit. Then send that file to the Whisper speech-to-text model and ask for verbose_json, the response format that includes segment start and end times — without those you lose the timestamps that make this whole guide worthwhile.
import os
from pathlib import Path
import yt_dlp
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv() # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def download_audio(video_id: str, out_dir: str = "audio") -> Path:
"""Download just the audio stream and re-encode it as a small MP3."""
Path(out_dir).mkdir(exist_ok=True)
options = {
"format": "bestaudio/best",
"outtmpl": f"{out_dir}/{video_id}.%(ext)s",
"quiet": True,
"postprocessors": [{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
"preferredquality": "64",
}],
}
with yt_dlp.YoutubeDL(options) as ydl:
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
return Path(out_dir) / f"{video_id}.mp3"
def transcribe_audio(path: Path):
"""Return the same {text, start, duration} shape the caption route returns."""
with open(path, "rb") as audio_file:
result = client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
response_format="verbose_json",
timestamp_granularities=["segment"],
)
return [
{"text": seg.text.strip(), "start": seg.start, "duration": seg.end - seg.start}
for seg in result.segments
]
def get_transcript(video_id: str):
"""Cheap route first, audio transcription only if it is really needed."""
lines = fetch_caption_track(video_id)
if lines is not None:
return lines
return transcribe_audio(download_audio(video_id))
The list comprehension at the end of transcribe_audio is the important line: it reshapes Whisper's segments into the exact dictionary keys the caption route produces, so get_transcript returns one consistent structure either way. For a deeper look at the transcription call itself — languages, prompts, and handling files that are too big — see Transcribe Audio with the Whisper API and Python.
A 64 kbps mono MP3 of an hour-long talk is a modest file, and transcription is billed by audio minute, so it is worth checking the current pricing page before you run this over a long playlist. If you plan to re-run the same videos while developing, Cache AI Responses in Python to Cut Costs shows how to avoid paying twice for the same audio.
Step 4 — Chunk the transcript and summarise hierarchically
A forty-minute video produces several thousand words of transcript. You could try to push all of it into one request, but you will hit the model's context window — the maximum amount of text it can consider at once — and get a context_length_exceeded error. Even when it fits, a model asked to summarise a huge blob tends to over-weight the beginning and forget the middle.
The reliable pattern is hierarchical summarisation: cut the transcript into chunks, summarise each chunk on its own, then run one more pass that summarises the summaries. Each individual request stays small and focused, and the final pass sees a compressed version of the entire video.
The trick that makes timestamps survive both passes is to write them into the text you send. Prefix every transcript line with its own [MM:SS] marker and tell the model to reuse the marker nearest to whatever it is describing. The timestamps then travel through the chunk summaries and out the other side of the merge.
def format_timestamp(seconds: float) -> str:
"""Seconds as [MM:SS] or [HH:MM:SS] for anything past an hour."""
total = int(seconds)
hours, minutes, secs = total // 3600, (total % 3600) // 60, total % 60
if hours:
return f"[{hours}:{minutes:02d}:{secs:02d}]"
return f"[{minutes}:{secs:02d}]"
def chunk_transcript(lines, max_chars: int = 6000):
"""Group timestamped lines into chunks small enough for one request."""
chunks, current, size = [], [], 0
for line in lines:
stamped = f"{format_timestamp(line['start'])} {line['text'].strip()}"
if size + len(stamped) > max_chars and current:
chunks.append("\n".join(current))
current, size = [], 0
current.append(stamped)
size += len(stamped) + 1
if current:
chunks.append("\n".join(current))
return chunks
Six thousand characters is a deliberately conservative chunk size — roughly fifteen hundred tokens, which leaves plenty of room for the instructions and the reply. If you want to reason about chunk sizes properly rather than guessing, Split Long Text into Chunks for AI APIs covers the trade-offs, and Fix the Context-Length-Exceeded Error in Python explains what happens when you get it wrong.
Now the two passes. Notice that both use the cheap gpt-4o-mini model and both put the formatting rules in the system message, where the model treats them as standing instructions rather than a suggestion buried in the data.
MAP_SYSTEM = (
"You summarise part of a video transcript. Each line begins with a timestamp "
"in square brackets. Write at most three bullets. Start every bullet with the "
"timestamp of the moment it refers to, copied exactly, then a dash and one "
"sentence. Use only what the transcript says."
)
REDUCE_SYSTEM = (
"You are given bullets from consecutive parts of one video, in order. "
"Keep the eight most important, merge duplicates, and preserve each bullet's "
"original timestamp. Return only the bullets, one per line."
)
def summarise_chunk(chunk: str) -> str:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": MAP_SYSTEM},
{"role": "user", "content": chunk},
],
temperature=0.2,
)
return response.choices[0].message.content.strip()
def summarise_transcript(lines) -> str:
chunks = chunk_transcript(lines)
partials = [summarise_chunk(chunk) for chunk in chunks]
if len(partials) == 1:
return partials[0]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": REDUCE_SYSTEM},
{"role": "user", "content": "\n".join(partials)},
],
temperature=0.2,
)
return response.choices[0].message.content.strip()
A low temperature keeps the model close to the transcript instead of embellishing. Skipping the merge pass when there is only one chunk saves a request on short videos. If the loop over chunks starts failing partway through a long video, you are probably sending requests faster than your account allows — Fix the 429 Rate-Limit Error in Python shows how to back off and retry. The same map-then-merge structure works on any long document, as Summarize Long PDFs with Python and Chunking demonstrates on paperwork rather than video.
Step 5 — Turn the bullets into clickable timestamps
Right now each bullet starts with something like [12:34]. That is readable, but a reader still has to scrub the player by hand. YouTube accepts a t parameter on a watch URL, measured in whole seconds, and opens the player at exactly that moment — so converting [12:34] into 754 and appending &t=754s turns every takeaway into a one-click jump back to the source.
The parser below accepts both [MM:SS] and [HH:MM:SS], tolerates a leading dash or asterisk from the model's bullet formatting, and silently drops any line that does not match — which is a cheap way to filter out stray preamble like "Here are the key points".
import re
BULLET = re.compile(r"^[-*•]?\s*\[(\d{1,2}):(\d{2})(?::(\d{2}))?\]\s*[-—:]?\s*(.+)$")
def to_linked_markdown(summary: str, video_id: str) -> str:
"""Rewrite [MM:SS] bullets as markdown links into the video."""
out = []
for raw in summary.splitlines():
match = BULLET.match(raw.strip())
if not match:
continue
first, second, third, text = match.groups()
if third:
seconds = int(first) * 3600 + int(second) * 60 + int(third)
else:
seconds = int(first) * 60 + int(second)
label = format_timestamp(seconds).strip("[]")
url = f"https://www.youtube.com/watch?v={video_id}&t={seconds}s"
out.append(f"- [{label}]({url}) — {text.strip()}")
return "\n".join(out)
if __name__ == "__main__":
url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
vid = video_id_from_url(url)
transcript = get_transcript(vid)
print(to_linked_markdown(summarise_transcript(transcript), vid))
Run it and you get markdown you can paste straight into a notes app, a wiki page, or the body of a newsletter. Each line reads as a claim plus a link that proves it. If you want the same transcript reshaped into prose instead of bullets, Turn a Podcast Episode into a Blog Post picks up from exactly this point, and Generate SRT Subtitles with Python and AI uses the same timestamps for captions instead.
Quick reference
| Piece | What it does | Watch out for |
|---|---|---|
YouTubeTranscriptApi.get_transcript | Fetches published captions, no key needed | Raises TranscriptsDisabled when captions are off |
yt_dlp.YoutubeDL | Downloads the audio stream only | Needs FFmpeg installed separately |
response_format="verbose_json" | Returns segments with start and end times | Plain json gives you text with no timestamps |
t=754s on a watch URL | Opens the player at that second | Must be whole seconds, not 12:34 |
Troubleshooting
youtube_transcript_api._errors.TranscriptsDisabled— the uploader disabled captions, or the video is age-restricted so the track is not served. Cause: there is nothing to fetch. Fix: catch the exception and route todownload_audioplustranscribe_audioas shown in step 3.ERROR: ffprobe and ffmpeg not found—yt-dlpdownloaded the audio but cannot convert it. Cause: FFmpeg is missing or not on your PATH. Fix: install FFmpeg with your system package manager, close and reopen the terminal, then confirm withffmpeg -version.ModuleNotFoundError: No module named 'yt_dlp'— the package is installed somewhere your interpreter is not looking. Cause: an inactive or wrong virtual environment. Fix: activate.venvand reinstall; Fix ModuleNotFoundError: No Module Named openai walks through the same diagnosis in detail.- The summary comes back with no timestamps at all — every bullet fails the
BULLETregex andto_linked_markdownreturns an empty string. Cause: the model paraphrased the markers away, usually because the chunk text lost its[MM:SS]prefixes. Fix: print one chunk before sending it and confirm the prefixes are there, then restate the timestamp rule in the system message. Write System Prompts that Control Output Format has stronger phrasings for format rules.
Summarise for yourself, not for republication
A transcript and the video it came from belong to whoever made them. A summary you write for your own notes, research, or study is ordinary fair use in most places; a scraped transcript republished as a page on your site, or a summary detailed enough that nobody needs to watch the original, is a different thing entirely and can land you a takedown.
Three habits keep you on the right side of this. Always link back to the source video, which the timestamps in step 5 do for you automatically. Keep summaries genuinely short — takeaways, not a retelling. And read the terms of service of any platform before you point a script at it in bulk, because downloading audio in particular is treated differently from reading a public caption track. If you are summarising your own uploads or clips you have permission to use, none of this bites, and running the script across your own back catalogue is the safest place to start.
When to use this vs. alternatives
- Use this script when you need the summary inside another program — a database row, a Slack message, a weekly digest — or when you are processing more than a handful of videos and clicking through a web tool would take longer than writing the loop.
- Use YouTube's own summary features or a browser extension when you just want to skim one video once. A script you have to maintain is a poor trade for a single reading session.
- Use a full transcription pipeline instead when you need the exact words rather than the gist: legal quoting, subtitle files, or search across a whole archive. In that case skip the summarising passes and keep the raw segments, and read Estimate OpenAI API Costs with Python before you start, because transcription of a large library adds up.
Once the script runs end to end, the obvious next moves are to loop it over a list of URLs from a spreadsheet, to store the transcripts locally so re-summarising costs nothing, and to swap gpt-4o-mini for gpt-4o on the final merge pass when a video really deserves a sharper summary. Back to AI Transcription and Content Repurposing with Python.
Related guides
- Transcribe Audio with the Whisper API and Python — the fallback route in step 3, covered properly.
- Turn a Podcast Episode into a Blog Post — reshape the same transcript into publishable prose.
- Generate SRT Subtitles with Python and AI — reuse the timestamps to build a caption file.
- Split Long Text into Chunks for AI APIs — choose chunk sizes with a reason rather than a guess.
- Summarize Long PDFs with Python and Chunking — the same two-pass pattern applied to documents.