# YouTube Playlist Scraper (`zinin/youtube-playlist-scraper`) Actor

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.

- **URL**: https://apify.com/zinin/youtube-playlist-scraper.md
- **Developed by:** [Tim Zinin](https://apify.com/zinin) (community)
- **Categories:** Social media, Videos
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.85 / 1,000 video record delivereds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## YouTube Playlist Scraper: Extract Every Video From Any Public YouTube Playlist by ID or URL

![How this Actor turns your input into a decision](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/youtube-playlist-scraper/readme-hero.webp)
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"` with `error:null`. A malformed id gets
  `status:"error"` with the source's own `HTTP 404` quoted 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:true` with `action:"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:null` as data, not as an error — the
  row still ships as `status:"ok"` or `"partial"`, with `action:"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 that `viewCountText` (`"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.txt` disallows `/api/`, `/youtubei/`, `/results` and `/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's `playlists` field will not work — this Actor expects a
  `list=` value or a playlist URL, not a channel identifier, and does not attempt channel resolution as
  a fallback.

![Where this Actor sits in your workflow](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/youtube-playlist-scraper/readme-workflow.webp)

### How to run

1. Populate `playlists` with one or more values — each either a bare `list=` 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.
2. Leave `maxConcurrency` at its default of `5` unless 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.
3. 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.
4. 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.
5. Check `status` before anything else. `"ok"` and `"partial"` are the two billed, video-bearing
   outcomes; `"empty"` and `"error"` are always free and always carry `videoId:null`. Do not branch on
   whether `error` is set — an `"empty"` row correctly has `error:null` even though nothing was found;
   `error` is populated specifically on the `"error"` path, where the source itself refused the request.
6. Check this run's key-value store record `OUTPUT` for 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; `OUTPUT` is the
   roll-up, not a replacement for it.
7. A `partial:true` row is still a delivered, billed row (when its `status` is `"partial"`) — it is not
   an error and it is not free. It means "delivered, with a named, honest caveat attached." Read `action`
   to 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 bare `list=` value
  (e.g. `PLBCF2DAC6FFB574DE`) or a full `youtube.com`/`www.youtube.com`/`m.youtube.com` playlist URL
  containing a `list=` 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 with `status:"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, default `5`. 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.

```json
{
  "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.)*

```json
{
  "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.

```json
{
  "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:

```json
{
  "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
`videoId`s 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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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](https://apify.com/zinin/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](https://apify.com/zinin/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](https://apify.com/zinin/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](https://apify.com/zinin/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](https://apify.com/zinin/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:true`
  rather than presenting it as complete.
- No upload date or video-age field of any kind — unlike the sibling `youtube-channel-videos-list`
  Actor, 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-list` instead.
- 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).
- `maxConcurrency` accepts 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.

# Actor input Schema

## `playlists` (type: `array`):

List of playlists: the list= parameter value (e.g. PLBCF2DAC6FFB574DE) or full URL (https://www.youtube.com/playlist?list=...). The Actor accepts ANY playlist, including curated collections from other channels — not just a channel's own uploads playlist.

## `maxConcurrency` (type: `integer`):

How many playlists to process in parallel. Kept conservative until sustained concurrency is measured separately in production.

## Actor input object example

```json
{
  "playlists": [
    "PLBCF2DAC6FFB574DE"
  ],
  "maxConcurrency": 5
}
```

# Actor output Schema

## `results` (type: `string`):

API URL for the default dataset items produced by this run.

## `runSummary` (type: `string`):

One JSON object written once at the end of this run: requestedCount, deliveredRows, paidRows, freeRows, failedCount, incompleteRows, replaySafe + replaySafeReason, fatalError + fatalErrorReason, generatedAt.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "playlists": [
        "PLBCF2DAC6FFB574DE"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/youtube-playlist-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "playlists": ["PLBCF2DAC6FFB574DE"] }

# Run the Actor and wait for it to finish
run = client.actor("zinin/youtube-playlist-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "playlists": [
    "PLBCF2DAC6FFB574DE"
  ]
}' |
apify call zinin/youtube-playlist-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,zinin/youtube-playlist-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/abLPfH0nmZ2w4C7jH/builds/bFmtae0eU7W6RS5Lp/openapi.json
