Commit 1fe0a5a9 by luoqi

fix(transforms): strip_html 处理双重编码 —— 剥一轮不够

真实数据打脸:首轮导入 2552 条回访后,有 1 条落库仍是 "<p>阿斯蒂芬撒的发生</p>"。
查源库(customer_return_visit id=266151)发现是**双重编码**:

  <p><span style="color:#000000">…<p>阿斯蒂芬撒的发生</p>…</span></p>
  外层是真标签,内层是被转义的标签。

原实现「剥标签 → 还原实体」跑一轮:外层剥掉后,<p> 被还原成字面的 <p> 留在结果里。
算子逻辑本身没错(单次剥离),但真实数据证明必须处理这种形态 —— 宿主富文本编辑器
把粘贴进来的 HTML 又转义了一层。

改成**有界两轮**:还原实体后若又出现标签则再剥一轮,最多 2 轮。
不做无界循环 —— 构造输入(每轮都能再生标签,如 &amp;lt;p&amp;gt;)会把摄入卡死,
两轮后仍有残留是可接受的,关键是必然终止。第 2 轮只在确实又露出标签时才跑,
绝大多数行一轮即净,无谓开销为零。

回归测试 2 项:线上那条原文按原样断言;畸形嵌套输入断言必然返回(不卡死)。
parent 0333c49b
Pipeline #3661 failed in 0 seconds
...@@ -136,19 +136,31 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown { ...@@ -136,19 +136,31 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown {
* - 收尾清理连续空行/首尾空白;全部剥完只剩空白 → 返回 null(空壳不入库) * - 收尾清理连续空行/首尾空白;全部剥完只剩空白 → 返回 null(空壳不入库)
*/ */
export function stripHtml(input: string): string | null { export function stripHtml(input: string): string | null {
const text = input // 单轮:剥标签 → 还原实体。实体还原可能**再露出字面标签** —— 宿主实测存在双重编码:
// 块级结束/换行标签先转成换行,保住段落边界 // '<p><span…>&lt;p&gt;阿斯蒂芬撒的发生&lt;/p&gt;</span></p>'
.replace(/<\s*br\s*\/?\s*>/gi, '\n') // 外层是真标签、内层是被转义的标签,剥一轮后 &lt;p&gt; 还原成 <p> 留在结果里。
.replace(/<\/\s*(p|div|li|tr|h[1-6]|blockquote)\s*>/gi, '\n') // 故做**有界多轮**:还原后若又出现标签则再剥一轮,最多 2 轮 —— 不做无界循环,
// 其余标签一律剥掉 // 避免构造出的畸形输入(每轮都产生新标签)把摄入卡死。
.replace(/<[^>]*>/g, '') const once = (v: string): string =>
// 实体还原(&amp; 放最后,避免 &amp;lt; 被二次解码成 <) v
.replace(/&nbsp;/gi, ' ') // 块级结束/换行标签先转成换行,保住段落边界
.replace(/&lt;/gi, '<') .replace(/<\s*br\s*\/?\s*>/gi, '\n')
.replace(/&gt;/gi, '>') .replace(/<\/\s*(p|div|li|tr|h[1-6]|blockquote)\s*>/gi, '\n')
.replace(/&quot;/gi, '"') // 其余标签一律剥掉
.replace(/&#39;/g, "'") .replace(/<[^>]*>/g, '')
.replace(/&amp;/gi, '&') // 实体还原(&amp; 放最后,避免 &amp;lt; 被二次解码成 <)
.replace(/&nbsp;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/gi, '&');
let text = once(input);
// 第 2 轮只在确实又露出标签时才跑(绝大多数行一轮即净,不做无谓开销)
if (/<[^>]+>/.test(text)) text = once(text);
text = text
// 连续空行压成一个,首尾清干净 // 连续空行压成一个,首尾清干净
.replace(/[ \t]+\n/g, '\n') .replace(/[ \t]+\n/g, '\n')
.replace(/\n{2,}/g, '\n') .replace(/\n{2,}/g, '\n')
......
...@@ -168,5 +168,20 @@ describe('FRIDAY 回访摄入', () => { ...@@ -168,5 +168,20 @@ describe('FRIDAY 回访摄入', () => {
test('&amp;lt; 不被二次解码成 <(实体还原顺序)', () => { test('&amp;lt; 不被二次解码成 <(实体还原顺序)', () => {
expect(stripHtml('&amp;lt;')).toBe('&lt;'); expect(stripHtml('&amp;lt;')).toBe('&lt;');
}); });
test('⭐ 双重编码:剥完外层标签、还原实体后又露出字面标签 → 再剥一轮', () => {
// 线上真实数据(customer_return_visit id=266151):外层是真标签,内层是被转义的标签。
// 首轮导入时这条**没被剥干净**,落库后仍是 "<p>阿斯蒂芬撒的发生</p>" —— 由此加的有界第二轮。
const real =
'<p><span style="color:#000000"><span style="font-size:13px">' +
'<span style="background-color:#ffffff">&lt;p&gt;阿斯蒂芬撒的发生&lt;/p&gt;</span></span></span></p>';
expect(stripHtml(real)).toBe('阿斯蒂芬撒的发生');
});
test('只剥两轮,不做无界循环(畸形输入不能卡死摄入)', () => {
// 每轮都能再生出标签的构造输入:两轮后仍有残留是可接受的,关键是**必然终止**
const nested = '&amp;amp;lt;p&amp;amp;gt;x';
expect(typeof stripHtml(nested)).toBe('string');
});
}); });
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment