Files
diary-news/backend/app/services/translation/text_clean.py
xiaji 8dccf08126 feat(translate): 增加译文清洗 — pipeline 接入源头防御 + 批量清洗历史脚本
- 新增 app/services/translation/text_clean.py
  clean_markdown_asterisks / clean_html_inner_text / wrap_html 共用工具,
  清洗 LLM 输出残留的 ** / * / *** markdown 标记
- 改 pipeline.translate_article: 写库前清洗 tr_title/tr_body,
  新翻译不再带 **;同时把私有 _wrap_html 替换为公开 wrap_html
- 新增 app/scripts/clean_translations.py
  批量清洗历史脏数据 — 5 字段(title_zh/body_zh_text/body_zh_html/
  body_zh_formatted/summary_zh),支持 dry-run/limit/source-slug/field
2026-06-16 22:12:45 +08:00

121 lines
4.2 KiB
Python

"""译文文本清洗工具。
应用场景:
- LLM 翻译时偶尔把 markdown 标记(加粗 `**`、`*`、`***`)原样带进译文里,
前端展示出来就成了 `**FBI**局长` 这种带星号的脏数据。
- enrichment 阶段也会把 `**` 带到 `body_zh_formatted` / `summary_zh`。
提供两个核心函数:
- `clean_markdown_asterisks(text)`:清洗字符串里的 `*` / `**` / `***` 标记
- `clean_html_inner_text(html)`:BeautifulSoup 解析 HTML,只清洗文本节点,
保留标签结构和内联 style
另附 `wrap_html(text)`:把清洗后的 body_zh_text 包成简单 `<p>` HTML,
之前在 pipeline.py 是私有 `_wrap_html`,提到此处供复用。
设计原则:
- 不引入额外依赖(只用 `re` + 项目已有的 `beautifulsoup4`)
- 对 None / 空串安全返回 None / 空串
- 处理顺序从长到短,避免 `**` 被 `*` 先吃掉
- 反复循环直到稳定,应对 `**a****b**` 这种连续多对
- 兜底删除所有残留 `*`,保守但符合"去掉星号"的用户意图
"""
from __future__ import annotations
import re
from bs4 import BeautifulSoup
# === 核心清洗函数 ===
# ***text*** → text(粗+斜体)
_ASTERISK_TRIPLE_RE = re.compile(r"\*\*\*([^*]+?)\*\*\*")
# **text** → text(粗体)
_ASTERISK_DOUBLE_RE = re.compile(r"\*\*([^*]+?)\*\*")
# *text* → text(斜体,要求 text 不为空且不与 * 相邻,避免误伤 `*2*3*` 这种)
# 第一个负向回看 (?<!\*) 防止匹配 ** 中的 *
# 第二个 (?!\\*) 同理
# [^*\s] 强制开头非空白非 *,避免匹配孤立 " * "
_ASTERISK_SINGLE_RE = re.compile(r"(?<!\*)\*([^*\s][^*]*?)\*(?!\*)")
# 兜底:删除所有残留的 * (单/双/多)
_ANY_ASTERISK_RE = re.compile(r"\*+")
def clean_markdown_asterisks(text: str | None) -> str | None:
"""清洗字符串里的 markdown 星号标记残留。
处理顺序:
1. `***text***` -> `text`
2. `**text**` -> `text`(循环直到稳定,处理 `**a****b**` 这种连续)
3. `*text*` -> `text`
4. 兜底:残留的 `*` / `**` / `***` 一律删除(LLM 输出不严谨的脏数据)
对 None / 空串安全返回原值。
"""
if not text:
return text
# 1+2) 先把 *** / ** 多轮替换,直到稳定(处理嵌套/连续多对)
prev: str | None = None
while prev != text:
prev = text
text = _ASTERISK_TRIPLE_RE.sub(r"\1", text)
text = _ASTERISK_DOUBLE_RE.sub(r"\1", text)
# 3) 单星号斜体
text = _ASTERISK_SINGLE_RE.sub(r"\1", text)
# 4) 兜底:删掉所有零散 *
text = _ANY_ASTERISK_RE.sub("", text)
return text
def clean_html_inner_text(html: str | None) -> str | None:
"""清洗 HTML 内的文本节点(保留标签结构和属性)。
用途:`body_zh_formatted` 这种由 LLM 排版产物 ——
不能整个重新生成(会丢 `diary-para` class 和内联 style),
只能用 BeautifulSoup 找到所有文本节点单独清洗。
对 None / 空串安全返回原值。
"""
if not html:
return html
soup = BeautifulSoup(html, "html.parser")
changed = False
for node in list(soup.find_all(string=True)):
# 跳过纯空白文本节点
original = str(node)
if not original or not original.strip():
continue
cleaned = clean_markdown_asterisks(original)
if cleaned != original:
node.replace_with(cleaned)
changed = True
return str(soup) if changed else html
# === 公开版 wrap_html(原 pipeline._wrap_html,提到此处供脚本复用)===
def wrap_html(text: str | None) -> str | None:
"""把清洗后的译文纯文本包成简单的 `<p>` 段落 HTML。
内部会自动调用 `clean_markdown_asterisks` 清洗 `**` / `*` / `***`,
调用方无需"先清洗再 wrap"——这是幂等的,即使输入已清洗也是 no-op。
行为:
- 按 `\n\n` 切段,空段过滤
- 每段包 `<p>...</p>`
- 段落之间用 `\n` 拼接
对 None / 空串返回 None。
"""
if not text:
return None
cleaned = clean_markdown_asterisks(text)
if not cleaned:
return None
parts = [f"<p>{p.strip()}</p>" for p in cleaned.split("\n\n") if p.strip()]
return "\n".join(parts) if parts else None