1. 车载日志文件格式识别与解析入口构建

问题定位:来源混杂时,解析器无法自动分派

CTCS车载设备通常同时输出多类日志:ATP主控的二进制记录文件、事件文本日志、DMI操作日志,甚至BTM/应答器相关记录。这些文件在扩展名、命名规则上往往不可靠——现场常见.dat、.log、.txt混用,甚至同一扩展名下并存二进制与文本两种内容。若解析入口只按扩展名分派,必然出现“用文本解析器读二进制”或“用二进制解析器读文本”的错配。

因此解析入口的第一道工序不是解析内容,而是基于文件头特征做内容级识别,再据此分派到对应解析器。本节构建一个可运行的识别与分派入口:读取文件前若干字节,按特征表匹配,输出文件类型与解析器标识;无法识别时进入待定队列而非静默失败。

文件头特征设计

识别依据应选择“稳定、短、区分度高”的字节特征。以下为教学示例中约定的特征表(实际项目中应以设备文档或样本统计为准):

类型标识特征位置特征内容分派解析器
ATP_BIN偏移 0,4 字节魔数 0x41 0x54 0x50 0x42("ATPB")parse_atp_binary
EVT_TEXT偏移 0,行首以 [EVT] 开头parse_event_text
DMI_OP偏移 0,行首以 DMI| 开头parse_dmi_op
BTM_REC偏移 0,4 字节魔数 0x42 0x54 0x4D 0x52("BTMR")parse_btm_record

识别顺序上,先判二进制魔数(长度固定、误判率低),再判文本行首标记。文本判定需容忍BOM与前后空白,避免因编码差异漏判。

待识别文件 ATP_2024.dat evt_0912.log dmi_op.txt btm_0912.rec 读取文件头 前 64 字节 去 BOM / 空白 保留原始字节 魔数匹配? ATPB / BTMR ATP_BIN / BTM_REC 二进制解析器 是 行首标记? [EVT] / DMI| 否 EVT_TEXT / DMI_OP 文本解析器 是 UNKNOWN 进入待定队列,人工复核 否 统一入口返回结构 file_type:识别出的类型标识 parser:分派到的解析器名 confidence:匹配依据(魔数/行首) raw_head:保留的头部字节

识别与分派入口的实现

入口函数只做三件事:读头部、按特征表匹配、返回分派结果。解析器本身不在此处实现,入口只负责“认路”。以下代码可直接运行,示例文件由脚本生成,便于验证。

import os
import struct

# 特征表:类型标识 -> (匹配函数, 解析器名)
MAGIC_TABLE = [
    (b"ATPB", "ATP_BIN", "parse_atp_binary"),
    (b"BTMR", "BTM_REC", "parse_btm_record"),
]
TEXT_MARKERS = [
    (b"[EVT]", "EVT_TEXT", "parse_event_text"),
    (b"DMI|", "DMI_OP", "parse_dmi_op"),
]

def read_head(path, n=64):
    with open(path, "rb") as f:
        return f.read(n)

def strip_bom(head):
    if head.startswith(b"\xef\xbb\xbf"):
        return head[3:]
    return head

def identify(path):
    head = read_head(path)
    if not head:
        return {"file_type": "EMPTY", "parser": None,
                "confidence": "none", "raw_head": head}

    # 1) 二进制魔数优先
    for magic, ftype, parser in MAGIC_TABLE:
        if head.startswith(magic):
            return {"file_type": ftype, "parser": parser,
                    "confidence": "magic", "raw_head": head}

    # 2) 文本行首标记(容忍 BOM 与空白)
    text_head = strip_bom(head).lstrip()
    for marker, ftype, parser in TEXT_MARKERS:
        if text_head.startswith(marker):
            return {"file_type": ftype, "parser": parser,
                    "confidence": "marker", "raw_head": head}

    return {"file_type": "UNKNOWN", "parser": None,
            "confidence": "none", "raw_head": head}

def dispatch(paths):
    result = {}
    for p in paths:
        result[p] = identify(p)
    return result

用示例文件验证分派结果

构造四个最小样本文件,覆盖二进制魔数、文本标记与无法识别三种情况,验证入口是否按预期分派:

def make_samples(d="samples"):
    os.makedirs(d, exist_ok=True)
    files = {
        "atp.dat": b"ATPB" + struct.pack("<I", 1) + b"\x00" * 20,
        "btm.rec": b"BTMR" + b"\x01\x02\x03\x04" + b"\x00" * 16,
        "evt.log": b"[EVT] 2024-09-12 08:00:01 mode=C2\n",
        "dmi.txt": b"DMI|2024-09-12 08:00:02|btn=确认\n",
        "bad.bin": b"\x99\x88\x77\x66" + b"\x00" * 20,
    }
    for name, data in files.items():
        with open(os.path.join(d, name), "wb") as f:
            f.write(data)
    return [os.path.join(d, n) for n in files]

for path, info in dispatch(make_samples()).items():
    print(os.path.basename(path), "->", info["file_type"],
          "|", info["parser"], "|", info["confidence"])

预期输出(由上述输入直接推出,非实测):

atp.dat -> ATP_BIN | parse_atp_binary | magic
btm.rec -> BTM_REC | parse_btm_record | magic
evt.log -> EVT_TEXT | parse_event_text | marker
dmi.txt -> DMI_OP | parse_dmi_op | marker
bad.bin -> UNKNOWN | None | none
识别顺序不可随意调换:若先做文本标记匹配,二进制文件头部恰好含可打印字符时可能被误判为文本。先魔数、后行首标记,是降低误判率的关键约束。

结果判读与边界处理

  • confidence 字段:magic 表示命中固定字节,可信度最高;marker 表示命中行首文本标记,需注意编码与BOM;none 表示未识别,必须进入待定队列。
  • 空文件:单独标记为 EMPTY,与 UNKNOWN 区分,便于统计现场空日志比例。
  • 头部不足 64 字节:read_head 返回实际长度,匹配逻辑不受影响;但若魔数跨越读取边界,应增大读取长度而非截断判断。
  • 扩展名不参与判定:入口完全忽略文件名后缀,避免 .dat 混装两类内容时错配。
特征表必须与设备实际输出一致。若现场出现新固件版本改变了魔数或行首标记,入口会将其归入 UNKNOWN——这是正确行为,不应通过放宽匹配(如只匹配前两字节)来“消灭”待定项,否则会引入静默错配。

可直接使用的分派判定清单

检查项通过条件不通过时的处理
魔数匹配优先于文本标记代码中魔数循环在文本循环之前调整顺序,补充回归样本
BOM 与空白已剥离带 BOM 的 [EVT] 文件仍识别为 EVT_TEXT检查 strip_bom 与 lstrip
未知文件不静默丢弃返回 UNKNOWN 且 parser 为 None补待定队列与告警
空文件单独分类返回 EMPTY区分统计口径
扩展名不参与判定改名后识别结果不变移除任何后缀判断分支

完成以上入口后,后续解析器只需按 parser 字段注册即可接入,新增日志类型时仅扩展特征表,不改动分派主逻辑。