feishu Markdown

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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();
})();