migrate_image_urls.py 6.3 KB

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