Backup a thread
// ==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 r###lts</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('r###lts'); } // @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 }); })();