#!/usr/bin/env python3 """ Standalone image extraction script for 2025 Pharmacopoeia DOCX files. ===== ======== ========= ====== === ===== ============== === ====== Extracts all embedded images from DOCX files and generates a drug-image mapping manifest (drug_images.json). Key features: - Monkey-patches docx_ingest config so images go to /opt/static/images/ - Does NOT write to PostgreSQL or call any embedding / vectorization API - Tracks which section (性状/鉴别/检查/…) each image appears in - Falls back to filesystem scan for images the inline-parser may have missed - Generates a comprehensive drug_images.json manifest Environment variables: DOCX_SOURCE_DIR – root of the DOCX tree (default: /opt/2025) EXTRACT_IMAGES_DIR – where to write images (default: /opt/static/images) EXTRACT_IMAGE_URL_PREFIX – public URL prefix (default: /images/) EXTRACT_MANIFEST_FILE – path for the JSON manifest (default: …) Author: auto-generated via Claude Code (2026-07-28) """ from __future__ import annotations import hashlib import json import os import re import sys import time from pathlib import Path # --------------------------------------------------------------------------- # 1. load .env (replicated from docx_ingest.py so we work before importing it) # --------------------------------------------------------------------------- def _load_env() -> None: env_file = Path(__file__).resolve().parent.parent / ".env" if not env_file.exists(): return with open(env_file, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") os.environ.setdefault(key.strip(), val.strip()) _load_env() # --------------------------------------------------------------------------- # 2. Configuration # --------------------------------------------------------------------------- SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025") IMAGES_DIR = os.environ.get("EXTRACT_IMAGES_DIR", "/opt/static/images") IMAGE_URL_PREFIX = os.environ.get("EXTRACT_IMAGE_URL_PREFIX", "/images/") MANIFEST_FILE = os.environ.get( "EXTRACT_MANIFEST_FILE", os.path.join(IMAGES_DIR, "drug_images.json"), ) # --------------------------------------------------------------------------- # 3. Monkey-patch *before* calling any docx_ingest function. # Python resolves module-level globals at call-time, so mutating the # module dict after import is enough to redirect _extract_images. # --------------------------------------------------------------------------- sys.path.insert(0, str(Path(__file__).resolve().parent)) import docx_ingest # noqa: E402 docx_ingest.IMAGES_DIR = IMAGES_DIR docx_ingest.IMAGE_URL_PREFIX = IMAGE_URL_PREFIX # Convenience aliases — these are the SAME function objects, # they just happen to use our overridden globals when called. parse_docx = docx_ingest.parse_docx find_docx_files = docx_ingest.find_docx_files # --------------------------------------------------------------------------- # 4. Image -> section helpers # --------------------------------------------------------------------------- IMG_TAG_RE = re.compile(r']*/?>', re.IGNORECASE) def _hash_prefix(name: str) -> str: """Return 8-char MD5 hex for *name* (used as image filename prefix / fallback match).""" return hashlib.md5(name.encode()).hexdigest()[:8] def _collect_images_from_sections( sections: dict[str, str], ) -> list[dict[str, str]]: """Walk section text looking for ``<img src="…">`` tags. Returns a flat list of ``{section, filename, url}`` dicts. """ records: list[dict[str, str]] = [] for sec_key, sec_text in sections.items(): for m in IMG_TAG_RE.finditer(sec_text): url = m.group(1) filename = url.rsplit("/", 1)[-1] if "/" in url else url records.append({"section": sec_key, "filename": filename, "url": url}) return records def _find_orphan_images( name_hash: str, images_dir: str, already_found: set[str], ) -> list[dict[str, str]]: """Filesystem 兜底:找 标签中没有覆盖到的图片。 Some images sit in the same paragraph as text (line 214 of docx_ingest.py has the ``if not p_text`` guard), so the inline parser does not emit an ```` tag for them. We catch those here by matching file names. """ if not os.path.isdir(images_dir): return [] prefix = name_hash + "_" orphans: list[dict[str, str]] = [] try: for fname in os.listdir(images_dir): if fname.startswith(prefix) and fname not in already_found: orphans.append( { "section": "未分类", "filename": fname, "url": IMAGE_URL_PREFIX.rstrip("/") + "/" + fname, } ) except OSError: pass return orphans def _group_by_section( flat: list[dict[str, str]], ) -> list[dict]: """Group a flat list of ``{section, filename, url}`` by section key.""" groups: dict[str, list[dict[str, str]]] = {} for rec in flat: groups.setdefault(rec["section"], []).append( {"filename": rec["filename"], "url": rec["url"]} ) return [ { "section": sec, "images": imgs, "count": len(imgs), } for sec, imgs in groups.items() ] # --------------------------------------------------------------------------- # 5. Main orchestrator # --------------------------------------------------------------------------- def main() -> None: # re-read env in case caller uses different values after import (rare, # but harmless) global IMAGES_DIR, IMAGE_URL_PREFIX, SOURCE_DIR, MANIFEST_FILE IMAGES_DIR = os.environ.get("EXTRACT_IMAGES_DIR", "/opt/static/images") IMAGE_URL_PREFIX = os.environ.get("EXTRACT_IMAGE_URL_PREFIX", "/images/") SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025") MANIFEST_FILE = os.environ.get( "EXTRACT_MANIFEST_FILE", os.path.join(IMAGES_DIR, "drug_images.json"), ) # Apply any env-driven change back to docx_ingest docx_ingest.IMAGES_DIR = IMAGES_DIR docx_ingest.IMAGE_URL_PREFIX = IMAGE_URL_PREFIX # Ensure directories exist os.makedirs(IMAGES_DIR, exist_ok=True) manifest_dir = os.path.dirname(MANIFEST_FILE) or "." os.makedirs(manifest_dir, exist_ok=True) print("=" * 60) print("IMAGE EXTRACTION: Scanning DOCX files …") print(f" Source: {SOURCE_DIR}") print(f" Images out: {IMAGES_DIR}") print(f" URL prefix: {IMAGE_URL_PREFIX}") print(f" Manifest: {MANIFEST_FILE}") print("=" * 60) files = find_docx_files(SOURCE_DIR) if not files: print("No DOCX files found – nothing to do.") return print(f"Found {len(files)} DOCX file(s)") # ── process every file ────────────────────────────────────────────── drug_records: list[dict] = [] drugs_parsed = 0 drugs_with_images = 0 total_unique_images = 0 skipped = 0 t0 = time.time() for i, fp in enumerate(files, 1): entry = parse_docx(fp) # _extract_images runs as a side-effect if entry is None: skipped += 1 continue drugs_parsed += 1 # 4a – extract from tags embedded in section text section_images = _collect_images_from_sections(entry["sections"]) found_filenames = {r["filename"] for r in section_images} # 4b – filesystem fallback for images that the inline parser missed name_hash = _hash_prefix(entry["name"]) orphans = _find_orphan_images(name_hash, IMAGES_DIR, found_filenames) section_images.extend(orphans) if not section_images: continue grouped = _group_by_section(section_images) unique_count = len({r["filename"] for r in section_images}) drug_records.append( { "drug_id": entry["drug_id"], "name": entry["name"], "pinyin": entry.get("pinyin", ""), "category": entry.get("category", ""), "subcategory": entry.get("subcategory", ""), "volume": entry["source"]["volume"], "source_file": fp, "total_image_count": unique_count, "sections_with_images": grouped, } ) drugs_with_images += 1 total_unique_images += unique_count if i % 500 == 0: elapsed = time.time() - t0 rate = i / elapsed if elapsed > 0 else 0 print( f" … {i:>5d}/{len(files)} | " f"{rate:5.1f} docs/s | " f"{drugs_with_images} drugs with images | " f"{total_unique_images} images" ) # ── summary ───────────────────────────────────────────────────────── elapsed = time.time() - t0 # rough disk usage du_bytes = 0 try: for dirpath, _dirnames, filenames in os.walk(IMAGES_DIR): for fn in filenames: fp = os.path.join(dirpath, fn) try: du_bytes += os.path.getsize(fp) except OSError: pass except OSError: pass print() print("=" * 60) print("EXTRACTION COMPLETE") print(f" Files scanned: {len(files)}") print(f" Parsed successfully: {drugs_parsed}") print(f" Skipped (parse errors): {skipped}") print(f" Drugs with images: {drugs_with_images}") print(f" Total unique images: {total_unique_images}") print(f" Disk usage: {du_bytes / 1024 / 1024:.1f} MiB") print(f" Elapsed: {elapsed:.0f}s " f"({elapsed / 60:.1f} min)") print("=" * 60) # ── write manifest ────────────────────────────────────────────────── manifest = { "meta": { "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "source_dir": SOURCE_DIR, "images_dir": IMAGES_DIR, "image_url_prefix": IMAGE_URL_PREFIX, "total_drugs_parsed": drugs_parsed, "total_drugs_with_images": drugs_with_images, "total_unique_images": total_unique_images, "skipped_files": skipped, }, "drugs": drug_records, } with open(MANIFEST_FILE, "w", encoding="utf-8") as fh: json.dump(manifest, fh, ensure_ascii=False, indent=2) print(f"Manifest written to {MANIFEST_FILE}") if __name__ == "__main__": main()