How to Analyze YouTube Trends: Data API to Structured Data

Collect a niche's recent videos with the YouTube Data API for a few quota units, extract typed rows with TubeExtract, and turn them into a weekly trend table. Full scripts.

By TubeExtract team · 9 min read

A trend on YouTube is a change in what creators talk about: a product that goes from rare to everywhere in a month, a topic that flips from praise to complaints, a new name that suddenly appears in every review. View counts show that something is popular. What is said in the videos shows what it is and which way opinion is moving.

This post builds that pipeline end to end. First the YouTube Data API collects a niche’s recent videos with a handful of quota units. Then TubeExtract turns what is said in them into typed rows. Last, a short script turns the rows into a weekly trend table. The example follows electric bike reviews, and the same three scripts work for any niche: swap the channels and the two columns.

The pipeline in three steps

  • Collect. List the channels in your niche, fetch their uploads from the last few months with the YouTube Data API, and keep each video’s id, title, publish date, length and views.
  • Extract. Send the video URLs to TubeExtract with a schema: the columns you want per video, such as the product reviewed and the host’s sentiment.
  • Analyze. Join the rows back to the publish dates, group by week and compare the recent weeks with the ones before.

You need two keys: a YouTube Data API key from the Google Cloud console and a TubeExtract key from your dashboard. The scripts below are Node 20 or later and read them from YOUTUBE_API_KEY and TUBEEXTRACT_KEY.

Step 1: get the videos with the YouTube Data API

The obvious endpoint is search.list, and it is the expensive one. Google publishes the quota cost of each method: a search call is 100 units, while playlistItems.list, channels.list and videos.list are 1 unit each. A new project gets 10,000 units a day.

CallUnitsWhat it gives you
channels.list1A channel’s uploads playlist, from its handle
playlistItems.list1 per page of 50Every upload, newest first, with its publish date
videos.list1 per 50 videosTitle, length and view count
search.list100Ranked search results; not needed here

So the plan is to walk each channel’s uploads playlist. Because it is ordered newest first, the script stops paging the moment it reaches a video older than the cut-off. Twenty channels with a few dozen uploads each in the window come to well under a hundred units.

// fetch-videos.mjs: last 90 days of uploads from a list of channels
import { writeFile } from "node:fs/promises";
 
const KEY = process.env.YOUTUBE_API_KEY;
const API = "https://www.googleapis.com/youtube/v3";
const HANDLES = ["@channelone", "@channeltwo", "@channelthree"]; // your niche
const SINCE = new Date(Date.now() - 90 * 864e5).toISOString();
 
async function yt(path, params) {
const clean = Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined));
const url = new URL(API + "/" + path);
url.search = new URLSearchParams({ ...clean, key: KEY });
const res = await fetch(url);
if (!res.ok) throw new Error(path + " failed: " + res.status);
return res.json();
}
 
// 1 unit: a channel handle -> the playlist that holds all its uploads
async function uploadsPlaylist(handle) {
const data = await yt("channels", { part: "contentDetails", forHandle: handle });
return data.items[0].contentDetails.relatedPlaylists.uploads;
}
 
// 1 unit per page of 50, newest first, so we can stop at the cut-off date
async function recentVideoIds(playlistId) {
const ids = [];
let pageToken;
do {
const page = await yt("playlistItems", {
part: "contentDetails", playlistId, maxResults: 50, pageToken,
});
for (const item of page.items) {
if (item.contentDetails.videoPublishedAt < SINCE) return ids;
ids.push(item.contentDetails.videoId);
}
pageToken = page.nextPageToken;
} while (pageToken);
return ids;
}
 
// 1 unit per 50 videos: title, publish date, length and view count
function minutes(iso) {
const [, h = 0, m = 0, s = 0] = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
return Number(h) * 60 + Number(m) + Number(s) / 60;
}
 
async function details(ids) {
const out = [];
for (let i = 0; i < ids.length; i += 50) {
const data = await yt("videos", {
part: "snippet,contentDetails,statistics",
id: ids.slice(i, i + 50).join(","),
});
for (const v of data.items) {
out.push({
id: v.id,
title: v.snippet.title,
channel: v.snippet.channelTitle,
publishedAt: v.snippet.publishedAt,
views: Number(v.statistics.viewCount ?? 0),
minutes: minutes(v.contentDetails.duration),
});
}
}
return out;
}
 
const ids = [];
for (const handle of HANDLES) ids.push(...(await recentVideoIds(await uploadsPlaylist(handle))));
 
// Skip Shorts and clips: fewer minutes to read, fewer credits spent
const videos = (await details(ids)).filter((v) => v.minutes >= 3);
await writeFile("videos.json", JSON.stringify(videos, null, 2));
console.log(videos.length + " videos saved");
NOTE
Save the API responses to disk while you are developing. Re-running a script against a saved videos.json costs nothing, while re-fetching spends quota every time.

Choosing the channels

The channel list is the sample, so it decides what the trend means. Pick channels that cover your niche the way a reader of the result would expect: the big names and a spread of mid-sized ones. Fifteen to thirty channels over ninety days gives a few hundred videos, enough for week-by-week shares to be stable.

Step 2: feed the videos into TubeExtract

TubeExtract reads what is said in each video and returns one row per thing you asked for, typed and validated against your schema. For a trend you want few, well-worded columns. Here a row is one product a host reviews, with the host’s overall opinion of it:

  • product as a verbatim-string, so names come back as the host says them and can be matched across videos.
  • sentiment as an enum with three options, so every value is one you can count.
  • multiple: true, because a comparison video covers several products.

The script sends up to 200 URLs per request, polls the job until it settles and writes the rows to disk in the same step. Results are returned once, so persisting where you read is the safe pattern (see the get-job reference). On a paid plan a webhook_url can replace the polling loop.

// extract.mjs: send videos.json to TubeExtract, wait, save the rows
import { readFile, writeFile } from "node:fs/promises";
 
const API = "https://api.tubeextract.dev/v1";
const headers = {
"X-API-Key": process.env.TUBEEXTRACT_KEY,
"Content-Type": "application/json",
};
 
const schema = [
{ name: "product", type: "verbatim-string",
description: "An electric bike model the host reviews or compares, as spoken" },
{ name: "sentiment", type: "enum", options: ["positive", "mixed", "negative"],
description: "The host's overall opinion of that bike" },
];
 
const videos = JSON.parse(await readFile("videos.json", "utf8"));
const urls = videos.map((v) => "https://www.youtube.com/watch?v=" + v.id);
 
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const rows = {}; // video_id -> [{ product, sentiment }]
 
// 200 videos per request
for (let i = 0; i < urls.length; i += 200) {
const job = await fetch(API, {
method: "POST",
headers,
body: JSON.stringify({ videos: urls.slice(i, i + 200), multiple: true, schema }),
}).then((r) => r.json());
 
let result;
do {
await sleep(15_000);
result = await fetch(API + "/" + job.job_id, { headers }).then((r) => r.json());
} while (!["done", "partial", "failed"].includes(result.status));
 
// Rows are handed over once, so write them down in this same step
for (const v of result.videos) if (v.rows) rows[v.video_id] = v.rows;
await writeFile("rows.json", JSON.stringify(rows, null, 2));
}
console.log(Object.keys(rows).length + " videos extracted");

What a run costs

Credits are 10 per started minute of video, charged before extraction. A 12-minute video is 120 credits, so a run of 300 videos averaging 12 minutes is 36,000 credits. The levers are the ones already in the first script: a minimum length that drops Shorts, a tighter channel list and a shorter window. A video with no caption track returns no_captions and is never charged. See credits and limits for the plans.

Wording the columns

The description is the instruction the model follows. “An electric bike model the host reviews or compares” returns the bikes the video is about; a looser “a bike” also returns every bike mentioned in passing. If you want to try wording changes cheaply, run them on five or ten videos first. The writing a schema guide covers it in more detail.

Step 3: turn rows into a trend

Every row belongs to a video, and every video has a publish date from step 1. That join is the whole trick. The script groups mentions by the Monday of the week the video was published, counts each product once per video, and compares the last four weeks with the four before.

// trends.mjs: weekly share of mentions per product
import { readFile } from "node:fs/promises";
 
const videos = JSON.parse(await readFile("videos.json", "utf8"));
const rows = JSON.parse(await readFile("rows.json", "utf8"));
 
// Monday of the week a date falls in, as YYYY-MM-DD
function week(iso) {
const d = new Date(iso);
d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
return d.toISOString().slice(0, 10);
}
 
const norm = (name) => name.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
 
const perWeek = {}; // week -> { product -> { mentions, positive } }
for (const video of videos) {
const w = week(video.publishedAt);
const seen = new Set(); // count a product once per video
for (const row of rows[video.id] ?? []) {
const key = norm(row.product);
if (seen.has(key)) continue;
seen.add(key);
const cell = ((perWeek[w] ??= {})[key] ??= { mentions: 0, positive: 0 });
cell.mentions += 1;
if (row.sentiment === "positive") cell.positive += 1;
}
}
 
// Share of that week's videos mentioning each product
const videosPerWeek = {};
for (const v of videos) videosPerWeek[week(v.publishedAt)] = (videosPerWeek[week(v.publishedAt)] ?? 0) + 1;
 
const weeks = Object.keys(perWeek).sort();
const recent = weeks.slice(-4);
const before = weeks.slice(-8, -4);
 
const share = (set, key) => {
let m = 0, n = 0;
for (const w of set) { m += perWeek[w]?.[key]?.mentions ?? 0; n += videosPerWeek[w] ?? 0; }
return n ? m / n : 0;
};
 
const products = new Set(weeks.flatMap((w) => Object.keys(perWeek[w])));
const table = [...products]
.map((p) => ({ product: p, before: share(before, p), recent: share(recent, p) }))
.map((r) => ({ ...r, change: r.recent - r.before }))
.sort((a, b) => b.change - a.change);
 
console.table(table.slice(0, 10));

The output is a ranking of what is rising, in this shape (illustrative values):

┌─────────┬──────────────────┬────────┬────────┬────────┐
│ (index) │ product │ before │ recent │ change │
├─────────┼──────────────────┼────────┼────────┼────────┤
│ 0 │ 'model a' │ 0.04 │ 0.19 │ 0.15 │
│ 1 │ 'model b' │ 0.11 │ 0.21 │ 0.10 │
│ 2 │ 'model c' │ 0.17 │ 0.16 │ -0.01 │
└─────────┴──────────────────┴────────┴────────┴────────┘

Reading it well

  • Use shares, not counts. Channels upload more in some weeks. The share of that week’s videos mentioning a product stays comparable; a raw count does not.
  • Merge names. Hosts say “the model a”, “model a pro” and “model A”. Lower-casing handles the last; a small alias map handles the rest, built from the product values in rows.json.
  • Add sentiment. The script already tracks the positive count. A product that is rising and mostly positive is a different story from one that is rising because it is being complained about.
  • Weight by views when it matters. The views field in videos.json lets you count a mention in a million-view video for more than one in a small channel’s.
  • Keep the window rolling. Run step 1 weekly with a seven-day cut-off, extract only the new videos and append. The history builds itself, and each run costs a handful of units and credits.

From here the rows go wherever your analysis lives: a spreadsheet, a notebook, a warehouse table or a dashboard. The same pattern works for a topic instead of a product (use a column such as topic_discussed), for creators sponsoring a brand, for questions viewers keep asking, or for any list of channels you want to watch.

Ready to run it on your niche? The quickstart gets your first job back in a few minutes, and the starter credits that come with a free account (no card) let you try the three scripts on a small channel list.

Frequently asked questions

How do I get a list of videos from a YouTube channel with the API?

Call channels.list with the channel's handle to get its uploads playlist, then page through playlistItems.list. Each call costs 1 quota unit and returns up to 50 videos, newest first, so a channel's last three months is usually one or two calls.

Why not use YouTube search to find videos?

search.list costs 100 quota units a call against 1 for playlistItems.list, and the default quota is 10,000 units a day. Walking channel upload playlists covers the same ground for a hundredth of the price, and gives you every upload rather than a ranked sample.

How many videos can I send to TubeExtract at once?

Up to 200 video URLs in one request. Larger lists are split into several requests, each with its own job id.

What does analyzing a video cost?

10 credits per started minute of video, charged before extraction. A video without a caption track returns no_captions and is never charged, so filtering out very short videos and videos you do not need keeps a run small.

Try it on your own videos

Describe the columns, send up to 200 video URLs, get typed rows back. New accounts start with 3,000 credits, about 300 minutes of video.