Reviewed-on: #65 Reviewed-by: agent <agent@vakanaut.ru>
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 секундами; параллельные запросы не запускаются.
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 настроены на технические науки: пункт 5 — 9 публикаций для доктора; пункт 6 — 20 для кандидата/PhD, самостоятельного К2 недостаточно. Период — пять лет относительно текущей даты Москвы. Дата ходатайства и сведения о монографиях не отображаются и не препятствуют итогу по техническим наукам. Некорректную специальность отклоняет HTTP 422.
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
Сервис читает подготовленный 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 всех найденных сотрудников. Дата, отрасль и остальные параметры сохраняются при переходах и экспорте. Листы «Сотрудники», «Публикации» и «Параметры» используют тот же расчёт, включают причины проверки и источники; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
Парсинг
Worker запускает обход по CRAWL_CRON. Ручной запуск также доступен в админке на Dashboard и странице Runs или через REST:
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..."
Алгоритм обновления:
- найденные сотрудники получают статус
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.16.5. Админка всегда показывает версии 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 отдельная колонка содержит коды и названия
подтверждённых специальностей, включая выгрузку без фильтра. При выбранном фильтре
добавлены число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации.
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.
Подготовка публикаций для портала
Портал читает 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-БД локальной копией недопустима.
Порядок выпуска:
- Зафиксировать актуальный снимок локальных данных и создать проверенную резервную копию production с возможностью восстановления. Сверку и перенос проводить с учётом параллельных изменений: перед записью повторно проверить значения назначения в транзакции.
- Выполнить предварительное сравнение без записи. Сотрудников сопоставлять по устойчивому идентификатору профиля; публикации — внутри соответствующего сотрудника по идентификатору источника или нормализованному DOI. Сопоставление по названию и году разрешено только при однозначном совпадении без конфликтующих идентификаторов. Одного совпадения ФИО недостаточно. Сформировать перечни новых записей, недостающих значений и конфликтов.
- Добавлять только отсутствующие записи и значения полей. Заполненные значения production не перезаписывать и не удалять. В JSON-объектах добавлять недостающие ключи, включая вложенные ключи
raw_data.csv_enrichment, сохраняя остальные данные. Не заменять целиком заполненные JSON-объекты или массивы локальными значениями. - Считать отсутствующим значением SQL
NULL, отсутствующий JSON-ключ или JSONnull; для текстовых полей также пустую строку либо строку только из пробельных символов. Числа0, логическоеfalse, пустые массивы[]и объекты{}не считать отсутствующими значениями. В существующем объекте{}можно добавлять недостающие ключи без замены самого объекта. Некорректное заполненное значение — конфликт для отдельной проверки, а не разрешение на автоматическую перезапись. - Неоднозначные совпадения и различия заполненных значений фиксировать в отдельном отчёте и рассматривать вручную. Повторный перенос того же снимка должен быть безопасным: не создавать дубли и не изменять уже перенесённые значения. Проверять уникальность связей публикации с сотрудником и повторять сопоставление перед добавлением записи.
- После переноса сверить запланированные и фактически перенесённые записи и значения, проверить метаданные и показатели страницы/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, чтобы сбросить их кэш. Обновления публикаций из БД продолжают отображаться без перезапуска.