Fill cuisine/website/address from OSM tags when adding a restaurant

2475dea · Gabriel Morell · 2026-10-28 20:22

11 files +242 -15
Message
{commit_body(@commit)}

Files changed

modified README.md
+7 −0
@@ -126,6 +126,12 @@ need no CORS and send no referer:
126 126 trips), the app falls back to Photon reverse geocoding and skips POI for the
127 127 next 5 minutes, so every click doesn't wait 4 s for the timeout.
128 128
129 +Every candidate with an OSM ref then gets its **cuisine, website and
130 +address** from its OSM tags, all in one batch: `/poi/tags` first (fast, even
131 +when `/poi/nearby` is timing out), then the public OSM API (`OSM_API_URL`) for
132 +anything the tile server's import predates. Picking a candidate copies them
133 +into the form. Places OSM has no `cuisine` tag for stay blank to type in.
134 +
129 135 Anything OSM doesn't know about can be added by dropping a pin and typing the
130 136 name. Adding an OSM place that's already here just records your rank on the
131 137 existing one.
@@ -145,6 +151,7 @@ All via environment variables (defaults suit local `compose.yml`):
145 151 | `TILES_URL` | `https://tiles.neiam.org` | Tile server base; themes are `/{theme}/{z}/{x}/{y}.png` |
146 152 | `GEOCODE_URL` | `https://tiles.neiam.org/geocode` | Photon; empty disables search |
147 153 | `POI_URL` | `https://tiles.neiam.org/poi` | POI sidecar; empty uses Photon only |
154 +| `OSM_API_URL` | `https://api.openstreetmap.org` | Tags for places newer than the POI import; empty disables |
148 155 | `DEFAULT_LAT` / `DEFAULT_LON` | Boston | Where the "new area" map opens |
149 156
150 157 ## Containers
modified src/bin/webservice.rs
+1 −1
@@ -59,7 +59,7 @@ async fn main() -> anyhow::Result<()> {
59 59 env,
60 60 db: db.clone(),
61 61 config: config.clone(),
62 − osm: Arc::new(OsmClient::new(&config.geocode_url, &config.poi_url)?),
62 + osm: Arc::new(OsmClient::new(&config.geocode_url, &config.poi_url, &config.osm_api_url)?),
63 63 live,
64 64 };
65 65
modified src/config.rs
+5 −0
@@ -21,6 +21,9 @@ pub struct Config {
21 21 pub geocode_url: String,
22 22 /// The `/poi/` sidecar. Empty skips it and uses Photon reverse only.
23 23 pub poi_url: String,
24 + /// The public OSM API, for tags of places newer than the POI import.
25 + /// Empty disables it.
26 + pub osm_api_url: String,
24 27 /// Where the map opens before any area exists.
25 28 pub default_lat: f64,
26 29 pub default_lon: f64,
@@ -41,6 +44,8 @@ impl Config {
41 44 geocode_url: env::var("GEOCODE_URL")
42 45 .unwrap_or_else(|_| "https://tiles.neiam.org/geocode".to_string()),
43 46 poi_url: env::var("POI_URL").unwrap_or_else(|_| "https://tiles.neiam.org/poi".to_string()),
47 + osm_api_url: env::var("OSM_API_URL")
48 + .unwrap_or_else(|_| "https://api.openstreetmap.org".to_string()),
44 49 default_lat: parse_or("DEFAULT_LAT", 42.3601),
45 50 default_lon: parse_or("DEFAULT_LON", -71.0589),
46 51 }
modified src/handlers/places.rs
+7 −2
@@ -35,7 +35,7 @@ pub async fn search(
35 35 let q = qs.q.trim();
36 36 let near = qs.lat.zip(qs.lon);
37 37 let food = qs.food.as_deref() == Some("1");
38 − let (places, failed) = if q.is_empty() {
38 + let (mut places, failed) = if q.is_empty() {
39 39 (vec![], false)
40 40 } else {
41 41 match state.osm.search(q, near, food).await {
@@ -46,6 +46,10 @@ pub async fn search(
46 46 }
47 47 }
48 48 };
49 + // Area searches ("Austin") have no cuisine to find.
50 + if food {
51 + state.osm.enrich(&mut places).await;
52 + }
49 53 candidates(&state, places, failed, true).await
50 54 }
51 55
@@ -60,13 +64,14 @@ pub async fn nearby(
60 64 _user: UserContext,
61 65 Query(qs): Query<NearbyQuery>,
62 66 ) -> Result<Html<String>, StatusCode> {
63 − let (places, failed) = match state.osm.nearby(qs.lat, qs.lon, 150).await {
67 + let (mut places, failed) = match state.osm.nearby(qs.lat, qs.lon, 150).await {
64 68 Ok(p) => (p, false),
65 69 Err(e) => {
66 70 warn!(error = %e, "nearby lookup failed");
67 71 (vec![], true)
68 72 }
69 73 };
74 + state.osm.enrich(&mut places).await;
70 75 candidates(&state, places, failed, false).await
71 76 }
72 77
modified src/osm.rs
+221 −12
@@ -12,6 +12,11 @@
12 12 //! `/poi/nearby` has a hard 4 s statement timeout upstream and answers 504
13 13 //! when it trips, so "what's here?" falls back to Photon's reverse geocoder,
14 14 //! which knows names and kinds but not cuisine.
15 +//!
16 +//! Either way, [`OsmClient::enrich`] then fills each candidate's cuisine,
17 +//! website and address from its OSM tags: `/poi/tags` (a lookup near a known
18 +//! position, fast even when `/poi/nearby` isn't), then the public OSM API for
19 +//! whatever the import doesn't have — places added since, typically.
15 20 use std::collections::HashMap;
16 21 use std::sync::atomic::{AtomicU64, Ordering};
17 22 use std::sync::Arc;
@@ -41,6 +46,58 @@ pub struct Place {
41 46 pub address: Option<String>,
42 47 pub website: Option<String>,
43 48 pub distance_m: Option<i64>,
49 + /// Already built from the element's tags, so [`OsmClient::enrich`] has
50 + /// nothing to add.
51 + #[serde(skip)]
52 + pub tagged: bool,
53 +}
54 +
55 +/// The fields a [`Place`] takes from an OSM element's tags.
56 +#[derive(Debug, Default, PartialEq)]
57 +struct Details {
58 + kind: Option<String>,
59 + cuisine: Option<String>,
60 + website: Option<String>,
61 + address: Option<String>,
62 +}
63 +
64 +fn details(tags: &HashMap<String, String>) -> Details {
65 + let tag = |k: &str| tags.get(k).cloned().filter(|v| !v.is_empty());
66 + let street = [tag("addr:housenumber"), tag("addr:street")].into_iter().flatten().collect::<Vec<_>>().join(" ");
67 + let address = [Some(street).filter(|s| !s.is_empty()), tag("addr:city")]
68 + .into_iter()
69 + .flatten()
70 + .collect::<Vec<_>>()
71 + .join(", ");
72 + Details {
73 + kind: tag("amenity"),
74 + // "indian;nepalese" / "fish_and_chips" → "indian, nepalese" / "fish and chips"
75 + cuisine: tag("cuisine").map(|c| c.replace(';', ", ").replace('_', " ")),
76 + website: tag("website").or_else(|| tag("contact:website")),
77 + address: Some(address).filter(|a| !a.is_empty()),
78 + }
79 +}
80 +
81 +impl Place {
82 + /// Take what the tags know, keeping anything already set.
83 + fn fill(&mut self, d: Details) {
84 + self.kind = self.kind.take().or(d.kind);
85 + self.cuisine = self.cuisine.take().or(d.cuisine);
86 + self.website = self.website.take().or(d.website);
87 + self.address = self.address.take().or(d.address);
88 + self.tagged = true;
89 + }
90 +}
91 +
92 +/// `N123` → (`node`, 123), as the OSM API names element types.
93 +fn split_ref(osm_ref: &str) -> Option<(&'static str, i64)> {
94 + let kind = match osm_ref.get(..1)? {
95 + "N" => "node",
96 + "W" => "way",
97 + "R" => "relation",
98 + _ => return None,
99 + };
100 + Some((kind, osm_ref[1..].parse().ok()?))
44 101 }
45 102
46 103 #[derive(Clone)]
@@ -48,6 +105,7 @@ pub struct OsmClient {
48 105 http: reqwest::Client,
49 106 geocode_url: Option<String>,
50 107 poi_url: Option<String>,
108 + osm_api_url: Option<String>,
51 109 /// Unix seconds before which `/poi/` is skipped; see [`POI_BACKOFF`].
52 110 poi_skip_until: Arc<AtomicU64>,
53 111 }
@@ -58,7 +116,7 @@ fn now_secs() -> u64 {
58 116
59 117 impl OsmClient {
60 118 /// Empty URLs turn that API off.
61 − pub fn new(geocode_url: &str, poi_url: &str) -> Result<Self> {
119 + pub fn new(geocode_url: &str, poi_url: &str, osm_api_url: &str) -> Result<Self> {
62 120 let http = reqwest::Client::builder()
63 121 .timeout(Duration::from_secs(8))
64 122 .user_agent(concat!("fieri-eternal/", env!("CARGO_PKG_VERSION")))
@@ -68,6 +126,7 @@ impl OsmClient {
68 126 http,
69 127 geocode_url: url(geocode_url),
70 128 poi_url: url(poi_url),
129 + osm_api_url: url(osm_api_url),
71 130 poi_skip_until: Arc::new(AtomicU64::new(0)),
72 131 })
73 132 }
@@ -118,6 +177,101 @@ impl OsmClient {
118 177 self.photon_reverse(lat, lon, radius_m).await
119 178 }
120 179
180 + /// Fill cuisine, website and address from OSM tags for every place that
181 + /// has an OSM ref and wasn't built from tags already. Best effort: a
182 + /// failed lookup leaves places as they were.
183 + pub async fn enrich(&self, places: &mut [Place]) {
184 + let wanted = |p: &Place| !p.tagged && p.osm_ref.is_some();
185 + if !places.iter().any(wanted) {
186 + return;
187 + }
188 + if self.poi_url.is_some() {
189 + match self.poi_tags(places.iter().filter(|p| wanted(p))).await {
190 + Ok(mut found) => {
191 + for p in places.iter_mut().filter(|p| wanted(p)) {
192 + if let Some(tags) = p.osm_ref.as_ref().and_then(|r| found.remove(r)) {
193 + p.fill(details(&tags));
194 + }
195 + }
196 + }
197 + Err(e) => warn!(error = %e, "poi tags failed"),
198 + }
199 + }
200 + if self.osm_api_url.is_some() && places.iter().any(wanted) {
201 + match self.osm_api_tags(places.iter().filter(|p| wanted(p))).await {
202 + Ok(mut found) => {
203 + for p in places.iter_mut().filter(|p| wanted(p)) {
204 + if let Some(tags) = p.osm_ref.as_ref().and_then(|r| found.remove(r)) {
205 + p.fill(details(&tags));
206 + }
207 + }
208 + }
209 + Err(e) => warn!(error = %e, "osm api tags failed"),
210 + }
211 + }
212 + }
213 +
214 + /// `/poi/tags` looks elements up near a position (the render database has
215 + /// no index on osm_id), which every candidate has.
216 + async fn poi_tags<'a>(
217 + &self,
218 + places: impl Iterator<Item = &'a Place>,
219 + ) -> Result<HashMap<String, HashMap<String, String>>> {
220 + let base = self.poi_url.as_deref().unwrap_or_default();
221 + let params: Vec<(&str, String)> = places
222 + .filter_map(|p| Some(("ref", format!("{}@{},{}", p.osm_ref.as_ref()?, p.lat, p.lon))))
223 + .take(50)
224 + .collect();
225 + let body: PoiTags = self
226 + .http
227 + .get(format!("{base}/tags"))
228 + .query(&params)
229 + .send()
230 + .await?
231 + .error_for_status()?
232 + .json()
233 + .await
234 + .context("decoding poi tags")?;
235 + Ok(body.tags)
236 + }
237 +
238 + /// The live OSM API: one multi-fetch per element type. Interactive, a
239 + /// dozen elements at most, so well inside its usage policy.
240 + async fn osm_api_tags<'a>(
241 + &self,
242 + places: impl Iterator<Item = &'a Place>,
243 + ) -> Result<HashMap<String, HashMap<String, String>>> {
244 + let base = self.osm_api_url.as_deref().unwrap_or_default();
245 + let mut ids: HashMap<&str, Vec<String>> = HashMap::new();
246 + for (kind, id) in places.filter_map(|p| split_ref(p.osm_ref.as_deref()?)) {
247 + ids.entry(kind).or_default().push(id.to_string());
248 + }
249 + let mut found = HashMap::new();
250 + for (kind, ids) in ids {
251 + let body: OsmElements = self
252 + .http
253 + .get(format!("{base}/api/0.6/{kind}s.json"))
254 + .query(&[(format!("{kind}s"), ids.join(","))])
255 + .timeout(Duration::from_secs(4))
256 + .send()
257 + .await?
258 + .error_for_status()?
259 + .json()
260 + .await
261 + .context("decoding osm api elements")?;
262 + for e in body.elements {
263 + let prefix = match e.kind.as_str() {
264 + "node" => "N",
265 + "way" => "W",
266 + "relation" => "R",
267 + _ => continue,
268 + };
269 + found.insert(format!("{prefix}{}", e.id), e.tags);
270 + }
271 + }
272 + Ok(found)
273 + }
274 +
121 275 async fn poi_nearby(&self, lat: f64, lon: f64, radius_m: u32) -> Result<Vec<Place>> {
122 276 let base = self.poi_url.as_deref().unwrap_or_default();
123 277 let mut params: Vec<(&str, String)> = vec![
@@ -195,27 +349,41 @@ struct PoiPlace {
195 349
196 350 impl PoiPlace {
197 351 fn into_place(self) -> Place {
198 − let tag = |k: &str| self.tags.get(k).cloned().filter(|v| !v.is_empty());
199 − let street = [tag("addr:housenumber"), tag("addr:street")].into_iter().flatten().collect::<Vec<_>>().join(" ");
200 − let address = [Some(street).filter(|s| !s.is_empty()), tag("addr:city")]
201 − .into_iter()
202 − .flatten()
203 − .collect::<Vec<_>>()
204 − .join(", ");
352 + let d = details(&self.tags);
205 353 Place {
206 − kind: tag("amenity"),
207 − cuisine: tag("cuisine").map(|c| c.replace(';', ", ").replace('_', " ")),
208 − website: tag("website").or_else(|| tag("contact:website")),
209 − address: Some(address).filter(|a| !a.is_empty()),
354 + kind: d.kind,
355 + cuisine: d.cuisine,
356 + website: d.website,
357 + address: d.address,
210 358 osm_ref: Some(self.osm_ref),
211 359 name: self.name,
212 360 lat: self.lat,
213 361 lon: self.lon,
214 362 distance_m: self.distance_m,
363 + tagged: true,
215 364 }
216 365 }
217 366 }
218 367
368 +#[derive(Deserialize)]
369 +struct PoiTags {
370 + tags: HashMap<String, HashMap<String, String>>,
371 +}
372 +
373 +#[derive(Deserialize)]
374 +struct OsmElements {
375 + elements: Vec<OsmElement>,
376 +}
377 +
378 +#[derive(Deserialize)]
379 +struct OsmElement {
380 + #[serde(rename = "type")]
381 + kind: String,
382 + id: i64,
383 + #[serde(default)]
384 + tags: HashMap<String, String>,
385 +}
386 +
219 387 #[derive(Deserialize)]
220 388 struct PhotonCollection {
221 389 features: Vec<PhotonFeature>,
@@ -277,6 +445,7 @@ impl PhotonFeature {
277 445 address: Some(address).filter(|a| !a.is_empty()),
278 446 website: None,
279 447 distance_m: None,
448 + tagged: false,
280 449 })
281 450 }
282 451 }
@@ -310,6 +479,46 @@ mod tests {
310 479 assert_eq!(p.website.as_deref(), Some("https://example.com"));
311 480 }
312 481
482 + #[test]
483 + fn fill_keeps_what_is_set_and_takes_the_rest() {
484 + let mut p = Place {
485 + osm_ref: Some("N1".into()),
486 + name: "x".into(),
487 + lat: 0.0,
488 + lon: 0.0,
489 + kind: Some("restaurant".into()),
490 + cuisine: None,
491 + address: Some("from photon".into()),
492 + website: None,
493 + distance_m: None,
494 + tagged: false,
495 + };
496 + let tags: HashMap<String, String> = [
497 + ("cuisine", "fish_and_chips"),
498 + ("contact:website", "https://example.com"),
499 + ("addr:street", "Main St"),
500 + ]
501 + .into_iter()
502 + .map(|(k, v)| (k.to_string(), v.to_string()))
503 + .collect();
504 + p.fill(details(&tags));
505 + assert_eq!(p.cuisine.as_deref(), Some("fish and chips"));
506 + assert_eq!(p.website.as_deref(), Some("https://example.com"));
507 + assert_eq!(p.address.as_deref(), Some("from photon"));
508 + assert!(p.tagged);
509 + }
510 +
511 + #[test]
512 + fn osm_api_elements_parse() {
513 + let json = r#"{"version":"0.6","elements":[{"type":"node","id":12020644552,"lat":42.3,"lon":-71.0,
514 + "tags":{"amenity":"restaurant","website":"https://www.cocobeet.com/"}},{"type":"way","id":7}]}"#;
515 + let e: OsmElements = serde_json::from_str(json).unwrap();
516 + assert_eq!(e.elements[0].tags["website"], "https://www.cocobeet.com/");
517 + assert!(e.elements[1].tags.is_empty());
518 + assert_eq!(split_ref("W29560269"), Some(("way", 29560269)));
519 + assert_eq!(split_ref("X1"), None);
520 + }
521 +
313 522 #[test]
314 523 fn haversine_is_sane() {
315 524 // ~111 km per degree of latitude.
modified templates/places/_candidates.html
+1 −0
@@ -20,6 +20,7 @@
20 20 <span class="block text-xs opacity-70">
21 21 {{ [c.place.kind, c.place.cuisine, c.place.address] | select | join(" · ") }}
22 22 {%- if c.place.distance_m is not none %} · {{ c.place.distance_m }} m{% endif %}
23 + {%- if c.place.website %} · <i class="fas fa-globe" title="has a website"></i>{% endif %}
23 24 </span>
24 25 </span>
25 26 </button>

Parents: 504ebaf