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.
/enrichment returns the nearest place of each category with its walking, cycling, driving and public transport times, already flattened for a database. Use it instead of parsing /summary; the worker loop below applies unchanged.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.