From 58ac3120b29c488ba3fba8aeef78ae19399b6aa3 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Wed, 15 Jul 2026 16:53:11 -0700 Subject: [PATCH] SEO: crawlable programmatic climate pages + technical hygiene (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * SEO: generate curated city set for crawlable climate pages gen_cities.py reuses the GeoNames index places.py already parses to select the top ~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when it repeats the city name), and writes committed backend/cities.json. cities.py loads it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping for the upcoming hub + sitemap. * SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene - content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt (disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates the home/static pages plus every city, month, and records URL from cities.py). Registered on the app before the StaticFiles mount so the routes win. - templates/base.html.j2: shared layout with unique title/description, self- referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate link) and a footer link graph. - Give each existing page a unique (were 5x identical) and a self-referential ; add WebApplication JSON-LD to the home page. - Pin jinja2. * SEO: server-rendered per-city climate page (/climate/{slug}) The keystone crawlable page: for a city it snaps to the grid cell, loads the archive (fetching once if missing, self-healing), and renders as real HTML — a 'how today compares' block (grade + percentile per metric from grade_day, tinted by tier), a monthly normals table (climatology at each month's 15th, shown in °F and °C), all-time records (new grading.all_time_records helper), a breadcrumb, Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into the interactive tool + month/records pages. Content-page CSS added to style.css (renamed the table class to avoid colliding with the app's .normals flex row). * SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages Month pages render the exact-month long-tail ('average weather in {city} in {month}') with that month's average high/low, typical p10-p90 range, month-specific records, and prev/next month links. Records pages show all-time record highs/lows per metric with dates (grading.all_time_records). Shared _resolve_city helper; the literal /records route is registered before the {month} param and month names are validated (unknown month -> 404). * SEO: climate hub, weather glossary, and about/methodology pages - /climate: crawlable directory of all ~500 cities grouped by country — the internal-link graph that lets search engines discover every city page. - /glossary + /glossary/{term}: plain-language definitions (climate normal, percentile, temperature anomaly, feels-like, heat index, wind chill, humidity, reanalysis) with DefinedTerm JSON-LD and cross-links into the tool. - /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window, percentile grading) for E-E-A-T. All linked from the shared footer. * SEO: archive warmer, content-page tests, and deploy docs - warm_cities.py: paced, idempotent offline warmer that pre-fetches each city cell's archive so /climate pages serve from cache and a crawl can't burst the archive quota (pages self-heal if hit before warming). - tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap enumerating city/month/records URLs, and that a rendered city page carries the stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/ about routing and 404s. - DEPLOY.md: document the content pages, the warm step, and submitting the sitemap. --- app.py | 6 + cities.json | 5002 +++++++++++++++++++++++++++++++ cities.py | 54 + content.py | 506 ++++ gen_cities.py | 82 + grading.py | 21 + requirements.txt | 2 + templates/about.html.j2 | 36 + templates/base.html.j2 | 58 + templates/city.html.j2 | 75 + templates/glossary.html.j2 | 19 + templates/glossary_term.html.j2 | 21 + templates/hub.html.j2 | 23 + templates/month.html.j2 | 39 + templates/records.html.j2 | 33 + tests/test_content.py | 74 + warm_cities.py | 48 + 17 files changed, 6099 insertions(+) create mode 100644 cities.json create mode 100644 cities.py create mode 100644 content.py create mode 100644 gen_cities.py create mode 100644 templates/about.html.j2 create mode 100644 templates/base.html.j2 create mode 100644 templates/city.html.j2 create mode 100644 templates/glossary.html.j2 create mode 100644 templates/glossary_term.html.j2 create mode 100644 templates/hub.html.j2 create mode 100644 templates/month.html.j2 create mode 100644 templates/records.html.j2 create mode 100644 tests/test_content.py create mode 100644 warm_cities.py diff --git a/app.py b/app.py index 5d9bed0..d0b4092 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,7 @@ from fastapi.staticfiles import StaticFiles import api_accounts import audit import climate +import content import db import grid import notify @@ -617,6 +618,11 @@ app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEA app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False) +# Crawlable, server-rendered SEO pages (climate hub / per-city / month / records / +# glossary / about) + robots.txt + sitemap.xml. Registered before the static mount +# so these explicit routes win. +content.register(app) + # Everything else under BASE (app.js, style.css, nav.js, …) is a static asset. # Registered last so the explicit page routes above win. Mounts must start with # "/", so at the root (BASE == "") mount at "/" — the earlier routes still win diff --git a/cities.json b/cities.json new file mode 100644 index 0000000..bc2b0a7 --- /dev/null +++ b/cities.json @@ -0,0 +1,5002 @@ +[ +{ +"slug":"shanghai-cn", +"name":"Shanghai", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.22222, +"lon":121.45806, +"population":24874500 +}, +{ +"slug":"beijing-cn", +"name":"Beijing", +"admin1":"Beijing", +"country":"China", +"country_code":"CN", +"lat":39.9075, +"lon":116.39723, +"population":18960744 +}, +{ +"slug":"shenzhen-guangdong-cn", +"name":"Shenzhen", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":22.54554, +"lon":114.0683, +"population":17494398 +}, +{ +"slug":"guangzhou-guangdong-cn", +"name":"Guangzhou", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":23.11667, +"lon":113.25, +"population":16096724 +}, +{ +"slug":"kinshasa-cd", +"name":"Kinshasa", +"admin1":"Kinshasa", +"country":"Democratic Republic of the Congo", +"country_code":"CD", +"lat":-4.32758, +"lon":15.31357, +"population":16000000 +}, +{ +"slug":"istanbul-tr", +"name":"Istanbul", +"admin1":"Istanbul", +"country":"Turkey", +"country_code":"TR", +"lat":41.01384, +"lon":28.94966, +"population":15701602 +}, +{ +"slug":"lagos-ng", +"name":"Lagos", +"admin1":"Lagos", +"country":"Nigeria", +"country_code":"NG", +"lat":6.45407, +"lon":3.39467, +"population":15388000 +}, +{ +"slug":"ho-chi-minh-city-ho-chi-minh-city-hcmc-vn", +"name":"Ho Chi Minh City", +"admin1":"Ho Chi Minh City (HCMC)", +"country":"Vietnam", +"country_code":"VN", +"lat":10.82302, +"lon":106.62965, +"population":14002598 +}, +{ +"slug":"chengdu-sichuan-cn", +"name":"Chengdu", +"admin1":"Sichuan", +"country":"China", +"country_code":"CN", +"lat":30.66667, +"lon":104.06667, +"population":13568357 +}, +{ +"slug":"lahore-punjab-pk", +"name":"Lahore", +"admin1":"Punjab", +"country":"Pakistan", +"country_code":"PK", +"lat":31.558, +"lon":74.35071, +"population":13004135 +}, +{ +"slug":"mumbai-maharashtra-in", +"name":"Mumbai", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":19.07283, +"lon":72.88261, +"population":12691836 +}, +{ +"slug":"sao-paulo-br", +"name":"São Paulo", +"admin1":"São Paulo", +"country":"Brazil", +"country_code":"BR", +"lat":-23.5475, +"lon":-46.63611, +"population":12400232 +}, +{ +"slug":"mexico-city-mx", +"name":"Mexico City", +"admin1":"Mexico City", +"country":"Mexico", +"country_code":"MX", +"lat":19.42847, +"lon":-99.12766, +"population":12294193 +}, +{ +"slug":"karachi-sindh-pk", +"name":"Karachi", +"admin1":"Sindh", +"country":"Pakistan", +"country_code":"PK", +"lat":24.8608, +"lon":67.0104, +"population":11624219 +}, +{ +"slug":"tianjin-cn", +"name":"Tianjin", +"admin1":"Tianjin", +"country":"China", +"country_code":"CN", +"lat":39.14222, +"lon":117.17667, +"population":11090314 +}, +{ +"slug":"delhi-in", +"name":"Delhi", +"admin1":"Delhi", +"country":"India", +"country_code":"IN", +"lat":28.65195, +"lon":77.23149, +"population":11034555 +}, +{ +"slug":"wuhan-hubei-cn", +"name":"Wuhan", +"admin1":"Hubei", +"country":"China", +"country_code":"CN", +"lat":30.58333, +"lon":114.26667, +"population":10392693 +}, +{ +"slug":"moscow-ru", +"name":"Moscow", +"admin1":"Moscow", +"country":"Russia", +"country_code":"RU", +"lat":55.75204, +"lon":37.61781, +"population":10381222 +}, +{ +"slug":"dhaka-dhaka-division-bd", +"name":"Dhaka", +"admin1":"Dhaka Division", +"country":"Bangladesh", +"country_code":"BD", +"lat":23.7104, +"lon":90.40744, +"population":10356500 +}, +{ +"slug":"seoul-kr", +"name":"Seoul", +"admin1":"Seoul", +"country":"South Korea", +"country_code":"KR", +"lat":37.566, +"lon":126.9784, +"population":10349312 +}, +{ +"slug":"tokyo-jp", +"name":"Tokyo", +"admin1":"Tokyo", +"country":"Japan", +"country_code":"JP", +"lat":35.6895, +"lon":139.69171, +"population":9733276 +}, +{ +"slug":"dongguan-guangdong-cn", +"name":"Dongguan", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":23.01797, +"lon":113.74866, +"population":9644871 +}, +{ +"slug":"cairo-eg", +"name":"Cairo", +"admin1":"Cairo", +"country":"Egypt", +"country_code":"EG", +"lat":30.06263, +"lon":31.24967, +"population":9606916 +}, +{ +"slug":"xian-shaanxi-cn", +"name":"Xi’an", +"admin1":"Shaanxi", +"country":"China", +"country_code":"CN", +"lat":34.25833, +"lon":108.92861, +"population":9600000 +}, +{ +"slug":"johannesburg-gauteng-za", +"name":"Johannesburg", +"admin1":"Gauteng", +"country":"South Africa", +"country_code":"ZA", +"lat":-26.20227, +"lon":28.04363, +"population":9418183 +}, +{ +"slug":"nanjing-jiangsu-cn", +"name":"Nanjing", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":32.06167, +"lon":118.77778, +"population":9314685 +}, +{ +"slug":"hangzhou-zhejiang-cn", +"name":"Hangzhou", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":30.29365, +"lon":120.16142, +"population":9236032 +}, +{ +"slug":"foshan-guangdong-cn", +"name":"Foshan", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":23.02677, +"lon":113.13148, +"population":9042509 +}, +{ +"slug":"london-england-gb", +"name":"London", +"admin1":"England", +"country":"United Kingdom", +"country_code":"GB", +"lat":51.50853, +"lon":-0.12574, +"population":8961989 +}, +{ +"slug":"new-york-city-new-york-us", +"name":"New York City", +"admin1":"New York", +"country":"United States", +"country_code":"US", +"lat":40.71427, +"lon":-74.00597, +"population":8804190 +}, +{ +"slug":"jakarta-id", +"name":"Jakarta", +"admin1":"Jakarta", +"country":"Indonesia", +"country_code":"ID", +"lat":-6.21462, +"lon":106.84513, +"population":8540121 +}, +{ +"slug":"bengaluru-karnataka-in", +"name":"Bengaluru", +"admin1":"Karnataka", +"country":"India", +"country_code":"IN", +"lat":12.97194, +"lon":77.59369, +"population":8495492 +}, +{ +"slug":"hanoi-vn", +"name":"Hanoi", +"admin1":"Hanoi", +"country":"Vietnam", +"country_code":"VN", +"lat":21.0245, +"lon":105.84117, +"population":8053663 +}, +{ +"slug":"taipei-taiwan-tw", +"name":"Taipei", +"admin1":"Taiwan", +"country":"Taiwan", +"country_code":"TW", +"lat":25.05306, +"lon":121.52639, +"population":7871900 +}, +{ +"slug":"lima-lima-province-pe", +"name":"Lima", +"admin1":"Lima Province", +"country":"Peru", +"country_code":"PE", +"lat":-12.04318, +"lon":-77.02824, +"population":7737002 +}, +{ +"slug":"bogota-bogota-d-c-co", +"name":"Bogotá", +"admin1":"Bogota D.C.", +"country":"Colombia", +"country_code":"CO", +"lat":4.60971, +"lon":-74.08175, +"population":7674366 +}, +{ +"slug":"chongqing-cn", +"name":"Chongqing", +"admin1":"Chongqing", +"country":"China", +"country_code":"CN", +"lat":29.56026, +"lon":106.55771, +"population":7457599 +}, +{ +"slug":"hong-kong-hk", +"name":"Hong Kong", +"admin1":null, +"country":"Hong Kong", +"country_code":"HK", +"lat":22.27832, +"lon":114.17469, +"population":7396076 +}, +{ +"slug":"baghdad-iq", +"name":"Baghdad", +"admin1":"Baghdad", +"country":"Iraq", +"country_code":"IQ", +"lat":33.34058, +"lon":44.40088, +"population":7216000 +}, +{ +"slug":"wuzhong-ningxia-cn", +"name":"Wuzhong", +"admin1":"Ningxia", +"country":"China", +"country_code":"CN", +"lat":37.9867, +"lon":106.201, +"population":7202654 +}, +{ +"slug":"qingdao-shandong-cn", +"name":"Qingdao", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":36.06488, +"lon":120.38042, +"population":7172451 +}, +{ +"slug":"tehran-ir", +"name":"Tehran", +"admin1":"Tehran", +"country":"Iran", +"country_code":"IR", +"lat":35.69439, +"lon":51.42151, +"population":7153309 +}, +{ +"slug":"shenyang-liaoning-cn", +"name":"Shenyang", +"admin1":"Liaoning", +"country":"China", +"country_code":"CN", +"lat":41.79222, +"lon":123.43278, +"population":7050000 +}, +{ +"slug":"hyderabad-telangana-in", +"name":"Hyderabad", +"admin1":"Telangana", +"country":"India", +"country_code":"IN", +"lat":17.38405, +"lon":78.45636, +"population":6993262 +}, +{ +"slug":"rio-de-janeiro-br", +"name":"Rio de Janeiro", +"admin1":"Rio de Janeiro", +"country":"Brazil", +"country_code":"BR", +"lat":-22.90642, +"lon":-43.18223, +"population":6747815 +}, +{ +"slug":"suzhou-jiangsu-cn", +"name":"Suzhou", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":31.30408, +"lon":120.59538, +"population":6715559 +}, +{ +"slug":"puxi-shanghai-cn", +"name":"Puxi", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.24414, +"lon":121.46592, +"population":6683712 +}, +{ +"slug":"ahmedabad-gujarat-in", +"name":"Ahmedabad", +"admin1":"Gujarat", +"country":"India", +"country_code":"IN", +"lat":23.02579, +"lon":72.58727, +"population":6357693 +}, +{ +"slug":"abidjan-abidjan-autonomous-district-ci", +"name":"Abidjan", +"admin1":"Abidjan Autonomous District", +"country":"Ivory Coast", +"country_code":"CI", +"lat":5.35444, +"lon":-4.00167, +"population":6321017 +}, +{ +"slug":"pudong-shanghai-cn", +"name":"Pudong", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.23995, +"lon":121.50094, +"population":5681512 +}, +{ +"slug":"sydney-new-south-wales-au", +"name":"Sydney", +"admin1":"New South Wales", +"country":"Australia", +"country_code":"AU", +"lat":-33.86785, +"lon":151.20732, +"population":5638830 +}, +{ +"slug":"singapore-sg", +"name":"Singapore", +"admin1":null, +"country":"Singapore", +"country_code":"SG", +"lat":1.28967, +"lon":103.85007, +"population":5638700 +}, +{ +"slug":"melbourne-victoria-au", +"name":"Melbourne", +"admin1":"Victoria", +"country":"Australia", +"country_code":"AU", +"lat":-37.814, +"lon":144.96332, +"population":5435590 +}, +{ +"slug":"dar-es-salaam-dar-es-salaam-region-tz", +"name":"Dar es Salaam", +"admin1":"Dar es Salaam Region", +"country":"Tanzania", +"country_code":"TZ", +"lat":-6.82349, +"lon":39.26951, +"population":5383728 +}, +{ +"slug":"saint-petersburg-st-petersburg-ru", +"name":"Saint Petersburg", +"admin1":"St.-Petersburg", +"country":"Russia", +"country_code":"RU", +"lat":59.93863, +"lon":30.31413, +"population":5351935 +}, +{ +"slug":"alexandria-eg", +"name":"Alexandria", +"admin1":"Alexandria", +"country":"Egypt", +"country_code":"EG", +"lat":31.20176, +"lon":29.91582, +"population":5263542 +}, +{ +"slug":"harbin-heilongjiang-cn", +"name":"Harbin", +"admin1":"Heilongjiang", +"country":"China", +"country_code":"CN", +"lat":45.75, +"lon":126.65, +"population":5242897 +}, +{ +"slug":"bangkok-th", +"name":"Bangkok", +"admin1":"Bangkok", +"country":"Thailand", +"country_code":"TH", +"lat":13.75398, +"lon":100.50144, +"population":5104476 +}, +{ +"slug":"hefei-anhui-cn", +"name":"Hefei", +"admin1":"Anhui", +"country":"China", +"country_code":"CN", +"lat":31.86389, +"lon":117.28083, +"population":5050000 +}, +{ +"slug":"dalian-liaoning-cn", +"name":"Dalian", +"admin1":"Liaoning", +"country":"China", +"country_code":"CN", +"lat":38.91222, +"lon":121.60222, +"population":4913879 +}, +{ +"slug":"kano-kano-state-ng", +"name":"Kano", +"admin1":"Kano State", +"country":"Nigeria", +"country_code":"NG", +"lat":12.00012, +"lon":8.51672, +"population":4910000 +}, +{ +"slug":"santiago-santiago-metropolitan-cl", +"name":"Santiago", +"admin1":"Santiago Metropolitan", +"country":"Chile", +"country_code":"CL", +"lat":-33.45694, +"lon":-70.64827, +"population":4837295 +}, +{ +"slug":"cape-town-western-cape-za", +"name":"Cape Town", +"admin1":"Western Cape", +"country":"South Africa", +"country_code":"ZA", +"lat":-33.92584, +"lon":18.42322, +"population":4772846 +}, +{ +"slug":"peshawar-khyber-pakhtunkhwa-pk", +"name":"Peshawar", +"admin1":"Khyber Pakhtunkhwa", +"country":"Pakistan", +"country_code":"PK", +"lat":34.008, +"lon":71.57849, +"population":4758762 +}, +{ +"slug":"changchun-jilin-cn", +"name":"Changchun", +"admin1":"Jilin", +"country":"China", +"country_code":"CN", +"lat":43.88, +"lon":125.32278, +"population":4714996 +}, +{ +"slug":"jeddah-mecca-region-sa", +"name":"Jeddah", +"admin1":"Mecca Region", +"country":"Saudi Arabia", +"country_code":"SA", +"lat":21.49012, +"lon":39.18624, +"population":4697000 +}, +{ +"slug":"chennai-tamil-nadu-in", +"name":"Chennai", +"admin1":"Tamil Nadu", +"country":"India", +"country_code":"IN", +"lat":13.08784, +"lon":80.27847, +"population":4681087 +}, +{ +"slug":"kolkata-west-bengal-in", +"name":"Kolkata", +"admin1":"West Bengal", +"country":"India", +"country_code":"IN", +"lat":22.56263, +"lon":88.36304, +"population":4631392 +}, +{ +"slug":"xiamen-fujian-cn", +"name":"Xiamen", +"admin1":"Fujian", +"country":"China", +"country_code":"CN", +"lat":24.47979, +"lon":118.08187, +"population":4617251 +}, +{ +"slug":"surat-gujarat-in", +"name":"Surat", +"admin1":"Gujarat", +"country":"India", +"country_code":"IN", +"lat":21.19594, +"lon":72.83023, +"population":4591246 +}, +{ +"slug":"yangon-mm", +"name":"Yangon", +"admin1":"Yangon", +"country":"Myanmar", +"country_code":"MM", +"lat":16.80528, +"lon":96.15611, +"population":4477638 +}, +{ +"slug":"bao-an-guangdong-cn", +"name":"Bao'an", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":22.55213, +"lon":113.88288, +"population":4476554 +}, +{ +"slug":"kabul-af", +"name":"Kabul", +"admin1":"Kabul", +"country":"Afghanistan", +"country_code":"AF", +"lat":34.52813, +"lon":69.17233, +"population":4434550 +}, +{ +"slug":"nairobi-nairobi-county-ke", +"name":"Nairobi", +"admin1":"Nairobi County", +"country":"Kenya", +"country_code":"KE", +"lat":-1.28333, +"lon":36.81667, +"population":4397073 +}, +{ +"slug":"wuxi-jiangsu-cn", +"name":"Wuxi", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":31.56887, +"lon":120.28857, +"population":4396835 +}, +{ +"slug":"giza-eg", +"name":"Giza", +"admin1":"Giza", +"country":"Egypt", +"country_code":"EG", +"lat":30.00944, +"lon":31.20861, +"population":4367343 +}, +{ +"slug":"jinan-shandong-cn", +"name":"Jinan", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":36.66833, +"lon":116.99722, +"population":4335989 +}, +{ +"slug":"taiyuan-shanxi-cn", +"name":"Taiyuan", +"admin1":"Shanxi", +"country":"China", +"country_code":"CN", +"lat":37.86944, +"lon":112.56028, +"population":4303673 +}, +{ +"slug":"zhengzhou-henan-cn", +"name":"Zhengzhou", +"admin1":"Henan", +"country":"China", +"country_code":"CN", +"lat":34.75778, +"lon":113.64861, +"population":4253913 +}, +{ +"slug":"bamako-ml", +"name":"Bamako", +"admin1":"Bamako", +"country":"Mali", +"country_code":"ML", +"lat":12.60915, +"lon":-7.97522, +"population":4227569 +}, +{ +"slug":"riyadh-riyadh-region-sa", +"name":"Riyadh", +"admin1":"Riyadh Region", +"country":"Saudi Arabia", +"country_code":"SA", +"lat":24.68773, +"lon":46.72185, +"population":4205961 +}, +{ +"slug":"new-taipei-city-taipei-tw", +"name":"New Taipei City", +"admin1":"Taipei", +"country":"Taiwan", +"country_code":"TW", +"lat":25.06199, +"lon":121.45703, +"population":4004367 +}, +{ +"slug":"new-territories-hk", +"name":"New Territories", +"admin1":null, +"country":"Hong Kong", +"country_code":"HK", +"lat":22.42441, +"lon":114.11095, +"population":3984077 +}, +{ +"slug":"shijiazhuang-hebei-cn", +"name":"Shijiazhuang", +"admin1":"Hebei", +"country":"China", +"country_code":"CN", +"lat":38.04139, +"lon":114.47861, +"population":3938513 +}, +{ +"slug":"chattogram-chittagong-bd", +"name":"Chattogram", +"admin1":"Chittagong", +"country":"Bangladesh", +"country_code":"BD", +"lat":22.3384, +"lon":91.83168, +"population":3920222 +}, +{ +"slug":"addis-ababa-et", +"name":"Addis Ababa", +"admin1":"Addis Ababa", +"country":"Ethiopia", +"country_code":"ET", +"lat":9.02497, +"lon":38.74689, +"population":3860000 +}, +{ +"slug":"kunming-yunnan-cn", +"name":"Kunming", +"admin1":"Yunnan", +"country":"China", +"country_code":"CN", +"lat":25.03889, +"lon":102.71833, +"population":3855346 +}, +{ +"slug":"zhongshan-guangdong-cn", +"name":"Zhongshan", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":22.52306, +"lon":113.37912, +"population":3841873 +}, +{ +"slug":"nanning-guangxi-cn", +"name":"Nanning", +"admin1":"Guangxi", +"country":"China", +"country_code":"CN", +"lat":22.81667, +"lon":108.31667, +"population":3839800 +}, +{ +"slug":"shantou-guangdong-cn", +"name":"Shantou", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":23.35489, +"lon":116.67876, +"population":3838900 +}, +{ +"slug":"los-angeles-california-us", +"name":"Los Angeles", +"admin1":"California", +"country":"United States", +"country_code":"US", +"lat":34.05223, +"lon":-118.24368, +"population":3820914 +}, +{ +"slug":"faisalabad-punjab-pk", +"name":"Faisalabad", +"admin1":"Punjab", +"country":"Pakistan", +"country_code":"PK", +"lat":31.41554, +"lon":73.08969, +"population":3800193 +}, +{ +"slug":"dubai-ae", +"name":"Dubai", +"admin1":"Dubai", +"country":"United Arab Emirates", +"country_code":"AE", +"lat":25.07725, +"lon":55.30927, +"population":3790000 +}, +{ +"slug":"yokohama-kanagawa-jp", +"name":"Yokohama", +"admin1":"Kanagawa", +"country":"Japan", +"country_code":"JP", +"lat":35.43333, +"lon":139.65, +"population":3777491 +}, +{ +"slug":"fuzhou-fujian-cn", +"name":"Fuzhou", +"admin1":"Fujian", +"country":"China", +"country_code":"CN", +"lat":26.06139, +"lon":119.30611, +"population":3740000 +}, +{ +"slug":"ningbo-zhejiang-cn", +"name":"Ningbo", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":29.87819, +"lon":121.54945, +"population":3731203 +}, +{ +"slug":"casablanca-casablanca-settat-ma", +"name":"Casablanca", +"admin1":"Casablanca-Settat", +"country":"Morocco", +"country_code":"MA", +"lat":33.58831, +"lon":-7.61138, +"population":3665954 +}, +{ +"slug":"ibadan-oyo-state-ng", +"name":"Ibadan", +"admin1":"Oyo State", +"country":"Nigeria", +"country_code":"NG", +"lat":7.37756, +"lon":3.90591, +"population":3649000 +}, +{ +"slug":"puyang-zhejiang-cn", +"name":"Puyang", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":29.45679, +"lon":119.88872, +"population":3590000 +}, +{ +"slug":"ankara-tr", +"name":"Ankara", +"admin1":"Ankara", +"country":"Turkey", +"country_code":"TR", +"lat":39.91987, +"lon":32.85427, +"population":3517182 +}, +{ +"slug":"shiyan-hubei-cn", +"name":"Shiyan", +"admin1":"Hubei", +"country":"China", +"country_code":"CN", +"lat":32.6475, +"lon":110.77806, +"population":3460000 +}, +{ +"slug":"berlin-state-of-berlin-de", +"name":"Berlin", +"admin1":"State of Berlin", +"country":"Germany", +"country_code":"DE", +"lat":52.52437, +"lon":13.41053, +"population":3426354 +}, +{ +"slug":"tangshan-hebei-cn", +"name":"Tangshan", +"admin1":"Hebei", +"country":"China", +"country_code":"CN", +"lat":39.64381, +"lon":118.18319, +"population":3372102 +}, +{ +"slug":"rawalpindi-punjab-pk", +"name":"Rawalpindi", +"admin1":"Punjab", +"country":"Pakistan", +"country_code":"PK", +"lat":33.59733, +"lon":73.0479, +"population":3357612 +}, +{ +"slug":"luliang-shanxi-cn", +"name":"Lüliang", +"admin1":"Shanxi", +"country":"China", +"country_code":"CN", +"lat":37.5192, +"lon":111.14436, +"population":3346500 +}, +{ +"slug":"durban-kwazulu-natal-za", +"name":"Durban", +"admin1":"KwaZulu-Natal", +"country":"South Africa", +"country_code":"ZA", +"lat":-29.8579, +"lon":31.0292, +"population":3338026 +}, +{ +"slug":"changzhou-jiangsu-cn", +"name":"Changzhou", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":31.77359, +"lon":119.95401, +"population":3290918 +}, +{ +"slug":"busan-kr", +"name":"Busan", +"admin1":"Busan", +"country":"South Korea", +"country_code":"KR", +"lat":35.10168, +"lon":129.03004, +"population":3285147 +}, +{ +"slug":"madrid-es", +"name":"Madrid", +"admin1":"Madrid", +"country":"Spain", +"country_code":"ES", +"lat":40.4165, +"lon":-3.70256, +"population":3255944 +}, +{ +"slug":"pyongyang-kp", +"name":"Pyongyang", +"admin1":"Pyongyang", +"country":"North Korea", +"country_code":"KP", +"lat":39.03385, +"lon":125.75432, +"population":3222000 +}, +{ +"slug":"zibo-shandong-cn", +"name":"Zibo", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":36.79056, +"lon":118.06333, +"population":3129228 +}, +{ +"slug":"pune-maharashtra-in", +"name":"Pune", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":18.51957, +"lon":73.85535, +"population":3124458 +}, +{ +"slug":"bursa-bursa-province-tr", +"name":"Bursa", +"admin1":"Bursa Province", +"country":"Turkey", +"country_code":"TR", +"lat":40.19559, +"lon":29.06013, +"population":3101833 +}, +{ +"slug":"changsha-hunan-cn", +"name":"Changsha", +"admin1":"Hunan", +"country":"China", +"country_code":"CN", +"lat":28.19874, +"lon":112.97087, +"population":3093980 +}, +{ +"slug":"quezon-city-national-capital-region-ph", +"name":"Quezon City", +"admin1":"National Capital Region", +"country":"Philippines", +"country_code":"PH", +"lat":14.6488, +"lon":121.0509, +"population":3084270 +}, +{ +"slug":"jaipur-rajasthan-in", +"name":"Jaipur", +"admin1":"Rajasthan", +"country":"India", +"country_code":"IN", +"lat":26.91962, +"lon":75.78781, +"population":3046163 +}, +{ +"slug":"guiyang-guizhou-cn", +"name":"Guiyang", +"admin1":"Guizhou", +"country":"China", +"country_code":"CN", +"lat":26.58333, +"lon":106.71667, +"population":3037159 +}, +{ +"slug":"urumqi-xinjiang-cn", +"name":"Ürümqi", +"admin1":"Xinjiang", +"country":"China", +"country_code":"CN", +"lat":43.80096, +"lon":87.60046, +"population":3029372 +}, +{ +"slug":"surabaya-east-java-id", +"name":"Surabaya", +"admin1":"East Java", +"country":"Indonesia", +"country_code":"ID", +"lat":-7.24917, +"lon":112.75083, +"population":3018022 +}, +{ +"slug":"incheon-kr", +"name":"Incheon", +"admin1":"Incheon", +"country":"South Korea", +"country_code":"KR", +"lat":37.45646, +"lon":126.70515, +"population":3015482 +}, +{ +"slug":"lanzhou-gansu-cn", +"name":"Lanzhou", +"admin1":"Gansu", +"country":"China", +"country_code":"CN", +"lat":36.05701, +"lon":103.83987, +"population":3000000 +}, +{ +"slug":"caracas-distrito-federal-ve", +"name":"Caracas", +"admin1":"Distrito Federal", +"country":"Venezuela", +"country_code":"VE", +"lat":10.48801, +"lon":-66.87919, +"population":3000000 +}, +{ +"slug":"kyiv-kyiv-city-ua", +"name":"Kyiv", +"admin1":"Kyiv City", +"country":"Ukraine", +"country_code":"UA", +"lat":50.45466, +"lon":30.5238, +"population":2952301 +}, +{ +"slug":"izmir-izmir-province-tr", +"name":"İzmir", +"admin1":"İzmir Province", +"country":"Turkey", +"country_code":"TR", +"lat":38.41273, +"lon":27.13838, +"population":2938292 +}, +{ +"slug":"huizhou-guangdong-cn", +"name":"Huizhou", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":23.11147, +"lon":114.41523, +"population":2900113 +}, +{ +"slug":"buenos-aires-buenos-aires-f-d-ar", +"name":"Buenos Aires", +"admin1":"Buenos Aires F.D.", +"country":"Argentina", +"country_code":"AR", +"lat":-34.61315, +"lon":-58.37723, +"population":2891082 +}, +{ +"slug":"haikou-hainan-cn", +"name":"Haikou", +"admin1":"Hainan", +"country":"China", +"country_code":"CN", +"lat":20.03421, +"lon":110.34651, +"population":2873358 +}, +{ +"slug":"taichung-taiwan-tw", +"name":"Taichung", +"admin1":"Taiwan", +"country":"Taiwan", +"country_code":"TW", +"lat":24.1469, +"lon":120.6839, +"population":2850285 +}, +{ +"slug":"kanpur-uttar-pradesh-in", +"name":"Kanpur", +"admin1":"Uttar Pradesh", +"country":"India", +"country_code":"IN", +"lat":26.46523, +"lon":80.34975, +"population":2823249 +}, +{ +"slug":"toronto-ontario-ca", +"name":"Toronto", +"admin1":"Ontario", +"country":"Canada", +"country_code":"CA", +"lat":43.70643, +"lon":-79.39864, +"population":2794356 +}, +{ +"slug":"quito-pichincha-ec", +"name":"Quito", +"admin1":"Pichincha", +"country":"Ecuador", +"country_code":"EC", +"lat":-0.22985, +"lon":-78.52495, +"population":2781641 +}, +{ +"slug":"brisbane-queensland-au", +"name":"Brisbane", +"admin1":"Queensland", +"country":"Australia", +"country_code":"AU", +"lat":-27.46794, +"lon":153.02809, +"population":2780063 +}, +{ +"slug":"luanda-ao", +"name":"Luanda", +"admin1":"Luanda", +"country":"Angola", +"country_code":"AO", +"lat":-8.83682, +"lon":13.23432, +"population":2776168 +}, +{ +"slug":"osaka-jp", +"name":"Osaka", +"admin1":"Osaka", +"country":"Japan", +"country_code":"JP", +"lat":34.69379, +"lon":135.50107, +"population":2753862 +}, +{ +"slug":"linyi-shandong-cn", +"name":"Linyi", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":35.06306, +"lon":118.34278, +"population":2743843 +}, +{ +"slug":"baoding-hebei-cn", +"name":"Baoding", +"admin1":"Hebei", +"country":"China", +"country_code":"CN", +"lat":38.87288, +"lon":115.46246, +"population":2739887 +}, +{ +"slug":"kaohsiung-takao-tw", +"name":"Kaohsiung", +"admin1":"Takao", +"country":"Taiwan", +"country_code":"TW", +"lat":22.61626, +"lon":120.31333, +"population":2737660 +}, +{ +"slug":"brooklyn-new-york-us", +"name":"Brooklyn", +"admin1":"New York", +"country":"United States", +"country_code":"US", +"lat":40.6501, +"lon":-73.94958, +"population":2736074 +}, +{ +"slug":"guayaquil-guayas-ec", +"name":"Guayaquil", +"admin1":"Guayas", +"country":"Ecuador", +"country_code":"EC", +"lat":-2.19616, +"lon":-79.88621, +"population":2723665 +}, +{ +"slug":"belo-horizonte-minas-gerais-br", +"name":"Belo Horizonte", +"admin1":"Minas Gerais", +"country":"Brazil", +"country_code":"BR", +"lat":-19.92083, +"lon":-43.93778, +"population":2721564 +}, +{ +"slug":"minhang-shanghai-cn", +"name":"Minhang", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.10881, +"lon":121.37471, +"population":2716600 +}, +{ +"slug":"bazhong-sichuan-cn", +"name":"Bazhong", +"admin1":"Sichuan", +"country":"China", +"country_code":"CN", +"lat":31.8694, +"lon":106.74432, +"population":2712894 +}, +{ +"slug":"salvador-bahia-br", +"name":"Salvador", +"admin1":"Bahia", +"country":"Brazil", +"country_code":"BR", +"lat":-12.97563, +"lon":-38.49096, +"population":2711840 +}, +{ +"slug":"abuja-fct-ng", +"name":"Abuja", +"admin1":"FCT", +"country":"Nigeria", +"country_code":"NG", +"lat":9.05785, +"lon":7.49508, +"population":2690000 +}, +{ +"slug":"gazipur-dhaka-division-bd", +"name":"Gazipur", +"admin1":"Dhaka Division", +"country":"Bangladesh", +"country_code":"BD", +"lat":23.99844, +"lon":90.42234, +"population":2674697 +}, +{ +"slug":"chicago-illinois-us", +"name":"Chicago", +"admin1":"Illinois", +"country":"United States", +"country_code":"US", +"lat":41.85003, +"lon":-87.65005, +"population":2664452 +}, +{ +"slug":"wenzhou-zhejiang-cn", +"name":"Wenzhou", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":27.99942, +"lon":120.66682, +"population":2650000 +}, +{ +"slug":"bekasi-west-java-id", +"name":"Bekasi", +"admin1":"West Java", +"country":"Indonesia", +"country_code":"ID", +"lat":-6.2349, +"lon":106.9896, +"population":2648272 +}, +{ +"slug":"dakar-sn", +"name":"Dakar", +"admin1":"Dakar", +"country":"Senegal", +"country_code":"SN", +"lat":14.6937, +"lon":-17.44406, +"population":2646503 +}, +{ +"slug":"haiphong-hai-phong-vn", +"name":"Haiphong", +"admin1":"Hai Phong", +"country":"Vietnam", +"country_code":"VN", +"lat":20.86481, +"lon":106.68345, +"population":2625200 +}, +{ +"slug":"yunfu-guangdong-cn", +"name":"Yunfu", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":22.92787, +"lon":112.03809, +"population":2612800 +}, +{ +"slug":"navi-mumbai-maharashtra-in", +"name":"Navi Mumbai", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":19.03681, +"lon":73.01582, +"population":2600000 +}, +{ +"slug":"mogadishu-banaadir-so", +"name":"Mogadishu", +"admin1":"Banaadir", +"country":"Somalia", +"country_code":"SO", +"lat":2.03711, +"lon":45.34375, +"population":2587183 +}, +{ +"slug":"kumasi-ashanti-gh", +"name":"Kumasi", +"admin1":"Ashanti", +"country":"Ghana", +"country_code":"GH", +"lat":6.68848, +"lon":-1.62443, +"population":2544530 +}, +{ +"slug":"bandung-west-java-id", +"name":"Bandung", +"admin1":"West Java", +"country":"Indonesia", +"country_code":"ID", +"lat":-6.92222, +"lon":107.60694, +"population":2528163 +}, +{ +"slug":"gujranwala-punjab-pk", +"name":"Gujranwala", +"admin1":"Punjab", +"country":"Pakistan", +"country_code":"PK", +"lat":32.15567, +"lon":74.18705, +"population":2511118 +}, +{ +"slug":"huai-an-jiangsu-cn", +"name":"Huai'an", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":33.58861, +"lon":119.01917, +"population":2494013 +}, +{ +"slug":"medan-north-sumatra-id", +"name":"Medan", +"admin1":"North Sumatra", +"country":"Indonesia", +"country_code":"ID", +"lat":3.58333, +"lon":98.66667, +"population":2486283 +}, +{ +"slug":"lucknow-uttar-pradesh-in", +"name":"Lucknow", +"admin1":"Uttar Pradesh", +"country":"India", +"country_code":"IN", +"lat":26.83928, +"lon":80.92313, +"population":2472011 +}, +{ +"slug":"ouagadougou-centre-bf", +"name":"Ouagadougou", +"admin1":"Centre", +"country":"Burkina Faso", +"country_code":"BF", +"lat":12.36566, +"lon":-1.53388, +"population":2415266 +}, +{ +"slug":"nagpur-maharashtra-in", +"name":"Nagpur", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":21.14631, +"lon":79.08491, +"population":2405665 +}, +{ +"slug":"fortaleza-ceara-br", +"name":"Fortaleza", +"admin1":"Ceará", +"country":"Brazil", +"country_code":"BR", +"lat":-3.71722, +"lon":-38.54306, +"population":2400000 +}, +{ +"slug":"cali-valle-del-cauca-department-co", +"name":"Cali", +"admin1":"Valle del Cauca Department", +"country":"Colombia", +"country_code":"CO", +"lat":3.43054, +"lon":-76.5199, +"population":2392877 +}, +{ +"slug":"perth-western-australia-au", +"name":"Perth", +"admin1":"Western Australia", +"country":"Australia", +"country_code":"AU", +"lat":-31.95224, +"lon":115.8614, +"population":2384371 +}, +{ +"slug":"daegu-kr", +"name":"Daegu", +"admin1":"Daegu", +"country":"South Korea", +"country_code":"KR", +"lat":35.87028, +"lon":128.59111, +"population":2365523 +}, +{ +"slug":"algiers-dz", +"name":"Algiers", +"admin1":"Algiers", +"country":"Algeria", +"country_code":"DZ", +"lat":36.73225, +"lon":3.08746, +"population":2364230 +}, +{ +"slug":"nanchang-jiangxi-cn", +"name":"Nanchang", +"admin1":"Jiangxi", +"country":"China", +"country_code":"CN", +"lat":28.68396, +"lon":115.85306, +"population":2357839 +}, +{ +"slug":"baku-baki-az", +"name":"Baku", +"admin1":"Baki", +"country":"Azerbaijan", +"country_code":"AZ", +"lat":40.37767, +"lon":49.89201, +"population":2351300 +}, +{ +"slug":"hohhot-inner-mongolia-cn", +"name":"Hohhot", +"admin1":"Inner Mongolia", +"country":"China", +"country_code":"CN", +"lat":40.81056, +"lon":111.65222, +"population":2350000 +}, +{ +"slug":"nagoya-aichi-jp", +"name":"Nagoya", +"admin1":"Aichi", +"country":"Japan", +"country_code":"JP", +"lat":35.18147, +"lon":136.90641, +"population":2332176 +}, +{ +"slug":"rome-lazio-it", +"name":"Rome", +"admin1":"Lazio", +"country":"Italy", +"country_code":"IT", +"lat":41.89193, +"lon":12.51133, +"population":2318895 +}, +{ +"slug":"queens-new-york-us", +"name":"Queens", +"admin1":"New York", +"country":"United States", +"country_code":"US", +"lat":40.68149, +"lon":-73.83652, +"population":2316841 +}, +{ +"slug":"houston-texas-us", +"name":"Houston", +"admin1":"Texas", +"country":"United States", +"country_code":"US", +"lat":29.76328, +"lon":-95.36327, +"population":2314157 +}, +{ +"slug":"mashhad-razavi-khorasan-ir", +"name":"Mashhad", +"admin1":"Razavi Khorasan", +"country":"Iran", +"country_code":"IR", +"lat":36.29807, +"lon":59.60567, +"population":2307177 +}, +{ +"slug":"shaoxing-zhejiang-cn", +"name":"Shaoxing", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":30.00237, +"lon":120.57864, +"population":2300000 +}, +{ +"slug":"nantong-jiangsu-cn", +"name":"Nantong", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":32.03028, +"lon":120.87472, +"population":2273326 +}, +{ +"slug":"baoshan-shanghai-cn", +"name":"Baoshan", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.40845, +"lon":121.48956, +"population":2265900 +}, +{ +"slug":"kowloon-hk", +"name":"Kowloon", +"admin1":null, +"country":"Hong Kong", +"country_code":"HK", +"lat":22.31667, +"lon":114.18333, +"population":2232339 +}, +{ +"slug":"yantai-shandong-cn", +"name":"Yantai", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":37.47649, +"lon":121.44081, +"population":2227733 +}, +{ +"slug":"gaziantep-tr", +"name":"Gaziantep", +"admin1":"Gaziantep", +"country":"Turkey", +"country_code":"TR", +"lat":37.05944, +"lon":37.3825, +"population":2222415 +}, +{ +"slug":"lubumbashi-haut-katanga-cd", +"name":"Lubumbashi", +"admin1":"Haut-Katanga", +"country":"Democratic Republic of the Congo", +"country_code":"CD", +"lat":-11.66089, +"lon":27.47938, +"population":2221925 +}, +{ +"slug":"manaus-amazonas-br", +"name":"Manaus", +"admin1":"Amazonas", +"country":"Brazil", +"country_code":"BR", +"lat":-3.10194, +"lon":-60.025, +"population":2219580 +}, +{ +"slug":"lusaka-lusaka-province-zm", +"name":"Lusaka", +"admin1":"Lusaka Province", +"country":"Zambia", +"country_code":"ZM", +"lat":-15.40669, +"lon":28.28713, +"population":2212301 +}, +{ +"slug":"brasilia-federal-district-br", +"name":"Brasília", +"admin1":"Federal District", +"country":"Brazil", +"country_code":"BR", +"lat":-15.77972, +"lon":-47.92972, +"population":2207718 +}, +{ +"slug":"zhuhai-guangdong-cn", +"name":"Zhuhai", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":22.27694, +"lon":113.56778, +"population":2207090 +}, +{ +"slug":"santo-domingo-nacional-do", +"name":"Santo Domingo", +"admin1":"Nacional", +"country":"Dominican Republic", +"country_code":"DO", +"lat":18.47186, +"lon":-69.89232, +"population":2201941 +}, +{ +"slug":"lome-maritime-tg", +"name":"Lomé", +"admin1":"Maritime", +"country":"Togo", +"country_code":"TG", +"lat":6.12874, +"lon":1.22154, +"population":2188376 +}, +{ +"slug":"multan-punjab-pk", +"name":"Multan", +"admin1":"Punjab", +"country":"Pakistan", +"country_code":"PK", +"lat":30.19679, +"lon":71.47824, +"population":2169915 +}, +{ +"slug":"havana-cu", +"name":"Havana", +"admin1":"Havana", +"country":"Cuba", +"country_code":"CU", +"lat":23.13302, +"lon":-82.38304, +"population":2163824 +}, +{ +"slug":"depok-west-java-id", +"name":"Depok", +"admin1":"West Java", +"country":"Indonesia", +"country_code":"ID", +"lat":-6.4, +"lon":106.81861, +"population":2163635 +}, +{ +"slug":"baotou-inner-mongolia-cn", +"name":"Baotou", +"admin1":"Inner Mongolia", +"country":"China", +"country_code":"CN", +"lat":40.6516, +"lon":109.84389, +"population":2150000 +}, +{ +"slug":"paris-ile-de-france-fr", +"name":"Paris", +"admin1":"Île-de-France", +"country":"France", +"country_code":"FR", +"lat":48.85341, +"lon":2.3488, +"population":2138551 +}, +{ +"slug":"coimbatore-tamil-nadu-in", +"name":"Coimbatore", +"admin1":"Tamil Nadu", +"country":"India", +"country_code":"IN", +"lat":11.00555, +"lon":76.96612, +"population":2136916 +}, +{ +"slug":"qingyang-gansu-cn", +"name":"Qingyang", +"admin1":"Gansu", +"country":"China", +"country_code":"CN", +"lat":35.70976, +"lon":107.64455, +"population":2125400 +}, +{ +"slug":"port-harcourt-rivers-state-ng", +"name":"Port Harcourt", +"admin1":"Rivers State", +"country":"Nigeria", +"country_code":"NG", +"lat":4.77742, +"lon":7.0134, +"population":2120000 +}, +{ +"slug":"pretoria-gauteng-za", +"name":"Pretoria", +"admin1":"Gauteng", +"country":"South Africa", +"country_code":"ZA", +"lat":-25.74486, +"lon":28.18783, +"population":2112693 +}, +{ +"slug":"cordoba-ar", +"name":"Córdoba", +"admin1":"Cordoba", +"country":"Argentina", +"country_code":"AR", +"lat":-31.40648, +"lon":-64.18853, +"population":2106734 +}, +{ +"slug":"mbuji-mayi-east-kasai-cd", +"name":"Mbuji-Mayi", +"admin1":"East Kasai", +"country":"Democratic Republic of the Congo", +"country_code":"CD", +"lat":-6.13603, +"lon":23.58979, +"population":2101332 +}, +{ +"slug":"aleppo-sy", +"name":"Aleppo", +"admin1":"Aleppo", +"country":"Syria", +"country_code":"SY", +"lat":36.20124, +"lon":37.16117, +"population":2098210 +}, +{ +"slug":"kunshan-jiangsu-cn", +"name":"Kunshan", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":31.37762, +"lon":120.95431, +"population":2092496 +}, +{ +"slug":"al-mawsil-al-jadidah-nineveh-iq", +"name":"Al Mawşil al Jadīdah", +"admin1":"Nineveh", +"country":"Iraq", +"country_code":"IQ", +"lat":36.33271, +"lon":43.10555, +"population":2065597 +}, +{ +"slug":"weifang-shandong-cn", +"name":"Weifang", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":36.71, +"lon":119.10194, +"population":2044028 +}, +{ +"slug":"zunyi-guizhou-cn", +"name":"Zunyi", +"admin1":"Guizhou", +"country":"China", +"country_code":"CN", +"lat":27.68667, +"lon":106.90722, +"population":2037775 +}, +{ +"slug":"al-basrah-al-qadimah-basra-iq", +"name":"Al Başrah al Qadīmah", +"admin1":"Basra", +"country":"Iraq", +"country_code":"IQ", +"lat":30.50316, +"lon":47.81507, +"population":2015483 +}, +{ +"slug":"la-paz-la-paz-department-bo", +"name":"La Paz", +"admin1":"La Paz Department", +"country":"Bolivia", +"country_code":"BO", +"lat":-16.5, +"lon":-68.15, +"population":2004652 +}, +{ +"slug":"lianyungang-jiangsu-cn", +"name":"Lianyungang", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":34.59845, +"lon":119.21556, +"population":2001009 +}, +{ +"slug":"medellin-antioquia-co", +"name":"Medellín", +"admin1":"Antioquia", +"country":"Colombia", +"country_code":"CO", +"lat":6.245, +"lon":-75.57151, +"population":1999979 +}, +{ +"slug":"indore-madhya-pradesh-in", +"name":"Indore", +"admin1":"Madhya Pradesh", +"country":"India", +"country_code":"IN", +"lat":22.71792, +"lon":75.8333, +"population":1994397 +}, +{ +"slug":"brazzaville-cg", +"name":"Brazzaville", +"admin1":"Brazzaville", +"country":"Republic of the Congo", +"country_code":"CG", +"lat":-4.26613, +"lon":15.28318, +"population":1982000 +}, +{ +"slug":"tashkent-uz", +"name":"Tashkent", +"admin1":"Tashkent", +"country":"Uzbekistan", +"country_code":"UZ", +"lat":41.26465, +"lon":69.21627, +"population":1978028 +}, +{ +"slug":"ganzhou-jiangxi-cn", +"name":"Ganzhou", +"admin1":"Jiangxi", +"country":"China", +"country_code":"CN", +"lat":25.84664, +"lon":114.9326, +"population":1977253 +}, +{ +"slug":"almaty-kz", +"name":"Almaty", +"admin1":"Almaty", +"country":"Kazakhstan", +"country_code":"KZ", +"lat":43.25249, +"lon":76.9115, +"population":1977011 +}, +{ +"slug":"khartoum-sd", +"name":"Khartoum", +"admin1":"Khartoum", +"country":"Sudan", +"country_code":"SD", +"lat":15.55177, +"lon":32.53241, +"population":1974647 +}, +{ +"slug":"hamburg-de", +"name":"Hamburg", +"admin1":"Hamburg", +"country":"Germany", +"country_code":"DE", +"lat":53.55073, +"lon":9.99302, +"population":1973896 +}, +{ +"slug":"sapporo-hokkaido-jp", +"name":"Sapporo", +"admin1":"Hokkaido", +"country":"Japan", +"country_code":"JP", +"lat":43.06667, +"lon":141.35, +"population":1973832 +}, +{ +"slug":"songjiang-shanghai-cn", +"name":"Songjiang", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.03443, +"lon":121.22326, +"population":1973500 +}, +{ +"slug":"accra-greater-accra-gh", +"name":"Accra", +"admin1":"Greater Accra", +"country":"Ghana", +"country_code":"GH", +"lat":5.55602, +"lon":-0.1969, +"population":1963264 +}, +{ +"slug":"curitiba-parana-br", +"name":"Curitiba", +"admin1":"Paraná", +"country":"Brazil", +"country_code":"BR", +"lat":-25.42778, +"lon":-49.27306, +"population":1948626 +}, +{ +"slug":"ordos-inner-mongolia-cn", +"name":"Ordos", +"admin1":"Inner Mongolia", +"country":"China", +"country_code":"CN", +"lat":39.6086, +"lon":109.78157, +"population":1940653 +}, +{ +"slug":"sanaa-amanat-alasimah-ye", +"name":"Sanaa", +"admin1":"Amanat Alasimah", +"country":"Yemen", +"country_code":"YE", +"lat":15.35452, +"lon":44.20646, +"population":1937451 +}, +{ +"slug":"conakry-gn", +"name":"Conakry", +"admin1":"Conakry", +"country":"Guinea", +"country_code":"GN", +"lat":9.53795, +"lon":-13.67729, +"population":1928389 +}, +{ +"slug":"tangerang-banten-id", +"name":"Tangerang", +"admin1":"Banten", +"country":"Indonesia", +"country_code":"ID", +"lat":-6.17806, +"lon":106.63, +"population":1927815 +}, +{ +"slug":"tijuana-baja-california-mx", +"name":"Tijuana", +"admin1":"Baja California", +"country":"Mexico", +"country_code":"MX", +"lat":32.5027, +"lon":-117.00371, +"population":1922523 +}, +{ +"slug":"hyderabad-sindh-pk", +"name":"Hyderabad", +"admin1":"Sindh", +"country":"Pakistan", +"country_code":"PK", +"lat":25.39689, +"lon":68.37718, +"population":1921275 +}, +{ +"slug":"beirut-lb", +"name":"Beirut", +"admin1":"Beirut", +"country":"Lebanon", +"country_code":"LB", +"lat":33.89332, +"lon":35.50157, +"population":1916100 +}, +{ +"slug":"jieyang-guangdong-cn", +"name":"Jieyang", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":23.5418, +"lon":116.36581, +"population":1899394 +}, +{ +"slug":"jilin-cn", +"name":"Jilin", +"admin1":"Jilin", +"country":"China", +"country_code":"CN", +"lat":43.84652, +"lon":126.5608, +"population":1895865 +}, +{ +"slug":"jiading-shanghai-cn", +"name":"Jiading", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.38575, +"lon":121.24465, +"population":1886100 +}, +{ +"slug":"bucharest-ro", +"name":"Bucharest", +"admin1":"Bucharest", +"country":"Romania", +"country_code":"RO", +"lat":44.43225, +"lon":26.10626, +"population":1877155 +}, +{ +"slug":"camayenne-conakry-gn", +"name":"Camayenne", +"admin1":"Conakry", +"country":"Guinea", +"country_code":"GN", +"lat":9.535, +"lon":-13.68778, +"population":1871242 +}, +{ +"slug":"kakamega-kakamega-county-ke", +"name":"Kakamega", +"admin1":"Kakamega County", +"country":"Kenya", +"country_code":"KE", +"lat":0.28422, +"lon":34.75229, +"population":1867579 +}, +{ +"slug":"shangqiu-henan-cn", +"name":"Shangqiu", +"admin1":"Henan", +"country":"China", +"country_code":"CN", +"lat":34.4143, +"lon":115.65613, +"population":1859723 +}, +{ +"slug":"nanchong-sichuan-cn", +"name":"Nanchong", +"admin1":"Sichuan", +"country":"China", +"country_code":"CN", +"lat":30.79508, +"lon":106.08473, +"population":1858875 +}, +{ +"slug":"tainan-taiwan-tw", +"name":"Tainan", +"admin1":"Taiwan", +"country":"Taiwan", +"country_code":"TW", +"lat":22.99083, +"lon":120.21333, +"population":1856642 +}, +{ +"slug":"datong-shanxi-cn", +"name":"Datong", +"admin1":"Shanxi", +"country":"China", +"country_code":"CN", +"lat":40.09361, +"lon":113.29139, +"population":1850000 +}, +{ +"slug":"kaduna-kaduna-state-ng", +"name":"Kaduna", +"admin1":"Kaduna State", +"country":"Nigeria", +"country_code":"NG", +"lat":10.52641, +"lon":7.43879, +"population":1850000 +}, +{ +"slug":"omdurman-khartoum-sd", +"name":"Omdurman", +"admin1":"Khartoum", +"country":"Sudan", +"country_code":"SD", +"lat":15.64453, +"lon":32.47773, +"population":1849659 +}, +{ +"slug":"davao-davao-region-ph", +"name":"Davao", +"admin1":"Davao Region", +"country":"Philippines", +"country_code":"PH", +"lat":7.07306, +"lon":125.61278, +"population":1848947 +}, +{ +"slug":"thane-maharashtra-in", +"name":"Thāne", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":19.19704, +"lon":72.96355, +"population":1841488 +}, +{ +"slug":"iztapalapa-mexico-city-mx", +"name":"Iztapalapa", +"admin1":"Mexico City", +"country":"Mexico", +"country_code":"MX", +"lat":19.35529, +"lon":-99.06224, +"population":1835486 +}, +{ +"slug":"diyarbakr-diyarbakr-province-tr", +"name":"Diyarbakır", +"admin1":"Diyarbakır Province", +"country":"Turkey", +"country_code":"TR", +"lat":37.91363, +"lon":40.21721, +"population":1833684 +}, +{ +"slug":"santa-cruz-de-la-sierra-santa-cruz-department-bo", +"name":"Santa Cruz de la Sierra", +"admin1":"Santa Cruz Department", +"country":"Bolivia", +"country_code":"BO", +"lat":-17.78629, +"lon":-63.18117, +"population":1831434 +}, +{ +"slug":"vadodara-gujarat-in", +"name":"Vadodara", +"admin1":"Gujarat", +"country":"India", +"country_code":"IN", +"lat":22.29941, +"lon":73.20812, +"population":1822221 +}, +{ +"slug":"adana-tr", +"name":"Adana", +"admin1":"Adana", +"country":"Turkey", +"country_code":"TR", +"lat":36.98615, +"lon":35.32531, +"population":1816750 +}, +{ +"slug":"nanyang-henan-cn", +"name":"Nanyang", +"admin1":"Henan", +"country":"China", +"country_code":"CN", +"lat":33.00524, +"lon":112.54659, +"population":1811812 +}, +{ +"slug":"abu-dhabi-ae", +"name":"Abu Dhabi", +"admin1":"Abu Dhabi", +"country":"United Arab Emirates", +"country_code":"AE", +"lat":24.45118, +"lon":54.39696, +"population":1807000 +}, +{ +"slug":"palembang-south-sumatra-id", +"name":"Palembang", +"admin1":"South Sumatra", +"country":"Indonesia", +"country_code":"ID", +"lat":-2.91673, +"lon":104.7458, +"population":1801367 +}, +{ +"slug":"sharjah-ae", +"name":"Sharjah", +"admin1":"Sharjah", +"country":"United Arab Emirates", +"country_code":"AE", +"lat":25.3342, +"lon":55.41221, +"population":1800000 +}, +{ +"slug":"bhopal-madhya-pradesh-in", +"name":"Bhopal", +"admin1":"Madhya Pradesh", +"country":"India", +"country_code":"IN", +"lat":23.25469, +"lon":77.40289, +"population":1798218 +}, +{ +"slug":"jiangmen-guangdong-cn", +"name":"Jiangmen", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":22.58333, +"lon":113.08333, +"population":1795459 +}, +{ +"slug":"benin-city-edo-state-ng", +"name":"Benin City", +"admin1":"Edo State", +"country":"Nigeria", +"country_code":"NG", +"lat":6.33815, +"lon":5.62575, +"population":1782000 +}, +{ +"slug":"jiangyin-jiangsu-cn", +"name":"Jiangyin", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":31.91102, +"lon":120.26302, +"population":1779515 +}, +{ +"slug":"fuyang-anhui-cn", +"name":"Fuyang", +"admin1":"Anhui", +"country":"China", +"country_code":"CN", +"lat":32.9, +"lon":115.81667, +"population":1768947 +}, +{ +"slug":"montreal-quebec-ca", +"name":"Montréal", +"admin1":"Quebec", +"country":"Canada", +"country_code":"CA", +"lat":45.50884, +"lon":-73.58781, +"population":1762949 +}, +{ +"slug":"bayan-nur-inner-mongolia-cn", +"name":"Bayan Nur", +"admin1":"Inner Mongolia", +"country":"China", +"country_code":"CN", +"lat":40.74143, +"lon":107.38599, +"population":1760000 +}, +{ +"slug":"maracaibo-zulia-ve", +"name":"Maracaibo", +"admin1":"Zulia", +"country":"Venezuela", +"country_code":"VE", +"lat":10.64232, +"lon":-71.61089, +"population":1752602 +}, +{ +"slug":"chaozhou-guangdong-cn", +"name":"Chaozhou", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":23.65396, +"lon":116.62262, +"population":1750945 +}, +{ +"slug":"minsk-minsk-city-by", +"name":"Minsk", +"admin1":"Minsk City", +"country":"Belarus", +"country_code":"BY", +"lat":53.90019, +"lon":27.56653, +"population":1742124 +}, +{ +"slug":"budapest-hu", +"name":"Budapest", +"admin1":"Budapest", +"country":"Hungary", +"country_code":"HU", +"lat":47.49835, +"lon":19.04045, +"population":1741041 +}, +{ +"slug":"qingyuan-guangdong-cn", +"name":"Qingyuan", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":23.7, +"lon":113.03333, +"population":1738424 +}, +{ +"slug":"taian-shandong-cn", +"name":"Tai’an", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":36.18528, +"lon":117.12, +"population":1735425 +}, +{ +"slug":"rasapudipalem-andhra-pradesh-in", +"name":"Rasapūdipalem", +"admin1":"Andhra Pradesh", +"country":"India", +"country_code":"IN", +"lat":17.73308, +"lon":83.31622, +"population":1728128 +}, +{ +"slug":"pimpri-chinchwad-maharashtra-in", +"name":"Pimpri-Chinchwad", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":18.61867, +"lon":73.80375, +"population":1727692 +}, +{ +"slug":"caloocan-national-capital-region-ph", +"name":"Caloocan", +"admin1":"National Capital Region", +"country":"Philippines", +"country_code":"PH", +"lat":14.64953, +"lon":120.96788, +"population":1712945 +}, +{ +"slug":"warsaw-mazovia-pl", +"name":"Warsaw", +"admin1":"Mazovia", +"country":"Poland", +"country_code":"PL", +"lat":52.22977, +"lon":21.01178, +"population":1702139 +}, +{ +"slug":"soweto-gauteng-za", +"name":"Soweto", +"admin1":"Gauteng", +"country":"South Africa", +"country_code":"ZA", +"lat":-26.26781, +"lon":27.85849, +"population":1695047 +}, +{ +"slug":"semarang-central-java-id", +"name":"Semarang", +"admin1":"Central Java", +"country":"Indonesia", +"country_code":"ID", +"lat":-6.99306, +"lon":110.42083, +"population":1694740 +}, +{ +"slug":"puebla-mx", +"name":"Puebla", +"admin1":"Puebla", +"country":"Mexico", +"country_code":"MX", +"lat":19.04778, +"lon":-98.20723, +"population":1692181 +}, +{ +"slug":"vienna-state-of-vienna-at", +"name":"Vienna", +"admin1":"State of Vienna", +"country":"Austria", +"country_code":"AT", +"lat":48.20849, +"lon":16.37208, +"population":1691468 +}, +{ +"slug":"barcelona-catalonia-es", +"name":"Barcelona", +"admin1":"Catalonia", +"country":"Spain", +"country_code":"ES", +"lat":41.38879, +"lon":2.15899, +"population":1686208 +}, +{ +"slug":"patna-bihar-in", +"name":"Patna", +"admin1":"Bihar", +"country":"India", +"country_code":"IN", +"lat":25.59408, +"lon":85.13563, +"population":1684297 +}, +{ +"slug":"mosul-nineveh-iq", +"name":"Mosul", +"admin1":"Nineveh", +"country":"Iraq", +"country_code":"IQ", +"lat":36.335, +"lon":43.11889, +"population":1683000 +}, +{ +"slug":"kallakurichi-tamil-nadu-in", +"name":"Kallakurichi", +"admin1":"Tamil Nadu", +"country":"India", +"country_code":"IN", +"lat":11.73379, +"lon":78.95925, +"population":1682687 +}, +{ +"slug":"kampala-central-region-ug", +"name":"Kampala", +"admin1":"Central Region", +"country":"Uganda", +"country_code":"UG", +"lat":0.31628, +"lon":32.58219, +"population":1680600 +}, +{ +"slug":"xining-qinghai-cn", +"name":"Xining", +"admin1":"Qinghai", +"country":"China", +"country_code":"CN", +"lat":36.62554, +"lon":101.75739, +"population":1677177 +}, +{ +"slug":"changshu-jiangsu-cn", +"name":"Changshu", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":31.64615, +"lon":120.74221, +"population":1677050 +}, +{ +"slug":"huainan-anhui-cn", +"name":"Huainan", +"admin1":"Anhui", +"country":"China", +"country_code":"CN", +"lat":32.62639, +"lon":116.99694, +"population":1666826 +}, +{ +"slug":"rabat-rabat-sale-kenitra-ma", +"name":"Rabat", +"admin1":"Rabat-Salé-Kénitra", +"country":"Morocco", +"country_code":"MA", +"lat":34.01325, +"lon":-6.83255, +"population":1655753 +}, +{ +"slug":"recife-pernambuco-br", +"name":"Recife", +"admin1":"Pernambuco", +"country":"Brazil", +"country_code":"BR", +"lat":-8.05389, +"lon":-34.88111, +"population":1653461 +}, +{ +"slug":"phoenix-arizona-us", +"name":"Phoenix", +"admin1":"Arizona", +"country":"United States", +"country_code":"US", +"lat":33.44838, +"lon":-112.07404, +"population":1650070 +}, +{ +"slug":"suzhou-anhui-cn", +"name":"Suzhou", +"admin1":"Anhui", +"country":"China", +"country_code":"CN", +"lat":33.63611, +"lon":116.97889, +"population":1647642 +}, +{ +"slug":"ecatepec-de-morelos-mexico-mx", +"name":"Ecatepec de Morelos", +"admin1":"México", +"country":"Mexico", +"country_code":"MX", +"lat":19.60492, +"lon":-99.06064, +"population":1645352 +}, +{ +"slug":"luan-anhui-cn", +"name":"Lu’an", +"admin1":"Anhui", +"country":"China", +"country_code":"CN", +"lat":31.73561, +"lon":116.51688, +"population":1644344 +}, +{ +"slug":"valencia-carabobo-ve", +"name":"Valencia", +"admin1":"Carabobo", +"country":"Venezuela", +"country_code":"VE", +"lat":10.16153, +"lon":-68.00044, +"population":1619470 +}, +{ +"slug":"ludhiana-punjab-in", +"name":"Ludhiana", +"admin1":"Punjab", +"country":"India", +"country_code":"IN", +"lat":30.91204, +"lon":75.85379, +"population":1618879 +}, +{ +"slug":"yancheng-jiangsu-cn", +"name":"Yancheng", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":33.3575, +"lon":120.1573, +"population":1615717 +}, +{ +"slug":"novosibirsk-novosibirsk-oblast-ru", +"name":"Novosibirsk", +"admin1":"Novosibirsk Oblast", +"country":"Russia", +"country_code":"RU", +"lat":55.02259, +"lon":82.93175, +"population":1612833 +}, +{ +"slug":"erbil-iq", +"name":"Erbil", +"admin1":"Erbil", +"country":"Iraq", +"country_code":"IQ", +"lat":36.19117, +"lon":44.00943, +"population":1612700 +}, +{ +"slug":"fukuoka-jp", +"name":"Fukuoka", +"admin1":"Fukuoka", +"country":"Japan", +"country_code":"JP", +"lat":33.6, +"lon":130.41667, +"population":1612392 +}, +{ +"slug":"taizhou-jiangsu-cn", +"name":"Taizhou", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":32.49069, +"lon":119.90812, +"population":1607108 +}, +{ +"slug":"daqing-heilongjiang-cn", +"name":"Daqing", +"admin1":"Heilongjiang", +"country":"China", +"country_code":"CN", +"lat":46.58333, +"lon":125.0, +"population":1604027 +}, +{ +"slug":"manila-national-capital-region-ph", +"name":"Manila", +"admin1":"National Capital Region", +"country":"Philippines", +"country_code":"PH", +"lat":14.6042, +"lon":120.9822, +"population":1600000 +}, +{ +"slug":"wuhu-anhui-cn", +"name":"Wuhu", +"admin1":"Anhui", +"country":"China", +"country_code":"CN", +"lat":31.35259, +"lon":118.42947, +"population":1598165 +}, +{ +"slug":"santiago-de-queretaro-queretaro-mx", +"name":"Santiago de Querétaro", +"admin1":"Querétaro", +"country":"Mexico", +"country_code":"MX", +"lat":20.58806, +"lon":-100.38806, +"population":1594212 +}, +{ +"slug":"dazhou-sichuan-cn", +"name":"Dazhou", +"admin1":"Sichuan", +"country":"China", +"country_code":"CN", +"lat":31.2106, +"lon":107.46308, +"population":1589435 +}, +{ +"slug":"yangzhou-jiangsu-cn", +"name":"Yangzhou", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":32.39722, +"lon":119.43583, +"population":1584237 +}, +{ +"slug":"leon-de-los-aldama-guanajuato-mx", +"name":"León de los Aldama", +"admin1":"Guanajuato", +"country":"Mexico", +"country_code":"MX", +"lat":21.12183, +"lon":-101.68253, +"population":1579803 +}, +{ +"slug":"makkah-mecca-region-sa", +"name":"Makkah", +"admin1":"Mecca Region", +"country":"Saudi Arabia", +"country_code":"SA", +"lat":21.42664, +"lon":39.82563, +"population":1578722 +}, +{ +"slug":"philadelphia-pennsylvania-us", +"name":"Philadelphia", +"admin1":"Pennsylvania", +"country":"United States", +"country_code":"US", +"lat":39.95238, +"lon":-75.16362, +"population":1573916 +}, +{ +"slug":"phnom-penh-kh", +"name":"Phnom Penh", +"admin1":"Phnom Penh", +"country":"Cambodia", +"country_code":"KH", +"lat":11.56245, +"lon":104.91601, +"population":1573544 +}, +{ +"slug":"guilin-guangxi-cn", +"name":"Guilin", +"admin1":"Guangxi", +"country":"China", +"country_code":"CN", +"lat":25.28022, +"lon":110.29639, +"population":1572300 +}, +{ +"slug":"damascus-damascus-governorate-sy", +"name":"Damascus", +"admin1":"Damascus Governorate", +"country":"Syria", +"country_code":"SY", +"lat":33.5102, +"lon":36.29128, +"population":1569394 +}, +{ +"slug":"quetta-balochistan-pk", +"name":"Quetta", +"admin1":"Balochistan", +"country":"Pakistan", +"country_code":"PK", +"lat":30.18414, +"lon":67.00141, +"population":1565546 +}, +{ +"slug":"zhaoqing-guangdong-cn", +"name":"Zhaoqing", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":23.04893, +"lon":112.46091, +"population":1553109 +}, +{ +"slug":"onitsha-anambra-ng", +"name":"Onitsha", +"admin1":"Anambra", +"country":"Nigeria", +"country_code":"NG", +"lat":6.14978, +"lon":6.78569, +"population":1553000 +}, +{ +"slug":"mianyang-sichuan-cn", +"name":"Mianyang", +"admin1":"Sichuan", +"country":"China", +"country_code":"CN", +"lat":31.46784, +"lon":104.68168, +"population":1550000 +}, +{ +"slug":"auckland-nz", +"name":"Auckland", +"admin1":"Auckland", +"country":"New Zealand", +"country_code":"NZ", +"lat":-36.84853, +"lon":174.76349, +"population":1547200 +}, +{ +"slug":"isfahan-ir", +"name":"Isfahan", +"admin1":"Isfahan", +"country":"Iran", +"country_code":"IR", +"lat":32.65246, +"lon":51.67462, +"population":1547164 +}, +{ +"slug":"wanzhou-chongqing-cn", +"name":"Wanzhou", +"admin1":"Chongqing", +"country":"China", +"country_code":"CN", +"lat":30.76451, +"lon":108.39586, +"population":1545900 +}, +{ +"slug":"astana-kz", +"name":"Astana", +"admin1":"Astana", +"country":"Kazakhstan", +"country_code":"KZ", +"lat":51.1801, +"lon":71.44598, +"population":1544142 +}, +{ +"slug":"harare-zw", +"name":"Harare", +"admin1":"Harare", +"country":"Zimbabwe", +"country_code":"ZW", +"lat":-17.82772, +"lon":31.05337, +"population":1542813 +}, +{ +"slug":"monrovia-montserrado-county-lr", +"name":"Monrovia", +"admin1":"Montserrado County", +"country":"Liberia", +"country_code":"LR", +"lat":6.30054, +"lon":-10.7969, +"population":1542549 +}, +{ +"slug":"putian-fujian-cn", +"name":"Putian", +"admin1":"Fujian", +"country":"China", +"country_code":"CN", +"lat":25.43944, +"lon":119.01028, +"population":1539389 +}, +{ +"slug":"kawasaki-kanagawa-jp", +"name":"Kawasaki", +"admin1":"Kanagawa", +"country":"Japan", +"country_code":"JP", +"lat":35.52056, +"lon":139.71722, +"population":1538262 +}, +{ +"slug":"goiania-goias-br", +"name":"Goiânia", +"admin1":"Goiás", +"country":"Brazil", +"country_code":"BR", +"lat":-16.67861, +"lon":-49.25389, +"population":1536097 +}, +{ +"slug":"san-antonio-texas-us", +"name":"San Antonio", +"admin1":"Texas", +"country":"United States", +"country_code":"US", +"lat":29.42412, +"lon":-98.49363, +"population":1526656 +}, +{ +"slug":"kobe-hyogo-jp", +"name":"Kobe", +"admin1":"Hyōgo", +"country":"Japan", +"country_code":"JP", +"lat":34.6913, +"lon":135.183, +"population":1525152 +}, +{ +"slug":"stockholm-se", +"name":"Stockholm", +"admin1":"Stockholm", +"country":"Sweden", +"country_code":"SE", +"lat":59.32938, +"lon":18.06871, +"population":1515017 +}, +{ +"slug":"ciudad-juarez-chihuahua-mx", +"name":"Ciudad Juárez", +"admin1":"Chihuahua", +"country":"Mexico", +"country_code":"MX", +"lat":31.72024, +"lon":-106.46084, +"population":1512450 +}, +{ +"slug":"can-tho-can-tho-city-vn", +"name":"Cần Thơ", +"admin1":"Can Tho City", +"country":"Vietnam", +"country_code":"VN", +"lat":10.03711, +"lon":105.78825, +"population":1507187 +}, +{ +"slug":"munich-bavaria-de", +"name":"Munich", +"admin1":"Bavaria", +"country":"Germany", +"country_code":"DE", +"lat":48.13743, +"lon":11.57549, +"population":1505005 +}, +{ +"slug":"khulna-khulna-division-bd", +"name":"Khulna", +"admin1":"Khulna Division", +"country":"Bangladesh", +"country_code":"BD", +"lat":22.80979, +"lon":89.56439, +"population":1500689 +}, +{ +"slug":"belem-para-br", +"name":"Belém", +"admin1":"Pará", +"country":"Brazil", +"country_code":"BR", +"lat":-1.45583, +"lon":-48.50444, +"population":1499641 +}, +{ +"slug":"yekaterinburg-sverdlovsk-oblast-ru", +"name":"Yekaterinburg", +"admin1":"Sverdlovsk Oblast", +"country":"Russia", +"country_code":"RU", +"lat":56.85733, +"lon":60.61529, +"population":1495066 +}, +{ +"slug":"porto-alegre-rio-grande-do-sul-br", +"name":"Porto Alegre", +"admin1":"Rio Grande do Sul", +"country":"Brazil", +"country_code":"BR", +"lat":-30.03283, +"lon":-51.23019, +"population":1488252 +}, +{ +"slug":"yinchuan-ningxia-cn", +"name":"Yinchuan", +"admin1":"Ningxia", +"country":"China", +"country_code":"CN", +"lat":38.46806, +"lon":106.27306, +"population":1487579 +}, +{ +"slug":"manhattan-new-york-us", +"name":"Manhattan", +"admin1":"New York", +"country":"United States", +"country_code":"US", +"lat":40.78343, +"lon":-73.96625, +"population":1487536 +}, +{ +"slug":"nashik-maharashtra-in", +"name":"Nashik", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":19.99727, +"lon":73.79096, +"population":1486053 +}, +{ +"slug":"taizhou-zhejiang-cn", +"name":"Taizhou", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":28.66266, +"lon":121.43312, +"population":1485502 +}, +{ +"slug":"asuncion-py", +"name":"Asunción", +"admin1":"Asuncion", +"country":"Paraguay", +"country_code":"PY", +"lat":-25.28646, +"lon":-57.647, +"population":1482200 +}, +{ +"slug":"yiwu-zhejiang-cn", +"name":"Yiwu", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":29.31506, +"lon":120.07676, +"population":1481384 +}, +{ +"slug":"zapopan-jalisco-mx", +"name":"Zapopan", +"admin1":"Jalisco", +"country":"Mexico", +"country_code":"MX", +"lat":20.72111, +"lon":-103.38742, +"population":1476491 +}, +{ +"slug":"makassar-south-sulawesi-id", +"name":"Makassar", +"admin1":"South Sulawesi", +"country":"Indonesia", +"country_code":"ID", +"lat":-5.14861, +"lon":119.43194, +"population":1474393 +}, +{ +"slug":"adelaide-south-australia-au", +"name":"Adelaide", +"admin1":"South Australia", +"country":"Australia", +"country_code":"AU", +"lat":-34.92866, +"lon":138.59863, +"population":1469163 +}, +{ +"slug":"quanzhou-fujian-cn", +"name":"Quanzhou", +"admin1":"Fujian", +"country":"China", +"country_code":"CN", +"lat":24.91389, +"lon":118.58583, +"population":1469157 +}, +{ +"slug":"madurai-tamil-nadu-in", +"name":"Madurai", +"admin1":"Tamil Nadu", +"country":"India", +"country_code":"IN", +"lat":9.919, +"lon":78.11953, +"population":1465625 +}, +{ +"slug":"jinhua-zhejiang-cn", +"name":"Jinhua", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":29.10678, +"lon":119.64421, +"population":1463990 +}, +{ +"slug":"kyoto-jp", +"name":"Kyoto", +"admin1":"Kyoto", +"country":"Japan", +"country_code":"JP", +"lat":35.02107, +"lon":135.75385, +"population":1463723 +}, +{ +"slug":"cixi-zhejiang-cn", +"name":"Cixi", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":30.1764, +"lon":121.2457, +"population":1457510 +}, +{ +"slug":"changde-hunan-cn", +"name":"Changde", +"admin1":"Hunan", +"country":"China", +"country_code":"CN", +"lat":29.03205, +"lon":111.69844, +"population":1457419 +}, +{ +"slug":"kuala-lumpur-my", +"name":"Kuala Lumpur", +"admin1":"Kuala Lumpur", +"country":"Malaysia", +"country_code":"MY", +"lat":3.1412, +"lon":101.68653, +"population":1453975 +}, +{ +"slug":"kayseri-tr", +"name":"Kayseri", +"admin1":"Kayseri", +"country":"Turkey", +"country_code":"TR", +"lat":38.73222, +"lon":35.48528, +"population":1452458 +}, +{ +"slug":"kaifeng-henan-cn", +"name":"Kaifeng", +"admin1":"Henan", +"country":"China", +"country_code":"CN", +"lat":34.7986, +"lon":114.30742, +"population":1451741 +}, +{ +"slug":"anshan-liaoning-cn", +"name":"Anshan", +"admin1":"Liaoning", +"country":"China", +"country_code":"CN", +"lat":41.12361, +"lon":122.99, +"population":1450000 +}, +{ +"slug":"karaj-alborz-province-ir", +"name":"Karaj", +"admin1":"Alborz Province", +"country":"Iran", +"country_code":"IR", +"lat":35.83266, +"lon":50.99155, +"population":1448075 +}, +{ +"slug":"kathmandu-bagmati-province-np", +"name":"Kathmandu", +"admin1":"Bagmati Province", +"country":"Nepal", +"country_code":"NP", +"lat":27.70169, +"lon":85.3206, +"population":1442271 +}, +{ +"slug":"daejeon-kr", +"name":"Daejeon", +"admin1":"Daejeon", +"country":"South Korea", +"country_code":"KR", +"lat":36.34913, +"lon":127.38493, +"population":1441203 +}, +{ +"slug":"baoji-shaanxi-cn", +"name":"Baoji", +"admin1":"Shaanxi", +"country":"China", +"country_code":"CN", +"lat":34.36775, +"lon":107.23705, +"population":1437802 +}, +{ +"slug":"suqian-jiangsu-cn", +"name":"Suqian", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":33.94917, +"lon":118.29583, +"population":1437685 +}, +{ +"slug":"liuzhou-guangxi-cn", +"name":"Liuzhou", +"admin1":"Guangxi", +"country":"China", +"country_code":"CN", +"lat":24.32405, +"lon":109.40698, +"population":1436599 +}, +{ +"slug":"tirunelveli-tamil-nadu-in", +"name":"Tirunelveli", +"admin1":"Tamil Nadu", +"country":"India", +"country_code":"IN", +"lat":8.72742, +"lon":77.6838, +"population":1435844 +}, +{ +"slug":"konya-tr", +"name":"Konya", +"admin1":"Konya", +"country":"Turkey", +"country_code":"TR", +"lat":37.87135, +"lon":32.48464, +"population":1433861 +}, +{ +"slug":"zhangjiagang-jiangsu-cn", +"name":"Zhangjiagang", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":31.865, +"lon":120.53889, +"population":1432044 +}, +{ +"slug":"agra-uttar-pradesh-in", +"name":"Agra", +"admin1":"Uttar Pradesh", +"country":"India", +"country_code":"IN", +"lat":27.18333, +"lon":78.01667, +"population":1430055 +}, +{ +"slug":"south-tangerang-banten-id", +"name":"South Tangerang", +"admin1":"Banten", +"country":"Indonesia", +"country_code":"ID", +"lat":-6.28862, +"lon":106.71789, +"population":1429529 +}, +{ +"slug":"tabriz-east-azerbaijan-ir", +"name":"Tabriz", +"admin1":"East Azerbaijan", +"country":"Iran", +"country_code":"IR", +"lat":38.08, +"lon":46.2919, +"population":1424641 +}, +{ +"slug":"kharkiv-ua", +"name":"Kharkiv", +"admin1":"Kharkiv", +"country":"Ukraine", +"country_code":"UA", +"lat":49.98177, +"lon":36.25475, +"population":1421125 +}, +{ +"slug":"jinjiang-fujian-cn", +"name":"Jinjiang", +"admin1":"Fujian", +"country":"China", +"country_code":"CN", +"lat":24.81978, +"lon":118.57415, +"population":1416151 +}, +{ +"slug":"faridabad-haryana-in", +"name":"Faridabad", +"admin1":"Haryana", +"country":"India", +"country_code":"IN", +"lat":28.41124, +"lon":77.31316, +"population":1414050 +}, +{ +"slug":"bozhou-anhui-cn", +"name":"Bozhou", +"admin1":"Anhui", +"country":"China", +"country_code":"CN", +"lat":33.87722, +"lon":115.77028, +"population":1409436 +}, +{ +"slug":"qujing-yunnan-cn", +"name":"Qujing", +"admin1":"Yunnan", +"country":"China", +"country_code":"CN", +"lat":25.48333, +"lon":103.78333, +"population":1408500 +}, +{ +"slug":"san-diego-california-us", +"name":"San Diego", +"admin1":"California", +"country":"United States", +"country_code":"US", +"lat":32.71571, +"lon":-117.16472, +"population":1404452 +}, +{ +"slug":"gwangju-kr", +"name":"Gwangju", +"admin1":"Gwangju", +"country":"South Korea", +"country_code":"KR", +"lat":35.15472, +"lon":126.91556, +"population":1401235 +}, +{ +"slug":"zhanjiang-guangdong-cn", +"name":"Zhanjiang", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":21.23391, +"lon":110.38749, +"population":1400709 +}, +{ +"slug":"fushun-liaoning-cn", +"name":"Fushun", +"admin1":"Liaoning", +"country":"China", +"country_code":"CN", +"lat":41.88669, +"lon":123.94363, +"population":1400646 +}, +{ +"slug":"rajkot-gujarat-in", +"name":"Rājkot", +"admin1":"Gujarat", +"country":"India", +"country_code":"IN", +"lat":22.29161, +"lon":70.79322, +"population":1390640 +}, +{ +"slug":"luoyang-henan-cn", +"name":"Luoyang", +"admin1":"Henan", +"country":"China", +"country_code":"CN", +"lat":34.67345, +"lon":112.43684, +"population":1390581 +}, +{ +"slug":"guadalajara-jalisco-mx", +"name":"Guadalajara", +"admin1":"Jalisco", +"country":"Mexico", +"country_code":"MX", +"lat":20.67738, +"lon":-103.34749, +"population":1385629 +}, +{ +"slug":"the-bronx-new-york-us", +"name":"The Bronx", +"admin1":"New York", +"country":"United States", +"country_code":"US", +"lat":40.84985, +"lon":-73.86641, +"population":1385108 +}, +{ +"slug":"guankou-hunan-cn", +"name":"Guankou", +"admin1":"Hunan", +"country":"China", +"country_code":"CN", +"lat":28.15861, +"lon":113.62709, +"population":1380000 +}, +{ +"slug":"hue-thua-thien-hue-province-vn", +"name":"Huế", +"admin1":"Thừa Thiên Huế Province", +"country":"Vietnam", +"country_code":"VN", +"lat":16.4619, +"lon":107.59546, +"population":1380000 +}, +{ +"slug":"milan-lombardy-it", +"name":"Milan", +"admin1":"Lombardy", +"country":"Italy", +"country_code":"IT", +"lat":45.46427, +"lon":9.18951, +"population":1371498 +}, +{ +"slug":"najafgarh-delhi-in", +"name":"Najafgarh", +"admin1":"Delhi", +"country":"India", +"country_code":"IN", +"lat":28.60922, +"lon":76.97982, +"population":1365000 +}, +{ +"slug":"n-djamena-ndjamena-td", +"name":"N'Djamena", +"admin1":"N’Djaména", +"country":"Chad", +"country_code":"TD", +"lat":12.10672, +"lon":15.0444, +"population":1359526 +}, +{ +"slug":"handan-hebei-cn", +"name":"Handan", +"admin1":"Hebei", +"country":"China", +"country_code":"CN", +"lat":36.60999, +"lon":114.48764, +"population":1358318 +}, +{ +"slug":"bannu-khyber-pakhtunkhwa-pk", +"name":"Bannu", +"admin1":"Khyber Pakhtunkhwa", +"country":"Pakistan", +"country_code":"PK", +"lat":32.98527, +"lon":70.60403, +"population":1357890 +}, +{ +"slug":"yichang-hubei-cn", +"name":"Yichang", +"admin1":"Hubei", +"country":"China", +"country_code":"CN", +"lat":30.71444, +"lon":111.28472, +"population":1350150 +}, +{ +"slug":"antananarivo-analamanga-mg", +"name":"Antananarivo", +"admin1":"Analamanga", +"country":"Madagascar", +"country_code":"MG", +"lat":-18.91368, +"lon":47.53613, +"population":1349501 +}, +{ +"slug":"heze-shandong-cn", +"name":"Heze", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":35.23929, +"lon":115.47358, +"population":1346717 +}, +{ +"slug":"guarulhos-sao-paulo-br", +"name":"Guarulhos", +"admin1":"São Paulo", +"country":"Brazil", +"country_code":"BR", +"lat":-23.46278, +"lon":-46.53333, +"population":1345364 +}, +{ +"slug":"abobo-abidjan-autonomous-district-ci", +"name":"Abobo", +"admin1":"Abidjan Autonomous District", +"country":"Ivory Coast", +"country_code":"CI", +"lat":5.41613, +"lon":-4.0159, +"population":1340083 +}, +{ +"slug":"jamshedpur-jharkhand-in", +"name":"Jamshedpur", +"admin1":"Jharkhand", +"country":"India", +"country_code":"IN", +"lat":22.80278, +"lon":86.18545, +"population":1339438 +}, +{ +"slug":"douala-littoral-cm", +"name":"Douala", +"admin1":"Littoral", +"country":"Cameroon", +"country_code":"CM", +"lat":4.04827, +"lon":9.70428, +"population":1338082 +}, +{ +"slug":"antalya-tr", +"name":"Antalya", +"admin1":"Antalya", +"country":"Turkey", +"country_code":"TR", +"lat":36.90812, +"lon":30.69556, +"population":1335002 +}, +{ +"slug":"basrah-basra-iq", +"name":"Basrah", +"admin1":"Basra", +"country":"Iraq", +"country_code":"IQ", +"lat":30.50852, +"lon":47.7804, +"population":1326564 +}, +{ +"slug":"dallas-texas-us", +"name":"Dallas", +"admin1":"Texas", +"country":"United States", +"country_code":"US", +"lat":32.78306, +"lon":-96.80667, +"population":1326087 +}, +{ +"slug":"saitama-jp", +"name":"Saitama", +"admin1":"Saitama", +"country":"Japan", +"country_code":"JP", +"lat":35.90807, +"lon":139.65657, +"population":1324854 +}, +{ +"slug":"gorakhpur-haryana-in", +"name":"Gorakhpur", +"admin1":"Haryana", +"country":"India", +"country_code":"IN", +"lat":29.44768, +"lon":75.67206, +"population":1324570 +}, +{ +"slug":"niamey-ne", +"name":"Niamey", +"admin1":"Niamey", +"country":"Niger", +"country_code":"NE", +"lat":13.51366, +"lon":2.1098, +"population":1323691 +}, +{ +"slug":"liupanshui-guizhou-cn", +"name":"Liupanshui", +"admin1":"Guizhou", +"country":"China", +"country_code":"CN", +"lat":26.59444, +"lon":104.83333, +"population":1320825 +}, +{ +"slug":"taguig-national-capital-region-ph", +"name":"Taguig", +"admin1":"National Capital Region", +"country":"Philippines", +"country_code":"PH", +"lat":14.5243, +"lon":121.0792, +"population":1308085 +}, +{ +"slug":"maoming-guangdong-cn", +"name":"Maoming", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":21.66625, +"lon":110.91364, +"population":1307802 +}, +{ +"slug":"calgary-alberta-ca", +"name":"Calgary", +"admin1":"Alberta", +"country":"Canada", +"country_code":"CA", +"lat":51.05011, +"lon":-114.08529, +"population":1306784 +}, +{ +"slug":"tripoli-ly", +"name":"Tripoli", +"admin1":"Tripoli", +"country":"Libya", +"country_code":"LY", +"lat":32.88743, +"lon":13.18733, +"population":1302947 +}, +{ +"slug":"madinah-medina-region-sa", +"name":"Madinah", +"admin1":"Medina Region", +"country":"Saudi Arabia", +"country_code":"SA", +"lat":24.46861, +"lon":39.61417, +"population":1300000 +}, +{ +"slug":"yaounde-centre-cm", +"name":"Yaoundé", +"admin1":"Centre", +"country":"Cameroon", +"country_code":"CM", +"lat":3.86667, +"lon":11.51667, +"population":1299369 +}, +{ +"slug":"batam-riau-id", +"name":"Batam", +"admin1":"Riau", +"country":"Indonesia", +"country_code":"ID", +"lat":1.14937, +"lon":104.02491, +"population":1296960 +}, +{ +"slug":"qinzhou-guangxi-cn", +"name":"Qinzhou", +"admin1":"Guangxi", +"country":"China", +"country_code":"CN", +"lat":21.98247, +"lon":108.65061, +"population":1296300 +}, +{ +"slug":"luohe-henan-cn", +"name":"Luohe", +"admin1":"Henan", +"country":"China", +"country_code":"CN", +"lat":33.57424, +"lon":114.03261, +"population":1294974 +}, +{ +"slug":"xiangyang-hubei-cn", +"name":"Xiangyang", +"admin1":"Hubei", +"country":"China", +"country_code":"CN", +"lat":32.0422, +"lon":112.14479, +"population":1294733 +}, +{ +"slug":"yangjiang-guangdong-cn", +"name":"Yangjiang", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":21.85563, +"lon":111.96272, +"population":1292987 +}, +{ +"slug":"yixing-jiangsu-cn", +"name":"Yixing", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":31.36059, +"lon":119.82016, +"population":1285785 +}, +{ +"slug":"pimpri-maharashtra-in", +"name":"Pimpri", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":18.62292, +"lon":73.80696, +"population":1284606 +}, +{ +"slug":"da-nang-da-nang-city-vn", +"name":"Da Nang", +"admin1":"Da Nang City", +"country":"Vietnam", +"country_code":"VN", +"lat":16.06778, +"lon":108.22083, +"population":1276000 +}, +{ +"slug":"amman-jo", +"name":"Amman", +"admin1":"Amman", +"country":"Jordan", +"country_code":"JO", +"lat":31.95522, +"lon":35.94503, +"population":1275857 +}, +{ +"slug":"budta-autonomous-region-in-muslim-mindanao-ph", +"name":"Budta", +"admin1":"Autonomous Region in Muslim Mindanao", +"country":"Philippines", +"country_code":"PH", +"lat":7.20417, +"lon":124.43972, +"population":1273715 +}, +{ +"slug":"belgrade-central-serbia-rs", +"name":"Belgrade", +"admin1":"Central Serbia", +"country":"Serbia", +"country_code":"RS", +"lat":44.80401, +"lon":20.46513, +"population":1273651 +}, +{ +"slug":"bien-hoa-dong-nai-vn", +"name":"Biên Hòa", +"admin1":"Dong Nai", +"country":"Vietnam", +"country_code":"VN", +"lat":10.94469, +"lon":106.82432, +"population":1272235 +}, +{ +"slug":"qingpu-shanghai-cn", +"name":"Qingpu", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.15394, +"lon":121.11408, +"population":1271424 +}, +{ +"slug":"montevideo-montevideo-department-uy", +"name":"Montevideo", +"admin1":"Montevideo Department", +"country":"Uruguay", +"country_code":"UY", +"lat":-34.90328, +"lon":-56.18816, +"population":1270737 +}, +{ +"slug":"xuchang-henan-cn", +"name":"Xuchang", +"admin1":"Henan", +"country":"China", +"country_code":"CN", +"lat":34.03189, +"lon":113.86299, +"population":1265536 +}, +{ +"slug":"kalyan-maharashtra-in", +"name":"Kalyān", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":19.2437, +"lon":73.13554, +"population":1262255 +}, +{ +"slug":"zigong-sichuan-cn", +"name":"Zigong", +"admin1":"Sichuan", +"country":"China", +"country_code":"CN", +"lat":29.34162, +"lon":104.77689, +"population":1262064 +}, +{ +"slug":"nizhniy-novgorod-nizhny-novgorod-oblast-ru", +"name":"Nizhniy Novgorod", +"admin1":"Nizhny Novgorod Oblast", +"country":"Russia", +"country_code":"RU", +"lat":56.32867, +"lon":44.00205, +"population":1259013 +}, +{ +"slug":"jepara-central-java-id", +"name":"Jepara", +"admin1":"Central Java", +"country":"Indonesia", +"country_code":"ID", +"lat":-6.5924, +"lon":110.671, +"population":1257912 +}, +{ +"slug":"maputo-maputo-city-mz", +"name":"Maputo", +"admin1":"Maputo City", +"country":"Mozambique", +"country_code":"MZ", +"lat":-25.96553, +"lon":32.58322, +"population":1254837 +}, +{ +"slug":"xuzhou-jiangsu-cn", +"name":"Xuzhou", +"admin1":"Jiangsu", +"country":"China", +"country_code":"CN", +"lat":34.20442, +"lon":117.28386, +"population":1253991 +}, +{ +"slug":"dammam-eastern-province-sa", +"name":"Dammam", +"admin1":"Eastern Province", +"country":"Saudi Arabia", +"country_code":"SA", +"lat":26.43442, +"lon":50.10326, +"population":1252523 +}, +{ +"slug":"ras-bayrut-beirut-lb", +"name":"Ra’s Bayrūt", +"admin1":"Beirut", +"country":"Lebanon", +"country_code":"LB", +"lat":33.9, +"lon":35.48333, +"population":1251739 +}, +{ +"slug":"neijiang-sichuan-cn", +"name":"Neijiang", +"admin1":"Sichuan", +"country":"China", +"country_code":"CN", +"lat":29.58354, +"lon":105.06216, +"population":1251095 +}, +{ +"slug":"shiraz-fars-ir", +"name":"Shiraz", +"admin1":"Fars", +"country":"Iran", +"country_code":"IR", +"lat":29.61031, +"lon":52.53113, +"population":1249942 +}, +{ +"slug":"heshan-hunan-cn", +"name":"Heshan", +"admin1":"Hunan", +"country":"China", +"country_code":"CN", +"lat":28.56938, +"lon":112.34733, +"population":1249807 +}, +{ +"slug":"dombivali-maharashtra-in", +"name":"Dombivali", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":19.21667, +"lon":73.08333, +"population":1247327 +}, +{ +"slug":"kananga-kasai-central-cd", +"name":"Kananga", +"admin1":"Kasai-Central", +"country":"Democratic Republic of the Congo", +"country_code":"CD", +"lat":-5.89624, +"lon":22.41659, +"population":1247168 +}, +{ +"slug":"kazan-tatarstan-republic-ru", +"name":"Kazan", +"admin1":"Tatarstan Republic", +"country":"Russia", +"country_code":"RU", +"lat":55.78874, +"lon":49.12214, +"population":1243500 +}, +{ +"slug":"jining-shandong-cn", +"name":"Jining", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":35.405, +"lon":116.58139, +"population":1241012 +}, +{ +"slug":"barquisimeto-lara-ve", +"name":"Barquisimeto", +"admin1":"Lara", +"country":"Venezuela", +"country_code":"VE", +"lat":10.0647, +"lon":-69.35703, +"population":1240714 +}, +{ +"slug":"shubra-al-khaymah-qalyubia-eg", +"name":"Shubrā al Khaymah", +"admin1":"Qalyubia", +"country":"Egypt", +"country_code":"EG", +"lat":30.12511, +"lon":31.25053, +"population":1240289 +}, +{ +"slug":"putuo-shanghai-cn", +"name":"Putuo", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.251, +"lon":121.3897, +"population":1239100 +}, +{ +"slug":"port-au-prince-ouest-ht", +"name":"Port-au-Prince", +"admin1":"Ouest", +"country":"Haiti", +"country_code":"HT", +"lat":18.54349, +"lon":-72.33881, +"population":1234742 +}, +{ +"slug":"suwon-gyeonggi-do-kr", +"name":"Suwon", +"admin1":"Gyeonggi-do", +"country":"South Korea", +"country_code":"KR", +"lat":37.29111, +"lon":127.00889, +"population":1234582 +}, +{ +"slug":"xinyang-henan-cn", +"name":"Xinyang", +"admin1":"Henan", +"country":"China", +"country_code":"CN", +"lat":32.12278, +"lon":114.06556, +"population":1230042 +}, +{ +"slug":"liaocheng-shandong-cn", +"name":"Liaocheng", +"admin1":"Shandong", +"country":"China", +"country_code":"CN", +"lat":36.45064, +"lon":116.00247, +"population":1229768 +}, +{ +"slug":"jinzhong-shanxi-cn", +"name":"Jinzhong", +"admin1":"Shanxi", +"country":"China", +"country_code":"CN", +"lat":37.68403, +"lon":112.75471, +"population":1226617 +}, +{ +"slug":"callao-pe", +"name":"Callao", +"admin1":"Callao", +"country":"Peru", +"country_code":"PE", +"lat":-12.05162, +"lon":-77.13452, +"population":1226200 +}, +{ +"slug":"meerut-uttar-pradesh-in", +"name":"Meerut", +"admin1":"Uttar Pradesh", +"country":"India", +"country_code":"IN", +"lat":28.98002, +"lon":77.70636, +"population":1223184 +}, +{ +"slug":"virar-maharashtra-in", +"name":"Virār", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":19.45591, +"lon":72.81136, +"population":1222390 +}, +{ +"slug":"nowrangapur-odisha-in", +"name":"Nowrangapur", +"admin1":"Odisha", +"country":"India", +"country_code":"IN", +"lat":19.23114, +"lon":82.54826, +"population":1220946 +}, +{ +"slug":"karbala-iq", +"name":"Karbala", +"admin1":"Karbala", +"country":"Iraq", +"country_code":"IQ", +"lat":32.61603, +"lon":44.02488, +"population":1218732 +}, +{ +"slug":"changzhi-shanxi-cn", +"name":"Changzhi", +"admin1":"Shanxi", +"country":"China", +"country_code":"CN", +"lat":36.18389, +"lon":113.10528, +"population":1214940 +}, +{ +"slug":"tianshui-gansu-cn", +"name":"Tianshui", +"admin1":"Gansu", +"country":"China", +"country_code":"CN", +"lat":34.57952, +"lon":105.74238, +"population":1212791 +}, +{ +"slug":"sadr-city-baghdad-iq", +"name":"Sadr City", +"admin1":"Baghdad", +"country":"Iraq", +"country_code":"IQ", +"lat":33.3889, +"lon":44.4583, +"population":1211849 +}, +{ +"slug":"yangpu-shanghai-cn", +"name":"Yangpu", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.26193, +"lon":121.51904, +"population":1210800 +}, +{ +"slug":"mombasa-mombasa-county-ke", +"name":"Mombasa", +"admin1":"Mombasa County", +"country":"Kenya", +"country_code":"KE", +"lat":-4.05466, +"lon":39.66359, +"population":1208333 +}, +{ +"slug":"mandalay-mandalay-region-mm", +"name":"Mandalay", +"admin1":"Mandalay Region", +"country":"Myanmar", +"country_code":"MM", +"lat":21.97473, +"lon":96.08359, +"population":1208099 +}, +{ +"slug":"srinagar-jammu-and-kashmir-in", +"name":"Srinagar", +"admin1":"Jammu and Kashmir", +"country":"India", +"country_code":"IN", +"lat":34.08565, +"lon":74.80555, +"population":1206419 +}, +{ +"slug":"barranquilla-atlantico-co", +"name":"Barranquilla", +"admin1":"Atlántico", +"country":"Colombia", +"country_code":"CO", +"lat":10.96854, +"lon":-74.78132, +"population":1206319 +}, +{ +"slug":"chelyabinsk-ru", +"name":"Chelyabinsk", +"admin1":"Chelyabinsk", +"country":"Russia", +"country_code":"RU", +"lat":55.1611, +"lon":61.42877, +"population":1202371 +}, +{ +"slug":"merida-yucatan-mx", +"name":"Mérida", +"admin1":"Yucatán", +"country":"Mexico", +"country_code":"MX", +"lat":20.967, +"lon":-89.62318, +"population":1201000 +}, +{ +"slug":"hiroshima-jp", +"name":"Hiroshima", +"admin1":"Hiroshima", +"country":"Japan", +"country_code":"JP", +"lat":34.4, +"lon":132.45, +"population":1200754 +}, +{ +"slug":"santiago-de-los-caballeros-santiago-province-do", +"name":"Santiago de los Caballeros", +"admin1":"Santiago Province", +"country":"Dominican Republic", +"country_code":"DO", +"lat":19.45036, +"lon":-70.69085, +"population":1200000 +}, +{ +"slug":"shymkent-kz", +"name":"Shymkent", +"admin1":"Shymkent", +"country":"Kazakhstan", +"country_code":"KZ", +"lat":42.30988, +"lon":69.60042, +"population":1200000 +}, +{ +"slug":"weinan-shaanxi-cn", +"name":"Weinan", +"admin1":"Shaanxi", +"country":"China", +"country_code":"CN", +"lat":34.50355, +"lon":109.50891, +"population":1199290 +}, +{ +"slug":"ghaziabad-uttar-pradesh-in", +"name":"Ghāziābād", +"admin1":"Uttar Pradesh", +"country":"India", +"country_code":"IN", +"lat":28.66535, +"lon":77.43915, +"population":1199191 +}, +{ +"slug":"matola-maputo-province-mz", +"name":"Matola", +"admin1":"Maputo Province", +"country":"Mozambique", +"country_code":"MZ", +"lat":-25.96222, +"lon":32.45889, +"population":1198988 +}, +{ +"slug":"dhanbad-jharkhand-in", +"name":"Dhanbad", +"admin1":"Jharkhand", +"country":"India", +"country_code":"IN", +"lat":23.79759, +"lon":86.42992, +"population":1196214 +}, +{ +"slug":"arequipa-pe", +"name":"Arequipa", +"admin1":"Arequipa", +"country":"Peru", +"country_code":"PE", +"lat":-16.39899, +"lon":-71.53747, +"population":1195700 +}, +{ +"slug":"hong-kong-island-hk", +"name":"Hong Kong Island", +"admin1":null, +"country":"Hong Kong", +"country_code":"HK", +"lat":22.26302, +"lon":114.18419, +"population":1195529 +}, +{ +"slug":"fes-fes-meknes-ma", +"name":"Fes", +"admin1":"Fes-Meknes", +"country":"Morocco", +"country_code":"MA", +"lat":34.03313, +"lon":-5.00028, +"population":1191905 +}, +{ +"slug":"gustavo-adolfo-madero-mexico-city-mx", +"name":"Gustavo Adolfo Madero", +"admin1":"Mexico City", +"country":"Mexico", +"country_code":"MX", +"lat":19.49392, +"lon":-99.11075, +"population":1185772 +}, +{ +"slug":"nouakchott-mr", +"name":"Nouakchott", +"admin1":null, +"country":"Mauritania", +"country_code":"MR", +"lat":18.08581, +"lon":-15.9785, +"population":1184530 +}, +{ +"slug":"kisangani-tshopo-cd", +"name":"Kisangani", +"admin1":"Tshopo", +"country":"Democratic Republic of the Congo", +"country_code":"CD", +"lat":0.51528, +"lon":25.19099, +"population":1181788 +}, +{ +"slug":"jiaxing-zhejiang-cn", +"name":"Jiaxing", +"admin1":"Zhejiang", +"country":"China", +"country_code":"CN", +"lat":30.7522, +"lon":120.75, +"population":1180000 +}, +{ +"slug":"aurangabad-maharashtra-in", +"name":"Aurangabad", +"admin1":"Maharashtra", +"country":"India", +"country_code":"IN", +"lat":19.87757, +"lon":75.34226, +"population":1175116 +}, +{ +"slug":"zhongwei-ningxia-cn", +"name":"Zhongwei", +"admin1":"Ningxia", +"country":"China", +"country_code":"CN", +"lat":37.51129, +"lon":105.19067, +"population":1174600 +}, +{ +"slug":"omsk-omsk-oblast-ru", +"name":"Omsk", +"admin1":"Omsk Oblast", +"country":"Russia", +"country_code":"RU", +"lat":54.99244, +"lon":73.36859, +"population":1172070 +}, +{ +"slug":"pikine-dakar-sn", +"name":"Pikine", +"admin1":"Dakar", +"country":"Senegal", +"country_code":"SN", +"lat":14.76457, +"lon":-17.39071, +"population":1170791 +}, +{ +"slug":"pekanbaru-riau-id", +"name":"Pekanbaru", +"admin1":"Riau", +"country":"Indonesia", +"country_code":"ID", +"lat":0.51667, +"lon":101.44167, +"population":1167599 +}, +{ +"slug":"panjin-liaoning-cn", +"name":"Panjin", +"admin1":"Liaoning", +"country":"China", +"country_code":"CN", +"lat":41.121, +"lon":122.0739, +"population":1166481 +}, +{ +"slug":"bandar-lampung-lampung-id", +"name":"Bandar Lampung", +"admin1":"Lampung", +"country":"Indonesia", +"country_code":"ID", +"lat":-5.42917, +"lon":105.26111, +"population":1166066 +}, +{ +"slug":"prague-cz", +"name":"Prague", +"admin1":"Prague", +"country":"Czechia", +"country_code":"CZ", +"lat":50.08804, +"lon":14.42076, +"population":1165581 +}, +{ +"slug":"varanasi-uttar-pradesh-in", +"name":"Varanasi", +"admin1":"Uttar Pradesh", +"country":"India", +"country_code":"IN", +"lat":25.31668, +"lon":83.01041, +"population":1164404 +}, +{ +"slug":"jiujiang-jiangxi-cn", +"name":"Jiujiang", +"admin1":"Jiangxi", +"country":"China", +"country_code":"CN", +"lat":29.70475, +"lon":116.00206, +"population":1164268 +}, +{ +"slug":"samara-samara-oblast-ru", +"name":"Samara", +"admin1":"Samara Oblast", +"country":"Russia", +"country_code":"RU", +"lat":53.20767, +"lon":50.13553, +"population":1163399 +}, +{ +"slug":"aba-abia-state-ng", +"name":"Aba", +"admin1":"Abia State", +"country":"Nigeria", +"country_code":"NG", +"lat":5.10658, +"lon":7.36667, +"population":1160000 +}, +{ +"slug":"amritsar-punjab-in", +"name":"Amritsar", +"admin1":"Punjab", +"country":"India", +"country_code":"IN", +"lat":31.62234, +"lon":74.87534, +"population":1159227 +}, +{ +"slug":"birmingham-england-gb", +"name":"Birmingham", +"admin1":"England", +"country":"United Kingdom", +"country_code":"GB", +"lat":52.48142, +"lon":-1.89983, +"population":1157603 +}, +{ +"slug":"copenhagen-capital-region-dk", +"name":"Copenhagen", +"admin1":"Capital Region", +"country":"Denmark", +"country_code":"DK", +"lat":55.67594, +"lon":12.56553, +"population":1153615 +}, +{ +"slug":"sofia-sofia-capital-bg", +"name":"Sofia", +"admin1":"Sofia-Capital", +"country":"Bulgaria", +"country_code":"BG", +"lat":42.69751, +"lon":23.32415, +"population":1152556 +}, +{ +"slug":"anyang-henan-cn", +"name":"Anyang", +"admin1":"Henan", +"country":"China", +"country_code":"CN", +"lat":36.096, +"lon":114.38278, +"population":1146839 +}, +{ +"slug":"yerevan-am", +"name":"Yerevan", +"admin1":"Yerevan", +"country":"Armenia", +"country_code":"AM", +"lat":40.17765, +"lon":44.5126, +"population":1144700 +}, +{ +"slug":"luohu-district-guangdong-cn", +"name":"Luohu District", +"admin1":"Guangdong", +"country":"China", +"country_code":"CN", +"lat":22.54721, +"lon":114.13149, +"population":1143801 +}, +{ +"slug":"vijayawada-andhra-pradesh-in", +"name":"Vijayawada", +"admin1":"Andhra Pradesh", +"country":"India", +"country_code":"IN", +"lat":16.50745, +"lon":80.6466, +"population":1143232 +}, +{ +"slug":"fengxiang-shanghai-cn", +"name":"Fengxiang", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":30.85841, +"lon":121.46779, +"population":1140872 +}, +{ +"slug":"bijie-guizhou-cn", +"name":"Bijie", +"admin1":"Guizhou", +"country":"China", +"country_code":"CN", +"lat":27.30193, +"lon":105.28627, +"population":1137383 +}, +{ +"slug":"monterrey-nuevo-leon-mx", +"name":"Monterrey", +"admin1":"Nuevo León", +"country":"Mexico", +"country_code":"MX", +"lat":25.68435, +"lon":-100.31721, +"population":1135512 +}, +{ +"slug":"kigali-rw", +"name":"Kigali", +"admin1":"Kigali", +"country":"Rwanda", +"country_code":"RW", +"lat":-1.94995, +"lon":30.05885, +"population":1132686 +}, +{ +"slug":"rostov-on-don-rostov-ru", +"name":"Rostov-on-Don", +"admin1":"Rostov", +"country":"Russia", +"country_code":"RU", +"lat":47.21997, +"lon":39.70769, +"population":1130305 +}, +{ +"slug":"zhuzhou-hunan-cn", +"name":"Zhuzhou", +"admin1":"Hunan", +"country":"China", +"country_code":"CN", +"lat":27.83333, +"lon":113.15, +"population":1129687 +}, +{ +"slug":"malingao-soccsksargen-ph", +"name":"Malingao", +"admin1":"Soccsksargen", +"country":"Philippines", +"country_code":"PH", +"lat":7.16083, +"lon":124.475, +"population":1121974 +}, +{ +"slug":"touba-diourbel-region-sn", +"name":"Touba", +"admin1":"Diourbel Region", +"country":"Senegal", +"country_code":"SN", +"lat":14.86229, +"lon":-15.87532, +"population":1120824 +}, +{ +"slug":"ufa-bashkortostan-republic-ru", +"name":"Ufa", +"admin1":"Bashkortostan Republic", +"country":"Russia", +"country_code":"RU", +"lat":54.74306, +"lon":55.96779, +"population":1120547 +}, +{ +"slug":"ranchi-jharkhand-in", +"name":"Ranchi", +"admin1":"Jharkhand", +"country":"India", +"country_code":"IN", +"lat":23.34316, +"lon":85.3094, +"population":1120374 +}, +{ +"slug":"shangrao-jiangxi-cn", +"name":"Shangrao", +"admin1":"Jiangxi", +"country":"China", +"country_code":"CN", +"lat":28.45179, +"lon":117.94287, +"population":1116486 +}, +{ +"slug":"lilongwe-central-region-mw", +"name":"Lilongwe", +"admin1":"Central Region", +"country":"Malawi", +"country_code":"MW", +"lat":-13.96692, +"lon":33.78725, +"population":1115815 +}, +{ +"slug":"huaibei-anhui-cn", +"name":"Huaibei", +"admin1":"Anhui", +"country":"China", +"country_code":"CN", +"lat":33.97444, +"lon":116.79167, +"population":1113321 +}, +{ +"slug":"maiduguri-borno-state-ng", +"name":"Maiduguri", +"admin1":"Borno State", +"country":"Nigeria", +"country_code":"NG", +"lat":11.84692, +"lon":13.15712, +"population":1110000 +}, +{ +"slug":"xuhui-shanghai-cn", +"name":"Xuhui", +"admin1":"Shanghai", +"country":"China", +"country_code":"CN", +"lat":31.19594, +"lon":121.44709, +"population":1109800 +}, +{ +"slug":"meishan-sichuan-cn", +"name":"Meishan", +"admin1":"Sichuan", +"country":"China", +"country_code":"CN", +"lat":30.04392, +"lon":103.83696, +"population":1107742 +}, +{ +"slug":"mwanza-tz", +"name":"Mwanza", +"admin1":"Mwanza", +"country":"Tanzania", +"country_code":"TZ", +"lat":-2.51667, +"lon":32.9, +"population":1104521 +} +] diff --git a/cities.py b/cities.py new file mode 100644 index 0000000..6aa9b0f --- /dev/null +++ b/cities.py @@ -0,0 +1,54 @@ +"""Access to the curated city set (backend/cities.json) that gets crawlable +climate pages. Loaded once, lazily; regenerate the JSON with gen_cities.py.""" +import json +import os + +_PATH = os.path.join(os.path.dirname(__file__), "cities.json") +_CITIES: list[dict] | None = None +_BY_SLUG: dict[str, dict] | None = None + + +def _load() -> list[dict]: + global _CITIES, _BY_SLUG + if _CITIES is None: + with open(_PATH, encoding="utf-8") as f: + _CITIES = json.load(f) + _BY_SLUG = {c["slug"]: c for c in _CITIES} + return _CITIES + + +def all_cities() -> list[dict]: + return _load() + + +def all_slugs() -> list[str]: + return [c["slug"] for c in _load()] + + +def get(slug: str) -> dict | None: + """The city for a slug, or None (→ 404).""" + _load() + return _BY_SLUG.get(slug) + + +def display_name(city: dict) -> str: + """Human label: 'Seattle, Washington, United States' (drops repeated admin1).""" + parts = [city["name"]] + if city.get("admin1") and city["admin1"] != city["name"]: + parts.append(city["admin1"]) + if city.get("country"): + parts.append(city["country"]) + return ", ".join(parts) + + +def by_country() -> dict[str, list[dict]]: + """Cities grouped by country (population-descending within each), country keys + ordered by their largest city — for the /climate hub's crawlable link graph.""" + groups: dict[str, list[dict]] = {} + for c in _load(): + key = c.get("country") or c.get("country_code") or "Other" + groups.setdefault(key, []).append(c) + for v in groups.values(): + v.sort(key=lambda x: -x["population"]) + # order countries by their biggest city's population (most prominent first) + return dict(sorted(groups.items(), key=lambda kv: -kv[1][0]["population"])) diff --git a/content.py b/content.py new file mode 100644 index 0000000..5b7ba77 --- /dev/null +++ b/content.py @@ -0,0 +1,506 @@ +"""Server-rendered, crawlable content pages (climate hub / per-city / month / +records / glossary / about) plus robots.txt and sitemap.xml. + +These are the SEO surface: real URLs with the climate stats in the HTML, rendered +with Jinja2 from the same builders the API uses, linking into the interactive tool. +Registered on the app (via register()) BEFORE the StaticFiles mount so they win. +""" +import datetime +import hashlib +import json +import os + +import polars as pl +from fastapi import HTTPException, Request, Response +from fastapi.responses import PlainTextResponse +from jinja2 import Environment, FileSystemLoader, select_autoescape + +import cities +import climate +import grading +import grid +from views import OBS_COLS + +_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") +BASE = f"/{_BASE}" if _BASE else "" + +TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") +_env = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml", "j2"]), + trim_blocks=True, + lstrip_blocks=True, +) + +MONTHS = ["january", "february", "march", "april", "may", "june", + "july", "august", "september", "october", "november", "december"] +MONTHS_TITLE = [m.capitalize() for m in MONTHS] +MONTH_INDEX = {m: i + 1 for i, m in enumerate(MONTHS)} + +# Display labels for the graded metrics (order = how they appear on the page). +METRIC_LABELS = [ + ("tmax", "High"), ("tmin", "Low"), ("feels", "Feels-like"), + ("humid", "Humidity"), ("wind", "Wind"), ("gust", "Gust"), ("precip", "Precip"), +] +_TEMP_METRICS = {"tmax", "tmin", "feels"} + + +def _c(f: float) -> int: + return round((f - 32) * 5 / 9) + + +def _temp(f) -> str: + """A Fahrenheit value shown in both units: '72°F (22°C)'.""" + if f is None: + return "—" + return f"{round(f)}°F ({_c(f)}°C)" + + +def _fmt(metric: str, v) -> str: + if v is None: + return "—" + if metric in _TEMP_METRICS: + return _temp(v) + if metric == "precip": + return f"{v:.2f} in" + if metric == "humid": + return f"{v:.1f} g/m³" + return f"{round(v)} mph" # wind, gust + + +def _month_doy(month_idx: int) -> int: + return datetime.date(2001, month_idx, 15).timetuple().tm_yday + + +# --- helpers ----------------------------------------------------------------- +def origin(request: Request) -> str: + proto = request.headers.get("x-forwarded-proto") or request.url.scheme + host = request.headers.get("host") or request.url.netloc + return f"{proto}://{host}" + + +def _respond_html(request: Request, template: str, **ctx) -> Response: + o = origin(request) + html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx) + etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"' + inm = request.headers.get("if-none-match") + if inm and etag in {t.strip() for t in inm.split(",")}: + return Response(status_code=304, headers={"ETag": etag}) + return Response(html, media_type="text/html", headers={"ETag": etag}) + + +# --- robots.txt & sitemap.xml ------------------------------------------------- +def robots_txt(request: Request) -> Response: + base_url = f"{origin(request)}{BASE}" + body = ( + "User-agent: *\n" + "Allow: /\n" + f"Disallow: {BASE}/api/\n" # JSON endpoints, nothing to index + f"Disallow: {BASE}/alerts\n" # per-user, requires login + f"Sitemap: {base_url}/sitemap.xml\n" + ) + return PlainTextResponse(body) + + +def sitemap_xml(request: Request) -> Response: + base_url = f"{origin(request)}{BASE}" + today = datetime.date.today().isoformat() + urls: list[tuple[str, str, str]] = [] # (loc, changefreq, priority) + + def add(path: str, changefreq: str, priority: str) -> None: + urls.append((f"{base_url}{path}", changefreq, priority)) + + add("/", "daily", "1.0") + for p in ("/climate", "/about", "/glossary", "/calendar", "/compare", "/legend"): + add(p, "weekly", "0.6") + for slug in cities.all_slugs(): + add(f"/climate/{slug}", "daily", "0.8") + add(f"/climate/{slug}/records", "monthly", "0.5") + for m in MONTHS: + add(f"/climate/{slug}/{m}", "monthly", "0.5") + + parts = ['', + ''] + for loc, cf, pr in urls: + parts.append( + f"{loc}{today}" + f"{cf}{pr}" + ) + parts.append("") + return Response("\n".join(parts), media_type="application/xml") + + +# --- per-city climate pages --------------------------------------------------- +def _history_for(cell: dict): + """Cached archive for a cell, fetching once if missing (self-heals like the + notifier). Returns a polars frame or None.""" + hist = climate.load_cached_history(cell) + if hist is not None and not hist.is_empty(): + return hist + try: + hist, _ = climate.get_history(cell) + except Exception: # noqa: BLE001 - upstream unavailable: caller renders a 503 + return None + return hist if (hist is not None and not hist.is_empty()) else None + + +def _monthly_normals(history) -> list[dict]: + """One row per month: average high/low and average precip, from the ±7-day + climatology around each month's 15th.""" + rows = [] + for i, name in enumerate(MONTHS_TITLE, start=1): + clim = grading.climatology(history, _month_doy(i)) + tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip") + rows.append({ + "name": name, + "slug": MONTHS[i - 1], + "high": _temp(tmax["mean"]) if tmax else "—", + "high_f": tmax["mean"] if tmax else None, + "low": _temp(tmin["mean"]) if tmin else "—", + "low_f": tmin["mean"] if tmin else None, + "precip": f"{precip['mean']:.2f} in" if precip else "—", + "precip_v": precip["mean"] if precip else None, + }) + return rows + + +def _today_vs_normal(history, cell) -> dict | None: + """Grade the latest recorded day against its climatology, for the hero block.""" + try: + recent = climate.get_recent_forecast(cell) + except Exception: # noqa: BLE001 + return None + if recent is None or recent.is_empty() or "date" not in recent.columns: + return None + today = datetime.date.today() + observed = recent.filter(pl.col("date") <= today).sort("date") + if observed.is_empty(): + return None + row = observed.row(observed.height - 1, named=True) + obs = {k: row[k] for k in OBS_COLS if k in row} + graded = grading.grade_day(history, row["date"], obs) + cards = [] + for key, label in METRIC_LABELS: + g = graded.get(key) + if not g: + continue + cards.append({ + "label": label, "metric": key, + "value": _fmt(key, g.get("value")), + "percentile": g.get("percentile"), + "grade": g.get("grade"), "cls": g.get("class"), + }) + date = row["date"] + return { + "date": date.isoformat() if hasattr(date, "isoformat") else str(date), + "cards": cards, + } + + +def _city_context(request, city, cell, history) -> dict: + name = city["name"] + display = cities.display_name(city) + years = history["date"].dt.year() + year_range = [int(years.min()), int(years.max())] + months = _monthly_normals(history) + warmest = max((m for m in months if m["high_f"] is not None), key=lambda m: m["high_f"], default=None) + coldest = min((m for m in months if m["low_f"] is not None), key=lambda m: m["low_f"], default=None) + wettest = max((m for m in months if m["precip_v"] is not None), key=lambda m: m["precip_v"], default=None) + records = grading.all_time_records(history) + tool_hash = f"{city['lat']:.5f},{city['lon']:.5f}" + + breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate")] + if city.get("country"): + breadcrumb.append((city["country"], None)) + breadcrumb.append((name, None)) + + o = origin(request) + page_url = f"{o}{BASE}/climate/{city['slug']}" + jsonld = { + "@context": "https://schema.org", + "@graph": [ + {"@type": "Dataset", + "name": f"{display} climate normals and records", + "description": f"Average temperatures, precipitation and record highs and lows for " + f"{display}, from ~{year_range[1] - year_range[0]} years of daily climate history.", + "url": page_url, + "temporalCoverage": f"{year_range[0]}/{year_range[1]}", + "spatialCoverage": {"@type": "Place", "name": display, + "geo": {"@type": "GeoCoordinates", + "latitude": city["lat"], "longitude": city["lon"]}}, + "creator": {"@type": "Organization", "name": "Thermograph"}, + "isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"}, + {"@type": "BreadcrumbList", + "itemListElement": [ + {"@type": "ListItem", "position": i + 1, "name": nm, + **({"item": f"{o}{href}"} if href else {})} + for i, (nm, href) in enumerate(breadcrumb)]}, + ], + } + return { + "section": "climate", + "city": city, "display": display, "name": name, + "year_range": year_range, "n_years": year_range[1] - year_range[0], + "months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest, + "records": records, "today": _today_vs_normal(history, cell), + "tool_hash": tool_hash, + "breadcrumb": breadcrumb, + "canonical_path": f"/climate/{city['slug']}", + "page_title": f"{display} climate: average temperatures, records & how today compares", + "page_description": (f"Average monthly high and low temperatures, rainfall, and all-time records " + f"for {display}, plus how today's weather compares — graded against " + f"~{year_range[1] - year_range[0]} years of local climate history."), + "jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")), + } + + +def _resolve_city(slug: str): + """(city, cell, history) for a slug, or raise 404 (unknown) / 503 (warming).""" + city = cities.get(slug) + if city is None: + raise HTTPException(status_code=404, detail="Unknown city.") + cell = grid.snap(city["lat"], city["lon"]) + history = _history_for(cell) + if history is None: + raise HTTPException(status_code=503, detail="Climate data is warming up; please retry shortly.") + return city, cell, history + + +def city_page(request: Request, slug: str) -> Response: + city, cell, history = _resolve_city(slug) + return _respond_html(request, "city.html.j2", **_city_context(request, city, cell, history)) + + +# --- month & records pages ---------------------------------------------------- +def _month_context(request, city, history, month_idx: int) -> dict: + display = cities.display_name(city) + name = city["name"] + month_name = MONTHS_TITLE[month_idx - 1] + month_slug = MONTHS[month_idx - 1] + years = history["date"].dt.year() + year_range = [int(years.min()), int(years.max())] + + clim = grading.climatology(history, _month_doy(month_idx)) + tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip") + mdf = history.filter(pl.col("date").dt.month() == month_idx) + mrec = grading.all_time_records(mdf) if not mdf.is_empty() else {} + + stats = [] + if tmax: + stats.append(("Average high", _temp(tmax["mean"]))) + stats.append(("Typical high range", f"{_temp(tmax['p10'])} to {_temp(tmax['p90'])}")) + if tmin: + stats.append(("Average low", _temp(tmin["mean"]))) + stats.append(("Typical low range", f"{_temp(tmin['p10'])} to {_temp(tmin['p90'])}")) + if precip: + stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in")) + records = [] + if mrec.get("tmax"): + records.append(("Warmest on record", _temp(mrec["tmax"]["max"]), mrec["tmax"]["max_date"])) + if mrec.get("tmin"): + records.append(("Coldest on record", _temp(mrec["tmin"]["min"]), mrec["tmin"]["min_date"])) + + prev_i = 12 if month_idx == 1 else month_idx - 1 + next_i = 1 if month_idx == 12 else month_idx + 1 + breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"), + (name, f"{BASE}/climate/{city['slug']}"), (month_name, None)] + return { + "section": "climate", "city": city, "display": display, "name": name, + "month_name": month_name, "month_slug": month_slug, + "year_range": year_range, "n_years": year_range[1] - year_range[0], + "avg_high": _temp(tmax["mean"]) if tmax else "—", + "avg_low": _temp(tmin["mean"]) if tmin else "—", + "stats": stats, "records": records, + "tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}", + "prev": {"name": MONTHS_TITLE[prev_i - 1], "slug": MONTHS[prev_i - 1]}, + "next": {"name": MONTHS_TITLE[next_i - 1], "slug": MONTHS[next_i - 1]}, + "breadcrumb": breadcrumb, + "canonical_path": f"/climate/{city['slug']}/{month_slug}", + "page_title": f"Average weather in {display} in {month_name}", + "page_description": (f"Average high and low temperatures, typical range, records and rainfall for " + f"{display} in {month_name}, from ~{year_range[1] - year_range[0]} years of local records."), + } + + +def month_page(request: Request, slug: str, month: str) -> Response: + if month not in MONTH_INDEX: + raise HTTPException(status_code=404, detail="Unknown month.") + city, cell, history = _resolve_city(slug) + return _respond_html(request, "month.html.j2", **_month_context(request, city, history, MONTH_INDEX[month])) + + +def _records_context(request, city, history) -> dict: + display = cities.display_name(city) + name = city["name"] + years = history["date"].dt.year() + year_range = [int(years.min()), int(years.max())] + rec = grading.all_time_records(history) + rows = [] + for key, label in METRIC_LABELS: + r = rec.get(key) + if not r: + continue + rows.append({ + "label": label, + "high": _fmt(key, r["max"]), "high_date": r["max_date"], + "low": _fmt(key, r["min"]), "low_date": r["min_date"], + }) + breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"), + (name, f"{BASE}/climate/{city['slug']}"), ("Records", None)] + return { + "section": "climate", "city": city, "display": display, "name": name, + "year_range": year_range, "n_years": year_range[1] - year_range[0], + "rows": rows, + "canonical_path": f"/climate/{city['slug']}/records", + "breadcrumb": breadcrumb, + "page_title": f"{display} weather records: hottest and coldest days on record", + "page_description": (f"All-time record high and low temperatures (and the dates they occurred) for " + f"{display}, from ~{year_range[1] - year_range[0]} years of daily climate history."), + } + + +def records_page(request: Request, slug: str) -> Response: + city, cell, history = _resolve_city(slug) + return _respond_html(request, "records.html.j2", **_records_context(request, city, history)) + + +# --- hub / glossary / about --------------------------------------------------- +def _breadcrumb(*items): + return list(items) + + +def hub_page(request: Request) -> Response: + groups = cities.by_country() + ctx = { + "section": "climate", + "groups": groups, + "n_cities": sum(len(v) for v in groups.values()), + "n_countries": len(groups), + "canonical_path": "/climate", + "breadcrumb": [("Home", f"{BASE}/"), ("Climate", None)], + "page_title": "City climate pages — averages, records and how today compares", + "page_description": ("Browse climate pages for hundreds of cities worldwide: average temperatures by " + "month, all-time records, and how today's weather compares to local history."), + } + return _respond_html(request, "hub.html.j2", **ctx) + + +# Weather-terms glossary. Each entry: slug -> (term, short definition, body HTML). +GLOSSARY: dict[str, dict] = { + "climate-normal": { + "term": "Climate normal", + "short": "The typical value of a weather metric for a place and time of year, averaged over decades.", + "body": "A climate normal is the long-term average of a weather variable (say, the daily high) " + "for a specific place and time of year. Thermograph builds each day's normal from every " + "historical day within ±7 days of that day-of-year, across ~45 years — so a day is judged " + "against its own season, not a single annual average.", + }, + "percentile": { + "term": "Percentile", + "short": "Where a value ranks within a distribution — the 90th percentile is warmer than 90% of days.", + "body": "A percentile says where a value falls within a range of past values. If today's high is at " + "the 97th percentile, only about 3% of comparable days in this location's history were warmer. " + "Thermograph grades every day by its percentile against the local ±7-day seasonal distribution.", + }, + "temperature-anomaly": { + "term": "Temperature anomaly", + "short": "How far a temperature departs from normal — the difference from the long-term average.", + "body": "A temperature anomaly is how much warmer or colder it is than the local normal for the " + "time of year. Thermograph expresses the same idea as a percentile and a grade (from “Below " + "Normal” to “Near Record”), so an anomaly is easy to read at a glance for any location.", + }, + "feels-like": { + "term": "Feels-like temperature", + "short": "What the air actually feels like once humidity and wind are accounted for.", + "body": "Feels-like (apparent temperature) combines air temperature with humidity and wind. In heat " + "it uses the heat index; in cold it uses " + "wind chill. Thermograph grades feels-like against its " + "own local history, so “Near Record” means extreme for that place.", + }, + "heat-index": { + "term": "Heat index", + "short": "How hot it feels when humidity is factored into the air temperature.", + "body": "The heat index is the apparent temperature on a hot, humid day: high humidity slows sweat " + "evaporation, so it feels hotter than the thermometer reads. Thermograph folds it into the " + "feels-like metric and grades how unusual it is locally.", + }, + "wind-chill": { + "term": "Wind chill", + "short": "How cold it feels when wind is factored into the air temperature.", + "body": "Wind chill is the apparent temperature on a cold, windy day: wind strips away body heat, so " + "it feels colder than the air temperature. It's the cold-weather half of " + "feels-like.", + }, + "humidity": { + "term": "Absolute humidity", + "short": "The actual mass of water vapor in the air, in grams per cubic meter.", + "body": "Thermograph reports absolute humidity (g/m³) — the real amount of water vapor in the " + "air — rather than relative humidity, which shifts with temperature. Graded against local history, " + "it shows genuinely muggy or unusually dry days.", + }, + "reanalysis": { + "term": "Reanalysis (ERA5)", + "short": "A gridded, physically-consistent record of past weather for anywhere on Earth.", + "body": "A reanalysis blends historical observations with a weather model to produce a consistent " + "record of past conditions everywhere — even where no station exists. Thermograph's ~45-year history " + "comes from ECMWF's ERA5 reanalysis (via Open-Meteo), which is why it works for any point on Earth.", + }, +} + + +def _glossary_body(entry: dict) -> str: + return entry["body"].replace("{base}", BASE) + + +def glossary_index(request: Request) -> Response: + ctx = { + "terms": [{"slug": s, **e} for s, e in GLOSSARY.items()], + "canonical_path": "/glossary", + "breadcrumb": [("Home", f"{BASE}/"), ("Glossary", None)], + "page_title": "Weather & climate glossary — heat index, feels-like, percentile, and more", + "page_description": ("Plain-language definitions of weather and climate terms: climate normal, percentile, " + "temperature anomaly, feels-like, heat index, wind chill, humidity, and reanalysis."), + } + return _respond_html(request, "glossary.html.j2", **ctx) + + +def glossary_term(request: Request, term: str) -> Response: + entry = GLOSSARY.get(term) + if entry is None: + raise HTTPException(status_code=404, detail="Unknown term.") + ctx = { + "term": entry["term"], "body": _glossary_body(entry), + "canonical_path": f"/glossary/{term}", + "breadcrumb": [("Home", f"{BASE}/"), ("Glossary", f"{BASE}/glossary"), (entry["term"], None)], + "page_title": f"{entry['term']} — what it means | Thermograph", + "page_description": entry["short"], + "others": [{"slug": s, "term": e["term"]} for s, e in GLOSSARY.items() if s != term], + } + return _respond_html(request, "glossary_term.html.j2", **ctx) + + +def about_page(request: Request) -> Response: + ctx = { + "canonical_path": "/about", + "breadcrumb": [("Home", f"{BASE}/"), ("About", None)], + "page_title": "About Thermograph — how the weather grades are calculated", + "page_description": ("How Thermograph works: ~45 years of ERA5 climate history, a ±7-day seasonal " + "window, and empirical percentiles that grade each day relative to its own location."), + } + return _respond_html(request, "about.html.j2", **ctx) + + +# --- registration ------------------------------------------------------------ +def register(app) -> None: + """Attach all content routes. Call from app.py BEFORE the StaticFiles mount.""" + app.add_api_route(f"{BASE}/robots.txt", robots_txt, methods=["GET"], include_in_schema=False) + app.add_api_route(f"{BASE}/sitemap.xml", sitemap_xml, methods=["GET"], include_in_schema=False) + app.add_api_route(f"{BASE}/about", about_page, methods=["GET", "HEAD"], include_in_schema=False) + app.add_api_route(f"{BASE}/glossary", glossary_index, methods=["GET", "HEAD"], include_in_schema=False) + app.add_api_route(f"{BASE}/glossary/{{term}}", glossary_term, methods=["GET", "HEAD"], include_in_schema=False) + # Hub before /climate/{slug} so the literal path wins. + app.add_api_route(f"{BASE}/climate", hub_page, methods=["GET", "HEAD"], include_in_schema=False) + app.add_api_route(f"{BASE}/climate/{{slug}}", city_page, methods=["GET", "HEAD"], include_in_schema=False) + # Records before the {month} param so the literal path wins. + app.add_api_route(f"{BASE}/climate/{{slug}}/records", records_page, methods=["GET", "HEAD"], include_in_schema=False) + app.add_api_route(f"{BASE}/climate/{{slug}}/{{month}}", month_page, methods=["GET", "HEAD"], include_in_schema=False) diff --git a/gen_cities.py b/gen_cities.py new file mode 100644 index 0000000..693ecf6 --- /dev/null +++ b/gen_cities.py @@ -0,0 +1,82 @@ +"""Offline generator for backend/cities.json — the finite set of cities that get +crawlable climate pages (/climate/). Run occasionally to refresh the list: + + python gen_cities.py [N] # default N=500 top metros by population + +It reuses the GeoNames index that places.py already downloads/parses (calling +places._load() synchronously fills places._data), takes the top-N places by +population, and assigns each a stable, unique, URL-safe slug. Committing the output +keeps the routable city set explicit and reviewable, and decouples page-serving +from the async place-name loader. +""" +import json +import os +import re +import sys +import unicodedata + +import places + +OUT_PATH = os.path.join(os.path.dirname(__file__), "cities.json") + +# GeoNames entry tuple layout (see places._load): the fields we keep. +_NAME, _ADMIN1, _COUNTRY, _CC, _LAT, _LON, _POP = 1, 2, 3, 4, 5, 6, 7 + + +def slugify(*parts: str) -> str: + """ASCII, lowercase, hyphenated slug from name/admin/country parts.""" + text = " ".join(p for p in parts if p) + text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() + text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower() + return re.sub(r"-{2,}", "-", text) + + +def build(n: int = 500) -> list[dict]: + places._load() # synchronous parse; fills places._data (entries are pop-desc) + if not places._data: + raise SystemExit("GeoNames index failed to load (see logs); cannot generate cities.") + entries = places._data[0] + + out: list[dict] = [] + seen_slugs: set[str] = set() + for e in entries: + if len(out) >= n: + break + name, admin1, country, cc = e[_NAME], e[_ADMIN1], e[_COUNTRY], e[_CC] + # Drop admin1 from the slug when it just repeats the city name + # (e.g. Tokyo/Tokyo, Singapore/Singapore) to avoid "tokyo-tokyo-jp". + admin_part = admin1 if admin1 and slugify(admin1) != slugify(name) else "" + base = slugify(name, admin_part, cc or "") + if not base: + continue + slug = base + i = 2 + while slug in seen_slugs: # disambiguate the rare collision + slug = f"{base}-{i}" + i += 1 + seen_slugs.add(slug) + out.append({ + "slug": slug, + "name": name, + "admin1": admin1, + "country": country, + "country_code": cc, + "lat": round(e[_LAT], 5), + "lon": round(e[_LON], 5), + "population": e[_POP], + }) + return out + + +def main() -> None: + n = int(sys.argv[1]) if len(sys.argv) > 1 else 500 + cities = build(n) + with open(OUT_PATH, "w", encoding="utf-8") as f: + json.dump(cities, f, ensure_ascii=False, indent=0, separators=(",", ":")) + f.write("\n") + print(f"wrote {len(cities)} cities -> {OUT_PATH}") + print("sample:", ", ".join(c["slug"] for c in cities[:8])) + + +if __name__ == "__main__": + main() diff --git a/grading.py b/grading.py index 12f4920..eb55041 100644 --- a/grading.py +++ b/grading.py @@ -161,6 +161,27 @@ def climatology(df: pl.DataFrame, target_doy: int) -> dict: return out +def all_time_records(df: pl.DataFrame) -> dict: + """All-time record high/low (and the date each occurred) per metric across the + full archive — the raw material for the records page and the city teaser.""" + out: dict = {} + for var in CLIMO_METRICS: + if var not in df.columns: + continue + sub = df.select(["date", var]).drop_nulls(var) + if sub.is_empty(): + continue + hi = sub.row(int(sub[var].arg_max()), named=True) + lo = sub.row(int(sub[var].arg_min()), named=True) + out[var] = { + "max": round(float(hi[var]), 2), + "max_date": hi["date"].isoformat() if hasattr(hi["date"], "isoformat") else str(hi["date"]), + "min": round(float(lo[var]), 2), + "min_date": lo["date"].isoformat() if hasattr(lo["date"], "isoformat") else str(lo["date"]), + } + return out + + def _band_stats(samples: np.ndarray) -> dict | None: if samples.size == 0: return None diff --git a/requirements.txt b/requirements.txt index 70b76c0..1f14143 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,5 @@ aiosqlite==0.22.1 # Web Push (VAPID) delivery of notifications (see backend/push.py). Pulls in # py-vapid, cryptography, and http-ece. pywebpush==2.0.0 +# Server-rendered SEO content pages (see backend/content.py, templates/). +jinja2==3.1.6 diff --git a/templates/about.html.j2 b/templates/about.html.j2 new file mode 100644 index 0000000..3467de2 --- /dev/null +++ b/templates/about.html.j2 @@ -0,0 +1,36 @@ +{% extends "base.html.j2" %} +{% block title %}{{ page_title }}{% endblock %} +{% block description %}{{ page_description }}{% endblock %} +{% block canonical_path %}{{ canonical_path }}{% endblock %} +{% block content %} + +
+

About Thermograph

+

Thermograph answers one question for any point on Earth: how unusual is this + weather? Instead of an absolute temperature, it shows where a day falls in that exact + location's own climate history.

+ +

Where the data comes from

+

The ~45-year daily record — high, low, feels-like, humidity, wind, gust and precipitation — + comes from the ERA5 reanalysis (ECMWF), served via Open-Meteo. + Because reanalysis is a gridded, model-consistent record, Thermograph works even where there's + no weather station — anywhere on the ~4 sq mi grid.

+ +

How a day is graded

+

For each metric and date, Thermograph builds a reference distribution from every historical day + within ±7 days of that day-of-year — a 15-day seasonal window across all years. The + observed value is placed on that distribution as an empirical + percentile, then mapped to a grade from “Below Normal” through “High” to “Near Record”.

+

Everything is relative to the location: a 60°F day can be “Above Normal” in one place and + “Below Normal” in another. Precipitation is graded only among days that actually rained, so “Heavy” + means heavy for a rainy day here. See the grade guide for the full scale.

+ +

Freshness

+

The historical archive updates as new days are added; recent and forecast conditions refresh hourly. + City climate pages show the latest recorded day graded against the local normal.

+ +

Open the weather grader →

+
+{% endblock %} diff --git a/templates/base.html.j2 b/templates/base.html.j2 new file mode 100644 index 0000000..e100d1c --- /dev/null +++ b/templates/base.html.j2 @@ -0,0 +1,58 @@ + + + + + + {% block title %}Thermograph{% endblock %} + + + + + + + + + + + + + + + + + + {% block jsonld %}{% endblock %} + + +
+
+ +
+

Thermograph

+

How unusual is the weather? Graded against ~45 years of local climate history.

+
+ +
+
+ +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/templates/city.html.j2 b/templates/city.html.j2 new file mode 100644 index 0000000..59cf87a --- /dev/null +++ b/templates/city.html.j2 @@ -0,0 +1,75 @@ +{% extends "base.html.j2" %} +{% block title %}{{ page_title }}{% endblock %} +{% block description %}{{ page_description }}{% endblock %} +{% block canonical_path %}{{ canonical_path }}{% endblock %} +{% block jsonld %}{% endblock %} +{% block content %} + + +
+

{{ display }} climate

+

Average temperatures, precipitation and all-time records for {{ display }}, + with every day graded against ~{{ n_years }} years of local climate history + ({{ year_range[0] }}–{{ year_range[1] }}). See where {% if warmest %}the warmest month is + {{ warmest.name }} ({{ warmest.high }} average high){% endif %}{% if coldest %} and the coldest + is {{ coldest.name }} ({{ coldest.low }} average low){% endif %}.

+ + {% if today %} +
+

How today compares

+

Latest recorded day: {{ today.date }} — each reading placed on {{ display }}'s + own ±7-day seasonal distribution.

+
+ {% for c in today.cards %} +
+ {{ c.label }} + {{ c.value }} + {% if c.percentile is not none %}{{ c.grade }} · {{ c.percentile }}th pct + {% else %}{{ c.grade }}{% endif %} +
+ {% endfor %} +
+

Open {{ name }} in the live weather grader →

+
+ {% endif %} + +
+

{{ name }} average temperatures by month

+

Typical daily high and low, and average precipitation, for each month in {{ display }}, + based on {{ year_range[0] }}–{{ year_range[1] }} records. + {% if wettest %}The wettest month is {{ wettest.name }} ({{ wettest.precip }} on an average day).{% endif %}

+
+ + + + {% for m in months %} + + + + + {% endfor %} + +
MonthAvg highAvg lowAvg precip
{{ m.name }}{{ m.high }}{{ m.low }}{{ m.precip }}
+
+
+ + {% if records.tmax or records.tmin %} +
+

Record extremes

+
    + {% if records.tmax %}
  • Hottest day on record: {{ records.tmax.max | round | int }}°F + ({{ ((records.tmax.max - 32) * 5 / 9) | round | int }}°C) on {{ records.tmax.max_date }}
  • {% endif %} + {% if records.tmin %}
  • Coldest day on record: {{ records.tmin.min | round | int }}°F + ({{ ((records.tmin.min - 32) * 5 / 9) | round | int }}°C) on {{ records.tmin.min_date }}
  • {% endif %} +
+

All {{ name }} weather records →

+
+ {% endif %} + +

Percentiles compare each day to {{ display }}'s own history, so grades are + relative to this location — a “Near Record” day here isn't the same temperature as elsewhere. + How the grades work →

+
+{% endblock %} diff --git a/templates/glossary.html.j2 b/templates/glossary.html.j2 new file mode 100644 index 0000000..65a7c71 --- /dev/null +++ b/templates/glossary.html.j2 @@ -0,0 +1,19 @@ +{% extends "base.html.j2" %} +{% block title %}{{ page_title }}{% endblock %} +{% block description %}{{ page_description }}{% endblock %} +{% block canonical_path %}{{ canonical_path }}{% endblock %} +{% block content %} + +
+

Weather & climate glossary

+

Plain-language definitions of the terms Thermograph uses to grade the weather.

+ {% for t in terms %} +
+

{{ t.term }}

+

{{ t.short }}

+
+ {% endfor %} +
+{% endblock %} diff --git a/templates/glossary_term.html.j2 b/templates/glossary_term.html.j2 new file mode 100644 index 0000000..8febad1 --- /dev/null +++ b/templates/glossary_term.html.j2 @@ -0,0 +1,21 @@ +{% extends "base.html.j2" %} +{% block title %}{{ page_title }}{% endblock %} +{% block description %}{{ page_description }}{% endblock %} +{% block canonical_path %}{{ canonical_path }}{% endblock %} +{% block jsonld %}{% endblock %} +{% block content %} + + +{% endblock %} diff --git a/templates/hub.html.j2 b/templates/hub.html.j2 new file mode 100644 index 0000000..1180b1b --- /dev/null +++ b/templates/hub.html.j2 @@ -0,0 +1,23 @@ +{% extends "base.html.j2" %} +{% block title %}{{ page_title }}{% endblock %} +{% block description %}{{ page_description }}{% endblock %} +{% block canonical_path %}{{ canonical_path }}{% endblock %} +{% block content %} + +
+

City climates

+

Average temperatures by month, all-time records, and how today compares — for + {{ n_cities }} cities across {{ n_countries }} countries. Every day is graded against that + location's own ~45 years of climate history.

+ {% for country, cs in groups.items() %} +
+

{{ country }}

+ +
+ {% endfor %} +
+{% endblock %} diff --git a/templates/month.html.j2 b/templates/month.html.j2 new file mode 100644 index 0000000..3c38f1e --- /dev/null +++ b/templates/month.html.j2 @@ -0,0 +1,39 @@ +{% extends "base.html.j2" %} +{% block title %}{{ page_title }}{% endblock %} +{% block description %}{{ page_description }}{% endblock %} +{% block canonical_path %}{{ canonical_path }}{% endblock %} +{% block content %} + + +
+

Weather in {{ display }} in {{ month_name }}

+

In an average {{ month_name }}, {{ name }} sees a daily high around {{ avg_high }} + and a low around {{ avg_low }}, based on {{ year_range[0] }}–{{ year_range[1] }} of local + climate records.

+ + + + {% for label, value in stats %} + + {% endfor %} + +
{{ label }}{{ value }}
+ + {% if records %} +

{{ month_name }} records

+
    + {% for label, value, date in records %}
  • {{ label }}: {{ value }} on {{ date }}
  • {% endfor %} +
+ {% endif %} + +

See {{ name }}'s live weather grade →

+ +

+ ‹ {{ prev.name }} · + {{ name }} climate overview · + {{ next.name }} › +

+
+{% endblock %} diff --git a/templates/records.html.j2 b/templates/records.html.j2 new file mode 100644 index 0000000..55345a0 --- /dev/null +++ b/templates/records.html.j2 @@ -0,0 +1,33 @@ +{% extends "base.html.j2" %} +{% block title %}{{ page_title }}{% endblock %} +{% block description %}{{ page_description }}{% endblock %} +{% block canonical_path %}{{ canonical_path }}{% endblock %} +{% block content %} + + +
+

{{ display }} weather records

+

All-time record highs and lows for {{ display }}, and the dates they occurred, across + {{ year_range[0] }}–{{ year_range[1] }} of daily climate history.

+ +
+ + + + {% for r in rows %} + + + + + + {% endfor %} + +
MetricRecord highOnRecord lowOn
{{ r.label }}{{ r.high }}{{ r.high_date }}{{ r.low }}{{ r.low_date }}
+
+ +

Grade {{ name }}'s weather now →

+

‹ Back to {{ name }} climate

+
+{% endblock %} diff --git a/tests/test_content.py b/tests/test_content.py new file mode 100644 index 0000000..0b9d265 --- /dev/null +++ b/tests/test_content.py @@ -0,0 +1,74 @@ +"""Tests for the crawlable SEO content pages: the city set, robots/sitemap, and +that the server-rendered pages contain the stats in the HTML (with the weather +layer faked, like test_api.py).""" +import pytest +from fastapi.testclient import TestClient + +import app as appmod +import cities +import climate + +# BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset). +B = "/thermograph" +SLUG = "london-england-gb" # present in cities.json + + +@pytest.fixture +def client(monkeypatch, history, recent): + monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone()) + monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone()) + monkeypatch.setattr(climate, "get_history", lambda cell: (history.clone(), {"cached": True})) + return TestClient(appmod.app) + + +def test_city_set_slugs_unique_and_lookup(): + slugs = cities.all_slugs() + assert len(slugs) == len(set(slugs)) >= 100 + assert cities.get(SLUG) is not None + assert cities.get("does-not-exist") is None + + +def test_robots_txt(client): + r = client.get(f"{B}/robots.txt") + assert r.status_code == 200 + assert "Sitemap:" in r.text and "/sitemap.xml" in r.text + assert "Disallow: /thermograph/api/" in r.text + + +def test_sitemap_lists_city_urls(client): + r = client.get(f"{B}/sitemap.xml") + assert r.status_code == 200 + assert "" in r.text + assert f"/climate/{SLUG}/july" in r.text + assert f"/climate/{SLUG}/records" in r.text + + +def test_city_page_renders_stats_in_html(client): + r = client.get(f"{B}/climate/{SLUG}") + assert r.status_code == 200 + b = r.text + assert "London" in b and "climate" in b.lower() + assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b + assert '"@type":"Dataset"' in b + assert "average temperatures by month" in b.lower() + # a real number rendered (°F appears in the normals/today text) + assert "°F" in b + + +def test_city_404(client): + assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404 + + +def test_month_and_records(client): + assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200 + assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200 + assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404 + + +def test_hub_glossary_about(client): + assert client.get(f"{B}/climate").status_code == 200 + assert client.get(f"{B}/glossary").status_code == 200 + assert client.get(f"{B}/glossary/percentile").status_code == 200 + assert client.get(f"{B}/glossary/not-a-term").status_code == 404 + assert client.get(f"{B}/about").status_code == 200 diff --git a/warm_cities.py b/warm_cities.py new file mode 100644 index 0000000..3813f1c --- /dev/null +++ b/warm_cities.py @@ -0,0 +1,48 @@ +"""Pre-warm the archives for the curated city set (backend/cities.json) so the +crawlable /climate pages render from cache and a search-engine crawl never bursts +the archive API quota. Run at/after deploy: + + python warm_cities.py [--limit N] [--pace SECONDS] + +Idempotent: a cell whose archive is already cached is skipped. Fetches are paced +(default 2s) to stay well under the archive API's rate limit. A cell that still +has no cached archive when its page is first requested self-heals via get_history, +so this is an optimization, not a hard dependency. +""" +import sys +import time + +import cities +import climate +import grid + + +def main(limit: int | None = None, pace: float = 2.0) -> None: + todo = cities.all_cities() + if limit: + todo = todo[:limit] + fetched = skipped = failed = 0 + for i, c in enumerate(todo, 1): + cell = grid.snap(c["lat"], c["lon"]) + cached = climate.load_cached_history(cell) + if cached is not None and not cached.is_empty(): + skipped += 1 + continue + try: + climate.get_history(cell) # fetch + cache the ~45-yr archive + climate.get_recent_forecast(cell) # + the recent/forecast bundle (today block) + fetched += 1 + print(f"[{i}/{len(todo)}] warmed {c['slug']} ({cell['id']})") + time.sleep(pace) + except Exception as e: # noqa: BLE001 - keep going; the page self-heals later + failed += 1 + print(f"[{i}/{len(todo)}] FAILED {c['slug']}: {e}") + time.sleep(pace) + print(f"done: fetched={fetched} skipped(cached)={skipped} failed={failed}") + + +if __name__ == "__main__": + args = sys.argv[1:] + lim = int(args[args.index("--limit") + 1]) if "--limit" in args else None + pc = float(args[args.index("--pace") + 1]) if "--pace" in args else 2.0 + main(limit=lim, pace=pc)