// ==UserScript== // @name Markdown文件阅读器 // @namespace http://tampermonkey.net/ // @version 2.2 // @description 纯离线处理Markdown,内置解析器,完美支持本地文件,右上角一键切换预览与源码,支持导出doc文档。智能识别纯文本页面,避免误渲染GitHub等网页。 // @author MRBANK // @match file:///* // @match *://*/*.md // @match *://*/*.markdown // @match *://*/*.txt // @grant GM_addStyle // @run-at document-start // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHJ4PSI1IiBmaWxsPSIjMjgyYTM2IiBzdHJva2U9IiM1MGZhN2IiIHN0cm9rZS13aWR0aD0iMS41Ii8+PHRleHQgeD0iMTIiIHk9IjE2LjUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC13ZWlnaHQ9ImJvbGQiIGZvbnQtc2l6ZT0iMTQiIGZpbGw9IiM1MGZhN2IiPk08L3RleHQ+PC9zdmc+ // ==/UserScript== (function() { 'use strict'; // ========== 配置 ========== const CONFIG = { autoRender: true }; // ========== 内置 Markdown 解析引擎 ========== const MarkdownParser = { parse(md) { let html = md; html = this.parseCodeBlocks(html); html = this.escapeHtml(html); // Headers html = html.replace(/^######\s+(.+)$/gm, '
$1');
// Blockquotes, Lists, Tables
html = this.parseBlockquotes(html);
html = this.parseLists(html);
html = this.parseTables(html);
html = this.restoreCodeBlocks(html);
html = this.cleanupHtmlArtifacts(html);
// Paragraphs
html = this.wrapParagraphs(html);
return html;
},
escapeHtml(text) {
return text.replace(/&/g, '&').replace(//g, '>');
},
codeBlocks: [],
parseCodeBlocks(text) {
this.codeBlocks = [];
return text.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
const index = this.codeBlocks.push({ lang, code: code.trim() }) - 1;
return `CODEBLOCKPLACEHOLDER${index}BLOCK`;
});
},
restoreCodeBlocks(text) {
return text.replace(/CODEBLOCKPLACEHOLDER(\d+)BLOCK/g, (match, index) => {
const block = this.codeBlocks[index];
const escapedCode = block.code.replace(/&/g, '&').replace(//g, '>');
return `${this.highlight(escapedCode, block.lang)}`;
});
},
highlight(code, lang) {
const keywords = {
js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'import', 'export'],
python: ['def', 'class', 'return', 'if', 'else', 'import', 'from', 'as', 'True', 'False'],
default: []
};
const kwList = keywords[lang] || keywords.default;
const placeholderStore = [];
const stash = (text) => `HIGHLIGHTPLACEHOLDER${placeholderStore.push(text) - 1}TOKEN`;
let res = code;
res = res.replace(/(["'`])(?:(?!\1)[^\\]|\\.)*?\1/g, match => stash(`${match}`));
res = res.replace(/(\/\/.*$|#.*$)/gm, match => stash(`${match}`));
res = res.replace(/\b(\d+\.?\d*)\b/g, '$1');
kwList.forEach(k => {
res = res.replace(new RegExp(`\\b(${k})\\b`, 'g'), '$1');
});
res = res.replace(/HIGHLIGHTPLACEHOLDER(\d+)TOKEN/g, (match, index) => placeholderStore[Number(index)] || '');
return res;
},
parseBlockquotes(text) {
const lines = text.split('\n');
let html = [];
let inQuote = false;
lines.forEach(line => {
if (line.startsWith('> ') || line.startsWith('> ')) {
if (!inQuote) { html.push(''); inQuote = true; } html.push(line.replace(/^(?:>|>)\s*/, '')); } else { if (inQuote) { html.push(''); inQuote = false; } html.push(line); } }); if (inQuote) html.push(''); return html.join('\n'); }, parseLists(text) { const lines = text.split('\n'); let html = []; let listType = null; lines.forEach(line => { const ulMatch = line.match(/^[\s]*[-*+]\s+(.+)/); const olMatch = line.match(/^[\s]*\d+\.\s+(.+)/); if (ulMatch) { if (listType !== 'ul') { if (listType) html.push(`${listType}>`); html.push('
${block.replace(/\n/g, '
')}
标签包裹纯文本展示
if (preElements.length === 1 && body.textContent.trim() === preElements[0].textContent.trim()) {
isPlainTextDom = true;
}
// 文本直接在 body 下,没有被 包裹
else if (body.childElementCount === 0 && body.textContent.trim().length > 0) {
isPlainTextDom = true;
}
}
// 步骤 B:如果是纯文本 DOM,再进行内容嗅探
if (isPlainTextDom) {
textContent = body.innerText || body.textContent;
// 只要内容看起来像 Markdown,不管是 .md 还是 .txt 结尾,都接管
if (looksLikeMarkdown(textContent)) {
shouldTakeOver = true;
}
}
}
// 如果不符合接管条件,什么都不做,保持网页原样
if (!shouldTakeOver) return;
// --- 符合条件,开始接管渲染 ---
document.body.appendChild(panel);
originalContent = textContent;
renderMarkdown();
}
function getMarkdownSource() {
return document.body.dataset.mdSource || originalContent || document.body.innerText || '';
}
function getDocumentTitle() {
const pathname = decodeURIComponent(window.location.pathname || 'document');
const filename = pathname.split('/').pop() || 'document';
return filename.replace(/\.(md|markdown|txt)$/i, '') || 'document';
}
function getExportTimestamp() {
const now = new Date();
const pad = (num) => String(num).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
function escapeHtmlForWord(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function buildWordHtml(title, html) {
const exportTime = getExportTimestamp();
return `
${escapeHtmlForWord(title)}
${escapeHtmlForWord(title)}
${html}
`;
}
function normalizeMixedText(text) {
return text
.replace(/\u00a0/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/([\u4e00-\u9fff])\s+([A-Za-z0-9@#&])/g, '$1 $2')
.replace(/([A-Za-z0-9@#&])\s+([\u4e00-\u9fff])/g, '$1 $2')
.replace(/([\u4e00-\u9fff])\s+([,。!?;:、])/g, '$1$2')
.replace(/([(《“‘])\s+/g, '$1')
.replace(/\s+([)》)。!?;:,、”’])/g, '$1')
.replace(/\s+([,.!?;:])/g, '$1')
.replace(/([,.!?;:])(?!\s|$|[)\]}>"'])/g, '$1 ')
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')')
.replace(/\[\s+/g, '[')
.replace(/\s+\]/g, ']')
.replace(/\{\s+/g, '{')
.replace(/\s+\}/g, '}')
.trim();
}
function normalizeBlockHtml(html) {
return html
.replace(/ /g, ' ')
.replace(/\s*
\s*/gi, '
')
.replace(/(
){3,}/gi, '
')
.replace(/[ \t]{2,}/g, ' ')
.replace(/(?:
\s*)+$/gi, '')
.replace(/^(?:\s*
)+/gi, '')
.trim();
}
function simplifyWordHtml(html) {
const container = document.createElement('div');
container.innerHTML = html;
container.querySelectorAll('pre code').forEach(codeEl => {
const plainCode = codeEl.textContent || '';
const newCode = document.createElement('code');
newCode.textContent = plainCode
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd();
codeEl.replaceWith(newCode);
});
container.querySelectorAll('code:not(pre code)').forEach(codeEl => {
codeEl.textContent = (codeEl.textContent || '').replace(/\s+/g, ' ').trim();
});
container.querySelectorAll('br + br').forEach(br => br.remove());
container.querySelectorAll('p, li, blockquote, td, th').forEach(el => {
el.innerHTML = normalizeBlockHtml(el.innerHTML);
});
container.querySelectorAll('p, li, blockquote, td, th, h1, h2, h3, h4, h5, h6').forEach(el => {
if (el.children.length === 0) {
el.textContent = normalizeMixedText(el.textContent || '');
}
});
container.querySelectorAll('li').forEach(li => {
li.innerHTML = normalizeBlockHtml(li.innerHTML);
if (!li.innerHTML) {
li.remove();
}
});
container.querySelectorAll('ul, ol').forEach(list => {
const items = list.querySelectorAll(':scope > li');
if (items.length === 0) {
list.remove();
}
});
container.querySelectorAll('blockquote').forEach(quote => {
quote.innerHTML = normalizeBlockHtml(quote.innerHTML);
});
container.querySelectorAll('td, th').forEach(cell => {
cell.innerHTML = normalizeBlockHtml(cell.innerHTML);
});
container.querySelectorAll('p').forEach(p => {
if (!p.textContent.trim() && !p.querySelector('img, br, code')) {
p.remove();
}
});
container.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src') || '';
const alt = normalizeMixedText(img.getAttribute('alt') || '图片');
const fallback = document.createElement('div');
fallback.className = 'md-image-fallback';
fallback.innerHTML = src
? `图片:${escapeHtmlForWord(alt)}
路径:${escapeHtmlForWord(src)}`
: `图片:${escapeHtmlForWord(alt)}`;
img.replaceWith(fallback);
});
container.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
if (attr.name === 'class' || attr.name === 'style') {
el.removeAttribute(attr.name);
}
});
});
let cleanedHtml = container.innerHTML;
cleanedHtml = cleanedHtml
.replace(/ /g, ' ')
.replace(/ /g, ' ')
.replace(/\u00a0/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.replace(/>\s+<')
.replace(/<(p|li|blockquote|td|th)>
<\/(p|li|blockquote|td|th)>/gi, '')
.replace(/<(ul|ol)>\s*<\/(ul|ol)>/gi, '')
.trim();
cleanedHtml = cleanedHtml
.replace(/\s*<\/p>/gi, '')
.replace(/
\s*<\/blockquote>/gi, '') .replace(/\s*<\/td>/gi, ' ') .replace(/ \s*<\/th>/gi, ' ') .replace(/
\s*<\/li>/gi, '
/gi, '
') .replace(/<\/blockquote>\s*
/gi, '
'); return cleanedHtml; } function downloadBlob(filename, content, mimeType, addBom = true) { const blob = content instanceof Blob ? content : new Blob(addBom ? ['\ufeff', content] : [content], { type: mimeType }); const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = objectUrl; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(objectUrl); } function exportWord() { const source = getMarkdownSource(); const html = MarkdownParser.parse(source); const simplifiedHtml = simplifyWordHtml(html); const title = getDocumentTitle(); const wordHtml = buildWordHtml(title, simplifiedHtml); downloadBlob(`${title}.doc`, wordHtml, 'application/msword;charset=utf-8'); } function renderMarkdown() { if (isRendered) return; if (!document.body.dataset.mdSource) { document.body.dataset.mdSource = originalContent; } const html = MarkdownParser.parse(document.body.dataset.mdSource); document.body.innerHTML = ''; document.body.className = 'md-rendered-body'; document.body.innerHTML = html; document.body.appendChild(panel); // 更新按钮状态 btnPreview.classList.add('active'); btnSource.classList.remove('active'); isRendered = true; } function showSource() { if (!isRendered) return; document.body.className = ''; document.body.innerHTML = ''; const pre = document.createElement('pre'); pre.style.whiteSpace = 'pre-wrap'; pre.style.wordWrap = 'break-word'; pre.style.fontFamily = 'monospace'; pre.textContent = document.body.dataset.mdSource || originalContent; document.body.appendChild(pre); document.body.appendChild(panel); // 更新按钮状态 btnSource.classList.add('active'); btnPreview.classList.remove('active'); isRendered = false; } // 绑定事件 btnPreview.addEventListener('click', () => { if (!isRendered) renderMarkdown(); }); btnSource.addEventListener('click', () => { if (isRendered) showSource(); }); btnExportWord.addEventListener('click', () => { try { exportWord(); } catch (error) { console.error('导出 Word 失败:', error); alert('导出 Word 失败,请打开控制台查看错误信息。'); } }); // 等待DOM加载 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();