Discourse Thread Backup

Backup a thread

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

  1. // ==UserScript==
  2. // @name Discourse Thread Backup
  3. // @namespace polv
  4. // @version 0.2.1
  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 main = document.createElement('main');
  42.  
  43. let cursor = 0;
  44. let lastMark = 0;
  45. while (true) {
  46. let nextCursor = cursor;
  47.  
  48. const jsonURL =
  49. location.origin +
  50. '/t/-/' +
  51. thread_id +
  52. (cursor ? '/' + cursor : '') +
  53. '.json' +
  54. (x1000 ? '?print=true' : '');
  55.  
  56. const obj = await fetch(jsonURL).then((r) => r.json());
  57.  
  58. if (x1000) {
  59. // TODO: ?print=true is rate limited. Not sure for how long.
  60. x1000 = false;
  61. setTimeout(() => {
  62. fetch(jsonURL);
  63. }, 1 * 60 * 1000);
  64. }
  65.  
  66. if (!thread_slug) {
  67. thread_slug = obj.slug;
  68. }
  69. if (!thread_title) {
  70. thread_title = obj.unicode_title || obj.title;
  71. }
  72.  
  73. obj.post_stream.posts.map((p) => {
  74. const { username, cooked, polls, post_number, actions_summary } = p;
  75. if (post_number > nextCursor) {
  76. nextCursor = post_number;
  77.  
  78. const section = document.createElement('section');
  79. main.append(section);
  80.  
  81. section.append(
  82. ((p) => {
  83. p.innerText = `#${post_number}: ${username} ${actions_summary
  84. .filter((a) => a.count)
  85. .map((a) => `❤️ ${a.count}`)
  86. .join(', ')}`;
  87.  
  88. return p;
  89. })(document.createElement('p')),
  90. );
  91.  
  92. if (polls?.length) {
  93. const details = document.createElement('details');
  94. section.append(details);
  95.  
  96. const summary = document.createElement('summary');
  97. summary.innerText = 'Polls results';
  98. details.append(summary);
  99.  
  100. polls.map((p) => {
  101. const pre = document.createElement('pre');
  102. pre.textContent = JSON.stringify(
  103. p,
  104. (k, v) => {
  105. if (/^(avatar|assign)_/.test(k)) return;
  106. if (v === null || v === '') return;
  107. return v;
  108. },
  109. 2,
  110. );
  111. details.append(p);
  112. });
  113. }
  114.  
  115. section.append(
  116. ((div) => {
  117. div.className = 'cooked';
  118. div.innerHTML = cooked;
  119. return div;
  120. })(document.createElement('div')),
  121. );
  122. }
  123. });
  124.  
  125. if (cursor >= nextCursor) {
  126. break;
  127. }
  128.  
  129. if (cursor > (lastMark + 1) * 1000) {
  130. lastMark = Math.floor(cursor / 1000);
  131. console.log(cursor);
  132. }
  133.  
  134. cursor = nextCursor;
  135. }
  136.  
  137. main.querySelectorAll('img').forEach((img) => {
  138. img.loading = 'lazy';
  139. });
  140.  
  141. const url =
  142. location.origin + '/t/' + (thread_slug || '-') + '/' + thread_id;
  143.  
  144. if (!thread_slug) {
  145. thread_slug = String(thread_id);
  146. }
  147.  
  148. const html = document.createElement('html');
  149.  
  150. const head = document.createElement('head');
  151. html.append(head);
  152.  
  153. head.append(
  154. ...Array.from(
  155. document.querySelectorAll(
  156. 'meta[charset], link[rel="icon"], link[rel="stylesheet"], style',
  157. ),
  158. ).map((el) => el.cloneNode(true)),
  159. ((el) => {
  160. el.innerText = thread_title;
  161. return el;
  162. })(document.createElement('title')),
  163. ((el) => {
  164. el.textContent = /* css */ `
  165. main {max-width: 1000px; margin: 0 auto;}
  166. .cooked {margin: 2em;}
  167. .spoiler:not(:hover):not(:active) {filter:blur(5px);}
  168. `;
  169. return el;
  170. })(document.createElement('style')),
  171. );
  172.  
  173. const body = document.createElement('body');
  174. html.append(body);
  175.  
  176. body.append(
  177. ((el) => {
  178. el.innerText = thread_title;
  179. return el;
  180. })(document.createElement('h1')),
  181. ((el) => {
  182. const a1 = document.createElement('a');
  183. el.append(a1);
  184. a1.href = url;
  185. a1.innerText = decodeURI(url);
  186. a1.target = '_blank';
  187.  
  188. const span = document.createElement('span');
  189. el.append(span);
  190. span.innerText = '・';
  191.  
  192. const a2 = document.createElement('a');
  193. el.append(a2);
  194. a2.href = url + '.json';
  195. a2.innerText = 'JSON';
  196. a2.target = '_blank';
  197.  
  198. return el;
  199. })(document.createElement('p')),
  200. main,
  201. );
  202.  
  203. const a = document.createElement('a');
  204. a.href = URL.createObjectURL(
  205. new Blob([html.outerHTML], {
  206. type: 'text/html',
  207. }),
  208. );
  209. a.download = decodeURIComponent(thread_slug) + '.html';
  210. a.click();
  211. URL.revokeObjectURL(a.href);
  212. a.remove();
  213.  
  214. html.remove();
  215. }
  216.  
  217. Object.assign(window, { backupThread });
  218. })();