autotrain/scripts/add_invoice_passenger_name.py
xinxin6623 7a01ed4db9 feat: add invoice batch wrap-up (name extraction + roster annotation) and web ui automation improvements
- add scripts/annotate_roster_invoices.py: match passenger names to invoice PDFs, write status + relative encoded hyperlinks into roster xlsx
- add scripts/add_invoice_passenger_name.py, scripts/calibrate.py
- add .opencode/skills/invoice-wrapup skill
- improve invoice_tool.py / web_ui.py / vision_fallback.py automation
- add device/screen/windows adaptation docs
- ignore out/ (delivered invoice PDFs contain sensitive data)
2026-07-10 00:11:45 +08:00

137 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""Extract passenger names from railway invoice PDFs and add them to filenames."""
from __future__ import annotations
import argparse
import csv
import re
from datetime import datetime
from pathlib import Path
from pypdf import PdfReader
ID_NAME_RE = re.compile(r"\d{6,18}\*+\d{3}[\dXx]\s*([一-鿿·]{2,8})")
NAME_RE = re.compile(r"^[一-鿿·]{2,8}$")
KEYWORD_RE = re.compile(r"电子发票|铁路电子客票")
INVALID_FILENAME_CHARS_RE = re.compile(r"[\\/:*?\"<>|\r\n\t]")
def extract_pdf_text(path: Path) -> str:
reader = PdfReader(str(path))
return "\n".join(page.extract_text() or "" for page in reader.pages)
def extract_passenger_name(text: str) -> str | None:
compact = re.sub(r"\s+", " ", text)
match = ID_NAME_RE.search(compact)
if match:
return match.group(1)
lines = [line.strip() for line in text.splitlines() if line.strip()]
for index, line in enumerate(lines):
if re.search(r"\d{6,18}\*+\d{3}[\dXx]", line):
tail = re.split(r"\d{6,18}\*+\d{3}[\dXx]", line, maxsplit=1)[-1].strip()
if NAME_RE.match(tail):
return tail
if index + 1 < len(lines) and NAME_RE.match(lines[index + 1]):
return lines[index + 1]
return None
def clean_name(name: str) -> str:
return INVALID_FILENAME_CHARS_RE.sub("", name).strip()
def build_target(path: Path, name: str) -> Path:
stem = path.stem
if name in stem:
return path
match = KEYWORD_RE.search(stem)
if match:
new_stem = f"{stem[:match.start()].rstrip('_')}_{name}_{stem[match.start():].lstrip('_')}"
else:
new_stem = f"{stem}_{name}"
candidate = path.with_name(f"{new_stem}{path.suffix}")
counter = 2
while candidate.exists() and candidate != path:
candidate = path.with_name(f"{new_stem}_{counter}{path.suffix}")
counter += 1
return candidate
def main() -> int:
parser = argparse.ArgumentParser(description="Add passenger names extracted from railway invoice PDFs to filenames.")
parser.add_argument("directory", type=Path, help="Directory containing invoice PDFs")
parser.add_argument("--apply", action="store_true", help="Actually rename files; default is dry-run")
parser.add_argument("--log", type=Path, help="CSV log path; default writes into the target directory")
args = parser.parse_args()
directory = args.directory.expanduser().resolve()
if not directory.is_dir():
raise SystemExit(f"not a directory: {directory}")
log_path = args.log or directory / f"rename_passenger_names_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
rows: list[dict[str, str]] = []
renamed = 0
skipped = 0
failed = 0
for path in sorted(directory.glob("*.pdf")):
status = ""
name = ""
target = path
error = ""
try:
text = extract_pdf_text(path)
extracted = extract_passenger_name(text)
if not extracted:
status = "failed"
error = "name_not_found"
failed += 1
else:
name = clean_name(extracted)
target = build_target(path, name)
if target == path:
status = "skipped"
error = "name_already_in_filename"
skipped += 1
else:
status = "renamed" if args.apply else "dry_run"
if args.apply:
path.rename(target)
renamed += 1
except Exception as exc: # noqa: BLE001 - keep batch processing and report per file.
status = "failed"
error = repr(exc)
failed += 1
rows.append(
{
"status": status,
"name": name,
"old_path": str(path),
"new_path": str(target),
"error": error,
}
)
print(f"{status}: {path.name} -> {target.name}" + (f" ({error})" if error else ""))
if args.apply or rows:
with log_path.open("w", newline="", encoding="utf-8-sig") as fh:
writer = csv.DictWriter(fh, fieldnames=["status", "name", "old_path", "new_path", "error"])
writer.writeheader()
writer.writerows(rows)
print(f"total={len(rows)} renamed={renamed} skipped={skipped} failed={failed} log={log_path}")
if not args.apply:
print("dry-run only; rerun with --apply to rename files")
return 0 if failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())