Compare commits

..

5 Commits

16 changed files with 3164 additions and 2598 deletions

9
CHANGELOG.md Normal file
View File

@@ -0,0 +1,9 @@
# Changelog
## 0.7.2
- В каталоге сотрудников добавлены фильтр и колонка учёной степени.
## 0.7.1
- Добавлена кнопка «Проверить уволенных» для принудительной сверки статуса уволенных сотрудников с текущим списком источника.

View File

@@ -28,6 +28,8 @@ cp .env.example .env
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки. - `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки.
- `SESSION_SECRET`: секрет подписи cookie. - `SESSION_SECRET`: секрет подписи cookie.
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок. - `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
## Локальный запуск ## Локальный запуск
@@ -92,10 +94,16 @@ curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=.
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`; - найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
- новые сотрудники добавляются в `employees`; - новые сотрудники добавляются в `employees`;
- если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
- старые URL сохраняются в истории `employee_profile_urls`;
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`; - количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
- публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля; - публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля;
- новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля; - новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля;
- активные сотрудники, исчезнувшие из текущего списка источника, получают статус `dismissed` и `dismissed_at`; - один `404` старого профиля переводит сотрудника в статус `verification_required`, а не в `dismissed`;
- статус `dismissed` устанавливается только после нескольких последовательных проверок `404`/`410`;
- сетевые ошибки и ответы `5xx` не считаются подтверждением увольнения;
- если число кандидатов на увольнение превышает защитный лимит, автоматическое увольнение приостанавливается;
- кнопка «Проверить уволенных» сверяет только их profile_key с текущим списком источника и возвращает найденных сотрудников в `active` без обновления содержимого профиля;
- каждый успешный новый или измененный разбор сохраняет запись в `employee_snapshots`; - каждый успешный новый или измененный разбор сохраняет запись в `employee_snapshots`;
- неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot. - неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot.
@@ -139,4 +147,4 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
docker compose down docker compose down
``` ```
Версия сервиса: `0.7.0`. Админка всегда показывает версии backend и frontend в footer. Версия сервиса: `0.7.1`. Админка всегда показывает версии backend и frontend в footer.

View File

@@ -17,7 +17,7 @@ from app.services.admin_data import (
stats_payload, stats_payload,
) )
from app.services.crawl_control import get_running_run, run_crawl_if_idle from app.services.crawl_control import get_running_run, run_crawl_if_idle
from app.services.crawler import refresh_employee from app.services.crawler import refresh_dismissed_status, refresh_employee
from app.version import BACKEND_VERSION, FRONTEND_VERSION from app.version import BACKEND_VERSION, FRONTEND_VERSION
router = APIRouter(prefix="/admin") router = APIRouter(prefix="/admin")
@@ -80,6 +80,7 @@ def directory(
started_from: str | None = None, started_from: str | None = None,
started_to: str | None = None, started_to: str | None = None,
has_email: str | None = None, has_email: str | None = None,
has_academic_degree: str | None = None,
sort: str = "full_name", sort: str = "full_name",
direction: str = "asc", direction: str = "asc",
limit: int = 50, limit: int = 50,
@@ -91,6 +92,7 @@ def directory(
parsed_started_from = _parse_date(started_from) parsed_started_from = _parse_date(started_from)
parsed_started_to = _parse_date(started_to) parsed_started_to = _parse_date(started_to)
parsed_has_email = None if has_email in (None, "") else has_email == "true" parsed_has_email = None if has_email in (None, "") else has_email == "true"
parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true"
page = list_employees_page( page = list_employees_page(
db, db,
status=status, status=status,
@@ -98,6 +100,7 @@ def directory(
started_from=parsed_started_from, started_from=parsed_started_from,
started_to=parsed_started_to, started_to=parsed_started_to,
has_email=parsed_has_email, has_email=parsed_has_email,
has_academic_degree=parsed_has_academic_degree,
sort=sort, sort=sort,
direction=direction, direction=direction,
limit=limit, limit=limit,
@@ -114,6 +117,7 @@ def directory(
"started_from": started_from or "", "started_from": started_from or "",
"started_to": started_to or "", "started_to": started_to or "",
"has_email": has_email or "", "has_email": has_email or "",
"has_academic_degree": has_academic_degree or "",
"sort": sort, "sort": sort,
"direction": direction, "direction": direction,
"limit": page["limit"], "limit": page["limit"],
@@ -231,6 +235,25 @@ def crawl_now(
return RedirectResponse("/admin", status_code=303) return RedirectResponse("/admin", status_code=303)
@router.post("/dismissed/refresh")
def refresh_dismissed(
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 _refresh() -> None:
with SessionLocal() as db:
refresh_dismissed_status(db, settings)
background_tasks.add_task(_refresh)
return RedirectResponse("/admin", status_code=303)
def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse: def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse:
payload = { payload = {
"request": request, "request": request,

View File

@@ -28,6 +28,7 @@ def list_employees(
started_from: date | None = None, started_from: date | None = None,
started_to: date | None = None, started_to: date | None = None,
has_email: bool | None = None, has_email: bool | None = None,
has_academic_degree: bool | None = None,
sort: str = "full_name", sort: str = "full_name",
direction: str = "asc", direction: str = "asc",
limit: int = 50, limit: int = 50,
@@ -43,6 +44,7 @@ def list_employees(
started_from=started_from, started_from=started_from,
started_to=started_to, started_to=started_to,
has_email=has_email, has_email=has_email,
has_academic_degree=has_academic_degree,
sort=sort, sort=sort,
direction=direction, direction=direction,
limit=limit, limit=limit,

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import re
from datetime import date, datetime, time from datetime import date, datetime, time
from math import ceil from math import ceil
from typing import Any from typing import Any
@@ -19,6 +20,8 @@ EMPLOYEE_SORTS = {
"hse_start_year": Employee.current_data["hse_start_year"].as_integer(), "hse_start_year": Employee.current_data["hse_start_year"].as_integer(),
} }
_ACADEMIC_DEGREE_PATTERN = re.compile(r"\b(?:кандидат|доктор)\s+[\w\s-]{0,80}?\s+наук\b|\bph\.?\s*d\.?\b", re.IGNORECASE)
def employee_display_payload(employee: Employee) -> dict[str, Any]: def employee_display_payload(employee: Employee) -> dict[str, Any]:
data = _as_dict(employee.current_data) data = _as_dict(employee.current_data)
@@ -28,6 +31,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
positions = _clean_list(data.get("positions")) positions = _clean_list(data.get("positions"))
emails = _clean_list(contacts.get("emails")) emails = _clean_list(contacts.get("emails"))
phones = _clean_list(contacts.get("phones")) phones = _clean_list(contacts.get("phones"))
academic_degrees = _academic_degrees(sections)
return { return {
"id": employee.id, "id": employee.id,
"full_name": employee.full_name, "full_name": employee.full_name,
@@ -42,6 +46,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
"phones": phones, "phones": phones,
"phone_text": ", ".join(phones), "phone_text": ", ".join(phones),
"address": contacts.get("address"), "address": contacts.get("address"),
"academic_degree_text": "; ".join(academic_degrees),
"publications_count": _count_section_items(sections, "publications"), "publications_count": _count_section_items(sections, "publications"),
"courses_count": _count_section_items(sections, "courses_by_year"), "courses_count": _count_section_items(sections, "courses_by_year"),
"news_count": len(stored_news_links) or _count_section_items(sections, "news"), "news_count": len(stored_news_links) or _count_section_items(sections, "news"),
@@ -81,6 +86,7 @@ def build_employee_query(
started_from: date | None = None, started_from: date | None = None,
started_to: date | None = None, started_to: date | None = None,
has_email: bool | None = None, has_email: bool | None = None,
has_academic_degree: bool | None = None,
) -> Select[tuple[Employee]]: ) -> Select[tuple[Employee]]:
stmt = select(Employee) stmt = select(Employee)
filters = [] filters = []
@@ -97,6 +103,15 @@ def build_employee_query(
filters.append(Employee.current_data.cast(Text).ilike("%@%")) filters.append(Employee.current_data.cast(Text).ilike("%@%"))
elif has_email is False: elif has_email is False:
filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%"))) filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%")))
if has_academic_degree is not None:
data_text = Employee.current_data.cast(Text)
degree_condition = or_(
and_(_json_text_contains(data_text, "кандидат"), _json_text_contains(data_text, "наук")),
and_(_json_text_contains(data_text, "доктор"), _json_text_contains(data_text, "наук")),
_json_text_contains(data_text, "phd"),
_json_text_contains(data_text, "ph.d."),
)
filters.append(degree_condition if has_academic_degree else or_(Employee.current_data.is_(None), ~degree_condition))
if filters: if filters:
stmt = stmt.where(and_(*filters)) stmt = stmt.where(and_(*filters))
return stmt return stmt
@@ -110,6 +125,7 @@ def list_employees_page(
started_from: date | None = None, started_from: date | None = None,
started_to: date | None = None, started_to: date | None = None,
has_email: bool | None = None, has_email: bool | None = None,
has_academic_degree: bool | None = None,
sort: str = "full_name", sort: str = "full_name",
direction: str = "asc", direction: str = "asc",
limit: int = 50, limit: int = 50,
@@ -123,6 +139,7 @@ def list_employees_page(
started_from=started_from, started_from=started_from,
started_to=started_to, started_to=started_to,
has_email=has_email, has_email=has_email,
has_academic_degree=has_academic_degree,
) )
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0 total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name) sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name)
@@ -214,6 +231,36 @@ def format_admin_datetime(value: Any) -> str:
return value.strftime("%d.%m.%Y %H:%M") return value.strftime("%d.%m.%Y %H:%M")
def _academic_degrees(sections: list[Any]) -> list[str]:
degrees = []
for section in sections:
section_data = _as_dict(section)
title = str(section_data.get("title") or "")
if not re.search(r"уч[её]н.*степен|academic degree", title, re.IGNORECASE):
continue
values = [
*(_as_dict(entry).get("text") for entry in _as_list(section_data.get("year_entries"))),
*_clean_list(section_data.get("paragraphs")),
*_clean_list(section_data.get("items")),
section_data.get("raw_text"),
]
for value in values:
text = str(value or "").strip()
if text and _ACADEMIC_DEGREE_PATTERN.search(text) and text not in degrees:
degrees.append(text)
return degrees
def _json_text_contains(data_text: Any, value: str) -> Any:
escaped = value.encode("unicode_escape").decode("ascii")
escaped_capitalized = value.capitalize().encode("unicode_escape").decode("ascii")
return or_(
data_text.ilike(f"%{value}%"),
data_text.ilike(f"%{escaped}%"),
data_text.ilike(f"%{escaped_capitalized}%"),
)
def _employee_status_display(status: str | None) -> str: def _employee_status_display(status: str | None) -> str:
labels = {"active": "Работает", "dismissed": "Уволен"} labels = {"active": "Работает", "dismissed": "Уволен"}
return labels.get(status or "", status or "Не указано") return labels.get(status or "", status or "Не указано")

View File

@@ -17,6 +17,7 @@ from app.models import (
Employee, Employee,
EmployeeNewsLink, EmployeeNewsLink,
EmployeePublication, EmployeePublication,
EmployeeProfileUrl,
EmployeeSnapshot, EmployeeSnapshot,
ParserSource, ParserSource,
ProfileTab, ProfileTab,
@@ -66,7 +67,9 @@ def run_crawl(db: Session, settings: Settings) -> CrawlRun:
) )
if not parsed: if not parsed:
continue continue
_, changed = _upsert_employee(db, run, parsed) employee, changed = _upsert_employee(db, run, parsed)
if employee.profile_key:
found_keys.add(employee.profile_key)
if changed: if changed:
parsed_count += 1 parsed_count += 1
else: else:
@@ -88,7 +91,15 @@ def run_crawl(db: Session, settings: Settings) -> CrawlRun:
finally: finally:
time.sleep(settings.request_delay_seconds) time.sleep(settings.request_delay_seconds)
run.dismissed_count = _mark_dismissed(db, run, found_keys, session, settings.request_timeout) 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" run.status = "completed"
get_or_create_current_version(db, crawl_run_id=run.id) get_or_create_current_version(db, crawl_run_id=run.id)
except Exception as exc: except Exception as exc:
@@ -101,6 +112,59 @@ def run_crawl(db: Session, settings: Settings) -> CrawlRun:
return 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)
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)
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"
get_or_create_current_version(db, crawl_run_id=run.id)
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()
db.refresh(run)
return run
def refresh_employee(db: Session, employee: Employee, settings: Settings) -> CrawlRun: def refresh_employee(db: Session, employee: Employee, settings: Settings) -> CrawlRun:
run = CrawlRun(source_url=employee.canonical_url, status="running", found_count=1) run = CrawlRun(source_url=employee.canonical_url, status="running", found_count=1)
db.add(run) db.add(run)
@@ -170,6 +234,8 @@ def _upsert_employee(db: Session, run: CrawlRun, parsed: dict) -> tuple[Employee
checksum = _checksum(parsed) checksum = _checksum(parsed)
key = _parsed_profile_key(parsed) key = _parsed_profile_key(parsed)
employee = db.scalar(select(Employee).where(Employee.profile_key == key)) 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) now = datetime.now(timezone.utc)
if not employee: if not employee:
employee = Employee( employee = Employee(
@@ -187,15 +253,23 @@ def _upsert_employee(db: Session, run: CrawlRun, parsed: dict) -> tuple[Employee
parser_version = parsed.get("parser_version") parser_version = parsed.get("parser_version")
changed = is_new or employee.current_checksum != checksum or employee.parser_version != 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.full_name = parsed.get("full_name")
employee.status = "active" employee.status = "active"
employee.last_seen_at = now employee.last_seen_at = now
employee.dismissed_at = None employee.dismissed_at = None
employee.profile_unavailable_streak = 0
employee.last_profile_check_at = now
employee.parser_version = parser_version employee.parser_version = parser_version
if changed: if changed:
employee.current_data = parsed employee.current_data = parsed
employee.current_checksum = checksum employee.current_checksum = checksum
db.flush() db.flush()
_sync_profile_url_history(db, employee, previous_url, employee.canonical_url, now)
if is_new: if is_new:
_record_employee_change( _record_employee_change(
@@ -235,6 +309,74 @@ def _upsert_employee(db: Session, run: CrawlRun, parsed: dict) -> tuple[Employee
return employee, changed 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: def _try_sync_employee_publications(db: Session, run: CrawlRun, employee: Employee, parsed: dict) -> None:
try: try:
if not _publication_payloads(parsed): if not _publication_payloads(parsed):
@@ -453,15 +595,41 @@ def _datetime_or_none(value: object) -> datetime | None:
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) 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) -> int: 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 dismissed = 0
active = db.scalars(select(Employee).where(Employee.status == "active")).all() candidates = db.scalars(
select(Employee).where(Employee.status.in_(("active", "verification_required")))
).all()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
for employee in active: unavailable = []
for employee in candidates:
if employee.profile_key in found_keys: if employee.profile_key in found_keys:
continue continue
profile_available = _profile_is_available(session, employee.canonical_url, timeout) 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: if profile_available:
employee.profile_unavailable_streak = 0
if employee.status == "verification_required":
employee.status = "active"
_record_employee_change( _record_employee_change(
db, db,
run, run,
@@ -471,6 +639,37 @@ def _mark_dismissed(db: Session, run: CrawlRun, found_keys: set[str], session: r
message="Профиль доступен, но ссылка отсутствует в исходном списке.", message="Профиль доступен, но ссылка отсутствует в исходном списке.",
) )
continue 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.status = "dismissed"
employee.dismissed_at = now employee.dismissed_at = now
_record_employee_change( _record_employee_change(
@@ -479,7 +678,10 @@ def _mark_dismissed(db: Session, run: CrawlRun, found_keys: set[str], session: r
employee, employee,
"dismissed", "dismissed",
profile_available=False, profile_available=False,
message="Сотрудник отсутствует в исходном списке, профиль не подтвердился как доступный.", message=(
"Сотрудник отсутствует в исходном списке, профиль не подтвердился "
f"{confirmation_runs} раза подряд."
),
) )
dismissed += 1 dismissed += 1
db.commit() db.commit()
@@ -487,11 +689,19 @@ def _mark_dismissed(db: Session, run: CrawlRun, found_keys: set[str], session: r
def _profile_is_available(session: requests.Session, url: str, timeout: int) -> bool: 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: try:
response = session.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True) response = session.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
return response.status_code < 400 if response.status_code < 400:
except requests.RequestException: return True
if response.status_code in {404, 410}:
return False return False
return None
except requests.RequestException:
return None
def _record_employee_change( def _record_employee_change(

View File

@@ -137,6 +137,11 @@
color: #991b1b; color: #991b1b;
} }
.badge--verification {
background: #fef3c7;
color: #92400e;
}
.form { .form {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -428,6 +433,17 @@
gap: 16px; gap: 16px;
} }
.progress-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.button--secondary {
color: #0f766e;
background: #ccfbf1;
}
.progress-panel__body { .progress-panel__body {
display: grid; display: grid;
gap: 10px; gap: 10px;

View File

@@ -5,6 +5,7 @@
"positions", "positions",
"hse_start_year", "hse_start_year",
"email", "email",
"academic_degree",
"last_seen_at", "last_seen_at",
"dismissed_at", "dismissed_at",
"profile", "profile",

View File

@@ -4,6 +4,7 @@
<section class="admin__grid"> <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"><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=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="{% 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> <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>
@@ -28,9 +29,14 @@
<section class="panel progress-panel" data-progress-panel> <section class="panel progress-panel" data-progress-panel>
<div class="progress-panel__header"> <div class="progress-panel__header">
<h2 class="panel__title">Прогресс парсинга</h2> <h2 class="panel__title">Прогресс парсинга</h2>
<div class="progress-panel__actions">
<form method="post" action="/admin/crawl-now"> <form method="post" action="/admin/crawl-now">
<button class="button" type="submit">Запустить парсинг</button> <button class="button" type="submit">Запустить парсинг</button>
</form> </form>
<form method="post" action="/admin/dismissed/refresh">
<button class="button button--secondary" type="submit">Проверить уволенных</button>
</form>
</div>
</div> </div>
{% set run = counts.current_running_run or latest_run %} {% set run = counts.current_running_run or latest_run %}
<div class="progress-panel__body" data-progress-body> <div class="progress-panel__body" data-progress-body>

View File

@@ -22,6 +22,11 @@
<option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option> <option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option>
<option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option> <option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option>
</select> </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_from" value="{{ filters.started_from }}" aria-label="Впервые найден с">
<input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}" aria-label="Впервые найден по"> <input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}" aria-label="Впервые найден по">
<select class="directory__input" name="sort"> <select class="directory__input" name="sort">
@@ -53,6 +58,7 @@
<th class="directory-table__head" data-column="email">Email</th> <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="phone">Телефон</th>
<th class="directory-table__head" data-column="address">Адрес</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="publications_count">Публикации</th>
<th class="directory-table__head" data-column="courses_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="news_count">Новости</th>
@@ -72,6 +78,7 @@
<td class="directory-table__cell" data-column="email">{{ employee.email_text }}</td> <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="phone">{{ employee.phone_text }}</td>
<td class="directory-table__cell" data-column="address">{{ employee.address or "" }}</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="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="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="news_count">{{ employee.news_count }}</td>
@@ -81,7 +88,7 @@
<td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}">Открыть</a></td> <td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}">Открыть</a></td>
</tr> </tr>
{% else %} {% else %}
<tr><td class="directory-table__empty" colspan="14">По этим фильтрам сотрудники не найдены.</td></tr> <tr><td class="directory-table__empty" colspan="15">По этим фильтрам сотрудники не найдены.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -108,7 +115,7 @@
<button class="button button--ghost" type="button" data-columns-close>Закрыть</button> <button class="button button--ghost" type="button" data-columns-close>Закрыть</button>
</div> </div>
<div class="columns-modal__grid"> <div class="columns-modal__grid">
{% for key, label in [("full_name", "ФИО"), ("status", "Статус"), ("positions", "Должности"), ("hse_start_year", "Год начала"), ("email", "Email"), ("phone", "Телефон"), ("address", "Адрес"), ("publications_count", "Публикации"), ("courses_count", "Курсы"), ("news_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> <label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" value="{{ key }}" data-column-toggle> {{ label }}</label>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -1,3 +1,3 @@
APP_VERSION = "0.7.0" APP_VERSION = "0.7.2"
FRONTEND_VERSION = "0.7.0" FRONTEND_VERSION = "0.7.2"
BACKEND_VERSION = "0.7.0" BACKEND_VERSION = "0.7.2"

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "miem-workers" name = "miem-workers"
version = "0.7.0" version = "0.7.2"
description = "MIEM employees parser, admin API, and MCP server" description = "MIEM employees parser, admin API, and MCP server"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -51,6 +51,45 @@ def test_employee_display_payload_extracts_common_fields(db_session):
assert payload["first_seen_display"] != "Не указано" 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),
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),
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"] == "Доктор технических наук"
def test_employee_detail_payload_normalizes_human_readable_sections(db_session): def test_employee_detail_payload_normalizes_human_readable_sections(db_session):
employee = Employee( employee = Employee(
profile_key="staff:person", profile_key="staff:person",

View File

@@ -23,10 +23,13 @@ def test_directory_template_is_russian_and_uses_display_dates():
assert "{% for value in [25, 50, 100] %}" in template assert "{% for value in [25, 50, 100] %}" in template
assert "Найдено:" in template assert "Найдено:" 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.news_count" in template
assert "employee.first_seen_display" in template assert "employee.first_seen_display" in template
assert "employee.last_seen_display" in template assert "employee.last_seen_display" in template
assert "employee.dismissed_display" in template assert "employee.dismissed_display" in template
assert "verification_required" in template
assert "Directory" not in template assert "Directory" not in template
assert "employees found" not in template assert "employees found" not in template
@@ -62,6 +65,7 @@ def test_run_detail_template_extends_base_and_shows_change_groups():
assert 'id="new-employees"' in template assert 'id="new-employees"' in template
assert "Новые сотрудники" in template assert "Новые сотрудники" in template
assert "Потеряшки" in template assert "Потеряшки" in template
assert "Требуют проверки" in template
assert "Уволенные" in template assert "Уволенные" in template
assert "Детализация сотрудников для этого запуска недоступна" in template assert "Детализация сотрудников для этого запуска недоступна" in template
@@ -71,11 +75,19 @@ def test_dashboard_metric_cards_link_to_admin_targets():
assert 'href="/admin/directory"' in template assert 'href="/admin/directory"' in template
assert 'href="/admin/directory?status=active"' 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 '/admin/runs/{{ latest_run.id }}#new-employees' in template
assert 'href="/admin/directory?status=dismissed"' in template assert 'href="/admin/directory?status=dismissed"' in template
assert 'href="/admin/runs"' in template assert 'href="/admin/runs"' in template
def test_dashboard_has_dismissed_status_refresh_action():
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")
assert 'action="/admin/dismissed/refresh"' in template
assert "Проверить уволенных" in template
def test_dashboard_latest_run_rows_link_to_run_detail(): def test_dashboard_latest_run_rows_link_to_run_detail():
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8") template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")

View File

@@ -20,7 +20,7 @@ def test_health_returns_versions():
response = client.get("/api/health") response = client.get("/api/health")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["backend_version"] == "0.7.0" assert response.json()["backend_version"] == "0.7.1"
def test_mcp_lists_tools_without_auth_and_ignores_auth_header(): def test_mcp_lists_tools_without_auth_and_ignores_auth_header():
@@ -154,7 +154,7 @@ def test_mcp_service_info_returns_tools_and_dataset_hash():
assert response.status_code == 200 assert response.status_code == 200
payload = json.loads(response.json()["result"]["content"][0]["text"]) payload = json.loads(response.json()["result"]["content"][0]["text"])
assert payload["service_name"] == "miem-employees" assert payload["service_name"] == "miem-employees"
assert payload["backend_version"] == "0.7.0" assert payload["backend_version"] == "0.7.1"
assert payload["dataset"]["hash"] assert payload["dataset"]["hash"]
assert any(tool["name"] == "sync_employees" for tool in payload["tools"]) assert any(tool["name"] == "sync_employees" for tool in payload["tools"])

View File

@@ -11,7 +11,8 @@ from app.models import (
EmployeeSnapshot, EmployeeSnapshot,
ParseResourceCache, ParseResourceCache,
) )
from app.services.crawler import _checksum, _mark_dismissed, _upsert_employee 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 from app.services.resource_cache import ResourceCache
@@ -56,6 +57,41 @@ class ConditionalSession:
return ConditionalResponse(200, "fresh", {"ETag": '"fresh"'}) return ConditionalResponse(200, "fresh", {"ETag": '"fresh"'})
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,
)
still_dismissed = Employee(
profile_key="staff:gone",
canonical_url="https://www.hse.ru/staff/gone",
status="dismissed",
dismissed_at=now,
first_seen_at=now,
last_seen_at=now,
)
db_session.add_all([found, still_dismissed])
db_session.commit()
monkeypatch.setattr(
"app.services.crawler.collect_profile_links",
lambda *_args, **_kwargs: ["https://www.hse.ru/staff/returned"],
)
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): def test_mark_dismissed_records_missing_source_when_profile_is_available(db_session):
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running") run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
db_session.add(run) db_session.add(run)
@@ -97,7 +133,7 @@ def test_mark_dismissed_records_missing_source_when_profile_is_available(db_sess
assert change.profile_available is True assert change.profile_available is True
def test_mark_dismissed_marks_missing_employee_when_profile_is_unavailable(db_session): def test_mark_dismissed_requires_consecutive_unavailable_checks(db_session):
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running") run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
employee = Employee( employee = Employee(
profile_key="staff:gone", profile_key="staff:gone",
@@ -109,22 +145,129 @@ def test_mark_dismissed_marks_missing_employee_when_profile_is_unavailable(db_se
db_session.add_all([run, employee]) db_session.add_all([run, employee])
db_session.commit() db_session.commit()
dismissed = _mark_dismissed( first_check = _mark_dismissed(
db_session, db_session,
run, run,
set(), set(),
FakeSession({"https://www.hse.ru/staff/gone": 404}), FakeSession({"https://www.hse.ru/staff/gone": 404}),
30, 30,
confirmation_runs=2,
) )
assert dismissed == 1 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.status == "dismissed"
assert employee.dismissed_at is not None assert employee.dismissed_at is not None
change = db_session.query(CrawlRunEmployeeChange).one() assert employee.profile_unavailable_streak == 2
change = db_session.query(CrawlRunEmployeeChange).order_by(CrawlRunEmployeeChange.id).all()[-1]
assert change.change_type == "dismissed" assert change.change_type == "dismissed"
assert change.profile_available is False 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): 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") run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
db_session.add(run) db_session.add(run)
@@ -152,6 +295,49 @@ def test_upsert_employee_increments_new_count_and_records_change_for_new_employe
assert change.full_name == "New Person" 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): def test_resource_cache_uses_etag_and_reuses_cached_body_on_304(db_session):
db_session.add( db_session.add(
ParseResourceCache( ParseResourceCache(