Initial commit: BiliDownloader Web 版:Python 零依赖后端 + shadcn/ui 前端
This commit is contained in:
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user