Proxyrack - October 27, 2026
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:
cd youtube fails - because the zip has no youtube folder inside it. Step 3 fixes this.
uv sync fails - because uv is not installed by default. Step 4 fixes this.
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.
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.
A small program that, for one YouTube video you give it:
Gets the video's title, description, likes, views, channel and tags
Gets the top comments (20 by default - you can pick how many, and whether to include replies)
Gets the subtitles in every language the uploader provided, plus YouTube's automatic captions in the video's own language
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.
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:
Press Ctrl + Alt + T at the same time, or
Open your applications menu and search for Terminal, Console, or GNOME Terminal
Check it works: type this and press Enter:
pwdIt 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.
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 curlFedora users:
sudo dnf install -y unzip curlVerify:
unzip -v
curl --versionEach should print version information. If either says command not found, run the install command again and read the error message.
youtube)cd youtube problemThe 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
lsAfter 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 ~/youtubeErrors 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.
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 | shThis 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 --versionShould 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.venv)uv sync from inside the projectcd ~/youtube
uv syncThis one command does three things:
Downloads Python if your system doesn't have a suitable one
Creates a private folder called .venv (a "virtual environment") inside the project
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 --versionShould 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 syncIf 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 libatomic1uv run python main.pyWhat happens:
The program reads the bundled sample.json (an example already fetched for you)
It prints the video's details, subtitle languages and top comments
It saves the full result to data/dQw4w9WgXcQ.json
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].
--fetch to go onlineCopy the video's link from your browser's address bar, then:
uv run python main.py --url "https://www.youtube.com/watch?v=jNQXAC9IVRw" --fetchAlways 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.
| You want | Command |
|---|---|
| Top 20 comments (default) | uv run python main.py --url "LINK" --fetch |
| Top 50 comments | uv run python main.py --url "LINK" --fetch --comments 50 |
| Top 10 comments + their replies | uv 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.)
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.
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.
| File | What it does |
|---|---|
| main.py | The command you run (--url, --fetch, --comments, --replies) |
| api.py | Talks to YouTube through yt-dlp, picks the subtitle tracks, saves sample.json |
| youtube_parse.py | Turns sample.json into clean data (subtitle text, nested replies) |
| models/video.py | The shape of the data (video, subtitle, comment) |
| constants.py | Settings: default video, comment limits |
| fetch_sample.py | Optional: fetch only sample.json |
| sample.json | The 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.pyfrom 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 shapefrom 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(" ", " ").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 lineapi.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()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" --fetchQuestions? Double-check file names and paths - most issues are a missed step above or a typo.
Katy Salgado - October 30, 2025
Why Residential IP Intelligence Services Are Highly Inaccurate?
Katy Salgado - November 13, 2025
Why Unmetered Proxies Are Cheaper (Even With a Lower Success Rate)
Katy Salgado - November 27, 2025
TCP OS Fingerprinting: How Websites Detect Automated Requests (and How Proxies Help)
Katy Salgado - December 15, 2025
Analyzing Competitor TCP Fingerprints: Do Their Opt-In Networks Really Match Their Public Claims?