用Python将.doc劳动合同转为docx并自动化风险扫描
2026/9/18 12:38:37 网站建设 项目流程

简介:一份面向软件公司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 的二进制格式,底层是一个 OLE2(Object 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 转换系统安装 LibreOffice,libreoffice-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_ok=True) 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), ], check=True, capture_output=True, ) print(f"converted: {doc_file.name}")

这里有两个容易被忽略的点:一是过滤掉以~结尾的临时文件,否则单元格内容解析会报错;二是check=True让失败直接抛异常,避免批量任务里静默跳过真实错误。首次在服务器上跑,还可能遇到缺少字体导致的中文乱码,需要在系统里安装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(days=30 * 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", encoding="utf-8")) output_dir = Path("./generated") output_dir.mkdir(exist_ok=True) 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(f"generated: {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, window=60): """返回命中文本首次出现的页码,需要在第2节转换后追加执行。""" import subprocess, tempfile from pathlib import Path with tempfile.TemporaryDirectory() as tmp: subprocess.run( ["soffice", "--headless", "--convert-to", "pdf", "--outdir", tmp, docx_path], check=True, capture_output=True, ) 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 None

pdfplumber对中文的支持依赖系统安装的中文字体,字体缺失时提取结果是空串,这属于环境问题,不是代码问题。页码定位输出的字段结构建议统一成五元组:(员工编号, 条款名, 页码, 命中文本, 风险等级)

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], encoding="utf-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(f"audit passed: {len(archived)} contracts")

验证逻辑不放在生成和扫描的函数内部,而是单独放在入口脚本里。这样做有一个好处:无论哪一天换了模板字段,或者 HR 往模板里加了一个新段落,验证都是最后一道闸门,失败时打印具体文件路径而不是只给一个退出码。

最后再补一个实用技巧:在employees.json里刻意放入一条“试用期 = 12 个月”的测试数据,单独跑一遍这条流水线,确认扫描器能命中、报告里按 HIGH 标红、流水线退出码非 0。以后每次改模板或改规则,都用这组数据做回归,而不是拿真实合同去试错。这份regression.json就是整个工具链的可迁移验证资产。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询