使用 MarkItDown 实现一个文档转 Markdown 服务
MarkItDown 是一个微软开源的 Python 工具, 用于将多种文件格式转换为 Markdown 格式. 本文将介绍如何使用 MarkItDown, 最后用 MarkItDown 实现一个文档转 Markdown 服务
前置条件
- Dev Container 开发环境, 可以参考我之前的文章 Dev Container 教程 (一) 基本使用
- 安装 Docker, Windows 系统推荐安装 Docker Desktop
安装
全量依赖安装
pip install 'markitdown[all]
可选依赖安装
pip install 'markitdown[pdf, docx, pptx]'
可选的依赖列表如下:
[all]
安装所有可选依赖项[pptx]
安装 PPT 文件的依赖项[docx]
安装 Word 文件的依赖项[xlsx]
安装 Excel 文件的依赖项[xls]
安装旧版 Excel 文件的依赖项[pdf]
安装 PDF 文件的依赖项[outlook]
安装 Outlook 邮件的依赖项[az-doc-intel]
安装 Azure Document Intelligence 的依赖项[audio-transcription]
安装 wav 和 mp3 文件音频转录的依赖项[youtube-transcription]
安装获取 YouTube 视频转录的依赖项
依赖
ffmpeg
转换 PDF 时需要依赖 ffmpeg, 这在官方文档并没有提到
Ubuntu 安装 ffmpeg
apt install -y ffmpeg
exiftool
转换图片需要依赖 exiftool
直接用 Ubuntu 包管理安装的 exiftool 可能有版本太低存在安全漏洞的问题, 需要源码编译高版本
使用 MarkItDown 图片转 Makrdown 的效果并不好, 只能得到类似 "ImageSize: 931x895" 这样的文本, 需要借助大模型的能力才能识别图片内容, 我尝试了用阿里千问的 qwen-vl-max
模型集成, 确实能够正确转换为 Markdown, 但是这样的话还不如直接就用大模型识别, 感觉有点鸡肋
插件
MarkItDown 支持第三方插件, 感觉功能不太成熟就不多介绍了, 想了解的可以参考官方文档: MarkItDown Plugins
使用
命令行
markitdown path-to-file.pdf > document.md
可以使用 -o
参数指定输出文件
markitdown path-to-file.pdf -o document.md
或者使用 Linux 管道符
cat path-to-file.pdf | markitdown
Python API
基本使用
from markitdown import MarkItDown
md = MarkItDown(enable_plugins=False) # Set to True to enable plugins
result = md.convert("test.xlsx")
print(result.text_content)
集成大模型
我使用阿里千问的 qwen-vl-max
模型
from markitdown import MarkItDown
from openai import OpenAI
client = OpenAI(base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", api_key="sk-xxx")
md = MarkItDown(llm_client=client, llm_model="qwen-vl-max", llm_prompt="识别图片中的内容, 直接返回原始内容, 不要任何多余处理, 不要返回任何多余的文本")
result = md.convert("a.jpg")
print(result.text_content)
Docker
MarkItDown 官方并没有提供构建好的 Docker 镜像, 需要自己下载源码构建
博主自己构建了一个镜像, 可以直接使用
docker run --rm -i linrepo/markitdown < ~/your-file.pdf > output.md
封装成服务
使用 FastAPI 框架将 MarkItDown 封装成一个服务
源码已开源到 GitHub: lintech1024/markitdown-server
开发环境准备
用 Dev Container 启动开发环境
配置文件
{
"name": "markitdown-server",
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
"customizations": {
"vscode": {
"settings": {
"window.newWindowProfile": "Default", // 新窗口使用的配置文件, 只能在"默 认配置文件"中设置
"files.autoSave": "afterDelay", // 自动保存文件
"editor.formatOnSave": true, // 保存时自动格式化
"files.simpleDialog.enable": true, // 简单对话框, ctrl+n 新建文件时不再弹出对话框
"editor.insertSpaces": true, // 使用空格代替制表符
"editor.tabSize": 4, // 缩进空格数
"editor.autoClosingDelete": "always", // 删除左括号时总是自动删除右括号
"editor.minimap.autohide": "mouseover", // 鼠标不在缩略图上时隐藏缩略图, 鼠标在缩略图上时显示缩略图
"terminal.integrated.cursorStyle": "underline", // 终端光标样式使用下划线
"explorer.confirmDelete": false, // 删除文件时不再弹出确认对话框
"explorer.confirmDragAndDrop": false, // 拖放移动文件或文件夹时不再弹出确认对话框
"vsicons.dontShowNewVersionMessage": true, // 不显示新版本消息
"workbench.iconTheme": "vscode-icons" // 使用 vscode-icons 图标主题
},
"extensions": [
"mhutchie.git-graph", // Git 图形化视图
"eamodio.gitlens", // Git 增强工具, 主要为了能显示代码作者
"ms-python.vscode-pylance", // Python 语言支持
"vscode-icons-team.vscode-icons", // VSCode 图标主题
"github.copilot", // GitHub Copilot, AI 代码补全
"github.copilot-chat" // GitHub Copilot Chat, AI 聊天助手
]
}
},
"containerEnv": {
"PIP_INDEX_URL": "https://mirrors.aliyun.com/pypi/simple/",
"PIP_TRUSTED_HOST": "mirrors.aliyun.com"
},
"postCreateCommand": "bash scripts/install.sh",
"postStartCommand": "uvicorn main:app --reload",
"forwardPorts": [8000]
}
# 使用阿里源
sudo sed -i "s@deb.debian.org@mirrors.aliyun.com@g" /etc/apt/sources.list
# 更新 apt 并安装 ffmpeg
sudo apt update && sudo apt upgrade -y
sudo apt install -y ffmpeg
# sudo apt install -y exiftool
# 更新 pip 并安装 markitdown
pip install -U pip && pip install markitdown[all]
# 安装 fastapi
pip install fastapi uvicorn[standard] python-multipart
API 接口
上传文件并让 MarkItDown 转换
@app.post("/uploadfile/")
async def convert_file(file: UploadFile):
try:
contents = await file.read()
converter = MarkItDown()
result = converter.convert(io.BytesIO(contents))
return Response(content=result.text_content)
except Exception as e:
print(f"Error during conversion: {str(e)}")
raise HTTPException(status_code=500, detail=f"转换失败: {str(e)}")
MarkItDown 支持直接转换网页, 再编写一个转网页接口
@app.post("/convertPage/")
async def convert_page(url: str):
try:
converter = MarkItDown()
result = converter.convert(url)
return Response(content=result.text_content)
except Exception as e:
print(f"Error during conversion: {str(e)}")
raise HTTPException(status_code=500, detail=f"转换失败: {str(e)}")
前端页面
直接让AI帮我们写前端页面, 效果是真不错 👍
页面代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文档转Markdown</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background-color: #f5f7fa;
color: #333;
line-height: 1.6;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.container {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 30px;
margin-top: 20px;
}
header {
text-align: center;
margin-bottom: 30px;
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
}
.description {
color: #7f8c8d;
max-width: 600px;
margin: 0 auto;
}
.url-converter {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.url-converter h2 {
text-align: center;
margin-bottom: 10px;
color: #2c3e50;
}
.url-input-group {
display: flex;
gap: 10px;
margin-top: 10px;
}
.url-input {
flex: 1;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
outline: none;
transition: border-color 0.3s;
}
.url-input:focus {
border-color: #3498db;
}
.upload-area {
border: 2px dashed #3498db;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
background-color: #f8f9fa;
transition: all 0.3s ease;
cursor: pointer;
margin-bottom: 20px;
}
.upload-area:hover,
.upload-area.dragover {
background-color: #e3f2fd;
border-color: #2980b9;
}
.upload-icon {
font-size: 48px;
color: #3498db;
margin-bottom: 15px;
}
.upload-text {
font-size: 18px;
color: #2c3e50;
margin-bottom: 10px;
}
.upload-hint {
color: #7f8c8d;
font-size: 14px;
}
.formats-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-top: 15px;
}
.format-tag {
background-color: #e1f5fe;
color: #0288d1;
padding: 3px 10px;
border-radius: 15px;
font-size: 12px;
font-weight: bold;
}
.file-input {
display: none;
}
.btn {
background-color: #3498db;
color: white;
border: none;
padding: 12px 24px;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: inline-block;
margin-top: 10px;
}
.btn:hover {
background-color: #2980b9;
}
.btn:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.status {
margin-top: 20px;
padding: 15px;
border-radius: 5px;
display: none;
}
.status.processing {
background-color: #e3f2fd;
color: #1976d2;
display: block;
}
.status.success {
background-color: #d4edda;
color: #155724;
display: block;
}
.status.error {
background-color: #f8d7da;
color: #721c24;
display: none;
}
.progress-container {
width: 100%;
height: 10px;
background-color: #e9ecef;
border-radius: 5px;
margin-top: 15px;
overflow: hidden;
display: none;
}
.progress-bar {
height: 100%;
background-color: #28a745;
width: 0%;
transition: width 0.3s;
}
.file-info {
margin-top: 15px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 5px;
display: none;
}
.features {
display: flex;
justify-content: space-between;
margin-top: 30px;
flex-wrap: wrap;
gap: 20px;
}
.feature {
flex: 1;
min-width: 250px;
padding: 15px;
text-align: center;
background: #f8f9fa;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.feature-icon {
font-size: 28px;
color: #3498db;
margin-bottom: 10px;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #3498db;
animation: spin 1s ease-in-out infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 600px) {
.container {
padding: 20px 15px;
}
.upload-area {
padding: 30px 15px;
}
.features {
flex-direction: column;
}
.feature {
min-width: 100%;
}
.url-input-group {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>文档转Markdown</h1>
<p class="description">上传PDF、Office文档等文件, 一键转换为结构完整的Markdown格式, 支持拖拽上传</p>
</header>
<div class="upload-area" id="dropZone">
<div class="upload-icon">📄</div>
<div class="upload-text">点击选择文件或拖拽到此处</div>
<div class="upload-hint">支持多种格式, 最大支持50MB</div>
<div class="formats-list">
<span class="format-tag">PDF</span>
<span class="format-tag">Word</span>
<span class="format-tag">Excel</span>
<span class="format-tag">PPT</span>
<span class="format-tag">TXT</span>
<span class="format-tag">HTML</span>
</div>
<input type="file" class="file-input" id="fileInput" accept=".pdf,.docx,.xlsx,.xls,.pptx,.txt,.html" />
<button class="btn" id="browseBtn">选择文件</button>
</div>
<div class="file-info" id="fileInfo">
<div>已选择: <span id="fileName"></span></div>
<div>大小: <span id="fileSize"></span></div>
</div>
<!-- URL转换区域移动到文档转换区域下方 -->
<div class="url-converter">
<h2>网页转Markdown</h2>
<p class="description">输入网页URL地址, 一键转换为Markdown格式</p>
<div class="url-input-group">
<input type="url" class="url-input" id="urlInput" placeholder="https://example.com" required>
<button class="btn" id="convertUrlBtn">转换网页</button>
</div>
</div>
<div class="status processing" id="processingStatus" style="display: none;">
<span class="spinner"></span>正在转换文档,请稍候...
</div>
<div class="progress-container" id="progressContainer">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="status success" id="successStatus" style="display: none;">
转换成功!正在下载...
</div>
<div class="status error" id="errorStatus" style="display: none;">
转换失败:未知错误
</div>
<!-- 更新features区域,强调支持转换页面 -->
<div class="features">
<div class="feature">
<div class="feature-icon">🔄</div>
<h3>智能转换</h3>
<p>保留文档结构, 准确转换为Markdown格式</p>
</div>
<div class="feature">
<div class="feature-icon">🌐</div>
<h3>网页与文档转换</h3>
<p>支持网页和文档一键转换为Markdown格式</p>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// 原有文件上传相关元素
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const browseBtn = document.getElementById('browseBtn');
const fileInfo = document.getElementById('fileInfo');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
// 新增URL转换相关元素
const urlInput = document.getElementById('urlInput');
const convertUrlBtn = document.getElementById('convertUrlBtn');
// 状态元素(共用)
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const processingStatus = document.getElementById('processingStatus');
const successStatus = document.getElementById('successStatus');
const errorStatus = document.getElementById('errorStatus');
// 确保所有状态默认隐藏
processingStatus.style.display = 'none';
successStatus.style.display = 'none';
errorStatus.style.display = 'none';
progressContainer.style.display = 'none';
fileInfo.style.display = 'none';
// ============= URL转换功能 =============
// URL转换按钮点击事件
convertUrlBtn.addEventListener('click', function () {
const url = urlInput.value.trim();
if (!url) {
showError('请输入有效的URL地址');
return;
}
// 验证URL格式
try {
new URL(url);
} catch (e) {
showError('请输入有效的URL地址');
return;
}
convertUrl(url);
});
// 处理URL转换请求
function convertUrl(url) {
// 重置状态
processingStatus.style.display = 'block';
successStatus.style.display = 'none';
errorStatus.style.display = 'none';
progressContainer.style.display = 'none'; // URL转换不需要进度条
const xhr = new XMLHttpRequest();
xhr.onload = function () {
if (xhr.status === 200) {
// 处理成功响应
processingStatus.style.display = 'none';
successStatus.style.display = 'block';
// 创建下载链接
const blob = new Blob([xhr.response], { type: 'text/markdown' });
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
// 从URL生成文件名
try {
const domain = new URL(url).hostname;
// 清理域名中的特殊字符
let safeName = domain.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '');
// 如果清理后为空,使用默认名称
if (!safeName) safeName = 'webpage';
a.download = safeName + '.md';
} catch (e) {
a.download = 'webpage.md';
}
document.body.appendChild(a);
a.click();
// 清理
window.URL.revokeObjectURL(blobUrl);
document.body.removeChild(a);
} else {
processingStatus.style.display = 'none';
try {
const error = JSON.parse(xhr.responseText);
showError(error.detail || '转换失败');
} catch (e) {
showError('服务器错误');
}
}
};
xhr.onerror = function () {
processingStatus.style.display = 'none';
showError('网络错误');
};
// 发送请求到 /convertPage/ 接口
xhr.open('POST', `/convertPage/?url=${encodeURIComponent(url)}`);
xhr.responseType = 'blob'; // 重要:接收二进制响应
xhr.send();
}
// ============= 原有文件上传功能 =============
// 点击区域选择文件
dropZone.addEventListener('click', function (e) {
if (e.target !== fileInput) {
fileInput.click();
}
});
// 选择文件按钮
browseBtn.addEventListener('click', function (e) {
e.stopPropagation();
fileInput.click();
});
// 文件选择事件
fileInput.addEventListener('change', function () {
if (fileInput.files.length) {
handleFile(fileInput.files[0]);
}
});
// 拖拽事件
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, unhighlight, false);
});
function highlight() {
dropZone.classList.add('dragover');
}
function unhighlight() {
dropZone.classList.remove('dragover');
}
// 处理拖放的文件
dropZone.addEventListener('drop', function (e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length) {
handleFile(files[0]);
}
});
// 处理文件
function handleFile(file) {
// 验证文件大小 (50MB)
const maxSize = 50 * 1024 * 1024; // 50MB
if (file.size > maxSize) {
showError(`文件过大(最大支持${maxSize / 1024 / 1024}MB)`);
return;
}
// 显示文件信息
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
fileInfo.style.display = 'block';
// 上传文件
uploadFile(file);
}
// 上传文件
function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
// 重置状态
processingStatus.style.display = 'block';
successStatus.style.display = 'none';
errorStatus.style.display = 'none';
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
const xhr = new XMLHttpRequest();
// 进度事件
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
progressBar.style.width = percentComplete + '%';
}
};
xhr.onload = function () {
progressContainer.style.display = 'none';
if (xhr.status === 200) {
// 处理成功响应
processingStatus.style.display = 'none';
successStatus.style.display = 'block';
// 创建下载链接
const blob = new Blob([xhr.response], { type: 'text/markdown' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 关键修复:确保所有文档类型都以.md结尾
// 1. 移除原始文件名中的特殊字符
let safeName = file.name.replace(/[^a-zA-Z0-9\u4e00-\u9fa5._-]/g, '');
// 2. 移除任何现有扩展名
safeName = safeName.replace(/\.[^/.]+$/, "");
// 3. 添加.md扩展名
safeName = safeName + '.md';
a.download = safeName;
document.body.appendChild(a);
a.click();
// 清理
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
processingStatus.style.display = 'none';
try {
const error = JSON.parse(xhr.responseText);
showError(error.detail || '转换失败');
} catch (e) {
showError('服务器错误');
}
}
};
xhr.onerror = function () {
progressContainer.style.display = 'none';
processingStatus.style.display = 'none';
showError('网络错误');
};
// 发送请求
xhr.open('POST', '/uploadfile/');
xhr.responseType = 'blob'; // 重要:接收二进制响应
xhr.send(formData);
}
// 显示错误
function showError(message) {
errorStatus.textContent = '转换失败: ' + message;
errorStatus.style.display = 'block';
// 5秒后自动隐藏错误
setTimeout(() => {
errorStatus.style.display = 'none';
}, 5000);
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
});
</script>
</body>
</html>
简单起见, 就不用 nginx 部署前端页面了, 直接挂到 FastAPI 服务上
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
async def read_root(request: Request):
return Response(
content=open("static/index.html", "r", encoding="utf-8").read(),
media_type="text/html"
)
在浏览器访问 localhost:8000
打包成 Docker 镜像
Dockerfile 配置
FROM python:3.12-slim-bullseye
ENV DEBIAN_FRONTEND=noninteractive
ENV FFMPEG_PATH=/usr/bin/ffmpeg
ENV PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ \
PIP_TRUSTED_HOST=mirrors.aliyun.com \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
RUN sed -i "s@deb.debian.org@mirrors.aliyun.com@g" /etc/apt/sources.list
# Runtime dependency
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ffmpeg
# Cleanup
RUN rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
RUN pip --no-cache-dir install markitdown[all] fastapi uvicorn[standard] python-multipart
# Default USERID and GROUPID
ARG USERID=nobody
ARG GROUPID=nogroup
USER $USERID:$GROUPID
ENTRYPOINT [ "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
docker build . -t linrepo/markitdown-server:latest
Docker 运行服务
docker run -it --rm -p 8000:8000 linrepo/markitdown-server
在浏览器访问 localhost:8000
评价
MarkItDown 现在还处于 v0.1.3
版本, 项目不算成熟, 转换效果只能说属于能用, 期待后续继续迭代吧