读取文件url
This commit is contained in:
34
README.md
34
README.md
@@ -1,10 +1,18 @@
|
|||||||
## DOCX 转换工具 MCP 服务器
|
## DOCX 转换 / 编辑 MCP 服务器
|
||||||
|
|
||||||
这是一个基于 MCP (Model Context Protocol) 的服务器,目前**只提供 HTML → DOCX** 的转换能力,底层通过 Pandoc 实现高质量排版。
|
这是一个基于 MCP (Model Context Protocol) 的服务器,目前提供两类能力:
|
||||||
|
|
||||||
|
- **DOCX 编辑工具**:基于 `mcp_docx.py`,支持列出图片、文本替换、关键字上色、图片替换(可直接使用 URL 作为输入)。
|
||||||
|
- **HTML → DOCX 转换工具**:基于 Pandoc,实现高质量排版。
|
||||||
|
|
||||||
### 功能
|
### 功能
|
||||||
|
|
||||||
- **html_to_docx_pandoc**:将包含 HTML 标签的文本转换为 DOCX 文件,支持引用模板、Lua 过滤器等高级格式控制。
|
- `list_docx_images`:列出 DOCX 中的图片信息,支持 `docx_path` 为本地路径或 HTTP/HTTPS URL。
|
||||||
|
- `edit_docx`:对 DOCX 进行编辑,支持:
|
||||||
|
- `input_docx` 为本地路径或 HTTP/HTTPS URL;
|
||||||
|
- `image_replacements[*].file` 为本地路径或 HTTP/HTTPS URL;
|
||||||
|
- 返回结果中包含 `output_path` 和可选的 `output_url`(见下文)。
|
||||||
|
- `html_to_docx_pandoc`:将包含 HTML 标签的文本转换为 DOCX 文件,支持引用模板、Lua 过滤器等高级格式控制。
|
||||||
|
|
||||||
### 安装依赖(本机运行)
|
### 安装依赖(本机运行)
|
||||||
|
|
||||||
@@ -24,7 +32,25 @@ pip install -r requirements.txt
|
|||||||
python mcp_docx_server.py
|
python mcp_docx_server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
在 MCP 客户端中连接该服务器后,会看到一个名为 `html_to_docx_pandoc` 的工具。
|
在 MCP 客户端中连接该服务器后,会看到上述三个工具。
|
||||||
|
|
||||||
|
#### 输出 URL(output_url)
|
||||||
|
|
||||||
|
如果你希望 `edit_docx` 返回一个可直接访问的 URL,需要:
|
||||||
|
|
||||||
|
- 在运行服务器前设置环境变量 `MCP_OUTPUT_BASE_URL`,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set MCP_OUTPUT_BASE_URL=http://localhost:8000/files/
|
||||||
|
```
|
||||||
|
|
||||||
|
或在类 Unix 系统中:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MCP_OUTPUT_BASE_URL="http://localhost:8000/files/"
|
||||||
|
```
|
||||||
|
|
||||||
|
然后确保你的 HTTP 服务器能在该前缀下提供生成的 DOCX 文件(默认逻辑是:`output_url = MCP_OUTPUT_BASE_URL + 文件名`)。
|
||||||
|
|
||||||
#### 方式二:使用 Docker 封装运行
|
#### 方式二:使用 Docker 封装运行
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
|
import urllib.parse
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from mcp.server.transport_security import TransportSecuritySettings
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
|
||||||
@@ -58,13 +61,60 @@ mcp = FastMCP(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_url(path: str) -> bool:
|
||||||
|
"""简单判断一个字符串是否为 HTTP/HTTPS URL。"""
|
||||||
|
return path.startswith("http://") or path.startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
def _download_to_temp(url: str, suffix: str = ".tmp") -> str:
|
||||||
|
"""
|
||||||
|
将远程 URL 下载到临时文件,返回本地临时路径。
|
||||||
|
|
||||||
|
调用方负责在使用完毕后删除该文件。
|
||||||
|
"""
|
||||||
|
resp = requests.get(url, stream=True, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=suffix)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
for chunk in resp.iter_content(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
except Exception:
|
||||||
|
# 出错时清理临时文件
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _build_output_url(abs_output_path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
根据环境变量 MCP_OUTPUT_BASE_URL 构造输出文件的 URL。
|
||||||
|
|
||||||
|
约定:
|
||||||
|
- MCP_OUTPUT_BASE_URL 形如: http://host:port/files/
|
||||||
|
- 最终 URL = MCP_OUTPUT_BASE_URL.rstrip('/') + '/' + 文件名
|
||||||
|
"""
|
||||||
|
base = os.getenv("MCP_OUTPUT_BASE_URL")
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
|
||||||
|
filename = os.path.basename(abs_output_path)
|
||||||
|
return base.rstrip("/") + "/" + filename
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def list_docx_images(docx_path: str) -> List[Dict[str, Any]]:
|
async def list_docx_images(docx_path: str) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
列出指定 DOCX 文件中的所有图片信息。
|
列出指定 DOCX 文件中的所有图片信息。
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
- docx_path: DOCX 文件的路径(相对或绝对)
|
- docx_path: DOCX 文件的路径(相对或绝对),也可以是 HTTP/HTTPS URL。
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
- 图片信息列表,每一项包含:
|
- 图片信息列表,每一项包含:
|
||||||
@@ -74,14 +124,29 @@ async def list_docx_images(docx_path: str) -> List[Dict[str, Any]]:
|
|||||||
- docpr_name: Word 内部的图片名称
|
- docpr_name: Word 内部的图片名称
|
||||||
- width_cm / height_cm: 近似尺寸(厘米),可能为 None
|
- width_cm / height_cm: 近似尺寸(厘米),可能为 None
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(docx_path):
|
tmp_file: Optional[str] = None
|
||||||
raise FileNotFoundError(f"DOCX 文件不存在: {docx_path}")
|
try:
|
||||||
|
local_path = docx_path
|
||||||
|
if _is_url(docx_path):
|
||||||
|
parsed = urllib.parse.urlparse(docx_path)
|
||||||
|
ext = os.path.splitext(parsed.path)[1] or ".docx"
|
||||||
|
tmp_file = _download_to_temp(docx_path, suffix=ext)
|
||||||
|
local_path = tmp_file
|
||||||
|
|
||||||
imgs = get_images_info(docx_path)
|
if not os.path.exists(local_path):
|
||||||
# 为了避免泄露容器内部路径,屏蔽 abs_path 字段
|
raise FileNotFoundError(f"DOCX 文件不存在: {docx_path}")
|
||||||
for img in imgs:
|
|
||||||
img.pop("abs_path", None)
|
imgs = get_images_info(local_path)
|
||||||
return imgs
|
# 为了避免泄露容器内部路径,屏蔽 abs_path 字段
|
||||||
|
for img in imgs:
|
||||||
|
img.pop("abs_path", None)
|
||||||
|
return imgs
|
||||||
|
finally:
|
||||||
|
if tmp_file and os.path.exists(tmp_file):
|
||||||
|
try:
|
||||||
|
os.remove(tmp_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -100,7 +165,7 @@ async def edit_docx(
|
|||||||
- 替换指定序号的图片
|
- 替换指定序号的图片
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
- input_docx: 输入 DOCX 文件路径
|
- input_docx: 输入 DOCX 文件路径,或 HTTP/HTTPS URL
|
||||||
- output_docx: 输出 DOCX 文件路径
|
- output_docx: 输出 DOCX 文件路径
|
||||||
- replacements: 文本替换规则列表,例如:
|
- replacements: 文本替换规则列表,例如:
|
||||||
[
|
[
|
||||||
@@ -112,56 +177,100 @@ async def edit_docx(
|
|||||||
{\"index\": 1, \"file\": \"new_chart.png\"},
|
{\"index\": 1, \"file\": \"new_chart.png\"},
|
||||||
{\"index\": 2, \"file\": \"new_photo.jpg\"}
|
{\"index\": 2, \"file\": \"new_photo.jpg\"}
|
||||||
]
|
]
|
||||||
|
其中 file 字段同样可以是本地路径或 HTTP/HTTPS URL。
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
- {\"output_path\": 生成的 DOCX 绝对路径}
|
- {
|
||||||
|
\"output_path\": 生成的 DOCX 绝对路径,
|
||||||
|
\"output_url\": 如果配置了 MCP_OUTPUT_BASE_URL,则为可访问该文件的 URL,否则为 null
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(input_docx):
|
tmp_input: Optional[str] = None
|
||||||
raise FileNotFoundError(f"输入 DOCX 文件不存在: {input_docx}")
|
tmp_images: List[str] = []
|
||||||
|
|
||||||
if replacements is None:
|
try:
|
||||||
replacements = []
|
local_input = input_docx
|
||||||
if image_replacements is None:
|
if _is_url(input_docx):
|
||||||
image_replacements = []
|
parsed = urllib.parse.urlparse(input_docx)
|
||||||
|
ext = os.path.splitext(parsed.path)[1] or ".docx"
|
||||||
|
tmp_input = _download_to_temp(input_docx, suffix=ext)
|
||||||
|
local_input = tmp_input
|
||||||
|
|
||||||
# 解析文本替换与颜色关键字(复用 CLI 逻辑)
|
if not os.path.exists(local_input):
|
||||||
rep_pairs = []
|
raise FileNotFoundError(f"输入 DOCX 文件不存在: {input_docx}")
|
||||||
color_keywords = []
|
|
||||||
for item in replacements:
|
|
||||||
old = item.get("old")
|
|
||||||
new_raw = item.get("new")
|
|
||||||
if not old:
|
|
||||||
continue
|
|
||||||
if new_raw is None:
|
|
||||||
new_raw = ""
|
|
||||||
new_plain, spans = _parse_span_replacement(new_raw)
|
|
||||||
rep_pairs.append((old, new_plain))
|
|
||||||
color_keywords.extend(spans)
|
|
||||||
|
|
||||||
# 处理图片替换参数
|
if replacements is None:
|
||||||
img_pairs = []
|
replacements = []
|
||||||
for item in image_replacements:
|
if image_replacements is None:
|
||||||
try:
|
image_replacements = []
|
||||||
idx = int(item.get("index"))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
path = item.get("file")
|
|
||||||
if not path:
|
|
||||||
continue
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise FileNotFoundError(f"图片文件不存在: {path}")
|
|
||||||
img_pairs.append((idx, path))
|
|
||||||
|
|
||||||
# 复用原始处理函数
|
# 解析文本替换与颜色关键字(复用 CLI 逻辑)
|
||||||
process(
|
rep_pairs = []
|
||||||
input_docx=input_docx,
|
color_keywords = []
|
||||||
output_docx=output_docx,
|
for item in replacements:
|
||||||
replacements=rep_pairs,
|
old = item.get("old")
|
||||||
image_replacements=img_pairs,
|
new_raw = item.get("new")
|
||||||
color_keywords=color_keywords,
|
if not old:
|
||||||
)
|
continue
|
||||||
|
if new_raw is None:
|
||||||
|
new_raw = ""
|
||||||
|
new_plain, spans = _parse_span_replacement(new_raw)
|
||||||
|
rep_pairs.append((old, new_plain))
|
||||||
|
color_keywords.extend(spans)
|
||||||
|
|
||||||
return {"output_path": os.path.abspath(output_docx)}
|
# 处理图片替换参数(支持本地路径或 URL)
|
||||||
|
img_pairs = []
|
||||||
|
for item in image_replacements:
|
||||||
|
try:
|
||||||
|
idx = int(item.get("index"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = item.get("file")
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_img = path
|
||||||
|
if _is_url(path):
|
||||||
|
parsed = urllib.parse.urlparse(path)
|
||||||
|
ext = os.path.splitext(parsed.path)[1] or ""
|
||||||
|
suffix = ext if ext else ".img"
|
||||||
|
tmp_img = _download_to_temp(path, suffix=suffix)
|
||||||
|
tmp_images.append(tmp_img)
|
||||||
|
local_img = tmp_img
|
||||||
|
|
||||||
|
if not os.path.exists(local_img):
|
||||||
|
raise FileNotFoundError(f"图片文件不存在: {path}")
|
||||||
|
|
||||||
|
img_pairs.append((idx, local_img))
|
||||||
|
|
||||||
|
# 复用原始处理函数
|
||||||
|
process(
|
||||||
|
input_docx=local_input,
|
||||||
|
output_docx=output_docx,
|
||||||
|
replacements=rep_pairs,
|
||||||
|
image_replacements=img_pairs,
|
||||||
|
color_keywords=color_keywords,
|
||||||
|
)
|
||||||
|
|
||||||
|
abs_out = os.path.abspath(output_docx)
|
||||||
|
return {
|
||||||
|
"output_path": abs_out,
|
||||||
|
"output_url": _build_output_url(abs_out),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if tmp_input and os.path.exists(tmp_input):
|
||||||
|
try:
|
||||||
|
os.remove(tmp_input)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for p in tmp_images:
|
||||||
|
if os.path.exists(p):
|
||||||
|
try:
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ mcp>=1.0.0
|
|||||||
python-docx>=1.1.0
|
python-docx>=1.1.0
|
||||||
lxml>=5.0.0
|
lxml>=5.0.0
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
|
requests>=2.0.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user