Recipe: bulk POI scoring for a property catalog
Score every listing in your database for proximity to schools, transport, shops and so on. Run it nightly, store the result, filter on it in your own search.
Scenario
You have 50,000 active listings and want users to filter on “within 5 minutes' walk of a school”. Calling Yatmo at search time is slow and unnecessary: precompute one score per listing, refresh it nightly, and search your own index.
- For each listing, call
/summarywith its coordinates, from your server, with your backend key. - Find the nearest place of the categories you care about, in the travel mode you care about.
- Store the value on the listing, and reindex.
- Throttle: 50 to 100 ms between calls keeps you clear of
429.
The fields you read
The /summary response uses short field names. These are the ones this recipe relies on, as the API returns them:
| Name | In | Type | Description |
|---|---|---|---|
| AvailableCategoriesAroundPosition[].ct | int |
Category, stable across languages: 1 education, 2 transport, 3 shopping, 7 tourism.
|
|
| …[].sc[].st | int |
Sub-type within the category. Its numbering is per market: in Belgium st: 4 under education is nurseries, in France it is 5.
|
|
| …[].sc[].l, lb | string | Display labels, translated. Show them; never compare them. | |
| …[].sc[].d[] | array |
The places. n name, la/lo coordinates, id place-type id, td travel data. Not guaranteed to be sorted: compute the minimum yourself.
|
|
| …d[].td[].tm | int |
Travel mode, stable: 1 driving, 2 walking, 3 cycling, 4 transit. (ttm is the same mode as a translated label.)
|
|
| …d[].td[].hti | bool |
false when there is no travel information for that mode. Skip the entry.
|
|
| …d[].td[].ptdd, tt | int | Distance in metres along the network, and time in seconds. |
Choosing categories without comparing labels
Take the place-type ids of the group you want from
/simplifiedcategories for the
market you are scoring. An id has fourteen digits: 1, three for the category, four
for the sub-type, six for the variant. That gives you the (ct, st) pair to match in
/summary, whatever the language. For Belgium the education group is
10010001000000 to 10010004000000, that is ct 1, sub-types 1 to
4. To leave nurseries out there, drop 10010004000000: find each market's nursery id
once, at setup, by reading its label in a /summary response, then keep the id.
Worker example
Each tab is complete: it defines every function it calls. The JavaScript tab is server-side (Node.js 18 or later); the backend key must never reach a browser.
# One listing, to inspect the response by hand.
curl -H 'LicenseKey: YOUR_BACKEND_KEY' \
'https://be.yatmo.com/summary?latitude=50.846714&longitude=4.352514&language=EN' \
| jq '.AvailableCategoriesAroundPosition[] | {ct, sub: [.sc[] | {st, l}]}'
// Server-side JavaScript (Node.js 18+). Run as an ES module: node score.mjs
import fs from 'node:fs/promises';
const WALKING = 2; // td[].tm: 1 driving, 2 walking, 3 cycling, 4 transit
// Belgian education group from /simplifiedcategories, nurseries (…0004…) left out.
const SCHOOL_IDS = [10010001000000, 10010002000000, 10010003000000];
// '1' + category (3 digits) + sub-type (4 digits) + variant (6 digits)
const toPair = id => ({ ct: Math.floor(id / 1e10) % 1000, st: Math.floor(id / 1e6) % 10000 });
// Nearest place of the given types in the given mode; null when there is none.
export function nearest(summary, typeIds, mode) {
const pairs = typeIds.map(toPair);
let best = null;
for (const category of summary.AvailableCategoriesAroundPosition ?? []) {
for (const sub of category.sc ?? []) {
if (!pairs.some(p => p.ct === Number(category.ct) && p.st === Number(sub.st))) continue;
for (const place of sub.d ?? []) {
const t = (place.td ?? []).find(x => x.tm === mode && x.hti);
if (t && (best === null || t.ptdd < best.metres)) {
best = { name: place.n, metres: t.ptdd, seconds: t.tt };
}
}
}
}
return best;
}
async function scoreListing(listing) {
const url = 'https://be.yatmo.com/summary?latitude=' + listing.lat +
'&longitude=' + listing.lng + '&language=EN';
const res = await fetch(url, { headers: { LicenseKey: process.env.YATMO_KEY } });
if (!res.ok) {
console.error('Yatmo error', res.status, await res.text());
return null; // unknown, not zero: retry on the next run
}
const school = nearest(await res.json(), SCHOOL_IDS, WALKING);
return school ? school.seconds : null;
}
const listings = JSON.parse(await fs.readFile('listings.json', 'utf8'));
for (const l of listings) {
l.walkToSchoolSec = await scoreListing(l);
await new Promise(resolve => setTimeout(resolve, 80)); // throttle
}
await fs.writeFile('scored.json', JSON.stringify(listings, null, 2));
<?php
// PHP worker (CLI), PHP 8.
const WALKING = 2; // td[].tm: 1 driving, 2 walking, 3 cycling, 4 transit
const SCHOOL_IDS = [10010001000000, 10010002000000, 10010003000000];
function toPair(int $id): array {
return [intdiv($id, 10000000000) % 1000, intdiv($id, 1000000) % 10000];
}
// Nearest place of the given types in the given mode; null when there is none.
function nearest(array $summary, array $typeIds, int $mode): ?array {
$pairs = array_map('toPair', $typeIds);
$best = null;
foreach ($summary['AvailableCategoriesAroundPosition'] ?? [] as $category) {
foreach ($category['sc'] ?? [] as $sub) {
if (!in_array([(int)$category['ct'], (int)$sub['st']], $pairs, true)) continue;
foreach ($sub['d'] ?? [] as $place) {
foreach ($place['td'] ?? [] as $t) {
if ($t['tm'] !== $mode || empty($t['hti'])) continue;
if ($best === null || $t['ptdd'] < $best['metres']) {
$best = ['name' => $place['n'], 'metres' => $t['ptdd'], 'seconds' => $t['tt']];
}
}
}
}
}
return $best;
}
$key = getenv('YATMO_KEY');
$listings = json_decode(file_get_contents('listings.json'), true);
foreach ($listings as &$l) {
$url = 'https://be.yatmo.com/summary?' . http_build_query(
['latitude' => $l['lat'], 'longitude' => $l['lng'], 'language' => 'EN']);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['LicenseKey: ' . $key]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$school = $status === 200 ? nearest(json_decode($body, true), SCHOOL_IDS, WALKING) : null;
$l['walkToSchoolSec'] = $school['seconds'] ?? null;
usleep(80 * 1000);
}
unset($l);
file_put_contents('scored.json', json_encode($listings, JSON_PRETTY_PRINT));
// .NET 8 console worker. dotnet new console, then replace Program.cs.
using System.Net.Http.Json;
using System.Text.Json;
const int Walking = 2; // td[].tm: 1 driving, 2 walking, 3 cycling, 4 transit
long[] schoolIds = { 10010001000000, 10010002000000, 10010003000000 };
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("LicenseKey", Environment.GetEnvironmentVariable("YATMO_KEY"));
var listings = JsonSerializer.Deserialize<List<Listing>>(await File.ReadAllTextAsync("listings.json"))!;
foreach (var l in listings)
{
var url = $"https://be.yatmo.com/summary?latitude={l.Lat}&longitude={l.Lng}&language=EN";
using var res = await http.GetAsync(url);
l.WalkToSchoolSec = res.IsSuccessStatusCode
? Scoring.Nearest(await res.Content.ReadFromJsonAsync<JsonElement>(), schoolIds, Walking)?.Seconds
: null;
await Task.Delay(80); // throttle
}
await File.WriteAllTextAsync("scored.json", JsonSerializer.Serialize(listings));
public sealed class Listing
{
public double Lat { get; set; }
public double Lng { get; set; }
public int? WalkToSchoolSec { get; set; }
}
public sealed record NearestPlace(string Name, int Metres, int Seconds);
public static class Scoring
{
// Nearest place of the given types in the given mode; null when there is none.
public static NearestPlace? Nearest(JsonElement summary, IEnumerable<long> typeIds, int mode)
{
var pairs = typeIds.Select(id => ((int)(id / 10000000000 % 1000), (int)(id / 1000000 % 10000))).ToHashSet();
NearestPlace? best = null;
if (!summary.TryGetProperty("AvailableCategoriesAroundPosition", out var categories)) return null;
foreach (var category in categories.EnumerateArray())
{
var ct = int.Parse(category.GetProperty("ct").ToString());
foreach (var sub in category.GetProperty("sc").EnumerateArray())
{
if (!pairs.Contains((ct, int.Parse(sub.GetProperty("st").ToString())))) continue;
foreach (var place in sub.GetProperty("d").EnumerateArray())
foreach (var t in place.GetProperty("td").EnumerateArray())
{
if (t.GetProperty("tm").GetInt32() != mode || !t.GetProperty("hti").GetBoolean()) continue;
var metres = t.GetProperty("ptdd").GetInt32();
if (best is null || metres < best.Metres)
best = new NearestPlace(place.GetProperty("n").GetString() ?? "", metres, t.GetProperty("tt").GetInt32());
}
}
}
return best;
}
}
Production checklist
- Store unknown as unknown.
nullmeans no place of that type, or no travel information in that mode. Do not store it as 0 or as a large number, or your filter will lie. - Run incrementally. Rescore new listings plus a rolling sample of older ones, not the whole catalogue every night.
- Handle
429calmly. Slow down rather than retrying in a loop. - Persist partial progress. If the worker stops after 30,000 of 50,000 listings, resume rather than restart.
- Responses are cached for 7 days per query on the Yatmo side (
latitude,longitude,language): rescoring the same listing within a week returns the same data.