keledge-helper

可知网导出页面到PDF,仅对PDF预览有效

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         keledge-helper
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  可知网导出页面到PDF,仅对PDF预览有效
// @author       [email protected]
// @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();
})();