Spaces:
Sleeping
Sleeping
serve pairs from per-city index (numbeo rankings): 361 cities, up to 10/country, all 92 capitals; myanmar->yangon
11b094a | """Build the per-city cost index from Numbeo's "Cost of Living Index by City". | |
| ONE request to the rankings page yields ~550 cities with their Cost of Living | |
| Index and Rent Index (NYC = 100 basis) — no per-pair scraping, no rate-limit | |
| dance. We keep up to MAX_PER_COUNTRY cities per dropdown country (always including | |
| that country's capital, aliased if Numbeo names it differently), and the app | |
| computes any pair from these indices on the fly (ratios are basis-independent). | |
| uv run python scripts/build_cache.py | |
| Commit the regenerated app/data/numbeo_index.json. Cost-of-living data moves | |
| slowly, so a snapshot stays valid for months; re-run to refresh. | |
| """ | |
| import json | |
| import sys | |
| from collections import defaultdict | |
| from pathlib import Path | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| from bs4 import BeautifulSoup # noqa: E402 | |
| from curl_cffi.requests import Session # noqa: E402 | |
| from app.currencies import COUNTRIES, country_to_capital # noqa: E402 | |
| from app.data_sources import _INDEX_PATH, _index_key # noqa: E402 | |
| RANKINGS_URL = "https://www.numbeo.com/cost-of-living/rankings_current.jsp" | |
| MAX_PER_COUNTRY = 10 | |
| # Rankings country label (lowercased) → our dropdown country name, when they differ. | |
| COUNTRY_ALIAS = { | |
| "czechia": "Czech Republic", | |
| "hong kong (china)": "Hong Kong", | |
| } | |
| # Capitals Numbeo's ranking omits (too little data to be ranked). NYC=100 basis, | |
| # taken once from the compare page; low-data cities that rarely move, so a static | |
| # snapshot is fine. Without these, selecting the country would 503 in production. | |
| FALLBACK_CITIES = { | |
| ("Laos", "Vientiane"): {"col": 35.2, "rent": 13.7}, | |
| ("Brunei", "Bandar Seri Begawan"): {"col": 42.8, "rent": 15.9}, | |
| ("Myanmar", "Yangon"): {"col": 38.6, "rent": 10.1}, | |
| } | |
| _OUR = {c.lower(): c for c in COUNTRIES} | |
| def fetch_rankings() -> list[tuple[str, str, float, float]]: | |
| """Return (country_raw, city, col_index, rent_index) for every ranked city.""" | |
| s = Session() | |
| r = s.get(RANKINGS_URL, impersonate="chrome", timeout=30) | |
| r.raise_for_status() | |
| soup = BeautifulSoup(r.text, "html.parser") | |
| table = soup.select_one("table#t2") or soup.select_one("table") | |
| rows = [] | |
| for tr in table.find_all("tr")[1:]: | |
| tds = tr.find_all("td") | |
| if len(tds) < 4: | |
| continue | |
| label = tds[1].get_text(strip=True) # e.g. "New York, NY, United States" | |
| if ", " not in label: | |
| continue | |
| parts = [p.strip() for p in label.split(",")] | |
| country_raw, city = parts[-1], ", ".join(parts[:-1]) | |
| try: | |
| col = float(tds[2].get_text(strip=True)) | |
| rent = float(tds[3].get_text(strip=True)) | |
| except ValueError: | |
| continue | |
| rows.append((country_raw, city, col, rent)) | |
| return rows | |
| def build_index(rows): | |
| by_country = defaultdict(list) # our_country -> [(city, col, rent)] in rank order | |
| unmatched = set() | |
| for country_raw, city, col, rent in rows: | |
| our = COUNTRY_ALIAS.get(country_raw.lower()) or _OUR.get(country_raw.lower()) | |
| if not our: | |
| unmatched.add(country_raw) | |
| continue | |
| by_country[our].append((city, col, rent)) | |
| index = {} | |
| for our, cities in by_country.items(): | |
| for city, col, rent in cities[:MAX_PER_COUNTRY]: | |
| index[_index_key(our, city)] = {"col": col, "rent": rent} | |
| # Ensure the dropdown capital resolves even if Numbeo names it differently | |
| # (e.g. "New York" vs "New York, NY") by aliasing it to the best match. | |
| cap = country_to_capital(our) | |
| if cap and _index_key(our, cap) not in index: | |
| match = next( | |
| (t for t in cities | |
| if cap.lower() in t[0].lower() or t[0].lower() in cap.lower()), | |
| None, | |
| ) | |
| if match: | |
| index[_index_key(our, cap)] = {"col": match[1], "rent": match[2]} | |
| # Backfill capitals the ranking doesn't carry. | |
| for (country, city), vals in FALLBACK_CITIES.items(): | |
| index.setdefault(_index_key(country, city), dict(vals)) | |
| return index, unmatched | |
| def main() -> int: | |
| rows = fetch_rankings() | |
| print(f"rankings rows parsed: {len(rows)}") | |
| index, unmatched = build_index(rows) | |
| caps = [(c, country_to_capital(c)) for c in COUNTRIES] | |
| covered = [c for c, cap in caps if cap and _index_key(c, cap) in index] | |
| missing = [c for c, cap in caps if cap and _index_key(c, cap) not in index] | |
| print(f"cities indexed: {len(index)}") | |
| print(f"capitals covered: {len(covered)}/{len(COUNTRIES)}") | |
| if missing: | |
| print("capitals NOT covered (live-scrape fallback):", ", ".join(missing)) | |
| payload = { | |
| "_meta": { | |
| "source": "Numbeo Cost of Living Index by City (rankings_current.jsp)", | |
| "basis": "New York City = 100", | |
| "max_per_country": MAX_PER_COUNTRY, | |
| "cities": len(index), | |
| "note": "Pairs computed on the fly; see app/data_sources.py + scripts/build_cache.py", | |
| }, | |
| **dict(sorted(index.items())), | |
| } | |
| _INDEX_PATH.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") | |
| print(f"wrote {_INDEX_PATH}") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |