extract_images.py 11 KB

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