// ==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 = '打包下载选中项';
}
};