// ==UserScript== // @name 光鸭云盘 秒传工具 (txt 解析) // @namespace https://github.com/anomalyco/opencode // @version 1.4.2 // @description 右下角常驻按钮;点开选择当前目录的秒传文档(.txt/.json),解析出文件条目并勾选,点"开始秒传"才执行(新版两步流程)。 // @author opencode // @match https://www.guangyapan.com/* // @match https://gy.airp0.pw/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect api.guangyapan.com // @connect h5.guangyapan.com // @connect guangyacdn.com // @run-at document-end // ==/UserScript== (function () { 'use strict'; const API_BASE = 'https://api.guangyapan.com'; const CONCURRENCY = 4; const FLASH_DOC_FILE_RE = /秒传|flash|hashes?/i; // 秒传文档文件名匹配模式 const FLASH_DOC_EXTS = ['.json', '.txt']; // ===================================================================== // Auth // ===================================================================== function getAuthHeaders() { const credRaw = localStorage.getItem('credentials_aMe-8VSlkrbQXpUR'); if (!credRaw) throw new Error('未登录:localStorage 没有 credentials_aMe-8VSlkrbQXpUR'); let token; try { const cred = JSON.parse(credRaw); token = cred.access_token; } catch (e) { throw new Error('credentials JSON 解析失败: ' + e.message); } if (!token) throw new Error('credentials.access_token 为空'); const did = localStorage.getItem('swangpan_web_device_id'); const smid = localStorage.getItem('shumei_id:v1'); if (!did || !smid) throw new Error('未登录或 localStorage 缺 did/smid'); return { 'authorization': 'Bearer ' + token, 'did': did, 'smid': smid, 'dt': '4', 'content-type': 'application/json', 'accept': 'application/json, text/plain, */*', 'origin': location.origin, 'referer': location.origin + '/' }; } function apiPost(path, body) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: API_BASE + path, headers: getAuthHeaders(), data: JSON.stringify(body), responseType: 'json', onload: (r) => { try { const j = typeof r.response === 'string' ? JSON.parse(r.response) : r.response; if (r.status !== 200) return reject(new Error('HTTP ' + r.status + ': ' + (j && j.msg || r.responseText))); if (j && j.code !== undefined && j.code !== 0 && j.code !== 156) { return reject(new Error('server code=' + j.code + ' msg=' + j.msg)); } resolve(j); } catch (e) { reject(e); } }, onerror: (e) => reject(new Error('network: ' + (e.error || e.responseText || 'unknown'))) }); }); } function apiGet(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'referer': location.origin + '/', 'accept': '*/*' }, responseType: 'text', onload: (r) => { if (r.status >= 200 && r.status < 300) resolve(r.response); else reject(new Error('HTTP ' + r.status + ': ' + (r.responseText || '').slice(0, 100))); }, onerror: (e) => reject(new Error('network: ' + (e.error || 'unknown'))) }); }); } async function tryFlash(entry, parentId) { const r1 = await apiPost('/userres/v1/get_res_center_token', { capacity: 2, name: entry.name, res: { fileSize: entry.size }, parentId: parentId || '' }); const taskId = r1.data.taskId; const r2 = await apiPost('/userres/v1/check_can_flash_upload', { taskId, gcid: entry.gcid, cid: entry.cid }); return { instant: !!r2.data.canFlashUpload, taskId }; } // ===================================================================== // Context: URL hash → parentId / kind // ===================================================================== function getCurrentCtx() { const hash = location.hash || ''; // /home//-name[/-name...]* — match 最末尾的数字 ID let m = hash.match(/^\/?#?\/(?:home|folder|drive)\/(?:[^\/]+\/)*(\d+)/); if (m) return { parentId: m[1], kind: 'folder', label: '当前文件夹' }; const cat = hash.match(/\/home\/(all|video|img|audio|doc|installer|recent)(?:\/|$)/); if (cat) return { parentId: '', kind: 'category', label: '当前: ' + cat[1] + '(分类视图)' }; const sp = hash.match(/\/(transfer|publish|trash)/); if (sp) return { parentId: '', kind: 'system', label: '当前: ' + sp[1] }; const pv = hash.match(/\/preview\/(\d+)/); if (pv) return { parentId: '', kind: 'preview', fileId: pv[1] }; return { parentId: '', kind: 'root', label: '根目录' }; } // ===================================================================== // Folder utilities // ===================================================================== async function listFolder(parentId) { const r = await apiPost('/userres/v1/file/get_file_list', { parentId: parentId || '', pageSize: 200, page: 0, orderBy: 1, sortType: 1 }); return (r.data && r.data.list) || []; } // ===================================================================== // Concurrency pool // ===================================================================== async function runPool(items, limit, worker, onItemDone) { let i = 0; const runners = Array.from({ length: limit }, async () => { while (true) { const idx = i++; if (idx >= items.length) return; try { const res = await worker(items[idx], idx); onItemDone(idx, { ok: true, ...res }); } catch (e) { onItemDone(idx, { ok: false, error: e.message || String(e) }); } } }); await Promise.all(runners); } // ===================================================================== // CSS // ===================================================================== GM_addStyle(` #gy-fab { position: fixed; right: 24px; bottom: 24px; z-index: 2147483600; width: 52px; height: 52px; border-radius: 50%; background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: #fff; border: 0; cursor: pointer; font-size: 20px; line-height: 1; display: flex; align-items: center; justify-content: center; box-shadow: 0 6px 20px rgba(79,70,229,.35), 0 2px 6px rgba(0,0,0,.1); transition: transform .15s ease; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } #gy-fab:hover { transform: scale(1.08); } #gy-modal-bg { position: fixed; inset: 0; z-index: 2147483646; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } #gy-modal { background: #1f1f23; color: #e5e7eb; width: min(920px, 96vw); max-height: 90vh; border-radius: 12px; box-shadow: 0 24px 60px rgba(0,0,0,.5); display: flex; flex-direction: column; overflow: hidden; } #gy-modal header { padding: 16px 20px; border-bottom: 1px solid #2c2c30; display: flex; justify-content: space-between; align-items: center; } #gy-modal header h3 { margin: 0; font-size: 16px; font-weight: 600; } #gy-modal header .close { background: transparent; border: 0; color: #9ca3af; font-size: 22px; cursor: pointer; padding: 0 4px; line-height: 1; } #gy-modal header .close:hover { color: #fff; } #gy-modal .body { padding: 16px 20px; overflow: auto; flex: 1; } #gy-modal footer { padding: 12px 20px; border-top: 1px solid #2c2c30; display: flex; gap: 10px; justify-content: flex-end; align-items: center; } #gy-modal footer .right { margin-left: auto; display: flex; gap: 10px; } .gy-btn { padding: 7px 14px; border-radius: 6px; border: 0; font-size: 13px; font-weight: 600; cursor: pointer; background: #353539; color: #d1d5db; } .gy-btn:hover { background: #42424a; } .gy-btn-primary { background: #4f46e5; color: #fff; } .gy-btn-primary:hover { background: #4338ca; } .gy-btn-primary:disabled { background: #353539; color: #6b7280; cursor: not-allowed; } .gy-summary { padding: 10px 12px; margin: 12px 0; background: #14141a; border-radius: 6px; font-size: 13px; color: #d1d5db; } .gy-doc-row { display: flex; gap: 8px; align-items: center; padding: 6px 10px; font-size: 13px; border-bottom: 1px solid #2c2c30; cursor: pointer; } .gy-doc-row:hover { background: #2c2c30; } .gy-doc-row input { transform: scale(1.15); cursor: pointer; } .gy-doc-row .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gy-doc-row .sz { color: #9ca3af; font-size: 12px; } table.gy-list { width: 100%; border-collapse: collapse; font-size: 13px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; } table.gy-list th { background: #2c2c30; color: #d1d5db; text-align: left; padding: 8px 10px; font-weight: 500; position: sticky; top: 0; border-bottom: 1px solid #353539; } table.gy-list td { padding: 6px 10px; border-bottom: 1px solid #2c2c30; color: #d1d5db; vertical-align: middle; } table.gy-list td.cb-col { width: 30px; text-align: center; } table.gy-list td.cb-col input { transform: scale(1.15); cursor: pointer; } table.gy-list td.size { text-align: right; width: 90px; } table.gy-list td.status { width: 200px; } .gy-status-pending { color: #9ca3af; } .gy-status-running { color: #fbbf24; } .gy-status-ok { color: #34d399; } .gy-status-fail { color: #f87171; } .gy-status-error { color: #f87171; font-weight: 600; } .gy-bar { height: 6px; background: #2c2c30; border-radius: 3px; overflow: hidden; margin-top: 8px; } .gy-bar > div { height: 100%; background: linear-gradient(90deg, #4f46e5, #7c3aed); width: 0%; transition: width .25s ease; } `); // ===================================================================== // Modal state // ===================================================================== let modalState = null; function openFlashUI() { if (document.getElementById('gy-modal-bg')) return; const ctx = getCurrentCtx(); if (!ctx.parentId) { alert('请先进入一个文件夹目录,再点秒传按钮'); return; } const bg = document.createElement('div'); bg.id = 'gy-modal-bg'; bg.addEventListener('click', (e) => { if (e.target === bg) closeModal(); }); const m = document.createElement('div'); m.id = 'gy-modal'; m.innerHTML = `

🐤 秒传工具

📄 正在扫描当前目录的秒传文档…
`; bg.appendChild(m); document.body.appendChild(bg); modalState = { bg, modal: m, parentId: ctx.parentId, docs: [], // 目录里的秒传文档 entries: [], // 解析出的秒传条目 selected: new Set(), // 勾选的条目 index results: [], running: false, done: false, step: 'docs', // 'docs' → 'entries' → running/done }; m.querySelector('.close').addEventListener('click', closeModal); m.querySelector('#gy-cancel').addEventListener('click', closeModal); m.querySelector('#gy-start').addEventListener('click', onStartClick); const onKey = (e) => { if (e.key === 'Escape') closeModal(); }; document.addEventListener('keydown', onKey); bg._escHandler = onKey; loadDocList(); } function closeModal() { if (!modalState) return; if (modalState.bg._escHandler) document.removeEventListener('keydown', modalState.bg._escHandler); modalState.bg.remove(); modalState = null; } // ===================================================================== // Step 1: 列出当前目录的秒传文档,供勾选 // ===================================================================== async function loadDocList() { const summary = modalState.modal.querySelector('#gy-doc-summary'); const wrap = modalState.modal.querySelector('#gy-doc-wrap'); try { const list = await listFolder(modalState.parentId); const docs = list.filter((f) => { if (f.dirType !== 1) return false; const ext = (f.fileName.match(/\.[^.]+$/) || [''])[0].toLowerCase(); return FLASH_DOC_EXTS.includes(ext) && FLASH_DOC_FILE_RE.test(f.fileName); }); modalState.docs = docs; if (!docs.length) { summary.textContent = '❌ 当前目录没有找到秒传文档(文件名含"秒传"的 .txt/.json)'; return; } summary.innerHTML = `📄 找到 ${docs.length} 个秒传文档,勾选要解析的:`; renderDocList(); } catch (e) { summary.textContent = '⚠️ 扫描失败:' + e.message; } } function renderDocList() { const wrap = modalState.modal.querySelector('#gy-doc-wrap'); wrap.innerHTML = modalState.docs.map((d, i) => `
${escapeHtml(d.fileName)} ${fmtSize(d.fileSize != null ? d.fileSize : (d.size || 0))}
`).join(''); const updateBtn = () => { const anyChecked = wrap.querySelectorAll('input:checked').length > 0; modalState.modal.querySelector('#gy-start').disabled = !anyChecked; }; wrap.querySelectorAll('input').forEach((cb) => { cb.addEventListener('change', () => { cb.closest('.gy-doc-row').style.background = cb.checked ? 'rgba(99,102,241,.15)' : ''; updateBtn(); }); }); wrap.querySelectorAll('input:checked').forEach((cb) => { cb.closest('.gy-doc-row').style.background = 'rgba(99,102,241,.15)'; }); updateBtn(); } // ===================================================================== // Step 2: 解析所选文档 → 条目列表(可勾选) // ===================================================================== async function resolveDocs() { if (modalState.running || modalState.done) return; const wrap = modalState.modal.querySelector('#gy-doc-wrap'); const docIdxs = [...wrap.querySelectorAll('input:checked')].map((cb) => parseInt(cb.dataset.docIdx, 10)); if (!docIdxs.length) { alert('请先勾选要解析的秒传文档'); return; } const startBtn = modalState.modal.querySelector('#gy-start'); startBtn.disabled = true; startBtn.textContent = '解析中…'; const allEntries = []; const docNames = []; let errors = []; for (const idx of docIdxs) { const doc = modalState.docs[idx]; docNames.push(doc.fileName); try { const dl = await apiPost('/userres/v1/get_res_download_url', { fileId: doc.fileId }); const text = await apiGet(dl.data.signedURL); const arr = JSON.parse(text); if (Array.isArray(arr) && arr.length) allEntries.push(...arr); else errors.push(doc.fileName + ': 内容不是数组或为空'); } catch (e) { errors.push(doc.fileName + ': ' + e.message); } } if (!allEntries.length) { startBtn.disabled = false; startBtn.textContent = '解析所选文档'; alert('解析失败:\n' + errors.join('\n')); return; } loadEntries(allEntries, docNames.join('、')); modalState.step = 'entries'; startBtn.disabled = false; startBtn.textContent = '开始秒传'; if (errors.length) { modalState.modal.querySelector('#gy-summary').innerHTML += `
⚠️ 部分文档解析失败:${escapeHtml(errors.join(';'))}
`; } } // ===================================================================== // Helpers // ===================================================================== function fmtSize(n) { n = Number(n) || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; if (n < 1073741824) return (n / 1048576).toFixed(2) + ' MB'; return (n / 1073741824).toFixed(2) + ' GB'; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } function loadEntries(data, name) { const bad = data.filter((x) => !x || !x.name || !x.size || !x.gcid || !x.cid); if (bad.length) throw new Error(`有 ${bad.length} 条目缺 name/size/gcid/cid`); modalState.entries = data; modalState.results = data.map(() => ({ status: 'pending' })); modalState.selected = new Set(data.map((_, i) => i)); renderTable(); const totalSize = data.reduce((s, x) => s + (Number(x.size) || 0), 0); const summary = modalState.modal.querySelector('#gy-summary'); summary.style.display = ''; summary.innerHTML = ` 来自 ${escapeHtml((name || '秒传文档').slice(0, 60))},共 ${data.length} 个文件,总计 ${fmtSize(totalSize)}, 已选 ${data.length} 个 `; } // ----- Table render ----- function renderTable() { const wrap = modalState.modal.querySelector('#gy-table-wrap'); if (!modalState.entries.length) { wrap.innerHTML = '
还没有条目
'; return; } const rows = modalState.entries.map((e, i) => { const r = modalState.results[i] || { status: 'pending' }; const st = r.status; const cls = 'gy-status-' + (st === 'pending' ? 'pending' : st); const txt = ({ pending: '⏳ 排队', running: '🔄 探测中…', ok: '✅ 秒传命中', no_match: '❌ 服务端未命中', error: '⚠️ 错误' })[st] || st; const msg = r.message ? `
${escapeHtml(r.message)}` : ''; const checked = modalState.selected.has(i); return ` ${i + 1} ${escapeHtml(e.name)} ${fmtSize(e.size)} ${txt}${msg} `; }).join(''); wrap.innerHTML = ` ${rows}
# name size 状态
`; wrap.querySelectorAll('input.gy-cb').forEach((cb) => { cb.addEventListener('change', (e) => { const idx = parseInt(e.target.getAttribute('data-idx'), 10); if (e.target.checked) modalState.selected.add(idx); else modalState.selected.delete(idx); updateSelCount(); }); }); const headCb = wrap.querySelector('#gy-cb-head'); if (headCb) { headCb.addEventListener('change', (e) => { const all = e.target.checked; modalState.selected = new Set(); wrap.querySelectorAll('input.gy-cb').forEach((cb) => { cb.checked = all; if (all) modalState.selected.add(parseInt(cb.getAttribute('data-idx'), 10)); }); updateSelCount(); }); } } function updateSelCount() { const el = modalState.modal.querySelector('#gy-sel-count'); if (el) el.textContent = modalState.selected.size; } function renderRow(idx) { const wrap = modalState.modal.querySelector('#gy-table-wrap'); const tr = wrap.querySelectorAll('tbody tr')[idx]; if (!tr) return; const r = modalState.results[idx] || { status: 'pending' }; const st = r.status; const cls = 'gy-status-' + (st === 'pending' ? 'pending' : st); const txt = ({ pending: '⏳ 排队', running: '🔄 探测中…', ok: '✅ 秒传命中', no_match: '❌ 服务端未命中', error: '⚠️ 错误' })[st] || st; const msg = r.message ? `
${escapeHtml(r.message)}` : ''; tr.querySelector('.status').innerHTML = `${txt}${msg}`; } function updateProgress(done, total, ok, fail) { const pct = Math.round(done * 100 / total); const el = modalState.modal.querySelector('#gy-summary'); el.innerHTML = ` ${done}/${total} 完成 · ✅ ${ok} 秒传 · ❌ ${fail} 未命中/错
`; } // ===================================================================== // Main action button // ===================================================================== function onStartClick() { if (!modalState) return; if (modalState.done) { closeModal(); return; } if (modalState.running) return; if (modalState.step === 'docs') { resolveDocs(); return; } startFlash(); } // ===================================================================== // Flash run // ===================================================================== async function startFlash() { if (!modalState || modalState.running || modalState.done) return; const indices = [...modalState.selected].sort((a, b) => a - b); if (!indices.length) { alert('请先勾选要秒传的条目'); return; } modalState.running = true; const startBtn = modalState.modal.querySelector('#gy-start'); startBtn.disabled = true; startBtn.textContent = '秒传中…'; modalState.results = modalState.entries.map(() => ({ status: 'pending' })); renderTable(); let done = 0, ok = 0, fail = 0; const total = indices.length; await runPool(indices, CONCURRENCY, async (idx) => { const entry = modalState.entries[idx]; modalState.results[idx] = { status: 'running' }; renderRow(idx); try { const res = await tryFlash(entry, modalState.parentId); modalState.results[idx] = { status: res.instant ? 'ok' : 'no_match', message: res.instant ? 'taskId=' + res.taskId : '服务端没这个 gcid+cid' }; if (res.instant) ok++; else fail++; } catch (e) { modalState.results[idx] = { status: 'error', message: e.message }; fail++; } done++; renderRow(idx); updateProgress(done, total, ok, fail); }, () => {}); modalState.running = false; modalState.done = true; startBtn.disabled = false; startBtn.textContent = '✅ 完成,点此关闭'; updateProgress(done, total, ok, fail); const reloadChecked = modalState.modal.querySelector('#gy-reload-after'); if (reloadChecked && reloadChecked.checked && ok > 0) { setTimeout(() => { try { location.reload(); } catch (e) {} }, 600); } } // ===================================================================== // Boot: 常驻悬浮按钮 // ===================================================================== function ensureFab() { if (document.getElementById('gy-fab')) return; const btn = document.createElement('button'); btn.id = 'gy-fab'; btn.type = 'button'; btn.title = '秒传工具'; btn.innerHTML = '⚡'; btn.addEventListener('click', openFlashUI); document.body.appendChild(btn); } function boot() { if (!document.body) { setTimeout(boot, 100); return; } ensureFab(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();