copy-notion-page-content-as-markdown

一键复制 Notion 页面内容为标准 Markdown 格式。

  1. // ==UserScript==
  2. // @name copy-notion-page-content-as-markdown
  3. // @name:en Copy Notion Page Content AS Markdown
  4. // @name:zh-CN 一键复制 Notion 页面内容为标准 Markdown 格式
  5. // @namespace https://github.com/Seven-Steven/tampermonkey-scripts/tree/main/copy-notion-page-content-as-markdown
  6. // @supportURL https://github.com/Seven-Steven/tampermonkey-scripts/issues
  7. // @description 一键复制 Notion 页面内容为标准 Markdown 格式。
  8. // @description:zh-CN 一键复制 Notion 页面内容为标准 Markdown 格式。
  9. // @description:en Copy Notion Page Content AS Markdown.
  10. // @version 2.2
  11. // @license MIT
  12. // @author Seven
  13. // @homepage https://blog.diqigan.cn
  14. // @match *://www.notion.so/*
  15. // @icon https://www.google.com/s2/favicons?sz=64&domain=notion.so
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20.  
  21. /**
  22. * 复制按钮 ID
  23. */
  24. const DOM_ID_OF_COPY_BUTTON = 'tamper-monkey-plugin-copy-notion-content-as-markdown-copy-button';
  25. /**
  26. * Notion 页面祖先节点 Selector
  27. */
  28. const DOM_SELECTOR_NOTION_PAGE_ANCESTOR = '#notion-app';
  29. /**
  30. * 公共的 Notion Page Content Selector
  31. */
  32. const DOM_SELECTOR_NOTION_PAGE_CONTENT_COMMON = `${DOM_SELECTOR_NOTION_PAGE_ANCESTOR} .notion-page-content`;
  33. /**
  34. * 普通页面的 Notion Page Content Selector
  35. */
  36. const DOM_SELECTOR_NOTION_PAGE_CONTENT_NORMAL = `${DOM_SELECTOR_NOTION_PAGE_ANCESTOR} main.notion-frame .notion-page-content`;
  37. /**
  38. * 插件挂载状态
  39. */
  40. let PLUGIN_MOUNT_STATUS = false;
  41.  
  42. init();
  43.  
  44. /**
  45. * 初始化动作
  46. */
  47. function init() {
  48. console.log('init TamperMonkey plugin: Copy Notion Content AS Markdown.');
  49.  
  50. const mountPlugin = () => {
  51. console.log('find Notion Page, mount Plugin directly.');
  52. onMount();
  53. };
  54.  
  55. waitForElements(10000, 1000, DOM_SELECTOR_NOTION_PAGE_CONTENT_COMMON)
  56. // 对于 Notion Page 页面,直接初始化插件就好
  57. .then(mountPlugin).catch(() => { });
  58.  
  59. waitForElements(10000, 1000, DOM_SELECTOR_NOTION_PAGE_CONTENT_NORMAL)
  60. .then(mountPlugin)
  61. // 对于 DataBase / View 等其他页面,需要监听 DOM 节点变化判断当前页面有没有 Notion Page Content DOM,进而装载 / 卸载插件
  62. .catch(() => {
  63. console.log('can not find notion page, add observe for ancestor.');
  64. autoMountOrUmountPluginByObserverFor(DOM_SELECTOR_NOTION_PAGE_ANCESTOR)
  65. });
  66. }
  67.  
  68. /**
  69. * 监听指定 DOM 的子节点变化,并根据子节点变化动态装载 / 卸载插件
  70. * @param {string} selector 节点选择器
  71. */
  72. const autoMountOrUmountPluginDebounce = debounce(autoMountOrUmountPlugin, 500);
  73. function autoMountOrUmountPluginByObserverFor(selector) {
  74. const ancestorDOM = document.querySelector(selector);
  75. if (!ancestorDOM) {
  76. console.error('Ancestor DOM of Notion Page does not exist!');
  77. return;
  78. }
  79.  
  80. // 创建 MutationObserver 实例,监听页面节点变化
  81. const observer = new MutationObserver(mutations => {
  82. for (let mutation of mutations) {
  83. if (mutation.type === 'childList') {
  84. // 在页面节点子元素发生变化时,根据条件挂载/卸载插件
  85. autoMountOrUmountPluginDebounce();
  86. break;
  87. }
  88. }
  89. });
  90.  
  91. // 配置 MutationObserver 监听选项
  92. const observerConfig = {
  93. childList: true,
  94. subtree: true,
  95. characterData: false,
  96. attributes: false,
  97. };
  98.  
  99. // 开始监听页面节点变化
  100. observer.observe(ancestorDOM, observerConfig);
  101. }
  102.  
  103. /**
  104. * 装载/卸载插件
  105. */
  106. function autoMountOrUmountPlugin() {
  107. console.log('auto solve plugin...');
  108. waitForElements(500, 100, DOM_SELECTOR_NOTION_PAGE_CONTENT_COMMON).then(() => {
  109. console.log('Find Notion Page Content, begin to mount plugin......');
  110. onMount();
  111. }).catch(() => {
  112. console.log('Can not find Notion Page Content, begin to umount plugin......');
  113. onUmount();
  114. })
  115. }
  116.  
  117. /**
  118. * 装载插件
  119. */
  120. function onMount() {
  121. if (PLUGIN_MOUNT_STATUS) {
  122. console.log('Plugin already mounted, return.');
  123. return;
  124. }
  125.  
  126. initCopyButton();
  127. window.addEventListener('copy', fixNotionMarkdownInClipboard);
  128. PLUGIN_MOUNT_STATUS = true;
  129. console.log('Plugin Mounted.');
  130. }
  131.  
  132. /**
  133. * 卸载插件
  134. */
  135. function onUmount() {
  136. if (!PLUGIN_MOUNT_STATUS) {
  137. console.log('Plugin not mounted, return.');
  138. return;
  139. }
  140.  
  141. removeCopyButton();
  142. window.removeEventListener('copy', fixNotionMarkdownInClipboard);
  143. PLUGIN_MOUNT_STATUS = false;
  144. console.log('Plugin UnMounted.');
  145. }
  146.  
  147. /**
  148. * 修正剪切板中的 Notion Markdown 文本格式
  149. */
  150. function fixNotionMarkdownInClipboard() {
  151. navigator.clipboard.readText().then(text => {
  152. const markdown = fixMarkdownFormat(text);
  153. navigator.clipboard.writeText(markdown).then(() => {
  154. showMessage('复制成功');
  155. }, () => {
  156. console.log('failed.');
  157. })
  158. })
  159. }
  160.  
  161. /**
  162. * 修正 markdown 格式
  163. */
  164. function fixMarkdownFormat(markdown) {
  165. if (!markdown) {
  166. return;
  167. }
  168.  
  169. // 给没有 Caption 的图片添加默认 ALT 文字
  170. markdown = markdown.replaceAll(/^!(http\S+)$/gm, (match, imgUrl) => {
  171. return `![picture](${imgUrl})`;
  172. });
  173. // 给有 Caption 的图片去除多余文字
  174. const captionRegex = /(\!\[(?<title>.+?)\]\(.*?\)\s*)\k<title>\s*/g;
  175. return markdown.replaceAll(captionRegex, '$1');
  176. }
  177.  
  178. /**
  179. * 初始化复制按钮
  180. */
  181. function initCopyButton() {
  182. const copyButton = document.createElement('div');
  183.  
  184. copyButton.style.position = 'fixed';
  185. copyButton.style.width = '80px';
  186. copyButton.style.height = '22px';
  187. copyButton.style.lineHeight = '22px';
  188. copyButton.style.top = '14%';
  189. copyButton.style.right = '1%';
  190. copyButton.style.background = '#0084ff';
  191. copyButton.style.fontSize = '14px';
  192. copyButton.style.color = '#fff';
  193. copyButton.style.textAlign = 'center';
  194. copyButton.style.borderRadius = '6px';
  195. copyButton.style.zIndex = 10000;
  196. copyButton.style.cursor = 'pointer';
  197. copyButton.style.opacity = 0.7;
  198. copyButton.innerHTML = '复制内容';
  199. copyButton.id = DOM_ID_OF_COPY_BUTTON;
  200. copyButton.addEventListener('click', copyNotionPageContent);
  201.  
  202. document.body.prepend(copyButton);
  203. }
  204.  
  205. /**
  206. * 移除复制按钮
  207. */
  208. function removeCopyButton() {
  209. const copyButton = document.getElementById(DOM_ID_OF_COPY_BUTTON);
  210. if (!copyButton) {
  211. return;
  212. }
  213.  
  214. copyButton.remove();
  215. }
  216.  
  217. /**
  218. * 复制 Notion Page 内容
  219. */
  220. function copyNotionPageContent() {
  221. const selection = window.getSelection();
  222. selection.removeAllRanges();
  223. const pageContent = document.querySelector(DOM_SELECTOR_NOTION_PAGE_CONTENT_COMMON);
  224. if (!pageContent) {
  225. console.error("No Notion Page Content on Current Page.");
  226. return;
  227. }
  228. const range = new Range();
  229.  
  230. const contentNextUncle = findNextElement(pageContent);
  231. range.setStart(pageContent, 0);
  232. if (contentNextUncle) {
  233. range.setEnd(contentNextUncle, 0);
  234. } else {
  235. range.setEndAfter(pageContent.lastChild);
  236. }
  237.  
  238. selection.addRange(range);
  239.  
  240. // console.log('childrenNodeCount', pageContent.childElementCount, pageContent.childNodes.length);
  241. // Array.from(pageContent.childNodes).forEach(e => console.log(selection.containsNode(e)));
  242.  
  243. setTimeout(() => {
  244. document.execCommand('copy');
  245. selection.removeAllRanges();
  246. }, 500);
  247. }
  248.  
  249. /**
  250. * 查找指定 DOM 的下一个元素
  251. * @param {Node} node DOM
  252. * @returns 指定 DOM 的下一个元素
  253. */
  254. function findNextElement(node) {
  255. while (node.nextSibling === null) {
  256. node = node.parentNode;
  257. }
  258. return node.nextSibling;
  259. }
  260.  
  261. /**
  262. * 在页面显示提示信息
  263. */
  264. function showMessage(message) {
  265. const toast = document.createElement('div');
  266. toast.style.position = 'fixed';
  267. toast.style.bottom = '20px';
  268. toast.style.left = '50%';
  269. toast.style.transform = 'translateX(-50%)';
  270. toast.style.padding = '10px 20px';
  271. toast.style.background = 'rgba(0, 0, 0, 0.8)';
  272. toast.style.color = 'white';
  273. toast.style.borderRadius = '5px';
  274. toast.style.zIndex = '9999';
  275. toast.innerText = message;
  276. document.body.appendChild(toast);
  277. setTimeout(function () {
  278. toast.remove();
  279. }, 3000);
  280. }
  281.  
  282. /**
  283. * 延迟执行
  284. **/
  285. function sleep(ms) {
  286. return new Promise(resolve => setTimeout(resolve, ms));
  287. }
  288.  
  289. /**
  290. * 防抖方法,连续触发场景下只执行一次
  291. * 触发高频事件后一段时间(wait)只会执行一次函数,如果指定时间(wait)内高频事件再次被触发,则重新计算时间。
  292. * @param {function} func 待执行的方法
  293. * @param {number} wait 执行方法前要等待的毫秒数
  294. * @returns
  295. */
  296. function debounce(func, wait) {
  297. let timeout = null;
  298. return function () {
  299. let context = this;
  300. let args = arguments;
  301. if (timeout) clearTimeout(timeout);
  302. timeout = setTimeout(() => {
  303. func.apply(context, args);
  304. }, wait);
  305. };
  306. }
  307.  
  308. /**
  309. * 节流方法,连续触发场景下每 wait 时间区间执行一次
  310. * 规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效
  311. * @param {function} func 待执行的方法
  312. * @param {number} wait 执行方法前要等待的毫秒数
  313. * @returns
  314. */
  315. function throttle(func, wait) {
  316. let timeout = null;
  317. return function () {
  318. let context = this;
  319. let args = arguments;
  320. if (!timeout) {
  321. timeout = setTimeout(() => {
  322. timeout = null;
  323. func.apply(context, args);
  324. }, wait);
  325. }
  326. };
  327. }
  328.  
  329. /**
  330. * 在 maxWait 时间内等待所有 selectors 对应的 DOM 全部加载完成,每隔 interval 毫秒检查一次
  331. * @param {number} maxWait 最大等待毫秒数
  332. * @param {number} interval 检查间隔毫秒数
  333. * @param {...string} selectors DOM 选择器
  334. * @returns Promise
  335. */
  336. function waitForElements(maxWait, interval, ...selectors) {
  337. return new Promise((resolve, reject) => {
  338. const checkElements = () => {
  339. const elements = selectors.map(selector => document.querySelector(selector));
  340. if (elements.every(element => element != null)) {
  341. resolve(elements);
  342. } else if (maxWait <= 0) {
  343. reject(new Error('Timeout'));
  344. } else {
  345. setTimeout(checkElements, interval);
  346. maxWait -= interval;
  347. }
  348. };
  349.  
  350. setTimeout(() => {
  351. reject(new Error('Timeout'));
  352. }, maxWait);
  353.  
  354. checkElements();
  355. });
  356. }
  357.  
  358. })();