// ==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 = `
全集下载 ✕
0%
`; 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 }); })();