

Задача
Дашборд, который по имени канала собирает цифры, которых нет на самом YouTube. Без OAuth и аккаунтов: пользователь вводит запрос, получает список каналов, выбирает один — и видит средние просмотры, лайки, комментарии, вовлечённость в процентах, частоту загрузок, суммарную длину контента, лучший и худший ролик, а также сортируемую таблицу видео.
Как делал
Поиск сделан в два запроса, а не в один. search.list возвращает только id канала плюс обрывки сниппета — статистики там нет. Поэтому сначала забираем до десяти channelId, а затем одним запросом channels.list с id=id1,id2,... догружаем счётчики, баннер и темы. Квота тратится один раз вместо десяти, а задержка не удваивается — поиск всё ещё один round-trip.
const searchRes = await fetchYouTube<SearchListResponse>(
`${BASE}/search?part=snippet&type=channel&maxResults=10&q=${encodeURIComponent(query)}&key=${apiKey}`,
)
const channelIds = searchRes.items.map((i) => i.id.channelId).filter(Boolean)
if (channelIds.length === 0) return []
// statistics, brandingSettings и topicDetails лежат только в channels.list,
// поэтому все id уезжают в один запрос через запятую
const channelRes = await fetchYouTube<ChannelListResponse>(
`${BASE}/channels?part=snippet,statistics,brandingSettings,topicDetails&id=${channelIds.join(",")}&key=${apiKey}`,
)
Вовлечённость считается как среднее отношение лайков к просмотрам по каждому ролику отдельно, а не как сумма лайков, делённая на сумму просмотров. Разница видна на выборке с хитовым роликом: один вирусный ролик раздувает общий знаменатель и занижает процент, а среднее по роликам показывает типичную реакцию на обычное видео. Ролики без лайков и без просмотров из выборки выпадают, иначе они тянули бы среднее к нулю.
const videosWithLikes = videos.filter((v) => v.likeCount > 0 && v.viewCount > 0)
const engagementRate = videosWithLikes.length > 0
? videosWithLikes.reduce((s, v) => s + v.likeCount / v.viewCount, 0) / videosWithLikes.length * 100
: 0
Картинки с YouTube прогоняются через локальные роуты: домены аватаров и превью подменяются на свои префиксы, потому что при прямом обращении к CDN у аватаров слетает referrer-политика и часть каналов отдаёт заглушку вместо аватарки.
function proxyUrl(url: string | undefined): string | undefined {
if (!url) return undefined
if (url.startsWith("https://i.ytimg.com/")) return url.replace("https://i.ytimg.com", "/cdn/ytimg")
if (url.startsWith("https://yt3.ggpht.com/")) return url.replace("https://yt3.ggpht.com", "/cdn/ggpht")
if (url.startsWith("https://yt3.googleusercontent.com/")) return url.replace("https://yt3.googleusercontent.com", "/cdn/yt3")
return url
}
Ключ можно привести с собой: если поле не заполнено, роут берёт ключ из YOUTUBE_API_KEY в окружении. Это снимает вопрос «у меня не работает», когда у человека нет своего ключа, но не заставляет никого его заводить. Кэш при этом отключён явно — revalidate: 0, потому что счётчики подписчиков устаревают за минуты.
let { apiKey, channelId } = await req.json()
if (!channelId) {
return NextResponse.json({ error: "Missing channelId" }, { status: 400 })
}
apiKey = apiKey || process.env.YOUTUBE_API_KEY // поле пустое — берём серверный
if (!apiKey) {
return NextResponse.json({ error: "No API key configured" }, { status: 400 })
}
Даты в таблице не форматируются библиотекой, а считаются вручную с делением на сутки: «сегодня», «вчера», «Nd ago», затем недели, месяцы и годы. В длинной ленте точные даты превращаются в кашу, а такая шкала остаётся читаемой на любом горизонте.
const diff = now.getTime() - d.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return "Today"
if (days === 1) return "Yesterday"
if (days < 7) return `${days}d ago`
if (days < 30) return `${Math.floor(days / 7)}w ago`
if (days < 365) return `${Math.floor(days / 30)}mo ago`
return `${Math.floor(days / 365)}y ago`
Длительность приходит из API строкой в формате ISO 8601 (PT1H2M3S) и разбирается регуляркой на часы, минуты и секунды — дальше из неё считается и суммарная длительность всего канала, и колонка в таблице. Сортировка по любой колонке переиспользует один компаратор: направление вынесено в множитель -1, а разница типов для дат сводится к тем же числам через getTime().
export function parseISO8601Duration(duration: string): number {
const match = duration.match(/PT(\d+H)?(\d+M)?(\d+S)?/)
if (!match) return 0
const hours = parseInt(match[1]?.replace("H", "") ?? "0")
const minutes = parseInt(match[2]?.replace("M", "") ?? "0")
const seconds = parseInt(match[3]?.replace("S", "") ?? "0")
return hours * 3600 + minutes * 60 + seconds
}
Итог
Next.js 14 на App Router, TypeScript в строгом режиме, Tailwind и семь shadcn-компонентов на @base-ui/react. Логика аналитики вынесена в lib/youtube.ts и живёт в server-компоненте с "use server" — ключ не покидает сервер, а клиент получает только готовые числа и сортируемую таблицу.