feishu Markdown

⚡一键将飞书文档转为 Markdown 并复制到剪贴板;支持表格、引用、代码块、嵌入式电子表格等复杂内容。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         feishu Markdown
// @namespace    http://tampermonkey.net/
// @version      1.0.6
// @description  ⚡一键将飞书文档转为 Markdown 并复制到剪贴板;支持表格、引用、代码块、嵌入式电子表格等复杂内容。
// @author       mike868
// @match        *://*.feishu.cn/*
// @run-at       document-start
// @license      AGPL-v3.0
// @grant        GM_setClipboard
// @grant        GM_addStyle
// ==/UserScript==

(function () {
    'use strict';
    const SCRIPT_TAG = '[FeishuMD]';
    const BUTTON_ID = 'scrollCopyButton';
    const MD_ICON = '<svg xmlns="http://www.w3.org/2000/svg" style="height:15px; padding-right:5px; fill:#fff; display:inline;" viewBox="0 0 640 512"><path d="M593.8 59.1H46.2C20.7 59.1 0 79.8 0 105.2v301.5c0 25.5 20.7 46.2 46.2 46.2h547.7c25.5 0 46.2-20.7 46.1-46.1V105.2c0-25.4-20.7-46.1-46.2-46.1zM338.5 360.6H277v-120l-61.5 76.9-61.5-76.9v120H92.3V151.4h61.5l61.5 76.9 61.5-76.9h61.5v209.2zm135.3 3.1L381.5 256H443V151.4h61.5V256H566z"/></svg>';
    const BUTTON_RESET_DELAY_MS = 2500;
    const SCAN_TIMEOUT_MS = 180000;
    let copyBypassInstalled = false;
    let copyBypassEventsInstalled = false;
    let copyBypassObserver = null;
    let copyBypassObservedRoot = null;
    let copyBypassRetryTimer = null;
    let copyBypassFlushQueued = false;
    const copyBypassQueue = new Set();
    let watermarkInstalled = false;

    // === Canvas 文本捕获 ===
    // 飞书嵌入式表格(sheet)使用 Canvas 绘制,hook fillText/strokeText 捕获绘制的文字和坐标
    // 默认关闭,仅在扫描期间启用,避免思维导图等 Canvas 重绘导致性能风暴
    let canvasCaptureEnabled = false;
    const canvasTextCaptures = new WeakMap();
    (function hookCanvasText() {
        try {
            const proto = CanvasRenderingContext2D.prototype;
            const origFillText = proto.fillText;
            const origStrokeText = proto.strokeText;
            const CANVAS_CAPTURE_LIMIT = 50000;
            proto.fillText = function (text, x, y) {
                try {
                    if (canvasCaptureEnabled && text && String(text).trim() && this.canvas) {
                        if (!canvasTextCaptures.has(this.canvas)) canvasTextCaptures.set(this.canvas, []);
                        const arr = canvasTextCaptures.get(this.canvas);
                        if (arr.length < CANVAS_CAPTURE_LIMIT) arr.push({ t: String(text), x: +x, y: +y });
                    }
                } catch (e) { }
                return origFillText.apply(this, arguments);
            };
            proto.strokeText = function (text, x, y) {
                try {
                    if (canvasCaptureEnabled && text && String(text).trim() && this.canvas) {
                        if (!canvasTextCaptures.has(this.canvas)) canvasTextCaptures.set(this.canvas, []);
                        const arr = canvasTextCaptures.get(this.canvas);
                        if (arr.length < CANVAS_CAPTURE_LIMIT) arr.push({ t: String(text), x: +x, y: +y });
                    }
                } catch (e) { }
                return origStrokeText.apply(this, arguments);
            };
        } catch (e) {
            console.warn('[FeishuMD] canvas text hook failed', e);
        }
    })();

    // Tampermonkey/Greasemonkey compatibility fallback
    const addStyle = (cssText) => {
        if (typeof GM_addStyle === 'function') {
            return GM_addStyle(cssText);
        }
        const style = document.createElement('style');
        style.setAttribute('type', 'text/css');
        style.textContent = cssText;
        (document.head || document.documentElement).appendChild(style);
        return style;
    };

    function isDocPage(urlString = window.location.href) {
        try {
            const { pathname } = new URL(urlString);
            return /\/(docx|wiki|docs)\//.test(pathname);
        } catch (e) {
            return /\/(docx|wiki|docs)\//.test(window.location.pathname);
        }
    }

    function installWatermarkRemoval() {
        if (watermarkInstalled) return;
        watermarkInstalled = true;

        const bgImageNone = '{background-image: none !important;}';
        const genStyle = (selector) => `${selector}${bgImageNone}`;

        // Global watermark selectors
        addStyle(genStyle('[class*="watermark"]'));
        addStyle(genStyle('#docx [style*="pointer-events: none"]:not(video):not(canvas):not([class*="player"])'));
        addStyle(genStyle('[class*="docx-editor"] [style*="pointer-events: none"]:not(video):not(canvas):not([class*="player"])'));
        addStyle(genStyle('[class*="wiki-content"] [style*="pointer-events: none"]:not(video):not(canvas):not([class*="player"])'));

        // Feishu/Lark specific selectors
        addStyle(genStyle('.ssrWaterMark'));
        addStyle(genStyle('[class*="TIAWBFTROSIDWYKTTIAW"]'));
        addStyle(genStyle('#watermark-cache-container'));
        addStyle(genStyle('.chatMessages>div[style*="inset: 0px;"]'));

        // Keep :has selectors for Chromium; Firefox will ignore these and use class fallbacks above.
        addStyle(genStyle('body>div>div>div>div[style*="position: fixed"]:not(:has(*))'));
        addStyle(genStyle('body>div[style*="position: fixed"]:not(:has(*))'));
        addStyle(genStyle('body>div[style*="inset: 0px;"]:not(:has(*))'));
    }

    function getDocRoot() {
        return document.querySelector('#docx, [class*="docx-editor"], [class*="wiki-content"], [role="main"]');
    }

    // 跳过嵌入组件(思维导图、视频播放器等),它们依赖 pointer-events/user-select 正常工作
    const SKIP_UNLOCK_TAGS = new Set(['canvas', 'svg', 'video', 'iframe', 'object', 'embed']);
    let isUnlocking = false;

    function unlockElementSelection(el) {
        if (!el || el.nodeType !== 1 || !el.style) return;
        if (SKIP_UNLOCK_TAGS.has((el.tagName || '').toLowerCase())) return;
        if (el.style.userSelect === 'none') el.style.userSelect = 'text';
        if (el.style.webkitUserSelect === 'none') el.style.webkitUserSelect = 'text';
        if (el.style.pointerEvents === 'none') el.style.pointerEvents = 'auto';
    }

    const UNLOCK_THROTTLE_MS = 500;
    const UNLOCK_MAX_BATCH = 50;
    let lastUnlockFlush = 0;

    function flushUnlockQueue() {
        copyBypassFlushQueued = false;
        lastUnlockFlush = Date.now();
        const nodes = Array.from(copyBypassQueue);
        copyBypassQueue.clear();
        const batch = nodes.slice(0, UNLOCK_MAX_BATCH);
        // 超出部分放回队列,延迟处理
        if (nodes.length > UNLOCK_MAX_BATCH) {
            for (let i = UNLOCK_MAX_BATCH; i < nodes.length; i++) {
                copyBypassQueue.add(nodes[i]);
            }
            copyBypassFlushQueued = true;
            setTimeout(flushUnlockQueue, UNLOCK_THROTTLE_MS);
        }
        // 设置自修改标记,防止我们的 style 写入重新触发 observer
        isUnlocking = true;
        try {
            batch.forEach((node) => {
                if (!node || node.nodeType !== 1) return;
                unlockElementSelection(node);
                // CSS !important 已覆盖内联样式,无需 querySelectorAll 全树扫描
            });
        } finally {
            isUnlocking = false;
        }
    }

    function queueUnlockNode(node) {
        if (!node || node.nodeType !== 1) return;
        copyBypassQueue.add(node);
        if (copyBypassFlushQueued) return;
        copyBypassFlushQueued = true;
        const elapsed = Date.now() - lastUnlockFlush;
        const delay = Math.max(0, UNLOCK_THROTTLE_MS - elapsed);
        setTimeout(flushUnlockQueue, delay);
    }

    function installCopyBypass() {
        if (!isDocPage()) return;

        if (!copyBypassInstalled) {
            copyBypassInstalled = true;

            // Keep unlock scope in document area to avoid touching unrelated pages/components.
            // 注意:pointer-events 不能加在 div 上——飞书的滚动容器和 overlay 都是 div,
            // 强制 auto 会阻断滚动。仅对文本相关的内联/叶子元素设置 pointer-events。
            addStyle(`
                #docx, #docx p, #docx span, #docx div, #docx li, #docx td, #docx th,
                #docx h1, #docx h2, #docx h3, #docx h4, #docx h5, #docx h6,
                #docx a, #docx blockquote, #docx pre, #docx code,
                [class*="docx-editor"], [class*="docx-editor"] p, [class*="docx-editor"] span,
                [class*="docx-editor"] div, [class*="docx-editor"] li,
                [class*="docx-editor"] td, [class*="docx-editor"] th,
                [class*="docx-editor"] h1, [class*="docx-editor"] h2, [class*="docx-editor"] h3,
                [class*="docx-editor"] h4, [class*="docx-editor"] h5, [class*="docx-editor"] h6,
                [class*="docx-editor"] a, [class*="docx-editor"] blockquote,
                [class*="docx-editor"] pre, [class*="docx-editor"] code,
                [class*="wiki-content"], [class*="wiki-content"] p, [class*="wiki-content"] span,
                [class*="wiki-content"] div, [class*="wiki-content"] li,
                [class*="wiki-content"] td, [class*="wiki-content"] th,
                [class*="wiki-content"] h1, [class*="wiki-content"] h2, [class*="wiki-content"] h3,
                [class*="wiki-content"] h4, [class*="wiki-content"] h5, [class*="wiki-content"] h6,
                [class*="wiki-content"] a, [class*="wiki-content"] blockquote,
                [class*="wiki-content"] pre, [class*="wiki-content"] code {
                    user-select: text !important;
                    -webkit-user-select: text !important;
                }
                /* pointer-events 仅对文本叶子元素启用,不包含普通 div(避免阻断滚动)
                   但飞书表格 cell 用 div+role 渲染,需要单独加回来 */
                #docx p, #docx span, #docx li, #docx td, #docx th,
                #docx h1, #docx h2, #docx h3, #docx h4, #docx h5, #docx h6,
                #docx a, #docx blockquote, #docx pre, #docx code,
                #docx [role="gridcell"], #docx [role="cell"],
                #docx [role="columnheader"], #docx [role="rowheader"],
                #docx [data-block-type*="cell"],
                [class*="docx-editor"] p, [class*="docx-editor"] span,
                [class*="docx-editor"] li, [class*="docx-editor"] td, [class*="docx-editor"] th,
                [class*="docx-editor"] h1, [class*="docx-editor"] h2, [class*="docx-editor"] h3,
                [class*="docx-editor"] h4, [class*="docx-editor"] h5, [class*="docx-editor"] h6,
                [class*="docx-editor"] a, [class*="docx-editor"] blockquote,
                [class*="docx-editor"] pre, [class*="docx-editor"] code,
                [class*="docx-editor"] [role="gridcell"], [class*="docx-editor"] [role="cell"],
                [class*="docx-editor"] [role="columnheader"], [class*="docx-editor"] [role="rowheader"],
                [class*="docx-editor"] [data-block-type*="cell"],
                [class*="wiki-content"] p, [class*="wiki-content"] span,
                [class*="wiki-content"] li, [class*="wiki-content"] td, [class*="wiki-content"] th,
                [class*="wiki-content"] h1, [class*="wiki-content"] h2, [class*="wiki-content"] h3,
                [class*="wiki-content"] h4, [class*="wiki-content"] h5, [class*="wiki-content"] h6,
                [class*="wiki-content"] a, [class*="wiki-content"] blockquote,
                [class*="wiki-content"] pre, [class*="wiki-content"] code,
                [class*="wiki-content"] [role="gridcell"], [class*="wiki-content"] [role="cell"],
                [class*="wiki-content"] [role="columnheader"], [class*="wiki-content"] [role="rowheader"],
                [class*="wiki-content"] [data-block-type*="cell"] {
                    pointer-events: auto !important;
                }
                #docx [style*="user-select: none"],
                [class*="docx-editor"] [style*="user-select: none"],
                [class*="wiki-content"] [style*="user-select: none"] {
                    user-select: text !important;
                    -webkit-user-select: text !important;
                }
                /* Restore browser native "Copy Image" in right-click context menu.
                   Feishu places overlay divs on top of <img>; our blanket
                   pointer-events:auto forces those overlays to intercept clicks,
                   hiding the <img> from the browser.  Raising <img> above overlays
                   makes the browser recognise the right-click target as an image. */
                #docx img,
                [class*="docx-editor"] img,
                [class*="wiki-content"] img {
                    pointer-events: auto !important;
                    position: relative !important;
                    z-index: 2 !important;
                }
            `);
        }

        if (!copyBypassEventsInstalled) {
            copyBypassEventsInstalled = true;
            const blockedEvents = ['copy', 'cut', 'paste', 'contextmenu', 'selectstart'];
            const bypassHandler = (event) => {
                if (!isDocPage()) return;
                const root = getDocRoot();
                if (root && event.target instanceof Node && !root.contains(event.target)) return;
                // Only cut propagation to bypass page-level restriction listeners.
                // Do not call preventDefault so browser default copy keeps working.
                event.stopImmediatePropagation();
                event.stopPropagation();
            };
            blockedEvents.forEach((eventName) => {
                document.addEventListener(eventName, bypassHandler, { capture: true, passive: false });
            });

            document.addEventListener('keydown', (event) => {
                if (!isDocPage()) return;
                const root = getDocRoot();
                if (root && event.target instanceof Node && !root.contains(event.target)) return;
                const isCopyShortcut = (event.ctrlKey || event.metaKey) && (event.key === 'c' || event.key === 'C');
                if (!isCopyShortcut) return;
                event.stopImmediatePropagation();
                event.stopPropagation();
            }, { capture: true, passive: false });
        }

        const startObserver = () => {
            const root = getDocRoot();
            if (!root) return false;

            if (copyBypassObserver && copyBypassObservedRoot === root) {
                return true;
            }
            if (copyBypassObserver) {
                copyBypassObserver.disconnect();
            }
            copyBypassObserver = new MutationObserver((mutations) => {
                // 跳过自身引起的 DOM 变更,打破反馈循环
                if (isUnlocking) return;
                for (const mutation of mutations) {
                    mutation.addedNodes.forEach(queueUnlockNode);
                }
            });
            // 仅监听 childList(新增节点),不监听 attributes
            // style/class 覆盖已通过 CSS !important 实现,无需 JS 重复修改
            copyBypassObserver.observe(root, {
                childList: true,
                subtree: true,
            });
            copyBypassObservedRoot = root;
            queueUnlockNode(root);
            return true;
        };

        const retryBindObserver = () => {
            if (startObserver()) {
                if (copyBypassRetryTimer) {
                    clearInterval(copyBypassRetryTimer);
                    copyBypassRetryTimer = null;
                }
                return;
            }

            if (copyBypassRetryTimer) return;
            let retries = 0;
            copyBypassRetryTimer = setInterval(() => {
                retries += 1;
                if (startObserver() || retries >= 40) {
                    clearInterval(copyBypassRetryTimer);
                    copyBypassRetryTimer = null;
                }
            }, 500);
        };

        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', retryBindObserver, { once: true });
        } else {
            retryBindObserver();
        }
    }

    function sanitizeTableCell(text) {
        return (text || '')
            .replace(/\|/g, '\\|')
            .replace(/\n+/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }
    function buildMarkdownTable(rows) {
        if (!rows || rows.length === 0) return '';
        const colCount = Math.max(...rows.map(row => row.length));
        if (!Number.isFinite(colCount) || colCount <= 0) return '';

        const normalized = rows.map(row => {
            const cells = row.slice(0, colCount);
            while (cells.length < colCount) cells.push(' ');
            return cells.map(cell => cell || ' ');
        });

        if (normalized.length === 1) {
            normalized.push(new Array(colCount).fill(' '));
        }

        const header = normalized[0];
        const body = normalized.slice(1);
        const separator = new Array(colCount).fill('---');

        const lines = [
            `| ${header.join(' | ')} |`,
            `| ${separator.join(' | ')} |`,
            ...body.map(row => `| ${row.join(' | ')} |`),
        ];
        return `\n${lines.join('\n')}\n`;
    }

    function extractRowsFromNodes(rowNodes, cellSelector) {
        const rows = [];
        // rowspanTracker[colIdx] = remaining rowspan count(跟踪尚未过期的 rowspan 占位)
        const rowspanTracker = {};

        rowNodes.forEach(rowNode => {
            // 关键修复:仅选择当前 row 的直接子单元格,避免匹配嵌套在 cell 内部的后代元素
            const allCells = rowNode.querySelectorAll(cellSelector);
            const cells = Array.from(allCells).filter(cell => {
                // cell 的最近 row 祖先必须是当前 rowNode
                let parent = cell.parentElement;
                while (parent && parent !== rowNode) {
                    // 如果 parent 本身也匹配 row 的角色,说明 cell 属于嵌套行
                    const pRole = parent.getAttribute('role') || '';
                    const pType = (parent.getAttribute('data-block-type') || '').toLowerCase();
                    if (pRole === 'row' || pType.includes('row') || parent.tagName.toLowerCase() === 'tr') {
                        return false; // 属于嵌套行,跳过
                    }
                    parent = parent.parentElement;
                }
                return parent === rowNode;
            });
            if (!cells.length) return;

            const row = [];
            let cellIdx = 0;
            let colIdx = 0;

            while (cellIdx < cells.length) {
                // 跳过被上方 rowspan 占据的列位置
                while (rowspanTracker[colIdx] && rowspanTracker[colIdx] > 0) {
                    row.push(' ');
                    rowspanTracker[colIdx]--;
                    if (rowspanTracker[colIdx] <= 0) delete rowspanTracker[colIdx];
                    colIdx++;
                }

                if (cellIdx >= cells.length) break;

                const cellNode = cells[cellIdx];
                const value = sanitizeTableCell(cellNode.innerText || cellNode.textContent || '');
                const colspan = Math.max(1, Number.parseInt(cellNode.getAttribute('colspan') || '1', 10));
                const rowspan = Math.max(1, Number.parseInt(cellNode.getAttribute('rowspan') || '1', 10));

                for (let c = 0; c < colspan; c++) {
                    row.push(c === 0 ? (value || ' ') : ' ');
                    if (rowspan > 1) {
                        rowspanTracker[colIdx] = rowspan - 1;
                    }
                    colIdx++;
                }
                cellIdx++;
            }

            // 处理行尾剩余的 rowspan 占位
            while (rowspanTracker[colIdx] && rowspanTracker[colIdx] > 0) {
                row.push(' ');
                rowspanTracker[colIdx]--;
                if (rowspanTracker[colIdx] <= 0) delete rowspanTracker[colIdx];
                colIdx++;
            }

            if (row.some(cell => cell.trim() !== '')) rows.push(row);
        });
        return rows;
    }

    function tableNodeToMarkdown(tableNode) {
        if (!tableNode) return '';

        let rows = [];
        // Strategy 1: Standard HTML table
        const htmlRows = tableNode.querySelectorAll('tr');
        if (htmlRows.length) {
            rows = extractRowsFromNodes(htmlRows, 'th, td');
        }

        // Strategy 2: ARIA roles
        if (!rows.length) {
            const roleRows = tableNode.querySelectorAll('[role="row"]');
            if (roleRows.length) {
                rows = extractRowsFromNodes(roleRows, '[role="columnheader"], [role="gridcell"], [role="cell"]');
            }
        }

        // Strategy 3: Class-based selectors
        if (!rows.length) {
            const classRows = tableNode.querySelectorAll(
                '[class*="table-row"], [class*="grid-row"], [data-row-index]'
            );
            if (classRows.length) {
                rows = extractRowsFromNodes(
                    classRows,
                    '[class*="table-cell"], [class*="grid-cell"], [data-col-index], [role="gridcell"], [role="cell"], [role="columnheader"]'
                );
            }
        }

        // Strategy 4: Feishu block-type based structure (data-block-type containing row/cell)
        if (!rows.length) {
            const blockRows = tableNode.querySelectorAll(
                '[data-block-type*="row"], [data-block-type*="tr"]'
            );
            if (blockRows.length) {
                rows = extractRowsFromNodes(
                    blockRows,
                    '[data-block-type*="cell"], [data-block-type*="td"], [data-block-type*="th"]'
                );
            }
        }

        // Strategy 5: Grid-like child div structure (children as rows, grandchildren as cells)
        if (!rows.length) {
            const container = tableNode.querySelector('[class*="table"], [class*="grid"]') || tableNode;
            const potentialRows = Array.from(container.children).filter(
                child => child.children && child.children.length > 1
            );
            if (potentialRows.length > 1) {
                const colCount = potentialRows[0].children.length;
                const isConsistent = potentialRows.every(
                    r => Math.abs(r.children.length - colCount) <= 1
                );
                if (isConsistent) {
                    rows = potentialRows.map(rowEl =>
                        Array.from(rowEl.children).map(cellEl =>
                            sanitizeTableCell(cellEl.innerText || cellEl.textContent || '')
                        )
                    );
                }
            }
        }

        return buildMarkdownTable(rows);
    }

    // ===== 嵌入式电子表格(sheet)数据提取 =====
    // 飞书 sheet 块使用 Canvas 渲染,DOM 中无文本节点
    // 通过 fillText hook 捕获的坐标+文字还原为 Markdown 表格

    function extractSheetMarkdown(sheetBlock) {
        const allCanvases = sheetBlock.querySelectorAll('canvas');
        for (const canvas of allCanvases) {
            const data = canvasTextCaptures.get(canvas);
            const rawTexts = data ? data.filter(item => item.t.trim()) : [];
            if (rawTexts.length < 2) continue;

            // 去重:Canvas 多次重绘会在相同坐标绘制相同文字
            const seen = new Set();
            const texts = rawTexts.filter(item => {
                const key = `${Math.round(item.x)},${Math.round(item.y)},${item.t}`;
                if (seen.has(key)) return false;
                seen.add(key);
                return true;
            });
            if (texts.length < 2) continue;

            // Step 1: 按 Y 坐标分组为视觉行(容差 4px)
            const sorted = [...texts].sort((a, b) => a.y - b.y || a.x - b.x);
            const visualLines = [];
            let currentGroup = [sorted[0]];
            for (let i = 1; i < sorted.length; i++) {
                if (Math.abs(sorted[i].y - currentGroup[0].y) <= 4) {
                    currentGroup.push(sorted[i]);
                } else {
                    visualLines.push(currentGroup);
                    currentGroup = [sorted[i]];
                }
            }
            visualLines.push(currentGroup);

            if (visualLines.length < 2) continue;

            // Step 2: 从拥有最多单元格的视觉行(通常是表头)确定列 X 坐标
            const referenceRow = visualLines.reduce((a, b) => a.length > b.length ? a : b);
            const colXPositions = referenceRow
                .sort((a, b) => a.x - b.x)
                .map(item => item.x);
            const numCols = colXPositions.length;
            if (numCols < 2) continue;

            // 根据 X 坐标找到最近的列索引
            function findColumn(x) {
                let minDist = Infinity, bestCol = 0;
                for (let i = 0; i < colXPositions.length; i++) {
                    const dist = Math.abs(x - colXPositions[i]);
                    if (dist < minDist) { minDist = dist; bestCol = i; }
                }
                return bestCol;
            }

            // Step 3: 计算视觉行间 Y 间距,区分"行内换行"和"行边界"
            // 行内换行间距小(~16-20px),行边界间距大(~40+px)
            const yValues = visualLines.map(g => g[0].y);
            const gaps = [];
            for (let i = 1; i < yValues.length; i++) {
                gaps.push(yValues[i] - yValues[i - 1]);
            }
            // 阈值:区分"行内换行"(小间距)和"行边界"(大间距)
            // 如果所有间距相近(maxGap/minGap < 1.5),说明没有行内换行,每个视觉行就是独立行
            // 如果间距差距大,说明有行内换行,需要合并小间距的视觉行
            const sortedGaps = [...gaps].sort((a, b) => a - b);
            const minGap = sortedGaps[0] || 1;
            const maxGap = sortedGaps[sortedGaps.length - 1] || 1;
            const hasLineWraps = maxGap > minGap * 1.5;
            const rowBoundaryThreshold = hasLineWraps ? minGap * 1.8 : minGap * 0.5;

            // Step 4: 按列位置分配文字,并根据 Y 间距合并视觉行为逻辑行
            const logicalRows = [];
            let currentRow = new Array(numCols).fill('');

            visualLines.forEach((group, idx) => {
                // 大间距 = 新的逻辑行边界
                const isNewLogicalRow = idx === 0 || gaps[idx - 1] > rowBoundaryThreshold;

                if (isNewLogicalRow && idx > 0) {
                    logicalRows.push(currentRow);
                    currentRow = new Array(numCols).fill('');
                }

                // 将当前视觉行中的每个文字分配到对应的列
                group.forEach(item => {
                    const col = findColumn(item.x);
                    if (currentRow[col]) {
                        currentRow[col] += ' ' + item.t; // 同列多行文字用空格连接
                    } else {
                        currentRow[col] = item.t;
                    }
                });
            });

            // 推入最后一行
            if (currentRow.some(c => c.trim())) {
                logicalRows.push(currentRow);
            }

            if (logicalRows.length < 2) continue;

            // Step 5: 清理并构建 Markdown 表格
            const rows = logicalRows.map(row =>
                row.map(cell => sanitizeTableCell(cell))
            );

            const hasContent = rows.some(row => row.some(cell => cell.trim() !== ''));
            if (!hasContent) continue;

            return buildMarkdownTable(rows);
        }

        return null;
    }

    function convertToMarkdown(html) {
        // Known limitations: formula/callout/mermaid-like blocks may degrade to readable text.
        // 使用纯 DOM 遍历,避免正则预处理破坏 HTML 结构
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, 'text/html');

        // 预处理:飞书代码块(在遍历前替换为文本占位符)
        doc.querySelectorAll('div.docx-code-block-container > div.docx-code-block-inner-container').forEach(codearea => {
            let header = codearea.querySelector('div.code-block-header .code-block-header-btn-con');
            let language = header ? (header.textContent || '').trim() : 'text';
            const codeHeader = codearea.querySelector('div.code-block-header');
            if (codeHeader) codeHeader.remove();
            codearea.querySelectorAll('span[data-enter="true"]').forEach(enterSpan => {
                enterSpan.outerHTML = '\n';
            });
            const codeContent = codearea.textContent || '';
            const outer = codearea.closest('div.docx-code-block-container') || codearea;
            outer.outerHTML = `\n\`\`\`${language}\n${codeContent}\n\`\`\`\n`;
        });

        // 预处理:飞书文件框
        doc.querySelectorAll('div.chat-uikit-multi-modal-file-image-content').forEach(multifile => {
            multifile.innerHTML = multifile.innerHTML
                .replace(/<span class="chat-uikit-file-card__info__size">(.*?)<\/span>/gi, '\n$1');
            multifile.outerHTML = `\n\`\`\`file\n${multifile.textContent}\n\`\`\`\n`;
        });

        // 预处理:表格(转为文本占位符)
        Array.from(doc.querySelectorAll('table, [role="table"], [role="grid"]')).forEach(tableNode => {
            const mdTable = tableNodeToMarkdown(tableNode);
            if (mdTable) tableNode.outerHTML = mdTable;
        });

        // 递归 DOM→Markdown 转换
        function nodeToMd(node, listDepth) {
            if (node.nodeType === Node.TEXT_NODE) {
                return node.textContent || '';
            }
            if (node.nodeType !== Node.ELEMENT_NODE) return '';

            const tag = (node.tagName || '').toLowerCase();
            if (tag === 'script' || tag === 'style' || tag === 'input') return '';

            const childMd = () =>
                Array.from(node.childNodes).map(c => nodeToMd(c, listDepth)).join('');

            // 飞书自定义 heading div
            if (tag === 'div' && node.classList.contains('heading')) {
                const text = normalizeText(node.textContent || '');
                if (!text) return '';
                for (let lv = 1; lv <= 5; lv++) {
                    if (node.classList.contains(`heading-h${lv}`)) {
                        return `\n\n${'#'.repeat(lv)} ${text}\n\n`;
                    }
                }
                return `\n\n## ${text}\n\n`;
            }

            switch (tag) {
                case 'b': case 'strong': {
                    const c = childMd().trim();
                    return c ? `**${c}**` : '';
                }
                case 'i': case 'em': {
                    const c = childMd().trim();
                    return c ? `*${c}*` : '';
                }
                case 'code':
                    return `\`${(node.textContent || '').trim()}\``;
                case 's': case 'del': case 'strike': {
                    const c = childMd().trim();
                    return c ? `~~${c}~~` : '';
                }
                case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': {
                    const level = parseInt(tag[1], 10);
                    return `\n${'#'.repeat(level)} ${childMd().trim()}\n`;
                }
                case 'p':
                    return `${childMd()}\n\n`;
                case 'br':
                    return '\n';
                case 'hr':
                    return '\n---\n';
                case 'a': {
                    const href = node.getAttribute('href') || '';
                    const text = childMd().trim() || href;
                    return href ? `[${text}](${href})` : text;
                }
                case 'img': {
                    const src = node.getAttribute('src') || '';
                    if (!src) return '';
                    const alt = (node.getAttribute('alt') || 'image').replace(/[\[\]]/g, '');
                    return `\n![${alt}](${src})\n`;
                }
                case 'blockquote': {
                    const content = childMd().trim();
                    if (!content) return '';
                    return '\n' + content.split('\n').map(line => `> ${line}`).join('\n') + '\n';
                }
                case 'ul': case 'ol': {
                    let result = '';
                    let idx = 1;
                    Array.from(node.children).forEach(child => {
                        if (!child.tagName || child.tagName.toLowerCase() !== 'li') return;
                        const inlineParts = [];
                        const nestedParts = [];
                        Array.from(child.childNodes).forEach(liChild => {
                            if (liChild.nodeType === Node.ELEMENT_NODE &&
                                ['ul', 'ol'].includes((liChild.tagName || '').toLowerCase())) {
                                nestedParts.push(nodeToMd(liChild, listDepth + 1));
                            } else {
                                inlineParts.push(nodeToMd(liChild, listDepth));
                            }
                        });
                        const checkbox = child.querySelector('input[type="checkbox"]');
                        let bullet;
                        if (checkbox) {
                            const checked = checkbox.checked ||
                                checkbox.getAttribute('checked') !== null ||
                                checkbox.getAttribute('aria-checked') === 'true';
                            bullet = `- [${checked ? 'x' : ' '}] `;
                        } else if (tag === 'ol') {
                            bullet = `${idx++}. `;
                        } else {
                            bullet = '- ';
                        }
                        const indent = '  '.repeat(listDepth);
                        const itemText = inlineParts.join('').replace(/\n+$/g, '').trim();
                        result += `${indent}${bullet}${itemText}\n`;
                        result += nestedParts.join('');
                    });
                    return `\n${result}`;
                }
                default:
                    return childMd();
            }
        }

        return normalizeText(nodeToMd(doc.body, 0));
    }

    // 等待目标DIV加载完成
    function waitForElement(selector, callback, timeoutMs = 15000) {
        const existingElement = document.querySelector(selector);
        if (existingElement) {
            callback(existingElement);
            return;
        }

        const observer = new MutationObserver(() => {
            const element = document.querySelector(selector);
            if (element) {
                observer.disconnect();
                callback(element);
            }
        });
        const observeRoot = document.body || document.documentElement;
        if (!observeRoot) return;
        observer.observe(observeRoot, { childList: true, subtree: true });

        setTimeout(() => observer.disconnect(), timeoutMs);
    }

    // 初始化数据
    const dataBlocks = new Map();
    const coveredIds = new Set();
    let isScrolling = false;
    let scanStartedAt = 0;
    const BLOCK_SELECTOR = '[data-block-id]';
    let styleInjected = false;
    let buttonResetTimer = null;
    let routeWatcherInstalled = false;
    let lastHref = window.location.href;
    const ButtonState = Object.freeze({
        IDLE: 'idle',
        SCANNING: 'scanning',
        COPYING: 'copying',
        DONE: 'done',
        ERROR: 'error',
    });
    let buttonState = ButtonState.IDLE;

    function normalizeText(text) {
        return (text || '').replace(/\u00a0/g, ' ').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '\n').replace(/\n+$/, '\n');
    }

    function getCopyButton() {
        return document.querySelector(`button#${BUTTON_ID}`);
    }

    function clearButtonResetTimer() {
        if (buttonResetTimer) {
            clearTimeout(buttonResetTimer);
            buttonResetTimer = null;
        }
    }

    function renderCopyButtonLabel(label, disabled) {
        const button = getCopyButton();
        if (!button) return;
        button.innerHTML = `${MD_ICON}${label}`;
        button.disabled = disabled;
        button.style.cursor = disabled ? 'not-allowed' : 'pointer';
    }

    function setButtonState(state, customLabel = '') {
        buttonState = state;
        const labelMap = {
            [ButtonState.IDLE]: '复制全文',
            [ButtonState.SCANNING]: '扫描中: 0.0%',
            [ButtonState.COPYING]: '写入剪贴板...',
            [ButtonState.DONE]: '已复制',
            [ButtonState.ERROR]: '复制失败',
        };
        const label = customLabel || labelMap[state] || labelMap[ButtonState.IDLE];
        const disabled = state === ButtonState.SCANNING || state === ButtonState.COPYING;
        renderCopyButtonLabel(label, disabled);
    }

    function scheduleButtonReset(delayMs = BUTTON_RESET_DELAY_MS) {
        clearButtonResetTimer();
        buttonResetTimer = setTimeout(() => {
            if (!isDocPage()) return;
            setButtonState(ButtonState.IDLE);
        }, delayMs);
    }

    function buildFallbackContent() {
        const roots = [
            '#docx',
            '[class*="docx-editor"]',
            '[class*="wiki-content"]',
            '[role="main"]',
            'main',
            'body',
        ];
        for (const selector of roots) {
            const node = document.querySelector(selector);
            if (!node) continue;
            const clone = node.cloneNode(true);
            clone.querySelectorAll(`#${BUTTON_ID}, script, style`).forEach(el => el.remove());
            const markdown = normalizeText(convertToMarkdown(clone.innerHTML || ''));
            if (markdown.length > 20) return markdown;
            const text = normalizeText(clone.innerText || clone.textContent || '');
            if (text.length > 20) return text;
        }
        return '';
    }

    function getScrollContainer() {
        const selectorCandidates = [
            '#docx > div',
            '#docx',
            '[class*="docx"][class*="container"]',
            '[class*="docx"] [class*="scroll"]',
        ];

        for (const selector of selectorCandidates) {
            const container = document.querySelector(selector);
            if (container) return container;
        }

        const firstBlock = document.querySelector(BLOCK_SELECTOR);
        let cur = firstBlock ? firstBlock.parentElement : null;
        while (cur) {
            if (cur.scrollHeight > cur.clientHeight + 20) return cur;
            cur = cur.parentElement;
        }

        return document.scrollingElement || document.documentElement;
    }

    async function copyTextToClipboard(text) {
        const payload = String(text || '');
        if (!payload.trim()) {
            throw new Error('empty-content');
        }

        let gmError = null;
        if (typeof GM_setClipboard === 'function') {
            try {
                GM_setClipboard(payload, 'text');
                return { method: 'gm' };
            } catch (error) {
                gmError = error;
                console.warn(`${SCRIPT_TAG} GM_setClipboard failed`, error);
            }
        }

        let nativeError = null;
        if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
            try {
                await navigator.clipboard.writeText(payload);
                return { method: 'native' };
            } catch (error) {
                nativeError = error;
                console.warn(`${SCRIPT_TAG} navigator.clipboard.writeText failed`, error);
            }
        }

        const reasons = [];
        if (gmError) reasons.push(`GM_setClipboard: ${gmError.message || gmError}`);
        if (nativeError) reasons.push(`navigator.clipboard: ${nativeError.message || nativeError}`);
        if (!gmError && !nativeError) reasons.push('No available clipboard API');
        throw new Error(reasons.join(' | '));
    }

    function resolveCopyErrorReason(error) {
        const msg = (error && error.message) ? error.message : String(error || '');
        if (!msg) return '未知错误';
        if (/empty-content/i.test(msg)) return '抓取结果为空,请确认文档正文已加载';
        if (/NotAllowedError|denied|permission|permissions/i.test(msg)) return '剪贴板权限被浏览器或页面策略拒绝';
        if (/No available clipboard API/i.test(msg)) return '当前环境没有可用的剪贴板接口';
        return msg;
    }

    // 列表块递归渲染:将嵌套的 bullet/ordered/todoList 块转为带缩进的 Markdown 列表
    function renderListBlock(block, depth) {
        const type = block.getAttribute('data-block-type') || '';

        // 查找所有后代块,但只处理直接子项(逻辑层级)
        // 逻辑子项的定义:其最近的父级 data-block 必须是当前 block
        const allDescendants = Array.from(block.querySelectorAll(BLOCK_SELECTOR));
        const childBlocks = allDescendants.filter(child => {
            const parentBlock = child.parentElement.closest(BLOCK_SELECTOR);
            return parentBlock === block;
        });

        // 获取块自身文本(排除子 data-block 的内容及其容器)
        // 克隆并移除所有子 data-block 元素,避免它们的内容被提取到当前层级
        const clone = block.cloneNode(true);
        // 注意:飞书的嵌套列表通常包裹在 .list-wrapper 或 .list-children 中,直接移除所有后代 block 元素即可
        clone.querySelectorAll(BLOCK_SELECTOR).forEach(c => c.remove());

        // 提取纯文本并清理
        let ownText = normalizeText(convertToMarkdown(clone.innerHTML)).replace(/\n/g, ' ').trim();
        // 清理飞书可能残留的 bullet 符号 (如 "•", "1." 等) 防止双重 bullet
        ownText = ownText.replace(/^([•●▪\-\+]|\d+\.)\s*/, '');

        // 确定列表前缀
        let bullet;
        if (/todoList/i.test(type)) {
            const checkbox = block.querySelector('input[type="checkbox"]');
            const checked = checkbox && (checkbox.checked || checkbox.getAttribute('checked') !== null || checkbox.getAttribute('aria-checked') === 'true');
            bullet = `- [${checked ? 'x' : ' '}] `;
        } else if (/ordered/i.test(type)) {
            bullet = `1. `; // Markdown 渲染器会自动排序
        } else {
            bullet = '- ';
        }

        const indent = '  '.repeat(depth);
        let result = ownText ? `${indent}${bullet}${ownText}\n` : '';

        // 递归渲染子块
        childBlocks.forEach(child => {
            const childType = child.getAttribute('data-block-type') || '';
            if (/^(bullet|ordered|todoList)$/i.test(childType)) {
                result += renderListBlock(child, depth + 1);
            } else {
                // 非列表子块(如代码块、图片等):作为缩进内容
                const childMd = normalizeText(convertToMarkdown(child.innerHTML)).replace(/\n/g, ' ').trim();
                if (childMd) {
                    result += `${'  '.repeat(depth + 1)}${childMd}\n`;
                }
            }
        });

        return result;
    }

    // 获取所有的 data-block-id 元素并存储其内容(处理嵌套块避免重复)
    function scrapeDataBlocks() {
        const blocks = document.querySelectorAll(BLOCK_SELECTOR);

        blocks.forEach((block, idx) => {
            const id = block.getAttribute('data-block-id') || `auto-${idx}`;
            if (coveredIds.has(id)) return;

            const type = block.getAttribute('data-block-type') || '';
            if (type === 'back_ref_list') return;

            // 检测代码块和表格块:虚拟渲染可能导致内容不完整,需多次采集保留最长结果
            const isCodeBlock = /^code$/i.test(type) || !!block.querySelector('div.docx-code-block-container');
            const isSheetBlock = /sheet/i.test(type);
            const isTableLike = /table/i.test(type)
                || !!block.querySelector('table, [role="table"], [role="grid"]');
            const canUpdate = isCodeBlock || isTableLike || isSheetBlock;
            const alreadyCaptured = dataBlocks.has(id);

            // 非可更新块:已采集过则跳过
            if (alreadyCaptured && !canUpdate) return;

            // 辅助函数:将所有子块标记为已覆盖,防止后续 tick 重复采集
            const markChildrenCovered = () => {
                block.querySelectorAll(BLOCK_SELECTOR).forEach(child => {
                    const childId = child.getAttribute('data-block-id');
                    if (childId) coveredIds.add(childId);
                });
            };

            // 辅助函数:仅当新内容更长时才更新(虚拟渲染逐步加载)
            const updateIfLonger = (id, newContent) => {
                if (!newContent) return;
                if (alreadyCaptured) {
                    const prev = dataBlocks.get(id) || '';
                    if (newContent.length > prev.length) {
                        dataBlocks.set(id, newContent);
                    }
                } else {
                    dataBlocks.set(id, newContent);
                }
            };

            try {
                const isQuoteBlock = /quote/i.test(type);
                const hasChildBlocks = block.querySelector(BLOCK_SELECTOR) !== null;

                if (type === 'page') {
                    return;
                }

                if (isQuoteBlock) {
                    // 引用块:收集子块内容并添加 > 前缀
                    markChildrenCovered();
                    const childBlocks = block.querySelectorAll(BLOCK_SELECTOR);
                    let quoteContent;
                    if (childBlocks.length > 0) {
                        const parts = [];
                        childBlocks.forEach(child => {
                            const childMd = normalizeText(convertToMarkdown(child.innerHTML));
                            if (childMd) parts.push(childMd);
                        });
                        quoteContent = parts.join('\n');
                    } else {
                        quoteContent = normalizeText(block.innerText || block.textContent || '');
                    }
                    if (quoteContent) {
                        const quoted = quoteContent.split('\n').map(line => `> ${line}`).join('\n');
                        dataBlocks.set(id, quoted);
                    }
                } else if (isSheetBlock) {
                    // 嵌入式电子表格:允许多次采集(Canvas 可能在后续 tick 才渲染)
                    markChildrenCovered();
                    const sheetMd = extractSheetMarkdown(block);
                    if (sheetMd) {
                        updateIfLonger(id, sheetMd);
                    } else if (!alreadyCaptured) {
                        // 首次采集失败时使用 fallback 占位,后续 tick 如果成功会覆盖
                        const fallbackText = normalizeText(block.innerText || block.textContent || '');
                        if (fallbackText) {
                            dataBlocks.set(id, fallbackText);
                        } else {
                            dataBlocks.set(id, '\n> ⚠️ [嵌入式电子表格 — 数据在 Canvas 中渲染,暂无法自动提取为文本]\n');
                        }
                    }
                } else if (isCodeBlock) {
                    // 代码块:允许多次采集保留最长内容
                    markChildrenCovered();
                    const markdown = normalizeText(convertToMarkdown(block.innerHTML));
                    updateIfLonger(id, markdown);
                } else if (isTableLike) {
                    // 表格块:允许多次采集保留最长内容(虚拟渲染可能导致首次采集不完整)
                    markChildrenCovered();
                    const tableMarkdown = normalizeText(tableNodeToMarkdown(block));
                    if (tableMarkdown) {
                        updateIfLonger(id, tableMarkdown);
                    } else {
                        const markdown = normalizeText(convertToMarkdown(block.innerHTML));
                        updateIfLonger(id, markdown);
                    }
                } else if (/^(bullet|ordered|todoList)$/i.test(type) && hasChildBlocks) {
                    // 列表块且含有子块:递归渲染为嵌套 Markdown 列表
                    markChildrenCovered();
                    const listMd = renderListBlock(block, 0);
                    if (listMd) {
                        dataBlocks.set(id, listMd);
                    }
                } else if (hasChildBlocks) {
                    // 普通容器块:跳过自身,让子块各自处理
                    return;
                } else {
                    // 叶子块
                    const markdown = normalizeText(convertToMarkdown(block.innerHTML));
                    if (markdown) dataBlocks.set(id, markdown);
                }
            } catch (error) {
                const textFallback = normalizeText(block.innerText || block.textContent || '');
                if (textFallback) dataBlocks.set(id, textFallback);
                console.warn(`${SCRIPT_TAG} parse block failed`, error);
            }
        });
    }

    // 滚动页面并获取所有的 data-block-id 元素
    function scrollAndScrape(container, onDone) {
        if (isScrolling) return;
        isScrolling = true;
        canvasCaptureEnabled = true;
        scanStartedAt = Date.now();

        const savedScrollTop = container.scrollTop;
        let currentY = Math.max(0, container.scrollTop || 0);
        let percent = 0;
        let prevScrollHeight = container.scrollHeight;
        let bottomStableRounds = 0;

        const finish = () => {
            if (!isScrolling) return;
            // 最终额外扫描:在收尾前再做两次延迟扫描,捕获最后一批 Canvas 绘制
            scrapeDataBlocks();
            setTimeout(() => {
                scrapeDataBlocks();
                isScrolling = false;
                canvasCaptureEnabled = false;
                try { container.scrollTo({ top: savedScrollTop, behavior: 'auto' }); } catch (e) { }
                if (typeof onDone === 'function') onDone();
            }, 600);
        };

        const tick = () => {
            if (!isScrolling) return;
            scrapeDataBlocks();

            if (Date.now() - scanStartedAt > SCAN_TIMEOUT_MS) {
                console.warn(`${SCRIPT_TAG} scan timeout`);
                finish();
                return;
            }

            const scrollHeight = Math.max(container.scrollHeight, 1);
            const maxScrollable = Math.max(0, scrollHeight - container.clientHeight);
            const curTop = Math.max(container.scrollTop, currentY);
            const reachedBottom = curTop >= maxScrollable - 4;

            if (reachedBottom) {
                if (scrollHeight <= prevScrollHeight + 4) {
                    bottomStableRounds += 1;
                } else {
                    bottomStableRounds = 0;
                }
                if (bottomStableRounds >= 2) {
                    finish();
                    return;
                }
            } else {
                bottomStableRounds = 0;
            }
            prevScrollHeight = scrollHeight;

            // 步长取视口 50%(而非 66%),确保相邻两次 tick 有足够重叠,
            // 让虚拟渲染有时间加载表格等复杂块
            const step = Math.max(200, Math.floor(container.clientHeight * 0.5));
            currentY = Math.min(maxScrollable, curTop + step);
            container.scrollTo({ top: currentY, behavior: 'auto' });

            const curPercent = ((currentY + container.clientHeight) / scrollHeight) * 100;
            percent = Math.max(percent, Math.min(100, curPercent));
            setButtonState(ButtonState.SCANNING, `扫描中: ${percent.toFixed(1)}%`);

            // 延迟 400ms 后再做一次扫描,给 Canvas 元素留出绘制时间
            setTimeout(() => {
                if (isScrolling) scrapeDataBlocks();
            }, 400);

            setTimeout(tick, 850);
        };

        setButtonState(ButtonState.SCANNING, '扫描中: 0.0%');
        setTimeout(tick, 300);
    }

    // 点击一键复制事件
    function CopyAllListener(event) {
        if (event) {
            event.preventDefault();
            event.stopPropagation();
            if (typeof event.stopImmediatePropagation === 'function') {
                event.stopImmediatePropagation();
            }
        }

        if (isScrolling || buttonState === ButtonState.SCANNING || buttonState === ButtonState.COPYING) {
            alert('正在扫描中,请稍候再试。');
            return;
        }

        clearButtonResetTimer();
        dataBlocks.clear();
        coveredIds.clear();
        setButtonState(ButtonState.SCANNING, '扫描中: 0.0%');

        const container = getScrollContainer();
        if (!container) {
            setButtonState(ButtonState.ERROR, '未找到正文');
            alert('未找到文档滚动容器,请确认当前页面为飞书文档正文页面。');
            scheduleButtonReset();
            return;
        }

        scrollAndScrape(container, async () => {
            scrapeDataBlocks();
            let allContent = normalizeText(Array.from(dataBlocks.values()).join('\n\n'));
            if (!allContent) {
                allContent = buildFallbackContent();
            }
            if (!allContent) {
                setButtonState(ButtonState.ERROR, '未抓到内容');
                alert('未抓到内容,请确认文档正文已加载后重试。');
                scheduleButtonReset();
                return;
            }

            setButtonState(ButtonState.COPYING, '写入剪贴板...');
            try {
                const result = await copyTextToClipboard(allContent);
                const methodLabel = result.method === 'gm' ? 'GM剪贴板' : '浏览器剪贴板';
                alert(`已复制全文到剪贴板(${allContent.length} 字符,${methodLabel})`);
                setButtonState(ButtonState.DONE, '已复制');
                scheduleButtonReset();
            } catch (error) {
                console.warn(`${SCRIPT_TAG} copy-all failed`, error);
                const reason = resolveCopyErrorReason(error);
                alert(`复制失败:${reason}`);
                setButtonState(ButtonState.ERROR, '复制失败');
                scheduleButtonReset(3200);
            }
        });
    }

    // 创建复制按钮
    function createCopyButton(forceIdle = true) {
        if (!isDocPage()) {
            const existed = getCopyButton();
            if (existed) existed.remove();
            return;
        }

        if (!document.body) return;

        let button = getCopyButton();
        if (!button) {
            button = document.createElement('button');
            button.id = BUTTON_ID;
            button.innerHTML = `${MD_ICON}复制全文`;
            document.body.appendChild(button);

            if (!styleInjected) {
                addStyle(`
            #${BUTTON_ID} {
                position: fixed;
                top: 15px;
                left: 50%;
                transform: translateX(-50%);
                padding: 6px 18px;
                font-size: 16px;
                background: #007bff;
                color: white;
                border: none;
                border-radius: 5px;
                cursor: pointer;
                z-index: 2147483647;
                display: flex;
                place-items: center;
                box-shadow: 0 0 3px #1117;
            }
            #${BUTTON_ID}:hover {
                background: #0056b3;
            }
           `);
                styleInjected = true;
            }

            button.addEventListener('click', CopyAllListener);
        }

        if (forceIdle) {
            setButtonState(ButtonState.IDLE);
        } else {
            setButtonState(buttonState);
        }
    }

    function bootstrapButton() {
        createCopyButton(true);
        waitForElement('#docx > div, #docx, div[data-block-id]', () => {
            if (buttonState !== ButtonState.IDLE && getCopyButton()) return;
            if (buttonState === ButtonState.IDLE) {
                createCopyButton(true);
            } else {
                createCopyButton(false);
            }
        }, 60000);
    }

    function bootstrapGuards() {
        if (!isDocPage()) return;
        installWatermarkRemoval();
        installCopyBypass();
    }

    function handleRouteChange() {
        if (window.location.href === lastHref) return;
        lastHref = window.location.href;
        isScrolling = false;
        dataBlocks.clear();
        coveredIds.clear();
        clearButtonResetTimer();
        console.log(`${SCRIPT_TAG} route changed: ${lastHref}`);
        setTimeout(() => {
            bootstrapGuards();
            bootstrapButton();
        }, 350);
    }

    function installRouteWatcher() {
        if (routeWatcherInstalled) return;
        routeWatcherInstalled = true;

        const wrapHistory = (methodName) => {
            const raw = history[methodName];
            if (typeof raw !== 'function') return;
            history[methodName] = function (...args) {
                const result = raw.apply(this, args);
                handleRouteChange();
                return result;
            };
        };

        wrapHistory('pushState');
        wrapHistory('replaceState');
        window.addEventListener('popstate', handleRouteChange);
        window.addEventListener('hashchange', handleRouteChange);
        setInterval(handleRouteChange, 2500);
    }

    // 主函数
    console.log(`${SCRIPT_TAG} injected: ${window.location.href}`);
    bootstrapGuards();
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', bootstrapButton, { once: true });
    } else {
        bootstrapButton();
    }
    installRouteWatcher();

})();