# YouTube Scraper: Search, Channel, Video and Playlist Data (`datamule/youtube-data-scraper`) Actor

Get YouTube search results, a channel's videos/shorts/live, video details or playlist items as JSON rows: title, channel, views, duration, publish date, URL. No API key or login. $0.0004 per result row.

- **URL**: https://apify.com/datamule/youtube-data-scraper.md
- **Developed by:** [Datamule](https://apify.com/datamule) (community)
- **Categories:** Social media, Developer tools, AI
- **Features:** Limited permissions
- **Stats:** 7 total users, 2 monthly users, 98.8% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.40 / 1,000 youtube results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## YouTube Scraper — Search, Channels, Videos and Playlists

Get public YouTube data as clean JSON rows: keyword search results, a channel's videos/shorts/live streams, details for specific video URLs, or every item of a playlist. For marketers, researchers and developers who need YouTube metadata without an API key, quota or login. **Price: $0.0004 per result row ($0.40 per 1,000), no start fee.** Failed or empty lookups are never billed.

### Pick the right mode

| You have… | Set `mode` | Fill field |
|---|---|---|
| a keyword | `search` (default) | `searchQueries` |
| video links or IDs | `videos` | `videoUrls` |
| a channel handle/URL | `channel` | `channelUrls` (+ `channelTabs`) |
| a playlist link/ID | `playlist` | `playlistUrls` |

An empty `{}` input runs `search` for "python tutorial" with up to 100 rows (about $0.04). Set `maxItems` lower for a cheap test.

### Example input

```json
{
  "mode": "channel",
  "channelUrls": ["@YouTube"],
  "channelTabs": ["videos"],
  "maxItems": 25
}
```

### Example output

One row from a real run of `{"mode": "search", "searchQueries": ["python tutorial"]}` (selected fields):

```json
{
  "type": "video",
  "id": "fWjsdhR3z3c",
  "url": "https://www.youtube.com/watch?v=fWjsdhR3z3c",
  "title": "Learn Python in Less than 10 Minutes for Beginners (Fast & Easy)",
  "channelId": "UCuudpdbKmQWq2PPzYgVCWlA",
  "channelName": "Indently",
  "durationSeconds": 630,
  "viewCount": 1805262,
  "viewCountText": "1,805,262 views",
  "publishedText": "5 years ago",
  "publishedApprox": "2021",
  "publishedApproxPrecision": "year",
  "_mode": "search",
  "_input": "python tutorial"
}
```

### Output fields

`type` (video / short / channel / playlistItem), `id`, `url`, `title`, `description`, `channelId`, `channelName`, `channelUrl`, `durationSeconds`, `durationText`, `viewCount`, `viewCountText`, `likeCount`, `publishedAt`, `publishedText`, `publishedApprox`, `publishedApproxPrecision`, `category`, `keywords`, `thumbnailUrl`, `isLive`, `playlistId`, `playlistPosition`, plus provenance fields `_mode`, `_source`, `_input`, `_language`, `_region`. Fields YouTube does not serve for a row are omitted or null.

### Limits

- Public data only; private, members-only, login-gated and region-blocked videos are skipped.
- Search/channel lists carry YouTube's approximate counts and relative dates; `mode: "videos"` adds exact fields when YouTube serves the player to the run.
- `maxItems` (default 100, max 100,000) caps rows and cost for the whole run.

### Use from an AI agent / API

**Python (apify-client)**

```python
from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("datamule/youtube-data-scraper").call(
    run_input={"mode": "search", "searchQueries": ["python tutorial"], "maxItems": 20},
    max_total_charge_usd=0.05,   # hard cost cap for the run
)
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)
```

**MCP (Claude, Cursor, any MCP client):** add `https://mcp.apify.com/?tools=datamule/youtube-data-scraper` as a remote MCP server with your Apify token as a Bearer header. The Actor then appears as one tool whose arguments are the input fields above; call it with the example input.

**Agent rules:** pass JSON matching the input schema; `{}` is valid and returns real default data; always set a maximum charge per run; read results from the run's default dataset (one row per result).

### FAQ

**Do I need a YouTube API key or login?** No.
**What does `{}` return?** Up to 100 search results for "python tutorial".
**I pasted channel URLs but left mode on search. What happens?** The run switches to the mode matching the one URL field you filled and logs a warning.
**How do I cap cost?** Set `maxItems` and the run's maximum charge.
**Is the like count always present?** Only when YouTube serves the player payload; otherwise it is omitted, not guessed.

### Modes

| Mode | Input field | What you get |
|---|---|---|
| `search` | `searchQueries` | Keyword search results, paginated — one row per video |
| `videos` | `videoUrls` | One row per resolvable exact video ID (an unavailable ID yields no row and is never charged): exact player detail (exact view/like counts, publish date, category, keywords) when YouTube serves the player, otherwise exact-ID list metadata |
| `channel` | `channelUrls` | A channel's **Videos**, **Shorts** and **Live** tabs, plus a channel summary row |
| `playlist` | `playlistUrls` | Every item of a playlist, in order, with its position |

Handles (`@name`), `/channel/UC…`, `/c/…`, `/user/…`, `youtu.be/…`, `/shorts/…`,
`?v=…` and bare 11-character IDs are all accepted and normalised for you.

Forgot to switch **Mode**? If the selected mode's field is empty but exactly one
of the URL fields (videos, channels, playlists) has entries (e.g. channel URLs pasted
with Mode still on `search`), the run uses that mode and logs a warning instead of
failing. The same applies when the search box still holds only the untouched sample
query (`python tutorial`): your pasted URLs win. It never switches to `search`, so
leftover sample queries are never billed.
If the sample query is untouched and URLs are in two or more of those fields, the
run stops with an input error naming them (nothing fetched or charged): pick the
Mode you want or keep URLs in one field.

### Pricing — pay per result, nothing else

There is **no actor-start fee**. You are charged only for rows actually
delivered: every `video`, `short`, `channel` or `playlistItem` row is one
`result` event at **$0.0004** ($0.40 per 1,000).

| Rows delivered (`maxItems`) | Actor charge (USD) |
|---:|---:|
| 25 | $0.01 |
| 100 (default) | $0.04 |
| 1,000 | $0.40 |

A video that is unavailable, a channel that will not resolve, or a playlist that
returns nothing emits **no row and costs you nothing**. `maxItems` is a hard
ceiling on rows (and so on cost) for the whole run. If you also set a positive
Apify maximum charge per run, the Actor stops when it is reached and keeps the
rows already delivered.

### Use it from your own code (Python, standard library only)

Latest 10 videos of a channel into your pipeline: start a run with an explicit
Mode, a `maxItems` ceiling and a per-run cost cap (`maxTotalChargeUsd`), wait for
it to finish, then read the dataset. Set `APIFY_TOKEN` in your environment.
The run stops when the cap is reached, so raise `maxTotalChargeUsd` if you raise
`maxItems`.

```python
import json, os, time, urllib.request

TOKEN = os.environ["APIFY_TOKEN"]
API = "https://api.apify.com/v2"

def call(method, path, body=None):
    req = urllib.request.Request(
        API + path, method=method,
        data=None if body is None else json.dumps(body).encode(),
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"})
    with urllib.request.urlopen(req) as r:
        return json.load(r)

run_input = {"mode": "channel", "channelUrls": ["@YouTube"],
             "channelTabs": ["videos"], "maxItems": 10, "includeChannelInfo": False}

## Start the run; maxTotalChargeUsd caps what this run can cost you.
run = call("POST", "/acts/datamule~youtube-data-scraper/runs?maxTotalChargeUsd=0.01", run_input)["data"]

## Poll until the run finishes (waitForFinish holds each request up to 60 s).
while run["status"] in ("READY", "RUNNING"):
    run = call("GET", f"/actor-runs/{run['id']}?waitForFinish=60")["data"]
    time.sleep(1)
if run["status"] != "SUCCEEDED":
    raise SystemExit(f"run {run['id']} ended {run['status']}")

items = call("GET", f"/datasets/{run['defaultDatasetId']}/items?clean=true&fields=id,title,url,viewCount,publishedText")
for v in items:
    print(v.get("publishedText"), v.get("viewCount"), v["title"], v["url"])
```

Real output from a test run on 2026-09-27 (first 3 of 10 rows; 10 rows = $0.004 at the base price, less on volume tiers):

```
1 month ago 2200000 Keira Riff reacts to her Watch History https://www.youtube.com/watch?v=wGA27zJEnaU
1 month ago 4099999 Sam Smith reacts to their Watch History https://www.youtube.com/watch?v=NDHk8bGGq74
1 month ago 7700000 Marina Sena reacts to her Watch History https://www.youtube.com/watch?v=FNoK3gc-Jk0
```

#### Optional: on repeat runs, print only newly observed videos

Append this to the recipe above. The first run for an input only saves a
baseline. Later runs print IDs not seen before for that exact `run_input`. The
snapshot is a local file you own. It is updated atomically, and only after a
successful, non-empty result.

```python
import hashlib

def newly_observed(run_input, items, path="seen_videos.json"):
    ids = [v.get("id") for v in items]
    if not ids or not all(isinstance(i, str) and i for i in ids):
        raise SystemExit("empty or invalid result; snapshot left unchanged")
    key = hashlib.sha256(json.dumps(run_input, sort_keys=True).encode()).hexdigest()[:16]
    state = {}
    if os.path.exists(path):
        with open(path) as f:
            state = json.load(f)  # a corrupt file stops here, unchanged
    first_run = key not in state
    seen = set(state.get(key, []))
    new = [] if first_run else [v for v in items if v["id"] not in seen]
    state[key] = sorted(seen | set(ids))
    with open(path + ".tmp", "w") as f:
        json.dump(state, f)
    os.replace(path + ".tmp", path)  # atomic: old snapshot survives a crash
    return first_run, new

first_run, new = newly_observed(run_input, items)
print("baseline saved, nothing reported" if first_run else f"{len(new)} newly observed")
for v in new:
    print(v["id"], v["title"], v["url"])
```

"Newly observed" means that this ID was not in any earlier result for this
input (changing any input field, even `maxItems`, starts a new baseline). It does not mean the video was newly published. Pinned or re-ranked older
videos can show up this way. A video that is missing from a later run has not
necessarily been deleted, and a run limited by `maxItems` or the cost cap only
covers the channel's most recent window, not its full history. Run it often
enough that the channel posts fewer than `maxItems` new videos between runs,
or you will miss some.

### Output

Every row carries `type`, `id`, `url`, `title` and provenance (`_mode`,
`_source`, `_input`, `_language`, `_region`). Depending on the type you also get
`channelId` / `channelName` / `channelUrl`, `durationSeconds` / `durationText`,
`viewCount` / `viewCountText`, `likeCount`, `publishedAt` / `publishedText` /
`publishedApprox` + `publishedApproxPrecision`,
`category`, `keywords`, `thumbnailUrl` and `playlistId` / `playlistPosition`.

Counts that YouTube publishes only as human text ("1.2M views") are parsed to an
integer **and** kept verbatim, so you never have to guess how a number was rounded.

### Notes and honest limits

- Only **public** data is read. Private, members-only, age-gated-login and
  region-blocked videos are skipped, not faked.
- Search and channel-tab list rows carry YouTube's own approximate view counts and
  relative publish text ("8 months ago").
- To sort or filter those rows by date in a spreadsheet, use the **Published
  (approx.)** column (`publishedApprox`): the relative text turned into a date on
  the run day — `2024` for "2 years ago", `2026-07` for "2 months ago",
  `2026-09-26` for "3 days ago". `publishedApproxPrecision` says which unit it is
  (YouTube rounds ages down, so it can be off by one unit), or `exact` when the
  exact `publishedAt` date is known. It is filled for English (`language: "en"`,
  the default) and costs nothing extra.
- `mode: "videos"` returns one row for each **resolvable** ID you list, and every
  delivered row belongs to the exact ID you asked for. It returns
  YouTube's exact player fields (`viewCount`, `likeCount`, `publishedAt`,
  `uploadedAt`, `category`, `keywords`) when YouTube serves the player payload to
  the run's IP. When the player is refused — which is common from plain
  datacenter IPs — the row falls back to the same exact-ID list metadata as
  search (`viewCountText`, `publishedText`) and the exact-only fields are simply
  omitted rather than guessed. Either way the row is real data for the ID you
  asked for, never a substitute video.
- If a run resolves zero rows it exits non-zero instead of reporting an empty
  success, so a broken input never looks like a clean run.

This is an independent implementation built against public YouTube client
behaviour. It is not affiliated with, endorsed by, or derived from YouTube,
Google, or any other Apify Actor.

# Actor input Schema

## `mode` (type: `string`):

Which job to run. 'search' = keyword search results (uses searchQueries). 'videos' = details for exact video URLs/IDs (uses videoUrls). 'channel' = a channel's videos/shorts/live (uses channelUrls). 'playlist' = every item of a playlist (uses playlistUrls). Only the field of the chosen mode is read. Default 'search'.

## `searchQueries` (type: `array`):

Keyword queries (mode = search). Each query is paginated up to 'Max items per target'. Default: \["python tutorial"] so an empty {} input returns real results.

## `videoUrls` (type: `array`):

Watch URLs, Shorts URLs, youtu.be links or bare 11-character video IDs (mode = videos). One row per resolvable ID; unavailable IDs return nothing and are not charged. Exact view/like counts and publish date are included when YouTube serves the player to the run; otherwise approximate view text and relative publish text.

## `channelUrls` (type: `array`):

Channel handles (@name), /channel/UC… URLs, /c/… or /user/… URLs, or bare UC… channel IDs (mode = channel). Handles are resolved automatically.

## `playlistUrls` (type: `array`):

Playlist URLs (…?list=PL…) or bare playlist IDs (mode = playlist). Items come back in playlist order with playlistPosition.

## `channelTabs` (type: `array`):

Which channel tabs to read when mode = channel: videos, shorts, streams (live). Shorts rows have type 'short'; videos and live streams have type 'video'.

## `maxItems` (type: `integer`):

Hard cap on total rows delivered AND billed in the whole run, across all targets (1 row = 1 result = $0.0004 base price; 100 rows ≈ $0.04). Default 100.

## `maxItemsPerTarget` (type: `integer`):

Optional cap per query / channel tab / playlist so one big target cannot use the whole budget. Default: same as Max items.

## `language` (type: `string`):

Interface language (hl), e.g. 'en', 'de', 'ja'. Affects titles and human-readable metadata text.

## `region` (type: `string`):

Two-letter region code (gl) used for results, e.g. 'US', 'GB', 'SG'. Affects ranking and availability.

## `includeChannelInfo` (type: `boolean`):

When on, mode = channel also emits one 'channel' row per channel (title, description, handle URL, keywords) in addition to its videos.

## `timeoutSecs` (type: `integer`):

Per-request timeout for each YouTube call, in seconds.

## `maxRetries` (type: `integer`):

Bounded retry attempts per request for transient failures (HTTP 429/5xx and network errors), with exponential backoff.

## `proxyConfiguration` (type: `object`):

Optional proxy for YouTube requests. All four modes work without a proxy; enable one only if you want IP rotation on very large runs. Proxy traffic is billed by the Apify platform on top of this Actor's per-result price.

## Actor input object example

```json
{
  "mode": "channel",
  "searchQueries": [
    "python tutorial",
    "rust async"
  ],
  "videoUrls": [
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "dQw4w9WgXcQ"
  ],
  "channelUrls": [
    "@YouTube",
    "https://www.youtube.com/@NASA"
  ],
  "playlistUrls": [
    "https://www.youtube.com/playlist?list=PLFgquLnL59alCl_2TQvOiD5Vgm1hCaGSI"
  ],
  "channelTabs": [
    "videos",
    "shorts"
  ],
  "maxItems": 25,
  "maxItemsPerTarget": 20,
  "language": "en",
  "region": "US",
  "includeChannelInfo": true,
  "timeoutSecs": 60,
  "maxRetries": 4,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

No description

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("datamule/youtube-data-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 = {}

# Run the Actor and wait for it to finish
run = client.actor("datamule/youtube-data-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 '{}' |
apify call datamule/youtube-data-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,datamule/youtube-data-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/KVbtyZJNNViWsy8jx/builds/QDLvyV1OIwp82qAOn/openapi.json
