用标准库做网页正文抽取:从 HTML 到结构化字段的轻量实现

把公开网页整理成结构化语料,是知识库、检索和问答类项目的前置步骤。可用的方案很多:成熟的正文抽取库、无头浏览器、第三方解析服务。但在内网、离线或需要审计的场景里,引入外部依赖往往比写一份小实现更麻烦。

本文给出一份不依赖第三方库的抽取实现:用标准库解析 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 规律抓取后合并,抽取层不负责这件事。

验收方式可以量化:取二十个目标站点的页面,人工标出正文段落的起止,用抽取结果计算准确率与召回率,把低于阈值的站点挑出来单独调参。这份标注集同时也是回归测试的输入,改动抽取逻辑后重跑一次即可看出有没有退化。

整份实现只有标准库依赖,适合放进离线流水线。把它和后续的去重、切分、入库步骤串起来,就得到一条从网页到结构化语料的完整链路。

相关推荐
高级程序源1 小时前
django校企合作实习基地管理系统82506-计算机课程设计、毕业设计
vue.js·后端·python·mysql·django·课程设计·pygame
东方芷兰1 小时前
Agent 技术摘要 02 —— 区块链、比特币、挖矿、ETF、比特币疯涨事件、以太坊、以太币、显卡荒事件
人工智能·笔记·python
贝猫说python1 小时前
阿里云部署qwen 大模型从0-1,第2步:阿里云平台创建ubuntu实例
python
峥嵘life1 小时前
2026华为AI码道 CodeArts 使用分享:Windows端 + 服务器CLI 实战总结
android·大数据·开发语言·python
贝猫说python1 小时前
阿里云部署qwen 大模型从0-1,第3步:阿里云服务器上部署大模型
python
少陽君1 小时前
服务器服务检查报告
python
x秀x1 小时前
sam3新手使用教程
开发语言·python
Java后端的Ai之路1 小时前
大模型LLM评估完全指南
开发语言·人工智能·python·llm·评估
外收内放1 小时前
Python学习记录25(基础知识终结篇)
python·学习