fedstat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fedstat/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """fedstat — неофициальный Python-клиент к fedstat.ru (ЕМИСС).
2
+
3
+ Быстрый старт:
4
+ import fedstat
5
+ fedstat.list_filters("31452") # какие фильтры доступны
6
+ f = fedstat.filter_template("31452") # шаблон со всеми полями
7
+ f["Год"] = "2023"
8
+ df = fedstat.load("31452", filters=f) # нормализованный DataFrame
9
+ """
10
+
11
+ from .api import filter_template, get_data_ids, list_filters, load
12
+ from .client import FedstatClient
13
+ from .discovery import DataIds, parse_indicator_page
14
+ from .errors import (
15
+ CSRFTokenError,
16
+ DownloadError,
17
+ FedstatError,
18
+ FilterError,
19
+ IndicatorPageError,
20
+ SDMXParseError,
21
+ )
22
+ from .reshape import to_wide
23
+ from .sdmx import sdmx_to_dataframe
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "load",
29
+ "list_filters",
30
+ "filter_template",
31
+ "get_data_ids",
32
+ "to_wide",
33
+ "FedstatClient",
34
+ "DataIds",
35
+ "parse_indicator_page",
36
+ "sdmx_to_dataframe",
37
+ "FedstatError",
38
+ "IndicatorPageError",
39
+ "CSRFTokenError",
40
+ "FilterError",
41
+ "DownloadError",
42
+ "SDMXParseError",
43
+ ]
fedstat/api.py ADDED
@@ -0,0 +1,79 @@
1
+ """Высокоуровневый API: get_data_ids, list_filters, filter_template, load."""
2
+
3
+ import time
4
+
5
+ from .client import FedstatClient
6
+ from .discovery import build_download_body, parse_indicator_page
7
+ from .errors import CSRFTokenError, DownloadError
8
+ from .filters import select_rows
9
+ from .sdmx import sdmx_to_dataframe
10
+
11
+
12
+ def _client(client):
13
+ return client if client is not None else FedstatClient()
14
+
15
+
16
+ def get_data_ids(indicator_id, *, client=None):
17
+ """Скачивает и разбирает страницу индикатора -> DataIds (поля, значения, CSRF)."""
18
+ cl = _client(client)
19
+ html = cl.get_indicator_html(str(indicator_id))
20
+ return parse_indicator_page(html, str(indicator_id))
21
+
22
+
23
+ def list_filters(indicator_id, *, client=None):
24
+ """DataFrame со всеми доступными полями-фильтрами и их значениями."""
25
+ return get_data_ids(indicator_id, client=client).to_frame()
26
+
27
+
28
+ def filter_template(indicator_id, *, client=None):
29
+ """Готовый словарь {field_title: '*'} — заполни нужные поля и передай в load()."""
30
+ return get_data_ids(indicator_id, client=client).template()
31
+
32
+
33
+ def load(indicator_id, filters=None, *, client=None, retry_max_times=3,
34
+ retry_pause=3.0, try_numeric=True, with_codes=False, drop_empty=False):
35
+ """Скачивает подмножество данных индикатора -> нормализованный DataFrame.
36
+
37
+ filters: dict(field_title -> value | [values] | '*'); пропущенное поле = все значения.
38
+ Повторяет весь цикл (свежий CSRF + новая попытка) при отклонении POST,
39
+ с нарастающей паузой между попытками (fedstat часто отвечает 503 при перегрузке).
40
+
41
+ retry_max_times: сколько раз пытаться скачать.
42
+ retry_pause: базовая пауза (сек) между попытками; растёт как retry_pause * 2**n.
43
+ """
44
+ indicator_id = str(indicator_id)
45
+ cl = _client(client)
46
+
47
+ attempts = max(1, retry_max_times)
48
+ last_exc = None
49
+ for attempt in range(attempts):
50
+ data_ids = get_data_ids(indicator_id, client=cl)
51
+ if not data_ids.csrf_token or not data_ids.csrf_token_name:
52
+ raise CSRFTokenError(
53
+ f"На странице индикатора {indicator_id} не найден CSRF-токен."
54
+ )
55
+ selected = select_rows(data_ids, filters)
56
+ body = build_download_body(data_ids, selected)
57
+ referer = f"{getattr(cl, 'base_url', '')}/indicator/{indicator_id}"
58
+ try:
59
+ raw = cl.download(body, data_format="sdmx", referer=referer)
60
+ except DownloadError as exc:
61
+ last_exc = exc # CSRF одноразовый -> следующая попытка перезаберёт токен
62
+ if attempt < attempts - 1:
63
+ # чистая сессия (новые cookies + новый UA) — аналог Cmd+Shift+R
64
+ if hasattr(cl, "reset_session"):
65
+ cl.reset_session()
66
+ time.sleep(retry_pause * (2 ** attempt)) # 3, 6, 12 ... сек
67
+ continue
68
+ return sdmx_to_dataframe(
69
+ raw, try_numeric=try_numeric, with_codes=with_codes, drop_empty=drop_empty
70
+ )
71
+
72
+ raise DownloadError(
73
+ f"Не удалось скачать данные индикатора {indicator_id} за {attempts} попыток. "
74
+ f"Последняя ошибка: {last_exc} "
75
+ "Если это 503 — временная перегрузка fedstat, повторите позже или увеличьте "
76
+ "retry_max_times/retry_pause. Если стабильно 302 — почти наверняка выбранная "
77
+ "комбинация фильтров пустая: проверьте значения через list_filters() "
78
+ "(частый случай — похожие варианты значения одного поля)."
79
+ )
fedstat/client.py ADDED
@@ -0,0 +1,179 @@
1
+ """HTTP-клиент для fedstat.ru: сессия, заголовки, ретраи GET, POST на скачивание."""
2
+
3
+ import random
4
+ import time
5
+
6
+ import requests
7
+
8
+ from .errors import DownloadError
9
+
10
+ BASE_URL = "https://www.fedstat.ru"
11
+
12
+ # Пул реалистичных User-Agent (разные браузеры/ОС) — ротируется между сессиями,
13
+ # чтобы снизить шанс антибота/кэширования на стороне fedstat.
14
+ USER_AGENTS = [
15
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
16
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
17
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
18
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
19
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
20
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0",
21
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0",
22
+ "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
23
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
24
+ "Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
25
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
26
+ "Mozilla/5.0 (X11; Fedora; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
27
+ ]
28
+
29
+
30
+ def _build_headers(user_agent):
31
+ """Набор «браузерных» заголовков (снижает шанс блокировки/кэша)."""
32
+ return {
33
+ "User-Agent": user_agent,
34
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
35
+ "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
36
+ "Accept-Encoding": "gzip, deflate, br",
37
+ "Connection": "keep-alive",
38
+ "Upgrade-Insecure-Requests": "1",
39
+ "Sec-Fetch-Dest": "document",
40
+ "Sec-Fetch-Mode": "navigate",
41
+ "Sec-Fetch-Site": "none",
42
+ "Sec-Fetch-User": "?1",
43
+ # аналог Cmd+Shift+R — не отдавать кэшированную страницу/токен
44
+ "Cache-Control": "no-cache",
45
+ "Pragma": "no-cache",
46
+ }
47
+
48
+
49
+ # для обратной совместимости (использовалось в ноутбуках снятия фикстур)
50
+ DEFAULT_HEADERS = _build_headers(USER_AGENTS[0])
51
+
52
+
53
+ class FedstatClient:
54
+ """Тонкая обёртка над requests.Session с ретраями (сайт часто лагает).
55
+
56
+ rotate_user_agent: на каждую свежую сессию выбирать случайный User-Agent из пула.
57
+ reset_session(): начать чистую сессию (новые cookies + новый UA) — аналог
58
+ жёсткого обновления страницы (Cmd+Shift+R), помогает при 302/ошибке CSRF.
59
+ """
60
+
61
+ def __init__(self, base_url=BASE_URL, timeout=180.0, retry_max_times=3,
62
+ verify=True, session=None, user_agent=None, rotate_user_agent=True,
63
+ warm_up=True):
64
+ self.base_url = base_url.rstrip("/")
65
+ self.timeout = timeout
66
+ self.retry_max_times = retry_max_times
67
+ self.verify = verify
68
+ self.rotate_user_agent = rotate_user_agent
69
+ self.warm_up = warm_up
70
+ self._warmed = False
71
+ self._user_agent = user_agent or random.choice(USER_AGENTS)
72
+ self.session = session or requests.Session()
73
+ self.session.headers.update(_build_headers(self._user_agent))
74
+
75
+ def reset_session(self):
76
+ """Чистая сессия: новые cookies и (при rotate_user_agent) новый User-Agent."""
77
+ if self.rotate_user_agent:
78
+ self._user_agent = random.choice(USER_AGENTS)
79
+ self.session = requests.Session()
80
+ self.session.headers.update(_build_headers(self._user_agent))
81
+ self._warmed = False
82
+ return self
83
+
84
+ def _warm_up(self):
85
+ """Зайти на главную, чтобы получить стартовые cookies сессии (как браузер).
86
+
87
+ Best-effort: ошибки прогрева не критичны и игнорируются.
88
+ """
89
+ if self._warmed or not self.warm_up:
90
+ return
91
+ try:
92
+ self.session.get(self.base_url + "/", timeout=self.timeout,
93
+ verify=self.verify)
94
+ except requests.RequestException:
95
+ pass
96
+ self._warmed = True
97
+
98
+ def get_indicator_html(self, indicator_id):
99
+ """GET страницы индикатора с ретраями и растущей паузой. Возвращает text."""
100
+ self._warm_up()
101
+ url = f"{self.base_url}/indicator/{indicator_id}"
102
+ last_exc = None
103
+ for attempt in range(self.retry_max_times):
104
+ try:
105
+ resp = self.session.get(url, timeout=self.timeout, verify=self.verify)
106
+ except requests.RequestException as exc:
107
+ last_exc = exc
108
+ else:
109
+ if resp.status_code == 200:
110
+ return resp.text
111
+ if resp.status_code == 403:
112
+ raise DownloadError(
113
+ f"fedstat вернул 403 (Forbidden) для {indicator_id}: "
114
+ "запрос заблокирован антиботом. Попробуйте задать заголовки/UA."
115
+ )
116
+ if resp.status_code != 503: # 503 = перегрузка, имеет смысл повторить
117
+ raise DownloadError(
118
+ f"fedstat вернул HTTP {resp.status_code} для {indicator_id}."
119
+ )
120
+ last_exc = DownloadError(f"HTTP {resp.status_code}")
121
+ time.sleep(2 ** attempt) # 1, 2, 4 ... сек
122
+ raise DownloadError(
123
+ f"Не удалось получить страницу индикатора {indicator_id}: {last_exc}"
124
+ )
125
+
126
+ def download(self, body, data_format="sdmx", referer=None):
127
+ """POST на downloadData.do. Возвращает сырые байты (SDMX/Excel).
128
+
129
+ Без ретраев: CSRF-токен одноразовый. Повтор — на уровне api.load
130
+ (перезабор токена + новая попытка). referer — URL страницы индикатора,
131
+ с которой «пришёл» запрос (fedstat может проверять Referer).
132
+ """
133
+ url = f"{self.base_url}/indicator/downloadData.do?format={data_format}"
134
+ headers = {
135
+ "Content-Type": "application/x-www-form-urlencoded",
136
+ "Referer": referer or f"{self.base_url}/indicator/",
137
+ "Origin": self.base_url,
138
+ "Sec-Fetch-Dest": "document",
139
+ "Sec-Fetch-Mode": "navigate",
140
+ "Sec-Fetch-Site": "same-origin",
141
+ "Sec-Fetch-User": "?1",
142
+ }
143
+ try:
144
+ resp = self.session.post(
145
+ url, data=body.encode("utf-8"), headers=headers,
146
+ timeout=self.timeout, verify=self.verify, allow_redirects=False,
147
+ )
148
+ except requests.RequestException as exc:
149
+ raise DownloadError(f"POST на скачивание не удался: {exc}") from exc
150
+
151
+ status = resp.status_code
152
+ ctype = resp.headers.get("content-type", "")
153
+
154
+ if status == 302:
155
+ raise DownloadError(
156
+ "fedstat отклонил запрос (302). Чаще всего это значит, что выбранная "
157
+ "комбинация фильтров НЕ содержит данных — проверьте значения фильтров. "
158
+ "Типичный подвох: похожие значения одного поля (например для ОКАТО за 2023 "
159
+ "данные лежат под 'Российская Федерация без учёта новых субъектов', "
160
+ "а не под 'Российская Федерация'). Реже причина — устаревший CSRF-токен."
161
+ )
162
+ if status == 403:
163
+ raise DownloadError("fedstat вернул 403 (Forbidden): заблокировано антиботом.")
164
+ if status == 503:
165
+ raise DownloadError("fedstat вернул 503 (Service Unavailable): сервер перегружен.")
166
+ if status != 200:
167
+ raise DownloadError(f"fedstat вернул HTTP {status}.")
168
+
169
+ if not any(t in ctype for t in ("xml", "excel", "octet-stream")):
170
+ preview = resp.content[:400].decode("utf-8", "replace")
171
+ if "csrf" in preview.lower():
172
+ raise DownloadError(
173
+ "Проверка CSRF-токена не пройдена (токен устарел/использован). "
174
+ "Повторите запрос заново."
175
+ )
176
+ raise DownloadError(
177
+ f"Ожидались данные, а пришёл content-type={ctype!r}. Начало ответа: {preview[:200]}"
178
+ )
179
+ return resp.content
fedstat/discovery.py ADDED
@@ -0,0 +1,183 @@
1
+ """Discovery: разбор страницы индикатора в таблицу фильтров (data_ids) + CSRF.
2
+
3
+ Здесь только парсинг (без сети), поэтому легко тестируется на сохранённом HTML.
4
+ Сетевую часть выполняет client.FedstatClient, а связывает всё api.get_data_ids.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from urllib.parse import quote
9
+
10
+ from lxml import html as lxml_html
11
+
12
+ from .errors import IndicatorPageError
13
+ from .jsparse import parse_js1, parse_js2
14
+ from .utils import normalize, xpath_literal
15
+
16
+ # left_columns/top_columns/groups/filterObjectIds -> тип расположения поля
17
+ _OBJECT_RENAME = {
18
+ "left_columns": "lineObjectIds",
19
+ "top_columns": "columnObjectIds",
20
+ "groups": "lineObjectIds",
21
+ "filterObjectIds": "lineObjectIds",
22
+ }
23
+
24
+
25
+ @dataclass
26
+ class DataIds:
27
+ """Результат discovery по одному индикатору.
28
+
29
+ rows: список dict(filter_field_id, filter_field_title, filter_value_id,
30
+ filter_value_title, filter_field_object_ids)
31
+ object_map: field_id -> 'lineObjectIds'|'columnObjectIds'|'filterObjectIds'
32
+ """
33
+
34
+ indicator_id: str
35
+ indicator_title: str
36
+ rows: list = field(default_factory=list)
37
+ object_map: dict = field(default_factory=dict)
38
+ csrf_token_name: str = None
39
+ csrf_token: str = None
40
+
41
+ # --- удобные представления ---------------------------------------------
42
+ def fields(self):
43
+ """OrderedDict: filter_field_title -> {field_id, object, values: [titles]}."""
44
+ from collections import OrderedDict
45
+
46
+ out = OrderedDict()
47
+ for r in self.rows:
48
+ info = out.setdefault(
49
+ r["filter_field_title"],
50
+ {"field_id": r["filter_field_id"],
51
+ "object": r["filter_field_object_ids"], "values": []},
52
+ )
53
+ info["values"].append(r["filter_value_title"])
54
+ return out
55
+
56
+ def to_frame(self):
57
+ """DataFrame со всеми полями и значениями (то, что видит пользователь)."""
58
+ import pandas as pd
59
+
60
+ return pd.DataFrame(self.rows)[
61
+ ["filter_field_title", "filter_value_title",
62
+ "filter_field_id", "filter_value_id", "filter_field_object_ids"]
63
+ ]
64
+
65
+ def template(self):
66
+ """Готовый словарь фильтров {field_title: '*'} для правки пользователем."""
67
+ return {title: "*" for title in self.fields()}
68
+
69
+
70
+ def _find_data_script(doc):
71
+ for node in doc.xpath(".//script"):
72
+ txt = node.text_content()
73
+ if "filters: {" in txt and "left_columns: [" in txt:
74
+ return txt
75
+ scripts = doc.xpath(".//script")
76
+ if len(scripts) >= 12: # историческое поведение R (12-й скрипт)
77
+ return scripts[11].text_content()
78
+ return None
79
+
80
+
81
+ def _extract_csrf(doc):
82
+ holder = doc.xpath("//div[@id='downloadTokenHolder']")
83
+ if not holder:
84
+ return None, None
85
+ name = holder[0].xpath(".//input[@name='struts.token.name']/@value")
86
+ if not name:
87
+ return None, None
88
+ token_name = name[0]
89
+ token = holder[0].xpath(f".//input[@name={xpath_literal(token_name)}]/@value")
90
+ return token_name, (token[0] if token else None)
91
+
92
+
93
+ def _indicator_title_from_html(doc, fallback):
94
+ for xp in (".//h1", ".//title"):
95
+ node = doc.xpath(xp)
96
+ if node and normalize(node[0].text_content()):
97
+ return node[0].text_content().strip()
98
+ return fallback
99
+
100
+
101
+ def parse_indicator_page(html_text, indicator_id):
102
+ """HTML страницы индикатора -> DataIds. Без сети."""
103
+ doc = lxml_html.fromstring(html_text)
104
+
105
+ script = _find_data_script(doc)
106
+ if script is None:
107
+ raise IndicatorPageError(
108
+ "Не найден <script> с filters/left_columns на странице индикатора "
109
+ f"{indicator_id} (структура страницы могла измениться)"
110
+ )
111
+ lines = script.split("\n")
112
+ filters = parse_js1(lines)
113
+ objects = parse_js2(lines)
114
+
115
+ object_map = {}
116
+ for key, val in objects.items():
117
+ obj_type = _OBJECT_RENAME.get(key, "lineObjectIds")
118
+ ids = val if isinstance(val, list) else [val]
119
+ for fid in ids:
120
+ object_map[str(fid)] = obj_type
121
+ object_map.setdefault("0", "filterObjectIds") # сам индикатор — скрытый фильтр
122
+
123
+ rows = []
124
+ indicator_title = None
125
+ for field_id, fld in filters.items():
126
+ field_id = str(field_id)
127
+ title = fld.get("title", "")
128
+ values = fld.get("values", {}) or {}
129
+ obj_type = object_map.get(field_id, "lineObjectIds")
130
+ for value_id, value in values.items():
131
+ vtitle = value.get("title", "") if isinstance(value, dict) else str(value)
132
+ rows.append({
133
+ "filter_field_id": field_id,
134
+ "filter_field_title": title,
135
+ "filter_value_id": str(value_id),
136
+ "filter_value_title": vtitle.replace("&quot;", '"'),
137
+ "filter_field_object_ids": obj_type,
138
+ })
139
+ if field_id == "0" and len(values) == 1:
140
+ (_, val), = values.items()
141
+ indicator_title = val.get("title") if isinstance(val, dict) else str(val)
142
+
143
+ token_name, token = _extract_csrf(doc)
144
+ if indicator_title is None:
145
+ indicator_title = _indicator_title_from_html(doc, str(indicator_id))
146
+
147
+ return DataIds(
148
+ indicator_id=str(indicator_id),
149
+ indicator_title=indicator_title,
150
+ rows=rows,
151
+ object_map=object_map,
152
+ csrf_token_name=token_name,
153
+ csrf_token=token,
154
+ )
155
+
156
+
157
+ def build_download_body(data_ids, selected_rows):
158
+ """Собирает URL-encoded тело POST-запроса на downloadData.do (как в R)."""
159
+ parts = [
160
+ ("title", data_ids.indicator_title),
161
+ ("struts.token.name", data_ids.csrf_token_name),
162
+ (data_ids.csrf_token_name, data_ids.csrf_token),
163
+ ("id", data_ids.indicator_id),
164
+ ]
165
+
166
+ field_types = {}
167
+ for r in selected_rows:
168
+ field_types.setdefault(r["filter_field_id"], r["filter_field_object_ids"])
169
+
170
+ for fid, t in field_types.items():
171
+ if t == "lineObjectIds":
172
+ parts.append(("lineObjectIds", fid))
173
+ elif t == "columnObjectIds":
174
+ parts.append(("columnObjectIds", fid))
175
+ for r in selected_rows:
176
+ parts.append(("selectedFilterIds", f'{r["filter_field_id"]}_{r["filter_value_id"]}'))
177
+ for fid, t in field_types.items():
178
+ if t == "filterObjectIds":
179
+ parts.append(("filterObjectIds", fid))
180
+ if "0" not in field_types:
181
+ parts.append(("filterObjectIds", "0"))
182
+
183
+ return "&".join(f"{k}={quote(str(v), safe='')}" for k, v in parts)
fedstat/errors.py ADDED
@@ -0,0 +1,25 @@
1
+ """Типизированные исключения библиотеки fedstat."""
2
+
3
+
4
+ class FedstatError(Exception):
5
+ """Базовое исключение библиотеки."""
6
+
7
+
8
+ class IndicatorPageError(FedstatError):
9
+ """Не удалось разобрать страницу индикатора (структура JS/HTML изменилась)."""
10
+
11
+
12
+ class CSRFTokenError(FedstatError):
13
+ """Не найден CSRF-токен на странице индикатора."""
14
+
15
+
16
+ class FilterError(FedstatError):
17
+ """Неверное имя поля-фильтра или значение."""
18
+
19
+
20
+ class DownloadError(FedstatError):
21
+ """Сервер отклонил запрос данных (302 / 403 / 503 / CSRF / нет данных)."""
22
+
23
+
24
+ class SDMXParseError(FedstatError):
25
+ """Не удалось разобрать SDMX-ответ (например, вместо данных пришёл HTML)."""
fedstat/filters.py ADDED
@@ -0,0 +1,87 @@
1
+ """Отбор строк data_ids по человекочитаемым фильтрам.
2
+
3
+ Правила:
4
+ * ключ фильтра — заголовок поля (как на fedstat.ru); сравнение нормализованное
5
+ (регистр и лишние пробелы игнорируются);
6
+ * значение "*" или пропущенное поле = взять все значения этого поля;
7
+ * значение может быть строкой или списком строк;
8
+ * неизвестное поле или значение -> FilterError с подсказкой похожего варианта.
9
+ """
10
+
11
+ import difflib
12
+
13
+ from .errors import FilterError
14
+ from .utils import normalize
15
+
16
+
17
+ def _suggest(name, candidates):
18
+ match = difflib.get_close_matches(
19
+ normalize(name), [normalize(c) for c in candidates], n=1, cutoff=0.5
20
+ )
21
+ if not match:
22
+ return ""
23
+ real = candidates[[normalize(c) for c in candidates].index(match[0])]
24
+ return f" Возможно, вы имели в виду {real!r}?"
25
+
26
+
27
+ def _match_values(field_title, wanted, available_rows):
28
+ """Возвращает подмножество строк одного поля, подходящих под `wanted`."""
29
+ if wanted == "*" or wanted is None:
30
+ return available_rows
31
+
32
+ wanted_list = wanted if isinstance(wanted, (list, tuple, set)) else [wanted]
33
+ titles = [r["filter_value_title"] for r in available_rows]
34
+
35
+ selected = []
36
+ for w in wanted_list:
37
+ wn = normalize(w)
38
+ exact = [r for r in available_rows if normalize(r["filter_value_title"]) == wn]
39
+ if exact:
40
+ selected.extend(exact)
41
+ continue
42
+ substr = [r for r in available_rows if wn in normalize(r["filter_value_title"])]
43
+ if substr:
44
+ selected.extend(substr)
45
+ continue
46
+ raise FilterError(
47
+ f"Значение {w!r} не найдено в поле {field_title!r}."
48
+ + _suggest(w, titles)
49
+ )
50
+ # убрать дубли, сохранив порядок
51
+ seen, out = set(), []
52
+ for r in selected:
53
+ k = (r["filter_field_id"], r["filter_value_id"])
54
+ if k not in seen:
55
+ seen.add(k)
56
+ out.append(r)
57
+ return out
58
+
59
+
60
+ def select_rows(data_ids, filters=None):
61
+ """DataIds + dict(field_title -> value|list|'*') -> список выбранных строк.
62
+
63
+ Поля, не упомянутые в filters, берутся целиком (все значения).
64
+ """
65
+ filters = filters or {}
66
+ fields = data_ids.fields()
67
+ field_titles = list(fields)
68
+ norm_to_title = {normalize(t): t for t in field_titles}
69
+
70
+ # проверка ключей на опечатки
71
+ for key in filters:
72
+ if normalize(key) not in norm_to_title:
73
+ raise FilterError(
74
+ f"Поля {key!r} нет у индикатора {data_ids.indicator_id}."
75
+ + _suggest(key, field_titles)
76
+ + f" Доступные поля: {field_titles}"
77
+ )
78
+
79
+ # нормализованный доступ к запрошенным значениям
80
+ requested = {normalize(k): v for k, v in filters.items()}
81
+
82
+ selected = []
83
+ for title in field_titles:
84
+ rows_of_field = [r for r in data_ids.rows if r["filter_field_title"] == title]
85
+ wanted = requested.get(normalize(title), "*") # пропущено -> все значения
86
+ selected.extend(_match_values(title, wanted, rows_of_field))
87
+ return selected
fedstat/jsparse.py ADDED
@@ -0,0 +1,60 @@
1
+ """Разбор встроенного в страницу индикатора JavaScript.
2
+
3
+ Порт parse_js1/parse_js2 из R-пакета fedstatAPIr. На странице индикатора
4
+ fedstat.ru нужные данные (поля-фильтры, их значения и расположение) лежат не в
5
+ JSON, а в исходнике JS. Здесь тот же трюк: «слова» вне уже закавыченных участков
6
+ обрамляются кавычками, ' меняется на ", результат оборачивается в {...} и
7
+ парсится как JSON.
8
+ """
9
+
10
+ import json
11
+ import re
12
+
13
+ from .errors import IndicatorPageError
14
+
15
+ # Вставляет ' на границе слова, только если это НЕ внутри одинарных кавычек
16
+ # (в остатке строки чётное число кавычек). Эквивалент R-регекса из parse_js*.
17
+ _WORD_QUOTE = re.compile(r"\b(?=([^']*'[^']*')*[^']*$)")
18
+ # JS допускает хвостовые запятые перед } или ], JSON — нет.
19
+ _TRAILING_COMMA = re.compile(r",\s*([}\]])")
20
+
21
+
22
+ def js_lines_to_json(lines):
23
+ """Список строк JS -> объект Python (dict), как в parse_js1/parse_js2."""
24
+ quoted = [_WORD_QUOTE.sub("'", ln) for ln in lines]
25
+ text = "\n".join(quoted).replace("'", '"')
26
+ text = "{" + text + "}"
27
+ text = _TRAILING_COMMA.sub(r"\1", text)
28
+ try:
29
+ return json.loads(text)
30
+ except json.JSONDecodeError as exc: # pragma: no cover - диагностика
31
+ raise IndicatorPageError(
32
+ f"Не удалось преобразовать JS в JSON: {exc}"
33
+ ) from exc
34
+
35
+
36
+ def _slice(lines, start_pat, end_pat, start_off, end_off):
37
+ start = end = None
38
+ for i, ln in enumerate(lines):
39
+ if start is None and re.search(start_pat, ln):
40
+ start = i
41
+ elif start is not None and re.search(end_pat, ln):
42
+ end = i
43
+ break
44
+ if start is None or end is None:
45
+ raise IndicatorPageError(
46
+ f"Не найдены границы блока в JS: {start_pat!r} .. {end_pat!r}"
47
+ )
48
+ return lines[start + start_off : end + end_off + 1]
49
+
50
+
51
+ def parse_js1(script_lines):
52
+ """filters: { field_id: {title, values: {value_id: {title}}} }."""
53
+ block = _slice(script_lines, r"filters: \{", r"left_columns: \[", 1, -2)
54
+ return js_lines_to_json(block)
55
+
56
+
57
+ def parse_js2(script_lines):
58
+ """left_columns/top_columns/groups/filterObjectIds -> списки field_id по типам."""
59
+ block = _slice(script_lines, r"left_columns: \[", r"grid\.init\(\);", 0, -2)
60
+ return js_lines_to_json(block)
fedstat/reshape.py ADDED
@@ -0,0 +1,37 @@
1
+ """Преобразование нормализованного ("длинного") DataFrame в "широкий" (pivot)."""
2
+
3
+ from .errors import FedstatError
4
+
5
+
6
+ def to_wide(df, columns="PERIOD", values="VALUE", index=None, aggfunc="mean"):
7
+ """Разносит одно измерение по столбцам (обёртка над pandas.pivot_table).
8
+
9
+ df: результат fedstat.load(...) (длинный вид).
10
+ columns: какое поле развернуть в столбцы (напр. "PERIOD" или "TIME").
11
+ values: столбец со значениями (по умолчанию "VALUE").
12
+ index: что оставить в строках. По умолчанию — все прочие столбцы,
13
+ кроме `columns` и `values` (т.е. полный ключ наблюдения).
14
+ aggfunc: как агрегировать дубли (по умолчанию среднее).
15
+
16
+ Возвращает DataFrame с обычным (сброшенным) индексом.
17
+ """
18
+ import pandas as pd
19
+
20
+ for col in (columns, values):
21
+ if col not in df.columns:
22
+ raise FedstatError(
23
+ f"Столбца {col!r} нет в данных. Доступные: {list(df.columns)}"
24
+ )
25
+
26
+ if index is None:
27
+ index = [c for c in df.columns if c not in (columns, values)]
28
+ if not index:
29
+ raise FedstatError(
30
+ "Не осталось столбцов для строк (index). Укажите index явно."
31
+ )
32
+
33
+ wide = pd.pivot_table(df, index=index, columns=columns, values=values,
34
+ aggfunc=aggfunc)
35
+ wide = wide.reset_index()
36
+ wide.columns.name = None
37
+ return wide
fedstat/sdmx.py ADDED
@@ -0,0 +1,111 @@
1
+ """Разбор SDMX-ML (GenericData v1.0) в нормализованный ("длинный") DataFrame.
2
+
3
+ Структура ответа fedstat:
4
+ <CodeLists> -- справочники: код -> человекочитаемое название
5
+ <structure:CodeList id=...><structure:Name/>
6
+ <structure:Code value=...><structure:Description/></structure:Code>
7
+ <DataSet>
8
+ <generic:Series>
9
+ <generic:SeriesKey> <generic:Value concept=.. value=../> -- измерения
10
+ <generic:Attributes> <generic:Value concept=.. value=../> -- напр. EI, PERIOD
11
+ <generic:Obs> <generic:Time/> <generic:ObsValue value=../>
12
+
13
+ Итог: одна строка = одно наблюдение. Столбцы измерений расшифрованы по CodeLists,
14
+ ObsValue приведён к числу (десятичная запятая -> точка).
15
+ """
16
+
17
+ from lxml import etree
18
+
19
+ from .errors import SDMXParseError
20
+
21
+
22
+ def _lname(tag):
23
+ return tag.rsplit("}", 1)[-1]
24
+
25
+
26
+ def _iter_descendants(el, name):
27
+ return (c for c in el.iter() if _lname(c.tag) == name)
28
+
29
+
30
+ def sdmx_to_dataframe(source, *, try_numeric=True, with_codes=False, drop_empty=False):
31
+ """Разбирает SDMX в pandas.DataFrame.
32
+
33
+ source: путь к файлу, bytes или file-like.
34
+ try_numeric: привести VALUE к числу (запятая -> точка).
35
+ with_codes: добавить исходные коды измерений колонками '<поле>_code'.
36
+ drop_empty: выбросить строки без значения.
37
+ """
38
+ import pandas as pd
39
+
40
+ if isinstance(source, (bytes, bytearray)):
41
+ head = bytes(source[:512]).lstrip().lower()
42
+ if head.startswith(b"<!doctype html") or head.startswith(b"<html"):
43
+ raise SDMXParseError(
44
+ "Вместо SDMX получен HTML (вероятно, страница ошибки или истёкший CSRF-токен)."
45
+ )
46
+ import io
47
+
48
+ source = io.BytesIO(source)
49
+
50
+ codelists = {}
51
+ rows = []
52
+ try:
53
+ for _, el in etree.iterparse(source, events=("end",), recover=True):
54
+ ln = _lname(el.tag)
55
+ if ln == "CodeList":
56
+ cid = el.get("id")
57
+ name = next((c.text for c in el if _lname(c.tag) == "Name"), cid)
58
+ codes = {}
59
+ for code in el:
60
+ if _lname(code.tag) == "Code":
61
+ desc = next(
62
+ (d.text or "" for d in code if _lname(d.tag) == "Description"),
63
+ "",
64
+ )
65
+ codes[code.get("value")] = desc
66
+ codelists[cid] = {"name": name, "codes": codes}
67
+ el.clear()
68
+ elif ln == "Series":
69
+ key, attrs = {}, {}
70
+ for v in _iter_descendants(el, "Value"):
71
+ parent = _lname(v.getparent().tag)
72
+ (key if parent == "SeriesKey" else attrs)[v.get("concept")] = v.get("value")
73
+ for obs in _iter_descendants(el, "Obs"):
74
+ tv = next((c.text for c in obs if _lname(c.tag) == "Time"), None)
75
+ ov = next(
76
+ (c.get("value") for c in obs if _lname(c.tag) == "ObsValue"), None
77
+ )
78
+ row = dict(key)
79
+ row.update(attrs)
80
+ row["TIME"] = tv
81
+ row["VALUE"] = ov
82
+ rows.append(row)
83
+ el.clear()
84
+ except etree.XMLSyntaxError as exc:
85
+ raise SDMXParseError(f"Ошибка разбора SDMX-XML: {exc}") from exc
86
+
87
+ if not rows:
88
+ raise SDMXParseError(
89
+ "В SDMX не найдено наблюдений (пустой ответ или неверные фильтры)."
90
+ )
91
+
92
+ df = pd.DataFrame(rows)
93
+
94
+ # расшифровка кодов измерений в подписи
95
+ names = {cid: (cl["name"] or cid) for cid, cl in codelists.items()}
96
+ for cid, cl in codelists.items():
97
+ if cid in df.columns:
98
+ if with_codes:
99
+ df[f"{names[cid]}_code"] = df[cid]
100
+ df[cid] = df[cid].map(cl["codes"]).fillna(df[cid])
101
+ df = df.rename(columns=names)
102
+
103
+ if try_numeric:
104
+ df["VALUE"] = pd.to_numeric(
105
+ df["VALUE"].astype("string").str.replace(",", ".", regex=False),
106
+ errors="coerce",
107
+ )
108
+ if drop_empty:
109
+ df = df[df["VALUE"].notna()].reset_index(drop=True)
110
+
111
+ return df
fedstat/utils.py ADDED
@@ -0,0 +1,18 @@
1
+ """Мелкие вспомогательные функции."""
2
+
3
+ import re
4
+
5
+
6
+ def normalize(s):
7
+ """Нормализация заголовков: убрать лишние пробелы, привести к нижнему регистру."""
8
+ return re.sub(r"\s+", " ", (s or "")).strip().lower()
9
+
10
+
11
+ def xpath_literal(s):
12
+ """Безопасный строковый литерал для XPath (учёт кавычек в значении)."""
13
+ if '"' not in s:
14
+ return f'"{s}"'
15
+ if "'" not in s:
16
+ return f"'{s}'"
17
+ parts = s.split('"')
18
+ return "concat(" + ", '\"', ".join(f'"{p}"' for p in parts) + ")"
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: fedstat
3
+ Version: 0.1.0
4
+ Summary: Неофициальный Python-клиент к fedstat.ru (ЕМИСС): показатели с фильтрами в pandas.DataFrame
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: fedstat,emiss,rosstat,sdmx,statistics,open-data,pandas
8
+ Author: Timofei Ryadovoi
9
+ Author-email: timtrue18@gmail.com
10
+ Requires-Python: >=3.11,<4.0
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Natural Language :: Russian
17
+ Requires-Dist: lxml (>=5.0)
18
+ Requires-Dist: openpyxl (>=3.1.5,<4.0.0)
19
+ Requires-Dist: pandas (>=3.0.6,<4.0.0)
20
+ Requires-Dist: requests (>=2.31)
21
+ Project-URL: Homepage, https://github.com/timryadovouu/fedstat
22
+ Project-URL: Issues, https://github.com/timryadovouu/fedstat/issues
23
+ Project-URL: Repository, https://github.com/timryadovouu/fedstat
24
+ Description-Content-Type: text/markdown
25
+
26
+ # fedstat
27
+
28
+ ![tests](https://github.com/timryadovouu/fedstat/actions/workflows/tests.yml/badge.svg)
29
+
30
+ Неофициальный Python-клиент к [fedstat.ru](https://www.fedstat.ru) (ЕМИСС) —
31
+ порт идей R-пакета [`fedstatAPIr`](https://github.com/DenchPokepon/fedstatAPIr).
32
+ Скачивает данные показателей с фильтрами и отдаёт нормализованный
33
+ `pandas.DataFrame`.
34
+
35
+ Статус: **MVP** — `list_filters` / `filter_template` / `load` (формат SDMX).
36
+ Дальше планируется догнать полный функционал R-пакета (Excel, режим словаря,
37
+ расширенные ретраи и т.д.).
38
+
39
+ ## Установка
40
+
41
+ **Для использования** (после публикации на PyPI — планируется):
42
+
43
+ ```bash
44
+ pip install fedstat
45
+ ```
46
+
47
+ **Пока проект на GitHub** — ставится напрямую из репозитория, без ручного клонирования:
48
+
49
+ ```bash
50
+ pip install git+ssh://git@github.com/timryadovouu/fedstat.git
51
+ ```
52
+
53
+ **Для разработки** (клон + окружение poetry):
54
+
55
+ ```bash
56
+ git clone git@github.com:timryadovouu/fedstat.git
57
+ cd fedstat
58
+ poetry install
59
+ ```
60
+
61
+ `poetry install` ставит зависимости в локальное окружение проекта — эта команда
62
+ предполагает, что исходники уже склонированы (вариант «для разработки»).
63
+
64
+ ## Пример
65
+
66
+ Готовый ноутбук: [`notebooks/example.ipynb`](notebooks/example.ipynb).
67
+
68
+ ## Быстрый старт
69
+
70
+ ```python
71
+ import fedstat
72
+
73
+ # 1. Какие фильтры есть у показателя (id — из URL вида /indicator/31452)
74
+ fedstat.list_filters("31452") # DataFrame: поле -> допустимые значения
75
+
76
+ # 2. Шаблон со всеми полями (значения по умолчанию "*" = все)
77
+ f = fedstat.filter_template("31452")
78
+ f["Год"] = "2023"
79
+ f["Рынок жилья"] = "Первичный рынок жилья"
80
+
81
+ # 3. Скачать нормализованный ("длинный") DataFrame
82
+ df = fedstat.load("31452", filters=f)
83
+ df.to_csv("cena.csv", index=False)
84
+ df.to_excel("cena.xlsx", index=False)
85
+
86
+ # 4. При желании — "широкий" вид (одно измерение по столбцам)
87
+ wide = fedstat.to_wide(df, columns="PERIOD", values="VALUE", index="TIME")
88
+ ```
89
+
90
+ Правила фильтров:
91
+ - ключ — заголовок поля, как на сайте (регистр и лишние пробелы игнорируются);
92
+ - значение — строка, список строк или `"*"` (все значения);
93
+ - пропущенное поле = все значения;
94
+ - неверное имя поля/значения -> `FilterError` с подсказкой похожего варианта.
95
+
96
+ ## Формат вывода
97
+
98
+ Одна строка = одно наблюдение. Столбцы измерений расшифрованы из справочников
99
+ (codelists) в человекочитаемые названия, `VALUE` приведён к числу (десятичная
100
+ запятая -> точка). Опции `load(...)`:
101
+ - `with_codes=True` — добавить исходные коды измерений (`<поле>_code`);
102
+ - `drop_empty=True` — выбросить строки без значения;
103
+ - `try_numeric=False` — оставить `VALUE` строкой.
104
+
105
+ ## Модули
106
+
107
+ | модуль | назначение |
108
+ |---|---|
109
+ | `fedstat.client` | HTTP-сессия, заголовки, ретраи GET, POST на скачивание |
110
+ | `fedstat.discovery` | разбор страницы индикатора -> `DataIds` (поля, значения, CSRF) |
111
+ | `fedstat.jsparse` | разбор встроенного JS (порт `parse_js1/parse_js2`) |
112
+ | `fedstat.filters` | отбор строк по фильтрам, шаблон, подсказки |
113
+ | `fedstat.sdmx` | SDMX -> нормализованный `DataFrame` |
114
+ | `fedstat.api` | высокоуровневые `load` / `list_filters` / `filter_template` |
115
+
116
+ ## Разработка и тесты
117
+
118
+ Тесты гоняются офлайн на сохранённых фикстурах (`fixtures/`), сеть не нужна:
119
+
120
+ ```bash
121
+ poetry run pytest -q
122
+ ```
123
+
124
+ Снятие новых фикстур с живого сайта — ноутбук `notebooks/capture_fixtures.ipynb`
125
+ (нужен доступ к fedstat.ru).
126
+
@@ -0,0 +1,14 @@
1
+ fedstat/__init__.py,sha256=Sp1SlsxhhVBACxrFgxv2Lj434wWuJRgyT7y10gDrp9Y,1136
2
+ fedstat/api.py,sha256=Gve_RmdsM6XUOZBkwvHnabzPgtSNdyqMUMjIzGyM4KE,4151
3
+ fedstat/client.py,sha256=q9J_ZKN5z0pwetK-kLAGgZ1a5GwzXjD3-7NYm1zq0HU,9755
4
+ fedstat/discovery.py,sha256=ZtFOkGSOCpLEcvh3EnzlQFsMd_Z85T9EdsiKNdrnClI,6916
5
+ fedstat/errors.py,sha256=OshJyxir1kloXBjduq85UxOVfPCXpP3gOEVWbRveXsA,930
6
+ fedstat/filters.py,sha256=NHn7eK4gbJkIR3702IG49IXkCaq7kcU1xnKj8Vhwjwg,3582
7
+ fedstat/jsparse.py,sha256=OusweGNGbtJre4OJqn8egW8lAXtabR-WKILssTrPJCc,2713
8
+ fedstat/reshape.py,sha256=zYUMUH90U3VBXdE9lMhBuYImBu4N8vEmScD02wz_HgU,1752
9
+ fedstat/sdmx.py,sha256=kopTqLYVIe_JbXIrMGu2OxAJENDkuAhBl4oNlCvNbo4,4581
10
+ fedstat/utils.py,sha256=9l2LYhPzeXTJtWdj6_WT-BbqFA_UrJUu8Ofj4vKFCPU,641
11
+ fedstat-0.1.0.dist-info/METADATA,sha256=4Z8LKWppTHeQVCjl8hOKg-aqpokk8COnE3JGPA1p0qc,5690
12
+ fedstat-0.1.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
13
+ fedstat-0.1.0.dist-info/licenses/LICENSE,sha256=3XAIMZgA_O17WMd8QK5TxYD30nExrfx_bxEK6FpULX8,1073
14
+ fedstat-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Timofei Ryadovoi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.