#!/usr/bin/env python3 """Ищет гендерно-зависимые обращения к читателю в главе Книги Монтана. Использование: python3 gender_check.py "/путь/к/главе.md" Возвращает код 0 если нарушений нет, 1 если есть. Логика через pymorphy3: 1. VERB прошедшего времени мужского рода — блокер, только если «ты» идёт в том же предложении и в пределах 3 токенов до глагола (схема «ты успел подумать»). Иначе глагол описывает другого субъекта (например, «Клод сформулировал»). 2. Полные прилагательные (ADJF) и полные причастия (PRTF) мужского рода — блокер только в именительном падеже и только в окне ±2 токенов от «ты» (схема «Ты — единственный», «другой ты»). В косвенных падежах они почти всегда описывают существительное м.р. в предложении («в этот момент», «этому сну»), а не читателя. 3. Краткие прилагательные (ADJS) и краткие причастия (PRTS) мужского рода — блокер, если «ты» есть в предложении (схема «ты готов», «ты уверен», «ты покинут»). 4. Местоимения (NPRO) не проверяются — они согласуются с существительным, не с обращением. 5. Существительные (NOUN) не проверяются — свой род имеют по лексеме. Омонимы (слова с разборами и masc, и femn) пропускаются — даём шанс контексту. """ import re import sys from pathlib import Path import pymorphy3 MORPH = pymorphy3.MorphAnalyzer() READER = {"ты", "тебя", "тебе", "тобой", "тобою"} WORD_RE = re.compile(r"[А-Яа-яЁё]+") # Относительные местоимения, которые pymorphy3 классифицирует как ADJF, # но функционально они описывают антецедент, а не читателя. RELATIVE_PRONOUNS = { "который", "которая", "которое", "которые", "которого", "которой", "которых", "которому", "которым", "которыми", "которому", "котором", "которой", "тот", "та", "то", "те", "того", "той", "тех", "этот", "эта", "это", "эти", "этого", "этой", "этих", "такой", "такая", "такое", "такие", "всякий", "каждый", "любой", "иной", "сам", "мой", "твой", "свой", "наш", "ваш", "его", "её", "их", } def analyse(word: str): """Возвращает словарь-справку по разборам слова. Ключи — POS, значения — dict с флагами: has_masc_nomn: есть разбор этого POS, в котором masc + nomn (подходящий для обращения) has_masc_past: есть разбор VERB с past + masc has_masc_short: для ADJS/PRTS — есть разбор masc has_verb_past_any: есть VERB past любого рода (для различения омонима с NOUN) Разные формы одной лексемы имеют разные разборы; если хотя бы одна форма «masc+nomn» или «masc+past» подходит — форма потенциально гендерно-зависима.""" parses = MORPH.parse(word.lower()) found = {} for p in parses: pos = p.tag.POS if pos is None: continue entry = found.setdefault(pos, dict(masc_nomn=False, masc_past=False, masc_short=False)) if "masc" in p.tag and "nomn" in p.tag: entry["masc_nomn"] = True if pos == "VERB" and "past" in p.tag and "masc" in p.tag: entry["masc_past"] = True if pos in ("ADJS", "PRTS") and "masc" in p.tag: entry["masc_short"] = True return found def split_sentences(text: str): cleaned = re.sub(r"[*_`#>|]+", " ", text) out = [] for paragraph in re.split(r"\n\n+", cleaned): for sent in re.split(r"(?<=[.!?])\s+", paragraph): sent = sent.strip() if sent: out.append(sent) return out def scan_sentence(sent: str): tokens = [(m.start(), m.group()) for m in WORD_RE.finditer(sent)] lower = [t[1].lower() for t in tokens] reader_idxs = [i for i, w in enumerate(lower) if w in READER] if not reader_idxs: return [] hits = [] for i, (_, word) in enumerate(tokens): if lower[i] in READER: continue parses = analyse(word) # 1. Краткие прил/причастия м.р. — ловим в любом месте предложения reported = False for pos_name in ("ADJS", "PRTS"): info = parses.get(pos_name) if info and info["masc_short"]: hits.append((word, pos_name, "ты + краткое прил/прич м.р.")) reported = True break if reported: continue # 2. VERB past masc — только если «ты» в пределах 3 токенов назад v = parses.get("VERB") if v and v["masc_past"]: near_before = any(0 < (i - ri) <= 3 for ri in reader_idxs) if near_before: hits.append((word, "VERB", "ты перед глаголом прошедшего м.р.")) continue # 3. ADJF / PRTF масculine в именительном падеже + окно ±2 от «ты» if lower[i] in RELATIVE_PRONOUNS: continue # Эвристика: если следующий токен — существительное м.р. в им.п., # прилагательное согласовано с ним, а не с «ты». # Исключение: если между «ты» и прил. есть тире/двоеточие/"это" — # именное сказуемое («Ты — единственный носитель»), проверяем. next_is_masc_noun = False if i + 1 < len(tokens): next_word = tokens[i + 1][1] for p in MORPH.parse(next_word.lower()): if p.tag.POS == "NOUN" and "masc" in p.tag and "nomn" in p.tag: next_is_masc_noun = True break predication_marker = False for ri in reader_idxs: if ri >= i: continue start = tokens[ri][0] + len(tokens[ri][1]) end = tokens[i][0] between = sent[start:end] if "—" in between or "–" in between or ":" in between: predication_marker = True break if next_is_masc_noun and not predication_marker: continue for pos_name in ("ADJF", "PRTF"): info = parses.get(pos_name) if not info or not info["masc_nomn"]: continue # В именной группе после тире/двоеточия увеличиваем окно до 4 токенов, # чтобы поймать перечисление: «Ты — другой, живой, дышащий». window = 4 if predication_marker else 2 near = any(abs(i - ri) <= window for ri in reader_idxs) if near: hits.append((word, pos_name, "ты + прил/прич м.р. в именит. рядом")) break return hits def find_line(text: str, sent: str) -> int: marker = sent[:50].strip() if not marker: return 0 key = marker[:25] for i, line in enumerate(text.split("\n"), 1): if key in line: return i return 0 def check_file(path: Path): text = path.read_text(encoding="utf-8") hits = [] for sent in split_sentences(text): for word, pos, reason in scan_sentence(sent): line = find_line(text, sent) hits.append((line, word, pos, reason, sent[:180])) return hits def main(): if len(sys.argv) != 2: print("usage: gender_check.py ", file=sys.stderr) sys.exit(2) path = Path(sys.argv[1]) if not path.exists(): print(f"not found: {path}", file=sys.stderr) sys.exit(2) hits = check_file(path) if not hits: print(f"OK: {path.name} — гендерно-зависимых обращений к читателю не найдено") sys.exit(0) print(f"БЛОКЕРОВ: {len(hits)}") prev = None for line, word, pos, reason, ctx in hits: if (line, ctx) != prev: print(f"\nстрока {line}: {ctx}") prev = (line, ctx) print(f" → {word!r} [{pos}] — {reason}") sys.exit(1) if __name__ == "__main__": main()