💡 Полный рабочий пример доступен на GitHub:
document-version-metadata-diff-python

Что вы построите

В этом руководстве вы сравните каждое свойство метаданных между двумя версиями документа и выведите точно, что было добавлено, удалено или изменено. Сравнение версий метаданных — это сравнение свойств двух ревизий одного файла, которое фиксирует сигналы, недоступные при текстовом сравнении: новый Creator, изменённый RevisionNumber, сеанс редактирования, зафиксированный после закрытия рецензии. К концу вы получите работающий скрипт, два специализированных детектора и два формата экспорта, всё из готового репозитория с примерной парой ревизий.

Уровень навыков: промежуточный разработчик Python
Что требуется: Python 3, pip и две ревизии одного документа

Мой первый запуск этого скрипта обнаружил изменение значения Company, которое никто из команды не помнил менять; эта одна строка оправдала настройку. Всё ниже готово к копированию‑вставке и укладывается в менее чем сто строк.

Конвейер преднамеренно прост: два открытия файлов, три словарных включения, цикл печати. Скука — это цель. Споры о версиях решаются тем, можно ли метод объяснить и повторить, а такой небольшой скрипт может полностью прочитать любой, кто ставит под сомнение результат.


1. Установка

pip install groupdocs-metadata-net==26.5

Сопутствующий репозиторий фиксирует эту версию и содержит document-v1.docx и document-v2.docx, так что код ниже работает «как есть». Зафиксируйте ту же версию, с которой запускался ваш аудит; воспроизводимость — часть доказательства.


2. Основной код

Считайте оба дерева свойств, а затем классифицируйте дельту с помощью логики множеств. Это весь diff:

# Flatten a file's complete property tree into a dict
def read_props(path):
    props = {}
    with Metadata(path) as metadata:
        for p in metadata.find_properties(lambda p: p.name is not None):
            props[p.name] = (str(p.interpreted_value) if p.interpreted_value is not None
                             else (str(p.value) if p.value is not None else ""))
    return props

v1 = read_props("resources/document-v1.docx")
v2 = read_props("resources/document-v2.docx")

# Classify every key; changed entries keep both values
added = {k: v for k, v in v2.items() if k not in v1}
removed = {k: v for k, v in v1.items() if k not in v2}
changed = {k: (v1[k], v2[k]) for k in v1 if k in v2 and v1[k] != v2[k]}

print(f"added={len(added)} removed={len(removed)} changed={len(changed)}")
for k, (old_v, new_v) in changed.items():
    print(f"  {k}: {old_v} -> {new_v}")

Это минимум, который вам нужен. Ожидайте небольшие количества при реальных парах ревизий; дельта в десятках обычно означает, что файл прошёл через изменение шаблона или миграцию хранилища. В следующих разделах объясняются ключевые вызовы и показываются настройки, которые большинство команд добавляют в первую очередь.


3. Как это работает

  • Metadata: менеджер контекста, открывающий файл и освобождающий его при выходе; один экземпляр — одна ревизия.
  • find_properties: проходит по встроенным полям, пользовательским свойствам и XMP за один проход, возвращая всё, что принимает предикат.
  • interpreted_value: человекочитаемая форма свойства; предпочтение ей позволяет сравнивать даты и перечисления как строки, которые можно вывести в отчёте.
  • Квалифицированные имена как ключи: встроенные и пользовательские поля не могут конфликтовать в словаре, поэтому логика множеств остаётся безопасной.

Никакое из этого не парсит структуру DOCX. Документация продукта перечисляет более 170 форматов, поддерживаемых тем же вызовом, так что идентичный скрипт сравнивает пары PDF или XLSX.

Ещё один аспект дизайна стоит упомянуть: граница API заканчивается двумя вызовами read_props. Всё, что после них, — это стандартная библиотека Python, поэтому юнит‑тесты, пороги и правила оповещений никогда не касаются уровня документа. Команды, оборачивающие это в сервис, обычно кэшируют извлечённые словари по ревизии и позволяют каждому downstream‑проверяющему использовать их повторно, оставляя файловый ввод‑вывод на один открытый файл на версию, независимо от количества вопросов.


4. Распространённые настройки

Детектировать только изменения владения

Когда вопрос — «кто трогал этот файл», фильтруйте уже при чтении с помощью предикатов тегов, а не после полного diff:

# Identity fields only, whatever the format calls them
def read_ownership(path):
    result = {}
    with Metadata(path) as metadata:
        props = metadata.find_properties(lambda p:
            Tags.person.creator in list(p.tags)
            or Tags.person.editor in list(p.tags)
            or Tags.person.manager in list(p.tags)
            or Tags.corporate.company in list(p.tags))
        for prop in props:
            result[prop.name] = (str(prop.interpreted_value)
                                 if prop.interpreted_value is not None
                                 else (str(prop.value) if prop.value is not None else ""))
    return result

Запустите тот же цикл дельты над двумя такими словарями, используя <missing> как значение по умолчанию, чтобы поле, исчезнувшее, всё равно появилось. Имена предикатов не зависят от конкретного поля, что позволяет одному детектору обслуживать любой формат, читаемый библиотекой.

Отслеживание временной линии редактирования

Замените предикат на Tags.time плюс правила имён счётчиков, и детектор будет сообщать о перемещениях RevisionNumber, TotalEditingTime и LastPrinted:

props = metadata.find_properties(lambda p:
    Tags.time.modified in list(p.tags)
    or Tags.time.created in list(p.tags)
    or Tags.time.printed in list(p.tags)
    or (p.name is not None and ("Revision" in p.name
        or "EditTime" in p.name or "EditingTime" in p.name)))

Экспортировать аудиторский отчёт

Вывод в консоль умирает там. Четыре столбца покрывают таблицу и случай SIEM:

with open("output/diff.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["change_type", "property", "old_value", "new_value"])
    for k, v in added.items():
        writer.writerow(["added", k, "", v])
    for k, v in removed.items():
        writer.writerow(["removed", k, v, ""])
    for k, (old_v, new_v) in changed.items():
        writer.writerow(["changed", k, old_v, new_v])

В репозитории также есть экспортер в JSON со стабильной схемой из трёх карт для дашбордов и API управления кейсами.


5. Где это используется на практике

Три типичных развертывания. Конвейеры приёма сравнивают каждый поступающий документ с копией, уже находящейся в системе, и изолируют пары с изменениями идентификации. Задачи комплаенса запускают diff по расписанию и архивируют CSV‑файл для каждой пары, формируя временную шкалу свойств, которую потом не придётся восстанавливать вручную. Инструменты разрешения споров запускают оба детектора по запросу, потому что когда появляется претензия, первый вопрос всегда — кто и когда трогал файл, а не что изменилось в четвёртом абзаце.

Четвёртый паттерн — сравнение файла с его последним известным «хорошим» снимком, используя тот же код с сохранённым словарём с одной стороны. Во всех случаях экспортный файл является конечным результатом; вывод в консоль — только шум прогресса. Шаблон кода возвращает код выхода, аналогичный main.py репозитория, поэтому планировщики и CI трактуют провал утверждения как провал выполнения без дополнительной обвязки. Ни одна из этих схем не требовала кода, выходящего за рамки того, что показано на этой странице.


6. Что считается изменением, требующим пометки?

Любое свойство, классифицированное diff‑ом, плюс контекст, который вы добавляете. Добавленные и удалённые свойства всегда заслуживают внимания, потому что они означают изменение структуры, а не только значения. Для изменённых записей большинство команд сначала оповещают о группах идентификации и ревизии, а остальное рассматривают как информационное. Детекторы существуют, чтобы первый проход стоил лишь одного вызова функции.


7. Быстрая справка: ключевые вызовы

Вызов Что делает
Metadata(path) Открывает файл; менеджер контекста освобождает его
find_properties(predicate) Возвращает каждое свойство, которое принимает предикат, во всех слоях
p.interpreted_value Человекочитаемое значение; при отсутствии — p.value
Tags.person.* / Tags.corporate.company Классификация идентификации, независимая от формата
Tags.time.* Классификация временных меток для детектора ревизий

Смотрите полную справочную документацию API для полного перечня возможностей поиска и тегирования. Словарь тегов шире, чем эти строки; группы тегов origin, content и legal используют те же тесты членства.


8. Распространённые проблемы и их решения

Diff огромный и выглядит как шум
→ Скорее всего, два пути — это не ревизии одного документа. Исправление: проверьте происхождение файлов перед сравнением; несвязанные файлы дают бессмысленные дельты.

Известное поле автора не появляется в детекторе владения
→ Некоторые генераторы сохраняют идентификацию в нетегированных пользовательских полях. Исправление: выполните полный diff один раз, найдите реальное имя поля и расширьте предикат правилом по имени.

В консоли появляется предупреждение о режиме оценки
→ Не найден файл лицензии. Исправление: укажите LICENSE_PATH в main.py к вашему .lic файлу, либо оставьте режим оценки для разработки; логика остаётся той же.

Даты выводятся как сырые серийные числа
→ Где‑то в читателе попал p.value вместо interpreted_value. Исправление: сохраняйте шаблон «сначала interpreted_value», как в read_props; именно он делает отчёты читаемыми.


9. Что дальше?

У вас есть работающий diff метаданных. Дальнейшие шаги:

  • Пакетировать: обойдите скрипт по парам документов и сохраняйте CSV для каждой пары; стоимость — два открытия файлов, а CSV легко конкатенировать для обзора всей библиотеки.
  • Запланировать: main.py репозитория проверяет каждый шаг и возвращает корректный код выхода, который сразу же можно подключить к CI или планировщику.
  • Пройти учебный вариант: руководство по использованию строит тот же конвейер в трёх поэтапных уроках.
  • Посмотреть весь проект: document-version-metadata-diff-python с заготовленной парой ревизий.

Ресурсы