Discourse Thread Backup

Backup a thread

  1. // ==UserScript==
  2. // @name Discourse Thread Backup
  3. // @namespace polv
  4. // @version 0.2.6
  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. /**
  21. *
  22. * @param {Object} [opts] Number for thread, or `true` for `?print=true`, or Object specifying options
  23. * @param {boolean} [opts.x1000=false]
  24. * @param {number} [opts.thread_id]
  25. * @param {number} [opts.start]
  26. * @param {number} [opts.end]
  27. * @param {number} [opts.max]
  28. * @returns {Promise}
  29. */
  30. async function backupThread(opts) {
  31. let x1000 = false;
  32. let thread_id = 0;
  33. let start = 0;
  34. let end = 0;
  35. let max = 0;
  36.  
  37. switch (typeof opts) {
  38. case 'boolean':
  39. x1000 = opts;
  40. break;
  41. case 'number':
  42. thread_id = opts;
  43. break;
  44. case 'object':
  45. if (opts) {
  46. for (const k of Object.keys(opts)) {
  47. const v = opts[k];
  48. switch (k) {
  49. case 'x1000':
  50. x1000 = v;
  51. break;
  52. case 'thread_id':
  53. thread_id = v;
  54. break;
  55. case 'start':
  56. start = v;
  57. break;
  58. case 'end':
  59. end = v;
  60. break;
  61. case 'max':
  62. max = v;
  63. break;
  64. }
  65. }
  66. }
  67. }
  68.  
  69. if (typeof thread_id === 'boolean') {
  70. x1000 = thread_id;
  71. thread_id = 0;
  72. }
  73.  
  74. let thread_slug = '';
  75. let thread_title = '';
  76.  
  77. if (!thread_id) {
  78. const [pid, tid, slug] = location.pathname.split('/').reverse();
  79. thread_id = Number(tid);
  80. if (!thread_id) {
  81. thread_slug = tid;
  82. thread_id = Number(pid);
  83. } else {
  84. thread_slug = slug;
  85. }
  86. }
  87. if (!thread_id) return;
  88.  
  89. const url =
  90. location.origin + '/t/' + (thread_slug || '-') + '/' + thread_id;
  91.  
  92. const output = [];
  93. let cursor = start;
  94.  
  95. const markBatch = 500;
  96. let lastMark = 0;
  97.  
  98. while (true) {
  99. let nextCursor = cursor;
  100.  
  101. const jsonURL =
  102. location.origin +
  103. '/t/-/' +
  104. thread_id +
  105. (cursor ? '/' + cursor : '') +
  106. '.json' +
  107. (x1000 ? '?print=true' : '');
  108.  
  109. const obj = await fetch(jsonURL).then((r) => r.json());
  110.  
  111. if (x1000) {
  112. // TODO: ?print=true is rate limited. Not sure for how long.
  113. x1000 = false;
  114. setTimeout(() => {
  115. fetch(jsonURL);
  116. }, 1 * 60 * 1000);
  117. }
  118.  
  119. if (!thread_slug) {
  120. thread_slug = obj.slug;
  121. }
  122. if (!thread_title) {
  123. thread_title = obj.unicode_title || obj.title;
  124. }
  125.  
  126. obj.post_stream.posts.map((p) => {
  127. const { username, cooked, polls, post_number, actions_summary } = p;
  128.  
  129. if (end) {
  130. if (post_number > end) return;
  131. }
  132. if (max) {
  133. if (post_number - start > max) return;
  134. }
  135.  
  136. if (post_number > nextCursor) {
  137. nextCursor = post_number;
  138.  
  139. const lines = [];
  140.  
  141. lines.push(
  142. `#${post_number}: ${username} ${actions_summary
  143. .filter((a) => a.count)
  144. .map((a) => `❤️ ${a.count}`)
  145. .join(', ')}`,
  146. );
  147. if (polls?.length) {
  148. lines.push(
  149. `<details style="display:none"><summary>Poll results</summary>${polls
  150. .map((p) => {
  151. const pre = document.createElement('pre');
  152. pre.setAttribute('data-poll-name', p.name);
  153. pre.textContent = JSON.stringify(
  154. p,
  155. (k, v) => {
  156. if (/^(assign)_/.test(k)) return;
  157. if (v === null || v === '') return;
  158. return v;
  159. },
  160. 2,
  161. );
  162. return pre.outerHTML;
  163. })
  164. .join('')}</details>`,
  165. );
  166. }
  167. lines.push(
  168. `<div class="cooked">${cooked
  169. .replace(/(<a[^>]+\bhref=")(\/\/)/g, `$1https:$2`)
  170. .replace(/(<a[^>]+\bhref=")\//g, `$1${location.origin}/`)
  171. .replace(/(<img[^>]+)>/g, '$1 loading="lazy">')}</div>`,
  172. );
  173.  
  174. output.push(
  175. `<section data-post-number="${post_number}">${lines.join(
  176. '\n',
  177. )}</section>`,
  178. );
  179. }
  180. });
  181.  
  182. if (cursor >= nextCursor) {
  183. break;
  184. }
  185.  
  186. if (end) {
  187. if (nextCursor > end) break;
  188. }
  189. if (max) {
  190. if (nextCursor - start > max) break;
  191. }
  192.  
  193. if (cursor > (lastMark + 1) * markBatch) {
  194. lastMark = Math.floor(cursor / markBatch);
  195. console.log(`Downloading at ${url}/${cursor}`);
  196. }
  197.  
  198. cursor = nextCursor;
  199. }
  200.  
  201. console.log('Downloaded ' + url);
  202.  
  203. if (!thread_slug) {
  204. thread_slug = String(thread_id);
  205. }
  206.  
  207. const a = document.createElement('a');
  208. a.href = URL.createObjectURL(
  209. new Blob(
  210. [
  211. `<html>`,
  212. ...[
  213. `<head>`,
  214. ...[
  215. `<link rel="canonical" href="${url}">`,
  216. `<style>
  217. main {max-width: 1000px; margin: 0 auto;}
  218. .cooked {margin: 2em;}
  219. .spoiler:not(:hover):not(:active) {filter:blur(5px);}
  220. </style>`,
  221. Array.from(
  222. document.querySelectorAll(
  223. 'meta[charset], link[rel="icon"], link[rel="canonical"], link[rel="stylesheet"], style',
  224. ),
  225. )
  226. .map((el) => el.outerHTML)
  227. .join('\n'),
  228. `<title>${text2html(thread_title)}</title>`,
  229. ],
  230. `</head>`,
  231. `<body>`,
  232. ...[
  233. `<h1>${text2html(thread_title)}</h1>`,
  234. `<p><a href="${url}" target="_blank">${text2html(
  235. decodeURI(url),
  236. )}</a>・<a href="${url}${
  237. start ? '/' + start : ''
  238. }.json" target="_blank">JSON</a></p>`,
  239. `<main>${output.join('\n<hr>\n')}</main>`,
  240. `<script>${
  241. /* js */ `
  242. window.cdn = "${getCDN()}"
  243. ${renderAll}
  244. ${buildPoll}
  245. ${html2html}
  246. renderAll();`
  247. }</script>`,
  248. ],
  249. `</body>`,
  250. ],
  251. `</html>`,
  252. ],
  253. {
  254. type: 'text/html',
  255. },
  256. ),
  257. );
  258. a.download = decodeURIComponent(thread_slug) + '.html';
  259. a.click();
  260. URL.revokeObjectURL(a.href);
  261. a.remove();
  262. }
  263.  
  264. function text2html(s) {
  265. const div = document.createElement('div');
  266. div.innerText = s;
  267. const { innerHTML } = div;
  268. div.remove();
  269. return innerHTML;
  270. }
  271.  
  272. function html2html(s) {
  273. const div = document.createElement('div');
  274. div.innerHTML = s;
  275. const { innerHTML } = div;
  276. div.remove();
  277. return innerHTML;
  278. }
  279.  
  280. function getCDN() {
  281. // @ts-ignore
  282. return (document.querySelector('img.avatar').src || '')
  283. .replace(/(:\/\/[^/]+\/[^/]+).+$/g, '$1')
  284. .replace('/user_avatar', '');
  285. }
  286.  
  287. function renderAll() {
  288. doRender();
  289. addEventListener('scroll', doRender);
  290.  
  291. function doRender() {
  292. document
  293. .querySelectorAll('[data-post-number]:not([data-polls="done"])')
  294. .forEach((post) => {
  295. const rect = post.getBoundingClientRect();
  296. if (rect.bottom > 0 && rect.top < window.innerHeight) {
  297. buildPoll(post);
  298. }
  299. });
  300. }
  301. }
  302.  
  303. function buildPoll(post) {
  304. const main = /** @type {HTMLElement} */ (post);
  305. if (main.getAttribute('data-polls') === 'done') return;
  306.  
  307. main.querySelectorAll('.poll').forEach((p) => {
  308. const preEl = main.querySelector(
  309. `pre[data-poll-name="${p.getAttribute('data-poll-name')}"]`,
  310. );
  311. if (!preEl) return;
  312. const obj = JSON.parse(preEl.textContent || '');
  313.  
  314. const el = p.querySelector('.info-number');
  315. if (el) {
  316. el.textContent = obj.voters || el.textContent;
  317. }
  318.  
  319. const ul = p.querySelector('ul');
  320. if (ul) {
  321. ul.classList.add('results');
  322. }
  323.  
  324. // @ts-ignore
  325. const baseURL = window.cdn;
  326. if (obj.options) {
  327. const { voters, preloaded_voters } = obj;
  328. obj.options.map((op) => {
  329. const li = p.querySelector(`li[data-poll-option-id="${op.id}"]`);
  330. if (li) {
  331. const percent = voters
  332. ? Math.round((op.votes / voters) * 100) + '%'
  333. : '';
  334. li.innerHTML = /*html */ `
  335. <div class="option">
  336. <p>
  337. <span class="percentage">${percent}</span>${html2html(
  338. li.innerHTML,
  339. )}</span>
  340. </p>
  341. </div>
  342. <div class="bar-back"><div style="${
  343. percent ? 'width: ' + percent : ''
  344. }" class="bar"></div></div>
  345. ${
  346. preloaded_voters && preloaded_voters[op.id]
  347. ? `<ul class="poll-voters-list"><div class="poll-voters">
  348. ${preloaded_voters[op.id]
  349. .map(
  350. (v) => /* html */ `
  351. <li>
  352. <a class="trigger-user-card" data-user-card="${
  353. v.username
  354. }" aria-hidden="true"
  355. ><img
  356. alt=""
  357. width="24"
  358. height="24"
  359. src="${
  360. v.avatar_template.startsWith('//')
  361. ? 'https:'
  362. : baseURL
  363. }${v.avatar_template.replace('{size}', '24')}"
  364. title="${v.username}"
  365. aria-hidden="true"
  366. loading="lazy"
  367. tabindex="-1"
  368. class="avatar"
  369. /></a>
  370. </li>
  371. `,
  372. )
  373. .join('\n')}</div></ul>`
  374. : ''
  375. }`;
  376. }
  377. });
  378. }
  379. });
  380. main.setAttribute('data-polls', 'done');
  381. }
  382.  
  383. Object.assign(window, { backupThread });
  384. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址