Discourse Thread Backup

Backup a thread

目前为 2023-11-01 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Discourse Thread Backup
  3. // @namespace polv
  4. // @version 0.2.3
  5. // @description Backup a thread
  6. // @author polv
  7. // @match *://community.wanikani.com/*
  8. // @match *://forums.learnnatively.com/*
  9. // @license MIT
  10. // @supportURL https://community.wanikani.com/t/a-way-to-backup-discourse-threads/63679/9
  11. // @source https://github.com/patarapolw/wanikani-userscript/blob/master/userscripts/wk-com-backup.user.js
  12. // @icon https://www.google.com/s2/favicons?sz=64&domain=meta.discourse.org
  13. // @grant none
  14. // ==/UserScript==
  15.  
  16. // @ts-check
  17. (function () {
  18. 'use strict';
  19.  
  20. async function backupThread(thread_id = 0, x1000 = false) {
  21. if (typeof thread_id === 'boolean') {
  22. x1000 = thread_id;
  23. thread_id = 0;
  24. }
  25.  
  26. let thread_slug = '';
  27. let thread_title = '';
  28.  
  29. if (!thread_id) {
  30. const [pid, tid, slug] = location.pathname.split('/').reverse();
  31. thread_id = Number(tid);
  32. if (!thread_id) {
  33. thread_slug = tid;
  34. thread_id = Number(pid);
  35. } else {
  36. thread_slug = slug;
  37. }
  38. }
  39. if (!thread_id) return;
  40.  
  41. const output = [];
  42. let cursor = 0;
  43.  
  44. const markBatch = 500;
  45. let lastMark = 0;
  46.  
  47. while (true) {
  48. let nextCursor = cursor;
  49.  
  50. const jsonURL =
  51. location.origin +
  52. '/t/-/' +
  53. thread_id +
  54. (cursor ? '/' + cursor : '') +
  55. '.json' +
  56. (x1000 ? '?print=true' : '');
  57.  
  58. const obj = await fetch(jsonURL).then((r) => r.json());
  59.  
  60. if (x1000) {
  61. // TODO: ?print=true is rate limited. Not sure for how long.
  62. x1000 = false;
  63. setTimeout(() => {
  64. fetch(jsonURL);
  65. }, 1 * 60 * 1000);
  66. }
  67.  
  68. if (!thread_slug) {
  69. thread_slug = obj.slug;
  70. }
  71. if (!thread_title) {
  72. thread_title = obj.unicode_title || obj.title;
  73. }
  74.  
  75. obj.post_stream.posts.map((p) => {
  76. const { username, cooked, polls, post_number, actions_summary } = p;
  77. if (post_number > nextCursor) {
  78. nextCursor = post_number;
  79.  
  80. const lines = [];
  81.  
  82. lines.push(
  83. `#${post_number}: ${username} ${actions_summary
  84. .filter((a) => a.count)
  85. .map((a) => `❤️ ${a.count}`)
  86. .join(', ')}`,
  87. );
  88. if (polls?.length) {
  89. lines.push(
  90. `<details><summary>Poll results</summary>${polls
  91. .map((p) => {
  92. const pre = document.createElement('pre');
  93. pre.textContent = JSON.stringify(
  94. p,
  95. (k, v) => {
  96. if (/^(avatar|assign)_/.test(k)) return;
  97. if (v === null || v === '') return;
  98. return v;
  99. },
  100. 2,
  101. );
  102. return pre.outerHTML;
  103. })
  104. .join('')}</details>`,
  105. );
  106. }
  107. lines.push(
  108. `<div class="cooked">${cooked.replace(
  109. /<img /g,
  110. '<img loading="lazy" ',
  111. )}</div>`,
  112. );
  113.  
  114. output.push(lines.join('\n'));
  115. }
  116. });
  117.  
  118. if (cursor >= nextCursor) {
  119. break;
  120. }
  121.  
  122. if (cursor > (lastMark + 1) * markBatch) {
  123. lastMark = Math.floor(cursor / markBatch);
  124. console.log(cursor);
  125. }
  126.  
  127. cursor = nextCursor;
  128. }
  129.  
  130. const url =
  131. location.origin + '/t/' + (thread_slug || '-') + '/' + thread_id;
  132.  
  133. console.log('Downloaded ' + url);
  134.  
  135. if (!thread_slug) {
  136. thread_slug = String(thread_id);
  137. }
  138.  
  139. const a = document.createElement('a');
  140. a.href = URL.createObjectURL(
  141. new Blob(
  142. [
  143. `<html>`,
  144. ...[
  145. `<head>`,
  146. ...[
  147. `<style>
  148. main {max-width: 1000px; margin: 0 auto;}
  149. .cooked {margin: 2em;}
  150. .spoiler:not(:hover):not(:active) {filter:blur(5px);}
  151. </style>`,
  152. Array.from(
  153. document.querySelectorAll(
  154. 'meta[charset], link[rel="icon"], link[rel="stylesheet"], style',
  155. ),
  156. )
  157. .map((el) => el.outerHTML)
  158. .join('\n'),
  159. `<title>${toHTML(thread_title)}</title>`,
  160. ],
  161. `</head>`,
  162. `<body>`,
  163. ...[
  164. `<h1>${toHTML(thread_title)}</h1>`,
  165. `<p><a href="${url}" target="_blank">${toHTML(
  166. decodeURI(url),
  167. )}</a>・<a href="${url}.json" target="_blank">JSON</p>`,
  168. `<main>${output.join('\n<hr>\n')}</main>`,
  169. ],
  170. `</body>`,
  171. ],
  172. `</html>`,
  173. ],
  174. {
  175. type: 'text/html',
  176. },
  177. ),
  178. );
  179. a.download = decodeURIComponent(thread_slug) + '.html';
  180. a.click();
  181. URL.revokeObjectURL(a.href);
  182. a.remove();
  183. }
  184.  
  185. function toHTML(s) {
  186. const div = document.createElement('div');
  187. div.innerText = s;
  188. const { innerHTML } = div;
  189. div.remove();
  190. return innerHTML;
  191. }
  192.  
  193. Object.assign(window, { backupThread });
  194. })();