feat(places): descriptions from the place's own website

Overture has no descriptions, and the encyclopaedias only describe the
places that have an article, which is a fraction of a percent. What
Overture does have is the address of the page the description is on, for
43 percent of places, and those pages publish a summary in JSON-LD or
og:description precisely so machines can read it.

The API fetches each page once and keeps the summary; every instance
reads it from there. TREK never opens a business's website itself, which
would mean one request per instance per place against small servers.

In the ladder it sits below the encyclopaedias and above Google: an
article about this exact place beats the operator's own copy, but for a
restaurant or a shop the operator's summary is the only one that exists.
Credited by host and linked back, with no licence claimed.
This commit is contained in:
Maurice
2026-09-06 00:24:12 +02:00
committed by Maurice
parent b3d69aa760
commit 715993feaf
7 changed files with 217 additions and 14 deletions
@@ -116,8 +116,8 @@ export class BookingImportService {
let found: { lat: number; lng: number } | null = null;
try {
for (const q of queries) {
const hit = (await this.maps.searchNominatim(q, undefined, 'background'))[0];
if (hit?.lat != null && hit?.lng != null) { found = { lat: hit.lat, lng: hit.lng }; break; }
const hit = await this.maps.geocodeQuery(q);
if (hit) { found = hit; break; }
}
} catch {
// geocoding failure is non-fatal — the endpoint stays, and is warned about
@@ -240,9 +240,8 @@ export class BookingImportService {
].filter((q): q is string => !!q);
for (const q of queries) {
const results = await this.maps.searchNominatim(q, undefined, 'background');
const hit = results[0];
if (hit?.lat != null && hit?.lng != null) {
const hit = await this.maps.geocodeQuery(q);
if (hit) {
lat = hit.lat;
lng = hit.lng;
break;
+29
View File
@@ -695,6 +695,35 @@ export class MapsService {
return this.resolveMapsKey(userId).key;
}
/**
* A coordinate for a name that came out of an import, from whichever source
* has it.
*
* Booking imports geocode every venue and every uncoordinated endpoint in one
* request loop, up to thirty sequential lookups. Every one of those used to be
* a Nominatim call on the background lane, waiting out its throttle. The index
* answers most of them without leaving our own infrastructure and without a
* throttle at all; a street address it does not know still falls through to
* Nominatim, which is the source that resolves addresses.
*
* Never throws: an import that cannot place a hotel still imports the hotel.
*/
async geocodeQuery(query: string): Promise<{ lat: number; lng: number } | null> {
if (this.trekPlacesEnabled()) {
try {
const found = await trekPlacesSearch(query, { limit: 1 });
const hit = found[0];
if (hit && Number.isFinite(hit.lat) && Number.isFinite(hit.lng)) {
return { lat: hit.lat, lng: hit.lng };
}
} catch (err: unknown) {
console.warn('TREK Places geocode failed, falling back:', (err as Error).message);
}
}
const hit = (await this.searchNominatim(query, undefined, 'background'))[0];
return hit?.lat != null && hit?.lng != null ? { lat: hit.lat, lng: hit.lng } : null;
}
// ── Nominatim search ───────────────────────────────────────────────────────
/**
@@ -21,6 +21,7 @@ import {
type WikiIdentity,
} from '../maps/maps.service';
import { buildOsmDetails, isGooglePlaceId, parseWikipediaTag, rankCommonsCandidates, toWikiLang } from '../maps/maps.helpers';
import { trekPlacesById } from '../maps/trek-places.client';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
/**
@@ -570,6 +571,38 @@ export class PlaceEnrichmentService {
// ── Description ────────────────────────────────────────────────────────────
/**
* The description the place publishes on its own site, served from the TREK
* Places API rather than fetched here.
*
* TREK never opens the business's website itself: that would be one request
* per instance per place, against small servers, from hundreds of homelabs.
* The API fetches each page once, keeps the summary, and every instance reads
* it from there. Only places carrying a GERS id can be looked up, which is
* exactly the ones that came from the API in the first place.
*/
private async websiteDescription(placeId: string): Promise<PlaceDescription | null> {
if (!placeId.startsWith('gers:')) return null;
try {
const place = await trekPlacesById(placeId.slice(5));
const got = (place as { description?: { text?: string; sourceUrl?: string } } | null)?.description;
const text = typeof got?.text === 'string' ? got.text.trim() : '';
if (!text) return null;
return {
text,
source: 'website',
sourceUrl: typeof got?.sourceUrl === 'string' ? got.sourceUrl : null,
// Not a licensed corpus: a quoted summary from the operator's own page,
// credited and linked back. Saying "CC-something" here would be a claim
// about terms nobody granted.
license: null,
};
} catch {
// The API being down must never cost the place its other sources.
return null;
}
}
private async collectDescription(
userId: number,
placeId: string,
@@ -599,6 +632,19 @@ export class PlaceEnrichmentService {
return { text: extract.text, source: extract.source, sourceUrl: extract.sourceUrl, license: 'CC BY-SA 4.0' };
}
// Then the place's own website, through the TREK Places API.
//
// Placed here and not higher on purpose: an encyclopaedia article reached
// through the OSM `wikipedia` tag is about this exact place and was written
// by someone with no stake in it, which beats marketing copy. But for the
// ordinary case — a restaurant, a shop, a hotel, none of which will ever
// have an article — the operator's own summary is the only description that
// exists, and it is published in JSON-LD or og:description precisely so
// machines can read it. That covers roughly 43 percent of places, where the
// encyclopaedias cover a fraction of a percent.
const fromSite = await this.websiteDescription(placeId);
if (fromSite) return fromSite;
const apiKey = this.maps.getMapsKey(userId);
if (apiKey && !this.maps.detailsDisabled() && isGooglePlaceId(placeId)) {
const summary = await this.maps.fetchEditorialSummary(placeId, apiKey, req.lang);