SEO: crawlable programmatic climate pages + technical hygiene (#96)
* 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 <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; 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.
2026-07-15 23:53:11 +00:00
|
|
|
"""Offline generator for backend/cities.json — the finite set of cities that get
|
|
|
|
|
crawlable climate pages (/climate/<slug>). 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
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
import paths
|
|
|
|
|
from data import places
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* 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 <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; 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.
2026-07-15 23:53:11 +00:00
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
OUT_PATH = paths.CITIES_JSON
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* 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 <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; 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.
2026-07-15 23:53:11 +00:00
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 01:14:30 +00:00
|
|
|
# Core English-speaking countries — the first English top-up tier.
|
SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.
Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
|
|
|
ENGLISH_CC = {"US", "GB", "CA", "AU", "NZ", "IE", "ZA"}
|
|
|
|
|
|
2026-07-16 01:14:30 +00:00
|
|
|
# A second English top-up tier: the remaining countries where English is an official
|
|
|
|
|
# language, plus countries where >35% of the population speaks English (Eurobarometer
|
|
|
|
|
# 2012 "can hold a conversation in English" / EF EPI). Both drive English-language
|
|
|
|
|
# search demand. Editable — this is a judgment call, not a hard rule.
|
|
|
|
|
ENGLISH_EXTENDED_CC = {
|
|
|
|
|
# English official (beyond the core seven)
|
|
|
|
|
"IN", "PK", "PH", "SG", "HK", "MY", "LK", "PG", "FJ",
|
|
|
|
|
"NG", "KE", "GH", "UG", "TZ", "ZW", "ZM", "MW", "BW", "NA", "RW", "SL", "LR", "MU", "SS", "SZ", "LS", "GM", "SC",
|
|
|
|
|
"JM", "TT", "BB", "BS", "BZ", "GY", "GD", "LC", "VC", "AG", "DM", "KN", "MT",
|
|
|
|
|
# >35% English proficiency (non-official)
|
|
|
|
|
"NL", "SE", "DK", "NO", "IS", "FI", "DE", "AT", "BE", "CH", "LU", "CY", "SI", "GR", "EE", "LV", "LT", "FR", "IL",
|
|
|
|
|
}
|
|
|
|
|
|
SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.
Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
|
|
|
|
SEO: values filter — trim cities in anti-LGBTQ countries, keep notable hubs (#102)
Add EXCLUDE_CC + KEEP_SLUGS to gen_cities.py: drop cities in countries that
criminalize LGBTQ+ people (plus China, Pakistan, Indonesia by choice), except a
hand-kept list of notable / tourist / high-English hubs — Lagos & Abuja; Nairobi &
Mombasa; Cairo, Giza, Alexandria; Dubai & Abu Dhabi; Kuala Lumpur/Malacca/Kota
Kinabalu/Kuching; Dar es Salaam/Zanzibar/Arusha; Casablanca/Fes/Rabat; Tehran;
Jeddah; Baghdad; Kabul; 7 Pakistani cities (Karachi, Lahore, Islamabad, Faisalabad,
Rawalpindi, Multan, Hyderabad); Jakarta + Surabaya + Bekasi; and China's 5 most
English-friendly cities (Shanghai, Beijing, Shenzhen, Guangzhou, Chengdu). Removed
slots backfill from the next-ranked non-excluded cities, so cities.json stays 1000.
Flavor regenerated (prunes removed, fetches backfilled).
2026-07-16 02:20:41 +00:00
|
|
|
# Values filter: exclude cities in countries that criminalize LGBTQ+ people (plus
|
|
|
|
|
# Pakistan and Indonesia by request), EXCEPT the individually-kept notable/tourist/
|
|
|
|
|
# high-English hubs in KEEP_SLUGS. Editable — regenerate cities.json after changes.
|
|
|
|
|
EXCLUDE_CC = {
|
|
|
|
|
"NG", "KE", "GH", "UG", "TZ", "ZM", "ZW", "MW", "SL", "LR", "GM", "ET", "SN", "CM", "EG",
|
|
|
|
|
"SA", "AE", "QA", "KW", "OM", "YE", "IR", "IQ", "SY", "AF", "BD", "LK", "MY", "BN", "MM",
|
|
|
|
|
"JM", "GD", "DM", "LC", "VC", "KN", "GY", "DZ", "MA", "LY", "SD", "SS", "ER", "MR",
|
|
|
|
|
"PK", "ID", "CN",
|
|
|
|
|
}
|
|
|
|
|
KEEP_SLUGS = {
|
|
|
|
|
"lagos-ng", "abuja-fct-ng", "kuala-lumpur-my", "malacca-melaka-my",
|
|
|
|
|
"kota-kinabalu-sabah-my", "kuching-sarawak-my", "nairobi-nairobi-county-ke",
|
|
|
|
|
"mombasa-mombasa-county-ke", "dar-es-salaam-dar-es-salaam-region-tz",
|
|
|
|
|
"zanzibar-zanzibar-urban-west-tz", "arusha-tz", "cairo-eg", "giza-eg", "alexandria-eg",
|
|
|
|
|
"dubai-ae", "abu-dhabi-ae", "casablanca-casablanca-settat-ma", "rabat-rabat-sale-kenitra-ma",
|
|
|
|
|
"fes-fes-meknes-ma", "accra-greater-accra-gh", "kumasi-ashanti-gh", "dhaka-dhaka-division-bd",
|
|
|
|
|
"kampala-central-region-ug", "dakar-sn", "lusaka-lusaka-province-zm", "harare-zw",
|
|
|
|
|
"yangon-mm", "colombo-western-province-lk", "kingston-jm", "addis-ababa-et", "tehran-ir",
|
|
|
|
|
"jeddah-mecca-region-sa", "baghdad-iq", "kabul-af", "karachi-sindh-pk", "multan-punjab-pk",
|
|
|
|
|
"lahore-punjab-pk", "hyderabad-sindh-pk", "islamabad-pk", "faisalabad-punjab-pk",
|
|
|
|
|
"rawalpindi-punjab-pk", "jakarta-id", "surabaya-east-java-id", "bekasi-west-java-id",
|
|
|
|
|
# China — only the 5 most English-friendly cities (per a where-to-speak-English guide).
|
|
|
|
|
"shanghai-cn", "beijing-cn", "shenzhen-guangdong-cn", "guangzhou-guangdong-cn", "chengdu-sichuan-cn",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _base_slug(e) -> str:
|
|
|
|
|
name, admin1 = e[_NAME], e[_ADMIN1]
|
SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.
Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
|
|
|
# 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 ""
|
SEO: values filter — trim cities in anti-LGBTQ countries, keep notable hubs (#102)
Add EXCLUDE_CC + KEEP_SLUGS to gen_cities.py: drop cities in countries that
criminalize LGBTQ+ people (plus China, Pakistan, Indonesia by choice), except a
hand-kept list of notable / tourist / high-English hubs — Lagos & Abuja; Nairobi &
Mombasa; Cairo, Giza, Alexandria; Dubai & Abu Dhabi; Kuala Lumpur/Malacca/Kota
Kinabalu/Kuching; Dar es Salaam/Zanzibar/Arusha; Casablanca/Fes/Rabat; Tehran;
Jeddah; Baghdad; Kabul; 7 Pakistani cities (Karachi, Lahore, Islamabad, Faisalabad,
Rawalpindi, Multan, Hyderabad); Jakarta + Surabaya + Bekasi; and China's 5 most
English-friendly cities (Shanghai, Beijing, Shenzhen, Guangzhou, Chengdu). Removed
slots backfill from the next-ranked non-excluded cities, so cities.json stays 1000.
Flavor regenerated (prunes removed, fetches backfilled).
2026-07-16 02:20:41 +00:00
|
|
|
return slugify(name, admin_part, e[_CC] or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _excluded(e) -> bool:
|
|
|
|
|
return e[_CC] in EXCLUDE_CC and _base_slug(e) not in KEEP_SLUGS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _to_city(e, seen_slugs: set[str]) -> dict | None:
|
|
|
|
|
name, admin1, country, cc = e[_NAME], e[_ADMIN1], e[_COUNTRY], e[_CC]
|
|
|
|
|
base = _base_slug(e)
|
SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.
Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
|
|
|
if not base:
|
|
|
|
|
return None
|
|
|
|
|
slug = base
|
|
|
|
|
i = 2
|
|
|
|
|
while slug in seen_slugs: # disambiguate the rare collision
|
|
|
|
|
slug = f"{base}-{i}"
|
|
|
|
|
i += 1
|
|
|
|
|
seen_slugs.add(slug)
|
|
|
|
|
return {
|
|
|
|
|
"slug": slug, "name": name, "admin1": admin1,
|
|
|
|
|
"country": country, "country_code": cc,
|
|
|
|
|
"lat": round(e[_LAT], 5), "lon": round(e[_LON], 5),
|
|
|
|
|
"population": e[_POP],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 01:14:30 +00:00
|
|
|
def build(n_global: int = 500, n_english: int = 250, n_extended: int = 250) -> list[dict]:
|
|
|
|
|
"""Three tiers, population-descending, de-duplicated:
|
|
|
|
|
1. top n_global cities worldwide,
|
|
|
|
|
2. up to n_english more from core English-speaking countries,
|
|
|
|
|
3. up to n_extended more from the remaining English-official + >35%-English
|
|
|
|
|
countries — all not already chosen."""
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* 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 <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; 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.
2026-07-15 23:53:11 +00:00
|
|
|
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()
|
2026-07-16 01:14:30 +00:00
|
|
|
chosen_ids: set = set() # (name, admin1, cc) already added, so tiers don't overlap
|
|
|
|
|
|
|
|
|
|
def add_from(pred, limit: int) -> int:
|
|
|
|
|
added = 0
|
|
|
|
|
for e in entries:
|
|
|
|
|
if added >= limit:
|
|
|
|
|
break
|
|
|
|
|
if not pred(e):
|
|
|
|
|
continue
|
SEO: values filter — trim cities in anti-LGBTQ countries, keep notable hubs (#102)
Add EXCLUDE_CC + KEEP_SLUGS to gen_cities.py: drop cities in countries that
criminalize LGBTQ+ people (plus China, Pakistan, Indonesia by choice), except a
hand-kept list of notable / tourist / high-English hubs — Lagos & Abuja; Nairobi &
Mombasa; Cairo, Giza, Alexandria; Dubai & Abu Dhabi; Kuala Lumpur/Malacca/Kota
Kinabalu/Kuching; Dar es Salaam/Zanzibar/Arusha; Casablanca/Fes/Rabat; Tehran;
Jeddah; Baghdad; Kabul; 7 Pakistani cities (Karachi, Lahore, Islamabad, Faisalabad,
Rawalpindi, Multan, Hyderabad); Jakarta + Surabaya + Bekasi; and China's 5 most
English-friendly cities (Shanghai, Beijing, Shenzhen, Guangzhou, Chengdu). Removed
slots backfill from the next-ranked non-excluded cities, so cities.json stays 1000.
Flavor regenerated (prunes removed, fetches backfilled).
2026-07-16 02:20:41 +00:00
|
|
|
if _excluded(e): # values filter (keeps KEEP_SLUGS); backfills the rest
|
|
|
|
|
continue
|
2026-07-16 01:14:30 +00:00
|
|
|
ident = (e[_NAME], e[_ADMIN1], e[_CC])
|
|
|
|
|
if ident in chosen_ids:
|
|
|
|
|
continue
|
|
|
|
|
c = _to_city(e, seen_slugs)
|
|
|
|
|
if c:
|
|
|
|
|
out.append(c)
|
|
|
|
|
chosen_ids.add(ident)
|
|
|
|
|
added += 1
|
|
|
|
|
return added
|
|
|
|
|
|
|
|
|
|
add_from(lambda e: True, n_global) # tier 1: global
|
|
|
|
|
add_from(lambda e: e[_CC] in ENGLISH_CC, n_english) # tier 2: core English
|
|
|
|
|
add_from(lambda e: e[_CC] in ENGLISH_EXTENDED_CC, n_extended) # tier 3: extended English
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* 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 <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; 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.
2026-07-15 23:53:11 +00:00
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
2026-07-16 01:14:30 +00:00
|
|
|
a = sys.argv[1:]
|
|
|
|
|
n_global = int(a[0]) if len(a) > 0 else 500
|
|
|
|
|
n_english = int(a[1]) if len(a) > 1 else 250
|
|
|
|
|
n_extended = int(a[2]) if len(a) > 2 else 250
|
|
|
|
|
cities = build(n_global, n_english, n_extended)
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* 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 <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; 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.
2026-07-15 23:53:11 +00:00
|
|
|
with open(OUT_PATH, "w", encoding="utf-8") as f:
|
|
|
|
|
json.dump(cities, f, ensure_ascii=False, indent=0, separators=(",", ":"))
|
|
|
|
|
f.write("\n")
|
2026-07-16 01:14:30 +00:00
|
|
|
print(f"wrote {len(cities)} cities ({n_global} global + {n_english} core-English + "
|
|
|
|
|
f"{n_extended} extended-English) -> {OUT_PATH}")
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* 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 <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; 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.
2026-07-15 23:53:11 +00:00
|
|
|
print("sample:", ", ".join(c["slug"] for c in cities[:8]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|