// ==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 = '📦 青空下载器▾';
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
};
})();