This commit is contained in:
jze9
2026-06-01 17:04:41 +05:00
parent 02d52b2850
commit 89c3500151
19 changed files with 100 additions and 64555 deletions

View File

@@ -0,0 +1,60 @@
# ФИАС — поиск адресов и выгрузка выписок ГАР
Программа по списку кадастровых номеров (Excel) ищет объекты на
[fias.nalog.ru](https://fias.nalog.ru/Search/Extended), скачивает выписки ГАР (PDF)
и собирает результаты в таблицу.
## Для конечного пользователя
Нужно установить **только одно**:
- **КриптоПро ЭЦП Browser plug-in** + КриптоПро CSP — сайт ФИАС без них не работает.
Скачать: <https://www.cryptopro.ru/products/cades/plugin/>
Firefox, geckodriver и расширение КриптоПро для браузера **уже встроены** в `ФИАС_поиск.exe`
ставить и настраивать их не нужно.
Дальше:
1. Запустить `ФИАС_поиск.exe`.
2. Выбрать Excel со списком КН (нужна колонка «КН» или «Кадастровый номер»).
3. Выбрать папку для результатов и нажать «Запустить».
Результаты (таблица + PDF + журнал) складываются в подпапку с датой и временем запуска.
## Для сборки (разработчику)
Чтобы `.exe` был самодостаточным, перед сборкой надо один раз скачать
портативный Firefox и geckodriver в папку `vendor/`:
```bash
# нужен 7-Zip для распаковки Firefox:
# Linux: sudo apt install p7zip-full
# Windows: установить 7-Zip и добавить 7z в PATH
python fetch_vendor.py # win64 (по умолчанию)
# python fetch_vendor.py --arch win32 # для 32-битного Windows
```
После этого:
```bash
# На Windows (32-bit Python 3.8 для win32 .exe):
pip install -r requirements.txt
python build.py
# Либо кросс-сборка на Linux через wine:
./build_wine.sh
```
Готовый `dist/ФИАС_поиск.exe` уже содержит Firefox + geckodriver + расширение КриптоПро.
### Что во что встраивается
| Компонент | Источник | Где в сборке |
|---|---|---|
| Расширение КриптоПро (XPI) | `extensions/` (в репозитории) | внутри .exe |
| Портативный Firefox | `vendor/firefox/` (`fetch_vendor.py`) | внутри .exe |
| geckodriver | `vendor/geckodriver.exe` (`fetch_vendor.py`) | внутри .exe |
| КриптоПро CSP + нативный plug-in | ставит пользователь | — |
Папка `vendor/` в git не хранится (большие бинарники) — её наполняет `fetch_vendor.py`.
Если собрать без неё, программа попробует системный Firefox и скачает geckodriver из интернета.

Binary file not shown.

View File

@@ -10,6 +10,7 @@
""" """
from __future__ import annotations from __future__ import annotations
import os
import subprocess import subprocess
import sys import sys
import shutil import shutil
@@ -51,6 +52,32 @@ def clean():
print("Очищено") print("Очищено")
def _data_args() -> list[str]:
"""--add-data for resources bundled into the .exe so the program is
self-contained: КриптоПро XPI, and (if present) portable Firefox + geckodriver."""
args: list[str] = []
# Расширение КриптоПро — обязательно, всегда в репозитории.
ext = ROOT / "extensions"
if ext.is_dir():
args += ["--add-data", f"{ext}{os.pathsep}extensions"]
else:
print("ВНИМАНИЕ: папка extensions/ не найдена — расширение КриптоПро не попадёт в сборку!")
# Портативный Firefox + geckodriver — наполняются скриптом fetch_vendor.py.
vendor = ROOT / "vendor"
if vendor.is_dir() and any(vendor.iterdir()):
args += ["--add-data", f"{vendor}{os.pathsep}vendor"]
print(f"vendor/ включён в сборку: {[p.name for p in vendor.iterdir()]}")
else:
print(
"ВНИМАНИЕ: папка vendor/ пуста или отсутствует — Firefox и geckodriver НЕ будут\n"
" встроены. Сначала запустите: python fetch_vendor.py"
)
return args
def build(): def build():
cmd = [ cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
@@ -58,6 +85,8 @@ def build():
"--noconsole", "--noconsole",
"--name", "ФИАС_поиск", "--name", "ФИАС_поиск",
*_data_args(),
# Собрать целиком вместе с data-файлами # Собрать целиком вместе с data-файлами
"--collect-all", "selenium", "--collect-all", "selenium",
"--collect-all", "webdriver_manager", "--collect-all", "webdriver_manager",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,125 +0,0 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named pyimod02_importers - imported by C:\Python38\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed), C:\Python38\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgres.py (delayed)
missing module named _posixsubprocess - imported by subprocess (optional), multiprocessing.util (delayed)
missing module named org - imported by copy (optional)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named posix - imported by os (conditional, optional), shutil (conditional), importlib._bootstrap_external (conditional)
missing module named resource - imported by posix (top-level)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named 'org.python' - imported by pickle (optional), setuptools.sandbox (conditional), xml.sax (delayed, conditional)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named termios - imported by getpass (optional), tty (top-level)
missing module named pwd - imported by posixpath (delayed, conditional), shutil (optional), tarfile (optional), pathlib (delayed, conditional, optional), netrc (delayed, conditional), getpass (delayed), distutils.util (delayed, conditional, optional), distutils.archive_util (optional), http.server (delayed, optional), webbrowser (delayed)
missing module named 'java.lang' - imported by platform (delayed, optional), xml.sax._exceptions (conditional)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
missing module named _manylinux - imported by pkg_resources._vendor.packaging.tags (delayed, optional), packaging._manylinux (delayed, optional), setuptools._vendor.packaging.tags (delayed, optional)
missing module named _uuid - imported by uuid (optional)
missing module named netbios - imported by uuid (delayed)
missing module named win32wnet - imported by uuid (delayed)
missing module named readline - imported by site (delayed, optional), rlcompleter (optional), cmd (delayed, conditional, optional), code (delayed, conditional, optional), pdb (delayed, optional)
missing module named __builtin__ - imported by pkg_resources._vendor.pyparsing (conditional), setuptools._vendor.pyparsing (conditional)
missing module named ordereddict - imported by pkg_resources._vendor.pyparsing (optional), setuptools._vendor.pyparsing (optional)
missing module named collections.MutableMapping - imported by collections (optional), pkg_resources._vendor.pyparsing (optional), setuptools._vendor.pyparsing (optional)
missing module named collections.Iterable - imported by collections (optional), pkg_resources._vendor.pyparsing (optional), setuptools._vendor.pyparsing (optional)
missing module named grp - imported by shutil (optional), tarfile (optional), pathlib (delayed), distutils.archive_util (optional)
missing module named 'pkg_resources.extern.pyparsing' - imported by pkg_resources._vendor.packaging.markers (top-level), pkg_resources._vendor.packaging.requirements (top-level)
missing module named 'win32com.shell' - imported by pkg_resources._vendor.appdirs (conditional, optional)
missing module named 'com.sun' - imported by pkg_resources._vendor.appdirs (delayed, conditional, optional)
missing module named com - imported by pkg_resources._vendor.appdirs (delayed)
missing module named win32api - imported by pkg_resources._vendor.appdirs (delayed, conditional, optional)
missing module named win32com - imported by pkg_resources._vendor.appdirs (delayed)
missing module named _winreg - imported by platform (delayed, optional), selenium.webdriver.firefox.firefox_binary (delayed, optional), pkg_resources._vendor.appdirs (delayed, conditional)
missing module named pkg_resources.extern.packaging - imported by pkg_resources.extern (top-level), pkg_resources (top-level)
missing module named pkg_resources.extern.appdirs - imported by pkg_resources.extern (top-level), pkg_resources (top-level)
missing module named 'setuptools.extern.pyparsing' - imported by setuptools._vendor.packaging.markers (top-level), setuptools._vendor.packaging.requirements (top-level)
missing module named collections.Sequence - imported by collections (optional), sortedcontainers.sortedlist (optional), sortedcontainers.sortedset (optional), sortedcontainers.sorteddict (optional), setuptools._vendor.ordered_set (optional)
missing module named collections.MutableSet - imported by collections (optional), sortedcontainers.sortedset (optional), setuptools._vendor.ordered_set (optional)
missing module named 'setuptools.extern.packaging.version' - imported by setuptools.config (top-level), setuptools.msvc (top-level)
missing module named usercustomize - imported by site (delayed, optional)
missing module named sitecustomize - imported by site (delayed, optional)
missing module named 'setuptools.extern.packaging.utils' - imported by setuptools.wheel (top-level)
missing module named 'setuptools.extern.packaging.tags' - imported by setuptools.wheel (top-level)
missing module named 'setuptools.extern.packaging.specifiers' - imported by setuptools.config (top-level)
missing module named setuptools.extern.ordered_set - imported by setuptools.extern (top-level), setuptools.dist (top-level), setuptools.command.sdist (top-level)
missing module named setuptools.extern.packaging - imported by setuptools.extern (top-level), setuptools.dist (top-level), setuptools.command.egg_info (top-level)
missing module named wincertstore - imported by setuptools.ssl_support (delayed, optional)
missing module named 'backports.ssl_match_hostname' - imported by setuptools.ssl_support (optional)
missing module named backports - imported by setuptools.ssl_support (optional)
missing module named 'typing.io' - imported by importlib.resources (top-level)
missing module named 'defusedxml.ElementTree' - imported by openpyxl.xml.functions (conditional)
missing module named 'lxml.etree' - imported by openpyxl.xml.functions (conditional)
missing module named defusedxml - imported by openpyxl.xml (delayed, optional), PIL.Image (optional)
missing module named lxml - imported by openpyxl.xml (delayed, optional)
missing module named pandas - imported by pdfplumber.utils.generic (conditional), pdfplumber.display (conditional), openpyxl.utils.dataframe (delayed)
missing module named numpy - imported by openpyxl.compat.numbers (optional), PIL.Image (delayed, conditional, optional), pypdfium2._lazy (delayed), openpyxl.utils.dataframe (top-level)
missing module named openpyxl.tests - imported by openpyxl.reader.excel (optional)
missing module named olefile - imported by PIL.FpxImagePlugin (top-level), PIL.MicImagePlugin (top-level)
missing module named collections.Callable - imported by collections (optional), socks (optional), cffi.api (optional)
missing module named dummy_thread - imported by cffi.lock (conditional, optional), sortedcontainers.sortedlist (conditional, optional)
missing module named thread - imported by cffi.lock (conditional, optional), cffi.cparser (conditional, optional), sortedcontainers.sortedlist (conditional, optional)
missing module named cStringIO - imported by cffi.ffiplatform (optional)
missing module named cPickle - imported by pycparser.ply.yacc (delayed, optional)
missing module named cffi._pycparser - imported by cffi (optional), cffi.cparser (optional)
missing module named 'numpy.typing' - imported by PIL._typing (optional)
missing module named bcrypt - imported by cryptography.hazmat.primitives.serialization.ssh (optional)
missing module named pygame - imported by pdfminer.ccitt (delayed)
missing module named simplejson - imported by requests.compat (conditional, optional)
missing module named 'OpenSSL.crypto' - imported by urllib3.contrib.pyopenssl (delayed, conditional)
missing module named cryptography.x509.UnsupportedExtension - imported by cryptography.x509 (optional), urllib3.contrib.pyopenssl (optional)
missing module named OpenSSL - imported by urllib3.contrib.pyopenssl (top-level), trio._dtls (delayed, conditional)
missing module named pyodide - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named js - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named 'h2.events' - imported by urllib3.http2.connection (top-level)
missing module named 'h2.connection' - imported by urllib3.http2.connection (top-level)
missing module named h2 - imported by urllib3.http2.connection (top-level)
missing module named zstandard - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotli - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotlicffi - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named 'IPython.core' - imported by dotenv.ipython (top-level)
missing module named IPython - imported by dotenv.ipython (top-level)
missing module named wsaccel - imported by websocket._utils (optional)
missing module named 'python_socks.sync' - imported by websocket._http (optional)
missing module named 'python_socks._types' - imported by websocket._http (optional)
missing module named python_socks - imported by websocket._http (optional)
missing module named 'wsaccel.xormask' - imported by websocket._abnf (optional)
missing module named win_inet_pton - imported by socks (conditional, optional)
missing module named apport_python_hook - imported by exceptiongroup._formatting (conditional)
missing module named curio - imported by sniffio._impl (delayed, conditional)
missing module named _typeshed - imported by trio._file_io (conditional), trio._path (conditional)
missing module named hypothesis - imported by trio._core._run (delayed)
missing module named tputil - imported by trio._core._concat_tb (optional)
missing module named collections.ValuesView - imported by collections (optional), sortedcontainers.sorteddict (optional)
missing module named collections.Mapping - imported by collections (optional), sortedcontainers.sorteddict (optional)
missing module named collections.KeysView - imported by collections (optional), sortedcontainers.sorteddict (optional)
missing module named collections.ItemsView - imported by collections (optional), sortedcontainers.sorteddict (optional)
missing module named collections.Set - imported by collections (optional), sortedcontainers.sortedset (optional)
missing module named collections.MutableSequence - imported by collections (optional), sortedcontainers.sortedlist (optional)
missing module named annotationlib - imported by attr._compat (conditional)
missing module named pytest - imported by trio.testing._raises_group (conditional, optional)
missing module named _pytest - imported by trio.testing._raises_group (conditional)
missing module named _dummy_threading - imported by dummy_threading (optional)
missing module named chardet - imported by requests (optional)
missing module named vms_lib - imported by platform (delayed, conditional, optional)
missing module named java - imported by platform (delayed)

File diff suppressed because it is too large Load Diff

View File

@@ -71,10 +71,21 @@ cd "${SCRIPT_DIR}"
# Очистка # Очистка
rm -rf build dist *.spec rm -rf build dist *.spec
# Портативный Firefox + geckodriver включаем, только если папка vendor наполнена.
VENDOR_ARG=()
if [ -d vendor ] && [ -n "$(ls -A vendor 2>/dev/null)" ]; then
VENDOR_ARG=(--add-data "vendor;vendor")
echo ">>> vendor/ включён в сборку"
else
echo ">>> ВНИМАНИЕ: vendor/ пуст — Firefox и geckodriver НЕ встроятся. Запустите: python fetch_vendor.py"
fi
${WINE_PYTHON} -m PyInstaller \ ${WINE_PYTHON} -m PyInstaller \
--onefile \ --onefile \
--noconsole \ --noconsole \
--name "ФИАС_поиск" \ --name "ФИАС_поиск" \
--add-data "extensions;extensions" \
"${VENDOR_ARG[@]}" \
--collect-all selenium \ --collect-all selenium \
--collect-all webdriver_manager \ --collect-all webdriver_manager \
--collect-all pdfplumber \ --collect-all pdfplumber \

581
main.py
View File

@@ -1,581 +0,0 @@
#!/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("&amp;", "&")
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()