copy-notion-page-content-as-markdown

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

当前为 2023-10-16 提交的版本,查看 最新版本

  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.0
  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.*\.\w+)/g, (match, group1) => {
  171. const processedText = decodeURIComponent(group1);
  172. return `![picture](${processedText})`;
  173. });
  174. // 给有 Caption 的图片去除多余文字
  175. const captionRegex = /(\!\[(?<title>.+?)\]\(.*?\)\s*)\k<title>\s*/g;
  176. return markdown.replaceAll(captionRegex, '$1');
  177. }
  178.  
  179. /**
  180. * 初始化复制按钮
  181. */
  182. function initCopyButton() {
  183. const copyButton = document.createElement('div');
  184.  
  185. copyButton.style.position = 'fixed';
  186. copyButton.style.width = '80px';
  187. copyButton.style.height = '22px';
  188. copyButton.style.lineHeight = '22px';
  189. copyButton.style.top = '14%';
  190. copyButton.style.right = '1%';
  191. copyButton.style.background = '#0084ff';
  192. copyButton.style.fontSize = '14px';
  193. copyButton.style.color = '#fff';
  194. copyButton.style.textAlign = 'center';
  195. copyButton.style.borderRadius = '6px';
  196. copyButton.style.zIndex = 10000;
  197. copyButton.style.cursor = 'pointer';
  198. copyButton.style.opacity = 0.7;
  199. copyButton.innerHTML = '复制内容';
  200. copyButton.id = DOM_ID_OF_COPY_BUTTON;
  201. copyButton.addEventListener('click', copyNotionPageContent);
  202.  
  203. document.body.prepend(copyButton);
  204. }
  205.  
  206. /**
  207. * 移除复制按钮
  208. */
  209. function removeCopyButton() {
  210. const copyButton = document.getElementById(DOM_ID_OF_COPY_BUTTON);
  211. if (!copyButton) {
  212. return;
  213. }
  214.  
  215. copyButton.remove();
  216. }
  217.  
  218. /**
  219. * 复制 Notion Page 内容
  220. */
  221. function copyNotionPageContent() {
  222. const selection = window.getSelection();
  223. selection.removeAllRanges();
  224. const pageContent = document.querySelector(DOM_SELECTOR_NOTION_PAGE_CONTENT_COMMON);
  225. const range = new Range();
  226. // 方法一
  227. // range.setStartBefore(pageContent.firstChild);
  228. // range.setEndAfter(pageContent.lastChild);
  229.  
  230. // 方法二
  231. // range.selectNodeContents(pageContent);
  232.  
  233. // 方法三
  234. // range.selectNode(pageContent);
  235.  
  236. const contentParent = pageContent.parentNode;
  237. const contentNextUncle = contentParent.nextElementSibling;
  238. range.setStart(contentParent, 0);
  239. // range.setEndAfter(contentParent);
  240. range.setEnd(contentNextUncle, 0);
  241.  
  242. selection.addRange(range);
  243. // 方法四
  244. // selection.selectAllChildren(pageContent);
  245. // 方法五
  246. // selection.selectAllChildren(pageContent.parentNode)
  247.  
  248. // console.log('childrenNodeCount', pageContent.childElementCount, pageContent.childNodes.length);
  249. // Array.from(pageContent.childNodes).forEach(e => console.log(selection.containsNode(e)));
  250. setTimeout(() => {
  251. document.execCommand('copy');
  252. selection.removeAllRanges();
  253. }, 500);
  254. }
  255.  
  256. /**
  257. * 在页面显示提示信息
  258. */
  259. function showMessage(message) {
  260. const toast = document.createElement('div');
  261. toast.style.position = 'fixed';
  262. toast.style.bottom = '20px';
  263. toast.style.left = '50%';
  264. toast.style.transform = 'translateX(-50%)';
  265. toast.style.padding = '10px 20px';
  266. toast.style.background = 'rgba(0, 0, 0, 0.8)';
  267. toast.style.color = 'white';
  268. toast.style.borderRadius = '5px';
  269. toast.style.zIndex = '9999';
  270. toast.innerText = message;
  271. document.body.appendChild(toast);
  272. setTimeout(function () {
  273. toast.remove();
  274. }, 3000);
  275. }
  276.  
  277. /**
  278. * 延迟执行
  279. **/
  280. function sleep(ms) {
  281. return new Promise(resolve => setTimeout(resolve, ms));
  282. }
  283.  
  284. /**
  285. * 防抖方法,连续触发场景下只执行一次
  286. * 触发高频事件后一段时间(wait)只会执行一次函数,如果指定时间(wait)内高频事件再次被触发,则重新计算时间。
  287. * @param {function} func 待执行的方法
  288. * @param {number} wait 执行方法前要等待的毫秒数
  289. * @returns
  290. */
  291. function debounce(func, wait) {
  292. let timeout = null;
  293. return function () {
  294. let context = this;
  295. let args = arguments;
  296. if (timeout) clearTimeout(timeout);
  297. timeout = setTimeout(() => {
  298. func.apply(context, args);
  299. }, wait);
  300. };
  301. }
  302.  
  303. /**
  304. * 节流方法,连续触发场景下每 wait 时间区间执行一次
  305. * 规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效
  306. * @param {function} func 待执行的方法
  307. * @param {number} wait 执行方法前要等待的毫秒数
  308. * @returns
  309. */
  310. function throttle(func, wait) {
  311. let timeout = null;
  312. return function () {
  313. let context = this;
  314. let args = arguments;
  315. if (!timeout) {
  316. timeout = setTimeout(() => {
  317. timeout = null;
  318. func.apply(context, args);
  319. }, wait);
  320. }
  321. };
  322. }
  323.  
  324. /**
  325. * 在 maxWait 时间内等待所有 selectors 对应的 DOM 全部加载完成,每隔 interval 毫秒检查一次
  326. * @param {number} maxWait 最大等待毫秒数
  327. * @param {number} interval 检查间隔毫秒数
  328. * @param {...string} selectors DOM 选择器
  329. * @returns Promise
  330. */
  331. function waitForElements(maxWait, interval, ...selectors) {
  332. return new Promise((resolve, reject) => {
  333. const checkElements = () => {
  334. const elements = selectors.map(selector => document.querySelector(selector));
  335. if (elements.every(element => element != null)) {
  336. resolve(elements);
  337. } else if (maxWait <= 0) {
  338. reject(new Error('Timeout'));
  339. } else {
  340. setTimeout(checkElements, interval);
  341. maxWait -= interval;
  342. }
  343. };
  344.  
  345. setTimeout(() => {
  346. reject(new Error('Timeout'));
  347. }, maxWait);
  348.  
  349. checkElements();
  350. });
  351. }
  352.  
  353. })();