Bilibili-Markdown

B站专栏 Markdown 编辑器

// ==UserScript==
// @name                    Bilibili-Markdown
// @namespace               https://github.com/LuckyPuppy514
// @version                 1.0.3
// @author                  LuckyPuppy514
// @copyright               2023, Grant LuckyPuppy514 (https://github.com/LuckyPuppy514)
// @license                 MIT
// @description             B站专栏 Markdown 编辑器
// @homepage                https://github.com/LuckyPuppy514/Bilibili-Markdown
// @icon                    https://article.biliimg.com/bfs/article/3e927f211d063b57cd39c4041ac2d07fd959726c.png
// @match                   https://member.bilibili.com/article-text/home*
// @require                 https://unpkg.com/jquery@3.2.1/dist/jquery.min.js
// ==/UserScript==

"use strict";

console.log(`

🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️
Ⓜ️                                                       🅱️
🅱️                   Bilibili-Markdown                   Ⓜ️
Ⓜ️                                                       🅱️
🅱️  https://github.com/LuckyPuppy514/Bilibili-Markdown   Ⓜ️
Ⓜ️                                                       🅱️
🅱️                  2023 @LuckyPuppy514                  Ⓜ️
Ⓜ️                                                       🅱️
🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️

`);

// markdown 编辑器地址
// const BILIBILI_MARKDOWN_URL = "http://127.0.0.1:5500/web/tampermonkey/Bilibili-Markdown/index.html";
const BILIBILI_MARKDOWN_URL = "https://www.lckp.top/bilibili-markdown/index.html";
// id / name 公共前缀
const PREFIX = "bilibili-markdown-";
// 等待时间(ms)
const waitTime = {
    long: 2500,
    normal: 1000,
    short: 600,
};
// localStorage key
const key = {
    isMarkdown: "isMarkdown",
    isFullscreen: "isFullscreen"
}
// element id
const eid = {
    button: {
        switchToHtmlEditor: `${PREFIX}switch-to-html-editor-button`
    },
    iframe: {
        main: `${PREFIX}main-iframe`
    }
};
// element
const elements = {
    // 附加
    switchToMarkdownEditorButton: undefined,
    mainIframe: undefined,
    // 原有
    editorBox: undefined,
    loading: undefined,
    save: undefined,
    mbpreview: undefined
};
// class name
const cname = {
    fullscreen: `${PREFIX}fullscreen`,
    toast: `${PREFIX}toast`,
};
// z-index
const zIndex = {
    first: 999999,
    second: 999998
};
// display
const display = {
    none: "none",
    block: "block"
}

var needReload;
var bilibili;
var bilibiliMarkdown;

const CSS = `
/*切换 markdown 编辑器按钮*/
#${eid.button.switchToHtmlEditor} {
    font-size: 22px;
    border-width: 0px 1px 0px 0px;
    border-style: solid;
    border-color: white;
    margin-left: -9px;
    padding-right: 5px;
}
/*markdown 编辑器 iframe*/
#${eid.iframe.main} {    
    width: 100%;
    height: 480px;
    z-index: ${zIndex.second};
    border: none;
    display: none;
}
/*全屏*/
.${cname.fullscreen} {
    position: fixed !important;
    top: 0 !important;
    left: 0 !important;
    bottom: 0 !important;
    right: 0 !important;
    width: 100% !important;
    height: 100% !important;
    border: none !important;
    margin: 0 !important;
    padding: 0 !important;
    overflow: hidden !important;
    z-index: ${zIndex.second} !important;
}
/*消息*/
.${cname.toast} {
    max-width: 60%;
    min-width: 160px;
    padding: 0 14px;
    height: 50px;
    color: rgb(255, 255, 255);
    line-height: 50px;
    text-align: center;
    border-radius: 4px;
    position: fixed;
    top: 6%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: ${zIndex.first};
    background: rgba(119, 199, 104, 0.9);
    font-size: 14px;
    box-shadow: 0px 0px 10px rgba(119, 199, 104, 0.9);
}
/*手机端预览*/
.preview-mask,
.preview-mask .preview-content {
    padding-top: 35px !important;
    z-index: ${zIndex.first} !important;
}
`;
const HTML = `
<iframe id="${eid.iframe.main}" src="${BILIBILI_MARKDOWN_URL}"></iframe>
`;

function appendCSS() {
    let css = document.createElement("style");
    css.innerHTML = CSS.trim();
    document.head.appendChild(css);
}
function appendHTML() {
    let div = document.createElement("div");
    div.innerHTML = HTML.trim();
    document.getElementsByClassName("editor-wrap")[0].appendChild(div);
}
function appendSwitchToMarkdownEditorButton() {
    let button = document.createElement('li');
    button.id = eid.button.switchToHtmlEditor;
    button.className = 'toolbar-item left';
    button.innerHTML = 'Ⓜ️';
    document.getElementsByClassName('editor-toolbar clearfix')[0].prepend(button);
}
function getAllElement() {
    elements.switchToMarkdownEditorButton = document.getElementById(eid.button.switchToHtmlEditor);
    elements.mainIframe = document.getElementById(eid.iframe.main);

    elements.editorBox = document.getElementsByClassName("editor-box")[0];
    elements.save = document.getElementsByClassName("ui-btn white")[0];
    elements.mbpreview = document.getElementsByClassName("ui-btn white")[1];
    elements.loading = document.getElementById("loading");
    elements.loading.innerHTML = elements.loading.innerHTML.replace("玩儿命加载中", "处理中,请稍后");
    elements.loading.style.zIndex = zIndex.first;
}
function addListener() {
    elements.switchToMarkdownEditorButton.onclick = async function () {
        if (!bilibili.aid) {
            bilibili.aid = await bilibili.getAidFromLocalStorage();
        }
        if (bilibili.aid) {
            bilibili.switchToMarkdownEditor();
        } else {
            Toast("矮油,起码写个标题嘛~");
        }
    }
    elements.save.onclick = function () {
        if (localStorage.getItem(key.isMarkdown)) {
            bilibili.loading();
            setTimeout(() => {
                bilibiliMarkdown.save();
            }, waitTime.normal);
        }
    }
    elements.mbpreview.onclick = function () {
        if (localStorage.getItem(key.isMarkdown) && needReload) {
            setTimeout(() => {
                bilibiliMarkdown.save();
                bilibili.mbpreview();
            }, waitTime.normal);
        }
    }
}
// 显示消息
function Toast(msg, duration) {
    duration = isNaN(duration) ? 2000 : duration;
    let div = document.createElement("div");
    div.innerHTML = msg;
    div.className = cname.toast;
    document.body.appendChild(div);
    setTimeout(function () {
        div.style.opacity = "0";
        setTimeout(function () { document.body.removeChild(div) }, 500);
    }, duration);
}
// webp 转 jpg
function webpToJpg(webp) {
    return new Promise(function (resolve, reject) {
        let image = new Image();
        image.src = URL.createObjectURL(webp);
        image.onload = function () {
            let canvas = document.createElement("canvas");
            canvas.width = image.width;
            canvas.height = image.height;
            canvas.getContext("2d").drawImage(image, 0, 0);
            let blob = dataURLtoBlob(canvas.toDataURL("image/jpeg"));
            file = new File([blob], blob.name, {
                type: blob.type,
            });
            resolve(file);
        }
    });
}
function dataURLtoBlob(dataurl) {
    var arr = dataurl.split(','),
        mime = arr[0].match(/:(.*?);/)[1],
        bstr = atob(arr[1]),
        n = bstr.length,
        u8arr = new Uint8Array(n);
    while (n--) {
        u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
}
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

class Bilibili {
    constructor() {
        this.api = {
            upcover: "https://api.bilibili.com/x/article/creative/article/upcover",
            addupdate: "https://api.bilibili.com/x/article/creative/draft/addupdate",
        }
        this.page = {
            edit: "https://member.bilibili.com/platform/upload/text/edit",
            pcpreview: "https://www.bilibili.com/read/pcpreview",
            home: "https://member.bilibili.com/article-text/home"
        }
        this.csrf = this.getCsrf();
        this.aid = this.getAidFromLocation();
        this.addListener();
        this.uploading = 0;
        this.uploadList = new Map();
    }
    getCsrf() {
        let cookie = document.cookie;
        let csrf = cookie.substring(cookie.indexOf("bili_jct"));
        csrf = csrf.substring(9, csrf.indexOf(";"));
        return csrf;
    }
    getAidFromLocation() {
        let aid = undefined;
        let aids = window.location.href.match(/aid=[0-9]+/g);
        if (aids && aids.length > 0) {
            aid = aids[0].replace("aid=", "");
        }
        if (aid && aid.toString().length > 5) {
            return aid;
        }
        return undefined;
    }
    async getAidFromLocalStorage() {
        let aid = undefined;
        // 等待 TIMEOUT_TIME 后读取 localStorage (更新需要时间)
        this.loading();
        await new Promise((resolve, reject) => {
            setTimeout(() => {
                aid = JSON.parse(localStorage.bili_localDraft).id;
                resolve();
            }, waitTime.long);
        })
        this.hideLoading();

        if (aid && aid.toString().length > 5) {
            // 新建专栏跳转编辑页面
            if (window.location.href.endsWith("?")) {
                top.location.href = this.page.edit + "?aid=" + aid;
            }
            return aid;
        }
        return undefined;
    }
    addListener() {
        window.addEventListener("message", function (event) {
            bilibili[event.data.method](event.data.param);
        }, false);
    }
    switchToMarkdownEditor() {
        localStorage.setItem(key.isMarkdown, true);
        elements.mainIframe.style.display = display.block;
        elements.editorBox.style.display = display.none;
        if (localStorage.getItem(key.isFullscreen)) {
            localStorage.removeItem(key.isFullscreen);
            this.switchToFullscreen();
        }
    }
    switchToHtmlEditor() {
        localStorage.removeItem(key.isMarkdown);
        elements.mainIframe.style.display = display.none;
        elements.editorBox.style.display = display.block;
        document.body.style.overflowY = "";
        if (needReload) {
            needReload = false;
            location.reload();
        }
    }
    switchToFullscreen(param) {
        if (param && param.isFullscreen != undefined) {
            if (param.isFullscreen === true) {
                fullscreen();
                if (top != self) {
                    top.location.href = bilibili.page.home + "?aid=" + bilibili.aid;
                }
            } else {
                exitFullscreen();
            }
        } else {
            if (localStorage.getItem(key.isFullscreen)) {
                exitFullscreen();
            } else {
                fullscreen();
            }
        }

        function fullscreen() {
            localStorage.setItem(key.isFullscreen, true);
            elements.mainIframe.className = cname.fullscreen;
            document.body.style.overflowY = "hidden";
        }
        function exitFullscreen() {
            localStorage.removeItem(key.isFullscreen);
            elements.mainIframe.className = "";
            document.body.style.overflowY = "";
        }
    }
    loading() {
        elements.loading.style.display = display.block;
        setTimeout(this.hideLoading, waitTime.long);
    }
    hideLoading() {
        elements.loading.style.display = display.none;
    }
    pcpreview() {
        window.open(this.page.pcpreview + "?aid=" + this.aid);
    }
    mbpreview() {
        if (needReload) {
            localStorage.setItem(key.needMbpreview, true);
            location.reload();
        } else {
            localStorage.removeItem(key.needMbpreview);
            document.getElementsByClassName("ui-btn white")[1].click();
        }
    }
    async appendImage(param) {
        bilibiliMarkdown.appendImage(await this.uploadImage(param.image));
    }
    toBLink(param) {
        this.loading();
        let link = param.link;
        let xhr = new XMLHttpRequest();
        xhr.open("get", link, true);
        xhr.responseType = "blob";
        xhr.onload = async function () {
            let image = new File([xhr.response], link.substring(link.lastIndexOf('/') + 1));
            let bLink = await bilibili.uploadImage(image);
            bilibiliMarkdown.toBLink(link, bLink);
            bilibili.hideLoading();
        }
        xhr.send();
    }
    async uploadImage(image) {
        let name = image.name;
        let bLink = this.uploadList.get(name);
        if (bLink) {
            if(bLink == "uploading"){
                return undefined;
            } else {
                return bLink;
            }
        } else {
            this.uploadList.set(name, "uploading");
        }
        // webp 转 jpg
        if (name.endsWith(".webp")) {
            image = await webpToJpg(image);
        }
        bLink = "图片上传B站失败,请重试";
        let formData = new FormData();
        formData.append("binary", image);
        formData.append("csrf", this.csrf);

        // 限制上传频率
        let that = this;
        while (that.uploading > 0) {
            await sleep(waitTime.normal);
        }
        that.uploading++;
        $.ajax({
            type: "POST",
            contentType: false,
            processData: false,
            async: false,
            data: formData,
            url: bilibili.api.upcover,
            xhrFields: {
                withCredentials: true
            },
            success: function (res) {
                if (res && res.data) {
                    bLink = res.data.url;
                    that.uploadList.set(name, bLink);
                } else {
                    that.uploadList.delete(name);
                    Toast("上传失败:" + JSON.stringify(res));
                }
            }
        })

        // 释放限制频率锁
        setTimeout(() => {
            that.uploading--;
        }, waitTime.normal);
        return bLink;
    }
    async tableToImage(html, tables) {
        if (tables && tables.size > 0) {
            for (let [oldHtml, image] of tables) {
                let bLink = await this.uploadImage(image);
                let newHtml = `<figure contenteditable="false" class="img-box"><img referrerpolicy="no-referrer" src="${bLink}"><figcaption class="caption" contenteditable="false"></figcaption></figure>`;
                html = html.replaceAll(oldHtml, newHtml);
            }
        }
        return html;
    }
    async save(param) {
        let html = param.html ? param.html : "";
        // 保存到本地
        localStorage.setItem(PREFIX + this.aid, param.markdown);
        // 表格转图片
        html = await this.tableToImage(html, param.tables);
        // 提取内容
        let words = html.replace(/<(h[1-6]|code)[^>]*>[^<]*<\/\1>/g, "")
            .replace(/<[^>]*>/g, "")
            .replace(/[\s| |\n\|\r]*/g, "");
        // 提取总结
        let summary = words.slice(0, 100);
        // B站接口参数
        let biliLocalDraft = JSON.parse(localStorage.bili_localDraft);
        $.ajax({
            type: "POST",
            data: {
                title: biliLocalDraft.title,
                content: html,
                summary: summary,
                words: words.length,
                category: biliLocalDraft.category,
                list_id: biliLocalDraft.list_id,
                tid: biliLocalDraft.template.id,
                reprint: 0,
                media_id: biliLocalDraft.media_id,
                spoiler: biliLocalDraft.is_spoiler ? "1" : "0",
                original: biliLocalDraft.isOriginal,
                aid: biliLocalDraft.id,
                csrf: this.csrf
            },
            url: bilibili.api.addupdate,
            xhrFields: {
                withCredentials: true
            },
            success: function (res) {
                bilibili.hideLoading();
                if (res && res.code == 0) {
                    if (localStorage.getItem(key.needMbpreview)) {
                        location.reload();
                    } else {
                        needReload = true;
                        Toast(" 草稿已保存 ");
                    }
                } else {
                    Toast("保存失败: " + JSON.stringify(res));
                }
            },
            error: function (err) {
                Toast("保存失败: " + JSON.stringify(err.message));
            }
        });
    }
}

class BilibiliMarkdown {
    constructor() {
        setTimeout(() => {
            this.hello();

            if (bilibili.aid) {
                if (localStorage.getItem(key.isMarkdown)) {
                    bilibili.switchToMarkdownEditor();
                }

                let markdown = localStorage.getItem(PREFIX + bilibili.aid);
                if (markdown) {
                    this.setMarkdown(markdown);
                }
                if (localStorage.getItem(key.needMbpreview)) {
                    bilibili.mbpreview();
                }
            }
        }, waitTime.short);
    }
    message(method, param) {
        elements.mainIframe.contentWindow.postMessage({ method: method, param: param }, BILIBILI_MARKDOWN_URL);
    }
    hello() {
        this.message(this.hello.name);
    }
    save() {
        this.message(this.save.name);
    }
    toBLink(link, bLink) {
        if(bLink){
            this.message(this.toBLink.name, { link: link, bLink: bLink });
        }
    }
    appendImage(bLink) {
        this.message(this.appendImage.name, { bLink: bLink });
    }
    setMarkdown(markdown) {
        this.message(this.setMarkdown.name, { markdown: markdown });
    }
}

window.onload = function () {
    setTimeout(() => {
        let saveButton = document.getElementsByClassName("ui-btn white")[0];
        if (!saveButton || saveButton.innerHTML != "存草稿") {
            console.log("文章已提交");
            return;
        }

        appendCSS();
        appendHTML();
        appendSwitchToMarkdownEditorButton();
        getAllElement();
        addListener();

        bilibili = new Bilibili();
        bilibiliMarkdown = new BilibiliMarkdown();
    }, waitTime.short);
}