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()
|