mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user