i2 ANB 8导出.doc解析:Python抽取实体关系与CSV回导实战
简介面向安全与金融领域的调查分析人员IBM i2 Analysts Notebook 8培训教材系统讲解了专业数据可视化分析工具的核心用法。内容涵盖数据导入、节点链接图与时间线分析、聚类算法等基本概念并通过话单关系分析、案件串并分析、人员物品动态关系、银行账户交易分析等大量实战演练逐步演示从海量通话记录、银行交易流中定位关键信息、串联案件线索的操作流程。资源为单个doc格式文档压缩包大小25.9MB已有1583人学习下载。教程按入门、基本绘图、基础功能、功能演练等章节组织功能演练部分包含十余个贴近实战的场景既适合初次接触i2的初学者也可供有经验的分析师查漏补缺快速应用于犯罪侦查、金融调查等实际场景。1. 拿到 i2 Analysts Notebook 8 的 .doc先分清它是文档还是数据做调查分析的人经常会收到一种奇怪的 .doc双击打开后看不到多少正文满屏是表格表格里是一批实体名称、属性、关系记录和时间线。这不是写给人看的报告而是 i2 Analysts Notebook 8后面统一叫 ANB 8在交付和归档阶段落下来的结构化数据。它的价值不在排版而在表格行里那些能重新入图、入库的实体与关系。很多项目组第一次拿到这种文件都犯过同样的错把 .doc 当普通 Word 文档归档等需要重新做关联分析时发现数据进不了 ANB只能手工复制粘贴。事实上这类文件有固定的解析套路——先判断真伪格式再从表格里抽实体与关系最后导回 ANB 重建 Chart。整个过程不依赖 ANB 正版许可之外的东西一台装了 Python 的电脑就能干完。这份笔记写给两类人一类是手头攒了一批 ANB 导出的 .doc 需要批量转数据的分析人员另一类是要给分析团队写转换工具的工程师。读完你会知道这份 .doc 内容通常怎么排布、哪些字段能抽、哪几步容易翻车以及怎么把结果原样倒回 ANB 里继续画图。2. .doc 从哪来、里面装了什么ANB 8 导出文件的三种存储形态2.1 导出链路Chart 里的实体与关系如何落成 Word 表格ANB 8 的原始工作文件是 Chart后缀多为 .anb。它保存的不只是节点和连线还有每个实体的属性表、时间窗口、关联强度等信息。分析人员把 Chart 导出成通用格式时通常不是为了继续编辑而是为了交付、存档或喂给另一个系统。常见做法是导出成 Word 系列格式ANB 会把图里的实体清单转成一张实体表把连线转成关系表把时间窗转成时间线表再在开头附一段摘要文字。所以这类 .doc 真正的信息密度集中在表格里正文段落反而是次要的。我一般拿到文件先不看字先数表格个数和行数因为实体表动辄上千行靠肉眼读会崩溃。需要注意ANB 导出 Word 时并不保证不同版本都有完全相同的布局有的版本把关系和属性合并成一张宽表有的拆成三张。解析脚本必须能容忍列顺序不同最好按表头关键字定位而不是按固定列序号。还要区分文件是怎么到你手里的。常见两种来源一种是分析人员自己从 ANB 8 里导出的报告另一种是某个跨平台系统在同步数据时自动生成的摘要件后者往往只保留实体和部分属性关系表可能被省略。拿到文件后先去确认生成时间和有没有伴生的 .anb 或图片文件这决定了你后面能恢复到几成。2.2 先分清真 .doc、伪装成 .doc 的 HTML 与 RTF.doc这个扩展名在后端工程师眼里是个坑。它至少对应三种完全不同的存储形态判断错了后面所有解析工作都白做。形态真实格式文件头特征解析建议真 Word .docOLE2 复合文档D0 CF 11 E0 A1 B1 1A E1走 COM 转 docx 或专用解析HTML 伪装HTML 文档以html或!DOCTYPE开头剥离标签后按文本行解析RTF 伪装RTF 富文本以{\rtf开头转纯文本后再做结构抽取如果按 Word 文档的方式去解析一个 HTML 伪装文件python-docx 会直接报错antiword 也可能吐出一堆乱码。判断文件类型最可靠的办法是读文件头魔数。命令行下可以这样快速确认file example.doc xxd -l 8 example.doc第一条命令会输出整体类型描述第二条命令直接看前 8 个字节。如果看到d0 cf 11 e0 a1 b1 1a e1说明是真正的 OLE2 复合文档也就是货真价实的旧版 Word 文件如果看到html或{\rtf说明只是改了扩展名。这一步判断决定了后面用哪一套解析工具值得在写脚本时单独抽成一个函数。如果要在 Python 脚本里自动判断可以写一个简单的探测函数读文件头而不是依赖扩展名import struct def sniff_doc_type(path): with open(path, rb) as f: head f.read(8) # OLE2 复合文档魔数对应真 .doc if head[:8] bytes.fromhex(d0cf11e0a1b11ae1): return ole2 # 文本类格式读开头一小段做判断 with open(path, r, encodingutf-8, errorsignore) as f: text_head f.read(256).lstrip().lower() if text_head.startswith(html) or text_head.startswith(!doctype): return html if text_head.startswith({\\rtf): return rtf return unknown注意errorsignore是为了防止读到非法 UTF-8 字节时直接抛异常但这也意味着伪 HTML 文件若是 GBK 编码开头判断仍可能失真。更稳妥的做法是先用二分法试 utf-8 和 gbk 两种编码去读前 256 字节哪种能解出可读标签就用哪种。这个函数返回的类型就是你后续解析流程的分支依据。2.3 典型内容布局标题、实体表、关系表、时间线一份从 ANB 8 导出的 .doc 内部通常按固定顺序排布。开头是报告标题、生成时间和案例摘要接着是实体表列一般包含实体名称、实体类型、关键属性然后是关系表列出两个实体之间的关联类型、方向、起始时间和结束时间最后往往还有一段时间线或备注。实体表里可能有重复名称但不同 ID 的实体关系表里也可能有多条同名关系解析时必须保留原始行号作为锚点。实体表大概长这样实体名称实体类型出生日期地址备注张某人员1978-03-15某市某区无某商贸公司公司无某市某路关联待确认关系表则类似左实体、右实体、关系类型、开始时间、结束时间、来源。理解这个布局的意义在于抽取代码应该按表头语义来定位列而不是写死第几列。常见字段的别称很多比如实体名称可能写作名称、Name、Entity Name关系类型可能写作类型、Type、Link Type、关联类型。脚本里维护一份别称映射表能显著提高对不同导出版本的兼容度。还有一个容易忽略的地方时间线字段。ANB 里每个实体和关系都可能有时间窗口导出成 Word 后经常变成两列时间或一段自由文本。时间线数据对重建 Chart 非常重要因为它决定关联图里元素在时间轴上的位置。如果文件里时间列格式混乱不要急着丢弃先原样保留后面第 5 章会讲怎么归一化。3. 用 Python 把 .doc 里的实体与关系抽成结构化数据3.1 按文件类型路由解析分支拿到文件后第一步永远是先跑上一步的sniff_doc_type然后按类型走不同解析分支。我的习惯是把整条流程包在parse_doc()入口函数里后续批量处理时只调用这一个函数。def parse_doc(path, work_dir): ftype sniff_doc_type(path) if ftype ole2: # 真 Word 文档转 docx 后读表格 docx_path doc_to_docx(path, work_dir) return extract_from_docx(docx_path) if ftype html: # HTML 伪装抽文本行 lines html_to_lines(path) return extract_from_lines(lines) if ftype rtf: # RTF 伪装先转纯文本再走文本分支 text rtf_to_text(path) return extract_from_lines(text.splitlines()) raise ValueError(f不支持的文件类型: {ftype})这个路由看起来很直白但实际项目里最容易出的问题不是分支逻辑而是 work_dir 的磁盘空间。真 .doc 转成 docx 后体积可能膨胀几倍批量处理几百个文件时中间产物要及时清理或者放到临时目录用完即删。我一般会在parse_doc的 finally 块里删掉 docx 中间文件只保留最终的 CSV。3.2 Windows 上有 Word 环境COM 转 docx 再读表格如果跑脚本的机器装了完整版 Word最省力的路径是用 COM 接口把 .doc 转成 .docx再用 python-docx 读表格。原因很简单python-docx 只支持 OOXML 格式也就是 .docx不支持老的 OLE2 .doc而 Word COM 是官方解析引擎对 ANB 导出时产生的各种不规则表格容错率最高。import os import win32com.client def doc_to_docx(src_path, dst_dir): # 启动 Word 进程后台运行 word win32com.client.Dispatch(Word.Application) word.Visible False word.DisplayAlerts 0 # 关闭弹窗防止脚本卡住 try: doc word.Documents.Open(os.path.abspath(src_path)) base os.path.splitext(os.path.basename(src_path))[0] out os.path.join(dst_dir, base .docx) # FileFormat16 对应 wdFormatDocumentDefault即 docx doc.SaveAs2(out, FileFormat16) doc.Close() return out finally: word.Quit() # 确保进程退出否则残留 Word 进程这个函数有两个参数值得调FileFormat固定用 16有的环境用SaveAs不认SaveAs2旧版 Office 会报错遇到就换成doc.SaveAs(out, FileFormat16)。DisplayAlerts0很重要ANB 导出的文档偶尔带格式警告不关掉的话弹窗会让脚本挂在那等人工点确定。转成 docx 后用 python-docx 遍历所有表格from docx import Document def extract_from_docx(docx_path): doc Document(docx_path) all_rows [] for table in doc.tables: for row in table.rows: # 合并单元格时cells 里同一个对象会出现多次先去掉重复 seen [] for cell in row.cells: if cell not in seen: seen.append(cell) values [c.text.strip() for c in seen] if values: all_rows.append(values) return all_rows注意cell not in seen这个去重逻辑。python-docx 对合并单元格的处理方式是让多个坐标引用同一个_Cell对象直接用会把同一格内容读出两遍。去重之后行内列数可能与表头不一致这个先不处理留到归一化阶段再对齐。COM 方案的本质是把解码难题外包给 Word 引擎所以它对中文、特殊符号、旧格式的兼容性最好。代价是必须跑在 Windows 且有 Office 的环境而且并发时要小心——多个进程同时调 Word COM 会互相打架批量处理建议串行或者用消息队列限流。3.3 没有 Word 环境的兜底方案剥离标签后按模式抽取很多分析团队的后端服务器是 Linux没法跑 COM。这时候只能用纯 Python 硬解。HTML 伪装成 .doc 的文件反而好处理因为 HTML 结构比 OLE2 透明得多Python 标准库就够用。from html.parser import HTMLParser class TextExtractor(HTMLParser): def __init__(self): super().__init__() self.parts [] def handle_data(self, data): text data.strip() if text: self.parts.append(text) # 只保留非空文本 def html_to_lines(path): # 部分 ANB 导出件是 GBK 编码探测后再读 with open(path, r, encodingutf-8, errorsignore) as f: html f.read() p TextExtractor() p.feed(html) return p.parts这里有个参数层面的坑encoding不能写死。ANB 8 中文版的导出件经常是 GBK/GB18030 编码用 utf-8 读会得到一堆乱码而乱码之后的 HTML 标签无法被正确识别。我一般会先读文件头 4 个字节看有没有 BOM没有 BOM 就同时尝试 utf-8 和 gbk用html.parser能否解析出标签作为判据。拿到纯文本行之后需要按模式抽取实体和关系。ANB 导出的文本行通常有一定的规律实体行形如名称|类型|属性关系行形如A — B [关系类型]import re def extract_from_lines(lines): entities [] links [] # 实体行示例: 张某|人员|1978-03-15 ent_re re.compile(r^([^|]{1,60})\|([^|]{1,20})\|(.*)$) # 关系行示例: 张某 — 某商贸公司 [控股] link_re re.compile(r^(.{1,60})\s*[—\-]\s*(.{1,60})\s*\[(.{1,20})\]\s*$) for line in lines: m ent_re.match(line) if m: entities.append([m.group(1), m.group(2), m.group(3)]) continue m link_re.match(line) if m: links.append([m.group(1), m.group(2), m.group(3)]) return {entities: entities, links: links}正则里的长度上限是我拍脑袋设的经验值实体名一般不会超过 60 字符关系类型不会超过 20。遇到超长名称被截断的情况优先调大上限而不是改匹配逻辑。文本兜底方案的最大局限是丢失表格的列名信息所以抽取结果只能保证行级正确不保证列语义正确这一步产出的数据需要更多人工抽查。3.4 归一化输出实体表、关系表、时间字段重排不管从哪个分支解析最后都要落成统一结构的 CSV。实体表统一成四列entity_id, name, type, attributes_json。关系表统一成六列from_id, to_id, link_type, start_time, end_time, source。这里的 entity_id 优先用原始文件里的 ID 列没有就用表序号_行号自动生成保证不重复。import csv import json import re from datetime import datetime # 常见日期写法2020/03/04、03-04-2020、2020年3月4日 DATE_PATTERNS [ r(\d{4})[/\-年](\d{1,2})[/\-月](\d{1,2})日?, ] def normalize_date(value): value value.strip() for pat in DATE_PATTERNS: m re.match(pat, value) if m: y, mo, d int(m.group(1)), int(m.group(2)), int(m.group(3)) return datetime(y, mo, d).isoformat() return value # 解析不出来就保留原文 def write_csvs(entities, links, out_dir): with open(f{out_dir}/entities.csv, w, newline, encodingutf-8-sig) as f: w csv.writer(f) w.writerow([entity_id, name, type, attributes]) for row in entities: w.writerow([row[0], row[1], row[2], row[3]]) with open(f{out_dir}/links.csv, w, newline, encodingutf-8-sig) as f: w csv.writer(f) w.writerow([from_id, to_id, link_type, start_time, end_time, source]) for row in links: w.writerow([row[0], row[1], row[2], normalize_date(row[3]), normalize_date(row[4]), row[5]])编码用utf-8-sig不是玄学。ANB 8 跑在 Windows 上导入 CSV 用的向导默认按系统 ANSI 码读不带 BOM 的 UTF-8 文件会被误判成 GBK中文瞬间变乱码。带 BOM 之后 Excel 和 ANB 都能正确识别。属性字段用 JSON 串存放是因为 ANB 的实体属性数量不固定JSON 可以保留地址、电话、备注这类半结构化信息后续导入时再按需拆列。4. 把抽取结果导回 ANB 8用 CSV 导入向导重建 Chart4.1 CSV 是 ANB 8 最常见的交换格式重建 Chart 的目的不是把图画回来而是把实体和关系重新放进 ANB 的分析环境让分析人员可以继续做布局、过滤、时间轴分析。ANB 8 对外部数据最常见的接入方式是 CSV 导入它会把 CSV 的每一行映射成一个实体或一条链接向导里可以选择导入模式、指定主键列、定义实体类型。我一般会把第 3 章产出的两个 CSV 直接作为导入源不做中间转换。原因很简单CSV 是文本格式出问题容易排查ANB 导入向导里的字段映射是可视化的分析人员自己就能操作不需要每次都找工程师。这里要注意的是ANB 8 对 CSV 的列名没有硬性要求但前两行的类型判断很敏感如果第一行不是表头而是数据向导会把第一行数据当成字段名导入后所有实体名都会错位。导入前的准备工作是把 CSV 打开检查一遍重点看三件事表头行是否规范、空行是否混在数据中间、日期列是否被 Excel 自动转成了序列号。第三点最坑Excel 打开 CSV 后如果自动识别了日期保存再导入时会变成一串数字ANB 里完全不可读。我的习惯是CSV 永远不做打开后另存为这个动作要用 Excel 查数据就复制一份来查。4.2 实体清单ID、类型、属性列的用户定义字段映射实体表导入 ANB 时字段映射要明确三组对应关系实体主键、显示名称、实体类型。主键列决定去重逻辑ANB 内部每个实体必须有一个唯一标识如果 CSV 里没有合适的 IDANB 会按导入批次自动生成但这样做的副作用是同一个人在不同批次导入后变成两个实体后面合并很麻烦。因此我建议在生成实体 CSV 时就保证entity_id稳定。它可以是原 .doc 里的实体编号也可以是文件名_行号只要能保证跨文件不重复就合格。导入向导里把这列标成主键ANB 后续再导入同一批数据时会做合并而不是追加。属性字段的处理策略是能拆则拆不能拆就进 JSON。ANB 的自定义属性是预定义的如果你没有提前建好地址、电话、出生日期这些属性类型导入时多出来的列会被丢弃或变成备注。所以在导入之前先让分析人员确认 ANB 图谱模板里已经建好了哪些属性然后把 CSV 列对应到已有属性上没对应上的内容保留在 attributes JSON 里至少不丢数据。4.3 关系清单两端实体、方向与时间窗口必填关系表导入的逻辑比实体表更严格因为每一条链接的两端必须指向已存在的实体。ANB 导入关系时通过主键值来定位实体所以关系 CSV 里的from_id和to_id必须和实体表里的entity_id完全一致连空格都不能多。常见翻车现场是实体表 ID 是001关系表里写成了1导入时 ANB 报找不到左端实体整条链接被跳过。关系字段是否必填说明from_id是必须等于实体表的 entity_idto_id是必须等于实体表的 entity_idlink_type建议空值会生成默认关系类型start_time / end_time否决定时间轴上位置建议填source否记录来源方便回溯时间窗口我建议尽量填。ANB 的特色功能是时间轴分析如果所有关系都没有时间属性导入后时间轴上什么都看不到分析价值直接打对折。上一章归一化后的 ISO 格式字符串如2020-03-15T00:00:00可以直接用ANB 8 能识别 ISO 8601。如果你的日期归一化失败保留了原文导入时大概率会解析成无效时间此时宁可不填也别填一串 ANB 看不懂的文本否则导入日志里全是时间解析错误。4.4 导入后的完整性验证导入完成不等于数据正确。我每次导入后都会做一次数量校验原始 CSV 实体行数、ANB 导入日志的成功行数、撤销统计里的失败行数三者对不上就要查原因。ANB 8 的导入窗口会显示导入摘要里面有创建了 X 个实体、Y 条链接之类的数字拿这个数字和 CSV 行数比对是最快的验证方式。def validate_csv_counts(entities_path, links_path, report): ent_count sum(1 for _ in open(entities_path, encodingutf-8-sig)) - 1 link_count sum(1 for _ in open(links_path, encodingutf-8-sig)) - 1 assert ent_count report[entities_created], \ f实体数不符: CSV {ent_count}, ANB {report[entities_created]} assert link_count report[links_created], \ f链接数不符: CSV {link_count}, ANB {report[links_created]}这里有个细节ANB 导入时如果 CSV 里两行实体完全相同且主键也相同会去重合并导致创建数小于 CSV 行数。所以校验前要确认主键是否唯一。我通常会在生成 CSV 时加一层去重entity_id重复的行直接报错而不是静默过滤这样文件里每一行都能和导入结果一一对应。5. 避坑解析 ANB 导出的 .doc 时最容易翻车的 5 个细节5.1 扩展名是 .doc内容其实是 HTML现象批量处理一百个文件前几个解析正常突然某几个文件用 python-docx 读直接抛异常用记事本打开看到一堆table标签。原因这些文件不是 Word 老格式而是 HTML 改了扩展名。某些导出工具为了兼容性会把网页内容存成 .docWord 能打开是因为它内置了 HTML 渲染但解析库不认。解决把sniff_doc_type放在所有解析逻辑最前面遇到 HTML 就走文本分支。我后来习惯在入口函数里直接断言文件类型类型未知就抛异常并记录文件名宁可让整批任务停下来也不让错误数据混进结果里。5.2 Word COM 调用卡死或报错现象脚本第一次跑成功第二次运行报远程过程调用失败或者任务卡在Dispatch那行不动。打开任务管理器发现一堆 WINWORD.EXE 进程占着内存。原因word.Quit()没被调用或者之前某个文档打开时弹了模态对话框Word 进程在后台等用户确认新的 COM 调用就排队卡住。解决用 try/finally 包住 QuitDisplayAlerts置 0 只是第一道保险进程残留还需要在脚本开头做一次清理。我的做法是每次运行时先用taskkill /F /IM WINWORD.EXE清理残留进程仅限专用处理机上然后把 COM 调用封装成独立函数超时自动重试。如果处理机不允许杀进程就用第 3.3 节的纯 Python 兜底方案绕开 COM。5.3 合并单元格造成表格错位现象读出来的表格行字段对不上。实体名称列里出现了日期日期列里出现了地址整列数据错位而且只发生在部分行。原因ANB 导出 Word 表格时相邻行相同内容会被自动合并单元格。python-docx 读合并单元格会返回重复对象简单去重后行内列数变少与表头列数不一致按序号赋值就错位了。解决不要按固定列序号取值改用表头定位。先找到表头行的每个列名所在索引再去读数据行对应索引的值。合并单元格导致的列数不足时跳过这一行并记日志交由人工核对而不是强行补齐。5.4 中文内容变成乱码现象HTML 伪装的 .doc 在 Windows 上打开是正常中文用 Python 读出来全是锟斤拷或问号。原因文件是 GBK 编码脚本用 UTF-8 解码字节流硬解成乱码。HTML 里虽然声明了 charset但很多导出工具声明的是 utf-8实际编码却是 GBK字段信息失真。解决读文本时先探测编码gbk和utf-8都试一遍以能否解析出完整中文字符为标准。真 .doc 和 RTF 的情况建议不要自己解码交给 Word COM 或者专门的 RTF 解析库人肉解码 OLE2 内部编码属于给自己挖坑。5.5 日期字段格式歧义现象同一批文件里日期既有2020/03/04也有03-04-2020甚至还有2020年3月4日。归一化后时间轴乱成一团。原因ANB 是国际软件不同地区导出列表的日期格式不同甚至同一份文档里不同表也可能因为生成模板不同而混用格式。解决归一化时不猜只认带四位年份的格式四位年份在前优先转 ISO年月日中文格式也转。像03-04-2020这种月和日无法区分的情况保留原文存进 attributes JSON导入时留空时间字段等人确认后再补不要自作聪明乱转。5.6 什么情况该放弃程序化解析偏执地用脚本处理所有文件不是好习惯。有几种情况我建议直接放弃自动解析文件本身是扫描件的图片套了 .doc 外壳OLE2 文件结构损坏COM 打开都报格式无效表格被手动改过表头行丢失。这些文件数量通常不多混在批量结果里反而拉低整体置信度。判断标准很简单脚本解析结果的实体行数和文件实际表格行数对不上抽查三条都错位就标记为需人工处理不要继续调参死磕。程序化的目标是覆盖 90% 的常规文件剩下的 10% 直接导成 PDF 图片交给分析人员原始查看比写十个补丁都高效。6. 进阶批量处理一堆 .doc 时的校验与增量修正技巧批量处理不只要考虑解析还要考虑怎么让结果可信。我最后分享一个自己固定的处理流程每个文件解析完后立即校验校验不过就写错误日志不中断整批任务。这个习惯帮我避免过好几次全量返工。import glob import logging logging.basicConfig(filenameanb_import.log, levellogging.INFO) def process_batch(raw_dir, out_dir): has_error False for path in glob.glob(f{raw_dir}/*.doc): try: result parse_doc(path, work_dirout_dir) validate_result(result) # 见下方校验函数 write_csvs(result, out_dir) logging.info(fOK {path}) except Exception as e: has_error True logging.error(fFAIL {path}: {e}) if has_error: raise RuntimeError(本次批量处理存在失败文件请查看日志)校验函数的核心是一条不变量关系表里每个 from_id 和 to_id 必须存在于实体表中。如果某个文件里只有关系表而没有实体表说明导出时漏了表解析结果不可用。def validate_result(result): assert result[entities], 实体表为空 ids {e[0] for e in result[entities]} for link in result[links]: if link[0] not in ids or link[1] not in ids: raise ValueError(f关系引用未知实体: {link})增量修正的方法也有点讲究。我不去改原始解析脚本而是维护一个overrides.json人工核对后发现的错误修正记录放进去。每次批量处理前先加载 overrides解析结果出来后被修正记录直接覆盖对应字段这样第二次跑同一批文件时已修正过的内容不会再被脚本覆盖。我吃过一次亏早期图省事跳过格式探测直接按 docx 解析结果一批两百多个文件全部解析失败而失败原因其实只是其中几个 HTML 伪装文件。后来我再也没省过这一步探测函数永远排在流程第一行。这些老格式文件里的数据不是拿来看的是用来继续做分析的源头错一个字段后面所有关联推理都会跟着偏。希望这篇笔记能帮你把这块硬骨头啃下来至少下次再看到 i2 Analysts Notebook 8 的 .doc 时你不会想把它当普通文档存档了。本文还有配套的精品资源点击获取