Files
huangguo-video/huangguo_video_dl.user.js
T
toor 19a3828b18 v1.1.3: 网络层加固
- gfetch 失败自动重试 2 次(1s 间隔),应对代理/节点瞬断
- 重试耗尽降级原生 fetch(CDN 带 ACAO:* 可直连)
- 错误原因完整显示到面板,不再只报网络错误
- 背景: 17:25 mihomo 节点故障导致分片请求全部建连失败,
  Reqable 记录 remote.ip 为空 + mihomo 17:29 重启为证
2026-10-08 17:42:58 +08:00

377 lines
16 KiB
JavaScript

// ==UserScript==
// @name 黄果短剧全集下载
// @namespace huangguo-video
// @version 1.1.3
// @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', retries = 2) {
const fail = (why) => {
if (retries > 0) {
console.warn(`[全集下载] ${why},1s 后重试(剩 ${retries} 次):`, url);
return new Promise((r) => setTimeout(r, 1000))
.then(() => gfetch(url, type, retries - 1));
}
// 重试耗尽 → 降级原生 fetch(CDN 带 Access-Control-Allow-Origin: * 可直连)
console.warn(`[全集下载] GM 失败,尝试原生 fetch:`, url);
return fetch(url).then((resp) => {
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${url.slice(0, 100)}`);
return type === 'arraybuffer' ? resp.arrayBuffer() : resp.text();
}).catch((e) => {
throw new Error(`${why}(且 fetch 兜底失败:${e.message}): ${url.slice(0, 120)}`);
});
};
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: () => fail(`网络错误`).then(resolve, reject),
ontimeout: () => fail(`超时30s`).then(resolve, reject),
});
});
}
/* ---------- 详情页:剧名 + 集数列表 ---------- */
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('&amp;', '&'), 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 });
})();