- 新增 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
121 lines
4.2 KiB
Python
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 |