1"""Eurostat データソース.
2
3公式 v1 JSON-stat API: https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/
4geo は ISO-3166 alpha-2 (DE/FR/JP...) 形式が正解。
5SDMX 2.1 API はドット区切りキーが必要で複雑なため v1 を使用する。
6"""
7from __future__ import annotations
8
9import logging
10from typing import Any, Iterable, Optional
11
12import httpx
13
14from ..models import DataSource, RawIndicatorRecord
15from ..normalizer import safe_float
16from .base import BaseSource
17
18logger = logging.getLogger(__name__)
19
20
21
22EUROSTAT_COUNTRIES: list[dict[str, str]] = [
23 {"code": cc, "name": nm, "region": "Europe (EU)"}
24 for cc, nm in [
25 ("AT", "Austria"), ("BE", "Belgium"), ("BG", "Bulgaria"),
26 ("CY", "Cyprus"), ("CZ", "Czechia"), ("DE", "Germany"),
27 ("DK", "Denmark"), ("EE", "Estonia"), ("ES", "Spain"),
28 ("FI", "Finland"), ("FR", "France"), ("GR", "Greece"),
29 ("HR", "Croatia"), ("HU", "Hungary"), ("IE", "Ireland"),
30 ("IT", "Italy"), ("LT", "Lithuania"), ("LU", "Luxembourg"),
31 ("LV", "Latvia"), ("MT", "Malta"), ("NL", "Netherlands"),
32 ("PL", "Poland"), ("PT", "Portugal"), ("RO", "Romania"),
33 ("SE", "Sweden"), ("SI", "Slovenia"), ("SK", "Slovakia"),
34 ]
35]
36
37
38assert len(EUROSTAT_COUNTRIES) == 27, len(EUROSTAT_COUNTRIES)
39
40
41
42
43EUROSTAT_DATASETS: list[dict[str, Any]] = [
44 {
45 "code": "namq_10_gdp",
46 "name": "GDP and main components (quarterly)",
47 "indicator_code": "GDP",
48 "indicator_name": "GDP (chain linked volumes, quarterly)",
49 "unit": "CLV10_MEUR",
50
51 "params": {
52 "unit": "CLV10_MEUR",
53 "s_adj": "SCA",
54 "na_item": "B1GQ",
55 },
56 },
57 {
58 "code": "demo_pjan",
59 "name": "Population on 1 January by age and sex",
60 "indicator_code": "POPULATION",
61 "indicator_name": "Population (total, 1 January)",
62 "unit": "persons",
63
64 "params": {"sex": "T", "age": "TOTAL"},
65 },
66 {
67 "code": "une_rt_a",
68 "name": "Unemployment rate (annual)",
69 "indicator_code": "UNEMPLOYMENT",
70 "indicator_name": "Unemployment rate (15-74, % active population)",
71 "unit": "%",
72
73 "params": {"unit": "PC_ACT", "sex": "T", "age": "Y15-74"},
74 },
75 {
76 "code": "tec00118",
77 "name": "HICP - inflation rate (annual)",
78 "indicator_code": "INFLATION",
79 "indicator_name": "HICP inflation rate (all items)",
80 "unit": "%",
81
82 "params": {"unit": "RCH_A_AVG", "coicop18": "TOTAL"},
83 },
84]
85
86
87class EurostatSource(BaseSource):
88 """Eurostat v1 JSON-stat データソース (2026-09-05 実測 200 OK 形式)."""
89
90 source_id = DataSource.EUROSTAT
91 default_url = (
92 "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data"
93 )
94
95 def __init__(
96 self,
97 base_url: Optional[str] = None,
98 timeout: float = 30.0,
99 datasets: Optional[list[dict[str, Any]]] = None,
100 countries: Optional[list[dict[str, str]]] = None,
101 ) -> None:
102 """初期化.
103
104 Args:
105 base_url: ベース URL.
106 timeout: タイムアウト秒.
107 datasets: dataset メタ. None なら既定.
108 countries: 国リスト. None なら EU 27 (ISO2).
109 """
110 super().__init__(base_url=base_url, timeout=timeout)
111 self._datasets = datasets or EUROSTAT_DATASETS
112 self._countries = countries or EUROSTAT_COUNTRIES
113
114 def list_countries(self) -> list[dict[str, Any]]:
115 """国リスト返却."""
116 return list(self._countries)
117
118 def list_indicators(self) -> list[dict[str, Any]]:
119 """指標メタのリストを返す."""
120 out: list[dict[str, Any]] = []
121 for ds in self._datasets:
122 out.append(
123 {
124 "code": ds["indicator_code"],
125 "name": ds["indicator_name"],
126 "unit": ds.get("unit"),
127 "description": ds["name"],
128 }
129 )
130 return out
131
132 def fetch(
133 self,
134 client: httpx.Client,
135 ) -> Iterable[RawIndicatorRecord]:
136 """v1 JSON-stat API からデータ取得.
137
138 形式: GET /statistics/1.0/data/{DATASET}?format=JSON&geo=XX&...
139 レスポンス: JSON-stat 1.0 (id/size/value 辞書 + dimension.time.category.index)
140 """
141 country_map = {c["code"]: c for c in self._countries}
142 for ds in self._datasets:
143 dataset_id = str(ds["code"])
144 for c in self._countries:
145 cc = str(c["code"])
146 params: dict[str, str] = {
147 "format": "JSON",
148 "geo": cc,
149 "sinceTimePeriod": "2015",
150 }
151 ds_params = ds.get("params")
152 if isinstance(ds_params, dict):
153 params.update({str(k): str(v) for k, v in ds_params.items()})
154 url = f"{self.default_url}/{dataset_id}"
155 try:
156 resp = client.get(url, params=params)
157 except httpx.HTTPError as exc:
158 logger.warning(
159 "[eurostat] request failed for %s/%s: %s",
160 dataset_id, cc, exc,
161 )
162 continue
163 if resp.status_code != 200:
164 continue
165 try:
166 payload = resp.json()
167 except Exception as exc:
168 logger.warning("[eurostat] JSON parse failed: %s", exc)
169 continue
170
171 observations = _extract_jsonstat_observations(payload)
172 for year, value in observations:
173 yield RawIndicatorRecord(
174 source=self.source_id,
175 country_code=cc,
176 country_name=country_map.get(cc, {}).get("name", cc),
177 indicator_code=str(ds["indicator_code"]),
178 indicator_name=str(ds["indicator_name"]),
179 year=year,
180 value=safe_float(value),
181 unit=ds.get("unit"),
182 raw_data={"dataset": dataset_id, "country": cc},
183 )
184
185
186def _extract_jsonstat_observations(
187 payload: dict[str, Any],
188) -> list[tuple[int, Any]]:
189 """JSON-stat 1.0 から (年, 値) ペアを抽出.
190
191 value のキーは time 次元の index (最後の次元が time の場合)。
192 time category index を参照して実際の期間文字列に変換する。
193 """
194 obs_pairs: list[tuple[int, Any]] = []
195 dims = payload.get("id")
196 if not isinstance(dims, list) or not dims:
197 return obs_pairs
198 size = payload.get("size")
199 if not isinstance(size, list):
200 return obs_pairs
201
202
203 time_pos = dims.index("time") if "time" in dims else len(dims) - 1
204 dimension = payload.get("dimension")
205 time_cat: dict[str, Any] = {}
206 if isinstance(dimension, dict):
207 time_dim = dimension.get("time")
208 if isinstance(time_dim, dict):
209 category = time_dim.get("category")
210 if isinstance(category, dict):
211 idx = category.get("index")
212 if isinstance(idx, dict):
213 time_cat = idx
214
215
216 n_time = size[time_pos] if time_pos < len(size) else 0
217 periods: list[str] = [""] * n_time
218 for period, pos in time_cat.items():
219 if isinstance(pos, int) and 0 <= pos < n_time:
220 periods[pos] = str(period)
221
222 value = payload.get("value")
223 if not isinstance(value, dict):
224 return obs_pairs
225
226
227 last_dim_is_time = time_pos == len(dims) - 1
228 for k, v in value.items():
229 try:
230 pos = int(k)
231 except (TypeError, ValueError):
232 continue
233 if last_dim_is_time:
234
235 period = periods[pos] if pos < len(periods) else ""
236 else:
237
238 stride = 1
239 strides: list[int] = []
240 for s in reversed(size):
241 strides.append(stride)
242 stride *= int(s)
243 strides.reverse()
244 if time_pos >= len(strides) or not size[time_pos]:
245 continue
246 time_idx_val = (pos // strides[time_pos]) % int(size[time_pos])
247 period = periods[time_idx_val] if time_idx_val < len(periods) else ""
248 year_str = period[:4]
249 try:
250 year = int(year_str)
251 except (TypeError, ValueError):
252 continue
253 obs_pairs.append((year, v))
254 return obs_pairs