Files
gungun88 27bce3f694 fix(data): 清除标题里的 Markdown 强调标记
问题:部分商家把 Markdown 直接写进帖子标题,标记泄漏到界面,例如
  **25G Unmetered** 30% lifetime discount - Dedicated Servers
  **10G FULL UNMETERED ** - 3 Months Half Price! - from $87.00

影响:title 与由标题派生的 provider 各 2 处,共 4 处。

修复:在解析层新增 strip_md_markers,处理成对标记
(**x** / __x__ / ~~x~~)及未配对的裸标记,并清理多余空格。
刻意不动单个 * 与 _ —— 它们可能是正常内容(通配符、型号、
servers_guru 这类用户名),测试已覆盖这些反例。

放在解析层而非渲染层:provider 由 title 派生,在源头清理可
让两个字段同时干净,也避免后续新增字段再次踩到。

验证:146 测试通过(新增 8 个);实际数据中标记数 4 -> 0。
2026-10-16 16:27:08 +08:00

243 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""文本工具:HTML 清洗、单位换算、拍平表格重建。
这三个是抽取层的地基,也是最容易出错的地方,
因此单独成模块并配单元测试。
"""
from __future__ import annotations
import html
import re
# --------------------------------------------------------------------------
# HTML 清洗
# --------------------------------------------------------------------------
RE_TAG = re.compile(r"<[^>]+>")
RE_BQ_OPEN = re.compile(r"<blockquote\b", re.I)
RE_BQ_CLOSE = re.compile(r"</blockquote\s*>", re.I)
# 把 HTML 切成「标签 / 文本」token 流
RE_TOKEN = re.compile(r"<[^>]+>|[^<]+", re.S)
def strip_quotes_depth_aware(fragment: str) -> str:
"""剥离任意深度的嵌套 <blockquote>,只保留深度 0 的正文。
LET 的引用是**多层嵌套**的(一条回复可引用「A 引用 B 引用 C」)。
简单的非贪婪正则 ``<blockquote.*?</blockquote>`` 会从最内层开始剥,
反而把外层被引用的**别人的话**留在结果里 —— 导致把用户的话
当成商家表态。因此必须按嵌套深度过滤。
"""
out: list[str] = []
depth = 0
for tok in RE_TOKEN.findall(fragment):
if tok.startswith("<"):
if RE_BQ_OPEN.match(tok):
depth += 1
elif RE_BQ_CLOSE.match(tok):
depth = max(0, depth - 1)
elif depth == 0 and re.match(
r"</(p|div|li|h[1-6]|br)\s*/?>", tok, re.I):
out.append("\n")
continue
if depth == 0:
out.append(tok)
return "".join(out)
def strip_html(s: str, *, drop_quotes: bool = False) -> str:
"""HTML -> 纯文本。
drop_quotes=True 时同时剔除(含嵌套的)引用块,
用于回帖正文,避免引用内容污染判定。
"""
if not s:
return ""
if drop_quotes:
s = strip_quotes_depth_aware(s)
s = re.sub(r"<script.*?</script>", " ", s, flags=re.S | re.I)
s = re.sub(r"<style.*?</style>", " ", s, flags=re.S | re.I)
s = re.sub(r"<br\s*/?>", "\n", s, flags=re.I)
s = re.sub(r"</(p|div|li|tr|h[1-6]|td|th)>", "\n", s, flags=re.I)
s = RE_TAG.sub(" ", s)
s = html.unescape(s)
s = s.replace("\u00a0", " ").replace("\u200b", "")
s = re.sub(r"[ \t]+", " ", s)
s = re.sub(r"\n\s*\n+", "\n", s)
return s.strip()
# 有些商家直接把 Markdown 写进帖子标题,例如
# "**25G Unmetered** 30% lifetime discount"。
RE_MD_PAIRED = re.compile(r"\*\*(.+?)\*\*|__(.+?)__|~~(.+?)~~", re.S)
RE_MD_LEFTOVER = re.compile(r"\*{2,}|_{2,}|~{2,}")
def strip_md_markers(s: str) -> str:
"""去掉 Markdown 强调标记,保留其中的文字。
只处理**成对**标记(**x** / __x__ / ~~x~~)以及没有配对的裸标记。
刻意不动单个 * 或 _ —— 它们可能是正常内容(型号、下划线、通配符)。
"""
if not s:
return ""
out = RE_MD_PAIRED.sub(
lambda m: m.group(1) or m.group(2) or m.group(3) or "", s)
out = RE_MD_LEFTOVER.sub("", out)
out = re.sub(r"[ \t]+", " ", out)
return out.strip()
# --------------------------------------------------------------------------
# 单位换算
# --------------------------------------------------------------------------
def to_float(s: str) -> float:
"""'1,5' -> 1.5(欧洲小数逗号)。"""
return float(s.replace(",", "."))
def to_gb(amount: float, unit: str) -> float:
u = unit.upper()
if u == "TB":
return amount * 1024.0
if u == "MB":
return amount / 1024.0
return amount
def to_tb(amount: float, unit: str) -> float:
u = unit.upper()
if u == "GB":
return round(amount / 1024.0, 2)
return round(amount, 2)
# --------------------------------------------------------------------------
# 拍平表格重建
# --------------------------------------------------------------------------
#
# HTML 表格被 strip_html 拍平后,每个单元格独占一行:
#
# Anniversary XXL / 8 Cores / 32 GB / 300 GB / 1 IPv4 / /64 IPv6 / 25 TB / €129
#
# 关键:**裸容量单元格本身不带语义**("300 GB" 可能是内存/硬盘/流量),
# 必须按**出现顺序**推断。LET 的套餐表列序高度一致:
# 套餐名 | CPU | RAM | 存储 | IPv4 | IPv6 | 流量 | 价格
CELL_VCPU = re.compile(
r"^(\d{1,3})\s*(?:x\s*)?(?:Cores?|vCores?|vCPUs?|Kerne)$", re.I)
CELL_RAM = re.compile(
r"^(\d+(?:[.,]\d+)?)\s*(GB|MB|TB)(?:\s*DDR[45])?(?:\s*ECC)?$", re.I)
CELL_STORAGE_TYPED = re.compile(
r"^(\d+(?:[.,]\d+)?)\s*(GB|TB)\s*"
r"(NVMe(?:\s*(?:Gen\s*\d|SSD))?|SSD|HDD|SATA)$", re.I)
CELL_SIZE_BARE = re.compile(r"^(\d+(?:[.,]\d+)?)\s*(GB|MB|TB)$", re.I)
CELL_BW = re.compile(r"^(\d+(?:[.,]\d+)?)\s*(TB|GB)$", re.I)
CELL_PRICE = re.compile(
r"^([$€£])\s*(\d+(?:[.,]\d+)?)$|^(\d+(?:[.,]\d+)?)\s*([$€£])$")
CELL_IPV4 = re.compile(r"^\d+\s*IPv4$|^IPv4$", re.I)
CELL_IPV6 = re.compile(r"^/?64\s*IPv6$|^IPv6$", re.I)
CELL_PLAN = re.compile(r"^[A-Za-z][\w\s\-\.\+]{1,40}$")
VALUE_CELLS = (CELL_VCPU, CELL_RAM, CELL_STORAGE_TYPED, CELL_SIZE_BARE,
CELL_BW, CELL_PRICE, CELL_IPV4, CELL_IPV6)
def is_value_cell(s: str) -> bool:
return any(rx.match(s) for rx in VALUE_CELLS)
def parse_flat_tables(body: str) -> list[dict]:
"""从拍平的文本里恢复出「套餐行」列表。"""
lines = [ln.strip() for ln in body.split("\n")]
plans: list[dict] = []
i, n = 0, len(lines)
while i < n:
name = lines[i]
if (not name or not CELL_PLAN.match(name) or is_value_cell(name)
or len(name.split()) > 5):
i += 1
continue
seq: list[tuple[str, object]] = []
last = i
for j in range(i + 1, min(i + 16, n)):
cell = lines[j]
if not cell:
continue
# 撞到下一个套餐名 -> 结束本块
if CELL_PLAN.match(cell) and not is_value_cell(cell):
break
if CELL_VCPU.match(cell):
seq.append(("vcpu", int(CELL_VCPU.match(cell).group(1))))
elif CELL_STORAGE_TYPED.match(cell):
mm = CELL_STORAGE_TYPED.match(cell)
seq.append(("storage", (to_gb(to_float(mm.group(1)), mm.group(2)),
mm.group(3).strip())))
elif CELL_IPV4.match(cell) or CELL_IPV6.match(cell):
seq.append(("ip", cell))
elif CELL_PRICE.match(cell):
mm = CELL_PRICE.match(cell)
if mm.group(1):
seq.append(("price", (to_float(mm.group(2)), mm.group(1))))
else:
seq.append(("price", (to_float(mm.group(3)), mm.group(4))))
elif CELL_RAM.match(cell):
mm = CELL_RAM.match(cell)
seq.append(("size", (to_float(mm.group(1)), mm.group(2).upper())))
else:
break
last = j
if not seq:
i += 1
continue
vcpu = next((v for k, v in seq if k == "vcpu"), None)
price = next((v for k, v in seq if k == "price"), None)
typed_storage = next((v for k, v in seq if k == "storage"), None)
sizes = [v for k, v in seq if k == "size"]
ram = None
storage = None
bw = None
if typed_storage:
storage = {"gb": typed_storage[0], "type": typed_storage[1]}
for amt, unit in sizes:
if unit in ("GB", "MB") and ram is None:
ram = to_gb(amt, unit)
elif unit == "TB" and bw is None:
bw = to_tb(amt, unit)
else:
# 裸容量:按顺序 第1个=RAM, 第2个=存储, TB=流量
for amt, unit in sizes:
if unit in ("GB", "MB"):
if ram is None:
ram = to_gb(amt, unit)
elif storage is None:
storage = {"gb": to_gb(amt, unit), "type": "unspecified"}
elif bw is None:
bw = to_tb(amt, unit)
elif unit == "TB":
if bw is None:
bw = to_tb(amt, unit)
if vcpu is not None and (ram is not None or storage is not None):
plans.append({
"name": name, "vcpu": vcpu, "ram_gb": ram,
"storage_gb": storage["gb"] if storage else None,
"storage_type": storage["type"] if storage else None,
"bandwidth_tb": bw,
"price": ({"amount": price[0], "currency": price[1]}
if price else None),
})
i = last + 1
else:
i += 1
return plans