// ==UserScript==
// @name copy-notion-page-content-as-markdown
// @name:en Copy Notion Page Content AS Markdown
// @name:zh-CN 一键复制 Notion 页面内容为标准 Markdown 格式
// @namespace https://github.com/Seven-Steven/tampermonkey-scripts/tree/main/copy-notion-page-content-as-markdown
// @supportURL https://github.com/Seven-Steven/tampermonkey-scripts/issues
// @description 一键复制 Notion 页面内容为标准 Markdown 格式。
// @description:zh-CN 一键复制 Notion 页面内容为标准 Markdown 格式。
// @description:en Copy Notion Page Content AS Markdown.
// @version 2.2
// @license MIT
// @author Seven
// @homepage https://blog.diqigan.cn
// @match *://www.notion.so/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=notion.so
// ==/UserScript==
(function () {
'use strict';
/**
* 复制按钮 ID
*/
const DOM_ID_OF_COPY_BUTTON = 'tamper-monkey-plugin-copy-notion-content-as-markdown-copy-button';
/**
* Notion 页面祖先节点 Selector
*/
const DOM_SELECTOR_NOTION_PAGE_ANCESTOR = '#notion-app';
/**
* 公共的 Notion Page Content Selector
*/
const DOM_SELECTOR_NOTION_PAGE_CONTENT_COMMON = `${DOM_SELECTOR_NOTION_PAGE_ANCESTOR} .notion-page-content`;
/**
* 普通页面的 Notion Page Content Selector
*/
const DOM_SELECTOR_NOTION_PAGE_CONTENT_NORMAL = `${DOM_SELECTOR_NOTION_PAGE_ANCESTOR} main.notion-frame .notion-page-content`;
/**
* 插件挂载状态
*/
let PLUGIN_MOUNT_STATUS = false;
init();
/**
* 初始化动作
*/
function init() {
console.log('init TamperMonkey plugin: Copy Notion Content AS Markdown.');
const mountPlugin = () => {
console.log('find Notion Page, mount Plugin directly.');
onMount();
};
waitForElements(10000, 1000, DOM_SELECTOR_NOTION_PAGE_CONTENT_COMMON)
// 对于 Notion Page 页面,直接初始化插件就好
.then(mountPlugin).catch(() => { });
waitForElements(10000, 1000, DOM_SELECTOR_NOTION_PAGE_CONTENT_NORMAL)
.then(mountPlugin)
// 对于 DataBase / View 等其他页面,需要监听 DOM 节点变化判断当前页面有没有 Notion Page Content DOM,进而装载 / 卸载插件
.catch(() => {
console.log('can not find notion page, add observe for ancestor.');
autoMountOrUmountPluginByObserverFor(DOM_SELECTOR_NOTION_PAGE_ANCESTOR)
});
}
/**
* 监听指定 DOM 的子节点变化,并根据子节点变化动态装载 / 卸载插件
* @param {string} selector 节点选择器
*/
const autoMountOrUmountPluginDebounce = debounce(autoMountOrUmountPlugin, 500);
function autoMountOrUmountPluginByObserverFor(selector) {
const ancestorDOM = document.querySelector(selector);
if (!ancestorDOM) {
console.error('Ancestor DOM of Notion Page does not exist!');
return;
}
// 创建 MutationObserver 实例,监听页面节点变化
const observer = new MutationObserver(mutations => {
for (let mutation of mutations) {
if (mutation.type === 'childList') {
// 在页面节点子元素发生变化时,根据条件挂载/卸载插件
autoMountOrUmountPluginDebounce();
break;
}
}
});
// 配置 MutationObserver 监听选项
const observerConfig = {
childList: true,
subtree: true,
characterData: false,
attributes: false,
};
// 开始监听页面节点变化
observer.observe(ancestorDOM, observerConfig);
}
/**
* 装载/卸载插件
*/
function autoMountOrUmountPlugin() {
console.log('auto solve plugin...');
waitForElements(500, 100, DOM_SELECTOR_NOTION_PAGE_CONTENT_COMMON).then(() => {
console.log('Find Notion Page Content, begin to mount plugin......');
onMount();
}).catch(() => {
console.log('Can not find Notion Page Content, begin to umount plugin......');
onUmount();
})
}
/**
* 装载插件
*/
function onMount() {
if (PLUGIN_MOUNT_STATUS) {
console.log('Plugin already mounted, return.');
return;
}
initCopyButton();
window.addEventListener('copy', fixNotionMarkdownInClipboard);
PLUGIN_MOUNT_STATUS = true;
console.log('Plugin Mounted.');
}
/**
* 卸载插件
*/
function onUmount() {
if (!PLUGIN_MOUNT_STATUS) {
console.log('Plugin not mounted, return.');
return;
}
removeCopyButton();
window.removeEventListener('copy', fixNotionMarkdownInClipboard);
PLUGIN_MOUNT_STATUS = false;
console.log('Plugin UnMounted.');
}
/**
* 修正剪切板中的 Notion Markdown 文本格式
*/
function fixNotionMarkdownInClipboard() {
navigator.clipboard.readText().then(text => {
const markdown = fixMarkdownFormat(text);
navigator.clipboard.writeText(markdown).then(() => {
showMessage('复制成功');
}, () => {
console.log('failed.');
})
})
}
/**
* 修正 markdown 格式
*/
function fixMarkdownFormat(markdown) {
if (!markdown) {
return;
}
// 给没有 Caption 的图片添加默认 ALT 文字
markdown = markdown.replaceAll(/^!(http\S+)$/gm, (match, imgUrl) => {
return ``;
});
// 给有 Caption 的图片去除多余文字
const captionRegex = /(\!\[(?<title>.+?)\]\(.*?\)\s*)\k<title>\s*/g;
return markdown.replaceAll(captionRegex, '$1');
}
/**
* 初始化复制按钮
*/
function initCopyButton() {
const copyButton = document.createElement('div');
copyButton.style.position = 'fixed';
copyButton.style.width = '80px';
copyButton.style.height = '22px';
copyButton.style.lineHeight = '22px';
copyButton.style.top = '14%';
copyButton.style.right = '1%';
copyButton.style.background = '#0084ff';
copyButton.style.fontSize = '14px';
copyButton.style.color = '#fff';
copyButton.style.textAlign = 'center';
copyButton.style.borderRadius = '6px';
copyButton.style.zIndex = 10000;
copyButton.style.cursor = 'pointer';
copyButton.style.opacity = 0.7;
copyButton.innerHTML = '复制内容';
copyButton.id = DOM_ID_OF_COPY_BUTTON;
copyButton.addEventListener('click', copyNotionPageContent);
document.body.prepend(copyButton);
}
/**
* 移除复制按钮
*/
function removeCopyButton() {
const copyButton = document.getElementById(DOM_ID_OF_COPY_BUTTON);
if (!copyButton) {
return;
}
copyButton.remove();
}
/**
* 复制 Notion Page 内容
*/
function copyNotionPageContent() {
const selection = window.getSelection();
selection.removeAllRanges();
const pageContent = document.querySelector(DOM_SELECTOR_NOTION_PAGE_CONTENT_COMMON);
if (!pageContent) {
console.error("No Notion Page Content on Current Page.");
return;
}
const range = new Range();
const contentNextUncle = findNextElement(pageContent);
range.setStart(pageContent, 0);
if (contentNextUncle) {
range.setEnd(contentNextUncle, 0);
} else {
range.setEndAfter(pageContent.lastChild);
}
selection.addRange(range);
// console.log('childrenNodeCount', pageContent.childElementCount, pageContent.childNodes.length);
// Array.from(pageContent.childNodes).forEach(e => console.log(selection.containsNode(e)));
setTimeout(() => {
document.execCommand('copy');
selection.removeAllRanges();
}, 500);
}
/**
* 查找指定 DOM 的下一个元素
* @param {Node} node DOM
* @returns 指定 DOM 的下一个元素
*/
function findNextElement(node) {
while (node.nextSibling === null) {
node = node.parentNode;
}
return node.nextSibling;
}
/**
* 在页面显示提示信息
*/
function showMessage(message) {
const toast = document.createElement('div');
toast.style.position = 'fixed';
toast.style.bottom = '20px';
toast.style.left = '50%';
toast.style.transform = 'translateX(-50%)';
toast.style.padding = '10px 20px';
toast.style.background = 'rgba(0, 0, 0, 0.8)';
toast.style.color = 'white';
toast.style.borderRadius = '5px';
toast.style.zIndex = '9999';
toast.innerText = message;
document.body.appendChild(toast);
setTimeout(function () {
toast.remove();
}, 3000);
}
/**
* 延迟执行
**/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 防抖方法,连续触发场景下只执行一次
* 触发高频事件后一段时间(wait)只会执行一次函数,如果指定时间(wait)内高频事件再次被触发,则重新计算时间。
* @param {function} func 待执行的方法
* @param {number} wait 执行方法前要等待的毫秒数
* @returns
*/
function debounce(func, wait) {
let timeout = null;
return function () {
let context = this;
let args = arguments;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
};
}
/**
* 节流方法,连续触发场景下每 wait 时间区间执行一次
* 规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效
* @param {function} func 待执行的方法
* @param {number} wait 执行方法前要等待的毫秒数
* @returns
*/
function throttle(func, wait) {
let timeout = null;
return function () {
let context = this;
let args = arguments;
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
func.apply(context, args);
}, wait);
}
};
}
/**
* 在 maxWait 时间内等待所有 selectors 对应的 DOM 全部加载完成,每隔 interval 毫秒检查一次
* @param {number} maxWait 最大等待毫秒数
* @param {number} interval 检查间隔毫秒数
* @param {...string} selectors DOM 选择器
* @returns Promise
*/
function waitForElements(maxWait, interval, ...selectors) {
return new Promise((resolve, reject) => {
const checkElements = () => {
const elements = selectors.map(selector => document.querySelector(selector));
if (elements.every(element => element != null)) {
resolve(elements);
} else if (maxWait <= 0) {
reject(new Error('Timeout'));
} else {
setTimeout(checkElements, interval);
maxWait -= interval;
}
};
setTimeout(() => {
reject(new Error('Timeout'));
}, maxWait);
checkElements();
});
}
})();