#!/usr/bin/env python3 """ФИАС: поиск кадастровых номеров и скачивание выписок ГАР.""" from __future__ import annotations # str | None etc. on Python 3.8 import os import re import sys import time import queue import shutil import logging import platform import threading import requests from datetime import datetime from pathlib import Path import tkinter as tk from tkinter import filedialog, scrolledtext, messagebox import openpyxl import pdfplumber from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.firefox.options import Options as FirefoxOptions from selenium.webdriver.firefox.service import Service as FirefoxService from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException, WebDriverException from webdriver_manager.firefox import GeckoDriverManager FIAS_URL = "https://fias.nalog.ru/Search/Extended" _CRYPTOPRO_ID = "ru.cryptopro.nmcades@cryptopro.ru" # --------------------------------------------------------------------------- # КриптоПро XPI finder # --------------------------------------------------------------------------- def _find_cryptopro_xpi() -> str | None: candidates: list[Path] = [] system = platform.system() if system == "Windows": appdata = Path(os.environ.get("APPDATA", "")) localappdata = Path(os.environ.get("LOCALAPPDATA", "")) # Zen Browser (Windows) for base in (appdata / "Zen" / "Profiles", localappdata / "Zen" / "Profiles"): if base.is_dir(): candidates.extend(base.glob(f"*/extensions/{_CRYPTOPRO_ID}.xpi")) # Firefox (Windows) for base in (appdata / "Mozilla" / "Firefox" / "Profiles",): if base.is_dir(): candidates.extend(base.glob(f"*/extensions/{_CRYPTOPRO_ID}.xpi")) else: # Zen Browser (Linux Flatpak) for d in Path.home().glob(".var/app/app.zen_browser.zen/.zen/*/extensions/"): p = d / f"{_CRYPTOPRO_ID}.xpi" if p.exists(): candidates.append(p) # Firefox (Linux / snap) for base in ( Path.home() / ".mozilla" / "firefox", Path.home() / "snap" / "firefox" / "common" / ".mozilla" / "firefox", ): if base.is_dir(): candidates.extend(base.glob(f"*/extensions/{_CRYPTOPRO_ID}.xpi")) # Downloads folder — works on both platforms for folder in ("Downloads", "Загрузки"): d = Path.home() / folder if d.is_dir(): candidates.extend(d.glob("*.xpi")) exact = [p for p in candidates if _CRYPTOPRO_ID in p.name] if exact: return str(max(exact, key=lambda p: p.stat().st_mtime)) fuzzy = [p for p in candidates if any(k in p.name.lower() for k in ("cryptopro", "cades"))] if fuzzy: return str(max(fuzzy, key=lambda p: p.stat().st_mtime)) return None # --------------------------------------------------------------------------- # Browser automation # --------------------------------------------------------------------------- class FIASSearcher: def __init__(self, log=print): self.log = log self.driver: webdriver.Firefox | None = None self.wait: WebDriverWait | None = None def setup(self): opts = FirefoxOptions() opts.set_preference("pdfjs.disabled", True) opts.set_preference("browser.download.manager.showWhenStarting", False) opts.set_preference("browser.helperApps.neverAsk.saveToDisk", "application/pdf") try: service = FirefoxService(GeckoDriverManager().install()) self.driver = webdriver.Firefox(service=service, options=opts) except Exception: self.driver = webdriver.Firefox(options=opts) self.wait = WebDriverWait(self.driver, 20) self.driver.get(FIAS_URL) if self._popup_visible(): self._try_install_cryptopro() self.driver.refresh() time.sleep(2) self._dismiss_popup() self.wait.until(EC.element_to_be_clickable((By.ID, "ByIdentifiersTab"))).click() time.sleep(1.5) self.log("Браузер запущен, открыта вкладка «По уникальным идентификаторам»") def _popup_visible(self) -> bool: try: WebDriverWait(self.driver, 3).until( EC.presence_of_element_located(( By.XPATH, "//button[contains(@class,'close') or @aria-label='Close' or normalize-space()='×']", )) ) return True except TimeoutException: return False def _try_install_cryptopro(self): xpi = _find_cryptopro_xpi() if not xpi: self.log(" XPI КриптоПро не найден, попап будет закрыт кнопкой") return try: self.log(f" Установка расширения: {Path(xpi).name}") self.driver.install_addon(xpi, temporary=True) time.sleep(2) self.log(" КриптоПро установлен") except Exception as e: self.log(f" Не удалось установить расширение: {e}") def _dismiss_popup(self): try: btn = WebDriverWait(self.driver, 3).until( EC.element_to_be_clickable(( By.XPATH, "//button[contains(@class,'close') or @aria-label='Close' or normalize-space()='×']", )) ) btn.click() time.sleep(0.3) except (TimeoutException, WebDriverException): pass def search(self, kn: str) -> dict | None: self._dismiss_popup() self._clear_form() # Wait until the previous results panel is fully hidden before proceeding. # Without this, the old SearchResultDiv is still visible and the next # wait.until(visibility_of...) returns immediately with stale data. try: WebDriverWait(self.driver, 10).until( EC.invisibility_of_element_located((By.ID, "SearchResultDiv")) ) except TimeoutException: pass # First search — div never appeared, that's fine field = self.wait.until(EC.element_to_be_clickable((By.ID, "CadastralNumberInput"))) field.clear() field.send_keys(kn) self.driver.find_element( By.XPATH, "//button[contains(@class,'btn_primary')][@title='Найти']" ).click() try: self.wait.until(EC.visibility_of_element_located((By.ID, "SearchResultDiv"))) except TimeoutException: self.log(f" Таймаут ожидания результатов") return None time.sleep(0.5) rows = self.driver.find_elements(By.CSS_SELECTOR, "#ResultPagerDataPlaceholder tr") if not rows: return None cells = rows[0].find_elements(By.TAG_NAME, "td") if len(cells) < 6: self.log(f" Неожиданное число столбцов: {len(cells)}") return None data = { "full_name": cells[0].text.strip(), "status": cells[1].text.strip(), "obj_type": cells[2].text.strip(), "fias_id": cells[5].text.strip(), "pdf_url": None, } self.log(f" Статус: {data['status']} | Тип: {data['obj_type']} | ID: {data['fias_id']}") try: row_html = (rows[0].get_attribute("innerHTML") or "").replace("&", "&") m = re.search(r"(/Export/ExportPdfStatement\?[^'\"]+)", row_html) if m: data["pdf_url"] = "https://fias.nalog.ru" + m.group(1).rstrip("'\"") self.log(" PDF URL найден") else: self.log(" PDF URL не найден в HTML") except Exception as e: self.log(f" Ошибка извлечения PDF URL: {e}") return data def download_pdf(self, pdf_url: str, save_path: str) -> bool: try: session = requests.Session() for c in self.driver.get_cookies(): session.cookies.set(c["name"], c["value"]) session.headers.update({ "User-Agent": self.driver.execute_script("return navigator.userAgent;"), "Referer": FIAS_URL, "Accept": "application/pdf,*/*", }) resp = session.get(pdf_url, timeout=40) resp.raise_for_status() content = resp.content if content[:4] != b"%PDF": self.log(f" Ответ не PDF: Content-Type={resp.headers.get('Content-Type','?')}") return False with open(save_path, "wb") as f: f.write(content) self.log(f" Скачано {len(content):,} байт") return True except Exception as e: self.log(f" Ошибка загрузки PDF: {e}") return False def _clear_form(self): try: self.driver.find_element( By.XPATH, "//button[@title='Очистить форму'] | //button[normalize-space()='Очистить форму']", ).click() except Exception: pass def close(self): if self.driver: try: self.driver.quit() except Exception: pass self.driver = None # --------------------------------------------------------------------------- # PDF parsing # --------------------------------------------------------------------------- def parse_pdf_extract(pdf_path: str, log=print) -> tuple[str | None, str | None]: try: with pdfplumber.open(pdf_path) as pdf: text = "\n".join(p.extract_text() or "" for p in pdf.pages) except Exception as e: log(f" Ошибка чтения PDF: {e}") return None, None log(f" PDF (начало): {text[:200].replace(chr(10),' ')}") date = None m = re.search( r"(?:дат[аеу]|выписк[аи]|формирован)[^\n\d]{0,30}(\d{2}\.\d{2}\.\d{4})", text, re.IGNORECASE, ) if not m: m = re.search(r"\b(\d{2}\.\d{2}\.\d{4})\b", text) if m: date = m.group(1) uuid_pat = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" number = None m = re.search(r"(?:номер|идентификатор)[^\n]{0,40}(" + uuid_pat + ")", text, re.IGNORECASE) if not m: m = re.search(uuid_pat, text) if m: number = m.group(1) if m.lastindex else m.group(0) return date, number # --------------------------------------------------------------------------- # File helpers # --------------------------------------------------------------------------- def rename_extract(pdf_path: str, kn: str, date_str: str | None, output_dir: str) -> str: kn_safe = kn.replace(":", "_") date_part = date_str or datetime.now().strftime("%d.%m.%Y") dest = os.path.join(output_dir, f"ГАР_{kn_safe}_{date_part}.pdf") n = 1 while os.path.exists(dest): dest = os.path.join(output_dir, f"ГАР_{kn_safe}_{date_part}_{n}.pdf") n += 1 shutil.move(pdf_path, dest) return dest # --------------------------------------------------------------------------- # Main processing # --------------------------------------------------------------------------- def process_all(input_xlsx: str, output_dir: str, log=print) -> None: # ---- Session directory: named by start time ---- session_ts = datetime.now().strftime("%d.%m.%Y_%H-%M") session_dir = os.path.join(output_dir, session_ts) os.makedirs(session_dir, exist_ok=True) # File logger inside session dir — survives window close log_file = os.path.join(session_dir, "fias_log.txt") file_handler = logging.FileHandler(log_file, encoding="utf-8") file_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", "%H:%M:%S")) file_logger = logging.getLogger("fias") file_logger.setLevel(logging.DEBUG) file_logger.handlers.clear() file_logger.addHandler(file_handler) def logged(msg: str): log(msg) file_logger.info(msg) logged(f"Папка сессии: {session_dir}") # ---- Load input ---- wb_in = openpyxl.load_workbook(input_xlsx) ws_in = wb_in.active kn_col = vid_col = None for row_vals in ws_in.iter_rows(min_row=1, max_row=5, values_only=True): for i, cell in enumerate(row_vals): s = str(cell or "").strip().lower() if s in ("кн", "кадастровый номер"): kn_col = i if s in ("вид он", "вид объекта", "вид"): vid_col = i if kn_col is not None: break if kn_col is None: logged("ОШИБКА: колонка «КН» не найдена") return entries = [ (str(row[kn_col]).strip(), row[vid_col] if vid_col is not None else None) for row in ws_in.iter_rows(min_row=2, values_only=True) if row[kn_col] and str(row[kn_col]).strip() ] logged(f"Загружено КН: {len(entries)}") # ---- Output workbook — named with session timestamp ---- output_xlsx_path = os.path.join(session_dir, f"Результаты_ФИАС_{session_ts}.xlsx") wb_out = openpyxl.Workbook() ws_out = wb_out.active ws_out.title = "Результаты" ws_out.append([ "КН", "Вид ОН", "Уникальный номер в ГАР (ID FIAS)", "Статус записи", "Наименование (адрес)", "Тип", "Дата выписки ГАР", "Номер выписки ГАР", ]) ws_out.freeze_panes = "A2" done_kns: set[str] = set() # ---- Run automation ---- searcher = FIASSearcher(log=logged) try: searcher.setup() total = len(entries) for idx, (kn, vid_on) in enumerate(entries): if kn in done_kns: continue logged(f"\n[{idx + 1}/{total}] {kn}") # Per-KN try/except — one bad KN doesn't kill the run try: data = searcher.search(kn) except WebDriverException as e: logged(f" WebDriver ошибка: {e} — пропуск КН") ws_out.append([kn, vid_on, "ошибка", "ошибка", str(e)[:100], "", None, None]) wb_out.save(output_xlsx_path) continue except Exception as e: logged(f" Непредвиденная ошибка: {e} — пропуск КН") ws_out.append([kn, vid_on, "ошибка", "ошибка", str(e)[:100], "", None, None]) wb_out.save(output_xlsx_path) continue if data is None: logged(" → не найден в ФИАС") ws_out.append([kn, vid_on, "отсутствует", "отсутствует", "отсутствует", "отсутствует", None, None]) else: logged(f" → {data['full_name']}") date_str = number_str = None if data.get("pdf_url"): tmp_pdf = os.path.join(session_dir, f"_tmp_{kn.replace(':', '_')}.pdf") try: if searcher.download_pdf(data["pdf_url"], tmp_pdf): date_str, number_str = parse_pdf_extract(tmp_pdf, logged) final_pdf = rename_extract(tmp_pdf, kn, date_str, session_dir) logged(f" Выписка: {Path(final_pdf).name}") logged(f" Дата: {date_str} | Номер: {number_str}") except Exception as e: logged(f" Ошибка при скачивании/разборе выписки: {e}") else: logged(" PDF URL отсутствует") ws_out.append([ kn, vid_on, data.get("fias_id"), data.get("status"), data.get("full_name"), data.get("obj_type"), date_str, number_str, ]) wb_out.save(output_xlsx_path) finally: searcher.close() file_handler.close() logged(f"\nГотово!\nПапка: {session_dir}\nТаблица: {Path(output_xlsx_path).name}") # --------------------------------------------------------------------------- # GUI — thread-safe via queue # --------------------------------------------------------------------------- class App: BG = "#2C3E50" FG = "#ECF0F1" BTN = "#2980B9" def __init__(self): self.root = tk.Tk() self.root.title("ФИАС — Поиск адресов и выгрузка выписок") self.root.configure(bg=self.BG) self.root.resizable(True, True) self._log_queue: queue.Queue[str | None] = queue.Queue() self._build_ui() # Poll the log queue from the main thread — the only safe way self._poll_log() def _build_ui(self): pad = {"padx": 8, "pady": 4} frame = tk.Frame(self.root, bg=self.BG, padx=16, pady=16) frame.pack(fill="both", expand=True) tk.Label(frame, text="Исходный файл (КН):", bg=self.BG, fg=self.FG).grid( row=0, column=0, sticky="w", **pad) self.v_input = tk.StringVar() tk.Entry(frame, textvariable=self.v_input, width=55).grid( row=0, column=1, sticky="ew", **pad) tk.Button(frame, text="Выбрать…", command=self._pick_input, bg=self.BTN, fg="white", relief="flat").grid(row=0, column=2, **pad) tk.Label(frame, text="Папка результатов:", bg=self.BG, fg=self.FG).grid( row=1, column=0, sticky="w", **pad) self.v_output = tk.StringVar() tk.Entry(frame, textvariable=self.v_output, width=55).grid( row=1, column=1, sticky="ew", **pad) tk.Button(frame, text="Выбрать…", command=self._pick_output, bg=self.BTN, fg="white", relief="flat").grid(row=1, column=2, **pad) self.btn_start = tk.Button( frame, text="▶ Запустить", command=self._start, bg="#27AE60", fg="white", relief="flat", font=("", 11, "bold"), padx=20, pady=6, ) self.btn_start.grid(row=2, column=0, columnspan=3, pady=12) self.log_box = scrolledtext.ScrolledText( frame, width=90, height=28, bg="#1A252F", fg=self.FG, font=("Courier", 9), state="disabled", ) self.log_box.grid(row=3, column=0, columnspan=3, sticky="nsew", **pad) frame.rowconfigure(3, weight=1) frame.columnconfigure(1, weight=1) def _poll_log(self): """Drain the log queue in the main thread — safe for tkinter.""" try: while True: msg = self._log_queue.get_nowait() if msg is None: # Sentinel: processing finished self.btn_start.config(state="normal", text="▶ Запустить") else: self.log_box.configure(state="normal") self.log_box.insert("end", msg + "\n") self.log_box.see("end") self.log_box.configure(state="disabled") except queue.Empty: pass self.root.after(100, self._poll_log) def _log(self, msg: str): """Thread-safe: put message in queue, never touch tkinter directly.""" self._log_queue.put(msg) def _pick_input(self): path = filedialog.askopenfilename( title="Файл с кадастровыми номерами", filetypes=[("Excel", "*.xlsx *.xls"), ("Все файлы", "*")], ) if path: self.v_input.set(path) if not self.v_output.get(): self.v_output.set(str(Path(path).parent)) def _pick_output(self): path = filedialog.askdirectory(title="Папка для результатов") if path: self.v_output.set(path) def _start(self): inp = self.v_input.get().strip() out = self.v_output.get().strip() if not inp or not os.path.isfile(inp): messagebox.showerror("Ошибка", "Выберите существующий файл Excel с КН") return if not out or not os.path.isdir(out): messagebox.showerror("Ошибка", "Выберите существующую папку") return self.btn_start.config(state="disabled", text="⏳ Выполняется…") def worker(): try: process_all(inp, out, log=self._log) # Schedule GUI notifications on main thread self.root.after(0, lambda: messagebox.showinfo( "Готово", "Обработка завершена!\nСм. Результаты_ФИАС.xlsx" )) except Exception as exc: self._log(f"\nКРИТИЧЕСКАЯ ОШИБКА: {exc}") self.root.after(0, lambda msg=str(exc): messagebox.showerror("Ошибка", msg)) finally: self._log_queue.put(None) # Sentinel → re-enable button threading.Thread(target=worker, daemon=True).start() def run(self): self.root.mainloop() # --------------------------------------------------------------------------- def main(): App().run() if __name__ == "__main__": main()