#!/usr/bin/env python3 """Convert new or changed PDFs in one local folder; run one instance at a time.""" import argparse import hashlib import json import os from pathlib import Path import sys import tempfile import requests ENDPOINT = "https://api.parsebridge.com/v1/parse/document" MAX_BYTES = 50 * 1024 * 1024 def atomic_write(path, text): """Replace a file only after writing its complete UTF-8 contents.""" with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as handle: temporary = Path(handle.name) try: handle.write(text) handle.flush() os.fsync(handle.fileno()) except BaseException: temporary.unlink(missing_ok=True) raise try: temporary.replace(path) finally: temporary.unlink(missing_ok=True) def parse_pdf(session, api_key, name, data): """Send once. A timeout can occur after conversion; do not retry automatically.""" if len(data) > MAX_BYTES: raise ValueError("PDF exceeds 50 MB") response = session.post( ENDPOINT, headers={"Authorization": f"Bearer {api_key}"}, files={"file": (name, data, "application/pdf")}, timeout=(15, 600), ) if response.status_code != 200: raise ValueError(f"Parsebridge HTTP {response.status_code}") result = response.json() if (not isinstance(result, dict) or result.get("success") is not True or not isinstance(result.get("content"), str)): raise ValueError("Invalid Parsebridge response") return result["content"] # An empty string is a valid result. def process_reports(input_dir, output_dir, api_key, session): """Persist successful versions; leave failed versions eligible for a later run.""" input_dir = input_dir.resolve() if not input_dir.is_dir(): raise ValueError("Input must be an existing directory") output_dir.mkdir(parents=True, exist_ok=True) state_path = output_dir / ".reports-state.json" state = json.loads(state_path.read_text()) if state_path.exists() else { "input": str(input_dir), "files": {} } if state.get("input") != str(input_dir) or not isinstance(state.get("files"), dict): raise ValueError("Use a separate output directory for each input folder") failed = False for source in sorted(input_dir.iterdir()): if not source.is_file() or source.suffix.lower() != ".pdf": continue try: if source.stat().st_size > MAX_BYTES: raise ValueError("PDF exceeds 50 MB") data = source.read_bytes() digest = hashlib.sha256(data).hexdigest() # Full filename hash distinguishes e.g. report.pdf from report.PDF. target = output_dir / (hashlib.sha256(source.name.encode()).hexdigest() + ".md") if state["files"].get(source.name) == digest and target.is_file(): print(f"Skipped {source.name}") continue content = parse_pdf(session, api_key, source.name, data) atomic_write(target, content) state["files"][source.name] = digest atomic_write(state_path, json.dumps(state, indent=2) + "\n") print(f"Converted {source.name} -> {target.name}") except Exception as error: failed = True # Do not print response bodies, request headers, or credential values. detail = str(error) if isinstance(error, ValueError) and not isinstance(error, requests.RequestException) else type(error).__name__ print(f"Failed {source.name}: {detail}", file=sys.stderr) return 1 if failed else 0 def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--input", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) args = parser.parse_args() api_key = os.environ.get("PARSEBRIDGE_API_KEY") if not api_key: parser.error("Set PARSEBRIDGE_API_KEY") try: with requests.Session() as session: return process_reports(args.input, args.output, api_key, session) except Exception as error: detail = str(error) if isinstance(error, ValueError) and not isinstance(error, requests.RequestException) else type(error).__name__ print(f"Stopped: {detail}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())