Mastodon status2html

Save status to a html file.

  1. // ==UserScript==
  2. // @name Mastodon status2html
  3. // @namespace https://blog.bgme.me
  4. // @match https://*/web/*
  5. // @match https://bgme.me/*
  6. // @match https://bgme.bid/*
  7. // @match https://c.bgme.bid/*
  8. // @grant none
  9. // @run-at document-end
  10. // @version 1.0.2
  11. // @author bgme
  12. // @description Save status to a html file.
  13. // @supportURL https://github.com/yingziwu/Greasemonkey/issues
  14. // @license AGPL-3.0-or-later
  15. // ==/UserScript==
  16.  
  17.  
  18. /* eslint-disable @typescript-eslint/explicit-member-accessibility */
  19. class Status {
  20. token = JSON.parse(document.querySelector('#initial-state').text).meta.access_token;
  21.  
  22. constructor(domain, statusID, sortbytime = false) {
  23. this.API = {
  24. 'status': `https://${domain}/api/v1/statuses/${statusID}`,
  25. 'context': `https://${domain}/api/v1/statuses/${statusID}/context`
  26. };
  27. this.sortbytime = sortbytime;
  28. }
  29.  
  30. async init() {
  31. const status = await this.request(this.API.status);
  32. const context = await this.request(this.API.context);
  33.  
  34. const statusList = [];
  35. const statusMap = new Map();
  36. const statusIndents = new Map();
  37.  
  38. if (context.ancestors.length) {
  39. for (const obj of context.ancestors) {
  40. spush(obj)
  41. }
  42. }
  43. spush(status);
  44. if (context.descendants.length) {
  45. for (const obj of context.descendants) {
  46. spush(obj);
  47. }
  48. }
  49. if (this.sortbytime) {
  50. statusList.sort((a, b) => ((new Date(a.created_at)) - (new Date(b.created_at))));
  51. }
  52. this.statusList = statusList;
  53.  
  54. statusList.forEach(obj => {
  55. let k = obj.id;
  56. statusIndents.set(k, getIndent(k));
  57. })
  58. this.statusIndents = statusIndents;
  59.  
  60. function spush(obj) {
  61. statusList.push(obj);
  62. if (obj.in_reply_to_id) {
  63. statusMap.set(obj.id, obj.in_reply_to_id);
  64. }
  65. }
  66. function getIndent(id) {
  67. if (statusMap.get(id)) {
  68. return 1 + getIndent(statusMap.get(id))
  69. } else {
  70. return 0
  71. }
  72. }
  73. }
  74.  
  75. async request(url) {
  76. console.log(`正在请求:${url}`);
  77. const resp = await fetch(url, {
  78. headers: {
  79. Authorization: `Bearer ${this.token}`,
  80. },
  81. method: 'GET',
  82. });
  83. return await resp.json();
  84. }
  85.  
  86. html(anonymity_list = []) {
  87. const HTMLTemplate = `<html>
  88. <head>
  89. <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
  90. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  91. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fomantic-ui@2.8.7/dist/semantic.min.css" integrity="sha256-2+dssJtgusl/DZZZ8gF9ayAgRzcewXQsaP86E4Ul+ss=" crossorigin="anonymous">
  92. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fancybox@3.0.1/dist/css/jquery.fancybox.css" integrity="sha256-iK+zjGHeeTQux1laFiGc4EZWPacH5acc6CnZBGji1ns=" crossorigin="anonymous">
  93. <style>
  94. .ui.feed > .event > .content .user > img {
  95. max-height: 1.5em;
  96. padding-left: 0.2em;
  97. }
  98. .emojione {
  99. max-height: 1.5em;
  100. }
  101. .ui.feed > .event > .content .meta {
  102. padding-left: 0.5em;
  103. }
  104. .ui.feed > .event > .content .meta > button {
  105. position: relative;
  106. top: -1.1em;
  107. }
  108. .ui.feed > .event.hidden {
  109. display: none;
  110. }
  111. body {
  112. overflow-x: scroll;
  113. }
  114. </style>
  115. </header>
  116. <body>
  117. <main id="main">
  118. <div id="main-content" class="ui text container">
  119. <div class="ui large feed" id="main-feed"></div>
  120. </div>
  121. </main>
  122.  
  123. <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
  124. <script src="https://cdn.jsdelivr.net/npm/fomantic-ui@2.8.7/dist/semantic.min.js" integrity="sha256-yibQd6vg4YwSTFUcgd+MwPALTUAVCKTjh4jMON4j+Gk=" crossorigin="anonymous"></script>
  125. <script src="https://cdn.jsdelivr.net/npm/fancybox@3.0.1/dist/js/jquery.fancybox.pack.js" integrity="sha256-VRL0AMrD+7H9+7Apie0Jj4iir1puS6PYigObxCHqf/4=" crossorigin="anonymous"></script> <script>
  126. $(document).ready(function() {
  127. $('.image-reference').fancybox();
  128. document.querySelectorAll('.ui.feed > .event > .content .meta > button.jump')
  129. .forEach(button => {
  130. button.addEventListener('click', function() {
  131. const pid = this.parentElement.parentElement.parentElement.getAttribute('pid');
  132. document.location.hash = pid;
  133. }
  134. );
  135. }
  136. );
  137. document.querySelectorAll('.ui.feed > .event > .content .meta > button.stream')
  138. .forEach(button => {
  139. button.addEventListener('click', function() {
  140. const event = this.parentElement.parentElement.parentElement;
  141. const id = event.id;
  142.  
  143. document.querySelectorAll('.ui.feed > .event').forEach(e => e.classList.add('hidden'));
  144. displayAncestor(id);
  145. displayDescendant(id);
  146.  
  147. document.location.hash = id;
  148.  
  149. function displayAncestor(id) {
  150. const event = document.getElementById(id);
  151. event.classList.remove('hidden');
  152. if (event.getAttribute('pid')) {
  153. return displayAncestor(event.getAttribute('pid'));
  154. } else {
  155. return
  156. }
  157. }
  158. function displayDescendant(id) {
  159. const event = document.getElementById(id);
  160. event.classList.remove('hidden');
  161. const s = '.event[pid="' + id + '"]'
  162. const descendants = document.querySelectorAll(s);
  163. if (descendants.length) {
  164. return descendants.forEach(event => displayDescendant(event.id));
  165. } else {
  166. return
  167. }
  168. }
  169. }
  170. );
  171. }
  172. );
  173. document.querySelectorAll('.ui.feed > .event > .content .meta > button.show-all')
  174. .forEach(button => {
  175. button.addEventListener('click', function() {
  176. const event = this.parentElement.parentElement.parentElement;
  177. const id = event.id;
  178.  
  179. document.querySelectorAll('.ui.feed > .event.hidden').forEach(e => e.classList.remove('hidden'));
  180.  
  181. document.location.hash = id;
  182. }
  183. );
  184. }
  185. );
  186. });
  187. </script>
  188. </body>
  189. </html>`;
  190. const HTML = new DOMParser().parseFromString(HTMLTemplate, "text/html");
  191. const feeds = HTML.getElementById('main-feed');
  192.  
  193. for (const obj of this.statusList) {
  194. let feed;
  195. if (anonymity_list.includes(obj.account.acct)) {
  196. feed = this.feed(obj, true);
  197. } else {
  198. feed = this.feed(obj);
  199. }
  200. feeds.append(feed);
  201. }
  202.  
  203. return HTML.documentElement.outerHTML
  204. }
  205.  
  206. feed(obj, anonymity = false) {
  207. let feedHtml;
  208. let content = obj.content;
  209. if (obj.emojis) {
  210. for (const emoji of obj.emojis) {
  211. content = content.replace(`:${emoji.shortcode}:`, `<img src="${emoji.url}" alt=":${emoji.shortcode}:" class="emojione">`);
  212. }
  213. }
  214.  
  215. let displayName;
  216. if (obj.account.display_name) {
  217. displayName = obj.account.display_name;
  218. for (const emoji of obj.account.emojis) {
  219. displayName = displayName.replace(`:${emoji.shortcode}:`, `<img src="${emoji.url}" alt=":${emoji.shortcode}:" class="emojione">`);
  220. }
  221. } else {
  222. displayName = obj.account.username;
  223. }
  224.  
  225. if (anonymity) {
  226. feedHtml = `<div class="event">
  227. <div class="label">
  228. <img src="https://bgme.me/avatars/original/missing.png">
  229. </div>
  230. <div class="content">
  231. <div class="user">Anonymity</div>
  232. <div class="content">${content}</div>
  233. <span class="date">${obj.created_at.replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}</span>
  234. </div>
  235. </div>`
  236. } else {
  237. feedHtml = `<div class="event">
  238. <div class="label">
  239. <a href="${obj.account.url}" rel="noopener noreferrer" target="_blank">
  240. <img src="${obj.account.avatar}">
  241. </a>
  242. </div>
  243. <div class="content">
  244. <div class="user">${(displayName)}</div>
  245. <div class="content">${content}</div>
  246. <a href="${obj.url}" rel="noopener noreferrer" target="_blank" class="date">${obj.created_at.replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}</a>
  247. </div>
  248. </div>`
  249. }
  250. const feed = (new DOMParser().parseFromString(feedHtml, "text/html")).documentElement.querySelector('.event');
  251.  
  252. feed.id = obj.id;
  253. feed.classList.add(`child-${this.statusIndents.get(obj.id)}`);
  254. if (this.statusIndents.get(obj.id) && !this.sortbytime) {
  255. feed.style = `margin-left: ${this.statusIndents.get(obj.id)}em;`
  256. }
  257. if (obj.in_reply_to_id) {
  258. feed.setAttribute('pid', obj.in_reply_to_id);
  259. }
  260.  
  261. if (obj.media_attachments.length) {
  262. const images = document.createElement('div');
  263. images.className = 'extra images';
  264. for (const media_attachment of obj.media_attachments) {
  265. const img = document.createElement('img');
  266. img.src = media_attachment.preview_url;
  267. if (media_attachment.description) {
  268. img.alt = media_attachment.description;
  269. }
  270.  
  271. const a = document.createElement('a');
  272. a.href = media_attachment.url;
  273. a.className = 'image-reference';
  274.  
  275. a.append(img);
  276. images.append(a);
  277. feed.querySelector('.date').before(images);
  278. }
  279. }
  280.  
  281. const button0 = genButton('jump', 'arrow up');
  282. const button1 = genButton('stream', 'stream');
  283. const button2 = genButton('show-all', 'globe');
  284.  
  285. const meta = document.createElement('div');
  286. meta.className = 'meta';
  287. meta.textContent = `层级${this.statusIndents.get(obj.id)}`;
  288. if (this.statusIndents.get(obj.id)) {
  289. meta.append(button0);
  290. meta.append(button1);
  291. }
  292. meta.append(button2);
  293. feed.querySelector('.date').after(meta);
  294.  
  295. return feed
  296.  
  297. function genButton(className, iconName) {
  298. const button = document.createElement('button');
  299. button.className = `mini ui icon tertiary button ${className}`;
  300. const icon = document.createElement('i');
  301. icon.className = `${iconName} icon`;
  302. button.append(icon);
  303. return button
  304. }
  305. }
  306. }
  307.  
  308. function saveFile(data, filename, type) {
  309. const file = new Blob([data], { type: type });
  310. const a = document.createElement('a');
  311. const url = URL.createObjectURL(file);
  312. a.href = url;
  313. a.download = filename;
  314. document.body.appendChild(a);
  315. a.click();
  316. setTimeout(function () {
  317. document.body.removeChild(a);
  318. window.URL.revokeObjectURL(url);
  319. }, 0);
  320. }
  321.  
  322. function chromeClickChecker(event) {
  323. return (
  324. event.target.tagName.toLowerCase() === 'i' &&
  325. event.target.classList.contains('fa-ellipsis-h') &&
  326. document.querySelector('div.dropdown-menu') === null
  327. );
  328. }
  329.  
  330. function firefoxClickChecker(event) {
  331. return (
  332. event.target.tagName.toLowerCase() === 'button' &&
  333. event.target.classList.contains('icon-button') &&
  334. document.querySelector('div.dropdown-menu') === null
  335. );
  336. }
  337.  
  338. function activate() {
  339. document.querySelector('body').addEventListener('click', function (event) {
  340. if (chromeClickChecker(event) || firefoxClickChecker(event)) {
  341. // Get the status for this event
  342. let status = event.target.parentNode.parentNode.parentNode.parentNode.parentNode;
  343. if (status.className.match('detailed-status__wrapper')) {
  344. addLink(status);
  345. }
  346. };
  347. }, false);
  348. }
  349.  
  350. function addLink(status) {
  351. setTimeout(function () {
  352. const url = status.querySelector('.detailed-status__link').getAttribute('href');
  353. const id = url.match(/\/(\d+)\//)[1];
  354.  
  355. const dropdown = document.querySelector('div.dropdown-menu ul');
  356. const separator = dropdown.querySelector('li.dropdown-menu__separator');
  357.  
  358. const listItem = document.createElement('li');
  359. listItem.classList.add('dropdown-menu__item');
  360. listItem.classList.add('mastodon__lottery');
  361.  
  362. const link = document.createElement('a');
  363. link.setAttribute('href', '#');
  364. link.setAttribute('target', '_blank');
  365. link.textContent = 'Save as HTML';
  366.  
  367. link.addEventListener('click', function (e) {
  368. e.preventDefault();
  369. if (!window.Running) {
  370. window.Running = true;
  371. link.textContent = 'Saving, please wait……';
  372. run(id)
  373. .then(() => { window.Running = false; })
  374. .catch(e => {
  375. window.Running = false;
  376. throw e;
  377. });
  378. }
  379. }, false);
  380.  
  381. listItem.appendChild(link);
  382. dropdown.insertBefore(listItem, separator);
  383. }, 100);
  384. }
  385.  
  386. function run(id) {
  387. const domain = document.location.host;
  388.  
  389. const s1 = new Status(domain, id, false);
  390. s1.init().then(() => {
  391. const html = s1.html();
  392. saveFile(html, `${id}.html`, 'text/plain; charset=utf-8');
  393. });
  394.  
  395. const s2 = new Status(domain, id, true);
  396. s2.init().then(() => {
  397. const html = s2.html();
  398. saveFile(html, `${id}-time.html`, 'text/plain; charset=utf-8');
  399. });
  400. }
  401.  
  402.  
  403. window.addEventListener('load', function () {
  404. activate();
  405. }, false)