feishu Markdown

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         feishu Markdown
// @namespace    http://tampermonkey.net/
// @version      1.4.1
// @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 WRAPPER_ID = 'feishuMdBtnGroup';
    const BUTTON_ID = 'scrollCopyButton';
    const DOWNLOAD_BUTTON_ID = 'scrollDownloadButton';
    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 DL_ICON = '<svg xmlns="http://www.w3.org/2000/svg" style="height:15px; padding-right:5px; fill:#fff; display:inline;" viewBox="0 0 512 512"><path d="M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32v242.7l-73.4-73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l128-128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L288 274.7V32zM64 352c-35.3 0-64 28.7-64 64v32c0 35.3 28.7 64 64 64h384c35.3 0 64-28.7 64-64v-32c0-35.3-28.7-64-64-64H346.5l-45.3 45.3c-25 25-65.5 25-90.5 0L165.5 352H64zm368 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/></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;

    // === 下载点击保护 ===
    // 在 capture 阶段拦截我们的下载链接点击,阻止飞书的"导出权限"检查器接收到事件
    // @run-at document-start 保证此 listener 比飞书的脚本更早注册,在 capture 阶段优先触发
    document.addEventListener('click', function (e) {
        var el = e.target;
        while (el && el !== document) {
            if (el.getAttribute && el.getAttribute('data-feishumd-download') === '1') {
                e.stopImmediatePropagation();
                e.stopPropagation();
                // 不调用 preventDefault —— 让浏览器正常处理下载
                return;
            }
            el = el.parentElement;
        }
    }, { capture: true });

    // === 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);
        }
        isUnlocking = true;
        try {
            batch.forEach((node) => {
                if (!node || node.nodeType !== 1) return;
                unlockElementSelection(node);
            });
        } 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;

            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;
                }
                #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;
                }
                #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;
                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) => {
                if (isUnlocking) return;
                for (const mutation of mutations) {
                    mutation.addedNodes.forEach(queueUnlockNode);
                }
            });
            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();
        }
    }

    // 去除零宽/方向控制 Unicode 字符(飞书内容中大量存在)
    function stripInvisible(text) {
        return (text || '').replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g, '');
    }

    function sanitizeTableCell(text) {
        return stripInvisible(text || '')
            .replace(/\|/g, '\\|')
            .replace(/\n+/g, '<br>')              // GFM 支持 <br> 多行单元格
            .replace(/[ \t]+/g, ' ')              // 普通空白压缩,但保留 <br>
            .replace(/^(?:<br>)+|(?:<br>)+$/g, '') // 去除首尾空行产生的 <br>
            .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 = [];
        const rowspanTracker = {};
        rowNodes.forEach(rowNode => {
            const allCells = rowNode.querySelectorAll(cellSelector);
            const cells = Array.from(allCells).filter(cell => {
                // 1) 跳过隐藏的占位 TD(Feishu 的 rowspan 占位实现:保留全部 TD 但对被跨越的格子设 display:none)
                if (cell.style && cell.style.display === 'none') return false;
                // 2) 确保 cell 是当前 row 的直接单元格,排除嵌套表格
                let parent = cell.parentElement;
                while (parent && parent !== rowNode) {
                    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) {
                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 || ' ') : ' ');
                    // 对 colspan+rowspan 合并单元格,必须为每一列都注册 rowspan,
                    // 这样后续行在这些列位置都会被填充占位符
                    if (rowspan > 1) rowspanTracker[colIdx] = rowspan - 1;
                    colIdx++;
                }
                cellIdx++;
            }
            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 = [];
        const htmlRows = tableNode.querySelectorAll('tr');
        if (htmlRows.length) rows = extractRowsFromNodes(htmlRows, 'th, td');
        if (!rows.length) {
            const roleRows = tableNode.querySelectorAll('[role="row"]');
            if (roleRows.length) rows = extractRowsFromNodes(roleRows, '[role="columnheader"], [role="gridcell"], [role="cell"]');
        }
        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"]');
        }
        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"]');
        }
        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);
    }

    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;
            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;
            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;
            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;
            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;
            }
            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]);
            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;
            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;
            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) {
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, 'text/html');

        // 预处理:表格 **必须在代码块预处理之前**
        // 原因:代码块预处理会给 code 块加上 ``` 包裹,如果表格单元格内含代码块,
        // 后续表格提取 innerText 时会读到字面 ``` 字符,破坏表格格式
        // **reverse()** 处理嵌套表格:内层先替换,外层再提取 innerText 时就得到纯文本
        Array.from(doc.querySelectorAll('table, [role="table"], [role="grid"]')).reverse().forEach(tableNode => {
            const mdTable = tableNodeToMarkdown(tableNode);
            if (mdTable) tableNode.outerHTML = mdTable;
        });

        // 预处理:飞书代码块
        doc.querySelectorAll('div.docx-code-block-container > div.docx-code-block-inner-container').forEach(codearea => {
            const header = codearea.querySelector('div.code-block-header .code-block-header-btn-con');
            const rawLang = header ? (header.textContent || '').trim() : '';
            // 标准化语言标识:lowercase + 去空格 + 常见别名映射
            const langMap = {
                'plaintext': '', 'plain text': '', 'text': '',
                'c++': 'cpp', 'c#': 'csharp', 'objective-c': 'objc',
                'shell': 'bash', 'sh': 'bash',
            };
            const langKey = rawLang.toLowerCase();
            const language = (langMap[langKey] !== undefined ? langMap[langKey] : langKey.replace(/\s+/g, ''));
            const codeHeader = codearea.querySelector('div.code-block-header');
            if (codeHeader) codeHeader.remove();
            // 去除 fold 控制器、零宽占位等装饰元素
            codearea.querySelectorAll('.code-block-fold-controller--wrapper, .docx-block-zero-space, [data-void="true"]').forEach(el => el.remove());
            codearea.querySelectorAll('span[data-enter="true"]').forEach(enterSpan => {
                enterSpan.outerHTML = '\n';
            });
            const codeContent = stripInvisible(codearea.textContent || '').replace(/\n+$/, '');
            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';
        });

        // 递归 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' || tag === 'iframe' || tag === 'svg') return '';
            // 跳过飞书装饰元素:bullet/ordered 标号、code-block 头部、fold 控制等
            if (node.classList && (
                node.classList.contains('bullet') ||
                node.classList.contains('bullet-dot-style') ||
                node.classList.contains('ordered-dot-style') ||
                node.classList.contains('heading-order') ||
                node.classList.contains('code-block-header') ||
                node.classList.contains('code-block-fold-controller--wrapper') ||
                node.classList.contains('fold-wrapper') ||
                node.classList.contains('docx-block-zero-space') ||
                node.classList.contains('gpf-at-user-avatar')  // @mention 头像图片容器
            )) return '';
            // Feishu @user mention:只保留 @姓名 纯文本
            if (node.classList && node.classList.contains('at-user-embed-container')) {
                const nameEl = node.querySelector('.gpf-at-user-name, .embed-text-container');
                const name = nameEl ? (nameEl.textContent || '').replace(/[\u200b-\u200f\ufeff]/g, '').trim() : '';
                return name ? '@' + name : '';
            }
            // 飞书的 contenteditable=false 容器通常是装饰元素(如 bullet 圆点、调整手柄)
            if (tag === 'button' && node.classList && (node.classList.contains('order') || node.classList.contains('heading-order'))) return '';

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

            // 飞书 ace-line:每行末尾添加 \n(ace-line 是 Feishu 的视觉行单位)
            if (tag === 'div' && node.classList.contains('ace-line')) {
                return childMd() + '\n';
            }

            // 飞书 data-enter span:行结束标记,转为 \n
            if (tag === 'span' && node.getAttribute('data-enter') === 'true') return '\n';
            // 飞书 data-zero-space span:零宽占位,丢弃
            if (tag === 'span' && node.getAttribute('data-zero-space') === 'true') return '';

            // 飞书自定义 heading div
            if (tag === 'div' && node.classList.contains('heading')) {
                // 取 .heading-content 内的文本 + 单独读取 button.heading-order 的自动编号前缀
                const contentEl = node.querySelector('.heading-content') || node;
                const text = Array.from(contentEl.childNodes)
                    .map(c => nodeToMd(c, listDepth)).join('')
                    .replace(/\n+/g, ' ').trim();
                if (!text) return '';
                const numBtn = node.querySelector('button.heading-order');
                const numRaw = numBtn ? (numBtn.textContent || '').replace(/[\u200b-\u200f\ufeff]/g, '').trim() : '';
                const numText = numRaw ? (/[^\d.]/.test(numRaw) ? numRaw : (numRaw.endsWith('.') ? numRaw : numRaw + '.')) : '';
                let lv = 2;
                for (let i = 1; i <= 9; i++) {
                    if (node.classList.contains('heading-h' + i)) { lv = Math.min(i, 6); break; }
                }
                return '\n\n' + '#'.repeat(lv) + ' ' + (numText ? numText + ' ' : '') + text + '\n\n';
            }

            // 飞书 inline 格式:<span style="font-weight:bold;..."> 等
            if (tag === 'span') {
                const style = node.getAttribute('style') || '';
                const inner = childMd();
                if (!inner) return '';
                const trimmed = inner.replace(/\s+/g, '');
                if (!trimmed) return inner; // 纯空白,原样返回
                let result = inner;
                // 顺序:strike/underline → italic → bold(嵌套从内到外包裹)
                if (/text-decoration[^;]*line-through/i.test(style)) result = '~~' + result + '~~';
                if (/text-decoration[^;]*underline/i.test(style)) result = '<u>' + result + '</u>';
                if (/font-style\s*:\s*italic/i.test(style)) result = '*' + result + '*';
                if (/font-weight\s*:\s*(bold|[6-9]\d\d)/i.test(style)) result = '**' + result + '**';
                // 高亮(背景色)→ <mark>
                if (/background(?:-color)?\s*:\s*(rgb|#)/i.test(style) && !/background[^;]*transparent/i.test(style)) {
                    result = '<mark>' + result + '</mark>';
                }
                return result;
            }

            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 'u': { const c = childMd().trim(); return c ? '<u>' + c + '</u>' : ''; }
                case 'code': return '`' + (node.textContent || '').trim() + '`';
                case 's': case 'del': case 'strike': { const c = childMd().trim(); return c ? '~~' + c + '~~' : ''; }
                case 'sup': { const c = childMd().trim(); return c ? '<sup>' + c + '</sup>' : ''; }
                case 'sub': { const c = childMd().trim(); return c ? '<sub>' + c + '</sub>' : ''; }
                case 'mark': { 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': {
                    // 跳过 mention 头像
                    if (node.closest && (node.closest('.at-user-embed-container') || node.closest('.gpf-at-user-avatar'))) return '';
                    const alt = (node.getAttribute('alt') || 'image').replace(/[\[\]]/g, '');
                    // 优先从祖先 .image-block 上的 image-token 构造稳定 URL(避开 blob:)
                    const imageBlock = node.closest ? node.closest('.image-block[image-token]') : null;
                    if (imageBlock) {
                        const token = imageBlock.getAttribute('image-token');
                        const recordBlock = node.closest('[data-record-id]');
                        const mountNode = recordBlock ? (recordBlock.getAttribute('data-record-id') || '') : '';
                        if (token) {
                            const stableUrl = 'https://internal-api-drive-stream.feishu.cn/space/api/box/stream/download/v2/cover/' +
                                encodeURIComponent(token) + '/?mount_node_token=' + encodeURIComponent(mountNode) +
                                '&mount_point=docx_image&policy=equal';
                            return '\n![' + alt + '](' + stableUrl + ')\n';
                        }
                    }
                    const src = node.getAttribute('src') || '';
                    // blob: URL 在会话外无效,丢弃
                    if (!src || src.indexOf('blob:') === 0) return '';
                    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 = '- ';
                        }
                        // 4 空格缩进:与 renderListBlock 保持一致,符合 CommonMark + Typora
                        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));
    }

    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(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g, '')  // 移除零宽/方向控制字符
            .replace(/\u00a0/g, ' ')                                           // NBSP → 普通空格
            .replace(/\n{3,}/g, '\n\n')
            .replace(/^\n+/, '\n')
            .replace(/\n+$/, '\n');
    }

    // 判断一行是否是列表项(以 - / * / N. / - [x] 等开头,允许前导空格表示缩进)
    function isListLine(line) {
        return /^\s*(?:[-*+]|\d+\.)\s/.test(line || '');
    }

    // 智能拼接块条目:相邻列表项之间用单换行(保持列表连续性),其它用双换行(段落分隔)
    // 关键:检查 prev 的**最后一行**和 cur 的**第一行**,而非整个字符串的起始
    function joinBlockEntries(entries) {
        if (!entries || !entries.length) return '';
        let result = entries[0];
        for (let i = 1; i < entries.length; i++) {
            const prev = entries[i - 1];
            const cur = entries[i];
            // prev 可能是多行(嵌套列表),取最后一行有内容的
            const prevLines = prev.split('\n').filter(l => l.length > 0);
            const prevLastLine = prevLines[prevLines.length - 1] || '';
            const curFirstLine = cur.split('\n')[0] || '';
            const sep = (isListLine(prevLastLine) && isListLine(curFirstLine)) ? '\n' : '\n\n';
            result += sep + cur;
        }
        return result;
    }

    function getWrapper() { return document.getElementById(WRAPPER_ID); }
    function getCopyButton() { return document.getElementById(BUTTON_ID); }
    function getDownloadButton() { return document.getElementById(DOWNLOAD_BUTTON_ID); }

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

    function renderButtonLabels(copyLabel, dlLabel, disabled) {
        const copyBtn = getCopyButton();
        const dlBtn = getDownloadButton();
        if (copyBtn) {
            copyBtn.innerHTML = MD_ICON + (copyLabel || '复制全文');
            copyBtn.disabled = disabled;
            copyBtn.style.cursor = disabled ? 'not-allowed' : 'pointer';
        }
        if (dlBtn) {
            dlBtn.innerHTML = DL_ICON + (dlLabel || '下载MD');
            dlBtn.disabled = disabled;
            dlBtn.style.cursor = disabled ? 'not-allowed' : 'pointer';
        }
    }

    function setButtonState(state, customLabel) {
        buttonState = state;
        switch (state) {
            case ButtonState.IDLE:
                renderButtonLabels('复制全文', '下载MD', false);
                break;
            case ButtonState.SCANNING:
                renderButtonLabels(customLabel || '扫描中: 0.0%', customLabel || '扫描中...', true);
                break;
            case ButtonState.COPYING:
                renderButtonLabels('写入剪贴板...', '写入中...', true);
                break;
            case ButtonState.DONE:
                renderButtonLabels(customLabel || '已复制', customLabel || '已下载', false);
                break;
            case ButtonState.ERROR:
                renderButtonLabels(customLabel || '复制失败', customLabel || '下载失败', false);
                break;
            default:
                renderButtonLabels('复制全文', '下载MD', false);
        }
    }

    function scheduleButtonReset(delayMs) {
        if (delayMs === undefined) 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('#' + WRAPPER_ID + ', script, style').forEach(el => el.remove());
            const markdown = 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;
    }

    function getDocTitle() {
        // 优先从 DOM 标题元素读取纯净文本(避免 document.title 中的不可见 Unicode 字符)
        var selectors = [
            '#docx [data-block-type="title"]',
            '#docx h1:first-of-type',
            '[class*="docx-editor"] [data-block-type="title"]',
            '[class*="wiki-content"] [data-block-type="title"]',
            '.suite-title-input',
            '[data-testid="doc-title"]',
        ];
        for (var i = 0; i < selectors.length; i++) {
            var el = document.querySelector(selectors[i]);
            if (el) {
                var text = (el.textContent || el.innerText || '').trim();
                if (text.length > 0) return text;
            }
        }
        // 回退:从 document.title 提取
        return (document.title || '')
            .replace(/\s*[-\u2013\u2014]\s*(飞书云文档|飞书文档|Lark\s*Docs?|飞书|Lark)\s*$/i, '')
            .replace(/\s*[-\u2013\u2014]\s*$/, '')
            .trim();
    }

    function sanitizeFilename(name) {
        // 1) 先移除在 Windows/macOS 均非法的字符(含半角和全角对应字符)
        //    半角: \ / : * ? " < > |    全角: \ / : * ? " < > |
        const illegalRe = /[\\/:*?"<>|\uff3c\uff0f\uff1a\uff0a\uff1f\uff02\uff1c\uff1e\uff5c]/g;
        // 2) 只保留:字母数字、中日韩文字、CJK 标点、基本标点、空格、连字符等
        return name
            .replace(illegalRe, '')
            .replace(/[^\w\s\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef.()\[\]\u2010-\u2015-]/g, '')
            .replace(/\s+/g, ' ')
            .trim();
    }

    function downloadMarkdownFile(content) {
        var rawTitle = getDocTitle();
        var title = sanitizeFilename(rawTitle) || 'document';
        var filename = title + '.md';
        // 用页面原始上下文创建 Blob,避免 Tampermonkey 沙箱包装导致潜在的兼容问题
        var realWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        var blob = new realWin.Blob([content], { type: 'application/octet-stream' });
        var url = realWin.URL.createObjectURL(blob);
        var a = document.createElement('a');
        a.href = url;
        a.download = filename;
        a.style.display = 'none';
        a.setAttribute('data-feishumd-download', '1');
        document.body.appendChild(a);
        a.click();
        console.log(SCRIPT_TAG + ' download triggered: ' + filename);
        setTimeout(function () {
            if (a.parentNode) a.parentNode.removeChild(a);
            realWin.URL.revokeObjectURL(url);
        }, 3000);
        return filename;
    }

    // 渲染 Feishu 列表块(bullet / ordered / todoList)为带缩进的 Markdown
    // Feishu 结构特点:
    //  1. bullet 块内有 .bullet-dot-style (▪/•/◦),需剥离
    //  2. ordered 块内有 <button class="order button">a./b./i./ii.</button>,需剥离
    //  3. 嵌套子列表作为当前块的 [data-block-id] 直接后代存在
    function renderListBlock(block, depth) {
        const type = block.getAttribute('data-block-type') || '';
        // 1. 找到当前块的"直接"子列表块(不跨层级)
        const childBlocks = Array.from(block.querySelectorAll(BLOCK_SELECTOR)).filter(child => {
            const parentBlock = child.parentElement && child.parentElement.closest(BLOCK_SELECTOR);
            return parentBlock === block;
        });
        // 2. 克隆并移除所有 data-block-id 子节点 + Feishu 标签元素(避免 ▪/a./b. 混入文本)
        const clone = block.cloneNode(true);
        clone.querySelectorAll(BLOCK_SELECTOR).forEach(c => c.remove());
        // 只移除 Feishu 列表项的标号装饰元素(避免误删 inline code/公式等合法内容)
        clone.querySelectorAll(
            '.bullet, .bullet-dot-style, button.order, button.bullet, .order.button, .ordered-dot-style'
        ).forEach(c => c.remove());
        // 3. 提取本块自身文本
        let ownText = convertToMarkdown(clone.innerHTML).replace(/\n+/g, ' ').trim();
        // 防御性兜底:若仍残留 Feishu 前缀符号(▪ • ◦ / a. / 1.),再剥一次
        ownText = ownText.replace(/^([•●▪◦○◆◇■□]|[a-zA-Z]+\.|\d+\.)\s*/, '');

        // 4. 决定当前列表前缀
        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 = '- ';
        }
        // 嵌套缩进用 4 空格:CommonMark 标准 + Typora/大多数渲染器的默认要求
        // (2 空格对 `1. ` 类 ordered 列表不够,会导致嵌套项被识别为独立列表)
        const INDENT_UNIT = '    ';
        const indent = INDENT_UNIT.repeat(depth);
        let result = ownText ? (indent + bullet + ownText + '\n') : '';

        // 5. 递归渲染子列表块(保留每一行的尾部 \n,避免兄弟项粘连)
        childBlocks.forEach(child => {
            const childType = child.getAttribute('data-block-type') || '';
            if (/^(bullet|ordered|todoList)$/i.test(childType)) {
                const nested = renderListBlock(child, depth + 1);
                // renderListBlock 返回已去尾 \n 的字符串,递归拼接时补回 \n
                if (nested) result += nested + '\n';
            } else {
                // 非列表子块(如代码块、图片):作为缩进段落
                const childMd = convertToMarkdown(child.innerHTML).replace(/\n+/g, ' ').trim();
                if (childMd) result += INDENT_UNIT.repeat(depth + 1) + childMd + '\n';
            }
        });
        // 去除尾部多余换行,方便外层拼接时决定分隔符
        return result.replace(/\n+$/, '');
    }

    // 显式提取 Feishu heading 文本,保留 Feishu 自动编号 (1, 1.1, 1.1.1 等)
    function extractHeadingMarkdown(block, level) {
        const contentEl = block.querySelector('.heading-content') || block.querySelector('.ace-line');
        if (!contentEl) return '';
        // 用 convertToMarkdown 处理子树,保留内部的 inline 格式
        const md = convertToMarkdown(contentEl.innerHTML).replace(/\n+/g, ' ').trim();
        if (!md) return '';
        const lv = Math.max(1, Math.min(6, level || 2));
        // Feishu 在 .heading-order button 里渲染自动编号("1"、"1.1"、"2.3.1" 等)
        const numBtn = block.querySelector('button.heading-order');
        const numRaw = numBtn ? stripInvisible((numBtn.textContent || '').trim()) : '';
        // 编号格式归一:如果缺少尾部点号且是纯数字/点号组成,添加尾部点号便于阅读
        const numText = numRaw ? (/[^\d.]/.test(numRaw) ? numRaw : (numRaw.endsWith('.') ? numRaw : numRaw + '.')) : '';
        const prefix = numText ? numText + ' ' : '';
        return '#'.repeat(lv) + ' ' + prefix + md;
    }

    // 提取 Feishu 图片块 → Markdown
    // 优先使用 image-token + record-id 构造稳定 URL(避开 Feishu 懒加载切换成 blob: 的临时 URL)
    function extractImageMarkdown(block) {
        if (block.closest('.at-user-embed-container')) return '';
        const img = block.querySelector('img');
        if (!img) return '';
        if (img.closest('.at-user-embed-container') || img.closest('.gpf-at-user-avatar')) return '';
        const alt = (img.getAttribute('alt') || 'image').replace(/[\[\]]/g, '');
        // 优先从 .image-block 的 image-token 属性构造稳定的 Feishu URL
        const imageBlock = block.querySelector('.image-block[image-token]');
        const token = imageBlock ? imageBlock.getAttribute('image-token') : null;
        const mountNode = block.getAttribute('data-record-id') || '';
        if (token) {
            const stableUrl = 'https://internal-api-drive-stream.feishu.cn/space/api/box/stream/download/v2/cover/' +
                encodeURIComponent(token) + '/?mount_node_token=' + encodeURIComponent(mountNode) +
                '&mount_point=docx_image&policy=equal';
            return '\n![' + alt + '](' + stableUrl + ')\n';
        }
        // Fallback:用 img.src,如果是 blob: 则跳过(blob URL 在会话外无效)
        const src = img.getAttribute('src') || '';
        if (!src || src.indexOf('blob:') === 0) return '';
        return '\n![' + alt + '](' + src + ')\n';
    }

    function scrapeDataBlocks() {
        const blocks = document.querySelectorAll(BLOCK_SELECTOR);
        blocks.forEach((block, idx) => {
            const id = block.getAttribute('data-block-id');
            if (!id) return; // 跳过无 ID 的块,避免 auto-N 索引在多次扫描间漂移导致内容重复
            if (coveredIds.has(id)) return;
            const type = block.getAttribute('data-block-type') || '';
            if (type === 'back_ref_list') return;
            // 类型判断必须严格基于 block 自身 type,不能用 querySelector 匹配后代——
            // 否则 Feishu 的 text/heading wrapper 块(里面嵌套有表格/代码)会被误判,
            // 进而走到 code/table 分支并 markChildrenCovered,导致内部真正的 heading/table 被跳过
            const isCodeBlock = /^code$/i.test(type);
            const isSheetBlock = /sheet/i.test(type);
            const isTableLike = /^table$/i.test(type);
            const isListBlock = /^(bullet|ordered|todoList)$/i.test(type);
            // 列表块需要允许重复处理:虚拟渲染可能导致嵌套子项被独立捕获在父项未渲染前
            const canUpdate = isCodeBlock || isTableLike || isSheetBlock || isListBlock;
            const alreadyCaptured = dataBlocks.has(id);
            if (alreadyCaptured && !canUpdate) return;

            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 {
                // 严格匹配 quote 相关类型,避免误匹配(如 "block_quote_code" 等假想名称)
                const isQuoteBlock = /^quote(_container)?$/i.test(type);
                const isDivider = /^divider$/i.test(type);
                const hasChildBlocks = block.querySelector(BLOCK_SELECTOR) !== null;
                if (type === 'page') return;

                // 分割线块:飞书用 div 渲染而非 <hr>,需要显式处理
                if (isDivider) {
                    dataBlocks.set(id, '\n---\n');
                    return;
                }

                // 标题块:显式提取,避开 button.heading-order 自动编号
                const headingMatch = /^heading([1-9])$/i.test(type);
                if (headingMatch) {
                    const level = parseInt(type.replace(/^heading/i, ''), 10);
                    const md = extractHeadingMarkdown(block, level);
                    if (md) dataBlocks.set(id, md);
                    return;
                }

                // 图片块:用 canvas 提取为 data URL,避免外部环境无法显示
                if (/^image$/i.test(type)) {
                    const md = extractImageMarkdown(block);
                    if (md) dataBlocks.set(id, md);
                    return;
                }

                if (isQuoteBlock) {
                    markChildrenCovered();
                    const childBlocks = block.querySelectorAll(BLOCK_SELECTOR);
                    let quoteContent;
                    if (childBlocks.length > 0) {
                        const parts = [];
                        childBlocks.forEach(child => {
                            const childMd = convertToMarkdown(child.innerHTML);
                            if (childMd) parts.push(childMd);
                        });
                        quoteContent = parts.join('\n');
                    } else {
                        quoteContent = normalizeText(block.innerText || block.textContent || '');
                    }
                    if (quoteContent) {
                        dataBlocks.set(id, quoteContent.split('\n').map(line => '> ' + line).join('\n'));
                    }
                } else if (isSheetBlock) {
                    markChildrenCovered();
                    const sheetMd = extractSheetMarkdown(block);
                    if (sheetMd) {
                        updateIfLonger(id, sheetMd);
                    } else if (!alreadyCaptured) {
                        const fallbackText = normalizeText(block.innerText || block.textContent || '');
                        dataBlocks.set(id, fallbackText || '\n> ⚠️ [嵌入式电子表格 — 数据在 Canvas 中渲染,暂无法自动提取为文本]\n');
                    }
                } else if (isCodeBlock) {
                    markChildrenCovered();
                    updateIfLonger(id, convertToMarkdown(block.innerHTML));
                } else if (isTableLike) {
                    markChildrenCovered();
                    const tableMarkdown = normalizeText(tableNodeToMarkdown(block));
                    updateIfLonger(id, tableMarkdown || convertToMarkdown(block.innerHTML));
                } else if (isListBlock) {
                    // 列表块处理(关键:处理虚拟渲染导致的嵌套错位)
                    // 1. 如果当前块在 DOM 中是另一个列表块的后代 → 跳过,由父列表块统一渲染
                    const listAncestor = block.parentElement &&
                        block.parentElement.closest('[data-block-type="bullet"],[data-block-type="ordered"],[data-block-type="todoList"]');
                    if (listAncestor) {
                        coveredIds.add(id);
                        dataBlocks.delete(id); // 清理之前可能独立捕获的条目
                        return;
                    }
                    // 2. 作为顶层列表块处理:清理所有 DOM 后代块的历史条目
                    //    (虚拟渲染可能在父块未渲染时独立捕获子项)
                    block.querySelectorAll(BLOCK_SELECTOR).forEach(child => {
                        const cid = child.getAttribute('data-block-id');
                        if (cid) {
                            dataBlocks.delete(cid);
                            coveredIds.add(cid);
                        }
                    });
                    const listMd = renderListBlock(block, 0);
                    if (listMd) dataBlocks.set(id, listMd);
                } else if (/^grid$/i.test(type)) {
                    // 分栏容器:让内部 grid_column 各自处理子块
                    return;
                } else if (/^grid_column$/i.test(type)) {
                    // 分栏列容器:让内部子块各自处理
                    return;
                } else if (hasChildBlocks) {
                    return;
                } else {
                    const markdown = 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);
            }
        });
    }

    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;
            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;
            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) + '%');
            setTimeout(() => { if (isScrolling) scrapeDataBlocks(); }, 400);
            setTimeout(tick, 850);
        };

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

    // 共享扫描逻辑:扫描完成后回调 onComplete(allContent)
    function scanAndCollect(event, onComplete) {
        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, () => {
            scrapeDataBlocks();
            let allContent = normalizeText(joinBlockEntries(Array.from(dataBlocks.values())));
            if (!allContent) allContent = buildFallbackContent();
            if (!allContent) {
                setButtonState(ButtonState.ERROR, '未抓到内容');
                alert('未抓到内容,请确认文档正文已加载后重试。');
                scheduleButtonReset();
                return;
            }
            onComplete(allContent);
        });
    }

    function CopyAllListener(event) {
        scanAndCollect(event, async (allContent) => {
            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);
                alert('复制失败:' + resolveCopyErrorReason(error));
                setButtonState(ButtonState.ERROR);
                scheduleButtonReset(3200);
            }
        });
    }

    function DownloadListener(event) {
        scanAndCollect(event, async (allContent) => {
            setButtonState(ButtonState.COPYING);
            var clipboardOk = false;
            try {
                await copyTextToClipboard(allContent);
                clipboardOk = true;
            } catch (e) {
                console.warn(SCRIPT_TAG + ' clipboard copy during download failed', e);
            }
            try {
                const filename = downloadMarkdownFile(allContent);
                var msg = '已下载 ' + filename + '(' + allContent.length + ' 字符)';
                if (clipboardOk) msg += ',内容同时已复制到剪贴板';
                alert(msg);
                setButtonState(ButtonState.DONE);
                scheduleButtonReset();
            } catch (error) {
                console.warn(SCRIPT_TAG + ' download failed', error);
                alert('下载失败:' + (error.message || error));
                setButtonState(ButtonState.ERROR);
                scheduleButtonReset(3200);
            }
        });
    }

    function createButtons(forceIdle) {
        if (forceIdle === undefined) forceIdle = true;
        if (!isDocPage()) {
            const existed = getWrapper();
            if (existed) existed.remove();
            return;
        }
        if (!document.body) return;
        let wrapper = getWrapper();
        if (!wrapper) {
            wrapper = document.createElement('div');
            wrapper.id = WRAPPER_ID;

            var copyBtn = document.createElement('button');
            copyBtn.id = BUTTON_ID;
            copyBtn.innerHTML = MD_ICON + '复制全文';
            copyBtn.addEventListener('click', CopyAllListener);

            var dlBtn = document.createElement('button');
            dlBtn.id = DOWNLOAD_BUTTON_ID;
            dlBtn.innerHTML = DL_ICON + '下载MD';
            dlBtn.addEventListener('click', DownloadListener);

            wrapper.appendChild(copyBtn);
            wrapper.appendChild(dlBtn);
            document.body.appendChild(wrapper);

            if (!styleInjected) {
                addStyle(
                    '#' + WRAPPER_ID + ' {' +
                    'position: fixed; top: 15px; left: 50%; transform: translateX(-50%);' +
                    'display: flex; z-index: 2147483647; box-shadow: 0 2px 8px rgba(0,0,0,0.15);' +
                    'border-radius: 5px; overflow: hidden;' +
                    '}' +
                    '#' + WRAPPER_ID + ' button {' +
                    'padding: 6px 16px; font-size: 14px; background: #007bff; color: white;' +
                    'border: none; cursor: pointer; display: flex; align-items: center;' +
                    'white-space: nowrap; line-height: 1;' +
                    '}' +
                    '#' + WRAPPER_ID + ' button:hover { background: #0056b3; }' +
                    '#' + DOWNLOAD_BUTTON_ID + ' {' +
                    'border-left: 1px solid rgba(255,255,255,0.3);' +
                    '}'
                );
                styleInjected = true;
            }
        }
        setButtonState(forceIdle ? ButtonState.IDLE : buttonState);
    }

    function bootstrapButton() {
        createButtons(true);
        waitForElement('#docx > div, #docx, div[data-block-id]', () => {
            if (buttonState !== ButtonState.IDLE && getWrapper()) return;
            createButtons(buttonState === ButtonState.IDLE);
        }, 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 () {
                const result = raw.apply(this, arguments);
                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();
})();