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:
@@ -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)."""
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user