Easy Web Page to Markdown

Convert selected HTML to Markdown

  1. // ==UserScript==
  2. // @name Easy Web Page to Markdown
  3. // @name:zh 网页转Markdown工具
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.3.6
  6. // @description Convert selected HTML to Markdown
  7. // @description:zh 将选定的HTML转换为Markdown
  8. // @author shiquda
  9. // @match *://*/*
  10. // @namespace https://github.com/shiquda/shiquda_UserScript
  11. // @supportURL https://github.com/shiquda/shiquda_UserScript/issues
  12. // @grant GM_addStyle
  13. // @grant GM_registerMenuCommand
  14. // @grant GM_setClipboard
  15. // @grant GM_setValue
  16. // @grant GM_getValue
  17. // @require https://code.jquery.com/jquery-3.6.0.min.js
  18. // @require https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js
  19. // @require https://unpkg.com/turndown/dist/turndown.js
  20. // @require https://unpkg.com/@guyplusplus/turndown-plugin-gfm/dist/turndown-plugin-gfm.js
  21. // @require https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.0/marked.min.js
  22. // @license AGPL-3.0
  23. // ==/UserScript==
  24.  
  25.  
  26. (function () {
  27. 'use strict';
  28.  
  29. // User Config
  30. // Short cut
  31.  
  32. const shortCutUserConfig = {
  33. /* Example:
  34. "Shift": false,
  35. "Ctrl": true,
  36. "Alt": false,
  37. "Key": "m"
  38. */
  39. }
  40.  
  41. // Obsidian
  42. const obsidianUserConfig = {
  43. /* Example:
  44. "my note": [
  45. "Inbox/Web/",
  46. "Collection/Web/Reading/"
  47. ]
  48. */
  49. }
  50.  
  51. const guide = `
  52. - 使用**方向键**选择元素
  53. - 上:选择父元素
  54. - 下:选择第一个子元素
  55. - 左:选择上一个兄弟元素
  56. - 右:选择下一个兄弟元素
  57. - 使用**滚轮**放大缩小
  58. - 上:选择父元素
  59. - 下:选择第一个子元素
  60. - 点击元素选择
  61. - 按下 \`Esc\` 键取消选择
  62. `
  63.  
  64. // 全局变量
  65. var isSelecting = false;
  66. var selectedElement = null;
  67. let shortCutConfig, obsidianConfig;
  68. // 读取配置
  69. // 初始化快捷键配置
  70. let storedShortCutConfig = GM_getValue('shortCutConfig');
  71. if (Object.keys(shortCutUserConfig).length !== 0) {
  72. GM_setValue('shortCutConfig', JSON.stringify(shortCutUserConfig));
  73. shortCutConfig = shortCutUserConfig;
  74. } else if (storedShortCutConfig) {
  75. shortCutConfig = JSON.parse(storedShortCutConfig);
  76. }
  77.  
  78. // 初始化Obsidian配置
  79. let storedObsidianConfig = GM_getValue('obsidianConfig');
  80. if (Object.keys(obsidianUserConfig).length !== 0) {
  81. GM_setValue('obsidianConfig', JSON.stringify(obsidianUserConfig));
  82. obsidianConfig = obsidianUserConfig;
  83. } else if (storedObsidianConfig) {
  84. obsidianConfig = JSON.parse(storedObsidianConfig);
  85. }
  86.  
  87.  
  88.  
  89. // HTML2Markdown
  90. function convertToMarkdown(element) {
  91. var html = element.outerHTML;
  92. let turndownMd = turndownService.turndown(html);
  93. turndownMd = turndownMd.replaceAll('[\n\n]', '[]'); // 防止 <a> 元素嵌套的暂时方法,并不完善
  94. return turndownMd;
  95. }
  96.  
  97.  
  98. // 预览
  99. function showMarkdownModal(markdown) {
  100. var $modal = $(`
  101. <div class="h2m-modal-overlay">
  102. <div class="h2m-modal">
  103. <textarea>${markdown}</textarea>
  104. <div class="h2m-preview">${marked.parse(markdown)}</div>
  105. <div class="h2m-buttons">
  106. <button class="h2m-copy">Copy to clipboard</button>
  107. <button class="h2m-download">Download as MD</button>
  108. <select class="h2m-obsidian-select">Send to Obsidian</select>
  109. </div>
  110. <button class="h2m-close">X</button>
  111. </div>
  112. </div>
  113. `);
  114.  
  115.  
  116. $modal.find('.h2m-obsidian-select').append($('<option>').val('').text('Send to Obsidian'));
  117. for (const vault in obsidianConfig) {
  118. for (const path of obsidianConfig[vault]) {
  119. // 插入元素
  120. const $option = $('<option>')
  121. .val(`obsidian://advanced-uri?vault=${vault}&filepath=${path}`)
  122. .text(`${vault}: ${path}`);
  123. $modal.find('.h2m-obsidian-select').append($option);
  124. }
  125. }
  126.  
  127. $modal.find('textarea').on('input', function () {
  128. // console.log("Input event triggered");
  129. var markdown = $(this).val();
  130. var html = marked.parse(markdown);
  131. // console.log("Markdown:", markdown);
  132. // console.log("HTML:", html);
  133. $modal.find('.h2m-preview').html(html);
  134. });
  135.  
  136. $modal.on('keydown', function (e) {
  137. if (e.key === 'Escape') {
  138. $modal.remove();
  139. }
  140. });
  141.  
  142.  
  143. $modal.find('.h2m-copy').on('click', function () { // 复制到剪贴板
  144. GM_setClipboard($modal.find('textarea').val());
  145. $modal.find('.h2m-copy').text('Copied!');
  146. setTimeout(() => {
  147. $modal.find('.h2m-copy').text('Copy to clipboard');
  148. }, 1000);
  149. });
  150.  
  151. $modal.find('.h2m-download').on('click', function () { // 下载
  152. var markdown = $modal.find('textarea').val();
  153. var blob = new Blob([markdown], { type: 'text/markdown' });
  154. var url = URL.createObjectURL(blob);
  155. var a = document.createElement('a');
  156. a.href = url;
  157. // 当前页面标题 + 时间
  158. a.download = `${document.title}-${new Date().toISOString().replace(/:/g, '-')}.md`;
  159. a.click();
  160. });
  161.  
  162. $modal.find('.h2m-obsidian-select').on('change', function () { // 发送到 Obsidian
  163. const val = $(this).val();
  164. if (!val) return;
  165. const markdown = $modal.find('textarea').val();
  166. GM_setClipboard(markdown);
  167. const title = document.title.replaceAll(/[\\/:*?"<>|]/g, '_'); // File name cannot contain any of the following characters: * " \ / < > : | ?
  168. const url = `${val}${title}.md&clipboard=true`;
  169. window.open(url);
  170. });
  171.  
  172. $modal.find('.h2m-close').on('click', function () { // 关闭按钮 X
  173. $modal.remove();
  174. });
  175.  
  176. // 同步滚动
  177. // 获取两个元素
  178. var $textarea = $modal.find('textarea');
  179. var $preview = $modal.find('.h2m-preview');
  180. var isScrolling = false;
  181.  
  182. // 当 textarea 滚动时,设置 preview 的滚动位置
  183. $textarea.on('scroll', function () {
  184. if (isScrolling) {
  185. isScrolling = false;
  186. return;
  187. }
  188. var scrollPercentage = this.scrollTop / (this.scrollHeight - this.offsetHeight);
  189. $preview[0].scrollTop = scrollPercentage * ($preview[0].scrollHeight - $preview[0].offsetHeight);
  190. isScrolling = true;
  191. });
  192.  
  193. // 当 preview 滚动时,设置 textarea 的滚动位置
  194. $preview.on('scroll', function () {
  195. if (isScrolling) {
  196. isScrolling = false;
  197. return;
  198. }
  199. var scrollPercentage = this.scrollTop / (this.scrollHeight - this.offsetHeight);
  200. $textarea[0].scrollTop = scrollPercentage * ($textarea[0].scrollHeight - $textarea[0].offsetHeight);
  201. isScrolling = true;
  202. });
  203.  
  204. $(document).on('keydown', function (e) {
  205. if (e.key === 'Escape' && $('.h2m-modal-overlay').length > 0) {
  206. $('.h2m-modal-overlay').remove();
  207. }
  208. });
  209.  
  210. $('body').append($modal);
  211. }
  212.  
  213. // 开始选择
  214. function startSelecting() {
  215. $('body').addClass('h2m-no-scroll'); // 防止页面滚动
  216. isSelecting = true;
  217. // 操作指南
  218. tip(marked.parse(guide));
  219. }
  220.  
  221. // 结束选择
  222. function endSelecting() {
  223. isSelecting = false;
  224. $('.h2m-selection-box').removeClass('h2m-selection-box');
  225. $('body').removeClass('h2m-no-scroll');
  226. $('.h2m-tip').remove();
  227. }
  228.  
  229. function tip(message, timeout = null) {
  230. var $tipElement = $('<div>')
  231. .addClass('h2m-tip')
  232. .html(message)
  233. .appendTo('body')
  234. .hide()
  235. .fadeIn(200);
  236. if (timeout === null) {
  237. return;
  238. }
  239. setTimeout(function () {
  240. $tipElement.fadeOut(200, function () {
  241. $tipElement.remove();
  242. });
  243. }, timeout);
  244. }
  245.  
  246. // Turndown 配置
  247. var turndownPluginGfm = TurndownPluginGfmService;
  248. var turndownService = new TurndownService({ codeBlockStyle: 'fenced' });
  249.  
  250. turndownPluginGfm.gfm(turndownService); // 引入全部插件
  251. // turndownService.addRule('strikethrough', {
  252. // filter: ['del', 's', 'strike'],
  253. // replacement: function (content) {
  254. // return '~' + content + '~'
  255. // }
  256. // });
  257.  
  258. // turndownService.addRule('latex', {
  259. // filter: ['mjx-container'],
  260. // replacement: function (content, node) {
  261. // const text = node.querySelector('img')?.title;
  262. // const isInline = !node.getAttribute('display');
  263. // if (text) {
  264. // if (isInline) {
  265. // return '$' + text + '$'
  266. // }
  267. // else {
  268. // return '$$' + text + '$$'
  269. // }
  270. // }
  271. // return '';
  272. // }
  273. // });
  274.  
  275.  
  276.  
  277.  
  278. // 添加CSS样式
  279. GM_addStyle(`
  280. .h2m-selection-box {
  281. border: 2px dashed #f00;
  282. background-color: rgba(255, 0, 0, 0.2);
  283. }
  284. .h2m-no-scroll {
  285. overflow: hidden;
  286. z-index: 9997;
  287. }
  288. .h2m-modal {
  289. position: fixed;
  290. top: 50%;
  291. left: 50%;
  292. transform: translate(-50%, -50%);
  293. width: 80%;
  294. height: 80%;
  295. background: white;
  296. border-radius: 10px;
  297. display: flex;
  298. flex-direction: row;
  299. z-index: 9999;
  300. }
  301. .h2m-modal-overlay {
  302. position: fixed;
  303. top: 0;
  304. left: 0;
  305. width: 100%;
  306. height: 100%;
  307. background: rgba(0, 0, 0, 0.5);
  308. z-index: 9998;
  309. }
  310. .h2m-modal textarea,
  311. .h2m-modal .h2m-preview {
  312. width: 50%;
  313. height: 100%;
  314. padding: 20px;
  315. box-sizing: border-box;
  316. overflow-y: auto;
  317. }
  318. .h2m-modal .h2m-buttons {
  319. position: absolute;
  320. bottom: 10px;
  321. right: 10px;
  322. }
  323. .h2m-modal .h2m-buttons button,
  324. .h2m-modal .h2m-obsidian-select {
  325. margin-left: 10px;
  326. background-color: #4CAF50; /* Green */
  327. border: none;
  328. color: white;
  329. padding: 13px 16px;
  330. border-radius: 10px;
  331. text-align: center;
  332. text-decoration: none;
  333. display: inline-block;
  334. font-size: 16px;
  335. transition-duration: 0.4s;
  336. cursor: pointer;
  337. }
  338. .h2m-modal .h2m-buttons button:hover,
  339. .h2m-modal .h2m-obsidian-select:hover {
  340. background-color: #45a049;
  341. }
  342. .h2m-modal .h2m-close {
  343. position: absolute;
  344. top: 10px;
  345. right: 10px;
  346. cursor: pointer;
  347. width: 25px;
  348. height: 25px;
  349. background-color: #f44336;
  350. color: white;
  351. font-size: 16px;
  352. border-radius: 50%;
  353. display: flex;
  354. justify-content: center;
  355. align-items: center;
  356. }
  357. .h2m-tip {
  358. position: fixed;
  359. top: 22%;
  360. left: 82%;
  361. transform: translate(-50%, -50%);
  362. background-color: white;
  363. border: 1px solid black;
  364. padding: 8px;
  365. z-index: 9999;
  366. border-radius: 10px;
  367. box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.5);
  368. background-color: rgba(255, 255, 255, 0.7);
  369. }
  370. `);
  371.  
  372. // 注册触发器
  373. shortCutConfig = shortCutConfig ? shortCutConfig : {
  374. "Shift": false,
  375. "Ctrl": true,
  376. "Alt": false,
  377. "Key": "m"
  378. };
  379. $(document).on('keydown', function (e) {
  380. if (e.ctrlKey === shortCutConfig['Ctrl'] &&
  381. e.altKey === shortCutConfig['Alt'] &&
  382. e.shiftKey === shortCutConfig['Shift'] &&
  383. e.key.toUpperCase() === shortCutConfig['Key'].toUpperCase()) {
  384. e.preventDefault();
  385. startSelecting();
  386. }
  387. // else {
  388. // console.log(e.ctrlKey, e.altKey, e.shiftKey, e.key.toUpperCase());
  389. // }
  390. });
  391. // $(document).on('keydown', function (e) {
  392. // if (e.ctrlKey && e.key === 'm') {
  393. // e.preventDefault();
  394. // startSelecting()
  395. // }
  396. // });
  397.  
  398. GM_registerMenuCommand('Convert to Markdown', function () {
  399. startSelecting()
  400. });
  401.  
  402.  
  403.  
  404. $(document).on('mouseover', function (e) { // 开始选择
  405. if (isSelecting) {
  406. $(selectedElement).removeClass('h2m-selection-box');
  407. selectedElement = e.target;
  408. $(selectedElement).addClass('h2m-selection-box');
  409. }
  410. }).on('wheel', function (e) { // 滚轮事件
  411. if (isSelecting) {
  412. e.preventDefault();
  413. if (e.originalEvent.deltaY < 0) {
  414. selectedElement = selectedElement.parentElement ? selectedElement.parentElement : selectedElement; // 扩大
  415. if (selectedElement.tagName === 'HTML' || selectedElement.tagName === 'BODY') {
  416. selectedElement = selectedElement.firstElementChild;
  417. }
  418. } else {
  419. selectedElement = selectedElement.firstElementChild ? selectedElement.firstElementChild : selectedElement; // 缩小
  420. }
  421. $('.h2m-selection-box').removeClass('h2m-selection-box');
  422. $(selectedElement).addClass('h2m-selection-box');
  423. }
  424. }).on('keydown', function (e) { // 键盘事件
  425. if (isSelecting) {
  426. e.preventDefault();
  427. if (e.key === 'Escape') {
  428. endSelecting();
  429. return;
  430. }
  431. switch (e.key) { // 方向键:上下左右
  432. case 'ArrowUp':
  433. selectedElement = selectedElement.parentElement ? selectedElement.parentElement : selectedElement; // 扩大
  434. if (selectedElement.tagName === 'HTML' || selectedElement.tagName === 'BODY') { // 排除HTML 和 BODY
  435. selectedElement = selectedElement.firstElementChild;
  436. }
  437. break;
  438. case 'ArrowDown':
  439. selectedElement = selectedElement.firstElementChild ? selectedElement.firstElementChild : selectedElement; // 缩小
  440. break;
  441. case 'ArrowLeft': // 寻找上一个元素,若是最后一个子元素则选择父元素的下一个兄弟元素,直到找到一个元素
  442. var prev = selectedElement.previousElementSibling;
  443. while (prev === null && selectedElement.parentElement !== null) {
  444. selectedElement = selectedElement.parentElement;
  445. prev = selectedElement.previousElementSibling ? selectedElement.previousElementSibling.lastChild : null;
  446. }
  447. if (prev !== null) {
  448. if (selectedElement.tagName === 'HTML' || selectedElement.tagName === 'BODY') {
  449. selectedElement = selectedElement.firstElementChild;
  450. }
  451. selectedElement = prev;
  452. }
  453. break;
  454. case 'ArrowRight':
  455. var next = selectedElement.nextElementSibling;
  456. while (next === null && selectedElement.parentElement !== null) {
  457. selectedElement = selectedElement.parentElement;
  458. next = selectedElement.nextElementSibling ? selectedElement.nextElementSibling.firstElementChild : null;
  459. }
  460. if (next !== null) {
  461. if (selectedElement.tagName === 'HTML' || selectedElement.tagName === 'BODY') {
  462. selectedElement = selectedElement.firstElementChild;
  463. }
  464. selectedElement = next;
  465. }
  466. break;
  467. }
  468.  
  469. $('.h2m-selection-box').removeClass('h2m-selection-box');
  470. $(selectedElement).addClass('h2m-selection-box'); // 更新选中元素的样式
  471. }
  472. }
  473. ).on('mousedown', function (e) { // 鼠标事件,选择 mousedown 是因为防止点击元素后触发其他事件
  474. if (isSelecting) {
  475. e.preventDefault();
  476. var markdown = convertToMarkdown(selectedElement);
  477. showMarkdownModal(markdown);
  478. endSelecting();
  479. }
  480. });
  481.  
  482. })();