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