Initial commit: book-mcp 教辅处理 MCP server + Arco Vue 管理端

后端 (book_mcp_server.py, 335 行, 15 个 MCP 工具):
- create_book / add_document / mark_html_ready / mark_json_ready / mark_failed
- update_book_status / soft_delete_book / retry_failed
- get_book / list_books / get_document / list_documents / get_document_url
- upload_to_rustfs / health
- 走 JSON-RPC + stateless_http + json_response(前端可直接调)
- launchd 守护 / 6 秒拉起(macOS)
- systemd 守护脚本(Linux/codelab)

存储:
- MongoDB (mongo:8.0 容器) - book/book_document/document_file 三表
- RustFS 对象存储(fs.code-lab.cn/scan/)- book/<id>/docs/<doc_id>/{docx,html,json}
- 幂等设计: zip_sha256 唯一 + (book_id+original_path) 唯一

前端 (Vue 3 + TS + Arco Design):
- 列表页 /book/list - 搜索 + 状态过滤 + 进度
- 详情页 /book/:id - 文档折叠 + 文件下载
- MCP 客户端 src/api/mcp.ts (JSON-RPC over fetch)

部署:
- deploy/install.sh (codelab 一键装 backend + systemd)
- deploy/install-frontend.sh (vite dev / 生产构建)
- docs/deploy.md (运维手册)

测试:
- tests/test_basic.py (sanity 7 项)
- tests/test_e2e.py (完整 e2e: docx→HTML→入库→上传→校验)
This commit is contained in:
2026-10-11 18:03:45 +08:00
parent f7fb571bce
commit 9b0910ccb1
160 changed files with 23628 additions and 3 deletions

View File

@@ -0,0 +1,18 @@
"""wordparse 子包:docx → HTML 工具集
子模块:
cleaner — 修脏 docx XML
post — HTTP 调 docx4j
render — HTML 美化(MathJax / 样式 / 导航 / 统计)
convert — 高级入口(convert_one / batch_convert)
"""
from .cleaner import clean_docx
from .post import post_docx, health
from .render import clean_html, stats, copy_mathjax_to
from .convert import convert_one, batch_convert
__all__ = [
'clean_docx', 'post_docx', 'health',
'clean_html', 'stats', 'copy_mathjax_to',
'convert_one', 'batch_convert',
]

View File

@@ -0,0 +1,76 @@
"""docx XML 清洗:去脏值(单位 / NaN / 浮点)以避开 docx4j 8.3.9 JAXB 解析失败。
参考 wordparse_server.py 的 _clean_docx_xml,独立成模块方便测试。
"""
import re
import os
import zipfile
XML_DECL = re.compile(r'<\?xml[^?]*\?>')
def _fix_text(txt: str) -> str:
"""修三类问题:
1. 带单位的数字属性:"210mm" / "25.4cm" → "0"
2. NaN / Inf / -Inf / 空字符串 → "0"
3. 浮点属性 → 整数(JAXB BigInteger 不接小数)
"""
decl = XML_DECL.search(txt)
decl_str = decl.group(0) if decl else '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
if decl:
txt = XML_DECL.sub('__XMLDECL__', txt, count=1)
# 1. 单位
txt = re.sub(r'="(-?[0-9]*\.?[0-9]+)\s*(mm|cm|pt|px|in|em|%)"', r'="0"', txt, flags=re.I)
# 2. NaN / Inf
txt = re.sub(r'="(NaN|nan|Inf|-Inf|inf|-inf)"', '="0"', txt, flags=re.I)
# 3. 空
txt = re.sub(r'=""', '="0"', txt)
# 4. 浮点 → 整数
def _num(m):
try:
f = float(m.group(1))
if f != f or f == float('inf') or f == float('-inf'):
return '="0"'
return f'="{int(f)}"'
except Exception:
return m.group(0)
txt = re.sub(r'="(-?[0-9]+\.[0-9]+(?:[eE][-+]?[0-9]+)?)"', _num, txt)
if decl:
txt = txt.replace('__XMLDECL__', decl_str, 1)
return txt
def clean_docx(path: str) -> str:
"""清洗 docx 里的 word/document.xml,幂等。失败回退原文件。"""
marker = path + '.cleaned'
if os.path.exists(marker):
return path
try:
with zipfile.ZipFile(path, 'r') as zin:
entries = []
changed = False
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == 'word/document.xml':
raw = data.decode('utf-8', errors='replace')
new = _fix_text(raw)
if new != raw:
data = new.encode('utf-8')
changed = True
entries.append((item, data))
if not changed:
open(marker, 'w').close()
return path
tmp = path + '.tmp'
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout:
for item, data in entries:
zout.writestr(item, data)
os.replace(tmp, path)
open(marker, 'w').close()
return path
except Exception:
return path

View File

@@ -0,0 +1,91 @@
"""docx → HTML 高层入口(集成清洗 + 转码 + 美化)"""
import os
import re
import zipfile
from typing import Callable
from .cleaner import clean_docx
from .post import post_docx
from .render import clean_html, stats as render_stats, copy_mathjax_to
def _zip_name(info) -> str:
"""zip 条目名:带 UTF-8 标志位(0x800)直接用;否则按 cp437→GBK 还原"""
if info.flag_bits // 2048 % 2:
return info.filename
try:
return info.filename.encode('cp437').decode('gbk')
except Exception:
return info.filename
def convert_one(docx_path: str, auto_clean: bool = True) -> dict:
"""单份 docx → HTML 字符串 + stats
Returns: {ok, html, stats, error?}
"""
try:
if auto_clean:
docx_path = clean_docx(docx_path)
raw = post_docx(docx_path)
html = clean_html(raw, mathjax=True)
return {'ok': True, 'html': html, 'stats': render_stats(html)}
except Exception as e:
return {'ok': False, 'error': str(e)[:300]}
def batch_convert(path: str, out_dir: str, auto_clean: bool = True,
progress_cb: Callable | None = None) -> dict:
"""目录或 zip → 每份 docx 转 HTML 落 out_dir
Returns: {count, errors[{file, error}], files[{name, group, stats}]}
"""
os.makedirs(out_dir, exist_ok=True)
copy_mathjax_to(out_dir)
items: list[tuple[str, str]] = []
if os.path.isdir(path):
for f in sorted(os.listdir(path)):
if f.lower().endswith('.docx'):
items.append((f, os.path.join(path, f)))
else:
with zipfile.ZipFile(path) as z:
for i in z.infolist():
if i.is_dir() or i.filename.startswith('__MACOSX/') or not i.filename.lower().endswith('.docx'):
continue
parts = [p for p in _zip_name(i).split('/') if p]
rel = '/'.join(parts[1:]) if len(parts) > 1 else parts[-1]
# 不解压到磁盘,直接 post bytes
items.append((rel, zipfile.ZipFile(path).read(i)))
files, errors = [], []
for rel, src in items:
try:
if isinstance(src, bytes):
# 写临时文件
tmp = f'/tmp/_wpc_{abs(hash(rel)) & 0xFFFFFFFF:x}.docx'
with open(tmp, 'wb') as f:
f.write(src)
src = tmp
r = convert_one(src, auto_clean=auto_clean)
if r['ok']:
html = r['html']
rel_html = re.sub(r'\.docx$', '', rel, flags=re.I) + '.html'
out = os.path.join(out_dir, rel_html)
os.makedirs(os.path.dirname(out) or out_dir, exist_ok=True)
with open(out, 'w', encoding='utf-8') as f:
f.write(html)
files.append({
'name': rel_html,
'group': os.path.dirname(rel_html),
**r['stats'],
})
else:
errors.append({'file': rel, 'error': r.get('error', '?')})
except Exception as e:
errors.append({'file': rel, 'error': str(e)[:200]})
if progress_cb:
progress_cb(len(files) + len(errors), len(items))
return {'count': len(files), 'errors': errors, 'files': files}

View File

@@ -0,0 +1,46 @@
"""docx4j HTTP 客户端 — POST 一份 docx,返回 HTML 字符串"""
import os
import uuid
import json
import urllib.request
from db.database import load_config
def _config():
return load_config()['wordparse']
def post_docx(path: str, timeout: int | None = None) -> str:
"""返回 HTML 字符串(原始,未做 MathJax/导航注入)"""
cfg = _config()
url = cfg['url']
timeout = timeout or cfg.get('timeout', 600)
boundary = '----WP' + uuid.uuid4().hex
with open(path, 'rb') as f:
data = f.read()
fn = os.path.basename(path)
body = (
f'--{boundary}\r\n'.encode()
+ f'Content-Disposition: form-data; name="file"; filename="{fn}"\r\n'.encode('utf-8')
+ b'Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document\r\n\r\n'
+ data
+ b'\r\n' + f'--{boundary}--\r\n'.encode()
)
req = urllib.request.Request(url, data=body, headers={
'Content-Type': f'multipart/form-data; boundary={boundary}',
'Content-Length': str(len(body)),
})
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode('utf-8'))
return resp.get('content') or ''
def health() -> bool:
cfg = _config()
try:
with urllib.request.urlopen(cfg['health_url'], timeout=5) as r:
j = json.loads(r.read().decode('utf-8'))
return j.get('status') == 'ok'
except Exception:
return False

View File

@@ -0,0 +1,70 @@
"""HTML 美化:注入 MathJax / 样式 / 导航 / 统计"""
import re
import os
import shutil
import urllib.request
# --- 常量 ---
NAV = ('<div class="nav" style="position:sticky;top:0;background:#f8fafc;border-bottom:1px solid #e2e8f0;'
'padding:8px 0;margin:0 0 16px;font-size:14px"><a href="index.html" '
'style="color:#2563eb;text-decoration:none">← 返回索引</a></div>')
STYLE = ('<meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>body{max-width:900px;margin:0 auto;padding:24px 18px;font-family:-apple-system,'
'"PingFang SC","Microsoft YaHei",serif;font-size:16px;line-height:1.9;color:#1a1a1a;background:#fff}'
'img{max-width:100%;height:auto}table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:4px 6px}'
'mjx-container{overflow-x:auto;overflow-y:hidden;max-width:100%}</style>')
MATHJAX_TAG = ('<script>window.MathJax={tex:{inlineMath:[["\\\\(","\\\\)"]],displayMath:[["\\\\[","\\\\]"]]},'
'svg:{fontCache:"local"},options:{enableMenu:false}};</script>'
'<script src="tex-svg.js"></script>')
ASSETS_DIR = os.path.expanduser('~/.hermes/mcp/assets')
MATHJAX_FILE = os.path.join(ASSETS_DIR, 'tex-svg.js')
MATHJAX_MIRROR = 'https://registry.npmmirror.com/mathjax/3.2.2/files/es5/tex-svg.js'
def _fix_math(m):
inner = (m.group(1).replace('&nbsp;', ' ').replace('&amp;', '&')
.replace('&lt;', '<').replace('&gt;', '>').replace('&#160;', ' '))
return r'\(' + re.sub(r'[ \t]{2,}', ' ', inner).strip() + r'\)'
def clean_html(content: str, mathjax: bool = True) -> str:
"""原始 HTML → 加 MathJax + 导航 + 样式"""
c = re.sub(r'^<\?xml[^>]*\?>\s*', '', content)
c = re.sub(r'<!DOCTYPE[^>]*>', '<!DOCTYPE html>', c, count=1)
c = re.sub(r'\\\((.*?)\\\)', _fix_math, c, flags=re.S)
inject = STYLE + (MATHJAX_TAG if mathjax else '')
c = c.replace('</head>', inject + '</head>', 1) if '</head>' in c else inject + c
m = re.search(r'<body[^>]*>', c, re.I)
c = (c[:m.end()] + NAV + c[m.end():]) if m else (NAV + c)
return c
def stats(html: str) -> dict:
"""HTML 统计:字符 / 公式 / 图片 / 未包裹 LaTeX"""
stripped = re.sub(r'\\\(.*?\\\)', '', html, flags=re.S)
return {
'chars': len(html),
'formulas': len(re.findall(r'\\\(', html)),
'images': html.count('<img'),
'unwrapped_latex': len(re.findall(
r'\\(?:dfrac|frac|sqrt|left|begin\{array)', stripped)),
}
def ensure_mathjax() -> str:
"""下载/复用 MathJax 离线文件 → 返回路径"""
os.makedirs(ASSETS_DIR, exist_ok=True)
if not os.path.exists(MATHJAX_FILE) or os.path.getsize(MATHJAX_FILE) < 500_000:
urllib.request.urlretrieve(MATHJAX_MIRROR, MATHJAX_FILE)
return MATHJAX_FILE
def copy_mathjax_to(out_dir: str) -> str:
"""把 MathJax 复制到 out_dir,返回目标路径"""
src = ensure_mathjax()
dst = os.path.join(out_dir, 'tex-svg.js')
shutil.copy(src, dst)
return dst