Initial commit: BiliDownloader Web 版:Python 零依赖后端 + shadcn/ui 前端

This commit is contained in:
WpyQwq
2026-09-19 11:49:36 +08:00
commit f42cf11831
41 changed files with 7576 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
/**
* ui-check.mjs —— 浏览器端验收(Edge 无头 + CDP)
*
* 不只是"页面能打开":真的驱动界面做一次搜索、点选结果、等待清晰度解析,
* 并读取渲染后的 DOM 断言每一处关键内容。
*
* 用法: node tools/ui-check.mjs [baseUrl]
*/
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import WebSocket from '../ui/node_modules/ws/index.js';
const BASE = process.argv[2] || 'http://127.0.0.1:8799/';
const PORT = 9333;
const EDGE_CANDIDATES = [
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
function check(name, ok, detail = '') {
results.push({ name, ok, detail });
console.log(`${ok ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
async function main() {
const fs = await import('node:fs');
const edge = EDGE_CANDIDATES.find((p) => fs.existsSync(p));
if (!edge) throw new Error('找不到 Edge');
const profile = mkdtempSync(join(tmpdir(), 'bd-ui-'));
const child = spawn(
edge,
[
'--headless=new',
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
`--remote-debugging-port=${PORT}`,
`--user-data-dir=${profile}`,
'--window-size=1280,860',
BASE,
],
{ stdio: 'ignore' },
);
let target = null;
for (let i = 0; i < 40 && !target; i++) {
await sleep(500);
try {
const res = await fetch(`http://127.0.0.1:${PORT}/json/list`);
const list = await res.json();
target = list.find((t) => t.type === 'page' && t.url.startsWith('http'));
} catch {
/* 还没起来 */
}
}
if (!target) throw new Error('CDP 目标未就绪');
const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
await new Promise((res, rej) => {
ws.once('open', res);
ws.once('error', rej);
});
let id = 0;
const pending = new Map();
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id && pending.has(msg.id)) {
pending.get(msg.id)(msg);
pending.delete(msg.id);
}
});
const send = (method, params = {}) =>
new Promise((res) => {
const myId = ++id;
pending.set(myId, res);
ws.send(JSON.stringify({ id: myId, method, params }));
});
const evaluate = async (expression) => {
const r = await send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
});
if (r.result?.exceptionDetails) {
throw new Error(r.result.exceptionDetails.exception?.description || 'JS 异常');
}
return r.result?.result?.value;
};
await send('Runtime.enable');
await sleep(3500); // 等 React 挂载 + 首屏取配置/登录状态
console.log(`\n目标: ${BASE}\n`);
// ---------- 1. 首屏渲染 ----------
const text = await evaluate('document.body.innerText');
check('React 已挂载并渲染', !!text && text.length > 60, `${text?.length ?? 0} 字符`);
for (const key of ['BiliDownloader', '搜索站内视频', '未登录', '还没有选择视频', '清晰度', '开始下载', '日志']) {
check(`首屏含「${key}」`, text.includes(key));
}
// ---------- 2. 暗色主题 ----------
const isDark = await evaluate("document.documentElement.classList.contains('dark')");
check('默认深色主题生效', isDark === true);
const bg = await evaluate(
"getComputedStyle(document.body).backgroundColor",
);
check('背景取自 shadcn token(非默认白)', bg !== 'rgb(255, 255, 255)' && bg !== 'rgba(0, 0, 0, 0)', bg);
// ---------- 3. 无横向溢出 ----------
const overflow = await evaluate(
'document.documentElement.scrollWidth - document.documentElement.clientWidth',
);
check('无横向溢出', overflow <= 0, `diff=${overflow}`);
// ---------- 4. 真的搜一次 ----------
console.log('\n —— 驱动一次真实搜索 ——');
await evaluate(`(() => {
const input = document.querySelector('input');
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, '航拍中国');
input.dispatchEvent(new Event('input', { bubbles: true }));
const form = input.closest('form');
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
return true;
})()`);
await sleep(6000);
const afterSearch = await evaluate('document.body.innerText');
const cardCount = await evaluate(
"document.querySelectorAll('button[type=button]').length",
);
check('搜索结果已渲染出卡片', cardCount >= 10, `${cardCount} 个可点卡片`);
// ---------- 5. 点选一个结果 → 清晰度解析 ----------
console.log('\n —— 点选第一个结果,等待清晰度解析 ——');
await evaluate(`(() => {
const btn = document.querySelector('button[type=button]');
btn.click();
return true;
})()`);
await sleep(9000);
const afterPick = await evaluate('document.body.innerText');
check('已进入待下载状态(出现「保存到」)', afterPick.includes('保存到'));
check('详情面板显示 BV 号', /BV[0-9A-Za-z]{10}/.test(afterPick));
check('清晰度已解析(出现「可用清晰度」日志或档位)', /可用清晰度|480P|360P|1080P|4K/.test(afterPick));
const hasStartEnabled = await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => x.textContent.includes('开始下载'));
return b ? !b.disabled : null;
})()`);
check('「开始下载」按钮可点', hasStartEnabled === true);
// ---------- 6. 控制台报错 ----------
const errs = await evaluate('window.__bdErrors ? window.__bdErrors.length : 0');
check('页面无致命脚本错误', errs === 0, `${errs} 个`);
console.log('\n —— 渲染文本采样 ——');
console.log(
afterPick
.split('\n')
.filter((l) => l.trim())
.slice(0, 26)
.map((l) => ' ' + l.slice(0, 96))
.join('\n'),
);
ws.close();
child.kill();
try {
rmSync(profile, { recursive: true, force: true });
} catch {}
const failed = results.filter((r) => !r.ok);
console.log(`\n结果: ${results.length - failed.length}/${results.length} 通过`);
if (failed.length) {
console.log('失败项:');
for (const f of failed) console.log(` - ${f.name} ${f.detail}`);
process.exit(1);
}
}
main().catch((e) => {
console.error('验收脚本异常:', e.message);
process.exit(1);
});