// ==UserScript== // @name 青空次元视频下载器 (sorani.net) // @namespace sorani-dl // @version 1.0.1 // @description 在青空次元番剧页一键缓存视频到本地:当前集/整季批量、多线路选择、实时进度。输出 TS 文件(VLC/PotPlayer 可直接播放,可用 ffmpeg 无损转 MP4) // @match https://www.sorani.net/* // @match https://sorani.net/* // @run-at document-idle // @grant none // @license Apache-2.0 // ==/UserScript== (function () { 'use strict'; var CONCURRENCY = 8; // 分段并发下载数 var SAVE_TIP_KEY = 'soraniDlTipShown'; var state = { running: false, abort: false, line: null }; var currentInfo = null; var panel = null, progEl = null, logEl = null, bodyEl = null; var btnEp = null, btnAll = null, btnCancel = null; function apiPlay(eid, line) { return 'https://api.sorani.cc/sorani-cms/api/video/episode/' + eid + '/play?lineCode=' + encodeURIComponent(line); } /* ---------------- 工具 ---------------- */ function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } function sanitize(name) { return name.replace(/[\\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim(); } function fmtSize(n) { var u = ['B', 'KB', 'MB', 'GB'], i = 0; while (n >= 1024 && i < 3) { n /= 1024; i++; } return n.toFixed(1) + u[i]; } // 速度格式化: 传入已下载字节数和起始时间戳, 自适应单位 function fmtSpeed(bytes, t0) { var sec = Math.max((Date.now() - t0) / 1000, 0.1); var bps = (bytes || 0) / sec; // B/s return bps >= 1048576 ? (bps / 1048576).toFixed(1) + 'MB/s' : (bps / 1024).toFixed(0) + 'KB/s'; } function epLabel(ep) { return ep.title || ('第' + String(ep.order).padStart(2, '0') + '集'); } async function fetchRetry(url, tries) { tries = tries || 3; var lastErr = null; for (var i = 0; i < tries; i++) { try { var r = await fetch(url, { credentials: 'omit' }); if (!r.ok) throw new Error('HTTP ' + r.status); return r; } catch (e) { lastErr = e; if (i < tries - 1) await sleep(700 * (i + 1)); } } throw lastErr; } /* ---------------- 页面解析 ---------------- */ function parseFromHtml(html, pageUrl) { var eps = [], seen = {}; var re = /episodeId:(\d+),videoId:\d+,videoTitle:null,title:"([^"]*)",playUrl:null,duration:null,viewCount:\d+,episodeLabel:"[^"]*",episodeOrder:(\d+)/g, m; while ((m = re.exec(html))) { if (!seen[m[1]]) { seen[m[1]] = 1; eps.push({ id: m[1], title: m[2], order: +m[3] }); } } eps.sort(function (a, b) { return a.order - b.order; }); var tm = html.match(/og:title" content="([^"]+?)(?:\s+全集|")/) || html.match(/([^<]+?)(?:\s+全集|<\/title>)/); var title = tm ? tm[1].trim() : 'sorani视频'; var cur = null; var cm = pageUrl.match(/\/episode\/(\d+)/); if (cm) cur = +cm[1]; var lines = []; var lm = html.match(/playLines:\[(.*?)\],playLinesForbidden/); if (lm) { var orr = /\{[^{}]+\}/g, om; while ((om = orr.exec(lm[1]))) { var o = om[0]; var code = (o.match(/code:"([^"]+)"/) || [])[1]; var name = (o.match(/name:"([^"]+)"/) || [])[1]; var en = (o.match(/enable:(true|false)/) || [])[1]; var pe = (o.match(/parseEnabled:(true|false)/) || [])[1]; if (code && en === 'true' && pe !== 'true') { lines.push({ code: code, name: name || code }); } } } var sel = (html.match(/selectedLineCode:"([^"]+)"/) || [])[1]; return { title: title, eps: eps, cur: cur, lines: lines, defLine: sel || (lines[0] && lines[0].code) || 'anime_jp_m3u8' }; } async function parsePage() { var info = parseFromHtml(document.documentElement.outerHTML, location.href); if (!info.eps.length) { try { var html = await (await fetch(location.href, { credentials: 'omit' })).text(); info = parseFromHtml(html, location.href); } catch (e) { /* ignore */ } } return info; } /* ---------------- 下载一集 ---------------- */ // 获取播放清单: API现取新链接 -> m3u8 -> 密钥 (链接有时效, 失败时可重新获取) async function getManifest(ep, lineCode, segLimit) { var res = await fetchRetry(apiPlay(ep.id, lineCode)); var info = await res.json(); var d = info && info.data; if (!info.success || !d || !d.canPlay) { throw new Error((d && d.message) || '该集暂时无法播放 (可能需要登录或VIP)'); } var playUrl = d.playUrl; var base = playUrl.split('?')[0]; var m3u8 = await (await fetchRetry(playUrl)).text(); var segs = [], sm; var segRe = /#EXTINF:[\d.]+,\s*\n([^\s]+)/g; while ((sm = segRe.exec(m3u8))) segs.push(sm[1]); if (!segs.length) throw new Error('m3u8 中未找到分段'); if (segLimit) segs = segs.slice(0, segLimit); // AES-128 密钥 (HLS 标准 PKCS7 填充, Web Crypto 解密时自动去除) var key = null, iv = new Uint8Array(16); var km = m3u8.match(/#EXT-X-KEY:METHOD=AES-128,URI="([^"]+)"(?:,IV=0x([0-9a-fA-F]+))?/); if (km) { var keyBuf = await (await fetchRetry(new URL(km[1], base).href)).arrayBuffer(); if (keyBuf.byteLength !== 16 && keyBuf.byteLength !== 24 && keyBuf.byteLength !== 32) { throw new Error('密钥获取失败 (长度' + keyBuf.byteLength + ')'); } key = await crypto.subtle.importKey('raw', keyBuf, { name: 'AES-CBC' }, false, ['decrypt']); if (km[2]) { var hex = km[2].padStart(32, '0'); for (var i = 0; i < 16; i++) iv[i] = parseInt(hex.substr(i * 2, 2), 16); } } return { base: base, segs: segs, key: key, iv: iv }; } // 返回 {name, bytes, segs}; segLimit 仅测试用 async function downloadEpisode(ep, seriesTitle, lineCode, onProg, segLimit) { var M = await getManifest(ep, lineCode, segLimit); var parts = new Array(M.segs.length); var done = 0, next = 0, bytesDone = 0, t0 = Date.now(); var lastRefresh = 0; // 分段拉取失败(链接过期/网络抖动)时刷新清单, 所有 worker 共享新地址 async function refreshManifest() { if (Date.now() - lastRefresh < 3000) return; // 3秒冷却, 防止worker同时刷 lastRefresh = Date.now(); try { var nm = await getManifest(ep, lineCode, segLimit); if (nm.segs.length === M.segs.length) { M.base = nm.base; M.segs = nm.segs; M.key = nm.key; M.iv = nm.iv; } } catch (e) { /* 刷新本身失败则下次再试 */ } } async function one(j) { if (state.abort) throw new Error('已取消'); var buf; try { buf = await (await fetch(new URL(M.segs[j], M.base).href)).arrayBuffer(); } catch (e) { await refreshManifest(); buf = await (await fetchRetry(new URL(M.segs[j], M.base).href)).arrayBuffer(); } parts[j] = M.key ? await crypto.subtle.decrypt({ name: 'AES-CBC', iv: M.iv }, M.key, buf) : buf; done++; bytesDone += buf.byteLength; if (onProg) onProg(done, M.segs.length, t0, bytesDone); } var pool = []; for (var w = 0; w < Math.min(CONCURRENCY, M.segs.length); w++) { pool.push((async function () { while (next < M.segs.length && !state.abort) { var j = next++; await one(j); } })()); } await Promise.all(pool); var bytes = 0; for (var k = 0; k < parts.length; k++) bytes += parts[k].byteLength; var blob = new Blob(parts, { type: 'video/mp2t' }); var name = sanitize(seriesTitle + '_' + epLabel(ep)) + '.ts'; var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(url); }, 60000); return { name: name, bytes: bytes, segs: parts.length }; } /* ---------------- UI ---------------- */ function h(tag, css, text) { var el = document.createElement(tag); if (css) el.style.cssText = css; if (text !== undefined) el.textContent = text; return el; } function log(msg) { if (!logEl) return; var line = h('div', 'padding:1px 0;word-break:break-all;', msg); logEl.appendChild(line); logEl.scrollTop = logEl.scrollHeight; } function prog(msg) { if (progEl) progEl.textContent = msg; } function setButtons() { if (btnEp) btnEp.disabled = state.running; if (btnAll) btnAll.disabled = state.running; if (btnCancel) btnCancel.style.display = state.running ? 'inline-block' : 'none'; } function buildUI(info) { if (panel) panel.remove(); if (!info.eps.length) { panel = null; return; } var DARK = 'position:fixed;right:16px;bottom:16px;z-index:2147483647;width:308px;' + 'background:rgba(17,19,28,.96);color:#e8eaf0;border:1px solid #3a3f52;' + 'border-radius:12px;padding:12px 14px;font:12px/1.65 system-ui,-apple-system,"Microsoft YaHei",sans-serif;' + 'box-shadow:0 6px 24px rgba(0,0,0,.45);'; panel = h('div', DARK); panel.id = 'sorani-dl-panel'; // 标题栏(可折叠) var head = h('div', 'cursor:pointer;font-weight:600;font-size:13px;display:flex;justify-content:space-between;'); head.innerHTML = '<span>📦 青空下载器</span><span style="color:#8b93a7" id="sorani-dl-fold">▾</span>'; bodyEl = h('div'); panel.appendChild(head); panel.appendChild(bodyEl); head.addEventListener('click', function () { var fold = bodyEl.style.display === 'none'; bodyEl.style.display = fold ? 'block' : 'none'; head.querySelector('#sorani-dl-fold').textContent = fold ? '▾' : '▸'; }); var infoLine = h('div', 'color:#aeb6c8;margin:2px 0 8px;', '《' + info.title + '》 共 ' + info.eps.length + ' 集'); bodyEl.appendChild(infoLine); // 线路选择 if (info.lines.length > 1) { var lineRow = h('div', 'margin:0 0 8px;'); lineRow.appendChild(h('span', '', '线路: ')); var sel = document.createElement('select'); sel.style.cssText = 'background:#232735;color:#e8eaf0;border:1px solid #3a3f52;border-radius:6px;padding:2px 4px;max-width:180px;'; info.lines.forEach(function (ln) { var o = document.createElement('option'); o.value = ln.code; o.textContent = ln.name; if (ln.code === info.defLine) o.selected = true; sel.appendChild(o); }); sel.addEventListener('change', function () { state.line = sel.value; }); lineRow.appendChild(sel); bodyEl.appendChild(lineRow); } state.line = info.defLine; // 按钮 var btnCss = 'flex:1;border:0;border-radius:8px;padding:6px 8px;cursor:pointer;' + 'font-size:12px;font-weight:600;color:#fff;'; var row = h('div', 'display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap;'); var curEp = null; if (info.cur) { curEp = info.eps.find(function (e) { return e.order === info.cur; }) || info.eps[0]; } else { curEp = info.eps[0]; } btnEp = document.createElement('button'); btnEp.style.cssText = btnCss + 'background:#4f6ef7;'; btnEp.textContent = '⬇ 下载本集'; btnEp.addEventListener('click', function () { runList([curEp], '本集'); }); btnAll = document.createElement('button'); btnAll.style.cssText = btnCss + 'background:#2ea36a;'; btnAll.textContent = '⬇ 下载整季(' + info.eps.length + ')'; btnAll.addEventListener('click', function () { runList(info.eps.slice(), '整季'); }); btnCancel = document.createElement('button'); btnCancel.style.cssText = btnCss + 'background:#c04040;display:none;flex:0 0 100%;'; btnCancel.textContent = '⛔ 取消'; btnCancel.addEventListener('click', function () { state.abort = true; log('正在取消...'); }); row.appendChild(btnEp); row.appendChild(btnAll); row.appendChild(btnCancel); bodyEl.appendChild(row); // 进度 / 日志 progEl = h('div', 'color:#7fb0ff;min-height:16px;'); logEl = h('div', 'max-height:150px;overflow-y:auto;color:#9aa3b8;border-top:1px dashed #333a4d;padding-top:4px;margin-top:2px;'); bodyEl.appendChild(progEl); bodyEl.appendChild(logEl); // 首次提示 var tipShown = false; try { tipShown = !!localStorage.getItem(SAVE_TIP_KEY); } catch (e) {} if (!tipShown) { var tip = h('div', 'background:#232735;border-radius:8px;padding:8px;margin-top:8px;color:#c3cad9;', 'ℹ️ 保存位置说明: 浏览器安全限制, 脚本无法自选文件夹, 文件会存到浏览器"默认下载目录"。' + '想每次选路径: 浏览器设置 → 下载 → 勾选"每次下载前询问保存位置"。' + '整季批量时浏览器若询问"允许下载多个文件", 请选允许。'); var okBtn = h('button', 'margin-top:6px;border:0;border-radius:6px;background:#4f6ef7;color:#fff;padding:4px 12px;cursor:pointer;', '我知道了'); okBtn.addEventListener('click', function () { tip.remove(); try { localStorage.setItem(SAVE_TIP_KEY, '1'); } catch (e) {} }); tip.appendChild(okBtn); bodyEl.appendChild(tip); } var fmtTip = h('div', 'color:#6b7387;font-size:11px;margin-top:6px;', '输出 .ts 文件, VLC/PotPlayer 可直接播放; 无损转MP4: ffmpeg -i 输入.ts -c copy 输出.mp4'); bodyEl.appendChild(fmtTip); document.body.appendChild(panel); setButtons(); } /* ---------------- 批量执行 ---------------- */ async function runList(list, label) { if (state.running || !currentInfo) return; state.running = true; state.abort = false; setButtons(); log('▶ 开始下载' + label + ' (' + list.length + '个文件)'); var ok = 0, fail = 0; for (var i = 0; i < list.length; i++) { if (state.abort) { log('⛔ 已取消 (完成 ' + ok + ')'); break; } var ep = list[i]; var lab = epLabel(ep); var lineCode = state.line || currentInfo.defLine; prog('[' + (i + 1) + '/' + list.length + '] ' + lab + ' 准备中...'); try { var st = await downloadEpisode(ep, currentInfo.title, lineCode, function (d, t, t0, bytes) { var pct = Math.round(d / t * 100); prog('[' + (i + 1) + '/' + list.length + '] ' + lab + ' ' + d + '/' + t + ' 段 (' + pct + '%, ' + fmtSpeed(bytes, t0) + ')'); }); log('✅ ' + st.name + ' ' + fmtSize(st.bytes)); ok++; } catch (e) { if (String(e.message || e).indexOf('取消') >= 0) { log('⛔ ' + lab + ' 已取消'); break; } log('❌ ' + lab + ': ' + (e.message || e)); fail++; } if (i < list.length - 1) await sleep(1200); } log('—— 结束: 成功 ' + ok + (fail ? ', 失败 ' + fail : '') + ' ——'); prog(''); state.running = false; state.abort = false; setButtons(); } /* ---------------- SPA 路由监听 ---------------- */ var lastPath = null; setInterval(function () { if (location.pathname !== lastPath) { lastPath = location.pathname; if (/^\/anime\//.test(location.pathname) && !state.running) { refresh(); } } }, 1000); async function refresh() { var info = await parsePage(); if (!info.eps.length) return; currentInfo = info; buildUI(info); } refresh(); // 调试钩子 (日常使用无影响) window.__SORANI_DL__ = { downloadEpisode: downloadEpisode, parsePage: parsePage, fmtSpeed: fmtSpeed, state: state }; })();