feat(markets): geo-localize article + map sorting via CF-IPCountry

- Read Cloudflare CF-IPCountry header into g.user_country (before_request)
- _filter_articles() sorts user's country first, nearby countries second,
  then rest — falls back to published_at DESC when header is absent
- map_countries sorted so user's country bubble renders on top (Leaflet z-order)
- Nearby-country mapping covers DACH, Iberia, Nordics, Benelux, UK/IE, Americas

Prerequisite: Nginx must forward CF-IPCountry header to Quart (same as Umami).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-08 20:25:43 +01:00
parent 814e8290a2
commit d379dc7551
2 changed files with 56 additions and 5 deletions

View File

@@ -148,6 +148,11 @@ def create_app() -> Quart:
# Per-request hooks # Per-request hooks
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@app.before_request
async def set_user_country():
"""Stash Cloudflare CF-IPCountry header (ISO alpha-2) in g for geo sorting."""
g.user_country = request.headers.get("CF-IPCountry", "").upper() or ""
@app.before_request @app.before_request
async def validate_lang(): async def validate_lang():
"""404 unsupported language prefixes (e.g. /fr/terms).""" """404 unsupported language prefixes (e.g. /fr/terms)."""

View File

@@ -212,6 +212,13 @@ async def markets():
FROM serving.pseo_country_overview FROM serving.pseo_country_overview
ORDER BY total_venues DESC ORDER BY total_venues DESC
""") """)
# Sort so user's country renders last (on top in Leaflet z-order)
user_country = g.get("user_country", "")
if user_country and map_countries:
map_countries = sorted(
map_countries,
key=lambda c: 1 if c["country_code"] == user_country else 0,
)
return await render_template( return await render_template(
"markets.html", "markets.html",
@@ -237,9 +244,46 @@ async def market_results():
return await render_template("partials/market_results.html", articles=articles) return await render_template("partials/market_results.html", articles=articles)
_NEARBY_COUNTRIES: dict[str, tuple[str, ...]] = {
"DE": ("AT", "CH"), "AT": ("DE", "CH"), "CH": ("DE", "AT"),
"ES": ("PT",), "PT": ("ES",),
"GB": ("IE",), "IE": ("GB",),
"US": ("CA",), "CA": ("US",),
"IT": ("CH",), "FR": ("BE", "CH"), "BE": ("FR", "NL", "DE"),
"NL": ("BE", "DE"), "SE": ("NO", "DK", "FI"), "NO": ("SE", "DK"),
"DK": ("SE", "NO", "DE"), "FI": ("SE",),
"MX": ("US",), "BR": ("AR",), "AR": ("BR",),
}
def _geo_order_clause(user_country: str) -> tuple[str, list]:
"""Build ORDER BY clause that sorts user's country first, nearby second.
Returns (order_sql, params) where order_sql starts with the geo CASE
followed by published_at DESC. Caller prepends 'ORDER BY'.
"""
if not user_country:
return "published_at DESC", []
nearby = _NEARBY_COUNTRIES.get(user_country, ())
if nearby:
placeholders = ",".join("?" * len(nearby))
geo_case = f"""CASE WHEN country = ? THEN 0
WHEN country IN ({placeholders}) THEN 1
ELSE 2 END,
published_at DESC"""
return geo_case, [user_country, *nearby]
return """CASE WHEN country = ? THEN 0 ELSE 1 END,
published_at DESC""", [user_country]
async def _filter_articles(q: str, country: str, region: str) -> list[dict]: async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
"""Query published articles for the current language.""" """Query published articles for the current language, geo-sorted."""
lang = g.get("lang", "en") lang = g.get("lang", "en")
user_country = g.get("user_country", "")
geo_order, geo_params = _geo_order_clause(user_country)
if q: if q:
# FTS query # FTS query
wheres = ["articles_fts MATCH ?"] wheres = ["articles_fts MATCH ?"]
@@ -253,14 +297,16 @@ async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
wheres.append("a.region = ?") wheres.append("a.region = ?")
params.append(region) params.append(region)
where = " AND ".join(wheres) where = " AND ".join(wheres)
# Geo-sort references a.country
order = geo_order.replace("country", "a.country")
return await fetch_all( return await fetch_all(
f"""SELECT a.* FROM articles a f"""SELECT a.* FROM articles a
JOIN articles_fts ON articles_fts.rowid = a.id JOIN articles_fts ON articles_fts.rowid = a.id
WHERE {where} WHERE {where}
AND a.status = 'published' AND a.published_at <= datetime('now') AND a.status = 'published' AND a.published_at <= datetime('now')
ORDER BY a.published_at DESC ORDER BY {order}
LIMIT 100""", LIMIT 100""",
tuple(params), tuple(params + geo_params),
) )
else: else:
wheres = ["status = 'published'", "published_at <= datetime('now')", "language = ?"] wheres = ["status = 'published'", "published_at <= datetime('now')", "language = ?"]
@@ -274,8 +320,8 @@ async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
where = " AND ".join(wheres) where = " AND ".join(wheres)
return await fetch_all( return await fetch_all(
f"""SELECT * FROM articles WHERE {where} f"""SELECT * FROM articles WHERE {where}
ORDER BY published_at DESC LIMIT 100""", ORDER BY {geo_order} LIMIT 100""",
tuple(params), tuple(params + geo_params),
) )