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
156 lines
7.2 KiB
JavaScript
156 lines
7.2 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||
import { apiGet, resolveBvid, parsePageArg, selectVideoPart } from './utils.js';
|
||
|
||
function requireObject(value, label) {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
throw new CommandExecutionError(`${label} returned a malformed payload`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function unwrapBrowserResult(value) {
|
||
if (value && typeof value === 'object' && typeof value.session === 'string' && Object.hasOwn(value, 'data')) {
|
||
return value.data;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function readOptionalFlag(value, label) {
|
||
if (value == null) return false;
|
||
if (typeof value === 'boolean') return value;
|
||
if (typeof value === 'number') return value !== 0;
|
||
throw new CommandExecutionError(`${label} returned a malformed flag`);
|
||
}
|
||
|
||
function readOptionalString(value, label) {
|
||
if (value == null) return '';
|
||
if (typeof value === 'string') return value;
|
||
throw new CommandExecutionError(`${label} returned a malformed string`);
|
||
}
|
||
|
||
cli({
|
||
site: 'bilibili',
|
||
name: 'video',
|
||
access: 'read',
|
||
description: 'Get Bilibili video metadata (title, author, duration, stats, etc.)',
|
||
strategy: Strategy.COOKIE,
|
||
args: [
|
||
{ name: 'bvid', required: true, positional: true, help: 'BV ID, video URL, or b23.tv short link' },
|
||
{ name: 'page', required: false, help: '分P 选集序号(从 1 开始)。多 P 视频指定某一集,title/cid 返回该集;缺省取整集默认(P1)' },
|
||
],
|
||
columns: ['field', 'value'],
|
||
func: async (page, kwargs) => {
|
||
if (!page) {
|
||
throw new CommandExecutionError('Browser session required for bilibili video');
|
||
}
|
||
|
||
// 选集序号(--page):缺省 null = 不下钻分P,保持整集(P1)旧行为。
|
||
const selectedPage = parsePageArg(kwargs.page);
|
||
|
||
// Resolve BV ID from three advertised input forms:
|
||
// 1. Bare "BV..." id
|
||
// 2. Full bilibili.com/video/<BV>... URL (with or without query string / www / m.)
|
||
// 3. b23.tv short link (delegated to resolveBvid)
|
||
// resolveBvid() alone handles (1) and (3) but not (2), so we pre-extract
|
||
// from bilibili URLs before falling through.
|
||
const input = String(kwargs.bvid ?? '').trim();
|
||
const bilibiliUrlMatch = input.match(/bilibili\.com\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
|
||
const bvid = bilibiliUrlMatch ? bilibiliUrlMatch[1] : await resolveBvid(input);
|
||
|
||
// Navigate to video page first so subsequent api call shares a primed session.
|
||
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
|
||
|
||
const payload = unwrapBrowserResult(await apiGet(page, '/x/web-interface/view', {
|
||
params: { bvid },
|
||
}));
|
||
requireObject(payload, 'Bilibili view API');
|
||
if (payload.code !== 0) {
|
||
throw new CommandExecutionError(`Bilibili view API failed: ${payload.message} (${payload.code})`);
|
||
}
|
||
|
||
const d = requireObject(payload.data, 'Bilibili view API data');
|
||
const stat = d.stat || {};
|
||
const owner = d.owner || {};
|
||
|
||
// 付费/会员标记:view API 的 rights 位 + 充电专属字段本来就在响应里,
|
||
// 透出给下游在下载/截屏前判断"拿不到视频流"。
|
||
// rights.pay=1 → 付费 OGV(大会员专享/单点付费番剧、影视;实测会员番剧单集 pay=1)
|
||
// rights.ugc_pay=1 / arc_pay=1 → UGC 单点付费 / 付费合集
|
||
// is_upower_exclusive=true → 充电专属视频
|
||
// redirect_url 非空(指向 /bangumi/play/ep<id>)= OGV 内容,细分可再查 pgc season API。
|
||
const rights = requireObject(d.rights, 'Bilibili view API data.rights');
|
||
const rightsPay = readOptionalFlag(rights.pay, 'Bilibili rights.pay');
|
||
const rightsUgcPay = readOptionalFlag(rights.ugc_pay, 'Bilibili rights.ugc_pay');
|
||
const rightsArcPay = readOptionalFlag(rights.arc_pay, 'Bilibili rights.arc_pay');
|
||
const upowerExclusive = readOptionalFlag(d.is_upower_exclusive, 'Bilibili is_upower_exclusive');
|
||
const paymentType = rightsPay
|
||
? 'vip'
|
||
: (rightsUgcPay || rightsArcPay)
|
||
? 'ugc_pay'
|
||
: upowerExclusive
|
||
? 'upower'
|
||
: '';
|
||
const payPreview = readOptionalFlag(rights.ugc_pay_preview, 'Bilibili rights.ugc_pay_preview')
|
||
|| readOptionalFlag(d.is_upower_preview, 'Bilibili is_upower_preview');
|
||
const redirectUrl = readOptionalString(d.redirect_url, 'Bilibili redirect_url');
|
||
|
||
const pubDate = d.pubdate ? new Date(d.pubdate * 1000).toISOString().slice(0, 16).replace('T', ' ') : '';
|
||
|
||
// 选集下钻:--page 给定时从 data.pages 取该集,title 用分集标题(part),
|
||
// 越界由 selectVideoPart 抛结构化错。缺省保持整集 title = d.title(旧行为不变)。
|
||
let title = d.title ?? '';
|
||
let partCid = '';
|
||
let partDur = d.duration || 0;
|
||
if (selectedPage != null) {
|
||
const part = selectVideoPart(d, selectedPage);
|
||
partCid = String(part.cid ?? '');
|
||
const partTitle = typeof part.part === 'string' ? part.part.trim() : '';
|
||
title = partTitle || `${d.title ?? ''} P${selectedPage}`;
|
||
// 分集时长(pages[].duration)比整集 d.duration 更贴合该集;缺则回退整集。
|
||
if (Number.isFinite(Number(part.duration)) && Number(part.duration) > 0) {
|
||
partDur = Number(part.duration);
|
||
}
|
||
}
|
||
|
||
const dur = partDur || 0;
|
||
const mm = Math.floor(dur / 60);
|
||
const ss = dur % 60;
|
||
|
||
const rows = [
|
||
{ field: 'bvid', value: d.bvid ?? '' },
|
||
{ field: 'aid', value: String(d.aid ?? '') },
|
||
{ field: 'title', value: title },
|
||
{ field: 'author', value: owner.name ? `${owner.name} (mid: ${owner.mid})` : '' },
|
||
{ field: 'category', value: d.tname_v2 || d.tname || '' },
|
||
{ field: 'publish_time', value: pubDate },
|
||
{ field: 'duration', value: dur ? `${mm}m${ss}s (${dur}s)` : '' },
|
||
{ field: 'view', value: String(stat.view ?? '') },
|
||
{ field: 'danmaku', value: String(stat.danmaku ?? '') },
|
||
{ field: 'reply', value: String(stat.reply ?? '') },
|
||
{ field: 'like', value: String(stat.like ?? '') },
|
||
{ field: 'coin', value: String(stat.coin ?? '') },
|
||
{ field: 'favorite', value: String(stat.favorite ?? '') },
|
||
{ field: 'share', value: String(stat.share ?? '') },
|
||
{ field: 'parts', value: String(d.videos ?? 1) },
|
||
{ field: 'thumbnail', value: d.pic ?? '' },
|
||
{ field: 'description', value: d.desc ?? '' },
|
||
{ field: 'requires_payment', value: String(!!paymentType) },
|
||
{ field: 'payment_type', value: paymentType },
|
||
// 可试看(ugc_pay_preview / 充电预览):有预览流但拿不到完整正片
|
||
{ field: 'pay_preview', value: String(payPreview) },
|
||
{ field: 'redirect_url', value: redirectUrl },
|
||
];
|
||
|
||
// --page 时透出分集专属字段:page(选集序号)、cid(该集弹幕/字幕轴 id)、
|
||
// series_title(整集标题,给下游做"分集标题为空"兜底)。缺省不加,保持旧输出。
|
||
if (selectedPage != null) {
|
||
rows.push({ field: 'page', value: String(selectedPage) });
|
||
rows.push({ field: 'cid', value: partCid });
|
||
rows.push({ field: 'series_title', value: d.title ?? '' });
|
||
}
|
||
|
||
return rows;
|
||
},
|
||
});
|