diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38441df --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Портативный Firefox + geckodriver (большие, скачиваются fetch_vendor.py) +vendor/ + +# Сборка +build/ +dist/ +*.spec +__pycache__/ +*.pyc + +# Результаты прогонов (папки вида ДД.ММ.ГГГГ_ЧЧ-ММ-СС) +[0-9][0-9].[0-9][0-9].[0-9][0-9][0-9][0-9]_*/ + +# Прочее +.pytest_cache/ +.venv/ +python-3.8.10-win32.exe diff --git a/extensions/ru.cryptopro.nmcades@cryptopro.ru.xpi b/extensions/ru.cryptopro.nmcades@cryptopro.ru.xpi new file mode 100644 index 0000000..0d09434 Binary files /dev/null and b/extensions/ru.cryptopro.nmcades@cryptopro.ru.xpi differ diff --git a/fetch_vendor.py b/fetch_vendor.py new file mode 100644 index 0000000..4a87341 --- /dev/null +++ b/fetch_vendor.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Скачивает портативный Firefox и geckodriver (Windows) в папку vendor/, +чтобы build.py / build_wine.sh встроили их в .exe. Тогда конечному +пользователю нужно установить только КриптоПро — Firefox и драйвер уже внутри. + +Запуск (на машине сборки, можно на Linux): + python fetch_vendor.py # win64 (по умолчанию) + python fetch_vendor.py --arch win32 + python fetch_vendor.py --lang en-US + +Для распаковки Firefox нужен 7-Zip: + Linux: sudo apt install p7zip-full + Windows: установите 7-Zip и добавьте 7z в PATH +""" +from __future__ import annotations + +import argparse +import io +import json +import shutil +import subprocess +import sys +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).parent +VENDOR = ROOT / "vendor" +GECKO_API = "https://api.github.com/repos/mozilla/geckodriver/releases/latest" + + +def _download(url: str) -> bytes: + print(f" ↓ {url}") + req = urllib.request.Request(url, headers={"User-Agent": "fetch-vendor"}) + with urllib.request.urlopen(req, timeout=120) as resp: + return resp.read() + + +def _which_7z() -> str | None: + for name in ("7z", "7za", "7zr"): + if shutil.which(name): + return name + return None + + +def fetch_geckodriver(arch: str) -> None: + print("geckodriver:") + data = json.loads(_download(GECKO_API).decode("utf-8")) + asset = next( + (a for a in data["assets"] if f"{arch}.zip" in a["name"] and a["name"].endswith(".zip")), + None, + ) + if not asset: + names = [a["name"] for a in data["assets"]] + sys.exit(f" Не найден ассет для {arch}. Доступно: {names}") + + blob = _download(asset["browser_download_url"]) + with zipfile.ZipFile(io.BytesIO(blob)) as z: + member = next(n for n in z.namelist() if n.endswith("geckodriver.exe")) + VENDOR.mkdir(exist_ok=True) + with z.open(member) as src, open(VENDOR / "geckodriver.exe", "wb") as dst: + shutil.copyfileobj(src, dst) + print(f" ✓ {VENDOR / 'geckodriver.exe'} ({data['tag_name']})") + + +def fetch_firefox(arch: str, lang: str) -> None: + print("Firefox (портативный):") + sevenzip = _which_7z() + if not sevenzip: + print( + " ✗ 7-Zip не найден — не могу распаковать установщик Firefox.\n" + " Установите 7-Zip (Linux: sudo apt install p7zip-full), либо вручную\n" + " распакуйте Firefox так, чтобы получилось vendor/firefox/firefox.exe" + ) + return + + os_param = "win64" if arch == "win64" else "win" + url = f"https://download.mozilla.org/?product=firefox-latest&os={os_param}&lang={lang}" + installer = VENDOR / "_firefox_setup.exe" + VENDOR.mkdir(exist_ok=True) + installer.write_bytes(_download(url)) + + extract_dir = VENDOR / "_ff_extract" + if extract_dir.exists(): + shutil.rmtree(extract_dir) + # Установщик Firefox — это 7-Zip SFX; внутри папка core/ с firefox.exe. + subprocess.run( + [sevenzip, "x", "-y", f"-o{extract_dir}", str(installer)], + check=True, stdout=subprocess.DEVNULL, + ) + + core = extract_dir / "core" + if not (core / "firefox.exe").exists(): + sys.exit(f" В установщике не найден core/firefox.exe (распаковано в {extract_dir})") + + target = VENDOR / "firefox" + if target.exists(): + shutil.rmtree(target) + shutil.move(str(core), str(target)) + + installer.unlink(missing_ok=True) + shutil.rmtree(extract_dir, ignore_errors=True) + print(f" ✓ {target / 'firefox.exe'}") + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--arch", choices=("win64", "win32"), default="win64") + ap.add_argument("--lang", default="ru") + args = ap.parse_args() + + print(f"Папка: {VENDOR} (arch={args.arch}, lang={args.lang})\n") + fetch_geckodriver(args.arch) + print() + fetch_firefox(args.arch, args.lang) + print("\nГотово. Теперь соберите: python build.py") + + +if __name__ == "__main__": + main() diff --git a/test_adversarial.py b/test_adversarial.py new file mode 100644 index 0000000..60a290e --- /dev/null +++ b/test_adversarial.py @@ -0,0 +1,521 @@ +""" +Adversarial tests — написаны ДО исправлений. +Часть из них должна упасть и показать реальные баги. +Запуск: python3 -m pytest test_adversarial.py -v +""" +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch +import pytest +import openpyxl + +import main +from main import _normalize_kn, parse_pdf_extract, rename_extract, process_all, FIASSearcher +from selenium.common.exceptions import WebDriverException + + +FOUND_DATA = { + "full_name": "ул. Тестовая, 1", + "status": "Актуальный", + "obj_type": "Здание", + "fias_id": "aaaa-bbbb-cccc-dddd-eeee", + "pdf_url": None, +} + + +def make_xlsx(tmp_path, rows, headers=("КН", "Вид ОН")) -> str: + p = tmp_path / "in.xlsx" + wb = openpyxl.Workbook() + ws = wb.active + ws.append(list(headers)) + for r in rows: + ws.append(list(r)) + wb.save(str(p)) + return str(p) + + +def get_result_rows(tmp_path): + files = list(Path(tmp_path).rglob("Результаты_ФИАС_*.xlsx")) + assert files + return list(openpyxl.load_workbook(str(files[0])).active.iter_rows(min_row=2, values_only=True)) + + +def run(tmp_path, rows, headers=("КН", "Вид ОН"), searcher=None): + xlsx = make_xlsx(tmp_path, rows, headers) + if searcher is None: + searcher = MagicMock() + searcher.search.return_value = None + with patch("main.FIASSearcher", return_value=searcher): + process_all(xlsx, str(tmp_path), log=lambda _: None) + return get_result_rows(tmp_path) + + +def pdf_mock(text): + page = MagicMock() + page.extract_text.return_value = text + ctx = MagicMock() + ctx.__enter__ = lambda s: ctx + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = [page] + return ctx + + +# ───────────────────────────────────────────────────────────────────────────── +# rename_extract — path safety +# ───────────────────────────────────────────────────────────────────────────── + +class TestRenameExtractPathSafety: + + def _src(self, tmp_path): + f = tmp_path / "_tmp.pdf" + f.write_bytes(b"%PDF") + return str(f) + + def test_slash_in_kn_does_not_escape_output_dir(self, tmp_path): + """ + Слэш в КН создаёт путь вида output_dir/ГАР_01/01_... — + подпапки не существует → FileNotFoundError. + БАГ: kn.replace(':', '_') не трогает '/'. + """ + result = rename_extract(self._src(tmp_path), "01/01:0001001:1", "25.03.2024", str(tmp_path)) + assert Path(result).parent == tmp_path, ( + f"Файл создан вне output_dir: {Path(result).parent}" + ) + + def test_dotdot_in_kn_does_not_escape_output_dir(self, tmp_path): + """ + КН с '../' может вывести файл за пределы сессионной папки. + """ + result = rename_extract(self._src(tmp_path), "../evil:1:1:1", "25.03.2024", str(tmp_path)) + assert tmp_path in Path(result).parents or Path(result).parent == tmp_path, ( + f"Path traversal: {result}" + ) + + def test_backslash_in_kn_does_not_break_path(self, tmp_path): + """Обратный слэш (Windows-данные в Excel) не должен ломать путь.""" + result = rename_extract(self._src(tmp_path), "77\\01:0001001:1", "25.03.2024", str(tmp_path)) + assert Path(result).exists() + + +# ───────────────────────────────────────────────────────────────────────────── +# _normalize_kn — граничные случаи +# ───────────────────────────────────────────────────────────────────────────── + +class TestNormalizeKnEdge: + + def test_only_colons(self): + """':::' остаётся ':::' — не падает.""" + assert _normalize_kn(":::") == ":::" + + def test_numeric_value_from_excel(self): + """ + Excel иногда хранит числа без двоеточий (авто-формат). + Функция не должна добавлять двоеточия — это не КН. + """ + result = _normalize_kn(770100010001) # int, без двоеточий + assert ":" not in result + assert result == "770100010001" + + def test_float_from_excel(self): + """Число с плавающей точкой из Excel — не должно превращаться в 'None'.""" + result = _normalize_kn(77.0) + assert result != "None" + assert "." in result or result.isdigit() + + def test_empty_string(self): + assert _normalize_kn("") == "" + + def test_newlines_inside_kn_removed(self): + """Перенос строки внутри значения ячейки.""" + assert _normalize_kn("77:01\n:0001001:1") == "77:01:0001001:1" + + +# ───────────────────────────────────────────────────────────────────────────── +# parse_pdf_extract — регрессии и граничные случаи +# ───────────────────────────────────────────────────────────────────────────── + +class TestParsePdfExtractEdge: + + UUID = "12345678-abcd-abcd-abcd-123456789abc" + + def _parse(self, tmp_path, text): + p = str(tmp_path / "t.pdf") + Path(p).touch() + with patch("pdfplumber.open", return_value=pdf_mock(text)): + return parse_pdf_extract(p, log=lambda _: None) + + def test_multiple_uuids_keyword_match_beats_fallback(self, tmp_path): + """ + В тексте два UUID; у второго есть ключевое слово 'номер'. + Должен быть выбран второй, а не первый. + """ + uuid_noise = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + uuid_correct = "11111111-2222-3333-4444-555555555555" + _, num = self._parse(tmp_path, f"Прочее {uuid_noise}\nномер выписки {uuid_correct}") + assert num == uuid_correct, f"Выбран неправильный UUID: {num}" + + def test_short_uuid_not_matched(self, tmp_path): + """UUID с неправильным числом символов не должен матчиться.""" + short = "12345678-abcd-abcd-abcd-12345678" # последняя группа слишком короткая + _, num = self._parse(tmp_path, f"номер {short}") + assert num != short + + def test_page_extract_text_returns_none(self, tmp_path): + """ + pdfplumber может вернуть None для страницы без текста. + Код делает `or ""` — не должен падать. + """ + page = MagicMock() + page.extract_text.return_value = None # явный None + ctx = MagicMock() + ctx.__enter__ = lambda s: ctx + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = [page] + p = str(tmp_path / "t.pdf") + Path(p).touch() + with patch("pdfplumber.open", return_value=ctx): + date, num = parse_pdf_extract(p, log=lambda _: None) + assert date is None + assert num is None + + def test_date_regex_does_not_validate_calendar(self, tmp_path): + """ + Регулярка проверяет только цифровой формат ДД.ММ.ГГГГ, + а не реальность даты — '99.99.9999' пройдёт. + Тест документирует это поведение (не баг, но нужно знать). + """ + date, _ = self._parse(tmp_path, "дата выписки 99.99.9999") + assert date == "99.99.9999" # regexp не валидирует, принимает любые цифры + + +# ───────────────────────────────────────────────────────────────────────────── +# process_all — нештатные входные данные +# ───────────────────────────────────────────────────────────────────────────── + +class TestProcessAllEdge: + + def test_short_data_rows_no_crash(self, tmp_path): + """ + Строка данных короче заголовка (vid_col существует, но ячейка пустая). + openpyxl дополняет None — не должно быть IndexError. + """ + wb = openpyxl.Workbook() + ws = wb.active + ws.append(["КН", "Вид ОН"]) + ws.append(["77:01:0001001:1"]) # только 1 ячейка, нет vid_on + p = str(tmp_path / "in.xlsx") + wb.save(p) + ms = MagicMock() + ms.search.return_value = None + with patch("main.FIASSearcher", return_value=ms): + process_all(p, str(tmp_path), log=lambda _: None) + rows = get_result_rows(tmp_path) + assert len(rows) == 1 + assert rows[0][1] is None # vid_on должен быть None, не IndexError + + def test_header_at_row5_boundary_found(self, tmp_path): + """Заголовок на строке 5 (граница поиска) — должен найтись.""" + wb = openpyxl.Workbook() + ws = wb.active + for _ in range(4): + ws.append(["мусор"]) + ws.append(["КН"]) # строка 5 + ws.append(["77:01:0001001:1"]) + p = str(tmp_path / "in.xlsx") + wb.save(p) + ms = MagicMock() + ms.search.return_value = FOUND_DATA + with patch("main.FIASSearcher", return_value=ms): + process_all(p, str(tmp_path), log=lambda _: None) + ms.search.assert_called() + + def test_header_at_row6_not_found_logs_error(self, tmp_path): + """Заголовок на строке 6 (за пределами поиска) — должен залогировать ошибку.""" + wb = openpyxl.Workbook() + ws = wb.active + for _ in range(5): + ws.append(["мусор"]) + ws.append(["КН"]) # строка 6 — вне окна поиска + ws.append(["77:01:0001001:1"]) + p = str(tmp_path / "in.xlsx") + wb.save(p) + logs = [] + ms = MagicMock() + with patch("main.FIASSearcher", return_value=ms): + process_all(p, str(tmp_path), log=logs.append) + assert any("не найдена" in l for l in logs) + ms.search.assert_not_called() + + def test_differently_formatted_duplicates_searched_once(self, tmp_path): + """ + '77:01:0001001:1' и '77 : 01 : 0001001 : 1' нормализуются одинаково — + должны считаться дублями и вести к одному поиску (+ retry = max 2 вызовов). + """ + ms = MagicMock() + ms.search.return_value = FOUND_DATA + xlsx = make_xlsx(tmp_path, [ + ("77:01:0001001:1",), + ("77 : 01 : 0001001 : 1",), + ], headers=("КН",)) + with patch("main.FIASSearcher", return_value=ms): + process_all(xlsx, str(tmp_path), log=lambda _: None) + assert ms.search.call_count == 1, ( + f"Ожидался 1 вызов (дубли дедуплицированы), получено {ms.search.call_count}" + ) + + def test_exception_on_first_attempt_not_retried_marks_error(self, tmp_path): + """ + WebDriverException на 1-й попытке НЕ вызывает повтор — + КН сразу пишется как 'ошибка'. + Это ограничение текущей логики (retry работает только для None). + """ + attempts = {"n": 0} + + def boom(kn): + attempts["n"] += 1 + if attempts["n"] == 1: + raise WebDriverException("stale") + return FOUND_DATA # вторая попытка дала бы результат, но не дойдёт + + ms = MagicMock() + ms.search.side_effect = boom + rows = run(tmp_path, [("77:01:0001001:1", None)], searcher=ms) + assert rows[0][2] == "ошибка" + assert ms.search.call_count == 1 # второй шанс не даётся + + def test_kn_with_slash_in_excel_does_not_crash_process_all(self, tmp_path): + """ + Если ячейка Excel содержит КН со слэшем (некорректные данные), + download_pdf не должен завершаться path traversal или крашем. + Ошибка должна быть поймана и записана в Excel. + """ + data_with_pdf = {**FOUND_DATA, "pdf_url": "https://fias.nalog.ru/Export/ExportPdfStatement?id=1"} + ms = MagicMock() + ms.search.return_value = data_with_pdf + ms.download_pdf.return_value = False # скачивание не удалось — не важно + # Главное — не должно быть необработанного исключения + rows = run(tmp_path, [("01/01:0001001:1", None)], searcher=ms) + # Строка должна быть в результатах (найдена, пусть без PDF) + assert len(rows) == 1 + + def test_output_xlsx_created_even_when_no_kns(self, tmp_path): + """ + При пустом списке КН результирующий файл всё равно должен существовать. + До исправления: wb_out.save() вызывался только внутри цикла → файл не создавался. + """ + run(tmp_path, []) + files = list(Path(tmp_path).rglob("Результаты_ФИАС_*.xlsx")) + assert files, "Файл результатов не создан при пустом входном xlsx" + + def test_search_result_fias_id_empty_string_not_crashes(self, tmp_path): + """ + fias_id = '' (сайт вернул пустую ячейку) — не должно падать. + openpyxl хранит пустую строку как пустую ячейку и читает обратно как None — + это поведение библиотеки, не баг кода. + """ + ms = MagicMock() + ms.search.return_value = {**FOUND_DATA, "fias_id": ""} + rows = run(tmp_path, [("77:01:0001001:1", None)], searcher=ms) + # Пустая строка → пустая ячейка → None при чтении (openpyxl behavior) + assert rows[0][2] in ("", None) + # Другие поля должны быть на месте + assert rows[0][4] == "ул. Тестовая, 1" + + def test_vid_on_value_preserved_in_output(self, tmp_path): + """Значение «Вид ОН» из Excel должно попасть в итоговую таблицу.""" + ms = MagicMock() + ms.search.return_value = FOUND_DATA + rows = run(tmp_path, [("77:01:0001001:1", "Здание")], searcher=ms) + assert rows[0][1] == "Здание" + + def test_none_kn_cell_skipped(self, tmp_path): + """Пустые ячейки КН не попадают в список и не ищутся.""" + wb = openpyxl.Workbook() + ws = wb.active + ws.append(["КН"]) + ws.append([None]) + ws.append([""]) + ws.append([" "]) + ws.append(["77:01:0001001:1"]) # единственный валидный + p = str(tmp_path / "in.xlsx") + wb.save(p) + ms = MagicMock() + ms.search.return_value = None + with patch("main.FIASSearcher", return_value=ms): + process_all(p, str(tmp_path), log=lambda _: None) + assert ms.search.call_count <= 2 # только 1 КН × 2 retry + + +# ───────────────────────────────────────────────────────────────────────────── +# FIASSearcher — парсинг результатов, граничные случаи +# ───────────────────────────────────────────────────────────────────────────── + +def cells_of(texts): + return [MagicMock(text=t) for t in texts] + + +def tr(texts, html=""): + row = MagicMock() + row.find_elements.side_effect = lambda by, tag: cells_of(texts) if tag == "td" else [] + row.get_attribute.return_value = html + return row + + +def th_row(): + row = MagicMock() + row.find_elements.return_value = [] + return row + + +def run_search_rows(rows): + """Прогоняет логику парсинга search() с замоканным Selenium.""" + s = FIASSearcher(log=lambda _: None) + driver = MagicMock() + driver.find_elements.side_effect = lambda by, sel: ( + rows if "ResultPagerDataPlaceholder" in sel else [] + ) + driver.find_element.return_value = MagicMock() + s.driver = driver + s.wait = MagicMock() + s.wait.until.return_value = MagicMock() + + with patch("main.WebDriverWait") as wdw, patch("main.time"): + inst = MagicMock() + wdw.return_value = inst + calls = [0] + + def until_se(cond): + calls[0] += 1 + if calls[0] <= 2: + from selenium.common.exceptions import TimeoutException + raise TimeoutException() + return MagicMock() + + inst.until.side_effect = until_se + return s.search("77:01:0001001:1") + + +DATA_CELLS = ["Адрес", "Актуальный", "Здание", "x", "y", "fias-id-here"] + + +class TestFIASSearcherEdge: + + def test_whitespace_only_cells_stripped(self): + """Ячейки с пробелами должны возвращаться как пустые строки после strip().""" + cells = [" Адрес ", " ", "Здание", "x", "y", " fias "] + row = tr(cells) + result = run_search_rows([row]) + assert result is not None + assert result["full_name"] == "Адрес" + assert result["status"] == "" + assert result["fias_id"] == "fias" + + def test_six_cells_exactly_accepted(self): + """Ровно 6 ячеек — граница проверки len(cells) >= 6.""" + row = tr(DATA_CELLS) # ровно 6 + result = run_search_rows([row]) + assert result is not None + + def test_five_cells_returns_none(self): + """5 ячеек — меньше минимума, must return None.""" + row = tr(DATA_CELLS[:5]) + result = run_search_rows([row]) + assert result is None + + def test_many_header_rows_then_data(self): + """Несколько заголовочных строк перед строкой данных — пропускаются все.""" + result = run_search_rows([th_row(), th_row(), th_row(), tr(DATA_CELLS)]) + assert result is not None + assert result["full_name"] == "Адрес" + + def test_pdf_url_with_single_quotes_in_html(self): + """URL в одинарных кавычках — rstrip должен убрать кавычку.""" + html = "href='/Export/ExportPdfStatement?id=42&t=pdf'" + row = tr(DATA_CELLS, html=html) + result = run_search_rows([row]) + assert result["pdf_url"] == "https://fias.nalog.ru/Export/ExportPdfStatement?id=42&t=pdf" + + def test_pdf_url_ampersand_decoded(self): + """& в HTML должен быть раскодирован в & в URL.""" + html = 'href="/Export/ExportPdfStatement?a=1&b=2"' + row = tr(DATA_CELLS, html=html) + result = run_search_rows([row]) + assert "&" not in (result["pdf_url"] or "") + assert "a=1&b=2" in (result["pdf_url"] or "") + + def test_no_rows_returns_none(self): + assert run_search_rows([]) is None + + def test_only_header_rows_returns_none(self): + result = run_search_rows([th_row(), th_row()]) + assert result is None + + +# ───────────────────────────────────────────────────────────────────────────── +# tmp_pdf path safety +# ───────────────────────────────────────────────────────────────────────────── +class TestTmpPdfPathSafety: + + def test_slash_in_kn_tmp_pdf_stays_in_session_dir(self, tmp_path): + """ + КН со слэшем не должен создавать tmp_pdf вне session_dir. + До исправления: f"_tmp_{kn.replace(':', '_')}.pdf" оставлял '/' → path traversal. + """ + data_with_pdf = {**FOUND_DATA, "pdf_url": "https://fias.nalog.ru/Export/ExportPdfStatement?id=1"} + ms = MagicMock() + ms.search.return_value = data_with_pdf + + captured_paths = [] + original_download = ms.download_pdf + + def capture_path(url, path): + captured_paths.append(path) + return False # не скачиваем реально + + ms.download_pdf.side_effect = capture_path + + with patch("main.FIASSearcher", return_value=ms): + process_all( + make_xlsx(tmp_path, [("01/999:0001001:1", None)]), + str(tmp_path), + log=lambda _: None, + ) + + # Хотя бы один путь должен был быть передан, и все они должны быть внутри tmp_path + assert captured_paths, "download_pdf не был вызван" + session_dir = str(tmp_path) + for p in captured_paths: + resolved = str(Path(p).resolve()) + assert resolved.startswith(str(Path(session_dir).resolve())), ( + f"tmp_pdf вышел за пределы session_dir: {p}" + ) + + def test_dotdot_in_kn_tmp_pdf_stays_in_session_dir(self, tmp_path): + """../ в КН не должен выводить tmp_pdf за пределы session_dir.""" + data_with_pdf = {**FOUND_DATA, "pdf_url": "https://fias.nalog.ru/Export/ExportPdfStatement?id=1"} + ms = MagicMock() + ms.search.return_value = data_with_pdf + + captured_paths = [] + + def capture_path(url, path): + captured_paths.append(path) + return False + + ms.download_pdf.side_effect = capture_path + + with patch("main.FIASSearcher", return_value=ms): + process_all( + make_xlsx(tmp_path, [("../evil:0001001:1", None)]), + str(tmp_path), + log=lambda _: None, + ) + + assert captured_paths, "download_pdf не был вызван" + for p in captured_paths: + resolved = str(Path(p).resolve()) + assert resolved.startswith(str(Path(str(tmp_path)).resolve())), ( + f"tmp_pdf вышел за пределы session_dir: {p}" + ) diff --git a/test_main.py b/test_main.py new file mode 100644 index 0000000..52b23ac --- /dev/null +++ b/test_main.py @@ -0,0 +1,629 @@ +""" +Автотесты для main.py. +Запуск: python3 -m pytest test_main.py -v +Браузер и настоящий ФИАС не нужны — всё замокано. +""" +from __future__ import annotations + +import os +import re +from pathlib import Path +from unittest.mock import MagicMock, patch, call +import pytest +import openpyxl + +import main +from main import ( + _normalize_kn, + parse_pdf_extract, + rename_extract, + process_all, + FIASSearcher, +) +from selenium.common.exceptions import TimeoutException, WebDriverException + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def make_xlsx(tmp_path: Path, rows, headers=("КН", "Вид ОН")) -> str: + path = tmp_path / "input.xlsx" + wb = openpyxl.Workbook() + ws = wb.active + ws.append(list(headers)) + for row in rows: + ws.append(list(row)) + wb.save(str(path)) + return str(path) + + +def get_result_rows(output_dir: Path) -> list: + """Читает первый Результаты_ФИАС_*.xlsx из папки сессии (без заголовка).""" + files = list(output_dir.rglob("Результаты_ФИАС_*.xlsx")) + assert files, "Результирующий xlsx не найден" + wb = openpyxl.load_workbook(str(files[0])) + return list(wb.active.iter_rows(min_row=2, values_only=True)) + + +def fake_searcher(search_map: dict | None = None, *, side_effect=None): + """Возвращает мок FIASSearcher с заданными результатами поиска.""" + m = MagicMock() + if side_effect is not None: + m.search.side_effect = side_effect + elif search_map is not None: + m.search.side_effect = lambda kn: search_map.get(kn) + else: + m.search.return_value = None + return m + + +def make_pdf_mock(text: str): + """Мок pdfplumber.open(path) как контекстный менеджер с одной страницей.""" + page = MagicMock() + page.extract_text.return_value = text + ctx = MagicMock() + ctx.__enter__ = lambda s: ctx + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = [page] + return ctx + + +# ───────────────────────────────────────────────────────────────────────────── +# _normalize_kn +# ───────────────────────────────────────────────────────────────────────────── + +class TestNormalizeKn: + def test_already_clean(self): + assert _normalize_kn("77:01:0001001:1") == "77:01:0001001:1" + + def test_spaces_around_colons(self): + assert _normalize_kn("77 : 01 : 0001001 : 1") == "77:01:0001001:1" + + def test_tabs_around_colons(self): + assert _normalize_kn("77\t:\t01\t:\t0001001\t:\t1") == "77:01:0001001:1" + + def test_mixed_whitespace(self): + assert _normalize_kn(" 77 :01: 0001001:1 ") == "77:01:0001001:1" + + def test_trailing_newline(self): + assert _normalize_kn("77:01:0001001:1\n") == "77:01:0001001:1" + + def test_none_becomes_string_none(self): + # raw=None приходит если ячейка пустая, но это отфильтровывается раньше + assert _normalize_kn("None") == "None" + + +# ───────────────────────────────────────────────────────────────────────────── +# parse_pdf_extract +# ───────────────────────────────────────────────────────────────────────────── + +class TestParsePdfExtract: + UUID = "12345678-abcd-abcd-abcd-123456789abc" + + def _parse(self, tmp_path, text): + path = str(tmp_path / "test.pdf") + Path(path).touch() + with patch("pdfplumber.open", return_value=make_pdf_mock(text)): + return parse_pdf_extract(path, log=lambda _: None) + + def test_date_near_keyword(self, tmp_path): + date, _ = self._parse(tmp_path, "дата выписки 25.03.2024 прочее") + assert date == "25.03.2024" + + def test_date_near_vipiska_keyword(self, tmp_path): + date, _ = self._parse(tmp_path, "выписка от 01.01.2023") + assert date == "01.01.2023" + + def test_date_fallback_first_in_text(self, tmp_path): + date, _ = self._parse(tmp_path, "Документ 11.11.2021 без ключевых слов") + assert date == "11.11.2021" + + def test_date_prefers_keyword_over_first(self, tmp_path): + # "01.01.2000" стоит до ключевого слова, "25.03.2024" — рядом с ним + date, _ = self._parse(tmp_path, "01.01.2000 текст дата документа 25.03.2024") + assert date == "25.03.2024" + + def test_uuid_near_keyword(self, tmp_path): + _, num = self._parse(tmp_path, f"номер идентификатор {self.UUID}") + assert num == self.UUID + + def test_uuid_fallback(self, tmp_path): + _, num = self._parse(tmp_path, f"Сведения {self.UUID} конец") + assert num == self.UUID + + def test_uuid_case_insensitive(self, tmp_path): + uuid_upper = self.UUID.upper() + _, num = self._parse(tmp_path, f"Идентификатор {uuid_upper}") + assert num is not None + assert num.lower() == self.UUID.lower() + + def test_nothing_found(self, tmp_path): + date, num = self._parse(tmp_path, "Текст без дат и UUID") + assert date is None + assert num is None + + def test_both_found(self, tmp_path): + date, num = self._parse(tmp_path, f"дата 31.12.2023 номер {self.UUID}") + assert date == "31.12.2023" + assert num == self.UUID + + def test_corrupt_pdf_returns_none_none(self, tmp_path): + path = str(tmp_path / "bad.pdf") + Path(path).write_bytes(b"garbage") + with patch("pdfplumber.open", side_effect=Exception("corrupt")): + date, num = parse_pdf_extract(path, log=lambda _: None) + assert date is None + assert num is None + + def test_empty_text_returns_none_none(self, tmp_path): + date, num = self._parse(tmp_path, "") + assert date is None + assert num is None + + def test_multipage_text_joined(self, tmp_path): + """Текст со всех страниц объединяется.""" + page1, page2 = MagicMock(), MagicMock() + page1.extract_text.return_value = "стр 1" + page2.extract_text.return_value = f"дата 15.06.2022 номер {self.UUID}" + ctx = MagicMock() + ctx.__enter__ = lambda s: ctx + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = [page1, page2] + path = str(tmp_path / "multi.pdf") + Path(path).touch() + with patch("pdfplumber.open", return_value=ctx): + date, num = parse_pdf_extract(path, log=lambda _: None) + assert date == "15.06.2022" + assert num == self.UUID + + +# ───────────────────────────────────────────────────────────────────────────── +# rename_extract +# ───────────────────────────────────────────────────────────────────────────── + +class TestRenameExtract: + KN = "77:01:0001001:1" + DATE = "25.03.2024" + EXPECTED_STEM = "ГАР_77_01_0001001_1_25.03.2024" + + def _make_src(self, tmp_path): + src = tmp_path / "_tmp.pdf" + src.write_bytes(b"%PDF-test") + return str(src) + + def test_normal_rename(self, tmp_path): + src = self._make_src(tmp_path) + result = rename_extract(src, self.KN, self.DATE, str(tmp_path)) + assert Path(result).exists() + assert Path(result).name == f"{self.EXPECTED_STEM}.pdf" + assert not Path(src).exists() + + def test_colons_replaced_with_underscores(self, tmp_path): + src = self._make_src(tmp_path) + result = rename_extract(src, self.KN, self.DATE, str(tmp_path)) + assert ":" not in Path(result).name + + def test_conflict_appends_counter(self, tmp_path): + (tmp_path / f"{self.EXPECTED_STEM}.pdf").write_bytes(b"existing") + src = self._make_src(tmp_path) + result = rename_extract(src, self.KN, self.DATE, str(tmp_path)) + assert Path(result).name == f"{self.EXPECTED_STEM}_1.pdf" + + def test_multiple_conflicts_increment(self, tmp_path): + for suffix in ("", "_1", "_2"): + (tmp_path / f"{self.EXPECTED_STEM}{suffix}.pdf").write_bytes(b"x") + src = self._make_src(tmp_path) + result = rename_extract(src, self.KN, self.DATE, str(tmp_path)) + assert Path(result).name == f"{self.EXPECTED_STEM}_3.pdf" + + def test_no_date_uses_today(self, tmp_path): + src = self._make_src(tmp_path) + result = rename_extract(src, self.KN, None, str(tmp_path)) + assert Path(result).exists() + assert re.search(r"\d{2}\.\d{2}\.\d{4}", Path(result).name) + + +# ───────────────────────────────────────────────────────────────────────────── +# process_all — интеграционные тесты с замоканным FIASSearcher +# ───────────────────────────────────────────────────────────────────────────── + +FOUND_DATA = { + "full_name": "ул. Тестовая, 1", + "status": "Актуальный", + "obj_type": "Здание", + "fias_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "pdf_url": None, +} + + +class TestProcessAll: + def _run(self, tmp_path, rows, headers=("КН", "Вид ОН"), *, mock_searcher=None): + xlsx = make_xlsx(tmp_path, rows, headers) + if mock_searcher is None: + mock_searcher = fake_searcher() + with patch("main.FIASSearcher", return_value=mock_searcher): + process_all(xlsx, str(tmp_path), log=lambda _: None) + return get_result_rows(tmp_path) + + # ── столбцы ────────────────────────────────────────────────────────────── + + def test_missing_kn_column_returns_early(self, tmp_path): + xlsx = make_xlsx(tmp_path, [("val",)], headers=("НЕТ",)) + mock_s = fake_searcher() + logs = [] + with patch("main.FIASSearcher", return_value=mock_s): + process_all(xlsx, str(tmp_path), log=logs.append) + mock_s.search.assert_not_called() + assert any("не найдена" in l for l in logs) + + def test_kn_column_case_insensitive(self, tmp_path): + # "Кадастровый номер" (смешанный регистр) + rows = self._run(tmp_path, [("77:01:0001001:1",)], headers=("Кадастровый номер",)) + assert len(rows) == 1 + + # ── нормальные сценарии ─────────────────────────────────────────────────── + + def test_kn_found_writes_correct_row(self, tmp_path): + ms = fake_searcher({"77:01:0001001:1": FOUND_DATA}) + rows = self._run(tmp_path, [("77:01:0001001:1", "Здание")], mock_searcher=ms) + assert rows[0][0] == "77:01:0001001:1" + assert rows[0][3] == "Актуальный" + assert rows[0][4] == "ул. Тестовая, 1" + assert rows[0][2] == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + def test_kn_not_found_writes_absent_row(self, tmp_path): + rows = self._run(tmp_path, [("99:99:9999999:9", None)]) + assert rows[0][2] == "отсутствует" + assert rows[0][3] == "отсутствует" + + def test_multiple_kns_all_processed(self, tmp_path): + ms = fake_searcher({ + "77:01:0001001:1": FOUND_DATA, + "77:01:0001001:2": None, + }) + rows = self._run( + tmp_path, + [("77:01:0001001:1", None), ("77:01:0001001:2", None)], + mock_searcher=ms, + ) + assert len(rows) == 2 + + def test_empty_xlsx_produces_no_data_rows(self, tmp_path): + ms = fake_searcher() + rows = self._run(tmp_path, [], mock_searcher=ms) + assert rows == [] + ms.search.assert_not_called() + # Output file must exist even with zero entries + assert list((tmp_path).rglob("Результаты_ФИАС_*.xlsx")) + + # ── дедупликация ────────────────────────────────────────────────────────── + + def test_duplicate_kn_searched_once(self, tmp_path): + # Each unique KN gets up to 2 attempts (retry logic). + # A duplicate must not add extra attempts beyond that. + ms = fake_searcher({"77:01:0001001:1": FOUND_DATA}) # returns on first try + self._run( + tmp_path, + [("77:01:0001001:1", None), ("77:01:0001001:1", None)], + mock_searcher=ms, + ) + # Exactly 1 call: found on first attempt, duplicate skipped entirely + assert ms.search.call_count == 1 + + def test_duplicate_kn_writes_one_row(self, tmp_path): + ms = fake_searcher() + rows = self._run( + tmp_path, + [("77:01:0001001:1", None), ("77:01:0001001:1", None)], + mock_searcher=ms, + ) + assert len(rows) == 1 + + # ── нормализация КН ─────────────────────────────────────────────────────── + + def test_spaces_in_kn_normalized_before_search(self, tmp_path): + # search() is called with the normalised KN (spaces stripped around colons). + # It may be called twice due to retry logic; assert_any_call covers both. + ms = fake_searcher() + self._run(tmp_path, [("77 : 01 : 0001001 : 1", None)], mock_searcher=ms) + ms.search.assert_any_call("77:01:0001001:1") + # Must never be called with the raw un-normalised form + for c in ms.search.call_args_list: + assert c.args[0] == "77:01:0001001:1" + + def test_normalized_kn_written_to_output(self, tmp_path): + ms = fake_searcher() + rows = self._run(tmp_path, [("77 : 01 : 0001001 : 1", None)], mock_searcher=ms) + assert rows[0][0] == "77:01:0001001:1" + + # ── ошибки при поиске ───────────────────────────────────────────────────── + + def test_webdriver_error_writes_exactly_one_error_row(self, tmp_path): + """Регрессия: двойная запись 'ошибка' + 'отсутствует' для одного КН.""" + ms = fake_searcher(side_effect=WebDriverException("crash")) + rows = self._run(tmp_path, [("77:01:0001001:1", None)], mock_searcher=ms) + assert len(rows) == 1 + assert rows[0][2] == "ошибка" + # Убеждаемся что нет второй строки «отсутствует» + statuses = [r[2] for r in rows] + assert "отсутствует" not in statuses + + def test_generic_exception_writes_exactly_one_error_row(self, tmp_path): + ms = fake_searcher(side_effect=RuntimeError("unexpected")) + rows = self._run(tmp_path, [("77:01:0001001:1", None)], mock_searcher=ms) + assert len(rows) == 1 + assert rows[0][2] == "ошибка" + + def test_error_kn_still_marked_done(self, tmp_path): + """После ошибки дубликат того же КН не должен искаться повторно.""" + ms = fake_searcher(side_effect=WebDriverException("crash")) + self._run( + tmp_path, + [("77:01:0001001:1", None), ("77:01:0001001:1", None)], + mock_searcher=ms, + ) + assert ms.search.call_count == 1 + + # ── повтор поиска ───────────────────────────────────────────────────────── + + def test_retry_on_none_result(self, tmp_path): + """Первая попытка даёт None, вторая — данные: КН должен быть найден.""" + results = [None, FOUND_DATA] + ms = fake_searcher(side_effect=lambda kn: results.pop(0)) + rows = self._run(tmp_path, [("77:01:0001001:1", None)], mock_searcher=ms) + assert rows[0][3] == "Актуальный" + assert ms.search.call_count == 2 + + def test_both_retries_none_marks_absent(self, tmp_path): + ms = fake_searcher(side_effect=lambda kn: None) + rows = self._run(tmp_path, [("77:01:0001001:1", None)], mock_searcher=ms) + assert rows[0][2] == "отсутствует" + assert ms.search.call_count == 2 + + +# ───────────────────────────────────────────────────────────────────────────── +# FIASSearcher — парсинг строк таблицы результатов +# ───────────────────────────────────────────────────────────────────────────── + +def make_cell(text: str) -> MagicMock: + c = MagicMock() + c.text = text + return c + + +def make_tr(cells_text: list[str], inner_html: str = "") -> MagicMock: + cells = [make_cell(t) for t in cells_text] + row = MagicMock() + row.find_elements.side_effect = lambda by, tag: cells if tag == "td" else [] + row.get_attribute.return_value = inner_html + return row + + +def make_th_row() -> MagicMock: + """Заголовочная строка — вместо .""" + row = MagicMock() + row.find_elements.return_value = [] # td → пусто + return row + + +def _call_search_on_rows(rows: list) -> dict | None: + """ + Прогоняет только ту часть search(), которая читает строки из DOM, + полностью обходя WebDriverWait / Selenium ожидания. + """ + searcher = FIASSearcher(log=lambda _: None) + + # Настраиваем мок-драйвер + driver = MagicMock() + driver.find_elements.side_effect = lambda by, sel: ( + rows if "ResultPagerDataPlaceholder" in sel else [] + ) + driver.find_element.return_value = MagicMock() + searcher.driver = driver + + wait = MagicMock() + # wait.until всегда возвращает мок-поле для send_keys + wait.until.return_value = MagicMock() + searcher.wait = wait + + with ( + patch("main.WebDriverWait") as mock_wdw, + patch("main.time") as mock_time, + ): + # WebDriverWait(driver, n).until(...) → всегда успешно (кроме visibility) + instance = MagicMock() + mock_wdw.return_value = instance + + # invisibility → TimeoutException (div остаётся видимым) + # staleness → TimeoutException (обновление на месте) + # visibility → успех (возвращает мок) + call_count = [0] + + def until_side_effect(condition): + call_count[0] += 1 + # Первый вызов — invisibility → TimeoutException + # Второй — staleness → TimeoutException + if call_count[0] <= 2: + raise TimeoutException() + return MagicMock() + + instance.until.side_effect = until_side_effect + + return searcher.search("77:01:0001001:1") + + +class TestFIASSearcherRowParsing: + DATA_CELLS = ["ул. Тестовая, 1", "Актуальный", "Здание", "col3", "col4", + "aaaa-bbbb-cccc-dddd-eeee"] + + def test_normal_data_row_parsed(self): + row = make_tr(self.DATA_CELLS, inner_html="") + result = _call_search_on_rows([row]) + assert result is not None + assert result["full_name"] == "ул. Тестовая, 1" + assert result["status"] == "Актуальный" + assert result["fias_id"] == "aaaa-bbbb-cccc-dddd-eeee" + + def test_header_row_skipped(self): + """Регрессия: ... не должен давать None.""" + header = make_th_row() + data = make_tr(self.DATA_CELLS) + result = _call_search_on_rows([header, data]) + assert result is not None + assert result["full_name"] == "ул. Тестовая, 1" + + def test_only_header_row_returns_none(self): + result = _call_search_on_rows([make_th_row()]) + assert result is None + + def test_no_rows_returns_none(self): + result = _call_search_on_rows([]) + assert result is None + + def test_pdf_url_extracted_from_html(self): + html = 'href="/Export/ExportPdfStatement?id=123&type=abc"' + row = make_tr(self.DATA_CELLS, inner_html=html) + result = _call_search_on_rows([row]) + assert result is not None + assert result["pdf_url"] == "https://fias.nalog.ru/Export/ExportPdfStatement?id=123&type=abc" + + def test_pdf_url_none_when_absent(self): + row = make_tr(self.DATA_CELLS, inner_html="нет ссылки") + result = _call_search_on_rows([row]) + assert result is not None + assert result["pdf_url"] is None + + def test_pdf_url_extracted_from_second_row_when_header_first(self): + """PDF URL берётся из строки данных, а не из rows[0] (был баг).""" + html = 'href="/Export/ExportPdfStatement?id=CORRECT"' + header = make_th_row() + data = make_tr(self.DATA_CELLS, inner_html=html) + result = _call_search_on_rows([header, data]) + assert result is not None + assert "CORRECT" in (result["pdf_url"] or "") + + def test_row_with_less_than_6_cells_skipped(self): + short_row = make_tr(["a", "b", "c"]) # только 3 ячейки + data = make_tr(self.DATA_CELLS) + result = _call_search_on_rows([short_row, data]) + assert result is not None + assert result["full_name"] == "ул. Тестовая, 1" + + +# ───────────────────────────────────────────────────────────────────────────── +# FIASSearcher.download_pdf +# ───────────────────────────────────────────────────────────────────────────── + +class TestDownloadPdf: + PDF_BYTES = b"%PDF-1.4 fake pdf content" + URL = "https://fias.nalog.ru/Export/ExportPdfStatement?id=1" + + def _searcher(self): + s = FIASSearcher(log=lambda _: None) + s.driver = MagicMock() + s.driver.get_cookies.return_value = [{"name": "session", "value": "abc"}] + s.driver.execute_script.return_value = "Mozilla/5.0" + return s + + def _mock_resp(self, content=None, status=200): + resp = MagicMock() + resp.content = content or self.PDF_BYTES + resp.status_code = status + resp.raise_for_status = MagicMock( + side_effect=None if status < 400 else Exception(f"HTTP {status}") + ) + resp.headers = {"Content-Type": "application/pdf"} + return resp + + @patch("main.requests.Session") + def test_success_writes_file(self, mock_session_cls, tmp_path): + mock_session_cls.return_value.get.return_value = self._mock_resp() + s = self._searcher() + dest = str(tmp_path / "out.pdf") + assert s.download_pdf(self.URL, dest) is True + assert Path(dest).read_bytes() == self.PDF_BYTES + + @patch("main.requests.Session") + def test_non_pdf_response_returns_false(self, mock_session_cls, tmp_path): + mock_session_cls.return_value.get.return_value = self._mock_resp(b"login") + s = self._searcher() + assert s.download_pdf(self.URL, str(tmp_path / "out.pdf")) is False + assert not (tmp_path / "out.pdf").exists() + + @patch("main.requests.Session") + def test_http_error_returns_false(self, mock_session_cls, tmp_path): + resp = self._mock_resp(status=403) + resp.raise_for_status.side_effect = Exception("403 Forbidden") + mock_session_cls.return_value.get.return_value = resp + s = self._searcher() + assert s.download_pdf(self.URL, str(tmp_path / "out.pdf")) is False + + @patch("main.requests.Session") + def test_network_exception_returns_false(self, mock_session_cls, tmp_path): + mock_session_cls.return_value.get.side_effect = ConnectionError("timeout") + s = self._searcher() + assert s.download_pdf(self.URL, str(tmp_path / "out.pdf")) is False + + @patch("main.requests.Session") + def test_cookies_transferred_to_session(self, mock_session_cls, tmp_path): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.get.return_value = self._mock_resp() + s = self._searcher() + s.download_pdf(self.URL, str(tmp_path / "out.pdf")) + mock_session.cookies.set.assert_called_with("session", "abc") + + +class TestFIASSearcherSetup: + """Тесты для обработки ошибок при запуске браузера.""" + + @patch("main.webdriver.Firefox") + @patch("main.FirefoxService") + def test_setup_geckodriver_timeout_raises_webdriver_exception(self, mock_service, mock_ff): + """Таймаут при запуске geckodriver должен привести к понятной ошибке.""" + # Симулируем таймаут подключения к geckodriver + mock_ff.side_effect = WebDriverException("HTTPConnectionPool timeout") + + s = FIASSearcher(log=lambda _: None) + with pytest.raises(WebDriverException) as exc_info: + s.setup() + + assert "браузер" in str(exc_info.value).lower() + + @patch("main.WebDriverWait") + @patch("main.webdriver.Firefox") + @patch("main.FirefoxService") + def test_setup_fias_page_unreachable_raises_webdriver_exception(self, mock_service, mock_ff, mock_wdw): + """Таймаут при открытии страницы ФИАС должен привести к понятной ошибке.""" + mock_driver = MagicMock() + mock_ff.return_value = mock_driver + # Симулируем таймаут при driver.get(FIAS_URL) + mock_driver.get.side_effect = WebDriverException("net::ERR_CONNECTION_TIMED_OUT") + + s = FIASSearcher(log=lambda _: None) + with pytest.raises(WebDriverException) as exc_info: + s.setup() + + assert "fias.nalog.ru" in str(exc_info.value).lower() + assert "интернет" in str(exc_info.value).lower() + + @patch("main.WebDriverWait") + @patch("main.webdriver.Firefox") + @patch("main.FirefoxService") + def test_setup_form_not_loading_raises_webdriver_exception(self, mock_service, mock_ff, mock_wdw): + """Если форма поиска не загружается, должна быть понятная ошибка.""" + mock_driver = MagicMock() + mock_ff.return_value = mock_driver + mock_driver.get.return_value = None + + # Симулируем TimeoutException при ожидании элементов формы + mock_wait_instance = MagicMock() + mock_wdw.return_value = mock_wait_instance + mock_wait_instance.until.side_effect = TimeoutException("Element not found") + + s = FIASSearcher(log=lambda _: None) + with pytest.raises(WebDriverException) as exc_info: + s.setup() + + assert "форма" in str(exc_info.value).lower() or "загрузилась" in str(exc_info.value).lower() diff --git a/Описание формы таблицы для выгрузки с ФИАС.xlsx b/Описание формы таблицы для выгрузки с ФИАС.xlsx new file mode 100644 index 0000000..631b682 Binary files /dev/null and b/Описание формы таблицы для выгрузки с ФИАС.xlsx differ diff --git a/Пример перечня для поиска в Фиас.xlsx b/Пример перечня для поиска в Фиас.xlsx new file mode 100644 index 0000000..0e4a5c5 Binary files /dev/null and b/Пример перечня для поиска в Фиас.xlsx differ