ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

老式 .doc 解析:从 OLE2 转换到条款级检索与权限控制

老式 .doc 解析:从 OLE2 转换到条款级检索与权限控制 简介《周大福珠宝员工手册》是一份面向珠宝零售企业员工与人力资源从业者的内部管理规范文档围绕企业文化、日常行为准则与人事制度展开适合入职培训、制度梳理或门店管理参考。压缩包内仅含1个doc文件体积约91KB为完整手册正文按目录依次涵盖公司简介、远景与使命、核心价值观以及考勤、请假、加班、试用转正、发薪与离职等操作条款并延伸至绩效考核、培训晋升、表彰、纪律规范与安全生产等章节。已有31人学习。读者可据此快速了解企业“诚信、专业、创新”价值观的落地方式掌握从着装仪容、服务态度到考勤加班、薪资发放、离职手续的具体规定也能参考其章节编排用于自身制度文档的整理与对照。手册条款明确、结构完整既体现正规化管理也兼顾员工权益保障便于按模块检索查阅。1. 一份《周大福珠宝员工手册.doc》背后是二进制老文档的整条处理链HR 发来一个文件名字就叫《周大福珠宝员工手册.doc》要求三件事放进内部知识库能被搜到、按部门分级授权、和上一版逐条对比出改了哪几句。很多团队的第一反应是在 Word 里另存为 PDF 就交付了结果检索系统抓不到正文条款级的权限控制也没法做改版对比只能靠人眼翻。问题不在 PDF而在这份 .doc 本身——它和现在的 .docx 在磁盘上根本不是同一种结构任何基于 ZIP 的现代解析库都读不动它。这篇内容讲的是从这类老式 Word 文档里把正文、表格、修订痕迹完整取出来转成可切分、可索引、可授权的结构化条款条目。做企业文档平台、HR 系统集成、内部知识库的工程师可以照着跑不需要预先懂 OLE2但读完要能把一条转换命令跑通把一份手册拆成一条条带章节号的记录。2. 先看懂 .doc 的 OLE2 容器再决定用哪个解析器选错解析器是这类需求里最常见的返工原因。有人装好 python-docx 就直接Document(周大福珠宝员工手册.doc)报错以后又在网上抄一段 antiword终于拿到纯文本却发现假期表格里的天数全串行了。根子在格式差异上先把容器结构看清楚后面的工具选型就不需要试错。2.1 .doc 和 .docx 在磁盘上完全是两种东西.doc 采用 OLE2 复合文档格式一个文件内部像一套小型 FAT 文件系统装着 WordDocument、1Table或 0Table、Data、SummaryInformation 等若干个流。正文并不连续存放文字被切成若干 piece每片的偏移量和编码方式记录在表格流的 CLX 结构里每一片可能用单字节编码也可能是 UTF-16LE。经历过快速保存的文档 piece 数量会明显增多里面还叠着历史修订。.docx 则是一个普通 ZIP 包正文在 word/document.xml 中是干净的 XMLpython-docx 走的是 zipfile 加 lxml 这条路所以它天生不认 .doc。from docx import Document # .doc 不是 ZIP 包python-docx 会在打开阶段直接失败 Document(周大福珠宝员工手册.doc) # 典型报错BadZipFile: File is not a zip file # 有的环境会包一层PackageNotFoundError: Package not found at ...逻辑说明python-docx 的入口是docx.opc.package.OpcPackage.open()内部调用zipfile.ZipFile遇到 OLE2 头D0 CF 11 E0 A1 B1 1A E1会判定为非 ZIP。参数说明这里的报错信息在不同 Python 和库版本下措辞略有差异用报错类型判断走哪条分支即可不要按字符串精确匹配。2.2 四条解析路线的取舍Antiword、LibreOffice、Tika、Word COM用户手册这类文档的价值一半在正文一半在表格和编号条款上工具能不能保住表格比转换速度重要得多。路线依赖表格保真修订/批注并发能力适用场景antiword / catdoc单个二进制文件差按制表符近似还原不支持高几乎无状态只要正文喂检索LibreOffice headless需装 soffice好转 docx 后结构完整保留中需隔离 profile通用首选Apache TikaJVM中表格为扁平文本有限中已有 Java 服务栈Word COM 自动化Windows Office最好完整可读低进程独占内网 Windows 批处理机我的取舍顺序是需要保留结构与修订就上 LibreOffice headless它把 .doc 转成 .docx 之后后续所有处理都能回到 python-docx 的舒适区只在只需要纯文本、且机器资源很紧张时才考虑 antiword 这种轻量方案。Word COM 的保真度确实最高但它要求目标机器装 Office、无法跑在常见容器里、并发调度很难做只适合放在一台内网 Windows 机器上当兜底通道。Tika 的优势在生态如果知识库本身已经有 Java 检索服务用它省掉一次格式转换也合理。2.3 上线前先确认这份 .doc 到底是什么实务中会遇到扩展名骗人的情况把 RTF 改名成 .doc、把 docx 改名成 .doc、或者根本是 Word 2003 XML。转换脚本不做类型判断批量跑的时候会挂掉一批。# 看真实 MIME 类型别只看扩展名 file --mime-type /data/in/周大福珠宝员工手册.doc # 期望输出application/msword # 若是 application/rtf 或 application/zip说明扩展名与实际格式不一致 # 数一下这批文件里到底有几种类型 find /data/in -type f -name *.doc -print0 | xargs -0 file --mime-type | sort | uniq -cimport olefile # 对真正的 OLE2 文档列出内部流名称和大小判断文本存储位置 with olefile.OleFileIO(周大福珠宝员工手册.doc) as ole: for entry in ole.listdir(): stream /.join(entry) size ole.get_size(stream) print(f{stream:24} {size:10} bytes) # WordDocument 是正文流1Table 或 0Table 存放 piece 表和格式信息 # Data 放图片等二进制SummaryInformation 放标题、作者、页数等属性逻辑说明file --mime-type只读文件头几个字节速度快适合在批量转换前先做一次分拣。参数说明-print0配合xargs -0是为了让含空格和中文的文件名不被拆断中文文件名在批量脚本里是高频踩坑点。olefile 的listdir()返回的是路径数组用/拼回字符串才能丢掉它内部的嵌套表示拿到流清单后可以顺手判断WordDocument流为空的异常文档这类文件通常是下载中断或被杀毒软件改写过的损坏件。3. 用 LibreOffice headless 批量转换 .doc 到 .docx 的可用命令选定路线之后真正决定成败的是转换这一层的工程细节profile 冲突、超时、并发度、失败重试。单机跑通一份手册只要一分钟跑两千份就要考虑资源调度。3.1 单份手册的最小转换命令soffice --headless --norestore --invisible \ -env:UserInstallationfile:///tmp/lo_profile_01 \ --convert-to docx \ --outdir /data/out \ /data/in/周大福珠宝员工手册.doc参数含义不写会怎样--headless无界面模式无 X11 的服务器上直接启动失败--norestore跳过崩溃恢复对话框卡在交互提示直到超时--invisible窗口不可见部分环境下仍会尝试连显示服务-env:UserInstallation指定独立用户配置目录多进程共用同一 profile第二个进程静默退出--convert-to docx目标格式可显式带过滤器名默认过滤器一般够用但显式指定更稳--outdir输出目录必须已存在不自动创建直接报错退出逻辑说明这个命令是幂等的——输出文件按输入文件的主名生成重跑会覆盖。参数说明-env:UserInstallation必须写成file://开头的绝对路径写成相对路径在部分发行版上会被忽略第一次执行时 LibreOffice 会在这个目录里生成完整配置冷启动可能比后续调用慢好几倍所以预热一次再压测。输出目录的权限要提前确认以 nobody 之类的低权账号跑服务时写不进去的目录会表现为命令成功但没文件很容易误判。3.2 批量转换并发度、profile 隔离和超时import shutil import subprocess from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path SRC_DIR Path(/data/in) OUT_DIR Path(/data/out) PROFILE_ROOT Path(/tmp/lo_profiles) PROFILE_ROOT.mkdir(parentsTrue, exist_okTrue) def convert_one(doc_path: Path, worker_id: int) - tuple[str, bool, str]: profile PROFILE_ROOT / fp{worker_id} cmd [ soffice, --headless, --norestore, --invisible, f-env:UserInstallationfile://{profile}, --convert-to, docx, --outdir, str(OUT_DIR), str(doc_path), ] try: # 单份超时 120s慢文档多半是嵌了大图或损坏直接放弃比拖垮队列划算 proc subprocess.run(cmd, capture_outputTrue, textTrue, timeout120) out_file OUT_DIR / (doc_path.stem .docx) if proc.returncode 0 and out_file.exists() and out_file.stat().st_size 0: return doc_path.name, True, return doc_path.name, False, proc.stderr.strip()[:200] except subprocess.TimeoutExpired: return doc_path.name, False, timeout docs sorted(SRC_DIR.glob(*.doc)) # 并发度按 CPU 核数与内存取小值经验上 4 到 8 是收益拐点 workers 6 results [] with ThreadPoolExecutor(max_workersworkers) as pool: futures [pool.submit(convert_one, d, i % workers) for i, d in enumerate(docs)] for fut in as_completed(futures): results.append(fut.result()) failed [r for r in results if not r[1]] print(f成功 {len(results) - len(failed)} / 总计 {len(results)}) for name, _, err in failed[:20]: print(FAIL, name, err)逻辑说明每个线程绑定一个固定的 profile 序号避免同一目录被两个 soffice 进程同时写导致启动失败。参数说明并发度不是越高越好soffice 单实例内存占用不小核数乘以 2 之后往往开始出现超时率上升如果队列里混着几十兆的图文手册建议把并发压到 4 以下。超时值按文档体积分档更合理纯文字手册 30 秒足够带图表的可以放宽到 180 秒。3.3 转换完必须校验的三件事转换命令返回 0 不代表结果可用。常见失败有三种生成了 0 字节文件、正文在但表格丢了、页码域变成乱码。from docx import Document def check(docx_path: Path) - dict: d Document(str(docx_path)) text \n.join(p.text for p in d.paragraphs if p.text.strip()) return { chars: len(text), paragraphs: len(d.paragraphs), tables: len(d.tables), # 空段落占比过高通常意味着样式或分节被转坏 empty_ratio: round(1 - len(text.split(\n)) / max(len(d.paragraphs), 1), 3), }逻辑说明chars用来发现文件存在但正文为空tables用来发现表格丢失。参数说明如果原件里明确有假期表、薪酬表转换后tables为 0 就必须人工介入不能进检索库。抽检时把chars与原件页数做一次人工比对几十份样本就能估算出整批的转换质量。4. 从员工手册正文里抽出条款树、表格和修订痕迹拿到 .docx 只是中间产物检索系统需要的是第几章第几条讲了什么权限系统需要的是这条属于哪个部门改版对比需要的是哪几条变了。所以这一层要产出带元数据的条款对象而不是一整段长文本。4.1 用样式和编号规则还原章节层级中文手册有两种排版习惯一种认真用了 Word 的标题样式另一种全文都是正文样式靠第一章第 1 条这种文本编号。脚本要同时吃下两种。import re from docx import Document HEADING_RE re.compile(r^\s*(第[一二三四五六七八九十百零\d][章节条]|[(]?[一二三四五六七八九十][)]\s*)) def build_outline(path: str) - list[dict]: doc Document(path) nodes, current_chapter [], for p in doc.paragraphs: text p.text.strip() if not text: continue style (p.style.name or ).lower() is_heading style.startswith(heading) or style.startswith(标题) if is_heading or HEADING_RE.match(text): # heading 1 到 heading 6 映射为 1 到 6中文样式名同样按前缀判断 level 1 m re.search(r(\d)$, style) if m: level int(m.group(1)) nodes.append({level: level, title: text, body: []}) if level 1: current_chapter text elif nodes: nodes[-1][body].append(text) nodes[-1][chapter] current_chapter return nodes逻辑说明is_heading判断样式名覆盖英文 Heading 与中文标题两种命名HEADING_RE作为兜底抓没有被设成标题样式的编号段落。参数说明level从样式名的尾部数字解析这是 python-docx 唯一稳定的层级来源如果样式是自定义名比如手册章标题数字解析失败会退化成 1需要按实际模板补一条映射表再跑。4.2 表格抽取合并单元格会导致文字重复假期表、薪酬表这类内容对员工查询的价值最高也最容易在抽取时出错。python-docx 遇到纵向合并的单元格会把合并区的内容在每一行重复一遍。def iter_table_rows(table): seen set() for r_idx, row in enumerate(table.rows): cells, cells_text [], [] for c_idx, cell in enumerate(row.cells): key id(cell._tc) # 同一个 tc 表示物理上就是同一个单元格 if key in seen: cells_text.append(None) # 纵向合并的续行标空而不是重复 else: seen.add(key) cells_text.append(cell.text.strip()) cells.append(cell) yield r_idx, cells_text for t_idx, table in enumerate(Document(out/周大福珠宝员工手册.docx).tables): for r_idx, row in enumerate(iter_table_rows(table)): print(t_idx, r_idx, row)逻辑说明合并单元格在 XML 层共享同一个w:tc元素用id(cell._tc)做去重是可靠的判据比按文本比对更稳——如果两行恰好写了同样的内容按文本去重会误删。参数说明标记为None的格子在上层组装成 Markdown 或 HTML 时补成空串保持表格列对齐如果下游要放进检索库建议把表头和当前行的值拼成假期类型年假入职年限1-3 年天数5这种键值串效果比整表文本好得多。4.3 修订痕迹与批注先确定哪一版才算生效带修订的 .doc 转到 .docx 后改动标记保留在w:ins和w:del节点里python-docx 的paragraph.text对这两类是忽略的所以直接读文本会得到未改前也不是改后的奇怪结果。检索库必须明确只收生效版本。标记节点含义进检索库的处理w:ins修订中新增的内容接受修订时保留w:del修订中删除的内容接受修订时丢弃w:commentRangeStart/End批注锚点按需单独收集w:moveFrom/w:moveTo内容移动等价于删除加新增from docx import Document NS {http://schemas.openxmlformats.org/wordprocessingml/2006/main} def extract_text_resolved(paragraph, accept_revisionsTrue) - str: parts [] for node in paragraph._p.iter(): if node.tag NS t: # 判断该 t 是否位于 w:ins 内位于 w:del 内的一律跳过 parent node.getparent() in_del in_ins False while parent is not None: if parent.tag NS del: in_del True break if parent.tag NS ins: in_ins True parent parent.getparent() if in_del and accept_revisions: continue parts.append(node.text or ) elif node.tag NS delText and not accept_revisions: parts.append(node.text or ) return .join(parts) doc Document(out/周大福珠宝员工手册.docx) print(extract_text_resolved(doc.paragraphs[42], accept_revisionsTrue))逻辑说明遍历w:t并向上回溯祖先链判断它落在插入区还是删除区。参数说明accept_revisionsTrue表示只收生效后的文本适合给员工查询的检索库对比改版时改成False就能同时拿到删除前的内容做上一版 vs 本版的差异基线。批注锚点建议单独落一张表把批注人和批注时间一起存下来HR 复核时会用到。5. 把手册做成能搜、能授权、能比对的条款条目最后一层要解决的是检索质量和数据安全。员工手册里既有全员可见的总则也有只对特定层级公开的薪酬与考核条款切分粒度和权限过滤如果做错等于把内部制度文本裸奔。5.1 分块按条款切不按固定字数切按 500 字硬切会把第三条 年假的标题和正文拆到两个块里检索结果看起来前言不搭后语。正确做法是把 4.1 产出的节点当作天然分块边界一条款一块超长的条款再按段落二次切分。import hashlib def to_records(nodes, doc_id: str, acl: list[str]) - list[dict]: records [] for n in nodes: body \n.join(n[body]).strip() if not body: continue text f{n[title]}\n{body} records.append({ id: f{doc_id}-{n[title]}, doc_id: doc_id, chapter: n.get(chapter, ), title: n[title], text: text, # 内容指纹用于跨版本比对比存全文再逐字 diff 便宜得多 hash: hashlib.sha256(text.encode(utf-8)).hexdigest()[:16], acl: acl, }) return records逻辑说明id用文档标识加条款标题拼成保证同一份手册多次导入时是覆盖而不是重复。参数说明acl是部门或职级标识列表跟着块一起写入索引hash只取前 16 位碰撞概率在数万条量级下可以忽略存库时占的空间却能省一大截。标题和正文之间保留换行多数检索器会对标题字段额外加权。5.2 权限过滤放在查询侧脱敏放在入库侧权限有两种做法入库时就按部门拆成多个索引或者单索引里存 acl 字段、查询时过滤。前者隔离更彻底但维护成本高内部知识库规模下通常是后者更实际。脱敏必须在入库前做因为一旦进了索引后面所有查询都可能命中未脱敏的原文。import re MASK_RULES [ (re.compile(r1[3-9]\d{9}), 手机号), (re.compile(r\d{17}[\dXx]), 身份证号), (re.compile(r\d{16,19}), 银行卡号), ] def mask(text: str) - tuple[str, list[str]]: hits [] for pattern, label in MASK_RULES: if pattern.search(text): hits.append(label) text pattern.sub([已脱敏], text) return text, hits逻辑说明三类正则按从长到短排优先级避免身份证号被银行卡号规则先截走。参数说明返回值里的hits用于告警——员工手册正文里出现手机号通常意味着粘贴了员工样例数据应该让 HR 复查后再入库而不是默默脱敏放行。5.3 用内容指纹做版本比对只 diff 改动过的条款有了hash字段跨版本比对从全文 diff变成两个集合的比对几百条条款一秒出结果再把发生变化的条款送进difflib看细节。集合关系含义输出旧有新无条款被删除需 HR 确认是否失效新有旧无新增条款标记为本次改版重点两侧都有但 hash 不同条款被修改送 difflib 生成逐句差异两侧都有且 hash 相同未改动直接跳过import difflib def diff_versions(old_records: list[dict], new_records: list[dict]) - dict: old_map {r[id]: r for r in old_records} new_map {r[id]: r for r in new_records} added [k for k in new_map if k not in old_map] removed [k for k in old_map if k not in new_map] changed [] for k in old_map.keys() new_map.keys(): if old_map[k][hash] ! new_map[k][hash]: sm difflib.SequenceMatcher(None, old_map[k][text], new_map[k][text]) changed.append({ id: k, similarity: round(sm.ratio(), 3), # 低于 0.6 的条款建议人工整条重审 diff: list(sm.get_opcodes())[:10], }) return {added: added, removed: removed, changed: changed}逻辑说明set交集先筛出两侧同 id 的条款再用 hash 判断内容是否真的变了最后才动用较重的SequenceMatcher。参数说明similarity低于 0.6 的条款改动幅度已经接近重写自动合并差异没有意义直接推给 HR 人工确认比让模型瞎猜安全。get_opcodes()只取前 10 段差异够定位改动位置也避免一条长条款把输出撑爆。把每轮的 hash 快照单独落一张表下次改版时不用重新解析上一版 .doc直接读指纹就能出对比报告。本文还有配套的精品资源点击获取
返回列表