extract_images.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. #!/usr/bin/env python3
  2. """
  3. Standalone image extraction script for 2025 Pharmacopoeia DOCX files.
  4. ===== ======== ========= ====== === ===== ============== === ======
  5. Extracts all embedded images from DOCX files and generates a
  6. drug-image mapping manifest (drug_images.json).
  7. Key features:
  8. - Monkey-patches docx_ingest config so images go to /opt/static/images/
  9. - Does NOT write to PostgreSQL or call any embedding / vectorization API
  10. - Tracks which section (性状/鉴别/检查/…) each image appears in
  11. - Falls back to filesystem scan for images the inline-parser may have missed
  12. - Generates a comprehensive drug_images.json manifest
  13. Environment variables:
  14. DOCX_SOURCE_DIR – root of the DOCX tree (default: /opt/2025)
  15. EXTRACT_IMAGES_DIR – where to write images (default: /opt/static/images)
  16. EXTRACT_IMAGE_URL_PREFIX – public URL prefix (default: /images/)
  17. EXTRACT_MANIFEST_FILE – path for the JSON manifest (default: …)
  18. Author: auto-generated via Claude Code (2026-07-28)
  19. """
  20. from __future__ import annotations
  21. import hashlib
  22. import json
  23. import os
  24. import re
  25. import sys
  26. import time
  27. from pathlib import Path
  28. # ---------------------------------------------------------------------------
  29. # 1. load .env (replicated from docx_ingest.py so we work before importing it)
  30. # ---------------------------------------------------------------------------
  31. def _load_env() -> None:
  32. env_file = Path(__file__).resolve().parent.parent / ".env"
  33. if not env_file.exists():
  34. return
  35. with open(env_file, encoding="utf-8") as fh:
  36. for line in fh:
  37. line = line.strip()
  38. if not line or line.startswith("#") or "=" not in line:
  39. continue
  40. key, _, val = line.partition("=")
  41. os.environ.setdefault(key.strip(), val.strip())
  42. _load_env()
  43. # ---------------------------------------------------------------------------
  44. # 2. Configuration
  45. # ---------------------------------------------------------------------------
  46. SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025")
  47. IMAGES_DIR = os.environ.get("EXTRACT_IMAGES_DIR", "/opt/static/images")
  48. IMAGE_URL_PREFIX = os.environ.get("EXTRACT_IMAGE_URL_PREFIX", "/images/")
  49. MANIFEST_FILE = os.environ.get(
  50. "EXTRACT_MANIFEST_FILE",
  51. os.path.join(IMAGES_DIR, "drug_images.json"),
  52. )
  53. # ---------------------------------------------------------------------------
  54. # 3. Monkey-patch *before* calling any docx_ingest function.
  55. # Python resolves module-level globals at call-time, so mutating the
  56. # module dict after import is enough to redirect _extract_images.
  57. # ---------------------------------------------------------------------------
  58. sys.path.insert(0, str(Path(__file__).resolve().parent))
  59. import docx_ingest # noqa: E402
  60. docx_ingest.IMAGES_DIR = IMAGES_DIR
  61. docx_ingest.IMAGE_URL_PREFIX = IMAGE_URL_PREFIX
  62. # Convenience aliases — these are the SAME function objects,
  63. # they just happen to use our overridden globals when called.
  64. parse_docx = docx_ingest.parse_docx
  65. find_docx_files = docx_ingest.find_docx_files
  66. # ---------------------------------------------------------------------------
  67. # 4. Image -> section helpers
  68. # ---------------------------------------------------------------------------
  69. IMG_TAG_RE = re.compile(r'<img\s+src="([^"]+)"[^>]*/?>', re.IGNORECASE)
  70. def _hash_prefix(name: str) -> str:
  71. """Return 8-char MD5 hex for *name* (used as image filename prefix / fallback match)."""
  72. return hashlib.md5(name.encode()).hexdigest()[:8]
  73. def _collect_images_from_sections(
  74. sections: dict[str, str],
  75. ) -> list[dict[str, str]]:
  76. """Walk section text looking for ``&lt;img src="…"&gt;`` tags.
  77. Returns a flat list of ``{section, filename, url}`` dicts.
  78. """
  79. records: list[dict[str, str]] = []
  80. for sec_key, sec_text in sections.items():
  81. for m in IMG_TAG_RE.finditer(sec_text):
  82. url = m.group(1)
  83. filename = url.rsplit("/", 1)[-1] if "/" in url else url
  84. records.append({"section": sec_key, "filename": filename, "url": url})
  85. return records
  86. def _find_orphan_images(
  87. name_hash: str,
  88. images_dir: str,
  89. already_found: set[str],
  90. ) -> list[dict[str, str]]:
  91. """Filesystem 兜底:找 <img> 标签中没有覆盖到的图片。
  92. Some images sit in the same paragraph as text (line 214 of docx_ingest.py
  93. has the ``if not p_text`` guard), so the inline parser does not emit an
  94. ``<img>`` tag for them. We catch those here by matching file names.
  95. """
  96. if not os.path.isdir(images_dir):
  97. return []
  98. prefix = name_hash + "_"
  99. orphans: list[dict[str, str]] = []
  100. try:
  101. for fname in os.listdir(images_dir):
  102. if fname.startswith(prefix) and fname not in already_found:
  103. orphans.append(
  104. {
  105. "section": "未分类",
  106. "filename": fname,
  107. "url": IMAGE_URL_PREFIX.rstrip("/") + "/" + fname,
  108. }
  109. )
  110. except OSError:
  111. pass
  112. return orphans
  113. def _group_by_section(
  114. flat: list[dict[str, str]],
  115. ) -> list[dict]:
  116. """Group a flat list of ``{section, filename, url}`` by section key."""
  117. groups: dict[str, list[dict[str, str]]] = {}
  118. for rec in flat:
  119. groups.setdefault(rec["section"], []).append(
  120. {"filename": rec["filename"], "url": rec["url"]}
  121. )
  122. return [
  123. {
  124. "section": sec,
  125. "images": imgs,
  126. "count": len(imgs),
  127. }
  128. for sec, imgs in groups.items()
  129. ]
  130. # ---------------------------------------------------------------------------
  131. # 5. Main orchestrator
  132. # ---------------------------------------------------------------------------
  133. def main() -> None:
  134. # re-read env in case caller uses different values after import (rare,
  135. # but harmless)
  136. global IMAGES_DIR, IMAGE_URL_PREFIX, SOURCE_DIR, MANIFEST_FILE
  137. IMAGES_DIR = os.environ.get("EXTRACT_IMAGES_DIR", "/opt/static/images")
  138. IMAGE_URL_PREFIX = os.environ.get("EXTRACT_IMAGE_URL_PREFIX", "/images/")
  139. SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025")
  140. MANIFEST_FILE = os.environ.get(
  141. "EXTRACT_MANIFEST_FILE",
  142. os.path.join(IMAGES_DIR, "drug_images.json"),
  143. )
  144. # Apply any env-driven change back to docx_ingest
  145. docx_ingest.IMAGES_DIR = IMAGES_DIR
  146. docx_ingest.IMAGE_URL_PREFIX = IMAGE_URL_PREFIX
  147. # Ensure directories exist
  148. os.makedirs(IMAGES_DIR, exist_ok=True)
  149. manifest_dir = os.path.dirname(MANIFEST_FILE) or "."
  150. os.makedirs(manifest_dir, exist_ok=True)
  151. print("=" * 60)
  152. print("IMAGE EXTRACTION: Scanning DOCX files …")
  153. print(f" Source: {SOURCE_DIR}")
  154. print(f" Images out: {IMAGES_DIR}")
  155. print(f" URL prefix: {IMAGE_URL_PREFIX}")
  156. print(f" Manifest: {MANIFEST_FILE}")
  157. print("=" * 60)
  158. files = find_docx_files(SOURCE_DIR)
  159. if not files:
  160. print("No DOCX files found – nothing to do.")
  161. return
  162. print(f"Found {len(files)} DOCX file(s)")
  163. # ── process every file ──────────────────────────────────────────────
  164. drug_records: list[dict] = []
  165. drugs_parsed = 0
  166. drugs_with_images = 0
  167. total_unique_images = 0
  168. skipped = 0
  169. t0 = time.time()
  170. for i, fp in enumerate(files, 1):
  171. entry = parse_docx(fp) # _extract_images runs as a side-effect
  172. if entry is None:
  173. skipped += 1
  174. continue
  175. drugs_parsed += 1
  176. # 4a – extract from <img> tags embedded in section text
  177. section_images = _collect_images_from_sections(entry["sections"])
  178. found_filenames = {r["filename"] for r in section_images}
  179. # 4b – filesystem fallback for images that the inline parser missed
  180. name_hash = _hash_prefix(entry["name"])
  181. orphans = _find_orphan_images(name_hash, IMAGES_DIR, found_filenames)
  182. section_images.extend(orphans)
  183. if not section_images:
  184. continue
  185. grouped = _group_by_section(section_images)
  186. unique_count = len({r["filename"] for r in section_images})
  187. drug_records.append(
  188. {
  189. "drug_id": entry["drug_id"],
  190. "name": entry["name"],
  191. "pinyin": entry.get("pinyin", ""),
  192. "category": entry.get("category", ""),
  193. "subcategory": entry.get("subcategory", ""),
  194. "volume": entry["source"]["volume"],
  195. "source_file": fp,
  196. "total_image_count": unique_count,
  197. "sections_with_images": grouped,
  198. }
  199. )
  200. drugs_with_images += 1
  201. total_unique_images += unique_count
  202. if i % 500 == 0:
  203. elapsed = time.time() - t0
  204. rate = i / elapsed if elapsed > 0 else 0
  205. print(
  206. f" … {i:>5d}/{len(files)} | "
  207. f"{rate:5.1f} docs/s | "
  208. f"{drugs_with_images} drugs with images | "
  209. f"{total_unique_images} images"
  210. )
  211. # ── summary ─────────────────────────────────────────────────────────
  212. elapsed = time.time() - t0
  213. # rough disk usage
  214. du_bytes = 0
  215. try:
  216. for dirpath, _dirnames, filenames in os.walk(IMAGES_DIR):
  217. for fn in filenames:
  218. fp = os.path.join(dirpath, fn)
  219. try:
  220. du_bytes += os.path.getsize(fp)
  221. except OSError:
  222. pass
  223. except OSError:
  224. pass
  225. print()
  226. print("=" * 60)
  227. print("EXTRACTION COMPLETE")
  228. print(f" Files scanned: {len(files)}")
  229. print(f" Parsed successfully: {drugs_parsed}")
  230. print(f" Skipped (parse errors): {skipped}")
  231. print(f" Drugs with images: {drugs_with_images}")
  232. print(f" Total unique images: {total_unique_images}")
  233. print(f" Disk usage: {du_bytes / 1024 / 1024:.1f} MiB")
  234. print(f" Elapsed: {elapsed:.0f}s "
  235. f"({elapsed / 60:.1f} min)")
  236. print("=" * 60)
  237. # ── write manifest ──────────────────────────────────────────────────
  238. manifest = {
  239. "meta": {
  240. "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
  241. "source_dir": SOURCE_DIR,
  242. "images_dir": IMAGES_DIR,
  243. "image_url_prefix": IMAGE_URL_PREFIX,
  244. "total_drugs_parsed": drugs_parsed,
  245. "total_drugs_with_images": drugs_with_images,
  246. "total_unique_images": total_unique_images,
  247. "skipped_files": skipped,
  248. },
  249. "drugs": drug_records,
  250. }
  251. with open(MANIFEST_FILE, "w", encoding="utf-8") as fh:
  252. json.dump(manifest, fh, ensure_ascii=False, indent=2)
  253. print(f"Manifest written to {MANIFEST_FILE}")
  254. if __name__ == "__main__":
  255. main()