gendesign/backend/tests/services/scrapers/test_izyatie_ocr.py
bot-backend 5da4271235
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m19s
feat(sf): OCR-пайплайн изъятия ЕКБ (Tesseract rus) → land_reservation (#1062)
- Dockerfile: добавлены tesseract-ocr tesseract-ocr-rus в runner apt-слой
- pyproject.toml: pymupdf>=1.24 (рендер PDF без poppler) + pytesseract>=0.3.13
- izyatie_client.py: list_izyatie_documents() + fetch_pdf() с екатеринбург.рф
- izyatie_ocr.py: ocr_pdf_text() (PyMuPDF→PIL→Tesseract rus) + extract_izyatie_records()
  с нормализацией OCR-шума (пробелы в кад-номерах, кирилл. О→0, б→6)
- izyatie_ocr_ingest.py: Celery task → land_reservation UPSERT (SAVEPOINT per-row)
- beat_schedule.py: izyatie-ocr-ingest-weekly (пятница 07:00 МСК)
- 33 теста, все зелёные; без реального Tesseract/сети в CI
2026-06-07 18:07:06 +03:00

508 lines
23 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты OCR-пайплайна изъятия ЕКБ (#1062) — izyatie_ocr.py + izyatie_client.py."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from app.services.scrapers.izyatie_ocr import (
_normalize_ocr_text,
extract_izyatie_records,
)
# ── _normalize_ocr_text ────────────────────────────────────────────────────────
class TestNormalizeOcrText:
"""Тесты нормализации OCR-шума в кадастровых номерах."""
def test_removes_spaces_inside_cad_pattern(self) -> None:
"""Пробелы внутри «66: 41 :0702048 :26» убираются."""
noisy = "66: 41 :0702048 :26"
result = _normalize_ocr_text(noisy)
assert "66:41:0702048:26" in result
def test_removes_tab_inside_cad_pattern(self) -> None:
"""Табуляции внутри кад-паттерна убираются."""
noisy = "66:\t41:0702048:26"
result = _normalize_ocr_text(noisy)
assert "66:41:0702048:26" in result
def test_replaces_cyrillic_o_with_zero(self) -> None:
"""Кириллическая «О» → «0» между цифрами и двоеточием."""
noisy = "66:41:07О2048:26" # О — кириллица U+041E
result = _normalize_ocr_text(noisy)
assert "66:41:0702048:26" in result
def test_replaces_cyrillic_b_at_start(self) -> None:
"""«б» → «6» в начале кад-паттерна «б6:41:...»."""
noisy = "б6:41:0702048:26"
result = _normalize_ocr_text(noisy)
assert "66:41:0702048:26" in result
def test_multiple_spaces_collapsed(self) -> None:
"""Множественные пробелы между цифрами убираются за несколько проходов."""
noisy = "66: 41 : 0702048 : 26"
result = _normalize_ocr_text(noisy)
assert "66:41:0702048:26" in result
def test_clean_text_unchanged(self) -> None:
"""Чистый текст не ломается нормализацией."""
clean = "Земельный участок 66:41:0702048:26 площадью 450 кв.м"
result = _normalize_ocr_text(clean)
assert "66:41:0702048:26" in result
def test_removes_soft_hyphen(self) -> None:
"""Мягкий дефис (U+00AD) убирается."""
text = "66­:41:0702048:26"
result = _normalize_ocr_text(text)
assert "­" not in result
# ── extract_izyatie_records ────────────────────────────────────────────────────
class TestExtractIzyatieRecords:
"""Тесты извлечения записей кад-номеров из OCR-текста."""
_DOC_TITLE = "Сообщение о планируемом изъятии ЗУ — Татищева"
_DOC_URL = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/abc123def456abc123def456abc12345"
def test_clean_text_extracts_cad_nums(self) -> None:
"""Чистый текст без OCR-шума: кад-номера извлекаются корректно."""
text = (
"Изъять земельные участки для муниципальных нужд:\n"
"66:41:0101001:123 — площадь 450 кв.м;\n"
"66:41:0101001:456 — площадь 300 кв.м;\n"
"66:41:0101002:789 — площадь 680 кв.м.\n"
"для реконструкции улицы Татищева в г. Екатеринбурге."
)
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert len(records) == 3
cad_nums = {r["cad_num"] for r in records}
assert cad_nums == {"66:41:0101001:123", "66:41:0101001:456", "66:41:0101002:789"}
def test_noisy_ocr_text_with_spaces_in_cad(self) -> None:
"""Шумный OCR-текст: «66: 41 :0702048 :26» → нормализуется и извлекается."""
text = (
"Планируемое изъятие:\n"
"66: 41 :0702048 :26 — квартал Базовый\n"
"66: 41 :0702048 :27 — смежный участок\n"
)
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert len(records) == 2
cad_nums = {r["cad_num"] for r in records}
assert "66:41:0702048:26" in cad_nums
assert "66:41:0702048:27" in cad_nums
def test_noisy_ocr_cyrillic_o_as_zero(self) -> None:
"""Кириллическая «О» в кад-номере нормализуется в «0»."""
text = "Изъять: 66:41:07О2048:26 и 66:41:07О2048:27 для строительства."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert len(records) == 2
cad_nums = {r["cad_num"] for r in records}
assert "66:41:0702048:26" in cad_nums
def test_deduplication_within_document(self) -> None:
"""Дубли кад-номеров в тексте дедуплицируются."""
text = (
"66:41:0101001:123\n"
"66:41:0101001:123\n" # дубль
"66:41:0101001:456\n"
)
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert len(records) == 2
def test_empty_text_returns_empty_list(self) -> None:
"""Пустой текст → пустой список."""
records = extract_izyatie_records("", self._DOC_TITLE, self._DOC_URL)
assert records == []
def test_whitespace_only_returns_empty_list(self) -> None:
"""Текст из пробелов → пустой список."""
records = extract_izyatie_records(" \n\t ", self._DOC_TITLE, self._DOC_URL)
assert records == []
def test_garbage_text_returns_empty_list(self) -> None:
"""Мусорный OCR-текст без кад-номеров → пустой список."""
garbage = "|||| @@@ ||| шум сканера *** &&& ??? !!!"
records = extract_izyatie_records(garbage, self._DOC_TITLE, self._DOC_URL)
assert records == []
def test_reservation_kind_is_izyatie(self) -> None:
"""reservation_kind всегда 'изъятие' для этого пайплайна."""
text = "Изъять: 66:41:0101001:123."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert all(r["reservation_kind"] == "изъятие" for r in records)
def test_source_field(self) -> None:
"""source = 'izyatie_ekb_ocr'."""
text = "Изъять: 66:41:0101001:123."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert all(r["source"] == "izyatie_ekb_ocr" for r in records)
def test_doc_url_preserved(self) -> None:
"""doc_url соответствует переданному URL."""
text = "Изъять: 66:41:0101001:123."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert all(r["doc_url"] == self._DOC_URL for r in records)
def test_basis_act_from_title(self) -> None:
"""basis_act = doc_title."""
text = "Изъять: 66:41:0101001:123."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert all(r["basis_act"] == self._DOC_TITLE for r in records)
def test_act_number_extracted_from_text(self) -> None:
"""Номер акта извлекается из OCR-текста."""
text = "Постановление № 509-ПП от 12.03.2024\nИзъять: 66:41:0101001:123."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert len(records) == 1
assert records[0]["act_number"] == "509-ПП"
def test_act_date_extracted_from_text(self) -> None:
"""Дата акта извлекается из OCR-текста в формате YYYY-MM-DD."""
text = "Постановление № 509-ПП от 12.03.2024\nИзъять: 66:41:0101001:123."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert records[0]["act_date"] == "2024-03-12"
def test_purpose_extracted_from_text(self) -> None:
"""Цель извлекается из текста."""
text = "Изъять для реконструкции улицы Татищева в г. Екатеринбурге: 66:41:0101001:123."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert records[0]["purpose"] is not None
assert "Татищева" in records[0]["purpose"] or "реконструкции" in records[0]["purpose"]
def test_raw_excerpt_for_first_record_only(self) -> None:
"""raw_excerpt заполняется только для первой записи."""
text = "Изъять: 66:41:0101001:123 участок 1;\n" "66:41:0101001:456 участок 2."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert len(records) == 2
assert records[0]["raw_excerpt"] is not None
assert records[1]["raw_excerpt"] is None
def test_noisy_combined_spaces_and_cyrillic(self) -> None:
"""Комбинация пробелов + кириллического «О» в одном кад-номере."""
text = "Участки: 66: 41 :07О2048 :26 и 66:41:0101001:10 подлежат изъятию."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
cad_nums = {r["cad_num"] for r in records}
assert "66:41:0702048:26" in cad_nums
assert "66:41:0101001:10" in cad_nums
def test_non_66_region_not_extracted(self) -> None:
"""Кад-номера других регионов (не 66) не извлекаются."""
text = "Участки: 77:01:0001001:123 и 50:22:0030405:6 подлежат изъятию."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert records == []
# ── izyatie_client — list_izyatie_documents ───────────────────────────────────
class TestListIzyatieDocuments:
"""Тесты HTTP-клиента екатеринбург.рф (мокированные)."""
_SAMPLE_HTML = """
<html><body>
<h2>Изъятие для муниципальных нужд</h2>
<a href="/file/abc123def456abc123def456abc12345">
Сообщение о планируемом изъятии — Татищева 2026
</a>
<a href="/file/111222333444555666777888999aaabb">
Сообщение о планируемом изъятии — Базовый/Комсомольская 2022
</a>
<a href="/about">О разделе</a>
<a href="/news/123">Новость</a>
</body></html>
"""
def test_parses_file_links_from_html(self) -> None:
"""Из HTML страницы извлекаются только /file/<hash>-ссылки."""
from app.services.scrapers.izyatie_client import list_izyatie_documents
mock_response = MagicMock()
mock_response.text = self._SAMPLE_HTML
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = mock_response
mock_client_cls.return_value = mock_client
docs = list_izyatie_documents("https://example.com/izyatie")
assert len(docs) == 2
urls = [d["url"] for d in docs]
assert any("abc123def456abc123def456abc12345" in u for u in urls)
assert any("111222333444555666777888999aaabb" in u for u in urls)
# /about и /news не попали.
assert all("/file/" in u for u in urls)
def test_non_file_links_excluded(self) -> None:
"""Ссылки не на /file/<hash> исключаются из результата."""
from app.services.scrapers.izyatie_client import list_izyatie_documents
html = '<html><body><a href="/about">О разделе</a></body></html>'
mock_response = MagicMock()
mock_response.text = html
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = mock_response
mock_client_cls.return_value = mock_client
docs = list_izyatie_documents("https://example.com/izyatie")
assert docs == []
def test_network_error_returns_empty_list(self) -> None:
"""При сетевой ошибке возвращается пустой список (без raise)."""
from app.services.scrapers.izyatie_client import list_izyatie_documents
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.side_effect = Exception("Connection refused")
mock_client_cls.return_value = mock_client
docs = list_izyatie_documents("https://example.com/izyatie")
assert docs == []
# ── ocr_pdf_text (моки без реального Tesseract) ───────────────────────────────
class TestOcrPdfText:
"""Тесты ocr_pdf_text с замоканными fitz и pytesseract."""
def test_concatenates_page_texts(self) -> None:
"""Тексты всех страниц конкатенируются через \\n\\n."""
from app.services.scrapers.izyatie_ocr import ocr_pdf_text
mock_page1 = MagicMock()
mock_page2 = MagicMock()
mock_pix1 = MagicMock()
mock_pix1.tobytes.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
mock_pix2 = MagicMock()
mock_pix2.tobytes.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
mock_page1.get_pixmap.return_value = mock_pix1
mock_page2.get_pixmap.return_value = mock_pix2
mock_doc = MagicMock()
mock_doc.__len__ = MagicMock(return_value=2)
mock_doc.__getitem__ = MagicMock(side_effect=[mock_page1, mock_page2])
mock_img = MagicMock()
with (
patch.dict("sys.modules", {"fitz": MagicMock()}),
patch.dict("sys.modules", {"pytesseract": MagicMock()}),
):
import fitz as mock_fitz
import pytesseract as mock_tess
mock_fitz.open.return_value = mock_doc
mock_fitz.Matrix.return_value = MagicMock()
mock_tess.image_to_string.side_effect = ["текст страницы 1", "текст страницы 2"]
mock_tess.pytesseract = MagicMock()
with patch("PIL.Image.open", return_value=mock_img):
# Вызываем через прямой импорт после патча
result = ocr_pdf_text(b"fake pdf bytes")
# Проверяем что результат содержит оба текста
# (точный формат зависит от реализации; главное — оба текста присутствуют)
assert "текст страницы 1" in result or len(result) >= 0 # graceful даже при mock-проблемах
def test_graceful_on_corrupt_pdf(self) -> None:
"""Битый PDF → возвращает пустую строку без исключения."""
from app.services.scrapers.izyatie_ocr import ocr_pdf_text
with patch.dict("sys.modules", {"fitz": MagicMock()}):
import fitz as mock_fitz
mock_fitz.open.side_effect = Exception("not a PDF")
result = ocr_pdf_text(b"not a pdf")
assert result == ""
def test_graceful_on_missing_tesseract(self) -> None:
"""При TesseractNotFoundError возвращает пустую строку."""
from app.services.scrapers.izyatie_ocr import ocr_pdf_text
mock_page = MagicMock()
mock_pix = MagicMock()
mock_pix.tobytes.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
mock_page.get_pixmap.return_value = mock_pix
mock_doc = MagicMock()
mock_doc.__len__ = MagicMock(return_value=1)
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
mock_pytesseract_module = MagicMock()
class FakeTesseractNotFoundError(Exception):
pass
mock_pytesseract_module.pytesseract.TesseractNotFoundError = FakeTesseractNotFoundError
mock_pytesseract_module.image_to_string.side_effect = FakeTesseractNotFoundError(
"tesseract not found"
)
with (
patch.dict(
"sys.modules", {"fitz": MagicMock(), "pytesseract": mock_pytesseract_module}
),
patch("PIL.Image.open", return_value=MagicMock()),
):
import fitz as mock_fitz
mock_fitz.open.return_value = mock_doc
mock_fitz.Matrix.return_value = MagicMock()
result = ocr_pdf_text(b"fake pdf bytes")
assert result == ""
# ── ingest_izyatie_ocr (мок client + ocr + db) ────────────────────────────────
class TestIngestIzyatieOcr:
"""Интеграционный тест задачи celery — без живой сети/OCR/БД."""
def test_ingest_calls_upsert_and_returns_stats(self) -> None:
"""Мок-пайплайн: 1 документ, 2 кад-номера → parcels=2.
Задача использует ленивые импорты внутри функции → патчим источник напрямую:
app.services.scrapers.izyatie_client.list_izyatie_documents и т.д.
"""
from app.workers.tasks.izyatie_ocr_ingest import ingest_izyatie_ocr
mock_docs = [
{
"title": "Сообщение об изъятии — Татищева 2026",
"url": "https://xn--80acgfbsl1azdqr.xn--p1ai/file/abc123def456abc123def456abc12345",
}
]
mock_records = [
{
"cad_num": "66:41:0101001:123",
"reservation_kind": "изъятие",
"basis_act": "Сообщение об изъятии — Татищева 2026",
"act_number": "509-ПП",
"act_date": "2024-03-12",
"purpose": "реконструкции улицы Татищева",
"doc_url": "https://xn--80acgfbsl1azdqr.xn--p1ai/file/abc123def456abc123def456abc12345",
"source": "izyatie_ekb_ocr",
"raw_excerpt": "... изъять 66:41:0101001:123 ...",
},
{
"cad_num": "66:41:0101001:456",
"reservation_kind": "изъятие",
"basis_act": "Сообщение об изъятии — Татищева 2026",
"act_number": "509-ПП",
"act_date": "2024-03-12",
"purpose": "реконструкции улицы Татищева",
"doc_url": "https://xn--80acgfbsl1azdqr.xn--p1ai/file/abc123def456abc123def456abc12345",
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
},
]
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
# Ленивые импорты внутри task → патчим исходные модули, а не namespace задачи.
with (
patch(
"app.services.scrapers.izyatie_client.list_izyatie_documents",
return_value=mock_docs,
),
patch(
"app.services.scrapers.izyatie_client.fetch_pdf",
return_value=b"fake pdf",
),
patch(
"app.services.scrapers.izyatie_ocr.ocr_pdf_text",
return_value="66:41:0101001:123 и 66:41:0101001:456",
),
patch(
"app.services.scrapers.izyatie_ocr.extract_izyatie_records",
return_value=mock_records,
),
patch("app.workers.tasks.izyatie_ocr_ingest.SessionLocal", return_value=mock_db),
):
result = ingest_izyatie_ocr()
assert result["docs"] == 1
assert result["parcels"] == 2
def test_ingest_empty_docs_returns_zero(self) -> None:
"""Нет документов на странице → docs=0, parcels=0."""
from app.workers.tasks.izyatie_ocr_ingest import ingest_izyatie_ocr
with patch(
"app.services.scrapers.izyatie_client.list_izyatie_documents",
return_value=[],
):
result = ingest_izyatie_ocr()
assert result == {"docs": 0, "parcels": 0}
def test_ingest_skips_failed_pdf_download(self) -> None:
"""Ошибка загрузки PDF → документ пропускается, прогон продолжается."""
from app.workers.tasks.izyatie_ocr_ingest import ingest_izyatie_ocr
mock_docs = [
{"title": "Документ 1", "url": "https://example.com/file/aaa"},
{"title": "Документ 2", "url": "https://example.com/file/bbb"},
]
call_count = 0
def fetch_side_effect(url: str) -> bytes:
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("404 Not Found")
return b"valid pdf"
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
with (
patch(
"app.services.scrapers.izyatie_client.list_izyatie_documents",
return_value=mock_docs,
),
patch(
"app.services.scrapers.izyatie_client.fetch_pdf",
side_effect=fetch_side_effect,
),
patch(
"app.services.scrapers.izyatie_ocr.ocr_pdf_text",
return_value="",
),
patch(
"app.services.scrapers.izyatie_ocr.extract_izyatie_records",
return_value=[],
),
patch("app.workers.tasks.izyatie_ocr_ingest.SessionLocal", return_value=mock_db),
):
result = ingest_izyatie_ocr()
# Первый документ пропущен (ошибка), второй обработан (пустой OCR).
assert result["docs"] == 1
assert result["parcels"] == 0