DEV Community

Uchechukwu
Uchechukwu

Posted on

How I audited a 1,400-video YouTube channel without the Data API

A channel landed in my lap for a growth read. 1,404 uploads, 648 subscribers, and the owner's only dashboard was the phone app. The YouTube Data API would have capped me at a few hundred results and burned a quota to get there.

So I read the channel's own page. Here is the whole method, no API key, no quota.

1. Pull the channel page and grab the bootstrap

curl -s "https://www.youtube.com/@CHANNEL/videos" -H 'accept-language: en-US,en;q=0.9' > channel.html
Enter fullscreen mode Exit fullscreen mode

The page ships a JSON blob called ytInitialData plus two values you need for every later call:

const html = fs.readFileSync('channel.html', 'utf8');
const data = JSON.parse(
  html.match(/var ytInitialData = (\{.+?\});<\/script>/s)[1]
);
const apiKey = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/)[1];
const visitor = html.match(/"VISITOR_DATA":"([^"]+)"/)[1];
Enter fullscreen mode Exit fullscreen mode

2. Page through the grid

The first ~30 videos live at:

const grid = data.contents.twoColumnBrowseResultsRenderer
  .tabs[0].tabRenderer.content.richGridRenderer.contents;
const token = grid.at(-1).continuationItemRenderer
  .continuationEndpoint.continuationCommand.token;
Enter fullscreen mode Exit fullscreen mode

Feed that token back to the internal browse endpoint:

const res = await fetch(`https://www.youtube.com/youtubei/v1/browse?key=${apiKey}`, {
  method: 'POST',
  headers: { 'content-type': 'application/json', 'X-Goog-Visitor-Id': visitor },
  body: JSON.stringify({
    context: { client: { clientName: 'WEB', clientVersion: '2.20250101.00.00' } },
    continuation: token,
  }),
});
Enter fullscreen mode Exit fullscreen mode

Response shape is onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems. Pull the richItemRenderer, keep the next continuationItemRenderer token, repeat until it is gone. That is the full catalogue, newest first, at roughly one request per 30 videos. I added a 400 ms sleep per page and it walked 47 pages without a single block.

3. The fields that actually matter

Each grid item gives you videoId, title.runs[0].text, viewCountText, publishedTimeText ("2 days ago", not a date) and length. If you need exact dates, lengths in seconds, description and tags, hit the watch page for that video and read ytInitialPlayerResponse.videoDetails (lengthSeconds, shortDescription, keywords).

4. What I found, and why numbers alone lie

Raw totals said nothing. Bucketing the views did:

0 views        158
1-9            640
10-49          176
50-99           77
100-499        258
500-999         95
1000+            0
Enter fullscreen mode Exit fullscreen mode

A channel that cannot break 1,000 views is not a content problem first, it is a discovery problem. Three more facts made the diagnosis certain rather than guessed:

  • Cadence. 942 of 1,404 videos were uploaded in the last 14 days. That is ~68 a day from a channel whose own lifetime average is 9. Mass-uploading to a feed that already ignores you does not compound, it dilutes.
  • Titles. Ten fixed titles, repeated. <Rhyme> Compilation, over and over. Nothing for a search engine or a human to distinguish.
  • One identical description. All 1,404 videos share the same 2,031-character block: a pasted list of rhyme names. No timestamps, no links, no call to action. Zero tags on every video.
  • Thumbnails. Compared the thumbnail text against the title for each of the last 13. All 13 showed a different rhyme than the video. The click offer was wrong every time.

That last one is the kind of thing a dashboard will never tell you and a flat list of view counts will never surface. You only get it by reading the artwork and the title side by side.

The lesson

An audit is not a report of totals. It is the smallest set of comparisons that makes the cause obvious: wins against losses, title against thumbnail, upload rate against audience. Scraping the channel page got me 1,404 rows for free. The comparison is the part worth paying for.

If you try this: keep the request rate human, cache the HTML, and do not hammer the continuation endpoint. It is someone's page, not your database.

Top comments (0)