208 lines
9.3 KiB
Python
208 lines
9.3 KiB
Python
#!/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 <path.md>", 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()
|