⚡一键将飞书文档转为 Markdown 并复制/下载;支持表格、引用、代码块、嵌入式电子表格等复杂内容。
// ==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\n';
}
}
const src = node.getAttribute('src') || '';
// blob: URL 在会话外无效,丢弃
if (!src || src.indexOf('blob:') === 0) return '';
return '\n\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\n';
}
// Fallback:用 img.src,如果是 blob: 则跳过(blob URL 在会话外无效)
const src = img.getAttribute('src') || '';
if (!src || src.indexOf('blob:') === 0) return '';
return '\n\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();
})();