Markdown Grabber

markdown grabber

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

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

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

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

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

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Markdown Grabber
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  markdown grabber
// @author       5ec1cff
// @match        *://*/*
// @license      AGPL
// @grant        unsafeWindow
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      *
// ==/UserScript==

// 2021.12.24 Fri: 修正
// 2022.03.15 Tue: 增加下载图片支持(默认启用)
// 2023.03.01 Wed: 支持 xz.aliyun.cn ;下载图片附带 Referer
// 2025.06.30 Mon: 兼容 bbs.kanxue.com ,支持代码,支持解密链接

(function () {
    'use strict';

    if (window.top !== window) return; // 阻止在 iframe 启用

    const downloadPics = true;

    const picMap = new Map();

    const console = unsafeWindow.console.context();

    function decodeKanxueUrl(s) {
        let str = s;
        try {
            // 检查是否已经是解密后的URL格式
            if (str.includes('http://') || str.includes('https://') || str.includes('www.')) {
                return str;
            }
            // 1. 移除salt
            const encrypted = str.slice(3);
            // 2. 定义替换规则的反向映射
            const replacePairs = {
                'K9': 'a', 'L8': 'b', 'M7': 'c',
                'N6': 'd', 'P5': 'e', 'Q4': 'f',
                'R3': 'g', 'S2': 'h', 'T1': 'i',
                'U0': 'j', 'V1': 'k', 'W2': 'l',
                'X3': 'm', 'Y4': 'n', 'Z5': 'o',
                'A6': 'p', 'B7': 'q', 'C8': 'r',
                'D9': 's', 'E0': 't', 'F1': 'u',
                'G2': 'v', 'H3': 'w', 'I4': 'x',
                'J5': 'y', 'K6': 'z',
                'l9': 'A', 'm8': 'B', 'n7': 'C',
                'o6': 'D', 'p5': 'E', 'q4': 'F',
                'r3': 'G', 's2': 'H', 't1': 'I',
                'u0': 'J', 'v1': 'K', 'w2': 'L',
                'x3': 'M', 'y4': 'N', 'z5': 'O',
                'a6': 'P', 'b7': 'Q', 'c8': 'R',
                'd9': 'S', 'e0': 'T', 'f1': 'U',
                'g2': 'V', 'h3': 'W', 'i4': 'X',
                'j5': 'Y', 'k6': 'Z',
                '@1': '0', '#2': '1', '$3': '2',
                '%4': '3', '^5': '4', '&6': '5',
                '*7': '6', '(8': '7', ')9': '8',
                '!0': '9', '-_': '+', '|~': '/',
                '`.': '='
            };

            // 3. 按长度排序替换规则
            const sortedPairs = Object.entries(replacePairs)
                .sort((a, b) => b[0].length - a[0].length);

            // 4. 执行替换
            let base64 = encrypted;
            for (const [pattern, replacement] of sortedPairs) {
                base64 = base64.split(pattern).join(replacement);
            }

            // 5. 补全base64字符串的等号
            while (base64.length % 4) {
                base64 += '=';
            }

            // 6. 清理非法字符
            base64 = base64.replace(/[^A-Za-z0-9+/=]/g, '');

            // 7. base64解码
            const decoded = atob(base64);

            s = decoded;
        } catch (e) {
            console.log('decodeKanxueUrl', e);
            return str; // 如果解密失败,返回原始字符串
        }
	      s = s.replace(/_/g, "%");
	      s = decodeURIComponent(s);
	      return s;
    }

    function getPictureKey(url) {
        return url;
    }

    function getPicture(url) {
        const key = getPictureKey(url);
        if (picMap.get(url) == null) {
            picMap.set(url,
                new Promise((rs, rj) => {
                    if (!downloadPics || !url?.startsWith("http")) {
                        rs([key, url]);
                        return;
                    }
                    GM_xmlhttpRequest({
                        url: url,
                        headers: { Referer: location.href },
                        responseType: "blob",
                        onload(r) {
                            const fr = new FileReader();
                            fr.onloadend = () => {
                                console.log('load done:', url);
                                rs([key, fr.result]);
                            }
                            fr.onerror = (e) => {
                                rj(e);
                            }
                            fr.readAsDataURL(r.response);
                        },
                        onerror(e) {
                            rj(e);
                        },
                        onabort(e) {
                            rj(e);
                        }
                    })
                })
            );
        }
        return key;
    }

    function parseSimpleStyle(e) {
        let r = '';
        switch (e.tagName.toLowerCase()) {
            case 'b':
            case 'strong':
                r += `**${parseSingleLine(e)}**`;
                break;
            case 'i':
            case 'em':
                r += `*${parseSingleLine(e)}*`;
                break;
            case 's':
            case 'strike':
                r += `~~${parseSingleLine(e)}~~`;
                break;
            case 'a': {
                if (e.href) {
                    let href = e.getAttribute('href');
                    if (href.indexOf('elink@') !== -1) {
                        href = decodeKanxueUrl(href.substr(6));
                    }
                    r += `[${parseSingleLine(e)}](${href})`;
                }
                break;
            }
            case 'code':
                r += `\`${e.innerText}\``;
                break;
            case 'img':
                r += `\n![][${getPicture(e.src)}]\n`;
                break;
            default:
                r += parseSingleLine(e);
        }
        return r;
    }

    function parseSingleLine(element) {
        if (element instanceof Text) return element.data.trim();
        let r = '';
        if (element instanceof HTMLElement) {
            for (let e of element.childNodes) {
                if (e instanceof Text) r += e.data;
                if (!(e instanceof HTMLElement)) continue;
                r += parseSimpleStyle(e);
            }
        }
        return r.trim();
    }

    function isSingleLine(node) {
        return !node.querySelector('p,ul,ol,br');
    }

    function parseNode(element) {
        let lines = [], singleLine = null;
        if (element instanceof HTMLElement) {
            for (let e of element.childNodes) {
                if (!(e instanceof HTMLElement) && !(e instanceof Text)) continue;
                let tagName;
                if (e instanceof Text) {
                    tagName = 'TEXT';
                } else {
                    tagName = e.tagName.toLowerCase();
                }
                switch (tagName) {
                    case 'TEXT':
                    case 'a':
                    case 'b':
                    case 'strong':
                    case 'i':
                    case 'em':
                    case 's':
                    case 'strike':
                    case 'a':
                    case 'code': {
                        if (singleLine == null) singleLine = '';
                        if (tagName == 'TEXT') {
                            singleLine += e.data.trim();
                        }
                        else {
                            singleLine += parseSimpleStyle(e);
                        }
                        continue;
                    }
                    default:
                        if (singleLine != null) {
                            lines.push(singleLine);
                            singleLine = null;
                        }
                }

                switch (tagName) {
                    // ignores
                    case 'button':
                    case 'style':
                    case 'header':
                    case 'script':
                        continue;
                    case 'p':
                        lines.push(parseSingleLine(e) + '\n');
                        break;
                    case 'br':
                        lines.push('\n');
                        break;
                    case 'ul':
                    case 'ol': {
                        lines.push('');
                        let is_order = tagName == 'ol',
                            j = 1;
                        for (let item of e.childNodes) {
                            let pref = is_order ? `${j}. ` : `- `;
                            if (item instanceof HTMLLIElement) {
                                if (!isSingleLine(item)) {
                                    let item_lines = parseNode(item);
                                    for (let i = 0; i < item_lines.length; i++) {
                                        const l = item_lines[i].trim()
                                        if (l) {
                                            lines.push(`${i==0?pref:'    '}${item_lines[i]}`);
                                        }
                                    }
                                } else {
                                    lines.push(`${pref}${parseSingleLine(item)}`);
                                }
                                j++;
                            }
                        }
                        lines.push('');
                        break;
                    }
                    case 'pre': {
                        // debugger
                        lines.push('```');
                        lines.push(...(e.querySelector('code') || e).innerText.trim().split('\n'));
                        lines.push('```');
                        break;
                    }
                    case 'blockquote': {
                        lines.push('');
                        let item_lines = parseNode(e);
                        for (let i = 0; i < item_lines.length; i++) {
                            lines.push(`> ${item_lines[i]}`);
                        }
                        lines.push('');
                        break;
                    }
                    case 'table': {
                        if (e.classList.contains('syntaxhighlighter')) {
                            let content = e.querySelector('.code');
                            if (content) {
                                let lang = Array.from(e.classList).filter(x=>x!='syntaxhighlighter')[0] || '';
                                lines.push('```' + lang);
                                lines.push(...content.innerText.trim().split('\n'));
                                lines.push('```');
                                break;
                            }
                        }
                        lines.push('');
                        let head = e.querySelector('thead');
                        if (!head) {
                            console.warn('unknown table!');
                            // resolve body as normal tag
                            let body;
                            if (body = e.querySelector('tbody')) {
                                lines.push(...parseNode(body));
                            }
                            continue;
                        }
                        let head_line = '|',
                            sep_line = '|';
                        for (let h of head.querySelectorAll('th')) {
                            head_line += `${parseSingleLine(h)}|`;
                            sep_line += `--|`
                        }
                        lines.push(head_line);
                        lines.push(sep_line);
                        let body = e.querySelector('tbody');
                        for (let b of body.querySelectorAll('tr')) {
                            let line = '|';
                            for (let d of b.querySelectorAll('td')) {
                                line += `${parseSingleLine(d)}|`;
                            }
                            lines.push(line);
                        }
                        lines.push('');
                        break;
                    }
                    case 'hr':
                        lines.push('\n---\n');
                        break;
                    case 'img':
                        lines.push(`\n![][${getPicture(e.src)}]\n`);
                        break;
                    case 'figure': {
                        if (e.classList.contains('highlight')) {
                            let lang = e.classList[1] || '';
                            let code = e.querySelector('td.code pre');
                            if (code != null) {
                                lines.push('```' + lang);
                                lines.push(...code.innerText.trim().split('\n'));
                                lines.push('```');
                                break;
                            }
                        }
                        // fallthrough
                    }
                    case 'td': {
                        if (e.classList.contains('gutter')) continue;
                        // fallthrough
                    }
                    default: {
                        let r;
                        if (r = tagName.match(/h(\d+)/)) {
                            lines.push(`\n${'#'.repeat(Number(r[1]))} ${parseSingleLine(e)} \n`);
                        } else {
                            lines.splice(lines.length, 0, ...parseNode(e));
                        }
                    }
                }
            }
            if (singleLine != null) lines.push(singleLine);
        }
        return lines;
    }

    function findArticle() {
        let article = document.body.querySelector('article');
        if (article) return article;
        article = document.body.querySelector('div.markdown-body,div.mod-content');
        if (article) return article;
        article = document.body.querySelector('div.message_md_type'); // kanxue
        if (article) return article;
        let maxChild = 0, node = null;
        for (let n of document.querySelectorAll('h1')) {
            if (n.parentNode && n.parentNode.childElementCount >= maxChild) {
                node = n.parentNode;
            }
        }
        return node;
    }

    async function html2MD() {
        let article = findArticle();
        let title = document.querySelector('h1');
        let r = '';
        if (title) {
            r += `# ${parseSingleLine(title)}`;
        } else {
            r += `# ${document.title}`;
        }
        r += `\n${location.href}\n\n`;
        r += await nodeToMD(article);
        return r;
    }

    async function nodeToMD(node) {
        picMap.clear();
        let r = '';
        let lines = parseNode(node);
        for (let l of lines) {
            r += `${l}\n`;
        }

        let pics = await Promise.race([
            Promise.all(picMap.values()),
            new Promise((_, rj) => {
                console.log("waiting 10s for downloading pictures...", picMap.size);
                setTimeout(() => { rj('time out!'); }, 10000)
            })
        ]);
        r += '\n';
        for (let [key, url] of pics) {
            r += `[${key}]:${url}\n`;
        }
        return r;
    }

    unsafeWindow.md = nodeToMD;
    // unsafeWindow.__xhr = GM_xmlhttpRequest;
    // unsafeWindow._getpic = getPicture

    async function onClick() {
        let url = URL.createObjectURL(new Blob([await html2MD()], { type: 'text/plain' }));
        let a = document.createElement('a');
        a.download = `${document.title}.md`;
        a.href = url;
        document.body.append(a);
        a.click();
        a.remove();
    }

    GM_registerMenuCommand('下载 Markdown', () => {
        onClick();
    })

})();