Add resumable admin deep search
This commit is contained in:
@@ -79,7 +79,7 @@ export async function search(q, { fetchImpl = fetch, timeoutMs = 6000 } = {}) {
|
||||
// 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 } = {}) {
|
||||
export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs = 6000, maxPages = 14, onPage, pageDelayMs = 0, jitterMs = 0 } = {}) {
|
||||
const post = async (body) => {
|
||||
const res = await fetchImpl('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -93,19 +93,92 @@ export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs
|
||||
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());
|
||||
if (onPage) await onPage(all.slice());
|
||||
let next = first.next;
|
||||
for (let i = 0; i < maxPages && next && all.length < limit; i++) {
|
||||
if (pageDelayMs > 0 || jitterMs > 0) await new Promise(resolve => setTimeout(resolve, Math.max(0, pageDelayMs + Math.random() * jitterMs)));
|
||||
let page;
|
||||
try { page = parseContinuation(await post({ continuation: next })); } catch { break; }
|
||||
if (!page.cards.length) break;
|
||||
add(page.cards);
|
||||
if (onPage) onPage(all.slice());
|
||||
if (onPage) await onPage(all.slice());
|
||||
next = page.next;
|
||||
}
|
||||
return all.slice(0, limit);
|
||||
}
|
||||
|
||||
function browseToken(value) {
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
const token = value.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
|
||||
if (token) return token;
|
||||
for (const child of Object.values(value)) {
|
||||
if (Array.isArray(child)) { for (const item of child) { const found = browseToken(item); if (found) return found; } }
|
||||
else { const found = browseToken(child); if (found) return found; }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function parseBrowseVideos(json) {
|
||||
const cards = new Map();
|
||||
function visit(value) {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
const video = value.videoRenderer || value.gridVideoRenderer || value.richItemRenderer?.content?.videoRenderer;
|
||||
const id = video?.videoId;
|
||||
const title = video?.title?.runs?.map(run => run.text).join('') || video?.title?.simpleText || '';
|
||||
if (id && /^[\w-]{11}$/.test(id) && title) {
|
||||
const owner = video.ownerText?.runs?.[0] || video.shortBylineText?.runs?.[0] || {};
|
||||
const browse = owner.navigationEndpoint?.browseEndpoint || {};
|
||||
cards.set(id, { id, url: `https://www.youtube.com/watch?v=${id}`, title, channel: owner.text || '',
|
||||
channelId: browse.browseId || '', channelUrl: browse.canonicalBaseUrl ? `https://www.youtube.com${browse.canonicalBaseUrl}` : '',
|
||||
duration: lengthToSeconds(video.lengthText?.simpleText), thumbnail: `https://i.ytimg.com/vi/${id}/mqdefault.jpg` });
|
||||
return;
|
||||
}
|
||||
for (const child of Object.values(value)) if (Array.isArray(child)) child.forEach(visit); else visit(child);
|
||||
}
|
||||
visit(json);
|
||||
return [...cards.values()];
|
||||
}
|
||||
|
||||
// YouTube browse continuation for channel uploads and playlists. Channel IDs
|
||||
// are translated to their uploads playlist (UU…), while playlist URLs use VL.
|
||||
export async function browseUploads(source, { limit = 200, fetchImpl = fetch, timeoutMs = 8000, maxPages = 12, pageDelayMs = 0, jitterMs = 0 } = {}) {
|
||||
let id = String(source || '').trim();
|
||||
let handle = '';
|
||||
try {
|
||||
const url = new URL(id);
|
||||
id = url.searchParams.get('list') || url.pathname.match(/\/channel\/([^/]+)/)?.[1] || id;
|
||||
handle = url.pathname.match(/\/@([^/]+)/)?.[1] || '';
|
||||
} catch { /* raw channel or playlist id */ }
|
||||
if (handle) {
|
||||
const response = await fetchImpl(`https://www.youtube.com/@${encodeURIComponent(handle)}`, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!response.ok) throw new Error(`channel lookup: HTTP ${response.status}`);
|
||||
const html = await response.text();
|
||||
id = html.match(/"browseId"\s*:\s*"(UC[\w-]{22})"/)?.[1] || '';
|
||||
}
|
||||
let browseId;
|
||||
if (/^UC[\w-]{22}$/.test(id)) browseId = `UU${id.slice(2)}`;
|
||||
else if (/^(?:PL|UU|LL|FL|OL)[\w-]{10,}$/.test(id)) browseId = id.startsWith('UU') || id.startsWith('LL') || id.startsWith('FL') || id.startsWith('OL') ? id : `VL${id}`;
|
||||
else throw new Error('Expected a YouTube channel ID or playlist ID/URL.');
|
||||
const post = async body => {
|
||||
const response = await fetchImpl('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ context: { client: CLIENT }, ...body }), signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) throw new Error(`innertube browse: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
};
|
||||
let page = await post({ browseId });
|
||||
const seen = new Set(), cards = [];
|
||||
for (let i = 0; i < maxPages && cards.length < limit; i++) {
|
||||
for (const card of parseBrowseVideos(page)) if (!seen.has(card.id)) { seen.add(card.id); cards.push(card); }
|
||||
const token = browseToken(page);
|
||||
if (!token || cards.length >= limit) break;
|
||||
if (pageDelayMs > 0 || jitterMs > 0) await new Promise(resolve => setTimeout(resolve, Math.max(0, pageDelayMs + Math.random() * jitterMs)));
|
||||
page = await post({ continuation: token });
|
||||
}
|
||||
return cards.slice(0, limit);
|
||||
}
|
||||
|
||||
// Watch-next recommendations use compact renderers or the newer lockup model.
|
||||
// Traverse only the recommendation contents, never player configuration/ads.
|
||||
export function parseRelated(json) {
|
||||
|
||||
Reference in New Issue
Block a user