用Python将.doc劳动合同转为docx并自动化风险扫描

发布时间:2026/9/18 12:38:48
用Python将.doc劳动合同转为docx并自动化风险扫描 简介一份面向软件公司HR、法务及管理者的劳动合同范本用来规范员工入职签约流程避免因条款缺失引发劳动纠纷。文档以真实可用的科技公司劳动合同书为蓝本逐项讲解了员工编号与合同书结构、甲乙双方单位信息、固定期限/无固定期限/以完成生产工作为期限等三种合同期限、标准工时与综合计算工时、休息休假、工资支付及最低工资标准、社保缴纳、劳动保护和合同解除等必备条款同时覆盖试用期工资比例、加班费计算基数、裁员条件和离职流程等细节并重点提示加班费计算基数不得低于约定工资标准、试用期工资不得低于最低工资标准等合规要点企业可根据实际情况直接调整填写后使用。资源共1个DOC文件包体46KB轻量易编辑适配Word/WPS等常用办公软件。目前已有499人学习、下载是初创团队、小微企业以及需要更新劳动合同模板的行政人事人员较好的实用参考。1. 一份软件公司劳动合同里最值得被机器检查的部分拿到《软件公司劳动合同.doc》这类文件绝大多数团队的直觉是“改个名字和薪资就发出去”但真正值得被逐条检查的不是首页的工资数字而是保密、竞业限制、试用期、知识产权归属和离职后义务这几段。软件公司的核心资产是代码、数据和客户关系合同里一旦出现与日常开发节奏相冲突的条款仲裁时举证成本极高。而.doc这个老格式恰恰让这些条款既难以批量生成也难以被自动化扫描。这篇文章要做的是把一份.doc合同从“人工编辑”变成“模板生成 风险扫描”的流水线先搞清.doc与.docx在处理路径上的差异再写一套能批量生成、能自动标注高风险条款的工具链。适合正在做办公自动化、HR 系统集成的后端工程师也适合需要定期审计合同模板的法务或合规人员。下面按一条可复现的路径往下走。2. .doc 与 .docx 的格式边界先决定用什么库去解这个文件2.1 OLE2 与 OOXML为什么 .doc 不能直接用 python-docx 打开.doc是 Word 97-2003 的二进制格式底层是一个 OLE2Object Linking and Embedding 2复合文档文本内容按二进制流存储。.docx是 OOXML 格式本质是一个 zip 压缩包内部是word/document.xml等 XML 文件。这意味着处理路径完全不同python-docx只能解析.docx传一个.doc进去会直接报PackageNotFoundError。老一点的.wps文件甚至带有完整的 WPS 私有扩展头。所以第一件事是“格式归一化”。我一般把.doc统一转成.docx再做后续处理原因有三个一是.docx有成熟的纯 Python 生态二是转换后的 XML 可读定位占位符和表格更容易三是后续如果要做条款级比对git diff 也能派上用场。2.2 三条转换路径及选型对照处理.doc的常见路径有三条各有适用场景我列一张表对比。方案依赖转换质量适用场景LibreOffice headless 转换系统安装 LibreOfficelibreoffice-core高支持页面布局和表格Linux 服务器批量转换antiword/catdocapt 安装只提取文本丢格式只要纯文本做关键词扫描Win32 COM 调用 WordWindows Office 授权最高保留批注和修订内网 Windows 环境需要保留全部元数据其中最省心的是 LibreOffice headless。它是无界面进程可以稳定运行在 CI 或定时任务里不会弹出 Office 激活窗口也不需要 MSO 的 COM 权限。注意一点LibreOffice 与 Microsoft Word 在复杂排版上存在渲染差异但作为“先转格式再处理”的中间步骤完全够用。2.3 最小可用的批量转换命令单文件转换是基础命令soffice --headless --convert-to docx --outdir ./converted ./input/软件公司劳动合同.doc参数含义--headless表示不启动图形界面--convert-to docx指定输出格式--outdir指定输出目录默认输出到当前目录。转换成功后./converted下会出现同名.docx文件。目录里有一批合同要批量处理时用一段 Python 脚本循环即可import subprocess from pathlib import Path input_dir Path(./contracts) output_dir Path(./converted) output_dir.mkdir(exist_okTrue) for doc_file in input_dir.glob(*.doc): if doc_file.suffix.lower() ! .doc or doc_file.stem.endswith(~): continue # 跳过 Word 打开时产生的临时锁文件 subprocess.run( [ soffice, --headless, --convert-to, docx, --outdir, str(output_dir), str(doc_file), ], checkTrue, capture_outputTrue, ) print(fconverted: {doc_file.name})这里有两个容易被忽略的点一是过滤掉以~结尾的临时文件否则单元格内容解析会报错二是checkTrue让失败直接抛异常避免批量任务里静默跳过真实错误。首次在服务器上跑还可能遇到缺少字体导致的中文乱码需要在系统里安装fonts-noto-cjk这类中文字体包。3. 用 python-docx 做模板化生成把可变字段从正文里捞出来3.1 占位符替换段落和表格要分开遍历格式归一化之后接下来是批量生成。常见做法是维护一份.docx模板正文里写入{{name}}、{{title}}、{{base_salary}}这类占位符然后用员工数据一次性替换。python-docx遍历文档时段落和表格是两个独立的结构必须分别处理。import re from docx import Document PLACEHOLDER_PATTERN re.compile(r\{\{\s*(\w)\s*\}\}) def fill_paragraph(paragraph, data): for run in paragraph.runs: run.text PLACEHOLDER_PATTERN.sub( lambda m: str(data.get(m.group(1), m.group(0))), run.text, ) def fill_table(table, data): for row in table.rows: for cell in row.cells: for paragraph in cell.paragraphs: fill_paragraph(paragraph, data) def render_contract(template_path, employee, output_path): doc Document(template_path) for paragraph in doc.paragraphs: fill_paragraph(paragraph, employee) for table in doc.tables: fill_table(table, employee) doc.save(output_path)逻辑说明fill_paragraph只处理run.text不重排段落结构因此加粗、下划线这类格式能保留。re.sub的替换函数里data.get(m.group(1), m.group(0))表示字段缺失时保留原占位符而不写入空串方便事后检查。参数说明employee是一个普通 dict建议统一采用{name: 张三, position: 高级后端工程师}的形式template_path指向第 2 节转换或人工制作好的.docx模板。这里有一个容易踩的坑Word 默认把整段文字拆成多个 run如果占位符被拆成两半直接替换会失败。稳妥做法是先在模板里把占位符单独设成一种特殊颜色再由宏或在生成前做一次 run 合并。3.2 生成侧真正要管住的不是替换而是字段规则占位符替换逻辑本身很小真正要管理的是字段规则。我一般会写一个contract_variables.py集中定义每个字段的取值范围和格式化函数from datetime import date, timedelta def format_cny(amount: float) - str: if amount ! int(amount): raise ValueError(薪水必须为整数元) return f{int(amount):,}元 def probation_end_date(hire_date: date, months: int) - date: return hire_date timedelta(days30 * months) EMPLOYEE_FIELDS { name: {type: str, required: True}, id_card: {type: str, pattern: r\d{17}[\dXx]}, position: {type: str, required: True}, base_salary: {type: (int, float), fmt: format_cny, min: 1000}, hire_date: {type: date, fmt: lambda d: d.strftime(%Y年%m月%d日)}, probation_months: {type: int, choices: [1, 2, 3, 6]}, }这里的边界要卡死base_salary低于某个阈值时直接报错probation_months只允许有限枚举避免模板里出现“试用期 1 年”这种自造风险。字段规则独立于模板之后HR 改数据、你改规则两端不用互相等。3.3 批量生成并落地到目录生成时按“员工编号 姓名”命名文件方便后面扫描结果对齐import json from pathlib import Path from render_contract import render_contract employees json.load(open(employees.json, encodingutf-8)) output_dir Path(./generated) output_dir.mkdir(exist_okTrue) for emp in employees: output_path output_dir / f{emp[emp_id]}_{emp[name]}_劳动合同.docx render_contract(./templates/software_contract_template.docx, emp, output_path) print(fgenerated: {output_path})命名里带上emp_id后面做风险扫描、生成审计报告时就能按人追溯而不是打开文档再看一眼才知道是谁。到这里生成侧已经闭环。4. 合同风险扫描从 docx 里把高风险条款按行号找出来4.1 用正则规则引擎扫条款关键词生成之后不能直接归档还要过一遍风险扫描。扫描器用“规则列表 正则匹配”实现即可不需要上 NLP。每条规则包含条款名称、匹配正则、风险等级、建议文案。import re from docx import Document from docx.table import Table from docx.text.paragraph import Paragraph RULES [ { name: 试用期超过6个月, pattern: r试用期[^。]{0,20}(超过|为期)\s*([0-9])\s*个月, level: HIGH, suggestion: 试用期超过6个月在软件公司实践中极易引发争议建议核查最新法律口径, }, { name: 约定违约金, pattern: r(违约金|赔偿金)[^。]{0,30}(人民币|元|工资|%|), level: MEDIUM, suggestion: 除竞业限制与专项培训外约定违约金可能需要重新评估可执行性, }, { name: 竞业限制补偿缺失, pattern: r竞业限制(?!.*补偿|.*经济补偿), # 负向前瞻 level: HIGH, suggestion: 竞业限制条款若未约定补偿标准离职后员工可能主张条款不生效, }, { name: 加班审批制, pattern: r加班[^。]{0,10}(审批|申请), level: LOW, suggestion: 已有加班审批制注意需要配套保留审批记录否则认定时会吃亏, }, ] def iter_block_items(parent): parent_elm parent.element.body for child in parent_elm.iterchildren(): if child.tag.endswith(}p): yield Paragraph(child, parent) elif child.tag.endswith(}tbl): yield Table(child, parent)扫描时按块遍历给每个条款命中记录“块索引 前 30 字上下文”。软件公司的合同里最容易出事的是竞业限制这一段正则在设计时要同时匹配“竞业限制”和“补偿金”是否出现在同一句话中上面代码里的负向前瞻就是一个常见写法。4.2 命中结果要定位到页码而不是只给一段文字正则给出了命中文本但审计方打开合同后还是要逐页翻。要输出页码我一般再转一份 PDF按页切文本后回查命中位置def pdf_page_range(docx_path, start_text, window60): 返回命中文本首次出现的页码需要在第2节转换后追加执行。 import subprocess, tempfile from pathlib import Path with tempfile.TemporaryDirectory() as tmp: subprocess.run( [soffice, --headless, --convert-to, pdf, --outdir, tmp, docx_path], checkTrue, capture_outputTrue, ) pdf_path Path(tmp) / (Path(docx_path).stem .pdf) # 简单PDF文本抽取可使用 pdfplumber 或 pymupdf import pdfplumber with pdfplumber.open(pdf_path) as pdf: for idx, page in enumerate(pdf.pages, 1): text page.extract_text() or if start_text in text: return idx return Nonepdfplumber对中文的支持依赖系统安装的中文字体字体缺失时提取结果是空串这属于环境问题不是代码问题。页码定位输出的字段结构建议统一成五元组(员工编号, 条款名, 页码, 命中文本, 风险等级)。4.3 扫描结果的输出与门槛最后把全部命中按员工分组生成一份 Markdown 报告hits [] # [(E1001, 试用期超过6个月, 3, 试用期为9个月, HIGH), ...] for emp_id, name, page, text, level in hits: status ❌ if level HIGH else ⚠️ if level MEDIUM else ℹ️ if level HIGH: rejected.append(emp_id)报告文件直接输出到./audit_reports/audit_yyyy-mm-dd.md每一行写“谁、哪一页、命中什么、什么风险”。审计方不需要打开脚本只看这个报告就能决定哪些合同要退回重改。5. 把整条链串成一个命令用断言验证产出前面的模块已经齐了最后一步是串成流水线并做验收。我在项目根目录放一个run_audit.py按“转换 → 生成 → 扫描 → 报告”的顺序执行不做交互只做输出。#!/usr/bin/env python3 # 用法: python run_audit.py employees.json import sys from pathlib import Path employees json.load(open(sys.argv[1], encodingutf-8)) archived [] for emp in employees: docx_path Path(./generated) / f{emp[emp_id]}_{emp[name]}_劳动合同.docx # 验证1: 文件必须存在且非空 assert docx_path.exists() and docx_path.stat().st_size 0, docx_path # 验证2: 占位符不得残留 doc Document(docx_path) full_text \n.join(p.text for p in doc.paragraphs) assert {{ not in full_text, f存在未替换占位符: {docx_path} assert }}} not in full_text, f存在未替换占位符: {docx_path} # 验证3: HIGH 风险条款必须为 0 page scan_contract(docx_path) assert not any(h[level] HIGH for h in page), docx_path archived.append(docx_path) print(faudit passed: {len(archived)} contracts)验证逻辑不放在生成和扫描的函数内部而是单独放在入口脚本里。这样做有一个好处无论哪一天换了模板字段或者 HR 往模板里加了一个新段落验证都是最后一道闸门失败时打印具体文件路径而不是只给一个退出码。最后再补一个实用技巧在employees.json里刻意放入一条“试用期 12 个月”的测试数据单独跑一遍这条流水线确认扫描器能命中、报告里按 HIGH 标红、流水线退出码非 0。以后每次改模板或改规则都用这组数据做回归而不是拿真实合同去试错。这份regression.json就是整个工具链的可迁移验证资产。本文还有配套的精品资源点击获取