💡 完整的工作示例可在 GitHub 上获取:
office-metadata-pii-cleanup-nodejs

Introduction

上传端点接受来自员工的 DOCX 文件并将其存储到客户工单中。文本本身没有问题,属性却不行:文件记录了起草者、最后保存者、公司模板中的部门经理,以及因为文件来自 SharePoint 而记录的审批人。

元数据清理器是一个小脚本,在文件存储之前删除这些属性,并随后检查自己的工作。本文教程使用 Node.js 与 GroupDocs.Metadata 构建这样一个脚本,分四步完成:按标签选择属性、按名称选择属性、在选择性失效时一次性清除所有属性、以及验证剩余内容。每一步只需几行代码,完整脚本不足百行。

Why Metadata Sanitization Matters

这些数据会在无人主动选择的情况下累积。Word 在每次保存时会写入 AuthorLastSavedBy(来源于操作系统账户),维护修订计数、记录 TotalEditingTime,以及 LastPrinted。文档服务器会在签入时添加工作流路径、审批人标识和内容类型 URI。上述信息在文档阅读或打印时并不显示,因此校对时永远捕捉不到。

在 Node.js 中实现而不是手工操作的意义在于脚本会返回数字:每次删除调用会报告删除了多少属性,这个计数可以写入日志、在测试中断言,或附加到文档所属的记录上。

还有第二个原因——在批处理作业运行时更为明显。手动清理是由当时处理文件的人员一次性决定的,同一种文档由不同人清理会产生不同结果。脚本把规则固定在一个地方:相同的四个标签、相同的子字符串列表,无论队列中有三份文件还是三千份,都以完全相同的方式应用。

Prerequisites

该包通过 Java 在 Node.js 中运行,因此机器需要同时具备 Java 运行时和 Node。

Installation

npm install @groupdocs/groupdocs.metadata

示例项目锁定在 26.7 版本,并在 overrides 中将 nan 设置为 ^2.22.0,以保证在当前 Node 版本上仍能构建本机绑定。没有许可证文件时,库会以评估模式运行,足以完成本文的所有步骤。

Step 1 - Select properties by what they mean

不同格式和包的属性名称各不相同,因此第一条规则改为按标签匹配。ContainsTagSpecification 接收一个标签并匹配携带该标签的任何属性;.or() 将多个规格合并为一个。

const T = groupdocs.Tags;
const spec = new groupdocs.ContainsTagSpecification(T.getPerson().getCreator())
  .or(new groupdocs.ContainsTagSpecification(T.getPerson().getEditor()))
  .or(new groupdocs.ContainsTagSpecification(T.getPerson().getManager()))
  .or(new groupdocs.ContainsTagSpecification(T.getCorporate().getCompany()));
const affected = metadata.removeProperties(spec);
metadata.save(outputPath);

关键要点:

  • 四个标签覆盖了身份组:创建者、编辑者、经理以及公司字段。
  • Title、Subject 和 Keywords 保持不变,因而基于这些字段的记录索引仍能正常工作。
  • removeProperties 返回受影响的计数,而不是布尔值。

请将整个过程放在 try/finally 中,并在 finally 里调用 metadata.close()。绑定会一直保持文件打开状态,若不关闭,在循环中会耗尽句柄。

Step 2 - Select properties by name

评论线程、修订计数和服务器字段没有标签。对于这些情况,WithNameSpecification(needle, false) 能匹配名称中包含指定子串的任何属性,下面的四行构造器会为每个子串链式创建规格:

let spec = null;
for (const needle of needles) {
  const s = new groupdocs.WithNameSpecification(needle, false /* fullMatch */);
  spec = spec ? spec.or(s) : s;
}
return spec;

三次遍历使用该构造器并提供不同的列表。首先处理评论相关属性:

const affected = metadata.removeProperties(
  nameContainsSpec(['Comment', 'Reviewer', 'Reviewed']));
metadata.save(outputPath);

接下来是编辑时间线,这一组属性往往被遗忘,却正是描述文档生成过程的关键:

const affected = metadata.removeProperties(nameContainsSpec([
  'Revision', 'TrackedChange', 'LastPrinted', 'TotalEditingTime', 'EditTime',
]));
metadata.save(outputPath);

最后是 SharePoint 相关属性,调用方式相同,只需传入 ServerWorkflowApproverContentTypeTemplate 等子串。使用子串匹配是有意为之:它能一次捕获 CommentsCountComment,无需为每种格式维护完整的属性名列表。

Step 3 - Wipe everything when selectivity stops helping

对于最终要离开组织的副本,只需一次调用即可替代前面的四次遍历:

const affected = metadata.sanitize();
metadata.save(outputPath);

sanitize() 会清除库检测到的所有元数据包(包括自定义 OOXML 部分),其返回的计数通常会超过所有针对性遍历的总和。它同样会删除 Title 和 Subject,这也是它应在最终阶段而非审查循环中使用的原因。

Step 4 - Verify, because a silent miss looks like success

扫描再次使用相同的规格,通过 findProperties 只读不写。返回的是 Java 集合,需要通过索引遍历:

const props = metadata.findProperties(tagSpec.or(nameSpec));
for (let i = 0; i < props.getCount(); i++) {
  const p = props.get_Item(i);
  const val = p.getValue && p.getValue();
  const value = val ? String(val.getRawValue ? val.getRawValue() : val) : '';
  if (!value || value === '0' || value === '0.0') continue;
  leaks.push(`${p.getName()}=${value}`);
}

空值和零值过滤是有原因的。我在一次运行中发现一个已被清零的修订计数仍被报告为残留属性,于是加入了这段过滤逻辑。

Complete Working Example

仓库将六个函数组合进 index.js,该文件加载许可证、对 resources/pii-sample.docx 依次执行每个遍历、断言每个输出文件存在,并最终断言泄漏列表为空。若断言失败,进程会以非零码退出,这使得整个脚本可以作为 CI 检查而非仅供演示的示例。

有一点值得在自己的实现中复制:每次遍历都读取同一源文件并写入独立的输出,而不是把一次清理后的文件作为下一次的输入。这样可以保持各遍历的受影响计数相互独立,评论遍历的日志行会显示评论规则本身找到的属性数量,而不是在身份规则执行后剩余的数量。

When should I run a targeted pass instead of sanitize()?

只要文档仍在使用中就应采用针对性遍历。处于审阅流程的文件依赖 Title、Subject 和 Keywords 进行搜索和分类,而 sanitize() 会同时删除这三者以及个人数据。协作期间运行身份和评论遍历,保留描述性字段;在文档最终离开组织时再执行完整的 sanitize()

Real-World Applications

Upload handler

Express 路由在将附件写入存储之前先进行清理,将受影响计数记录到工单上,并在泄漏列表非空时拒绝上传。

Nightly export job

后台任务遍历导出文件夹,执行身份和服务器遍历;若仍检测到残留 PII,则任务直接失败,而不是仅记录警告。

Pre-publication gate

构建步骤在发布前清理文档附件,使用 sanitize() 因为这些文件不需要保留任何元数据。

Best Practices and Tips

  • 始终写入新路径,以便原始文件在争议解决时仍可保留。
  • finally 块中关闭 metadata 对象,尤其是在循环中使用时。
  • 在批处理作业中使用 .or() 合并规格;一次打开一次保存要比四次打开四次保存更高效。
  • 记录每次遍历的受影响计数,即使为零,这样可以发现未被识别的格式。

Troubleshooting Common Issues

受影响计数为零,但文档明显脏 先确认输入格式是否被库识别;未识别的文件和干净的文件都会返回零。

泄漏检查报告了刚刚删除的属性 请将扫描指向已保存的输出路径,而不是原始输入路径。扫描会读取它所收到的文件。

Word 中仍然显示评论气泡 评论文本存放在文档正文中,而不是元数据包。GroupDocs.Metadata 会清除与评论相关的属性,但要去除气泡本身需要使用如 Aspose.Words 之类的内容编辑库。

Conclusion

四步、六个函数、一个脚本即可报告所做的全部工作。标签规格处理跨格式的身份组,名称规格覆盖标签未分类的属性,sanitize() 负责边界清理,泄漏扫描将整个过程转化为检查。克隆仓库,对经过真实审阅的文档运行它,观察计数后再决定流水线需要哪些遍历。

Additional Resources