多语言网页采集:从中文乱码到正确的 CSV 导出
用 Python 建立原始字节、编码判断、严格解码和 CSV 回读流程,定位中文网页在下载、清洗和导出阶段出现的乱码。
中文页面出现乱码时,首先要判断损坏发生在哪里:响应字节被错误解码、正确文本被二次转码,还是 CSV 被表格软件按错误编码打开。把所有地方都改成 UTF-8,可能只是把同一份乱码重新保存了一遍。
本文面向已经能下载网页、但需要处理中文与混合编码来源的 Python 开发者。我们先用两份内容相同、编码不同的页面材料建立可解释的流程,再接入真实网页。最终得到正确的中文 CSV,以及能回溯每条记录所用编码的 JSON 记录。
区分三个层次
| 层次 | 示例 | 应该检查什么 |
|---|---|---|
| 响应字节 | UTF-8 或 GB18030 编码的 HTML | 字节是否完整,是否已解压 |
| 内存文本 | Python 的 str | 解码方式是否与字节一致 |
| 输出文件 | UTF-8 CSV | 写入编码与打开方式是否一致 |
HTTP 的 gzip 是传输压缩,不是字符编码。压缩字节不能直接按 GB18030 解码。下文的在线下载请求明确要求未压缩响应;若服务仍返回压缩数据,则拒绝继续处理,以免把传输问题误判成中文编码问题。
编码同样不能通过“能否解码”唯一确定。某些字节在多个编码中都合法,但含义不同。我们采用站点约定、响应声明、页面声明的证据顺序;没有依据就保留原始材料,不进行无声猜测。
准备环境与两种输入
使用 Python 3.12 或更新版本。本例只依赖标准库。在新目录中创建 encoding_flow.py,放入下面的完整代码。它会生成两份明确标注编码的演示字节,不需要启动测试站点。
代码同时提供 fetch,便于稍后接入在线来源;默认主流程只处理正文里定义的材料。
import csv
import hashlib
import json
import re
from pathlib import Path
from urllib.request import Request, urlopen
OUT = Path("encoding-output")
OUT.mkdir(exist_ok=True)
ALLOWED = {"utf-8", "utf-8-sig", "gb18030", "gbk"}
def fetch(url):
request = Request(url, headers={"Accept-Encoding": "identity"})
with urlopen(request, timeout=20) as response:
if response.headers.get("Content-Encoding", "identity") != "identity":
raise ValueError("响应仍被压缩,请先按 Content-Encoding 解压")
return (
response.read(),
response.headers.get_content_charset(),
response.geturl(),
)
def decode_html(raw, declared=None, override=None):
if override:
selected, reason = override.lower(), "site_override"
elif raw.startswith(b"\xef\xbb\xbf"):
selected, reason = "utf-8-sig", "bom"
elif declared:
selected, reason = declared.lower(), "http_header"
else:
head = raw[:4096].decode("ascii", errors="ignore")
match = re.search(r'charset\s*=\s*["\x27]?([\w-]+)', head, re.I)
if not match:
raise ValueError("缺少编码依据,请提供站点编码配置")
selected, reason = match[1].lower(), "html_meta"
if selected not in ALLOWED:
raise ValueError(f"尚未支持的编码:{selected}")
return raw.decode(selected, errors="strict"), selected, reason
def extract_title(text):
match = re.search(r"<h1>(.*?)</h1>", text, re.S | re.I)
if not match:
raise ValueError("演示页面缺少 h1")
return re.sub(r"\s+", " ", match[1]).strip()
materials = [
("utf8", '<meta charset="utf-8"><h1>中文采集指南</h1>'.encode("utf-8")),
("gb", '<meta charset="gb18030"><h1>中文采集指南</h1>'.encode("gb18030")),
]
rows = []
for name, raw in materials:
(OUT / f"{name}.bin").write_bytes(raw)
text, encoding, reason = decode_html(raw)
rows.append({
"source": name,
"title": extract_title(text),
"encoding": encoding,
"encoding_reason": reason,
"raw_sha256": hashlib.sha256(raw).hexdigest(),
})
with (OUT / "records.json").open("w", encoding="utf-8") as stream:
json.dump(rows, stream, ensure_ascii=False, indent=2)
with (OUT / "titles.csv").open("w", encoding="utf-8-sig", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
with (OUT / "titles.csv").open(encoding="utf-8-sig", newline="") as stream:
restored = list(csv.DictReader(stream))
if [row["title"] for row in restored] != ["中文采集指南"] * 2:
raise ValueError("CSV 回读内容与提取结果不一致")
print(json.dumps(rows, ensure_ascii=False, indent=2))运行方式:
python encoding_flow.pyextract_title 只处理这里明确给出的简化页面,不是通用 HTML 解析器。接入真实网页时,应在完成解码后使用现有 DOM 解析器读取字段;复杂标签、实体和嵌套结构不能交给这个演示正则。
为什么严格解码,为什么保留字节
errors="ignore" 会删掉无法处理的字节,errors="replace" 会插入替换字符。这两种策略适合某些展示场景,但采集主数据时可能把损坏隐藏成“成功记录”。本例只有扫描 HTML 开头的编码标签时忽略非 ASCII 字节,因为那一步读取的是英文属性名;真正生成正文时使用严格解码。
utf8.bin 与 gb.bin 是原始材料。即使后续解析规则错误,也能重新解码,不必再次访问可能已变化的页面。SHA-256 便于确认重新处理的是不是同一份材料,它不是内容正确性的证明。
编码选择顺序是本例的明确策略,不代表所有网站都诚实声明编码。已知响应头错误的来源可以用 override="gb18030" 覆盖,但应把这个约定绑定到具体来源,不能遇到失败就对所有页面轮流尝试十几种编码。Python 的严格解码与错误处理规则见 codecs 文档。
怎样接入真实页面
在代码末尾替换演示循环,按下面的方式取得一个来源的材料。地址是占位符,需要替换为实际页面:
raw, declared, final_url = fetch("https://example.16yun.cn/article")
text, encoding, reason = decode_html(raw, declared=declared)
print(final_url, encoding, reason)
print(extract_title(text))这段只演示下载到解码的连接,不代表占位站点存在对应接口。若来源明确使用 GB18030、响应却错误声明 UTF-8,可在 decode_html 调用中添加 override="gb18030",然后人工核对页面中的中文标题与至少一段正文。
不要对已经是正确 str 的文本再执行 text.encode("latin1").decode("utf-8")。这类操作只在已知某一步确实把 UTF-8 字节错误映射成 Latin-1 字符时才可能修复;对正常中文执行反而会报错或损坏数据。
检查输出与表格软件
预期两个记录的标题都是“中文采集指南”,但编码字段分别是 utf-8 和 gb18030。下面只展示关键字段,实际输出还包括选择依据与字节摘要:
[
{"source": "utf8", "title": "中文采集指南", "encoding": "utf-8"},
{"source": "gb", "title": "中文采集指南", "encoding": "gb18030"}
]JSON 中的 ensure_ascii=False 决定是否直接显示汉字,不决定写入文件的编码。真正的编码由 open(..., encoding="utf-8") 控制。看到 \u4e2d 形式不一定是乱码,它可能只是 JSON 的合法转义。
CSV 使用 utf-8-sig,为需要 BOM 帮助判断编码的表格软件提供提示。机器之间传输可以约定纯 UTF-8,但读写双方必须一致。用 newline="" 让 CSV 模块负责换行,避免某些平台多出空行;规则见 Python CSV 文档。
回读检查只能证明这条写入—读取路径保持了标题,不证明所有消费者都会选对编码。若文件在 Python 回读正确、Excel 中错误,使用“从文本/CSV 导入”明确选择 UTF-8,先不要修改爬虫的解码规则。
常见错误的定位顺序
直接报 UnicodeDecodeError。 看失败发生在下载后的 decode 还是文件读取阶段。前者检查编码依据和原始字节;后者检查输出文件实际编码。不要马上改成忽略错误;保留失败材料,修正来源配置后应能严格解码。
中文没有报错,但变成另一组字符。 这是错误编码仍能产生合法文本的可能表现。比对原始页面的已知标题,检查响应头和 meta 是否矛盾。仅靠成功返回字符串不能选出正确编码。
内容中出现“�”。 检查此前是否使用替换解码,或者上游服务已经返回损坏文本。回到原始字节重新处理;如果原始材料里就已经是替换字符,后续 CSV 设置不能还原丢失信息。
字段中有不间断空格或全角字符。 先区分格式差异与编码错误。可以针对搜索键做 Unicode 规范化,但原始名称、法律标识和展示文案应保留。不要用全局清洗把具有业务意义的符号删掉。
CSV 多出空行,或带逗号的标题被拆列。 用 CSV writer 和 newline="";不要手动以逗号拼字符串。回读行数和标题内容都正确后,再检查外部软件的导入分隔符。
可靠的多语言采集应保留原始字节、记录解码依据,并在输出端做回读。这样乱码发生时,你能定位具体阶段,而不是不断尝试不同的编码名称。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。