Подготовленные публикации и привязка к специальностям в XLSX #64
No files matched your search
@@ -0,0 +1,4 @@
|
||||
app/data/council_articles.csv text eol=lf
|
||||
app/data/council_articles.json text eol=lf
|
||||
app/data/k1_journals.json text eol=lf
|
||||
app/data/k1_specialties.json text eol=lf
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.4
|
||||
|
||||
- Исправлена аналогичная ошибка SHA-256 в справочнике специальностей К1: хеш исходного JSON рассчитывается с LF, оба генератора справочников записывают LF, формат закреплён в Git. Добавлена проверка одинаковой генерации из LF/CRLF; журналы и связи со специальностями не изменены.
|
||||
|
||||
## 0.15.3
|
||||
|
||||
- Исправлена воспроизводимость справочника публикаций на Windows и Linux: при подготовке CSV окончания CRLF нормализуются в LF, JSON записывается с LF, а SHA-256 совпадает с CSV из Git. Исходный CSV не перезаписывается; проверка воспроизводимости сохранена и дополнена тестом обоих форматов строк.
|
||||
|
||||
## 0.15.2
|
||||
|
||||
- Портал использует подготовленный читаемый JSON из 1797 публикаций и индекс 825 ФИО. CSV сохранён для проверки и импорта; преобразование выполняется отдельным атомарным скриптом, а готовые данные кэшируются на процесс. Номера строк, источники и правила сопоставления сохранены.
|
||||
- При подготовке кандидатов в диссертационный совет докторская степень проверяется до разбора публикаций сотрудника, исключая повторную обработку данных неподходящих сотрудников.
|
||||
|
||||
## 0.15.1
|
||||
|
||||
- В XLSX диссертационного совета добавлены отдельные колонки с кодами и названиями подтверждённых специальностей ВАК для сотрудников и публикаций, включая выгрузку без фильтра. При выборе специальности показаны число подходящих работ сотрудника и отметка соответствия каждой публикации.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
- На странице «Диссертационный совет» добавлен подбор докторов наук по 12 специальностям ВАК: учитывается хотя бы одна публикация за последние пять лет в релевантном журнале К1. Показаны основания соответствия и количество подходящих работ; поиск, пагинация и XLSX сохраняют выбранную специальность.
|
||||
|
||||
@@ -92,7 +92,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
Счётчики **Q1**, **Q2**, **К1** и **ВСЕГО** учитывают уникальные публикации за последние пять лет по году. Q1/Q2 берутся из отметок Scopus или Web of Science в CSV и сохранённом CSV-обогащении БД. К1 определяется по совпадению нормализованного названия журнала с итоговой категорией в предоставленном справочнике ВАК. ВСЕГО — объединение Q1 ИЛИ Q2 ИЛИ К1: работа в нескольких категориях учитывается один раз. При расхождении годов источников каждый критерий проверяется по году своей записи; неизвестный год наследуется только при единственном согласованном годе. Счётчики «В БД» и «Все публикации» включают все годы независимо от категорий.
|
||||
|
||||
CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Импорт CSV в БД не выдаётся за данные сайта. Для К1 поддерживаются необязательные колонки CSV «Название журнала» или «Журнал»; в исходном CSV их нет. В БД название берётся из поля `journal` в `raw_data` или короткого описания ВШЭ (`description.short`/`shortLeft`, название перед годом). Название самой публикации для определения журнала не используется. Неизвестный или несовпавший журнал не считается К1; перевод и нечёткое сопоставление не применяются. Явно указанное в PDF прежнее название журнала сохраняется как проверенный алиас; служебное примечание о переименовании хранится отдельно от названия.
|
||||
Подготовленный JSON `app/data/council_articles.json` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Импорт CSV в БД не выдаётся за данные сайта. Для К1 поддерживаются необязательные колонки CSV «Название журнала» или «Журнал»; в исходном CSV их нет. В БД название берётся из поля `journal` в `raw_data` или короткого описания ВШЭ (`description.short`/`shortLeft`, название перед годом). Название самой публикации для определения журнала не используется. Неизвестный или несовпавший журнал не считается К1; перевод и нечёткое сопоставление не применяются. Явно указанное в PDF прежнее название журнала сохраняется как проверенный алиас; служебное примечание о переименовании хранится отдельно от названия.
|
||||
|
||||
Справочник `app/data/k1_journals.json` содержит 773 журнала К1, номера строк, даты категорирования и SHA256 исходного «ВАК категории.pdf». При извлечении проверены все 3134 строки; для записей с историей смены категории взята последняя категория. PDF при запросах не читается. Для обновления справочника разово установите `pdfplumber` в среду разработки и выполните:
|
||||
|
||||
@@ -155,7 +155,7 @@ node --check app/static/admin.js
|
||||
|
||||
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
|
||||
|
||||
Версия сервиса: `0.15.0`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.15.4`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
@@ -173,6 +173,9 @@ node --check app/static/admin.js
|
||||
известным типом, отличным от статьи, не подтверждают специальность. Счётчики Q1/Q2/К1
|
||||
сохраняют прежний смысл; число работ по выбранной специальности указано внутри
|
||||
карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX.
|
||||
На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия
|
||||
подтверждённых специальностей, включая выгрузку без фильтра. При выбранном фильтре
|
||||
добавлены число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации.
|
||||
|
||||
`app/data/k1_specialties.json` содержит все 773 журнала из `k1_journals.json`, коды
|
||||
действующих специальностей, ISSN, страницы и строки исходного перечня, историю
|
||||
@@ -193,4 +196,34 @@ python scripts/extract_k1_specialties.py "app/data/ВАК Перечень.pdf"
|
||||
Скрипт проверяет последовательность всех 3231 строк. Проверенные различия названий
|
||||
сохранены в `scripts/k1_specialty_matches.json`; каждое сопоставление проверяется по
|
||||
номеру строки и точному исходному названию. Нечёткий поиск не применяется.
|
||||
Сервис читает готовый JSON и не требует PDF или `pdfplumber` при запуске.
|
||||
Сервис читает готовый JSON и не требует PDF или `pdfplumber` при запуске. Хеш
|
||||
`k1_source_sha256` рассчитывается для исходного JSON с окончаниями строк LF; оба
|
||||
JSON-справочника записываются с LF для воспроизводимости на Windows и Linux.
|
||||
|
||||
### Подготовка публикаций для портала
|
||||
|
||||
Портал читает `app/data/council_articles.json`: публикации представлены отдельными
|
||||
объектами с названием, годом, DOI, массивами авторов, квартилей и журналов.
|
||||
Дополнительные поля исходного экспорта сохранены в `metadata`; `source_row` связывает
|
||||
запись со строкой исходного CSV. `by_author` — готовый индекс нормализованных ФИО
|
||||
со ссылками на публикации, без копирования одной работы для каждого соавтора.
|
||||
|
||||
Исходный `app/data/council_articles.csv` сохранён для проверки и отдельного импорта
|
||||
в БД. Данные файла и индекс кэшируются на процесс, CSV не преобразуется при
|
||||
HTTP-запросах. Название источника «Файл CSV» и номера строк в интерфейсе и XLSX
|
||||
сохранены как происхождение данных. Перед разбором публикаций сотрудников из БД
|
||||
проверяется докторская степень; публикации остальных сотрудников не разбираются.
|
||||
|
||||
После обновления CSV пересоздайте JSON до сборки/развёртывания сервиса:
|
||||
|
||||
```sh
|
||||
python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
```
|
||||
|
||||
Нужна только стандартная библиотека Python. Скрипт проверяет структуру CSV и индекс,
|
||||
сохраняет число записей и SHA-256 источника с окончаниями строк LF и заменяет JSON
|
||||
атомарно после проверки. CRLF нормализуются в LF только при чтении; исходный CSV
|
||||
не перезаписывается. JSON и хеш одинаковы на Windows и Linux; Git фиксирует эти
|
||||
файлы с LF.
|
||||
После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления
|
||||
публикаций из БД продолжают отображаться без перезапуска.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,8 @@
|
||||
"source": "ВАК Перечень.pdf",
|
||||
"source_sha256": "d16ab69668fb4c4cab80eb75809928cca63b447df5ee246e4f9d56cae4d3273d",
|
||||
"as_of": "2026-09-15",
|
||||
"k1_source_sha256": "ae6c18ae283d89b7703264bd0047f0466207fd535b59c8c50288f0315c546392",
|
||||
"k1_source_sha256": "f0f4514b132e486e821ae64c92cc23dccc55b641d590946de53bc7199e6f4caa",
|
||||
"k1_source_hash_eol": "LF",
|
||||
"source_rows": 3231,
|
||||
"specialties": [
|
||||
{
|
||||
|
||||
+6
-53
@@ -1,10 +1,8 @@
|
||||
"""Public publication evidence from the council export and employee database."""
|
||||
|
||||
import calendar
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
@@ -18,27 +16,18 @@ from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import Employee
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
from app.services.council_source import _doi, _journal_names, _name, _quartiles, load_reference
|
||||
|
||||
|
||||
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.csv"
|
||||
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.json"
|
||||
K1_JOURNALS = EXPORT.with_name("k1_journals.json")
|
||||
K1_SPECIALTIES = EXPORT.with_name("k1_specialties.json")
|
||||
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
|
||||
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
|
||||
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
|
||||
"BOOK_CHAPTER": "Глава книги", "REPORT": "Доклад", "PATENT": "Патент",
|
||||
"PREPRINT": "Препринт", "CONFERENCE_PAPER": "Материал конференции"}
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
return " ".join(unicodedata.normalize("NFKC", value).casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _doi(value: str) -> str:
|
||||
value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value.strip(), flags=re.I).lower()
|
||||
return value if DOI.fullmatch(value) else ""
|
||||
|
||||
|
||||
def _safe_url(value: str | None) -> str | None:
|
||||
try:
|
||||
url = urlsplit(value or "")
|
||||
@@ -54,12 +43,6 @@ def period(today: date) -> tuple[date, date]:
|
||||
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
|
||||
|
||||
|
||||
def _quartiles(row: dict) -> list[str]:
|
||||
return [f"{label} {str(row.get(field) or '').strip().upper()}"
|
||||
for label, field in (("Scopus", "Квартиль scopus"), ("Web of Science", "Квартиль wos"))
|
||||
if str(row.get(field) or '').strip().upper() in {"Q1", "Q2"}]
|
||||
|
||||
|
||||
def _journal_key(value: str) -> str:
|
||||
return " ".join(re.findall(r"\w+", _name(value)))
|
||||
|
||||
@@ -83,17 +66,6 @@ def _k1_specialties() -> dict:
|
||||
return {**data, "by_journal": {j["name"]: j["specialty_codes"] for j in data["journals"]}}
|
||||
|
||||
|
||||
def _journal_names(data: dict) -> list[str]:
|
||||
names = []
|
||||
for field in ("Название журнала", "Журнал", "journal"):
|
||||
value = data.get(field)
|
||||
if isinstance(value, dict):
|
||||
value = value.get("name")
|
||||
if isinstance(value, str) and value.strip():
|
||||
names.append(value.strip())
|
||||
return names
|
||||
|
||||
|
||||
def _description_journals(description: dict) -> list[str]:
|
||||
names = []
|
||||
for field in ("short", "shortLeft"):
|
||||
@@ -109,26 +81,7 @@ def _description_journals(description: dict) -> list[str]:
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _articles_by_author() -> dict[str, list[dict]]:
|
||||
articles = defaultdict(list)
|
||||
with EXPORT.open(encoding="utf-8-sig", newline="") as source:
|
||||
reader = csv.DictReader(source, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"}
|
||||
if not required <= set(reader.fieldnames or []):
|
||||
raise ValueError("Missing council CSV headers")
|
||||
for row_number, row in enumerate(reader, 2):
|
||||
if None in row or any(value is None for value in row.values()):
|
||||
raise ValueError("Malformed council CSV record")
|
||||
title = row["Название публикации"].strip()
|
||||
if not title:
|
||||
continue
|
||||
year = int(row["Год"]) if row["Год"].strip().isdigit() else None
|
||||
article = {"title": title, "year": year, "doi": _doi(row["DOI"]),
|
||||
"types": [], "quartiles": _quartiles(row), "journals": _journal_names(row), "origins": ["Файл CSV"],
|
||||
"rows": [row_number], "in_csv": True, "in_db": False, "url": None}
|
||||
for author in set(_name(value) for value in row["Авторы НИУ ВШЭ"].split(";")) - {""}:
|
||||
articles[author].append(article)
|
||||
return articles
|
||||
|
||||
return load_reference(EXPORT)
|
||||
|
||||
def _database_article(publication) -> dict:
|
||||
raw = publication.raw_data if isinstance(publication.raw_data, dict) else {}
|
||||
@@ -245,9 +198,6 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
author = _name(employee.full_name or "")
|
||||
ambiguous = bool(author and name_counts[author] > 1 and by_author.get(author))
|
||||
csv_articles = by_author.get(author, []) if author and name_counts[author] == 1 else []
|
||||
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
|
||||
if not articles:
|
||||
continue
|
||||
data = employee.current_data if isinstance(employee.current_data, dict) else {}
|
||||
try:
|
||||
degrees = academic_degrees(data)
|
||||
@@ -255,6 +205,9 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
degrees = []
|
||||
if not any(DOCTOR.fullmatch(degree) for degree in degrees):
|
||||
continue
|
||||
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
|
||||
if not articles:
|
||||
continue
|
||||
specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0
|
||||
for article in articles:
|
||||
article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"])
|
||||
|
||||
@@ -22,21 +22,28 @@ def council_xlsx(result: dict) -> bytes:
|
||||
employees.title = "Сотрудники"
|
||||
publications = workbook.create_sheet("Публикации")
|
||||
parameters = workbook.create_sheet("Параметры")
|
||||
specialty_labels = {item["code"]: f"{item['code']}. — {item['name']}" for item in result["specialties"]}
|
||||
def specialties_text(codes):
|
||||
return "\n".join(label for code, label in specialty_labels.items() if code in codes) or "Не подтверждены"
|
||||
|
||||
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание"])
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"])
|
||||
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
|
||||
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV"])
|
||||
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV", "Специальности ВАК", "По выбранной специальности"])
|
||||
for number, candidate in enumerate(result["candidates"], 1):
|
||||
note = "Совпадение ФИО: публикации CSV не присвоены автоматически" if candidate["ambiguous"] else ""
|
||||
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
|
||||
_append(employees, [number, candidate["name"], candidate["degree"], candidate["q1_count"], candidate["q2_count"],
|
||||
candidate["k1_count"], candidate["total_count"],
|
||||
candidate["db_count"], candidate["count"],
|
||||
candidate["profile_url"], note])
|
||||
candidate["profile_url"], note, specialties_text(specialty_codes),
|
||||
candidate["specialty_count"] if result["specialty"] else None])
|
||||
for article in candidate["articles"]:
|
||||
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
|
||||
"; ".join(article["origins"]), "; ".join(article["criteria"]),
|
||||
*["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "qualifies", "in_db")],
|
||||
article["doi_url"], article["url"], ", ".join(map(str, article["rows"]))])
|
||||
article["doi_url"], article["url"], ", ".join(map(str, article["rows"])), specialties_text(article["specialty_codes"]),
|
||||
("Да" if article["matches_specialty"] else "Нет") if result["specialty"] else None])
|
||||
_append(parameters, ["Параметр", "Значение"])
|
||||
for values in [
|
||||
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
|
||||
@@ -52,13 +59,15 @@ def council_xlsx(result: dict) -> bytes:
|
||||
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
|
||||
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
|
||||
"Без названия журнала К1 не подтверждён."],
|
||||
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
|
||||
["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
|
||||
["ВСЕГО", "Уникальные публикации, входящие в Q1 ИЛИ Q2 ИЛИ К1; пересечения учитываются один раз."],
|
||||
["Страница ВШЭ / Все публикации", "Все годы и категории; общий перечень объединяет CSV и БД без дублей. "
|
||||
"Страница ВШЭ учитывает сохранённые публикации, включая импортированные из CSV; источник указан на листе Публикации."],
|
||||
]:
|
||||
_append(parameters, values)
|
||||
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55]),
|
||||
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22]),
|
||||
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28]),
|
||||
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28]),
|
||||
(parameters, [32, 95])]:
|
||||
sheet.freeze_panes = "A2"
|
||||
sheet.auto_filter.ref = sheet.dimensions
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Prepared council publication data and shared source normalization."""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from io import StringIO
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
return " ".join(unicodedata.normalize("NFKC", value).casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _doi(value: str) -> str:
|
||||
value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value.strip(), flags=re.I).lower()
|
||||
return value if DOI.fullmatch(value) else ""
|
||||
|
||||
|
||||
def _quartiles(row: dict) -> list[str]:
|
||||
return [f"{label} {str(row.get(field) or '').strip().upper()}"
|
||||
for label, field in (("Scopus", "Квартиль scopus"), ("Web of Science", "Квартиль wos"))
|
||||
if str(row.get(field) or '').strip().upper() in {"Q1", "Q2"}]
|
||||
|
||||
|
||||
def _journal_names(data: dict) -> list[str]:
|
||||
names = []
|
||||
for field in ("Название журнала", "Журнал", "journal"):
|
||||
value = data.get(field)
|
||||
if isinstance(value, dict):
|
||||
value = value.get("name")
|
||||
if isinstance(value, str) and value.strip():
|
||||
names.append(value.strip())
|
||||
return names
|
||||
|
||||
|
||||
def prepare_reference(source: Path) -> dict:
|
||||
publications = []
|
||||
by_author = defaultdict(list)
|
||||
# Git stores this text export with LF; canonicalize Windows CRLF before
|
||||
# parsing and hashing so the prepared artifact is identical on both hosts.
|
||||
source_bytes = source.read_bytes().replace(b"\r\n", b"\n")
|
||||
with StringIO(source_bytes.decode("utf-8-sig"), newline="") as stream:
|
||||
reader = csv.DictReader(stream, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"}
|
||||
if not required <= set(reader.fieldnames or []):
|
||||
raise ValueError("Missing council CSV headers")
|
||||
if len(reader.fieldnames) != len(set(reader.fieldnames)):
|
||||
raise ValueError("Duplicate council CSV headers")
|
||||
for row_number, row in enumerate(reader, 2):
|
||||
if None in row or any(value is None for value in row.values()):
|
||||
raise ValueError("Malformed council CSV record")
|
||||
authors = list(dict.fromkeys(value.strip() for value in row["Авторы НИУ ВШЭ"].split(";") if value.strip()))
|
||||
record = {"source_row": row_number, "title": row["Название публикации"].strip(),
|
||||
"year": int(row["Год"]) if row["Год"].strip().isdigit() else None,
|
||||
"doi": _doi(row["DOI"]), "authors": authors, "quartiles": _quartiles(row),
|
||||
"journals": _journal_names(row),
|
||||
"metadata": {key: value for key, value in row.items()
|
||||
if key not in {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ"}}}
|
||||
index = len(publications)
|
||||
publications.append(record)
|
||||
if record["title"]:
|
||||
for author in sorted({_name(value) for value in authors} - {""}):
|
||||
by_author[author].append(index)
|
||||
return {"schema_version": 1, "source_file": source.name,
|
||||
"source_sha256": hashlib.sha256(source_bytes).hexdigest(), "source_hash_eol": "LF",
|
||||
"source_rows": len(publications), "publications": publications, "by_author": dict(by_author)}
|
||||
|
||||
|
||||
def load_reference(path: Path) -> dict[str, list[dict]]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or type(data.get("schema_version")) is not int or data.get("schema_version") != 1:
|
||||
raise ValueError("Unsupported council reference schema")
|
||||
publications = data.get("publications")
|
||||
by_author = data.get("by_author")
|
||||
if not isinstance(publications, list) or not isinstance(by_author, dict) or data.get("source_rows") != len(publications):
|
||||
raise ValueError("Invalid council reference records")
|
||||
articles = []
|
||||
expected = defaultdict(list)
|
||||
for index, record in enumerate(publications):
|
||||
if not isinstance(record, dict) or not isinstance(record.get("title"), str) or not isinstance(record.get("doi"), str):
|
||||
raise ValueError("Invalid council publication text")
|
||||
if record["doi"] != _doi(record["doi"]) or (record.get("year") is not None and type(record["year"]) is not int):
|
||||
raise ValueError("Invalid council publication DOI/year")
|
||||
if record.get("source_row") != index + 2:
|
||||
raise ValueError("Invalid council source row")
|
||||
for field in ("authors", "quartiles", "journals"):
|
||||
if not isinstance(record.get(field), list) or not all(isinstance(value, str) for value in record[field]):
|
||||
raise ValueError("Invalid council publication list")
|
||||
if not set(record["quartiles"]) <= {"Scopus Q1", "Scopus Q2", "Web of Science Q1", "Web of Science Q2"}:
|
||||
raise ValueError("Invalid council quartiles")
|
||||
if record["title"]:
|
||||
for author in sorted({_name(value) for value in record["authors"]} - {""}):
|
||||
expected[author].append(index)
|
||||
articles.append({"title": record["title"], "year": record["year"], "doi": record["doi"],
|
||||
"types": [], "quartiles": record["quartiles"], "journals": record["journals"],
|
||||
"origins": ["Файл CSV"], "rows": [record["source_row"]],
|
||||
"in_csv": True, "in_db": False, "url": None})
|
||||
if by_author != dict(expected) or any(type(i) is not int for indexes in by_author.values() for i in indexes):
|
||||
raise ValueError("Council author index disagrees with publication authors")
|
||||
return {author: [articles[index] for index in indexes] for author, indexes in by_author.items()}
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.15.0"
|
||||
FRONTEND_VERSION = "0.15.0"
|
||||
BACKEND_VERSION = "0.15.0"
|
||||
APP_VERSION = "0.15.4"
|
||||
FRONTEND_VERSION = "0.15.4"
|
||||
BACKEND_VERSION = "0.15.4"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.15.0"
|
||||
version = "0.15.4"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -54,5 +54,5 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/k1_journals.json"))
|
||||
args = parser.parse_args()
|
||||
result = extract(args.source)
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
print(f"Saved {len(result['journals'])} K1 journals; verified {result['source_rows']} source rows")
|
||||
@@ -169,8 +169,8 @@ if __name__ == "__main__":
|
||||
entries = build_reference(journals, k1, json.loads(args.matches.read_text(encoding="utf-8")), args.as_of)
|
||||
result = {"schema_version": 1, "source": args.source.name,
|
||||
"source_sha256": hashlib.sha256(args.source.read_bytes()).hexdigest(), "as_of": args.as_of,
|
||||
"k1_source_sha256": hashlib.sha256(args.k1.read_bytes()).hexdigest(), "source_rows": len(journals),
|
||||
"k1_source_sha256": hashlib.sha256(args.k1.read_bytes().replace(b"\r\n", b"\n")).hexdigest(), "k1_source_hash_eol": "LF", "source_rows": len(journals),
|
||||
"specialties": [{"code": code, "name": name} for code, name in SPECIALTIES], "journals": entries}
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
from collections import Counter
|
||||
print(len(entries), dict(Counter(j['status'] for j in entries)))
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Prepare a readable, indexed council JSON from the owner-supplied CSV.
|
||||
|
||||
python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
No database access or app startup; only Python's standard library is required.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.services.council_source import load_reference, prepare_reference
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/council_articles.json"))
|
||||
args = parser.parse_args()
|
||||
if args.source.resolve() == args.output.resolve():
|
||||
parser.error("Output must not overwrite the source CSV")
|
||||
result = prepare_reference(args.source)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
|
||||
with NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=args.output.parent,
|
||||
prefix=args.output.name + ".", suffix=".tmp", delete=False) as stream:
|
||||
stream.write(text)
|
||||
temporary = Path(stream.name)
|
||||
try:
|
||||
load_reference(temporary)
|
||||
temporary.replace(args.output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
print(f"Prepared {result['source_rows']} publications and {len(result['by_author'])} author names")
|
||||
@@ -157,6 +157,10 @@ def main():
|
||||
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert filtered["Сотрудники"].max_row == 2
|
||||
assert dict(filtered["Параметры"].values)["Специальность ВАК"].startswith("1.2.2.")
|
||||
headers = {cell.value: cell.column for cell in filtered["Сотрудники"][1]}
|
||||
assert filtered["Сотрудники"].cell(2, headers["Специальности ВАК"]).value.startswith("1.2.2.")
|
||||
assert filtered["Сотрудники"].cell(2, headers["Работ по выбранной специальности"]).value == 1
|
||||
pending_download.value.save_as(str(output / "specialty-export.xlsx"))
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.15.0"
|
||||
assert response.json()["backend_version"] == "0.15.4"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
+55
-5
@@ -19,6 +19,7 @@ from app.services.council_export import _append
|
||||
@pytest.fixture()
|
||||
def export(monkeypatch, tmp_path):
|
||||
path = tmp_path / "articles.csv"
|
||||
prepared = tmp_path / "articles.json"
|
||||
def write(rows):
|
||||
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)", "Название журнала",
|
||||
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
|
||||
@@ -26,8 +27,11 @@ def export(monkeypatch, tmp_path):
|
||||
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
from app.services.council_source import prepare_reference
|
||||
import json
|
||||
prepared.write_text(json.dumps(prepare_reference(path), ensure_ascii=False), encoding="utf-8")
|
||||
council._articles_by_author.cache_clear()
|
||||
monkeypatch.setattr(council, "EXPORT", path)
|
||||
monkeypatch.setattr(council, "EXPORT", prepared)
|
||||
write([])
|
||||
yield write
|
||||
council._articles_by_author.cache_clear()
|
||||
@@ -211,7 +215,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
assert "<script>" in response.text and "<script>" not in response.text
|
||||
assert "javascript:" not in response.text and "private@example.org" not in response.text
|
||||
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
|
||||
assert "Backend 0.15.0 · Frontend 0.15.0" in response.text
|
||||
assert "Backend 0.15.4 · Frontend 0.15.4" in response.text
|
||||
assert "Вход в админку" in response.text
|
||||
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
|
||||
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
|
||||
@@ -256,11 +260,11 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
|
||||
people, works = book["Сотрудники"], book["Публикации"]
|
||||
assert [cell.value for cell in people[1]] == [
|
||||
"№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание"
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"
|
||||
]
|
||||
assert people["J2"].value == "https://www.hse.ru/staff/test"
|
||||
assert people["K2"].value is None
|
||||
assert people.max_column == 11
|
||||
assert people.max_column == 13
|
||||
assert works["L1"].value == "Страница ВШЭ"
|
||||
assert people.max_row == 29 and works.max_row == 29
|
||||
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
|
||||
@@ -515,7 +519,7 @@ def test_specialty_reference_integrity():
|
||||
from scripts.extract_k1_specialties import active_codes
|
||||
data = json.loads(council.K1_SPECIALTIES.read_text(encoding="utf-8"))
|
||||
k1 = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
assert data["k1_source_sha256"] == hashlib.sha256(council.K1_JOURNALS.read_bytes()).hexdigest()
|
||||
assert data["k1_source_sha256"] == hashlib.sha256(council.K1_JOURNALS.read_bytes().replace(b"\r\n", b"\n")).hexdigest()
|
||||
assert [j["name"] for j in data["journals"]] == [j["name"] for j in k1["journals"]]
|
||||
assert len(data["journals"]) == 773 and len(data["specialties"]) == 12
|
||||
assert data["source_rows"] == 3231
|
||||
@@ -529,3 +533,49 @@ def test_specialty_reference_integrity():
|
||||
assert journal["status"] == "ambiguous" and not journal["specialty_codes"]
|
||||
for specialty in data["specialties"]:
|
||||
assert any(specialty["code"] in j["specialty_codes"] for j in data["journals"])
|
||||
|
||||
|
||||
def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, export):
|
||||
export([row(title="Old journal", doi="10.1234/conflict", year="2020", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Old journal", doi="10.1234/conflict", year="2024")])
|
||||
person = employee(db_session)
|
||||
for title in ['Matching', 'Second matching']:
|
||||
publication(db_session, person, title=title, kind='ARTICLE', raw_data={'journal': 'Бизнес-информатика'})
|
||||
publication(db_session, person, title='Book', kind='BOOK', raw_data={'journal': 'Бизнес-информатика'})
|
||||
publication(db_session, person, title='Unknown journal', kind='ARTICLE', raw_data={'journal': 'Other'})
|
||||
other = employee(db_session, name='Без соответствия', key='other')
|
||||
publication(db_session, other, title='Unknown', kind='ARTICLE')
|
||||
db_session.commit()
|
||||
from app.services.council_export import council_xlsx
|
||||
for specialty in ('', '1.2.2'):
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), specialty=specialty, paginate=False)
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
staff = list(book['Сотрудники'].values)
|
||||
works = list(book['Публикации'].values)
|
||||
candidate = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == person.full_name)
|
||||
assert candidate['Специальности ВАК'].count('1.2.2.') == 1
|
||||
assert 'Математическое моделирование' in candidate['Специальности ВАК']
|
||||
assert '2.3.5.' in candidate['Специальности ВАК']
|
||||
assert '5.2.2.' not in candidate['Специальности ВАК'] # Only the site's twelve specialties.
|
||||
assert candidate['Работ по выбранной специальности'] == (2 if specialty else None)
|
||||
records = {values[2]: dict(zip(works[0], values)) for values in works[1:]}
|
||||
assert '1.2.2.' in records['Matching']['Специальности ВАК']
|
||||
assert records['Matching']['По выбранной специальности'] == ('Да' if specialty else None)
|
||||
for title in ('Book', 'Unknown journal', 'Old journal'):
|
||||
assert records[title]['Специальности ВАК'] == 'Не подтверждены'
|
||||
assert records[title]['По выбранной специальности'] == ('Нет' if specialty else None)
|
||||
if not specialty:
|
||||
unmatched = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == other.full_name)
|
||||
assert unmatched['Специальности ВАК'] == 'Не подтверждены'
|
||||
assert book['Сотрудники'].auto_filter.ref.endswith(f'M{len(staff)}')
|
||||
assert book['Публикации'].auto_filter.ref.endswith(f'Q{len(works)}')
|
||||
|
||||
|
||||
def test_non_doctor_publications_are_not_parsed(db_session, export, monkeypatch):
|
||||
person = employee(db_session, degree='Кандидат технических наук')
|
||||
publication(db_session, person, kind='ARTICLE', description={'short': {'ru': '<b>Journal</b>. 2024.'}})
|
||||
db_session.commit()
|
||||
def unexpected(publication):
|
||||
pytest.fail('Ineligible employee publications must not be parsed')
|
||||
monkeypatch.setattr(council, '_database_article', unexpected)
|
||||
assert council.council_candidates(db_session)['total'] == 0
|
||||
@@ -0,0 +1,124 @@
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import council
|
||||
from app.services.council_source import load_reference, prepare_reference
|
||||
|
||||
|
||||
def write_csv(path, rows):
|
||||
fields = ['Название публикации', 'Год', 'DOI', 'Авторы НИУ ВШЭ', 'Квартиль scopus', 'Квартиль wos', 'Название журнала', 'EID (Scopus)']
|
||||
with path.open('w', encoding='utf-8-sig', newline='') as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=fields, delimiter=';')
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def test_prepared_reference_preserves_quoted_fields_metadata_and_matching(tmp_path):
|
||||
source = tmp_path / 'source.csv'
|
||||
write_csv(source, [
|
||||
{'Название публикации': 'Quoted; title\ncontinuation', 'Год': '2024', 'DOI': 'https://doi.org/10.1234/TEST',
|
||||
'Авторы НИУ ВШЭ': ' Ёлкин Пётр; ЕЛКИН ПЕТР; Другой автор ', 'Квартиль scopus': ' q1 ',
|
||||
'Квартиль wos': 'Q2', 'Название журнала': 'Бизнес-информатика', 'EID (Scopus)': 'source-id'},
|
||||
{'Название публикации': '', 'Авторы НИУ ВШЭ': 'Другой автор'},
|
||||
{'Название публикации': 'Unknown', 'Год': '', 'DOI': 'invalid', 'Авторы НИУ ВШЭ': ''},
|
||||
])
|
||||
before = source.read_bytes()
|
||||
data = prepare_reference(source)
|
||||
assert source.read_bytes() == before
|
||||
assert data['source_rows'] == 3
|
||||
assert data['source_sha256'] == hashlib.sha256(before.replace(b'\r\n', b'\n')).hexdigest()
|
||||
assert data['by_author'] == {'другой автор': [0], 'елкин петр': [0]}
|
||||
record = data['publications'][0]
|
||||
assert record['title'] == 'Quoted; title\ncontinuation'
|
||||
assert record['doi'] == '10.1234/test' and record['year'] == 2024
|
||||
assert record['quartiles'] == ['Scopus Q1', 'Web of Science Q2']
|
||||
assert record['journals'] == ['Бизнес-информатика']
|
||||
assert record['metadata']['EID (Scopus)'] == 'source-id'
|
||||
output = tmp_path / 'data.json'
|
||||
output.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8')
|
||||
articles = load_reference(output)
|
||||
assert articles['елкин петр'][0]['rows'] == [2]
|
||||
assert articles['другой автор'][0]['title'] == record['title']
|
||||
assert len(articles['елкин петр']) == 1
|
||||
assert articles['елкин петр'][0]['origins'] == ['Файл CSV']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['author', 'index', 'bool_index', 'year', 'schema', 'source_row', 'list'])
|
||||
def test_corrupt_prepared_reference_is_rejected(tmp_path, change):
|
||||
source = tmp_path / 'source.csv'
|
||||
write_csv(source, [{'Название публикации': 'First', 'Авторы НИУ ВШЭ': 'Author'},
|
||||
{'Название публикации': 'Second', 'Авторы НИУ ВШЭ': 'Other'}])
|
||||
data = prepare_reference(source)
|
||||
if change == 'author': data['by_author'] = {'stranger': [0], 'other': [1]}
|
||||
if change == 'index': data['by_author']['author'] = [3]
|
||||
if change == 'bool_index': data['by_author']['other'] = [True]
|
||||
if change == 'year': data['publications'][0]['year'] = True
|
||||
if change == 'schema': data['schema_version'] = True
|
||||
if change == 'source_row': data['publications'][0]['source_row'] = 77
|
||||
if change == 'list': data['publications'][0]['journals'] = 'Journal'
|
||||
path = tmp_path / 'data.json'
|
||||
path.write_text(json.dumps(data), encoding='utf8')
|
||||
with pytest.raises(ValueError): load_reference(path)
|
||||
|
||||
|
||||
def test_committed_reference_is_reproducible_and_loaded_once(monkeypatch):
|
||||
source = council.EXPORT.with_suffix('.csv')
|
||||
data = json.loads(council.EXPORT.read_text(encoding='utf-8'))
|
||||
assert data == prepare_reference(source)
|
||||
assert data['source_rows'] == 1797 and len(data['by_author']) == 825
|
||||
calls = []
|
||||
original = council.load_reference
|
||||
def counted(path):
|
||||
calls.append(path)
|
||||
return original(path)
|
||||
monkeypatch.setattr(council, 'load_reference', counted)
|
||||
council._articles_by_author.cache_clear()
|
||||
try:
|
||||
first = council._articles_by_author()
|
||||
assert council._articles_by_author() is first
|
||||
assert calls == [council.EXPORT]
|
||||
finally:
|
||||
council._articles_by_author.cache_clear()
|
||||
|
||||
|
||||
def test_converter_does_not_replace_output_when_input_is_invalid(tmp_path):
|
||||
import subprocess
|
||||
import sys
|
||||
source = tmp_path / 'invalid.csv'
|
||||
source.write_text('Wrong header\nvalue\n', encoding='utf8')
|
||||
output = tmp_path / 'prepared.json'
|
||||
output.write_text('previous reference', encoding='utf8')
|
||||
result = subprocess.run([sys.executable, 'scripts/prepare_council_articles.py', str(source), '--output', str(output)],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode != 0
|
||||
assert output.read_text(encoding='utf8') == 'previous reference'
|
||||
assert not list(tmp_path.glob('*.tmp'))
|
||||
same = subprocess.run([sys.executable, 'scripts/prepare_council_articles.py', str(source), '--output', str(source)],
|
||||
capture_output=True, text=True)
|
||||
assert same.returncode != 0 and source.read_text(encoding='utf8') == 'Wrong header\nvalue\n'
|
||||
|
||||
|
||||
def test_preparation_is_identical_for_lf_and_crlf_sources(tmp_path):
|
||||
lf_dir = tmp_path / 'lf'
|
||||
crlf_dir = tmp_path / 'crlf'
|
||||
lf_dir.mkdir()
|
||||
crlf_dir.mkdir()
|
||||
lf = lf_dir / 'source.csv'
|
||||
crlf = crlf_dir / 'source.csv'
|
||||
write_csv(crlf, [{'Название публикации': 'Multiline\npublication', 'Год': '2024',
|
||||
'Авторы НИУ ВШЭ': 'Author; Coauthor', 'Квартиль scopus': 'Q1'}])
|
||||
canonical = crlf.read_bytes().replace(b'\r\n', b'\n')
|
||||
lf.write_bytes(canonical)
|
||||
crlf.write_bytes(canonical.replace(b'\n', b'\r\n'))
|
||||
before = crlf.read_bytes()
|
||||
from_lf = prepare_reference(lf)
|
||||
from_crlf = prepare_reference(crlf)
|
||||
assert from_lf == from_crlf
|
||||
assert from_crlf['source_sha256'] == hashlib.sha256(canonical).hexdigest()
|
||||
assert from_crlf['source_hash_eol'] == 'LF'
|
||||
assert from_crlf['publications'][0]['title'] == 'Multiline\npublication'
|
||||
assert crlf.read_bytes() == before
|
||||
@@ -36,3 +36,34 @@ def test_malformed_source_start_date_is_preserved_without_inventing_date():
|
||||
assert assignment["source_dates"] == "с 01.02.200"
|
||||
assert assignment["valid_from"] is None and assignment["date_note"]
|
||||
assert active_codes(journal, "2026-09-15") == ["1.3.8"]
|
||||
|
||||
|
||||
def test_k1_extraction_is_reproducible_for_lf_and_crlf(tmp_path):
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
source = tmp_path / 'list.pdf'
|
||||
source.write_bytes(b'PDF source bytes for cached-row extraction')
|
||||
rows = tmp_path / 'rows.json'
|
||||
rows.write_text(json.dumps([{'page': 1, 'cells': ['1.', 'Journal', '1234-5678', '1.2.2. Test', 'с 01.02.2022']}]), encoding='utf8')
|
||||
matches = tmp_path / 'matches.json'
|
||||
matches.write_text('[]', encoding='utf8')
|
||||
canonical = (json.dumps({'journals': [{'name': 'Journal'}]}, indent=2) + '\n').encode('utf8')
|
||||
outputs = []
|
||||
for name, content in [('lf', canonical), ('crlf', canonical.replace(b'\n', b'\r\n'))]:
|
||||
directory = tmp_path / name
|
||||
directory.mkdir()
|
||||
k1 = directory / 'k1.json'
|
||||
k1.write_bytes(content)
|
||||
output = directory / 'specialties.json'
|
||||
subprocess.run([sys.executable, 'scripts/extract_k1_specialties.py', str(source), '--k1', str(k1),
|
||||
'--matches', str(matches), '--rows-cache', str(rows), '--output', str(output)], check=True, capture_output=True)
|
||||
data = json.loads(output.read_text(encoding='utf8'))
|
||||
assert data['k1_source_sha256'] == hashlib.sha256(canonical).hexdigest()
|
||||
assert data['source_sha256'] == hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
assert data['journals'][0]['specialty_codes'] == ['1.2.2']
|
||||
assert k1.read_bytes() == content
|
||||
assert b'\r\n' not in output.read_bytes()
|
||||
outputs.append(output.read_bytes())
|
||||
assert outputs[0] == outputs[1]
|
||||
Reference in new issue
Block a user