Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3375,6 +3375,13 @@
"default": "best",
"required": false,
"help": "Video quality (best, 1080p, 720p, 480p)"
},
{
"name": "force",
"type": "boolean",
"default": false,
"required": false,
"help": "跳过付费内容预检直接下载(已购买/已充电/已开通会员时用)"
}
],
"columns": [
Expand Down
74 changes: 73 additions & 1 deletion clis/bilibili/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,76 @@
* - yt-dlp must be installed: pip install yt-dlp
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
import { checkYtdlp, sanitizeFilename } from '@jackwener/opencli/download';
import { downloadMedia } from '@jackwener/opencli/download/media-download';
import { resolveBvid } from './utils.js';
import { apiGet, resolveBvid } from './utils.js';

const PAYMENT_LABELS = {
vip: '大会员专享/付费 OGV',
ugc_pay: 'UGC 单点付费',
upower: '充电专属',
};

function isObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}

/**
* 下载前付费预检:付费/会员视频 yt-dlp 只能拿到试看流或直接失败,
* 与其跑一半吐一坨 yt-dlp stderr,不如提前抛结构化 PAID_CONTENT(exit 77)。
*
* 大会员专享(vip)会再查一次 nav API:当前账号大会员有效就放行(cookie 喂给
* yt-dlp 能下完整流)。ugc_pay / upower 的购买/充电状态没有廉价查询端点,保守
* 拦截,已购用户用 --force 跳过。预检自身的 API 失败不阻塞下载(保持旧行为)。
*/
async function assertNotPaidContent(page, bvid) {
let d;
try {
const payload = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
if (!isObject(payload) || !Object.hasOwn(payload, 'code')) {
throw new CommandExecutionError('Bilibili view API returned a malformed payload during paid-content pre-check');
}
if (payload.code !== 0)
return;
if (!isObject(payload.data) || !isObject(payload.data.rights)) {
throw new CommandExecutionError('Bilibili view API returned malformed paid-content metadata');
}
d = payload.data;
}
catch (error) {
if (error instanceof CommandExecutionError) {
throw error;
}
return;
}
const rights = d.rights;
const paymentType = rights.pay
? 'vip'
: (rights.ugc_pay || rights.arc_pay)
? 'ugc_pay'
: d.is_upower_exclusive
? 'upower'
: '';
if (!paymentType)
return;
if (paymentType === 'vip') {
try {
const nav = await apiGet(page, '/x/web-interface/nav');
if (nav.code === 0 && Number(nav.data?.vipStatus) === 1)
return;
}
catch {
// nav 查询失败按"无会员"保守处理,走下面的拦截
}
}
throw new CliError(
'PAID_CONTENT',
`该视频为付费内容(${PAYMENT_LABELS[paymentType]}),当前账号无观看权益,无法获取完整视频流`,
'若已购买/已充电/已开通会员,加 --force 跳过本检查直接下载',
EXIT_CODES.NOPERM,
);
}
cli({
site: 'bilibili',
name: 'download',
Expand All @@ -22,6 +89,7 @@ cli({
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g., BV1xxx)' },
{ name: 'output', default: './bilibili-downloads', help: 'Output directory' },
{ name: 'quality', default: 'best', help: 'Video quality (best, 1080p, 720p, 480p)' },
{ name: 'force', type: 'boolean', default: false, help: '跳过付费内容预检直接下载(已购买/已充电/已开通会员时用)' },
],
columns: ['bvid', 'title', 'status', 'size'],
func: async (page, kwargs) => {
Expand All @@ -40,6 +108,10 @@ cli({
// Navigate to video page to get title and cookies
await page.goto(`https://www.bilibili.com/video/${bvid}`);
await page.wait(3);
// 付费内容预检(--force 跳过)
if (!kwargs.force) {
await assertNotPaidContent(page, bvid);
}
// Extract video info
const data = await page.evaluate(`
(() => {
Expand Down
120 changes: 120 additions & 0 deletions clis/bilibili/download.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';

const { mockApiGet, mockDownloadMedia, mockCheckYtdlp } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
mockDownloadMedia: vi.fn(),
mockCheckYtdlp: vi.fn(),
}));

vi.mock('./utils.js', async (importOriginal) => ({
...(await importOriginal()),
apiGet: mockApiGet,
}));

vi.mock('@jackwener/opencli/download', () => ({
checkYtdlp: mockCheckYtdlp,
sanitizeFilename: (s) => s,
}));

vi.mock('@jackwener/opencli/download/media-download', () => ({
downloadMedia: mockDownloadMedia,
}));

import { getRegistry } from '@jackwener/opencli/registry';
import './download.js';

/** view API 成功响应的最小骨架 */
function viewPayload(extra = {}) {
return { code: 0, data: { bvid: 'BV1xx411c7mD', rights: {}, ...extra } };
}

describe('bilibili download paid-content pre-check', () => {
const command = getRegistry().get('bilibili/download');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({ title: '标题', author: 'UP主' }),
getCookies: vi.fn().mockResolvedValue([]),
};

beforeEach(() => {
mockApiGet.mockReset();
mockDownloadMedia.mockReset();
mockCheckYtdlp.mockReset();
mockCheckYtdlp.mockReturnValue(true);
mockDownloadMedia.mockResolvedValue([{ status: 'success', size: '10MB' }]);
page.goto.mockClear();
page.evaluate.mockClear();
});

it('downloads normal (free) video without interference', async () => {
mockApiGet.mockResolvedValueOnce(viewPayload());

const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });

expect(rows[0].status).toBe('success');
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
});

it('throws PAID_CONTENT for member-only bangumi when account has no vip', async () => {
mockApiGet
.mockResolvedValueOnce(viewPayload({ rights: { pay: 1 } })) // view
.mockResolvedValueOnce({ code: 0, data: { vipStatus: 0 } }); // nav

await expect(
command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
).rejects.toSatisfy((err) => err instanceof CliError && err.code === 'PAID_CONTENT');
expect(mockDownloadMedia).not.toHaveBeenCalled();
});

it('allows member-only content when account has active vip', async () => {
mockApiGet
.mockResolvedValueOnce(viewPayload({ rights: { pay: 1 } }))
.mockResolvedValueOnce({ code: 0, data: { vipStatus: 1 } });

const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });

expect(rows[0].status).toBe('success');
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
});

it('throws PAID_CONTENT for upower-exclusive video (no entitlement endpoint, conservative block)', async () => {
mockApiGet.mockResolvedValueOnce(viewPayload({ is_upower_exclusive: true }));

await expect(
command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
).rejects.toSatisfy((err) => err instanceof CliError && err.code === 'PAID_CONTENT');
// upower 没有权益查询端点,不应再打 nav API
expect(mockApiGet).toHaveBeenCalledTimes(1);
expect(mockDownloadMedia).not.toHaveBeenCalled();
});

it('fails closed when successful view payload lacks paid-content metadata', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1xx411c7mD' } });

await expect(
command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false }),
).rejects.toSatisfy(
(err) => err instanceof CommandExecutionError && /paid-content metadata/.test(err.message),
);
expect(mockDownloadMedia).not.toHaveBeenCalled();
});

it('skips pre-check entirely with --force', async () => {
const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: true });

expect(rows[0].status).toBe('success');
expect(mockApiGet).not.toHaveBeenCalled();
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
});

it('does not block download when the pre-check API itself fails', async () => {
mockApiGet.mockRejectedValueOnce(new Error('network down'));

const rows = await command.func(page, { bvid: 'BV1xx411c7mD', output: './o', quality: 'best', force: false });

expect(rows[0].status).toBe('success');
expect(mockDownloadMedia).toHaveBeenCalledTimes(1);
});
});