Files
miem_workers/README.md
codex a82f63d6cb
CI / test (pull_request) Successful in 10m8s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
feat: add system users, password management and activity history
2026-10-10 18:55:05 +03:00

51 KiB
Raw Permalink Blame History

MIEM Employees Server

Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres и показывает минимальную админку.

Архитектура

  • api: FastAPI, REST API, HTML-админка и healthcheck.
  • worker: weekly scheduler, который запускает парсинг по CRAWL_CRON.
  • postgres: основная БД.

Парсер использует фиксированный источник сотрудников, по умолчанию https://miem.hse.ru/persons. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу employee_publications, а новости из блока «В новостях» — в employee_news_links. Ссылки обходятся только из меню профиля самого сотрудника (person-menu), например #sci, #teaching, #main.

Переменные окружения

Скопируйте .env.example в .env и поменяйте секреты:

cp .env.example .env

Основные настройки:

  • DATABASE_URL: строка подключения SQLAlchemy.
  • SOURCE_URL: список сотрудников МИЭМ.
  • CRAWL_CRON: расписание в формате crontab, по умолчанию 0 3 * * 1.
  • CRAWL_LIMIT: опциональный лимит профилей для тестового запуска.
  • ADMIN_USERNAME, ADMIN_PASSWORD: начальные данные администратора для создания учётной записи в БД. После смены пароля используется пароль из БД; значения окружения его не перезаписывают.
  • SESSION_SECRET: секрет подписи cookie.
  • PARSER_USE_PLAYWRIGHT: включение Playwright-рендера динамических вкладок.
  • DISMISSAL_CONFIRMATION_RUNS: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию 3.
  • MAX_AUTO_DISMISSALS_PER_RUN: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию 25.

Локальный запуск

python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload

Админка: http://localhost:8000/admin.

В админке доступны:

  • «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
  • «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
  • «Запуски»: история обходов, ошибки и доступный индикатор прогресса.

Все фильтры применяются кнопкой «Применить фильтры» с переходом на первую страницу. «Сбросить» очищает условия; при устаревшем номере страницы каталог возвращает первую. Подписи полей показывают выбранные условия, над таблицей указан диапазон результатов. Пояснения дат и статусов находятся под фильтрами.

Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.

Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.

Пользователи и личный кабинет

/admin/users доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён.

Временный пароль создаётся криптографическим генератором, содержит 8 символов и все четыре группы: строчные/заглавные латинские буквы, цифры и символы. Показывается администратору только в ответе на создание или сброс; повторно прочитать его нельзя. В БД — PBKDF2-HMAC-SHA256 с 600 000 итерациями и отдельной случайной солью. Пароли и их хеши не включаются в журнал действий или страницы просмотра истории.

При первом успешном входе и после сброса требуется задать свой пароль (15–128 символов, можно фразу), указав текущий пароль. Попап нельзя закрыть Escape или нажатием на фон; можно сменить пароль либо выйти. Без JavaScript доступна обычная форма, а серверные операции до смены пароля заблокированы. Начальный администратор также меняет пароль при первом входе после обновления.

/admin/account показывает первый/последний вход, смену пароля и историю действий. Каждый видит только свои действия и события, относящиеся к его учётной записи; администратор на странице пользователей видит общую историю или фильтр по учётной записи. Журнал фиксирует успешные входы, смену/сброс пароля, изменения ролей/доступа и реальные правки карточек: сотрудник, статья, год, прежние/новые значения названия, журнала, квартиля и тематической связи. Сохранение правки и запись истории — одна транзакция; отклонённые и неизменённые сохранения не создают запись о редактировании. История разбита на страницы по 30 записей.

Сессия ограничена 12 часами. Смена/сброс пароля, изменение роли/доступа и выход отзывают прежние сессии. Собственную роль/доступ и пароль через административный сброс менять нельзя: свой пароль меняется в личном кабинете. Изменения доступа сериализуются, чтобы параллельные администраторы не лишили систему всех администраторов. После пяти неверных входов учётная запись блокирует новые входы на 15 минут; административный сброс снимает это ограничение.

Все POST-формы требуют CSRF-токен; JSON-редактор сохраняет обязательный специальный заголовок при отключённом CORS. Проверяется Origin. Cookie — HttpOnly/SameSite, при HTTPS или HTTPS за обратным прокси — Secure; приватные страницы и одноразовые пароли возвращаются с Cache-Control: no-store. Для программного запуска парсинга передавайте CSRF-токен действующей административной сессии в X-CSRF-Token.

При штатном запуске создаются таблицы system_users и user_actions и начальная административная запись, если её нет. Для ручного добавления схемы доступна migrations/013_system_users.sql. Перед обновлением рабочей БД сделайте штатную резервную копию. После обновления потребуется повторный вход; первый вход и история отсчитываются от введения этой функции, прежние события не восстанавливаются. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.

Проверки: python -m pytest -q, python tests/browser_users.py. Браузерный сценарий использует только синтетическую SQLite-БД и временный локальный HTTP-сервер без запуска миграций рабочего приложения; временные пароли на скриншотах скрыты. Для native PostgreSQL-проверок тестов пользователей задайте USERS_TEST_DATABASE_URL на отдельную одноразовую БД miem_users_test: эти тесты создают и удаляют в ней схему и не должны запускаться на рабочей БД.

Docker Compose

docker compose up -d --build --remove-orphans

По умолчанию:

  • API и админка: http://localhost:8000
  • PostgreSQL: postgres:5432 внутри сети Compose; порт на хост не опубликован.

Compose запускает api, worker и postgres. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. REST API данных требует сессию администратора.

Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например crawl_runs.skipped_count. SQL-миграции для ручного применения лежат в migrations/.

Наполнение БД

Основная карточка сотрудника хранится в employees: профиль, статус, даты обнаружения/увольнения, текущий JSON current_data, checksum и версия парсера. История успешных изменений сохраняется в employee_snapshots вместе с JSON-снимком и сжатым HTML профиля.

Публикации теперь хранятся в двух видах:

  • краткий список остается внутри employees.current_data.sections[].publications для обратной совместимости;
  • детальные записи сохраняются в employee_publications и связываются с сотрудником через employee_id.

employee_publications содержит publication_id, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа searchPubs и source_hash для безопасного повторного upsert. Уникальность поддерживается по (employee_id, publication_id) и (employee_id, source_hash), поэтому повторный crawl не должен создавать дубликаты.

Новости сотрудников также хранятся в двух видах:

  • краткий список остается внутри employees.current_data.sections[].news_links;
  • нормализованные карточки из вкладки «В новостях» сохраняются в employee_news_links.

employee_news_links содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и source_hash. Уникальность поддерживается по (employee_id, url) и (employee_id, source_hash), поэтому повторный crawl не создает дубликаты.

Кандидаты в диссертационный совет

Страница /admin/council доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.

На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422.

Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.

Начальный список диссовета содержит только сотрудников и счётчики. Публикации загружаются при раскрытии карточки через /admin/council/employees/{id}/articles с сохранением отрасли и специальности; повторное раскрытие использует уже загруженные данные текущей страницы. При ошибке доступна кнопка повтора, без JavaScript — ссылка на публикации. Экспорт XLSX продолжает включать все найденные записи.

До загрузки публикаций применяется поиск по ФИО и выбор конкретного сотрудника. Проверка одинаковых ФИО остаётся глобальной, а общий счётчик сотрудников не зависит от поиска. PostgreSQL возвращает только разделы профиля об образовании/степенях и необходимые поля публикаций; аннотации, авторы и остальной профиль не загружаются. Plain-text описания журналов не проходят через HTML-парсер; HTML и сущности обрабатываются как прежде.

Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены.

Кнопка «Статьи, журналы и квартили» открывает /admin/council/publications в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход пользователя или администратора.

Ручные изменения хранятся в отдельной таблице council_corrections, создаваемой при запуске приложения (migrations/012_council_corrections.sql для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.

Расчёт использует пять календарных лет, включая текущий год Москвы: в 2026 году — 2022–2026. Точные день и месяц для периода не нужны; конфликт годов остаётся причиной проверки. В форме фильтров выбирается отрасль оценки, которая задаёт порог для доктора (9 для технических, 11 для физико-математических и т. д.); для кандидата/PhD минимум 20. Выбранная отрасль сохраняется при переходах страниц и выгрузке XLSX, а все пороги вынесены в пояснения над списком.

Статья с известным видом материала, журналом и квартилем Q1/Q2 из источника, применимым к году публикации, автоматически входит в «Подтверждено», если год входит в период и нет конфликтов идентичности, вида и годов. Неизвестный вид, отсутствующий журнал, квартиль другого года или ручная замена квартиля без источника не дают автоматического зачёта. Подтверждение степени и рассмотрение содержания статьи по специальности оцениваются отдельно.

Редактор позволяет заменить связь журнала с одной или несколькими из доступных специальностей. Ручная связь применяется ко всем статьям журнала независимо от года; в фильтр попадают допустимые публикации за календарный период. Пустой ручной список исключает связь, переключатель «Использовать связь из справочников» восстанавливает справочные данные. Связь тематики журнала не заменяет рассмотрение содержания статьи. Ручные специальности сохраняются в той же таблице, в разделе specialties; дополнительная миграция не нужна.

Сервис читает подготовленный app/data/council_articles.json и таблицу employee_publications. Справочник преобразуется из CSV скриптом scripts/prepare_council_articles.py, проверяет схему и индекс авторов; импорт/парсинг CSV не объявляется доказательством выполнения критериев. Сопоставление данных ВШЭ по полному ФИО разрешено только при единственном совпадении. Дедупликация использует DOI либо нормализованные название и год при отсутствии неоднозначных DOI. Источники с разными годами не переносят категории между периодами.

К1/К2 сопоставляются по точным нормализованным названиям журнала и документированным алиасам; название статьи, переводы и нечёткое совпадение не используются. Справочник К1 содержит 773 журнала, К2 — 1539; экстрактор проверяет все 3134 строки, даты и SHA-256 источника. Это текущие категории, а не историческое доказательство. Q1/Q2 читаются также из двух тематических JSON-каталогов (схемы 1.0.0/1.1.0, 229 уникальных журналов): ISSN/eISSN, категории CiteScore/JCR, источники и версии паспортов. Снимок метрик 2025 не распространяется автоматически на другие годы; текущая индексация не проверена. Каталоги не являются белым списком. Аналитические прямые/условные связи журнала показываются отдельно от рассмотрения статьи по паспорту.

Подтверждения хранятся отдельно от перезаписываемых парсером данных, в app/data/council_evidence.json; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: docs/council-evidence.md. Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле hse_list не подтверждают RSCI. Неизвестные виды, конфликтующие годы и неподтверждённые основания не создают положительного заключения.

Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Отрасль и остальные параметры сохраняются при переходах и экспорте. XLSX содержит компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов), а правила и выбранные фильтры вынесены в «Параметры»; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.

В XLSX «Научная степень» — текущая выбранная степень, а «Всего» — уникальное объединение Q1 ИЛИ Q2 ИЛИ К1; отдельные К2/RSCI не увеличивают этот столбец. Поэтому экспортный итог может отличаться от общего счётчика сайта. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.

После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.

Парсинг

Worker запускает обход по CRAWL_CRON. Ручной запуск также доступен в админке на Dashboard и странице Runs или через REST:

curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..." -H "X-CSRF-Token: ..."

Алгоритм обновления:

  • найденные сотрудники получают статус active и обновленный last_seen_at;
  • новые сотрудники добавляются в employees;
  • если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
  • старые URL сохраняются в истории employee_profile_urls;
  • количество новых сотрудников за запуск сохраняется в crawl_runs.new_count;
  • публикации из HSE Publications записываются в employee_publications, а краткий список остается в JSON профиля;
  • новости из блока «В новостях» записываются в employee_news_links, а краткий список остается в JSON профиля;
  • один 404 старого профиля переводит сотрудника в статус verification_required, а не в dismissed;
  • статус dismissed устанавливается только после нескольких последовательных проверок 404/410;
  • сетевые ошибки и ответы 5xx не считаются подтверждением увольнения;
  • если число кандидатов на увольнение превышает защитный лимит, автоматическое увольнение приостанавливается;
  • кнопка «Проверить уволенных» сверяет только их profile_key с текущим списком источника и возвращает найденных сотрудников в active без обновления содержимого профиля;
  • каждый успешный новый или измененный разбор сохраняет запись в employee_snapshots;
  • неизмененные профили учитываются в crawl_runs.skipped_count и не получают новый snapshot.

Во время выполнения парсинга found_count, parsed_count, skipped_count и error_count обновляются в базе. Админка опрашивает /api/crawl-runs/latest и показывает прогресс как (parsed_count + skipped_count + error_count) / found_count.

Обслуживание

docker compose logs -f api
docker compose logs -f worker
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
docker compose down

Production deployment временно отключён до установки отдельного deployment-доступа Workers. Workflow CI запускает тесты на push в main и pull_request в main; шаги Upload release и Deploy and verify безусловно отключены для всех событий и значений input. Стандартные имена deployment secrets: PROD_HOST, PROD_USER, PROD_SSH_KEY, PROD_KNOWN_HOSTS; .env и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через infra/postgres-backup.sh: pg_dumpall сохраняет весь кластер в ~/backups/postgres/postgres-latest.sql. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы miem_workers удаляются после успешного создания нового. Эта правка не меняет существующий backup cron.

Ручной запуск с diagnostic_only=true (по умолчанию) выводит только SHA256 fingerprint ключа PROD_SSH_KEY, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При diagnostic_only=false диагностический job пропускается: это значение также не разрешает deployment. Возврат production deployment требует отдельной reviewed правки после установки нового доступа.

Проверки

После установки зависимостей из requirements.txt выполните:

python -m pytest -q
node --check app/static/admin.js

Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: pip install playwright, python -m playwright install chromium, затем python tests/browser_admin.py. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.

Версия сервиса: 0.18.1. Админка всегда показывает версии backend и frontend в footer.

Разовый импорт публикаций

Инструкция, минимальные права временной роли и ограниченный SSH-запуск: docs/publication-import.md. По умолчанию импортер выполняет dry-run; production-применение требует отдельного решения владельца.

Интеграционная проверка импорта на отдельном PostgreSQL: docs/postgresql-import-verification.md.

Отдельный тестовый стенд на VPS и read-only проверка ресурсов: docs/postgresql-import-vps.md. Стенд запускает только владелец после оценки ресурсов.

Подбор по специальностям ВАК

На странице /admin/council доступны 12 специальностей. При выборе специальности показаны доктора наук с хотя бы одной учитываемой за последние пять лет публикацией в подходящем журнале К1. Публикации без года или названия журнала и материалы с известным типом, отличным от статьи, не подтверждают специальность. Счётчики Q1/Q2/К1 сохраняют прежний смысл; число работ по выбранной специальности указано внутри карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX. На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия подтверждённых специальностей, включая выгрузку без фильтра. Число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации в компактный XLSX не включаются.

app/data/k1_specialties.json содержит все 773 журнала из k1_journals.json, коды действующих специальностей, ISSN, страницы и строки исходного перечня, историю назначений и SHA-256 источников. Специальности взяты из «ВАК Перечень.pdf» на 15.09.2026; категория К1 — из отдельного предоставленного справочника. Это связь по журналу, без оценки содержания статьи или исторической специальности на дату её публикации. Одинаковое название «Нефтегазовое дело» относится к двум ISSN: связь помечена как неоднозначная и не используется для автоматического подбора. Неполные даты начала из исходного PDF сохранены без восстановления даты; текущее назначение определяется по отсутствию истёкшего срока окончания в снимке перечня.

Для воспроизводимого обновления нужен pdfplumber только в среде разработки:

python scripts/extract_k1_specialties.py "app/data/ВАК Перечень.pdf"

Скрипт проверяет последовательность всех 3231 строк. Проверенные различия названий сохранены в scripts/k1_specialty_matches.json; каждое сопоставление проверяется по номеру строки и точному исходному названию. Нечёткий поиск не применяется. Сервис читает готовый JSON и не требует PDF или pdfplumber при запуске. Хеш k1_source_sha256 рассчитывается для исходного JSON с окончаниями строк LF; оба JSON-справочника записываются с LF для воспроизводимости на Windows и Linux.

Квартили публикаций и схема БД

Полный состав полей employee_publications, структура отдельного поля quartiles, ограничения сопоставления и команды обогащения описаны в docs/publication-database.md. Для существующей БД добавлены nullable JSONB-поля миграциями 010_publication_quartiles.sql и 011_publication_vak_categories.sql; данные переносятся отдельным инструментом, а не при старте приложения.

Квартили Q1–Q4 из данных ВШЭ сохраняются с исходной системой; неизвестный год метрики остаётся неизвестным. Для остальных публикаций журнал устанавливается по DOI, рейтинг — по ISSN. Исторические SJR доступны через открытые данные университета Эстремадуры за 2009–2025; CiteScore/JCR читаются из проверенных каталогов. При отсутствии метрики за год публикации допускается явно разрешённый последний доступный год, с сохранением его года и источника. Отсутствие DOI, неоднозначность и недоступная метрика фиксируются без присвоения вымышленного квартиля. Парсер сохраняет новое поле при обновлении записи.

Подготовка публикаций для портала

Портал читает app/data/council_articles.json: публикации представлены отдельными объектами с названием, годом, DOI, массивами авторов, квартилей и журналов. Дополнительные поля исходного экспорта сохранены в metadata; source_row связывает запись со строкой исходного CSV. by_author — готовый индекс нормализованных ФИО со ссылками на публикации, без копирования одной работы для каждого соавтора.

Исходный app/data/council_articles.csv сохранён для проверки и отдельного импорта в БД. Данные файла и индекс кэшируются на процесс, CSV не преобразуется при HTTP-запросах. В интерфейсе и XLSX используются названия «Данные ВШЭ» (выгрузка из базы публикаций ВШЭ) и «ЛК сотрудника ВШЭ» (публикации из личного кабинета). Номера строк выгрузки сохраняются для проверки источника. Показываются сотрудники с обнаруженной или подтверждённой степенью доктора, кандидата наук или PhD.

При сверке 08.10.2026 с локальной БД workers из исходной выгрузки удалены 797 строк, однозначно найденных у всех указанных авторов. Их метаданные перенесены в raw_data.csv_enrichment 1226 существующих публикаций; парсер сохраняет это обогащение при обновлении записи. Выгрузка теперь содержит 1000 строк и 776 ФИО. Резервные копии исходной выгрузки, JSON и записей БД сохранены в .impeccable/review/hse-source-backup/ и .impeccable/review/workers-enrichment-backup.json (не публикуются в Git). Строки с конфликтами или неполными связями авторов сохранены. Для другого экземпляра БД перенос метаданных из исходной выгрузки необходимо выполнить до использования сокращённого файла: сам Git-коммит данные БД не переносит.

Обязательный перенос данных перед выпуском в production

Production пока остаётся на текущей стабильной версии; локальная БД может продолжать обогащаться. Перед будущим обновлением production до версии из этого PR обязательно перенести недостающие данные из локальной БД, актуальной на момент выпуска. Наличие самой публикации в production не заменяет перенос её метаданных, включая квартильные сведения в raw_data.csv_enrichment. Обновление Git эти данные не переносит. Полная замена production-БД локальной копией недопустима.

Порядок выпуска:

  1. Зафиксировать актуальный снимок локальных данных и создать проверенную резервную копию production с возможностью восстановления. Сверку и перенос проводить с учётом параллельных изменений: перед записью повторно проверить значения назначения в транзакции.
  2. Выполнить предварительное сравнение без записи. Сотрудников сопоставлять по устойчивому идентификатору профиля; публикации — внутри соответствующего сотрудника по идентификатору источника или нормализованному DOI. Сопоставление по названию и году разрешено только при однозначном совпадении без конфликтующих идентификаторов. Одного совпадения ФИО недостаточно. Сформировать перечни новых записей, недостающих значений и конфликтов.
  3. Добавлять только отсутствующие записи и значения полей. Заполненные значения production не перезаписывать и не удалять. В JSON-объектах добавлять недостающие ключи, включая вложенные ключи raw_data.csv_enrichment, сохраняя остальные данные. Не заменять целиком заполненные JSON-объекты или массивы локальными значениями.
  4. Считать отсутствующим значением SQL NULL, отсутствующий JSON-ключ или JSON null; для текстовых полей также пустую строку либо строку только из пробельных символов. Числа 0, логическое false, пустые массивы [] и объекты {} не считать отсутствующими значениями. В существующем объекте {} можно добавлять недостающие ключи без замены самого объекта. Некорректное заполненное значение — конфликт для отдельной проверки, а не разрешение на автоматическую перезапись.
  5. Неоднозначные совпадения и различия заполненных значений фиксировать в отдельном отчёте и рассматривать вручную. Повторный перенос того же снимка должен быть безопасным: не создавать дубли и не изменять уже перенесённые значения. Проверять уникальность связей публикации с сотрудником и повторять сопоставление перед добавлением записи.
  6. После переноса сверить запланированные и фактически перенесённые записи и значения, проверить метаданные и показатели страницы/XLSX. Убедиться, что удалённые из справочника публикации доступны в production со всеми необходимыми данными и что счётчики не снизились из-за пропущенного переноса. Конфликты, мешающие подтвердить полноту переноса, разрешить до выпуска. Обновлять приложение и использовать сокращённый справочник только после успешной проверки миграции.

Миграция относится к будущему production-выпуску; в рамках исправления этого PR она не выполняется.

Пересоздание справочника публикаций

После обновления выгрузки ВШЭ пересоздайте JSON до сборки/развёртывания сервиса:

python scripts/prepare_council_articles.py app/data/council_articles.csv

Нужна только стандартная библиотека Python. Скрипт проверяет структуру CSV и индекс, сохраняет число записей и SHA-256 источника с окончаниями строк LF и заменяет JSON атомарно после проверки. CRLF нормализуются в LF только при чтении; исходный CSV не перезаписывается. JSON и хеш одинаковы на Windows и Linux; Git фиксирует эти файлы с LF. После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления публикаций из БД продолжают отображаться без перезапуска.