# YouTube Channel Videos & Views Scraper - No Blocked Paths (`neverempty/youtube-channel-videos`) Actor

Turn monitoring on and every later run returns only the videos it has not returned before, so you are never charged twice. Every video on a channel with its view count, length and publish date, read only through paths YouTube's robots.txt allows, and every row names the channel that published it.

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

## Pricing

from $0.73 / 1,000 video returneds

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/actors/running/actors-in-store.md#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 Channel Videos & Views Scraper — No Blocked Paths

**Turn monitoring on and every later run returns only the videos it has not returned before, so you are never charged for the same video twice.**

Give it a handle. Get that channel's videos with view count, length and publish time — read **only through paths YouTube's `robots.txt` allows**.

> **Unofficial.** Not affiliated with or endorsed by YouTube or Google.

### 🔴 What makes this one different

**YouTube's `robots.txt` disallows `/feeds/videos.xml` and `/youtubei/`** — the channel RSS feed and the internal player API. Those two are what most YouTube scrapers are built on.

This Actor uses neither. It reads `/@handle/videos` and `/watch?v=`, which are not disallowed, and **the URL builder physically refuses to construct a disallowed address** — a test fails if it ever does.

The allowed path is also the better one: the videos tab returned **30** videos when measured on 2026-09-06; the RSS feed returns **15**.

### 🔴 Every row tells you whose data it is

A YouTube channel page is full of recommendations from *other* channels. Taking the first `"channelId"` on the page gives you **someone else's channel** — a mistake that is invisible in the output, because the numbers still look like numbers.

So this Actor:

1. reads the channel id **only from the page's own `<link rel="canonical">`**,
2. reads video ids **only from inside the videos grid**, not the whole page,
3. and then opens each video and records **the channel that video itself says published it**, next to the one you asked for.

That last one is the column `ownerMatchesRequested`. If it is ever `false`, the row is telling you the data does not belong to the channel you named. When the owner cannot be read it is `null`, not `false` — unknown is not the same as wrong.

### What you get in every row

| Field | What it is |
|---|---|
| `channelInput`, `channelId`, `channelTitle` | What you asked for, and what it resolved to |
| `videoId`, `videoUrl`, `title` | The video |
| `viewCount`, `lengthSeconds` | Views and runtime |
| `publishedAt`, `publishedAtRaw` | ISO instant **and** the site's own string (`2026-09-05T14:18:49-07:00`), so you can check the conversion |
| `ownerChannelId`, `ownerChannelName`, `ownerMatchesRequested` | Who actually published it, and whether that matches |
| `playabilityStatus`, `playabilityReason` | Where the page reports one |
| `videoKey` | What monitoring compares |

### Pricing: per row, plus a monthly fee only if you monitor

- **Per video returned** — you pay for the rows you actually receive.
- **$5 once per calendar month (UTC)**, charged on the first *monitoring* run of that month.

The monthly fee is what pays for watching on the days when nothing was posted. Those runs return no rows and cost you nothing else, so a channel that goes quiet for three weeks does not quietly stop being watched to save money.

**Runs with monitoring turned off never trigger it** — one-off pulls are per-row only. If a charge cannot be made (for example the run hits its own maximum total charge), nothing is recorded and it is attempted again on the next run, so it is never taken twice.

### Monitoring: two meanings of "new"

- **New videos only** — a video is returned once, ever. Already-returned videos are skipped **without opening their page**, so a scheduled run on a quiet channel costs almost nothing.
- **Any change** — a video comes back when its view count or title changes, which is how you build a view-growth history.

### Configure the run

```json
{ "channels": ["@mkbhd"], "maxVideosPerChannel": 30, "maxVideos": 100 }
```

Follow several channels for new uploads:

```json
{ "channels": ["@mkbhd", "@veritasium", "UCLA_DiR1FfKNvjuUpBHmylQ"], "monitoringMode": true, "monitorMode": "new-videos" }
```

Long-form only, from this year, tracking view growth:

```json
{ "channels": ["@mkbhd"], "minLengthSeconds": 62, "publishedAfter": "2026-01-01", "monitoringMode": true, "monitorMode": "any-change" }
```

### What this Actor refuses to guess

A mistyped handle comes back as **`no-such-channel`**, not a guess — YouTube's search (`/results`) is disallowed by robots.txt, so this Actor does not have a way to "find something close" and does not pretend to.

A bot check is reported as **`blocked`**, never as an empty result. Channels with no uploads, filters that matched nothing and unreadable pages each come back as their own row with the reason, and none of them are charged.

### Input reference

Every field is optional; the defaults read the latest videos from one channel.

| Field | Type | Default | What it does |
|---|---|---|---|
| `channels` | array | `["@mkbhd"]` | Handles (@mkbhd), channel ids (UC...) or channel URLs. A mistyped handle is reported as 'no such channel' rather than guessed, because YouTube's robots.txt disallows… |
| `maxVideosPerChannel` | integer | `30` | How far down the channel's videos tab to go. The tab holds about 30 videos (measured 2026-09-06); the RSS feed other scrapers use holds 15, and is disallowed by… |
| `maxVideos` | integer | `100` | How many rows to return in total. You are charged for the rows you actually receive. Each video costs one extra page request for its view count. |
| `monitoringMode` | boolean | `false` | Off = return the videos on the tab. On = remember what was returned and, on later runs, return only what is new. With 'new videos only' below, already-returned videos… |
| `monitorMode` | string | `new-videos` | New videos only = a video is returned once, ever. Any change = a video is returned again when its view count or title changes, which is how you build a view-growth… |
| `resetMonitoringState` | boolean | `false` | Clear the remembered videos before this run, so everything counts as new again. |
| `minViews` | integer | `0` | Keep a video only if it has at least this many views. 0 = no limit. Videos whose view count could not be read are kept, because unknown is not the same as low. |
| `maxViews` | integer | `0` | Keep a video only if it has at most this many views. 0 = no limit. |
| `minLengthSeconds` | integer | `0` | Keep a video only if it runs at least this long. 0 = no limit. Use 62 to skip Shorts. |
| `maxLengthSeconds` | integer | `0` | Keep a video only if it runs at most this long. 0 = no limit. Use 61 to keep only Shorts. |
| `publishedAfter` | string | `(empty)` | Keep only videos published on or after this date. Videos with an unreadable publish time are kept. |
| `onlyOwnedByChannel` | boolean | `false` | Every row already reports whether the video's own page names the channel you asked for. Turn this on to drop the ones that do not match. Rows where the owner could… |
| `keywords` | array | `[]` | Keep a video only if one (Any) or all (All) of these appear in the title or channel name. |
| `keywordMatch` | string | `any` | Any = at least one keyword. All = every keyword. |
| `excludeKeywords` | array | `[]` | Drop a video if any of these appear in the same fields. |
| `useProxy` | boolean | `false` | Off by default. If YouTube answers with a bot check, the Actor switches to a proxy session on its own and tries again; turning this on from the start avoids that… |

### Pricing

Pay per result: you are charged for the video rows you actually receive, plus the $5 monthly access fee described above **only when monitoring mode is on**. Reading pages is free; explanation rows are never charged.

### Every number in this README is checked

The fixtures in `test/fixtures/` are the real pages this Actor was built from (2026-09-06). The test suite re-counts every figure quoted above — 30, 15, 3 — and fails if a disallowed URL can ever be built.

# Actor input Schema

## `channels` (type: `array`):

Handles (@mkbhd), channel ids (UC...) or channel URLs. A mistyped handle is reported as 'no such channel' rather than guessed, because YouTube's robots.txt disallows /results and this Actor does not use search.

## `maxVideosPerChannel` (type: `integer`):

How far down the channel's videos tab to go. The tab holds about 30 videos (measured 2026-09-06); the RSS feed other scrapers use holds 15, and is disallowed by robots.txt anyway.

## `maxVideos` (type: `integer`):

How many rows to return in total. You are charged for the rows you actually receive. Each video costs one extra page request for its view count.

## `monitoringMode` (type: `boolean`):

Off = return the videos on the tab, charged per row only. On = remember what was returned and, on later runs, return only what is new; already-returned videos are skipped without even opening their page. **Monitoring adds a flat access fee of $5, charged once per calendar month (UTC) on the first monitoring run of that month**, on top of the per-row price. That fee is what pays for watching on the days when nothing was posted - those runs return no rows and cost nothing else. Runs with monitoring off never trigger it.

## `monitorMode` (type: `string`):

New videos only = a video is returned once, ever. Any change = a video is returned again when its view count or title changes, which is how you build a view-growth history.

## `resetMonitoringState` (type: `boolean`):

Clear the remembered videos before this run, so everything counts as new again.

## `minViews` (type: `integer`):

Keep a video only if it has at least this many views. 0 = no limit. Videos whose view count could not be read are kept, because unknown is not the same as low.

## `maxViews` (type: `integer`):

Keep a video only if it has at most this many views. 0 = no limit.

## `minLengthSeconds` (type: `integer`):

Keep a video only if it runs at least this long. 0 = no limit. Use 62 to skip Shorts.

## `maxLengthSeconds` (type: `integer`):

Keep a video only if it runs at most this long. 0 = no limit. Use 61 to keep only Shorts.

## `publishedAfter` (type: `string`):

Keep only videos published on or after this date. Videos with an unreadable publish time are kept.

## `onlyOwnedByChannel` (type: `boolean`):

Every row already reports whether the video's own page names the channel you asked for. Turn this on to drop the ones that do not match. Rows where the owner could not be read are kept, because unknown is not the same as wrong.

## `keywords` (type: `array`):

Keep a video only if one (Any) or all (All) of these appear in the title or channel name.

## `keywordMatch` (type: `string`):

Any = at least one keyword. All = every keyword.

## `excludeKeywords` (type: `array`):

Drop a video if any of these appear in the same fields.

## `useProxy` (type: `boolean`):

On by default, and worth leaving on. Requested from Apify's own IP, YouTube returns the watch page with HTTP 200 but with the player data missing, which makes every video unreadable; through a proxy the data is there. If a page comes back without it, the run switches to a proxy session and tries again.

## Actor input object example

```json
{
  "channels": [
    "@mkbhd"
  ],
  "maxVideosPerChannel": 30,
  "maxVideos": 100,
  "monitoringMode": false,
  "monitorMode": "new-videos",
  "resetMonitoringState": false,
  "minViews": 0,
  "maxViews": 0,
  "minLengthSeconds": 0,
  "maxLengthSeconds": 0,
  "onlyOwnedByChannel": false,
  "keywords": [],
  "keywordMatch": "any",
  "excludeKeywords": [],
  "useProxy": true
}
```

# Actor output Schema

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

One row per video: what you asked for (the handle, id or URL) and the channel it resolved to, taken from the page's own canonical link rather than the first channel id on the page, because a channel page is full of recommendations from other channels; the video id, URL, title, publish time as both an ISO instant and the site's own string, length in seconds and view count; the channel that actually published the video, with a flag saying whether it matches the one you asked for, so you can check the data belongs to who you think; and the playability status where the page reports one. Channels that do not exist, pages that returned a bot check, filters that matched nothing and videos that could not be read come back as their own rows and are not charged.

# 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 = {
    "channels": [
        "@mkbhd"
    ],
    "publishedAfter": ""
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/youtube-channel-videos").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 = {
    "channels": ["@mkbhd"],
    "publishedAfter": "",
}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/youtube-channel-videos").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 '{
  "channels": [
    "@mkbhd"
  ],
  "publishedAfter": ""
}' |
apify call neverempty/youtube-channel-videos --silent --output-dataset

```

## MCP server setup

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

```

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/WMgB7drO6K99kIqH8/builds/avL4gPdMvGDmL454P/openapi.json
