- // ==UserScript==
- // @name Discourse Thread Backup
- // @namespace polv
- // @version 0.2.3
- // @description Backup a thread
- // @author polv
- // @match *://community.wanikani.com/*
- // @match *://forums.learnnatively.com/*
- // @license MIT
- // @supportURL https://community.wanikani.com/t/a-way-to-backup-discourse-threads/63679/9
- // @source https://github.com/patarapolw/wanikani-userscript/blob/master/userscripts/wk-com-backup.user.js
- // @icon https://www.google.com/s2/favicons?sz=64&domain=meta.discourse.org
- // @grant none
- // ==/UserScript==
-
- // @ts-check
- (function () {
- 'use strict';
-
- async function backupThread(thread_id = 0, x1000 = false) {
- if (typeof thread_id === 'boolean') {
- x1000 = thread_id;
- thread_id = 0;
- }
-
- let thread_slug = '';
- let thread_title = '';
-
- if (!thread_id) {
- const [pid, tid, slug] = location.pathname.split('/').reverse();
- thread_id = Number(tid);
- if (!thread_id) {
- thread_slug = tid;
- thread_id = Number(pid);
- } else {
- thread_slug = slug;
- }
- }
- if (!thread_id) return;
-
- const output = [];
- let cursor = 0;
-
- const markBatch = 500;
- let lastMark = 0;
-
- while (true) {
- let nextCursor = cursor;
-
- const jsonURL =
- location.origin +
- '/t/-/' +
- thread_id +
- (cursor ? '/' + cursor : '') +
- '.json' +
- (x1000 ? '?print=true' : '');
-
- const obj = await fetch(jsonURL).then((r) => r.json());
-
- if (x1000) {
- // TODO: ?print=true is rate limited. Not sure for how long.
- x1000 = false;
- setTimeout(() => {
- fetch(jsonURL);
- }, 1 * 60 * 1000);
- }
-
- if (!thread_slug) {
- thread_slug = obj.slug;
- }
- if (!thread_title) {
- thread_title = obj.unicode_title || obj.title;
- }
-
- obj.post_stream.posts.map((p) => {
- const { username, cooked, polls, post_number, actions_summary } = p;
- if (post_number > nextCursor) {
- nextCursor = post_number;
-
- const lines = [];
-
- lines.push(
- `#${post_number}: ${username} ${actions_summary
- .filter((a) => a.count)
- .map((a) => `❤️ ${a.count}`)
- .join(', ')}`,
- );
- if (polls?.length) {
- lines.push(
- `<details><summary>Poll results</summary>${polls
- .map((p) => {
- const pre = document.createElement('pre');
- pre.textContent = JSON.stringify(
- p,
- (k, v) => {
- if (/^(avatar|assign)_/.test(k)) return;
- if (v === null || v === '') return;
- return v;
- },
- 2,
- );
- return pre.outerHTML;
- })
- .join('')}</details>`,
- );
- }
- lines.push(
- `<div class="cooked">${cooked.replace(
- /<img /g,
- '<img loading="lazy" ',
- )}</div>`,
- );
-
- output.push(lines.join('\n'));
- }
- });
-
- if (cursor >= nextCursor) {
- break;
- }
-
- if (cursor > (lastMark + 1) * markBatch) {
- lastMark = Math.floor(cursor / markBatch);
- console.log(cursor);
- }
-
- cursor = nextCursor;
- }
-
- const url =
- location.origin + '/t/' + (thread_slug || '-') + '/' + thread_id;
-
- console.log('Downloaded ' + url);
-
- if (!thread_slug) {
- thread_slug = String(thread_id);
- }
-
- const a = document.createElement('a');
- a.href = URL.createObjectURL(
- new Blob(
- [
- `<html>`,
- ...[
- `<head>`,
- ...[
- `<style>
- main {max-width: 1000px; margin: 0 auto;}
- .cooked {margin: 2em;}
- .spoiler:not(:hover):not(:active) {filter:blur(5px);}
- </style>`,
- Array.from(
- document.querySelectorAll(
- 'meta[charset], link[rel="icon"], link[rel="stylesheet"], style',
- ),
- )
- .map((el) => el.outerHTML)
- .join('\n'),
- `<title>${toHTML(thread_title)}</title>`,
- ],
- `</head>`,
- `<body>`,
- ...[
- `<h1>${toHTML(thread_title)}</h1>`,
- `<p><a href="${url}" target="_blank">${toHTML(
- decodeURI(url),
- )}</a>・<a href="${url}.json" target="_blank">JSON</p>`,
- `<main>${output.join('\n<hr>\n')}</main>`,
- ],
- `</body>`,
- ],
- `</html>`,
- ],
- {
- type: 'text/html',
- },
- ),
- );
- a.download = decodeURIComponent(thread_slug) + '.html';
- a.click();
- URL.revokeObjectURL(a.href);
- a.remove();
- }
-
- function toHTML(s) {
- const div = document.createElement('div');
- div.innerText = s;
- const { innerHTML } = div;
- div.remove();
- return innerHTML;
- }
-
- Object.assign(window, { backupThread });
- })();