189 lines
6.6 KiB
Python
189 lines
6.6 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>... [--dry-run] [--root DIR]
|
||
"""
|
||
import argparse
|
||
import datetime
|
||
import hashlib
|
||
import json
|
||
import pathlib
|
||
import re
|
||
import sys
|
||
import urllib.parse
|
||
|
||
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
||
import from_docx
|
||
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"),
|
||
}
|
||
|
||
|
||
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 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 process_local(root, m, path, dry):
|
||
src = pathlib.Path(path).resolve()
|
||
if not src.is_file():
|
||
raise FileNotFoundError(src)
|
||
ext = src.suffix.lower()
|
||
if ext in (".html", ".htm"):
|
||
conv, conv_name, media = (
|
||
lambda p, assets_dir=None, md_dir=None: from_web.extract(
|
||
pathlib.Path(p).read_text(encoding="utf-8", errors="replace")),
|
||
"from_web", "html")
|
||
elif ext in CONVERTERS:
|
||
conv, conv_name, media = CONVERTERS[ext]
|
||
else:
|
||
raise ValueError(f"不支援的格式:{src.name}")
|
||
|
||
sha = hashlib.sha256(src.read_bytes()).hexdigest()
|
||
if _find_by_sha(m, sha):
|
||
print(f"skip: {src.name} 已登記(hash 相同)")
|
||
return
|
||
|
||
originals = root / "raw" / "originals"
|
||
if src.parent == originals.resolve():
|
||
dest = src
|
||
else:
|
||
dest = originals / _dest_name(originals, src.name, sha)
|
||
if not dry and not dest.exists():
|
||
dest.write_bytes(src.read_bytes())
|
||
orig_rel = f"raw/originals/{dest.name}"
|
||
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(src), assets_dir=assets, md_dir=root / "raw" / "converted")
|
||
if status == "needs_ocr":
|
||
orig_entry["status"] = "needs_ocr"
|
||
if not dry:
|
||
m["files"][orig_rel] = orig_entry
|
||
print(f"needs_ocr: {orig_rel} 無文字層,已登記並跳過(不擅自 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)
|
||
|
||
|
||
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("--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)
|
||
except Exception as e: # 單檔失敗不中斷批次,最後彙總報錯
|
||
failed.append((item, e))
|
||
print(f"error: {item}: {e}", file=sys.stderr)
|
||
if not a.dry_run:
|
||
save_manifest(root, m)
|
||
if failed:
|
||
raise SystemExit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|