⚡一键将飞书文档转为 Markdown 并复制到剪贴板;支持表格、引用、代码块、嵌入式电子表格等复杂内容。
// ==UserScript==
// @name feishu Markdown
// @namespace http://tampermonkey.net/
// @version 1.0.6
// @description ⚡一键将飞书文档转为 Markdown 并复制到剪贴板;支持表格、引用、代码块、嵌入式电子表格等复杂内容。
// @author mike868
// @match *://*.feishu.cn/*
// @run-at document-start
// @license AGPL-v3.0
// @grant GM_setClipboard
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_TAG = '[FeishuMD]';
const BUTTON_ID = 'scrollCopyButton';
const MD_ICON = '<svg xmlns="http://www.w3.org/2000/svg" style="height:15px; padding-right:5px; fill:#fff; display:inline;" viewBox="0 0 640 512"><path d="M593.8 59.1H46.2C20.7 59.1 0 79.8 0 105.2v301.5c0 25.5 20.7 46.2 46.2 46.2h547.7c25.5 0 46.2-20.7 46.1-46.1V105.2c0-25.4-20.7-46.1-46.2-46.1zM338.5 360.6H277v-120l-61.5 76.9-61.5-76.9v120H92.3V151.4h61.5l61.5 76.9 61.5-76.9h61.5v209.2zm135.3 3.1L381.5 256H443V151.4h61.5V256H566z"/></svg>';
const BUTTON_RESET_DELAY_MS = 2500;
const SCAN_TIMEOUT_MS = 180000;
let copyBypassInstalled = false;
let copyBypassEventsInstalled = false;
let copyBypassObserver = null;
let copyBypassObservedRoot = null;
let copyBypassRetryTimer = null;
let copyBypassFlushQueued = false;
const copyBypassQueue = new Set();
let watermarkInstalled = false;
// === Canvas 文本捕获 ===
// 飞书嵌入式表格(sheet)使用 Canvas 绘制,hook fillText/strokeText 捕获绘制的文字和坐标
// 默认关闭,仅在扫描期间启用,避免思维导图等 Canvas 重绘导致性能风暴
let canvasCaptureEnabled = false;
const canvasTextCaptures = new WeakMap();
(function hookCanvasText() {
try {
const proto = CanvasRenderingContext2D.prototype;
const origFillText = proto.fillText;
const origStrokeText = proto.strokeText;
const CANVAS_CAPTURE_LIMIT = 50000;
proto.fillText = function (text, x, y) {
try {
if (canvasCaptureEnabled && text && String(text).trim() && this.canvas) {
if (!canvasTextCaptures.has(this.canvas)) canvasTextCaptures.set(this.canvas, []);
const arr = canvasTextCaptures.get(this.canvas);
if (arr.length < CANVAS_CAPTURE_LIMIT) arr.push({ t: String(text), x: +x, y: +y });
}
} catch (e) { }
return origFillText.apply(this, arguments);
};
proto.strokeText = function (text, x, y) {
try {
if (canvasCaptureEnabled && text && String(text).trim() && this.canvas) {
if (!canvasTextCaptures.has(this.canvas)) canvasTextCaptures.set(this.canvas, []);
const arr = canvasTextCaptures.get(this.canvas);
if (arr.length < CANVAS_CAPTURE_LIMIT) arr.push({ t: String(text), x: +x, y: +y });
}
} catch (e) { }
return origStrokeText.apply(this, arguments);
};
} catch (e) {
console.warn('[FeishuMD] canvas text hook failed', e);
}
})();
// Tampermonkey/Greasemonkey compatibility fallback
const addStyle = (cssText) => {
if (typeof GM_addStyle === 'function') {
return GM_addStyle(cssText);
}
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.textContent = cssText;
(document.head || document.documentElement).appendChild(style);
return style;
};
function isDocPage(urlString = window.location.href) {
try {
const { pathname } = new URL(urlString);
return /\/(docx|wiki|docs)\//.test(pathname);
} catch (e) {
return /\/(docx|wiki|docs)\//.test(window.location.pathname);
}
}
function installWatermarkRemoval() {
if (watermarkInstalled) return;
watermarkInstalled = true;
const bgImageNone = '{background-image: none !important;}';
const genStyle = (selector) => `${selector}${bgImageNone}`;
// Global watermark selectors
addStyle(genStyle('[class*="watermark"]'));
addStyle(genStyle('#docx [style*="pointer-events: none"]:not(video):not(canvas):not([class*="player"])'));
addStyle(genStyle('[class*="docx-editor"] [style*="pointer-events: none"]:not(video):not(canvas):not([class*="player"])'));
addStyle(genStyle('[class*="wiki-content"] [style*="pointer-events: none"]:not(video):not(canvas):not([class*="player"])'));
// Feishu/Lark specific selectors
addStyle(genStyle('.ssrWaterMark'));
addStyle(genStyle('[class*="TIAWBFTROSIDWYKTTIAW"]'));
addStyle(genStyle('#watermark-cache-container'));
addStyle(genStyle('.chatMessages>div[style*="inset: 0px;"]'));
// Keep :has selectors for Chromium; Firefox will ignore these and use class fallbacks above.
addStyle(genStyle('body>div>div>div>div[style*="position: fixed"]:not(:has(*))'));
addStyle(genStyle('body>div[style*="position: fixed"]:not(:has(*))'));
addStyle(genStyle('body>div[style*="inset: 0px;"]:not(:has(*))'));
}
function getDocRoot() {
return document.querySelector('#docx, [class*="docx-editor"], [class*="wiki-content"], [role="main"]');
}
// 跳过嵌入组件(思维导图、视频播放器等),它们依赖 pointer-events/user-select 正常工作
const SKIP_UNLOCK_TAGS = new Set(['canvas', 'svg', 'video', 'iframe', 'object', 'embed']);
let isUnlocking = false;
function unlockElementSelection(el) {
if (!el || el.nodeType !== 1 || !el.style) return;
if (SKIP_UNLOCK_TAGS.has((el.tagName || '').toLowerCase())) return;
if (el.style.userSelect === 'none') el.style.userSelect = 'text';
if (el.style.webkitUserSelect === 'none') el.style.webkitUserSelect = 'text';
if (el.style.pointerEvents === 'none') el.style.pointerEvents = 'auto';
}
const UNLOCK_THROTTLE_MS = 500;
const UNLOCK_MAX_BATCH = 50;
let lastUnlockFlush = 0;
function flushUnlockQueue() {
copyBypassFlushQueued = false;
lastUnlockFlush = Date.now();
const nodes = Array.from(copyBypassQueue);
copyBypassQueue.clear();
const batch = nodes.slice(0, UNLOCK_MAX_BATCH);
// 超出部分放回队列,延迟处理
if (nodes.length > UNLOCK_MAX_BATCH) {
for (let i = UNLOCK_MAX_BATCH; i < nodes.length; i++) {
copyBypassQueue.add(nodes[i]);
}
copyBypassFlushQueued = true;
setTimeout(flushUnlockQueue, UNLOCK_THROTTLE_MS);
}
// 设置自修改标记,防止我们的 style 写入重新触发 observer
isUnlocking = true;
try {
batch.forEach((node) => {
if (!node || node.nodeType !== 1) return;
unlockElementSelection(node);
// CSS !important 已覆盖内联样式,无需 querySelectorAll 全树扫描
});
} finally {
isUnlocking = false;
}
}
function queueUnlockNode(node) {
if (!node || node.nodeType !== 1) return;
copyBypassQueue.add(node);
if (copyBypassFlushQueued) return;
copyBypassFlushQueued = true;
const elapsed = Date.now() - lastUnlockFlush;
const delay = Math.max(0, UNLOCK_THROTTLE_MS - elapsed);
setTimeout(flushUnlockQueue, delay);
}
function installCopyBypass() {
if (!isDocPage()) return;
if (!copyBypassInstalled) {
copyBypassInstalled = true;
// Keep unlock scope in document area to avoid touching unrelated pages/components.
// 注意:pointer-events 不能加在 div 上——飞书的滚动容器和 overlay 都是 div,
// 强制 auto 会阻断滚动。仅对文本相关的内联/叶子元素设置 pointer-events。
addStyle(`
#docx, #docx p, #docx span, #docx div, #docx li, #docx td, #docx th,
#docx h1, #docx h2, #docx h3, #docx h4, #docx h5, #docx h6,
#docx a, #docx blockquote, #docx pre, #docx code,
[class*="docx-editor"], [class*="docx-editor"] p, [class*="docx-editor"] span,
[class*="docx-editor"] div, [class*="docx-editor"] li,
[class*="docx-editor"] td, [class*="docx-editor"] th,
[class*="docx-editor"] h1, [class*="docx-editor"] h2, [class*="docx-editor"] h3,
[class*="docx-editor"] h4, [class*="docx-editor"] h5, [class*="docx-editor"] h6,
[class*="docx-editor"] a, [class*="docx-editor"] blockquote,
[class*="docx-editor"] pre, [class*="docx-editor"] code,
[class*="wiki-content"], [class*="wiki-content"] p, [class*="wiki-content"] span,
[class*="wiki-content"] div, [class*="wiki-content"] li,
[class*="wiki-content"] td, [class*="wiki-content"] th,
[class*="wiki-content"] h1, [class*="wiki-content"] h2, [class*="wiki-content"] h3,
[class*="wiki-content"] h4, [class*="wiki-content"] h5, [class*="wiki-content"] h6,
[class*="wiki-content"] a, [class*="wiki-content"] blockquote,
[class*="wiki-content"] pre, [class*="wiki-content"] code {
user-select: text !important;
-webkit-user-select: text !important;
}
/* pointer-events 仅对文本叶子元素启用,不包含普通 div(避免阻断滚动)
但飞书表格 cell 用 div+role 渲染,需要单独加回来 */
#docx p, #docx span, #docx li, #docx td, #docx th,
#docx h1, #docx h2, #docx h3, #docx h4, #docx h5, #docx h6,
#docx a, #docx blockquote, #docx pre, #docx code,
#docx [role="gridcell"], #docx [role="cell"],
#docx [role="columnheader"], #docx [role="rowheader"],
#docx [data-block-type*="cell"],
[class*="docx-editor"] p, [class*="docx-editor"] span,
[class*="docx-editor"] li, [class*="docx-editor"] td, [class*="docx-editor"] th,
[class*="docx-editor"] h1, [class*="docx-editor"] h2, [class*="docx-editor"] h3,
[class*="docx-editor"] h4, [class*="docx-editor"] h5, [class*="docx-editor"] h6,
[class*="docx-editor"] a, [class*="docx-editor"] blockquote,
[class*="docx-editor"] pre, [class*="docx-editor"] code,
[class*="docx-editor"] [role="gridcell"], [class*="docx-editor"] [role="cell"],
[class*="docx-editor"] [role="columnheader"], [class*="docx-editor"] [role="rowheader"],
[class*="docx-editor"] [data-block-type*="cell"],
[class*="wiki-content"] p, [class*="wiki-content"] span,
[class*="wiki-content"] li, [class*="wiki-content"] td, [class*="wiki-content"] th,
[class*="wiki-content"] h1, [class*="wiki-content"] h2, [class*="wiki-content"] h3,
[class*="wiki-content"] h4, [class*="wiki-content"] h5, [class*="wiki-content"] h6,
[class*="wiki-content"] a, [class*="wiki-content"] blockquote,
[class*="wiki-content"] pre, [class*="wiki-content"] code,
[class*="wiki-content"] [role="gridcell"], [class*="wiki-content"] [role="cell"],
[class*="wiki-content"] [role="columnheader"], [class*="wiki-content"] [role="rowheader"],
[class*="wiki-content"] [data-block-type*="cell"] {
pointer-events: auto !important;
}
#docx [style*="user-select: none"],
[class*="docx-editor"] [style*="user-select: none"],
[class*="wiki-content"] [style*="user-select: none"] {
user-select: text !important;
-webkit-user-select: text !important;
}
/* Restore browser native "Copy Image" in right-click context menu.
Feishu places overlay divs on top of <img>; our blanket
pointer-events:auto forces those overlays to intercept clicks,
hiding the <img> from the browser. Raising <img> above overlays
makes the browser recognise the right-click target as an image. */
#docx img,
[class*="docx-editor"] img,
[class*="wiki-content"] img {
pointer-events: auto !important;
position: relative !important;
z-index: 2 !important;
}
`);
}
if (!copyBypassEventsInstalled) {
copyBypassEventsInstalled = true;
const blockedEvents = ['copy', 'cut', 'paste', 'contextmenu', 'selectstart'];
const bypassHandler = (event) => {
if (!isDocPage()) return;
const root = getDocRoot();
if (root && event.target instanceof Node && !root.contains(event.target)) return;
// Only cut propagation to bypass page-level restriction listeners.
// Do not call preventDefault so browser default copy keeps working.
event.stopImmediatePropagation();
event.stopPropagation();
};
blockedEvents.forEach((eventName) => {
document.addEventListener(eventName, bypassHandler, { capture: true, passive: false });
});
document.addEventListener('keydown', (event) => {
if (!isDocPage()) return;
const root = getDocRoot();
if (root && event.target instanceof Node && !root.contains(event.target)) return;
const isCopyShortcut = (event.ctrlKey || event.metaKey) && (event.key === 'c' || event.key === 'C');
if (!isCopyShortcut) return;
event.stopImmediatePropagation();
event.stopPropagation();
}, { capture: true, passive: false });
}
const startObserver = () => {
const root = getDocRoot();
if (!root) return false;
if (copyBypassObserver && copyBypassObservedRoot === root) {
return true;
}
if (copyBypassObserver) {
copyBypassObserver.disconnect();
}
copyBypassObserver = new MutationObserver((mutations) => {
// 跳过自身引起的 DOM 变更,打破反馈循环
if (isUnlocking) return;
for (const mutation of mutations) {
mutation.addedNodes.forEach(queueUnlockNode);
}
});
// 仅监听 childList(新增节点),不监听 attributes
// style/class 覆盖已通过 CSS !important 实现,无需 JS 重复修改
copyBypassObserver.observe(root, {
childList: true,
subtree: true,
});
copyBypassObservedRoot = root;
queueUnlockNode(root);
return true;
};
const retryBindObserver = () => {
if (startObserver()) {
if (copyBypassRetryTimer) {
clearInterval(copyBypassRetryTimer);
copyBypassRetryTimer = null;
}
return;
}
if (copyBypassRetryTimer) return;
let retries = 0;
copyBypassRetryTimer = setInterval(() => {
retries += 1;
if (startObserver() || retries >= 40) {
clearInterval(copyBypassRetryTimer);
copyBypassRetryTimer = null;
}
}, 500);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', retryBindObserver, { once: true });
} else {
retryBindObserver();
}
}
function sanitizeTableCell(text) {
return (text || '')
.replace(/\|/g, '\\|')
.replace(/\n+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildMarkdownTable(rows) {
if (!rows || rows.length === 0) return '';
const colCount = Math.max(...rows.map(row => row.length));
if (!Number.isFinite(colCount) || colCount <= 0) return '';
const normalized = rows.map(row => {
const cells = row.slice(0, colCount);
while (cells.length < colCount) cells.push(' ');
return cells.map(cell => cell || ' ');
});
if (normalized.length === 1) {
normalized.push(new Array(colCount).fill(' '));
}
const header = normalized[0];
const body = normalized.slice(1);
const separator = new Array(colCount).fill('---');
const lines = [
`| ${header.join(' | ')} |`,
`| ${separator.join(' | ')} |`,
...body.map(row => `| ${row.join(' | ')} |`),
];
return `\n${lines.join('\n')}\n`;
}
function extractRowsFromNodes(rowNodes, cellSelector) {
const rows = [];
// rowspanTracker[colIdx] = remaining rowspan count(跟踪尚未过期的 rowspan 占位)
const rowspanTracker = {};
rowNodes.forEach(rowNode => {
// 关键修复:仅选择当前 row 的直接子单元格,避免匹配嵌套在 cell 内部的后代元素
const allCells = rowNode.querySelectorAll(cellSelector);
const cells = Array.from(allCells).filter(cell => {
// cell 的最近 row 祖先必须是当前 rowNode
let parent = cell.parentElement;
while (parent && parent !== rowNode) {
// 如果 parent 本身也匹配 row 的角色,说明 cell 属于嵌套行
const pRole = parent.getAttribute('role') || '';
const pType = (parent.getAttribute('data-block-type') || '').toLowerCase();
if (pRole === 'row' || pType.includes('row') || parent.tagName.toLowerCase() === 'tr') {
return false; // 属于嵌套行,跳过
}
parent = parent.parentElement;
}
return parent === rowNode;
});
if (!cells.length) return;
const row = [];
let cellIdx = 0;
let colIdx = 0;
while (cellIdx < cells.length) {
// 跳过被上方 rowspan 占据的列位置
while (rowspanTracker[colIdx] && rowspanTracker[colIdx] > 0) {
row.push(' ');
rowspanTracker[colIdx]--;
if (rowspanTracker[colIdx] <= 0) delete rowspanTracker[colIdx];
colIdx++;
}
if (cellIdx >= cells.length) break;
const cellNode = cells[cellIdx];
const value = sanitizeTableCell(cellNode.innerText || cellNode.textContent || '');
const colspan = Math.max(1, Number.parseInt(cellNode.getAttribute('colspan') || '1', 10));
const rowspan = Math.max(1, Number.parseInt(cellNode.getAttribute('rowspan') || '1', 10));
for (let c = 0; c < colspan; c++) {
row.push(c === 0 ? (value || ' ') : ' ');
if (rowspan > 1) {
rowspanTracker[colIdx] = rowspan - 1;
}
colIdx++;
}
cellIdx++;
}
// 处理行尾剩余的 rowspan 占位
while (rowspanTracker[colIdx] && rowspanTracker[colIdx] > 0) {
row.push(' ');
rowspanTracker[colIdx]--;
if (rowspanTracker[colIdx] <= 0) delete rowspanTracker[colIdx];
colIdx++;
}
if (row.some(cell => cell.trim() !== '')) rows.push(row);
});
return rows;
}
function tableNodeToMarkdown(tableNode) {
if (!tableNode) return '';
let rows = [];
// Strategy 1: Standard HTML table
const htmlRows = tableNode.querySelectorAll('tr');
if (htmlRows.length) {
rows = extractRowsFromNodes(htmlRows, 'th, td');
}
// Strategy 2: ARIA roles
if (!rows.length) {
const roleRows = tableNode.querySelectorAll('[role="row"]');
if (roleRows.length) {
rows = extractRowsFromNodes(roleRows, '[role="columnheader"], [role="gridcell"], [role="cell"]');
}
}
// Strategy 3: Class-based selectors
if (!rows.length) {
const classRows = tableNode.querySelectorAll(
'[class*="table-row"], [class*="grid-row"], [data-row-index]'
);
if (classRows.length) {
rows = extractRowsFromNodes(
classRows,
'[class*="table-cell"], [class*="grid-cell"], [data-col-index], [role="gridcell"], [role="cell"], [role="columnheader"]'
);
}
}
// Strategy 4: Feishu block-type based structure (data-block-type containing row/cell)
if (!rows.length) {
const blockRows = tableNode.querySelectorAll(
'[data-block-type*="row"], [data-block-type*="tr"]'
);
if (blockRows.length) {
rows = extractRowsFromNodes(
blockRows,
'[data-block-type*="cell"], [data-block-type*="td"], [data-block-type*="th"]'
);
}
}
// Strategy 5: Grid-like child div structure (children as rows, grandchildren as cells)
if (!rows.length) {
const container = tableNode.querySelector('[class*="table"], [class*="grid"]') || tableNode;
const potentialRows = Array.from(container.children).filter(
child => child.children && child.children.length > 1
);
if (potentialRows.length > 1) {
const colCount = potentialRows[0].children.length;
const isConsistent = potentialRows.every(
r => Math.abs(r.children.length - colCount) <= 1
);
if (isConsistent) {
rows = potentialRows.map(rowEl =>
Array.from(rowEl.children).map(cellEl =>
sanitizeTableCell(cellEl.innerText || cellEl.textContent || '')
)
);
}
}
}
return buildMarkdownTable(rows);
}
// ===== 嵌入式电子表格(sheet)数据提取 =====
// 飞书 sheet 块使用 Canvas 渲染,DOM 中无文本节点
// 通过 fillText hook 捕获的坐标+文字还原为 Markdown 表格
function extractSheetMarkdown(sheetBlock) {
const allCanvases = sheetBlock.querySelectorAll('canvas');
for (const canvas of allCanvases) {
const data = canvasTextCaptures.get(canvas);
const rawTexts = data ? data.filter(item => item.t.trim()) : [];
if (rawTexts.length < 2) continue;
// 去重:Canvas 多次重绘会在相同坐标绘制相同文字
const seen = new Set();
const texts = rawTexts.filter(item => {
const key = `${Math.round(item.x)},${Math.round(item.y)},${item.t}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (texts.length < 2) continue;
// Step 1: 按 Y 坐标分组为视觉行(容差 4px)
const sorted = [...texts].sort((a, b) => a.y - b.y || a.x - b.x);
const visualLines = [];
let currentGroup = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
if (Math.abs(sorted[i].y - currentGroup[0].y) <= 4) {
currentGroup.push(sorted[i]);
} else {
visualLines.push(currentGroup);
currentGroup = [sorted[i]];
}
}
visualLines.push(currentGroup);
if (visualLines.length < 2) continue;
// Step 2: 从拥有最多单元格的视觉行(通常是表头)确定列 X 坐标
const referenceRow = visualLines.reduce((a, b) => a.length > b.length ? a : b);
const colXPositions = referenceRow
.sort((a, b) => a.x - b.x)
.map(item => item.x);
const numCols = colXPositions.length;
if (numCols < 2) continue;
// 根据 X 坐标找到最近的列索引
function findColumn(x) {
let minDist = Infinity, bestCol = 0;
for (let i = 0; i < colXPositions.length; i++) {
const dist = Math.abs(x - colXPositions[i]);
if (dist < minDist) { minDist = dist; bestCol = i; }
}
return bestCol;
}
// Step 3: 计算视觉行间 Y 间距,区分"行内换行"和"行边界"
// 行内换行间距小(~16-20px),行边界间距大(~40+px)
const yValues = visualLines.map(g => g[0].y);
const gaps = [];
for (let i = 1; i < yValues.length; i++) {
gaps.push(yValues[i] - yValues[i - 1]);
}
// 阈值:区分"行内换行"(小间距)和"行边界"(大间距)
// 如果所有间距相近(maxGap/minGap < 1.5),说明没有行内换行,每个视觉行就是独立行
// 如果间距差距大,说明有行内换行,需要合并小间距的视觉行
const sortedGaps = [...gaps].sort((a, b) => a - b);
const minGap = sortedGaps[0] || 1;
const maxGap = sortedGaps[sortedGaps.length - 1] || 1;
const hasLineWraps = maxGap > minGap * 1.5;
const rowBoundaryThreshold = hasLineWraps ? minGap * 1.8 : minGap * 0.5;
// Step 4: 按列位置分配文字,并根据 Y 间距合并视觉行为逻辑行
const logicalRows = [];
let currentRow = new Array(numCols).fill('');
visualLines.forEach((group, idx) => {
// 大间距 = 新的逻辑行边界
const isNewLogicalRow = idx === 0 || gaps[idx - 1] > rowBoundaryThreshold;
if (isNewLogicalRow && idx > 0) {
logicalRows.push(currentRow);
currentRow = new Array(numCols).fill('');
}
// 将当前视觉行中的每个文字分配到对应的列
group.forEach(item => {
const col = findColumn(item.x);
if (currentRow[col]) {
currentRow[col] += ' ' + item.t; // 同列多行文字用空格连接
} else {
currentRow[col] = item.t;
}
});
});
// 推入最后一行
if (currentRow.some(c => c.trim())) {
logicalRows.push(currentRow);
}
if (logicalRows.length < 2) continue;
// Step 5: 清理并构建 Markdown 表格
const rows = logicalRows.map(row =>
row.map(cell => sanitizeTableCell(cell))
);
const hasContent = rows.some(row => row.some(cell => cell.trim() !== ''));
if (!hasContent) continue;
return buildMarkdownTable(rows);
}
return null;
}
function convertToMarkdown(html) {
// Known limitations: formula/callout/mermaid-like blocks may degrade to readable text.
// 使用纯 DOM 遍历,避免正则预处理破坏 HTML 结构
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// 预处理:飞书代码块(在遍历前替换为文本占位符)
doc.querySelectorAll('div.docx-code-block-container > div.docx-code-block-inner-container').forEach(codearea => {
let header = codearea.querySelector('div.code-block-header .code-block-header-btn-con');
let language = header ? (header.textContent || '').trim() : 'text';
const codeHeader = codearea.querySelector('div.code-block-header');
if (codeHeader) codeHeader.remove();
codearea.querySelectorAll('span[data-enter="true"]').forEach(enterSpan => {
enterSpan.outerHTML = '\n';
});
const codeContent = codearea.textContent || '';
const outer = codearea.closest('div.docx-code-block-container') || codearea;
outer.outerHTML = `\n\`\`\`${language}\n${codeContent}\n\`\`\`\n`;
});
// 预处理:飞书文件框
doc.querySelectorAll('div.chat-uikit-multi-modal-file-image-content').forEach(multifile => {
multifile.innerHTML = multifile.innerHTML
.replace(/<span class="chat-uikit-file-card__info__size">(.*?)<\/span>/gi, '\n$1');
multifile.outerHTML = `\n\`\`\`file\n${multifile.textContent}\n\`\`\`\n`;
});
// 预处理:表格(转为文本占位符)
Array.from(doc.querySelectorAll('table, [role="table"], [role="grid"]')).forEach(tableNode => {
const mdTable = tableNodeToMarkdown(tableNode);
if (mdTable) tableNode.outerHTML = mdTable;
});
// 递归 DOM→Markdown 转换
function nodeToMd(node, listDepth) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || '';
}
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const tag = (node.tagName || '').toLowerCase();
if (tag === 'script' || tag === 'style' || tag === 'input') return '';
const childMd = () =>
Array.from(node.childNodes).map(c => nodeToMd(c, listDepth)).join('');
// 飞书自定义 heading div
if (tag === 'div' && node.classList.contains('heading')) {
const text = normalizeText(node.textContent || '');
if (!text) return '';
for (let lv = 1; lv <= 5; lv++) {
if (node.classList.contains(`heading-h${lv}`)) {
return `\n\n${'#'.repeat(lv)} ${text}\n\n`;
}
}
return `\n\n## ${text}\n\n`;
}
switch (tag) {
case 'b': case 'strong': {
const c = childMd().trim();
return c ? `**${c}**` : '';
}
case 'i': case 'em': {
const c = childMd().trim();
return c ? `*${c}*` : '';
}
case 'code':
return `\`${(node.textContent || '').trim()}\``;
case 's': case 'del': case 'strike': {
const c = childMd().trim();
return c ? `~~${c}~~` : '';
}
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': {
const level = parseInt(tag[1], 10);
return `\n${'#'.repeat(level)} ${childMd().trim()}\n`;
}
case 'p':
return `${childMd()}\n\n`;
case 'br':
return '\n';
case 'hr':
return '\n---\n';
case 'a': {
const href = node.getAttribute('href') || '';
const text = childMd().trim() || href;
return href ? `[${text}](${href})` : text;
}
case 'img': {
const src = node.getAttribute('src') || '';
if (!src) return '';
const alt = (node.getAttribute('alt') || 'image').replace(/[\[\]]/g, '');
return `\n\n`;
}
case 'blockquote': {
const content = childMd().trim();
if (!content) return '';
return '\n' + content.split('\n').map(line => `> ${line}`).join('\n') + '\n';
}
case 'ul': case 'ol': {
let result = '';
let idx = 1;
Array.from(node.children).forEach(child => {
if (!child.tagName || child.tagName.toLowerCase() !== 'li') return;
const inlineParts = [];
const nestedParts = [];
Array.from(child.childNodes).forEach(liChild => {
if (liChild.nodeType === Node.ELEMENT_NODE &&
['ul', 'ol'].includes((liChild.tagName || '').toLowerCase())) {
nestedParts.push(nodeToMd(liChild, listDepth + 1));
} else {
inlineParts.push(nodeToMd(liChild, listDepth));
}
});
const checkbox = child.querySelector('input[type="checkbox"]');
let bullet;
if (checkbox) {
const checked = checkbox.checked ||
checkbox.getAttribute('checked') !== null ||
checkbox.getAttribute('aria-checked') === 'true';
bullet = `- [${checked ? 'x' : ' '}] `;
} else if (tag === 'ol') {
bullet = `${idx++}. `;
} else {
bullet = '- ';
}
const indent = ' '.repeat(listDepth);
const itemText = inlineParts.join('').replace(/\n+$/g, '').trim();
result += `${indent}${bullet}${itemText}\n`;
result += nestedParts.join('');
});
return `\n${result}`;
}
default:
return childMd();
}
}
return normalizeText(nodeToMd(doc.body, 0));
}
// 等待目标DIV加载完成
function waitForElement(selector, callback, timeoutMs = 15000) {
const existingElement = document.querySelector(selector);
if (existingElement) {
callback(existingElement);
return;
}
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
callback(element);
}
});
const observeRoot = document.body || document.documentElement;
if (!observeRoot) return;
observer.observe(observeRoot, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), timeoutMs);
}
// 初始化数据
const dataBlocks = new Map();
const coveredIds = new Set();
let isScrolling = false;
let scanStartedAt = 0;
const BLOCK_SELECTOR = '[data-block-id]';
let styleInjected = false;
let buttonResetTimer = null;
let routeWatcherInstalled = false;
let lastHref = window.location.href;
const ButtonState = Object.freeze({
IDLE: 'idle',
SCANNING: 'scanning',
COPYING: 'copying',
DONE: 'done',
ERROR: 'error',
});
let buttonState = ButtonState.IDLE;
function normalizeText(text) {
return (text || '').replace(/\u00a0/g, ' ').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '\n').replace(/\n+$/, '\n');
}
function getCopyButton() {
return document.querySelector(`button#${BUTTON_ID}`);
}
function clearButtonResetTimer() {
if (buttonResetTimer) {
clearTimeout(buttonResetTimer);
buttonResetTimer = null;
}
}
function renderCopyButtonLabel(label, disabled) {
const button = getCopyButton();
if (!button) return;
button.innerHTML = `${MD_ICON}${label}`;
button.disabled = disabled;
button.style.cursor = disabled ? 'not-allowed' : 'pointer';
}
function setButtonState(state, customLabel = '') {
buttonState = state;
const labelMap = {
[ButtonState.IDLE]: '复制全文',
[ButtonState.SCANNING]: '扫描中: 0.0%',
[ButtonState.COPYING]: '写入剪贴板...',
[ButtonState.DONE]: '已复制',
[ButtonState.ERROR]: '复制失败',
};
const label = customLabel || labelMap[state] || labelMap[ButtonState.IDLE];
const disabled = state === ButtonState.SCANNING || state === ButtonState.COPYING;
renderCopyButtonLabel(label, disabled);
}
function scheduleButtonReset(delayMs = BUTTON_RESET_DELAY_MS) {
clearButtonResetTimer();
buttonResetTimer = setTimeout(() => {
if (!isDocPage()) return;
setButtonState(ButtonState.IDLE);
}, delayMs);
}
function buildFallbackContent() {
const roots = [
'#docx',
'[class*="docx-editor"]',
'[class*="wiki-content"]',
'[role="main"]',
'main',
'body',
];
for (const selector of roots) {
const node = document.querySelector(selector);
if (!node) continue;
const clone = node.cloneNode(true);
clone.querySelectorAll(`#${BUTTON_ID}, script, style`).forEach(el => el.remove());
const markdown = normalizeText(convertToMarkdown(clone.innerHTML || ''));
if (markdown.length > 20) return markdown;
const text = normalizeText(clone.innerText || clone.textContent || '');
if (text.length > 20) return text;
}
return '';
}
function getScrollContainer() {
const selectorCandidates = [
'#docx > div',
'#docx',
'[class*="docx"][class*="container"]',
'[class*="docx"] [class*="scroll"]',
];
for (const selector of selectorCandidates) {
const container = document.querySelector(selector);
if (container) return container;
}
const firstBlock = document.querySelector(BLOCK_SELECTOR);
let cur = firstBlock ? firstBlock.parentElement : null;
while (cur) {
if (cur.scrollHeight > cur.clientHeight + 20) return cur;
cur = cur.parentElement;
}
return document.scrollingElement || document.documentElement;
}
async function copyTextToClipboard(text) {
const payload = String(text || '');
if (!payload.trim()) {
throw new Error('empty-content');
}
let gmError = null;
if (typeof GM_setClipboard === 'function') {
try {
GM_setClipboard(payload, 'text');
return { method: 'gm' };
} catch (error) {
gmError = error;
console.warn(`${SCRIPT_TAG} GM_setClipboard failed`, error);
}
}
let nativeError = null;
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(payload);
return { method: 'native' };
} catch (error) {
nativeError = error;
console.warn(`${SCRIPT_TAG} navigator.clipboard.writeText failed`, error);
}
}
const reasons = [];
if (gmError) reasons.push(`GM_setClipboard: ${gmError.message || gmError}`);
if (nativeError) reasons.push(`navigator.clipboard: ${nativeError.message || nativeError}`);
if (!gmError && !nativeError) reasons.push('No available clipboard API');
throw new Error(reasons.join(' | '));
}
function resolveCopyErrorReason(error) {
const msg = (error && error.message) ? error.message : String(error || '');
if (!msg) return '未知错误';
if (/empty-content/i.test(msg)) return '抓取结果为空,请确认文档正文已加载';
if (/NotAllowedError|denied|permission|permissions/i.test(msg)) return '剪贴板权限被浏览器或页面策略拒绝';
if (/No available clipboard API/i.test(msg)) return '当前环境没有可用的剪贴板接口';
return msg;
}
// 列表块递归渲染:将嵌套的 bullet/ordered/todoList 块转为带缩进的 Markdown 列表
function renderListBlock(block, depth) {
const type = block.getAttribute('data-block-type') || '';
// 查找所有后代块,但只处理直接子项(逻辑层级)
// 逻辑子项的定义:其最近的父级 data-block 必须是当前 block
const allDescendants = Array.from(block.querySelectorAll(BLOCK_SELECTOR));
const childBlocks = allDescendants.filter(child => {
const parentBlock = child.parentElement.closest(BLOCK_SELECTOR);
return parentBlock === block;
});
// 获取块自身文本(排除子 data-block 的内容及其容器)
// 克隆并移除所有子 data-block 元素,避免它们的内容被提取到当前层级
const clone = block.cloneNode(true);
// 注意:飞书的嵌套列表通常包裹在 .list-wrapper 或 .list-children 中,直接移除所有后代 block 元素即可
clone.querySelectorAll(BLOCK_SELECTOR).forEach(c => c.remove());
// 提取纯文本并清理
let ownText = normalizeText(convertToMarkdown(clone.innerHTML)).replace(/\n/g, ' ').trim();
// 清理飞书可能残留的 bullet 符号 (如 "•", "1." 等) 防止双重 bullet
ownText = ownText.replace(/^([•●▪\-\+]|\d+\.)\s*/, '');
// 确定列表前缀
let bullet;
if (/todoList/i.test(type)) {
const checkbox = block.querySelector('input[type="checkbox"]');
const checked = checkbox && (checkbox.checked || checkbox.getAttribute('checked') !== null || checkbox.getAttribute('aria-checked') === 'true');
bullet = `- [${checked ? 'x' : ' '}] `;
} else if (/ordered/i.test(type)) {
bullet = `1. `; // Markdown 渲染器会自动排序
} else {
bullet = '- ';
}
const indent = ' '.repeat(depth);
let result = ownText ? `${indent}${bullet}${ownText}\n` : '';
// 递归渲染子块
childBlocks.forEach(child => {
const childType = child.getAttribute('data-block-type') || '';
if (/^(bullet|ordered|todoList)$/i.test(childType)) {
result += renderListBlock(child, depth + 1);
} else {
// 非列表子块(如代码块、图片等):作为缩进内容
const childMd = normalizeText(convertToMarkdown(child.innerHTML)).replace(/\n/g, ' ').trim();
if (childMd) {
result += `${' '.repeat(depth + 1)}${childMd}\n`;
}
}
});
return result;
}
// 获取所有的 data-block-id 元素并存储其内容(处理嵌套块避免重复)
function scrapeDataBlocks() {
const blocks = document.querySelectorAll(BLOCK_SELECTOR);
blocks.forEach((block, idx) => {
const id = block.getAttribute('data-block-id') || `auto-${idx}`;
if (coveredIds.has(id)) return;
const type = block.getAttribute('data-block-type') || '';
if (type === 'back_ref_list') return;
// 检测代码块和表格块:虚拟渲染可能导致内容不完整,需多次采集保留最长结果
const isCodeBlock = /^code$/i.test(type) || !!block.querySelector('div.docx-code-block-container');
const isSheetBlock = /sheet/i.test(type);
const isTableLike = /table/i.test(type)
|| !!block.querySelector('table, [role="table"], [role="grid"]');
const canUpdate = isCodeBlock || isTableLike || isSheetBlock;
const alreadyCaptured = dataBlocks.has(id);
// 非可更新块:已采集过则跳过
if (alreadyCaptured && !canUpdate) return;
// 辅助函数:将所有子块标记为已覆盖,防止后续 tick 重复采集
const markChildrenCovered = () => {
block.querySelectorAll(BLOCK_SELECTOR).forEach(child => {
const childId = child.getAttribute('data-block-id');
if (childId) coveredIds.add(childId);
});
};
// 辅助函数:仅当新内容更长时才更新(虚拟渲染逐步加载)
const updateIfLonger = (id, newContent) => {
if (!newContent) return;
if (alreadyCaptured) {
const prev = dataBlocks.get(id) || '';
if (newContent.length > prev.length) {
dataBlocks.set(id, newContent);
}
} else {
dataBlocks.set(id, newContent);
}
};
try {
const isQuoteBlock = /quote/i.test(type);
const hasChildBlocks = block.querySelector(BLOCK_SELECTOR) !== null;
if (type === 'page') {
return;
}
if (isQuoteBlock) {
// 引用块:收集子块内容并添加 > 前缀
markChildrenCovered();
const childBlocks = block.querySelectorAll(BLOCK_SELECTOR);
let quoteContent;
if (childBlocks.length > 0) {
const parts = [];
childBlocks.forEach(child => {
const childMd = normalizeText(convertToMarkdown(child.innerHTML));
if (childMd) parts.push(childMd);
});
quoteContent = parts.join('\n');
} else {
quoteContent = normalizeText(block.innerText || block.textContent || '');
}
if (quoteContent) {
const quoted = quoteContent.split('\n').map(line => `> ${line}`).join('\n');
dataBlocks.set(id, quoted);
}
} else if (isSheetBlock) {
// 嵌入式电子表格:允许多次采集(Canvas 可能在后续 tick 才渲染)
markChildrenCovered();
const sheetMd = extractSheetMarkdown(block);
if (sheetMd) {
updateIfLonger(id, sheetMd);
} else if (!alreadyCaptured) {
// 首次采集失败时使用 fallback 占位,后续 tick 如果成功会覆盖
const fallbackText = normalizeText(block.innerText || block.textContent || '');
if (fallbackText) {
dataBlocks.set(id, fallbackText);
} else {
dataBlocks.set(id, '\n> ⚠️ [嵌入式电子表格 — 数据在 Canvas 中渲染,暂无法自动提取为文本]\n');
}
}
} else if (isCodeBlock) {
// 代码块:允许多次采集保留最长内容
markChildrenCovered();
const markdown = normalizeText(convertToMarkdown(block.innerHTML));
updateIfLonger(id, markdown);
} else if (isTableLike) {
// 表格块:允许多次采集保留最长内容(虚拟渲染可能导致首次采集不完整)
markChildrenCovered();
const tableMarkdown = normalizeText(tableNodeToMarkdown(block));
if (tableMarkdown) {
updateIfLonger(id, tableMarkdown);
} else {
const markdown = normalizeText(convertToMarkdown(block.innerHTML));
updateIfLonger(id, markdown);
}
} else if (/^(bullet|ordered|todoList)$/i.test(type) && hasChildBlocks) {
// 列表块且含有子块:递归渲染为嵌套 Markdown 列表
markChildrenCovered();
const listMd = renderListBlock(block, 0);
if (listMd) {
dataBlocks.set(id, listMd);
}
} else if (hasChildBlocks) {
// 普通容器块:跳过自身,让子块各自处理
return;
} else {
// 叶子块
const markdown = normalizeText(convertToMarkdown(block.innerHTML));
if (markdown) dataBlocks.set(id, markdown);
}
} catch (error) {
const textFallback = normalizeText(block.innerText || block.textContent || '');
if (textFallback) dataBlocks.set(id, textFallback);
console.warn(`${SCRIPT_TAG} parse block failed`, error);
}
});
}
// 滚动页面并获取所有的 data-block-id 元素
function scrollAndScrape(container, onDone) {
if (isScrolling) return;
isScrolling = true;
canvasCaptureEnabled = true;
scanStartedAt = Date.now();
const savedScrollTop = container.scrollTop;
let currentY = Math.max(0, container.scrollTop || 0);
let percent = 0;
let prevScrollHeight = container.scrollHeight;
let bottomStableRounds = 0;
const finish = () => {
if (!isScrolling) return;
// 最终额外扫描:在收尾前再做两次延迟扫描,捕获最后一批 Canvas 绘制
scrapeDataBlocks();
setTimeout(() => {
scrapeDataBlocks();
isScrolling = false;
canvasCaptureEnabled = false;
try { container.scrollTo({ top: savedScrollTop, behavior: 'auto' }); } catch (e) { }
if (typeof onDone === 'function') onDone();
}, 600);
};
const tick = () => {
if (!isScrolling) return;
scrapeDataBlocks();
if (Date.now() - scanStartedAt > SCAN_TIMEOUT_MS) {
console.warn(`${SCRIPT_TAG} scan timeout`);
finish();
return;
}
const scrollHeight = Math.max(container.scrollHeight, 1);
const maxScrollable = Math.max(0, scrollHeight - container.clientHeight);
const curTop = Math.max(container.scrollTop, currentY);
const reachedBottom = curTop >= maxScrollable - 4;
if (reachedBottom) {
if (scrollHeight <= prevScrollHeight + 4) {
bottomStableRounds += 1;
} else {
bottomStableRounds = 0;
}
if (bottomStableRounds >= 2) {
finish();
return;
}
} else {
bottomStableRounds = 0;
}
prevScrollHeight = scrollHeight;
// 步长取视口 50%(而非 66%),确保相邻两次 tick 有足够重叠,
// 让虚拟渲染有时间加载表格等复杂块
const step = Math.max(200, Math.floor(container.clientHeight * 0.5));
currentY = Math.min(maxScrollable, curTop + step);
container.scrollTo({ top: currentY, behavior: 'auto' });
const curPercent = ((currentY + container.clientHeight) / scrollHeight) * 100;
percent = Math.max(percent, Math.min(100, curPercent));
setButtonState(ButtonState.SCANNING, `扫描中: ${percent.toFixed(1)}%`);
// 延迟 400ms 后再做一次扫描,给 Canvas 元素留出绘制时间
setTimeout(() => {
if (isScrolling) scrapeDataBlocks();
}, 400);
setTimeout(tick, 850);
};
setButtonState(ButtonState.SCANNING, '扫描中: 0.0%');
setTimeout(tick, 300);
}
// 点击一键复制事件
function CopyAllListener(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
}
if (isScrolling || buttonState === ButtonState.SCANNING || buttonState === ButtonState.COPYING) {
alert('正在扫描中,请稍候再试。');
return;
}
clearButtonResetTimer();
dataBlocks.clear();
coveredIds.clear();
setButtonState(ButtonState.SCANNING, '扫描中: 0.0%');
const container = getScrollContainer();
if (!container) {
setButtonState(ButtonState.ERROR, '未找到正文');
alert('未找到文档滚动容器,请确认当前页面为飞书文档正文页面。');
scheduleButtonReset();
return;
}
scrollAndScrape(container, async () => {
scrapeDataBlocks();
let allContent = normalizeText(Array.from(dataBlocks.values()).join('\n\n'));
if (!allContent) {
allContent = buildFallbackContent();
}
if (!allContent) {
setButtonState(ButtonState.ERROR, '未抓到内容');
alert('未抓到内容,请确认文档正文已加载后重试。');
scheduleButtonReset();
return;
}
setButtonState(ButtonState.COPYING, '写入剪贴板...');
try {
const result = await copyTextToClipboard(allContent);
const methodLabel = result.method === 'gm' ? 'GM剪贴板' : '浏览器剪贴板';
alert(`已复制全文到剪贴板(${allContent.length} 字符,${methodLabel})`);
setButtonState(ButtonState.DONE, '已复制');
scheduleButtonReset();
} catch (error) {
console.warn(`${SCRIPT_TAG} copy-all failed`, error);
const reason = resolveCopyErrorReason(error);
alert(`复制失败:${reason}`);
setButtonState(ButtonState.ERROR, '复制失败');
scheduleButtonReset(3200);
}
});
}
// 创建复制按钮
function createCopyButton(forceIdle = true) {
if (!isDocPage()) {
const existed = getCopyButton();
if (existed) existed.remove();
return;
}
if (!document.body) return;
let button = getCopyButton();
if (!button) {
button = document.createElement('button');
button.id = BUTTON_ID;
button.innerHTML = `${MD_ICON}复制全文`;
document.body.appendChild(button);
if (!styleInjected) {
addStyle(`
#${BUTTON_ID} {
position: fixed;
top: 15px;
left: 50%;
transform: translateX(-50%);
padding: 6px 18px;
font-size: 16px;
background: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
z-index: 2147483647;
display: flex;
place-items: center;
box-shadow: 0 0 3px #1117;
}
#${BUTTON_ID}:hover {
background: #0056b3;
}
`);
styleInjected = true;
}
button.addEventListener('click', CopyAllListener);
}
if (forceIdle) {
setButtonState(ButtonState.IDLE);
} else {
setButtonState(buttonState);
}
}
function bootstrapButton() {
createCopyButton(true);
waitForElement('#docx > div, #docx, div[data-block-id]', () => {
if (buttonState !== ButtonState.IDLE && getCopyButton()) return;
if (buttonState === ButtonState.IDLE) {
createCopyButton(true);
} else {
createCopyButton(false);
}
}, 60000);
}
function bootstrapGuards() {
if (!isDocPage()) return;
installWatermarkRemoval();
installCopyBypass();
}
function handleRouteChange() {
if (window.location.href === lastHref) return;
lastHref = window.location.href;
isScrolling = false;
dataBlocks.clear();
coveredIds.clear();
clearButtonResetTimer();
console.log(`${SCRIPT_TAG} route changed: ${lastHref}`);
setTimeout(() => {
bootstrapGuards();
bootstrapButton();
}, 350);
}
function installRouteWatcher() {
if (routeWatcherInstalled) return;
routeWatcherInstalled = true;
const wrapHistory = (methodName) => {
const raw = history[methodName];
if (typeof raw !== 'function') return;
history[methodName] = function (...args) {
const result = raw.apply(this, args);
handleRouteChange();
return result;
};
};
wrapHistory('pushState');
wrapHistory('replaceState');
window.addEventListener('popstate', handleRouteChange);
window.addEventListener('hashchange', handleRouteChange);
setInterval(handleRouteChange, 2500);
}
// 主函数
console.log(`${SCRIPT_TAG} injected: ${window.location.href}`);
bootstrapGuards();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrapButton, { once: true });
} else {
bootstrapButton();
}
installRouteWatcher();
})();