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 +43 -0
- fedstat/api.py +79 -0
- fedstat/client.py +179 -0
- fedstat/discovery.py +183 -0
- fedstat/errors.py +25 -0
- fedstat/filters.py +87 -0
- fedstat/jsparse.py +60 -0
- fedstat/reshape.py +37 -0
- fedstat/sdmx.py +111 -0
- fedstat/utils.py +18 -0
- fedstat-0.1.0.dist-info/METADATA +126 -0
- fedstat-0.1.0.dist-info/RECORD +14 -0
- fedstat-0.1.0.dist-info/WHEEL +4 -0
- fedstat-0.1.0.dist-info/licenses/LICENSE +21 -0
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(""", '"'),
|
|
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
|
+

|
|
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,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.
|