- hls_dl.py: 通用 HLS 下载器(m3u8 解析/AES-128 解密/ffmpeg 合并) - reqable_mcp.py: Reqable MCP 客户端(stdio, --port 9002) - huangguo_video_dl.user.js: 油猴全集下载脚本 v1.1.2 * 详情页一键下载全部集数,解密转封装后打包 ZIP 存 ~/Downloads * 手写 STORE ZIP(逐集 Blob 卸堆外),修复 JSZip 大文件卡死 * @connect 覆盖 rlkfus.cn / cloudfront.net CDN - README.md: 用法与踩坑记录
362 lines
15 KiB
JavaScript
362 lines
15 KiB
JavaScript
// ==UserScript==
|
|
// @name 黄果短剧全集下载
|
|
// @namespace huangguo-video
|
|
// @version 1.1.2
|
|
// @description 在黄果短剧详情页添加"全集下载"按钮:下载全部集数 → 解密 → 转 MP4 → 打包 ZIP 存到 ~/Downloads,文件名为 剧名_集数
|
|
// @author CETACEA
|
|
// @match https://huangguo.me/detail/*
|
|
// @connect huangguo.me
|
|
// @connect *.rlkfus.cn
|
|
// @connect *.cloudfront.net
|
|
// @connect *
|
|
// @require https://cdn.jsdelivr.net/npm/mux.js@6/dist/mux.min.js
|
|
// @grant GM_xmlhttpRequest
|
|
// @run-at document-idle
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
const SITE = 'https://huangguo.me';
|
|
const M3U8_BASE = SITE + '/h5/m3u8/'; // data-url 是相对路径,拼这个前缀(拼错 404)
|
|
const CONCURRENCY = 4; // 分片并发下载数
|
|
|
|
/* ---------- 网络:GM_xmlhttpRequest promise 化(绕 CORS) ---------- */
|
|
function gfetch(url, type = 'text') {
|
|
return new Promise((resolve, reject) => {
|
|
GM_xmlhttpRequest({
|
|
method: 'GET',
|
|
url,
|
|
responseType: type === 'arraybuffer' ? 'arraybuffer' : 'text',
|
|
timeout: 30000,
|
|
onload: (r) => (r.status >= 200 && r.status < 300
|
|
? resolve(type === 'arraybuffer' ? r.response : r.responseText)
|
|
: reject(new Error(`HTTP ${r.status}: ${url.slice(0, 100)}`))),
|
|
onerror: () => reject(new Error(`网络错误: ${url.slice(0, 100)}`)),
|
|
ontimeout: () => reject(new Error(`超时: ${url.slice(0, 100)}`)),
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------- 详情页:剧名 + 集数列表 ---------- */
|
|
function collectInfo() {
|
|
const ctx = document.querySelector('[data-drama-context]') || document.body;
|
|
const title = (ctx.dataset.dramaTitle
|
|
|| (document.querySelector('h1') || {}).textContent
|
|
|| document.title.split(' - ')[0]).trim();
|
|
const total = parseInt(ctx.dataset.totalEpisode || '0', 10);
|
|
|
|
// 收集所有集;若 DOM 只渲染了当前 range,点 range 按钮补齐
|
|
const harvest = () => [...document.querySelectorAll('a.detail-episode[data-ep]')].map((a) => ({
|
|
ep: parseInt(a.dataset.ep, 10),
|
|
href: a.getAttribute('href'),
|
|
locked: a.hasAttribute('data-coin-dialog-open') || (a.getAttribute('href') || '').startsWith('javascript'),
|
|
chapterId: a.dataset.chapterId || null,
|
|
}));
|
|
let eps = harvest();
|
|
if (total && eps.length < total) {
|
|
for (const btn of document.querySelectorAll('[data-range-start]')) {
|
|
if (btn.classList.contains('detail-episode-range--active')) continue;
|
|
btn.click();
|
|
eps = harvest(); // 简化:以最后一次为准;长剧分页场景以实际 DOM 为准
|
|
}
|
|
}
|
|
// 去重 + 排序
|
|
const seen = new Map();
|
|
for (const e of eps) seen.set(e.ep, e);
|
|
return { title, total, episodes: [...seen.values()].sort((a, b) => a.ep - b.ep) };
|
|
}
|
|
|
|
/* ---------- 单集:play 页 → m3u8 → 解密 → 转 MP4 ---------- */
|
|
async function downloadEpisode(info, onStage) {
|
|
// 1. play 页拿 m3u8 相对地址
|
|
onStage('获取播放页');
|
|
const playUrl = info.href.startsWith('http')
|
|
? info.href
|
|
: new URL(info.href, SITE).href;
|
|
const page = await gfetch(playUrl);
|
|
const m = page.match(/data-url="([^"]*m3u8[^"]*)"/);
|
|
if (!m) throw new Error('播放页无 m3u8(可能未解锁)');
|
|
const m3u8url = new URL(m[1].replaceAll('&', '&'), M3U8_BASE).href;
|
|
|
|
// 2. 解析播放列表
|
|
onStage('解析列表');
|
|
let pl = await gfetch(m3u8url);
|
|
if (pl.includes('#EXT-X-STREAM-INF')) { // master → 取第一个变体
|
|
const varPath = pl.split('\n').find((l) => l && !l.startsWith('#'));
|
|
pl = await gfetch(new URL(varPath, m3u8url).href);
|
|
}
|
|
const segs = pl.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('http') || (l && !l.startsWith('#')));
|
|
const segUrls = segs.map((s) => new URL(s, m3u8url).href);
|
|
if (!segUrls.length) throw new Error('播放列表为空');
|
|
const keyMatch = pl.match(/#EXT-X-KEY:METHOD=AES-128,URI="([^"]+)"/);
|
|
const ivMatch = pl.match(/#EXT-X-KEY:[^,\n]*IV=(0x[0-9a-fA-F]+)/);
|
|
const mediaSeq = parseInt((pl.match(/#EXT-X-MEDIA-SEQUENCE:(\d+)/) || [0, 0])[1], 10);
|
|
|
|
// 3. 解密 key(AES-128,16 字节)
|
|
let cryptoKey = null;
|
|
if (keyMatch) {
|
|
onStage('获取解密 key');
|
|
const raw = await gfetch(new URL(keyMatch[1], m3u8url).href, 'arraybuffer');
|
|
cryptoKey = await crypto.subtle.importKey('raw', raw, 'AES-CBC', false, ['decrypt']);
|
|
}
|
|
|
|
// 4. 并发下载 + 解密
|
|
const plain = new Array(segUrls.length);
|
|
let done = 0;
|
|
const fetchOne = async (i) => {
|
|
const ct = await gfetch(segUrls[i], 'arraybuffer');
|
|
if (cryptoKey) {
|
|
const iv = new Uint8Array(16);
|
|
if (ivMatch) { // IV 显式声明
|
|
const hex = ivMatch[1].slice(2).padStart(32, '0');
|
|
for (let k = 0; k < 16; k++) iv[k] = parseInt(hex.slice(k * 2, k * 2 + 2), 16);
|
|
} else { // 无 IV:media sequence 大端填充
|
|
new DataView(iv.buffer).setUint32(12, mediaSeq + i, false);
|
|
}
|
|
try {
|
|
plain[i] = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ct));
|
|
} catch (e) {
|
|
throw new Error(`第 ${i + 1} 段解密失败(IV/key 不匹配): ${e.message}`);
|
|
}
|
|
} else {
|
|
plain[i] = new Uint8Array(ct);
|
|
}
|
|
done++;
|
|
onStage(`下载分片 ${done}/${segUrls.length}`);
|
|
};
|
|
for (let i = 0; i < segUrls.length; i += CONCURRENCY) {
|
|
await Promise.all([...Array(CONCURRENCY).keys()]
|
|
.map((k) => i + k < segUrls.length ? fetchOne(i + k) : null)
|
|
.filter(Boolean));
|
|
}
|
|
|
|
// 5. 拼 TS → mux.js 转 MP4(faststart 无关,fMP4 通用可播)
|
|
onStage('转封装 MP4');
|
|
const totalLen = plain.reduce((s, p) => s + p.length, 0);
|
|
const ts = new Uint8Array(totalLen);
|
|
let off = 0;
|
|
for (const p of plain) { ts.set(p, off); off += p.length; }
|
|
|
|
// mux.js v6 浏览器 UMD 全局名是 muxjs(非 mux),两者都兼容
|
|
const MuxNS = window.muxjs || window.mux;
|
|
if (!MuxNS || !MuxNS.mp4 || !MuxNS.mp4.Transmuxer)
|
|
throw new Error('mux.js 未加载(@require CDN 可能被墙),无法转封装');
|
|
const tm = new MuxNS.mp4.Transmuxer({ outputContainer: 'mp4' });
|
|
const chunks = [];
|
|
let initSeg = null;
|
|
tm.on('data', (seg) => {
|
|
if (seg.initSegment && !initSeg) initSeg = seg.initSegment;
|
|
if (seg.data && seg.data.length) chunks.push(seg.data);
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
tm.on('error', reject);
|
|
tm.push(ts);
|
|
tm.flush();
|
|
resolve();
|
|
});
|
|
if (!initSeg || !chunks.length) throw new Error('转封装无输出');
|
|
const mp4 = new Uint8Array(initSeg.length + chunks.reduce((s, c) => s + c.length, 0));
|
|
let o = 0;
|
|
for (const c of [initSeg, ...chunks]) { mp4.set(c, o); o += c.length; }
|
|
return mp4;
|
|
}
|
|
|
|
/* ---------- 手写 ZIP(STORE 不压缩;Blob 拼接不吃 JS 堆) ---------- */
|
|
const CRC_TABLE = (() => {
|
|
const t = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
t[n] = c;
|
|
}
|
|
return t;
|
|
})();
|
|
|
|
function crc32(u8) {
|
|
let c = 0xffffffff;
|
|
for (let i = 0; i < u8.length; i++) c = CRC_TABLE[(c ^ u8[i]) & 0xff] ^ (c >>> 8);
|
|
return (c ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
async function buildZip(entries, onStep) {
|
|
const enc = new TextEncoder();
|
|
const now = new Date();
|
|
const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff;
|
|
const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff;
|
|
const parts = [], central = [];
|
|
let offset = 0, cdSize = 0;
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const e = entries[i];
|
|
const name = enc.encode(e.name);
|
|
// 本地文件头
|
|
const lh = new DataView(new ArrayBuffer(30));
|
|
lh.setUint32(0, 0x04034b50, true);
|
|
lh.setUint16(4, 20, true);
|
|
lh.setUint16(6, 0x0800, true); // bit11:文件名 UTF-8
|
|
lh.setUint16(8, 0, true); // STORE 不压缩
|
|
lh.setUint16(10, dosTime, true);
|
|
lh.setUint16(12, dosDate, true);
|
|
lh.setUint32(14, e.crc, true);
|
|
lh.setUint32(18, e.size, true);
|
|
lh.setUint32(22, e.size, true);
|
|
lh.setUint16(26, name.length, true);
|
|
parts.push(lh.buffer, name, e.blob);
|
|
// 中央目录项
|
|
const ch = new DataView(new ArrayBuffer(46));
|
|
ch.setUint32(0, 0x02014b50, true);
|
|
ch.setUint16(4, 20, true);
|
|
ch.setUint16(6, 20, true);
|
|
ch.setUint16(8, 0x0800, true);
|
|
ch.setUint16(10, 0, true);
|
|
ch.setUint16(12, dosTime, true);
|
|
ch.setUint16(14, dosDate, true);
|
|
ch.setUint32(16, e.crc, true);
|
|
ch.setUint32(20, e.size, true);
|
|
ch.setUint32(24, e.size, true);
|
|
ch.setUint16(28, name.length, true);
|
|
ch.setUint32(42, offset, true);
|
|
central.push(ch.buffer, name);
|
|
offset += 30 + name.length + e.size;
|
|
cdSize += 46 + name.length;
|
|
if (onStep) onStep(i + 1, entries.length);
|
|
if ((i & 3) === 3) await new Promise((r) => setTimeout(r, 0)); // 让 UI 有机会刷新
|
|
}
|
|
const eocd = new DataView(new ArrayBuffer(22));
|
|
eocd.setUint32(0, 0x06054b50, true);
|
|
eocd.setUint16(8, entries.length, true);
|
|
eocd.setUint16(10, entries.length, true);
|
|
eocd.setUint32(12, cdSize, true);
|
|
eocd.setUint32(16, offset, true);
|
|
return new Blob([...parts, ...central, eocd.buffer], { type: 'application/zip' });
|
|
}
|
|
|
|
/* ---------- UI:右下角浮动面板 ---------- */
|
|
const ui = (() => {
|
|
const box = document.createElement('div');
|
|
box.style.cssText = `position:fixed;right:16px;bottom:16px;z-index:99999;width:300px;
|
|
background:#141824;color:#e8ecf5;border:1px solid #2c3550;border-radius:12px;
|
|
font:13px/1.6 -apple-system,sans-serif;padding:14px 16px;box-shadow:0 8px 32px rgba(0,0,0,.5);display:none`;
|
|
box.innerHTML = `
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
|
<b id="whale-title">全集下载</b>
|
|
<span id="whale-close" style="cursor:pointer;opacity:.6">✕</span>
|
|
</div>
|
|
<div id="whale-stage" style="color:#8fa3c8;min-height:20px"></div>
|
|
<div style="height:6px;background:#232b40;border-radius:3px;margin:8px 0;overflow:hidden">
|
|
<div id="whale-bar" style="height:100%;width:0;background:linear-gradient(90deg,#3b82f6,#22d3ee);transition:width .3s"></div>
|
|
</div>
|
|
<div id="whale-pct" style="color:#5d6b8c;font-size:12px">0%</div>`;
|
|
document.body.appendChild(box);
|
|
box.querySelector('#whale-close').onclick = () => (box.style.display = 'none');
|
|
const $ = (id) => box.querySelector('#whale-' + id);
|
|
return {
|
|
show(t) { $('title').textContent = t; box.style.display = 'block'; },
|
|
stage(s) { $('stage').textContent = s; },
|
|
prog(p) { $('bar').style.width = p + '%'; $('pct').textContent = Math.round(p) + '%'; },
|
|
hide() { box.style.display = 'none'; },
|
|
};
|
|
})();
|
|
|
|
/* ---------- 主流程 ---------- */
|
|
async function run() {
|
|
const { title, total, episodes } = collectInfo();
|
|
const free = episodes.filter((e) => !e.locked);
|
|
const locked = episodes.filter((e) => e.locked);
|
|
if (!free.length) return alert('没有可下载的免费集数');
|
|
|
|
const safe = title.replace(/[\\/:*?"<>|]/g, '_');
|
|
ui.show(`《${title}》全集下载`);
|
|
const entries = []; // {name, size, crc, blob} —— 逐集转 Blob 卸到堆外,绕开 JSZip 大文件卡死
|
|
const failList = [];
|
|
let firstErr = '';
|
|
|
|
for (let n = 0; n < free.length; n++) {
|
|
const e = free[n];
|
|
const base = ((n) / free.length) * 100;
|
|
try {
|
|
const mp4 = await downloadEpisode(e, (s) => ui.stage(`[${n + 1}/${free.length}] ${s}`));
|
|
entries.push({ name: `${safe}_${e.ep}.mp4`, size: mp4.length, crc: crc32(mp4), blob: new Blob([mp4], { type: 'video/mp4' }) });
|
|
ui.stage(`✅ 第 ${e.ep} 集完成 (${(mp4.length / 1048576).toFixed(1)} MB)`);
|
|
} catch (err) {
|
|
const emsg = (err && err.message) ? err.message : String(err);
|
|
console.error(`[全集下载] 第${e.ep}集失败:`, err);
|
|
failList.push(e.ep);
|
|
if (!firstErr) firstErr = `第${e.ep}集: ${emsg}`;
|
|
ui.stage(`❌ 第 ${e.ep} 集失败: ${emsg}`);
|
|
}
|
|
ui.prog(((n + 1) / free.length) * 85); // 打包占最后 15%
|
|
}
|
|
|
|
// 手写 STORE 打包 + 触发下载(浏览器默认下载目录 = ~/Downloads)
|
|
// JSZip generateAsync 处理多集大文件会永久卡在 90%,已弃用
|
|
if (!entries.length) {
|
|
ui.stage(`💥 全部失败!${firstErr || '未知原因'}(F12 控制台有完整错误)`);
|
|
console.error('[全集下载] 全部集数失败,首个错误:', firstErr);
|
|
return;
|
|
}
|
|
let savedAsZip = true;
|
|
let blob = null;
|
|
try {
|
|
blob = await buildZip(entries, (i, tot) => {
|
|
ui.stage(`打包 ZIP 中… ${i}/${tot}`);
|
|
ui.prog(85 + (i / tot) * 14);
|
|
});
|
|
} catch (zipErr) {
|
|
// 兜底:内存不足等导致 ZIP 组装失败 → 逐集保存
|
|
console.error('[全集下载] ZIP 组装失败,改为逐集保存:', zipErr);
|
|
savedAsZip = false;
|
|
for (const ent of entries) {
|
|
ui.stage(`逐集保存 ${ent.name} …`);
|
|
const a2 = document.createElement('a');
|
|
a2.href = URL.createObjectURL(ent.blob);
|
|
a2.download = ent.name;
|
|
document.body.appendChild(a2);
|
|
a2.click();
|
|
a2.remove();
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
}
|
|
}
|
|
if (savedAsZip) {
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `${safe}.zip`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 120000);
|
|
}
|
|
|
|
let msg = savedAsZip ? `完成!${safe}.zip → ~/Downloads` : `完成!已逐集保存 ${entries.length} 个 MP4 → ~/Downloads`;
|
|
if (locked.length) msg += ` | 跳过付费集:${locked.map((e) => '第' + e.ep + '集').join('、')}`;
|
|
if (failList.length) msg += ` | 失败:${failList.join('、')}(${firstErr})`;
|
|
ui.stage(msg);
|
|
ui.prog(100);
|
|
console.log(`[全集下载] ${msg}`);
|
|
}
|
|
|
|
/* ---------- 注入按钮(详情页剧集区) ---------- */
|
|
function inject() {
|
|
if (document.getElementById('whale-dl-btn')) return;
|
|
const anchor = document.querySelector('.detail-episodes-status')
|
|
|| document.querySelector('.detail-section-label')
|
|
|| document.querySelector('h1');
|
|
if (!anchor) return;
|
|
const btn = document.createElement('button');
|
|
btn.id = 'whale-dl-btn';
|
|
btn.textContent = '⬇ 全集下载';
|
|
btn.style.cssText = `margin-left:12px;padding:6px 14px;border:0;border-radius:8px;cursor:pointer;
|
|
background:linear-gradient(135deg,#3b82f6,#22d3ee);color:#fff;font:600 13px -apple-system,sans-serif;
|
|
vertical-align:middle`;
|
|
btn.onclick = () => run().catch((e) => {
|
|
console.error('[全集下载]', e);
|
|
ui.stage('💥 出错:' + e.message);
|
|
});
|
|
anchor.insertAdjacentElement('afterend', btn);
|
|
}
|
|
|
|
inject();
|
|
// SPA/异步渲染兜底
|
|
new MutationObserver(inject).observe(document.body, { childList: true, subtree: true });
|
|
})();
|