diff --git a/manifest.json b/manifest.json index 834d433..271a4d9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "B站评论助手", - "version": "1.8.0", + "version": "1.8.1", "description": "B站网页版关键词评论助手:按搜索关键词找视频,默认模拟真人(打开视频页、滚到评论区、逐字输入、点发布),也可快速API直发;支持下载当前页视频;带防重复记录、可中断执行与页面实时进度浮层。", "permissions": ["storage", "tabs", "scripting", "sidePanel", "downloads", "declarativeNetRequest"], "host_permissions": [ diff --git a/sidepanel.js b/sidepanel.js index 0c6df43..f9a5f8a 100644 --- a/sidepanel.js +++ b/sidepanel.js @@ -373,7 +373,11 @@ function refreshCustomVisibility() { function fmtMb(n) { return (n / 1048576).toFixed(1) + " MB"; } // 面板页自己抓文件(扩展页 fetch 免 CORS,DNR 规则注入 Referer)→ Blob → 触发保存 - async function fetchToBlob(url, onProgress) { + // 取视频流并封装成 Blob。 + // 两个必须修的坑: + // 1) Blob 必须带 MIME —— 不带的话 Chrome 会去嗅探内容,HTML 错误页会被存成 .txt/.html + // 2) 必须校验内容魔数 —— CDN 有时回 200 + HTML 验证页,不校验就会把网页存成"视频" + async function fetchToBlob(url, onProgress, mime) { const resp = await fetch(url); if (!resp.ok) throw new Error("HTTP " + resp.status + "(CDN拒绝,可能需要重新打开视频页)"); const total = Number(resp.headers.get("content-length") || 0); @@ -387,8 +391,31 @@ function refreshCustomVisibility() { got += value.length; if (onProgress) onProgress(got, total); } - return new Blob(chunks); + if (!chunks.length) throw new Error("CDN 返回了空内容"); + // 魔数校验:MP4/FLV/M4S 都是二进制;返回 HTML/JSON 说明拿到的是错误页 + const head = chunks[0]; + const b0 = head[0], b1 = head[1], b2 = head[2], b3 = head[3]; + const isFtyp = head.length >= 12 && b4(head,0)===0x66 && b4(head,1)===0x74 && b4(head,2)===0x79 && b4(head,3)===0x70; // "ftyp" + const isFlv = head.length >= 3 && b0===0x46 && b1===0x4C && b2===0x56; // "FLV" + const isIso = head.length >= 8 && b0===0x00 && b1===0x00; // MP4 长度前缀 + const isHtml = (b0===0x3C) || (b0===0xEF && b1===0xBB && b2===0xBF) || // "<" 或 BOM+"<" + (b0===0x0A || b0===0x0D || b0===0x20) || // 空白开头 + (b0===0x7B); // "{" JSON + if (isHtml) { + throw new Error("CDN 返回的不是视频(是网页/错误页),请刷新视频页后重试"); + } + if (!isFtyp && !isFlv && !isIso) { + throw new Error("下载内容不是有效的视频格式(文件头异常),已放弃保存"); + } + // MIME:优先用响应头,再按魔数判定,兜底 mp4 —— 绝不给空字符串 + const ct = (resp.headers.get("content-type") || "").split(";")[0].trim(); + let type = ct; + if (!/^video\//i.test(type) && !/^audio\//i.test(type)) { + type = isFtyp || isIso ? "video/mp4" : (isFlv ? "video/x-flv" : "video/mp4"); + } + return new Blob(chunks, { type: type }); } + function b4(u8, i) { return u8.length > i ? u8[i] : -1; } async function downloadCurrent() { const btn = $("btnDownload"), msg = $("dlMsg"); @@ -424,13 +451,14 @@ function refreshCustomVisibility() { name += "." + (f.ext || "mp4"); try { let lastShow = 0; + const MIME_BY_EXT = { mp4: "video/mp4", flv: "video/x-flv", m4s: "video/mp4", m4a: "audio/mp4", aac: "audio/aac" }; const blob = await fetchToBlob(f.url, (got, total) => { const now = Date.now(); if (now - lastShow > 500) { lastShow = now; setText("下载中 " + fmtMb(got) + (total ? " / " + fmtMb(total) : "") + "(" + name.slice(0, 18) + "…)"); } - }); + }, MIME_BY_EXT[(f.ext || "mp4").toLowerCase()] || "video/mp4"); const objUrl = URL.createObjectURL(blob); await chrome.downloads.download({ url: objUrl, filename: "B站视频/" + name, conflictAction: "uniquify", saveAs: false