#!/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()