把公开网页整理成结构化语料,是知识库、检索和问答类项目的前置步骤。可用的方案很多:成熟的正文抽取库、无头浏览器、第三方解析服务。但在内网、离线或需要审计的场景里,引入外部依赖往往比写一份小实现更麻烦。
本文给出一份不依赖第三方库的抽取实现:用标准库解析 HTML,按文本密度选出主内容区,再抽取标题、正文段落、图片与表格,输出 JSON。约一百五十行,可直接放进批处理脚本。
一、先把噪声去掉
网页里真正属于正文的文本占比通常很低。导航、页脚、推荐位、评论区、脚本样式都要在解析阶段就排除,否则后面的打分会被污染。
python
from html.parser import HTMLParser
DROP_TAGS = {'script', 'style', 'noscript', 'iframe', 'svg', 'form', 'nav', 'footer'}
BLOCK_TAGS = {'p', 'div', 'section', 'article', 'li', 'td', 'blockquote', 'h1', 'h2', 'h3', 'h4'}
class PlainParser(HTMLParser):
"""把 HTML 压成 (tag, text, attrs) 的块序列,丢弃噪声标签"""
def __init__(self):
super().__init__(convert_charrefs=True)
self.blocks, self.stack, self.skip = [], [], 0
def handle_starttag(self, tag, attrs):
if tag in DROP_TAGS:
self.skip += 1
self.stack.append(tag)
if tag in BLOCK_TAGS and not self.skip:
self.blocks.append([tag, '', dict(attrs)])
def handle_endtag(self, tag):
if tag in DROP_TAGS and self.skip:
self.skip -= 1
if self.stack and self.stack[-1] == tag:
self.stack.pop()
def handle_data(self, data):
text = data.strip()
if not text or self.skip:
return
if self.blocks and self.blocks[-1][0] in BLOCK_TAGS:
self.blocks[-1][1] += text
else:
self.blocks.append(['text', text, {}])

用 HTMLParser 而不是正则的好处是嵌套标签能被正确闭合,skip 计数也天然处理了多层嵌套的脚本块。
二、用文本密度选主内容区
主内容区的判据通常有两个:单位标签承载的文本量高 ,链接文本占比低。导航区文字少、链接多;正文区文字多、链接少。把这两条合起来打分,就能把主容器选出来。
python
import re
LINK_TEXT = re.compile(r'<a\b[^>]*>(.*?)</a>', re.S | re.I)
def score_blocks(html, blocks, min_len=200):
"""给每个块打分:文本长度 × (1 - 链接占比),并做父级聚合"""
scores = []
for idx, (tag, text, attrs) in enumerate(blocks):
n = len(text)
if n < min_len:
continue
# 该块在原始 HTML 中的片段(用于估算链接占比)
frag = text[:4000]
links = sum(len(m.group(1)) for m in LINK_TEXT.finditer(frag))
link_ratio = links / max(len(frag), 1)
# 长度分 + 标点分(正文标点密度明显高于导航)
punct = sum(text.count(p) for p in '。!?;')
score = n * (1 - min(link_ratio, 0.9)) + punct * 8
scores.append((score, idx))
scores.sort(reverse=True)
return [idx for _, idx in scores[:3]]

三个可调参数:min_len 过滤碎块,标点权重让正文优先于列表堆砌,top-3 聚合避免把正文拆在多个兄弟容器里时漏掉一半。不同站点的参数差异不小,实践中更稳的做法是按域名维护一份参数表,把调好的值记下来,而不是追求一套通用参数。
三、从块序列到结构化字段
选出主内容区之后,按标签类型归位到字段:标题取层级最高的标题块,正文取段落块,图片与表格单独抽。
python
def to_structured(blocks, keep_idx):
keep = set(keep_idx)
out = {'title': '', 'paragraphs': [], 'headings': [], 'images': [], 'tables': []}
for i, (tag, text, attrs) in enumerate(blocks):
if i not in keep and tag not in ('h1', 'h2', 'h3'):
continue
if tag == 'h1' and not out['title']:
out['title'] = text.strip()
elif tag in ('h2', 'h3', 'h4'):
out['headings'].append({'level': int(tag[1]), 'text': text.strip()})
elif tag == 'img':
src = attrs.get('src') or attrs.get('data-src') or ''
if src:
out['images'].append({'src': src, 'alt': attrs.get('alt', '')})
else:
t = text.strip()
if len(t) >= 20: # 短句多为提示语或按钮文案
out['paragraphs'].append(t)
return out
一个容易忽略的细节:很多站点用 data-src 做懒加载 ,src 指向占位图。抽取时要把两者都看一遍,并过滤 1x1 的统计像素图。
四、正文字段的清洗
抽出来的段落还需要一轮清洗,去向主要是三类。

· 重复内容:同一段文字因响应式布局出现两次,按段落哈希去重。
· 尾部噪声:版权声明、编辑署名、上一篇下一篇,通常集中在尾部,可按关键词与长度过滤。
· 不可见字符 :零宽空格、不换行空格(\u00a0)、软连字符,直接归一化掉。
python
import hashlib, unicodedata
TAIL_NOISE = ('版权', '转载请注明', '责任编辑', '上一篇', '下一篇', '免责声明')
def clean(paras, max_keep=400):
seen, out = set(), []
for p in paras:
p = unicodedata.normalize('NFKC', p).replace('\u200b', '').strip()
if any(k in p for k in TAIL_NOISE):
continue
h = hashlib.md5(p.encode('utf-8')).hexdigest()
if h in seen:
continue
seen.add(h)
out.append(p)
return out[:max_keep]
NFKC 归一化顺带解决了全角数字与全角标点的问题,对后续分词与检索都有好处。
五、边界与验收
这份实现能覆盖大部分资讯页与博客页,明确的边界也需要写清楚。
· 正文由前端渲染的页面:静态 HTML 里没有内容,需要先渲染,本方案不适用。
· 正文与评论在同一容器内的页面 :密度相近时容易混入,需要在参数表里为这类站点单独调 min_len。
· 分页内容 :多页文章要按 rel="next" 或 URL 规律抓取后合并,抽取层不负责这件事。
验收方式可以量化:取二十个目标站点的页面,人工标出正文段落的起止,用抽取结果计算准确率与召回率,把低于阈值的站点挑出来单独调参。这份标注集同时也是回归测试的输入,改动抽取逻辑后重跑一次即可看出有没有退化。
整份实现只有标准库依赖,适合放进离线流水线。把它和后续的去重、切分、入库步骤串起来,就得到一条从网页到结构化语料的完整链路。