來源檔名含空格或不平衡括號時,convert 產出的 Markdown 圖片連結會失效—— 圖檔明明存在於磁碟,整行卻退化成純文字(CommonMark 對未包住的空格與不平衡 括號視為分隔符)。用真 CommonMark parser 驗過:中文與成對括號正常,空格與 落單括號會壞。 - convert.py / from_docx.py / from_pdf.py / from_pptx.py:四處圖片連結目標 一律以 <> 包住(),同時涵蓋空格與不平衡括號。 - selfcheck.py:新增 assert_links_ok()——以 markdown_it 真 render,斷言圖片 連結數 == <img> 數且目標檔存在,取代原本只檢查 "![" 字串在不在的表面斷言。 測資補內嵌圖片(docx/pdf/pptx 各一路徑)、檔名改含空格與不平衡括號。 - requirements.txt:明確宣告 markdown-it-py(原為 fastmcp 傳遞依賴,自檢直接 使用,宣告以免上游調整依賴樹後自檢失效)。 - AGENTS.md §13.4:新增「斷言要驗結果可用,不是驗字串存在」規則。 驗證:四個自檢全通過;移除任一處 <> 修法,assert_links_ok 即失敗。 ingest / lint / search 在含括號檔名下全鏈路已另行驗證正常(source_ref 走 YAML 純量,不受影響)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""統一轉換入口:依副檔名/URL 分派轉換器,登記 SHA-256 至 raw/manifest.json。
|
||
|
||
流程(AGENTS.md §6.1、§9):
|
||
原始檔存入 raw/originals/ 並登記 hash → 轉換 → Markdown 存入 raw/converted/
|
||
並登記 hash 與原始檔對應 → 之後攝入管線只讀 converted 層。
|
||
|
||
用法:python tools/convert/convert.py <檔案路徑或URL>... [--vision] [--dry-run] [--root DIR]
|
||
支援 .docx/.xlsx/.pdf/.pptx/.html 與 URL;舊版 .doc/.xls/.ppt 經 LibreOffice 升版;
|
||
--vision 讓掃描/圖片型 PDF 走本地 vision 模型轉錄(見 from_vision.py)。
|
||
"""
|
||
import argparse
|
||
import datetime
|
||
import hashlib
|
||
import json
|
||
import pathlib
|
||
import re
|
||
import shutil
|
||
import sys
|
||
import tempfile
|
||
import traceback
|
||
import urllib.parse
|
||
|
||
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
||
import from_docx
|
||
import from_legacy
|
||
import from_pdf
|
||
import from_pptx
|
||
import from_web
|
||
import from_xlsx
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||
CONVERTERS = {
|
||
".docx": (from_docx.convert, "from_docx", "docx"),
|
||
".xlsx": (from_xlsx.convert, "from_xlsx", "xlsx"),
|
||
".pdf": (from_pdf.convert, "from_pdf", "pdf"),
|
||
".pptx": (from_pptx.convert, "from_pptx", "pptx"),
|
||
}
|
||
# 舊版 OLE 二進位 → 現代格式(經 LibreOffice 升版後走上表對應轉換器)。
|
||
# <!-- ASSUMPTION: media_type 記真實舊格式(doc/xls/ppt),擴充 §9 的列舉;
|
||
# converter 記為 "from_docx+libreoffice" 等,保留兩段式 provenance。 -->
|
||
LEGACY_MAP = {".doc": ".docx", ".xls": ".xlsx", ".ppt": ".pptx"}
|
||
|
||
|
||
def _now():
|
||
return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
||
|
||
|
||
def _slug(s):
|
||
s = re.sub(r"[^a-z0-9一-鿿]+", "-", s.lower()).strip("-")
|
||
return s or "page"
|
||
|
||
|
||
def load_manifest(root):
|
||
p = root / "raw" / "manifest.json"
|
||
m = json.loads(p.read_text(encoding="utf-8"))
|
||
if m.get("version") != 1 or "files" not in m:
|
||
raise SystemExit(f"manifest 結構不符:{p}")
|
||
return m
|
||
|
||
|
||
def save_manifest(root, m):
|
||
p = root / "raw" / "manifest.json"
|
||
tmp = p.with_suffix(".json.tmp")
|
||
tmp.write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
tmp.replace(p) # 原子替換,避免寫壞 manifest
|
||
|
||
|
||
def _find_by_sha(m, sha):
|
||
for path, e in m["files"].items():
|
||
if e["kind"] == "original" and e["sha256"] == sha:
|
||
return path
|
||
return None
|
||
|
||
|
||
def _dest_name(dir_, name, sha):
|
||
"""既有同名檔且內容不同時,附 hash 前 8 碼避免覆蓋(raw 不可變)。"""
|
||
p = dir_ / name
|
||
if p.exists() and hashlib.sha256(p.read_bytes()).hexdigest() != sha:
|
||
stem, ext = pathlib.Path(name).stem, pathlib.Path(name).suffix
|
||
return f"{stem}-{sha[:8]}{ext}"
|
||
return name
|
||
|
||
|
||
def _register_pair(m, root, orig_rel, orig_entry, md_rel, md_text, converter, dry):
|
||
if not md_text.strip():
|
||
# 空轉換結果幾乎都是失敗(如純圖片 docx);登記前示警,避免空頁靜默入庫
|
||
print(f"warning: {orig_rel} 轉換結果為空白,請人工檢查來源", file=sys.stderr)
|
||
if not dry:
|
||
md_path = root / md_rel
|
||
md_path.write_text(md_text, encoding="utf-8")
|
||
m["files"][orig_rel] = orig_entry
|
||
m["files"][md_rel] = {
|
||
"kind": "converted",
|
||
"sha256": hashlib.sha256(md_text.encode("utf-8")).hexdigest(),
|
||
"original_path": orig_rel,
|
||
"original_sha256": orig_entry["sha256"],
|
||
"converter": converter,
|
||
"converted_at": _now(),
|
||
}
|
||
print(f"converted: {orig_rel} -> {md_rel}" + (" [dry-run]" if dry else ""))
|
||
|
||
|
||
def _vision_pdf(root, pdf_path, assets, conv_name):
|
||
"""--vision fallback(AGENTS.md §3 稽核原則):needs_ocr 的 PDF 逐頁 render →
|
||
本地 vision 模型轉錄。回傳 (markdown, status, converter)。
|
||
|
||
成功 → ('...', 'ok', 'from_vision');vision 執行期失敗 → 降級回 needs_ocr(不遺失檔案)。
|
||
設定錯誤(未設 vision / :cloud)由 from_vision 拋 SystemExit,向上傳播不靜默降級。
|
||
"""
|
||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) # tools/ 供 import kb
|
||
import kb
|
||
import to_image
|
||
import from_vision
|
||
cfg = kb.load_config(root)
|
||
md_dir = root / "raw" / "converted"
|
||
try:
|
||
images = to_image.render_pdf_pages(pdf_path, assets)
|
||
texts, model = from_vision.transcribe(cfg, images)
|
||
except SystemExit:
|
||
raise
|
||
except Exception as e:
|
||
print(f"warning: {pathlib.Path(pdf_path).name} vision 轉錄失敗,降級為 needs_ocr:"
|
||
f"{type(e).__name__}: {e}", file=sys.stderr)
|
||
return "", "needs_ocr", conv_name
|
||
blocks = []
|
||
for i, (img, text) in enumerate(zip(images, texts), 1):
|
||
rel = img.relative_to(md_dir).as_posix()
|
||
# <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
|
||
blocks.append(f"<!-- page {i} (vision: {model}) -->\n\n\n\n{text}")
|
||
return "\n\n".join(blocks), "ok", "from_vision"
|
||
|
||
|
||
def process_local(root, m, path, dry, vision=False):
|
||
src = pathlib.Path(path).resolve()
|
||
if not src.is_file():
|
||
raise FileNotFoundError(src)
|
||
ext = src.suffix.lower()
|
||
|
||
sha = hashlib.sha256(src.read_bytes()).hexdigest()
|
||
if _find_by_sha(m, sha):
|
||
print(f"skip: {src.name} 已登記(hash 相同)")
|
||
return
|
||
|
||
# raw/originals 一律保存「真正的原始檔」(舊格式也是),維持 provenance(§1.4)
|
||
originals = root / "raw" / "originals"
|
||
dest = src if src.parent == originals.resolve() else originals / _dest_name(originals, src.name, sha)
|
||
orig_rel = f"raw/originals/{dest.name}"
|
||
|
||
tmpdir = None
|
||
try:
|
||
if ext in LEGACY_MAP:
|
||
if dry:
|
||
print(f"legacy: {src.name} 需 LibreOffice 升版為 {LEGACY_MAP[ext]}(dry-run 不執行)")
|
||
return
|
||
tmpdir = tempfile.mkdtemp(prefix="ppqa-legacy-")
|
||
upgraded = from_legacy.upgrade(src, tmpdir) # 找不到 soffice / 升版失敗 → RuntimeError
|
||
conv, base_name, _ = CONVERTERS[upgraded.suffix.lower()]
|
||
conv_name, media, conv_src = f"{base_name}+libreoffice", ext.lstrip("."), upgraded
|
||
elif ext in (".html", ".htm"):
|
||
conv = (lambda p, assets_dir=None, md_dir=None: from_web.extract(
|
||
pathlib.Path(p).read_text(encoding="utf-8", errors="replace")))
|
||
conv_name, media, conv_src = "from_web", "html", src
|
||
elif ext in CONVERTERS:
|
||
conv, conv_name, media = CONVERTERS[ext]
|
||
conv_src = src
|
||
else:
|
||
raise ValueError(f"不支援的格式:{src.name}")
|
||
|
||
if not dry and dest is not src and not dest.exists():
|
||
dest.write_bytes(src.read_bytes())
|
||
orig_entry = {"kind": "original", "sha256": sha, "media_type": media,
|
||
"added_at": _now(), "status": "converted"}
|
||
|
||
md_name = f"{dest.stem}-{sha[:8]}.md"
|
||
assets = None if dry else root / "raw" / "converted" / "assets" / f"{dest.stem}-{sha[:8]}"
|
||
md_text, status = conv(str(conv_src), assets_dir=assets,
|
||
md_dir=root / "raw" / "converted")
|
||
if status == "needs_ocr":
|
||
if vision and not dry:
|
||
md_text, status, conv_name = _vision_pdf(root, conv_src, assets, conv_name)
|
||
if status == "needs_ocr":
|
||
orig_entry["status"] = "needs_ocr"
|
||
if not dry:
|
||
m["files"][orig_rel] = orig_entry
|
||
print(f"needs_ocr: {orig_rel} 無文字層,已登記並跳過"
|
||
+ ("(--vision 轉錄未成功)" if vision and not dry else "(不擅自 OCR)")
|
||
+ (" [dry-run]" if dry else ""))
|
||
return
|
||
_register_pair(m, root, orig_rel, orig_entry, f"raw/converted/{md_name}",
|
||
md_text, conv_name, dry)
|
||
finally:
|
||
if tmpdir:
|
||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||
|
||
|
||
def process_url(root, m, url, dry):
|
||
html = from_web.fetch(url)
|
||
sha = hashlib.sha256(html.encode("utf-8")).hexdigest()
|
||
if _find_by_sha(m, sha):
|
||
print(f"skip: {url} 已登記(快照 hash 相同)")
|
||
return
|
||
u = urllib.parse.urlparse(url)
|
||
slug = _slug(f"{u.netloc}{u.path}")[:80]
|
||
originals = root / "raw" / "originals"
|
||
name = _dest_name(originals, f"{slug}.html", sha)
|
||
orig_rel = f"raw/originals/{name}"
|
||
orig_entry = {"kind": "original", "sha256": sha, "media_type": "html",
|
||
"added_at": _now(), "status": "converted",
|
||
"source_url": url, "fetched_at": _now()}
|
||
if not dry:
|
||
(originals / name).write_text(html, encoding="utf-8") # 完整快照(來源可能消失)
|
||
md_text, status = from_web.extract(html)
|
||
if status != "ok":
|
||
orig_entry["status"] = "pending"
|
||
if not dry:
|
||
m["files"][orig_rel] = orig_entry
|
||
print(f"pending: {url} 萃取不到正文,僅保存快照" + (" [dry-run]" if dry else ""))
|
||
return
|
||
md_name = f"{pathlib.Path(name).stem}-{sha[:8]}.md"
|
||
_register_pair(m, root, orig_rel, orig_entry, f"raw/converted/{md_name}",
|
||
md_text, "from_web", dry)
|
||
|
||
|
||
def main(argv=None):
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("inputs", nargs="+", help="檔案路徑或 http(s) URL")
|
||
ap.add_argument("--dry-run", action="store_true")
|
||
ap.add_argument("--vision", action="store_true",
|
||
help="掃描/圖片型 PDF(needs_ocr)改走本地 vision 模型轉錄,"
|
||
"而非跳過(需 config/models.yaml 設定 tasks.vision)")
|
||
ap.add_argument("--traceback", action="store_true",
|
||
help="轉換失敗時額外印出完整堆疊,供定位除錯")
|
||
ap.add_argument("--root", default=str(ROOT), help="專案根目錄(測試用)")
|
||
a = ap.parse_args(argv)
|
||
root = pathlib.Path(a.root).resolve()
|
||
m = load_manifest(root)
|
||
failed = []
|
||
for item in a.inputs:
|
||
try:
|
||
if item.startswith(("http://", "https://")):
|
||
process_url(root, m, item, a.dry_run)
|
||
else:
|
||
process_local(root, m, item, a.dry_run, vision=a.vision)
|
||
except Exception as e: # 單檔失敗不中斷批次,最後彙總報錯
|
||
failed.append((item, e))
|
||
# 帶例外型別:只印訊息字串常無法判斷成因(如 KeyError 只印 key)
|
||
print(f"error: {item}: {type(e).__name__}: {e}", file=sys.stderr)
|
||
if a.traceback:
|
||
traceback.print_exc()
|
||
if not a.dry_run:
|
||
save_manifest(root, m)
|
||
if failed:
|
||
# 尾端彙總,避免失敗行淹沒在大批次輸出中而漏看
|
||
print(f"\n{len(failed)} 個項目轉換失敗:", file=sys.stderr)
|
||
for item, e in failed:
|
||
print(f" - {item}: {type(e).__name__}: {e}", file=sys.stderr)
|
||
if not a.traceback:
|
||
print("(加 --traceback 可印完整堆疊定位)", file=sys.stderr)
|
||
raise SystemExit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|