1"""World Bank API ソース.
2
3公式: https://api.worldbank.org/v2/
4全 190+ ヶ国の GDP / 人口 / インフレ / 失業率 などを取得できる.
5"""
6from __future__ import annotations
7
8import logging
9from typing import Any, Iterable, Optional
10
11import httpx
12
13from ..models import DataSource, RawIndicatorRecord
14from ..normalizer import safe_float
15from .base import BaseSource
16
17logger = logging.getLogger(__name__)
18
19
20
21WORLDBANK_INDICATORS: list[dict[str, Any]] = [
22 {"code": "NY.GDP.MKTP.CD", "name": "GDP (current US$)", "unit": "USD"},
23 {"code": "NY.GDP.PCAP.CD", "name": "GDP per capita (current US$)", "unit": "USD"},
24 {"code": "SP.POP.TOTL", "name": "Population, total", "unit": "persons"},
25 {"code": "FP.CPI.TOTL.ZG", "name": "Inflation, consumer prices (annual %)", "unit": "%"},
26 {"code": "SL.UEM.TOTL.ZS", "name": "Unemployment (% of total labor force)", "unit": "%"},
27 {"code": "SP.DYN.LE00.IN", "name": "Life expectancy at birth (years)", "unit": "years"},
28 {"code": "NE.RSB.GNFS.CD", "name": "External balance on goods/services (current US$)",
29 "unit": "USD"},
30]
31
32
33
34WORLDBANK_COUNTRIES: list[dict[str, str]] = [
35 {"code": "ARG", "name": "Argentina", "region": "Americas"},
36 {"code": "AUS", "name": "Australia", "region": "Oceania"},
37 {"code": "AUT", "name": "Austria", "region": "Europe (EU)"},
38 {"code": "BEL", "name": "Belgium", "region": "Europe (EU)"},
39 {"code": "BRA", "name": "Brazil", "region": "Americas"},
40 {"code": "CAN", "name": "Canada", "region": "Americas"},
41 {"code": "CHE", "name": "Switzerland", "region": "Europe (EU)"},
42 {"code": "CHL", "name": "Chile", "region": "Americas"},
43 {"code": "CHN", "name": "China", "region": "Asia"},
44 {"code": "COL", "name": "Colombia", "region": "Americas"},
45 {"code": "CZE", "name": "Czech Republic", "region": "Europe (EU)"},
46 {"code": "DEU", "name": "Germany", "region": "Europe (EU)"},
47 {"code": "DNK", "name": "Denmark", "region": "Europe (EU)"},
48 {"code": "EGY", "name": "Egypt, Arab Rep.", "region": "Africa"},
49 {"code": "ESP", "name": "Spain", "region": "Europe (EU)"},
50 {"code": "FIN", "name": "Finland", "region": "Europe (EU)"},
51 {"code": "FRA", "name": "France", "region": "Europe (EU)"},
52 {"code": "GBR", "name": "United Kingdom", "region": "Europe (EU)"},
53 {"code": "GRC", "name": "Greece", "region": "Europe (EU)"},
54 {"code": "HKG", "name": "Hong Kong SAR, China", "region": "Asia"},
55 {"code": "HUN", "name": "Hungary", "region": "Europe (EU)"},
56 {"code": "IDN", "name": "Indonesia", "region": "Asia"},
57 {"code": "IND", "name": "India", "region": "Asia"},
58 {"code": "IRL", "name": "Ireland", "region": "Europe (EU)"},
59 {"code": "ISR", "name": "Israel", "region": "Middle East"},
60 {"code": "ITA", "name": "Italy", "region": "Europe (EU)"},
61 {"code": "JPN", "name": "Japan", "region": "Asia"},
62 {"code": "KOR", "name": "Korea, Rep.", "region": "Asia"},
63 {"code": "MEX", "name": "Mexico", "region": "Americas"},
64 {"code": "MYS", "name": "Malaysia", "region": "Asia"},
65 {"code": "NGA", "name": "Nigeria", "region": "Africa"},
66 {"code": "NLD", "name": "Netherlands", "region": "Europe (EU)"},
67 {"code": "NOR", "name": "Norway", "region": "Europe (EU)"},
68 {"code": "NZL", "name": "New Zealand", "region": "Oceania"},
69 {"code": "PER", "name": "Peru", "region": "Americas"},
70 {"code": "PHL", "name": "Philippines", "region": "Asia"},
71 {"code": "POL", "name": "Poland", "region": "Europe (EU)"},
72 {"code": "PRT", "name": "Portugal", "region": "Europe (EU)"},
73 {"code": "ROU", "name": "Romania", "region": "Europe (EU)"},
74 {"code": "RUS", "name": "Russian Federation", "region": "Europe (EU)"},
75 {"code": "SAU", "name": "Saudi Arabia", "region": "Middle East"},
76 {"code": "SGP", "name": "Singapore", "region": "Asia"},
77 {"code": "SWE", "name": "Sweden", "region": "Europe (EU)"},
78 {"code": "THA", "name": "Thailand", "region": "Asia"},
79 {"code": "TUR", "name": "Türkiye", "region": "Middle East"},
80 {"code": "TWN", "name": "Taiwan, China", "region": "Asia"},
81 {"code": "UKR", "name": "Ukraine", "region": "Europe (EU)"},
82 {"code": "USA", "name": "United States", "region": "Americas"},
83 {"code": "VNM", "name": "Vietnam", "region": "Asia"},
84 {"code": "ZAF", "name": "South Africa", "region": "Africa"},
85]
86
87
88class WorldBankSource(BaseSource):
89 """World Bank v2 API データソース."""
90
91 source_id = DataSource.WORLDBANK
92 default_url = "https://api.worldbank.org/v2"
93
94 def __init__(
95 self,
96 base_url: Optional[str] = None,
97 timeout: float = 30.0,
98 indicators: Optional[list[dict[str, Any]]] = None,
99 countries: Optional[list[dict[str, str]]] = None,
100 ) -> None:
101 """初期化.
102
103 Args:
104 base_url: API ベース URL.
105 timeout: HTTP タイムアウト.
106 indicators: 取得対象指標. None なら既定セット.
107 countries: 取得対象国. None なら既定セット.
108 """
109 super().__init__(base_url=base_url, timeout=timeout)
110 self._indicators = indicators or WORLDBANK_INDICATORS
111 self._countries = countries or WORLDBANK_COUNTRIES
112
113 def list_countries(self) -> list[dict[str, Any]]:
114 """国が事前定義されているのでそのリストを返す."""
115 return list(self._countries)
116
117 def list_indicators(self) -> list[dict[str, Any]]:
118 """指標メタのリストを返す."""
119 return list(self._indicators)
120
121 def fetch(
122 self,
123 client: httpx.Client,
124 ) -> Iterable[RawIndicatorRecord]:
125 """World Bank API から指標データを取得して yield.
126
127 形式: GET /v2/country/{CCs}/indicator/{IND}?format=json&per_page=1000
128 注意: indicator は 1 リクエスト 1 個のみ (セミコロン連結は非対応).
129
130 Yields:
131 RawIndicatorRecord.
132 """
133 country_codes = ";".join(c["code"] for c in self._countries)
134 ind_map = {i["code"]: i for i in self._indicators}
135 country_map = {c["code"]: c for c in self._countries}
136
137 for ind in self._indicators:
138 ind_code = ind["code"]
139 url = f"{self.base_url}/country/{country_codes}/indicator/{ind_code}"
140 params: dict[str, Any] = {
141 "format": "json",
142 "per_page": 20000,
143 "date": "2015:2024",
144 }
145 try:
146 resp = client.get(url, params=params)
147 except httpx.HTTPError as exc:
148 logger.warning(
149 "[worldbank] request failed (%s): %s", ind_code, exc)
150 continue
151 if resp.status_code != 200:
152 logger.warning(
153 "[worldbank] non-200 status (%s): %d %s",
154 ind_code, resp.status_code, resp.text[:200],
155 )
156 continue
157
158 try:
159 payload = resp.json()
160 except Exception as exc:
161 logger.warning(
162 "[worldbank] JSON parse failed (%s): %s", ind_code, exc)
163 continue
164
165
166 if not isinstance(payload, list) or len(payload) < 2:
167 logger.warning(
168 "[worldbank] unexpected payload format (%s)", ind_code)
169 continue
170 rows = payload[1]
171 if not isinstance(rows, list):
172 continue
173
174 for row in rows:
175 if not isinstance(row, dict):
176 continue
177 cc = row.get("countryiso3code") or ""
178 ind_id = ind_code
179 value = safe_float(row.get("value"))
180 year_raw = row.get("date")
181 try:
182 year = int(str(year_raw))
183 except (TypeError, ValueError):
184 continue
185 if not cc or not ind_id:
186 continue
187
188 ind_meta = ind_map.get(ind_id, {})
189 c_meta = country_map.get(cc, {})
190 country_value = row.get("country")
191 yield RawIndicatorRecord(
192 source=self.source_id,
193 country_code=cc,
194 country_name=(
195 c_meta.get("name")
196 or (country_value.get("value", "")
197 if isinstance(country_value, dict) else "")
198 ),
199 indicator_code=ind_id,
200 indicator_name=ind_meta.get("name")
201 or (ind.get("value", "")
202 if isinstance(ind, dict) else ""),
203 year=year,
204 value=value,
205 unit=ind_meta.get("unit"),
206 raw_data=row,
207 )