一、需求说明
开发一个 Python HTML 文本提取工具,将 HTML 转换为纯文本,同时尽可能保留原始内容结构。
- 标签剥离:移除所有 HTML 标签,删除 HTML 注释及
script、style等非正文元素及其内容。 - 结构保留:对
<br>、<div>、<p>、标题、列表等块级元素添加换行,避免文本粘连。 - 空白规范化:连续多个换行符合并为一个,清理普通文本行首、行尾的空格,去除文档首尾空白。
- 代码保护:保留
<pre>、<code>内的代码内容及其缩进,不执行普通文本的行首、行尾空白清理。 - 实体解码:将
&、<等 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> 内的空行也必须原样保留,需要进一步调整为分段规范化:普通文本单独清理,代码块整体跳过清理。