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.
This commit is contained in:
parent
58ac3120b2
commit
3bbd819d1d
2 changed files with 2553 additions and 27 deletions
2500
cities.json
2500
cities.json
File diff suppressed because it is too large
Load diff
|
|
@ -31,7 +31,37 @@ def slugify(*parts: str) -> str:
|
||||||
return re.sub(r"-{2,}", "-", text)
|
return re.sub(r"-{2,}", "-", text)
|
||||||
|
|
||||||
|
|
||||||
def build(n: int = 500) -> list[dict]:
|
# Countries where English is the primary/official language of web search. Used to
|
||||||
|
# top up the population-ranked global list (which skews to Asia) with the
|
||||||
|
# high-search-demand English-market cities that would otherwise be missed.
|
||||||
|
ENGLISH_CC = {"US", "GB", "CA", "AU", "NZ", "IE", "ZA"}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_city(e, seen_slugs: set[str]) -> dict | None:
|
||||||
|
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:
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build(n_global: int = 500, n_english: int = 250) -> list[dict]:
|
||||||
|
"""Top n_global cities worldwide by population, then up to n_english more from
|
||||||
|
English-speaking countries that weren't already in that global set."""
|
||||||
places._load() # synchronous parse; fills places._data (entries are pop-desc)
|
places._load() # synchronous parse; fills places._data (entries are pop-desc)
|
||||||
if not places._data:
|
if not places._data:
|
||||||
raise SystemExit("GeoNames index failed to load (see logs); cannot generate cities.")
|
raise SystemExit("GeoNames index failed to load (see logs); cannot generate cities.")
|
||||||
|
|
@ -40,41 +70,37 @@ def build(n: int = 500) -> list[dict]:
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
seen_slugs: set[str] = set()
|
seen_slugs: set[str] = set()
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if len(out) >= n:
|
if len(out) >= n_global:
|
||||||
break
|
break
|
||||||
name, admin1, country, cc = e[_NAME], e[_ADMIN1], e[_COUNTRY], e[_CC]
|
c = _to_city(e, seen_slugs)
|
||||||
# Drop admin1 from the slug when it just repeats the city name
|
if c:
|
||||||
# (e.g. Tokyo/Tokyo, Singapore/Singapore) to avoid "tokyo-tokyo-jp".
|
out.append(c)
|
||||||
admin_part = admin1 if admin1 and slugify(admin1) != slugify(name) else ""
|
|
||||||
base = slugify(name, admin_part, cc or "")
|
# Identity of the cities already chosen, so the English top-up skips them.
|
||||||
if not base:
|
chosen = {(c["name"], c["admin1"], c["country_code"]) for c in out}
|
||||||
|
added = 0
|
||||||
|
for e in entries:
|
||||||
|
if added >= n_english:
|
||||||
|
break
|
||||||
|
if e[_CC] not in ENGLISH_CC:
|
||||||
continue
|
continue
|
||||||
slug = base
|
if (e[_NAME], e[_ADMIN1], e[_CC]) in chosen:
|
||||||
i = 2
|
continue
|
||||||
while slug in seen_slugs: # disambiguate the rare collision
|
c = _to_city(e, seen_slugs)
|
||||||
slug = f"{base}-{i}"
|
if c:
|
||||||
i += 1
|
out.append(c)
|
||||||
seen_slugs.add(slug)
|
added += 1
|
||||||
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
n = int(sys.argv[1]) if len(sys.argv) > 1 else 500
|
n_global = int(sys.argv[1]) if len(sys.argv) > 1 else 500
|
||||||
cities = build(n)
|
n_english = int(sys.argv[2]) if len(sys.argv) > 2 else 250
|
||||||
|
cities = build(n_global, n_english)
|
||||||
with open(OUT_PATH, "w", encoding="utf-8") as f:
|
with open(OUT_PATH, "w", encoding="utf-8") as f:
|
||||||
json.dump(cities, f, ensure_ascii=False, indent=0, separators=(",", ":"))
|
json.dump(cities, f, ensure_ascii=False, indent=0, separators=(",", ":"))
|
||||||
f.write("\n")
|
f.write("\n")
|
||||||
print(f"wrote {len(cities)} cities -> {OUT_PATH}")
|
print(f"wrote {len(cities)} cities ({n_global} global + up to {n_english} English-market) -> {OUT_PATH}")
|
||||||
print("sample:", ", ".join(c["slug"] for c in cities[:8]))
|
print("sample:", ", ".join(c["slug"] for c in cities[:8]))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue