简介:《周大福珠宝员工手册》是一份面向珠宝零售企业员工与人力资源从业者的内部管理规范文档,围绕企业文化、日常行为准则与人事制度展开,适合入职培训、制度梳理或门店管理参考。压缩包内仅含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 中,是干净的 XML,python-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 Tika | JVM | 中,表格为扁平文本 | 有限 | 中 | 已有 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:UserInstallation=file:///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(parents=True, exist_ok=True) def convert_one(doc_path: Path, worker_id: int) -> tuple[str, bool, str]: profile = PROFILE_ROOT / f"p{worker_id}" cmd = [ "soffice", "--headless", "--norestore", "--invisible", f"-env:UserInstallation=file://{profile}", "--convert-to", "docx", "--outdir", str(OUT_DIR), str(doc_path), ] try: # 单份超时 120s,慢文档多半是嵌了大图或损坏,直接放弃比拖垮队列划算 proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) 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_workers=workers) 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_revisions=True) -> 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_revisions=True))逻辑说明:遍历<w:t>并向上回溯祖先链,判断它落在插入区还是删除区。参数说明:accept_revisions=True表示只收生效后的文本,适合给员工查询的检索库;对比改版时改成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(r"1[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,直接读指纹就能出对比报告。
本文还有配套的精品资源,点击获取