// ==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 = `