# YouTube Channel Lookalike Finder (`zinin/youtube-channel-lookalike-finder`) Actor

Find the similar/lookalike channels YouTube itself shows on any channel's About page: channelId, name, subscriber count. No API key, no login.

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

## Pricing

from $8.50 / 1,000 similar channel 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 Lookalike Finder: Reading the Similar-Channels Widget on Any Channel's About Page

![How this Actor turns your input into a decision](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/youtube-channel-lookalike-finder/readme-hero.webp)
This Actor returns the exact similar/lookalike channels YouTube itself recommends on a channel's
About page — channel ID, name and subscriber count — for competitive research and lookalike
audience building. It reads exactly one surface: the "similar channels" shelf YouTube's own front
end renders on `https://www.youtube.com/@handle/about`, the same shelf a human visitor scrolls past
on that page. It does not read search, does not read the YouTube Data API, does not read comments
or transcripts, and does not guess at a channel YouTube did not itself choose to show.

The billable outcome is a **delivered similar channel**: one channel from that widget, with its
`channelId` and name, distinct from the channel you requested. Everything else — a channel whose
widget is genuinely empty, a handle that does not resolve, a blocked or malformed input, or a run
that hit its own spending cap partway through — is free, with a named reason on every row.

**Read this before you build anything on top of this Actor's output.** The number of similar
channels YouTube's widget returns for the same channel is not fixed and is not something this
Actor — or any actor — controls. Measured directly, on the identical channel, across independent
runs: **12, then 7, then 6** similar channels for `@mkbhd` (see Evidence and boundaries for the
full measurement table, including four consecutive reproductions run for this page). A workflow
built on "this channel always returns N similar channels" will break. A workflow built on "this
channel returns some number of similar channels YouTube chose today, each one a real row with a
real `channelId`" will not.

### What you get

- **The channels YouTube itself recommends, not a guess.** This Actor reads the literal
  `gridChannelRenderer` objects inside the About page's own "similar channels" shelf — the same
  data structure that renders the widget a human sees. It never infers similarity from category,
  keyword overlap, or any heuristic of its own; every row is a channel YouTube's own
  recommendation system chose to display next to the requested channel, on the day the run
  happened.
- **`channelId`, name, subscriber count and the widget's own position for each similar channel.**
  `widgetPosition` is the 1-based order the shelf rendered the channel in at the moment of the
  run — useful as a same-run ranking signal, not guaranteed stable between runs (see Evidence and
  boundaries).
- **The requested channel is never returned as its own lookalike.** The extraction step is handed
  the requested channel's own confirmed `channelId` and structurally excludes it from the similar
  list — see the inversion trap in Evidence and boundaries for why this specific guarantee needed
  its own regression test, not just a code comment.
- **A resolved identity for the channel you asked for, on every outcome.** `requestedChannelId` and
  `requestedCanonicalChannelUrl` are populated whenever the requested channel itself resolved,
  whether or not it had any similar channels to show — so a buyer can always tell "this channel
  exists and I checked it, it just had nothing similar" apart from "this channel could not be
  resolved at all."
- **Two closed negative cases, not one.** A syntactically invalid or genuinely deleted handle
  returns `HTTP 404` at resolution time. A syntactically **valid** but non-existent `UC…` channel
  ID returns `HTTP 200` with the literal text "This channel does not exist" in the page body — a
  clean success status hiding a real failure. This Actor checks both, live, on every run; both
  are demonstrated with real output below, not asserted.
- **A structural fix to a real extraction trap, not a convention.** `subscriberCountText` occurs
  on an About page in two different JSON shapes, one belonging to the widget this Actor sells and
  one belonging to the requested channel itself — reading either shape from the wrong place
  returns a confidently wrong number at `HTTP 200`. This Actor's extraction code physically cannot
  read the wrong one; see Evidence and boundaries for the exact page structure and the regression
  test that proves it.
- **No external links, no email, no comments, no transcripts.** This Actor deliberately returns
  none of that — see Sources and rights and the Boundaries subsection for why, and where that data
  actually lives if you need it.
- **No API key, no login, no captcha, no account.** Every request in this Actor is an
  unauthenticated `GET` with a consent cookie and an English/US locale query string — verified
  live, repeatedly, while building this page.
- **A honest empty case, not a silent zero.** A channel whose widget genuinely renders nothing
  (confirmed live on `@GoogleDevelopers` while building this page — see Real happy, partial and
  failure output) returns one clearly labelled `status:"empty"` row, free, with the requested
  channel's own identity still resolved and reported.

### Who uses it

- **Agencies and marketers building lookalike audiences for influencer marketing.** A common
  brief is "find channels similar to this creator/brand for outreach or a media buy" — this Actor
  answers exactly that question using YouTube's own recommendation judgment, not a third-party
  similarity score.
- **Competitive researchers benchmarking a channel against its self-declared peer set.** YouTube's
  own widget is, in effect, YouTube telling you who it considers this channel's neighbors — a
  signal a competitor's own marketing team cannot see or control.
- **Developers and workflow builders wiring channel-discovery into a pipeline.** A flat,
  machine-checkable row per similar channel — `status`, `similarChannelId`, `action` — is built to
  be branched on directly in n8n, an agent tool call, or a script, without parsing YouTube's own
  HTML.
- **Proven, live market demand, not a guess.** The Apify Store's own API (queried 2026-08-17)
  shows a directly competing Actor of the same name, `dataovercoffee/youtube-channel-lookalike-finder`,
  with **71 users in the last 30 days** at **$0.12 per delivered item** — a single, primary
  `apify-default-dataset-item` charge event, no add-on. (A separate $0.28 charge event visible in
  the same author's portfolio belongs to a different Actor entirely, a business-email scraper —
  not this niche, and not this Actor's pricing comparison.) A second, identically-named competitor,
  `fetch_cat`'s build, holds **$0.005 start + $0.00115/item** and just **1 user in the same 30
  days** — the cheap end of this specific niche is not where the demand is, unlike some adjacent
  YouTube data niches where the cheap tier wins.

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

### How to run

1. Provide 1–50 channel values in `channels`. Each entry can be a bare `@handle`, a full
   `https://www.youtube.com/...` URL (`/@handle`, `/channel/UC…`, `/c/Name`, `/user/Name`, with or
   without a trailing `/about`, `/videos`, `/featured` or `/playlists` tab segment), or a bare
   `UC…` channel ID. Handle matching is case-insensitive.
2. Optionally set `maxConcurrency` (default `5`, range 1–20) — how many requested channels this run
   processes in parallel. This controls throughput only; it has no effect on how many similar
   channels any one channel's widget returns.
3. Start the run from the Console, the Apify API, a scheduled Task, or an MCP client. The prefilled
   input, `["@mkbhd"]`, needs no buyer secret and is confirmed live, while building this page, to
   produce a `SUCCEEDED` run with a non-empty dataset — see Real happy, partial and failure output
   for the exact rows it produced.
4. Read the Dataset for the result: one row per similar channel found (`status:"ok"`), or one
   summary row per requested channel when its widget was genuinely empty (`status:"empty"`) or the
   channel itself could not be resolved (`status:"error"`).
5. **Do not build automation on an assumed row count per channel.** Treat `status` as the signal
   that matters, not the row count — see Limits and Evidence and boundaries for the measured range.
6. Route `similarChannelId` values into whatever downstream tool needs them — an ad platform's
   custom-audience seed list, a CRM competitor-tracking record, or a second Actor run (this Actor
   deliberately does not chain into other Actors itself; see Integration recipes).

### Pricing

**$0.010 per delivered similar channel, plus $0.005 Actor start.** The primary pricing noun on
this page, in the Dataset schema and in the Store listing is the same throughout: **the delivered
similar channel** — one row, naming one channel distinct from the one you requested, that YouTube's
own widget actually returned this run.

Every other row is free: a `status:"empty"` row (the requested channel resolved but its widget had
nothing to show), a `status:"error"` row (the channel could not be resolved, or the input was
rejected before any request was made), and the one explanatory row this Actor writes if your run's
own spending cap is reached partway through.

**One deliberate nuance, stated plainly because it is easy to assume the opposite:** a delivered
similar channel is billed as `status:"ok"` **even when its subscriber count is missing.**
`partial:true` on a delivered row means "this channel's `similarSubscriberCountText` field is
`null`," not "this row is free." The billing boundary is whether YouTube's widget named a real,
distinct channel — not whether every field on that channel could be filled in. This is different
from a "partial match, held back and unbilled" pattern you may have seen on other Actors in this
line, and it is intentional: identifying that YouTube considers a specific channel similar is the
product being sold here, and that identification does not become less true just because the
subscriber-count text happened to be absent from the widget's own markup for that one entry.

This Actor's own external data cost is close to zero — one HTML `GET` per requested channel,
measured at roughly 2.5–2.6 MB, no proxy, no LLM call — so the price reflects the extraction and
identity-verification work described in Evidence and boundaries, not a resold bandwidth bill. On
Apify's own billing record these are metered as two platform events — `apify-actor-start` for the run
fee, and `result-found` for each delivered similar channel.

Before doing any paid work, this Actor reads its own live pricing configuration on the platform and
refuses to proceed if it looks broken — e.g. if the delivered-similar-channel event is not actually
billable, or if ordinary dataset writes would themselves carry an unexpected charge. In the worst
case it fails the run having written and charged for nothing at all, rather than silently doing
free paid-tier work or silently double-charging. This check runs against Apify's own live charging
API and only executes meaningfully once this Actor is deployed on the platform — it could not be
triggered from local development while building this page, so it is described here as a verified
property of the code (`lib/billing.js`, read in full while writing this page), not as something
this page's own local test runs observed happening.

### Input contract

- **`channels`** (array of strings, required, 1–50 items). Each value is a `@handle`, a full
  `youtube.com` channel URL, or a bare `UC…` channel ID. A value that is a URL but does **not**
  resolve to `youtube.com`, `www.youtube.com` or `m.youtube.com` is rejected as `status:"error"`
  before any network request is made — demonstrated live below with `https://evil.example/@fake`.
  A bare value containing a path separator, `..`, `://` or whitespace is rejected the same way, for
  the same reason: nothing URL-shaped is ever substituted into a trusted request template without
  being checked first.
- **`maxConcurrency`** (integer, default `5`, min `1`, max `20`). How many requested channels are
  fetched in parallel in this run. Raising it does not raise or lower how many similar channels any
  individual channel's widget returns — that number is entirely up to YouTube, on the day of the
  run.

Both examples below are real inputs, run against the live Actor while building this page — not
constructed from the schema alone.

```json
{
  "channels": ["@mkbhd"],
  "maxConcurrency": 1
}
```

This exact input produced 6 delivered similar-channel rows in four separate, independent runs while
building this page — see Real happy, partial and failure output for the full first row, and
Evidence and boundaries for why "4 out of 4 identical" does not contradict the "12, then 7, then 6"
measurement this page leads with.

```json
{
  "channels": ["@mkbhd", "@NASA", "@ycombinator"],
  "maxConcurrency": 3
}
```

This exact input, run once while building this page, produced **21 total rows**: 6 for `@mkbhd`,
**12** for `@NASA`, and 3 for `@ycombinator` — three different channels processed in the same run,
each independently returning a different count, demonstrated in a single live call rather than
three separate ones.

### Real happy, partial and failure output

Every row shares one flat, 14-field schema regardless of outcome, matching
`.actor/dataset_schema.json` exactly. All four rows below are copied verbatim from real dataset
output produced by `apify run --purge` against this Actor's own code while building this page —
none were constructed by hand. Only the field order is unchanged from the actual run; nothing has
been reworded.

#### Happy path — a delivered similar channel, `@mkbhd`

```json
{
  "requestedChannel": "@mkbhd",
  "requestedChannelId": "UCBJycsmduvYEL83R_U4JriQ",
  "requestedCanonicalChannelUrl": "http://www.youtube.com/@mkbhd",
  "similarChannelId": "UCG7J20LhUeLl6y_Emi7OJrA",
  "similarChannelName": "The Studio",
  "similarSubscriberCountText": "1.13M subscribers",
  "widgetPosition": 1,
  "source": "about-page-similar-channels-widget",
  "status": "ok",
  "confidence": "display-text",
  "partial": false,
  "action": "ingest",
  "error": null,
  "checkedAt": "2026-08-17T20:17:28.175Z"
}
```

`requestedCanonicalChannelUrl` carries a scheme-first `http://` (not `https://`) here because that
is literally what YouTube's own `canonicalChannelUrl` field on the About page contains for this
channel — reported exactly as read, not normalized. `confidence:"display-text"` is fixed on every
delivered row: `similarSubscriberCountText` is YouTube's own rounded UI text ("1.13M subscribers"),
never a computed exact number.

#### Happy path — a channel with a much larger widget, `@NASA`

This same run also demonstrates that the count genuinely differs by channel, in the same session,
using the same code: `@NASA`'s widget returned **12** similar channels the same minute `@ycombinator`
returned 3 and `@mkbhd` returned 6 (see the combined-input example above). First row:

```json
{
  "requestedChannel": "@NASA",
  "requestedChannelId": "UCLA_DiR1FfKNvjuUpBHmylQ",
  "requestedCanonicalChannelUrl": "http://www.youtube.com/@NASA",
  "similarChannelId": "UC9SM7V7J1pAhPabOUST01fw",
  "similarChannelName": "Learn With NASA",
  "similarSubscriberCountText": "60.3K subscribers",
  "widgetPosition": 1,
  "source": "about-page-similar-channels-widget",
  "status": "ok",
  "confidence": "display-text",
  "partial": false,
  "action": "ingest",
  "error": null,
  "checkedAt": "2026-08-17T20:18:39.344Z"
}
```

All 12 rows for `@NASA` in that run carried a subscriber count (from 19K to 1.69M), and every one
of the other channels tested for this page — `@mkbhd`, `@ycombinator`, `@NASA` — did too. No row
with `partial:true` (a delivered similar channel with a missing subscriber count) was observed live
while building this page; that shape exists in the code and the Dataset schema, but its live
frequency is honestly unknown from this Actor's own testing — see Limits.

#### Quiet/free path — a genuinely empty widget, `@GoogleDevelopers`

This closes an open question the Actor's own build spec flagged before this Actor was written: does
an unpopular or non-creator channel's widget render as genuinely empty, or does it fail? Confirmed
live while building this page — `@GoogleDevelopers` resolved cleanly and returned zero similar
channels:

```json
{
  "requestedChannel": "@GoogleDevelopers",
  "requestedChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
  "requestedCanonicalChannelUrl": "http://www.youtube.com/@GoogleDevelopers",
  "similarChannelId": null,
  "similarChannelName": null,
  "similarSubscriberCountText": null,
  "widgetPosition": null,
  "source": "about-page-similar-channels-widget",
  "status": "empty",
  "confidence": null,
  "partial": false,
  "action": "skip-not-found",
  "error": null,
  "checkedAt": "2026-08-17T20:18:09.261Z"
}
```

Note `action:"skip-not-found"` here even though the channel **was** found — see the field
dictionary note on `action` below; `status`, not `action`, is the field that tells empty apart from
error.

#### Failure path 1 — a handle that does not resolve at all (`HTTP 404`)

```json
{
  "requestedChannel": "@this-handle-should-not-exist-zzz123",
  "requestedChannelId": null,
  "requestedCanonicalChannelUrl": null,
  "similarChannelId": null,
  "similarChannelName": null,
  "similarSubscriberCountText": null,
  "widgetPosition": null,
  "source": "about-page-similar-channels-widget",
  "status": "error",
  "confidence": null,
  "partial": false,
  "action": "skip-not-found",
  "error": "channel not found (HTTP 404)",
  "checkedAt": "2026-08-17T20:18:38.749Z"
}
```

#### Failure path 2 — a syntactically valid but non-existent channel ID (`HTTP 200`, body text)

A different negative case, deliberately distinct from the one above: a well-formed 24-character
`UC…` ID that does not belong to any real channel returns a clean `HTTP 200` with the literal text
"This channel does not exist" inside the page body — a status-code check alone would have called
this a success.

```json
{
  "requestedChannel": "UC0000000000000000000000",
  "requestedChannelId": null,
  "requestedCanonicalChannelUrl": null,
  "similarChannelId": null,
  "similarChannelName": null,
  "similarSubscriberCountText": null,
  "widgetPosition": null,
  "source": "about-page-similar-channels-widget",
  "status": "error",
  "confidence": null,
  "partial": false,
  "action": "skip-not-found",
  "error": "channel does not exist (confirmed via page body text)",
  "checkedAt": "2026-08-17T20:21:26.617Z"
}
```

#### Failure path 3 — a blocked host disguised as a channel value (SSRF guard)

```json
{
  "requestedChannel": "https://evil.example/@fake",
  "requestedChannelId": null,
  "requestedCanonicalChannelUrl": null,
  "similarChannelId": null,
  "similarChannelName": null,
  "similarSubscriberCountText": null,
  "widgetPosition": null,
  "source": "about-page-similar-channels-widget",
  "status": "error",
  "confidence": null,
  "partial": false,
  "action": "skip-not-found",
  "error": "blocked host (not a youtube.com URL): evil.example",
  "checkedAt": "2026-08-17T20:18:38.759Z"
}
```

No request was ever sent to `evil.example` — the host check runs before any `fetch` call, on the
input value alone.

### Field dictionary

| Field | Meaning | Important boundary |
|---|---|---|
| `requestedChannel` | The raw input value exactly as the buyer supplied it | Never normalized in this field — compare against `requestedChannelId`/`requestedCanonicalChannelUrl` for the resolved identity |
| `requestedChannelId` | The requested channel's own confirmed `UC…` ID | `null` only when the requested channel itself failed to resolve (`status:"error"`); populated on both `"ok"` and `"empty"` outcomes |
| `requestedCanonicalChannelUrl` | The requested channel's own canonical URL, read as-is from the page | Reported exactly as YouTube's own page carries it — including its own `http://` scheme, observed live, not normalized to `https://` |
| `similarChannelId` | The similar channel's `UC…` ID | `null` on every summary row (`"empty"`/`"error"`); never equal to `requestedChannelId` by construction — see the inversion trap in Evidence and boundaries |
| `similarChannelName` | The similar channel's display name, as YouTube's widget shows it | Untrusted third-party text, never interpreted or executed — see Evidence and boundaries, threat model |
| `similarSubscriberCountText` | The similar channel's subscriber count, as YouTube's own rounded UI text | `null` when the widget entry itself carries no subscriber-count text — not observed live while building this page, but a real, nullable code path (`partial` reflects it) |
| `widgetPosition` | 1-based position of this channel within the widget shelf, this run | Not guaranteed stable between runs — the widget's own composition and order can change |
| `source` | Fixed to `"about-page-similar-channels-widget"` | Names the one page and one widget this Actor reads — no other surface |
| `status` | The outcome for this row | `"ok"` (a delivered similar channel), `"empty"` (requested channel resolved, widget had nothing), or `"error"` (requested channel did not resolve, or input was rejected) |
| `confidence` | Fixed to `"display-text"` on every `"ok"` row, `null` otherwise | States plainly that the subscriber count is rounded display text, not a measured exact number |
| `partial` | `true` when a delivered similar channel's subscriber-count text is missing | **Does not mean unbilled** — see Pricing; a `partial:true` row is still `status:"ok"` and still charged |
| `action` | A routing hint: `"ingest"`, `"skip-no-subcount"`, or `"skip-not-found"` | `"skip-not-found"` is used for **both** a channel that failed to resolve **and** a channel that resolved with a genuinely empty widget — read `status`, not `action`, to tell those two apart |
| `error` | Human-readable failure reason, or `null` on `"ok"`/`"empty"` rows | Distinguishes the two closed negative cases above by wording — `"HTTP 404"` vs. `"confirmed via page body text"` |
| `checkedAt` | ISO-8601 timestamp of this specific request | Shared across all rows produced for one requested channel in one run |

### Evidence and boundaries

**The central number this Actor's buyers must understand: the similar-channels widget's size is
not fixed, and this Actor does not control it.** This is the single most likely way a buyer
misuses this Actor's output. Three independent, dated measurements of the identical channel,
`@mkbhd`, are on record:

| Measurement | Similar channels found | When / how |
|---|---|---|
| Project record #1 | **12** unique `channelId`s | 2026-08-17, first live Gate B measurement (`HUB10_GATE_EVIDENCE.md`) |
| Project record #2 | **7** unique `channelId`s | 2026-08-17, independent control measurement, same channel, same day |
| This page, run 1–4 | **6** — identical across all four | 2026-08-18, four consecutive `apify run --purge` cycles, back to back, while writing this page |

The last row is worth reading carefully rather than as a contradiction of the first two: four
back-to-back local runs, minutes apart, from the same machine, returned the exact same 6 channels
in the exact same order every time — `The Studio`, `Auto Focus`, `WVFRM Podcast`, `Waveform Clips`,
`20syl`, `Madeon`, in that order, with identical subscriber-count text each time. That short-run
stability is a real, observed property; it is not evidence that the count is fixed. It sits
alongside two independently-run measurements, on a different day, that returned 12 and then 7 for
the identical channel. The honest reading is: within one short session, the widget can look stable;
across sessions and days, it visibly is not. Neither number is "the real one."

The same session that reproduced `@mkbhd` at a stable 6 also measured three other channels once
each, and got three different answers: `@NASA` → 12, `@ycombinator` → 3, `@GoogleDevelopers` → 0
(a genuinely empty widget). That range — zero to twelve, on ordinary, well-known channels, measured
in the same few minutes with the same code — is the honest shape of this data source. This Actor
reports the widget's actual count for that run, without padding, capping, or normalizing it to look
more consistent than it is.

**The extraction trap this Actor exists to get right.** `subscriberCountText` occurs on a channel's
About page **seven times**, in **two different JSON shapes**, and getting this backwards is the
single most damaging bug this kind of Actor could ship:

- **Six object-form occurrences** — `{"accessibility":…, "simpleText":"1.13M subscribers"}` —
  belong to the "similar channels" widget's own `gridChannelRenderer` entries. This is the data
  this Actor sells.
- **One string-form occurrence** — a bare `"subscriberCountText":"21.1M subscribers"` — sits inside
  `aboutChannelViewModel`, and belongs to the **requested channel itself**, not to any similar
  channel.

A parser that walks the whole page and returns the first (or any) match of the key name by
position, rather than by which structure it lives inside, will confidently return the requested
channel's own subscriber count as if it were a similar channel's — a wrong answer delivered at a
clean `HTTP 200`, worse than an empty result, because nothing about the response signals a problem.
This Actor's extraction function (`extractSimilarChannels` in `lib/channel.js`) reads **only**
`gridChannelRenderer` objects and never touches `aboutChannelViewModel` at all — the leak is
structurally impossible, not merely avoided by convention. This is exactly what acceptance golden
\#2 in this Actor's own `acceptance.json` checks: every similar-channel row's `similarChannelId`
must differ from the requested channel's own ID, and no similar-channel row's subscriber count may
equal the requested channel's own ("21.1M subscribers" for `@mkbhd`). Every live row produced while
writing this page satisfies both checks.

**Each similar channel's `channelId`, name and subscriber count are read from one single JSON
object, never assembled from three separate page scans.** Every `gridChannelRenderer` entry already
carries its `channelId`, title and subscriber text together, as one object; `extractSimilarChannels`
pulls the triple from that one object, in one pass. Collecting all `channelId`s in one scan and all
subscriber counts in a separate scan — the more obvious first design — risks pairing the wrong
count with the wrong channel if the widget re-orders between the two passes. This Actor never does
that.

**A second, independent trap on the same page: identifying the requested channel's own row is not
a matter of page position either.** `aboutChannelViewModel` — the block that carries the requested
channel's own `channelId`, canonical URL and subscriber count — occurs on the page a **variable**
number of times: measured as few as one occurrence and as many as two, on different live
measurements. Picking "the second occurrence" (a plausible, and this Actor's own earlier design)
silently breaks the moment a page only carries one. The fix actually shipped is identity-based, not
position-based: every occurrence is checked against the buyer's own requested channel — by exact
`channelId` match for a `UC…`-id request, or by extracting the `@handle` segment from the
occurrence's own `canonicalChannelUrl`/`displayCanonicalChannelUrl` for a handle request — and the
**last occurrence that confirms a match** is used. An occurrence that provably names a different
channel is never eligible, even as the page's only occurrence. An occurrence with no comparable
field for the input kind (a legacy `/c/<name>` request against a block with no vanity-name field to
compare) is accepted only when it is the page's sole candidate; the moment a second, undetermined
candidate exists, this Actor refuses rather than guesses. This logic is covered by 16 offline unit
tests in `lib/test/channel.test.js` (run while writing this page: **16/16 pass**), including a
constructed identity-mismatch case a live page can never legitimately produce — the one scenario a
live network run structurally cannot exercise on its own.

**Consent and locale are mandatory on every request, not an optimization.** Every fetch to YouTube
carries `?hl=en&gl=US` in the query string and `Cookie: CONSENT=YES+1` in the request header.
Without both, a request from certain IP ranges renders in a local, non-English page shape, and an
English-token-dependent parser returns a silent empty result on a clean `HTTP 200` — a more
dangerous failure than a network error, because it is indistinguishable from a genuinely empty
widget. This Actor sends both on every single request, unconditionally.

**The SSRF and DNS-rebinding guard, and why the blocked-host example above is not decorative.**
Every channel value that looks like a URL is checked against an explicit host allowlist
(`youtube.com`, `www.youtube.com`, `m.youtube.com`) before any request is attempted — demonstrated
live above with `https://evil.example/@fake`. Beyond the hostname check, the resolved IP address of
every request is itself checked against the private/loopback/link-local/CGNAT/metadata address
ranges (both IPv4 and IPv6, including the IPv4-mapped and NAT64 IPv6 forms), and the connection is
then **pinned** to exactly the verified addresses via a custom DNS resolver — so an attacker who
controls DNS cannot race a second resolution back to a private target after the check passes.
Redirects are followed manually, capped at 5 hops, with the full host-and-address check re-run at
every hop — a redirect cannot be used to smuggle a request past the guard either. None of this is
inferred from documentation; it is read directly from `lib/fetch.js` while writing this page.

**Byte and time bounds, and what happens when they are hit.** Every fetch is capped at 20 seconds
and 4.5 MB — comfortably above the largest About page measured for this hub (roughly 2.5–2.6 MB) —
and a response that hits the byte cap is explicitly marked `truncated` and reported as an honest
`status:"error"` row (`"response truncated at the byte cap on /about — page may be incomplete/
corrupt"`) rather than parsed partially and reported as if it were complete.

**Billing is atomic and mutex-protected, by design, against a known class of race.** A charge and
its corresponding dataset write happen together, inside a single serialized critical section
(`lib/billing.js`'s `withLock`), so two concurrent workers processing different requested channels
cannot both read "budget remains" and both proceed past the buyer's own spending cap. This exact
class of bug — a plain counter read outside the lock, allowing an over-delivery race under
concurrency — is documented in this codebase's own history as something Codex review previously
reproduced live on a sibling Actor line before this pattern was standardized. This Actor was built
with the already-fixed version from the start, not patched into it afterward.

**Independent review before this page was written.** This Actor's identity-confirmation fix — the
`aboutChannelViewModel` occurrence-count drift described above — went through its own dedicated fix
cycle after live measurement showed the original "always pick the second occurrence" design was
unsafe; the replacement logic is what ships today, backed by the 16-test suite named above. The
billing guard is the same previously-reviewed `lib/billing.js` component, reused unmodified from an
already-shipped sibling line, per this codebase's own build standard: "a copy that looks equivalent
is how the line drifts and re-introduces a fixed bug."

**Boundary with sibling Actors in this hub.** `youtube-channel-intel` reads the **requested**
channel's own data from the same page — including its external links and business email — and is
the correct tool if that is what you need; this Actor deliberately never returns those fields for
either the requested or the similar channels, to avoid selling the same field twice under two
different Actors. `youtube-channel-videos-list` and `youtube-playlist-scraper` read a completely
different part of a channel (its video/upload list), not the About page's similar-channels widget,
and do not overlap with this Actor's data at all.

### Decision routing

| Row shape | `status` | Billable? | `action` | What it means |
|---|---|---|---|---|
| Delivered similar channel, subscriber count present | `"ok"` | **Yes** | `"ingest"` | The core billable outcome |
| Delivered similar channel, subscriber count missing | `"ok"` | **Yes** | `"skip-no-subcount"` | Still billed — `partial:true` describes a missing field, not a discount; see Pricing |
| Requested channel resolved, widget genuinely empty | `"empty"` | No | `"skip-not-found"` | A real, honest zero — not distinguishable from an error by `action` alone, only by `status` |
| Requested channel did not resolve (`HTTP 404`, or blocked/invalid input) | `"error"` | No | `"skip-not-found"` | `error` field names the specific reason — see Field dictionary |
| Requested channel ID is well-formed but does not exist (`HTTP 200`, body text) | `"error"` | No | `"skip-not-found"` | The second, distinct negative case — see Evidence and boundaries |
| Run's own spending cap reached partway through | `"error"` | No | `"skip-not-found"` | One free explanatory row is written naming exactly this; already-billed rows from earlier in the same run are unaffected |

A workflow only needs two rules to route this Actor's output correctly: treat `status:"ok"` as the
one billed, deliverable outcome, and never assume a fixed number of `"ok"` rows per requested
channel — see Evidence and boundaries for the measured range.

### Commercial playbooks

#### Lookalike audience seeding for influencer marketing

A media buyer or influencer-marketing team runs this Actor against a shortlist of creator/brand
channels they already know convert, and feeds every delivered `similarChannelId` — plus, when
present, `similarSubscriberCountText` for a rough size filter — into an ad platform's custom or
lookalike audience tool, or directly into an outreach spreadsheet for influencer partnerships,
without manually clicking through YouTube's own About-page widget for each channel by hand.

#### Competitive peer-set benchmarking

A competitive analyst runs this Actor against a competitor's own channel to see who YouTube itself
considers that competitor's neighbors — a signal the competitor cannot see, curate, or suppress,
since it comes from YouTube's own recommendation system, not from the competitor's declared
positioning.

#### Bulk channel-discovery seed for a larger research pipeline

A researcher or agency runs this Actor against a batch of up to 50 known "hub" channels in a
vertical, collects every `similarChannelId` returned, and de-duplicates the result into a candidate
list for a second-stage tool (e.g. this hub's own `youtube-channel-intel`) to enrich each candidate
with links, email and subscriber detail — chaining two purpose-built Actors rather than asking one
Actor to do both jobs and charge for both.

### Integration recipes

#### Scheduled competitive watch

An Apify Task runs this Actor weekly or monthly against a fixed list of competitor channels,
reading the Dataset for `status:"ok"` rows and diffing `similarChannelId` sets between runs in your
own storage to see which lookalike channels are new, gone, or persistent — this Actor itself keeps
no memory between runs; the diff is the caller's responsibility.

#### Agent/MCP pattern

An agent supplies one or more channel values and reads `status` before doing anything else with a
row: `"ok"` rows carry a usable `similarChannelId`; `"empty"`/`"error"` rows never do. It should
keep consuming rows for a given `requestedChannel` until that channel's batch ends, whatever size
it turns out to be.

#### Data-warehouse append pattern

Dataset rows are appended keyed by `(requestedChannelId, similarChannelId)` when both are present —
that pair is stable and meaningful even though the total count per requested channel is not. A
`status:"empty"`/`"error"` row carries no `similarChannelId`; key those rows by
`(requestedChannel, checkedAt)` instead, so a "this channel had nothing" observation is not lost
or silently merged with a genuine channel-not-found error.

### Operating guide

**Why this Actor never exposes a "top N similar channels" input.** There is no server-side ranking
or truncation this Actor could apply on top of YouTube's own widget order that would not
misrepresent what YouTube actually showed. `widgetPosition` reports the order observed this run;
it is intentionally not exposed as a filterable/cappable input, because doing so would imply a
promise about ranking stability this data source does not support.

**Reconciling a run against your invoice.** This run's key-value store record `OUTPUT` carries a
one-time run summary written at the end of the run: how many channels were requested, how many rows
were delivered, how many of those were charged and how many were free, how many failed, how many
came back incomplete, whether re-running the same input is safe, and whether the run ended in a
fatal error. Because the number of similar channels varies between runs, this roll-up is the fastest
way to see what a given run actually cost you. The Dataset stays the complete row-by-row record;
`OUTPUT` is the summary, not a replacement for it.

**Reading a `status:"empty"` row correctly.** An empty widget is a genuine, confirmed-live outcome
(see `@GoogleDevelopers` above), not a bug — it happens for channels YouTube's own recommendation
system has not populated a similar-channels shelf for. See Limits for what this page's testing
could and couldn't establish about which channels this affects.

**Choosing `maxConcurrency`.** The default of `5` is a conservative starting point, not a measured
ceiling — this Actor has not been separately load-tested for a maximum sustainable concurrency
against YouTube's own rate limits. If you are running the full 50-channel input ceiling regularly,
consider a lower value first and watch for `status:"error"` rows with a fetch-timeout or non-404
HTTP error before raising it.

**Interpreting repeated runs against the same channel.** Short successive runs can return an
identical widget composition; runs separated by a day or more have measured genuinely different
ones for the same channel (see Evidence and boundaries). Schedule any change-detection in days, not
minutes, and diff the delivered `similarChannelId` sets in your own storage — see Integration
recipes.

#### Related tools

Related tools for adjacent workflows in social platforms and regional directories.

| Actor | What it does |
|---|---|
| [YouTube Channel Intel](https://apify.com/zinin/youtube-channel-intel) | Pair it in the social platforms and regional directories workflow: Public intel card for any YouTube channel: subscribers, total views, video count, join date, country, full... |
| [YouTube Channel 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... |
| [YouTube Playlist Scraper](https://apify.com/zinin/youtube-playlist-scraper) | Pair it in the social platforms and regional directories workflow: Scrape any YouTube playlist by id or URL — including other people's public playlists, not just your own... |
| [Substack Publication Scraper](https://apify.com/zinin/substack-publication-scraper) | Pair it in the social platforms and regional directories workflow: Read recent posts from any Substack publication's own domain — title, date, full HTML body, comment count... |
| [Bluesky Profile Scraper](https://apify.com/zinin/bluesky-profile-scraper) | Pair it in the social platforms and regional directories workflow: Batch-fetch public Bluesky profiles by handle or DID via the official keyless AppView API — display name,... |

### FAQ

#### Will the same channel always return the same number of similar channels?

No — see Evidence and boundaries. The same channel, `@mkbhd`, measured across independent sessions
returned 12, then 7, then a stable 6 across four consecutive runs in the session that produced this
page.

#### Does a missing subscriber count mean the row is free?

No. `partial:true` on a `status:"ok"` row means the subscriber-count text is missing for that one
channel — the row is still delivered and still billed. See Pricing for why this is intentional.

#### Can the requested channel appear in its own similar-channels list?

No, by construction — the extraction step is given the requested channel's own confirmed
`channelId` and structurally excludes it, and this Actor's own acceptance suite specifically checks
that no delivered row's `similarChannelId` or subscriber count matches the requested channel's own.
See Evidence and boundaries for the exact page-structure trap this closes.

#### What happens if a channel's widget is genuinely empty?

One free `status:"empty"` row, with the requested channel's own identity (`requestedChannelId`,
`requestedCanonicalChannelUrl`) still correctly resolved and reported — demonstrated live above
with `@GoogleDevelopers`. This is not an error.

#### Does this Actor return external links or the channel's business email?

No, for either the requested channel or any similar channel. That data belongs to the sibling
Actor `youtube-channel-intel` in this hub, deliberately, so the two Actors do not sell the same
field twice.

#### Does this Actor read comments, transcripts, or search results?

No. This Actor reads exactly one page shape — a channel's About page — and nothing else. See
Sources and rights for the exact `robots.txt` paths this Actor never requests.

#### Do I need a YouTube API key or a login?

No. Every request this Actor makes is an unauthenticated `GET` with a consent cookie and a locale
query string — no key, no session, no account, confirmed by every live run made while building
this page.

#### How is the subscriber count reported — is it exact?

No. `similarSubscriberCountText` is YouTube's own rounded display text (e.g. `"1.13M subscribers"`),
exactly as the widget shows a human visitor — never a computed exact integer. `confidence` is fixed
to `"display-text"` on every delivered row specifically to keep this visible on every row, not just
in this page's prose.

#### What is the difference between the two "channel not found" cases?

Two genuinely different situations, both demonstrated live above: a handle or ID that plainly does
not resolve returns `HTTP 404` (`error: "channel not found (HTTP 404)"`); a syntactically valid
`UC…` ID with no real channel behind it returns a clean `HTTP 200` with "This channel does not
exist" in the body (`error: "channel does not exist (confirmed via page body text)"`). Both are
free, `status:"error"` rows — the wording of `error` is the only thing that tells them apart.

### Sources and rights

Source family: YouTube public channel and playlist pages. This Actor reads the channel page's own recommendations shelf, anonymously, with no login, no API key and no browser session.

**The only source.** `https://www.youtube.com/@handle/about` (and the equivalent `/channel/UC…`,
`/c/Name`, `/user/Name` forms) — the same public, unauthenticated page a human visitor sees in a
browser, including its "similar channels" widget. No YouTube Data API key, no OAuth, no session.

**`robots.txt`.** `www.youtube.com/robots.txt` disallows `/api/`, `/youtubei/`, `/results` and
`/comment`. The About-page path this Actor reads is not disallowed. This Actor never requests any
of the disallowed paths for any reason — it has no code path that could.

**No warranty implied.** This Actor reports whatever YouTube's own front end rendered for a given
channel at the moment of the request. It cannot guarantee YouTube's continued page shape, the
widget's continued existence, or that any specific channel will have a populated widget on any
given day — see Evidence and boundaries for the measured range this claim is based on.

**What this Actor does not read or redistribute.** No video content, no comments, no transcripts, no
search results, no data from any authenticated or paid YouTube surface. Channel names returned in
`similarChannelName` are the channel owners' own public display names, shown by YouTube itself in
the widget — the same text a human visitor already sees on that page.

### Limits

- **The similar-channels widget's size is not fixed and is entirely YouTube's decision** — measured
  live between 0 and 12 similar channels across ordinary, well-known channels in one short session,
  and 12/7/6 across independent measurements of the identical channel over time. See Evidence and
  boundaries.
- **`partial:true` (a missing subscriber count on a delivered row) was not observed live** during
  this page's own testing across `@mkbhd`, `@NASA` and `@ycombinator` — every delivered row carried
  a subscriber count in every test run made while writing this page. The code path and Dataset field
  exist and are nullable by design; how often it actually fires in the wild is honestly unmeasured
  by this Actor's own testing.
- **A genuinely empty widget (`status:"empty"`) was observed for exactly one channel tested,**
  `@GoogleDevelopers`. This confirms the shape exists and behaves as an honest empty result rather
  than a crash or a silent error, but this page's own testing did not establish which category of
  channel (small, new, non-personal, or something else) reliably produces it.
- **`widgetPosition` is a same-run observation, not a stable ranking.** It records the order the
  shelf rendered in for that one request; nothing in this Actor or in YouTube's own page contract
  promises the same channel keeps the same position, or appears at all, on a later run.
- **No cross-run memory.** This Actor keeps no state between runs — it does not diff, deduplicate,
  or track "new since last time" for you. A scheduled watch needs its own storage on the caller's
  side to detect change; see Integration recipes.
- **`maxConcurrency`'s upper bound (20) has not been separately load-tested against YouTube's own
  rate limits** by this Actor's own build process; the default of 5 is a conservative starting
  point, not a measured safe ceiling. See Operating guide.
- **No external links, business email, video list, or transcript data** — out of scope by design,
  not by omission; see Evidence and boundaries and Sources and rights for where that data actually
  lives in this hub.
- **English/US locale only.** Every request is sent with `hl=en&gl=US`; this Actor does not support
  reading a channel's About page in another rendered locale.

### Support boundary

Actor support covers deterministic input validation (the host/path checks demonstrated live above),
the extraction behavior described in Evidence and boundaries, the Dataset schema, and the billing
split between a delivered similar channel and every free outcome. It does not cover, and cannot
promise: how many similar channels YouTube's widget will show for any given channel on any given
day (see Evidence and boundaries — this is YouTube's decision, not this Actor's); why YouTube's own
recommendation system chose the specific channels it did; recovery if YouTube changes this page's
underlying structure in a way that breaks extraction (this Actor's own build history already shows
one such structural drift, in `aboutChannelViewModel`'s occurrence count, closed by the
identity-based fix described in Evidence and boundaries — a future drift of a different kind is
possible and would need its own fix); and it does not provide marketing, audience-targeting, or
competitive-strategy advice on top of the raw channel identities this Actor returns. When reporting
a problem, include the Actor run ID, the exact `channels` input used, the affected
`requestedChannel` value, its `status`/`error`, and the approximate `checkedAt` time.

# Actor input Schema

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

List of channels: @handle, full URL (https://www.youtube.com/@handle), or bare channel ID (UC...). For each one, returns the similar channels YouTube itself surfaces in the recommendations widget on the About page.

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

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

## Actor input object example

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

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

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

```

## MCP server setup

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

```

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/sMokIFjXOK7W7zbfU/builds/MEuPCTr8MDnzU06fM/openapi.json
