fix(ext-data): CSV 编码转换保留原始换行, 避免最后一列被推断为字符串

This commit is contained in:
kevin9327
2026-09-07 08:07:02 +09:00
parent 4a27dc45d5
commit f2fac2e8f0
2 changed files with 90 additions and 1 deletions
+7 -1
View File
@@ -477,7 +477,13 @@ def ensure_utf8_csv(file_path: Path) -> Path:
except UnicodeDecodeError:
continue
out_path = file_path.with_suffix(file_path.suffix + ".utf8")
out_path.write_text(text, encoding="utf-8")
# newline="" 关闭写入时的换行转换。默认转换在 Windows 上把文本里的 \n
# 写成 \r\n,源文件本来就是 CRLF 时就变成 \r\r\n,多出来的 \r 被 Polars
# 当作最后一列内容的一部分:列名变成 "收盘价\r",每行的值变成 "12.34\r"
# 该列于是被推断为字符串而不是数值。本函数针对的同花顺/东财/通达信和
# Windows Excel 导出文件用的正是 CRLF。
with out_path.open("w", encoding="utf-8", newline="") as f:
f.write(text)
logger.info("CSV 编码转换 %s%s (%s)", file_path.name, out_path.name, enc)
return out_path
# 都无法解码:返回原路径,让 Polars 抛出更精确的原始错误
@@ -0,0 +1,83 @@
"""CSV 编码转换的换行保真回归测试。
ensure_utf8_csv 把 GBK 系 CSV 转成 UTF-8 再交给 Polars。转换本身用
`write_text`,默认换行转换在 Windows 上把文本里的 \n 写成 \r\n;源文件本来
就是 CRLF 时结果是 \r\r\n。多出来的 \r 会落在最后一列,使列名带上 \r、每行
的值也带上 \r,该列于是从数值被推断成字符串。
而本函数针对的正是同花顺 / 东财 / 通达信和 Windows 中文 Excel 导出的文件,
这些工具导出的就是 CRLF,所以这条路径上的文件基本都会命中。
判据取“同一份内容的 UTF-8 版本”:它不经过转换,是这份数据本该被解析成的
样子,转换后的 GBK 版本必须与它逐列一致。纯本地文件操作,不需要数据源。
"""
from __future__ import annotations
from pathlib import Path
import polars as pl
from app.services.ext_data import ensure_utf8_csv
# 名称/代码/收盘价三列,CRLF 换行,最后一列是数值——受损时最容易看出来。
CSV_TEXT = "名称,代码,收盘价\r\n浦发银行,600000,12.34\r\n招商银行,600036,45.67\r\n"
def _read(path: Path) -> pl.DataFrame:
return pl.read_csv(ensure_utf8_csv(path), infer_schema_length=10000)
def test_gbk_crlf_csv_parses_the_same_as_its_utf8_twin(tmp_path: Path) -> None:
gbk = tmp_path / "gbk.csv"
gbk.write_bytes(CSV_TEXT.encode("gb18030"))
utf8 = tmp_path / "utf8.csv"
utf8.write_bytes(CSV_TEXT.encode("utf-8"))
converted = _read(gbk)
untouched = _read(utf8)
# UTF-8 那份原样返回,不经过转换,所以它就是判据。
assert converted.columns == untouched.columns
assert converted.dtypes == untouched.dtypes
assert converted.to_dicts() == untouched.to_dicts()
def test_gbk_crlf_csv_keeps_the_last_column_numeric(tmp_path: Path) -> None:
path = tmp_path / "gbk.csv"
path.write_bytes(CSV_TEXT.encode("gb18030"))
df = _read(path)
# 列名不带 \r,最后一列仍是数值——修复前分别是 "收盘价\r" 和 String。
assert df.columns == ["名称", "代码", "收盘价"]
assert df["收盘价"].dtype == pl.Float64
assert df["收盘价"].to_list() == [12.34, 45.67]
def test_transcoded_file_has_no_extra_carriage_return(tmp_path: Path) -> None:
path = tmp_path / "gbk.csv"
path.write_bytes(CSV_TEXT.encode("gb18030"))
out = ensure_utf8_csv(path)
assert out != path # 确实走了转换分支
assert out.read_bytes().count(b"\r") == path.read_bytes().count(b"\r")
def test_lf_only_source_stays_lf(tmp_path: Path) -> None:
# 反向保护:源文件是 LF 时不能被转换成 CRLF。
lf_text = CSV_TEXT.replace("\r\n", "\n")
path = tmp_path / "gbk_lf.csv"
path.write_bytes(lf_text.encode("gb18030"))
out = ensure_utf8_csv(path)
assert out.read_bytes().count(b"\r") == 0
assert _read(path)["收盘价"].dtype == pl.Float64
def test_utf8_source_is_returned_unchanged(tmp_path: Path) -> None:
path = tmp_path / "utf8.csv"
path.write_bytes(CSV_TEXT.encode("utf-8"))
assert ensure_utf8_csv(path) == path