table-copier

适用于任意网站,快速复制表格为纯文本、HTML、图片

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name            table-copier
// @namespace       http://tampermonkey.net/
// @version         0.6
// @description     适用于任意网站,快速复制表格为纯文本、HTML、图片
// @match           *://*/*
// @require         https://cdn.staticfile.org/html2canvas/1.4.1/html2canvas.min.js
// @grant           none
// @run-at          document-idle
// @license         GPL-3.0-only
// @create          2023-06-27
// ==/UserScript==


(function() {
    "use strict";

    const SCRIPTS = [
        ["html2canvas", "https://cdn.staticfile.org/html2canvas/1.4.1/html2canvas.min.js"]
    ];
    const BTN = `<button class="table-copier-btn" style="width: 70px; height: 30px;" onclick="copy_table(this)">复制表格</button>`;
    const BOOT_DELAY = 2000;

    /**
     * 元素选择器
     * @param {string} selector 
     * @returns {Array<HTMLElement>}
     */
    function $(selector) {
        const self = this?.querySelectorAll ? this : document;
        return [...self.querySelectorAll(selector)];
    }

    /**
     * 加载CDN脚本
     * @param {string} url 
     */
    async function load_script(url) {
        try {
            const code = await (await fetch(url)).text();
            Function(code)();
        } catch(e) {
            return new Promise(resolve => {
                console.error(e);
                // 嵌入<script>方式
                const script = document.createElement("script");
                script.src = url;
                script.onload = resolve;
                document.body.appendChild(script);
            });
        }
    }

    async function until_scripts_loaded() {
        return gather(SCRIPTS.map(
            // kv: [prop, url]
            kv => (async () => {
                if (window[kv[0]]) return;
                await load_script(kv[1]);
            })()
        ));
    }

    /**
     * 等待全部任务落定后返回值的列表
     * @param {Iterable<Promise>} tasks 
     * @returns {Promise<Array>} values
     */
    async function gather(tasks) {
        const results = await Promise.allSettled(tasks);
        const filtered = [];
        for (const result of results) {
            if (result.value) {
                filtered.push(result.value);
            }
        }
        return filtered;
    }

    /**
     * 递归的修正表内元素
     * @param {HTMLElement} elem 
     */
    function adjust_table(elem) {
        for (const child of elem.children) {
            adjust_table(child);

            for (const attr of child.attributes) {
                // 链接补全
                const name = attr.name;
                if (["src", "href"].includes(name)) {
                    child.setAttribute(name, child[name]);
                }
            }
        }
    }

    /**
     * canvas转blob
     * @param {HTMLCanvasElement} canvas 
     * @param {string} type
     * @returns {Promise<Blob>}
     */
    function canvas_to_blob(canvas) {
        return new Promise(
            resolve => canvas.toBlob(resolve, "image/png")
        );
    }

    /**
     * @param {HTMLTableCellElement} cell
     * @returns {string} 
     */
    function cell_to_text(cell) {
        return cell
            .textContent
            .replace(/\n/g, " ")
            .replace(/\t/g, "    ")
            .trim();
        // const children = cell.children;
        // if (children.length === 0) {
        //     return cell
        //         .textContent
        //         .replace(/\n/g, " ")
        //         .replace(/\t/g, "    ")
        //         .trim();
        // }

        // return [...children].map(
        //     (child) => cell_to_text(child, depth + 1)
        // ).join(" ".repeat(depth));
    }

    /**
     * 表格转tsv字符串
     * @param {HTMLTableElement} table 
     * @returns {string}
     */
    function table_to_tsv(table) {
        return [...table.rows].map((row) => {
            return [...row.cells].map((cell) => {
                return cell_to_text(cell);
            }).join("\t")
        }).join("\n");
    }

    /**
     * 以包含变量名getter属性的对象输出到控制台,延迟计算
     * @param {*} obj 
     */
    function log_as_getter(obj) {
        const name = Object.getOwnPropertyNames(obj)[0];
        const getter = {
            get [name]() {
                return obj[name];
            }
        };
        console.log(name + ":", getter);
    }

    /**
     * @param {HTMLTableElement} table 
     * @returns {Promise<Blob>} 
     */
    async function table_to_text_blob(table) {
        console.log("table to text");
        // table 转 tsv 格式文本
        const text = table_to_tsv(table);
        log_as_getter({ text });
        return new Blob([text], { type: "text/plain" });
    }

    /**
     * @param {HTMLTableElement} table 
     * @returns {Promise<Blob>} 
     */
    async function table_to_html_blob(table) {
        console.log("table to html");

        const _table = table.cloneNode(true);
        adjust_table(_table);
        return new Blob([_table.outerHTML], { type: "text/html" });
    }

    /**
     * @param {HTMLTableElement} table 
     * @returns {Promise<Blob>} 
     */
    async function table_to_image_blob(table) {
        console.log("table to image");

        let canvas;
        try {
            canvas = await window.html2canvas(table);
        } catch(e) {
            console.error(e);
        }
        log_as_getter({ canvas });
        if (!canvas) return;

        return canvas_to_blob(canvas);
    }

    /**
     * 使用过时的 execCommand 复制文本
     * @param {string} text 
     * @returns {Promise<string>}
     */
    async function copy_text_legacy(text) {
        return new Promise(resolve => {
            document.oncopy = event => {
                event.clipboardData.setData("text/plain", text);
                event.preventDefault();
                resolve();
            };
            document.execCommand("copy");
        });
    }

    /**
     * @param {Array<Blob>} blobs 
     * @returns {Object}
     */
    function merge_blobs_to_bundle(blobs) {
        const bundle = Object.create(null);
        for (const blob of blobs) {
            bundle[blob.type] = blob;
        }
        return bundle;
    }

    /**
     * @param {Array<Blob>} blobs 
     * @returns {Promise<void>}
     */
    function copy(blobs) {
        const bundle = merge_blobs_to_bundle(blobs);
        const item = new ClipboardItem(bundle);
        log_as_getter({ bundle });
        log_as_getter({ item });
        return navigator.clipboard.write([item]);
    }
    
    /**
     * @param {HTMLTableElement} table
     * @returns {Promise<void>} 
     */
    async function copy_table_as_multi_types(table) {
        const converts = [
            table_to_text_blob,
            table_to_html_blob,
            table_to_image_blob,
        ];
        
        const blobs = await gather(converts.map(
            convert => convert(table)
        ));

        try {
            await copy(blobs);
            alert("复制成功!");

        } catch(e) {
            console.error(e);
            alert("复制失败!");
        }
    }

    /**
     * @param {HTMLTableElement} table
     * @returns {Promise<void>} 
     */
    async function copy_table_as_text_legacy(table) {
        try {
            await copy_text_legacy(table_to_tsv(table));
            alert("复制成功!");
        } catch(e) {
            console.error(e);
            alert("复制失败!");
        }
    }

    /**
     * 异步的复制表格到剪贴板
     * @param {HTMLButtonElement} btn
     * @param {boolean} ret_val 是否返回值(tsv)而不是复制到剪贴板(默认 false)
     * @returns {Promise<null | string>}
     * 
     */
    async function copy_table(btn, ret_val=false) {
        const table = btn.closest("table");
        if (!table) {
            alert("出错了:按钮外部没有表格");
            return;
        }

        if (ret_val) {
            return table_to_tsv(table);
        }

        // 移除按钮
        $(".table-copier-btn").forEach(
            btn => btn.remove()
        );
        // 复制表格
        if (!navigator.clipboard) {
            await copy_table_as_text_legacy(table);
        } else {
            await copy_table_as_multi_types(table);
        }
        // 增加按钮
        add_btns();
        return null;
    }

    function add_btns() {
        for (const table of $("table")) {
            // 跳过隐藏的表格
            if (!table.getClientRects()[0]) continue;
            table.insertAdjacentHTML("afterbegin", BTN);
        }
    }

    async function main() {
        try {
            await until_scripts_loaded();
        } catch(e) {
            console.error(e);
        }

        window.copy_table = copy_table;
        add_btns();

        // 递归的注入自身到iframe
        $("iframe").forEach(iframe => {
            try {
                iframe.contentWindow.eval(main.toString());
            } catch(e) {}
        });
    };

    setTimeout(main, BOOT_DELAY);
})();