Mastodon status2html

Save status to a html file.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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/[email protected]/dist/semantic.min.css" integrity="sha256-2+dssJtgusl/DZZZ8gF9ayAgRzcewXQsaP86E4Ul+ss=" crossorigin="anonymous">
            <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]/dist/jquery.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
            <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/semantic.min.js" integrity="sha256-yibQd6vg4YwSTFUcgd+MwPALTUAVCKTjh4jMON4j+Gk=" crossorigin="anonymous"></script>
            <script src="https://cdn.jsdelivr.net/npm/[email protected]/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)