Парсер: замечания отдельной строкой — уровня всей ячейки

В ячейке с несколькими занятиями (курсы по выбору) красная заметка,
занимающая строку(и) целиком, относится ко всем занятиям ячейки
(пара отменяется целиком) и раздаётся каждому занятию. Заметка,
написанная в одной строке с предметом/аудиторией, остаётся
привязанной к своему занятию. Многострочные standalone-заметки
сохраняют переводы строк.

Кейс: ПИЭ-13МО, понедельник, 5-я пара. Diff по 33 группам: затронуто
только поле note в multi-lesson ячейках; порядок «inline->ячейка».
This commit is contained in:
2026-09-14 01:37:53 +03:00
parent e61ff1d683
commit 419e65e960
2 changed files with 100 additions and 16 deletions

View File

@@ -4,8 +4,10 @@
необязательное: маркер, время, локация, примечания пишутся как придумается.
Поэтому разбор не ветвится по количеству строк, а проходит один конвейер:
1. красные фрагменты таблицы (⟪...⟫) выносятся в заметки и удаляются из текста —
они служат только примечаниями, даже если внутри «вложено» другое занятие;
1. красные фрагменты таблицы выносятся в примечания: заметка, занимающая
строку(и) целиком, относится ко всей ячейке (несколько курсов «по выбору»
в паре отменяются вместе) и раздаётся всем занятиям ячейки; заметка
в одной строке с предметом/аудиторией остаётся привязанной к занятию;
2. извлекается маркер «к.в.» (своя строка или префикс первой строки);
3. каждая строка классифицируется по ролям:
- аудитория — есть «ауд.»/«каб.», URL, «онлайн», «зал...» (вместе с названием
@@ -355,17 +357,56 @@ def _extract_marker(lines: list[str]) -> tuple[str, list[str]]:
return "", lines
def _red_fragments(text: str) -> tuple[list[str], str]:
"""Красные фрагменты -> примечания; текст остаётся на месте (пробелом)."""
frag_re = re.escape(RED_OPEN) + r"(.*?)" + re.escape(RED_CLOSE)
frags = []
for m in re.finditer(frag_re, text, re.S):
frag = _squish(m.group(1))
def _unwrap_note(frag: str) -> str:
if frag.startswith("(") and frag.endswith(")"):
frag = frag[1:-1].strip()
return frag
def _squish_note(text: str) -> str:
"""Как _squish, но переводы строк сохраняем: они несут смысл в заметках."""
text = re.sub(r"[ \t]+\n", "\n", text)
return re.sub(r"[ \t]{2,}", " ", text).strip()
_SPAN_RE = re.escape(RED_OPEN) + r"(.*?)" + re.escape(RED_CLOSE)
def _red_fragments(text: str) -> tuple[list[str], str]:
"""Красные фрагменты -> примечания; на их месте остаётся пробел."""
frags = []
for m in re.finditer(_SPAN_RE, text, re.S):
frag = _squish_note(m.group(1))
if frag:
frags.append(frag)
return frags, re.sub(frag_re, " ", text, flags=re.S)
frags.append(_unwrap_note(frag))
return frags, re.sub(_SPAN_RE, " ", text, flags=re.S)
# Строка(и), целиком занятая красным фрагментом (начинается с ⟪ на своей
# позиции, и весь фрагмент до ⟫ на конце строки; внутрь не попадает другой ⟪).
_CELL_NOTE_RE = re.compile(
r"^[ \t]*" + re.escape(RED_OPEN) + "([^" + re.escape(RED_OPEN) + r"]*?)" + re.escape(RED_CLOSE) + r"[ \t]*$",
re.M,
)
def _extract_cell_notes(text: str) -> tuple[list[str], str]:
"""Вычленяет красные заметки уровня всей ячейки: занимающие строку(и) целиком.
Такие заметки (например, «14.09 пары не будет» отдельной строкой)
относятся ко всем занятиям ячейки — курсы «по выбору» внутри одной пары
отменяются вместе. Заметки, приклеенные к содержательной строке
(«онлайн ⟪начало в 14:30⟫»), остаются привязанными к своему занятию.
"""
notes: list[str] = []
def repl(m: re.Match) -> str:
frag = _squish_note(m.group(1))
if frag:
notes.append(_unwrap_note(frag))
return ""
return notes, _CELL_NOTE_RE.sub(repl, text)
def _balanced(text: str) -> str:
@@ -385,16 +426,15 @@ def _parse_block(block: list[str]) -> Optional[Lesson]:
lines = [ln.strip() for ln in text.split("\n") if ln.strip()]
if not lines:
if red_notes:
# в ячейке только примечание (например, «пары не будет»)
# в блоке только примечание (например, «пары не будет»)
return Lesson(note="\n".join(dict.fromkeys(red_notes)))
return None
marker, lines = _extract_marker(lines)
if not lines:
if red_notes:
# в ячейке только примечание (например, «пары не будет»)
return Lesson(marker=marker, note="\n".join(dict.fromkeys(red_notes)))
return None # в ячейке только маркер — занятия ещё нет
return None # в блоке только маркер — занятия ещё нет
subjects: list[str] = []
teachers: list[str] = []
@@ -456,13 +496,21 @@ def _normalize_marker_of(line: str) -> Optional[str]:
def parse_lesson_cell(cell_text: str) -> list[Lesson]:
"""Текст ячейки -> список занятий."""
"""Текст ячейки -> список занятий.
Задача: несколько занятий в одной ячейке (курсы «по выбору») отменяются
вместе, поэтому красная заметка, занимающая строку(и) целиком, — уровня
всей ячейки и раздаётся всем занятиям. Заметка, написанная в одной строке
с предметом/аудиторией, остаётся привязанной к своему занятию.
"""
if not cell_text or not cell_text.strip():
return []
lines = cell_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
text = cell_text.replace("\r\n", "\n").replace("\r", "\n")
cell_notes, text = _extract_cell_notes(text)
lessons: list[Lesson] = []
last_marker = ""
for block in _split_blocks(lines):
for block in _split_blocks(text.split("\n")):
if len(block) == 1 and not _line_is_room(block[0]):
standalone = _normalize_marker_of(block[0])
if standalone:
@@ -476,6 +524,16 @@ def parse_lesson_cell(cell_text: str) -> list[Lesson]:
elif last_marker:
lesson = replace(lesson, marker=last_marker)
lessons.append(lesson)
if not cell_notes:
return lessons
if not lessons:
return [Lesson(note="\n".join(dict.fromkeys(cell_notes)))]
for idx, lesson in enumerate(lessons):
have = [n for n in lesson.note.split("\n") if n]
add = [c for c in cell_notes if c not in have]
if add:
lessons[idx] = replace(lesson, note="\n".join(have + add))
return lessons

View File

@@ -347,3 +347,29 @@ def test_full_name_inline_after_subject():
assert result["subject"] == "Основы тестирования веб-приложений"
assert result["teacher"] == "Денис Верховцев (Сигма)"
assert result["room"] == "онлайн"
def test_cell_note_applies_to_all_lessons():
# ПИЭ-13МО, понедельник, 5-я пара: два курса по выбору, заметка о паре
raw = (
"к.в. Управление ИТ-проектами\nЕрофеев Александр Александрович (ПравоТех)\nонлайн\n"
"к.в. Математические методы и модели принятия решения Наука онлайн\n"
+ R + "14.09 пары не будет" + Cc
)
ls = lessons(raw)
assert len(ls) == 2
assert all(l["note"] == "14.09 пары не будет" for l in ls)
assert {l["subject"] for l in ls} == {
"Управление ИТ-проектами",
"Математические методы и модели принятия решения",
}
def test_inline_note_stays_with_its_lesson():
raw = (
"Русский язык\nШаманова М.В.\nауд. 312 " + R + "с 7.09 по 20.09 пары не будет" + Cc
+ "\n\nМатематика\nСмирнов А.В.\nауд. 203"
)
ls = lessons(raw)
assert ls[0]["note"] == "с 7.09 по 20.09 пары не будет"
assert ls[1]["note"] == ""