# YouTube Channel Intel (`zinin/youtube-channel-intel`) Actor

Public intel card for any YouTube channel: subscribers, total views, video count, join date, country, full description, business email (if the author put one in the description), and every external link (site, Twitter/X, Instagram, Discord, ...). No API key, no login.

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

## Pricing

from $5.10 / 1,000 channel 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 Channel Intel: The Public /about-Page Card for Any YouTube Channel

![How this Actor turns your input into a decision](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/youtube-channel-intel/readme-hero.webp)
Paste `@handle`, a full channel URL, or a bare `UC...` channel ID, and this Actor returns the same "About"
card a human visitor sees on the channel's own page: subscriber/view/video counts, join date, country,
the full description text, any business email the channel owner typed into that description, and every
external link the channel lists — decoded to its real destination, never YouTube's redirect wrapper. No
login. No API key. No comment, transcript or search scraping — those are the paths YouTube's own
`robots.txt` closes; `/about` is not one of them, verified live below.

The billable outcome is a **delivered channel record**: a channel that resolved and whose card was
actually read, whether or not it happened to publish an email — subscribers, description and links alone
are useful for lead generation even with an empty `emailsFound`. A channel that does not resolve — a
wrong handle, a deleted channel, or a syntactically valid but nonexistent ID — is always free, with a
named, honest reason on the row, never charged.

### What you get

- **Subscribers, total views, video count** — YouTube's own displayed text, read from the one place on
  the page that actually belongs to the requested channel, not the six lookalike-widget numbers sitting
  right next to it under the same field name. See Evidence and boundaries for the exact trap this Actor
  was built to avoid.
- **Join date and country**, when the channel owner has set them. `null`, not a guess, when they have not.
- **The full description text**, exactly as the channel author wrote it — every paragraph, every line
  break, preserved verbatim. A real captured example below runs to five full paragraphs with no
  truncation applied by this Actor.
- **A business email, extracted only from that description text**, if and only if the owner typed one in.
  Never from YouTube's separate, login-gated "Business inquiries" button, which this Actor does not and
  will not attempt to bypass. An empty `emailsFound` array on a channel that clearly resolved is the
  normal, honest outcome for most channels — verified live below on two real, active, high-subscriber
  channels that simply never put an email in their own copy.
- **Every external link the channel lists** — site, Twitter/X, Instagram, Discord, Reddit, a second
  channel, whatever the owner added — with the real destination URL decoded out of YouTube's own
  `youtube.com/redirect?...&q=` wrapper, byte-for-byte verified against the raw page below.
- **An honest `found:false` with `error:null`** for a channel that provably does not exist, distinguished
  from a genuine technical failure by a populated `error` string. Neither case is ever charged.
- **Runs on Apify**: schedule it, call it from the REST API or an MCP client, export to JSON, CSV or
  Excel, or push results straight into a CRM or lead pipeline.

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

### Who uses it

- **B2B lead-gen agencies, SDRs and marketers** building an influencer or B2B-channel outreach list who
  need a business email, site or social handle for cold contact, without opening each channel's page by
  hand.
- **Partnership and sponsorship teams** doing first-pass triage on a list of candidate channels before a
  human reviews each one — subscriber count and description alone are often enough to cut a list down
  before anyone opens a browser tab.
- **Researchers and analysts** who need a channel's self-reported metadata (join date, country,
  description) captured as a stable, timestamped record rather than screenshotted by hand.
- **Developers and workflow builders** who want a flat, 16-field JSON contract — `found`, `error`,
  `emailsFound`, `externalLinks` — that an agent or n8n node can branch on directly, without parsing
  YouTube's own HTML or handling its embedded JSON shape by hand.

### How to run

1. Open the Actor on Apify and click **Try for free** — no card required on the free plan.
2. Paste channels into the **YouTube-каналы** (`channels`) field: `@handle`, a full channel URL
   (`https://www.youtube.com/@handle`, `.../channel/UC...`, `.../c/<name>`, `.../user/<name>`), or a bare
   `UC...` channel ID. One per line, up to 100 per run. The prefilled `["@mkbhd", "@ycombinator",
   "@semrush"]` is a live, working default — running it as-is produces a real, non-empty, three-row
   result at no cost beyond the platform's own run-start charge.
3. Optionally set `maxConcurrency` (default 5, range 1-20) — see Operating guide for why the default is
   deliberately conservative.
4. Press **Start**, or launch the same run from the Apify API, a scheduled Task, or an MCP client.
5. Read the Dataset — one row per requested channel, in the order they finished, not necessarily the
   order submitted, since channels are processed concurrently.
6. Route every `found:true` row into your CRM or outreach list; treat every `found:false` row as a free,
   documented non-match — the `error` field tells you whether it was a confirmed absence or a real
   failure.
7. 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, alongside the Dataset rows.

There is no separate "test mode." Every run against a real channel produces real data; the difference
between a free and a billed row is entirely `found`, decided by this Actor's own resolution logic, not by
anything you configure.

### Pricing

**$0.005 per run start, plus $0.006 per delivered channel record.** The delivered channel record is the
one and only billable event on this page — internally metered as the platform event `result-found`, fired
exactly once per row where `found:true`, inside the same atomic push-and-charge call that writes the row
to the Dataset (see Evidence and boundaries for why that matters under concurrency).

This Actor's own cost of goods is close to zero — one HTTP GET of roughly 2.5-2.6MB per channel, parsed
locally, no paid upstream API, no LLM, no proxy, no headless browser. The price is for the parsing,
identity-verification and delivery guarantees described on this page, not a resold data bill.

A **delivered channel record** is billed the moment a channel resolves and its card is read — regardless
of whether an email was found. A channel that fails to resolve, whether because it genuinely does not
exist or because the input itself was rejected before any request was made, is always free, with a named
reason on the row. A run of 20 channels where 14 resolve and 6 do not is billed for 14 delivered records
plus one run start, never for the 6 that came back empty.

### Input contract

- **`channels`** (required, array of strings, up to 100 per run, `editor: stringList`) — `@handle`, a
  full `youtube.com` URL in any of four shapes (`/@handle`, `/channel/UC...`, `/c/<name>`,
  `/user/<name>`), or a bare `UC...` channel ID. This Actor normalizes the value itself; you do not need
  to pre-format it. Any element that looks like a URL (starts with `http`) but does not resolve to
  `youtube.com`, `www.youtube.com` or `m.youtube.com` is rejected before any network request is made —
  see the SSRF-guard example under Real happy, partial and failure output.
- **`maxConcurrency`** (optional, integer, default `5`, range `1-20`) — how many channels this run reads
  at once. The default is conservative on purpose: live stability was verified at roughly one request per
  second, sequentially, ten times in a row with zero rate-limit responses — not at high sustained
  parallelism. Raising this is honored, but it is your own exposure to increase, not a limit this Actor
  has separately load-tested for you.

```json
{
  "channels": ["@mkbhd", "@ycombinator", "@semrush"]
}
```

```json
{
  "channels": [
    "https://www.youtube.com/channel/UCBJycsmduvYEL83R_U4JriQ",
    "https://www.youtube.com/user/marquesbrownlee",
    "@this-handle-should-not-exist-zzz123"
  ],
  "maxConcurrency": 10
}
```

*(Both blocks are contract illustrations built from `.actor/input_schema.json`, not run output — an input
has no result of its own to show. The second one deliberately mixes a bare channel ID, a legacy `/user/`
URL and a handle that does not exist, to show three of the four accepted input shapes in one call; every
value in it was independently exercised in the real runs quoted below.)*

### Real happy, partial and failure output

Every row shares one flat, 16-field shape (`input`, `found`, `channelId`, `handle`, `canonicalChannelUrl`,
`title`, `subscriberCountText`, `viewCountText`, `videoCountText`, `joinedDateText`, `country`,
`description`, `emailsFound`, `externalLinks`, `error`, `checkedAt`) regardless of outcome — the exact
field list `main.js`'s `buildRow()` always returns, never a partial or extended shape depending on the
path taken. Every JSON block below is copied verbatim from this Actor's own local runs (`apify run --purge`) against the real, live source, on 2026-08-17/18 — nothing here is constructed or filled in from
memory.

#### Happy path — a resolved channel with a published business email

```json
{
  "input": "@mkbhd",
  "found": true,
  "channelId": "UCBJycsmduvYEL83R_U4JriQ",
  "handle": "@mkbhd",
  "canonicalChannelUrl": "http://www.youtube.com/@mkbhd",
  "title": "Marques Brownlee",
  "subscriberCountText": "21.1M subscribers",
  "viewCountText": "5,533,853,712 views",
  "videoCountText": "1,841 videos",
  "joinedDateText": "Joined Mar 21, 2008",
  "country": "United States",
  "description": "MKBHD: Quality Tech Videos | YouTuber | Geek | Consumer Electronics | Tech Head | Internet Personality!\n\nbusiness@MKBHD.com\n\nNYC",
  "emailsFound": ["business@MKBHD.com"],
  "externalLinks": [
    { "title": "Twitter", "url": "http://twitter.com/MKBHD" },
    { "title": "Instagram", "url": "http://instagram.com/MKBHD" },
    { "title": "The Studio", "url": "http://youtube.com/c/TheStudio" },
    { "title": "Reddit", "url": "http://reddit.com/r/MKBHD" },
    { "title": "Discord", "url": "http://discord.gg/MKBHD" }
  ],
  "error": null,
  "checkedAt": "2026-08-17T20:22:35.100Z"
}
```

This is a **billed** row (`found:true`). Every field here was read from the real page — see Evidence and
boundaries for the raw HTML this exact row was built from, including proof that the `Twitter` URL above
was decoded out of a genuine `youtube.com/redirect?...&q=http%3A%2F%2Ftwitter.com%2FMKBHD` wrapper on the
live page, not copied from the visible link text.

#### Happy path — a resolved channel with no email (an honest empty result, not a bug)

```json
{
  "input": "@ycombinator",
  "found": true,
  "channelId": "UCcefcZRL2oaA_uBNeo5UOWg",
  "handle": "@ycombinator",
  "canonicalChannelUrl": "http://www.youtube.com/@ycombinator",
  "title": "Y Combinator",
  "subscriberCountText": "2.35M subscribers",
  "viewCountText": "105,903,453 views",
  "videoCountText": "892 videos",
  "joinedDateText": "Joined Oct 24, 2013",
  "country": "United States",
  "description": "All the world is changing around technology and you may contribute a line of code. What will yours be? \n\nSubscribe for startup advice, founder stories, and a look inside Y Combinator.\n\nWhat is Y Combinator?\nWe invest $500,000 in every startup and work intensively with the founders for three months. For the life of their company, founders have access to the most powerful community in the world, essential advice, later-stage funding and programs, recruiting resources, and exclusive deals. \n\nVisit ycombinator.com to learn more.\n\n",
  "emailsFound": [],
  "externalLinks": [
    { "title": "Apply to YC", "url": "http://ycombinator.com/apply" },
    { "title": "Subscribe to our newsletter", "url": "https://www.ycombinator.com/subscribe" }
  ],
  "error": null,
  "checkedAt": "2026-08-17T20:22:35.123Z"
}
```

This row is also **billed** (`found:true`, same price as the row above) — a 2.35-million-subscriber
channel with a full description and two useful links resolved cleanly, it simply never published an email
in its own copy. This is the exact case the task behind this page asked to be verified, not assumed: this
row is a real, live result, run specifically to confirm that an empty `emailsFound` on an active, resolved
channel is an honest outcome of the source, not a parsing failure. A second channel in the same batch
(`@semrush`, 221K subscribers, a five-paragraph description, seven external links) resolved the same way
with the same empty `emailsFound` — the pattern held twice, not once.

#### Confirmed-absence path — a handle that does not exist (HTTP 404)

```json
{
  "input": "@this-handle-should-not-exist-zzz123",
  "found": false,
  "channelId": null,
  "handle": "@this-handle-should-not-exist-zzz123",
  "canonicalChannelUrl": null,
  "title": null,
  "subscriberCountText": null,
  "viewCountText": null,
  "videoCountText": null,
  "joinedDateText": null,
  "country": null,
  "description": null,
  "emailsFound": [],
  "externalLinks": [],
  "error": null,
  "checkedAt": "2026-08-17T20:22:52.424Z"
}
```

**Free.** `error:null` here is deliberate, not a bug: a `curl` request against the exact same URL
(`youtube.com/@this-handle-should-not-exist-zzz123/about`) run independently of this Actor, at the same
session, returned a plain `404` — a confirmed, unambiguous absence, not a technical failure. This Actor
treats an HTTP 404 on `/about` as an honest negative and never charges for it.

#### Confirmed-absence path — a syntactically valid but nonexistent channel ID (HTTP 200, a different signal)

```json
{
  "input": "UCxxxxxxxxxxxxxxxxxxxxxx",
  "found": false,
  "channelId": null,
  "handle": null,
  "canonicalChannelUrl": null,
  "title": null,
  "subscriberCountText": null,
  "viewCountText": null,
  "videoCountText": null,
  "joinedDateText": null,
  "country": null,
  "description": null,
  "emailsFound": [],
  "externalLinks": [],
  "error": null,
  "checkedAt": "2026-08-17T20:22:52.513Z"
}
```

**Also free** — but for a genuinely different reason, and this Actor checks for both. An independent
`curl` against `youtube.com/channel/UCxxxxxxxxxxxxxxxxxxxxxx/about`, run in the same session, returned
`HTTP 200` with a 755,837-byte body containing the literal text **"This channel does not exist"** — a
clean success status code, wrapping an honest negative in the body text instead of the status line. A
status-code-only check would misread this as a live channel with an empty card. This Actor checks the
body text for that exact phrase *before* attempting any JSON extraction, specifically so this case is
never confused with a real parsing failure, and specifically so it is never billed.

#### Failure path — an input rejected before any request was made (SSRF/host-confusion guard)

```json
{
  "input": "https://evil.example/@fake",
  "found": false,
  "channelId": null,
  "handle": null,
  "canonicalChannelUrl": null,
  "title": null,
  "subscriberCountText": null,
  "viewCountText": null,
  "videoCountText": null,
  "joinedDateText": null,
  "country": null,
  "description": null,
  "emailsFound": [],
  "externalLinks": [],
  "error": "blocked host (not a youtube.com URL): evil.example",
  "checkedAt": "2026-08-17T20:22:52.089Z"
}
```

**Free**, and structurally different from the two confirmed-absence rows above: `error` is populated here,
because this row represents a real validation failure, not a source-confirmed negative. This Actor never
sends a request to `evil.example` at all — the host is checked against an allow-list of exactly three
YouTube hostnames before any URL is built, and a mismatch is rejected client-side. The `error` string
always starts with `blocked host` for this specific case, a stable prefix a downstream workflow can match
on.

### Field dictionary

| Field | Meaning | Important boundary |
|---|---|---|
| `input` | The channel value exactly as submitted | Never normalized or rewritten in this field, even when the request was |
| `found` | The billing boundary | `true` only on a resolved, delivered, billed channel record; `false` on every unresolved, error and invalid-input row |
| `channelId` | The canonical `UC...` id YouTube itself assigns the channel | `null` on every unresolved row |
| `handle` | The `@handle` form, when the input was — or normalized to — a handle | `null` for bare channel IDs and for legacy `/c/`/`/user/` paths, which carry no handle form |
| `canonicalChannelUrl` | YouTube's own canonical URL for the resolved channel | `null` on every unresolved row; not guaranteed to echo the input's own URL shape |
| `title` | The channel's display name | Read from the page's `og:title` meta tag specifically — see Evidence and boundaries for why not from the same JSON block as everything else |
| `subscriberCountText` | YouTube's own rounded display text | Never an exact number — the source itself does not publish one. Read only from the channel's own record, never the six lookalike-widget values sharing the same field name on the same page — see Evidence and boundaries |
| `viewCountText` | Same rounded-text convention, total views | `null` when the source omits it |
| `videoCountText` | Same rounded-text convention, video count | `null` when the source omits it |
| `joinedDateText` | The channel's self-reported join date, as text (`"Joined Mar 21, 2008"`) | `null` when the channel has not set it, or it is absent from the page |
| `country` | The channel's self-reported country | `null` when unset — never inferred from language, currency or any other signal |
| `description` | The full "About" description text, exactly as authored | No truncation applied by this Actor; preserved with original line breaks |
| `emailsFound` | Emails matched by a regex scan of `description` only | Commonly empty — most channels never publish one there. Never sourced from the login-gated "Business inquiries" button, and never guessed |
| `externalLinks` | `{title, url}` pairs for every link the channel lists | `url` is the real destination, decoded out of YouTube's `youtube.com/redirect?...&q=` wrapper when the source used one; passed through unchanged for the (also observed) direct-link case |
| `error` | `null` on both success and a confirmed-absence negative; populated only for a genuine technical or validation failure | This is what separates "the source confirmed this channel does not exist" from "something actually went wrong" — see Decision routing |
| `checkedAt` | ISO 8601 timestamp of when this row was produced | Shared granularity across a run, not per-request network latency |

### Evidence and boundaries

**The `subscriberCountText` trap, measured with real numbers.** On a single live fetch of `@mkbhd`'s
`/about` page, the literal key `"subscriberCountText"` appears **seven times**, in two different JSON
shapes. Six are object-form, `{"accessibility":{"accessibilityData":{"label":"1.13 million
subscribers"}},"simpleText":"1.13M subscribers"}` and five siblings reading `1.27M`, `523K`, `541K`,
`232K` and `905K` — every one of those belongs to the page's own "similar channels" recommendation widget,
and every one of them is **someone else's number**. The seventh occurrence is string-form,
`"subscriberCountText":"21.1M subscribers"`, sitting inside the one JSON object
(`aboutChannelViewModel`) that actually describes the requested channel. A parser that returns the first
match of the key — the naive, obvious implementation — returns `1.13M` at a clean `HTTP 200`: a
confidently wrong number, worse than an empty result, delivered to a paying buyer as if it were fact. This
Actor's extraction (`resolveChannelFromAboutBody` in `lib/channel.js`) reads exclusively the string-form
occurrence inside `aboutChannelViewModel`, and that value is never re-derived anywhere else in the code
path — it flows straight from the resolver into the row that gets billed. `main.js`'s own comment on this
exact line calls it out as the one field in this Actor that must never regress to a whole-page key search.

**Identity, not position — because the page itself drifts.** `aboutChannelViewModel` does not appear a
fixed number of times on `/about`. A live fetch during this README's own verification found it exactly
**once**; separate measurements recorded in this Actor's build evidence found it **twice** on the same
kind of page. Selecting "the second occurrence" — a plausible-looking fixed rule — silently breaks and
returns the wrong block the instant the page renders with only one. This Actor's rule is identity-based
instead: every occurrence of `aboutChannelViewModel` is scored against the channel the buyer actually
asked for (`channelId` for an ID-shaped input, the `@handle` segment parsed out of `canonicalChannelUrl`
for a handle-shaped input), and the **last confirmed match** wins — never a match by position. A block
that provably names a different channel is never eligible, even if it is the page's only block. This is
covered by a dedicated golden in `acceptance.json` (`[identity-guard, HUB10 gate blocker 17.08.2026]`)
that accepts either a fully-confirmed `found:true` with the exact right `channelId`, or a clean,
honest `found:false` — and explicitly forbids the one outcome that must never happen: a confirmed channel
carrying a mismatched identity.

**Legacy `/c/<name>` and `/user/<name>` input — a real gap in the source, handled honestly.** For a
handle or a bare channel ID, the page carries a directly comparable field (`canonicalChannelUrl`'s
`@handle` segment, or `channelId` itself) that this Actor can check the resolved block's identity against.
For the two legacy vanity-URL forms, **no such field exists anywhere in `aboutChannelViewModel`** — the
old `/c/` or `/user/` name the buyer typed is simply not present on the page in any comparable shape. This
Actor's rule for that specific gap: if the page carries exactly one `aboutChannelViewModel` occurrence,
that occurrence is accepted — there is nothing to disambiguate it from. If the page carries two or more,
and none of them can be positively confirmed against anything else, the request is refused rather than
guessed. Both legacy forms were run live for this page: `https://www.youtube.com/c/mkbhd` and
`https://www.youtube.com/user/marquesbrownlee` each resolved cleanly to `channelId:
"UCBJycsmduvYEL83R_U4JriQ"` with `handle:null` (the field this Actor never fills in for a legacy-form
request), because each page carried exactly one occurrence at the time of the check. The two-occurrence
refusal branch itself is not something a live YouTube page can be made to reproduce on demand — it is
proven by a dedicated offline unit test instead (`lib/test/channel.test.js`, "legacy `/c/<name>` request,
2 occurrences and neither is determinable -> ok:false"), part of a 16-test suite that passed 16/16 on the
same run that produced the JSON in this section. This asymmetry — live-verified for the common case,
unit-tested for the rare adversarial one — is stated here plainly rather than implied.

**Two different negative signals, and this Actor checks both.** A nonexistent handle and a
syntactically-valid-but-nonexistent channel ID do **not** fail the same way. Independent `curl` checks run
for this page confirm it directly: the handle case returns a plain `HTTP 404`; the channel-ID case returns
`HTTP 200` with the literal text `"This channel does not exist"` inside a 755,837-byte body. A check that
only inspects the status code would treat the second case as a live channel with an empty card. This
Actor's body-text check runs *before* any JSON extraction is attempted, so it always wins regardless of
what the "not found" page's own markup happens to contain elsewhere — and both cases are recorded with
`error:null`, the same honest-negative convention, never `error`-populated and never billed.

**External links, decoded and verified against the raw page.** The `Twitter` entry in the `@mkbhd` example
above reads `"url":"http://twitter.com/MKBHD"`. The raw HTML behind that same row, fetched independently
for this verification, contains
`"url":"https://www.youtube.com/redirect?event=channel_description&redir_token=...&q=http%3A%2F%2Ftwitter.com%2FMKBHD"`
— the real destination is buried in the `q=` parameter of a YouTube-hosted redirect wrapper, percent-
encoded. This Actor's `decodeLinkUrl` recognizes the wrapper by hostname and path (`/redirect` on a
`youtube.com` host specifically, never any other host), pulls the `q` parameter through `URLSearchParams`
(which percent-decodes automatically), and returns the real URL — which is exactly what the row above
shows. Not every link on the same page uses the wrapper: `"The Studio"` resolves directly to
`"http://youtube.com/c/TheStudio"` with no `/redirect` involved at all, and this Actor's decoder passes
that case through unchanged rather than assuming every link needs unwrapping. Both shapes were directly
observed on the same live page in the same fetch, and both are handled by the same function without a
special case for either.

**The business-email boundary, stated plainly.** `emailsFound` is populated by one regex scan of
`description` and nothing else. This Actor never follows a channel's own external links to check for a
contact email on a linked site, never queries a third-party enrichment service, and never attempts to
read YouTube's separate "Business inquiries" contact button, which requires a signed-in Google session to
even reveal — bypassing a login wall is out of scope by design, not a missing feature. The two live rows
in this page's "no email" example (`@ycombinator`, `@semrush`) are the direct, verified proof of this
boundary in practice: two real, large, active channels, fully resolved, with real multi-paragraph
descriptions, and a genuinely empty `emailsFound` — not a bug, not a `null`, an honest empty array,
because neither channel put an email in its own copy.

**Why `title` is read from `og:title`, not from the same JSON block as everything else.** In the current
page shape, `aboutChannelViewModel` — the object every other channel-identity field in this Actor comes
from — carries no `title` key at all. A whole-page recursive search for a `title` key would risk exactly
the same class of trap documented above for `subscriberCountText`: multiple unrelated `title` occurrences
elsewhere on the page (the similar-channels widget included) sharing the same key name. The page's
`<meta property="og:title" content="...">` tag, by contrast, is a single, plain-HTML value with no
duplicate or sibling occurrence on the page — a direct regex against it carries none of that ambiguity.
`title` is therefore read from that one tag, independently of the JSON-extraction path everything else in
this row uses, and is `null` on any page that never resolves to a real channel (the tag is simply absent).

**Billing correctness under concurrency.** `chargeAndPush` in `lib/billing.js` runs the budget check, the
platform charge, and the Dataset write inside one mutex-guarded critical section — a serialized queue that
survives a thrown exception in any single call rather than wedging. This closes the specific race a
naive implementation invites: two channels resolving at once, both reading "budget remains" before either
one spends it, both proceeding, and the buyer being charged for more than they authorized. The same guard
also refuses to do any paid work at all if the platform's own pricing configuration is broken — for
example, if Dataset writes themselves were accidentally priced, which would make even a free explanatory
row cost money — and fails the run outright rather than silently delivering unbilled or over-billed
results.

**Robots.txt, checked directly, not assumed.** `www.youtube.com/robots.txt`, fetched fresh for this
page's own verification, disallows `/api/`, `/comment`, `/live_chat`, `/login`, `/results`,
`/timedtext_video`, `/youtubei/` and a handful of other paths — and nowhere in that list is `/about`,
`/channel/`, `/@<handle>`, `/c/` or `/user/`, the exact paths this Actor reads. `/results` (search) and
`/comment` are the two paths that specifically close off a transcript-and-comment-scraping design for this
Actor's own product line — named directly in this Actor's own "What this Actor does NOT do" boundary, not
inferred from silence.

### Decision routing

| Row shape | Billable? | `error` | What it means |
|---|---|---|---|
| Resolved channel, card read | **Yes** | `null` | The one billable outcome — `found:true`, whether or not `emailsFound` is empty |
| Handle does not exist (`HTTP 404`) | No | `null` | A confirmed, unambiguous negative straight from the source's own status code |
| Syntactically valid ID, channel does not exist (`HTTP 200` + body text) | No | `null` | A confirmed negative wrapped in a success status — the second negative signal this Actor checks explicitly |
| Blocked/invalid input (SSRF guard) | No | `"blocked host (...)"` or similar, always populated | A real validation failure; no request was ever sent |
| Response truncated at the byte cap | No | `"response truncated at the byte cap on /about ..."` | The page exceeded this Actor's 4.5MB safety ceiling before finishing; not observed on any real page measured for this Actor (real pages run ~2.5-2.6MB), disclosed as a code path, not a live-reproduced case |
| `aboutChannelViewModel` absent or unresolvable identity | No | Populated, names the specific failure | A genuine parsing/shape failure, or a legacy-URL request with two or more undeterminable occurrences — never confused with a confirmed absence |
| Network error / timeout | No | Populated with the underlying cause | A real technical failure, distinguishable from a confirmed absence by `error` being non-null |
| Run's own budget exhausted mid-run | No | A free explanatory row is written naming the stop | Nothing already billed is affected; the remaining channels in that run are simply not attempted |

A workflow only needs two rules: act on `found === true`; and read `error === null` on a `found:false` row
as "the source confirmed this channel does not exist," never as "something went wrong" — that distinction
is exactly what `error` being populated or not is for.

### Commercial playbooks

#### Influencer and creator-partnership sourcing

A partnerships team feeds a candidate list of channel handles gathered from a niche search or a
competitor's sponsor reel into `channels`, and reads `subscriberCountText`, `description` and
`emailsFound` for a fast first-pass filter before anyone opens YouTube by hand — subscribers and
description alone are often enough to cut a list of fifty down to the ten worth a human look, and the
handful with a published email skip straight to outreach.

#### B2B channel and thought-leader lead lists

An SDR building a list of B2B YouTube channels in a given space (SaaS, fintech, dev tools) runs a batch of
known or discovered handles, keeps the rows with a real `emailsFound` entry for direct outreach, and keeps
the rows without one — `externalLinks` almost always includes the channel's own site — for a
site-contact-form or LinkedIn-based approach instead.

#### Periodic channel-health monitoring

A brand tracking its own or a partner's channel schedules a run against a small, fixed list of handles on
a recurring cadence, watching `subscriberCountText`, `videoCountText` and `description` drift over time
without manually revisiting the channel page — each run is a real, timestamped snapshot, not a cached
estimate.

### Integration recipes

#### Scheduled batch enrichment

An Apify Task runs this Actor on a fixed list of channels weekly, appending new Dataset rows each time;
downstream automation reads `found`, `emailsFound` and `externalLinks` and routes only the rows with a
real email into an outreach sequence, leaving the rest in a "no direct contact found" bucket for a
different approach.

#### Agent/MCP pattern

An agent supplies a list of handles or URLs discovered from another source (a search result, a
competitor's collaborator list), reads `found` and `error` before doing anything else with a row, and
never treats an empty `emailsFound` on a `found:true` row as a failure worth retrying — retrying does not
change what the channel's own description says.

#### CRM/spreadsheet append pattern

Dataset rows are appended keyed by `channelId` when `found:true` (stable across re-runs of the same
channel), or by `input` + `checkedAt` for an unresolved row (which carries no `channelId`); `null` is
preserved as `null` rather than coerced to an empty string, so a downstream query can tell "the channel
never set this field" apart from "this field is genuinely blank text."

### Operating guide

**Why `maxConcurrency` defaults to 5, not higher.** Live stability for this source was verified at
roughly one request per second, sequentially, ten requests in a row with zero rate-limit or blocking
responses. That measurement supports a modest default with headroom; it does not, on its own, prove
stability at high sustained parallelism across a hundred-channel run. The `1-20` range is honored if you
raise it, but doing so is your own exposure to increase, based on your own measured tolerance for the
channels you actually run — not a ceiling this Actor's own build evidence independently validated at the
high end.

**Why the input cap is 100, enforced twice.** `.actor/input_schema.json` declares `maxItems:100` for
buyer-facing validation in the Apify Console, but that schema check is advisory — an API caller can
bypass client-side schema validation entirely. `main.js` re-enforces the same 100-channel ceiling in code,
after deduplicating the input list, specifically so an API-driven run cannot exceed it by skipping the
Console form.

**Reading an empty `emailsFound`.** This is the normal outcome for most channels, not a sign of a broken
run — both live examples in Real happy, partial and failure output demonstrate it directly on large,
active, fully-resolved channels. Re-running the same channel will not change the result; the description
text is what it is.

**Choosing an input shape.** A bare `UC...` channel ID or an `@handle` gives this Actor a directly
comparable identity field to verify the resolved page against. A legacy `/c/<name>` or `/user/<name>` URL
does not — see Evidence and boundaries for exactly what that means for those two input shapes, and why
they are refused rather than guessed in the rare case the page carries more than one ambiguous block.

**Concurrency and row order.** Dataset rows are written in completion order, not submission order, when
`maxConcurrency` is greater than 1. A downstream process that needs to preserve the original request order
should sort on `input` after the run, not assume row order matches the input array.

#### Related tools

Related tools for adjacent workflows in B2B lead generation and data enrichment, social platforms and regional directories.

| Actor | What it does |
|---|---|
| [Telegram Export Intelligence](https://apify.com/zinin/telegram-channel-intel) | Pair it in the social platforms and regional directories workflow: Turn owner-authorized Telegram message exports into per-post reach context, evidence, confidence, review... |
| [Authorized Professional Directory Intelligence](https://apify.com/zinin/doctoralia-directory) | Pair it in the social platforms and regional directories workflow: Turn buyer-authorized or licensed professional directory exports into privacy-aware evidence, freshness,... |
| [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... |
| [Company Hiring Radar](https://apify.com/zinin/company-hiring-radar) | Pair it in the B2B lead generation and data enrichment workflow: Turn buyer-supplied Greenhouse, Lever, or Ashby board tokens into evidence-backed public hiring... |
| [Competitor Change Monitor — Tech, Hiring & Filings](https://apify.com/zinin/competitor-change-rollup) | Pair it in the B2B lead generation and data enrichment workflow: Monitor one competitor domain for technology, hiring, and SEC-name changes with explicit baselines,... |

### FAQ

#### Why is `emailsFound` empty for a real, active, high-subscriber channel?

Most channels never put an email into their description at all. YouTube's separate "Business inquiries"
button is gated behind a Google login this Actor does not and will not bypass. An empty array means
exactly that — no email in the free text, nothing more — and two of the real rows on this page
(`@ycombinator`, `@semrush`) exist specifically to demonstrate that this is the normal case, not a defect.

#### Why is `title` sometimes `null`?

It is read from the page's `og:title` meta tag, which is only present on pages that actually resolve to a
real channel. A `found:false` row never carries a `title`.

#### Does this include the channel's video list, or similar/lookalike channels?

No. This Actor is the contact/description/links card only. A companion Actor in the same fleet,
**YouTube Channel Videos List**, covers the channel's own uploads; another, **YouTube Channel Lookalike
Finder**, covers similar-channel discovery — the exact widget whose numbers this Actor deliberately
excludes from `subscriberCountText` (see Evidence and boundaries).

#### Why does a legacy `/c/<name>` or `/user/<name>` URL sometimes get refused when a handle for the same channel would succeed?

Because the page carries no field comparable to that old vanity name — nothing to positively confirm the
resolved block actually belongs to the channel you typed. When the page carries exactly one candidate
block, it is accepted; when it carries two or more and neither can be confirmed, this Actor refuses rather
than guesses. A handle or a bare channel ID does not have this gap, because the page does carry a directly
comparable field for both of those input shapes.

#### Can the same channel ever be billed twice for one run?

No — each requested channel produces exactly one row, and the charge for that row happens once, inside
the same atomic operation that writes it to the Dataset (see Evidence and boundaries, "Billing correctness
under concurrency").

#### What happens if I submit the same channel twice in one run's `channels` list?

Duplicate values are deduplicated before processing (`main.js` builds the run's channel list through a
`Set`), so a repeated value in your input produces one row and one charge, not two.

#### Is reading `/about` actually allowed under YouTube's own rules?

`robots.txt`, checked directly for this page, does not disallow `/about`, `/channel/`, `/@<handle>`,
`/c/` or `/user/` — only `/api/`, `/comment`, `/results`, `/login` and a specific list of other paths, none
of which this Actor touches. See Sources and rights for the exact quoted lines and what that boundary does
and does not mean for your own use of the resulting data.

#### Does this Actor guess or infer anything the source itself did not publish?

No. Every field is either read directly from the page or set to `null`. `subscriberCountText`,
`viewCountText` and `videoCountText` are the source's own rounded display text, never converted to a
fabricated exact number; a missing `country` or `joinedDateText` stays `null`, never inferred from any
other signal on the page.

### Sources and rights

Source family: YouTube public channel and playlist pages. This Actor reads exactly one of them — the channel's own About page — anonymously, with no login, no API key and no browser session.

**The only source.** The channel's own public `/about` page, served directly by `www.youtube.com` —
`https://www.youtube.com/@<handle>/about` or `https://www.youtube.com/channel/<id>/about`, with
`?hl=en&gl=US` and a consent cookie set on every request so the page renders in English regardless of the
runner's own IP location. No YouTube Data API. No API key. No account, login or session of any kind.

**`robots.txt`, quoted verbatim.** Fetched fresh for this page's own verification:

```
User-agent: *
Disallow: /api/
Disallow: /channel_picker
Disallow: /comment
Disallow: /feeds/videos.xml
Disallow: /file_download
Disallow: /get_video
Disallow: /get_video_info
Disallow: /get_midroll_info
Disallow: /live_chat
Disallow: /login
Disallow: /qr
Disallow: /results
Disallow: /signup
Disallow: /t/terms
Disallow: /timedtext_video
Disallow: /verify_age
Disallow: /watch_ajax
Disallow: /watch_fragments_ajax
Disallow: /watch_popup
Disallow: /watch_queue_ajax
Disallow: /youtubei/
```

None of the paths this Actor reads — `/about`, `/channel/`, `/@<handle>`, `/c/`, `/user/` — appear in this
list. `/results` (search) and `/comment` do, which is exactly why this Actor never scrapes search results
or comments, and never will under this design.

**What this is not.** Unlike UK government open data published under an explicit reuse licence, YouTube
publishes no equivalent commercial-redistribution grant for this page's content, and `robots.txt`
permission to *fetch* a path is not the same thing as a licence to *redistribute* what it contains. This
Actor reads a page that is publicly served with no login wall and not disallowed by the site's own crawl
policy; how you store, use and redistribute the resulting data — including any business email a channel
owner chose to publish — remains your own responsibility under YouTube's Terms of Service and applicable
data-protection law in your jurisdiction. This page makes no licensing claim on your behalf, and no claim
that this use is risk-free for every downstream purpose.

**Personal data note.** A business email captured by this Actor is one the channel owner voluntarily and
publicly published in their own channel description, specifically for the purpose of being contacted —
the same status as a business email published on a company's own "Contact us" page. It is not extracted
from a private field, a third-party leak, or any login-gated source.

### Limits

- No transcripts, no comments, no search results — `/results` and `/comment` are `robots.txt`-disallowed
  and this Actor never touches either path.
- Subscriber, view and video counts are YouTube's own rounded display text (`"21.1M subscribers"`), never
  an exact number — the source itself does not publish one, so this Actor does not invent one.
- Business email extraction is a regex scan of `description` text only. It never follows a channel's
  external links, never queries a third-party enrichment service, and never attempts to read YouTube's
  own login-gated "Business inquiries" field. An empty `emailsFound` on a fully resolved channel is a
  normal, expected outcome, verified live twice on this page.
- Legacy `/c/<name>` and `/user/<name>` input URLs cannot be identity-verified the way a handle or a bare
  channel ID can — the page carries no comparable field for either legacy form. A single-candidate page
  is accepted; two or more undeterminable candidates are refused rather than guessed.
- Video lists and similar/lookalike channels are out of scope for this specific Actor — see companion
  Actors named in the FAQ.
- `maxConcurrency`'s conservative default (5) reflects sequential-load verification, not independently
  validated high-parallelism stability; raising it toward the allowed maximum (20) is honored but is your
  own exposure to increase.
- The 100-channel-per-run cap is enforced in code as well as in the input schema, so it holds even for an
  API-driven run that bypasses Console-side schema validation.
- A response exceeding the 4.5MB per-page byte cap is reported as a free, honest `truncated` error rather
  than parsed partially and silently — real measured page sizes for this source run roughly 2.5-2.6MB,
  comfortably under that ceiling, so this path is a disclosed safety margin, not a case reproduced on a
  live page for this README.
- This Actor depends on YouTube's current page structure (`aboutChannelViewModel`, `og:title`,
  `channelExternalLinkViewModel`). A structural change on YouTube's side could break extraction; this
  Actor is built to fail safe when that happens — an honest `error`, never a silently wrong field — but
  cannot guarantee YouTube will not change its own page shape.

### Support boundary

Actor support covers this page's documented input validation, the resolution and identity-verification
logic described above, the Dataset schema, and billing correctness for a delivered channel record on an
accepted release. It cannot guarantee YouTube's own page structure, uptime, or continued availability of
any specific field — this Actor reports what the source publishes, and stops honestly when the source
publishes something this build does not recognize. It cannot verify that a captured business email is
still monitored, still belongs to the same person, or will ever be answered. It cannot recover data
YouTube itself never published (an exact subscriber count, a hidden or login-gated email, a channel's
video list or its similar-channel recommendations — see companion Actors in the FAQ). When reporting a
problem, include the Actor run ID, the specific `input` value involved, the full row's `error` text if
any, and the approximate `checkedAt` time.

***

Built by [zinin](https://apify.com/zinin). Questions? Telegram [@timzinin](https://t.me/timzinin).

# Actor input Schema

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

List of channels: @handle, full URL (https://www.youtube.com/@handle), or bare channel ID (UC...). The Actor resolves the channel automatically.

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

How many channels to process in parallel. Kept conservative by default — stability has only been verified live at 10 sequential requests (~1/sec), not under sustained high concurrency.

## Actor input object example

```json
{
  "channels": [
    "@mkbhd",
    "@ycombinator",
    "@semrush"
  ],
  "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 = {
    "channels": [
        "@mkbhd",
        "@ycombinator",
        "@semrush"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/youtube-channel-intel").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",
        "@ycombinator",
        "@semrush",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("zinin/youtube-channel-intel").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",
    "@ycombinator",
    "@semrush"
  ]
}' |
apify call zinin/youtube-channel-intel --silent --output-dataset

```

## MCP server setup

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

```

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/UsX9ec3wA1FGMJTdR/builds/3uAxYbStFGWN0SwJv/openapi.json
