Discourse Markdown 主题导出

将任意 Discourse 论坛的主题导出,支持动态页面加载

  1. // ==UserScript==
  2. // @name Discourse Markdown 主题导出
  3. // @namespace http://innjay.cn
  4. // @version 1.1.0
  5. // @description 将任意 Discourse 论坛的主题导出,支持动态页面加载
  6. // @author Hebaodan
  7. // @match http://*/*
  8. // @match https://*/*
  9. // @license MIT
  10. // @icon https://img.innjay.cn/i/2024/10/01/66fbf6914fe72.png
  11. // @grant GM_setClipboard
  12. // @require https://unpkg.com/turndown@7.1.3/dist/turndown.js
  13. // @downloadURL
  14. // @updateURL
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. let buttonContainer = null;
  21.  
  22. // 检查当前页面是否是 Discourse 论坛
  23. function isDiscourseForum() {
  24. return document.querySelector('meta[name="generator"][content*="Discourse"]') !== null;
  25. }
  26.  
  27. // 检查是否在主题页面
  28. function isTopicPage() {
  29. return window.location.pathname.match(/\/t\/.*\/\d+/);
  30. }
  31.  
  32. // 创建按钮容器
  33. function createButtonContainer() {
  34. if (buttonContainer) {
  35. buttonContainer.remove();
  36. }
  37.  
  38. const container = document.createElement('div');
  39. container.style.cssText = `
  40. position: fixed;
  41. bottom: 20px;
  42. right: 20px;
  43. z-index: 9999;
  44. display: flex;
  45. flex-direction: column;
  46. gap: 10px;
  47. `;
  48. document.body.appendChild(container);
  49. buttonContainer = container;
  50. return container;
  51. }
  52.  
  53. // 创建按钮
  54. function createButton(text, onClick) {
  55. const button = document.createElement('button');
  56. button.textContent = text;
  57. button.style.cssText = `
  58. padding: 10px;
  59. background-color: #4CAF50;
  60. color: white;
  61. border: none;
  62. border-radius: 5px;
  63. cursor: pointer;
  64. `;
  65. button.addEventListener('click', onClick);
  66. return button;
  67. }
  68.  
  69. // 获取文章内容
  70. function getArticleContent() {
  71. const titleElement = document.querySelector('#topic-title h1');
  72. const contentElement = document.querySelector('#post_1 .cooked');
  73.  
  74. if (!titleElement || !contentElement) {
  75. console.error('无法找到文章标题或内容');
  76. return null;
  77. }
  78.  
  79. return {
  80. title: titleElement.textContent.trim(),
  81. content: contentElement.innerHTML
  82. };
  83. }
  84.  
  85. // 转换为Markdown
  86. function convertToMarkdown(article) {
  87. const turndownService = new TurndownService({
  88. headingStyle: 'atx',
  89. codeBlockStyle: 'fenced'
  90. });
  91.  
  92. // 自定义规则处理图片和链接
  93. turndownService.addRule('images_and_links', {
  94. filter: ['a', 'img'],
  95. replacement: function (content, node) {
  96. // 处理图片
  97. if (node.nodeName === 'IMG') {
  98. const alt = node.alt || '';
  99. const src = node.getAttribute('src') || '';
  100. const title = node.title ? ` "${node.title}"` : '';
  101. return `![${alt}](${src}${title})`;
  102. }
  103. // 处理链接
  104. else if (node.nodeName === 'A') {
  105. const href = node.getAttribute('href');
  106. const title = node.title ? ` "${node.title}"` : '';
  107. // 检查链接是否包含图片
  108. const img = node.querySelector('img');
  109. if (img) {
  110. const alt = img.alt || '';
  111. const src = img.getAttribute('src') || '';
  112. const imgTitle = img.title ? ` "${img.title}"` : '';
  113. return `[![${alt}](${src}${imgTitle})](${href}${title})`;
  114. }
  115. // 普通链接
  116. return `[${node.textContent}](${href}${title})`;
  117. }
  118. }
  119. });
  120.  
  121. return `# ${article.title}\n\n${turndownService.turndown(article.content)}`;
  122. }
  123.  
  124. // 下载为Markdown文件
  125. function downloadAsMarkdown() {
  126. const article = getArticleContent();
  127. if (!article) {
  128. alert('无法获取文章内容,请检查网页结构是否变更。');
  129. return;
  130. }
  131.  
  132. const markdown = convertToMarkdown(article);
  133.  
  134. const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
  135. const url = URL.createObjectURL(blob);
  136. const a = document.createElement('a');
  137. a.href = url;
  138. a.download = `${article.title}.md`;
  139. a.click();
  140. URL.revokeObjectURL(url);
  141.  
  142. showNotification('Markdown 文件已下载');
  143. }
  144.  
  145. // 复制到剪贴板
  146. function copyToClipboard() {
  147. const article = getArticleContent();
  148. if (!article) {
  149. alert('无法获取文章内容,请检查网页结构是否变更。');
  150. return;
  151. }
  152.  
  153. const markdown = convertToMarkdown(article);
  154. GM_setClipboard(markdown);
  155.  
  156. showNotification('内容已复制到剪贴板');
  157. }
  158.  
  159. // 显示通知
  160. function showNotification(message) {
  161. const notification = document.createElement('div');
  162. notification.textContent = message;
  163. notification.style.cssText = `
  164. position: fixed;
  165. top: 20px;
  166. right: 20px;
  167. background-color: #333;
  168. color: white;
  169. padding: 10px;
  170. border-radius: 5px;
  171. z-index: 10000;
  172. `;
  173. document.body.appendChild(notification);
  174. setTimeout(() => notification.remove(), 3000);
  175. }
  176.  
  177. // 主函数
  178. function main() {
  179. // 首先检查是否是 Discourse 论坛
  180. if (!isDiscourseForum()) {
  181. return;
  182. }
  183.  
  184. if (isTopicPage()) {
  185. const container = createButtonContainer();
  186. const downloadButton = createButton('下载 Markdown', downloadAsMarkdown);
  187. const copyButton = createButton('复制到剪贴板', copyToClipboard);
  188. container.appendChild(downloadButton);
  189. container.appendChild(copyButton);
  190. } else {
  191. if (buttonContainer) {
  192. buttonContainer.remove();
  193. buttonContainer = null;
  194. }
  195. }
  196. }
  197.  
  198. // 延迟执行主函数,确保页面完全加载
  199. setTimeout(main, 1000);
  200.  
  201. // 监听 URL 变化
  202. let lastUrl = location.href;
  203. new MutationObserver(() => {
  204. const url = location.href;
  205. if (url !== lastUrl) {
  206. lastUrl = url;
  207. setTimeout(main, 1000);
  208. }
  209. }).observe(document, { subtree: true, childList: true });
  210. })();