// ==UserScript== // @name Acfun-hls // @name:zh-CN Acfun视频下载器 // @namespace https://greasyfork.org/zh-CN/users/135090 // @version 3.1.2 // @description Acfun parse hls url,streams download mpegts file. // @description:zh-CN Acfun解析各清晰度M3U8地址,可直接下载,也可复制链接给其他工具 // @author zwb83925462 // @match https://www.acfun.cn/v/ac* // @match https://www.acfun.cn/player/* // @grant unsafeWindow // @run-at document-end // @license CC0 // @noframes // ==/UserScript== (() => { const W = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; const $ = (t, s) => Object.assign(document.createElement(t), { style: s }); // 解析缓存:引用没变就不重复 parse let lastRaw = null, cachedReps = null; function getReps(force) { const raw = W.videoInfo?.currentVideoInfo?.ksPlayJson; if (!raw) return null; if (!force && raw === lastRaw) return cachedReps; try { const obj = (typeof raw === 'string') ? JSON.parse(raw) : raw; cachedReps = obj?.adaptationSet?.[0]?.representation || null; } catch { cachedReps = null; } lastRaw = raw; return cachedReps; } // 流式下载:Chrome/Edge 写盘,其它降级 Blob async function download(url, name, onProg) { const text = await (await fetch(url)).text(); const ts = text.split('\n').map(s => s.trim()) .filter(s => s && !s.startsWith('#')) .map(s => s.startsWith('http') ? s : new URL(s, url).href); if (!ts.length) throw new Error('m3u8 无 ts 分片'); if (window.showSaveFilePicker) { const w = (await window.showSaveFilePicker({ suggestedName: name, types: [{ description: 'MPEG-TS', accept: { 'video/mp2t': ['.ts'] } }] })).createWritable(); for (let i = 0; i < ts.length; i++) { const r = (await fetch(ts[i])).body.getReader(); for (;;) { const { done, value } = await r.read(); if (done) break; await w.write(value); } onProg?.(`下载中 ${i + 1}/${ts.length}`); } await w.close(); return { mode: 'stream', count: ts.length }; } if (ts.length > 300) throw new Error('该浏览器不支持流式写入且分片过多,请用 Chrome/Edge'); const buf = []; for (let i = 0; i < ts.length; i++) { buf.push(await (await fetch(ts[i])).arrayBuffer()); onProg?.(`下载中 ${i + 1}/${ts.length}`); } const blob = new Blob(buf, { type: 'video/mp2t' }); const a = $('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); return { mode: 'blob', count: ts.length }; } // 面板 function buildPanel(reps, acvid) { document.getElementById('acfun-hls-panel')?.remove(); const box = $('div', 'position:fixed;top:80px;right:12px;z-index:2147483647;background:rgba(20,20,20,.92);color:#eee;padding:10px 12px;border-radius:8px;font:13px/1.6 sans-serif;min-width:200px;box-shadow:0 2px 12px rgba(0,0,0,.4)'); box.id = 'acfun-hls-panel'; const head = $('div', 'display:flex;justify-content:space-between;margin-bottom:6px'); head.innerHTML = `${acvid}`; const x = $('span', 'cursor:pointer;color:#999;padding:0 4px'); x.textContent = '×'; x.onclick = () => box.remove(); head.appendChild(x); box.appendChild(head); const st = $('div', 'color:#8fc;font-size:12px;min-height:16px;margin-bottom:4px'); box.appendChild(st); reps.forEach((r, i) => { if (!r?.url) return; const row = $('div', 'display:flex;align-items:center;gap:8px;margin:4px 0'); const a = $('a', 'color:#11AA11;text-decoration:underline;min-width:70px'); a.href = r.url; a.target = '_blank'; a.textContent = r.qualityLabel || ('档' + i); const b = $('button', 'background:#4CAF50;color:#fff;border:0;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px'); b.textContent = '下载'; b.onclick = async () => { b.disabled = true; b.textContent = '…'; try { const res = await download(r.url, `acfun_${r.qualityLabel || i}.ts`, m => st.textContent = m); st.textContent = `完成:${res.count} 分片(${res.mode})`; } catch (e) { st.textContent = '失败:' + (e?.message || e); console.error('[acfun-hls]', e); } finally { b.disabled = false; b.textContent = '下载'; } }; row.append(a, b); box.appendChild(row); }); document.body.appendChild(box); } // 扫描:先做零成本判断,有变化才建面板 let lastKey = ''; function scan(force) { const reps = getReps(force); if (!reps?.length) return false; const key = location.pathname + '|' + reps.map(r => r.qualityLabel).join(','); if (!force && key === lastKey && document.getElementById('acfun-hls-panel')) return true; lastKey = key; buildPanel(reps, location.pathname.split('/').pop()); return true; } // 防抖调度:DOM/路由抖动时合并触发 let tid = 0; const schedule = (force) => { clearTimeout(tid); tid = setTimeout(() => scan(force), 300); }; // 事件驱动:SPA 切集 const onNav = () => { lastKey = ''; schedule(true); }; const _push = history.pushState.bind(history), _replace = history.replaceState.bind(history); history.pushState = function (...a) { _push(...a); onNav(); }; history.replaceState = function (...a) { _replace(...a); onNav(); }; addEventListener('popstate', onNav); // 事件驱动:播放器/数据挂载(回调极轻,引用没变直接返回) new MutationObserver(() => schedule()).observe(document.body, { childList: true, subtree: true }); // 兜底:5 秒一次,且因缓存命中几乎不消耗 setInterval(() => scan(), 5000); // 手动兜底按钮(强制重新解析) const fab = $('button', 'position:fixed;bottom:20px;right:12px;z-index:2147483647;background:#ff69b4;color:#fff;border:0;padding:6px 12px;border-radius:16px;cursor:pointer'); fab.textContent = 'AC 提取'; fab.onclick = () => { lastRaw = null; lastKey = ''; scan(true) || alert('没读到 videoInfo.ksPlayJson,可能播放器还没初始化'); }; document.body.appendChild(fab); schedule(true); })();