Mastodon status2html

Save status to a html file.

// ==UserScript==
// @name        Mastodon status2html
// @namespace   https://blog.bgme.me
// @match       https://*/web/*
// @match       https://bgme.me/*
// @match       https://bgme.bid/*
// @match       https://c.bgme.bid/*
// @grant       none
// @run-at      document-end
// @version     1.0.2
// @author      bgme
// @description Save status to a html file.
// @supportURL  https://github.com/yingziwu/Greasemonkey/issues
// @license     AGPL-3.0-or-later
// ==/UserScript==


/* eslint-disable @typescript-eslint/explicit-member-accessibility */
class Status {
    token = JSON.parse(document.querySelector('#initial-state').text).meta.access_token;

    constructor(domain, statusID, sortbytime = false) {
        this.API = {
            'status': `https://${domain}/api/v1/statuses/${statusID}`,
            'context': `https://${domain}/api/v1/statuses/${statusID}/context`
        };
        this.sortbytime = sortbytime;
    }

    async init() {
        const status = await this.request(this.API.status);
        const context = await this.request(this.API.context);

        const statusList = [];
        const statusMap = new Map();
        const statusIndents = new Map();

        if (context.ancestors.length) {
            for (const obj of context.ancestors) {
                spush(obj)
            }
        }
        spush(status);
        if (context.descendants.length) {
            for (const obj of context.descendants) {
                spush(obj);
            }
        }
        if (this.sortbytime) {
            statusList.sort((a, b) => ((new Date(a.created_at)) - (new Date(b.created_at))));
        }
        this.statusList = statusList;

        statusList.forEach(obj => {
            let k = obj.id;
            statusIndents.set(k, getIndent(k));
        })
        this.statusIndents = statusIndents;

        function spush(obj) {
            statusList.push(obj);
            if (obj.in_reply_to_id) {
                statusMap.set(obj.id, obj.in_reply_to_id);
            }
        }
        function getIndent(id) {
            if (statusMap.get(id)) {
                return 1 + getIndent(statusMap.get(id))
            } else {
                return 0
            }
        }
    }

    async request(url) {
        console.log(`正在请求:${url}`);
        const resp = await fetch(url, {
            headers: {
                Authorization: `Bearer ${this.token}`,
            },
            method: 'GET',
        });
        return await resp.json();
    }

    html(anonymity_list = []) {
        const HTMLTemplate = `<html>
        <head>
            <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <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">
            <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fancybox@3.0.1/dist/css/jquery.fancybox.css" integrity="sha256-iK+zjGHeeTQux1laFiGc4EZWPacH5acc6CnZBGji1ns=" crossorigin="anonymous">
            <style>
                .ui.feed > .event > .content .user > img {
                    max-height: 1.5em;
                    padding-left: 0.2em;
                }
                .emojione {
                    max-height: 1.5em;
                }
                .ui.feed > .event > .content .meta {
                    padding-left: 0.5em;
                }
                .ui.feed > .event > .content .meta > button {
                    position: relative;
                    top: -1.1em;
                }
                .ui.feed > .event.hidden {
                    display: none;
                }
                body {
                    overflow-x: scroll;
                }
            </style>
        </header>
        <body>
            <main id="main">
                <div id="main-content" class="ui text container">
                    <div class="ui large feed" id="main-feed"></div>
                </div>
            </main>

            <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
            <script src="https://cdn.jsdelivr.net/npm/fomantic-ui@2.8.7/dist/semantic.min.js" integrity="sha256-yibQd6vg4YwSTFUcgd+MwPALTUAVCKTjh4jMON4j+Gk=" crossorigin="anonymous"></script>
            <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>
                $(document).ready(function() {
                    $('.image-reference').fancybox();
                    document.querySelectorAll('.ui.feed > .event > .content .meta > button.jump')
                        .forEach(button => {
                            button.addEventListener('click', function() {
                                    const pid = this.parentElement.parentElement.parentElement.getAttribute('pid');
                                    document.location.hash = pid;
                                }
                            );
                        }
                    );
                    document.querySelectorAll('.ui.feed > .event > .content .meta > button.stream')
                        .forEach(button => {
                            button.addEventListener('click', function() {
                                    const event = this.parentElement.parentElement.parentElement;
                                    const id = event.id;

                                    document.querySelectorAll('.ui.feed > .event').forEach(e => e.classList.add('hidden'));
                                    displayAncestor(id);
                                    displayDescendant(id);

                                    document.location.hash = id;

                                    function displayAncestor(id) {
                                        const event = document.getElementById(id);
                                        event.classList.remove('hidden');
                                          
                                        if (event.getAttribute('pid')) {
                                            return displayAncestor(event.getAttribute('pid'));
                                        } else {
                                            return
                                        }
                                    }
                                    function displayDescendant(id) {
                                        const event = document.getElementById(id);
                                        event.classList.remove('hidden');
                                        
                                        const s = '.event[pid="' + id + '"]'
                                        const descendants = document.querySelectorAll(s);
                                        if (descendants.length) {
                                            return descendants.forEach(event => displayDescendant(event.id));
                                        } else {
                                            return
                                        }
                                    }
                                }
                            );
                        }
                    );
                    document.querySelectorAll('.ui.feed > .event > .content .meta > button.show-all')
                        .forEach(button => {
                            button.addEventListener('click', function() {
                                    const event = this.parentElement.parentElement.parentElement;
                                    const id = event.id;

                                    document.querySelectorAll('.ui.feed > .event.hidden').forEach(e => e.classList.remove('hidden'));

                                    document.location.hash = id;
                                }
                            );
                        }
                    );
                });
            </script>
        </body>
        </html>`;
        const HTML = new DOMParser().parseFromString(HTMLTemplate, "text/html");
        const feeds = HTML.getElementById('main-feed');

        for (const obj of this.statusList) {
            let feed;
            if (anonymity_list.includes(obj.account.acct)) {
                feed = this.feed(obj, true);
            } else {
                feed = this.feed(obj);
            }
            feeds.append(feed);
        }

        return HTML.documentElement.outerHTML
    }

    feed(obj, anonymity = false) {
        let feedHtml;
        let content = obj.content;
        if (obj.emojis) {
            for (const emoji of obj.emojis) {
                content = content.replace(`:${emoji.shortcode}:`, `<img src="${emoji.url}" alt=":${emoji.shortcode}:" class="emojione">`);
            }
        }

        let displayName;
        if (obj.account.display_name) {
            displayName = obj.account.display_name;
            for (const emoji of obj.account.emojis) {
                displayName = displayName.replace(`:${emoji.shortcode}:`, `<img src="${emoji.url}" alt=":${emoji.shortcode}:" class="emojione">`);
            }
        } else {
            displayName = obj.account.username;
        }

        if (anonymity) {
            feedHtml = `<div class="event">
            <div class="label">
                <img src="https://bgme.me/avatars/original/missing.png">
            </div>
            <div class="content">
                <div class="user">Anonymity</div>
                <div class="content">${content}</div>
                <span class="date">${obj.created_at.replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}</span>
            </div>
            </div>`
        } else {
            feedHtml = `<div class="event">
            <div class="label">
                <a href="${obj.account.url}" rel="noopener noreferrer" target="_blank">
                    <img src="${obj.account.avatar}">
                </a>
            </div>
            <div class="content">
                <div class="user">${(displayName)}</div>
                <div class="content">${content}</div>
                <a href="${obj.url}" rel="noopener noreferrer" target="_blank" class="date">${obj.created_at.replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}</a>
            </div>
            </div>`
        }
        const feed = (new DOMParser().parseFromString(feedHtml, "text/html")).documentElement.querySelector('.event');

        feed.id = obj.id;
        feed.classList.add(`child-${this.statusIndents.get(obj.id)}`);
        if (this.statusIndents.get(obj.id) && !this.sortbytime) {
            feed.style = `margin-left: ${this.statusIndents.get(obj.id)}em;`
        }
        if (obj.in_reply_to_id) {
            feed.setAttribute('pid', obj.in_reply_to_id);
        }

        if (obj.media_attachments.length) {
            const images = document.createElement('div');
            images.className = 'extra images';
            for (const media_attachment of obj.media_attachments) {
                const img = document.createElement('img');
                img.src = media_attachment.preview_url;
                if (media_attachment.description) {
                    img.alt = media_attachment.description;
                }

                const a = document.createElement('a');
                a.href = media_attachment.url;
                a.className = 'image-reference';

                a.append(img);
                images.append(a);
                feed.querySelector('.date').before(images);
            }
        }

        const button0 = genButton('jump', 'arrow up');
        const button1 = genButton('stream', 'stream');
        const button2 = genButton('show-all', 'globe');

        const meta = document.createElement('div');
        meta.className = 'meta';
        meta.textContent = `层级${this.statusIndents.get(obj.id)}`;
        if (this.statusIndents.get(obj.id)) {
            meta.append(button0);
            meta.append(button1);
        }
        meta.append(button2);
        feed.querySelector('.date').after(meta);

        return feed

        function genButton(className, iconName) {
            const button = document.createElement('button');
            button.className = `mini ui icon tertiary button ${className}`;
            const icon = document.createElement('i');
            icon.className = `${iconName} icon`;
            button.append(icon);
            return button
        }
    }
}

function saveFile(data, filename, type) {
    const file = new Blob([data], { type: type });
    const a = document.createElement('a');
    const url = URL.createObjectURL(file);
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(function () {
        document.body.removeChild(a);
        window.URL.revokeObjectURL(url);
    }, 0);
}

function chromeClickChecker(event) {
    return (
        event.target.tagName.toLowerCase() === 'i' &&
        event.target.classList.contains('fa-ellipsis-h') &&
        document.querySelector('div.dropdown-menu') === null
    );
}

function firefoxClickChecker(event) {
    return (
        event.target.tagName.toLowerCase() === 'button' &&
        event.target.classList.contains('icon-button') &&
        document.querySelector('div.dropdown-menu') === null
    );
}

function activate() {
    document.querySelector('body').addEventListener('click', function (event) {
        if (chromeClickChecker(event) || firefoxClickChecker(event)) {
            // Get the status for this event
            let status = event.target.parentNode.parentNode.parentNode.parentNode.parentNode;
            if (status.className.match('detailed-status__wrapper')) {
                addLink(status);
            }
        };
    }, false);
}

function addLink(status) {
    setTimeout(function () {
        const url = status.querySelector('.detailed-status__link').getAttribute('href');
        const id = url.match(/\/(\d+)\//)[1];

        const dropdown = document.querySelector('div.dropdown-menu ul');
        const separator = dropdown.querySelector('li.dropdown-menu__separator');

        const listItem = document.createElement('li');
        listItem.classList.add('dropdown-menu__item');
        listItem.classList.add('mastodon__lottery');

        const link = document.createElement('a');
        link.setAttribute('href', '#');
        link.setAttribute('target', '_blank');
        link.textContent = 'Save as HTML';

        link.addEventListener('click', function (e) {
            e.preventDefault();
            if (!window.Running) {
                window.Running = true;
                link.textContent = 'Saving, please wait……';
                run(id)
                    .then(() => { window.Running = false; })
                    .catch(e => {
                        window.Running = false;
                        throw e;
                    });
            }
        }, false);

        listItem.appendChild(link);
        dropdown.insertBefore(listItem, separator);
    }, 100);
}

function run(id) {
    const domain = document.location.host;

    const s1 = new Status(domain, id, false);
    s1.init().then(() => {
        const html = s1.html();
        saveFile(html, `${id}.html`, 'text/plain; charset=utf-8');
    });

    const s2 = new Status(domain, id, true);
    s2.init().then(() => {
        const html = s2.html();
        saveFile(html, `${id}-time.html`, 'text/plain; charset=utf-8');
    });
}


window.addEventListener('load', function () {
    activate();
}, false)