YouTube Playlist Scraper
Pricing
from $0.85 / 1,000 video record delivereds
YouTube Playlist Scraper
Scrape any YouTube playlist by id or URL — including other people's public playlists, not just your own uploads: videoId, title, view count, position. No API key, no login.
Pricing
from $0.85 / 1,000 video record delivereds
Rating
0.0
(0)
Developer
Tim Zinin
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
Share
YouTube Playlist Scraper: Extract Every Video From Any Public YouTube Playlist by ID or URL
This Actor takes a YouTube playlist you already have — the list= value, or the full
youtube.com/playlist?list=... URL — and returns its contents: every video's id, title, view count
and position in the list. You do not need to own the playlist. You do not need a YouTube API key, a
login, or a browser. One GET request per playlist, parsed, returned as flat JSON.
This is deliberately narrow. It accepts any playlist id you supply, including someone else's public
curated collection — a competitor's "best of" list, a course creator's syllabus playlist, a brand's
campaign playlist, your own channel's uploads if you happen to know its technical id. It does not
look up a channel, does not guess which playlist belongs to which channel, and does not build YouTube's
internal UU<channelId> uploads-playlist identifier for you. If what you actually have is a channel
handle or URL and you want that channel's own recent uploads, the sibling Actor
youtube-channel-videos-list does that resolution step for you and this one deliberately does not — see
Evidence and boundaries for exactly why that split exists and which of the two you need.
The billable outcome is a delivered video: one row, successfully read from a playlist page you
supplied, with a status of "ok" or "partial". Every other outcome is free: a playlist id that is
correctly formed but does not exist (YouTube itself answers with HTTP 200 and zero videos — a genuine
silent-empty result), a playlist id that is malformed enough that YouTube's own servers answer with
HTTP 404, a request blocked by this Actor's own host guard before it ever reaches the network, or a run
that stops early because its spend cap was reached. Those first two free outcomes look similar from the
outside — "I got nothing back" — but they are not the same signal, and this page shows you the real JSON
for both so you can tell them apart in your own pipeline. See Real happy, partial and failure output.
What you get
- Any playlist id, not just your own uploads. The input field accepts a bare
list=value or a full playlist URL, and this Actor never resolves a channel, never guesses an owner, and never restricts itself to playlists you can prove you control. If your use case is "what has this channel uploaded recently," read Who uses it below for the actor that fits that job instead. - A silent-empty result is never confused with a real failure, and neither ever costs you money. A
well-formed but nonexistent playlist id gets
status:"empty"witherror:null. A malformed id getsstatus:"error"with the source's ownHTTP 404quoted in the message. Both are free. Neither is guessed at — the HTTP status is checked before this Actor ever tries to parse a response body, so a 404 can never be silently reinterpreted as "playlist found, zero videos." - A measured, disclosed ceiling — never a silent truncation. One playlist page returns at most 100
videos per request; this Actor does not paginate past that. When a response lands on exactly that
ceiling, every row from that playlist is flagged
partial:truewithaction:"flag-possible-truncation"rather than quietly presented as the playlist's complete contents. See Evidence and boundaries for the live measurement behind that number, taken while writing this page. - A missing title never fails the row. YouTube's own rendering occasionally omits a video's title on
an otherwise well-formed playlist entry. This Actor treats
title:nullas data, not as an error — the row still ships asstatus:"ok"or"partial", withaction:"flag-missing-title", so you can decide what to do with a nameless video id instead of losing the row entirely. - View counts are exactly what YouTube shows a visitor, no more precise than that. Every video row
carries
confidence:"display-text"as a standing reminder thatviewCountText("2.6M views") is YouTube's own rounded display string, not an exact counter pulled from a private API. - No transcripts, no comments — not a missing feature, a source boundary. YouTube's own
robots.txtdisallows/api/,/youtubei/,/resultsand/comment; the paths that serve transcripts and comment threads live behind those disallowed prefixes. This Actor's code never requests them, on any input, under any condition. - A hardened fetch layer shared across this Actor's whole hub. Every request goes through the same
library (
lib/youtube/fetch.js) used by three sibling YouTube Actors in this line: DNS-rebinding-safe SSRF guarding, a pinned-address connection so a re-resolution race cannot slip past the guard, a byte cap on the response stream, and manual redirect handling that re-runs the same host check on every hop rather than trusting the first one. A playlist "URL" pointing at a non-YouTube host is rejected before any request leaves the process — confirmed live below, not just asserted. - A billing guard that never charges twice for the same row and never bills a row it could not actually verify was delivered. Charging and writing a row happen inside one atomic, mutex-protected step; a push that throws is reported as "delivery unknown," never silently written off as either charged or free, because guessing wrong in either direction would be dishonest.
Who uses it
- Content researchers and analysts studying a specific curated playlist — someone else's "top 10" list, a competitor's campaign playlist, a themed collection — where the unit of interest is the playlist itself, not a channel's whole upload history.
- Course and learning-content aggregators cataloguing the video sequence of a public teaching playlist: order, titles, and rough popularity, without watching each video or scraping the page by hand.
- Competitive intelligence teams auditing what a rival channel, brand, or influencer has assembled into a specific public collection, separate from that channel's general upload cadence.
- Developers and workflow/agent builders who want one flat JSON row per video —
status,partial,action,error— that a script, an n8n node, or an LLM agent can branch on directly, without parsing YouTube's HTML or its internal JSON view-model shapes by hand. - Not for buyers who only have a channel name/handle and want that channel's own recent uploads —
that buyer does not want to look up or construct a playlist id at all. That job belongs to the sibling
Actor
youtube-channel-videos-list, which takes a channel and resolves its uploads playlist itself. Feeding a channel handle into this Actor'splaylistsfield will not work — this Actor expects alist=value or a playlist URL, not a channel identifier, and does not attempt channel resolution as a fallback.

How to run
- Populate
playlistswith one or more values — each either a barelist=id (e.g.PLBCF2DAC6FFB574DE) or a full URL (https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DE). Up to 50 per run; duplicates in the input are silently deduplicated before any request is made. - Leave
maxConcurrencyat its default of5unless you have a specific reason to raise it (up to 20) or lower it (down to 1) — see Operating guide for what this number actually controls. - Start the run from the Console, the Apify API, a scheduled Task, or an MCP client. The prefilled
input (
["PLBCF2DAC6FFB574DE"]) is a real, verified 10-video playlist — a default run with no changes produces a small, real, non-empty result at essentially no cost, so you can see the actual shape of the data before committing a larger list. - Read the Dataset: one row per video for a playlist that returned content, or one summary row for a playlist that was empty, malformed, blocked, or held back by budget. Every row shares the same 13-field shape — see Field dictionary.
- Check
statusbefore anything else."ok"and"partial"are the two billed, video-bearing outcomes;"empty"and"error"are always free and always carryvideoId:null. Do not branch on whethererroris set — an"empty"row correctly haserror:nulleven though nothing was found;erroris populated specifically on the"error"path, where the source itself refused the request. - Check this run's key-value store record
OUTPUTfor a one-time run summary — requested/delivered/ paid/free/failed/incomplete counts, replay safety and any fatal error — written once at the end of the run. Treat the Dataset itself as the complete, row-by-row record of what happened;OUTPUTis the roll-up, not a replacement for it. - A
partial:truerow is still a delivered, billed row (when itsstatusis"partial") — it is not an error and it is not free. It means "delivered, with a named, honest caveat attached." Readactionto see which caveat applies.
Pricing
$0.005 per Actor start, plus $0.001 per delivered video. The primary pricing noun on this page, in
the Dataset, and in this Actor's platform pricing record is the same throughout: the delivered video
— one row, read from a playlist you supplied, with status:"ok" or status:"partial". A run of 50
playlists that mostly turn out empty or malformed is not charged per playlist attempted; it is charged
for the videos it actually delivered, plus the one fixed start fee.
Both of this Actor's free outcomes are enforced in code, not left to a documentation promise: the charge
and the dataset write happen together, inside one mutex-protected step (lib/youtube/billing.js), and a
row is never pushed to the paid channel unless its status is already "ok" or "partial" at that
point — an "empty" or "error" row is written through a separate, unconditionally free path. There is
no code path in this build where a status:"empty" or status:"error" row is billed.
This Actor's own external data cost is close to zero — one GET request per playlist, no proxy, no LLM,
no paid third-party API — so the per-video price reflects extraction, validation and delivery guarantees
described on this page, not a resold data bill. On Apify's own billing record these are metered as two
separate platform events — apify-actor-start for the run fee, and result-found for each delivered
video — the same two lines you will see on your invoice.
Input contract
Exactly one field is required.
playlists— array of strings, 1 to 50 items, required. Each item is either a barelist=value (e.g.PLBCF2DAC6FFB574DE) or a fullyoutube.com/www.youtube.com/m.youtube.complaylist URL containing alist=parameter. A value containing/,.., or://where a bare id is expected is rejected before any request is made (status:"error", no network call) — that guard exists to stop a path-injection or scheme-confusion attempt from being substituted into the request URL, not to police ordinary punctuation; a value with plain spaces or other non-path characters is deliberately not rejected client-side, and is instead sent to YouTube to receive the source's own honest answer (see the 404 example below — that is exactly this path). A URL whose host is not one of the three YouTube hosts above is rejected withstatus:"error"and an explicit"blocked host"message, and no request is ever sent to that host — verified live below, not just asserted.maxConcurrency— integer, 1 to 20, default5. How many playlists this run processes at once. See Operating guide for why the default is conservative.
Two runnable input examples, built directly from .actor/input_schema.json — these are input
illustrations, not run output; the Real happy, partial and failure output section below shows the actual
Dataset rows these kinds of inputs produce, taken from a real run of this exact build.
{"playlists": ["PLBCF2DAC6FFB574DE"],"maxConcurrency": 5}
(This is the Actor's own prefilled default input — a single, verified real playlist, run at the default concurrency. This is what a buyer sees and can run with zero edits.)
{"playlists": ["https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DE","UUBJycsmduvYEL83R_U4JriQ","PLBCF2DAC6FFB574DF"],"maxConcurrency": 2}
(Mixes the full-URL input form with a bare-id form, and deliberately includes a playlist id that does
not exist — PLBCF2DAC6FFB574DF — to show that a buyer does not need to pre-filter their own list
before sending it in; the nonexistent one simply comes back as a free, honest "empty" row alongside the
real results. All three ids in this example are the same ones this page's live evidence was gathered
against.)
Real happy, partial and failure output
Every row in the Dataset shares one flat, 13-field shape regardless of outcome — requestedPlaylist,
normalizedPlaylistUrl, videoId, position, title, viewCountText, source, status,
confidence, partial, action, error, checkedAt. All four examples below are copied verbatim
from an actual local run of this exact main.js build (apify run via node main.js with
APIFY_LOCAL_STORAGE_DIR set to a local storage folder), executed while this page was being written,
against the live YouTube playlist pages named in each block. Nothing here is invented or reconstructed
from the schema — every field value below is what the Dataset actually contained after that run
finished.
Happy path — a real 10-video playlist, position 1 of 10
PLBCF2DAC6FFB574DE is a real, publicly accessible YouTube playlist, verified independently in
HUB10_GATE_EVIDENCE.md before this Actor was built and reconfirmed by this exact run. The run
returned exactly 10 rows, positions 1 through 10, all status:"ok", all with a real videoId and a
real title.
{"requestedPlaylist": "PLBCF2DAC6FFB574DE","normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DE","videoId": "GvgqDSnpRQM","position": 1,"title": "Andrew Willis, Skatepark Engineer","viewCountText": "2.6M views","source": "playlist-page","status": "ok","confidence": "display-text","partial": false,"action": "ingest","error": null,"checkedAt": "2026-08-17T20:17:57.854Z"}
The tenth and last row of the same playlist, confirming the position count runs cleanly to the playlist's real end with no gaps:
{"requestedPlaylist": "PLBCF2DAC6FFB574DE","normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DE","videoId": "CyRQJBBVI7g","position": 10,"title": "Cheryl and Morgan: Learning Independence","viewCountText": "3.6M views","source": "playlist-page","status": "ok","confidence": "display-text","partial": false,"action": "ingest","error": null,"checkedAt": "2026-08-17T20:17:57.854Z"}
Both rows above are billed, delivered videos — status:"ok", partial:false, action:"ingest".
Partial path — the measured 100-item ceiling, flagged honestly
To measure this Actor's real per-request ceiling, the same run also requested
UUBJycsmduvYEL83R_U4JriQ — a large, publicly accessible playlist already verified in
HUB10_GATE_EVIDENCE.md on both a local network path and a datacenter network path, with exactly 100
videoIds returned on both. This Actor's playlists field accepts any list= value with no channel
resolution involved, so using this particular id here is simply "a large playlist," feeding the exact
same /playlist?list=... code path as any other input — not a boundary crossing into the sibling
channel Actor's territory (see Evidence and boundaries for the full reasoning).
The run returned exactly 100 rows for this playlist — no more, no fewer — and every single one of them
was flagged partial:true, action:"flag-possible-truncation", because the extraction layer's own
sawExactly100 check fired on the raw count returned by the page, before this Actor's own defensive cap
was even applied. Position 1 of that 100:
{"requestedPlaylist": "UUBJycsmduvYEL83R_U4JriQ","normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=UUBJycsmduvYEL83R_U4JriQ","videoId": "o4SSoURPODY","position": 1,"title": "Google Pixel 11/Pro/Fold Impressions: It Is What It Is","viewCountText": "2.9M views","source": "playlist-page","status": "partial","confidence": "display-text","partial": true,"action": "flag-possible-truncation","error": null,"checkedAt": "2026-08-17T20:17:58.418Z"}
Position 100 of the same playlist, the last row this Actor will ever return for it in a single run:
{"requestedPlaylist": "UUBJycsmduvYEL83R_U4JriQ","normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=UUBJycsmduvYEL83R_U4JriQ","videoId": "HP_mXUuHPbo","position": 100,"title": "Samsung broke this","viewCountText": "7M views","source": "playlist-page","status": "partial","confidence": "display-text","partial": true,"action": "flag-possible-truncation","error": null,"checkedAt": "2026-08-17T20:17:58.418Z"}
These rows are still billed — status:"partial" is one of the two billable statuses — but every one of
them carries the honest flag that this playlist may hold more than 100 videos and this Actor, by design,
never fetches a second page to find out. On this specific run, all 100 titles were present — the
separate "missing title" trap described next did not fire on this particular fetch. It is a real,
disclosed possibility in this Actor's own code (see below), not something reproducible on demand; a
buyer relying on this page for a specific missing-title example should treat title:null as expected
behavior in general, not assume it is rare because it did not happen on this one run.
Empty path — a well-formed, nonexistent playlist id, HTTP 200 with zero videos
PLBCF2DAC6FFB574DF — the real evidence playlist id above with its final character changed — is
syntactically indistinguishable from a real playlist id and was included in the same run specifically to
capture this exact outcome live. YouTube answered with HTTP 200 and a real page body (roughly 759 KB,
independently reconfirmed by a direct curl request made while writing this page), containing zero
video entries. This Actor's extraction step checked the HTTP status first, found it healthy, then parsed
the body and found nothing — which is exactly what makes this a silent-empty result rather than an
error:
{"requestedPlaylist": "PLBCF2DAC6FFB574DF","normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=PLBCF2DAC6FFB574DF","videoId": null,"position": null,"title": null,"viewCountText": null,"source": "playlist-page","status": "empty","confidence": null,"partial": false,"action": "skip-not-found","error": null,"checkedAt": "2026-08-17T20:17:57.793Z"}
error is null here, not a message — this is the row shape's own way of saying "the source answered
successfully, and the honest answer was nothing." This row is free. Nothing about it looks like a
failure to a downstream consumer that only checks error, which is exactly why status is the field to
branch on, never error alone.
Error path — a malformed playlist id, the source's own honest HTTP 404
The same run also requested this is not a real playlist id !! — a value containing spaces and
punctuation that this Actor's own client-side guard deliberately does not reject (see Input
contract: the guard only blocks /, .. and ://), specifically so a value like this reaches YouTube
and receives the source's own real answer rather than a guessed client-side rejection. YouTube's servers
answered with a genuine HTTP 404, checked and recorded before any attempt was made to parse a body:
{"requestedPlaylist": "this is not a real playlist id !!","normalizedPlaylistUrl": "https://www.youtube.com/playlist?list=this%20is%20not%20a%20real%20playlist%20id%20!!","videoId": null,"position": null,"title": null,"viewCountText": null,"source": "playlist-page","status": "error","confidence": null,"partial": false,"action": "skip-not-found","error": "playlist not found (HTTP 404) — http 404","checkedAt": "2026-08-17T20:17:58.047Z"}
This row is also free — an "error" status is never billed, same as "empty". The distinction that
matters to you as a buyer: status:"empty" means "this playlist genuinely has nothing in it right now,"
and status:"error" means "the source itself refused this specific request." Neither ever costs
anything, and this Actor never collapses one into the other.
Field dictionary
| Field | Meaning | Important boundary |
|---|---|---|
requestedPlaylist | Exactly the string you supplied in playlists, unmodified | Echoed even when the value was rejected before any request — lets you match every input back to its outcome |
normalizedPlaylistUrl | The full youtube.com/playlist?list=... URL this Actor actually requested | null when the input was rejected before a URL could be built (invalid value, blocked host) |
videoId | The video's 11-character YouTube id | null on every summary row — empty, error, or budget-stopped — never present without a real video behind it |
position | 1-based order of this video within the fetched page | null on every summary row; not guaranteed stable across separate runs if the source itself reorders a playlist between requests |
title | The video's title as rendered on the playlist page | Nullable by design — a missing title never turns the row into an error; see action:"flag-missing-title" |
viewCountText | YouTube's own rounded display text (e.g. "2.6M views") | Not an exact count — see confidence |
source | Fixed constant "playlist-page" on every row | Present even on a row where no request actually reached YouTube (e.g. an SSRF-blocked or invalid-value row) — it names the kind of page this Actor reads, not proof that a fetch happened; check status/error for that |
status | The billing and outcome boundary | "ok" or "partial" = billed, video-bearing row. "empty" or "error" = free, videoId:null, no exceptions in this build's code |
confidence | Fixed "display-text" on every video row | null on every summary row; exists specifically to keep viewCountText's imprecision visible at the row level, not buried in documentation only |
partial | true when this row carries a disclosed caveat | Still billed when status:"partial" — partial:true is not the same thing as free |
action | What this row suggests doing with it | "ingest" (clean), "flag-missing-title", "flag-possible-truncation", or "skip-not-found" — the last one is the fixed default on every summary row (empty, error, SSRF-blocked, or budget-stopped), not a literal claim that something specific was "not found" in every case; read status/error for the real reason |
error | null on every honestly-completed row, including "empty" | Populated only on status:"error" and on free notice rows (budget-exhausted, blocked-host); when present, often quotes the source's own response text rather than a message this Actor composed |
checkedAt | ISO-8601 timestamp of this row's outcome | Per-row, not per-run — rows from the same run can carry slightly different timestamps under concurrency |
Evidence and boundaries
Why this Actor takes an arbitrary list= value and performs no channel resolution. The hub this
Actor belongs to also ships youtube-channel-videos-list, which takes a channel handle/URL/UC... id
and builds that channel's technical UU... uploads-playlist id internally before fetching it — the
buyer for that Actor never sees or supplies a playlist id at all. This Actor solves a different,
narrower problem: a buyer who already has a specific playlist id or URL — often one they do not own,
like a competitor's curated collection or a course creator's syllabus — and wants its contents without
any assumption about which channel, if any, "owns" it in a meaningful sense. Building both resolution
paths into one Actor would blur that distinction for the buyer and for this Actor's own pricing and
input contract; keeping them separate means each Actor's input field means exactly one thing.
The two-part negative case, proven live, not asserted. A correctly formed but nonexistent playlist
id and a syntactically broken one look identical from a distance — "nothing came back" — but they are
different signals from the source, and this Actor's code checks the HTTP status before it ever
attempts to parse a response body, specifically so the two paths can never be confused with each other.
The live run behind the two JSON blocks above confirms both halves on the same execution:
PLBCF2DAC6FFB574DF (well-formed, nonexistent) returned HTTP 200 with a genuine ~759 KB page body and
zero video entries — status:"empty", error:null. this is not a real playlist id !! (syntactically
broken) returned a real HTTP 404 from YouTube's own servers — status:"error", error naming the
404 explicitly. Both are free. A parser that treated "any non-404 response" as "playlist found" would
have silently sold the first case as a successful, if boring, result — this build's own test suite
(acceptance.json goldens 2 and 3) exists specifically to catch that class of regression before release.
The 100-item ceiling, measured live while writing this page, not assumed from documentation. A
direct curl request against UUBJycsmduvYEL83R_U4JriQ made independently of the Actor run above
(with the same Cookie: CONSENT=YES+1 header and hl=en&gl=US query parameters this Actor always
sends) returned HTTP 200 and roughly 2.06 MB of HTML — in the same order of magnitude as
HUB10_GATE_EVIDENCE.md's own earlier measurement of the same playlist (2,059,988 bytes on a local
network path, 2,066,685 bytes from a Contabo datacenter path, both also 100 videoId values). This
Actor's own live run above independently extracted exactly 100 unique video entries from that same page
— not 100 because of an artificial slice, but because the extraction layer's own count, taken before
this Actor's separate defensive MAX_ITEMS_PER_PLAYLIST cap is even applied, already landed on exactly
100. The 10-video playlist used for the happy-path example above, fetched in the same run, did not
trigger this flag — partial stayed false on all 10 of its rows — confirming the flag fires on the
measured ceiling specifically, not on "any moderately large playlist."
Why the flag on a 100-row result says "possible truncation," not "definitely truncated." A playlist
with exactly 100 videos and a playlist with 1,000 videos are indistinguishable from the outside of a
single GET request — both render the same first 100 entries and no visible signal that says "this is
where it stops." This Actor has no pagination fallback beyond one request per playlist, so rather than
either quietly presenting a 100-row result as "the whole playlist" (which could be wrong) or refusing to
deliver those 100 rows at all (which would withhold real data that is correct as far as it goes), every
row from a page that hit the ceiling is billed and delivered with partial:true — an honest, disclosed
uncertainty rather than a silent guess in either direction.
The missing-title trap is real and disclosed, even though it did not reproduce on this page's own
evidence run. HUB10_GATE_EVIDENCE.md records a separate, earlier live measurement of this exact
playlist (UUBJycsmduvYEL83R_U4JriQ) that returned 99 titles out of 100 video entries — one position
with a real videoId and no title at all. This Actor's code treats that outcome as data, not failure:
title:null never changes a row's status away from "ok"/"partial", it only sets
action:"flag-missing-title". On the specific run this page's own JSON examples were pulled from, all
110 delivered video rows (10 from the happy-path playlist, 100 from the ceiling playlist) carried a
non-null title — YouTube's own rendering is not perfectly deterministic across separate requests, and
this Actor's own acceptance suite (acceptance.json golden 4) is written to accept either outcome for
exactly that reason, rather than pin a specific position that may or may not repeat.
The SSRF/host guard, confirmed live, not just read from source. A separate run of this Actor with
playlists: ["https://evil.example.com/playlist?list=X"] was made specifically to confirm the guard
described in Input contract. The result: status:"error",
error:"blocked host (not a youtube.com URL): evil.example.com"normalizedPlaylistUrl:null — no request was ever sent to evil.example.com. This
guard, and the DNS-rebinding protection and pinned-connection logic behind it in lib/youtube/fetch.js,
is shared, unmodified, across every YouTube Actor in this hub — a change made here changes all of them,
and none of them "simplify" it away, per the guard file's own header comment.
Why robots.txt is read as permission for /playlist, checked live, not cached from a prior date.
A direct request to www.youtube.com/robots.txt, made independently while writing this page, confirmed
the disallow list explicitly names /api/, /comment, /results, and /youtubei/ — and does not
name /playlist. This matches HUB10_GATE_EVIDENCE.md's own earlier reading of the same file. This
Actor's own code never requests any of the four disallowed prefixes above, on any input, under any
condition — which is also the reason it can never return video transcripts or comment threads: the
paths that would serve them are the exact ones robots.txt disallows.
Why the consent cookie and language parameters are sent on every request, without exception. Every
request this Actor makes carries Cookie: CONSENT=YES+1 and hl=en&gl=US. Without both, YouTube renders
the page in whatever language the runner's own network location implies, and this Actor's English-token
parsing logic would return a silent-empty result on a clean HTTP 200 — a failure mode that looks
identical to a genuinely empty playlist unless you already know to suspect it. This is a fixed
requirement of the shared fetch layer, not a per-request option.
Private and access-restricted playlists — not separately measured for this build. This Actor sends
no authentication of any kind — no login, no session, no API key — so it can only ever see what YouTube
serves to an anonymous visitor. A private or unlisted-with-restrictions playlist is expected, by the
nature of an unauthenticated request, to be unreachable in the same way any other anonymous visitor
would find it unreachable. What exact response shape that produces — a 200 with no content, a 404, a
redirect to a sign-in page — was not independently measured for this specific build before this page
was written, and no live example of it exists in this evidence set. Treat a private playlist as simply
outside this Actor's reach rather than assuming it will surface as any one specific status value.
Decision routing
status | action values seen | Billable? | What it means |
|---|---|---|---|
"ok" | "ingest" | Yes | A video was read cleanly — title present, page not at the 100-item ceiling |
"partial" | "flag-missing-title" or "flag-possible-truncation" | Yes | A video was delivered with one disclosed caveat — a missing title, or this page landed on the measured 100-item ceiling |
"empty" | "skip-not-found" | No | A correctly formed playlist id that currently has no videos in it — HTTP 200, genuinely empty |
"error" | "skip-not-found" | No | The playlist id was rejected before any request (invalid value, blocked host), or the source itself refused the request (e.g. HTTP 404) |
A workflow needs only two rules to be correct: bill and use only rows where status is "ok" or
"partial"; and never assume "empty" and "error" mean the same thing even though both are free and
both carry videoId:null — read error to tell them apart, since only the "error" path populates it.
Commercial playbooks
Auditing a competitor's or influencer's curated collection
A brand or agency researcher takes the exact playlist URL of a competitor's "best of" list, a rival creator's campaign playlist, or an industry account's themed collection, and pulls the full ordered contents — titles and rough popularity by view count — without opening YouTube and scrolling by hand or building a bespoke scraper for a one-off research task.
Course and syllabus cataloguing
A learning-content aggregator or course reviewer feeds in the playlist URLs of public teaching playlists — a university department's lecture series, an independent instructor's course sequence — and gets back the exact video order and titles as a structured catalog entry, ready to cross-reference against a syllabus or a course-comparison table.
Lightweight research pipeline feeding an agent or n8n workflow
A researcher or workflow builder who already discovers playlist URLs some other way — a web search, a
manual list, a scraped page of links — routes each one through this Actor to turn "I found a playlist
URL" into structured, machine-checkable rows without writing a YouTube-specific parser, checking
status and partial before treating any row as final.
Integration recipes
One-shot batch lookup
Feed a list of playlist URLs or ids collected from any source — a spreadsheet, a search result set, a
manual list — into one run with maxConcurrency set to match the batch size (up to 20). Filter the
resulting Dataset to status in ["ok", "partial"] before loading it into a catalog or spreadsheet;
"empty"/"error" rows are the free record of what did not resolve, not data to import.
Agent/MCP pattern
An agent that has already located a playlist URL (via search, a page it read, or a user-supplied link)
passes it directly into playlists, reads status and partial before treating any row as usable, and
never assumes a status:"ok" row with title:null is broken — that combination is a valid,
action:"flag-missing-title" outcome, not a parsing failure the agent needs to retry.
Spreadsheet/catalog append pattern
Dataset rows are appended keyed by videoId for "ok"/"partial" rows, and by requestedPlaylist +
checkedAt for "empty"/"error" summary rows (which carry no videoId). null fields are preserved
as null, not coerced into an empty string, so a downstream query can tell "this video has no title
according to YouTube" apart from "this field was never populated."
Operating guide
Choosing maxConcurrency. The default of 5 is a deliberately conservative starting point, not a
number derived from a sustained-load production measurement — this Actor's own SPEC document states
plainly that the ceiling this input allows (20) has not been separately load-tested at scale. Raising
it speeds up a run with many playlists in one input; it does not change what any individual playlist
request returns, since each playlist is fetched with exactly one request regardless of concurrency
setting.
Reading the 100-item ceiling flag correctly. partial:true with action:"flag-possible-truncation"
on every row of a 100-row result means exactly one thing: this Actor does not know, and does not claim
to know, whether the real playlist has 100 videos or many more than that. It never re-requests a second
page and never estimates a true count. If your use case genuinely needs a playlist's complete contents
and you have reason to believe it may exceed 100 videos, treat every row from that request as a
sample of the first 100, not a guaranteed-complete answer.
Reading the missing-title flag correctly. partial:true with action:"flag-missing-title" applies
to exactly the one row missing its title, not to the whole playlist's result set — a 100-row result with
one missing title carries 99 ordinary "ingest" rows with partial:false (unless the same page also
hit the ceiling, in which case every row already carries partial:true from that flag alone) and one row
with partial:true/"flag-missing-title". The two flags are independent and can both apply.
Telling "empty" and "error" apart in your own code. Never write a check that treats "any row
with no videoId" as a failure. Check status specifically. "empty" is a legitimate, free, non-error
outcome — the playlist id was valid and the source answered normally; there was simply nothing to
report. "error" means the value was rejected before a request, or the source itself refused the
request. Conflating the two in a downstream alert or retry policy will produce false alarms on every
genuinely empty playlist you check.
Related tools
Related tools for adjacent workflows in social platforms and regional directories.
| Actor | What it does |
|---|---|
| YouTube Channel Videos List | Pair it in the social platforms and regional directories workflow: List up to 100 most recent uploads for any YouTube channel: videoId, title, view count, and publish age... |
| Substack Publication Scraper | Pair it in the social platforms and regional directories workflow: Read recent posts from any Substack publication's own domain — title, date, full HTML body, comment count... |
| YouTube Channel Intel | Pair it in the social platforms and regional directories workflow: Public intel card for any YouTube channel: subscribers, total views, video count, join date, country, full... |
| YouTube Channel Lookalike Finder | Pair it in the social platforms and regional directories workflow: Find the similar/lookalike channels YouTube itself shows on any channel's About page: channelId, name,... |
| Bluesky Profile Scraper | Pair it in the social platforms and regional directories workflow: Batch-fetch public Bluesky profiles by handle or DID via the official keyless AppView API — display name,... |
FAQ
Can I use this to get my own channel's recent uploads?
Only if you already know your channel's technical UU... uploads-playlist id and supply it directly as
a list= value — this Actor will fetch it exactly like any other playlist, as demonstrated by the
100-item example above, which deliberately reused a real uploads-playlist id as a large-playlist test
case. If you do not already know that id, or you would rather supply a channel handle/URL/UC... id and
have the uploads playlist resolved for you, use the sibling Actor youtube-channel-videos-list instead
— that is exactly the problem it solves, and it also adds upload-age data for the first 30 videos, which
this Actor does not attempt at all (see Limits).
What happens if I give it a playlist id that doesn't exist?
A free row with status:"empty" and error:null — YouTube answers HTTP 200 with a real page and zero
video entries. See the live example under Real happy, partial and failure output. Nothing is billed.
What happens if I give it a garbled or malformed value?
A free row with status:"error", and error naming the source's own HTTP 404 response. See the live
example under Real happy, partial and failure output. Nothing is billed.
Is there a way to get more than 100 videos from one playlist?
Not in this build. One playlist page returns at most 100 videos per request, measured live and
confirmed against an independent historical measurement (see Evidence and boundaries), and this Actor
performs exactly one request per playlist with no pagination fallback. A result that lands on that
ceiling is flagged partial:true rather than silently presented as complete.
Are the view counts exact numbers?
No. viewCountText is YouTube's own rounded display text ("2.6M views"), exactly as rendered to a
visitor on the page. confidence:"display-text" is set on every video row as a standing reminder of
that. There is no numeric, exact-count field in this build.
Does this Actor return upload date or video age?
No, not at all — this build has no age field of any kind, unlike the sibling youtube-channel-videos-list
Actor, which cross-references a channel's /videos page for that data. This Actor has no equivalent
cross-reference available for an arbitrary third-party playlist, so it does not attempt to estimate or
guess an age field rather than publish an unreliable one.
Does this Actor return transcripts or comments?
No. YouTube's own robots.txt disallows the paths that would serve either, and this Actor's code never
requests them under any input.
Can it read a private or restricted playlist?
No authentication of any kind is sent, so this Actor can only see what an anonymous visitor sees. See Evidence and boundaries for the honest disclosure that the exact failure shape for a private playlist was not separately measured for this build.
How is this different from youtube-channel-videos-list?
This Actor takes a playlist id or URL you already have, including someone else's — it performs zero
channel resolution. youtube-channel-videos-list takes a channel (handle, URL, or UC... id) and builds
its uploads-playlist id for you — the buyer for that Actor never needs to know or supply a playlist id.
They are two different input contracts for two different buyer intents, sharing the same underlying
fetch and parsing library.
Sources and rights
Source family: YouTube public channel and playlist pages. This Actor reads the playlist page you name, anonymously, with no login, no API key and no browser session.
The only source. The public HTML page at https://www.youtube.com/playlist?list=<id>, fetched
anonymously with Cookie: CONSENT=YES+1 and ?hl=en&gl=US on every request. No YouTube Data API, no
API key, no OAuth, no login session.
robots.txt, checked fresh, not cached. www.youtube.com/robots.txt disallows /api/, /comment,
/results, and /youtubei/; it does not disallow /playlist, the only path this Actor requests.
Confirmed live during the writing of this page, and consistent with the independent, earlier reading
recorded in HUB10_GATE_EVIDENCE.md.
No explicit reuse licence — unlike a government open-data feed. YouTube publishes no equivalent of
an Open Government Licence for the content of its playlist pages. This Actor's engineering posture is
limited to what robots.txt permits to be crawled and what a genuinely anonymous request receives; it
does not constitute legal advice about how you may use the resulting data downstream, and this page
makes no claim of an explicit redistribution licence the way a government open-data source would carry
one. If your use case depends on a specific reuse right beyond what an anonymous, robots.txt-permitted
fetch implies, that determination is yours to make, not this Actor's to certify.
The consent cookie is a standard region/consent flow, not a bypass. CONSENT=YES+1 is the same
cookie value YouTube's own consent-interstitial flow sets for a visitor who accepts it; sending it
directly on each request avoids the page rendering a consent wall instead of playlist content. It is not
an authentication credential and grants no access beyond what any anonymous visitor who clicked "accept"
would already see.
Limits
Two different negative outcomes exist and this Actor never confuses them, and never bills either. A
correctly formed but nonexistent playlist id returns HTTP 200 with zero video entries — a genuine
silent-empty result, status:"empty", error:null. A syntactically malformed playlist id returns the
source's own HTTP 404 — status:"error", error naming the 404 explicitly. Both are free in every
code path this build has. Neither is treated as, or silently converted into, the other. See Real happy,
partial and failure output for the live JSON of both, taken from the same run.
- Private and access-restricted playlists are not reachable — this Actor sends no authentication of any kind, and the exact response shape a private playlist produces was not separately measured for this build (see Evidence and boundaries).
- View counts (
viewCountText) are YouTube's own rounded display text, never an exact number — there is no exact-count field in this build. - No transcripts and no comments, ever — the paths that would serve either are disallowed by YouTube's
own
robots.txt, and this Actor's code never requests them. - The measured per-request ceiling is exactly 100 videos per playlist page; this Actor performs one
request per playlist with no pagination, and flags a result that hits that ceiling as
partial:truerather than presenting it as complete. - No upload date or video-age field of any kind — unlike the sibling
youtube-channel-videos-listActor, this one has no companion source page to cross-reference age against for an arbitrary third-party playlist. - No channel resolution — this Actor never looks up, guesses, or builds a channel's uploads-playlist id.
A buyer who only has a channel name needs
youtube-channel-videos-listinstead. - A missing video title never fails a row, but it also cannot be predicted or guaranteed absent — it is disclosed as a possibility, not a fixed rate; the specific run behind this page's own evidence did not reproduce it, an earlier independent measurement of the same playlist did (99/100 titles).
maxConcurrencyaccepts up to 20, but that upper end has not been separately load-tested against sustained production traffic for this build; the default of 5 is the conservative, verified starting point.- The billing safety nets described in this Actor's code — refusing to run at all when the platform's own pricing configuration is broken, stopping mid-run when a spend cap is reached, failing the run outright if the remaining budget itself cannot be read — are real, reviewed code paths, but none of them were exercised during this evidence-gathering session, because a local, off-platform run never engages the platform's own charging manager. They are documented from the code, not from a live, monetized observation.
Support boundary
Actor support covers this Actor's own input validation, the two-part negative-case behavior, the
100-item ceiling flag, the missing-title handling, the SSRF/host guard, and the Dataset schema described
on this page — all of which are backed by a live, verified run referenced above. It does not cover
whether a specific third-party playlist is a good research target, does not restore access if YouTube
changes this page's markup or its anti-bot posture in a way that breaks extraction, does not cover
private-playlist access (never supported, not a regression), and does not provide legal guidance on how
scraped playlist data may be reused downstream. When reporting a problem, include the run id, the exact
requestedPlaylist value, the status and error returned, and the checkedAt timestamp of the
affected row.