#!/usr/bin/env python3 import argparse import json import re from datetime import datetime from pathlib import Path from zipfile import ZipFile, ZIP_DEFLATED import xml.etree.ElementTree as ET NS = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} ET.register_namespace('w', NS['w']) def extract_docx_text(path: str) -> str: with ZipFile(path, 'r') as z: xml = z.read('word/document.xml').decode('utf-8', 'ignore') chunks = re.findall(r']*>(.*?)', xml) text = ' '.join(chunks) text = text.replace('&', '&').replace('<', '<').replace('>', '>') text = re.sub(r'\s+', ' ', text).strip() return text def _p_text(p) -> str: return ''.join((t.text or '') for t in p.findall('.//w:t', NS)).strip() def _set_p_text(p, new_text: str): ts = p.findall('.//w:t', NS) if not ts: return ts[0].text = new_text for t in ts[1:]: t.text = '' def autofix_docx(in_docx: str, out_docx: str): src = Path(in_docx) out = Path(out_docx) with ZipFile(src, 'r') as zin: xml = zin.read('word/document.xml') root = ET.fromstring(xml) changed = [] for p in root.findall('.//w:p', NS): txt = _p_text(p) if not txt: continue if 'Пушкино' in txt: _set_p_text(p, txt.replace('Пушкино', 'Пушкина')) changed.append('address_typo') continue if txt.startswith('1.1.'): new = ( '1.1. Арендодатель передает, а Арендатор принимает во временное владение и пользование ' 'недвижимое имущество — нежилые помещения, расположенные по адресу: Российская Федерация, ' 'Республика Крым, г. Симферополь, ул. Пушкина, д. 24 (1 этаж): помещение с кадастровым номером ' '90:22:010301:552, площадью 80,6 кв.м; помещение с кадастровым номером 90:22:010301:7181, ' 'площадью 84,2 кв.м. Общая площадь арендуемых помещений составляет 164,8 кв.м, ' 'в том числе площадь зала для обслуживания посетителей — 48 кв.м.' ) _set_p_text(p, new) changed.append('clause_1_1') elif txt.startswith('1.2.'): new = '1.2. Срок аренды устанавливается с 01.01.2026 г. по 30.11.2026 г. (включительно), что составляет 11 месяцев.' _set_p_text(p, new) changed.append('clause_1_2') elif txt.startswith('3.1.'): new = ( '3.1. Арендная плата установлена за базовый месяц в размере 180 000 ' '(Сто восемьдесят тысяч) рублей. Уплата арендной платы производится до 7 (седьмого) ' 'числа каждого месяца, следующего за отчетным, путем перечисления на расчетный счет ' 'Арендодателя по реквизитам, указанным в разделе 14 настоящего договора. ' 'Оплата начинается с 01.01.2026 г.' ) _set_p_text(p, new) changed.append('clause_3_1') elif txt.startswith('3.2.'): new = ( '3.2. Арендная плата выплачивается Арендатором Арендодателю ежемесячно не позднее ' 'седьмого числа каждого месяца, следующего за отчетным. В случае задержки с оплатой ' 'аренды более чем на 10 дней это считается невыполнением условий договора, дающим ' 'право Арендодателю прекратить настоящий договор в одностороннем порядке.' ) _set_p_text(p, new) changed.append('clause_3_2') elif 'последние 30 дней Аренды' in txt or 'Гарантированным платежом согласно первоначальному договору Аренды' in txt: _set_p_text(p, 'Стороны подтверждают, что срок действия настоящего договора не превышает 11 месяцев.') changed.append('clause_30days_conflict') new_xml = ET.tostring(root, encoding='utf-8', xml_declaration=True) with ZipFile(src, 'r') as zin, ZipFile(out, 'w', ZIP_DEFLATED) as zout: for item in zin.infolist(): data = zin.read(item.filename) if item.filename == 'word/document.xml': data = new_xml zout.writestr(item, data) return sorted(set(changed)) def parse_date(s: str): for fmt in ('%d.%m.%Y', '%d.%m.%y'): try: return datetime.strptime(s, fmt) except ValueError: pass return None def months_diff(a: datetime, b: datetime) -> int: return (b.year - a.year) * 12 + (b.month - a.month) + (1 if b.day >= a.day else 0) def find_all(pattern, text, flags=0): return re.findall(pattern, text, flags) def audit(doc_text: str, egrn_texts: list[str]): findings = [] fixes = [] if 'Пушкино' in doc_text: findings.append('Опечатка в адресе: найдено «Пушкино» вместо «Пушкина».') fixes.append('Заменить во всех пунктах: «ул. Пушкино» -> «ул. Пушкина».') if 'Арендная плата выплачивается Арендатору' in doc_text and 'Арендодателем' in doc_text: findings.append('Перепутаны плательщик и получатель арендной платы.') fixes.append('Исправить формулировку: «Арендная плата выплачивается Арендатором Арендодателю ...».') if re.search(r'1\s*8\s*0000', doc_text): findings.append('Некорректная запись суммы аренды (разрыв числа).') fixes.append('Исправить сумму на единый формат: «180 000 (Сто восемьдесят тысяч) рублей».') has_30days = 'последние 30 дней Аренды' in doc_text or 'Гарантированным платежом' in doc_text m = re.search(r'Срок\s+Аренды\s+устанавливается\s+с\s+(\d{2}\.\d{2}\.\d{2,4})\s*г?\.?\s+по\s+(\d{2}\.\d{2}\.\d{2,4})', doc_text, re.I) if m: d1 = parse_date(m.group(1)) d2 = parse_date(m.group(2)) if d1 and d2: months = months_diff(d1, d2) if months > 11: findings.append(f'Срок договора превышает 11 месяцев: ~{months} мес.') fixes.append('Сократить срок до 11 месяцев максимум и явно указать период в п.1.2.') if has_30days and d2.month == 11: findings.append('Есть конфликт срока с блоком про «последние 30 дней» за пределами периода аренды.') fixes.append('Удалить/переформулировать блок про «последние 30 дней», оставив условия в пределах срока договора.') cadasters = sorted(set(find_all(r'90:22:010301:\d{3,5}', doc_text))) areas = sorted(set(find_all(r'\b\d{2,3}[\.,]\d\b\s*кв\.?м', doc_text, re.I))) if egrn_texts: e_text = ' '.join(egrn_texts) e_cadasters = sorted(set(find_all(r'90:22:010301:\d{3,5}', e_text))) e_areas = sorted(set(find_all(r'\b\d{2,3}[\.,]\d\b', e_text))) if e_cadasters and set(e_cadasters) - set(cadasters): findings.append(f'В договоре отсутствуют кадастровые номера из ЕГРН: {", ".join(sorted(set(e_cadasters)-set(cadasters)))}.') fixes.append('Добавить недостающие кадастровые номера в п.1.1.') if e_areas and not any(a in doc_text for a in ('80,6', '84,2', '80.6', '84.2')): findings.append('Площади помещений из ЕГРН не отражены явно в договоре.') fixes.append('В п.1.1 указать площади каждого помещения и общую площадь.') if not findings: findings.append('Критичных несогласованностей по шаблонным проверкам не найдено.') return { 'findings': findings, 'fixes': fixes, 'meta': { 'cadasters_in_doc': cadasters, 'areas_in_doc': areas, }, } def main(): ap = argparse.ArgumentParser() ap.add_argument('--docx', required=True) ap.add_argument('--egrn-text', action='append', default=[]) ap.add_argument('--json', action='store_true') ap.add_argument('--autofix', action='store_true') ap.add_argument('--out', default='') args = ap.parse_args() target_docx = args.docx applied = [] if args.autofix: out = args.out or str(Path(args.docx).with_name(Path(args.docx).stem + '_fixed.docx')) applied = autofix_docx(args.docx, out) target_docx = out doc_text = extract_docx_text(target_docx) egrn_texts = [] for p in args.egrn_text: with open(p, 'r', encoding='utf-8', errors='ignore') as f: egrn_texts.append(f.read()) result = audit(doc_text, egrn_texts) if applied: result['autofix'] = { 'applied': applied, 'output_docx': target_docx, } if args.json: print(json.dumps(result, ensure_ascii=False, indent=2)) else: if applied: print(f'Автоправки применены: {", ".join(applied)}') print(f'Файл: {target_docx}\n') print('Найдено:') for i, x in enumerate(result['findings'], 1): print(f'{i}. {x}') if result['fixes']: print('\nРекомендованные правки:') for i, x in enumerate(result['fixes'], 1): print(f'{i}. {x}') if __name__ == '__main__': main()