// ==UserScript==
// @name keledge-helper
// @namespace http://tampermonkey.net/
// @version 0.2
// @description 可知网导出页面到PDF,仅对PDF预览有效
// @author 2690874578@qq.com
// @match https://www.keledge.com/pdfReader?*
// @require https://cdn.staticfile.net/pdf-lib/1.17.1/pdf-lib.min.js
// @require https://cdn.staticfile.net/sweetalert2/11.10.3/sweetalert2.all.min.js
// @icon https://www.google.com/s2/favicons?sz=64&domain=keledge.com
// @grant none
// @run-at document-start
// @license GPL-3.0-only
// ==/UserScript==
(function () {
"use strict";
// 全局常量
const GUI = `<div><style class="keledge-style">.keledge-fold-btn{position:fixed;left:151px;top:36%;user-select:none;font-size:large;z-index:1001}.keledge-fold-btn::after{content:"🐵"}.keledge-fold-btn.folded{left:20px}.keledge-fold-btn.folded::after{content:"🙈"}.keledge-box{position:fixed;width:154px;left:10px;top:32%;z-index:1000}.btns-sec{background:#e7f1ff;border:2px solid #1676ff;padding:0 0 10px 0;font-weight:600;border-radius:2px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei','Helvetica Neue',Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol'}.btns-sec.folded{display:none}.logo-title{width:100%;background:#1676ff;text-align:center;font-size:large;color:#e7f1ff;line-height:40px;height:40px;margin:0 0 16px 0}.keledge-box button{display:block;width:128px;height:28px;border-radius:4px;color:#fff;font-size:12px;border:none;outline:0;margin:8px auto;font-weight:700;cursor:pointer;opacity:.9}.keledge-box button.folded{display:none}.keledge-box .btn-1{background:linear-gradient(180deg,#00e7f7 0,#feb800 .01%,#ff8700 100%)}.keledge-box .btn-1:hover,.keledge-box .btn-2:hover{opacity:.8}.keledge-box .btn-1:active,.keledge-box .btn-2:active{opacity:1}</style><div class="keledge-box"><section class="btns-sec"><p class="logo-title">keledge-helper</p><button class="btn-1" onclick="btn1_fn(this)">{{btn1_desc}}</button></section><p class="keledge-fold-btn" onclick="[this, this.parentElement.querySelector('.btns-sec')].forEach(elem => elem.classList.toggle('folded'))"></p></div></div>`;
const pdf_data_map = new Map();
const println = console.log.bind(console);
const logs = [];
// 全局变量
let page_index = -1;
// 全局属性
Object.assign(window, { println, pdf_data_map });
function log(...args) {
const time = new Date().toTimeString().split(" ")[0];
const record = `[${time}]\t${args}`;
logs.push(record);
println(...args);
}
function clear_pdf_data() {
const size = pdf_data_map.size;
pdf_data_map.clear();
log(`PDF缓存已清空,共清理 ${size} 页`);
}
/**
* @param {number} delay
*/
function sleep(delay) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
/**
* @param {string[]} libs
*/
async function wait_for_libs(libs) {
let not_ready = true;
while (not_ready) {
for (const lib of libs) {
if (!window[lib]) {
not_ready = true;
break;
} else {
not_ready = false;
}
}
await sleep(200);
}
}
/**
* 替换 window.glob_obj_name.method 为 new_method
* @param {string} glob_obj_name
* @param {string} method
* @param {Function} new_method
*/
function hook_method(glob_obj_name, method, new_method) {
const obj = window[glob_obj_name];
window[method] = obj[method].bind(obj);
window["_" + glob_obj_name] = obj;
window[glob_obj_name] = new Proxy(obj, {
get(target, property, _) {
if (property === method) {
println(
`代理并替换了 ${glob_obj_name}.${property} 属性(方法)访问`
);
return new_method;
}
return target[property];
},
});
}
function hooked_get_doc(pdf_data) {
// debugger;
if (!pdf_data_map.has(page_index)) {
pdf_data_map.set(page_index, pdf_data.data);
log(`已经捕获数量:${pdf_data_map.size}`);
}
return window["getDocument"](pdf_data);
}
function hook_pdfjs() {
hook_method("pdfjsLib", "getDocument", hooked_get_doc);
}
/**
* @param {{ id: string, container: HTMLDivElement, eventBus: any, "110n": any, linkService: any, textLayerMode: number }} config
*/
function hooked_viewer(config) {
// id: "pdf-page-0"
page_index = parseInt(config.id.split("-").at(-1));
log(`正在加载页面:${page_index + 1}`);
return new window["PDFViewer"](config);
}
function hook_viewer() {
hook_method("pdfjsViewer", "PDFViewer", hooked_viewer);
}
/**
* 返回一个包含计数器的迭代器, 其每次迭代值为 [index, value]
* @param {Iterable} iterable
* @returns
*/
function* enumerate(iterable) {
let i = 0;
for (let value of iterable) {
yield [i, value];
i++;
}
}
async function myalert(text) {
return Sweetalert2.fire({
text,
icon: "error",
allowOutsideClick: false,
});
}
/**
* 合并多个PDF
* @param {Array<ArrayBuffer | Uint8Array>} pdfs
* @returns {Promise<Uint8Array>}
*/
async function join_pdfs(pdfs) {
if (!window.PDFLib) {
const url =
"https://cdn.staticfile.org/pdf-lib/1.17.1/pdf-lib.min.js";
const code = await fetch(url).then((resp) => resp.text());
eval(code);
}
if (!window.PDFLib) {
const msg = "缺少 PDFLib 无法导出 PDF!";
myalert(msg);
throw new Error(msg);
}
const combined = await PDFLib.PDFDocument.create();
for (const [i, buffer] of enumerate(pdfs)) {
const pdf = await PDFLib.PDFDocument.load(buffer);
const pages = await combined.copyPages(pdf, pdf.getPageIndices());
for (const page of pages) {
combined.addPage(page);
}
log(`已经合并 ${i + 1} 组`);
}
return combined.save();
}
/**
* 创建并下载文件
* @param {string} file_name 文件名
* @param {ArrayBuffer | ArrayBufferView | Blob | string} content 内容
* @param {string} type 媒体类型,需要符合 MIME 标准
*/
function save(file_name, content, type = "") {
const blob = new Blob([content], { type });
const size = (blob.size / 1024).toFixed(1);
log(`blob saved, size: ${size} kb, type: ${blob.type}`);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = file_name || "未命名文件";
a.href = url;
a.click();
URL.revokeObjectURL(url);
}
/**
* @param {string} text
* @returns {Promise<boolean>}
*/
async function myconfirm(text) {
const result = await Sweetalert2.fire({
text,
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
allowOutsideClick: false,
});
return result.isConfirmed;
}
async function export_pdf() {
const yes = await myconfirm("是否导出已经捕获的页面?导出后会清空缓存");
if (!yes) {
return;
}
// 每个 Item 是 [页码, 数据]
const pdfs = Array.from(pdf_data_map)
.sort((a, b) => a[0] - b[0])
.map((item) => item[1]);
const combined = await join_pdfs(pdfs);
save(document.title + ".pdf", combined, "application/pdf");
clear_pdf_data();
}
function show_tips() {
Sweetalert2.fire({
title: "可知助手小提示",
html: "<p>以下快捷键可用: </p><p>显示帮助: ALT + H</p><p>导出文档: ALT + S</p><p>显示日志: ALT + L</p><p>进度明细: ALT + P</p><p>清空缓存: ALT + C</p>",
timer: 10000,
timerProgressBar: true,
allowOutsideClick: true,
});
}
/**
* 按下 alt + h 弹出帮助文档
* @param {KeyboardEvent} event
*/
function shortcut_alt_h(event) {
if (!(event.altKey && event.code === "KeyH")) {
return;
}
show_tips();
}
/**
* 按下 alt + s 以导出PDF
* @param {KeyboardEvent} event
*/
function shortcut_alt_s(event) {
if (!(event.altKey && event.code === "KeyS")) {
return;
}
export_pdf();
}
/**
* 按下 alt + l 以显示日志
* @param {KeyboardEvent} event
*/
function shortcut_alt_l(event) {
if (!(event.altKey && event.code === "KeyL")) {
return;
}
const text = logs.join("\n");
Sweetalert2.fire({
title: "可知助手日志",
html: `<textarea readonly rows="10" cols="50" style="resize: none;">${text}</textarea>`,
showConfirmButton: false,
});
}
/**
* 描述整数数组
* @param {number[]} nums
* @returns {string}
*/
function desc_num_arr(nums) {
const result = [];
let start = null;
let end = null;
for (let i = 0; i < nums.length; i++) {
if (start === null) {
start = nums[i];
end = nums[i];
} else if (nums[i] === end + 1) {
end = nums[i];
} else {
if (start === end) {
result.push(`${start}`);
} else {
result.push(`${start}-${end}`);
}
start = nums[i];
end = nums[i];
}
}
if (start !== null) {
if (start === end) {
result.push(start.toString());
} else {
result.push(`${start}-${end}`);
}
}
return result.join(", ");
}
/**
* 按下 alt + p 以显示进度详情
* @param {KeyboardEvent} event
*/
function shortcut_alt_p(event) {
if (!(event.altKey && event.code === "KeyP")) {
return;
}
const captured = Array
.from(pdf_data_map.keys())
.sort((a, b) => a - b)
.map(pn => pn + 1);
const progress = desc_num_arr(captured);
Sweetalert2.fire({
title: "页面捕获进度",
text: captured.length ? `已经捕获的页码:${progress}` : `尚未捕获任何页面`,
});
}
/**
* 按下 alt + c 以显示进度详情
* @param {KeyboardEvent} event
*/
async function shortcut_alt_c(event) {
if (!(event.altKey && event.code === "KeyC")) {
return;
}
const hint = `是否清空所有已经捕获的页面(共 ${pdf_data_map.size} 页)?`;
const yes = await myconfirm(hint);
if (!yes) {
return;
}
clear_pdf_data();
Sweetalert2.fire({
icon: "info",
text: "缓存已清空",
});
}
async function early_main() {
log("进入 keledge-helper 脚本");
await wait_for_libs(["pdfjsLib", "pdfjsViewer"]);
hook_viewer();
hook_pdfjs();
window.btn1_fn = export_pdf;
const gui = GUI.replace("{{btn1_desc}}", "导出PDF");
document.body.insertAdjacentHTML("beforeend", gui);
}
function set_shortcuts() {
const shortcuts = [
shortcut_alt_h, // 显示帮助
shortcut_alt_s, // 导出pdf
shortcut_alt_l, // 显示日志
shortcut_alt_p, // 显示捕获进度
shortcut_alt_c, // 清空缓存
];
for (const shortcut of shortcuts) {
window.addEventListener("keydown", shortcut, true);
}
}
function later_main() {
show_tips();
set_shortcuts();
}
function main() {
early_main();
document.addEventListener("DOMContentLoaded", later_main);
}
main();
})();