107 lines
4.5 KiB
JavaScript
107 lines
4.5 KiB
JavaScript
/* innertube.js — YouTube search via the InnerTube JSON API (no yt-dlp spawn).
|
|
* parseSearch() is pure (tested against fixtures/innertube-search.json);
|
|
* search() does the HTTP call. Callers MUST fall back to yt-dlp on any throw. */
|
|
const CLIENT = { clientName: 'WEB', clientVersion: '2.20250101.00.00', hl: 'en', gl: 'US' };
|
|
|
|
export function lengthToSeconds(s) {
|
|
if (typeof s !== 'string' || !/^\d+(:\d{1,2}){0,2}$/.test(s.trim())) return 0;
|
|
return s.trim().split(':').map(Number).reduce((acc, n) => acc * 60 + n, 0);
|
|
}
|
|
|
|
// Cards from a list of itemSectionRenderer-style sections.
|
|
function cardsFromSections(sections) {
|
|
const out = [];
|
|
for (const s of sections) {
|
|
for (const it of s?.itemSectionRenderer?.contents || []) {
|
|
const v = it && it.videoRenderer;
|
|
if (!v || typeof v.videoId !== 'string') continue;
|
|
const owner = v.ownerText?.runs?.[0] || {};
|
|
const be = owner.navigationEndpoint?.browseEndpoint || {};
|
|
const path = be.canonicalBaseUrl || (be.browseId ? `/channel/${be.browseId}` : '');
|
|
out.push({
|
|
id: v.videoId,
|
|
title: v.title?.runs?.map((r) => r.text).join('') || '(untitled)',
|
|
channel: owner.text || '',
|
|
channelId: be.browseId || '',
|
|
channelUrl: path ? `https://www.youtube.com${path}` : '',
|
|
duration: lengthToSeconds(v.lengthText?.simpleText),
|
|
thumbnail: `https://i.ytimg.com/vi/${v.videoId}/mqdefault.jpg`,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const tokenOf = (sections) => {
|
|
for (const s of sections || []) {
|
|
const t = s?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
|
|
if (t) return t;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
export function parseSearch(json) {
|
|
const sections = json?.contents?.twoColumnSearchResultsRenderer?.primaryContents
|
|
?.sectionListRenderer?.contents;
|
|
if (!Array.isArray(sections)) throw new Error('innertube: unexpected response shape');
|
|
return cardsFromSections(sections);
|
|
}
|
|
|
|
// First page plus the token for the next one ('' when there is none).
|
|
export function parseSearchPage(json) {
|
|
const sections = json?.contents?.twoColumnSearchResultsRenderer?.primaryContents
|
|
?.sectionListRenderer?.contents;
|
|
if (!Array.isArray(sections)) throw new Error('innertube: unexpected response shape');
|
|
return { cards: cardsFromSections(sections), next: tokenOf(sections) };
|
|
}
|
|
|
|
// A continuation response: more cards plus the next token.
|
|
export function parseContinuation(json) {
|
|
const items = (json?.onResponseReceivedCommands || [])
|
|
.flatMap((c) => c?.appendContinuationItemsAction?.continuationItems || []);
|
|
if (!Array.isArray(items) || !items.length) return { cards: [], next: '' };
|
|
return { cards: cardsFromSections(items), next: tokenOf(items) };
|
|
}
|
|
|
|
export async function search(q, { fetchImpl = fetch, timeoutMs = 6000 } = {}) {
|
|
const res = await fetchImpl('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ context: { client: CLIENT }, query: q }),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!res.ok) throw new Error(`innertube: HTTP ${res.status}`);
|
|
return parseSearch(await res.json());
|
|
}
|
|
|
|
// Up to `limit` results (default 200) by following continuation tokens. The pages
|
|
// are a chain, so they are fetched one after another; every page is optional —
|
|
// a failure part-way returns what was gathered. `onPage(cards)` lets a caller
|
|
// save progress.
|
|
export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs = 6000, maxPages = 14, onPage } = {}) {
|
|
const post = async (body) => {
|
|
const res = await fetchImpl('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ context: { client: CLIENT }, ...body }), signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!res.ok) throw new Error(`innertube: HTTP ${res.status}`);
|
|
return res.json();
|
|
};
|
|
const first = parseSearchPage(await post({ query: q }));
|
|
const seen = new Set();
|
|
const all = [];
|
|
const add = (cards) => { for (const c of cards) if (!seen.has(c.id)) { seen.add(c.id); all.push(c); } };
|
|
add(first.cards);
|
|
if (onPage) onPage(all.slice());
|
|
let next = first.next;
|
|
for (let i = 0; i < maxPages && next && all.length < limit; i++) {
|
|
let page;
|
|
try { page = parseContinuation(await post({ continuation: next })); } catch { break; }
|
|
if (!page.cards.length) break;
|
|
add(page.cards);
|
|
if (onPage) onPage(all.slice());
|
|
next = page.next;
|
|
}
|
|
return all.slice(0, limit);
|
|
}
|