DEV Community

Cover image for How to Export YouTube Playlists Without a Paid API
Tim Zinin
Tim Zinin

Posted on Originally published at apify.com

How to Export YouTube Playlists Without a Paid API

The problem

Playlists are the unit of curation on YouTube — a competitor's "best of" collection, a course creator's syllabus, a brand's campaign list — and getting their contents into a spreadsheet means scrolling a page, copying titles, and losing the ordering that makes a playlist useful. The official YouTube Data API will hand you playlist items, but only after a Google Cloud project, an API key, and quota management you should not need for a read-only, one-off export.

A hand-rolled scraper has subtler traps. A playlist page returns at most 100 videos per request, so a naive parser silently presents a truncated list as complete. YouTube sometimes renders a video entry with no title at all. And a well-formed but nonexistent playlist id answers with HTTP 200 and zero videos — a silent-empty result that looks identical to success unless you know to check.

What the actor does

The YouTube Playlist Scraper takes any public playlist — the list= value or a full youtube.com/playlist?list=... URL, including playlists you do not own — and returns one flat JSON row per video: videoId, title, viewCountText, and position. No API key, no login, no browser; one GET request per playlist.

From the README:

  • Any playlist, no channel resolution. It accepts any list= value and never looks up channels or builds uploads-playlist ids — if all you have is a channel handle, the README points to the sibling youtube-channel-videos-list actor instead. Up to 50 playlists per run, duplicates deduplicated before any request, concurrency 1–20 (default 5).
  • One flat 13-field row regardless of outcome: requestedPlaylist, normalizedPlaylistUrl, videoId, position, title, viewCountText, source, status, confidence, partial, action, error, checkedAt.
  • Honest status boundary. status:"ok" and status:"partial" are the billed, video-bearing outcomes. A well-formed but nonexistent playlist returns status:"empty" with error:null (HTTP 200, genuinely zero videos); a malformed one returns status:"error" naming YouTube's own HTTP 404. Both are free, and the HTTP status is checked before any body parsing so the two can never be confused.
  • A measured 100-item ceiling, disclosed. When a page returns exactly 100 videos, every row is flagged partial:true with action:"flag-possible-truncation" — delivered and billed, but honestly marked as a possible sample rather than the complete list. The actor never paginates past the first page.
  • Missing titles are data, not failures. A title:null row still ships as ok/partial with action:"flag-missing-title", so you keep the video id and decide what to do with it.
  • View counts are display text. Every video row carries confidence:"display-text" as a standing reminder that viewCountText ("2.6M views") is YouTube's own rounded string, not an exact counter.
  • Hardened fetch layer. Host guard (only the three YouTube hosts), DNS-rebinding-safe SSRF protection with pinned addresses, byte caps, and manual redirect re-checks; every request sends the standard consent cookie and hl=en&gl=US so English parsing cannot silently return empty. No transcripts or comments, ever — the paths serving those are disallowed by YouTube's own robots.txt and never requested.

Each run also writes a one-time KVS OUTPUT roll-up: requested/delivered/paid/free/failed counts, replay safety, and any fatal error.

Example: input and output

The prefilled default input is a real, verified 10-video playlist:

{
  "playlists": ["PLBCF2DAC6FFB574DE"],
  "maxConcurrency": 5
}
Enter fullscreen mode Exit fullscreen mode

The README's real happy-path row (position 1 of that playlist, verbatim):

{
  "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"
}
Enter fullscreen mode Exit fullscreen mode

A nonexistent id such as PLBCF2DAC6FFB574DF comes back as one free summary row — status:"empty", videoId:null, action:"skip-not-found" — and a malformed value comes back status:"error" quoting the 404. Both cost nothing.

Pricing and the free limit

Pay-per-event: $0.005 per actor start plus $0.001 per delivered video (result-found), with lower per-event prices on paid Apify tiers (the Store headline works out to $0.85 per 1,000 delivered videos). At list prices, a full 100-video export costs $0.005 + 100 × $0.001 = $0.105. Apify's free plan includes $5 of usage credits per month, which covers about 47 full 100-video runs — roughly 5,000 delivered video rows — before you pay anything. Empty, error, and blocked-host rows are never billed.

Try it

Run the prefilled playlist with zero edits to see the real row shape, then feed your own list: YouTube Playlist Scraper.

For AI agents and MCP

The actor takes JSON in and returns structured JSON via the Apify API, and the README documents running it from an MCP client plus an agent/MCP pattern. The agent contract is two rules: branch on status (never on error alone — an empty row correctly has error:null), and treat partial/action as machine-readable caveats — flag-possible-truncation means "sample of the first 100," and flag-missing-title on an otherwise ok row is a valid outcome to keep, not a parse failure to retry.

Top comments (0)