gendesign/backend/tests/scrapers/test_rosstat_emiss.py
Light1YT 2da8a285b3
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m18s
CI / backend-tests (pull_request) Successful in 6m33s
feat(macro): Росстат open-data macro scraper -> macro_indicator (#946)
EPIC2 macro-ingest: региональный (Свердловская обл. ОКТМО 65 / ЕКБ) скрейпер
Росстата в унифицированную macro_indicator (м.123). Зеркалит форму cbr_macro:
pure-парсеры + тонкий httpx, Celery-таска с SAVEPOINT-per-row upsert
(CAST not ::, ON CONFLICT по PK indicator_type/region/obs_date).

ЕМИСС/fedstat.ru (SDMX) за WAF (hard-403 на всех путях из dev-IP) -> ИПЦ/доходы/
СМР-цены пока недоступны (документировано в коде). rosstat.gov.ru/opendata
(стандарт 4.0) WAF-free -> приземляет демографию population_total
(sverdl 4.32M / ekb 1.41M, §7.11).

- services/scrapers/rosstat_emiss.py — fetch+parse open-data (registry->meta->data CSV)
- workers/tasks/rosstat_macro_sync.py — Celery upsert (source=rosstat)
- beat: rosstat-macro-sync-monthly; include в celery_app
- site_finder/macro.py: population_total -> region-aware default sverdl (additive)
- tests: 18 offline-тестов парсера + контракта таски

Без миграции (вписано в существующую схему), без новых зависимостей.

Refs #946.
2026-06-07 18:07:56 +05:00

173 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""Тесты pure-парсеров Росстат-скрейпера (offline, без живой сети).
Покрывают: разбор meta.csv (URL данных + год), парсинг значений (запятая-десятичный,
разделители тысяч), извлечение целевых ОКТМО-строк из data-CSV (Свердловская обл. /
ЕКБ), фоллбэк года из meta, дедуп по (region, obs_date), graceful на пустом /
без-нужных-колонок CSV. Фикстуры — из реального формата open-data Росстата 4.0
(verified live 2026-06-07: датасет 7708234640-population).
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from app.services.scrapers.rosstat_emiss import (
OKTMO_EKATERINBURG,
OKTMO_SVERDLOVSK_OBLAST,
POPULATION_SPEC,
DatasetSpec,
RegionTarget,
_parse_value,
parse_dataset_rows,
parse_meta_data_url,
parse_meta_modified_year,
)
# Реальный shape meta.csv Росстата (open-data 4.0): property,value; строка данных —
# property 'data-<ts>-structure-<ts>.csv' → абсолютный URL CSV-данных.
FIXTURE_META = (
"property,value\n"
"standardversion,https://rosstat.gov.ru/storage/.../versiya_4.0.pdf\n"
'identifier,7708234640-population\n'
'title,"Численность постоянного населения"\n'
"created,20180905\n"
"modified,20140919\n"
"format,CSV\n"
"data-20140919T1709-structure-20141013T1710.csv,"
"https://rosstat.gov.ru/opendata/7708234640-population/"
"data-20140919T1709-structure-20141013T1710.csv\n"
"structure-20141013T1710.csv,"
"https://rosstat.gov.ru/opendata/7708234640-population/structure-20141013T1710.csv\n"
)
# Реальный shape data-CSV (population): header + муниципальные строки, ОКТМО в 'kode',
# значение в 'total', год в 'year'. Запятая — разделитель полей; значения — целые.
FIXTURE_POPULATION_DATA = (
"number,year,kode,region,municipalities,total,urban,rural\n"
'1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,'
"446406,209929,236477\n"
"2,2014,6500000000,Свердловская область,Муниципальные образования Свердловской области,"
"4320677,3500000,820677\n"
"3,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,1412346,0\n"
"4,2014,657013640011000,Свердловская область,Верх-Исетский район,201817,201817,0\n"
)
def test_parse_meta_data_url() -> None:
"""meta.csv → URL последнего data-файла."""
url = parse_meta_data_url(FIXTURE_META)
assert url == (
"https://rosstat.gov.ru/opendata/7708234640-population/"
"data-20140919T1709-structure-20141013T1710.csv"
)
def test_parse_meta_modified_year() -> None:
"""meta.csv 'modified' (YYYYMMDD) → год."""
assert parse_meta_modified_year(FIXTURE_META) == 2014
def test_parse_meta_missing_data_url() -> None:
"""meta без строки данных → None (не бросает)."""
assert parse_meta_data_url("property,value\nidentifier,foo\n") is None
def test_parse_value_comma_decimal() -> None:
"""Запятая как десятичный разделитель: '1,5' → Decimal('1.5')."""
assert _parse_value("1,5") == Decimal("1.5")
def test_parse_value_thousands_separator() -> None:
"""Пробелы-разделители тысяч вырезаются: '4 320 677' → Decimal('4320677')."""
assert _parse_value("4 320 677") == Decimal("4320677")
# узкий неразрывный пробел (Росстат иногда так)
assert _parse_value("1 412 346") == Decimal("1412346")
def test_parse_value_empty_and_dash() -> None:
"""Пустое / прочерк / многоточие → None (строка пропускается)."""
assert _parse_value("") is None
assert _parse_value("-") is None
assert _parse_value("") is None
assert _parse_value("не число") is None
def test_parse_dataset_targets_only() -> None:
"""Из data-CSV извлекаются ТОЛЬКО целевые ОКТМО (субъект + ЕКБ), не районы/др.регионы."""
rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None)
by_region = {r.region: r for r in rows}
assert set(by_region) == {"sverdl", "ekb"}
# Адыгея и Верх-Исетский район — НЕ среди целей
assert all(r.region in {"sverdl", "ekb"} for r in rows)
def test_parse_dataset_values_and_dates() -> None:
"""Значения и obs_date (1 января года из колонки 'year') корректны."""
rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None)
by_region = {r.region: r for r in rows}
assert by_region["sverdl"].value == Decimal("4320677")
assert by_region["sverdl"].obs_date == date(2014, 1, 1)
assert by_region["ekb"].value == Decimal("1412346")
assert by_region["ekb"].obs_date == date(2014, 1, 1)
# тип / единица из spec
assert by_region["sverdl"].indicator_type == "population_total"
assert by_region["sverdl"].unit == "чел"
def test_parse_dataset_fallback_year_when_no_year_col() -> None:
"""Если в spec year_col_header=None — год берётся из fallback_year (meta modified)."""
spec_no_year = DatasetSpec(
code=POPULATION_SPEC.code,
indicator_type="population_total",
unit="чел",
value_col_header="total",
oktmo_col_header="kode",
year_col_header=None, # год не из данных
targets=(RegionTarget(oktmo=OKTMO_SVERDLOVSK_OBLAST, region="sverdl"),),
)
rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, spec_no_year, fallback_year=2014)
assert len(rows) == 1
assert rows[0].obs_date == date(2014, 1, 1)
def test_parse_dataset_dedup_last_wins() -> None:
"""Дубль строки по (region, obs_date) схлопывается — последняя выигрывает."""
data = (
"number,year,kode,region,municipalities,total,urban,rural\n"
"1,2014,6500000000,Свердловская область,МО,4320677,0,0\n"
"2,2014,6500000000,Свердловская область,МО,4400000,0,0\n" # дубль ОКТМО+год
)
rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None)
assert len(rows) == 1
assert rows[0].value == Decimal("4400000")
def test_parse_dataset_skips_unparsable_value() -> None:
"""Строка с непарсящимся значением пропускается, валидные остаются."""
data = (
"number,year,kode,region,municipalities,total,urban,rural\n"
"1,2014,6500000000,Свердловская область,МО,-,0,0\n" # value '-'
"2,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,0,0\n"
)
rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None)
assert len(rows) == 1
assert rows[0].region == "ekb"
def test_parse_dataset_missing_columns_graceful() -> None:
"""CSV без нужных колонок (kode/total) → [] (+warning), не бросает."""
data = "foo,bar\n1,2\n3,4\n"
assert parse_dataset_rows(data, POPULATION_SPEC, fallback_year=2014) == []
def test_parse_dataset_empty_graceful() -> None:
"""Пустой CSV → []."""
assert parse_dataset_rows("", POPULATION_SPEC, fallback_year=2014) == []
def test_oktmo_constants_used_in_spec() -> None:
"""Спека population таргетит именно субъект-ОКТМО и ЕКБ-ОКТМО (контракт ТЗ §7.11)."""
oktmos = {t.oktmo for t in POPULATION_SPEC.targets}
assert OKTMO_SVERDLOVSK_OBLAST in oktmos
assert OKTMO_EKATERINBURG in oktmos