// ==UserScript== // @name 国自然结题报告下载助手 // @namespace nsfc-report-pdf // @version 2.0.3 // @description 在国家自然科学基金官网查看结题报告时,本人完成滑块验证后,一键自动翻页,把整份报告保存为一个 PDF。仅供个人学习查阅。 // @license MIT // @tag 国自然 // @tag 结题报告 // @tag PDF // @tag 科研工具 // @author researchsheep // @copyright 2026, researchsheep // @match https://kd.nsfc.cn/finalDetails* // @require https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js#sha256=85ba2cc3ff858a20fa49fe6e457bec863ea40b55a9f3725e58a940e62f6f61a4 // @grant GM_xmlhttpRequest // @connect kd.nsfc.cn // @run-at document-start // ==/UserScript== (function () { 'use strict'; const API = location.origin + '/api'; const MAX_PAGES = 500; const DELAY_MS = 1000; const BUSY_WAITS = [5, 15, 30]; const CAP_ATTR = 'data-nsfc-report-req'; const hook = `(${function (ATTR) { const XP = XMLHttpRequest.prototype; const oOpen = XP.open, oSet = XP.setRequestHeader, oSend = XP.send; XP.open = function (m, u) { this.__nsfc = { url: String(u), headers: {} }; return oOpen.apply(this, arguments); }; XP.setRequestHeader = function (k, v) { if (this.__nsfc) this.__nsfc.headers[k] = v; return oSet.apply(this, arguments); }; XP.send = function (body) { const info = this.__nsfc; if (info && info.url.indexOf('completeProjectReport') !== -1) { info.body = typeof body === 'string' ? body : ''; this.addEventListener('load', () => { const root = document.documentElement; const p = new URLSearchParams(info.body); let j = null; try { j = JSON.parse(this.responseText); } catch (e) { } const ok = !!(j && j.code === 200 && j.data && j.data.url); const log = JSON.parse(root.getAttribute(ATTR + '-log') || '[]'); log.push({ t: new Date().toTimeString().slice(0, 8), index: p.get('index'), tryCode: p.has('tryCode'), code: j ? j.code : this.status, msg: j && !ok ? j.message : '' }); root.setAttribute(ATTR + '-log', JSON.stringify(log.slice(-15))); if (!ok) return; root.setAttribute(ATTR, JSON.stringify(info)); const pages = JSON.parse(root.getAttribute(ATTR + '-pages') || '{}'); if (p.get('id')) { pages[p.get('id') + ':' + p.get('index')] = j.data.url; } root.setAttribute(ATTR + '-pages', JSON.stringify(pages)); }); } return oSend.apply(this, arguments); }; document.addEventListener('nsfc-pdf-req', e => { const { seq, url, headers, body } = JSON.parse(e.detail); const x = new XMLHttpRequest(); const reply = (status, text) => document.dispatchEvent(new CustomEvent('nsfc-pdf-res', { detail: JSON.stringify({ seq, status, text }) })); oOpen.call(x, 'POST', url); for (const k in headers) oSet.call(x, k, headers[k]); x.onload = () => reply(x.status, x.responseText); x.onerror = () => reply(0, ''); oSend.call(x, body); }); }})(${JSON.stringify(CAP_ATTR)});`; const s = document.createElement('script'); s.textContent = hook; (document.head || document.documentElement).appendChild(s); s.remove(); const captured = () => JSON.parse(document.documentElement.getAttribute(CAP_ATTR) || 'null'); let reqSeq = 0; function pageXhr(url, headers, body) { const seq = ++reqSeq; return new Promise((resolve, reject) => { const timer = setTimeout(() => { document.removeEventListener('nsfc-pdf-res', on); reject(new Error('官网请求超时')); }, 60000); function on(e) { const r = JSON.parse(e.detail); if (r.seq !== seq) return; clearTimeout(timer); document.removeEventListener('nsfc-pdf-res', on); try { resolve(JSON.parse(r.text)); } catch (err) { const e2 = new Error('官网返回异常 HTTP ' + r.status); e2.status = r.status; reject(e2); } } document.addEventListener('nsfc-pdf-res', on); document.dispatchEvent(new CustomEvent('nsfc-pdf-req', { detail: JSON.stringify({ seq, url, headers, body }) })); }); } const sleep = ms => new Promise(r => setTimeout(r, ms)); const projectId = () => new URLSearchParams(location.search).get('id'); async function nsfcPost(path, params, headers) { const r = await fetch(API + path, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', ...(headers || {}) }, body: new URLSearchParams(params).toString(), }); return r.json(); } function reportPage(index) { const cap = captured(); const params = new URLSearchParams(cap.body); params.delete('tryCode'); params.set('index', String(index)); return pageXhr(cap.url, cap.headers, params.toString()); } function gmRequest(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ ...opts, onload: resolve, onerror: reject, ontimeout: () => reject(new Error('超时')) }); }); } async function fetchImage(url) { const res = await gmRequest({ method: 'GET', url: new URL(url, location.origin).href, responseType: 'arraybuffer', timeout: 60000 }); if (res.status !== 200) { const e = new Error('图片下载失败 HTTP ' + res.status); e.status = res.status; throw e; } const buf = new Uint8Array(res.response); const fmt = buf[0] === 0x89 && buf[1] === 0x50 ? 'PNG' : (buf[0] === 0xff && buf[1] === 0xd8 ? 'JPEG' : null); const blob = new Blob([buf], { type: fmt === 'PNG' ? 'image/png' : 'image/jpeg' }); const img = await new Promise((ok, bad) => { const i = new Image(); i.onload = () => ok(i); i.onerror = () => bad(new Error('图片无法解析')); i.src = URL.createObjectURL(blob); }); if (fmt) return { data: buf, fmt, w: img.naturalWidth, h: img.naturalHeight }; const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight; c.getContext('2d').drawImage(img, 0, 0); return { data: c.toDataURL('image/jpeg', 0.92), fmt: 'JPEG', w: c.width, h: c.height }; } const box = document.createElement('div'); box.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:99999;background:#fff;border:1px solid #d0d7de;' + 'border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,.15);padding:12px 14px;width:270px;font:13px/1.6 system-ui,"Microsoft YaHei",sans-serif;color:#1f2328'; box.innerHTML = '
结题报告 → PDF
' + '
先点官网的「查看报告」并完成滑块验证,再点下面的按钮。
' + '' + '
'; const mount = () => document.body.appendChild(box); if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount); const $ = k => box.querySelector(`[data-k="${k}"]`); const status = (t, color) => { $('st').textContent = t; $('st').style.color = color || '#1f2328'; }; async function run() { const id = projectId(); if (!id) return status('地址里没有项目 id', '#b91c1c'); let title = id; try { const info = await nsfcPost('/baseQuery/conclusionProjectInfo/' + id, {}); if (info.code === 200 && info.data) title = `${info.data.ratifyNo}_${info.data.projectName}`; } catch (e) { } title = title.replace(/[\\/:*?"<>|]/g, '_'); const { jsPDF } = window.jspdf; let doc = null, pages = 0, lastUrl = null; const cap = captured(); if (!cap) { return status('还没看到官网加载报告:请先点官网的「查看报告」并拖动滑块,看到报告第一页后再点我。\n' + '(如果刚更新过脚本,请先刷新页面再验证一次)', '#b91c1c'); } if (new URLSearchParams(cap.body).get('id') !== id) { return status('记录到的是别的项目的报告请求,请刷新页面后重新验证。', '#b91c1c'); } const known = JSON.parse(document.documentElement.getAttribute(CAP_ATTR + '-pages') || '{}'); const officialLog = () => JSON.parse(document.documentElement.getAttribute(CAP_ATTR + '-log') || '[]') .map(r => `${r.t} 第${r.index}页 ${r.tryCode ? '带凭证' : '不带凭证'} → ${r.code}${r.msg ? ' ' + r.msg : ''}`).join('\n'); let refused = null, busy = null; const missing = []; async function getUrl(index) { const k = id + ':' + index; if (known[k]) return { url: known[k] }; let j; for (let attempt = 0; ; attempt++) { try { j = await reportPage(index); break; } catch (e) { if (![0, 429, 502, 503, 504].includes(e.status) || attempt >= BUSY_WAITS.length) { if ([0, 429, 502, 503, 504].includes(e.status)) return { busy: e.message }; throw e; } for (let t = BUSY_WAITS[attempt]; t > 0; t--) { status(`官网繁忙(${e.message}),${t} 秒后重试第 ${index} 页…`); await sleep(1000); } } } await sleep(DELAY_MS); if (j.code !== 200 || !j.data || !j.data.url) { const msg = j.message || ('code=' + j.code); return /验证/.test(msg) ? { refused: msg } : { end: true }; } known[k] = j.data.url; return { url: j.data.url }; } async function loadImage(url) { for (let attempt = 0; ; attempt++) { try { return await fetchImage(url); } catch (e) { if (e.status === 404) { if (attempt === 0) { await sleep(1000); continue; } return null; } if (attempt >= BUSY_WAITS.length) throw e; for (let t = BUSY_WAITS[attempt]; t > 0; t--) { status(`图片服务器繁忙(${e.message}),${t} 秒后重试…`); await sleep(1000); } } } } for (let index = 1; index <= MAX_PAGES; index++) { status(`正在获取第 ${index} 页…`); const r = await getUrl(index); if (r.busy) { busy = { index, msg: r.busy }; break; } if (r.refused) { refused = { index, msg: r.refused }; break; } if (r.end || r.url === lastUrl) break; lastUrl = r.url; const im = await loadImage(r.url); if (!im) { status(`第 ${index} 页图片取不到,检查后面是否还有…`); let more = false; for (let k = 1; k <= 2 && !more; k++) { const r2 = await getUrl(index + k); if (r2.busy) { busy = { index: index + k, msg: r2.busy }; break; } if (r2.refused) { refused = { index: index + k, msg: r2.refused }; break; } if (r2.url && await loadImage(r2.url)) more = true; } if (busy || refused || !more) break; missing.push(index); continue; } const orient = im.w > im.h ? 'l' : 'p'; if (!doc) doc = new jsPDF({ unit: 'px', format: [im.w, im.h], orientation: orient, compress: true, hotfixes: ['px_scaling'] }); else doc.addPage([im.w, im.h], orient); doc.addImage(im.data, im.fmt, 0, 0, im.w, im.h, undefined, 'FAST'); pages++; } const missNote = missing.length ? `\n注意:第 ${missing.join('、')} 页图片取不到,已跳过。` : ''; if (busy) { return status(`官网一直繁忙(${busy.msg}),停在第 ${busy.index} 页(已取到 ${pages} 页,未生成 PDF)。 ` + '可能是短时间内请求太多被限流了,请过几分钟再试。', '#b91c1c'); } if (refused) { return status(`官网在第 ${refused.index} 页拒绝:${refused.msg}(已取到 ${pages} 页,未生成 PDF)\n` + `官网自己的报告请求记录:\n${officialLog() || '(无)'}`, '#b91c1c'); } if (!pages) return status('一页也没取到。\n官网请求记录:\n' + (officialLog() || '(无)'), '#b91c1c'); status(`共 ${pages} 页,正在生成 PDF…`); doc.setProperties({ title }); doc.save(title + '.pdf'); status(`完成:${pages} 页,已保存到浏览器的下载文件夹。${missNote}`, missing.length ? '#92400e' : '#15803d'); } $('go').addEventListener('click', async () => { $('go').disabled = true; $('go').style.opacity = '.6'; try { await run(); } catch (e) { status('出错:' + (e && e.message || e), '#b91c1c'); } $('go').disabled = false; $('go').style.opacity = '1'; }); })();