Discourse Thread Backup

Backup a thread

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

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