// ==UserScript== // @name 法院送达文件选择打包下载 // @namespace https://zxfw.court.gov.cn/ // @version 2.3.0 // @description 识别文书列表,选择后打包为一个 ZIP 下载 // @match https://zxfw.court.gov.cn/zxfw/* // @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js // @grant none // @noframes // @run-at document-end // @license MIT // ==/UserScript== const style = document.head.appendChild(document.createElement('style')); style.textContent = ` #cz-open{position:fixed;right:16px;bottom:80px;z-index:99999;padding:10px 16px;border:0;border-radius:8px;color:#fff;background:#1677ff} #cz-panel{position:fixed;inset:5%;z-index:100000;display:none;flex-direction:column;background:#fff;color:#222;border-radius:12px;box-shadow:0 8px 40px #0005;font-size:14px} #cz-panel header,#cz-tools,#cz-foot{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid #eee} #cz-panel header{font-size:17px;font-weight:bold}#cz-panel header button{margin-left:auto} #cz-panel button{padding:7px 12px;border:1px solid #ccc;border-radius:6px;background:#fff}#cz-panel button.primary{color:#fff;background:#1677ff;border-color:#1677ff} #cz-panel button.cancel{color:#fff;background:#d93026;border-color:#d93026} #cz-list{flex:1;overflow:auto;padding:6px 16px}#cz-list label{display:flex;gap:9px;padding:9px 2px;border-bottom:1px solid #f2f2f2}#cz-list input{flex:none} #cz-status{flex:1;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#cz-progress{width:180px}#cz-foot{border-top:1px solid #eee;border-bottom:0} `; const open = document.body.appendChild(document.createElement('button')); open.id = 'cz-open'; open.textContent = '选择打包下载'; const panel = document.body.appendChild(document.createElement('div')); panel.id = 'cz-panel'; panel.innerHTML = `
送达文书列表
尚未读取
`; const $ = s => panel.querySelector(s), list = $('#cz-list'), status = $('#cz-status'), bar = $('#cz-progress'); let files = [], job = null; const params = () => Object.fromEntries(new URLSearchParams(location.hash.split('?')[1])); const update = () => $('#cz-count').textContent = `已选 ${list.querySelectorAll('input:checked').length}/${files.length}`; const nameOf = f => `${f.c_wsmc}.${f.c_wjgs}`.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim(); const cancelled = () => Object.assign(Error('用户已取消'), { cancelled: true }); const getOnce = async (url, progress, task) => { const ctrl = new AbortController(); let timer; task.controllers.add(ctrl); const reset = () => { clearTimeout(timer); timer = setTimeout(() => ctrl.abort(), 30000); }; reset(); try { const r = await fetch(url, { signal: ctrl.signal }); if (!r.ok) throw Error(`HTTP ${r.status}`); const reader = r.body.getReader(), total = +r.headers.get('content-length') || 0, chunks = []; let loaded = 0; while (true) { const { value, done } = await reader.read(); if (done) break; chunks.push(value); loaded += value.length; reset(); progress(total ? loaded / total : 0); } return new Blob(chunks, { type: r.headers.get('content-type') || '' }); } catch (e) { if (task.cancelled) throw cancelled(); throw Error(e.name === 'AbortError' ? '30 秒无数据,读取超时' : e.message); } finally { clearTimeout(timer); task.controllers.delete(ctrl); } }; const getFile = async (url, progress, task) => { let error; for (let attempt = 0; attempt < 4; attempt++) { if (task.cancelled) throw cancelled(); try { return await getOnce(url, progress, task); } catch (e) { if (e.cancelled) throw e; error = e; } if (attempt < 3) await new Promise(r => setTimeout(r, 500 * (attempt + 1))); } throw error; }; async function loadList() { if (files.length) return; status.textContent = '正在识别文书…'; const r = await fetch('/yzw/yzw-zxfw-sdfw/api/v1/sdfw/getWsListBySdbhNew', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params()) }).then(r => r.json()); if (r.code !== 200 || !r.data?.length) throw Error(r.msg || '未找到文书'); files = r.data; list.replaceChildren(...files.map((f, i) => { const row = document.createElement('label'), box = document.createElement('input'), text = document.createElement('span'); box.type = 'checkbox'; box.checked = true; box.dataset.i = i; text.textContent = nameOf(f); row.append(box, text); return row; })); status.textContent = `已识别 ${files.length} 份文书`; update(); } open.onclick = async () => { panel.style.display = 'flex'; try { await loadList(); } catch (e) { status.textContent = '识别失败'; alert('读取失败:' + e.message); } }; $('#cz-close').onclick = () => panel.style.display = 'none'; $('#cz-all').onclick = () => { list.querySelectorAll('input').forEach(x => x.checked = true); update(); }; $('#cz-none').onclick = () => { list.querySelectorAll('input').forEach(x => x.checked = false); update(); }; $('#cz-invert').onclick = () => { list.querySelectorAll('input').forEach(x => x.checked = !x.checked); update(); }; list.onchange = update; const download = $('#cz-download'); download.onclick = async () => { if (job) { job.cancelled = true; job.controllers.forEach(x => x.abort()); download.disabled = true; download.textContent = '正在取消…'; status.textContent = '正在取消打包…'; return; } const chosen = [...list.querySelectorAll('input:checked')].map(x => files[x.dataset.i]); if (!chosen.length) return alert('请至少选择一份文书'); job = { cancelled: false, controllers: new Set() }; const task = job; download.classList.add('cancel'); download.textContent = '取消打包'; status.textContent = '整体进度 0%'; bar.value = 0; let success = 0; try { const zip = new JSZip(), rates = Array(chosen.length).fill(0), used = new Map(), failed = []; let next = 0; const names = chosen.map(f => { const name = nameOf(f), n = (used.get(name) || 0) + 1; used.set(name, n); return n === 1 ? name : name.replace(/(\.[^.]+)$/, `(重名${n})$1`); }); const show = () => { const p = rates.reduce((a, b) => a + b, 0) / chosen.length * 90; bar.value = p; status.textContent = `整体进度 ${p.toFixed(0)}%`; }; await Promise.all(Array.from({ length: Math.min(8, chosen.length) }, async () => { while (next < chosen.length) { const i = next++, f = chosen[i]; try { zip.file(names[i], await getFile(f.wjlj, p => { rates[i] = Math.max(rates[i], p); show(); }, task)); success++; } catch (e) { if (e.cancelled) throw e; failed.push(`${names[i]}:${e.message}`); } finally { rates[i] = 1; show(); } } })); if (failed.length) { status.textContent = `完成:成功 ${success},失败 ${failed.length};未生成 ZIP`; alert(`打包未完成,未生成 ZIP。\n成功:${success}\n失败:${failed.length}\n\n${failed.slice(0, 10).join('\n')}`); return; } const blob = await zip.generateAsync({ type: 'blob', compression: 'STORE' }, x => { const p = 90 + x.percent / 10; bar.value = p; status.textContent = `整体进度 ${p.toFixed(0)}%`; }); if (task.cancelled) throw cancelled(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `送达文书_${chosen.length}份.zip`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 60000); bar.value = 100; status.textContent = `完成:成功 ${success},失败 0;ZIP 下载已开始`; } catch (e) { if (e.cancelled) status.textContent = `已取消:已读取 ${success},未完成 ${chosen.length - success}`; else { status.textContent = '打包失败'; alert('打包失败:' + e.message); console.error(e); } } finally { job = null; download.disabled = false; download.classList.remove('cancel'); download.textContent = '打包下载选中项'; } };