Compare commits
38 Commits
fix/groupe
...
fix/academ
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d8a854a12 | |||
| d07711f665 | |||
| cf810f9cad | |||
| e142138c1b | |||
| c031fb9f4a | |||
| 70a4719bab | |||
| 3bdb8b1d90 | |||
| f3fb714126 | |||
| 3ed1dd8832 | |||
| 3c2057dcec | |||
| b028d81e9d | |||
| 9eb7c3b3f8 | |||
| 2d2ebd818d | |||
| 9c43e55aec | |||
| aac909d0ef | |||
| 8351823bf6 | |||
| 41ca058bd7 | |||
| 4a4b1d89e3 | |||
| b527791a10 | |||
| 7d3ec617be | |||
| f3403b371e | |||
| 3cfd1d5532 | |||
| 5993411a38 | |||
| cd46f6d361 | |||
|
|
4d2a071ec0 | ||
| 680ac6e980 | |||
|
|
dbaf3af468 | ||
| 2819a6c334 | |||
|
|
41fb54c5e7 | ||
| 4b91effee3 | |||
|
|
6724b3f369 | ||
| 1791ad8d4d | |||
|
|
993888b003 | ||
| 5180b89b81 | |||
|
|
29451ccee1 | ||
| a3ff9c6e9c | |||
|
|
8e19dc9f35 | ||
| 5b9d71426d |
@@ -15,4 +15,3 @@ ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
SESSION_SECRET=change-me-session-secret
|
||||
API_PORT=8000
|
||||
MCP_PORT=8001
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ pytest-cache-files-*/
|
||||
.coverage
|
||||
htmlcov/
|
||||
postgres_data/
|
||||
MCP_DESCRIPTION.md
|
||||
|
||||
33
CHANGELOG.md
Normal file
33
CHANGELOG.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.8
|
||||
|
||||
- В колонке учёной степени отображаются все найденные степени без лишнего текста.
|
||||
|
||||
## 0.7.7
|
||||
|
||||
- Удалена неиспользуемая интеграция обмена данными и связанная документация и тесты.
|
||||
|
||||
## 0.7.6
|
||||
|
||||
- Возвращён еженедельный автоматический запуск обхода сотрудников.
|
||||
|
||||
## 0.7.5
|
||||
|
||||
- Production Compose запускает только API и PostgreSQL.
|
||||
|
||||
## 0.7.4
|
||||
|
||||
- Ускорена фильтрация сотрудников по учёной степени.
|
||||
|
||||
## 0.7.3
|
||||
|
||||
- Восстановлена проверка профилей сотрудников и хранение истории URL профиля.
|
||||
|
||||
## 0.7.2
|
||||
|
||||
- В каталоге сотрудников добавлены фильтр и колонка учёной степени.
|
||||
|
||||
## 0.7.1
|
||||
|
||||
- Добавлена кнопка «Проверить уволенных» для принудительной сверки статуса уволенных сотрудников с текущим списком источника.
|
||||
220
README.md
220
README.md
@@ -1,113 +1,119 @@
|
||||
# MIEM Employees Server
|
||||
|
||||
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres, показывает минимальную админку и отдает read-only MCP endpoint для ИИ-агентов.
|
||||
|
||||
## Архитектура
|
||||
|
||||
- `api`: FastAPI, REST API, HTML-админка, healthcheck.
|
||||
# MIEM Employees Server
|
||||
|
||||
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres и показывает минимальную админку.
|
||||
|
||||
## Архитектура
|
||||
|
||||
- `api`: FastAPI, REST API, HTML-админка и healthcheck.
|
||||
- `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`.
|
||||
- `mcp`: открытый HTTP MCP endpoint для ИИ-агентов.
|
||||
- `postgres`: основная БД.
|
||||
|
||||
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, JSON-снапшот и сжатый HTML-снапшот. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Скопируйте `.env.example` в `.env` и поменяйте секреты:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Основные настройки:
|
||||
|
||||
- `DATABASE_URL`: строка подключения SQLAlchemy.
|
||||
- `postgres`: основная БД.
|
||||
|
||||
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Скопируйте `.env.example` в `.env` и поменяйте секреты:
|
||||
|
||||
```bash
|
||||
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-рендера динамических вкладок.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Админка: `http://localhost:8000/admin`.
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `Dashboard`: общая статистика, последний добавленный сотрудник, прогресс текущего/последнего парсинга и ручной запуск.
|
||||
- `Directory`: настраиваемая таблица сотрудников с фильтрами, сортировкой, пагинацией и выбором колонок.
|
||||
- `Employees`: простая legacy-таблица сотрудников.
|
||||
- `Runs`: история запусков, ошибки и progress bar.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
По умолчанию:
|
||||
|
||||
- API и админка: `http://localhost:8000`
|
||||
- MCP: `http://localhost:8001/mcp`
|
||||
- Postgres: `localhost:5432`
|
||||
|
||||
Таблицы создаются приложением при старте. SQL-миграция для ручного применения лежит в `migrations/001_init.sql`.
|
||||
|
||||
## Парсинг
|
||||
|
||||
Weekly worker запускается по `CRAWL_CRON`. Ручной запуск доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..."
|
||||
```
|
||||
|
||||
Алгоритм обновления:
|
||||
|
||||
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
|
||||
- новые сотрудники добавляются в `employees`;
|
||||
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
|
||||
- активные сотрудники, исчезнувшие из текущего списка источника, получают статус `dismissed` и `dismissed_at`;
|
||||
- каждый успешный разбор сохраняет запись в `employee_snapshots`.
|
||||
|
||||
Во время выполнения парсинга `found_count`, `parsed_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `parsed_count + error_count / found_count`.
|
||||
|
||||
## MCP
|
||||
|
||||
Endpoint: `POST /mcp`, без авторизации на уровне приложения.
|
||||
|
||||
Поддерживаемые tools:
|
||||
|
||||
- `search_employees(query, status?, limit?)`
|
||||
- `get_employee(profile_id_or_url)`
|
||||
- `list_employee_publications(profile_id_or_url)`
|
||||
- `list_employee_courses(profile_id_or_url)`
|
||||
- `get_crawl_status()`
|
||||
|
||||
Пример локального legacy-режима со статическим токеном:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
```
|
||||
|
||||
Если MCP нужно ограничить, делайте это на сетевом уровне: localhost binding, VPN, firewall, reverse proxy или другой внешний контур доступа.
|
||||
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки.
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
|
||||
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
|
||||
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Админка: `http://localhost:8000/admin`.
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `Dashboard`: общая статистика, последний добавленный сотрудник, прогресс текущего/последнего парсинга и ручной запуск.
|
||||
- `Directory`: настраиваемая таблица сотрудников с фильтрами, сортировкой, пагинацией и выбором колонок.
|
||||
- `Runs`: история запусков, ошибки и progress bar.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --remove-orphans
|
||||
```
|
||||
|
||||
По умолчанию:
|
||||
|
||||
- API и админка: `http://localhost:8000`
|
||||
- Postgres: `localhost:5432`
|
||||
|
||||
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие 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 не должен создавать дубликаты.
|
||||
|
||||
`list_employee_publications` сначала читает `employee_publications`; если детальных строк еще нет, возвращает старые публикации из `current_data`.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
|
||||
- краткий список остается внутри `employees.current_data.sections[].news_links`;
|
||||
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
|
||||
|
||||
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
|
||||
|
||||
## Парсинг
|
||||
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
## Обслуживание
|
||||
|
||||
```bash
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Версия сервиса: `0.4.5`. Админка всегда показывает версии backend и frontend в footer.
|
||||
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Версия сервиса: `0.7.7`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
461
app/admin.py
461
app/admin.py
@@ -1,197 +1,242 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlError, CrawlRun, Employee
|
||||
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin
|
||||
from app.services.admin_data import (
|
||||
employee_detail_payload,
|
||||
format_admin_datetime,
|
||||
list_employees_page,
|
||||
run_detail_payload,
|
||||
run_payload,
|
||||
stats_payload,
|
||||
)
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
counts = stats_payload(db)
|
||||
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
|
||||
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
|
||||
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(5)).all()
|
||||
runs = [run_payload(run) for run in run_models]
|
||||
return _render(request, "dashboard.html", {"counts": counts, "runs": runs, "latest_run": runs[0] if runs else None})
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_form(request: Request):
|
||||
return _render(request, "login.html", {"error": None})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
if not verify_admin(username, password, settings):
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль"}, status_code=401)
|
||||
redirect = RedirectResponse("/admin", status_code=303)
|
||||
redirect.set_cookie(SESSION_COOKIE, sign_session(username, settings), httponly=True, samesite="lax")
|
||||
return redirect
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
redirect = RedirectResponse("/admin/login", status_code=303)
|
||||
redirect.delete_cookie(SESSION_COOKIE)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.get("/employees", response_class=HTMLResponse)
|
||||
def employees(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
|
||||
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
started_from: str | None = None,
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlError, CrawlRun, Employee
|
||||
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin
|
||||
from app.services.admin_data import (
|
||||
employee_detail_payload,
|
||||
format_admin_datetime,
|
||||
list_employees_page,
|
||||
run_detail_payload,
|
||||
run_payload,
|
||||
stats_payload,
|
||||
)
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawler import refresh_dismissed_status, refresh_employee
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
counts = stats_payload(db)
|
||||
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
|
||||
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
|
||||
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(5)).all()
|
||||
runs = [run_payload(run) for run in run_models]
|
||||
return _render(request, "dashboard.html", {"counts": counts, "runs": runs, "latest_run": runs[0] if runs else None})
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_form(request: Request):
|
||||
return _render(request, "login.html", {"error": None})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
if not verify_admin(username, password, settings):
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль"}, status_code=401)
|
||||
redirect = RedirectResponse("/admin", status_code=303)
|
||||
redirect.set_cookie(SESSION_COOKIE, sign_session(username, settings), httponly=True, samesite="lax")
|
||||
return redirect
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
redirect = RedirectResponse("/admin/login", status_code=303)
|
||||
redirect.delete_cookie(SESSION_COOKIE)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.get("/employees", response_class=HTMLResponse)
|
||||
def employees(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
|
||||
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
started_from: str | None = None,
|
||||
started_to: str | None = None,
|
||||
has_email: str | None = None,
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
parsed_started_from = _parse_date(started_from)
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
has_academic_degree: str | None = None,
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
parsed_started_from = _parse_date(started_from)
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true"
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return _render(
|
||||
request,
|
||||
"directory.html",
|
||||
{
|
||||
"page": page,
|
||||
"filters": {
|
||||
"status": status or "",
|
||||
"q": q or "",
|
||||
"started_from": started_from or "",
|
||||
"started_to": started_to or "",
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return _render(
|
||||
request,
|
||||
"directory.html",
|
||||
{
|
||||
"page": page,
|
||||
"filters": {
|
||||
"status": status or "",
|
||||
"q": q or "",
|
||||
"started_from": started_from or "",
|
||||
"started_to": started_to or "",
|
||||
"has_email": has_email or "",
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
"limit": page["limit"],
|
||||
"offset": offset,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
def employee_detail(
|
||||
employee_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/employees", status_code=303)
|
||||
snapshots = [
|
||||
{
|
||||
"captured_display": format_admin_datetime(snapshot.captured_at),
|
||||
"checksum": snapshot.checksum,
|
||||
"parser_version": snapshot.parser_version,
|
||||
}
|
||||
for snapshot in sorted(employee.snapshots, key=lambda item: item.captured_at, reverse=True)[:20]
|
||||
]
|
||||
return _render(
|
||||
request,
|
||||
"employee_detail.html",
|
||||
{"employee": employee, "employee_view": employee_detail_payload(employee), "snapshots": snapshots},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runs", response_class=HTMLResponse)
|
||||
def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all()
|
||||
items = [run_payload(run) for run in run_models]
|
||||
errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all()
|
||||
return _render(request, "runs.html", {"runs": items, "errors": errors})
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_class=HTMLResponse)
|
||||
def run_detail(
|
||||
run_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
return _render(request, "run_detail.html", {"run": run_detail_payload(db, run)})
|
||||
|
||||
|
||||
@router.post("/runs")
|
||||
def trigger_run(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
|
||||
def _crawl() -> None:
|
||||
with SessionLocal() as db:
|
||||
run_crawl_if_idle(db, settings)
|
||||
|
||||
background_tasks.add_task(_crawl)
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
|
||||
|
||||
"has_academic_degree": has_academic_degree or "",
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
"limit": page["limit"],
|
||||
"offset": offset,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
def employee_detail(
|
||||
employee_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/employees", status_code=303)
|
||||
snapshots = [
|
||||
{
|
||||
"captured_display": format_admin_datetime(snapshot.captured_at),
|
||||
"checksum": snapshot.checksum,
|
||||
"parser_version": snapshot.parser_version,
|
||||
}
|
||||
for snapshot in sorted(employee.snapshots, key=lambda item: item.captured_at, reverse=True)[:20]
|
||||
]
|
||||
return _render(
|
||||
request,
|
||||
"employee_detail.html",
|
||||
{
|
||||
"employee": employee,
|
||||
"employee_view": employee_detail_payload(employee),
|
||||
"snapshots": snapshots,
|
||||
"refresh_status": request.query_params.get("refresh_status"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/employees/{employee_id}/refresh")
|
||||
def refresh_employee_detail(
|
||||
employee_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
run = refresh_employee(db, employee, settings)
|
||||
status = "success" if run.status == "completed" else "error"
|
||||
return RedirectResponse(f"/admin/employees/{employee_id}?refresh_status={status}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/runs", response_class=HTMLResponse)
|
||||
def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all()
|
||||
items = [run_payload(run) for run in run_models]
|
||||
errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all()
|
||||
return _render(request, "runs.html", {"runs": items, "errors": errors})
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_class=HTMLResponse)
|
||||
def run_detail(
|
||||
run_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
return _render(request, "run_detail.html", {"run": run_detail_payload(db, run)})
|
||||
|
||||
|
||||
@router.post("/runs")
|
||||
def trigger_run(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
|
||||
def _crawl() -> None:
|
||||
with SessionLocal() as db:
|
||||
run_crawl_if_idle(db, settings)
|
||||
|
||||
background_tasks.add_task(_crawl)
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
|
||||
|
||||
@router.post("/crawl-now")
|
||||
def crawl_now(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
def _crawl() -> None:
|
||||
with SessionLocal() as db:
|
||||
run_crawl_if_idle(db, settings)
|
||||
|
||||
background_tasks.add_task(_crawl)
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@router.post("/dismissed/refresh")
|
||||
def refresh_dismissed(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -201,30 +246,30 @@ def crawl_now(
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
def _crawl() -> None:
|
||||
def _refresh() -> None:
|
||||
with SessionLocal() as db:
|
||||
run_crawl_if_idle(db, settings)
|
||||
refresh_dismissed_status(db, settings)
|
||||
|
||||
background_tasks.add_task(_crawl)
|
||||
background_tasks.add_task(_refresh)
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse:
|
||||
payload = {
|
||||
"request": request,
|
||||
"backend_version": BACKEND_VERSION,
|
||||
"frontend_version": FRONTEND_VERSION,
|
||||
**context,
|
||||
}
|
||||
return templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
|
||||
|
||||
def _parse_date(value: str | None):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
from datetime import date
|
||||
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
payload = {
|
||||
"request": request,
|
||||
"backend_version": BACKEND_VERSION,
|
||||
"frontend_version": FRONTEND_VERSION,
|
||||
**context,
|
||||
}
|
||||
return templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
|
||||
|
||||
def _parse_date(value: str | None):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
from datetime import date
|
||||
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@@ -28,6 +28,7 @@ def list_employees(
|
||||
started_from: date | None = None,
|
||||
started_to: date | None = None,
|
||||
has_email: bool | None = None,
|
||||
has_academic_degree: bool | None = None,
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
@@ -43,6 +44,7 @@ def list_employees(
|
||||
started_from=started_from,
|
||||
started_to=started_to,
|
||||
has_email=has_email,
|
||||
has_academic_degree=has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
|
||||
48
app/db.py
48
app/db.py
@@ -1,6 +1,6 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -25,6 +25,52 @@ def init_db() -> None:
|
||||
import app.models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_runtime_schema()
|
||||
|
||||
|
||||
def _ensure_runtime_schema() -> None:
|
||||
import app.models as models
|
||||
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "employees" in table_names and "employee_publications" not in table_names:
|
||||
models.EmployeePublication.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "employees" in table_names and "employee_news_links" not in table_names:
|
||||
models.EmployeeNewsLink.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "employees" in table_names and "employee_profile_urls" not in table_names:
|
||||
models.EmployeeProfileUrl.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
if "employees" in table_names:
|
||||
employee_columns = {column["name"] for column in inspector.get_columns("employees")}
|
||||
missing_columns = []
|
||||
if "profile_unavailable_streak" not in employee_columns:
|
||||
missing_columns.append("profile_unavailable_streak INTEGER NOT NULL DEFAULT 0")
|
||||
if "last_profile_check_at" not in employee_columns:
|
||||
missing_columns.append("last_profile_check_at TIMESTAMPTZ")
|
||||
if "has_academic_degree" not in employee_columns:
|
||||
missing_columns.append("has_academic_degree BOOLEAN NOT NULL DEFAULT FALSE")
|
||||
if missing_columns:
|
||||
with engine.begin() as connection:
|
||||
for column in missing_columns:
|
||||
connection.execute(text(f"ALTER TABLE employees ADD COLUMN {column}"))
|
||||
if engine.dialect.name == "postgresql":
|
||||
connection.execute(
|
||||
text(
|
||||
"UPDATE employees SET has_academic_degree = "
|
||||
"COALESCE(current_data::text ~* '(кандидат|доктор).{0,80}наук|ph\\.?[[:space:]]*d\\.?', FALSE)"
|
||||
)
|
||||
)
|
||||
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_employees_has_academic_degree ON employees (has_academic_degree)"))
|
||||
if "crawl_runs" not in table_names:
|
||||
return
|
||||
crawl_run_columns = {column["name"] for column in inspector.get_columns("crawl_runs")}
|
||||
if "skipped_count" not in crawl_run_columns:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("ALTER TABLE crawl_runs ADD COLUMN skipped_count INTEGER NOT NULL DEFAULT 0"))
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
|
||||
@@ -4,14 +4,12 @@ from fastapi.staticfiles import StaticFiles
|
||||
from app.admin import router as admin_router
|
||||
from app.api import router as api_router
|
||||
from app.db import init_db
|
||||
from app.mcp import router as mcp_router
|
||||
from app.version import BACKEND_VERSION
|
||||
|
||||
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
app.include_router(api_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(mcp_router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
180
app/mcp.py
180
app/mcp.py
@@ -1,180 +0,0 @@
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import desc, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import CrawlRun, Employee
|
||||
from app.services.admin_data import run_detail_payload
|
||||
from app.version import BACKEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/mcp")
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "search_employees",
|
||||
"description": "Search MIEM employees by name or profile URL.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["active", "dismissed"]},
|
||||
"limit": {"type": "integer", "default": 20},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_employee",
|
||||
"description": "Get one employee by profile id, profile key, or canonical URL.",
|
||||
"inputSchema": {"type": "object", "properties": {"profile_id_or_url": {"type": "string"}}, "required": ["profile_id_or_url"]},
|
||||
},
|
||||
{
|
||||
"name": "list_employee_publications",
|
||||
"description": "List publications parsed from an employee profile.",
|
||||
"inputSchema": {"type": "object", "properties": {"profile_id_or_url": {"type": "string"}}, "required": ["profile_id_or_url"]},
|
||||
},
|
||||
{
|
||||
"name": "list_employee_courses",
|
||||
"description": "List teaching courses parsed from an employee profile.",
|
||||
"inputSchema": {"type": "object", "properties": {"profile_id_or_url": {"type": "string"}}, "required": ["profile_id_or_url"]},
|
||||
},
|
||||
{
|
||||
"name": "get_crawl_status",
|
||||
"description": "Return the latest crawl run status.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_crawl_run_details",
|
||||
"description": "Return detailed employee changes and errors for one crawl run.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"run_id": {"type": "integer"}},
|
||||
"required": ["run_id"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def mcp_http(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
payload = await request.json()
|
||||
method = payload.get("method")
|
||||
request_id = payload.get("id")
|
||||
params = payload.get("params") or {}
|
||||
|
||||
try:
|
||||
if method == "initialize":
|
||||
result = {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {"name": "miem-employees", "version": BACKEND_VERSION},
|
||||
"capabilities": {"tools": {}},
|
||||
}
|
||||
elif method == "tools/list":
|
||||
result = {"tools": TOOLS}
|
||||
elif method == "tools/call":
|
||||
result = _call_tool(db, params.get("name"), params.get("arguments") or {})
|
||||
else:
|
||||
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": "Method not found"}}
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
except Exception as exc:
|
||||
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32000, "message": str(exc)}}
|
||||
|
||||
|
||||
def _call_tool(db: Session, name: str, arguments: dict) -> dict:
|
||||
if name == "search_employees":
|
||||
return _tool_response(_search_employees(db, arguments))
|
||||
if name == "get_employee":
|
||||
employee = _find_employee(db, arguments["profile_id_or_url"])
|
||||
return _tool_response(_employee_payload(employee) if employee else {"error": "not_found"})
|
||||
if name == "list_employee_publications":
|
||||
employee = _find_employee(db, arguments["profile_id_or_url"])
|
||||
return _tool_response(_collect_section_items(employee, "publications"))
|
||||
if name == "list_employee_courses":
|
||||
employee = _find_employee(db, arguments["profile_id_or_url"])
|
||||
return _tool_response(_collect_section_items(employee, "courses_by_year"))
|
||||
if name == "get_crawl_status":
|
||||
run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
|
||||
return _tool_response(_run_payload(run) if run else {"status": "never_run"})
|
||||
if name == "get_crawl_run_details":
|
||||
run = db.get(CrawlRun, int(arguments["run_id"]))
|
||||
return _tool_response(run_detail_payload(db, run) if run else {"error": "not_found"})
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
def _search_employees(db: Session, arguments: dict) -> list[dict]:
|
||||
query = arguments.get("query", "")
|
||||
limit = min(int(arguments.get("limit") or 20), 100)
|
||||
stmt = select(Employee)
|
||||
if arguments.get("status"):
|
||||
stmt = stmt.where(Employee.status == arguments["status"])
|
||||
if query:
|
||||
pattern = f"%{query}%"
|
||||
stmt = stmt.where(or_(Employee.full_name.ilike(pattern), Employee.canonical_url.ilike(pattern)))
|
||||
employees = db.scalars(stmt.order_by(Employee.full_name).limit(limit)).all()
|
||||
return [_employee_payload(employee, include_data=False) for employee in employees]
|
||||
|
||||
|
||||
def _find_employee(db: Session, value: str) -> Employee | None:
|
||||
pattern = value.strip()
|
||||
stmt = select(Employee).where(
|
||||
or_(
|
||||
Employee.profile_key == pattern,
|
||||
Employee.profile_id == pattern,
|
||||
Employee.canonical_url == pattern,
|
||||
Employee.canonical_url.ilike(f"%{pattern}%"),
|
||||
)
|
||||
)
|
||||
return db.scalar(stmt.limit(1))
|
||||
|
||||
|
||||
def _collect_section_items(employee: Employee | None, section_type: str) -> dict:
|
||||
if not employee or not employee.current_data:
|
||||
return {"items": []}
|
||||
items = []
|
||||
for section in employee.current_data.get("sections") or []:
|
||||
if section.get("type") != section_type:
|
||||
continue
|
||||
if section_type == "publications":
|
||||
items.extend(section.get("publications") or [])
|
||||
elif section_type == "courses_by_year":
|
||||
items.extend(section.get("courses") or [])
|
||||
return {"employee": _employee_payload(employee, include_data=False), "items": items}
|
||||
|
||||
|
||||
def _employee_payload(employee: Employee, include_data: bool = True) -> dict:
|
||||
payload = {
|
||||
"profile_key": employee.profile_key,
|
||||
"profile_id": employee.profile_id,
|
||||
"full_name": employee.full_name,
|
||||
"status": employee.status,
|
||||
"canonical_url": employee.canonical_url,
|
||||
"last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None,
|
||||
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
|
||||
}
|
||||
if include_data:
|
||||
payload["data"] = employee.current_data
|
||||
return payload
|
||||
|
||||
|
||||
def _run_payload(run: CrawlRun) -> dict:
|
||||
return {
|
||||
"id": run.id,
|
||||
"status": run.status,
|
||||
"source_url": run.source_url,
|
||||
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||
"found_count": run.found_count,
|
||||
"parsed_count": run.parsed_count,
|
||||
"error_count": run.error_count,
|
||||
"dismissed_count": run.dismissed_count,
|
||||
}
|
||||
|
||||
|
||||
def _tool_response(data: object) -> dict:
|
||||
return {"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False, default=str)}]}
|
||||
109
app/models.py
109
app/models.py
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
@@ -21,6 +21,7 @@ class Employee(Base):
|
||||
UniqueConstraint("profile_key", name="uq_employees_profile_key"),
|
||||
Index("ix_employees_full_name", "full_name"),
|
||||
Index("ix_employees_status", "status"),
|
||||
Index("ix_employees_has_academic_degree", "has_academic_degree"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
@@ -33,17 +34,39 @@ class Employee(Base):
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
profile_unavailable_streak: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_profile_check_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
parser_version: Mapped[str | None] = mapped_column(String(32))
|
||||
current_data: Mapped[dict | None] = mapped_column(json_type)
|
||||
has_academic_degree: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
current_checksum: Mapped[str | None] = mapped_column(String(64))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
snapshots: Mapped[list["EmployeeSnapshot"]] = relationship(back_populates="employee")
|
||||
profile_urls: Mapped[list["EmployeeProfileUrl"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
tabs: Mapped[list["ProfileTab"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
publications: Mapped[list["EmployeePublication"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
news_links: Mapped[list["EmployeeNewsLink"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
crawl_run_changes: Mapped[list["CrawlRunEmployeeChange"]] = relationship(back_populates="employee")
|
||||
|
||||
|
||||
class EmployeeProfileUrl(Base):
|
||||
__tablename__ = "employee_profile_urls"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "url", name="uq_employee_profile_urls_employee_url"),
|
||||
Index("ix_employee_profile_urls_employee_id", "employee_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
employee: Mapped[Employee] = relationship(back_populates="profile_urls")
|
||||
|
||||
|
||||
class EmployeeSnapshot(Base):
|
||||
__tablename__ = "employee_snapshots"
|
||||
__table_args__ = (Index("ix_employee_snapshots_employee_id", "employee_id"),)
|
||||
@@ -60,6 +83,68 @@ class EmployeeSnapshot(Base):
|
||||
employee: Mapped[Employee] = relationship(back_populates="snapshots")
|
||||
|
||||
|
||||
class EmployeePublication(Base):
|
||||
__tablename__ = "employee_publications"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "publication_id", name="uq_employee_publications_employee_publication"),
|
||||
UniqueConstraint("employee_id", "source_hash", name="uq_employee_publications_employee_source_hash"),
|
||||
Index("ix_employee_publications_employee_id", "employee_id"),
|
||||
Index("ix_employee_publications_publication_id", "publication_id"),
|
||||
Index("ix_employee_publications_doi_url", "doi_url"),
|
||||
Index("ix_employee_publications_year", "year"),
|
||||
Index("ix_employee_publications_publication_type", "publication_type"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
||||
publication_id: Mapped[str | None] = mapped_column(String(64))
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
year: Mapped[int | None] = mapped_column(Integer)
|
||||
publication_type: Mapped[str | None] = mapped_column(String(64))
|
||||
language: Mapped[str | None] = mapped_column(String(16))
|
||||
status: Mapped[int | None] = mapped_column(Integer)
|
||||
url: Mapped[str | None] = mapped_column(Text)
|
||||
doi_url: Mapped[str | None] = mapped_column(Text)
|
||||
other_url: Mapped[str | None] = mapped_column(Text)
|
||||
document_url: Mapped[str | None] = mapped_column(Text)
|
||||
citation_text: Mapped[str | None] = mapped_column(Text)
|
||||
annotation: Mapped[dict | None] = mapped_column(json_type)
|
||||
description: Mapped[dict | None] = mapped_column(json_type)
|
||||
authors: Mapped[list | None] = mapped_column(json_type)
|
||||
raw_data: Mapped[dict | None] = mapped_column(json_type)
|
||||
source_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
employee: Mapped[Employee] = relationship(back_populates="publications")
|
||||
|
||||
|
||||
class EmployeeNewsLink(Base):
|
||||
__tablename__ = "employee_news_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "url", name="uq_employee_news_links_employee_url"),
|
||||
UniqueConstraint("employee_id", "source_hash", name="uq_employee_news_links_employee_source_hash"),
|
||||
Index("ix_employee_news_links_employee_id", "employee_id"),
|
||||
Index("ix_employee_news_links_url", "url"),
|
||||
Index("ix_employee_news_links_published_at", "published_at"),
|
||||
Index("ix_employee_news_links_published_year", "published_year"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
url: Mapped[str | None] = mapped_column(Text)
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
published_year: Mapped[int | None] = mapped_column(Integer)
|
||||
source_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
raw_data: Mapped[dict | None] = mapped_column(json_type)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
employee: Mapped[Employee] = relationship(back_populates="news_links")
|
||||
|
||||
|
||||
class CrawlRun(Base):
|
||||
__tablename__ = "crawl_runs"
|
||||
|
||||
@@ -70,6 +155,7 @@ class CrawlRun(Base):
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
found_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
parsed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
skipped_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
new_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
error_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
dismissed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
@@ -134,3 +220,24 @@ class ParserSource(Base):
|
||||
source_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
class ParseResourceCache(Base):
|
||||
__tablename__ = "parse_resource_cache"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("profile_key", "resource_key", "request_fingerprint", name="uq_parse_resource_cache_resource"),
|
||||
Index("ix_parse_resource_cache_profile_key", "profile_key"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
profile_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
resource_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
method: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
etag: Mapped[str | None] = mapped_column(Text)
|
||||
last_modified: Mapped[str | None] = mapped_column(Text)
|
||||
body_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
body_snapshot: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
parser_version: Mapped[str | None] = mapped_column(String(32))
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
@@ -99,6 +102,8 @@ def extract_person_header(soup: BeautifulSoup, source_url: str) -> dict:
|
||||
def extract_sections(soup: BeautifulSoup, source_url: str) -> list[dict]:
|
||||
sections = []
|
||||
for h2 in soup.select("h2"):
|
||||
if h2.find_parent(class_="post") or h2.find_parent(attrs={"data-tab": "press_links_news"}):
|
||||
continue
|
||||
title = normalize_ws(h2.get_text(" ", strip=True))
|
||||
if not title or "расписание занятий" in title.lower():
|
||||
continue
|
||||
@@ -140,6 +145,21 @@ def extract_sections(soup: BeautifulSoup, source_url: str) -> list[dict]:
|
||||
if section_type in {"generic", "paragraphs"}:
|
||||
section["type"] = "year_blocks"
|
||||
sections.append(section)
|
||||
news_links = _parse_news_links(soup, source_url)
|
||||
if news_links:
|
||||
sections.append(
|
||||
{
|
||||
"title": "В новостях",
|
||||
"slug": "v_novostyah",
|
||||
"type": "news",
|
||||
"raw_text": "",
|
||||
"paragraphs": [],
|
||||
"items": [item["title"] for item in news_links if item.get("title")],
|
||||
"links": [{"text": item["title"], "url": item["url"]} for item in news_links if item.get("title") and item.get("url")],
|
||||
"news_count": len(news_links),
|
||||
"news_links": news_links,
|
||||
}
|
||||
)
|
||||
return sections
|
||||
|
||||
|
||||
@@ -149,22 +169,42 @@ def parse_person_profile(
|
||||
headers: dict[str, str],
|
||||
timeout: int,
|
||||
use_playwright: bool = False,
|
||||
resource_cache=None,
|
||||
) -> dict | None:
|
||||
normalized_url = normalize_profile_url(source_url)
|
||||
if not normalized_url:
|
||||
return None
|
||||
response = session.get(normalized_url, headers=headers, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
profile_type, profile_id = parse_profile_identity(normalized_url)
|
||||
cache_profile_key = f"{profile_type}:{profile_id}"
|
||||
resource_manifest = []
|
||||
html = _fetch_text(
|
||||
session,
|
||||
normalized_url,
|
||||
headers,
|
||||
timeout,
|
||||
resource_cache=resource_cache,
|
||||
profile_key=cache_profile_key,
|
||||
resource_key="main-html",
|
||||
resource_manifest=resource_manifest,
|
||||
)
|
||||
if use_playwright:
|
||||
html = _render_with_playwright(normalized_url, html)
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
profile_type, profile_id = parse_profile_identity(normalized_url)
|
||||
header = extract_person_header(soup, normalized_url)
|
||||
tabs = extract_person_tabs(soup, normalized_url)
|
||||
sections = extract_sections(soup, normalized_url)
|
||||
sections = enrich_sections_from_hse_widgets(session, soup, normalized_url, headers, timeout, sections)
|
||||
sections = enrich_sections_from_hse_widgets(
|
||||
session,
|
||||
soup,
|
||||
normalized_url,
|
||||
headers,
|
||||
timeout,
|
||||
sections,
|
||||
resource_cache=resource_cache,
|
||||
profile_key=cache_profile_key,
|
||||
resource_manifest=resource_manifest,
|
||||
)
|
||||
internal_links = [tab["href"] for tab in tabs if tab.get("href")]
|
||||
|
||||
return {
|
||||
@@ -181,6 +221,7 @@ def parse_person_profile(
|
||||
"employee_internal_links": internal_links,
|
||||
"parser_version": BACKEND_VERSION,
|
||||
"_html": html,
|
||||
"_resource_manifest": resource_manifest,
|
||||
}
|
||||
|
||||
|
||||
@@ -191,13 +232,33 @@ def enrich_sections_from_hse_widgets(
|
||||
headers: dict[str, str],
|
||||
timeout: int,
|
||||
sections: list[dict],
|
||||
resource_cache=None,
|
||||
profile_key: str | None = None,
|
||||
resource_manifest: list[dict] | None = None,
|
||||
) -> list[dict]:
|
||||
enriched = list(sections)
|
||||
publications = _load_widget_publications(session, soup, headers, timeout)
|
||||
publications = _load_widget_publications(
|
||||
session,
|
||||
soup,
|
||||
headers,
|
||||
timeout,
|
||||
resource_cache=resource_cache,
|
||||
profile_key=profile_key,
|
||||
resource_manifest=resource_manifest,
|
||||
)
|
||||
if publications:
|
||||
enriched = _upsert_publications_section(enriched, publications)
|
||||
|
||||
theses = _load_widget_graduation_theses(session, soup, source_url, headers, timeout)
|
||||
theses = _load_widget_graduation_theses(
|
||||
session,
|
||||
soup,
|
||||
source_url,
|
||||
headers,
|
||||
timeout,
|
||||
resource_cache=resource_cache,
|
||||
profile_key=profile_key,
|
||||
resource_manifest=resource_manifest,
|
||||
)
|
||||
if theses:
|
||||
enriched = _upsert_graduation_theses_section(enriched, theses)
|
||||
return enriched
|
||||
@@ -226,7 +287,16 @@ def _render_with_playwright(source_url: str, fallback_html: str) -> str:
|
||||
return fallback_html
|
||||
|
||||
|
||||
def _load_widget_publications(session: Session, soup: BeautifulSoup, headers: dict[str, str], timeout: int) -> list[dict]:
|
||||
def _load_widget_publications(
|
||||
session: Session,
|
||||
soup: BeautifulSoup,
|
||||
headers: dict[str, str],
|
||||
timeout: int,
|
||||
*,
|
||||
resource_cache=None,
|
||||
profile_key: str | None = None,
|
||||
resource_manifest: list[dict] | None = None,
|
||||
) -> list[dict]:
|
||||
script = soup.select_one('script[data-widget-name="AuthorSearch"][data-author]')
|
||||
if not script:
|
||||
return []
|
||||
@@ -251,14 +321,29 @@ def _load_widget_publications(session: Session, soup: BeautifulSoup, headers: di
|
||||
},
|
||||
}
|
||||
try:
|
||||
response = session.post(
|
||||
"https://publications.hse.ru/api/searchPubs",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if resource_cache and profile_key:
|
||||
text = _fetch_text(
|
||||
session,
|
||||
"https://publications.hse.ru/api/searchPubs",
|
||||
headers,
|
||||
timeout,
|
||||
resource_cache=resource_cache,
|
||||
profile_key=profile_key,
|
||||
resource_key=f"publications-page-{page_id}",
|
||||
resource_manifest=resource_manifest,
|
||||
method="POST",
|
||||
json_payload=payload,
|
||||
)
|
||||
data = json.loads(text)
|
||||
else:
|
||||
response = session.post(
|
||||
"https://publications.hse.ru/api/searchPubs",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
return publications
|
||||
|
||||
@@ -266,7 +351,7 @@ def _load_widget_publications(session: Session, soup: BeautifulSoup, headers: di
|
||||
items = _extract_publication_items(result)
|
||||
if not items:
|
||||
break
|
||||
publications.extend(_normalize_publication_item(item) for item in items)
|
||||
publications.extend(_normalize_publication_item(item, author_id) for item in items)
|
||||
|
||||
total = int(result.get("total") or 0)
|
||||
if not result.get("more") and len(publications) >= total:
|
||||
@@ -309,6 +394,10 @@ def _load_widget_graduation_theses(
|
||||
source_url: str,
|
||||
headers: dict[str, str],
|
||||
timeout: int,
|
||||
*,
|
||||
resource_cache=None,
|
||||
profile_key: str | None = None,
|
||||
resource_manifest: list[dict] | None = None,
|
||||
) -> list[dict]:
|
||||
script = soup.select_one('script[src*="/n/stat/vkr/app.js"][data-person-id]')
|
||||
if not script:
|
||||
@@ -320,14 +409,30 @@ def _load_widget_graduation_theses(
|
||||
|
||||
request_headers = {**headers, "x-portal-language": "ru"}
|
||||
try:
|
||||
response = session.get(
|
||||
urljoin(source_url, api_url),
|
||||
params={"supervisorId": person_id},
|
||||
headers=request_headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
url = urljoin(source_url, api_url)
|
||||
params = {"supervisorId": person_id}
|
||||
if resource_cache and profile_key:
|
||||
text = _fetch_text(
|
||||
session,
|
||||
url,
|
||||
request_headers,
|
||||
timeout,
|
||||
resource_cache=resource_cache,
|
||||
profile_key=profile_key,
|
||||
resource_key="graduation-theses",
|
||||
resource_manifest=resource_manifest,
|
||||
params=params,
|
||||
)
|
||||
data = json.loads(text)
|
||||
else:
|
||||
response = session.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=request_headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -387,7 +492,7 @@ def _infer_section_type(title: str, nodes: list) -> str:
|
||||
lowered = title.lower()
|
||||
if _has_table(nodes):
|
||||
return "table"
|
||||
if "публикац" in lowered:
|
||||
if _is_publications_title(lowered):
|
||||
return "publications"
|
||||
if "учебные курсы" in lowered:
|
||||
return "courses_by_year"
|
||||
@@ -398,6 +503,10 @@ def _infer_section_type(title: str, nodes: list) -> str:
|
||||
return "generic"
|
||||
|
||||
|
||||
def _is_publications_title(lowered_title: str) -> bool:
|
||||
return lowered_title.startswith("публикац")
|
||||
|
||||
|
||||
def _has_table(nodes: list) -> bool:
|
||||
return any(isinstance(node, Tag) and (node.name == "table" or node.find("table")) for node in nodes)
|
||||
|
||||
@@ -484,20 +593,126 @@ def _parse_vkr_items(nodes: list) -> list[str]:
|
||||
return [item for item in dict.fromkeys(items) if item]
|
||||
|
||||
|
||||
def _normalize_publication_item(item: dict) -> dict:
|
||||
def _parse_news_links(soup: BeautifulSoup, source_url: str) -> list[dict]:
|
||||
news = []
|
||||
for post in soup.select('[data-tab="press_links_news"] .post'):
|
||||
if not isinstance(post, Tag):
|
||||
continue
|
||||
anchor = post.select_one(".post__content h2 a[href], h2 a[href], a[href]")
|
||||
title = normalize_ws(anchor.get_text(" ", strip=True)) if anchor else ""
|
||||
href = normalize_ws(anchor.get("href")) if anchor else ""
|
||||
summary_node = post.select_one(".post__text")
|
||||
summary = normalize_ws(summary_node.get_text(" ", strip=True)) if summary_node else ""
|
||||
published_at = _parse_post_date(post)
|
||||
if not title and not href:
|
||||
continue
|
||||
item = {
|
||||
"title": title or href,
|
||||
"url": urljoin(source_url, href) if href else None,
|
||||
"summary": summary or None,
|
||||
"published_at": published_at.isoformat() if published_at else None,
|
||||
"published_year": published_at.year if published_at else _int_or_none(normalize_ws(_select_text(post, ".post-meta__year"))),
|
||||
"raw_data": {
|
||||
"title": title or href,
|
||||
"url": href or None,
|
||||
"summary": summary or None,
|
||||
"date_text": normalize_ws(_select_text(post, ".post-meta__date")),
|
||||
},
|
||||
}
|
||||
news.append(item)
|
||||
return _dedupe_news_links(news)
|
||||
|
||||
|
||||
def _select_text(node: Tag, selector: str) -> str:
|
||||
selected = node.select_one(selector)
|
||||
return selected.get_text(" ", strip=True) if selected else ""
|
||||
|
||||
|
||||
def _parse_post_date(post: Tag) -> datetime | None:
|
||||
day = _int_or_none(normalize_ws(_select_text(post, ".post-meta__day")))
|
||||
month = _month_number(normalize_ws(_select_text(post, ".post-meta__month")))
|
||||
year = _int_or_none(normalize_ws(_select_text(post, ".post-meta__year")))
|
||||
if not day or not month or not year:
|
||||
return None
|
||||
try:
|
||||
return datetime(year, month, day, tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _month_number(value: str) -> int | None:
|
||||
lowered = value.lower().strip(".")
|
||||
months = {
|
||||
"янв": 1,
|
||||
"январь": 1,
|
||||
"января": 1,
|
||||
"фев": 2,
|
||||
"февр": 2,
|
||||
"февраль": 2,
|
||||
"февраля": 2,
|
||||
"март": 3,
|
||||
"мар": 3,
|
||||
"марта": 3,
|
||||
"апр": 4,
|
||||
"апрель": 4,
|
||||
"апреля": 4,
|
||||
"май": 5,
|
||||
"мая": 5,
|
||||
"июнь": 6,
|
||||
"июня": 6,
|
||||
"июль": 7,
|
||||
"июля": 7,
|
||||
"авг": 8,
|
||||
"август": 8,
|
||||
"августа": 8,
|
||||
"сент": 9,
|
||||
"сен": 9,
|
||||
"сентябрь": 9,
|
||||
"сентября": 9,
|
||||
"окт": 10,
|
||||
"октябрь": 10,
|
||||
"октября": 10,
|
||||
"нояб": 11,
|
||||
"ноябрь": 11,
|
||||
"ноября": 11,
|
||||
"дек": 12,
|
||||
"декабрь": 12,
|
||||
"декабря": 12,
|
||||
}
|
||||
return months.get(lowered)
|
||||
|
||||
|
||||
def _normalize_publication_item(item: dict, current_author_id: str | None = None) -> dict:
|
||||
publication_id = str(item.get("id") or "").strip()
|
||||
title = _html_to_text(item.get("title"))
|
||||
year = item.get("year")
|
||||
year = _int_or_none(item.get("year"))
|
||||
publication_type = str(item.get("type") or "").strip() or None
|
||||
description = item.get("description") if isinstance(item.get("description"), dict) else {}
|
||||
short_description = _localized_value(description.get("short")) or _localized_value(description.get("shortLeft"))
|
||||
documents = item.get("documents") if isinstance(item.get("documents"), dict) else {}
|
||||
language = item.get("language") if isinstance(item.get("language"), dict) else {}
|
||||
annotation = _localized_text_map(item.get("annotation"))
|
||||
authors = _normalize_publication_authors(item.get("authorsByType"), current_author_id)
|
||||
citation_text = normalize_ws(str(description.get("main") or "")) or _build_publication_citation(title, authors, year)
|
||||
text = normalize_ws(" ".join(part for part in [title, str(year or ""), short_description] if part))
|
||||
return {
|
||||
"id": publication_id or None,
|
||||
"publication_id": publication_id or None,
|
||||
"title": title or publication_id,
|
||||
"year": year,
|
||||
"type": publication_type,
|
||||
"publication_type": publication_type,
|
||||
"language": normalize_ws(language.get("name")) or None,
|
||||
"status": _int_or_none(item.get("status")),
|
||||
"url": f"https://publications.hse.ru/view/{publication_id}" if publication_id else None,
|
||||
"doi_url": _document_href(documents, "DOI"),
|
||||
"other_url": _document_href(documents, "OTHER_URL"),
|
||||
"document_url": _document_href(documents, "DOCUMENT"),
|
||||
"citation_text": citation_text or None,
|
||||
"annotation": annotation,
|
||||
"description": description or None,
|
||||
"authors": authors,
|
||||
"raw_data": item,
|
||||
"text": text or title or publication_id,
|
||||
}
|
||||
|
||||
@@ -590,16 +805,84 @@ def _dedupe_publications(items: list[dict]) -> list[dict]:
|
||||
return unique
|
||||
|
||||
|
||||
def _dedupe_news_links(items: list[dict]) -> list[dict]:
|
||||
seen = set()
|
||||
unique = []
|
||||
for item in items:
|
||||
key = item.get("url") or item.get("title")
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(item)
|
||||
return unique
|
||||
|
||||
|
||||
def _html_to_text(value: object) -> str:
|
||||
return normalize_ws(BeautifulSoup(str(value or ""), "html.parser").get_text(" ", strip=True))
|
||||
|
||||
|
||||
def _localized_text_map(value: object) -> dict[str, str]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
localized = {}
|
||||
for key in ("ru", "en", "publ"):
|
||||
text = _html_to_text(value.get(key))
|
||||
if text:
|
||||
localized[key] = text
|
||||
return localized
|
||||
|
||||
|
||||
def _localized_value(value: object) -> str:
|
||||
if isinstance(value, dict):
|
||||
return normalize_ws(value.get("ru") or value.get("publ") or value.get("en"))
|
||||
return normalize_ws(str(value or ""))
|
||||
|
||||
|
||||
def _normalize_publication_authors(value: object, current_author_id: str | None) -> list[dict]:
|
||||
if not isinstance(value, dict):
|
||||
return []
|
||||
authors = []
|
||||
for author in value.get("author") or []:
|
||||
if not isinstance(author, dict):
|
||||
continue
|
||||
title = author.get("title") if isinstance(author.get("title"), dict) else {}
|
||||
reverse_title = author.get("reverseTitle") if isinstance(author.get("reverseTitle"), dict) else {}
|
||||
author_id = normalize_ws(author.get("id"))
|
||||
href = normalize_ws(author.get("href"))
|
||||
authors.append(
|
||||
{
|
||||
"id": author_id or None,
|
||||
"href": urljoin("https://www.hse.ru", href) if href else None,
|
||||
"title_ru": _html_to_text(title.get("ru")),
|
||||
"title_en": _html_to_text(title.get("en")),
|
||||
"reverse_title_ru": _html_to_text(reverse_title.get("ru")),
|
||||
"reverse_title_en": _html_to_text(reverse_title.get("en")),
|
||||
"alt_name": normalize_ws(author.get("altName")) or None,
|
||||
"other_name": normalize_ws(author.get("otherName")) or None,
|
||||
"is_current_employee": bool(current_author_id and author_id == current_author_id),
|
||||
}
|
||||
)
|
||||
return authors
|
||||
|
||||
|
||||
def _document_href(documents: dict, key: str) -> str | None:
|
||||
document = documents.get(key)
|
||||
if not isinstance(document, dict):
|
||||
return None
|
||||
return normalize_ws(document.get("href")) or None
|
||||
|
||||
|
||||
def _build_publication_citation(title: str, authors: list[dict], year: int | None) -> str:
|
||||
author_names = [author.get("title_ru") or author.get("title_en") or author.get("alt_name") for author in authors]
|
||||
return normalize_ws(". ".join(part for part in [", ".join(filter(None, author_names)), title, str(year or "")] if part))
|
||||
|
||||
|
||||
def _int_or_none(value: object) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
cleaned = re.sub(r"[^\w\s-]", "", value.lower(), flags=re.UNICODE)
|
||||
return re.sub(r"[-\s]+", "_", cleaned).strip("_") or "section"
|
||||
@@ -625,3 +908,62 @@ def _dedupe_dicts(items: list[dict]) -> list[dict]:
|
||||
seen.add(key)
|
||||
unique.append(item)
|
||||
return unique
|
||||
|
||||
|
||||
def _fetch_text(
|
||||
session: Session,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
timeout: int,
|
||||
*,
|
||||
resource_cache=None,
|
||||
profile_key: str | None = None,
|
||||
resource_key: str,
|
||||
resource_manifest: list[dict] | None,
|
||||
method: str = "GET",
|
||||
json_payload: object | None = None,
|
||||
params: dict | None = None,
|
||||
) -> str:
|
||||
if resource_cache and profile_key:
|
||||
cached = resource_cache.fetch_text(
|
||||
session,
|
||||
profile_key=profile_key,
|
||||
resource_key=resource_key,
|
||||
method=method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
json_payload=json_payload,
|
||||
params=params,
|
||||
)
|
||||
if resource_manifest is not None:
|
||||
resource_manifest.append(
|
||||
{
|
||||
"resource_key": resource_key,
|
||||
"method": method,
|
||||
"url": url,
|
||||
"body_hash": cached.body_hash,
|
||||
"from_cache": cached.from_cache,
|
||||
"status_code": cached.status_code,
|
||||
}
|
||||
)
|
||||
return cached.text
|
||||
|
||||
if method.upper() == "POST":
|
||||
response = session.post(url, json=json_payload, headers=headers, timeout=timeout, params=params)
|
||||
else:
|
||||
response = session.get(url, headers=headers, timeout=timeout, params=params)
|
||||
response.raise_for_status()
|
||||
text = response.text
|
||||
if resource_manifest is not None:
|
||||
resource_manifest.append(
|
||||
{
|
||||
"resource_key": resource_key,
|
||||
"method": method,
|
||||
"url": url,
|
||||
"body_hash": hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
||||
"from_cache": False,
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
)
|
||||
return text
|
||||
|
||||
33
app/services/academic_degrees.py
Normal file
33
app/services/academic_degrees.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
_PATTERN = re.compile(r"\b(?:кандидат|доктор)(?:\s+[\w-]+){0,12}\s+наук\b|\bph\.?\s*d\.?(?!\w)", re.IGNORECASE)
|
||||
|
||||
|
||||
def academic_degrees(data: dict[str, Any] | None) -> list[str]:
|
||||
degrees = []
|
||||
seen = set()
|
||||
for section in (data or {}).get("sections") or []:
|
||||
if not isinstance(section, dict) or not re.search(
|
||||
r"образован|степен|academic degree|education", str(section.get("title") or ""), re.IGNORECASE
|
||||
):
|
||||
continue
|
||||
values = [
|
||||
*(entry.get("text") for entry in section.get("year_entries") or [] if isinstance(entry, dict)),
|
||||
*(section.get("paragraphs") or []),
|
||||
*(section.get("items") or []),
|
||||
section.get("raw_text"),
|
||||
]
|
||||
table = section.get("table") or {}
|
||||
for row in table.get("rows") or []:
|
||||
if isinstance(row, dict):
|
||||
values.extend(row.get("cells") or [])
|
||||
for value in values:
|
||||
text = str(value or "").strip()
|
||||
for match in _PATTERN.finditer(text):
|
||||
degree = match.group(0).strip()
|
||||
if degree.casefold() not in seen:
|
||||
seen.add(degree.casefold())
|
||||
degrees.append(degree)
|
||||
return degrees
|
||||
@@ -8,7 +8,8 @@ from zoneinfo import ZoneInfo
|
||||
from sqlalchemy import Select, Text, and_, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import CrawlError, CrawlRun, CrawlRunEmployeeChange, Employee
|
||||
from app.models import CrawlError, CrawlRun, CrawlRunEmployeeChange, Employee, EmployeeNewsLink
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
|
||||
EMPLOYEE_SORTS = {
|
||||
"full_name": Employee.full_name,
|
||||
@@ -19,14 +20,15 @@ EMPLOYEE_SORTS = {
|
||||
"hse_start_year": Employee.current_data["hse_start_year"].as_integer(),
|
||||
}
|
||||
|
||||
|
||||
def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
||||
data = _as_dict(employee.current_data)
|
||||
contacts = _as_dict(data.get("contacts"))
|
||||
sections = _as_list(data.get("sections"))
|
||||
stored_news_links = _stored_news_links(employee)
|
||||
positions = _clean_list(data.get("positions"))
|
||||
emails = _clean_list(contacts.get("emails"))
|
||||
phones = _clean_list(contacts.get("phones"))
|
||||
degree_values = academic_degrees(data)
|
||||
return {
|
||||
"id": employee.id,
|
||||
"full_name": employee.full_name,
|
||||
@@ -41,8 +43,10 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
||||
"phones": phones,
|
||||
"phone_text": ", ".join(phones),
|
||||
"address": contacts.get("address"),
|
||||
"academic_degree_text": "; ".join(degree_values),
|
||||
"publications_count": _count_section_items(sections, "publications"),
|
||||
"courses_count": _count_section_items(sections, "courses_by_year"),
|
||||
"news_count": len(stored_news_links) or _count_section_items(sections, "news"),
|
||||
"first_seen_at": employee.first_seen_at.isoformat() if employee.first_seen_at else None,
|
||||
"last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None,
|
||||
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
|
||||
@@ -67,6 +71,7 @@ def employee_detail_payload(employee: Employee) -> dict[str, Any]:
|
||||
"contact_items": _normalize_contact_items(contacts.get("items")),
|
||||
},
|
||||
"external_ids": _normalize_external_ids(data.get("external_ids")),
|
||||
"news_links": _detail_news_links(employee, data),
|
||||
"sections": [_normalize_section(section) for section in _as_list(data.get("sections"))],
|
||||
}
|
||||
|
||||
@@ -78,6 +83,7 @@ def build_employee_query(
|
||||
started_from: date | None = None,
|
||||
started_to: date | None = None,
|
||||
has_email: bool | None = None,
|
||||
has_academic_degree: bool | None = None,
|
||||
) -> Select[tuple[Employee]]:
|
||||
stmt = select(Employee)
|
||||
filters = []
|
||||
@@ -94,6 +100,8 @@ def build_employee_query(
|
||||
filters.append(Employee.current_data.cast(Text).ilike("%@%"))
|
||||
elif has_email is False:
|
||||
filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%")))
|
||||
if has_academic_degree is not None:
|
||||
filters.append(Employee.has_academic_degree.is_(has_academic_degree))
|
||||
if filters:
|
||||
stmt = stmt.where(and_(*filters))
|
||||
return stmt
|
||||
@@ -107,6 +115,7 @@ def list_employees_page(
|
||||
started_from: date | None = None,
|
||||
started_to: date | None = None,
|
||||
has_email: bool | None = None,
|
||||
has_academic_degree: bool | None = None,
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
@@ -120,6 +129,7 @@ def list_employees_page(
|
||||
started_from=started_from,
|
||||
started_to=started_to,
|
||||
has_email=has_email,
|
||||
has_academic_degree=has_academic_degree,
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
|
||||
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name)
|
||||
@@ -142,6 +152,10 @@ def stats_payload(db: Session) -> dict[str, Any]:
|
||||
return {
|
||||
"total": db.scalar(select(func.count()).select_from(Employee)) or 0,
|
||||
"active": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "active")) or 0,
|
||||
"verification_required": db.scalar(
|
||||
select(func.count()).select_from(Employee).where(Employee.status == "verification_required")
|
||||
)
|
||||
or 0,
|
||||
"dismissed": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "dismissed")) or 0,
|
||||
"new_in_last_run": latest_run.new_count if latest_run else 0,
|
||||
"latest_added": employee_display_payload(latest_added) if latest_added else None,
|
||||
@@ -153,7 +167,7 @@ def stats_payload(db: Session) -> dict[str, Any]:
|
||||
def run_payload(run: CrawlRun | None) -> dict[str, Any] | None:
|
||||
if not run:
|
||||
return None
|
||||
processed = run.parsed_count + run.error_count
|
||||
processed = run.parsed_count + run.skipped_count + run.error_count
|
||||
percent = round((processed / run.found_count) * 100, 1) if run.found_count else 0
|
||||
return {
|
||||
"id": run.id,
|
||||
@@ -166,6 +180,7 @@ def run_payload(run: CrawlRun | None) -> dict[str, Any] | None:
|
||||
"finished_display": format_admin_datetime(run.finished_at),
|
||||
"found_count": run.found_count,
|
||||
"parsed_count": run.parsed_count,
|
||||
"skipped_count": run.skipped_count,
|
||||
"new_count": run.new_count,
|
||||
"error_count": run.error_count,
|
||||
"dismissed_count": run.dismissed_count,
|
||||
@@ -184,7 +199,7 @@ def run_detail_payload(db: Session, run: CrawlRun | None) -> dict[str, Any] | No
|
||||
.order_by(CrawlRunEmployeeChange.created_at, CrawlRunEmployeeChange.id)
|
||||
).all()
|
||||
errors = db.scalars(select(CrawlError).where(CrawlError.crawl_run_id == run.id).order_by(CrawlError.created_at)).all()
|
||||
grouped_changes = {"new": [], "missing_from_source": [], "dismissed": []}
|
||||
grouped_changes = {"new": [], "missing_from_source": [], "verification_required": [], "dismissed": []}
|
||||
for change in changes:
|
||||
grouped_changes.setdefault(change.change_type, []).append(_change_payload(change))
|
||||
return {
|
||||
@@ -211,7 +226,7 @@ def format_admin_datetime(value: Any) -> str:
|
||||
|
||||
|
||||
def _employee_status_display(status: str | None) -> str:
|
||||
labels = {"active": "Работает", "dismissed": "Уволен"}
|
||||
labels = {"active": "Работает", "verification_required": "Требует проверки", "dismissed": "Уволен"}
|
||||
return labels.get(status or "", status or "Не указано")
|
||||
|
||||
|
||||
@@ -275,6 +290,8 @@ def _count_section_items(sections: list[dict[str, Any]], section_type: str) -> i
|
||||
total += len(section.get("publications") or section.get("items") or [])
|
||||
elif section_type == "courses_by_year":
|
||||
total += len(section.get("courses") or [])
|
||||
elif section_type == "news":
|
||||
total += len(section.get("news_links") or section.get("items") or [])
|
||||
return total
|
||||
|
||||
|
||||
@@ -347,6 +364,8 @@ def _normalize_section(section: Any) -> dict[str, Any]:
|
||||
"year_entries": _normalize_year_entries(section.get("year_entries")),
|
||||
"publications": _normalize_publications(section.get("publications")),
|
||||
"publications_count": section.get("publications_count"),
|
||||
"news_links": _normalize_news_links(section.get("news_links")),
|
||||
"news_count": section.get("news_count"),
|
||||
"theses": _normalize_theses(section.get("theses")),
|
||||
"theses_count": section.get("theses_count"),
|
||||
"academic_year": section.get("academic_year"),
|
||||
@@ -369,6 +388,77 @@ def _normalize_links(items: Any) -> list[dict[str, str | None]]:
|
||||
return normalized
|
||||
|
||||
|
||||
def _stored_news_links(employee: Employee) -> list[dict[str, Any]]:
|
||||
return [_stored_news_link_payload(item) for item in sorted(employee.news_links, key=_news_link_sort_key)]
|
||||
|
||||
|
||||
def _news_link_sort_key(item: EmployeeNewsLink) -> tuple:
|
||||
timestamp = item.published_at.timestamp() if item.published_at else 0
|
||||
return (-timestamp, item.title or "", item.id)
|
||||
|
||||
|
||||
def _stored_news_link_payload(item: EmployeeNewsLink) -> dict[str, Any]:
|
||||
return {
|
||||
"title": item.title,
|
||||
"url": item.url,
|
||||
"summary": item.summary,
|
||||
"published_at": item.published_at.isoformat() if item.published_at else None,
|
||||
"published_year": item.published_year,
|
||||
"published_display": format_admin_date(item.published_at) if item.published_at else str(item.published_year or ""),
|
||||
}
|
||||
|
||||
|
||||
def _detail_news_links(employee: Employee, data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
stored = _stored_news_links(employee)
|
||||
if stored:
|
||||
return stored
|
||||
for section in _as_list(data.get("sections")):
|
||||
if isinstance(section, dict) and section.get("type") == "news":
|
||||
return _normalize_news_links(section.get("news_links"))
|
||||
return []
|
||||
|
||||
|
||||
def format_admin_date(value: Any) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return value
|
||||
if not isinstance(value, datetime):
|
||||
return str(value)
|
||||
if value.tzinfo:
|
||||
value = value.astimezone(ZoneInfo("Europe/Moscow"))
|
||||
return value.strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
def _normalize_news_links(items: Any) -> list[dict[str, Any]]:
|
||||
normalized = []
|
||||
if not isinstance(items, list):
|
||||
return normalized
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title") or item.get("url") or "").strip()
|
||||
url = str(item.get("url") or "").strip()
|
||||
summary = str(item.get("summary") or "").strip()
|
||||
published_at = str(item.get("published_at") or "").strip()
|
||||
published_year = item.get("published_year")
|
||||
if title or url:
|
||||
normalized.append(
|
||||
{
|
||||
"title": title or url,
|
||||
"url": url or None,
|
||||
"summary": summary or None,
|
||||
"published_at": published_at or None,
|
||||
"published_year": published_year,
|
||||
"published_display": format_admin_date(published_at) if published_at else str(published_year or ""),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_year_entries(items: Any) -> list[dict[str, Any]]:
|
||||
normalized = []
|
||||
if not isinstance(items, list):
|
||||
|
||||
@@ -1,78 +1,161 @@
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import CrawlError, CrawlRun, CrawlRunEmployeeChange, Employee, EmployeeSnapshot, ParserSource, ProfileTab
|
||||
from app.parser.collector import collect_profile_links
|
||||
from app.parser.profile import parse_person_profile
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
from sqlalchemy import inspect, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import (
|
||||
CrawlError,
|
||||
CrawlRun,
|
||||
CrawlRunEmployeeChange,
|
||||
Employee,
|
||||
EmployeeNewsLink,
|
||||
EmployeePublication,
|
||||
EmployeeProfileUrl,
|
||||
EmployeeSnapshot,
|
||||
ParserSource,
|
||||
ProfileTab,
|
||||
)
|
||||
from app.parser.collector import collect_profile_links
|
||||
from app.parser.profile import parse_person_profile
|
||||
from app.parser.profile_url import profile_key
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; MIEMEmployeesBot/0.1.0; +https://miem.hse.ru/)"
|
||||
}
|
||||
|
||||
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
from app.services.resource_cache import ResourceCache
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; MIEMEmployeesBot/0.1.0; +https://miem.hse.ru/)"
|
||||
}
|
||||
|
||||
|
||||
def run_crawl(db: Session, settings: Settings) -> CrawlRun:
|
||||
source = _ensure_source(db, settings.source_url)
|
||||
run = CrawlRun(source_url=source.source_url, status="running")
|
||||
db.add(run)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
|
||||
found_keys: set[str] = set()
|
||||
parsed_count = 0
|
||||
skipped_count = 0
|
||||
try:
|
||||
with requests.Session() as session:
|
||||
resource_cache = ResourceCache(db)
|
||||
urls = collect_profile_links(session, source.source_url, HEADERS, settings.request_timeout)
|
||||
if settings.crawl_limit:
|
||||
urls = urls[: settings.crawl_limit]
|
||||
run.found_count = len(urls)
|
||||
db.commit()
|
||||
|
||||
for url in urls:
|
||||
key = profile_key(url)
|
||||
if key:
|
||||
found_keys.add(key)
|
||||
try:
|
||||
parsed = parse_person_profile(
|
||||
session,
|
||||
url,
|
||||
HEADERS,
|
||||
settings.request_timeout,
|
||||
settings.parser_use_playwright,
|
||||
resource_cache=resource_cache,
|
||||
)
|
||||
if not parsed:
|
||||
continue
|
||||
employee, changed = _upsert_employee(db, run, parsed)
|
||||
if employee.profile_key:
|
||||
found_keys.add(employee.profile_key)
|
||||
if changed:
|
||||
parsed_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
run.parsed_count = parsed_count
|
||||
run.skipped_count = skipped_count
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
run.error_count += 1
|
||||
db.add(
|
||||
CrawlError(
|
||||
crawl_run_id=run.id,
|
||||
profile_url=url,
|
||||
error_type=type(exc).__name__,
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
time.sleep(settings.request_delay_seconds)
|
||||
|
||||
run.dismissed_count = _mark_dismissed(
|
||||
db,
|
||||
run,
|
||||
found_keys,
|
||||
session,
|
||||
settings.request_timeout,
|
||||
confirmation_runs=settings.dismissal_confirmation_runs,
|
||||
max_auto_dismissals=settings.max_auto_dismissals_per_run,
|
||||
)
|
||||
run.status = "completed"
|
||||
except Exception as exc:
|
||||
run.status = "failed"
|
||||
run.message = str(exc)
|
||||
finally:
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
def refresh_dismissed_status(db: Session, settings: Settings) -> CrawlRun:
|
||||
source = _ensure_source(db, settings.source_url)
|
||||
run = CrawlRun(source_url=source.source_url, status="running")
|
||||
db.add(run)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
|
||||
found_keys: set[str] = set()
|
||||
parsed_count = 0
|
||||
try:
|
||||
employees = db.scalars(select(Employee).where(Employee.status == "dismissed")).all()
|
||||
run.found_count = len(employees)
|
||||
with requests.Session() as session:
|
||||
urls = collect_profile_links(session, source.source_url, HEADERS, settings.request_timeout)
|
||||
if settings.crawl_limit:
|
||||
urls = urls[: settings.crawl_limit]
|
||||
run.found_count = len(urls)
|
||||
db.commit()
|
||||
|
||||
for url in urls:
|
||||
key = profile_key(url)
|
||||
if key:
|
||||
found_keys.add(key)
|
||||
try:
|
||||
parsed = parse_person_profile(
|
||||
session,
|
||||
url,
|
||||
HEADERS,
|
||||
settings.request_timeout,
|
||||
settings.parser_use_playwright,
|
||||
)
|
||||
if not parsed:
|
||||
continue
|
||||
_upsert_employee(db, run, parsed)
|
||||
parsed_count += 1
|
||||
run.parsed_count = parsed_count
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
run.error_count += 1
|
||||
db.add(
|
||||
CrawlError(
|
||||
crawl_run_id=run.id,
|
||||
profile_url=url,
|
||||
error_type=type(exc).__name__,
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
time.sleep(settings.request_delay_seconds)
|
||||
|
||||
run.dismissed_count = _mark_dismissed(db, run, found_keys, session, settings.request_timeout)
|
||||
source_keys = {key for url in urls if (key := profile_key(url))}
|
||||
now = datetime.now(timezone.utc)
|
||||
for employee in employees:
|
||||
if employee.profile_key not in source_keys:
|
||||
run.skipped_count += 1
|
||||
continue
|
||||
employee.status = "active"
|
||||
employee.dismissed_at = None
|
||||
employee.last_seen_at = now
|
||||
employee.profile_unavailable_streak = 0
|
||||
employee.last_profile_check_at = now
|
||||
_record_employee_change(
|
||||
db,
|
||||
run,
|
||||
employee,
|
||||
"reactivated",
|
||||
profile_available=True,
|
||||
message="Сотрудник снова найден в исходном списке.",
|
||||
)
|
||||
run.parsed_count += 1
|
||||
run.status = "completed"
|
||||
except Exception as exc:
|
||||
run.status = "failed"
|
||||
run.error_count = 1
|
||||
run.message = str(exc)
|
||||
db.add(
|
||||
CrawlError(
|
||||
crawl_run_id=run.id,
|
||||
profile_url=source.source_url,
|
||||
error_type=type(exc).__name__,
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
@@ -80,144 +163,586 @@ def run_crawl(db: Session, settings: Settings) -> CrawlRun:
|
||||
return run
|
||||
|
||||
|
||||
def _ensure_source(db: Session, source_url: str) -> ParserSource:
|
||||
source = db.scalar(select(ParserSource).where(ParserSource.source_url == source_url))
|
||||
if source:
|
||||
return source
|
||||
source = ParserSource(source_url=source_url, enabled=True)
|
||||
db.add(source)
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
return source
|
||||
|
||||
|
||||
def _upsert_employee(db: Session, run: CrawlRun, parsed: dict) -> Employee:
|
||||
html = parsed.pop("_html", None)
|
||||
checksum = _checksum(parsed)
|
||||
key = f"{parsed.get('profile_type')}:{parsed.get('profile_id')}"
|
||||
employee = db.scalar(select(Employee).where(Employee.profile_key == key))
|
||||
now = datetime.now(timezone.utc)
|
||||
if not employee:
|
||||
employee = Employee(
|
||||
profile_key=key,
|
||||
profile_type=parsed.get("profile_type"),
|
||||
profile_id=parsed.get("profile_id"),
|
||||
canonical_url=parsed["source_url"],
|
||||
first_seen_at=now,
|
||||
)
|
||||
db.add(employee)
|
||||
run.new_count += 1
|
||||
is_new = True
|
||||
else:
|
||||
is_new = False
|
||||
|
||||
employee.full_name = parsed.get("full_name")
|
||||
employee.status = "active"
|
||||
employee.last_seen_at = now
|
||||
employee.dismissed_at = None
|
||||
employee.parser_version = parsed.get("parser_version")
|
||||
employee.current_data = parsed
|
||||
employee.current_checksum = checksum
|
||||
db.flush()
|
||||
|
||||
if is_new:
|
||||
_record_employee_change(
|
||||
db,
|
||||
run,
|
||||
employee,
|
||||
"new",
|
||||
profile_available=True,
|
||||
message="Сотрудник впервые найден в источнике.",
|
||||
)
|
||||
|
||||
db.query(ProfileTab).filter(ProfileTab.employee_id == employee.id).delete()
|
||||
for tab in parsed.get("tabs") or []:
|
||||
db.add(
|
||||
ProfileTab(
|
||||
employee_id=employee.id,
|
||||
title=tab.get("title") or "",
|
||||
href=tab.get("href") or "",
|
||||
data_index=tab.get("data_index"),
|
||||
)
|
||||
)
|
||||
|
||||
db.add(
|
||||
EmployeeSnapshot(
|
||||
employee_id=employee.id,
|
||||
crawl_run_id=run.id,
|
||||
parsed_data=parsed,
|
||||
html_snapshot=gzip.compress(html.encode("utf-8")) if html else None,
|
||||
checksum=checksum,
|
||||
parser_version=parsed.get("parser_version"),
|
||||
)
|
||||
)
|
||||
return employee
|
||||
|
||||
|
||||
def _mark_dismissed(db: Session, run: CrawlRun, found_keys: set[str], session: requests.Session, timeout: int) -> int:
|
||||
dismissed = 0
|
||||
active = db.scalars(select(Employee).where(Employee.status == "active")).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
for employee in active:
|
||||
if employee.profile_key in found_keys:
|
||||
continue
|
||||
profile_available = _profile_is_available(session, employee.canonical_url, timeout)
|
||||
if profile_available:
|
||||
_record_employee_change(
|
||||
db,
|
||||
run,
|
||||
employee,
|
||||
"missing_from_source",
|
||||
profile_available=True,
|
||||
message="Профиль доступен, но ссылка отсутствует в исходном списке.",
|
||||
)
|
||||
continue
|
||||
employee.status = "dismissed"
|
||||
employee.dismissed_at = now
|
||||
_record_employee_change(
|
||||
db,
|
||||
run,
|
||||
employee,
|
||||
"dismissed",
|
||||
profile_available=False,
|
||||
message="Сотрудник отсутствует в исходном списке, профиль не подтвердился как доступный.",
|
||||
)
|
||||
dismissed += 1
|
||||
db.commit()
|
||||
return dismissed
|
||||
|
||||
|
||||
def _profile_is_available(session: requests.Session, url: str, timeout: int) -> bool:
|
||||
try:
|
||||
response = session.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
|
||||
return response.status_code < 400
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
|
||||
def _record_employee_change(
|
||||
db: Session,
|
||||
run: CrawlRun,
|
||||
employee: Employee,
|
||||
change_type: str,
|
||||
*,
|
||||
profile_available: bool | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
db.add(
|
||||
CrawlRunEmployeeChange(
|
||||
crawl_run_id=run.id,
|
||||
employee_id=employee.id,
|
||||
profile_key=employee.profile_key,
|
||||
profile_url=employee.canonical_url,
|
||||
full_name=employee.full_name,
|
||||
change_type=change_type,
|
||||
profile_available=profile_available,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _checksum(data: dict) -> str:
|
||||
payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
def refresh_employee(db: Session, employee: Employee, settings: Settings) -> CrawlRun:
|
||||
run = CrawlRun(source_url=employee.canonical_url, status="running", found_count=1)
|
||||
db.add(run)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
|
||||
try:
|
||||
with requests.Session() as session:
|
||||
resource_cache = ResourceCache(db)
|
||||
parsed = parse_person_profile(
|
||||
session,
|
||||
employee.canonical_url,
|
||||
HEADERS,
|
||||
settings.request_timeout,
|
||||
settings.parser_use_playwright,
|
||||
resource_cache=resource_cache,
|
||||
)
|
||||
if not parsed:
|
||||
raise ValueError("Профиль не удалось распарсить.")
|
||||
if _parsed_profile_key(parsed) != employee.profile_key:
|
||||
raise ValueError("Распарсенный профиль не совпадает с обновляемым сотрудником.")
|
||||
|
||||
_, changed = _upsert_employee(db, run, parsed)
|
||||
if changed:
|
||||
run.parsed_count = 1
|
||||
else:
|
||||
run.skipped_count = 1
|
||||
run.status = "completed"
|
||||
except Exception as exc:
|
||||
run.status = "failed"
|
||||
run.error_count = 1
|
||||
run.message = str(exc)
|
||||
db.add(
|
||||
CrawlError(
|
||||
crawl_run_id=run.id,
|
||||
profile_url=employee.canonical_url,
|
||||
error_type=type(exc).__name__,
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
def _ensure_source(db: Session, source_url: str) -> ParserSource:
|
||||
source = db.scalar(select(ParserSource).where(ParserSource.source_url == source_url))
|
||||
if source:
|
||||
return source
|
||||
source = ParserSource(source_url=source_url, enabled=True)
|
||||
db.add(source)
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
return source
|
||||
|
||||
|
||||
def _parsed_profile_key(parsed: dict) -> str:
|
||||
return f"{parsed.get('profile_type')}:{parsed.get('profile_id')}"
|
||||
|
||||
|
||||
def _upsert_employee(db: Session, run: CrawlRun, parsed: dict) -> tuple[Employee, bool]:
|
||||
html = parsed.pop("_html", None)
|
||||
parsed.pop("_resource_manifest", None)
|
||||
checksum = _checksum(parsed)
|
||||
key = _parsed_profile_key(parsed)
|
||||
employee = db.scalar(select(Employee).where(Employee.profile_key == key))
|
||||
if not employee:
|
||||
employee = _find_employee_with_moved_profile(db, parsed)
|
||||
now = datetime.now(timezone.utc)
|
||||
if not employee:
|
||||
employee = Employee(
|
||||
profile_key=key,
|
||||
profile_type=parsed.get("profile_type"),
|
||||
profile_id=parsed.get("profile_id"),
|
||||
canonical_url=parsed["source_url"],
|
||||
first_seen_at=now,
|
||||
)
|
||||
db.add(employee)
|
||||
run.new_count += 1
|
||||
is_new = True
|
||||
else:
|
||||
is_new = False
|
||||
|
||||
parser_version = parsed.get("parser_version")
|
||||
changed = is_new or employee.current_checksum != checksum or employee.parser_version != parser_version
|
||||
previous_url = employee.canonical_url if employee.canonical_url != parsed["source_url"] else None
|
||||
employee.profile_key = key
|
||||
employee.profile_type = parsed.get("profile_type")
|
||||
employee.profile_id = parsed.get("profile_id")
|
||||
employee.canonical_url = parsed["source_url"]
|
||||
employee.full_name = parsed.get("full_name")
|
||||
employee.status = "active"
|
||||
employee.last_seen_at = now
|
||||
employee.dismissed_at = None
|
||||
employee.profile_unavailable_streak = 0
|
||||
employee.last_profile_check_at = now
|
||||
employee.parser_version = parser_version
|
||||
employee.has_academic_degree = bool(academic_degrees(parsed))
|
||||
if changed:
|
||||
employee.current_data = parsed
|
||||
employee.current_checksum = checksum
|
||||
db.flush()
|
||||
_sync_profile_url_history(db, employee, previous_url, employee.canonical_url, now)
|
||||
|
||||
if is_new:
|
||||
_record_employee_change(
|
||||
db,
|
||||
run,
|
||||
employee,
|
||||
"new",
|
||||
profile_available=True,
|
||||
message="Сотрудник впервые найден в источнике.",
|
||||
)
|
||||
|
||||
if changed:
|
||||
db.query(ProfileTab).filter(ProfileTab.employee_id == employee.id).delete()
|
||||
for tab in parsed.get("tabs") or []:
|
||||
db.add(
|
||||
ProfileTab(
|
||||
employee_id=employee.id,
|
||||
title=tab.get("title") or "",
|
||||
href=tab.get("href") or "",
|
||||
data_index=tab.get("data_index"),
|
||||
)
|
||||
)
|
||||
|
||||
db.add(
|
||||
EmployeeSnapshot(
|
||||
employee_id=employee.id,
|
||||
crawl_run_id=run.id,
|
||||
parsed_data=parsed,
|
||||
html_snapshot=gzip.compress(html.encode("utf-8")) if html else None,
|
||||
checksum=checksum,
|
||||
parser_version=parser_version,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
_try_sync_employee_publications(db, run, employee, parsed)
|
||||
_try_sync_employee_news_links(db, run, employee, parsed)
|
||||
return employee, changed
|
||||
|
||||
|
||||
def _find_employee_with_moved_profile(db: Session, parsed: dict) -> Employee | None:
|
||||
full_name = parsed.get("full_name")
|
||||
if not full_name:
|
||||
return None
|
||||
candidates = db.scalars(select(Employee).where(Employee.full_name == full_name)).all()
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
|
||||
parsed_identity = _profile_identity_values(parsed)
|
||||
if not parsed_identity:
|
||||
return None
|
||||
matches = [candidate for candidate in candidates if parsed_identity & _employee_identity_values(candidate)]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _profile_identity_values(profile: dict) -> set[str]:
|
||||
if not isinstance(profile, dict):
|
||||
return set()
|
||||
values = set()
|
||||
contacts = profile.get("contacts") or {}
|
||||
if not isinstance(contacts, dict):
|
||||
contacts = {}
|
||||
for email in contacts.get("emails") or []:
|
||||
normalized = str(email).strip().lower()
|
||||
if normalized:
|
||||
values.add(f"email:{normalized}")
|
||||
for item in profile.get("external_ids") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
system = str(item.get("system") or "").strip().lower()
|
||||
value = str(item.get("value") or "").strip().lower()
|
||||
if system and value:
|
||||
values.add(f"external:{system}:{value}")
|
||||
return values
|
||||
|
||||
|
||||
def _employee_identity_values(employee: Employee) -> set[str]:
|
||||
return _profile_identity_values(employee.current_data or {})
|
||||
|
||||
|
||||
def _sync_profile_url_history(
|
||||
db: Session,
|
||||
employee: Employee,
|
||||
previous_url: str | None,
|
||||
current_url: str,
|
||||
seen_at: datetime,
|
||||
) -> None:
|
||||
urls = {url for url in (previous_url, current_url) if url}
|
||||
for url in urls:
|
||||
history = db.scalar(
|
||||
select(EmployeeProfileUrl).where(
|
||||
EmployeeProfileUrl.employee_id == employee.id,
|
||||
EmployeeProfileUrl.url == url,
|
||||
)
|
||||
)
|
||||
if history:
|
||||
history.last_seen_at = seen_at
|
||||
else:
|
||||
db.add(
|
||||
EmployeeProfileUrl(
|
||||
employee_id=employee.id,
|
||||
url=url,
|
||||
first_seen_at=seen_at,
|
||||
last_seen_at=seen_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _try_sync_employee_publications(db: Session, run: CrawlRun, employee: Employee, parsed: dict) -> None:
|
||||
try:
|
||||
if not _publication_payloads(parsed):
|
||||
return
|
||||
if not _employee_publications_table_exists(db):
|
||||
return
|
||||
with db.begin_nested():
|
||||
_sync_employee_publications(db, employee, parsed)
|
||||
except Exception as exc:
|
||||
db.add(
|
||||
CrawlError(
|
||||
crawl_run_id=run.id,
|
||||
profile_url=employee.canonical_url,
|
||||
error_type=type(exc).__name__,
|
||||
message=f"Не удалось сохранить публикации сотрудника: {exc}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _employee_publications_table_exists(db: Session) -> bool:
|
||||
return inspect(db.connection()).has_table(EmployeePublication.__tablename__)
|
||||
|
||||
|
||||
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
publications = _publication_payloads(parsed)
|
||||
seen_hashes = set()
|
||||
for publication in publications:
|
||||
source_hash = _publication_hash(publication)
|
||||
seen_hashes.add(source_hash)
|
||||
publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
existing = None
|
||||
if publication_id:
|
||||
existing = db.scalar(
|
||||
select(EmployeePublication).where(
|
||||
EmployeePublication.employee_id == employee.id,
|
||||
EmployeePublication.publication_id == publication_id,
|
||||
)
|
||||
)
|
||||
if not existing:
|
||||
existing = db.scalar(
|
||||
select(EmployeePublication).where(
|
||||
EmployeePublication.employee_id == employee.id,
|
||||
EmployeePublication.source_hash == source_hash,
|
||||
)
|
||||
)
|
||||
if not existing:
|
||||
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
|
||||
db.add(existing)
|
||||
_apply_publication(existing, publication, source_hash)
|
||||
|
||||
if seen_hashes:
|
||||
stale = db.scalars(
|
||||
select(EmployeePublication).where(
|
||||
EmployeePublication.employee_id == employee.id,
|
||||
EmployeePublication.source_hash.not_in(seen_hashes),
|
||||
)
|
||||
).all()
|
||||
for item in stale:
|
||||
db.delete(item)
|
||||
|
||||
|
||||
def _publication_payloads(parsed: dict) -> list[dict]:
|
||||
publications = []
|
||||
for section in parsed.get("sections") or []:
|
||||
if not isinstance(section, dict) or section.get("type") != "publications":
|
||||
continue
|
||||
for publication in section.get("publications") or []:
|
||||
if isinstance(publication, dict):
|
||||
publications.append(publication)
|
||||
return publications
|
||||
|
||||
|
||||
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
|
||||
target.publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
target.title = _publication_title(publication)
|
||||
target.year = _int_or_none(publication.get("year"))
|
||||
target.publication_type = _clean_optional(publication.get("publication_type") or publication.get("type"))
|
||||
target.language = _clean_optional(publication.get("language"))
|
||||
target.status = _int_or_none(publication.get("status"))
|
||||
target.url = _clean_optional(publication.get("url"))
|
||||
target.doi_url = _clean_optional(publication.get("doi_url"))
|
||||
target.other_url = _clean_optional(publication.get("other_url"))
|
||||
target.document_url = _clean_optional(publication.get("document_url"))
|
||||
target.citation_text = _clean_optional(publication.get("citation_text") or publication.get("text"))
|
||||
target.annotation = publication.get("annotation") if isinstance(publication.get("annotation"), dict) else None
|
||||
target.description = publication.get("description") if isinstance(publication.get("description"), dict) else None
|
||||
target.authors = publication.get("authors") if isinstance(publication.get("authors"), list) else None
|
||||
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
|
||||
target.source_hash = source_hash
|
||||
|
||||
|
||||
def _publication_hash(publication: dict) -> str:
|
||||
return _payload_hash(publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication)
|
||||
|
||||
|
||||
def _payload_hash(value: object) -> str:
|
||||
payload = json.dumps(_stable_checksum_payload(value), ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _publication_title(publication: dict) -> str:
|
||||
return _clean_optional(publication.get("title") or publication.get("text") or publication.get("id")) or "Untitled publication"
|
||||
|
||||
|
||||
def _clean_optional(value: object) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _int_or_none(value: object) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _try_sync_employee_news_links(db: Session, run: CrawlRun, employee: Employee, parsed: dict) -> None:
|
||||
try:
|
||||
if not _news_link_payloads(parsed):
|
||||
return
|
||||
if not _employee_news_links_table_exists(db):
|
||||
return
|
||||
with db.begin_nested():
|
||||
_sync_employee_news_links(db, employee, parsed)
|
||||
except Exception as exc:
|
||||
db.add(
|
||||
CrawlError(
|
||||
crawl_run_id=run.id,
|
||||
profile_url=employee.canonical_url,
|
||||
error_type=type(exc).__name__,
|
||||
message=f"Не удалось сохранить новости сотрудника: {exc}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _employee_news_links_table_exists(db: Session) -> bool:
|
||||
return inspect(db.connection()).has_table(EmployeeNewsLink.__tablename__)
|
||||
|
||||
|
||||
def _sync_employee_news_links(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
news_links = _news_link_payloads(parsed)
|
||||
seen_hashes = set()
|
||||
for news_link in news_links:
|
||||
source_hash = _news_link_hash(news_link)
|
||||
seen_hashes.add(source_hash)
|
||||
url = _clean_optional(news_link.get("url"))
|
||||
existing = None
|
||||
if url:
|
||||
existing = db.scalar(
|
||||
select(EmployeeNewsLink).where(
|
||||
EmployeeNewsLink.employee_id == employee.id,
|
||||
EmployeeNewsLink.url == url,
|
||||
)
|
||||
)
|
||||
if not existing:
|
||||
existing = db.scalar(
|
||||
select(EmployeeNewsLink).where(
|
||||
EmployeeNewsLink.employee_id == employee.id,
|
||||
EmployeeNewsLink.source_hash == source_hash,
|
||||
)
|
||||
)
|
||||
if not existing:
|
||||
existing = EmployeeNewsLink(employee_id=employee.id, source_hash=source_hash, title=_news_link_title(news_link))
|
||||
db.add(existing)
|
||||
_apply_news_link(existing, news_link, source_hash)
|
||||
|
||||
if seen_hashes:
|
||||
stale = db.scalars(
|
||||
select(EmployeeNewsLink).where(
|
||||
EmployeeNewsLink.employee_id == employee.id,
|
||||
EmployeeNewsLink.source_hash.not_in(seen_hashes),
|
||||
)
|
||||
).all()
|
||||
for item in stale:
|
||||
db.delete(item)
|
||||
|
||||
|
||||
def _news_link_payloads(parsed: dict) -> list[dict]:
|
||||
news_links = []
|
||||
for section in parsed.get("sections") or []:
|
||||
if not isinstance(section, dict) or section.get("type") != "news":
|
||||
continue
|
||||
for item in section.get("news_links") or []:
|
||||
if isinstance(item, dict):
|
||||
news_links.append(item)
|
||||
return news_links
|
||||
|
||||
|
||||
def _apply_news_link(target: EmployeeNewsLink, news_link: dict, source_hash: str) -> None:
|
||||
target.title = _news_link_title(news_link)
|
||||
target.url = _clean_optional(news_link.get("url"))
|
||||
target.summary = _clean_optional(news_link.get("summary"))
|
||||
target.published_at = _datetime_or_none(news_link.get("published_at"))
|
||||
target.published_year = _int_or_none(news_link.get("published_year"))
|
||||
target.raw_data = news_link.get("raw_data") if isinstance(news_link.get("raw_data"), dict) else news_link
|
||||
target.source_hash = source_hash
|
||||
|
||||
|
||||
def _news_link_hash(news_link: dict) -> str:
|
||||
return _payload_hash(news_link.get("raw_data") if isinstance(news_link.get("raw_data"), dict) else news_link)
|
||||
|
||||
|
||||
def _news_link_title(news_link: dict) -> str:
|
||||
return _clean_optional(news_link.get("title") or news_link.get("url")) or "Untitled news"
|
||||
|
||||
|
||||
def _datetime_or_none(value: object) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _mark_dismissed(
|
||||
db: Session,
|
||||
run: CrawlRun,
|
||||
found_keys: set[str],
|
||||
session: requests.Session,
|
||||
timeout: int,
|
||||
*,
|
||||
confirmation_runs: int = 3,
|
||||
max_auto_dismissals: int | None = 25,
|
||||
) -> int:
|
||||
dismissed = 0
|
||||
candidates = db.scalars(
|
||||
select(Employee).where(Employee.status.in_(("active", "verification_required")))
|
||||
).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
unavailable = []
|
||||
for employee in candidates:
|
||||
if employee.profile_key in found_keys:
|
||||
continue
|
||||
profile_available = _profile_check(session, employee.canonical_url, timeout)
|
||||
employee.last_profile_check_at = now
|
||||
if profile_available is None:
|
||||
db.add(
|
||||
CrawlError(
|
||||
crawl_run_id=run.id,
|
||||
profile_url=employee.canonical_url,
|
||||
error_type="ProfileAvailabilityCheckError",
|
||||
message="Не удалось надёжно проверить доступность профиля; статус сотрудника не изменён.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if profile_available:
|
||||
employee.profile_unavailable_streak = 0
|
||||
if employee.status == "verification_required":
|
||||
employee.status = "active"
|
||||
_record_employee_change(
|
||||
db,
|
||||
run,
|
||||
employee,
|
||||
"missing_from_source",
|
||||
profile_available=True,
|
||||
message="Профиль доступен, но ссылка отсутствует в исходном списке.",
|
||||
)
|
||||
continue
|
||||
next_streak = employee.profile_unavailable_streak + 1
|
||||
unavailable.append((employee, next_streak))
|
||||
|
||||
dismissal_blocked = bool(
|
||||
max_auto_dismissals is not None and len(unavailable) > max_auto_dismissals
|
||||
)
|
||||
if dismissal_blocked:
|
||||
run.message = (
|
||||
f"Автоматическое увольнение приостановлено: {len(unavailable)} профилей "
|
||||
f"одновременно не подтвердились (лимит {max_auto_dismissals})."
|
||||
)
|
||||
|
||||
for employee, next_streak in unavailable:
|
||||
employee.profile_unavailable_streak = next_streak
|
||||
if next_streak < confirmation_runs or dismissal_blocked:
|
||||
employee.status = "verification_required"
|
||||
_record_employee_change(
|
||||
db,
|
||||
run,
|
||||
employee,
|
||||
"verification_required",
|
||||
profile_available=False,
|
||||
message=(
|
||||
"Профиль не подтвердился. Автоматическое увольнение отложено до "
|
||||
f"{confirmation_runs} последовательных проверок."
|
||||
if not dismissal_blocked
|
||||
else "Автоматическое увольнение отложено из-за массовой ошибки проверки профилей."
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
employee.status = "dismissed"
|
||||
employee.dismissed_at = now
|
||||
_record_employee_change(
|
||||
db,
|
||||
run,
|
||||
employee,
|
||||
"dismissed",
|
||||
profile_available=False,
|
||||
message=(
|
||||
"Сотрудник отсутствует в исходном списке, профиль не подтвердился "
|
||||
f"{confirmation_runs} раза подряд."
|
||||
),
|
||||
)
|
||||
dismissed += 1
|
||||
db.commit()
|
||||
return dismissed
|
||||
|
||||
|
||||
def _profile_is_available(session: requests.Session, url: str, timeout: int) -> bool:
|
||||
return _profile_check(session, url, timeout) is True
|
||||
|
||||
|
||||
def _profile_check(session: requests.Session, url: str, timeout: int) -> bool | None:
|
||||
try:
|
||||
response = session.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
|
||||
if response.status_code < 400:
|
||||
return True
|
||||
if response.status_code in {404, 410}:
|
||||
return False
|
||||
return None
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
|
||||
def _record_employee_change(
|
||||
db: Session,
|
||||
run: CrawlRun,
|
||||
employee: Employee,
|
||||
change_type: str,
|
||||
*,
|
||||
profile_available: bool | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
db.add(
|
||||
CrawlRunEmployeeChange(
|
||||
crawl_run_id=run.id,
|
||||
employee_id=employee.id,
|
||||
profile_key=employee.profile_key,
|
||||
profile_url=employee.canonical_url,
|
||||
full_name=employee.full_name,
|
||||
change_type=change_type,
|
||||
profile_available=profile_available,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _checksum(data: dict) -> str:
|
||||
payload = json.dumps(_stable_checksum_payload(data), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _stable_checksum_payload(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: _stable_checksum_payload(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_stable_checksum_payload(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return _normalize_date_dependent_experience(value)
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_date_dependent_experience(value: str) -> str:
|
||||
return re.sub(
|
||||
r"(?i)(стаж(?:\s+работы)?(?:\s+в\s+ниу\s+вшэ|\s+в\s+вшэ)?\s*:?\s*)\d+\s*(?:год(?:а|ов)?|лет)",
|
||||
r"\1<experience-years>",
|
||||
value,
|
||||
)
|
||||
|
||||
147
app/services/resource_cache.py
Normal file
147
app/services/resource_cache.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ParseResourceCache
|
||||
from app.version import BACKEND_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CachedResource:
|
||||
text: str
|
||||
body_hash: str
|
||||
from_cache: bool
|
||||
status_code: int
|
||||
|
||||
|
||||
class ResourceCache:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def fetch_text(
|
||||
self,
|
||||
session: requests.Session,
|
||||
*,
|
||||
profile_key: str,
|
||||
resource_key: str,
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
timeout: int,
|
||||
json_payload: Any | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> CachedResource:
|
||||
method = method.upper()
|
||||
fingerprint = _request_fingerprint(method=method, url=url, json_payload=json_payload, params=params)
|
||||
cached = self.db.scalar(
|
||||
select(ParseResourceCache).where(
|
||||
ParseResourceCache.profile_key == profile_key,
|
||||
ParseResourceCache.resource_key == resource_key,
|
||||
ParseResourceCache.request_fingerprint == fingerprint,
|
||||
)
|
||||
)
|
||||
|
||||
request_headers = dict(headers)
|
||||
if cached:
|
||||
if cached.etag:
|
||||
request_headers["If-None-Match"] = cached.etag
|
||||
if cached.last_modified:
|
||||
request_headers["If-Modified-Since"] = cached.last_modified
|
||||
|
||||
response = _send(
|
||||
session,
|
||||
method=method,
|
||||
url=url,
|
||||
headers=request_headers,
|
||||
timeout=timeout,
|
||||
json_payload=json_payload,
|
||||
params=params,
|
||||
)
|
||||
if response.status_code == 304 and cached:
|
||||
cached.fetched_at = datetime.now(timezone.utc)
|
||||
self.db.flush()
|
||||
return CachedResource(
|
||||
text=gzip.decompress(cached.body_snapshot).decode("utf-8"),
|
||||
body_hash=cached.body_hash,
|
||||
from_cache=True,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
text = response.text
|
||||
body_hash = _body_hash(text)
|
||||
etag = response.headers.get("ETag") if hasattr(response, "headers") else None
|
||||
last_modified = response.headers.get("Last-Modified") if hasattr(response, "headers") else None
|
||||
|
||||
if cached:
|
||||
cached.method = method
|
||||
cached.url = url
|
||||
cached.etag = etag
|
||||
cached.last_modified = last_modified
|
||||
cached.body_hash = body_hash
|
||||
cached.body_snapshot = gzip.compress(text.encode("utf-8"))
|
||||
cached.parser_version = BACKEND_VERSION
|
||||
cached.fetched_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
self.db.add(
|
||||
ParseResourceCache(
|
||||
profile_key=profile_key,
|
||||
resource_key=resource_key,
|
||||
method=method,
|
||||
url=url,
|
||||
request_fingerprint=fingerprint,
|
||||
etag=etag,
|
||||
last_modified=last_modified,
|
||||
body_hash=body_hash,
|
||||
body_snapshot=gzip.compress(text.encode("utf-8")),
|
||||
parser_version=BACKEND_VERSION,
|
||||
fetched_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
self.db.flush()
|
||||
return CachedResource(text=text, body_hash=body_hash, from_cache=False, status_code=response.status_code)
|
||||
|
||||
|
||||
def _send(
|
||||
session: requests.Session,
|
||||
*,
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
timeout: int,
|
||||
json_payload: Any | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> requests.Response:
|
||||
if method == "POST":
|
||||
return session.post(url, json=json_payload, headers=headers, timeout=timeout, params=params)
|
||||
return session.get(url, headers=headers, timeout=timeout, params=params)
|
||||
|
||||
|
||||
def _request_fingerprint(
|
||||
*,
|
||||
method: str,
|
||||
url: str,
|
||||
json_payload: Any | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> str:
|
||||
payload = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"json": json_payload,
|
||||
"params": params,
|
||||
}
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _body_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
1259
app/static/admin.css
1259
app/static/admin.css
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
"positions",
|
||||
"hse_start_year",
|
||||
"email",
|
||||
"academic_degree",
|
||||
"last_seen_at",
|
||||
"dismissed_at",
|
||||
"profile",
|
||||
@@ -89,12 +90,14 @@
|
||||
const status = document.querySelector("[data-progress-status]");
|
||||
const processed = document.querySelector("[data-progress-processed]");
|
||||
const found = document.querySelector("[data-progress-found]");
|
||||
const skipped = document.querySelector("[data-progress-skipped]");
|
||||
const errors = document.querySelector("[data-progress-errors]");
|
||||
const fill = document.querySelector("[data-progress-fill]");
|
||||
const percent = document.querySelector("[data-progress-percent]");
|
||||
if (status) status.textContent = run.status_display || run.status;
|
||||
if (processed) processed.textContent = run.processed_count;
|
||||
if (found) found.textContent = run.found_count;
|
||||
if (skipped) skipped.textContent = run.skipped_count;
|
||||
if (errors) errors.textContent = run.error_count;
|
||||
if (fill) fill.style.width = `${run.progress_percent}%`;
|
||||
if (percent) percent.textContent = run.progress_percent;
|
||||
|
||||
@@ -1,62 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Обзор · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="admin__grid">
|
||||
<a class="metric metric--link" href="/admin/directory"><span class="metric__label">Всего в базе</span><span class="metric__value">{{ counts.total }}</span></a>
|
||||
<a class="metric metric--link" href="/admin/directory?status=active"><span class="metric__label">Работают</span><span class="metric__value">{{ counts.active }}</span></a>
|
||||
<a class="metric metric--link" href="{% if latest_run %}/admin/runs/{{ latest_run.id }}#new-employees{% else %}/admin/runs{% endif %}"><span class="metric__label">Новые за запуск</span><span class="metric__value">{{ counts.new_in_last_run }}</span></a>
|
||||
<a class="metric metric--link" href="/admin/directory?status=dismissed"><span class="metric__label">Уволены</span><span class="metric__value">{{ counts.dismissed }}</span></a>
|
||||
</section>
|
||||
<section class="stats-strip">
|
||||
<div class="stats-strip__item">
|
||||
<span class="stats-strip__label">Последний добавленный</span>
|
||||
{% if counts.latest_added %}
|
||||
<a class="stats-strip__value" href="/admin/employees/{{ counts.latest_added.id }}">{{ counts.latest_added.full_name or counts.latest_added.canonical_url }}</a>
|
||||
{% else %}
|
||||
<span class="stats-strip__value">Сотрудников пока нет</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a class="stats-strip__item stats-strip__item--link" href="/admin/runs">
|
||||
<span class="stats-strip__label">Запуски</span>
|
||||
<span class="stats-strip__value">{{ counts.runs }}</span>
|
||||
</a>
|
||||
<div class="stats-strip__item">
|
||||
<span class="stats-strip__label">Ошибки</span>
|
||||
<span class="stats-strip__value">{{ counts.errors }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel progress-panel" data-progress-panel>
|
||||
<div class="progress-panel__header">
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Обзор · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="admin__grid">
|
||||
<a class="metric metric--link" href="/admin/directory"><span class="metric__label">Всего в базе</span><span class="metric__value">{{ counts.total }}</span></a>
|
||||
<a class="metric metric--link" href="/admin/directory?status=active"><span class="metric__label">Работают</span><span class="metric__value">{{ counts.active }}</span></a>
|
||||
<a class="metric metric--link" href="/admin/directory?status=verification_required"><span class="metric__label">Требуют проверки</span><span class="metric__value">{{ counts.verification_required }}</span></a>
|
||||
<a class="metric metric--link" href="{% if latest_run %}/admin/runs/{{ latest_run.id }}#new-employees{% else %}/admin/runs{% endif %}"><span class="metric__label">Новые за запуск</span><span class="metric__value">{{ counts.new_in_last_run }}</span></a>
|
||||
<a class="metric metric--link" href="/admin/directory?status=dismissed"><span class="metric__label">Уволены</span><span class="metric__value">{{ counts.dismissed }}</span></a>
|
||||
</section>
|
||||
<section class="stats-strip">
|
||||
<div class="stats-strip__item">
|
||||
<span class="stats-strip__label">Последний добавленный</span>
|
||||
{% if counts.latest_added %}
|
||||
<a class="stats-strip__value" href="/admin/employees/{{ counts.latest_added.id }}">{{ counts.latest_added.full_name or counts.latest_added.canonical_url }}</a>
|
||||
{% else %}
|
||||
<span class="stats-strip__value">Сотрудников пока нет</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a class="stats-strip__item stats-strip__item--link" href="/admin/runs">
|
||||
<span class="stats-strip__label">Запуски</span>
|
||||
<span class="stats-strip__value">{{ counts.runs }}</span>
|
||||
</a>
|
||||
<div class="stats-strip__item">
|
||||
<span class="stats-strip__label">Ошибки</span>
|
||||
<span class="stats-strip__value">{{ counts.errors }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel progress-panel" data-progress-panel>
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Прогресс парсинга</h2>
|
||||
<form method="post" action="/admin/crawl-now">
|
||||
<button class="button" type="submit">Запустить парсинг</button>
|
||||
</form>
|
||||
</div>
|
||||
{% set run = counts.current_running_run or latest_run %}
|
||||
<div class="progress-panel__body" data-progress-body>
|
||||
<div class="progress-panel__meta">
|
||||
<span data-progress-status>{{ run.status_display if run else "Ожидание" }}</span>
|
||||
<span>обработано: <span data-progress-processed>{{ run.processed_count if run else 0 }}</span> / <span data-progress-found>{{ run.found_count if run else 0 }}</span></span>
|
||||
<span>ошибок: <span data-progress-errors>{{ run.error_count if run else 0 }}</span></span>
|
||||
<div class="progress-panel__actions">
|
||||
<form method="post" action="/admin/crawl-now">
|
||||
<button class="button" type="submit">Запустить парсинг</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/dismissed/refresh">
|
||||
<button class="button button--secondary" type="submit">Проверить уволенных</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="progress-bar" aria-label="Parsing progress">
|
||||
<div class="progress-bar__fill" data-progress-fill style="width: {{ run.progress_percent if run else 0 }}%"></div>
|
||||
</div>
|
||||
<div class="progress-panel__percent"><span data-progress-percent>{{ run.progress_percent if run else 0 }}</span>%</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2 class="panel__title">Последние запуски</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Обработано</th><th class="table__head">Ошибки</th><th class="table__head">Старт</th></tr></thead>
|
||||
<tbody>
|
||||
{% for run in runs %}
|
||||
<tr class="table__row" onclick="window.location.href='/admin/runs/{{ run.id }}'" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); window.location.href='/admin/runs/{{ run.id }}'; }" role="link" tabindex="0"><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/admin.js"></script>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% set run = counts.current_running_run or latest_run %}
|
||||
<div class="progress-panel__body" data-progress-body>
|
||||
<div class="progress-panel__meta">
|
||||
<span data-progress-status>{{ run.status_display if run else "Ожидание" }}</span>
|
||||
<span>обработано: <span data-progress-processed>{{ run.processed_count if run else 0 }}</span> / <span data-progress-found>{{ run.found_count if run else 0 }}</span></span>
|
||||
<span>без изменений: <span data-progress-skipped>{{ run.skipped_count if run else 0 }}</span></span>
|
||||
<span>ошибок: <span data-progress-errors>{{ run.error_count if run else 0 }}</span></span>
|
||||
</div>
|
||||
<div class="progress-bar" aria-label="Parsing progress">
|
||||
<div class="progress-bar__fill" data-progress-fill style="width: {{ run.progress_percent if run else 0 }}%"></div>
|
||||
</div>
|
||||
<div class="progress-panel__percent"><span data-progress-percent>{{ run.progress_percent if run else 0 }}</span>%</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2 class="panel__title">Последние запуски</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Обработано</th><th class="table__head">Без изменений</th><th class="table__head">Ошибки</th><th class="table__head">Старт</th></tr></thead>
|
||||
<tbody>
|
||||
{% for run in runs %}
|
||||
<tr class="table__row" onclick="window.location.href='/admin/runs/{{ run.id }}'" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); window.location.href='/admin/runs/{{ run.id }}'; }" role="link" tabindex="0"><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/admin.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<select class="directory__input" name="status">
|
||||
<option value="" {% if not filters.status %}selected{% endif %}>Все статусы</option>
|
||||
<option value="active" {% if filters.status == "active" %}selected{% endif %}>Работает</option>
|
||||
<option value="verification_required" {% if filters.status == "verification_required" %}selected{% endif %}>Требует проверки</option>
|
||||
<option value="dismissed" {% if filters.status == "dismissed" %}selected{% endif %}>Уволен</option>
|
||||
</select>
|
||||
<select class="directory__input" name="has_email">
|
||||
@@ -22,6 +23,11 @@
|
||||
<option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option>
|
||||
<option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option>
|
||||
</select>
|
||||
<select class="directory__input" name="has_academic_degree" aria-label="Учёная степень">
|
||||
<option value="" {% if not filters.has_academic_degree %}selected{% endif %}>Любая учёная степень</option>
|
||||
<option value="true" {% if filters.has_academic_degree == "true" %}selected{% endif %}>Есть учёная степень</option>
|
||||
<option value="false" {% if filters.has_academic_degree == "false" %}selected{% endif %}>Нет учёной степени</option>
|
||||
</select>
|
||||
<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}" aria-label="Впервые найден с">
|
||||
<input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}" aria-label="Впервые найден по">
|
||||
<select class="directory__input" name="sort">
|
||||
@@ -53,8 +59,10 @@
|
||||
<th class="directory-table__head" data-column="email">Email</th>
|
||||
<th class="directory-table__head" data-column="phone">Телефон</th>
|
||||
<th class="directory-table__head" data-column="address">Адрес</th>
|
||||
<th class="directory-table__head" data-column="academic_degree">Учёная степень</th>
|
||||
<th class="directory-table__head" data-column="publications_count">Публикации</th>
|
||||
<th class="directory-table__head" data-column="courses_count">Курсы</th>
|
||||
<th class="directory-table__head" data-column="news_count">Новости</th>
|
||||
<th class="directory-table__head" data-column="first_seen_at">Впервые найден</th>
|
||||
<th class="directory-table__head" data-column="last_seen_at">Последний раз найден</th>
|
||||
<th class="directory-table__head" data-column="dismissed_at">Дата увольнения</th>
|
||||
@@ -71,15 +79,17 @@
|
||||
<td class="directory-table__cell" data-column="email">{{ employee.email_text }}</td>
|
||||
<td class="directory-table__cell" data-column="phone">{{ employee.phone_text }}</td>
|
||||
<td class="directory-table__cell" data-column="address">{{ employee.address or "" }}</td>
|
||||
<td class="directory-table__cell" data-column="academic_degree">{{ employee.academic_degree_text }}</td>
|
||||
<td class="directory-table__cell" data-column="publications_count">{{ employee.publications_count }}</td>
|
||||
<td class="directory-table__cell" data-column="courses_count">{{ employee.courses_count }}</td>
|
||||
<td class="directory-table__cell" data-column="news_count">{{ employee.news_count }}</td>
|
||||
<td class="directory-table__cell" data-column="first_seen_at">{{ employee.first_seen_display }}</td>
|
||||
<td class="directory-table__cell" data-column="last_seen_at">{{ employee.last_seen_display }}</td>
|
||||
<td class="directory-table__cell" data-column="dismissed_at">{{ employee.dismissed_display }}</td>
|
||||
<td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}">Открыть</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td class="directory-table__empty" colspan="13">По этим фильтрам сотрудники не найдены.</td></tr>
|
||||
<tr><td class="directory-table__empty" colspan="15">По этим фильтрам сотрудники не найдены.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -106,7 +116,7 @@
|
||||
<button class="button button--ghost" type="button" data-columns-close>Закрыть</button>
|
||||
</div>
|
||||
<div class="columns-modal__grid">
|
||||
{% for key, label in [("full_name", "ФИО"), ("status", "Статус"), ("positions", "Должности"), ("hse_start_year", "Год начала"), ("email", "Email"), ("phone", "Телефон"), ("address", "Адрес"), ("publications_count", "Публикации"), ("courses_count", "Курсы"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения"), ("profile", "Профиль")] %}
|
||||
{% for key, label in [("full_name", "ФИО"), ("status", "Статус"), ("positions", "Должности"), ("hse_start_year", "Год начала"), ("email", "Email"), ("phone", "Телефон"), ("address", "Адрес"), ("academic_degree", "Учёная степень"), ("publications_count", "Публикации"), ("courses_count", "Курсы"), ("news_count", "Новости"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения"), ("profile", "Профиль")] %}
|
||||
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" value="{{ key }}" data-column-toggle> {{ label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,18 @@
|
||||
<h2 class="employee-card__title">{{ employee_view.full_name or employee.profile_key }}</h2>
|
||||
<span class="badge {% if employee_view.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee_view.status_display }}</span>
|
||||
</div>
|
||||
<a class="admin__link" href="{{ employee_view.canonical_url }}">{{ employee_view.canonical_url }}</a>
|
||||
<div class="employee-card__actions">
|
||||
<form method="post" action="/admin/employees/{{ employee.id }}/refresh">
|
||||
<button class="button button--compact" type="submit">Обновить данные</button>
|
||||
</form>
|
||||
<a class="admin__link" href="{{ employee_view.canonical_url }}">{{ employee_view.canonical_url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if refresh_status == "success" %}
|
||||
<p class="employee-card__notice employee-card__notice--success">Данные сотрудника обновлены.</p>
|
||||
{% elif refresh_status == "error" %}
|
||||
<p class="employee-card__notice employee-card__notice--error">Не удалось обновить данные сотрудника.</p>
|
||||
{% endif %}
|
||||
|
||||
<section class="employee-card__section">
|
||||
<h3 class="employee-section__title">Основная информация</h3>
|
||||
@@ -94,6 +104,25 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if employee_view.news_links %}
|
||||
<section class="employee-card__section">
|
||||
<h3 class="employee-section__title">В новостях</h3>
|
||||
<ul class="employee-card__list">
|
||||
{% for news in employee_view.news_links %}
|
||||
<li class="employee-card__list-item">
|
||||
{% if news.published_display %}<div class="employee-section__meta"><span class="employee-section__meta-item">{{ news.published_display }}</span></div>{% endif %}
|
||||
{% if news.url %}
|
||||
<a class="admin__link" href="{{ news.url }}">{{ news.title }}</a>
|
||||
{% else %}
|
||||
{{ news.title }}
|
||||
{% endif %}
|
||||
{% if news.summary %}<div class="employee-section__text">{{ news.summary }}</div>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="employee-card__section">
|
||||
<h3 class="employee-section__title">Разделы профиля</h3>
|
||||
{% if employee_view.sections %}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<div class="stats-strip">
|
||||
<div class="stats-strip__item"><span class="stats-strip__label">Найдено</span><span class="stats-strip__value">{{ run.found_count }}</span></div>
|
||||
<div class="stats-strip__item"><span class="stats-strip__label">Обработано</span><span class="stats-strip__value">{{ run.parsed_count }}</span></div>
|
||||
<div class="stats-strip__item"><span class="stats-strip__label">Без изменений</span><span class="stats-strip__value">{{ run.skipped_count }}</span></div>
|
||||
<div class="stats-strip__item"><span class="stats-strip__label">Новые</span><span class="stats-strip__value">{{ run.new_count }}</span></div>
|
||||
<div class="stats-strip__item"><span class="stats-strip__label">Потеряшки</span><span class="stats-strip__value">{{ run.changes.missing_from_source | length }}</span></div>
|
||||
<div class="stats-strip__item"><span class="stats-strip__label">Уволены</span><span class="stats-strip__value">{{ run.dismissed_count }}</span></div>
|
||||
@@ -22,7 +23,7 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% for group, title in [("new", "Новые сотрудники"), ("missing_from_source", "Потеряшки"), ("dismissed", "Уволенные")] %}
|
||||
{% for group, title in [("new", "Новые сотрудники"), ("missing_from_source", "Потеряшки"), ("verification_required", "Требуют проверки"), ("dismissed", "Уволенные")] %}
|
||||
<section class="panel"{% if group == "new" %} id="new-employees"{% endif %}>
|
||||
<h2 class="panel__title">{{ title }}</h2>
|
||||
{% set items = run.changes[group] %}
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
</div>
|
||||
{% set run = runs[0] if runs else none %}
|
||||
{% if run %}
|
||||
{% set processed = run.parsed_count + run.error_count %}
|
||||
{% set processed = run.parsed_count + run.skipped_count + run.error_count %}
|
||||
{% set percent = ((processed / run.found_count) * 100) | round(1) if run.found_count else 0 %}
|
||||
<div class="progress-panel" data-progress-panel>
|
||||
<div class="progress-panel__meta">
|
||||
<span data-progress-status>{{ run.status_display }}</span>
|
||||
<span>обработано: <span data-progress-processed>{{ processed }}</span> / <span data-progress-found>{{ run.found_count }}</span></span>
|
||||
<span>без изменений: <span data-progress-skipped>{{ run.skipped_count }}</span></span>
|
||||
<span>ошибок: <span data-progress-errors>{{ run.error_count }}</span></span>
|
||||
</div>
|
||||
<div class="progress-bar" aria-label="Parsing progress">
|
||||
@@ -26,6 +27,7 @@
|
||||
<div class="progress-panel__meta">
|
||||
<span data-progress-status>Ожидание</span>
|
||||
<span>обработано: <span data-progress-processed>0</span> / <span data-progress-found>0</span></span>
|
||||
<span>без изменений: <span data-progress-skipped>0</span></span>
|
||||
<span>ошибок: <span data-progress-errors>0</span></span>
|
||||
</div>
|
||||
<div class="progress-bar" aria-label="Parsing progress">
|
||||
@@ -35,10 +37,10 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class="table">
|
||||
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Найдено</th><th class="table__head">Обработано</th><th class="table__head">Новые</th><th class="table__head">Ошибки</th><th class="table__head">Уволены</th><th class="table__head">Старт</th></tr></thead>
|
||||
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Найдено</th><th class="table__head">Обработано</th><th class="table__head">Без изменений</th><th class="table__head">Новые</th><th class="table__head">Ошибки</th><th class="table__head">Уволены</th><th class="table__head">Старт</th></tr></thead>
|
||||
<tbody>
|
||||
{% for run in runs %}
|
||||
<tr class="table__row" onclick="window.location.href='/admin/runs/{{ run.id }}'" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); window.location.href='/admin/runs/{{ run.id }}'; }" role="link" tabindex="0"><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.found_count }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.new_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.dismissed_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||
<tr class="table__row" onclick="window.location.href='/admin/runs/{{ run.id }}'" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); window.location.href='/admin/runs/{{ run.id }}'; }" role="link" tabindex="0"><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.found_count }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.new_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.dismissed_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.4.6"
|
||||
FRONTEND_VERSION = "0.4.6"
|
||||
BACKEND_VERSION = "0.4.6"
|
||||
APP_VERSION = "0.7.8"
|
||||
FRONTEND_VERSION = "0.7.8"
|
||||
BACKEND_VERSION = "0.7.8"
|
||||
|
||||
@@ -17,7 +17,14 @@ def crawl_once() -> None:
|
||||
settings = get_settings()
|
||||
with SessionLocal() as db:
|
||||
run = run_crawl(db, settings)
|
||||
logger.info("crawl finished: id=%s status=%s parsed=%s errors=%s", run.id, run.status, run.parsed_count, run.error_count)
|
||||
logger.info(
|
||||
"crawl finished: id=%s status=%s parsed=%s skipped=%s errors=%s",
|
||||
run.id,
|
||||
run.status,
|
||||
run.parsed_count,
|
||||
run.skipped_count,
|
||||
run.error_count,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -35,17 +35,5 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-miem}:${POSTGRES_PASSWORD:-miem_password}@postgres:5432/${POSTGRES_DB:-miem_workers}
|
||||
ports:
|
||||
- "127.0.0.1:${MCP_PORT:-8001}:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS crawl_runs (
|
||||
finished_at TIMESTAMPTZ,
|
||||
found_count INTEGER NOT NULL DEFAULT 0,
|
||||
parsed_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_count INTEGER NOT NULL DEFAULT 0,
|
||||
new_count INTEGER NOT NULL DEFAULT 0,
|
||||
error_count INTEGER NOT NULL DEFAULT 0,
|
||||
dismissed_count INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -32,6 +33,7 @@ CREATE TABLE IF NOT EXISTS employees (
|
||||
dismissed_at TIMESTAMPTZ,
|
||||
parser_version VARCHAR(32),
|
||||
current_data JSONB,
|
||||
has_academic_degree BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
current_checksum VARCHAR(64),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
@@ -39,6 +41,7 @@ CREATE TABLE IF NOT EXISTS employees (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employees_full_name ON employees (full_name);
|
||||
CREATE INDEX IF NOT EXISTS ix_employees_status ON employees (status);
|
||||
CREATE INDEX IF NOT EXISTS ix_employees_has_academic_degree ON employees (has_academic_degree);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS employee_snapshots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -73,3 +76,22 @@ CREATE TABLE IF NOT EXISTS profile_tabs (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_profile_tabs_employee_id ON profile_tabs (employee_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS parse_resource_cache (
|
||||
id SERIAL PRIMARY KEY,
|
||||
profile_key VARCHAR(255) NOT NULL,
|
||||
resource_key VARCHAR(255) NOT NULL,
|
||||
method VARCHAR(16) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
request_fingerprint VARCHAR(64) NOT NULL,
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
body_hash VARCHAR(64) NOT NULL,
|
||||
body_snapshot BYTEA NOT NULL,
|
||||
parser_version VARCHAR(32),
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_parse_resource_cache_resource UNIQUE (profile_key, resource_key, request_fingerprint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_parse_resource_cache_profile_key
|
||||
ON parse_resource_cache (profile_key);
|
||||
|
||||
29
migrations/004_dataset_versions.sql
Normal file
29
migrations/004_dataset_versions.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS dataset_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
previous_hash VARCHAR(64),
|
||||
crawl_run_id INTEGER REFERENCES crawl_runs(id),
|
||||
employee_count INTEGER NOT NULL DEFAULT 0,
|
||||
active_count INTEGER NOT NULL DEFAULT 0,
|
||||
dismissed_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_dataset_versions_created_at
|
||||
ON dataset_versions (created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dataset_version_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
dataset_version_id INTEGER NOT NULL REFERENCES dataset_versions(id),
|
||||
profile_key VARCHAR(255) NOT NULL,
|
||||
employee_id INTEGER REFERENCES employees(id),
|
||||
status VARCHAR(32) NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
CONSTRAINT uq_dataset_version_items_version_profile UNIQUE (dataset_version_id, profile_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_dataset_version_items_hash
|
||||
ON dataset_version_items (dataset_version_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_dataset_version_items_profile_key
|
||||
ON dataset_version_items (profile_key);
|
||||
21
migrations/005_parse_resource_cache.sql
Normal file
21
migrations/005_parse_resource_cache.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE crawl_runs
|
||||
ADD COLUMN IF NOT EXISTS skipped_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS parse_resource_cache (
|
||||
id SERIAL PRIMARY KEY,
|
||||
profile_key VARCHAR(255) NOT NULL,
|
||||
resource_key VARCHAR(255) NOT NULL,
|
||||
method VARCHAR(16) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
request_fingerprint VARCHAR(64) NOT NULL,
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
body_hash VARCHAR(64) NOT NULL,
|
||||
body_snapshot BYTEA NOT NULL,
|
||||
parser_version VARCHAR(32),
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_parse_resource_cache_resource UNIQUE (profile_key, resource_key, request_fingerprint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_parse_resource_cache_profile_key
|
||||
ON parse_resource_cache (profile_key);
|
||||
39
migrations/006_employee_publications.sql
Normal file
39
migrations/006_employee_publications.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
CREATE TABLE IF NOT EXISTS employee_publications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||
publication_id VARCHAR(64),
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
publication_type VARCHAR(64),
|
||||
language VARCHAR(16),
|
||||
status INTEGER,
|
||||
url TEXT,
|
||||
doi_url TEXT,
|
||||
other_url TEXT,
|
||||
document_url TEXT,
|
||||
citation_text TEXT,
|
||||
annotation JSONB,
|
||||
description JSONB,
|
||||
authors JSONB,
|
||||
raw_data JSONB,
|
||||
source_hash VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_employee_publications_employee_publication UNIQUE (employee_id, publication_id),
|
||||
CONSTRAINT uq_employee_publications_employee_source_hash UNIQUE (employee_id, source_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_publications_employee_id
|
||||
ON employee_publications (employee_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_publications_publication_id
|
||||
ON employee_publications (publication_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_publications_doi_url
|
||||
ON employee_publications (doi_url);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_publications_year
|
||||
ON employee_publications (year);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_publications_publication_type
|
||||
ON employee_publications (publication_type);
|
||||
27
migrations/007_employee_news_links.sql
Normal file
27
migrations/007_employee_news_links.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS employee_news_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT,
|
||||
summary TEXT,
|
||||
published_at TIMESTAMPTZ,
|
||||
published_year INTEGER,
|
||||
source_hash VARCHAR(64) NOT NULL,
|
||||
raw_data JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_employee_news_links_employee_url UNIQUE (employee_id, url),
|
||||
CONSTRAINT uq_employee_news_links_employee_source_hash UNIQUE (employee_id, source_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_news_links_employee_id
|
||||
ON employee_news_links (employee_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_news_links_url
|
||||
ON employee_news_links (url);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_news_links_published_at
|
||||
ON employee_news_links (published_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_news_links_published_year
|
||||
ON employee_news_links (published_year);
|
||||
17
migrations/008_profile_verification.sql
Normal file
17
migrations/008_profile_verification.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE employees
|
||||
ADD COLUMN IF NOT EXISTS profile_unavailable_streak INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE employees
|
||||
ADD COLUMN IF NOT EXISTS last_profile_check_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS employee_profile_urls (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_employee_profile_urls_employee_url UNIQUE (employee_id, url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_profile_urls_employee_id
|
||||
ON employee_profile_urls (employee_id);
|
||||
11
migrations/009_academic_degree_filter.sql
Normal file
11
migrations/009_academic_degree_filter.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE employees
|
||||
ADD COLUMN IF NOT EXISTS has_academic_degree BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
UPDATE employees
|
||||
SET has_academic_degree = COALESCE(
|
||||
current_data::text ~* '(кандидат|доктор).{0,80}наук|ph\.?[[:space:]]*d\.?',
|
||||
FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employees_has_academic_degree
|
||||
ON employees (has_academic_degree);
|
||||
@@ -1,28 +1,28 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.4.6"
|
||||
description = "MIEM employees parser, admin API, and MCP server"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"apscheduler>=3.10.4",
|
||||
"beautifulsoup4>=4.12.3",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.27.0",
|
||||
"jinja2>=3.1.4",
|
||||
"lxml>=5.2.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"requests>=2.32.0",
|
||||
"sqlalchemy>=2.0.32",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.7.8"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"apscheduler>=3.10.4",
|
||||
"beautifulsoup4>=4.12.3",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.27.0",
|
||||
"jinja2>=3.1.4",
|
||||
"lxml>=5.2.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"requests>=2.32.0",
|
||||
"sqlalchemy>=2.0.32",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
26
tests/test_academic_degrees.py
Normal file
26
tests/test_academic_degrees.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
|
||||
|
||||
def test_academic_degrees_extracts_all_degree_names_without_surrounding_text():
|
||||
data = {
|
||||
"sections": [
|
||||
{
|
||||
"title": "Образование",
|
||||
"year_entries": [
|
||||
{"text": "2008 — кандидат технических наук, доцент"},
|
||||
{"text": "2018 — Доктор физико-математических наук"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Учёные степени",
|
||||
"table": {"rows": [{"cells": ["Ph.D.", "Доктор физико-математических наук"]}]},
|
||||
},
|
||||
{"title": "Публикации", "items": ["Доктор медицинских наук выступил автором статьи"]},
|
||||
]
|
||||
}
|
||||
|
||||
assert academic_degrees(data) == [
|
||||
"кандидат технических наук",
|
||||
"Доктор физико-математических наук",
|
||||
"Ph.D.",
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import CrawlError, CrawlRun, CrawlRunEmployeeChange, Employee
|
||||
from app.models import CrawlError, CrawlRun, CrawlRunEmployeeChange, Employee, EmployeeNewsLink
|
||||
from app.services.admin_data import (
|
||||
employee_detail_payload,
|
||||
employee_display_payload,
|
||||
@@ -35,6 +35,7 @@ def test_employee_display_payload_extracts_common_fields(db_session):
|
||||
"sections": [
|
||||
{"type": "publications", "publications": [{"title": "Paper"}]},
|
||||
{"type": "courses_by_year", "courses": [{"title": "Course"}]},
|
||||
{"type": "news", "news_links": [{"title": "News", "url": "https://example.test/news"}]},
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -46,9 +47,55 @@ def test_employee_display_payload_extracts_common_fields(db_session):
|
||||
assert payload["email_text"] == "person@hse.ru"
|
||||
assert payload["publications_count"] == 1
|
||||
assert payload["courses_count"] == 1
|
||||
assert payload["news_count"] == 1
|
||||
assert payload["first_seen_display"] != "Не указано"
|
||||
|
||||
|
||||
def test_list_employees_page_filters_and_displays_academic_degrees(db_session):
|
||||
db_session.add_all(
|
||||
[
|
||||
Employee(
|
||||
profile_key="staff:degree",
|
||||
canonical_url="https://www.hse.ru/staff/degree",
|
||||
full_name="Doctor",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
has_academic_degree=True,
|
||||
current_data={
|
||||
"sections": [
|
||||
{
|
||||
"title": "Образование и учёные степени",
|
||||
"year_entries": [{"year": 2020, "text": "Доктор технических наук"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
Employee(
|
||||
profile_key="staff:no-degree",
|
||||
canonical_url="https://www.hse.ru/staff/no-degree",
|
||||
full_name="Master",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
has_academic_degree=False,
|
||||
current_data={"sections": [{"title": "Образование", "items": ["Магистратура"]}]},
|
||||
),
|
||||
]
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
page = list_employees_page(db_session, has_academic_degree=True)
|
||||
|
||||
assert page["total"] == 1
|
||||
assert page["employees"][0]["full_name"] == "Doctor"
|
||||
assert page["employees"][0]["academic_degree_text"] == "Доктор технических наук"
|
||||
|
||||
page = list_employees_page(db_session, has_academic_degree=False)
|
||||
|
||||
assert page["employees"][0]["full_name"] == "Master"
|
||||
|
||||
|
||||
def test_employee_detail_payload_normalizes_human_readable_sections(db_session):
|
||||
employee = Employee(
|
||||
profile_key="staff:person",
|
||||
@@ -104,6 +151,19 @@ def test_employee_detail_payload_normalizes_human_readable_sections(db_session):
|
||||
"type": "generic",
|
||||
"raw_text": "Fallback text",
|
||||
},
|
||||
{
|
||||
"title": "В новостях",
|
||||
"type": "news",
|
||||
"news_links": [
|
||||
{
|
||||
"title": "News title",
|
||||
"url": "https://example.test/news",
|
||||
"summary": "News summary",
|
||||
"published_at": "2026-04-28T00:00:00+00:00",
|
||||
"published_year": 2026,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -118,6 +178,41 @@ def test_employee_detail_payload_normalizes_human_readable_sections(db_session):
|
||||
assert payload["sections"][2]["courses"][0]["title"] == "Course"
|
||||
assert payload["sections"][3]["theses"][0]["student"] == "Student Name"
|
||||
assert payload["sections"][4]["paragraphs"] == ["Fallback text"]
|
||||
assert payload["sections"][5]["news_links"][0]["title"] == "News title"
|
||||
assert payload["news_links"][0]["published_display"] == "28.04.2026"
|
||||
|
||||
|
||||
def test_employee_payload_prefers_stored_news_links(db_session):
|
||||
employee = Employee(
|
||||
profile_key="staff:news",
|
||||
canonical_url="https://www.hse.ru/staff/news",
|
||||
full_name="News Person",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={"sections": [{"type": "news", "news_links": [{"title": "Old news"}]}]},
|
||||
)
|
||||
db_session.add(employee)
|
||||
db_session.commit()
|
||||
db_session.add(
|
||||
EmployeeNewsLink(
|
||||
employee_id=employee.id,
|
||||
title="Stored news",
|
||||
url="https://example.test/stored",
|
||||
summary="Stored summary",
|
||||
published_at=datetime(2026, 4, 28, tzinfo=timezone.utc),
|
||||
published_year=2026,
|
||||
source_hash="b" * 64,
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
display = employee_display_payload(employee)
|
||||
detail = employee_detail_payload(employee)
|
||||
|
||||
assert display["news_count"] == 1
|
||||
assert detail["news_links"][0]["title"] == "Stored news"
|
||||
assert detail["news_links"][0]["published_display"] == "28.04.2026"
|
||||
|
||||
|
||||
def test_employee_payloads_tolerate_malformed_current_data(db_session):
|
||||
@@ -200,13 +295,14 @@ def test_run_payload_calculates_progress():
|
||||
status="running",
|
||||
found_count=10,
|
||||
parsed_count=4,
|
||||
skipped_count=2,
|
||||
error_count=1,
|
||||
)
|
||||
|
||||
payload = run_payload(run)
|
||||
|
||||
assert payload["processed_count"] == 5
|
||||
assert payload["progress_percent"] == 50.0
|
||||
assert payload["processed_count"] == 7
|
||||
assert payload["progress_percent"] == 70.0
|
||||
assert payload["status_display"] == "Выполняется"
|
||||
|
||||
|
||||
|
||||
@@ -1,93 +1,107 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_base_navigation_is_russian_and_has_no_legacy_employees_link():
|
||||
template = Path("app/templates/base.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "Обзор" in template
|
||||
assert "Сотрудники" in template
|
||||
assert "Запуски" in template
|
||||
assert "Выйти" in template
|
||||
assert '<a class="admin__brand-link" href="/admin">MIEM Employees</a>' in template
|
||||
assert ">Employees<" not in template
|
||||
assert "/admin/employees" not in template
|
||||
|
||||
|
||||
def test_directory_template_is_russian_and_uses_display_dates():
|
||||
template = Path("app/templates/directory.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "Сотрудники" in template
|
||||
assert "Колонки" in template
|
||||
assert "Применить" in template
|
||||
assert "На странице: {{ value }}" in template
|
||||
assert "{% for value in [25, 50, 100] %}" in template
|
||||
assert "Найдено:" in template
|
||||
assert "employee.first_seen_display" in template
|
||||
assert "employee.last_seen_display" in template
|
||||
assert "employee.dismissed_display" in template
|
||||
assert "Directory" not in template
|
||||
assert "employees found" not in template
|
||||
|
||||
|
||||
def test_admin_employees_route_redirects_to_directory():
|
||||
source = Path("app/admin.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'RedirectResponse("/admin/directory", status_code=303)' in source
|
||||
|
||||
|
||||
def test_dashboard_limits_latest_runs_to_five():
|
||||
source = Path("app/admin.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "order_by(desc(CrawlRun.started_at)).limit(5)" in source
|
||||
assert "order_by(desc(CrawlRun.started_at)).limit(10)" not in source
|
||||
|
||||
|
||||
def test_runs_template_links_to_run_detail():
|
||||
template = Path("app/templates/runs.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' in template
|
||||
assert 'tabindex="0"' in template
|
||||
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
|
||||
|
||||
|
||||
def test_run_detail_template_extends_base_and_shows_change_groups():
|
||||
template = Path("app/templates/run_detail.html").read_text(encoding="utf-8")
|
||||
|
||||
assert '{% extends "base.html" %}' in template
|
||||
assert 'id="new-employees"' in template
|
||||
assert "Новые сотрудники" in template
|
||||
assert "Потеряшки" in template
|
||||
assert "Уволенные" in template
|
||||
assert "Детализация сотрудников для этого запуска недоступна" in template
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_base_navigation_is_russian_and_has_no_legacy_employees_link():
|
||||
template = Path("app/templates/base.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "Обзор" in template
|
||||
assert "Сотрудники" in template
|
||||
assert "Запуски" in template
|
||||
assert "Выйти" in template
|
||||
assert '<a class="admin__brand-link" href="/admin">MIEM Employees</a>' in template
|
||||
assert ">Employees<" not in template
|
||||
assert "/admin/employees" not in template
|
||||
|
||||
|
||||
def test_directory_template_is_russian_and_uses_display_dates():
|
||||
template = Path("app/templates/directory.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "Сотрудники" in template
|
||||
assert "Колонки" in template
|
||||
assert "Применить" in template
|
||||
assert "На странице: {{ value }}" in template
|
||||
assert "{% for value in [25, 50, 100] %}" in template
|
||||
assert "Найдено:" in template
|
||||
assert "Новости" in template
|
||||
assert "Есть учёная степень" in template
|
||||
assert 'data-column="academic_degree"' in template
|
||||
assert "employee.news_count" in template
|
||||
assert "employee.first_seen_display" in template
|
||||
assert "employee.last_seen_display" in template
|
||||
assert "employee.dismissed_display" in template
|
||||
assert "verification_required" in template
|
||||
assert "Directory" not in template
|
||||
assert "employees found" not in template
|
||||
|
||||
|
||||
def test_admin_employees_route_redirects_to_directory():
|
||||
source = Path("app/admin.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'RedirectResponse("/admin/directory", status_code=303)' in source
|
||||
|
||||
|
||||
def test_dashboard_limits_latest_runs_to_five():
|
||||
source = Path("app/admin.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "order_by(desc(CrawlRun.started_at)).limit(5)" in source
|
||||
assert "order_by(desc(CrawlRun.started_at)).limit(10)" not in source
|
||||
|
||||
|
||||
def test_runs_template_links_to_run_detail():
|
||||
template = Path("app/templates/runs.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' in template
|
||||
assert 'tabindex="0"' in template
|
||||
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
|
||||
|
||||
|
||||
def test_run_detail_template_extends_base_and_shows_change_groups():
|
||||
template = Path("app/templates/run_detail.html").read_text(encoding="utf-8")
|
||||
|
||||
assert '{% extends "base.html" %}' in template
|
||||
assert 'id="new-employees"' in template
|
||||
assert "Новые сотрудники" in template
|
||||
assert "Потеряшки" in template
|
||||
assert "Требуют проверки" in template
|
||||
assert "Уволенные" in template
|
||||
assert "Детализация сотрудников для этого запуска недоступна" in template
|
||||
|
||||
|
||||
def test_dashboard_metric_cards_link_to_admin_targets():
|
||||
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'href="/admin/directory"' in template
|
||||
assert 'href="/admin/directory?status=active"' in template
|
||||
assert '/admin/runs/{{ latest_run.id }}#new-employees' in template
|
||||
assert 'href="/admin/directory?status=dismissed"' in template
|
||||
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'href="/admin/directory"' in template
|
||||
assert 'href="/admin/directory?status=active"' in template
|
||||
assert 'href="/admin/directory?status=verification_required"' in template
|
||||
assert '/admin/runs/{{ latest_run.id }}#new-employees' in template
|
||||
assert 'href="/admin/directory?status=dismissed"' in template
|
||||
assert 'href="/admin/runs"' in template
|
||||
|
||||
|
||||
def test_dashboard_latest_run_rows_link_to_run_detail():
|
||||
def test_dashboard_has_dismissed_status_refresh_action():
|
||||
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' in template
|
||||
assert 'tabindex="0"' in template
|
||||
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
|
||||
|
||||
|
||||
def test_admin_js_supports_keyboard_activation_for_clickable_rows():
|
||||
source = Path("app/static/admin.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'addEventListener("keydown"' in source
|
||||
assert '"Enter"' in source
|
||||
assert '" "' in source
|
||||
assert 'action="/admin/dismissed/refresh"' in template
|
||||
assert "Проверить уволенных" in template
|
||||
|
||||
|
||||
def test_dashboard_latest_run_rows_link_to_run_detail():
|
||||
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' in template
|
||||
assert 'tabindex="0"' in template
|
||||
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
|
||||
|
||||
|
||||
def test_admin_js_supports_keyboard_activation_for_clickable_rows():
|
||||
source = Path("app/static/admin.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'addEventListener("keydown"' in source
|
||||
assert '"Enter"' in source
|
||||
assert '" "' in source
|
||||
|
||||
130
tests/test_api.py
Normal file
130
tests/test_api.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import CrawlRun, CrawlRunEmployeeChange, Employee
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
|
||||
|
||||
def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.7.7"
|
||||
|
||||
|
||||
def test_mcp_endpoint_is_removed():
|
||||
assert TestClient(app).get("/mcp").status_code == 404
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
db = session_factory()
|
||||
employee = Employee(
|
||||
profile_key="staff:alpha",
|
||||
profile_type="staff",
|
||||
profile_id="alpha",
|
||||
canonical_url="https://www.hse.ru/staff/alpha",
|
||||
full_name="Alpha Person",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={"contacts": {"emails": ["alpha@hse.ru"]}, "sections": []},
|
||||
)
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="completed", new_count=1)
|
||||
db.add_all([employee, run])
|
||||
db.commit()
|
||||
db.add(CrawlRunEmployeeChange(
|
||||
crawl_run_id=run.id,
|
||||
employee_id=employee.id,
|
||||
profile_key=employee.profile_key,
|
||||
profile_url=employee.canonical_url,
|
||||
full_name=employee.full_name,
|
||||
change_type="new",
|
||||
profile_available=True,
|
||||
message="added",
|
||||
))
|
||||
db.commit()
|
||||
run_id = run.id
|
||||
db.close()
|
||||
|
||||
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
|
||||
|
||||
def override_db():
|
||||
session = session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
employees = client.get("/api/employees", params={"q": "Alpha", "has_email": True})
|
||||
stats = client.get("/api/stats")
|
||||
run_details = client.get(f"/api/crawl-runs/{run_id}")
|
||||
|
||||
assert employees.status_code == 200
|
||||
assert employees.json()["total"] == 1
|
||||
assert stats.status_code == 200
|
||||
assert stats.json()["new_in_last_run"] == 1
|
||||
assert run_details.status_code == 200
|
||||
assert run_details.json()["changes"]["new"][0]["full_name"] == "Alpha Person"
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatch):
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
db = session_factory()
|
||||
db.add(Employee(
|
||||
profile_key="org_person:133709486",
|
||||
profile_type="org_person",
|
||||
profile_id="133709486",
|
||||
canonical_url="https://www.hse.ru/org/persons/133709486",
|
||||
full_name="Будков Юрий Алексеевич",
|
||||
status="active",
|
||||
))
|
||||
db.commit()
|
||||
employee_id = db.scalar(select(Employee.id))
|
||||
db.close()
|
||||
|
||||
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
|
||||
|
||||
def override_db():
|
||||
session = session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_refresh_employee(db, refreshed_employee, route_settings):
|
||||
calls.append((refreshed_employee.id, route_settings))
|
||||
return SimpleNamespace(status="completed")
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
monkeypatch.setattr("app.admin.refresh_employee", fake_refresh_employee)
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
response = client.post(f"/admin/employees/{employee_id}/refresh", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
|
||||
assert calls == [(employee_id, settings)]
|
||||
app.dependency_overrides.clear()
|
||||
@@ -1,250 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import CrawlRun, CrawlRunEmployeeChange, Employee
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
|
||||
|
||||
def test_health_returns_versions():
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.4.6"
|
||||
|
||||
|
||||
def test_mcp_lists_tools_without_auth_and_ignores_auth_header():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
|
||||
def override_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
without_auth = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
|
||||
with_auth = client.post(
|
||||
"/mcp",
|
||||
headers={"Authorization": "Bearer anything"},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
|
||||
)
|
||||
|
||||
assert without_auth.status_code == 200
|
||||
assert with_auth.status_code == 200
|
||||
assert without_auth.json()["result"]["tools"][0]["name"] == "search_employees"
|
||||
assert any(tool["name"] == "get_crawl_run_details" for tool in without_auth.json()["result"]["tools"])
|
||||
assert with_auth.json()["result"]["tools"] == without_auth.json()["result"]["tools"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mcp_search_employees_returns_matching_employee():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
session.add(
|
||||
Employee(
|
||||
profile_key="staff:avsergeev",
|
||||
profile_type="staff",
|
||||
profile_id="avsergeev",
|
||||
canonical_url="https://www.hse.ru/staff/avsergeev",
|
||||
full_name="Сергеев Алексей Викторович",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={"sections": []},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
def override_db():
|
||||
db = Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "search_employees", "arguments": {"query": "Сергеев"}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Сергеев Алексей Викторович" in response.json()["result"]["content"][0]["text"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mcp_get_crawl_run_details_returns_changes():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="completed", new_count=1)
|
||||
employee = Employee(
|
||||
profile_key="staff:new",
|
||||
profile_type="staff",
|
||||
profile_id="new",
|
||||
canonical_url="https://www.hse.ru/staff/new",
|
||||
full_name="New Person",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add_all([run, employee])
|
||||
session.commit()
|
||||
session.add(
|
||||
CrawlRunEmployeeChange(
|
||||
crawl_run_id=run.id,
|
||||
employee_id=employee.id,
|
||||
profile_key=employee.profile_key,
|
||||
profile_url=employee.canonical_url,
|
||||
full_name=employee.full_name,
|
||||
change_type="new",
|
||||
profile_available=True,
|
||||
message="added",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
run_id = run.id
|
||||
session.close()
|
||||
|
||||
def override_db():
|
||||
db = Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "get_crawl_run_details", "arguments": {"run_id": run_id}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
text = response.json()["result"]["content"][0]["text"]
|
||||
assert "New Person" in text
|
||||
assert "changes_detail_available" in text
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mcp_protected_resource_metadata_route_is_removed():
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/.well-known/oauth-protected-resource")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
db.add(
|
||||
Employee(
|
||||
profile_key="staff:alpha",
|
||||
profile_type="staff",
|
||||
profile_id="alpha",
|
||||
canonical_url="https://www.hse.ru/staff/alpha",
|
||||
full_name="Alpha Person",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={"contacts": {"emails": ["alpha@hse.ru"]}, "sections": []},
|
||||
)
|
||||
)
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="completed", new_count=1)
|
||||
db.add(run)
|
||||
db.commit()
|
||||
db.add(
|
||||
CrawlRunEmployeeChange(
|
||||
crawl_run_id=run.id,
|
||||
employee_id=1,
|
||||
profile_key="staff:alpha",
|
||||
profile_url="https://www.hse.ru/staff/alpha",
|
||||
full_name="Alpha Person",
|
||||
change_type="new",
|
||||
profile_available=True,
|
||||
message="added",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
run_id = run.id
|
||||
db.close()
|
||||
|
||||
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
|
||||
|
||||
def override_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
employees = client.get("/api/employees", params={"q": "Alpha", "has_email": True})
|
||||
stats = client.get("/api/stats")
|
||||
run_details = client.get(f"/api/crawl-runs/{run_id}")
|
||||
|
||||
assert employees.status_code == 200
|
||||
assert employees.json()["total"] == 1
|
||||
assert stats.status_code == 200
|
||||
assert stats.json()["new_in_last_run"] == 1
|
||||
assert run_details.status_code == 200
|
||||
assert run_details.json()["changes"]["new"][0]["full_name"] == "Alpha Person"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -1,113 +1,575 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import CrawlRun, CrawlRunEmployeeChange, Employee
|
||||
from app.services.crawler import _mark_dismissed, _upsert_employee
|
||||
import gzip
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import (
|
||||
CrawlError,
|
||||
CrawlRun,
|
||||
CrawlRunEmployeeChange,
|
||||
Employee,
|
||||
EmployeeNewsLink,
|
||||
EmployeePublication,
|
||||
EmployeeSnapshot,
|
||||
ParseResourceCache,
|
||||
)
|
||||
from app.config import Settings
|
||||
from app.services.crawler import _checksum, _mark_dismissed, _upsert_employee, refresh_dismissed_status
|
||||
from app.services.resource_cache import ResourceCache
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code):
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, statuses):
|
||||
self.statuses = statuses
|
||||
|
||||
def get(self, url, **_kwargs):
|
||||
return FakeResponse(self.statuses[url])
|
||||
|
||||
|
||||
class ConditionalResponse:
|
||||
def __init__(self, status_code, text="", headers=None):
|
||||
self.status_code = status_code
|
||||
self._text = text
|
||||
self.headers = headers or {}
|
||||
self.text_read = False
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
self.text_read = True
|
||||
return self._text
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
|
||||
class ConditionalSession:
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
self.not_modified_response = ConditionalResponse(304)
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
self.requests.append((url, kwargs))
|
||||
if kwargs["headers"].get("If-None-Match") == '"cached"':
|
||||
return self.not_modified_response
|
||||
return ConditionalResponse(200, "fresh", {"ETag": '"fresh"'})
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code):
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, statuses):
|
||||
self.statuses = statuses
|
||||
|
||||
def get(self, url, **_kwargs):
|
||||
return FakeResponse(self.statuses[url])
|
||||
|
||||
|
||||
def test_mark_dismissed_records_missing_source_when_profile_is_available(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.add(
|
||||
Employee(
|
||||
profile_key="staff:kept",
|
||||
canonical_url="https://www.hse.ru/staff/kept",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
def test_refresh_dismissed_status_reactivates_only_profiles_in_source(monkeypatch, db_session):
|
||||
now = datetime.now(timezone.utc)
|
||||
found = Employee(
|
||||
profile_key="staff:returned",
|
||||
canonical_url="https://www.hse.ru/staff/returned",
|
||||
status="dismissed",
|
||||
dismissed_at=now,
|
||||
first_seen_at=now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db_session.add(
|
||||
Employee(
|
||||
profile_key="staff:missing",
|
||||
canonical_url="https://www.hse.ru/staff/missing",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
dismissed = _mark_dismissed(
|
||||
db_session,
|
||||
run,
|
||||
{"staff:kept"},
|
||||
FakeSession({"https://www.hse.ru/staff/missing": 200}),
|
||||
30,
|
||||
)
|
||||
|
||||
assert dismissed == 0
|
||||
assert db_session.query(Employee).filter_by(profile_key="staff:kept").one().status == "active"
|
||||
missing = db_session.query(Employee).filter_by(profile_key="staff:missing").one()
|
||||
assert missing.status == "active"
|
||||
assert missing.dismissed_at is None
|
||||
change = db_session.query(CrawlRunEmployeeChange).one()
|
||||
assert change.change_type == "missing_from_source"
|
||||
assert change.profile_available is True
|
||||
|
||||
|
||||
def test_mark_dismissed_marks_missing_employee_when_profile_is_unavailable(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
employee = Employee(
|
||||
still_dismissed = Employee(
|
||||
profile_key="staff:gone",
|
||||
canonical_url="https://www.hse.ru/staff/gone",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
status="dismissed",
|
||||
dismissed_at=now,
|
||||
first_seen_at=now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db_session.add_all([run, employee])
|
||||
db_session.add_all([found, still_dismissed])
|
||||
db_session.commit()
|
||||
|
||||
dismissed = _mark_dismissed(
|
||||
db_session,
|
||||
run,
|
||||
set(),
|
||||
FakeSession({"https://www.hse.ru/staff/gone": 404}),
|
||||
30,
|
||||
monkeypatch.setattr(
|
||||
"app.services.crawler.collect_profile_links",
|
||||
lambda *_args, **_kwargs: ["https://www.hse.ru/staff/returned"],
|
||||
)
|
||||
|
||||
assert dismissed == 1
|
||||
assert employee.status == "dismissed"
|
||||
assert employee.dismissed_at is not None
|
||||
change = db_session.query(CrawlRunEmployeeChange).one()
|
||||
assert change.change_type == "dismissed"
|
||||
assert change.profile_available is False
|
||||
|
||||
|
||||
def test_upsert_employee_increments_new_count_and_records_change_for_new_employee(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
_upsert_employee(
|
||||
db_session,
|
||||
run,
|
||||
{
|
||||
"source_url": "https://www.hse.ru/staff/newperson",
|
||||
"profile_type": "staff",
|
||||
"profile_id": "newperson",
|
||||
"full_name": "New Person",
|
||||
"tabs": [],
|
||||
"sections": [],
|
||||
"parser_version": "0.2.0",
|
||||
"_html": "<html></html>",
|
||||
},
|
||||
)
|
||||
db_session.commit()
|
||||
run = refresh_dismissed_status(db_session, Settings())
|
||||
|
||||
assert run.status == "completed"
|
||||
assert run.parsed_count == 1
|
||||
assert run.skipped_count == 1
|
||||
assert found.status == "active"
|
||||
assert found.dismissed_at is None
|
||||
assert still_dismissed.status == "dismissed"
|
||||
|
||||
|
||||
def test_mark_dismissed_records_missing_source_when_profile_is_available(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.add(
|
||||
Employee(
|
||||
profile_key="staff:kept",
|
||||
canonical_url="https://www.hse.ru/staff/kept",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
Employee(
|
||||
profile_key="staff:missing",
|
||||
canonical_url="https://www.hse.ru/staff/missing",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
dismissed = _mark_dismissed(
|
||||
db_session,
|
||||
run,
|
||||
{"staff:kept"},
|
||||
FakeSession({"https://www.hse.ru/staff/missing": 200}),
|
||||
30,
|
||||
)
|
||||
|
||||
assert dismissed == 0
|
||||
assert db_session.query(Employee).filter_by(profile_key="staff:kept").one().status == "active"
|
||||
missing = db_session.query(Employee).filter_by(profile_key="staff:missing").one()
|
||||
assert missing.status == "active"
|
||||
assert missing.dismissed_at is None
|
||||
change = db_session.query(CrawlRunEmployeeChange).one()
|
||||
assert change.change_type == "missing_from_source"
|
||||
assert change.profile_available is True
|
||||
|
||||
|
||||
def test_mark_dismissed_requires_consecutive_unavailable_checks(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
employee = Employee(
|
||||
profile_key="staff:gone",
|
||||
canonical_url="https://www.hse.ru/staff/gone",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add_all([run, employee])
|
||||
db_session.commit()
|
||||
|
||||
first_check = _mark_dismissed(
|
||||
db_session,
|
||||
run,
|
||||
set(),
|
||||
FakeSession({"https://www.hse.ru/staff/gone": 404}),
|
||||
30,
|
||||
confirmation_runs=2,
|
||||
)
|
||||
|
||||
assert first_check == 0
|
||||
assert employee.status == "verification_required"
|
||||
assert employee.dismissed_at is None
|
||||
assert employee.profile_unavailable_streak == 1
|
||||
assert db_session.query(CrawlRunEmployeeChange).one().change_type == "verification_required"
|
||||
|
||||
second_check = _mark_dismissed(
|
||||
db_session,
|
||||
run,
|
||||
set(),
|
||||
FakeSession({"https://www.hse.ru/staff/gone": 404}),
|
||||
30,
|
||||
confirmation_runs=2,
|
||||
)
|
||||
|
||||
assert second_check == 1
|
||||
assert employee.status == "dismissed"
|
||||
assert employee.dismissed_at is not None
|
||||
assert employee.profile_unavailable_streak == 2
|
||||
change = db_session.query(CrawlRunEmployeeChange).order_by(CrawlRunEmployeeChange.id).all()[-1]
|
||||
assert change.change_type == "dismissed"
|
||||
assert change.profile_available is False
|
||||
|
||||
|
||||
def test_mark_dismissed_does_not_dismiss_on_server_error(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
employee = Employee(
|
||||
profile_key="staff:temporary-error",
|
||||
canonical_url="https://www.hse.ru/staff/temporary-error",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add_all([run, employee])
|
||||
db_session.commit()
|
||||
|
||||
dismissed = _mark_dismissed(
|
||||
db_session,
|
||||
run,
|
||||
set(),
|
||||
FakeSession({"https://www.hse.ru/staff/temporary-error": 503}),
|
||||
30,
|
||||
confirmation_runs=1,
|
||||
)
|
||||
|
||||
assert dismissed == 0
|
||||
assert employee.status == "active"
|
||||
assert employee.profile_unavailable_streak == 0
|
||||
assert db_session.query(CrawlError).one().error_type == "ProfileAvailabilityCheckError"
|
||||
|
||||
|
||||
def test_available_profile_resets_verification_streak(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
employee = Employee(
|
||||
profile_key="staff:restored",
|
||||
canonical_url="https://www.hse.ru/staff/restored",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add_all([run, employee])
|
||||
db_session.commit()
|
||||
|
||||
_mark_dismissed(
|
||||
db_session,
|
||||
run,
|
||||
set(),
|
||||
FakeSession({"https://www.hse.ru/staff/restored": 404}),
|
||||
30,
|
||||
confirmation_runs=3,
|
||||
)
|
||||
_mark_dismissed(
|
||||
db_session,
|
||||
run,
|
||||
set(),
|
||||
FakeSession({"https://www.hse.ru/staff/restored": 200}),
|
||||
30,
|
||||
confirmation_runs=3,
|
||||
)
|
||||
|
||||
assert employee.status == "active"
|
||||
assert employee.profile_unavailable_streak == 0
|
||||
|
||||
|
||||
def test_mark_dismissed_blocks_mass_auto_dismissals(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
employees = [
|
||||
Employee(
|
||||
profile_key=f"staff:gone-{index}",
|
||||
canonical_url=f"https://www.hse.ru/staff/gone-{index}",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
db_session.add_all([run, *employees])
|
||||
db_session.commit()
|
||||
|
||||
dismissed = _mark_dismissed(
|
||||
db_session,
|
||||
run,
|
||||
set(),
|
||||
FakeSession({employee.canonical_url: 404 for employee in employees}),
|
||||
30,
|
||||
confirmation_runs=1,
|
||||
max_auto_dismissals=1,
|
||||
)
|
||||
|
||||
assert dismissed == 0
|
||||
assert {employee.status for employee in employees} == {"verification_required"}
|
||||
assert "приостановлено" in run.message
|
||||
|
||||
|
||||
def test_upsert_employee_increments_new_count_and_records_change_for_new_employee(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
employee, _ = _upsert_employee(
|
||||
db_session,
|
||||
run,
|
||||
{
|
||||
"source_url": "https://www.hse.ru/staff/newperson",
|
||||
"profile_type": "staff",
|
||||
"profile_id": "newperson",
|
||||
"full_name": "New Person",
|
||||
"tabs": [],
|
||||
"sections": [
|
||||
{
|
||||
"title": "Образование и учёные степени",
|
||||
"year_entries": [{"text": "Кандидат технических наук"}],
|
||||
}
|
||||
],
|
||||
"parser_version": "0.2.0",
|
||||
"_html": "<html></html>",
|
||||
},
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert run.new_count == 1
|
||||
change = db_session.query(CrawlRunEmployeeChange).one()
|
||||
assert change.change_type == "new"
|
||||
assert change.full_name == "New Person"
|
||||
assert employee.has_academic_degree is True
|
||||
change = db_session.query(CrawlRunEmployeeChange).one()
|
||||
assert change.change_type == "new"
|
||||
assert change.full_name == "New Person"
|
||||
|
||||
|
||||
def test_upsert_employee_reconciles_profile_moved_to_new_url(db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
employee = Employee(
|
||||
profile_key="staff:abelov",
|
||||
canonical_url="https://www.hse.ru/staff/abelov",
|
||||
full_name="Белов Александр Владимирович",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add_all([run, employee])
|
||||
db_session.commit()
|
||||
employee_id = employee.id
|
||||
|
||||
updated, changed = _upsert_employee(
|
||||
db_session,
|
||||
run,
|
||||
{
|
||||
"source_url": "https://www.hse.ru/org/persons/47634735",
|
||||
"profile_type": "org_person",
|
||||
"profile_id": "47634735",
|
||||
"full_name": "Белов Александр Владимирович",
|
||||
"tabs": [],
|
||||
"sections": [],
|
||||
"parser_version": "0.7.0",
|
||||
"_html": "<html></html>",
|
||||
},
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert changed is True
|
||||
assert updated.id == employee_id
|
||||
assert updated.profile_key == "org_person:47634735"
|
||||
assert updated.canonical_url == "https://www.hse.ru/org/persons/47634735"
|
||||
assert updated.status == "active"
|
||||
assert run.new_count == 0
|
||||
assert db_session.query(Employee).count() == 1
|
||||
assert {item.url for item in updated.profile_urls} == {
|
||||
"https://www.hse.ru/staff/abelov",
|
||||
"https://www.hse.ru/org/persons/47634735",
|
||||
}
|
||||
|
||||
|
||||
def test_resource_cache_uses_etag_and_reuses_cached_body_on_304(db_session):
|
||||
db_session.add(
|
||||
ParseResourceCache(
|
||||
profile_key="staff:cached",
|
||||
resource_key="main-html",
|
||||
method="GET",
|
||||
url="https://www.hse.ru/staff/cached",
|
||||
request_fingerprint="020d59db7b358d9023d0f185bcbf5a9c085d3cf2bf91d92d48eee9147e8d0f01",
|
||||
etag='"cached"',
|
||||
body_hash="cached-hash",
|
||||
body_snapshot=gzip.compress("cached body".encode("utf-8")),
|
||||
parser_version="0.6.0",
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
session = ConditionalSession()
|
||||
|
||||
result = ResourceCache(db_session).fetch_text(
|
||||
session,
|
||||
profile_key="staff:cached",
|
||||
resource_key="main-html",
|
||||
method="GET",
|
||||
url="https://www.hse.ru/staff/cached",
|
||||
headers={"User-Agent": "test"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert session.requests[0][1]["headers"]["If-None-Match"] == '"cached"'
|
||||
assert result.text == "cached body"
|
||||
assert result.from_cache is True
|
||||
assert session.not_modified_response.text_read is False
|
||||
|
||||
|
||||
def test_upsert_employee_skips_snapshot_when_checksum_is_unchanged(db_session):
|
||||
first_run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
second_run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add_all([first_run, second_run])
|
||||
db_session.commit()
|
||||
|
||||
_, first_changed = _upsert_employee(db_session, first_run, _parsed_employee("same"))
|
||||
_, second_changed = _upsert_employee(db_session, second_run, _parsed_employee("same"))
|
||||
db_session.commit()
|
||||
|
||||
assert first_changed is True
|
||||
assert second_changed is False
|
||||
assert db_session.query(EmployeeSnapshot).count() == 1
|
||||
|
||||
|
||||
def test_upsert_employee_saves_publications_and_reuses_existing_rows(db_session):
|
||||
first_run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
second_run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add_all([first_run, second_run])
|
||||
db_session.commit()
|
||||
|
||||
parsed = _parsed_employee("published")
|
||||
parsed["sections"] = [
|
||||
{
|
||||
"type": "publications",
|
||||
"publications": [
|
||||
{
|
||||
"id": "888959076",
|
||||
"publication_id": "888959076",
|
||||
"title": "Detailed Publication",
|
||||
"year": 2023,
|
||||
"publication_type": "ARTICLE",
|
||||
"language": "ru",
|
||||
"status": 1,
|
||||
"url": "https://publications.hse.ru/view/888959076",
|
||||
"doi_url": "https://doi.org/10.1/test",
|
||||
"citation_text": "Detailed citation",
|
||||
"annotation": {"ru": "Аннотация"},
|
||||
"description": {"main": "Detailed citation"},
|
||||
"authors": [{"id": "1", "title_ru": "Автор"}],
|
||||
"raw_data": {"id": "888959076", "title": "Detailed Publication"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
employee, _ = _upsert_employee(db_session, first_run, parsed)
|
||||
db_session.commit()
|
||||
_upsert_employee(db_session, second_run, _parsed_employee_with_publication("published"))
|
||||
db_session.commit()
|
||||
|
||||
publications = db_session.query(EmployeePublication).filter_by(employee_id=employee.id).all()
|
||||
assert len(publications) == 1
|
||||
assert publications[0].doi_url == "https://doi.org/10.1/test"
|
||||
assert publications[0].authors == [{"id": "1", "title_ru": "Автор"}]
|
||||
|
||||
|
||||
def test_upsert_employee_records_publication_errors_without_failing_employee(monkeypatch, db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
def broken_sync(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("app.services.crawler._sync_employee_publications", broken_sync)
|
||||
|
||||
employee, changed = _upsert_employee(db_session, run, _parsed_employee_with_publication("error-safe"))
|
||||
db_session.commit()
|
||||
|
||||
assert changed is True
|
||||
assert employee.full_name == "Same Person"
|
||||
assert db_session.query(Employee).filter_by(profile_key="staff:error-safe").one()
|
||||
error = db_session.query(CrawlError).one()
|
||||
assert "публикации" in error.message.lower()
|
||||
|
||||
|
||||
def test_upsert_employee_saves_news_links_and_reuses_existing_rows(db_session):
|
||||
first_run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
second_run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add_all([first_run, second_run])
|
||||
db_session.commit()
|
||||
|
||||
employee, _ = _upsert_employee(db_session, first_run, _parsed_employee_with_news("news-person"))
|
||||
db_session.commit()
|
||||
_upsert_employee(db_session, second_run, _parsed_employee_with_news("news-person"))
|
||||
db_session.commit()
|
||||
|
||||
news_links = db_session.query(EmployeeNewsLink).filter_by(employee_id=employee.id).all()
|
||||
assert len(news_links) == 1
|
||||
assert news_links[0].title == "News Title"
|
||||
assert news_links[0].url == "https://www.hse.ru/news/1.html"
|
||||
assert news_links[0].published_year == 2026
|
||||
|
||||
|
||||
def test_upsert_employee_records_news_errors_without_failing_employee(monkeypatch, db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
def broken_sync(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("app.services.crawler._sync_employee_news_links", broken_sync)
|
||||
|
||||
employee, changed = _upsert_employee(db_session, run, _parsed_employee_with_news("news-error-safe"))
|
||||
db_session.commit()
|
||||
|
||||
assert changed is True
|
||||
assert employee.full_name == "Same Person"
|
||||
assert db_session.query(Employee).filter_by(profile_key="staff:news-error-safe").one()
|
||||
error = db_session.query(CrawlError).one()
|
||||
assert "новости" in error.message.lower()
|
||||
|
||||
|
||||
def test_checksum_changes_when_widget_data_changes():
|
||||
base = _parsed_employee("widgets")
|
||||
changed = _parsed_employee("widgets")
|
||||
changed["sections"] = [
|
||||
{
|
||||
"type": "publications",
|
||||
"publications": [{"id": "1", "title": "New publication"}],
|
||||
}
|
||||
]
|
||||
|
||||
assert _checksum(base) != _checksum(changed)
|
||||
|
||||
|
||||
def test_checksum_ignores_date_dependent_experience_text():
|
||||
first = _parsed_employee("experience")
|
||||
second = _parsed_employee("experience")
|
||||
first["sections"] = [{"raw_text": "Стаж работы в НИУ ВШЭ: 5 лет"}]
|
||||
second["sections"] = [{"raw_text": "Стаж работы в НИУ ВШЭ: 6 лет"}]
|
||||
|
||||
assert _checksum(first) == _checksum(second)
|
||||
|
||||
|
||||
def _parsed_employee(profile_id: str) -> dict:
|
||||
return {
|
||||
"source_url": f"https://www.hse.ru/staff/{profile_id}",
|
||||
"profile_type": "staff",
|
||||
"profile_id": profile_id,
|
||||
"full_name": "Same Person",
|
||||
"tabs": [],
|
||||
"sections": [],
|
||||
"parser_version": "0.6.0",
|
||||
"_html": "<html></html>",
|
||||
}
|
||||
|
||||
|
||||
def _parsed_employee_with_publication(profile_id: str) -> dict:
|
||||
parsed = _parsed_employee(profile_id)
|
||||
parsed["sections"] = [
|
||||
{
|
||||
"type": "publications",
|
||||
"publications": [
|
||||
{
|
||||
"id": "888959076",
|
||||
"publication_id": "888959076",
|
||||
"title": "Detailed Publication",
|
||||
"year": 2023,
|
||||
"publication_type": "ARTICLE",
|
||||
"language": "ru",
|
||||
"status": 1,
|
||||
"url": "https://publications.hse.ru/view/888959076",
|
||||
"doi_url": "https://doi.org/10.1/test",
|
||||
"citation_text": "Detailed citation",
|
||||
"annotation": {"ru": "Аннотация"},
|
||||
"description": {"main": "Detailed citation"},
|
||||
"authors": [{"id": "1", "title_ru": "Автор"}],
|
||||
"raw_data": {"id": "888959076", "title": "Detailed Publication"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
return parsed
|
||||
|
||||
|
||||
def _parsed_employee_with_news(profile_id: str) -> dict:
|
||||
parsed = _parsed_employee(profile_id)
|
||||
parsed["sections"] = [
|
||||
{
|
||||
"type": "news",
|
||||
"news_links": [
|
||||
{
|
||||
"title": "News Title",
|
||||
"url": "https://www.hse.ru/news/1.html",
|
||||
"summary": "News summary",
|
||||
"published_at": "2026-04-28T00:00:00+00:00",
|
||||
"published_year": 2026,
|
||||
"raw_data": {"title": "News Title", "url": "https://www.hse.ru/news/1.html"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
return parsed
|
||||
|
||||
144
tests/test_db_schema.py
Normal file
144
tests/test_db_schema.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app.db import _ensure_runtime_schema
|
||||
|
||||
|
||||
def test_runtime_schema_adds_skipped_count_to_existing_crawl_runs_table(monkeypatch):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE crawl_runs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_url TEXT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||
found_count INTEGER NOT NULL DEFAULT 0,
|
||||
parsed_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
|
||||
_ensure_runtime_schema()
|
||||
|
||||
columns = {column["name"] for column in inspect(engine).get_columns("crawl_runs")}
|
||||
assert "skipped_count" in columns
|
||||
|
||||
|
||||
def test_runtime_schema_creates_employee_publications_table_when_employees_exist(monkeypatch):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE employees (
|
||||
id INTEGER PRIMARY KEY,
|
||||
profile_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
canonical_url TEXT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE crawl_runs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_url TEXT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||
found_count INTEGER NOT NULL DEFAULT 0,
|
||||
parsed_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
|
||||
_ensure_runtime_schema()
|
||||
_ensure_runtime_schema()
|
||||
|
||||
inspector = inspect(engine)
|
||||
assert "employee_publications" in inspector.get_table_names()
|
||||
columns = {column["name"] for column in inspector.get_columns("employee_publications")}
|
||||
assert {"employee_id", "publication_id", "doi_url", "authors", "raw_data", "source_hash"}.issubset(columns)
|
||||
|
||||
|
||||
def test_runtime_schema_creates_employee_news_links_table_when_employees_exist(monkeypatch):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE employees (
|
||||
id INTEGER PRIMARY KEY,
|
||||
profile_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
canonical_url TEXT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE crawl_runs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_url TEXT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||
found_count INTEGER NOT NULL DEFAULT 0,
|
||||
parsed_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
|
||||
_ensure_runtime_schema()
|
||||
_ensure_runtime_schema()
|
||||
|
||||
inspector = inspect(engine)
|
||||
assert "employee_news_links" in inspector.get_table_names()
|
||||
columns = {column["name"] for column in inspector.get_columns("employee_news_links")}
|
||||
assert {"employee_id", "title", "url", "summary", "published_at", "published_year", "source_hash", "raw_data"}.issubset(columns)
|
||||
|
||||
|
||||
def test_runtime_schema_adds_profile_verification_fields(monkeypatch):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE employees (
|
||||
id INTEGER PRIMARY KEY,
|
||||
profile_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
canonical_url TEXT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
|
||||
_ensure_runtime_schema()
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = {column["name"] for column in inspector.get_columns("employees")}
|
||||
assert {"profile_unavailable_streak", "last_profile_check_at", "has_academic_degree"}.issubset(columns)
|
||||
assert "employee_profile_urls" in inspector.get_table_names()
|
||||
@@ -13,6 +13,9 @@ def test_employee_detail_template_is_human_readable():
|
||||
assert "section.list_items" in template
|
||||
assert "Основная информация" in template
|
||||
assert "Контакты" in template
|
||||
assert "В новостях" in template
|
||||
assert "employee_view.news_links" in template
|
||||
assert "news.summary" in template
|
||||
assert "Разделы профиля" in template
|
||||
assert "graduation_theses" in template
|
||||
assert "Год защиты" in template
|
||||
@@ -27,4 +30,6 @@ def test_employee_detail_template_is_human_readable():
|
||||
assert "Дата увольнения" in template
|
||||
assert "Тип профиля" in template
|
||||
assert "ID профиля" in template
|
||||
assert "Обновить данные" in template
|
||||
assert 'action="/admin/employees/{{ employee.id }}/refresh"' in template
|
||||
assert "Снапшоты" in template
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.parser.profile import enrich_sections_from_hse_widgets, extract_person_tabs
|
||||
from app.parser.profile import enrich_sections_from_hse_widgets, extract_person_tabs, extract_sections
|
||||
from app.parser.profile_url import normalize_profile_url, parse_profile_identity
|
||||
|
||||
|
||||
@@ -34,7 +34,21 @@ class FakeSession:
|
||||
"type": "ARTICLE",
|
||||
"title": "Дублирование пакетов",
|
||||
"year": 2023,
|
||||
"language": {"name": "ru"},
|
||||
"status": 1,
|
||||
"authorsByType": {
|
||||
"author": [
|
||||
{
|
||||
"id": "568398853",
|
||||
"href": "/org/persons/568398853",
|
||||
"title": {"ru": "Левицкий И. А.", "en": ""},
|
||||
"reverseTitle": {"ru": "И. А. Левицкий", "en": ""},
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": {"short": {"ru": "Информационные процессы. 2023."}},
|
||||
"annotation": {"ru": "<p>Русская аннотация</p>"},
|
||||
"documents": {"DOI": {"href": "https://doi.org/10.1/test"}},
|
||||
}
|
||||
],
|
||||
},
|
||||
@@ -153,6 +167,9 @@ def test_enrich_sections_from_hse_widgets_loads_publications_and_vkr():
|
||||
|
||||
assert publications["publications_count"] == 1
|
||||
assert publications["publications"][0]["url"] == "https://publications.hse.ru/view/888959076"
|
||||
assert publications["publications"][0]["doi_url"] == "https://doi.org/10.1/test"
|
||||
assert publications["publications"][0]["annotation"] == {"ru": "Русская аннотация"}
|
||||
assert publications["publications"][0]["authors"][0]["is_current_employee"] is True
|
||||
assert theses["theses_count"] == 1
|
||||
assert theses["theses"][0]["student"] == "Лесняк Владислав Евгеньевич"
|
||||
assert theses["theses"][0]["project_url"] == "https://www.hse.ru/edu/vkr/1045750164"
|
||||
@@ -184,3 +201,76 @@ def test_enrich_sections_from_hse_widgets_loads_grouped_publications():
|
||||
assert [item["id"] for item in publications["publications"]] == ["146366790", "146367323"]
|
||||
assert publications["publications"][0]["url"] == "https://publications.hse.ru/view/146366790"
|
||||
assert publications["publications"][1]["url"] == "https://publications.hse.ru/view/146367323"
|
||||
|
||||
|
||||
def test_news_heading_with_publications_word_does_not_absorb_widget_publications():
|
||||
soup = BeautifulSoup(
|
||||
"""
|
||||
<h2>Статья профессора МИЭМ вошла в число самых популярных публикаций на портале SpringerLink</h2>
|
||||
<div class="post__text">
|
||||
<p>Первоначально статья профессора вышла в российском журнале.</p>
|
||||
</div>
|
||||
<script src="/n/stat/publications/dist-w/publs.js" data-author="133709486" data-widget-name="AuthorSearch"></script>
|
||||
""",
|
||||
"html.parser",
|
||||
)
|
||||
session = FakeSession()
|
||||
|
||||
sections = extract_sections(soup, "https://www.hse.ru/org/persons/133709486")
|
||||
sections = enrich_sections_from_hse_widgets(
|
||||
session,
|
||||
soup,
|
||||
"https://www.hse.ru/org/persons/133709486",
|
||||
{"User-Agent": "test"},
|
||||
10,
|
||||
sections,
|
||||
)
|
||||
|
||||
assert sections[0]["type"] == "paragraphs"
|
||||
assert sections[0]["title"].startswith("Статья профессора")
|
||||
publications = [section for section in sections if section["type"] == "publications"]
|
||||
assert len(publications) == 1
|
||||
assert publications[0]["title"] == "Публикации и исследования"
|
||||
assert publications[0]["publications_count"] == 1
|
||||
|
||||
|
||||
def test_extract_sections_parses_employee_news_links():
|
||||
soup = BeautifulSoup(
|
||||
"""
|
||||
<div class="b-person-data posts hidden printable" data-tab="press_links_news" tab-node="press_links_news">
|
||||
<div class="post f8">
|
||||
<div class="post__extra">
|
||||
<div class="post-meta">
|
||||
<div class="post-meta__date">
|
||||
<div class="post-meta__day">28</div>
|
||||
<div class="post-meta__month">апр.</div>
|
||||
<div class="post-meta__year">2026</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post__content">
|
||||
<h2 class="first_child"><a class="link" href="/news/edu/1153850518.html">Как финал ВсОШ формирует кадры</a></h2>
|
||||
<div class="post__text"><p class="with-indent">Краткое описание новости.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post f8">
|
||||
<div class="post__content">
|
||||
<h2><a href="https://miem.hse.ru/news/1123589375.html">Партнер магистратуры</a></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
"html.parser",
|
||||
)
|
||||
|
||||
sections = extract_sections(soup, "https://www.hse.ru/staff/avsergeev")
|
||||
|
||||
assert len(sections) == 1
|
||||
news = sections[0]
|
||||
assert news["type"] == "news"
|
||||
assert news["news_count"] == 2
|
||||
assert news["news_links"][0]["title"] == "Как финал ВсОШ формирует кадры"
|
||||
assert news["news_links"][0]["url"] == "https://www.hse.ru/news/edu/1153850518.html"
|
||||
assert news["news_links"][0]["summary"] == "Краткое описание новости."
|
||||
assert news["news_links"][0]["published_at"] == "2026-04-28T00:00:00+00:00"
|
||||
assert news["news_links"][0]["published_year"] == 2026
|
||||
|
||||
Reference in New Issue
Block a user