ytscrape — Free YouTube Scraper for Python
Scrape YouTube search results, video metadata and comments — no API key, no quota, no browser.
ytscrape is a free YouTube scraper for Python built on top of the
internal YouTube InnerTube API — the same endpoints the YouTube web app uses.
Use it to search YouTube, extract video, channel and playlist data,
fetch video metadata and collect every comment (and reply) of a video —
all with transparent pagination, typed models and no API key.
from ytscrape import YouTube
with YouTube() as yt:
for video in yt.search("python", max_results=5):
print(video.title, video.url)
⚠️ This library talks to YouTube's private endpoints. Use it responsibly and at your own risk; the endpoints and
paramsvalues may change over time.
Table of Contents
- Why ytscrape?
- Installation
- Quick start
- Feature coverage
- ytscrape vs. the alternatives
- How it works
- Search
- Video details
- Channel details
- Comments
- Language & country
- Pagination
- Data models
- Error handling
- Advanced: proxies, retries, custom sessions
- Command line
- Examples
- FAQ
- Roadmap
- Contributing
- License
Why ytscrape?
- 🔑 No YouTube Data API key, no quota, no sign-up, no billing project.
- 🧊 No browser — no Selenium, no Playwright, no headless Chrome. Pure HTTP.
- 🧩 Typed models (
Video,Channel,Playlist,VideoDetails,ChannelDetails,Comment) instead of raw, deeply-nested JSON. - 📄 Transparent pagination — just iterate; continuation tokens are handled for you.
- 💬 Full comment scraping, including replies and the sort order that actually returns every comment.
- 🌍 Localisation — interface language (
hl) and content region (gl), validated withpycountry. - 🪶 Tiny install — only
requestsandpycountry. - 🖥️ CLI included:
ytscrape search "python" --max 10. - 🧱 Clean, extensible OOP design (facade + factory methods + strategy) — easy to build on or to mock in tests.
Installation
pip install ytscrape
or with uv:
uv add ytscrape
From source:
pip install .
Requires Python 3.10+. Runtime dependencies: requests, pycountry.
Quick start
Everything you usually need, in one snippet:
from ytscrape import YouTube, SearchFilter, CommentSort
with YouTube(language="en", region="US") as yt:
# 1. Search videos and iterate over as many pages as needed.
for video in yt.search("python", filter=SearchFilter.VIDEOS, max_results=20):
print(video.title, "-", video.url)
# 2. Search channels.
for channel in yt.search("python", filter=SearchFilter.CHANNELS, max_results=5):
print(channel.title, channel.url)
# 3. Fetch details for a single video (id or URL both work).
details = yt.video("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
print(details.title, details.channel, details.views, details.length_seconds)
# 4. Collect comments — including replies and *every* comment.
for comment in yt.comments(
"https://youtu.be/dQw4w9WgXcQ",
include_replies=True,
sort=CommentSort.NEWEST,
max_results=100,
):
marker = " ↳" if comment.is_reply else "-"
print(f"{marker} {comment.author}: {comment.text}")
📂 More runnable snippets live in
examples/.
Feature coverage
| Area | Status | Notes |
|---|---|---|
| Search — videos | ✅ | SearchFilter.VIDEOS |
| Search — channels | ✅ | SearchFilter.CHANNELS |
| Search — playlists | ✅ | SearchFilter.PLAYLISTS |
| Search — Shorts / movies | ✅ | SearchFilter.SHORTS, SearchFilter.MOVIES |
| Video metadata | ✅ | YouTube.video() (id or any URL) |
| Channel metadata | ✅ | YouTube.channel() (id, @handle or URL) |
| Comments | ✅ | YouTube.comments(), full pagination |
| Comment replies | ✅ | include_replies=True |
| Comment sort (top / newest) | ✅ | sort=CommentSort.NEWEST returns every comment |
| Continuation / pagination | ✅ | Transparent for search and comments |
| Transcripts / subtitles | ✅ | YouTube.transcript() / transcripts() |
Localisation (hl / gl) |
✅ | Validated ISO codes |
Typed models + py.typed |
✅ | PEP 561 compliant |
| CLI | ✅ | python -m ytscrape / ytscrape |
| Channel videos / Shorts / live tabs | 🚧 | Planned — see Roadmap |
| Playlist items | 🚧 | Planned |
| Related videos, trending, home feed | 🚧 | Planned |
| Community posts | 🚧 | Planned |
Async (asyncio) API |
🚧 | Planned |
ytscrape vs. the alternatives
| ytscrape | YouTube Data API | yt-dlp |
Browser automation | |
|---|---|---|---|---|
| API key required | ❌ | ✅ | ❌ | ❌ |
| Daily quota | ❌ | ✅ | ❌ | ❌ |
| Browser / driver needed | ❌ | ❌ | ❌ | ✅ |
| Search | ✅ | ✅ | ✅ | ✅ |
| Video metadata | ✅ | ✅ | ✅ | ✅ |
| Comments + replies | ✅ | ✅ (quota) | ✅ | ✅ |
| Typed Python models | ✅ | ❌ | ❌ | ❌ |
| Downloads media | ❌ | ❌ | ✅ | ✅ |
| Install size | tiny | medium | large | huge |
Rule of thumb: use yt-dlp when you need to download media, the official
Data API when you need guaranteed, ToS-blessed access, and ytscrape when you
need fast, key-less access to YouTube metadata and comments from Python.
How it works
ytscrape speaks the private YouTube InnerTube API directly:
- It loads
youtube.comonce and extracts the InnerTube context (API key, client version, visitor data). - It POSTs to
youtubei/v1/search,youtubei/v1/player,youtubei/v1/browseandyoutubei/v1/nextwith that context. - Responses are parsed into small, frozen dataclasses; continuation tokens are followed automatically while you iterate.
No browser. No Selenium. No Playwright. Pure HTTP.
Search
from ytscrape import YouTube, SearchFilter
with YouTube() as yt:
for item in yt.search("lofi", filter=SearchFilter.PLAYLISTS, max_results=10):
print(item.title, item.url)
| Filter | Description |
|---|---|
SearchFilter.ALL |
Everything (default) |
SearchFilter.VIDEOS |
Videos only |
SearchFilter.CHANNELS |
Channels only |
SearchFilter.PLAYLISTS |
Playlists only |
SearchFilter.SHORTS |
Shorts only |
SearchFilter.MOVIES |
Movies only |
You can also pass the string form: yt.search("python", filter="videos") — no
magic EgIQAQ== strings in your code.
Video details
with YouTube() as yt:
video = yt.video("dQw4w9WgXcQ") # id
video = yt.video("https://youtu.be/dQw4w9WgXcQ") # or any URL
print(video.title, video.views, video.length_seconds, video.is_live)
print(video.keywords)
watch?v=, youtu.be/, /shorts/ and /embed/ URLs are all accepted.
Channel details
with YouTube() as yt:
channel = yt.channel("UCuAXFkgsw1L7xaCfnd5JJOw") # id
channel = yt.channel("@RickAstleyYT") # handle
channel = yt.channel("https://www.youtube.com/@RickAstleyYT") # or any URL
print(channel.title, channel.subscribers, channel.video_count)
print(channel.country, channel.joined_date, channel.view_count)
print(channel.photo, channel.banner)
print(channel.links) # e.g. {"x": "https://twitter.com/…", "instagram": "…"}
print(channel.handle, channel.keywords[:5])
print(channel.url, channel.vanity_url, channel.rss_url)
# Transcripts / captions (manual preferred over auto-generated).
transcript = yt.transcript("dQw4w9WgXcQ", languages=["uk", "en"])
print(transcript.language_code, transcript.is_generated, len(transcript))
for line in transcript[:3]:
print(f"{line.start:.1f}s {line.text}")
# Or inspect tracks first:
for track in yt.transcripts("dQw4w9WgXcQ"):
print(track.language_code, track.is_generated, track.is_translatable)
/channel/UC…, /@handle, /c/… and /user/… URLs are all accepted. Handles
are resolved to a UC… id automatically.
Comments
YouTube.comments() returns a lazy CommentThread that transparently pages
through every comment:
from ytscrape import YouTube
with YouTube() as yt:
for comment in yt.comments("https://youtu.be/dQw4w9WgXcQ", max_results=50):
print(comment.author, "-", comment.text)
Replies. By default only top-level comments are collected. Pass
include_replies=True to also collect the replies of every thread; each reply
has is_reply=True and is yielded right after the comment it replies to:
for comment in yt.comments(video_url, include_replies=True):
marker = " ↳" if comment.is_reply else "-"
print(f"{marker} {comment.author}: {comment.text}")
Omit max_results to iterate over all comments. When
include_replies=True, max_results counts replies too. A ParseError is
raised if the video has comments disabled.
Collecting every comment (sort order)
YouTube's default "Top comments" view quietly hides some comments (less relevant ones and "potential spam"), so collecting in that order will appear to skip comments. To get every comment, switch to "Newest first":
from ytscrape import YouTube, CommentSort
with YouTube() as yt:
# `CommentSort.NEWEST` (or the string "newest") returns every comment.
for comment in yt.comments(video_url, sort=CommentSort.NEWEST):
print(comment.author, "-", comment.text)
sort accepts a CommentSort (TOP / NEWEST) or its string value; it
defaults to CommentSort.TOP to mirror YouTube's own default view.
Counting what you collected
comments() is a lazy iterator, so the total is known only after the last page:
total = 0
for comment in yt.comments(video_url, sort="newest"):
total += 1
print(f"Collected {total} comments")
# Or materialise everything at once:
comments = list(yt.comments(video_url, sort="newest"))
print(len(comments))
Language & country
YouTube localises results by interface language (hl) and content region
(gl). Configure both when creating YouTube, passing plain ISO codes (or the
Language / Country value objects, which wrap the same codes):
from ytscrape import YouTube, Language, Country, Locale
# Just pass raw ISO codes — they are validated and normalised for you.
with YouTube(language="uk", region="UA") as yt:
for video in yt.search("музика", max_results=10):
print(video.title, video.url)
# The Language / Country value objects are equivalent (and reusable).
yt = YouTube(language=Language("de"), region=Country("DE"))
# Invalid codes are rejected early (validated with pycountry).
YouTube(region="XX") # ValueError: Unknown country code 'XX'. ...
# Or pass a ready-made Locale.
yt = YouTube(locale=Locale(language="fr", country="FR"))
print(yt.locale.language.code, yt.locale.country.code) # fr FR
Language and Country are thin, self-validating value objects around a raw
ISO 639-1 / ISO 3166-1 alpha-2 code — there is no hard-coded list, so any valid
code works. The chosen locale is sent both in the request context (hl / gl)
and as the Accept-Language HTTP header.
Pagination
Pagination is transparent — iterating over the result object automatically loads the next page:
results = yt.search("python")
for item in results: # loads pages on demand
print(item.title)
You can also page manually:
results = yt.search("python")
print(len(results.fetch_next_page())) # explicitly load one more page
print(results.has_more) # is there another page?
Use max_results to cap how many items you consume.
Data models
All models are frozen dataclasses with full type hints (the package ships
py.typed, so mypy/pyright see the types).
Video (search results) — video_id, title, channel, channel_id,
duration, views, published, thumbnail, url.
Channel — channel_id, title, handle, subscribers, video_count,
thumbnail, url.
Playlist — playlist_id, title, channel, video_count, thumbnail,
url.
VideoDetails (from YouTube.video()) — video_id, title,
description, channel, channel_id, length_seconds (int), views
(int), keywords, is_live, thumbnail, url.
ChannelDetails (from YouTube.channel()) — channel_id, title,
description, handle, subscribers, video_count, view_count, keywords,
tags, thumbnail / photo (avatar), banner, vanity_url, rss_url,
is_family_safe, available_countries, country, joined_date,
links (dict like {"x": "…", "instagram": "…"}), url.
Comment:
| Field | Description |
|---|---|
comment_id |
Unique comment id. |
text |
The comment body. |
author |
Display name of the author. |
author_channel_id |
Channel id of the author (when available). |
author_thumbnail |
URL of the author's avatar. |
published |
Human-readable published time (e.g. 2 days ago). |
like_count |
Like count as an int (None when abbreviated, e.g. 1.2K). |
like_count_text |
Like count as YouTube renders it, keeping abbreviations (e.g. 1.2K, 894). |
reply_count |
Number of replies (top-level comments only). |
reply_count_text |
Reply count as a raw display string (e.g. 64). |
heart |
True if the video's creator hearted the comment. |
is_reply |
True for replies, False for top-level comments. |
Error handling
Every error raised by the library derives from YtScraperError:
from ytscrape import YouTube, YtScraperError, RequestError, ParseError
try:
with YouTube() as yt:
for comment in yt.comments("dQw4w9WgXcQ"):
print(comment.text)
except RequestError as exc: # network / HTTP problem
print("network error:", exc)
except ParseError as exc: # unexpected response (e.g. comments disabled)
print("cannot parse:", exc)
except YtScraperError as exc: # catch-all
print("ytscrape failed:", exc)
| Exception | Raised when |
|---|---|
YtScraperError |
Base class for everything below. |
ContextExtractionError |
The InnerTube context can't be read from YouTube. |
RequestError |
An HTTP request to YouTube fails. |
ParseError |
A response can't be parsed as expected. |
Advanced: proxies, retries, custom sessions
YouTube is a thin facade over InnerTubeClient, which accepts your own
requests.Session — that is the hook for proxies, retries, custom headers or
caching:
import requests
from requests.adapters import HTTPAdapter, Retry
from ytscrape import YouTube, InnerTubeClient
session = requests.Session()
session.proxies = {"https": "http://user:pass@proxy:8080"}
session.mount("https://", HTTPAdapter(max_retries=Retry(total=5, backoff_factor=1)))
client = InnerTubeClient(session=session, timeout=15.0, language="en", region="US")
with YouTube(client=client) as yt:
print(next(iter(yt.search("python"))).title)
The same injection point makes the library trivial to unit-test: pass a fake session and no network call ever happens.
Command line
# Search
python -m ytscrape search "python tutorial" --filter videos --max 10
# Localised search (Ukrainian interface, Ukrainian region)
python -m ytscrape --language uk --region UA search "музика" --max 10
# Video details
python -m ytscrape video https://www.youtube.com/watch?v=dQw4w9WgXcQ
# Channel details
python -m ytscrape channel @RickAstleyYT
python -m ytscrape transcript dQw4w9WgXcQ --lang en
python -m ytscrape transcript dQw4w9WgXcQ --list
# Collect comments (pass 0 to --max for no limit)
python -m ytscrape comments https://www.youtube.com/watch?v=dQw4w9WgXcQ --max 20
# Collect comments together with their replies
python -m ytscrape comments https://www.youtube.com/watch?v=dQw4w9WgXcQ --replies
# Collect EVERY comment (the default "top" order hides some)
python -m ytscrape comments https://www.youtube.com/watch?v=dQw4w9WgXcQ --sort newest
After installing, a ytscrape console script is also available:
ytscrape search "python" --filter channels --max 5
Examples
| Example | What it shows |
|---|---|
01_search_videos.py |
Search for videos with SearchFilter.VIDEOS. |
02_search_channels_playlists.py |
Search for channels and playlists. |
03_video_details.py |
Fetch detailed metadata for a single video. |
04_pagination.py |
Iterate transparently or page manually. |
05_language_region.py |
Localise results by language and region. |
06_error_handling.py |
Handle ytscrape exceptions gracefully. |
07_video_comments.py |
Collect all comments (and replies) of a video. |
08_channel_details.py |
Fetch detailed metadata for a single channel. |
09_transcript.py |
List caption tracks and fetch a transcript. |
FAQ
Do I need a YouTube Data API key?
No. ytscrape uses the same internal endpoints as the YouTube web app, so
there is nothing to register and no quota to manage.
Why are some comments missing?
Because YouTube's default "Top comments" view hides less relevant comments
and "potential spam". Pass sort="newest" (or CommentSort.NEWEST) to collect
every comment.
Why is like_count None?
YouTube abbreviates large counts (1.2K), which cannot be expressed exactly as
an int. The raw string is always available in like_count_text.
Can I use a proxy or rotate IPs?
Yes — inject your own requests.Session into InnerTubeClient. See
Advanced.
Will I get rate limited?
There is no published quota, but YouTube may throttle aggressive traffic. Reuse
one YouTube instance (it keeps a warm session and context), request only what
you need with max_results, and add delays for large crawls.
Does it download videos?
No, and it is not planned — ytscrape is a metadata library. Use
yt-dlp for media downloads.
Is scraping YouTube legal?
This library accesses private endpoints, which may conflict with YouTube's Terms of Service. It is provided for research and educational purposes — you are responsible for how you use it.
Is there an async API?
Not yet — it is the top item on the Roadmap. Today you can run the
synchronous client in a thread pool (asyncio.to_thread).
Roadmap
- ⚡ Async API (
asyncio/httpx) alongside the synchronous one. - 📺 Channel tabs: videos, Shorts, live, playlists, about.
- 🎵 Playlist items with transparent pagination.
- 🔗 Related videos, trending and home feed.
- 🗒️ Community posts.
- 📚 A dedicated documentation site with an API reference.
Ideas and votes are welcome in issues.
Contributing
Contributions are very welcome — bug reports, ideas and pull requests alike.
git clone https://github.com/vsmutok/ytscrape
cd ytscrape
uv sync --dev
uv run pytest # run the test suite
uv run pre-commit run --all-files # ruff + format + bandit
The project uses Ruff for linting and
formatting, bandit for security checks and
pre-commit to run them all. See CHANGELOG.md for release
notes.
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ytscrape-0.1.4.tar.gz.
File metadata
- Download URL: ytscrape-0.1.4.tar.gz
- Upload date:
- Size: 39.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
429a9f7fd83bc94c61dacbc08bdd0ccc30df91a9fa816c9b55bbb6878874a2db
|
|
| MD5 |
fe24daa5016c9bc62636c10855b93bb7
|
|
| BLAKE2b-256 |
d1863090fd4cdf18cc958d014a6a8a10b56e4388818e341bc6b78e44dc135fff
|
Provenance
The following attestation bundles were made for ytscrape-0.1.4.tar.gz:
Publisher:
publish.yml on vsmutok/ytscrape
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ytscrape-0.1.4.tar.gz -
Subject digest:
429a9f7fd83bc94c61dacbc08bdd0ccc30df91a9fa816c9b55bbb6878874a2db - Sigstore transparency entry: 2358982068
- Sigstore integration time:
-
Permalink:
vsmutok/ytscrape@f52be43dddb83b9627a0e04de4bfbc7eea53ff3d -
Branch / Tag:
refs/tags/v0.1.4 - Owner: https://github.com/vsmutok
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f52be43dddb83b9627a0e04de4bfbc7eea53ff3d -
Trigger Event:
push
-
Statement type:
File details
Details for the file ytscrape-0.1.4-py3-none-any.whl.
File metadata
- Download URL: ytscrape-0.1.4-py3-none-any.whl
- Upload date:
- Size: 45.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cedd99d28da150ace74c017cd8ba9552237852a405d51d29fd60c314e5c84339
|
|
| MD5 |
91ba03c2a0b24e4bfe32129c82713efd
|
|
| BLAKE2b-256 |
6921c24765122b3ac407f24e89310310e27933d0bb99fe118fd5e8cd54b1e2bc
|
Provenance
The following attestation bundles were made for ytscrape-0.1.4-py3-none-any.whl:
Publisher:
publish.yml on vsmutok/ytscrape
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ytscrape-0.1.4-py3-none-any.whl -
Subject digest:
cedd99d28da150ace74c017cd8ba9552237852a405d51d29fd60c314e5c84339 - Sigstore transparency entry: 2358983491
- Sigstore integration time:
-
Permalink:
vsmutok/ytscrape@f52be43dddb83b9627a0e04de4bfbc7eea53ff3d -
Branch / Tag:
refs/tags/v0.1.4 - Owner: https://github.com/vsmutok
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f52be43dddb83b9627a0e04de4bfbc7eea53ff3d -
Trigger Event:
push
-
Statement type: