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