html2text:HTML 中提取文本

一、需求说明

开发一个 Python HTML 文本提取工具,将 HTML 转换为纯文本,同时尽可能保留原始内容结构。

  1. 标签剥离:移除所有 HTML 标签,删除 HTML 注释及 script、style 等非正文元素及其内容。
  2. 结构保留:对 <br>、<div>、<p>、标题、列表等块级元素添加换行,避免文本粘连。
  3. 空白规范化:连续多个换行符合并为一个,清理普通文本行首、行尾的空格,去除文档首尾空白。
  4. 代码保护:保留 <pre>、<code> 内的代码内容及其缩进,不执行普通文本的行首、行尾空白清理。
  5. 实体解码:将 &amp;、&lt; 等 HTML 字符实体还原为对应字符。

实现时使用 HTML 解析器而非正则表达式剥离标签,以更可靠地处理嵌套元素和不规范的 HTML。代码块内部的空白保护优先于普通文本规范化。

二、Python 实现

使用标准库 html.parser.HTMLParser,无需安装第三方依赖。代码通过占位符保护 <code> 和 <pre> 内容,避免普通文本清理规则破坏代码缩进。

#!/usr/bin/env python3
"""Extract plain text from HTML while preserving code whitespace."""

import argparse
import re
import sys
from html.parser import HTMLParser


BLOCK_TAGS = {
    "address", "article", "aside", "blockquote", "br",
    "dd", "div", "dl", "dt", "fieldset", "figcaption",
    "figure", "footer", "form", "h1", "h2", "h3",
    "h4", "h5", "h6", "header", "hr", "li", "main",
    "nav", "ol", "p", "section", "table", "tbody",
    "td", "tfoot", "th", "thead", "tr", "ul",
}

# These elements and all their contents are discarded.
IGNORE_TAGS = {
    "head", "script", "style", "noscript",
    "template", "iframe", "object",
}

VOID_TAGS = {
    "area", "base", "br", "col", "embed", "hr", "img",
    "input", "link", "meta", "param", "source", "track", "wbr",
}


class TextExtractor(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.parts = []
        self.stack = []
        self.ignore_depth = 0
        self.code_depth = 0
        self.code_blocks = []

    def handle_starttag(self, tag, attrs):
        if self.ignore_depth:
            if tag not in VOID_TAGS:
                self.stack.append(tag)
                self.ignore_depth += 1
            return

        if tag in IGNORE_TAGS:
            self.ignore_depth = 1
            self.stack.append(tag)
            return

        if tag in BLOCK_TAGS:
            self.parts.append("\n")

        if tag in {"code", "pre"}:
            self.code_depth += 1

        if tag not in VOID_TAGS:
            self.stack.append(tag)

    def handle_startendtag(self, tag, attrs):
        self.handle_starttag(tag, attrs)
        if tag not in VOID_TAGS:
            self.handle_endtag(tag)

    def handle_endtag(self, tag):
        if self.ignore_depth:
            # HTML is usually properly nested; tolerate malformed input.
            if tag in self.stack:
                while self.stack:
                    current = self.stack.pop()
                    if current in IGNORE_TAGS:
                        self.ignore_depth = 0
                        break
            return

        if tag not in self.stack:
            return

        # Recover from imperfectly nested HTML.
        while self.stack:
            current = self.stack.pop()

            if current in {"code", "pre"}:
                self.code_depth = max(0, self.code_depth - 1)

            if current in BLOCK_TAGS:
                self.parts.append("\n")

            if current == tag:
                break

    def handle_data(self, data):
        if self.ignore_depth or not data:
            return

        if self.code_depth:
            self.code_blocks.append(data)
            self.parts.append(("CODE", len(self.code_blocks) - 1))
        else:
            self.parts.append(data)

    def get_text(self):
        # Replace protected code with unique placeholders.
        output = []
        for part in self.parts:
            if isinstance(part, tuple):
                output.append(f"\x00CODE{part[1]}\x00")
            else:
                output.append(part)

        text = "".join(output)

        # Collapse consecutive newlines and surrounding whitespace.
        text = re.sub(r"[^\S\n]*\n(?:[^\S\n]*\n)+", "\n", text)
        text = re.sub(r"[ \t]*\n[ \t]*", "\n", text)
        text = text.strip()

        # Trim ordinary lines without changing protected code.
        lines = text.split("\n")
        lines = [line.strip() for line in lines]
        text = "\n".join(lines)

        # Restore original code contents.
        for i, code in enumerate(self.code_blocks):
            text = text.replace(f"\x00CODE{i}\x00", code)

        return text


def html_to_text(html: str) -> str:
    parser = TextExtractor()
    parser.feed(html)
    parser.close()
    return parser.get_text()


def main():
    arg_parser = argparse.ArgumentParser(
        description="Extract plain text from HTML."
    )
    arg_parser.add_argument(
        "file", nargs="?", help="HTML file; reads stdin if omitted"
    )
    args = arg_parser.parse_args()

    if args.file:
        with open(args.file, encoding="utf-8") as f:
            html = f.read()
    else:
        html = sys.stdin.read()

    print(html_to_text(html))


if __name__ == "__main__":
    main()

三、使用方法

保存为 html2text.py,直接运行:

# 读取 HTML 文件
python html2text.py input.html

# 通过管道输入
cat input.html | python html2text.py

# 将结果保存到文件
python html2text.py input.html > output.txt

四、注意事项

当前实现适合常规 HTML 文本提取,但有两个值得注意的边界:

  • 代码块保护:占位符方案能保护代码内部空白,但代码块内部包含多个 HTML 文本节点时,会将这些节点分别保护;如果需要严格保留整个 <pre> 的原始内容,建议将代码块作为整体收集后再恢复。
  • HTML 容错:HTMLParser 不会像浏览器那样完整修复所有错误嵌套结构。对于来源复杂的网页,可考虑使用 BeautifulSoup 配合 html5lib,代价是增加第三方依赖。

另外,当前实现对连续换行的规范化是全局进行的。如果要求 <pre> 内的空行也必须原样保留,需要进一步调整为分段规范化:普通文本单独清理,代码块整体跳过清理。

如果你有魔法,你可以看到一个评论框~