mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
328b5fcb2b
Atlas throttled to the 1.1s OSM asks for and cached its answers in a 50k map; maps did neither, so five interactive routes fired straight at the service while the two atlas backfill loops politely waited their turn. From Nominatim's side that is one instance ignoring the rate limit. Both go through geo/nominatim.client.ts now. Three things the naive merge would have got wrong: - The shared client sends UA, the instance-identifying User-Agent, not the generic string atlas used. A shared generic UA is throttled harder. - One cursor across five interactive routes and two background loops would have put a keystroke behind however many places the backfill enqueued, so there are two lanes over the one cursor and background yields. - The cache key carries the query shape. Atlas asks at zoom 3 and 8, maps at 18; a coordinate-only key answers a street lookup with a country. The throttle cursor and the cache stay module state, the way the permissions and FX caches do, because the bridges build their collaborators outside the container and a second copy of the cursor is the bug being removed here. GeocodingService owns only the cleanup timer's lifecycle, which atlas used to start as an import-time side effect. Also folds the three haversine implementations into common/geo.ts, keeping the clamped form: asin of a value a hair over 1 is NaN, and a NaN distance fails every comparison it feeds instead of throwing. tests/setup.ts zeroes the interval. Nineteen maps cases drive these paths back to back with real timers and were sleeping nineteen seconds for it.
626 lines
26 KiB
TypeScript
626 lines
26 KiB
TypeScript
import { readEnv, getAppUrl } from '../../app-config';
|
||
|
||
/**
|
||
* Pure maps/geo helpers — no DB, no Nest, no side effects beyond reading env.
|
||
*
|
||
* Moved verbatim from the legacy services/mapsService.ts when the maps domain
|
||
* went DI-native. Kept as plain functions (files.constants.ts / client-ip.ts
|
||
* precedent) because they are consumed both by MapsService and by plain-module
|
||
* code outside the container (transitService's User-Agent, the unit suites'
|
||
* parser cases).
|
||
*/
|
||
|
||
// Overpass, Nominatim and Wikimedia all ask that requests carry a User-Agent that
|
||
// uniquely identifies the deploying instance — a shared, generic UA gets rate-limited
|
||
// and throttled harder (see #1309). When the instance URL is configured we append it;
|
||
// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it.
|
||
export function buildUserAgent(instanceUrl: string | undefined): string {
|
||
const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)';
|
||
if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`;
|
||
return base;
|
||
}
|
||
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
|
||
export const UA = buildUserAgent(getAppUrl());
|
||
|
||
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
|
||
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
|
||
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
|
||
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
|
||
// opening hours come back in the wrong language. Codes not listed here pass
|
||
// through unchanged (they are already valid), as do locale forms the client
|
||
// sometimes sends (e.g. 'pt-BR').
|
||
const API_LANG_OVERRIDES: Record<string, string> = {
|
||
br: 'pt-BR',
|
||
gr: 'el',
|
||
'el-GR': 'el',
|
||
};
|
||
export function toApiLang(lang: string | undefined, fallback = 'en'): string {
|
||
const code = (lang || '').trim();
|
||
if (!code) return fallback;
|
||
return API_LANG_OVERRIDES[code] ?? code;
|
||
}
|
||
|
||
/**
|
||
* A wiki subdomain out of a TREK language code.
|
||
*
|
||
* Not the same normalisation as `toApiLang`, and the difference bites: TREK
|
||
* calls Brazilian Portuguese `br`, but `br.wikipedia.org` exists and is the
|
||
* BRETON Wikipedia. Passing the raw code through would not fail — it would
|
||
* quietly return Breton articles. `pt-br.wikipedia.org` does not resolve at
|
||
* all, so the region subtag has to go as well.
|
||
*/
|
||
const WIKI_LANG_OVERRIDES: Record<string, string> = {
|
||
br: 'pt',
|
||
gr: 'el',
|
||
};
|
||
export function toWikiLang(lang: string | undefined, fallback = 'en'): string {
|
||
const raw = (lang || '').trim();
|
||
if (!raw) return fallback;
|
||
const mapped = WIKI_LANG_OVERRIDES[raw] ?? raw;
|
||
const base = mapped.split('-')[0].toLowerCase();
|
||
return /^[a-z]{2,3}$/.test(base) ? base : fallback;
|
||
}
|
||
|
||
// Re-exported, not redefined: this was one of three copies. Kept as an export
|
||
// here because callers and a test import it from maps.helpers.
|
||
export { haversineMetres } from '../common/geo';
|
||
|
||
/**
|
||
* Whether two place names plausibly refer to the same thing.
|
||
*
|
||
* The gate that keeps a coordinate-based lookup from confidently describing the
|
||
* wrong building. "Hamburg Airport" and "Flughafen Hamburg" pass on "hamburg";
|
||
* "Hamburg Airport" and "Bahnhof Ohlsdorf" do not.
|
||
*
|
||
* One shared word is enough only when it carries some weight — four letters or
|
||
* more. Otherwise two have to match. A single short word is almost always an
|
||
* article or a generic noun, and TREK speaks 23 languages, so "Der Kiosk" and
|
||
* "Der Bahnhof" would sail through a plain word-overlap test while a stopword
|
||
* list for all of them is its own maintenance problem. Anything the pair rule
|
||
* lets through has already survived the distance check.
|
||
*/
|
||
export function namesOverlap(a: string, b: string): boolean {
|
||
const words = (value: string): Set<string> =>
|
||
new Set(
|
||
value
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[̀-ͯ]/g, '')
|
||
.split(/[^a-z0-9]+/)
|
||
.filter((word) => word.length > 2),
|
||
);
|
||
const left = [...words(a)];
|
||
if (left.length === 0) return false;
|
||
|
||
/**
|
||
* Same word, allowing for an inflected ending.
|
||
*
|
||
* Providers localise names, and the two sides of this comparison rarely come
|
||
* from the same language: searching "Hamburg Airport" returns "Hamburger
|
||
* Flughafen Helmut Schmidt", and a plain equality test rejects its own
|
||
* correct answer over the "-er". A short prefix would over-match, so the
|
||
* shared stem has to carry weight and the endings have to be close.
|
||
*/
|
||
const sameStem = (x: string, y: string): boolean => {
|
||
if (x === y) return true;
|
||
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
|
||
return short.length >= 4 && long.length - short.length <= 3 && long.startsWith(short);
|
||
};
|
||
|
||
let shared = 0;
|
||
for (const word of words(b)) {
|
||
const match = left.find((candidate) => sameStem(candidate, word));
|
||
if (!match) continue;
|
||
if (Math.min(match.length, word.length) >= 4) return true;
|
||
if (++shared >= 2) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
|
||
|
||
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param.
|
||
// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN)
|
||
// with no ftid, so this returns null for most API responses — the precise query_place_id link is
|
||
// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link
|
||
// resolved by resolveGoogleMapsUrl or a Google MyMaps list import.
|
||
export function googleFtidFromMapsUrl(url?: string | null): string | null {
|
||
if (!url) return null;
|
||
try {
|
||
const ftid = new URL(url).searchParams.get('ftid')?.trim();
|
||
return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── Overpass POI categories ──────────────────────────────────────────────────
|
||
|
||
export interface OverpassPoi {
|
||
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
|
||
name: string;
|
||
lat: number;
|
||
lng: number;
|
||
category: string; // the requested pill category key, e.g. 'restaurant'
|
||
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
|
||
address: string | null;
|
||
website: string | null;
|
||
phone: string | null;
|
||
opening_hours: string | null;
|
||
cuisine: string | null;
|
||
source: 'openstreetmap';
|
||
}
|
||
|
||
// Each pill category → the OSM tag selectors it searches. Keys here are the
|
||
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
|
||
// client-side).
|
||
export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
|
||
cafe: ['amenity=cafe'],
|
||
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
|
||
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
|
||
sights: [
|
||
'tourism=attraction',
|
||
'tourism=viewpoint',
|
||
'historic=monument',
|
||
'historic=castle',
|
||
'historic=memorial',
|
||
'historic=ruins',
|
||
],
|
||
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
|
||
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
|
||
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
|
||
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
|
||
supermarket: ['shop=supermarket', 'shop=convenience'],
|
||
};
|
||
|
||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||
|
||
// Public Overpass mirrors, queried in PARALLEL (first valid response wins).
|
||
// Reachability and load vary a lot by network/region — the canonical instance is
|
||
// frequently overloaded (504s) and some community mirrors are unreachable from
|
||
// certain networks. Racing them means whichever mirror is fastest-reachable for
|
||
// this user answers, and an overloaded or blocked one never blocks the others.
|
||
const DEFAULT_OVERPASS_MIRRORS = [
|
||
'https://overpass-api.de/api/interpreter',
|
||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||
'https://overpass.kumi.systems/api/interpreter',
|
||
'https://overpass.private.coffee/api/interpreter',
|
||
];
|
||
|
||
// Operators behind locked-down egress — or running their own Overpass — can point TREK
|
||
// at one or more custom endpoints via OVERPASS_URL (comma-separated). When set it
|
||
// REPLACES the public mirrors, so a firewalled cluster never reaches out to them and a
|
||
// self-hosted instance is used exclusively (see #1309). Non-http(s) entries are dropped.
|
||
export function resolveOverpassEndpoints(raw: string | undefined = readEnv().integrations.overpassUrl): string[] {
|
||
const custom = (raw ?? '')
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter((s) => {
|
||
try {
|
||
const u = new URL(s);
|
||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS;
|
||
}
|
||
|
||
// Per-mirror fetch cap. Because mirrors race in parallel this is also the worst-case
|
||
// wait before every mirror is given up on and a 502 is returned. Public mirrors answer
|
||
// in 1–2s when reachable, so the cap mainly bounds dead/blocked ones; operators with a
|
||
// slow self-hosted endpoint can raise it via OVERPASS_TIMEOUT_MS. A non-positive or
|
||
// non-numeric value falls back to the default — a 0/negative cap would abort every
|
||
// request immediately and 502 the search.
|
||
export function resolveOverpassTimeoutMs(raw?: string): number {
|
||
if (raw === undefined) return readEnv().integrations.overpassTimeoutMs;
|
||
const n = Number(raw);
|
||
return Number.isFinite(n) && n > 0 ? n : 12000;
|
||
}
|
||
|
||
// ── Opening hours parsing ────────────────────────────────────────────────────
|
||
|
||
export interface GoogleTimePoint {
|
||
day?: number;
|
||
hour?: number;
|
||
minute?: number;
|
||
}
|
||
|
||
export interface GoogleOpeningHours {
|
||
weekdayDescriptions?: string[];
|
||
openNow?: boolean;
|
||
periods?: { open?: GoogleTimePoint; close?: GoogleTimePoint }[];
|
||
specialDays?: { date?: { year?: number; month?: number; day?: number } }[];
|
||
}
|
||
|
||
// Machine-readable opening hours, in Google's numbering: day 0 = Sunday … 6 = Saturday.
|
||
// The weekday descriptions next to them are display text in the requested language and
|
||
// cannot be parsed reliably; these can. A period without `close` is Google's way of
|
||
// saying the place never closes.
|
||
export interface OpeningTimePoint {
|
||
day: number;
|
||
hour: number;
|
||
minute: number;
|
||
}
|
||
export interface OpeningPeriod {
|
||
open: OpeningTimePoint;
|
||
close: OpeningTimePoint | null;
|
||
}
|
||
|
||
// Places (New) speaks proto3 JSON, which leaves out fields that hold the default
|
||
// value — a period starting Sunday midnight arrives as `{}`, not as three zeroes.
|
||
function toTimePoint(point: GoogleTimePoint | undefined): OpeningTimePoint | null {
|
||
if (!point || typeof point !== 'object') return null;
|
||
const field = (value: unknown): number | null => {
|
||
if (value === undefined || value === null) return 0;
|
||
return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : null;
|
||
};
|
||
const day = field(point.day);
|
||
const hour = field(point.hour);
|
||
const minute = field(point.minute);
|
||
if (day === null || hour === null || minute === null) return null;
|
||
if (day < 0 || day > 6 || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||
return { day, hour, minute };
|
||
}
|
||
|
||
export function normalizeOpeningPeriods(periods: GoogleOpeningHours['periods']): OpeningPeriod[] | null {
|
||
if (!Array.isArray(periods) || periods.length === 0) return null;
|
||
const result: OpeningPeriod[] = [];
|
||
for (const period of periods) {
|
||
const open = toTimePoint(period?.open);
|
||
if (!open) continue;
|
||
// A missing close means "never closes"; a close that is there but unusable makes
|
||
// the whole period a guess, so it is dropped rather than read as round-the-clock.
|
||
const close = period?.close == null ? null : toTimePoint(period.close);
|
||
if (period?.close != null && !close) continue;
|
||
result.push({ open, close });
|
||
}
|
||
return result.length > 0 ? result : null;
|
||
}
|
||
|
||
// Holidays and other exceptional days that the weekly periods do not describe. Google
|
||
// reports them as calendar dates; they travel to the client as YYYY-MM-DD so it can
|
||
// compare them against the place's own local date.
|
||
export function normalizeSpecialDays(specialDays: GoogleOpeningHours['specialDays']): string[] | null {
|
||
if (!Array.isArray(specialDays) || specialDays.length === 0) return null;
|
||
const dates: string[] = [];
|
||
for (const entry of specialDays) {
|
||
const date = entry?.date;
|
||
if (!date) continue;
|
||
const { year, month, day } = date;
|
||
if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') continue;
|
||
if (month < 1 || month > 12 || day < 1 || day > 31) continue;
|
||
const iso = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||
if (!dates.includes(iso)) dates.push(iso);
|
||
}
|
||
return dates.length > 0 ? dates : null;
|
||
}
|
||
|
||
// "09:00-18:00", also the "20:00-02:00" and "00:00-24:00" spellings OSM uses.
|
||
const OSM_TIME_RANGE = /(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g;
|
||
|
||
/** Periods for one OSM weekday line. `dayIdx` is Monday-based, the output is not. */
|
||
function osmPeriods(dayIdx: number, timePart: string): OpeningPeriod[] {
|
||
const day = (dayIdx + 1) % 7;
|
||
const periods: OpeningPeriod[] = [];
|
||
for (const match of timePart.matchAll(OSM_TIME_RANGE)) {
|
||
const openHour = parseInt(match[1], 10);
|
||
const openMinute = parseInt(match[2], 10);
|
||
let closeHour = parseInt(match[3], 10);
|
||
const closeMinute = parseInt(match[4], 10);
|
||
if (openHour > 23 || openMinute > 59 || closeHour > 24 || closeMinute > 59) continue;
|
||
// OSM writes the end of a day as 24:00, a clock reading Google's numbering has no
|
||
// hour 24 — that is midnight of the following day, same as any range that wraps.
|
||
let nextDay = closeHour * 60 + closeMinute <= openHour * 60 + openMinute;
|
||
if (closeHour === 24) {
|
||
if (closeMinute !== 0) continue;
|
||
closeHour = 0;
|
||
nextDay = true;
|
||
}
|
||
periods.push({
|
||
open: { day, hour: openHour, minute: openMinute },
|
||
close: { day: nextDay ? (day + 1) % 7 : day, hour: closeHour, minute: closeMinute },
|
||
});
|
||
}
|
||
return periods;
|
||
}
|
||
|
||
export function parseOpeningHours(ohString: string): {
|
||
weekdayDescriptions: string[];
|
||
openNow: boolean | null;
|
||
periods: OpeningPeriod[];
|
||
} {
|
||
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||
const result: string[] = LONG.map((d) => `${d}: ?`);
|
||
const periods: OpeningPeriod[] = [];
|
||
|
||
// "24/7" — and it is not an edge case. Every segment below needs a weekday
|
||
// prefix to match, so this fell straight through, all seven lines stayed "?",
|
||
// and `buildOsmDetails` then threw the whole thing away as unparseable. Which
|
||
// is why airports, main stations and petrol stations — the places most likely
|
||
// to be tagged this way — showed no opening hours at all.
|
||
if (/^\s*(24\/7|open)\s*$/i.test(ohString)) {
|
||
return {
|
||
weekdayDescriptions: LONG.map((d) => `${d}: 00:00-24:00`),
|
||
openNow: true,
|
||
// One period that never closes. `close: null` is how the client's
|
||
// open/closed logic already spells "does not close".
|
||
periods: [{ open: { day: 0, hour: 0, minute: 0 }, close: null }],
|
||
};
|
||
}
|
||
|
||
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
|
||
for (const segment of ohString.split(';')) {
|
||
const trimmed = segment.trim();
|
||
if (!trimmed) continue;
|
||
const match = trimmed.match(
|
||
/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i,
|
||
);
|
||
if (!match) continue;
|
||
const [, daysPart, timePart] = match;
|
||
const dayIndices = new Set<number>();
|
||
for (const range of daysPart.split(',')) {
|
||
const parts = range
|
||
.trim()
|
||
.split('-')
|
||
.map((d) => DAYS.indexOf(d.trim()));
|
||
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
|
||
// do/while, not while: a range that wraps all the way round — "Mo-Su",
|
||
// or "Tu-Mo", both of which mean every day — starts already satisfying
|
||
// the exit condition, so the loop never ran and only the closing day
|
||
// was added. "Mo-Su 11:30-23:00" is how a place open daily is usually
|
||
// tagged, and it produced exactly one day of hours.
|
||
let day = parts[0];
|
||
do {
|
||
dayIndices.add(day);
|
||
day = (day + 1) % 7;
|
||
} while (day !== (parts[1] + 1) % 7);
|
||
} else if (parts[0] >= 0) {
|
||
dayIndices.add(parts[0]);
|
||
}
|
||
}
|
||
for (const idx of dayIndices) {
|
||
result[idx] = `${LONG[idx]}: ${timePart.trim()}`;
|
||
periods.push(...osmPeriods(idx, timePart));
|
||
}
|
||
}
|
||
|
||
// Compute openNow
|
||
let openNow: boolean | null = null;
|
||
try {
|
||
const now = new Date();
|
||
const jsDay = now.getDay();
|
||
const dayIdx = jsDay === 0 ? 6 : jsDay - 1;
|
||
const todayLine = result[dayIdx];
|
||
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
|
||
if (timeRanges.length > 0) {
|
||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||
openNow = timeRanges.some((m) => {
|
||
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
|
||
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
|
||
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
|
||
});
|
||
}
|
||
} catch {
|
||
/* best effort */
|
||
}
|
||
|
||
return { weekdayDescriptions: result, openNow, periods };
|
||
}
|
||
|
||
// ── Build standardized OSM details ───────────────────────────────────────────
|
||
|
||
export function buildOsmDetails(tags: Record<string, string>, osmType: string, osmId: string) {
|
||
let opening_hours: string[] | null = null;
|
||
let open_now: boolean | null = null;
|
||
let opening_periods: OpeningPeriod[] | null = null;
|
||
if (tags.opening_hours) {
|
||
const parsed = parseOpeningHours(tags.opening_hours);
|
||
const hasData = parsed.weekdayDescriptions.some((line) => !line.endsWith('?'));
|
||
if (hasData) {
|
||
opening_hours = parsed.weekdayDescriptions;
|
||
open_now = parsed.openNow;
|
||
opening_periods = parsed.periods.length > 0 ? parsed.periods : null;
|
||
}
|
||
}
|
||
return {
|
||
website: tags['contact:website'] || tags.website || null,
|
||
phone: tags['contact:phone'] || tags.phone || null,
|
||
opening_hours,
|
||
open_now,
|
||
opening_periods,
|
||
osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`,
|
||
summary: tags.description || null,
|
||
// Kept so enrichment can resolve the right Wikipedia article instead of
|
||
// guessing one from the place name, which picks the wrong article whenever
|
||
// the name is ambiguous ("Bahnhofstraße", "Rathaus", any chain restaurant).
|
||
wikipedia: tags.wikipedia || null,
|
||
wikidata: tags.wikidata || null,
|
||
// A Commons category is pictures OF this place, where a coordinate search
|
||
// only finds whatever was photographed near it.
|
||
wikimedia_commons: tags.wikimedia_commons || null,
|
||
// The tags that make the difference for a restaurant or a shop: no
|
||
// encyclopaedia will ever describe one, but its cuisine, its hours and a
|
||
// link to its menu are usually right here.
|
||
cuisine: tags.cuisine || null,
|
||
menu_url: tags['website:menu'] || tags.menu || null,
|
||
outdoor_seating: tags.outdoor_seating || null,
|
||
takeaway: tags.takeaway || null,
|
||
delivery: tags.delivery || null,
|
||
wheelchair: tags.wheelchair || null,
|
||
diet_vegetarian: tags['diet:vegetarian'] || null,
|
||
diet_vegan: tags['diet:vegan'] || null,
|
||
internet_access: tags.internet_access || null,
|
||
source: 'openstreetmap' as const,
|
||
};
|
||
}
|
||
|
||
// ── Wiki metadata ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Commons extmetadata values arrive as HTML fragments — an author is typically
|
||
* an <a> to the uploader's user page, a licence can carry <span> wrappers. We
|
||
* show these as plain text next to a thumbnail, so the markup has to go.
|
||
*/
|
||
export function stripWikiMarkup(value: string | undefined | null): string | null {
|
||
if (!value) return null;
|
||
const text = value
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replace(/ /g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
return text || null;
|
||
}
|
||
|
||
/**
|
||
* Splits an OSM `wikipedia` tag ("de:Museum Ludwig") into host language and
|
||
* article title. Returns null for the bare-title spelling, which has no language
|
||
* and would send us to the wrong wiki.
|
||
*/
|
||
export function parseWikipediaTag(tag: string | undefined | null): { lang: string; title: string } | null {
|
||
if (!tag) return null;
|
||
const match = /^([a-z-]{2,12}):(.+)$/i.exec(tag.trim());
|
||
if (!match) return null;
|
||
const title = match[2].trim();
|
||
return title ? { lang: match[1].toLowerCase(), title } : null;
|
||
}
|
||
|
||
// ── Place-id classification ──────────────────────────────────────────────────
|
||
|
||
// Ids that can never resolve against the Google Places API: coordinate pseudo-ids
|
||
// (right-click places, in both the coords: and the bare "lat,lng" spelling the
|
||
// collection views send), OSM ids the client sends when a place has no
|
||
// google_place_id, the raw photo URLs legacy rows keep in image_url, and photo
|
||
// cache keys of the form "<placeId>~p3" that enrichment mints for the picker.
|
||
// Google answers those with a billable 400 INVALID_ARGUMENT, so every lookup
|
||
// sorts them out before the call and uses the OSM/Wikimedia path instead.
|
||
const NON_GOOGLE_PLACE_ID =
|
||
/^(?:coords|node|way|relation):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$|~p\d+$/i;
|
||
// The subset that still has a provider behind it — Overpass for details,
|
||
// Wikimedia for photos.
|
||
export const OSM_PLACE_ID = /^(?:node|way|relation):/i;
|
||
|
||
export function isGooglePlaceId(placeId: string): boolean {
|
||
return !NON_GOOGLE_PLACE_ID.test(placeId);
|
||
}
|
||
|
||
// ── Ranking Commons candidates ───────────────────────────────────────────────
|
||
|
||
/**
|
||
* A candidate as the ranker needs to see it. Structurally the subset of
|
||
* `CommonsCandidate` it reads, kept local so the pure function does not have to
|
||
* import from the service that imports this file.
|
||
*/
|
||
export interface RankableCommonsCandidate {
|
||
pageId: number | null;
|
||
title: string | null;
|
||
attribution: string | null;
|
||
width: number | null;
|
||
height: number | null;
|
||
descriptors: string | null;
|
||
photoUrl: string;
|
||
}
|
||
|
||
/**
|
||
* Commons files that are near a place but are not a picture of it.
|
||
*
|
||
* Orthophotos are the worst offender by volume: German states upload their
|
||
* aerial survey tiles to Commons with coordinates, so a geosearch around any
|
||
* airport returns the runway as seen from a plane at 3000m — four of them for
|
||
* Hamburg, one per survey year. The rest are documents that happen to live in a
|
||
* place's category: noise maps, floor plans, terminal layouts, logos, coats of
|
||
* arms.
|
||
*/
|
||
const NOT_A_PHOTO_OF_THE_PLACE =
|
||
/orthophoto|orthofoto|sommerbefliegung|luftbildkarte|dop\d+|l[äa]rmkarte|noise map|floor ?plan|grundriss|lageplan|layout|diagram|schematic|blueprint|coat of arms|wappen|\blogo\b|flag of/i;
|
||
|
||
/**
|
||
* Strips what makes two frames of the same burst look like different files, so
|
||
* they collapse onto one stem: trailing counters, camera dumps, dates, and the
|
||
* `- 17` / `(2)` suffixes press sets use.
|
||
*/
|
||
function seriesStem(title: string): string {
|
||
return title
|
||
.replace(/^File:/i, '')
|
||
.replace(/\.[a-z0-9]+$/i, '')
|
||
.toLowerCase()
|
||
// 20260614 100717648 HDR — a camera dump, all from the same minute
|
||
.replace(/\b\d{8}[ _-]\d{6,9}\b/g, ' ')
|
||
.replace(/\b(19|20)\d{2}\b/g, ' ')
|
||
.replace(/[ _-]+\(?\d{1,4}\)?$/g, '')
|
||
.replace(/[^a-z]+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* Picks the pictures worth showing out of whatever the providers returned.
|
||
*
|
||
* Everything here was written against a real failing case. Deduplication is by
|
||
* page id because the same file reaches us under different thumbnail URLs. The
|
||
* series rule exists because Commons categories are full of bursts — the four
|
||
* `Hauptbahnhof Berlin interior 0807..0810` shots that filled the picker were
|
||
* one person walking across a concourse. The author cap is the same problem
|
||
* seen from the other side: one contributor who documented a station thoroughly
|
||
* should not supply the whole strip.
|
||
*
|
||
* `perAuthor` is the one knob: coordinate search gets 1, because there being no
|
||
* evidence the pictures are even of the right subject makes variety the only
|
||
* defence. Curated sources get 2 — someone already vouched that these depict
|
||
* the place.
|
||
*/
|
||
export function rankCommonsCandidates<T extends RankableCommonsCandidate>(
|
||
candidates: T[],
|
||
limit: number,
|
||
opts: { perAuthor?: number } = {},
|
||
): T[] {
|
||
const perAuthor = opts.perAuthor ?? 2;
|
||
const seenPages = new Set<number>();
|
||
const seenUrls = new Set<string>();
|
||
const seenStems = new Set<string>();
|
||
const authorCounts = new Map<string, number>();
|
||
const out: T[] = [];
|
||
|
||
for (const candidate of candidates) {
|
||
if (out.length >= limit) break;
|
||
|
||
if (candidate.pageId != null) {
|
||
if (seenPages.has(candidate.pageId)) continue;
|
||
} else if (seenUrls.has(candidate.photoUrl)) {
|
||
// No page id (an older payload, or a provider that does not report one):
|
||
// the URL is a weaker key but better than letting an exact repeat through.
|
||
continue;
|
||
}
|
||
|
||
const haystack = `${candidate.title ?? ''} ${candidate.descriptors ?? ''}`;
|
||
if (NOT_A_PHOTO_OF_THE_PLACE.test(haystack)) continue;
|
||
|
||
const { width, height } = candidate;
|
||
if (width && height) {
|
||
// Survey tiles are square and enormous; nothing photographed by hand is.
|
||
if (width === height && width >= 3000) continue;
|
||
const ratio = Math.max(width / height, height / width);
|
||
// Panorama strips and tall banners crop to nothing in a square tile.
|
||
if (ratio > 4) continue;
|
||
}
|
||
|
||
const stem = candidate.title ? seriesStem(candidate.title) : '';
|
||
if (stem && seenStems.has(stem)) continue;
|
||
|
||
const author = (candidate.attribution ?? '').trim().toLowerCase();
|
||
if (author) {
|
||
const used = authorCounts.get(author) ?? 0;
|
||
if (used >= perAuthor) continue;
|
||
authorCounts.set(author, used + 1);
|
||
}
|
||
|
||
if (candidate.pageId != null) seenPages.add(candidate.pageId);
|
||
seenUrls.add(candidate.photoUrl);
|
||
if (stem) seenStems.add(stem);
|
||
out.push(candidate);
|
||
}
|
||
|
||
return out;
|
||
}
|