Greasy Fork is available in English.
知网文献、硕博论文PDF批量下载 (中英双语,智能驻守,自动核对)
// ==UserScript==
// @name CNKI Batch Downloader (Bilingual)
// @name:zh-CN 知网CNKI论文PDF批量下载-双语版
// @namespace http://greasyfork.icu/zh-CN/users/236397-hust-hzb
// @version 1.0
// @icon https://www.cnki.net/favicon.ico
// @description Batch download CNKI papers/theses PDF (Bilingual, Smart monitoring, Auto verification)
// @description:zh-CN 知网文献、硕博论文PDF批量下载 (中英双语,智能驻守,自动核对)
// @author HUST HuangZhenbin
// @license MIT
// @match *://*.cnki.net/*
// @run-at document-idle
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_download
// @grant GM_registerMenuCommand
// ==/UserScript==
(function() {
'use strict';
// --- 配置与状态 ---
let useWebVPN = GM_getValue('useWebVPN', false);
// 默认语言检测:如果浏览器语言包含 'zh' 则为 'zh',否则为 'en'
const defaultLang = navigator.language.includes('zh') ? 'zh' : 'en';
let currentLang = GM_getValue('cnki_lang', defaultLang);
const DEFAULT_MIN_DELAY = 5000;
const DEFAULT_MAX_DELAY = 10000;
const DEFAULT_FOLDER = "CNKI_Downloads";
let isRunning = false;
let lastCheckedIndex = null;
// --- 国际化文本字典 ---
const i18n = {
zh: {
title: "📚 CNKI 批量下载助手",
version: "v1.0",
close: "关闭",
guide_title: "使用前请务必检查以下配置,否则无法自动下载:",
guide_browser: "<b>浏览器设置:</b>请关闭“下载前询问每个文件的保存位置”(设置 -> 下载 -> 询问保存位置 -> 关)。",
guide_tamper: "<b>油猴权限:</b>请允许 Tampermonkey 扩展访问“管理下载”权限(扩展管理 -> 详情 -> 允许访问文件URL/下载)。",
guide_overwrite: "<b>文件去重:</b>下载时若本地存在同名文件,脚本将直接<b>替换覆盖</b>。",
mask_title: "任务已暂停:需要人工验证",
mask_desc: "检测到知网验证码拦截。<br>已为您自动打开验证窗口,请在<strong>新窗口中手动点击下载并完成滑块验证</strong>。<br>验证成功且开始下载后,关闭那个窗口,回来点击下方按钮。",
btn_resume: "✅ 我已解除,继续下载",
btn_stop_task: "⏹ 停止任务",
report_title: "📊 下载结果核对报告",
report_retry: "🔄 重试失败项目",
report_close: "关闭报告",
label_folder: "📂 归档文件夹:",
label_vpn: "开启 WebVPN 模式",
btn_scan: "🔍 1. 扫描当前页",
btn_start: "▶ 2. 开始下载选中",
btn_verify: "🛠️ 仅打开验证页",
btn_clear: "🗑 清空列表",
btn_reset_history: "🧹 清除历史记录",
tip_shift: "💡 <b>提示:</b> 按住 <b>Shift</b> 键点击复选框可进行批量多选。文件名纯净,同名文件将自动覆盖。",
th_check: "选",
th_no: "No.",
th_title: "标题",
th_author: "作者/来源",
th_status: "状态",
status_wait: "待下载",
status_done: "✔ 完成",
status_error: "✘ 失败",
status_pay: "💰 需付费",
status_nopdf: "⚪ 无PDF",
status_exists: "🔁 已存在",
status_running: "⟳ 解析中...",
status_downloading: "⬇ 下载中...",
status_skip: "⚠ 跳过",
status_ready: "等待操作...",
status_stopped: "🚫 任务已手动停止",
status_scanned: "扫描完成,新增 {new} 条,共 {total} 条。",
status_total: "列表共 {total} 条文献",
status_finished: "✅ 批量任务完成",
alert_no_item: "未找到文献,请确保在搜索结果页",
alert_no_check: "请先勾选需要下载的文献",
alert_history_clear: "确定要清除所有下载历史记录吗?\n这将导致脚本不再跳过同名文件。",
alert_history_done: "历史记录已清除。",
report_success: "成功",
report_exists: "已存在",
report_pay: "需付费",
report_nopdf: "无PDF",
report_fail: "失败",
report_msg_check: "请检查以下失败项目:",
report_msg_none: "🎉 没有下载失败的项目",
main_btn: "CNKI批量导出",
err_captcha: "触发验证码",
err_no_auth: "无权限/收费",
err_download_fail: "下载失败",
cool_down: "冷却"
},
en: {
title: "📚 CNKI Batch Downloader",
version: "v1.0",
close: "Close",
guide_title: "Please check the following configurations before use:",
guide_browser: "<b>Browser:</b> Disable 'Ask where to save each file before downloading' (Settings -> Downloads).",
guide_tamper: "<b>Tampermonkey:</b> Allow 'Manage Downloads' permission (Extension Management -> Details).",
guide_overwrite: "<b>Overwrite:</b> If a file with the same name exists locally, it will be <b>overwritten</b>.",
mask_title: "Task Paused: Manual Verification Required",
mask_desc: "CNKI CAPTCHA detected.<br>A verification window has been opened. Please <strong>manually click download and solve the slider</strong> in the new window.<br>After success, close that window and click the button below.",
btn_resume: "✅ I've Solved it, Continue",
btn_stop_task: "⏹ Stop Task",
report_title: "📊 Download Result Report",
report_retry: "🔄 Retry Failed Items",
report_close: "Close Report",
label_folder: "📂 Folder:",
label_vpn: "Enable WebVPN Mode",
btn_scan: "🔍 1. Scan Page",
btn_start: "▶ 2. Start Download",
btn_verify: "🛠️ Open Verify Page",
btn_clear: "🗑 Clear List",
btn_reset_history: "🧹 Reset History",
tip_shift: "💡 <b>Tip:</b> Hold <b>Shift</b> to select multiple items. Filenames are clean and will overwrite duplicates.",
th_check: "chk",
th_no: "No.",
th_title: "Title",
th_author: "Author/Source",
th_status: "Status",
status_wait: "Waiting",
status_done: "✔ Done",
status_error: "✘ Failed",
status_pay: "💰 Pay Req",
status_nopdf: "⚪ No PDF",
status_exists: "🔁 Exists",
status_running: "⟳ Parsing...",
status_downloading: "⬇ Downloading...",
status_skip: "⚠ Skipped",
status_ready: "Ready...",
status_stopped: "🚫 Task Stopped",
status_scanned: "Scanned, added {new}, total {total}.",
status_total: "Total {total} items",
status_finished: "✅ Batch Task Completed",
alert_no_item: "No papers found. Please use on search result page.",
alert_no_check: "Please select items first.",
alert_history_clear: "Are you sure to clear download history?\nThis will cause re-downloading of existing files.",
alert_history_done: "History cleared.",
report_success: "Success",
report_exists: "Exists",
report_pay: "Pay Req",
report_nopdf: "No PDF",
report_fail: "Failed",
report_msg_check: "Please check failed items:",
report_msg_none: "🎉 No failed items",
main_btn: "CNKI Export",
err_captcha: "Captcha Triggered",
err_no_auth: "No Auth/Paid",
err_download_fail: "Download Failed",
cool_down: "Cooldown"
}
};
function t(key) {
return i18n[currentLang][key] || key;
}
function toggleLang() {
currentLang = currentLang === 'zh' ? 'en' : 'zh';
GM_setValue('cnki_lang', currentLang);
// 移除旧界面重新渲染
const overlay = document.getElementById('cnki-overlay');
if (overlay) overlay.remove();
openDashboard();
}
// --- CSS ---
function injectStyle() {
if (document.getElementById('cnki-style')) return;
const style = document.createElement('style');
style.id = 'cnki-style';
style.textContent = `
.cnki-ui-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); z-index: 99999; display: flex; justify-content: center; align-items: center; }
.cnki-ui-modal { background: #fff; width: 950px; height: 90vh; border-radius: 12px; box-shadow: 0 15px 40px rgba(0,0,0,0.25); display: flex; flex-direction: column; overflow: hidden; font-family: "Microsoft YaHei", sans-serif; animation: fadeIn 0.3s ease; position: relative;}
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
.cnki-ui-header { padding: 15px 25px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; background: #f8f9fa; }
.cnki-ui-title { font-size: 18px; font-weight: bold; color: #333; display: flex; align-items: center; gap: 8px; }
.cnki-ui-close { cursor: pointer; border: none; background: none; font-size: 24px; color: #999; transition: color 0.2s; }
.cnki-ui-close:hover { color: #333; }
.cnki-lang-btn { font-size: 12px; background: #e0f2fe; color: #0284c7; border: 1px solid #bae6fd; padding: 2px 8px; border-radius: 4px; cursor: pointer; margin-right: 10px; }
.cnki-config-guide { background: #fff1f2; border-bottom: 1px solid #fecdd3; padding: 12px 25px; font-size: 13px; color: #881337; line-height: 1.6; display: flex; gap: 10px; align-items: flex-start; }
.cnki-guide-icon { font-size: 18px; }
.cnki-guide-list { margin: 0; padding-left: 20px; }
.cnki-ui-toolbar { padding: 15px 25px; border-bottom: 1px solid #eee; background: #fff; display: flex; flex-direction: column; gap: 12px; }
.cnki-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.cnki-ui-btn { padding: 8px 16px; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s; display: flex; align-items: center; gap: 5px; }
.cnki-ui-btn:hover { background: #f3f4f6; transform: translateY(-1px); }
.cnki-ui-btn:active { transform: translateY(0); }
.cnki-btn-primary { background: #3b82f6; color: #fff; border-color: #3b82f6; box-shadow: 0 2px 5px rgba(59,130,246,0.3); }
.cnki-btn-primary:hover { background: #2563eb; }
.cnki-btn-warn { background: #f59e0b; color: #fff; border-color: #f59e0b; }
.cnki-btn-warn:hover { background: #d97706; }
.cnki-btn-danger { background: #ef4444; color: #fff; border-color: #ef4444; }
.cnki-btn-danger:hover { background: #dc2626; }
.cnki-btn-info { background: #0ea5e9; color: #fff; border-color: #0ea5e9; }
.cnki-btn-info:hover { background: #0284c7; }
.cnki-input-group { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #555; background: #f9fafb; padding: 5px 10px; border-radius: 6px; border: 1px solid #e5e7eb; }
.cnki-input { padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; width: 160px; font-size: 13px; }
.cnki-input:focus { border-color: #3b82f6; outline: none; }
.cnki-table-wrap { flex: 1; overflow-y: auto; padding: 0; background: #fdfdfd; }
.cnki-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.cnki-table th { position: sticky; top: 0; background: #f1f5f9; padding: 12px 15px; text-align: left; color: #475569; font-weight: 600; border-bottom: 1px solid #e2e8f0; z-index: 10; }
.cnki-table td { padding: 10px 15px; border-bottom: 1px solid #f1f5f9; color: #334155; vertical-align: middle; }
.cnki-table tr:hover { background: #f8fafc; }
.cnki-row-selected { background: #eff6ff !important; }
.cnki-footer { padding: 10px 25px; border-top: 1px solid #eee; background: #f8f9fa; font-size: 12px; color: #666; display: flex; justify-content: space-between; align-items: center; }
.cnki-orcid-link { color: #a3d014; text-decoration: none; display: flex; align-items: center; gap: 5px; font-weight: bold; }
.cnki-orcid-link:hover { text-decoration: underline; }
.cnki-pause-mask { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.95); z-index: 20; display: none; flex-direction: column; justify-content: center; align-items: center; gap: 20px; }
.cnki-pause-box { background: white; padding: 30px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); border: 1px solid #eee; text-align: center; max-width: 450px; }
/* 结果报告遮罩 */
.cnki-report-mask { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 30; display: none; justify-content: center; align-items: center; }
.cnki-report-box { background: white; width: 650px; max-height: 85%; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.3); animation: fadeIn 0.2s ease; }
.cnki-report-header { padding: 20px; background: #f0fdf4; border-bottom: 1px solid #dcfce7; }
.cnki-report-header.has-error { background: #fef2f2; border-bottom: 1px solid #fee2e2; }
.cnki-report-list { flex: 1; overflow-y: auto; padding: 20px; }
.cnki-report-item { padding: 10px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; font-size: 13px; align-items: center; }
.cnki-report-item:last-child { border-bottom: none; }
.cnki-report-status-fail { color: #dc2626; font-weight: bold; background: #fef2f2; padding: 2px 6px; border-radius: 4px; font-size: 12px;}
.cnki-report-status-pay { color: #b45309; font-weight: bold; background: #fffbeb; padding: 2px 6px; border-radius: 4px; font-size: 12px;}
.cnki-report-status-nopdf { color: #6b7280; font-weight: bold; background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 12px;}
.cnki-report-btn { padding: 15px; text-align: right; border-top: 1px solid #eee; background: #fff; display: flex; justify-content: flex-end; gap: 10px;}
/* 主按钮 */
.cnki-main-btn { position: fixed; bottom: 60px; right: 40px; padding: 12px 20px; border-radius: 50px; background: #3b82f6; color: white; border: none; box-shadow: 0 4px 15px rgba(59,130,246,0.4); cursor: pointer; z-index: 2147483647 !important; display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: bold; transition: all 0.2s; }
.cnki-main-btn:hover { transform: translateY(-2px); background: #2563eb; }
.cnki-status-wait { color: #94a3b8; }
.cnki-status-run { color: #3b82f6; font-weight: bold; }
.cnki-status-ok { color: #16a34a; font-weight: bold; }
.cnki-status-err { color: #ef4444; font-weight: bold; }
.cnki-status-pay { color: #f59e0b; font-weight: bold; }
.cnki-status-nopdf { color: #6b7280; font-weight: bold; }
.cnki-status-exists { color: #9ca3af; font-weight: bold; }
`;
document.head.appendChild(style);
}
// --- 核心入口:智能驻守 (轮询检测) ---
function tryCreateButton() {
if (document.getElementById('cnki-main-btn')) return;
// 只要是知网搜索页就显示
const currentURL = window.location.href;
if (currentURL.includes('defaultresult') || currentURL.includes('advsearch') || currentURL.includes('search') || currentURL.includes('kns8s')) {
const btn = document.createElement('button');
btn.id = 'cnki-main-btn';
btn.className = 'cnki-main-btn';
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> ${t('main_btn')}`;
btn.title = t('title');
btn.onclick = openDashboard;
document.body.appendChild(btn);
}
}
// 使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver(() => {
tryCreateButton();
});
function startObserver() {
const targetNode = document.body;
if(targetNode) {
observer.observe(targetNode, { childList: true, subtree: true });
} else {
setTimeout(startObserver, 500);
}
}
const url = window.location.href.toLowerCase();
function openDashboard() {
if (document.getElementById('cnki-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'cnki-overlay';
overlay.className = 'cnki-ui-overlay';
overlay.innerHTML = `
<div class="cnki-ui-modal">
<div class="cnki-ui-header">
<div class="cnki-ui-title">
${t('title')}
<span style="font-size:12px;font-weight:normal;color:#666;background:#f3f4f6;padding:2px 6px;border-radius:4px">${t('version')}</span>
</div>
<div>
<button class="cnki-lang-btn" id="cnki-lang-toggle">${currentLang === 'zh' ? '中 / En' : 'En / 中'}</button>
<button class="cnki-ui-close" id="cnki-close">×</button>
</div>
</div>
<div class="cnki-config-guide">
<div class="cnki-guide-icon">⚠️</div>
<div>
<div style="font-weight:bold;margin-bottom:5px">${t('guide_title')}</div>
<ul class="cnki-guide-list">
<li>${t('guide_browser')}</li>
<li>${t('guide_tamper')}</li>
<li>${t('guide_overwrite')}</li>
</ul>
</div>
</div>
<div class="cnki-pause-mask" id="cnki-pause-mask">
<div class="cnki-pause-box">
<div style="font-size:48px;margin-bottom:10px">🛡️</div>
<h3 style="margin:0 0 10px 0;color:#333">${t('mask_title')}</h3>
<p style="color:#666;font-size:13px;margin-bottom:20px;line-height:1.6">
${t('mask_desc')}
</p>
<div style="display:flex;gap:10px;justify-content:center">
<button class="cnki-ui-btn cnki-btn-primary" id="cnki-resume">${t('btn_resume')}</button>
<button class="cnki-ui-btn" id="cnki-stop-pause">${t('btn_stop_task')}</button>
</div>
</div>
</div>
<div class="cnki-report-mask" id="cnki-report-mask">
<div class="cnki-report-box">
<div class="cnki-report-header" id="cnki-report-header">
<h3 style="margin:0;font-size:18px">${t('report_title')}</h3>
<div id="cnki-report-summary" style="margin-top:10px;font-size:14px;line-height:1.6"></div>
</div>
<div class="cnki-report-list" id="cnki-report-list"></div>
<div class="cnki-report-btn">
<button class="cnki-ui-btn cnki-btn-primary" id="cnki-report-retry" style="display:none">${t('report_retry')}</button>
<button class="cnki-ui-btn" id="cnki-report-close">${t('report_close')}</button>
</div>
</div>
</div>
<div class="cnki-ui-toolbar">
<div class="cnki-row">
<div class="cnki-input-group">
<span>${t('label_folder')}</span>
<input type="text" id="cnki-folder" class="cnki-input" value="${GM_getValue('savedFolder', DEFAULT_FOLDER)}" placeholder="CNKI_Downloads">
</div>
<div class="cnki-input-group">
<label style="cursor:pointer;display:flex;align-items:center;gap:5px"><input type="checkbox" id="cnki-webvpn" ${useWebVPN?'checked':''}> ${t('label_vpn')}</label>
</div>
<button class="cnki-ui-btn cnki-btn-info" id="cnki-reset-history" title="">${t('btn_reset_history')}</button>
</div>
<div class="cnki-row">
<button class="cnki-ui-btn cnki-btn-primary" id="cnki-scan">${t('btn_scan')}</button>
<button class="cnki-ui-btn cnki-btn-primary" id="cnki-start">${t('btn_start')}</button>
<button class="cnki-ui-btn cnki-btn-danger" id="cnki-stop" style="display:none">${t('btn_stop_task')}</button>
<button class="cnki-ui-btn cnki-btn-warn" id="cnki-verify">${t('btn_verify')}</button>
<button class="cnki-ui-btn" id="cnki-clear">${t('btn_clear')}</button>
</div>
<div style="font-size:12px;color:#666;margin-top:5px">
${t('tip_shift')}
</div>
</div>
<div class="cnki-table-wrap">
<table class="cnki-table">
<thead>
<tr>
<th style="width:40px"><input type="checkbox" id="cnki-check-all"></th>
<th style="width:50px">${t('th_no')}</th>
<th>${t('th_title')}</th>
<th style="width:150px">${t('th_author')}</th>
<th style="width:140px">${t('th_status')}</th>
</tr>
</thead>
<tbody id="cnki-tbody"></tbody>
</table>
</div>
<div class="cnki-footer">
<span id="cnki-status-text">${t('status_ready')}</span>
<div style="display:flex;gap:15px;align-items:center">
<span style="color:#999">Author: HuangZhenbin</span>
<a href="https://orcid.org/0000-0002-0628-0387" target="_blank" class="cnki-orcid-link">
<svg viewBox="0 0 256 256" width="16" height="16" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path fill="#A6CE39" d="M256 128c0 70.7-57.3 128-128 128S0 198.7 0 128 57.3 0 128 0s128 57.3 128 128z"/><path fill="#FFF" d="M86.3 186.2H70.9V79.1h15.4v107.1zM78.6 61.6c-5.8 0-10.5-4.7-10.5-10.5s4.7-10.5 10.5-10.5 10.5 4.7 10.5 10.5-4.7 10.5-10.5 10.5zM127 186.2H111.6V79.1h15.4v12.9c3.3-5.9 10.6-15.5 28.1-15.5 22.3 0 35.8 13.9 35.8 45.4v64.3h-15.4v-61.6c0-15.3-6.1-24.9-19.4-24.9-13.9 0-21.1 10.3-21.1 27.9v58.6z"/></svg>
ORCID
</a>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById('cnki-close').onclick = () => overlay.remove();
document.getElementById('cnki-lang-toggle').onclick = toggleLang;
document.getElementById('cnki-scan').onclick = scanPage;
document.getElementById('cnki-start').onclick = () => startBatchDownload(false);
document.getElementById('cnki-stop').onclick = stopDownload;
document.getElementById('cnki-clear').onclick = clearTable;
document.getElementById('cnki-report-close').onclick = () => document.getElementById('cnki-report-mask').style.display = 'none';
document.getElementById('cnki-report-retry').onclick = () => {
document.getElementById('cnki-report-mask').style.display = 'none';
startBatchDownload(true);
};
document.getElementById('cnki-verify').onclick = () => openVerificationWindow(null);
document.getElementById('cnki-reset-history').onclick = () => {
if(confirm(t('alert_history_clear'))) {
GM_setValue('cnki_dl_history', []);
alert(t('alert_history_done'));
}
};
document.getElementById('cnki-check-all').onclick = (e) => {
document.querySelectorAll('.cnki-item-check').forEach(cb => {
cb.checked = e.target.checked;
toggleRowHighlight(cb);
});
};
document.getElementById('cnki-webvpn').onchange = (e) => {
useWebVPN = e.target.checked;
GM_setValue('useWebVPN', useWebVPN);
};
document.getElementById('cnki-folder').onchange = (e) => {
GM_setValue('savedFolder', e.target.value.trim());
};
renderTable();
}
function toggleRowHighlight(checkbox) {
const tr = checkbox.closest('tr');
if(checkbox.checked) tr.classList.add('cnki-row-selected');
else tr.classList.remove('cnki-row-selected');
}
// --- 核心功能 ---
function openVerificationWindow(targetUrl) {
let url = targetUrl;
if (!url) {
const data = JSON.parse(sessionStorage.getItem('cnki_data') || '[]');
if (data.length > 0) url = data[0].detailUrl;
}
if (!url) {
alert(t('alert_no_item'));
return;
}
window.open(url, '_blank', 'width=1024,height=768');
}
function waitForUserVerification(url) {
return new Promise((resolve) => {
openVerificationWindow(url);
const mask = document.getElementById('cnki-pause-mask');
const resumeBtn = document.getElementById('cnki-resume');
const stopBtn = document.getElementById('cnki-stop-pause');
mask.style.display = 'flex';
const onResume = () => { mask.style.display = 'none'; cleanup(); resolve(true); };
const onStop = () => { mask.style.display = 'none'; cleanup(); resolve(false); };
const cleanup = () => { resumeBtn.removeEventListener('click', onResume); stopBtn.removeEventListener('click', onStop); };
resumeBtn.addEventListener('click', onResume);
stopBtn.addEventListener('click', onStop);
});
}
// 扫描页面
function scanPage() {
const rows = Array.from(document.querySelectorAll('tbody tr, .list-item')).filter(row => {
return row.style.display !== 'none' && row.innerText.trim() !== '';
});
if(rows.length === 0) {
alert(t('alert_no_item'));
return;
}
const currentData = JSON.parse(sessionStorage.getItem('cnki_data') || '[]');
let newCount = 0;
rows.forEach((row, index) => {
const link = row.querySelector('.fz14, .name a, .wx-tit h1');
if (!link) return;
let detailUrl = link.href;
if (!detailUrl || detailUrl.includes('javascript')) return;
if(useWebVPN) {
const origin = window.location.origin;
detailUrl = origin + detailUrl.replace(/^(https?:\/\/)?(www\.)?[^\/]+/, '');
}
const title = link.textContent.trim();
const author = row.querySelector('.author')?.textContent.trim() || '-';
const source = row.querySelector('.source')?.textContent.trim() || '-';
if(!currentData.find(d => d.detailUrl === detailUrl)) {
currentData.push({ id: Date.now() + index, title, author, source, detailUrl, status: 'wait', errorMsg: '' });
newCount++;
}
});
sessionStorage.setItem('cnki_data', JSON.stringify(currentData));
renderTable();
updateStatusText(t('status_scanned').replace('{new}', newCount).replace('{total}', currentData.length));
}
function renderTable() {
const tbody = document.getElementById('cnki-tbody');
if(!tbody) return;
tbody.innerHTML = '';
const data = JSON.parse(sessionStorage.getItem('cnki_data') || '[]');
data.forEach((item, idx) => {
const tr = document.createElement('tr');
let statusHtml = `<span class="cnki-status-wait">${t('status_wait')}</span>`;
if(item.status === 'done') statusHtml = `<span class="cnki-status-ok">${t('status_done')}</span>`;
if(item.status === 'error') statusHtml = `<span class="cnki-status-err">✘ ${item.errorMsg || t('status_error')}</span>`;
if(item.status === 'pay') statusHtml = `<span class="cnki-status-pay">${t('status_pay')}</span>`;
if(item.status === 'no_pdf') statusHtml = `<span class="cnki-status-nopdf">${t('status_nopdf')}</span>`;
if(item.status === 'exists') statusHtml = `<span class="cnki-status-exists">${t('status_exists')}</span>`;
if(item.status === 'running') statusHtml = `<span class="cnki-status-run">${t('status_running')}</span>`;
if(item.status === 'downloading') statusHtml = `<span class="cnki-status-run">${t('status_downloading')}</span>`;
tr.innerHTML = `
<td><input type="checkbox" class="cnki-item-check" value="${item.id}" ${item.status==='done' || item.status==='exists'?'':'checked'}></td>
<td>${idx + 1}</td>
<td><a href="${item.detailUrl}" target="_blank" style="text-decoration:none;color:#333;font-weight:bold" title="${item.title}">${item.title}</a></td>
<td><div style="font-size:12px;color:#666">${item.author}</div><div style="font-size:12px;color:#999">${item.source}</div></td>
<td id="status-${item.id}">${statusHtml}</td>
`;
tbody.appendChild(tr);
const checkbox = tr.querySelector('.cnki-item-check');
checkbox.addEventListener('click', (e) => {
toggleRowHighlight(checkbox);
if (e.shiftKey && lastCheckedIndex !== null) {
const checks = Array.from(document.querySelectorAll('.cnki-item-check'));
const start = Math.min(idx, lastCheckedIndex);
const end = Math.max(idx, lastCheckedIndex);
for (let i = start; i <= end; i++) {
checks[i].checked = checkbox.checked;
toggleRowHighlight(checks[i]);
}
}
lastCheckedIndex = idx;
});
if(checkbox.checked) toggleRowHighlight(checkbox);
});
updateStatusText(t('status_total').replace('{total}', data.length));
}
function clearTable() { sessionStorage.removeItem('cnki_data'); renderTable(); }
function updateStatusText(text) { const el = document.getElementById('cnki-status-text'); if(el) el.textContent = text; }
function stopDownload() {
isRunning = false;
document.getElementById('cnki-start').style.display = 'inline-block';
document.getElementById('cnki-stop').style.display = 'none';
updateStatusText(t('status_stopped'));
showFinalReport();
}
function showFinalReport() {
const data = JSON.parse(sessionStorage.getItem('cnki_data') || '[]');
let totalSelected = 0, success = 0, failed = 0, pay = 0, nopdf = 0, exists = 0;
const failedList = [];
data.forEach(item => {
if (item.status !== 'wait') {
totalSelected++;
if (item.status === 'done') success++;
else if (item.status === 'exists') exists++;
else if (item.status === 'pay') pay++;
else if (item.status === 'no_pdf') nopdf++;
else if (item.status === 'error') {
failed++;
failedList.push(item);
}
}
});
if (totalSelected === 0) return;
const mask = document.getElementById('cnki-report-mask');
const header = document.getElementById('cnki-report-header');
const summary = document.getElementById('cnki-report-summary');
const list = document.getElementById('cnki-report-list');
const retryBtn = document.getElementById('cnki-report-retry');
mask.style.display = 'flex';
list.innerHTML = '';
let reportHtml = `
<div style="display:flex;justify-content:space-between;margin-bottom:5px;">
<span>${t('report_success')}: <b style="color:#16a34a">${success}</b></span>
<span>${t('report_exists')}: <b style="color:#6b7280">${exists}</b></span>
<span>${t('report_pay')}: <b style="color:#d97706">${pay}</b></span>
<span>${t('report_nopdf')}: <b style="color:#6b7280">${nopdf}</b></span>
<span>${t('report_fail')}: <b style="color:#ef4444">${failed}</b></span>
</div>
`;
if (failed > 0) {
header.classList.add('has-error');
reportHtml += `<div style="color:#666">${t('report_msg_check')}</div>`;
summary.innerHTML = reportHtml;
failedList.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'cnki-report-item';
div.innerHTML = `
<div style="width:70%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${item.title}">${idx + 1}. ${item.title}</div>
<div class="cnki-report-status-fail">${item.errorMsg || t('status_error')}</div>
`;
list.appendChild(div);
});
retryBtn.style.display = 'inline-block';
} else {
header.classList.remove('has-error');
summary.innerHTML = reportHtml;
list.innerHTML = `<div style="text-align:center;color:#999;margin-top:30px">${t('report_msg_none')}</div>`;
retryBtn.style.display = 'none';
}
}
async function startBatchDownload(isRetry = false) {
if(isRunning) return;
let checkboxes = Array.from(document.querySelectorAll('.cnki-item-check'));
if (isRetry) {
const data = JSON.parse(sessionStorage.getItem('cnki_data') || '[]');
checkboxes.forEach(cb => {
const id = parseInt(cb.value);
const item = data.find(d => d.id === id);
if (item && item.status === 'error') cb.checked = true;
else cb.checked = false;
});
}
const checkedBoxes = document.querySelectorAll('.cnki-item-check:checked');
if(checkedBoxes.length === 0) { alert(t('alert_no_check')); return; }
isRunning = true;
document.getElementById('cnki-start').style.display = 'none';
document.getElementById('cnki-stop').style.display = 'inline-block';
const data = JSON.parse(sessionStorage.getItem('cnki_data') || '[]');
const folder = document.getElementById('cnki-folder').value.trim() || DEFAULT_FOLDER;
for(let i=0; i<checkedBoxes.length; i++) {
if(!isRunning) break;
const id = parseInt(checkedBoxes[i].value);
const item = data.find(d => d.id === id);
if(!item) continue;
const result = await processSingleItem(item, folder);
if (result === 'captcha') {
updateStatus(id, 'error', `⛔ ${t('err_captcha')}`);
item.errorMsg = t('err_captcha');
const userChoice = await waitForUserVerification(item.detailUrl);
if (userChoice) { i--; continue; } else { stopDownload(); return; }
} else if (result === 'skip') {
updateStatus(id, 'skip', `⚠ ${t('err_no_auth')}`);
item.status = 'skip';
item.errorMsg = t('err_no_auth');
sessionStorage.setItem('cnki_data', JSON.stringify(data));
checkedBoxes[i].checked = false;
} else if (result === 'no_pdf') {
updateStatus(id, 'no_pdf', `⚪ ${t('status_nopdf')}`);
item.status = 'no_pdf';
sessionStorage.setItem('cnki_data', JSON.stringify(data));
checkedBoxes[i].checked = false;
} else if (result === 'exists') {
updateStatus(id, 'exists', `🔁 ${t('status_exists')}`);
item.status = 'exists';
sessionStorage.setItem('cnki_data', JSON.stringify(data));
checkedBoxes[i].checked = false;
continue;
} else if (result === true) {
updateStatus(id, 'done', t('status_done'));
item.status = 'done';
item.errorMsg = '';
sessionStorage.setItem('cnki_data', JSON.stringify(data));
checkedBoxes[i].checked = false;
} else {
updateStatus(id, 'error', `✘ ${t('status_error')}`);
item.status = 'error';
item.errorMsg = t('err_download_fail');
sessionStorage.setItem('cnki_data', JSON.stringify(data));
}
if(i < checkedBoxes.length - 1 && isRunning) {
if (result === true) {
const delay = Math.floor(Math.random() * (DEFAULT_MAX_DELAY - DEFAULT_MIN_DELAY + 1)) + DEFAULT_MIN_DELAY;
let remaining = delay / 1000;
let finalText = t('status_done');
const timer = setInterval(() => {
if(!isRunning) clearInterval(timer);
updateStatus(id, item.status, `${finalText} (${t('cool_down')} ${remaining.toFixed(0)}s)`);
remaining--;
}, 1000);
await new Promise(r => setTimeout(r, delay));
clearInterval(timer);
updateStatus(id, item.status, finalText);
} else {
let msg = '';
if(item.status === 'exists') msg = `🔁 ${t('status_exists')}`;
else if(item.status === 'no_pdf') msg = `⚪ ${t('status_nopdf')}`;
else if(item.status === 'pay') msg = `💰 ${t('status_pay')}`;
else msg = `✘ ${t('status_error')}`;
updateStatus(id, item.status, msg + ` (⏩ ${t('status_skip')})`);
await new Promise(r => setTimeout(r, 500));
updateStatus(id, item.status, msg);
}
}
}
stopDownload();
if(isRunning) {
updateStatusText(t('status_finished'));
showFinalReport();
}
}
function updateStatus(id, status, text) {
const cell = document.getElementById(`status-${id}`);
if(cell) {
let color = '#94a3b8';
if(status.includes('run') || status.includes('download')) color = '#3b82f6';
if(status === 'done') color = '#16a34a';
if(status === 'error') color = '#ef4444';
if(status === 'pay') color = '#f59e0b';
if(status === 'no_pdf') color = '#6b7280';
if(status === 'exists') color = '#6b7280';
cell.innerHTML = `<span style="color:${color};font-weight:bold">${text || status}</span>`;
}
}
async function processSingleItem(item, folder) {
return new Promise(async (resolve) => {
try {
const safeTitle = item.title.replace(/[\\/:*?"<>|]/g, '_').trim();
const finalName = folder ? `${folder}/${safeTitle}.pdf` : `${safeTitle}.pdf`;
const history = GM_getValue('cnki_dl_history', []);
if (history.includes(finalName)) {
resolve('exists');
return;
}
updateStatus(item.id, 'running', t('status_running'));
const res = await new Promise((rs, rj) => {
GM_xmlhttpRequest({
method: 'GET',
url: item.detailUrl,
headers: { 'Referer': window.location.href },
onload: rs,
onerror: rj
});
});
const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
if (res.responseText.includes('captcha-element') || res.responseText.includes('TencentCaptcha') || res.responseText.includes('拼图校验')) {
resolve('captcha');
return;
}
let pdfLink = null;
const btnArea = doc.querySelector('.operate-btn') || doc.querySelector('#DownLoadParts');
if(btnArea) {
const links = btnArea.querySelectorAll('a');
for(let a of links) {
if(a.textContent.includes('PDF') || a.textContent.includes('整本') || a.textContent.includes('Whole')) {
pdfLink = a.href;
break;
}
}
}
if(!pdfLink) {
resolve('no_pdf');
return;
}
if(!pdfLink.startsWith('http')) {
const origin = new URL(item.detailUrl).origin;
pdfLink = origin + (pdfLink.startsWith('/') ? '' : '/') + pdfLink;
}
updateStatus(item.id, 'downloading', t('status_downloading'));
GM_xmlhttpRequest({
method: 'GET',
url: pdfLink,
responseType: 'blob',
headers: {
'Referer': item.detailUrl,
'Cookie': document.cookie,
'User-Agent': navigator.userAgent
},
onload: function(response) {
const blob = response.response;
const contentType = response.responseHeaders.match(/content-type:\s*(.*)/i)?.[1] || '';
if(contentType.includes('text/html')) {
const reader = new FileReader();
reader.onload = function() {
const text = reader.result;
if (text.includes('captcha-element') || text.includes('TencentCaptcha')) {
resolve('captcha');
} else if (text.includes('充值') || text.includes('登录') || text.includes('权限') || text.includes('fee') || text.includes('cz-alert') || text.includes('购买')) {
resolve('pay');
} else {
resolve(false);
}
};
reader.readAsText(blob);
return;
}
if(blob.size < 2000) {
resolve(false);
return;
}
const blobUrl = URL.createObjectURL(blob);
GM_download({
url: blobUrl,
name: finalName,
saveAs: false,
conflictAction: 'overwrite',
onload: () => {
const currentHistory = GM_getValue('cnki_dl_history', []);
if (!currentHistory.includes(finalName)) {
currentHistory.push(finalName);
GM_setValue('cnki_dl_history', currentHistory);
}
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
resolve(true);
},
onerror: (err) => {
console.error(err);
resolve(false);
}
});
},
onerror: function(err) {
console.error(err);
resolve(false);
}
});
} catch(e) {
console.error(e);
resolve(false);
}
});
}
// 启动
injectStyle();
setInterval(tryCreateButton, 1000);
tryCreateButton();
startObserver();
})();