Proxyrack - October 27, 2026

How to Scrape a Youtube Video (likes count, descriptions, subtitles and top comments)

Data ScrapingTutorials

A beginner-friendly guide for Linux. No programming knowledge needed - just follow each step in order. Written for a fresh server (or a normal desktop) with nothing installed.


Read this first. This guide assumes your computer has nothing installed for this project - no unzip, no curl, no uv, no yt-dlp. Every tool gets its own install step below. Do the steps in order and do not skip any. The four most common mistakes are:

  1. cd youtube fails - because the zip has no youtube folder inside it. Step 3 fixes this.

  2. uv sync fails - because uv is not installed by default. Step 4 fixes this.

  3. Installing yt-dlp with apt or pip: don't. Step 5 installs it into the project's own private folder (.venv). Always run things with uv run.

  4. Trying to install Python: you don't need to. uv supplies Python itself. The python3.12 package does not exist on many Ubuntu/Debian versions.


What We're Building

A small program that, for one YouTube video you give it:

  1. Gets the video's title, description, likes, views, channel and tags

  2. Gets the top comments (20 by default - you can pick how many, and whether to include replies)

  3. Gets the subtitles in every language the uploader provided, plus YouTube's automatic captions in the video's own language

  4. Prints a summary and saves everything to a file

It uses a free tool called yt-dlp. No browser is needed and the video itself is never downloaded - only text and numbers. It works on a server with no screen.

You should have received one file: youtube.zip. It already contains the whole program and a saved example (sample.json) so you can try it before going online. You do not type the code by hand.


Step 1: Open a Terminal

What is a terminal?

A terminal is a window where you type commands instead of clicking buttons. Every command in this guide is typed here, then you press Enter.

If you are on a server (a droplet/VPS), you are already inside a terminal after you connect. Skip to the check below.

To open it on a Linux desktop:

  1. Press Ctrl + Alt + T at the same time, or

  2. Open your applications menu and search for Terminal, Console, or GNOME Terminal

Check it works: type this and press Enter:

pwd

It prints the folder you are currently in. If it prints a path like /home/yourname, the terminal works.

Two words you will see a lot:

  • sudo - "run this as administrator." It will ask for your login password. Nothing appears on screen while you type the password - that is normal. Type it and press Enter.

  • apt - Ubuntu/Debian's built-in installer for system tools. If you use Fedora, replace apt install with dnf install.


Step 2: Install the Basic Tools (unzip and curl)

Why do we need these?

unzip opens your youtube.zip file. curl downloads the uv installer. Neither is installed by default on a fresh Linux system, so install them now.

sudo apt update
sudo apt install -y unzip curl

Fedora users:

sudo dnf install -y unzip curl

Verify:

unzip -v
curl --version

Each should print version information. If either says command not found, run the install command again and read the error message.


Step 3: Get the Project (extract into a folder named youtube)

This step fixes the cd youtube problem

The zip was built with its files at the top level - meaning there is no youtube folder inside the zip. If you just double-click "Extract Here," the files (main.py, api.py, ...) get dumped loose into the current folder, and later cd youtube fails with No such file or directory.

The fix: create a folder named youtube first, then tell unzip to put the files inside it.

# 1. Go to where the uploaded zip lives (usually your home folder)
cd ~

# 2. Create the destination folder
mkdir -p youtube

# 3. Extract the zip's contents INTO that folder (-d means destination)
unzip -o youtube.zip -d youtube

# 4. Enter the folder
cd youtube

# 5. Confirm the files are here
ls

After step 5, ls must show files such as main.py, pyproject.toml, youtube_parse.py, sample.json, and folders models, data.

Already extracted the files loose somewhere? Move them into a proper folder:

mkdir -p ~/youtube
mv api.py constants.py fetch_sample.py main.py pyproject.toml \
   youtube_parse.py uv.lock sample.json index.html TUTORIAL.md \
   models data ~/youtube/
cd ~/youtube

Errors about files that are "not found" are fine if those files are already in place.

If unzip says cannot find or open youtube.zip: the zip is not in your home folder. Find it with find ~ -name "youtube.zip", then cd into the folder it printed before running unzip again.

From now on, every command is run while inside ~/youtube. If you ever get lost, run cd ~/youtube to return. Confirm with pwd - it should end in /youtube.


Step 4: Install uv (Package Manager)

This step fixes the uv sync problem (and means you never install Python by hand)

uv is a fast tool that installs the Python libraries this project needs - including yt-dlp. It also provides Python itself, so you do not install Python - and you should not try to.

curl -LsSf https://astral.sh/uv/install.sh | sh

This uses curl (installed in Step 2) to download the installer and run it. It puts uv in the folder ~/.local/bin.

Now close and reopen your terminal so it can find the new program. (Or run source $HOME/.local/bin/env.) Then go back to the project: cd ~/youtube.

Verify:

uv --version

Should show something like uv 0.x.x.

If uv: command not found still appears, add the folder to your PATH, then reload it:

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
uv --version

Step 5: Install yt-dlp (into the project's own .venv)

Run uv sync from inside the project

cd ~/youtube
uv sync

This one command does three things:

  1. Downloads Python if your system doesn't have a suitable one

  2. Creates a private folder called .venv (a "virtual environment") inside the project

  3. Installs yt-dlp and pydantic into it, plus developer tools (pytest, ruff, pyright)

Everything stays inside ~/youtube/.venv - nothing is installed system-wide. To use it, always start commands with uv run.

Verify yt-dlp is installed:

uv run yt-dlp --version

Should print a date-like version such as 2026.08.19. Typing just yt-dlp (without uv run) says command not found - that is normal and fine.

Do not run sudo apt install yt-dlp or pip install yt-dlp. The apt version is often months out of date and stops working with YouTube. The project's copy in .venv is the one that is used.

Keeping yt-dlp up to date. YouTube changes often. If fetching suddenly stops working, update yt-dlp first:

uv lock --upgrade-package yt-dlp
uv sync

If uv sync fails with uv: command not found: go back to Step 4. If it fails with network/download errors, check your internet connection and run uv sync again.

Linux only - pyright dependency: If uv run pyright fails with libatomic.so.1: cannot open shared object file:

sudo apt install -y libatomic1

Step 6: Run It (offline first)

First run - read the saved example (no internet used)

uv run python main.py

What happens:

  1. The program reads the bundled sample.json (an example already fetched for you)

  2. It prints the video's details, subtitle languages and top comments

  3. It saves the full result to data/dQw4w9WgXcQ.json

Expected Output

Using cached /home/<you>/youtube/sample.json (55119 bytes)

============================================================
VIDEO: Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)
============================================================
  url        : https://www.youtube.com/watch?v=dQw4w9WgXcQ
  video id   : dQw4w9WgXcQ
  channel    : Rick Astley  (4,550,000 subs)
  likes      : 19,490,351   views: 1,828,540,610
  uploaded   : 20091025   duration: 213s
  desc       : The official video for “Never Gonna Give You Up” by Rick Astley. ...
  tags       : rick astley, Never Gonna Give You Up, nggyu, ...

  SUBTITLES (6 tracks)
    en       manual   89 lines  English
    de-DE    manual   75 lines  German (Germany)
    ja       manual   83 lines  Japanese
    pt-BR    manual   69 lines  Portuguese (Brazil)
    es-419   manual   69 lines  Spanish (Latin America)
    en       auto     52 lines  English (Original)

  TOP COMMENTS (20)
     1. @grustache4569  (105,000 likes)
        I Rick rolled my entire French class because we had to study a city ...
     2. @candycake9531  (292,000 likes)
        Is everyone going to ignore the fact that Rick Astley looks like ...
  ...

============================================================
Saved full result to /home/<you>/youtube/data/dQw4w9WgXcQ.json
Done.

What do "manual" and "auto" mean? manual subtitles were uploaded by the video's owner (one per language). auto is YouTube's automatic speech-to-text, in the language spoken in the video. YouTube's machine translations into ~150 other languages are skipped on purpose.

The comments are in YouTube's "Top comments" order - the same order you see on the website - not sorted by number of likes. A pinned comment is marked [pinned].


Step 7: Scrape a Live Video

Add --fetch to go online

Copy the video's link from your browser's address bar, then:

uv run python main.py --url "https://www.youtube.com/watch?v=jNQXAC9IVRw" --fetch

Always put the link in quotes. Short links (youtu.be/...), Shorts (youtube.com/shorts/...) and live links work too. It takes about 5 seconds and prints its progress:

Fetching metadata and top comments for https://www.youtube.com/watch?v=jNQXAC9IVRw ...
  got 20 comments
  subtitle en (manual): 440 bytes
  subtitle de (manual): 338 bytes
Backed up previous sample.json to sample.json.bak
Saved 16272 bytes to /home/<you>/youtube/sample.json

...followed by the same summary as Step 6.

Choosing comments

You wantCommand
Top 20 comments (default)uv run python main.py --url "LINK" --fetch
Top 50 commentsuv run python main.py --url "LINK" --fetch --comments 50
Top 10 comments + their repliesuv run python main.py --url "LINK" --fetch --comments 10 --replies
No comments (fastest)uv run python main.py --url "LINK" --fetch --comments 0

With --replies, up to 5 replies are kept under each comment (change MAX_REPLIES_PER_THREAD in constants.py). Replies make the fetch much slower.

If you use --url "something" without --fetch, the program stops and tells you there is no downloaded copy of that video. That is on purpose - it will not show you the wrong video's data. Just add --fetch.

Where is my data? Every run saves the complete result - full description, every subtitle (as text and as a .vtt subtitle file's contents), and all comments - to data/<video id>.json. Open it with any text editor, or view it with:

ls data
less data/jNQXAC9IVRw.json

(Press q to exit less.)


Troubleshooting

Common Problems and Fixes

cd youtube - "No such file or directory"

The zip has no youtube folder inside it. Redo Step 3: mkdir -p youtube, then unzip -o youtube.zip -d youtube, then cd youtube.

"Command not found: uv" or uv sync fails

You skipped Step 4. Install uv with the curl command, close and reopen the terminal, and check uv --version.

"Command not found: yt-dlp"

Normal. yt-dlp lives inside the project's .venv. Use uv run yt-dlp --version, and run the scraper with uv run python main.py.

"No module named 'yt_dlp'"

You ran python main.py without uv run in front. Use uv run python main.py. If it still happens, run uv sync again (Step 5).

"Command not found: python3.12"

You do not need system Python. uv supplies Python itself. Do not run apt install python3.12.

"Command not found: curl" / "unzip"

Install them: sudo apt install -y curl unzip (Step 2).

"Not a YouTube video URL"

The link must point to one video (it contains an 11-character id, like watch?v=dQw4w9WgXcQ or youtu.be/dQw4w9WgXcQ). Channel and playlist links do not work. Remember the quotes around the link.

"Sign in to confirm you're not a bot"

YouTube is blocking your server's internet address (common on cloud servers). Wait a while and try again, or run the scraper from a home computer. This scraper does not log in to YouTube.

"YouTube returned no data ... unavailable, private or removed"

The link is wrong, or the video was deleted or made private. Open the link in a browser to check. Your sample.json is not changed.

"Video unavailable" / "Private video" / age-restricted

These videos need a signed-in account and cannot be scraped. Try another video.

"HTTP Error 429: Too Many Requests"

YouTube is asking you to slow down. Wait several minutes, then use fewer comments and no --replies.

A line says subtitle xx (manual) failed

That one language could not be downloaded (usually the same "too many requests" limit). The other languages are still saved. Try again later.

SUBTITLES (0 tracks)

The video has no subtitles and no automatic captions (common for music-only or very new videos).

Fewer comments than I asked for

The video has fewer comments than that, or comments are turned off.

It worked yesterday, now it fails

YouTube changed something. Update yt-dlp: uv lock --upgrade-package yt-dlp, then uv sync.

I want the previous sample back

cp sample.json.bak sample.json

"No module named 'models'"

Make sure you ran unzip -o youtube.zip -d youtube fully, so the models/ folder exists, and that you are inside ~/youtube. Confirm with ls.

pytest says "no tests ran"

Expected - the zip ships no tests/ folder. Not a failure.


Reference: What's Inside the Project

Everything below already ships inside youtube.zip - you do not need to create, copy, or paste any of it. This section is here only so you know what each file does and can see the code if you are curious.

FileWhat it does
main.pyThe command you run (--url, --fetch, --comments, --replies)
api.pyTalks to YouTube through yt-dlp, picks the subtitle tracks, saves sample.json
youtube_parse.pyTurns sample.json into clean data (subtitle text, nested replies)
models/video.pyThe shape of the data (video, subtitle, comment)
constants.pySettings: default video, comment limits
fetch_sample.pyOptional: fetch only sample.json
sample.jsonThe saved example video used in Step 6

pyproject.toml

[project]
name = "youtube-scraper"
version = "0.1.0"
description = "YouTube video scraper (likes, description, subtitles, top comments) using yt-dlp"
requires-python = ">=3.12"
dependencies = [
    "pydantic>=2.14.1",
    "yt-dlp>=2026.8.19",
]

[dependency-groups]
dev = [
    "pyright>=1.1.414",
    "pytest>=9.1.1",
    "ruff>=0.17.1",
]

[tool.ruff]
target-version = "py312"
line-length = 88

[tool.ruff.lint]
fixable = ["ALL"]
select = ["I", "B", "E"]

[tool.pyright]
typeCheckingMode = "strict"
venvPath = "."
venv = ".venv"
pythonVersion = "3.12"

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

constants.py

from pathlib import Path

# Paths
HOME = Path.home()
BASE_DIR = Path(__file__).parent
DATA_DIR = BASE_DIR / "data"

# YouTube
DEFAULT_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
SAMPLE_JSON_PATH = BASE_DIR / "sample.json"

# Kept so a failed live fetch never destroys a good sample.json.
SAMPLE_JSON_BACKUP_PATH = BASE_DIR / "sample.json.bak"

# Comments
# Top-level comments to keep (sorted by "Top comments", like the YouTube UI).
DEFAULT_MAX_COMMENTS = 20
# With --replies, how many replies to keep under each top-level comment.
MAX_REPLIES_PER_THREAD = 5

# Subtitles
# Every uploader-provided track is kept, plus the auto-generated track in the
# video's own spoken language. YouTube's ~150 machine translations are skipped.
SUBTITLE_FORMAT = "vtt"

# yt-dlp prints these when no JavaScript runtime (Deno) is installed. They only
# affect downloadable video formats, which this scraper never uses.
IGNORED_WARNINGS = (
    "[jsc]",
    "challenge solving failed",
    "Signature solving failed",
    "formats may be missing",
    "EJS",
)

To change how many replies are kept per comment with --replies, edit the MAX_REPLIES_PER_THREAD line. To change the default number of comments, edit DEFAULT_MAX_COMMENTS (or just use --comments N).

models/video.py - the data shape

from pydantic import BaseModel, Field


class YouTubeComment(BaseModel):
    """One comment (or reply) under a video.

    ``parent_id`` is ``None`` for top-level comments and holds the id of the
    comment being replied to otherwise. ``timestamp`` is approximate: YouTube
    only shows relative dates ("5 years ago"), which yt-dlp converts.
    """

    comment_id: str
    parent_id: str | None = None
    author: str | None = None
    author_id: str | None = None
    author_is_uploader: bool = False
    text: str | None = None
    likes: int | None = None
    is_pinned: bool = False
    is_favorited: bool = False
    timestamp: int | None = None
    replies: list["YouTubeComment"] = Field(default_factory=list["YouTubeComment"])


class YouTubeSubtitle(BaseModel):
    """One subtitle track: ``kind`` is ``"manual"`` or ``"auto"``."""

    language: str
    name: str | None = None
    kind: str
    vtt: str
    text: str


class YouTubeVideoData(BaseModel):
    video_id: str
    video_url: str
    title: str | None = None
    description: str | None = None
    tags: list[str] = Field(default_factory=list)
    categories: list[str] = Field(default_factory=list)

    channel: str | None = None
    channel_id: str | None = None
    channel_url: str | None = None
    channel_followers: int | None = None
    channel_verified: bool | None = None

    likes: int | None = None
    views: int | None = None

    duration: int | None = None
    upload_date: str | None = None
    language: str | None = None
    thumbnail_url: str | None = None

    subtitles: list[YouTubeSubtitle] = Field(default_factory=list[YouTubeSubtitle])
    comments: list[YouTubeComment] = Field(default_factory=list[YouTubeComment])

youtube_parse.py - how the saved data is read

"""
Parse a saved yt-dlp bundle into structured YouTube video data.

The bundle (``sample.json``) is what ``api.py`` saves after a live fetch:
- ``info``: yt-dlp's metadata for the video, with the expiring/huge keys
  (formats, thumbnails, caption URL lists) removed.
- ``subtitles``: the downloaded subtitle tracks as raw WebVTT text.

All parsing works offline against that file.

Run directly to validate against the local sample.json:
  uv run python youtube_parse.py
"""

import json
import re
from typing import Any

from models.video import YouTubeComment, YouTubeSubtitle, YouTubeVideoData

_VTT_TIMING_RE = re.compile(r"^\d{2}:\d{2}[:.\d]* --> ")
_VTT_TAG_RE = re.compile(r"<[^>]+>")
_VTT_SKIP_PREFIXES = ("WEBVTT", "Kind:", "Language:", "NOTE", "STYLE", "REGION")


def vtt_to_text(vtt: str) -> str:
    """Strip WebVTT timings/tags and drop the repeated lines auto captions roll."""
    lines: list[str] = []
    for raw in vtt.splitlines():
        line = raw.strip()
        if not line or line.isdigit() or _VTT_TIMING_RE.match(line):
            continue
        if line.startswith(_VTT_SKIP_PREFIXES):
            continue
        line = _VTT_TAG_RE.sub("", line).replace("&nbsp;", " ").strip()
        if line and (not lines or lines[-1] != line):
            lines.append(line)
    return "\n".join(lines)


def _int(value: Any) -> int | None:
    return value if isinstance(value, int) else None


def _str(value: Any) -> str | None:
    return value if isinstance(value, str) and value else None


def _str_list(value: Any) -> list[str]:
    if not isinstance(value, list):
        return []
    return [item for item in value if isinstance(item, str)]  # type: ignore[misc]


def parse_comment(raw: dict[str, Any]) -> YouTubeComment:
    parent = _str(raw.get("parent"))
    return YouTubeComment(
        comment_id=str(raw.get("id")),
        parent_id=None if parent == "root" else parent,
        author=_str(raw.get("author")),
        author_id=_str(raw.get("author_id")),
        author_is_uploader=bool(raw.get("author_is_uploader")),
        text=_str(raw.get("text")),
        likes=_int(raw.get("like_count")),
        is_pinned=bool(raw.get("is_pinned")),
        is_favorited=bool(raw.get("is_favorited")),
        timestamp=_int(raw.get("timestamp")),
    )


def build_comment_tree(raw_comments: list[dict[str, Any]]) -> list[YouTubeComment]:
    """Nest replies under their parent, keeping YouTube's "Top" order."""
    by_id: dict[str, YouTubeComment] = {}
    roots: list[YouTubeComment] = []
    for raw in raw_comments:
        comment = parse_comment(raw)
        by_id[comment.comment_id] = comment
        parent = by_id.get(comment.parent_id) if comment.parent_id else None
        if parent is not None:
            parent.replies.append(comment)
        else:
            roots.append(comment)
    return roots


def parse_subtitle(raw: dict[str, Any]) -> YouTubeSubtitle:
    vtt = str(raw.get("vtt") or "")
    return YouTubeSubtitle(
        language=str(raw.get("language")),
        name=_str(raw.get("name")),
        kind=str(raw.get("kind")),
        vtt=vtt,
        text=vtt_to_text(vtt),
    )


def parse_bundle(bundle: dict[str, Any]) -> YouTubeVideoData:
    """Turn a saved yt-dlp bundle into a YouTubeVideoData."""
    info: dict[str, Any] = bundle.get("info") or {}
    raw_subtitles: list[dict[str, Any]] = bundle.get("subtitles") or []
    video_id = str(info.get("id"))
    return YouTubeVideoData(
        video_id=video_id,
        video_url=_str(info.get("webpage_url"))
        or f"https://www.youtube.com/watch?v={video_id}",
        title=_str(info.get("title")),
        description=_str(info.get("description")),
        tags=_str_list(info.get("tags")),
        categories=_str_list(info.get("categories")),
        channel=_str(info.get("channel")),
        channel_id=_str(info.get("channel_id")),
        channel_url=_str(info.get("channel_url")),
        channel_followers=_int(info.get("channel_follower_count")),
        channel_verified=info.get("channel_is_verified"),
        likes=_int(info.get("like_count")),
        views=_int(info.get("view_count")),
        duration=_int(info.get("duration")),
        upload_date=_str(info.get("upload_date")),
        language=_str(info.get("language")),
        thumbnail_url=_str(info.get("thumbnail")),
        subtitles=[parse_subtitle(s) for s in raw_subtitles],
        comments=build_comment_tree(info.get("comments") or []),
    )


if __name__ == "__main__":
    from constants import SAMPLE_JSON_PATH

    if not SAMPLE_JSON_PATH.exists():
        print("sample.json not found. Run fetch_sample.py first.")
        raise SystemExit(1)

    data = parse_bundle(json.loads(SAMPLE_JSON_PATH.read_text(encoding="utf-8")))

    print("=== Video ===")
    print(f"  title     : {data.title}")
    print(f"  channel   : {data.channel}")
    print(f"  likes     : {data.likes}   views: {data.views}")
    print(f"  subtitles : {[s.language for s in data.subtitles]}")
    print(f"  comments  : {len(data.comments)} top-level")
    for i, comment in enumerate(data.comments, 1):
        print(f"  {i:>2}. {comment.author}  ({comment.likes} likes)")
        print(f"      {(comment.text or '')[:80]}")
    print()
    print(json.dumps(data.model_dump(mode="json"), ensure_ascii=False)[:500] + " ...")

api.py and main.py - fetching and the command line

api.py calls yt-dlp (or reads sample.json) and hands the result to the parser. main.py is the command-line tool. Full source:

"""
YouTube video scraper client.

Live fetches go through yt-dlp (no browser): one call returns the video's
metadata and its top comments, then each selected subtitle track is downloaded
as WebVTT. Nothing is downloaded from the video itself.

The raw result is saved as a "bundle" (``sample.json``) and all parse work runs
against that local file, so re-parsing never touches YouTube.
"""

import json
import re
from pathlib import Path
from typing import Any

import yt_dlp  # type: ignore[import-untyped]

from constants import (
    IGNORED_WARNINGS,
    MAX_REPLIES_PER_THREAD,
    SAMPLE_JSON_BACKUP_PATH,
    SUBTITLE_FORMAT,
)
from models.video import YouTubeVideoData
from youtube_parse import parse_bundle

_VIDEO_ID_RE = re.compile(r"(?:v=|youtu\.be/|shorts/|embed/|live/)([\w-]{11})")

# Keys dropped from yt-dlp's info before saving: they are huge, expire within
# hours (signed URLs), or are replaced by the downloaded "subtitles" list.
_DROPPED_INFO_KEYS = (
    "formats",
    "requested_formats",
    "requested_subtitles",
    "automatic_captions",
    "subtitles",
    "thumbnails",
    "heatmap",
    "_format_sort_fields",
)


class VideoUnavailableError(RuntimeError):
    """Raised when YouTube returns no metadata (removed, private, bad id)."""


def video_id_from_url(url: str) -> str | None:
    """Pull the 11-character video id out of any common YouTube URL form."""
    match = _VIDEO_ID_RE.search(url)
    if match:
        return match.group(1)
    if re.fullmatch(r"[\w-]{11}", url):
        return url
    return None


class _QuietLogger:
    """Pass yt-dlp warnings/errors through, minus the harmless no-Deno noise."""

    def debug(self, msg: str) -> None:
        pass

    def info(self, msg: str) -> None:
        pass

    def warning(self, msg: str) -> None:
        if not any(part in msg for part in IGNORED_WARNINGS):
            print(f"  yt-dlp warning: {msg}")

    def error(self, msg: str) -> None:
        print(f"  yt-dlp error: {msg}")


class YouTubeClient:
    def __init__(self, max_comments: int = 20, replies: bool = False) -> None:
        self.max_comments = max_comments
        self.replies = replies

    def _comment_limits(self) -> list[str]:
        """yt-dlp's max_comments: max-comments,max-parents,max-replies,per-thread."""
        if self.replies:
            return ["all", str(self.max_comments), "all", str(MAX_REPLIES_PER_THREAD)]
        return [str(self.max_comments), "all", "0"]

    def _ydl_options(self) -> Any:
        return {
            "skip_download": True,
            "ignore_no_formats_error": True,
            "getcomments": self.max_comments > 0,
            "extractor_args": {
                "youtube": {
                    "comment_sort": ["top"],
                    "max_comments": self._comment_limits(),
                }
            },
            "logger": _QuietLogger(),
        }

    @staticmethod
    def _pick_track(formats: list[dict[str, Any]]) -> dict[str, Any] | None:
        for fmt in formats:
            if fmt.get("ext") == SUBTITLE_FORMAT:
                return fmt
        return None

    @staticmethod
    def _selected_tracks(info: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]:
        """All manual tracks + the auto track in the video's spoken language."""
        tracks: list[tuple[str, str, dict[str, Any]]] = []
        manual: dict[str, list[dict[str, Any]]] = info.get("subtitles") or {}
        for lang, formats in manual.items():
            if lang == "live_chat":
                continue
            fmt = YouTubeClient._pick_track(formats)
            if fmt:
                tracks.append((lang, "manual", fmt))

        auto: dict[str, Any] = info.get("automatic_captions") or {}
        lang = info.get("language")
        candidates = [f"{lang}-orig", lang] if lang else []
        candidates += [key for key in auto if key.endswith("-orig")]
        for key in candidates:
            fmt = YouTubeClient._pick_track(auto.get(key) or [])
            if fmt:
                tracks.append((key.removesuffix("-orig"), "auto", fmt))
                break
        return tracks

    def fetch_bundle(self, url: str) -> dict[str, Any]:
        """Fetch metadata + comments + subtitles for one video from YouTube."""
        with yt_dlp.YoutubeDL(self._ydl_options()) as ydl:
            print(f"Fetching metadata and top comments for {url} ...")
            raw = ydl.extract_info(url, download=False)
            info: dict[str, Any] = ydl.sanitize_info(raw)  # type: ignore[assignment]
            # ignore_no_formats_error also hides "video unavailable"; every real
            # video has a channel id, so its absence means YouTube sent nothing.
            if not info.get("channel_id"):
                raise VideoUnavailableError(
                    f"YouTube returned no data for {url} - the video is "
                    "unavailable, private or removed. sample.json was not changed."
                )
            print(f"  got {len(info.get('comments') or [])} comments")

            subtitles: list[dict[str, Any]] = []
            for lang, kind, fmt in self._selected_tracks(info):
                try:
                    vtt = ydl.urlopen(fmt["url"]).read().decode("utf-8", "replace")
                except Exception as exc:  # noqa: BLE001 - one bad track is not fatal
                    print(f"  subtitle {lang} ({kind}) failed: {exc}")
                    continue
                print(f"  subtitle {lang} ({kind}): {len(vtt)} bytes")
                subtitles.append(
                    {
                        "language": lang,
                        "name": fmt.get("name"),
                        "kind": kind,
                        "vtt": vtt,
                    }
                )

        for key in _DROPPED_INFO_KEYS:
            info.pop(key, None)
        return {"info": info, "subtitles": subtitles}

    @staticmethod
    def _save_sample(json_path: Path, bundle: dict[str, Any]) -> None:
        if json_path.exists():
            json_path.replace(SAMPLE_JSON_BACKUP_PATH)
            print(
                f"Backed up previous {json_path.name} to {SAMPLE_JSON_BACKUP_PATH.name}"
            )
        text = json.dumps(bundle, ensure_ascii=False, indent=1)
        json_path.write_text(text, encoding="utf-8")
        print(f"Saved {len(text)} bytes to {json_path}")

    def scrape_video(
        self,
        url: str,
        *,
        json_path: Path | None = None,
        force: bool = False,
    ) -> YouTubeVideoData:
        """
        Return parsed data for one video.

        If json_path exists and force is False, parse the local file (no
        network). Otherwise fetch via yt-dlp, save to json_path, then parse.
        """
        if json_path and json_path.exists() and not force:
            bundle = json.loads(json_path.read_text(encoding="utf-8"))
            return parse_bundle(bundle)

        bundle = self.fetch_bundle(url)
        if json_path:
            self._save_sample(json_path, bundle)
        return parse_bundle(bundle)
"""
YouTube video scraper: likes, description, subtitles and top comments.

Default: parse sample.json (local, no network). Live fetches use yt-dlp.

Usage:
  uv run python main.py                               # offline, parse sample.json
  uv run python main.py --fetch                       # live fetch default video
  uv run python main.py --url "https://youtu.be/ID" --fetch
  uv run python main.py --fetch --comments 50         # top 50 comments
  uv run python main.py --fetch --replies             # include replies

Every run also writes the parsed result to data/<video_id>.json.
"""

import argparse
import json
import sys
from typing import Any

from yt_dlp.utils import DownloadError  # type: ignore[import-untyped]

from api import VideoUnavailableError, YouTubeClient, video_id_from_url
from constants import DATA_DIR, DEFAULT_MAX_COMMENTS, DEFAULT_URL, SAMPLE_JSON_PATH
from models.video import YouTubeComment


def print_separator(char: str = "=", length: int = 60) -> None:
    print(char * length)


def _count(value: int | None) -> str:
    return f"{value:,}" if value is not None else "-"


def _one_line(text: str | None, width: int) -> str:
    return " ".join((text or "-").split())[:width]


def _cached_video_id() -> str | None:
    """Video id that produced the bundled/cached sample.json."""
    if not SAMPLE_JSON_PATH.exists():
        return None
    try:
        bundle: dict[str, Any] = json.loads(
            SAMPLE_JSON_PATH.read_text(encoding="utf-8")
        )
    except (OSError, ValueError):
        return None
    info: dict[str, Any] = bundle.get("info") or {}
    video_id = info.get("id")
    return video_id if isinstance(video_id, str) else None


def _print_comment(comment: YouTubeComment, label: str, indent: str) -> None:
    flags = " [pinned]" if comment.is_pinned else ""
    flags += " [uploader]" if comment.author_is_uploader else ""
    print(f"{indent}{label} {comment.author}{flags}  ({_count(comment.likes)} likes)")
    print(f"{indent}    {_one_line(comment.text, 90)}")
    for reply in comment.replies:
        _print_comment(reply, "↳", indent + "    ")


def main() -> None:
    parser = argparse.ArgumentParser(description="YouTube video scraper")
    parser.add_argument(
        "--url",
        default=DEFAULT_URL,
        help=f"video URL (default: {DEFAULT_URL}); needs --fetch if it differs "
        "from the cached sample",
    )
    parser.add_argument(
        "--fetch",
        action="store_true",
        help="Force a live yt-dlp fetch even if sample.json exists",
    )
    parser.add_argument(
        "--comments",
        type=int,
        default=DEFAULT_MAX_COMMENTS,
        metavar="N",
        help=f"top-level comments to fetch (default: {DEFAULT_MAX_COMMENTS}; "
        "0 skips comments)",
    )
    parser.add_argument(
        "--replies",
        action="store_true",
        help="also fetch replies under each top comment",
    )
    args = parser.parse_args()

    video_id = video_id_from_url(args.url)
    if video_id is None:
        print(f"Not a YouTube video URL: {args.url!r}", file=sys.stderr)
        raise SystemExit(2)

    json_path = SAMPLE_JSON_PATH
    cached_id = _cached_video_id()

    if not args.fetch and video_id != cached_id:
        print(
            f"No downloaded cache for video {video_id!r} "
            f"(the cached sample is for {cached_id!r}).\n"
            "Add --fetch to download it:\n"
            f"    uv run python main.py --url {args.url!r} --fetch",
            file=sys.stderr,
        )
        raise SystemExit(2)

    if json_path.exists() and not args.fetch:
        print(f"Using cached {json_path} ({json_path.stat().st_size} bytes)")

    try:
        client = YouTubeClient(max_comments=args.comments, replies=args.replies)
        data = client.scrape_video(args.url, json_path=json_path, force=args.fetch)
    except (DownloadError, VideoUnavailableError) as exc:
        print(f"\nyt-dlp could not fetch the video:\n{exc}", file=sys.stderr)
        raise SystemExit(1) from exc

    DATA_DIR.mkdir(exist_ok=True)
    out_path = DATA_DIR / f"{data.video_id}.json"
    out_path.write_text(
        json.dumps(data.model_dump(mode="json"), ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

    print()
    print_separator()
    print(f"VIDEO: {data.title}")
    print_separator()
    print(f"  url        : {data.video_url}")
    print(f"  video id   : {data.video_id}")
    print(f"  channel    : {data.channel}  ({_count(data.channel_followers)} subs)")
    print(f"  likes      : {_count(data.likes)}   views: {_count(data.views)}")
    print(f"  uploaded   : {data.upload_date or '-'}   duration: {data.duration}s")
    print(f"  desc       : {_one_line(data.description, 90)}")
    if data.tags:
        print(f"  tags       : {', '.join(data.tags[:10])}")

    print(f"\n  SUBTITLES ({len(data.subtitles)} tracks)")
    for sub in data.subtitles:
        lines = sub.text.count("\n") + 1 if sub.text else 0
        print(f"    {sub.language:<8} {sub.kind:<6} {lines:>4} lines  {sub.name or ''}")

    print(f"\n  TOP COMMENTS ({len(data.comments)})")
    for i, comment in enumerate(data.comments, 1):
        _print_comment(comment, f"{i:>2}.", "    ")

    print()
    print_separator()
    print(f"Saved full result to {out_path}")
    print("Done.")


if __name__ == "__main__":
    main()

Final File Checklist

Your ~/youtube folder should contain exactly this:


youtube/
├── index.html
├── TUTORIAL.md
├── pyproject.toml
├── uv.lock
├── constants.py
├── api.py
├── youtube_parse.py
├── fetch_sample.py
├── main.py
├── sample.json
├── models/
│ ├── init.py
│ └── video.py
└── data/
└── .gitkeep

Some extra files appear only after you run things - they are safe to ignore or delete:


.venv/ # the private Python + yt-dlp made by uv sync
sample.json.bak # previous sample, kept when a new fetch succeeds
data/<video id>.json # the full result of each run

You're done! If every file is in place and you ran uv sync, the scraper works. Run it anytime with:

cd ~/youtube
uv run python main.py --url "PASTE-VIDEO-LINK-HERE" --fetch

Questions? Double-check file names and paths - most issues are a missed step above or a typo.

Get Started by signing up for a Proxy Product