Mastodon status2html

Save status to a html file.

目前為 2020-11-22 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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)