Add resumable admin deep search

This commit is contained in:
Jonathan Sykes
2026-10-14 01:47:35 +08:00
parent ca10fe6486
commit 58d0e6f140
12 changed files with 600 additions and 22 deletions

View File

@@ -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) {