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.

  1. For each listing, call /summary with its coordinates, from your server, with your backend key.
  2. Find the nearest place of the categories you care about, in the travel mode you care about.
  3. Store the value on the listing, and reindex.
  4. 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