Files
nanocode/personal-blog/backend/routers/content_prune.py
T
Ubuntu e20c1656b7 a
2026-08-06 16:51:39 +08:00

126 lines
4.7 KiB
Python
Raw 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.
"""
正文区 vs 噪音过滤(crawl4ai Pruning 思路的本地化实现)。
应用场景:HTML → Markdown 转换前对整页 DOM 做剪枝,去掉导航/侧栏/评论区/
相关推荐等噪音容器,避免噪音进入正文 Markdown。
设计来源:crawl4ai 的 PruningContentFilter(/home/ubuntu/forgithub/crawl4ai
/crawl4ai/content_filter_strategy.py),核心为递归评分剪枝:
score = 0.4*text_density + 0.2*link_density + 0.2*tag_weight
+ 0.1*class_id_weight + 0.1*text_length
相对原版的两处修正(实测 CSDN 文章页验证,7505 → 2776 字符、正文零丢失):
1. 原版 text_length 项 = log(text_len+1) 无上限,超大噪音容器(如 3600+
字符的相关推荐列表)得分 0.90 远超阈值 0.48 而残留;
本实现将其封顶为 5.0。
2. 原版对命中负面模式(recommend/comment/sidebar/ads...)的类名仅做 -0.5
小惩罚;本实现改为硬删——类名/ID 命中负面模式的容器几乎必然是噪音。
安全性:质量门控——剪枝后正文文本 < min_output_chars 或 < 原文本 min_ratio
时回退返回原 HTML,避免误删导致正文丢失。对已定位正文容器的局部 HTML
调用时无副作用(正文容器内节点评分普遍高于阈值)。
"""
import math
import re
from bs4 import BeautifulSoup, Comment
# 确定的噪音标签:直接删除整个子树
EXCLUDED_TAGS = {
"nav", "footer", "header", "aside",
"script", "style", "form", "iframe", "noscript",
}
# 负面类名/ID:命中直接硬删(相对 crawl4ai 的 -0.5 惩罚改为硬删)
NEGATIVE_PATTERN = re.compile(
r"nav|header|footer|sidebar|comment|ads|advert|promo|social|share|"
r"recommend|related|breadcrumb|toolbar|menu|pagination|qr|qrcode|"
r"copyright|tip|hot|rank",
re.I,
)
# 正文标签权重(决定文本块的评分加成)
TAG_WEIGHT = {
"div": 0.5, "p": 1.0, "article": 1.5, "section": 1.2, "main": 1.5,
"li": 0.5, "td": 0.5, "th": 0.5,
}
# 保护标签:正文关键内容(代码/表格/图片/引用)不参与剪枝
PROTECT_TAGS = {"pre", "code", "table", "figure", "blockquote", "img"}
def _class_id_text(node) -> str:
return " ".join(node.get("class", []) + [node.get("id", "")])
def _prune_tree(node, threshold: float) -> None:
"""递归剪枝:对直接子节点评分,低于阈值或命中负面类名则删除。"""
for child in list(node.find_all(recursive=False)):
if child.name in PROTECT_TAGS:
continue
# 硬规则:负面类名/ID 直接删除
if (child.get("class") or child.get("id")) and NEGATIVE_PATTERN.search(
_class_id_text(child)
):
child.decompose()
continue
text_len = len(child.get_text(strip=True))
tag_len = len(str(child))
if tag_len == 0:
continue
link_text_len = sum(len(a.get_text(strip=True)) for a in child.find_all("a"))
text_density = text_len / tag_len
link_density = 1.0 - (link_text_len / text_len if text_len else 0.0)
tag_weight = TAG_WEIGHT.get(child.name, 0.5)
text_length = min(math.log(text_len + 1), 5.0) # 长度项封顶,避免大容器评分失真
score = (
0.4 * text_density
+ 0.2 * link_density
+ 0.2 * tag_weight
+ 0.1 * text_length
)
if score < threshold:
child.decompose()
else:
_prune_tree(child, threshold)
def prune_html_noise(
html: str,
threshold: float = 0.5,
min_output_chars: int = 300,
min_ratio: float = 0.2,
) -> str:
"""
对整页 HTML 做正文区 vs 噪音过滤,返回剪枝后的 HTML。
质量门控:剪枝后正文文本 < min_output_chars 或 < 原文本 min_ratio 时,
认为剪枝疑似误删,回退返回原 HTML(安全兜底)。
"""
if not html or not isinstance(html, str) or "<body" not in html.lower():
return html
try:
soup = BeautifulSoup(html, "lxml")
except Exception:
soup = BeautifulSoup(html, "html.parser")
if not soup.body:
return html
before_text = soup.body.get_text(strip=True)
if len(before_text) < min_output_chars:
return html # 页面本身就没多少文本,不剪
# 删除噪音标签与注释
for tag in list(soup.find_all(EXCLUDED_TAGS)):
tag.decompose()
for comment in list(soup.find_all(string=lambda s: isinstance(s, Comment))):
comment.extract()
_prune_tree(soup.body, threshold)
after_text = soup.body.get_text(strip=True)
if len(after_text) < min_output_chars or len(after_text) < len(before_text) * min_ratio:
return html # 疑似误删,回退原 HTML
return str(soup.body)