mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
Get the pictures right and stop reusing stale answers
Three things you could see on dev1. Pictures were "roughly right" rather than right, because a coordinate search only knows what was photographed within 300 m. Wikidata's P18 is one picture somebody chose to represent that exact object - "Zurletzteninstanz.jpg" for the restaurant, not a statue down the road - so it now leads, then the Commons category, and geosearch is the last resort it should always have been. Small venues carry a wikidata tag far more often than a Commons category, so this is also what reaches the places the other sources miss. Descriptions stayed on Wikipedia after the switch to Wikivoyage because the week-long result cache predates it. It carries a format version now; a release that changes the shape or the sources invalidates its own entries instead of serving last week's for another six days. Ratings and opening hours were being fetched from Google and then dropped on the floor - the fact list only looked at OpenStreetMap tags. Both are shown now, whichever provider supplied them. Also: the column follows a place opened from the map, not only one picked from search. A POI tap arrives as prefillCoords and never touched the selection, so the column kept showing the previously added place. Pictures are a tight three-column grid and only the picture in play is credited in full, on one line. Crediting all of them at two lines each drowned the column, and the licence attaches to the one that gets used - whose credit also stays visible in the inspector after saving. The request carries the place record the dialog already fetched, so the server skips a details lookup it was making in parallel with the client's own. That lookup was measured at 12.8 s for a large OSM relation.
This commit is contained in:
@@ -729,6 +729,51 @@ export class MapsService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one picture Wikidata records for a place (property P18).
|
||||
*
|
||||
* By far the most accurate source there is: someone chose this image to
|
||||
* represent this exact object, where a coordinate search only knows what was
|
||||
* photographed nearby. Restaurants and small venues carry a `wikidata` tag far
|
||||
* more often than a Commons category, so this is also the source that reaches
|
||||
* the places the others miss.
|
||||
*/
|
||||
async fetchWikidataImage(wikidataId: string): Promise<CommonsCandidate | null> {
|
||||
if (!/^Q\d+$/.test(wikidataId.trim())) return null;
|
||||
try {
|
||||
const claims = await fetch(
|
||||
`https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${wikidataId.trim()}&property=P18&format=json`,
|
||||
{ headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) },
|
||||
);
|
||||
if (!claims.ok) return null;
|
||||
const data = (await claims.json()) as {
|
||||
claims?: { P18?: { mainsnak?: { datavalue?: { value?: string } } }[] };
|
||||
};
|
||||
const fileName = data.claims?.P18?.[0]?.mainsnak?.datavalue?.value;
|
||||
if (!fileName) return null;
|
||||
|
||||
// Second hop for the URL and, more importantly, the licence — the claim
|
||||
// itself carries only a file name.
|
||||
const params = new URLSearchParams({
|
||||
action: 'query',
|
||||
format: 'json',
|
||||
titles: `File:${fileName}`,
|
||||
prop: 'imageinfo',
|
||||
iiprop: 'url|extmetadata|mime',
|
||||
iiurlwidth: '400',
|
||||
});
|
||||
const file = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
|
||||
});
|
||||
if (!file.ok) return null;
|
||||
const fileData = (await file.json()) as { query?: { pages?: Record<string, WikiCommonsPage> } };
|
||||
return this.toCommonsCandidates(fileData.query?.pages, 1)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commons images from a category, which is the set of pictures OF a place.
|
||||
*
|
||||
|
||||
@@ -31,6 +31,13 @@ const GOOGLE_CAP = 3;
|
||||
*/
|
||||
const CACHE_KIND = 2;
|
||||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
* Bumped whenever the shape or the sources change. Without it a release that
|
||||
* adds a field or swaps a provider keeps serving the previous week's answers
|
||||
* from before the change — which is exactly what happened when the facts and
|
||||
* Wikivoyage landed.
|
||||
*/
|
||||
const CACHE_VERSION = 2;
|
||||
|
||||
/** Cache key for one candidate picture. Deliberately not a bare place id. */
|
||||
function candidateKey(placeId: string, index: number): string {
|
||||
@@ -56,6 +63,10 @@ interface CachedEnrichment {
|
||||
facts: PlaceFact[];
|
||||
}
|
||||
|
||||
interface CachePayload extends CachedEnrichment {
|
||||
v?: number;
|
||||
}
|
||||
|
||||
/** OSM yes/no tags; anything else (limited, only, designated) is shown verbatim. */
|
||||
function yesNo(value: unknown): 'yes' | 'no' | string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null;
|
||||
@@ -73,19 +84,30 @@ function yesNo(value: unknown): 'yes' | 'no' | string | null {
|
||||
* and a deliberate `no` are indistinguishable to a reader either way.
|
||||
*/
|
||||
export function collectFacts(details: Record<string, unknown> | null): PlaceFact[] {
|
||||
if (!details || details.source !== 'openstreetmap') return [];
|
||||
if (!details) return [];
|
||||
|
||||
const facts: PlaceFact[] = [];
|
||||
const push = (kind: PlaceFact['kind'], value: string | null, url: string | null = null) =>
|
||||
facts.push({ kind, value, url });
|
||||
|
||||
const cuisine = typeof details.cuisine === 'string' ? details.cuisine : null;
|
||||
// OSM writes several cuisines semicolon-separated and underscored.
|
||||
if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', '));
|
||||
// Google supplies a rating and hours as well; both come back from the same
|
||||
// details lookup and were simply going unused.
|
||||
const rating = typeof details.rating === 'number' ? details.rating : null;
|
||||
if (rating) {
|
||||
const count = typeof details.rating_count === 'number' ? details.rating_count : null;
|
||||
push('rating', count ? `${rating} (${count})` : String(rating));
|
||||
}
|
||||
|
||||
const hours = Array.isArray(details.opening_hours) ? (details.opening_hours as string[]) : null;
|
||||
if (hours?.length) push('openingHours', hours.join(' · '));
|
||||
|
||||
// Everything below is OpenStreetMap tagging and has no Google equivalent.
|
||||
if (details.source !== 'openstreetmap') return facts;
|
||||
|
||||
const cuisine = typeof details.cuisine === 'string' ? details.cuisine : null;
|
||||
// OSM writes several cuisines semicolon-separated and underscored.
|
||||
if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', '));
|
||||
|
||||
const menu = typeof details.menu_url === 'string' ? details.menu_url : null;
|
||||
if (menu) push('menu', null, menu);
|
||||
|
||||
@@ -148,7 +170,10 @@ export class PlaceEnrichmentService {
|
||||
// category, the description needs its wiki tag, and the facts are its tags.
|
||||
// It is row-cached and the dialog just made the same call, so in practice
|
||||
// this is a cache read.
|
||||
const details = await this.readDetails(userId, placeId, lang);
|
||||
// The dialog already fetched this when the user picked the result, so it
|
||||
// comes along with the request. Refetching cost 12.8 seconds on a large
|
||||
// OSM relation, and it ran in parallel with the client's own lookup.
|
||||
const details = req.details ?? (await this.readDetails(userId, placeId, lang));
|
||||
|
||||
const [photos, description] = await Promise.all([
|
||||
this.collectPhotos(userId, placeId, req, details),
|
||||
@@ -184,12 +209,16 @@ export class PlaceEnrichmentService {
|
||||
|
||||
// Ask both providers at once — one listing call each, and they know nothing
|
||||
// about one another.
|
||||
// A Commons category is pictures OF the place; the coordinate search is
|
||||
// pictures NEAR it, which around a city centre means statues and passers-by.
|
||||
// Prefer the category whenever the place carries one.
|
||||
// Accuracy, best first. Wikidata's P18 is one picture somebody chose to
|
||||
// represent this exact object. A Commons category is pictures of it. The
|
||||
// coordinate search is only pictures NEAR it, which around a city centre
|
||||
// means statues and passers-by — it is the last resort, not the plan.
|
||||
const category = typeof details?.wikimedia_commons === 'string' ? details.wikimedia_commons : null;
|
||||
const [googleRefs, commons] = await Promise.all([
|
||||
const wikidata = typeof details?.wikidata === 'string' ? details.wikidata : null;
|
||||
|
||||
const [googleRefs, wikidataImage, commons] = await Promise.all([
|
||||
wantsGoogle ? this.maps.fetchGooglePhotoRefs(placeId, apiKey!, GOOGLE_CAP) : Promise.resolve([]),
|
||||
wikidata ? this.maps.fetchWikidataImage(wikidata) : Promise.resolve(null),
|
||||
category
|
||||
? this.maps
|
||||
.fetchCommonsCategoryCandidates(category, COMMONS_CAP)
|
||||
@@ -197,6 +226,12 @@ export class PlaceEnrichmentService {
|
||||
: this.maps.fetchCommonsCandidates(req.lat, req.lng, COMMONS_CAP),
|
||||
]);
|
||||
|
||||
// The Wikidata picture leads, and is dropped from the rest of the list when
|
||||
// a category happens to contain it too.
|
||||
const commonsList = wikidataImage
|
||||
? [wikidataImage, ...commons.filter((c) => c.photoUrl !== wikidataImage.photoUrl)].slice(0, COMMONS_CAP)
|
||||
: commons;
|
||||
|
||||
// Google first — its pictures are of the place itself, while Commons
|
||||
// geosearch finds whatever happens to have been photographed nearby.
|
||||
const pending: PlacePhotoCandidate[] = [
|
||||
@@ -211,7 +246,7 @@ export class PlaceEnrichmentService {
|
||||
sourceUrl: null,
|
||||
source: 'google' as const,
|
||||
})),
|
||||
...commons.map((candidate) => ({
|
||||
...commonsList.map((candidate) => ({
|
||||
key: '',
|
||||
url: '',
|
||||
attribution: candidate.attribution,
|
||||
@@ -232,7 +267,7 @@ export class PlaceEnrichmentService {
|
||||
const fetchBytes =
|
||||
candidate.source === 'google'
|
||||
? () => this.maps.fetchGooglePhotoBytes(googleRefs[index].name, apiKey!)
|
||||
: () => this.fetchRemoteBytes(commons[index - googleRefs.length].photoUrl);
|
||||
: () => this.fetchRemoteBytes(commonsList[index - googleRefs.length].photoUrl);
|
||||
const url = await this.storeCandidate(key, creditLine(candidate.attribution, candidate.license), fetchBytes);
|
||||
// The index is fixed before any download, so a picture that fails to
|
||||
// load leaves a gap rather than renumbering the ones after it — the key
|
||||
@@ -361,7 +396,8 @@ export class PlaceEnrichmentService {
|
||||
CACHE_KIND,
|
||||
);
|
||||
if (!row || Date.now() - row.fetched_at >= CACHE_TTL_MS) return null;
|
||||
const parsed = JSON.parse(row.payload_json) as CachedEnrichment;
|
||||
const parsed = JSON.parse(row.payload_json) as CachePayload;
|
||||
if (parsed.v !== CACHE_VERSION) return null;
|
||||
|
||||
// A cached candidate is only usable while its bytes are still on disk, and
|
||||
// the nightly sweep deletes every picture nobody picked — which is most of
|
||||
@@ -383,7 +419,7 @@ export class PlaceEnrichmentService {
|
||||
placeId,
|
||||
lang ?? '',
|
||||
CACHE_KIND,
|
||||
JSON.stringify(value),
|
||||
JSON.stringify({ ...value, v: CACHE_VERSION } satisfies CachePayload),
|
||||
Date.now(),
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user