Существуют десятки утилит для очистки метаданных (от ExifTool до встроенных средств ОС), но у всех них есть общий минус — они требуют ручного действия.

В результате родился MetaPure — фоновый демон для Windows, который перехватывает файлы в буфере обмена в момент копирования и бесшовно выжигает из них метаданные, прежде чем вы нажмете Ctrl+V.

Windows хранит скопированные файлы, почему парсить XML напрямую иногда лучше, чем использовать готовые библиотеки, и какие грабли поджидают при работе с win32clipboard.

Архитектура: все случается до Ctrl+V

Логика работы максимально проста для пользователя, но чуть сложнее под капотом:

  1. Пользователь копирует файл (или группу файлов).

  2. Фоновый поток (ClipboardMonitor) опрашивает буфер обмена с интервалом в 500 мс.

  3. Если обнаружен формат CF_HDROP (список файлов), запускается валидация пути.

  4. Переданный путь отправляется в MetadataScrubber.

  5. Файл на диске перезаписывается "чистой" версией.

Да, утилита перезаписывает оригинальный файл по месту. Это осознанное архитектурное решение: если мы попробуем создать копию, нам придется подменять данные прямо в буфере обмена, что в Windows API быстро превратится в ад с блокировками памяти.

Под капотом буфера обмена: охота на CF_HDROP

Работа с буфером обмена в Python обычно сводится к библиотеке pyperclip, но она умеет работать только с текстом. Чтобы получить список скопированных файлов, придется спуститься на уровень Win32 API через pywin32.

Файлы в буфере лежат не в виде списка строк, а в специфическом формате CF_HDROP (Drop File List).

И здесь начались первые грабли. Если посмотреть в логи разработки, можно увидеть классическую боль:

src.clipboard_monitor - ERROR - Ошибка при получении файлов из буфера обмена: module 'win32clipboard' has no attribute 'DragQueryFile'
...
src.clipboard_monitor - ERROR - Ошибка при получении файлов из буфера обмена: function 'DragQueryFileW' not found

Дело в том, что в старых версиях pywin32 метод GetClipboardData(win32clipboard.CF_HDROP) возвращал сырой указатель на структуру DROPFILES, и для получения имен файлов нужно было вручную вызывать функцию DragQueryFileW из shell32.dll через ctypes. В новых версиях обертка умеет возвращать сразу список строк, но при сборке через PyInstaller или при несовпадении версий DLL это поведение ломается.

В финальной версии кода я использую встроенный парсинг обертки, но валидация полученных данных стала критически важной:

if win32clipboard.IsClipboardFormatAvailable(win32clipboard.CF_HDROP):
    file_list = win32clipboard.GetClipboardData(win32clipboard.CF_HDROP)

Защита от symlink-атак

Поскольку скрипт работает в фоне и имеет доступ к записи файлов, нужно было исключить ситуации, когда кто-то скопирует ярлык, указывающий на C:\Windows\System32, или symlink.

В ClipboardMonitor реализован жесткий validate_path:

  • Проверка, что путь абсолютный.

  • Сравнение inode оригинального пути и resolved_path для блокировки симлинков и жестких ссылок.

  • Проверка, что файл не лежит внутри директории WINDIR.

  • Лимит на количество файлов за одно копирование (защита от перегрузки системы при копировании папки с тысячами фото).

В логах есть забавный момент: приложение пыталось обработать папку C:\Users\denis\Desktop\MetaPure как файл. Защита от срабатывания на директориях (resolved_path.is_file()) оказалась не лишней.

Хирургия по удалению метаданных

MetadataScrubber поддерживает изображения (JPEG, PNG, TIFF, BMP), видео (контейнеры MP4, MKV, MOV — хотя метаданные там часто хранятся в специфических атомах, требующих ffmpeg, пока поддержка базовая) и документы (PDF, DOCX).

Очистка делится на 4 категории: геолокация, устройство (камера/телефон), ПО (обработчики) и персональная информация.

Изображения: паттерн "Temp -> Move"

Удалять EXIF из картинок "на лету" опасно. Если прервать процесс записи, файл будет поврежден. Я использую атомарную замену через временный файл:

# Открываем, берем только "чистые" пиксели
with Image.open(file_path) as img:
    data = list(img.getdata())
    image_without_exif = Image.new(img.mode, img.size)
    image_without_exif.putdata(data)
    
    # Пишем во временный файл
    temp_path = file_path.with_suffix(file_path.suffix + '.temp')
    image_without_exif.save(temp_path, format=format_name)
    
    # Атомарная замена оригинала
    shutil.move(str(temp_path), str(file_path))

Это гарантирует, что если пользователь в момент очистки вставит файл куда-то, он получит либо оригинал, либо уже полностью пересохраненный файл, но никогда не "половинку".

Word (.docx): грязный хак с XML

Формат DOCX — это обычный ZIP-архив с XML-файлами внутри. Библиотека python-docx отлично умеет менять свойства документа (автора, дату изменения), но она абсолютно беспомощна, когда дело доходит до Track Changes (истории изменений) и скрытых комментариев.

Чтобы вычистить ревизии, пришлось лезть прямо в _blob внутренних частей документа (parts):

xml = part._blob.decode('utf-8')
if 'w:revision' in xml or 'w:ins' in xml or 'w:del' in xml:
    xml = xml.replace('w:revision', 'w:revision_removed')\
             .replace('w:ins ', 'w:p ')\
             .replace('w:del ', 'w:p ')
    part._blob = xml.encode('utf-8')

Да, прямая работа с сырым XML через replace — это грязный хак, который может сломаться на специфической разметке сложных таблиц. Но альтернативы в экосистеме Python просто нет. Этот подход выжигает теги правок на корневом уровне, заставляя Word воспринимать текст как финальный.

PDF: пересборка

С PDF всё проще. Используется pypdf: мы читаем все страницы и собираем их в новый объект PdfWriter, не перенося метаданные словаря.

writer = PdfWriter()
for page in reader.pages:
    writer.add_page(page)
writer.add_metadata({}) # Пустой словарь

Грабли с системным треем и Windows API

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

Но в логах остался след борьбы с системным треем:

src.notification - ERROR - Ошибка при показе уведомления: (-2147467259, 'Shell_NotifyIcon', 'Неопознанная ошибка')
src.notification - ERROR - Ошибка при уничтожении окна уведомлений: (1400, 'DestroyWindow', 'Недопустимый дескриптор окна.')

Windows Shell_NotifyIcon (NIM_ADD / NIM_DELETE) требует строгого соблюдения жизненного цикла оконных дескрипторов (HWND). Если поток, создавший иконку, завершается без корректного вызова DestroyWindow, или если попытаться обновить иконку после закрытия окна Tkinter, COM-объекты накрываются медным тазом. В итоге модуль уведомлений был полностью вырезан в угоду стабильности фонового процесса.

Итоги

MetaPure получился именно тем инструментом, которым хочется пользоваться самому — запустил и забыл. Он не требует подписки, не шлет данные на сторонние серверы (вся обработка идет локально через PIL и парсеры), и защищает от классических утечек данных при пересылке файлов.

Что можно улучшить в будущем:

  1. Поддержка HEIC (фотографии с iPhone требуют специфического парсинга EXIF).

  2. Глубокая очистка видео (вырезание метаданных из атомов moov/udta через mutagen или обертку над ffmpeg).

  3. Адаптация под Linux (через прослушку D-Bus интерфейса буфера обмена) и macOS (через NSPasteboard).

Исходный код проекта доступен на GitHub: https://github.com/slimeopus/MetaPure/releases

P.S. Приложение использует polling буфера обмена раз в полсекунды, что дает идеальный баланс между моментальностью реакции и нагрузкой на процессор (0% CPU в простое).