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
+37
View File
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""诊断:nav 为何报未登录 + dash.video 实际内容。"""
import json
import sys
import urllib.request
sys.path.insert(0, r"E:\deepseek\BiliDownloaderWeb")
import engine
cfg = json.load(open(r"E:\deepseek\BiliDownloaderWeb\config.json", encoding="utf-8"))
sess = cfg["sessdata"]
print("config 里 sessdata 长度 =", len(sess))
print("前 24 字符 =", sess[:24])
print("后 24 字符 =", sess[-24:])
print("\n--- 直接请求 nav,看原始返回 ---")
req = urllib.request.Request(
engine.API_NAV,
headers={"User-Agent": engine.UA, "Referer": "https://www.bilibili.com",
"Cookie": "SESSDATA=" + sess})
with urllib.request.urlopen(req, timeout=30) as r:
raw = json.loads(r.read().decode("utf-8"))
print("code =", raw.get("code"), " message =", raw.get("message"))
d = raw.get("data") or {}
print("isLogin =", d.get("isLogin"), " uname =", d.get("uname"))
print("有 wbi_img =", bool(d.get("wbi_img")))
print("\n--- dash.video 实际内容(qn=127)---")
vid = engine.video_detail("BV1HVBAYLEAo", "SESSDATA=" + sess)
cid = vid["pages"][0]["cid"]
p = engine.play_url("BV1HVBAYLEAo", cid, "SESSDATA=" + sess)
print("maxQuality =", p["maxQuality"])
for s in p["video"]:
print(f" 视频流 qn={s['quality']:>4} {s['width']}x{s['height']} codec={s['codecs']} {s['bandwidth']//1000}kbps")
for s in p["audio"]:
print(f" 音频流 {s['codecs']} {s['bandwidth']//1000}kbps")
print("qualities 的 width/height 字段示例:", p["qualities"][0])
+165
View File
@@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
"""
e2e_download.py —— 端到端验收:通过服务的 HTTP 接口真的下一次,并校验产物。
流程:playurl -> POST /api/download -> 订阅 SSE 收进度 -> 等完成 -> ffprobe 校验
"""
import json
import os
import subprocess
import sys
import threading
import time
import urllib.request
BASE = "http://127.0.0.1:8799"
BVID = sys.argv[1] if len(sys.argv) > 1 else "BV1HVBAYLEAo"
ok_count = 0
fail_count = 0
def check(name, ok, detail=""):
global ok_count, fail_count
if ok:
ok_count += 1
print(f" [OK] {name}" + (f" — {detail}" if detail else ""))
else:
fail_count += 1
print(f" [XX] {name}" + (f" — {detail}" if detail else ""))
def get(path):
with urllib.request.urlopen(BASE + path, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
def post(path, payload):
req = urllib.request.Request(
BASE + path, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
print("=" * 62)
print(f"端到端下载验收: {BVID}")
print("=" * 62)
video = get(f"/api/video?bvid={BVID}")
cid = video["pages"][0]["cid"]
print(f"\n视频: {video['title']}")
print(f"UP主: {video['owner']} 时长: {video['duration']}s")
play = get(f"/api/playurl?bvid={BVID}&cid={cid}")
check("拿到清晰度列表", len(play["qualities"]) > 0,
" / ".join(q["label"] for q in play["qualities"]))
quality = play["qualities"][0]["quality"]
print(f"选择清晰度: {quality} ({play['qualities'][0]['label']})")
# 先确认没有正在跑的任务
job0 = get("/api/job")
check("初始无进行中的任务", job0.get("state") != "running", str(job0.get("state")))
# 订阅 SSE
events = []
prog_samples = []
stop = threading.Event()
def listen():
try:
with urllib.request.urlopen(BASE + "/api/events", timeout=600) as r:
event = None
while not stop.is_set():
line = r.readline().decode("utf-8", "replace").strip()
if line.startswith("event:"):
event = line.split(":", 1)[1].strip()
elif line.startswith("data:"):
data = json.loads(line.split(":", 1)[1].strip())
events.append((event, data))
if event == "progress":
prog_samples.append(data)
except Exception as exc:
if not stop.is_set():
print(" SSE 读取结束:", exc)
t = threading.Thread(target=listen, daemon=True)
t.start()
time.sleep(0.8)
print("\n--- 开始下载 ---")
t0 = time.time()
job = post("/api/download", {"bvid": BVID, "cid": cid, "quality": quality,
"title": video["title"]})
check("已受理下载任务", job.get("state") == "running", str(job.get("state")))
deadline = time.time() + 300
last_state = None
while time.time() < deadline:
time.sleep(1)
cur = get("/api/job")
if cur.get("state") != last_state:
last_state = cur.get("state")
print(f" [{time.time() - t0:5.1f}s] state={last_state} stage={cur.get('stage')}")
if cur.get("state") in ("done", "error", "cancelled"):
break
elapsed = time.time() - t0
stop.set()
final = get("/api/job")
check("任务正常结束", final.get("state") == "done", str(final.get("state") or final.get("error")))
check("收到了进度事件", len(prog_samples) > 0, f"{len(prog_samples)} 条")
check("同时收到 job/stage 事件", len(events) > 0, f"{len(events)} 条")
out = final.get("outputPath") or ""
check("产物文件存在", bool(out) and os.path.exists(out), out)
if out and os.path.exists(out):
size = os.path.getsize(out)
check("产物大小合理", size > 100_000, f"{size / 1024 / 1024:.1f} MB")
ffprobe = None
import shutil
ffprobe = shutil.which("ffprobe") or r"C:\Users\Administrator\miniconda3\Library\bin\ffprobe.exe"
cp = subprocess.run(
[ffprobe, "-v", "error", "-show_entries",
"format=duration,size:stream=codec_type,codec_name,width,height",
"-of", "json", out],
capture_output=True, text=True)
if cp.returncode == 0:
info = json.loads(cp.stdout)
streams = info.get("streams", [])
kinds = {s["codec_type"]: s for s in streams}
check("产物含视频流", "video" in kinds,
f"{kinds.get('video', {}).get('codec_name')} "
f"{kinds.get('video', {}).get('width')}x{kinds.get('video', {}).get('height')}")
check("产物含音频流", "audio" in kinds, str(kinds.get("audio", {}).get("codec_name")))
dur = float(info["format"]["duration"])
check("时长与源基本一致", abs(dur - video["duration"]) < 5, f"{dur:.1f}s vs {video['duration']}s")
else:
check("ffprobe 可解析产物", False, cp.stderr.strip()[:120])
# 全片解码校验
ffmpeg = shutil.which("ffmpeg") or r"C:\Users\Administrator\miniconda3\Library\bin\ffmpeg.exe"
cp2 = subprocess.run([ffmpeg, "-v", "error", "-i", out, "-f", "null", "-"],
capture_output=True, text=True)
check("全片解码无错误", cp2.returncode == 0 and not cp2.stderr.strip(),
cp2.stderr.strip()[:160] or "clean")
# 临时文件应已清理
tmp_left = [f for f in os.listdir(os.path.dirname(out)) if f.endswith(".m4s")]
check("中间文件已清理", len(tmp_left) == 0, f"残留 {len(tmp_left)} 个" if tmp_left else "无残留")
# 并发保护:任务已结束,再来一次应能受理(说明状态机复位)
print(f"\n耗时 {elapsed:.1f}s")
print("\n进度采样(每 10 条取 1):")
for s in prog_samples[::max(1, len(prog_samples) // 10)][:10]:
print(f" {s['label']} {s['percent']:5.1f}% {s['downloaded']/1024/1024:6.1f}MB"
f"/{s['total']/1024/1024:.1f}MB {s['speed']/1024/1024:.2f}MB/s {s['threads']}线程")
print("\n" + "=" * 62)
print(f"结果: {ok_count} 通过 / {fail_count} 失败")
print("=" * 62)
sys.exit(1 if fail_count else 0)
+44
View File
@@ -0,0 +1,44 @@
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class WinEnum {
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lParam);
[DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
}
"@
$found = New-Object System.Collections.ArrayList
$cb = [WinEnum+EnumProc]{
param($h, $l)
$len = [WinEnum]::GetWindowTextLength($h)
if ($len -gt 0 -and [WinEnum]::IsWindowVisible($h)) {
$sb = New-Object System.Text.StringBuilder ($len + 2)
[void][WinEnum]::GetWindowText($h, $sb, $sb.Capacity)
$t = $sb.ToString()
$r = New-Object WinEnum+RECT
[void][WinEnum]::GetWindowRect($h, [ref]$r)
[void]$found.Add([pscustomobject]@{
Title = $t
W = $r.R - $r.L
H = $r.B - $r.T
X = $r.L
Y = $r.T
})
}
return $true
}
[void][WinEnum]::EnumWindows($cb, [IntPtr]::Zero)
Write-Output "=== 可见顶层窗口中标题含 BiliDownloader 的 ==="
$found | Where-Object { $_.Title -like "*BiliDownloader*" } |
Select-Object Title, W, H, X, Y | Format-Table -AutoSize | Out-String
Write-Output "=== 前 12 个可见窗口(对照)==="
$found | Sort-Object -Property W -Descending | Select-Object -First 12 Title, W, H |
Format-Table -AutoSize | Out-String
+52
View File
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""后端 API 冒烟测试。"""
import json
import urllib.parse
import urllib.request
BASE = "http://127.0.0.1:8799"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
def post(path, payload):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(BASE + path, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
print("--- /api/config ---")
cfg = get("/api/config")
print(f"threads={cfg['threads']} outputDir={cfg['outputDir']}")
print(f"ffmpeg={cfg['ffmpeg']}")
print(f"configPath={cfg['configPath']}")
print("\n--- /api/login ---")
print(get("/api/login"))
print("\n--- /api/search?q=航拍中国 ---")
s = get("/api/search?q=" + urllib.parse.quote("航拍中国"))
print(f"numResults={s['numResults']} hasMore={s['hasMore']} 条数={len(s['items'])}")
for it in s["items"][:5]:
print(f" {it['bvid']} {it['title'][:34]} up={it['author']} dur={it['duration']} play={it['play']}")
print(f" pic = {s['items'][0]['pic']}")
print("\n--- /api/video?bvid=BV1HVBAYLEAo ---")
v = get("/api/video?bvid=BV1HVBAYLEAo")
print(f"title={v['title']}")
print(f"owner={v['owner']} duration={v['duration']}s pages={len(v['pages'])}")
cid = v["pages"][0]["cid"]
print(f"cid={cid}")
print("\n--- /api/playurl ---")
p = get(f"/api/playurl?bvid=BV1HVBAYLEAo&cid={cid}")
print(f"maxQuality={p['maxQuality']} video流={len(p['video'])} audio流={len(p['audio'])}")
for q in p["qualities"]:
print(f" qn={q['quality']:>4} {q['label']} {q.get('width')}x{q.get('height')}")
print(f" 4K 候选: " + ", ".join(
f"{s['quality']}/{s['codecs']}/{s['bandwidth']//1000}kbps" for s in p["video"] if s["quality"] == 120))
+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);
});