migrate_image_urls.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. #!/usr/bin/env python3
  2. """
  3. 图片 URL 迁移脚本:DB sections/chunks 中的旧图片路径 → 新 hash 文件名路径
  4. 同时处理两种可能的 DB 现状(增量安全):
  5. - 旧 SQL 已执行: /images/甲硝唑_rId9.jpg → /images/fa123757_rId9.jpg
  6. - 旧 SQL 未执行: /images/drugs/甲硝唑_rId9.jpg → /images/fa123757_rId9.jpg
  7. """
  8. import re
  9. import sys
  10. import os
  11. import hashlib
  12. import asyncio
  13. from pathlib import Path
  14. import asyncpg
  15. # ======================================================================
  16. # 加载 .env(与环境变量)
  17. # ======================================================================
  18. def _load_env():
  19. env_file = Path(__file__).resolve().parent.parent / ".env"
  20. if env_file.exists():
  21. with open(env_file, encoding="utf-8") as fh:
  22. for line in fh:
  23. line = line.strip()
  24. if not line or line.startswith("#") or "=" not in line:
  25. continue
  26. key, _, val = line.partition("=")
  27. os.environ.setdefault(key.strip(), val.strip())
  28. _load_env()
  29. # ======================================================================
  30. # 配置
  31. # ======================================================================
  32. DB_HOST = os.environ.get("POSTGRES_HOST", "localhost")
  33. DB_PORT = os.environ.get("POSTGRES_PORT", "5432")
  34. DB_NAME = os.environ.get("POSTGRES_DB", "pharmacopoeia")
  35. DB_USER = os.environ.get("POSTGRES_USER", "postgres")
  36. DB_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "pharma2025")
  37. # 新旧 URL 前缀
  38. OLD_PREFIX = "/images/drugs/"
  39. NEW_PREFIX = "/images/"
  40. # 旧文件名安全的规则(与原 docx_ingest.py 的 _safe_drug_name 一致)
  41. _RE_SAFE = re.compile(r'[\s()() ]+')
  42. # 匹配所有旧格式图片 URL(兼容 /images/ 和 /images/drugs/ 两种前缀)
  43. # group(1) = 旧前缀(中文 safe_name), group(2) = rId, group(3) = 扩展名
  44. _OLD_IMG_RE = re.compile(
  45. r'/images/(?:drugs/)?' # /images/ 或 /images/drugs/
  46. r'([^"_\s]+)' # 旧文件名前缀(中文 safe_name)
  47. r'_(rId\d+)' # _rIdX 引用
  48. r'\.(jpg|jpeg|png|gif|bmp)' # 扩展名
  49. )
  50. # 只用于计数查询的 LIKE 模式
  51. _SEARCH_PATTERN = '/images/%_rId%.'
  52. def old_safe_name(drug_name: str) -> str:
  53. """与原始 docx_ingest.py 的 _safe_drug_name 一致"""
  54. return _RE_SAFE.sub("_", drug_name).strip("_")
  55. def new_hash_prefix(drug_name: str) -> str:
  56. """新的 hash 前缀(与更新后的 docx_ingest.py 一致)"""
  57. return hashlib.md5(drug_name.encode()).hexdigest()[:8]
  58. def replace_urls(text: str, mappings: dict[str, str]) -> str:
  59. """替换文本中所有旧格式图片 URL → 新 hash 格式。"""
  60. def _replacer(m: re.Match) -> str:
  61. old = m.group(1) # 旧 safe_name(中文)
  62. rId = m.group(2) # rIdX
  63. ext = m.group(3) # jpg/png/gif
  64. new = mappings.get(old)
  65. if new is None:
  66. # 没匹配到的保留原文件名,但确保前缀是 /images/
  67. return f"{NEW_PREFIX}{old}_{rId}.{ext}"
  68. return f"{NEW_PREFIX}{new}_{rId}.{ext}"
  69. return _OLD_IMG_RE.sub(_replacer, text)
  70. async def main():
  71. # 1. 连接数据库
  72. conn = await asyncpg.connect(
  73. host=DB_HOST, port=DB_PORT, database=DB_NAME,
  74. user=DB_USER, password=DB_PASSWORD,
  75. )
  76. # 2. 查有图片引用的所有药品(同时兼容 /images/drugs/ 和 /images/ 两种状态)
  77. drugs = await conn.fetch(f"""
  78. SELECT DISTINCT name FROM drugs
  79. WHERE sections::text LIKE '%{_SEARCH_PATTERN}%'
  80. """)
  81. drug_names = [row["name"] for row in drugs]
  82. print(f"从 drugs 表找到有图片引用的药品: {len(drug_names)} 个")
  83. # 3. 构建映射: 旧 safe_name → 新 hash
  84. mappings: dict[str, str] = {}
  85. for name in drug_names:
  86. safe = old_safe_name(name)
  87. h = new_hash_prefix(name)
  88. mappings[safe] = h
  89. print(f"映射条目: {len(mappings)}")
  90. for i, (old, new) in enumerate(mappings.items()):
  91. if i >= 10:
  92. print(f" ... 还有 {len(mappings) - 10} 条")
  93. break
  94. print(f" {old} → {new}")
  95. # 4. 统计待更新量
  96. sections_count = await conn.fetchval(f"""
  97. SELECT COUNT(*) FROM drugs
  98. WHERE sections::text LIKE '%{_SEARCH_PATTERN}%'
  99. """)
  100. chunks_count = await conn.fetchval(f"""
  101. SELECT COUNT(*) FROM drug_chunks
  102. WHERE content LIKE '%{_SEARCH_PATTERN}%'
  103. """)
  104. print(f"\n待更新: drugs={sections_count} chunks={chunks_count}")
  105. if sections_count == 0 and chunks_count == 0:
  106. print("✅ 无需更新")
  107. await conn.close()
  108. return
  109. # 5. 更新 drug_chunks.content(纯文本,直接替换)
  110. if chunks_count > 0:
  111. print("\n📦 更新 drug_chunks ...")
  112. rows = await conn.fetch(f"""
  113. SELECT id, content FROM drug_chunks
  114. WHERE content LIKE '%{_SEARCH_PATTERN}%'
  115. """)
  116. updates = []
  117. for row in rows:
  118. new_content = replace_urls(row["content"], mappings)
  119. if new_content != row["content"]:
  120. updates.append((new_content, row["id"]))
  121. if updates:
  122. async with conn.transaction():
  123. await conn.executemany(
  124. "UPDATE drug_chunks SET content = $1 WHERE id = $2",
  125. updates,
  126. )
  127. print(f" ✅ 更新了 {len(updates)} 条")
  128. else:
  129. print(" ✅ 无需更新")
  130. # 6. 更新 drugs.sections(jsonb,需 text ↔ jsonb 转换)
  131. if sections_count > 0:
  132. print("\n📦 更新 drugs.sections ...")
  133. rows = await conn.fetch(f"""
  134. SELECT drug_id, sections::text AS txt FROM drugs
  135. WHERE sections::text LIKE '%{_SEARCH_PATTERN}%'
  136. """)
  137. updates = []
  138. for row in rows:
  139. new_text = replace_urls(row["txt"], mappings)
  140. if new_text != row["txt"]:
  141. updates.append((new_text, row["drug_id"]))
  142. if updates:
  143. async with conn.transaction():
  144. await conn.executemany(
  145. "UPDATE drugs SET sections = $1::jsonb WHERE drug_id = $2",
  146. updates,
  147. )
  148. print(f" ✅ 更新了 {len(updates)} 条")
  149. else:
  150. print(" ✅ 无需更新")
  151. # 7. 验证残留:确认无旧格式 URL
  152. remaining_d = await conn.fetchval(f"""
  153. SELECT COUNT(*) FROM drugs
  154. WHERE sections::text LIKE '%{_SEARCH_PATTERN}%'
  155. """)
  156. remaining_c = await conn.fetchval(f"""
  157. SELECT COUNT(*) FROM drug_chunks
  158. WHERE content LIKE '%{_SEARCH_PATTERN}%'
  159. """)
  160. print(f"\n残留旧格式: drugs={remaining_d} chunks={remaining_c}")
  161. if remaining_d == 0 and remaining_c == 0:
  162. print("✅ 迁移完成")
  163. else:
  164. print("⚠️ 仍有残留,请手动检查")
  165. await conn.close()
  166. if __name__ == "__main__":
  167. asyncio.run(main())