// ==UserScript==
// @name Discourse Thread Backup
// @namespace polv
// @version 0.2.6
// @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';
/**
*
* @param {Object} [opts] Number for thread, or `true` for `?print=true`, or Object specifying options
* @param {boolean} [opts.x1000=false]
* @param {number} [opts.thread_id]
* @param {number} [opts.start]
* @param {number} [opts.end]
* @param {number} [opts.max]
* @returns {Promise}
*/
async function backupThread(opts) {
let x1000 = false;
let thread_id = 0;
let start = 0;
let end = 0;
let max = 0;
switch (typeof opts) {
case 'boolean':
x1000 = opts;
break;
case 'number':
thread_id = opts;
break;
case 'object':
if (opts) {
for (const k of Object.keys(opts)) {
const v = opts[k];
switch (k) {
case 'x1000':
x1000 = v;
break;
case 'thread_id':
thread_id = v;
break;
case 'start':
start = v;
break;
case 'end':
end = v;
break;
case 'max':
max = v;
break;
}
}
}
}
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 url =
location.origin + '/t/' + (thread_slug || '-') + '/' + thread_id;
const output = [];
let cursor = start;
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 (end) {
if (post_number > end) return;
}
if (max) {
if (post_number - start > max) return;
}
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 style="display:none"><summary>Poll results</summary>${polls
.map((p) => {
const pre = document.createElement('pre');
pre.setAttribute('data-poll-name', p.name);
pre.textContent = JSON.stringify(
p,
(k, v) => {
if (/^(assign)_/.test(k)) return;
if (v === null || v === '') return;
return v;
},
2,
);
return pre.outerHTML;
})
.join('')}</details>`,
);
}
lines.push(
`<div class="cooked">${cooked
.replace(/(<a[^>]+\bhref=")(\/\/)/g, `$1https:$2`)
.replace(/(<a[^>]+\bhref=")\//g, `$1${location.origin}/`)
.replace(/(<img[^>]+)>/g, '$1 loading="lazy">')}</div>`,
);
output.push(
`<section data-post-number="${post_number}">${lines.join(
'\n',
)}</section>`,
);
}
});
if (cursor >= nextCursor) {
break;
}
if (end) {
if (nextCursor > end) break;
}
if (max) {
if (nextCursor - start > max) break;
}
if (cursor > (lastMark + 1) * markBatch) {
lastMark = Math.floor(cursor / markBatch);
console.log(`Downloading at ${url}/${cursor}`);
}
cursor = nextCursor;
}
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>`,
...[
`<link rel="canonical" href="${url}">`,
`<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="canonical"], link[rel="stylesheet"], style',
),
)
.map((el) => el.outerHTML)
.join('\n'),
`<title>${text2html(thread_title)}</title>`,
],
`</head>`,
`<body>`,
...[
`<h1>${text2html(thread_title)}</h1>`,
`<p><a href="${url}" target="_blank">${text2html(
decodeURI(url),
)}</a>・<a href="${url}${
start ? '/' + start : ''
}.json" target="_blank">JSON</a></p>`,
`<main>${output.join('\n<hr>\n')}</main>`,
`<script>${
/* js */ `
window.cdn = "${getCDN()}"
${renderAll}
${buildPoll}
${html2html}
renderAll();`
}</script>`,
],
`</body>`,
],
`</html>`,
],
{
type: 'text/html',
},
),
);
a.download = decodeURIComponent(thread_slug) + '.html';
a.click();
URL.revokeObjectURL(a.href);
a.remove();
}
function text2html(s) {
const div = document.createElement('div');
div.innerText = s;
const { innerHTML } = div;
div.remove();
return innerHTML;
}
function html2html(s) {
const div = document.createElement('div');
div.innerHTML = s;
const { innerHTML } = div;
div.remove();
return innerHTML;
}
function getCDN() {
// @ts-ignore
return (document.querySelector('img.avatar').src || '')
.replace(/(:\/\/[^/]+\/[^/]+).+$/g, '$1')
.replace('/user_avatar', '');
}
function renderAll() {
doRender();
addEventListener('scroll', doRender);
function doRender() {
document
.querySelectorAll('[data-post-number]:not([data-polls="done"])')
.forEach((post) => {
const rect = post.getBoundingClientRect();
if (rect.bottom > 0 && rect.top < window.innerHeight) {
buildPoll(post);
}
});
}
}
function buildPoll(post) {
const main = /** @type {HTMLElement} */ (post);
if (main.getAttribute('data-polls') === 'done') return;
main.querySelectorAll('.poll').forEach((p) => {
const preEl = main.querySelector(
`pre[data-poll-name="${p.getAttribute('data-poll-name')}"]`,
);
if (!preEl) return;
const obj = JSON.parse(preEl.textContent || '');
const el = p.querySelector('.info-number');
if (el) {
el.textContent = obj.voters || el.textContent;
}
const ul = p.querySelector('ul');
if (ul) {
ul.classList.add('results');
}
// @ts-ignore
const baseURL = window.cdn;
if (obj.options) {
const { voters, preloaded_voters } = obj;
obj.options.map((op) => {
const li = p.querySelector(`li[data-poll-option-id="${op.id}"]`);
if (li) {
const percent = voters
? Math.round((op.votes / voters) * 100) + '%'
: '';
li.innerHTML = /*html */ `
<div class="option">
<p>
<span class="percentage">${percent}</span>${html2html(
li.innerHTML,
)}</span>
</p>
</div>
<div class="bar-back"><div style="${
percent ? 'width: ' + percent : ''
}" class="bar"></div></div>
${
preloaded_voters && preloaded_voters[op.id]
? `<ul class="poll-voters-list"><div class="poll-voters">
${preloaded_voters[op.id]
.map(
(v) => /* html */ `
<li>
<a class="trigger-user-card" data-user-card="${
v.username
}" aria-hidden="true"
><img
alt=""
width="24"
height="24"
src="${
v.avatar_template.startsWith('//')
? 'https:'
: baseURL
}${v.avatar_template.replace('{size}', '24')}"
title="${v.username}"
aria-hidden="true"
loading="lazy"
tabindex="-1"
class="avatar"
/></a>
</li>
`,
)
.join('\n')}</div></ul>`
: ''
}`;
}
});
}
});
main.setAttribute('data-polls', 'done');
}
Object.assign(window, { backupThread });
})();