Files
my-v0-server/app/datafiles/detect.py
T

144 lines
4.8 KiB
Python

"""按文件内容识别格式和文本编码,不看扩展名"""
import codecs
import csv
import json
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.datafiles.base import UNSUPPORTED_FORMAT, DataFileError
from app.schemas.data_file import DataFormat
# xlsx/xlsb 解压后的总大小上限,防止解压炸弹
MAX_UNZIPPED_BYTES = 1024 * 1024 * 1024
# 带 BOM 的文本直接按 BOM 解码;UTF-32 的 BOM 以 UTF-16 的开头,解出来含 NUL,按二进制拒绝
BOMS = (
(codecs.BOM_UTF8, "utf-8", "utf-8"),
(codecs.BOM_UTF16_LE, "utf-16-le", "utf-16"),
(codecs.BOM_UTF16_BE, "utf-16-be", "utf-16"),
)
# 没有 BOM 时依次尝试;GB18030 兼容 GBK、GB2312,中文 Windows 上 Excel 另存的 CSV 多是这种
FALLBACK_ENCODINGS = ("utf-8", "gb18030")
DELIMITERS = (",", "\t", ";", "|")
# 判断分隔符只看开头这么多行
SNIFF_LINES = 50
@dataclass(frozen=True)
class Detected:
format: DataFormat
encoding: str | None = None
# 文本格式解码后的内容
text: str | None = None
# CSV/TSV 的分隔符
delimiter: str | None = None
# JSON 已解析的内容,避免读取时再解析一遍
value: Any = None
def detect(path: Path) -> Detected:
with path.open("rb") as file:
head = file.read(8)
if head.startswith(b"PK\x03\x04"):
return Detected(_zip_format(path))
if head.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
return Detected("xls")
if head.startswith(b"PAR1"):
return Detected("parquet")
text, encoding = decode_text(path.read_bytes())
stripped = text.lstrip()
if not stripped:
raise DataFileError(422, "文件是空的")
if stripped[0] in "[{":
return _json_format(text, encoding)
delimiter = sniff_delimiter(text)
return Detected("tsv" if delimiter == "\t" else "csv", encoding, text, delimiter)
def _zip_format(path: Path) -> DataFormat:
try:
with zipfile.ZipFile(path) as archive:
infos = archive.infolist()
except zipfile.BadZipFile:
raise DataFileError(415, UNSUPPORTED_FORMAT) from None
if sum(info.file_size for info in infos) > MAX_UNZIPPED_BYTES:
raise DataFileError(413, "Excel 文件解压后过大")
names = {info.filename for info in infos}
if "xl/workbook.xml" in names:
return "xlsx"
if "xl/workbook.bin" in names:
return "xlsb"
raise DataFileError(415, UNSUPPORTED_FORMAT)
def decode_text(data: bytes) -> tuple[str, str]:
"""返回解码后的文本和编码名"""
for bom, codec, name in BOMS:
if data.startswith(bom):
try:
text = data[len(bom) :].decode(codec)
except UnicodeDecodeError:
raise DataFileError(
422, "无法识别文件编码,请另存为 UTF-8 后重新上传"
) from None
return _reject_binary(text), name
for encoding in FALLBACK_ENCODINGS:
try:
return _reject_binary(data.decode(encoding)), encoding
except UnicodeDecodeError:
continue
raise DataFileError(422, "无法识别文件编码,请另存为 UTF-8 后重新上传")
def _reject_binary(text: str) -> str:
# 文本文件不会有 NUL;图片、PDF 等二进制文件偶尔能按 GB18030 解码成功,在这里拦下
if "\x00" in text:
raise DataFileError(415, UNSUPPORTED_FORMAT)
return text
def _json_format(text: str, encoding: str) -> Detected:
try:
return Detected("json", encoding, text, value=json.loads(text))
except ValueError as error:
json_error = error
first = next(line for line in text.splitlines() if line.strip())
try:
if isinstance(json.loads(first), dict):
return Detected("jsonl", encoding, text)
except ValueError:
pass
# 以 [ 或 { 开头却不是合法 JSON,按 JSON 报错比当成 CSV 解析出一堆乱码更有用
line = getattr(json_error, "lineno", None)
position = f"第 {line} 行" if line else ""
raise DataFileError(
422, f"JSON 格式错误:{position}{getattr(json_error, 'msg', json_error)}"
)
def sniff_delimiter(text: str) -> str:
"""选出让开头各行列数最一致、列数最多的分隔符;都只有一列时按逗号处理"""
lines = []
for line in text.splitlines():
if line.strip():
lines.append(line)
if len(lines) == SNIFF_LINES:
break
best, best_score = ",", (0.0, 1)
for delimiter in DELIMITERS:
widths = [len(row) for row in csv.reader(lines, delimiter=delimiter)]
width = max(set(widths), key=widths.count)
if width < 2:
continue
score = (widths.count(width) / len(widths), width)
if score > best_score:
best, best_score = delimiter, score
return best