Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

153 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* YouTube video metadata — fetch watch HTML and parse bootstrap data without opening the watch UI.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { extractJsonAssignmentFromHtml, parseVideoId, prepareYoutubeApiPage } from './utils.js';
import { CommandExecutionError } from '@jackwener/opencli/errors';
function unwrapBrowserResult(value) {
if (value && typeof value === 'object' && 'session' in value && 'data' in value) {
return value.data;
}
return value;
}
function requireVideoPayload(value) {
const payload = unwrapBrowserResult(value);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new CommandExecutionError('Failed to extract video metadata from page');
}
if (payload.error) {
throw new CommandExecutionError(String(payload.error));
}
if (typeof payload.playabilityStatus !== 'string') {
throw new CommandExecutionError('YouTube video metadata is missing playabilityStatus');
}
if (typeof payload.playabilityReason !== 'string') {
throw new CommandExecutionError('YouTube video metadata is missing playabilityReason');
}
if (typeof payload.membersOnly !== 'boolean') {
throw new CommandExecutionError('YouTube video metadata is missing membersOnly');
}
return payload;
}
cli({
site: 'youtube',
name: 'video',
access: 'read',
description: 'Get YouTube video metadata (title, views, description, etc.)',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'url', required: true, positional: true, help: 'YouTube video URL or video ID' },
],
columns: ['field', 'value'],
func: async (page, kwargs) => {
const videoId = parseVideoId(kwargs.url);
await prepareYoutubeApiPage(page);
const data = await page.evaluate(`
(async () => {
const extractJsonAssignmentFromHtml = ${extractJsonAssignmentFromHtml.toString()};
const watchResp = await fetch('/watch?v=' + encodeURIComponent(${JSON.stringify(videoId)}), {
credentials: 'include',
});
if (!watchResp.ok) return { error: 'Watch HTML returned HTTP ' + watchResp.status };
const html = await watchResp.text();
const player = extractJsonAssignmentFromHtml(html, 'ytInitialPlayerResponse');
const yt = extractJsonAssignmentFromHtml(html, 'ytInitialData');
if (!player) return { error: 'ytInitialPlayerResponse not found in watch HTML' };
const details = player.videoDetails || {};
const microformat = player.microformat?.playerMicroformatRenderer || {};
const contents = yt?.contents?.twoColumnWatchNextResults?.results?.results?.contents || [];
// Try to get full description from watch bootstrap data
let fullDescription = details.shortDescription || '';
try {
if (contents) {
for (const c of contents) {
const desc = c.videoSecondaryInfoRenderer?.attributedDescription?.content;
if (desc) { fullDescription = desc; break; }
}
}
} catch {}
// Get like count if available
let likes = '';
try {
if (contents) {
for (const c of contents) {
const buttons = c.videoPrimaryInfoRenderer?.videoActions
?.menuRenderer?.topLevelButtons;
if (buttons) {
for (const b of buttons) {
const toggle = b.segmentedLikeDislikeButtonViewModel
?.likeButtonViewModel?.likeButtonViewModel?.toggleButtonViewModel
?.toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel;
if (toggle?.title) { likes = toggle.title; break; }
}
}
}
}
} catch {}
// Get publish date
const publishDate = microformat.publishDate
|| microformat.uploadDate
|| details.publishDate || '';
// Get category
const category = microformat.category || '';
// Get channel subscriber count if available
let subscribers = '';
try {
if (contents) {
for (const c of contents) {
const owner = c.videoSecondaryInfoRenderer?.owner
?.videoOwnerRenderer?.subscriberCountText?.simpleText;
if (owner) { subscribers = owner; break; }
}
}
} catch {}
// 播放门禁信号:会员专享(channel membership/ 付费点播等视频 metadata 照常
// 可见,但视频流拿不到——playabilityStatus.status != 'OK'。reason 文本是本地化
// 的(中文 cookie 下是中文),所以 membersOnly 用 watch HTML 里 locale 无关的
// BADGE_STYLE_TYPE_MEMBERS_ONLY 徽标枚举判定,下游不要去 parse reason。
const ps = player.playabilityStatus || {};
const membersOnly = html.indexOf('BADGE_STYLE_TYPE_MEMBERS_ONLY') !== -1;
return {
title: details.title || '',
channel: details.author || '',
channelId: details.channelId || '',
videoId: details.videoId || '',
views: details.viewCount || '',
likes,
subscribers,
duration: details.lengthSeconds ? details.lengthSeconds + 's' : '',
publishDate,
category,
description: fullDescription,
keywords: (details.keywords || []).join(', '),
isLive: details.isLiveContent || false,
thumbnail: details.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
playabilityStatus: ps.status || '',
playabilityReason: ps.reason || '',
membersOnly,
};
})()
`);
const payload = requireVideoPayload(data);
// Return as field/value pairs for table display
return Object.entries(payload).map(([field, value]) => ({
field,
value: String(value),
}));
},
});