后端 (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→入库→上传→校验)
92 lines
3.3 KiB
Python
92 lines
3.3 KiB
Python
"""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}
|