// ==UserScript== // @name QQ闪传 手机端下载助手 // @name:en Qfile (QQ) Mobile Download Helper // @namespace zeroyong/userscripts // @version 1.1 // @description 修复 qfile.qq.com(QQ闪传) 分享页手机端下载会存成 download.bin、文件名全要手改的问题:接管下载、显示进度与实时速度、按列表里的真实书名保存;勾选多个文件自动打包成一个 zip(按"当前文件夹+序号"命名)只弹一次保存框;顺手隐藏站点自带的「下载 / QQ极速下载」选择弹窗。一次最多 5 个文件防止手机卡死。手机端生效,PC 端文件名本来就对,默认不接管(地址栏加 ?qf=1 可强制开启)。 // @author zeroyong // @match https://qfile.qq.com/* // @match https://*.qfile.qq.com/* // @run-at document-start // @noframes // @grant GM_xmlhttpRequest // @grant GM_download // @connect qfile.qq.com // @connect *.qfile.qq.com // @connect multimedia.qfile.qq.com // @tag 下载 // @tag 批量下载 // @tag 手机端 // @tag QQ // @tag QQ闪传 // @tag 文件名 // @tag 打包 // @tag zip // @tag qfile // @tag download // @tag filename // @tag mobile // @license All Rights Reserved // ==/UserScript== (function () { 'use strict'; // 站点自己就是靠 UA 正则给 打 is-mobile 标记的,这里用同一套判定。 // PC 分支它用的是 ,名字本来就对,默认不接管; // 地址栏加 ?qf=1 (或控制台 localStorage.qf_force='1') 可以强制接管,方便在电脑上验证链路 const MOBILE_RE = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i; const FORCE = /[?&]qf=1/.test(location.search); if (!MOBILE_RE.test(navigator.userAgent) && !FORCE) return; // blob 要整个进内存,手机浏览器几百 MB 会直接崩;超了就不走 blob const MAX_BLOB_MB = 180; // 点完站点按钮到它拿到直链(要过一次 batch 下载接口)的时间上限 const URL_WAIT_MS = 15000; // 一次最多下这么多个:手机端下载/保存都很重,多文件很容易把浏览器拖死 const MAX_FILES = 5; // 选中超过这么多个就打成一个 zip,只弹一次保存框。设成 1 表示"只要多于 1 个就打包"; // 想回到"超过 3 个才打包"就把这里改成 3 const ZIP_MIN_FILES = 1; const isFileUrl = u => /\/download\?/.test(u) && /[?&](fileid|rkey)=/.test(u); // ===== 抢在站点之前把 iframe 下载地址截下来 ===== // 站点手机端分支:document.createElement('iframe'); f.src = 直链 → 浏览器按 // /download 这个路径名存盘,中文名解析不了就成了 download.bin const ifrDesc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'); let waiting = null; // 由我们主动点出来的下载,等直链的那颗 promise const stray = []; // 用户自己点站点按钮、我们没排队的下载 function setIframeSrc(url) { if (waiting) { const w = waiting; waiting = null; clearTimeout(w.timer); w.resolve(url); return; } stray.push(url); pumpStray(); } if (ifrDesc && ifrDesc.set) { Object.defineProperty(HTMLIFrameElement.prototype, 'src', { configurable: true, enumerable: ifrDesc.enumerable, get: ifrDesc.get, set(v) { const s = String(v || ''); if (isFileUrl(s)) { setIframeSrc(s); return; } // 不让它真的挂上去 return ifrDesc.set.call(this, v); }, }); const setAttr = Element.prototype.setAttribute; Element.prototype.setAttribute = function (name, value) { if (this.tagName === 'IFRAME' && /^src$/i.test(name) && isFileUrl(String(value || ''))) { setIframeSrc(String(value)); return; } return setAttr.apply(this, arguments); }; } // 站点 PC 分支是 a.href=直链 + a.download=名字 再 click()。手机浏览器如果把 UA 设成 // 电脑版也会走这里,所以一并接住;名字我们照收,只是不再让浏览器自己下 const anchorClick = HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click = function () { if (isFileUrl(this.href || '')) { lastAnchorName = this.download || ''; setIframeSrc(this.href); return; } return anchorClick.apply(this, arguments); }; let lastAnchorName = ''; // ===== 站点那个"下载 / QQ极速下载"弹窗:藏掉并自动按【下载】 ===== // 实测它有两种外壳:手机端 Popup(.download-choice-popup + .btns .btn), // 窄窗口/PC 走 Dialog(.download-choice-dialog + button.dialog-btns)。两种都得吃下, // 只按文案找"下载"这个次级按钮;找不到(文件夹/QQ-only 场景)就别拦,让用户看得见 const CHOICE_SEL = '.download-choice-popup, .download-choice-dialog'; const BTN_SEL = '.btns .btn, button.dialog-btns, .q-dialog-footer button, .ft-button'; // 遮罩和弹窗在同一个 teleport 容器里,只藏弹窗会留一层看不见的遮罩吃掉整页点击。 // 用 class 藏而不是内联 style:Vue 会复用同一个节点弹下一次,内联样式会把它永久闷死 function choiceNodes(pop) { let top = pop; while (top.parentElement && top.parentElement !== document.body && !/^(app|teleports)$/.test(top.parentElement.id || '')) { top = top.parentElement; } const mask = Array.from(document.querySelectorAll('.q-dialog__backdrop, .q-popup__mask')); return [pop, top].concat(mask); } function hideChoice(pop) { choiceNodes(pop).forEach(n => n.classList.add('qf-hide')); } function unhideStale() { document.querySelectorAll('.qf-hide').forEach(n => n.classList.remove('qf-hide')); } // 还在屏上的弹窗才算数;正在放退场动画的先跳过 function isLive(pop) { if (!pop || pop.classList.contains('qf-hide') || /leave/.test(pop.className)) return false; return !!pop.offsetParent && getComputedStyle(pop).display !== 'none'; } function plainBtn(root) { return Array.from(root.querySelectorAll(BTN_SEL)).find(b => b.textContent.trim() === '下载' && !/disabled|--primary/.test(b.className) && b.getAttribute('aria-disabled') !== 'true' && !b.disabled); } // 一出现就立刻藏:观察器回调跑在浏览器绘制之前的微任务里,用户看不到它闪 function swallow() { let hit = null; document.querySelectorAll(CHOICE_SEL).forEach(pop => { if (!isLive(pop) || !plainBtn(pop)) return; hideChoice(pop); hit = pop; }); return hit; } function clickDownload(pop) { const btn = pop && plainBtn(pop); if (!btn) return false; btn.click(); return true; } // 用户自己点站点下载图标时(我们没在排这条),替他把弹窗点掉。 // 排队中的下载由 askSiteForUrl 按自己的节奏点,这里只负责藏,免得两边重复点击 const confirmed = new WeakMap(); function confirmChoice() { const pop = swallow(); if (!pop) return false; if (waiting) return true; if (Date.now() - (confirmed.get(pop) || 0) < 5000) return false; confirmed.set(pop, Date.now()); return clickDownload(pop); } function watchChoice() { const hit = () => { if (document.querySelector(CHOICE_SEL)) confirmChoice(); }; const mo = new MutationObserver(hit); const start = () => { mo.observe(document.body, { childList: true, subtree: true }); // 站点有时只改 class 把弹窗亮出来(没有 childList 变化),用低频查询兜住,最迟 300ms 内藏掉 setInterval(hit, 300); }; if (document.body) start(); else document.addEventListener('DOMContentLoaded', start); } // ===== 文件名 ===== // 站点直链上带的 filename 是它自己按 110 字节中间省略过的(… 号),列表里的 title 才是全名 function nameFromUrl(u) { const m = /[?&]filename=([^&]+)/.exec(u); if (!m) return ''; try { return decodeURIComponent(m[1]); } catch (e) { return m[1]; } } function safeName(name, url) { let n = String(name || '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim(); if (!n) n = nameFromUrl(url); if (!n) n = 'qfile-' + Date.now(); if (n.length > 120) { const dot = n.lastIndexOf('.'); const ext = dot > 0 && dot > n.length - 12 ? n.slice(dot) : ''; n = n.slice(0, 100) + ext; } return n; } // 打包名 = 当前文件夹名 + 两位序号(心理01.zip、心理02.zip),序号按文件夹各自累加 function zipName() { const base = safeName(curFolder || '全部文件'); const key = 'qf_zip_' + (base || 'QQ闪传'); let n = 0; try { n = parseInt(localStorage.getItem(key) || '0', 10) || 0; localStorage.setItem(key, String(Math.min(98, n) + 1)); } catch (e) {} n = Math.min(98, n) + 1; return (base || 'QQ闪传') + (n < 10 ? '0' + n : n) + '.zip'; } function itemOf(node) { return node && node.closest ? node.closest('.file-item') : null; } function itemName(li) { const el = li && li.querySelector('.file-item-name'); return (el && (el.getAttribute('title') || el.textContent) || '').trim(); } const isFolder = li => !!li.querySelector('img[src*="folder"]'); // 站点是 SPA,当前所在文件夹既不在 URL 也不在标题里,只能自己记下用户点进的那个分类 let curFolder = ''; let rootTimer = 0; let lastClickedItem = null; document.addEventListener('click', e => { const li = itemOf(e.target); if (!li) return; if (isFolder(li)) { curFolder = itemName(li); return; } if (e.target.closest && e.target.closest('.file-item-dl-btn')) lastClickedItem = li; }, true); // ===== 任务表 ===== const tasks = []; let seq = 0; function addTask(name, size) { const t = { id: ++seq, name, size: size || 0, loaded: 0, total: 0, state: '排队', speed: 0, blob: null, url: '', err: '' }; tasks.push(t); render(); return t; } function fmtSize(b) { if (!b) return '--'; const u = ['B', 'KB', 'MB', 'GB']; let i = 0; while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } return (i ? b.toFixed(1) : b) + u[i]; } function fmtSpeed(bps) { return bps ? fmtSize(bps) + '/s' : ''; } // ===== 下载实现:GM_xmlhttpRequest(有进度) → GM_download → 退回站点 iframe ===== function xhrDownload(t, url) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') return reject(new Error('no-gmxhr')); let last = Date.now(), lastLoaded = 0, settled = false, handle = null; const fail = m => { if (settled) return; settled = true; reject(new Error(m)); }; handle = GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: 0, headers: { Referer: location.href }, onprogress(e) { if (settled) return; const now = Date.now(); t.loaded = e.loaded || t.loaded; t.total = e.total || t.total; if (now - last > 400) { t.speed = (t.loaded - lastLoaded) * 1000 / (now - last); last = now; lastLoaded = t.loaded; render(); } // 边下边盯大小,超了立刻断,别等整份进内存才反悔 if (t.total > MAX_BLOB_MB * 1024 * 1024) { try { if (handle) handle.abort(); } catch (err) {} fail('too-big'); } }, onload(r) { if (settled) return; settled = true; let raw = r.response != null ? r.response : r.responseText; const ctype = (r.responseHeaders || '').match(/content-type:\s*([^\s;]+)/i); if (typeof raw === 'string' || raw == null) { // 管理器不支持二进制,字符串拿回来的文件是废的,别硬存 return reject(new Error(r.status === 200 ? 'no-binary' : 'HTTP ' + r.status)); } if (r.status !== 200) return reject(new Error('HTTP ' + r.status)); if (ctype && /json/i.test(ctype[1])) { let msg = '服务端拒绝'; try { msg = JSON.parse(new TextDecoder().decode(raw)).retcode || msg; } catch (e) {} return reject(new Error(msg)); } const blob = new Blob([raw], { type: (ctype && ctype[1]) || 'application/octet-stream' }); t.total = t.total || blob.size; t.loaded = blob.size; t.speed = 0; t.blob = blob; resolve(); }, onerror() { fail('网络失败/被拦'); }, ontimeout() { fail('下载超时'); }, }); }); } function gmDownload(t, url) { return new Promise((resolve, reject) => { if (typeof GM_download !== 'function') return reject(new Error('no-gmdownload')); GM_download({ url, name: t.name, headers: { Referer: location.href }, saveAs: false, onprogress(e) { t.loaded = e.loaded || 0; t.total = e.total || 0; render(); }, onload() { t.state = '已保存'; t.speed = 0; resolve(); }, onerror(e) { reject(new Error('GM_download 失败: ' + (e && e.error || '未知'))); }, }); }); } // 兜底:让站点原来的 iframe 去下(还是 download.bin),同时把正确文件名塞进剪贴板 function nativeDownload(t, url) { const f = document.createElement('iframe'); f.style.display = 'none'; document.body.appendChild(f); ifrDesc.set.call(f, url); setTimeout(() => f.remove(), 60000); copyText(t.name); t.state = '已交给浏览器'; toast('已用浏览器下载:' + t.name + '(文件名已复制,另存为时粘贴)'); } function copyText(s) { try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(s).catch(() => {}); } catch (e) {} } // 同一个 fileid 的直链在 1 秒内会重复出现(站点自己也会挂 iframe),只认第一次 const seenFid = new Map(); function dupUrl(u) { const fid = (/fileid=(\w+)/.exec(u) || [])[1]; if (!fid) return false; const now = Date.now(); const prev = seenFid.get(fid) || 0; seenFid.set(fid, now); return now - prev < 1000; } async function runDownload(t) { t.state = '下载中'; t.startedAt = Date.now(); render(); if (dupUrl(t.url)) { t.state = '重复,已跳过'; render(); return; } try { await xhrDownload(t, t.url); t.state = '已完成'; } catch (e) { const m = String((e && e.message) || e); if (m === 'no-gmxhr' || m === 'no-binary' || m === 'too-big') { try { await gmDownload(t, t.url); return; } catch (e2) { const m2 = String((e2 && e2.message) || e2); if (m2 === 'no-gmdownload') { nativeDownload(t, t.url); return; } t.state = '失败'; t.err = m2; render(); return; } } t.state = '失败'; t.err = m; render(); return; } render(); } // ===== ZIP:把多个文件打成一个包,一次只弹一个保存框 ===== // epub/mobi 本身已是压缩包,deflate 再压收益极小,直接用 STORE(0) 最省事也最不容易错 const CRC_TAB = (() => { const t = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[i] = c >>> 0; } return t; })(); function crc32(u8) { let c = 0xffffffff; for (let i = 0; i < u8.length; i++) c = CRC_TAB[(c ^ u8[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function dosStamp(d) { return { time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1), date: ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(), }; } async function buildZip(files) { const enc = new TextEncoder(); const now = dosStamp(new Date()); const body = [], central = []; let offset = 0; for (const f of files) { const raw = new Uint8Array(await f.blob.arrayBuffer()); const nb = enc.encode(f.name); const crc = crc32(raw); const lh = new Uint8Array(30 + nb.length); const lv = new DataView(lh.buffer); lv.setUint32(0, 0x04034b50, true); lv.setUint16(4, 20, true); lv.setUint16(6, 0x0800, true); // 文件名字符集是 UTF-8 lv.setUint16(8, 0, true); // method = STORE lv.setUint16(10, now.time, true); lv.setUint16(12, now.date, true); lv.setUint32(14, crc, true); lv.setUint32(18, raw.length, true); lv.setUint32(22, raw.length, true); lv.setUint16(26, nb.length, true); lh.set(nb, 30); const ch = new Uint8Array(46 + nb.length); const cv = new DataView(ch.buffer); cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); cv.setUint16(8, 0x0800, true); cv.setUint16(10, 0, true); cv.setUint16(12, now.time, true); cv.setUint16(14, now.date, true); cv.setUint32(16, crc, true); cv.setUint32(20, raw.length, true); cv.setUint32(24, raw.length, true); cv.setUint16(28, nb.length, true); cv.setUint32(42, offset, true); ch.set(nb, 46); body.push(lh, raw); central.push(ch); offset += lh.length + raw.length; } const cdSize = central.reduce((s, c) => s + c.length, 0); const eo = new Uint8Array(22); const ev = new DataView(eo.buffer); ev.setUint32(0, 0x06054b50, true); ev.setUint16(8, files.length, true); ev.setUint16(10, files.length, true); ev.setUint32(12, cdSize, true); ev.setUint32(16, offset, true); return new Blob([...body, ...central, eo], { type: 'application/zip' }); } function uniqName(used, name) { if (!used.has(name)) { used.add(name); return name; } const dot = name.lastIndexOf('.'); const base = dot > 0 ? name.slice(0, dot) : name; const ext = dot > 0 ? name.slice(dot) : ''; let i = 2; while (used.has(base + ' (' + i + ')' + ext)) i++; const n = base + ' (' + i + ')' + ext; used.add(n); return n; } // 下载完成后统一收尾:超过 ZIP_MIN_FILES 个打成一个 zip,否则逐个保存(间隔开,别连弹) async function flushSaved(list) { const ok = list.filter(t => t.blob && !t.saved); if (!ok.length) return; if (ok.length <= ZIP_MIN_FILES) { ok.forEach((t, i) => setTimeout(() => saveTask(t), i * 2000)); return; } const used = new Set(); const zt = addTask(zipName()); zt.state = '打包中'; render(); try { const blob = await buildZip(ok.map(t => ({ name: uniqName(used, t.name), blob: t.blob }))); zt.total = zt.loaded = blob.size; zt.blob = blob; ok.forEach(t => { t.blob = null; t.state = '已打包'; }); // 手机端内存吃紧,原件释掉 render(); saveTask(zt); } catch (e) { zt.state = '失败'; zt.err = '打包失败: ' + ((e && e.message) || e); render(); ok.forEach((t, i) => setTimeout(() => saveTask(t), i * 2000)); } } function saveTask(t) { if (!t.blob) return; const a = document.createElement('a'); const href = URL.createObjectURL(t.blob); a.href = href; a.download = t.name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(href), 60000); t.state = '已保存'; t.saved = true; render(); } // 串行跑:站点一次只给一个直链,且并发下载在手机上更容易 OOM let chain = Promise.resolve(); function enqueue(t) { chain = chain.then(() => runDownload(t)); return chain; } // 正在跑的那一批。期间截到的地址(用户自己点的、或上一条迟到回来的)并进同一批, // 统一按数量决定分别保存还是打包,不要各弹各的保存框 let curBatch = null; function pumpStray() { while (stray.length) { const url = stray.shift(); const name = (lastClickedItem && itemName(lastClickedItem)) || lastAnchorName || nameFromUrl(url); const t = addTask(safeName(name, url)); t.url = url; if (curBatch) curBatch.push(t); // 用户自己点站点按钮下的,不在批次里就下完直接存 enqueue(t).then(() => { if (t.blob && !curBatch) saveTask(t); }); lastClickedItem = null; lastAnchorName = ''; } } // 主动点站点的下载图标,等它把直链算出来。 // 弹窗是异步挂载/可能是上一次那个被复用的节点,所以定时盯:没弹出就不点, // 点了 2.5s 还没回地址就认定这次点击没生效,补点,最多 3 次 function askSiteForUrl(li) { return new Promise((resolve, reject) => { if (waiting) { reject(new Error('上一个下载还没结束')); return; } const btn = li.querySelector('.file-item-dl-btn'); if (!btn) { reject(new Error('这一项没有下载按钮')); return; } unhideStale(); let poll = 0, clicks = 0, clickedAt = 0; const giveUp = msg => { clearTimeout(timer); clearInterval(poll); if (waiting) waiting = null; reject(new Error(msg)); }; const finish = ok => { clearTimeout(timer); clearInterval(poll); waiting = null; (ok ? resolve : reject)(ok); }; const nudge = () => { // 弹窗一冒出来就藏+点;补点时节点已被我们藏起来了,直接摸里面的按钮 const pop = swallow() || document.querySelector(CHOICE_SEL); if (!pop) return; if (clicks && Date.now() - clickedAt < 2500) return; if (!clickDownload(pop)) return; clicks++; clickedAt = Date.now(); if (clicks >= 3) giveUp('点了 3 次下载没回地址(可能只支持 QQ 下载)'); }; const timer = setTimeout(() => giveUp('没拿到下载地址(可能只支持 QQ 下载)'), URL_WAIT_MS); waiting = { timer, resolve: url => finish(url) }; poll = setInterval(nudge, 400); lastClickedItem = li; btn.click(); nudge(); }); } async function downloadItems(items) { if (items.length > MAX_FILES) items = items.slice(0, MAX_FILES); const list = []; curBatch = list; for (const li of items) { const t = addTask(safeName(itemName(li))); try { t.url = await askSiteForUrl(li); } catch (e) { t.state = '失败'; t.err = (e && e.message) || String(e); render(); continue; } await enqueue(t); list.push(t); } curBatch = null; await chain; // 期间并进来的零散下载也等它跑完 // 下完再统一收尾:>3 个打成一个 zip,只弹一次保存框 await flushSaved(list); } // 一次只跑一批:站点给直链是串行的,重叠点会攒出一堆重复任务把手机拖死 let busy = false; async function startBatch(items) { if (busy) { toast('上一批还没下完,先等它结束'); return; } busy = true; const panel = document.getElementById('qf-panel'); if (panel) panel.classList.add('open'); try { await downloadItems(items); } finally { busy = false; } } // ===== 底部栏 + 任务面板 ===== function injectStyle() { const st = document.createElement('style'); st.textContent = ` #qf-bar{position:fixed;left:0;right:0;bottom:0;z-index:99998;display:flex;align-items:center;gap:8px;padding:8px 12px calc(8px + env(safe-area-inset-bottom));background:#fff;border-top:1px solid #eee;font-size:13px;box-shadow:0 -2px 10px rgba(0,0,0,.06)} #qf-bar .info{flex:1;min-width:0;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #qf-bar .go{background:#12b7f4;color:#fff;border:none;border-radius:18px;padding:8px 18px;font-size:14px;font-weight:600;flex-shrink:0} #qf-bar .go:disabled{opacity:.5} #qf-panel{position:fixed;left:0;right:0;bottom:0;max-height:62vh;z-index:99999;background:#fff;border-top-left-radius:14px;border-top-right-radius:14px;box-shadow:0 -4px 20px rgba(0,0,0,.18);display:none;flex-direction:column;overflow:hidden} #qf-panel.open{display:flex} #qf-panel header{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid #f0f0f0;font-size:14px;font-weight:600} #qf-panel header .sp{flex:1} #qf-panel header .mini{background:#f2f3f5;border:none;border-radius:12px;padding:5px 10px;font-size:12px;color:#333} #qf-list{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:4px 12px 10px} .qf-task{padding:8px 0;border-bottom:1px solid #f5f5f5} .qf-task .row1{display:flex;align-items:center;gap:8px} .qf-task .nm{flex:1;min-width:0;font-size:13px;color:#222;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .qf-task .st{font-size:11px;color:#888;flex-shrink:0} .qf-task .st.ok{color:#0a0} .qf-task .st.bad{color:#e33} .qf-task .bar{margin-top:5px;height:4px;border-radius:2px;background:#eee;overflow:hidden} .qf-task .bar i{display:block;height:100%;background:#12b7f4;width:0;transition:width .3s} .qf-task .row2{display:flex;align-items:center;gap:8px;margin-top:4px;font-size:11px;color:#999} .qf-task .row2 .sp{flex:1} .qf-task .save{background:#12b7f4;color:#fff;border:none;border-radius:12px;padding:4px 12px;font-size:11px} .qf-task .save.ghost{background:#f2f3f5;color:#333} #qf-fab{position:fixed;right:10px;bottom:74px;z-index:99997;width:40px;height:40px;border-radius:50%;background:#12b7f4;color:#fff;border:none;font-size:12px;font-weight:700;display:none;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25)} #qf-toast{position:fixed;left:50%;bottom:120px;transform:translateX(-50%);max-width:80vw;background:rgba(0,0,0,.85);color:#fff;padding:8px 14px;border-radius:16px;font-size:12px;z-index:100000;opacity:0;transition:opacity .25s;pointer-events:none} #qf-toast.show{opacity:1} .qf-hide{display:none !important} .download-bar .mobile-bar{display:none !important}`; (document.head || document.documentElement).appendChild(st); } function buildUI() { injectStyle(); const bar = document.createElement('div'); bar.id = 'qf-bar'; bar.innerHTML = '
选择文件后点右侧按钮下载
'; document.body.appendChild(bar); const fab = document.createElement('button'); fab.id = 'qf-fab'; fab.textContent = '0'; document.body.appendChild(fab); const panel = document.createElement('div'); panel.id = 'qf-panel'; panel.innerHTML = '
下载任务' + '' + '' + '
' + '
'; document.body.appendChild(panel); const toastEl = document.createElement('div'); toastEl.id = 'qf-toast'; document.body.appendChild(toastEl); bar.querySelector('.go').addEventListener('click', () => { let items = selectedItems(); if (!items.length) { toast('先勾选要下载的文件(文件夹要先点进去),一次最多 ' + MAX_FILES + ' 个'); return; } if (items.length > MAX_FILES) { items = items.slice(0, MAX_FILES); toast('一次最多 ' + MAX_FILES + ' 个,已只取前 ' + MAX_FILES + ' 个,其余请分批下'); } startBatch(items); }); bar.querySelector('.info').addEventListener('click', () => panel.classList.toggle('open')); fab.addEventListener('click', () => panel.classList.toggle('open')); panel.querySelector('#qf-close').addEventListener('click', () => panel.classList.remove('open')); panel.querySelector('#qf-clear').addEventListener('click', () => { for (let i = tasks.length - 1; i >= 0; i--) if (/已保存|已完成|已打包|失败|重复/.test(tasks[i].state)) tasks.splice(i, 1); render(); }); panel.querySelector('#qf-saveall').addEventListener('click', () => { const list = tasks.filter(t => t.blob && !t.saved); if (!list.length) { toast('没有待保存的文件'); return; } flushSaved(list); // 超过 3 个自动走 zip,不会连弹保存框 }); document.addEventListener('click', e => { if (e.target && e.target.closest && e.target.closest('.file-checkbox, .file-item')) scheduleRefresh(); }, true); document.addEventListener('change', e => { if (e.target && e.target.classList && e.target.classList.contains('file-checkbox')) scheduleRefresh(); }, true); // 进文件夹/翻页会整块重绘列表,用观察器兜住刷新(节流,别每次 mutation 都全表扫) const mo = new MutationObserver(scheduleRefresh); mo.observe(document.body, { childList: true, subtree: true }); refreshBar(); } let refreshTimer = 0; function scheduleRefresh() { if (refreshTimer) return; refreshTimer = setTimeout(() => { refreshTimer = 0; refreshBar(); }, 200); } // 勾选状态是 Vue 管的,input.checked 不一定有;class/aria 都认一遍 function isChecked(li) { const c = li.querySelector('.file-checkbox'); if (!c) return false; const box = c.matches('input[type=checkbox]') ? c : c.querySelector('input[type=checkbox]'); if (box && box.checked) return true; for (const n of [c, li, box].filter(Boolean)) { if (n.getAttribute && n.getAttribute('aria-checked') === 'true') return true; const cn = String(n.className || ''); if (/(^|[\s_-])(checked|truthy|selected)([\s_-]|$)/i.test(cn)) return true; } return false; } function fileItems() { return Array.from(document.querySelectorAll('.file-item')).filter(li => !isFolder(li)); } function selectedItems() { return fileItems().filter(isChecked); } function refreshBar() { const info = document.querySelector('#qf-bar .info'); if (!info) return; // 整屏只剩分类可能是"刚点进文件夹、文件列表还没渲染"的中间态, // 要它稳 1.5s 才认定是退回根目录,否则会把刚记下的文件夹名冲掉 const rootish = !fileItems().length && document.querySelectorAll('.file-item').length > 0; if (!rootish) { if (rootTimer) { clearTimeout(rootTimer); rootTimer = 0; } } else if (!rootTimer) rootTimer = setTimeout(() => { rootTimer = 0; if (!fileItems().length) curFolder = ''; }, 1500); const files = selectedItems(); const all = fileItems(); const total = files.reduce((s, li) => { const el = li.querySelector('.file-item-size'); return s + parseSize(el && el.textContent); }, 0); const txt = files.length ? `已选 ${files.length} 个 · ${fmtSize(total)}${files.length > MAX_FILES ? ' (超 ' + MAX_FILES + ' 个,只取前 ' + MAX_FILES + ' 个)' : ''}` : `未勾选文件 · 当前目录 ${all.length} 个可下`; // 这行文字本身就是观察器盯着的 DOM,一样就别写,否则自己触发自己转成死循环 if (info.textContent !== txt) info.textContent = txt; } function parseSize(txt) { const m = /([\d.]+)\s*(KB|MB|GB|B)/i.exec(String(txt || '')); if (!m) return 0; const k = { b: 1, kb: 1024, mb: 1048576, gb: 1073741824 }[m[2].toLowerCase()]; return Math.round(parseFloat(m[1]) * k); } let toastTimer; function toast(msg) { const el = document.getElementById('qf-toast'); if (!el) return; el.textContent = msg; el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 2600); } let rafPending = false; function render() { if (rafPending) return; rafPending = true; requestAnimationFrame(() => { rafPending = false; draw(); }); } function draw() { const list = document.getElementById('qf-list'); if (!list) return; list.innerHTML = tasks.map(t => { const pct = t.total ? Math.min(100, Math.round(t.loaded * 100 / t.total)) : (t.state === '已完成' || t.state === '已保存' ? 100 : 0); const bad = /失败/.test(t.state); const ok = /已保存|已完成|已打包/.test(t.state); const right = ok && t.blob ? `` : bad ? `` : ''; const meta = bad ? t.err : (ok ? fmtSize(t.total) : `${fmtSize(t.loaded)} / ${fmtSize(t.total)} · ${fmtSpeed(t.speed)}`); return `
${t.name}
` + `${t.state}
` + `
` + `
${meta}${right}
`; }).join('') || '
还没有任务
'; list.querySelectorAll('[data-save]').forEach(b => b.addEventListener('click', () => { const t = tasks.find(x => x.id === +b.dataset.save); if (t) saveTask(t); })); list.querySelectorAll('[data-retry]').forEach(b => b.addEventListener('click', () => { const t = tasks.find(x => x.id === +b.dataset.retry); if (t && t.url) { t.err = ''; enqueue(t); } else if (t) { t.err = '没有可重试的地址'; render(); } })); const running = tasks.filter(t => /下载中|排队/.test(t.state)).length; const fab = document.getElementById('qf-fab'); if (fab) { fab.textContent = running || tasks.length; fab.style.display = tasks.length ? 'flex' : 'none'; } const panel = document.getElementById('qf-panel'); if (panel && running && !panel.classList.contains('open')) panel.classList.add('open'); if (panel && !tasks.length) panel.classList.remove('open'); } function boot() { if (!document.body) { setTimeout(boot, 60); return; } buildUI(); watchChoice(); } boot(); })();