Fill the gaps Google leaves from the free sources

This commit is contained in:
Maurice
2026-08-10 11:33:08 +02:00
committed by Maurice
parent 0cd67675c5
commit ad6640cdb2
4 changed files with 120 additions and 7 deletions
+10 -2
View File
@@ -373,8 +373,16 @@ export function parseOpeningHours(ohString: string): {
.split('-')
.map((d) => DAYS.indexOf(d.trim()));
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
dayIndices.add(parts[1]);
// 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]);
}
@@ -213,6 +213,19 @@ export function collectHours(details: Record<string, unknown> | null): PlaceHour
};
}
/**
* Facts from the maps provider, topped up from OpenStreetMap.
*
* Provider first where both know a thing: it is the source the user searched
* and the one more likely to be current for a business. OSM contributes
* everything the provider has no concept of, which for Google is most of this
* list — cuisine, wheelchair access, outdoor seating, the menu link.
*/
export function mergeFacts(primary: PlaceFact[], extra: PlaceFact[]): PlaceFact[] {
const seen = new Set(primary.map((fact) => fact.kind));
return [...primary, ...extra.filter((fact) => !seen.has(fact.kind))];
}
/** The provider's star rating, with its count when there is one. */
export function collectRating(details: Record<string, unknown> | null): PlaceRating | null {
const value = typeof details?.rating === 'number' ? details.rating : null;
@@ -281,12 +294,18 @@ export class PlaceEnrichmentService {
// no transport deadline at all.
const osmDetails = identity.osmTags ? buildOsmDetails(identity.osmTags, '', '') : null;
// Where Google is silent, the free sources fill in. Google's payload has no
// cuisine, no wheelchair access, no outdoor seating and no menu link — it
// never had — so a Google place used to show a rating and nothing else,
// while the same building in OpenStreetMap carried all of it. Each field is
// taken from whichever source actually has it rather than from one source
// for everything.
const result: CachedEnrichment = {
photos,
description,
facts: collectFacts(details),
facts: mergeFacts(collectFacts(details), collectFacts(osmDetails)),
hours: collectHours(details) ?? collectHours(osmDetails),
rating: collectRating(details),
rating: collectRating(details) ?? collectRating(osmDetails),
};
this.writeCache(placeId, lang, result);
return result;
@@ -188,6 +188,24 @@ describe('parseOpeningHours', () => {
expect(result.openNow).toBeNull();
});
it('MAPS-005b: a weekday range that wraps the whole week covers all seven days', () => {
// "Mo-Su" is how a place open daily is usually tagged, and it produced
// exactly ONE day: the loop's exit condition was already true on entry, so
// the body never ran and only the closing day was added.
const daily = parseOpeningHours('Mo-Su 11:30-23:00');
expect(daily.weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(7);
expect(daily.weekdayDescriptions[0]).toBe('Monday: 11:30-23:00');
expect(daily.weekdayDescriptions[6]).toBe('Sunday: 11:30-23:00');
// Same shape starting anywhere else in the week.
expect(parseOpeningHours('Tu-Mo 08:00-20:00').weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(7);
// Ranges that do not wrap are unchanged.
expect(parseOpeningHours('Mo-Fr 09:00-18:00').weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(5);
expect(parseOpeningHours('Sa-Su 10:00-14:00').weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(2);
expect(parseOpeningHours('Mo 09:00-12:00').weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(1);
});
it('MAPS-006: handles comma-separated days', () => {
const result = parseOpeningHours('Mo,We,Fr 08:00-17:00');
expect(result.weekdayDescriptions[0]).toContain('Monday: 08:00-17:00');
@@ -115,7 +115,7 @@ describe('identity resolution', () => {
expect(maps.resolveOsmIdentity).not.toHaveBeenCalled();
});
it('ENRICH-072: keeps OSM tags out of a Google payload', async () => {
it('ENRICH-072: never writes OSM tags into a Google payload, but still shows them', async () => {
// collectFacts and the OSM branch of collectDescription both gate on
// details.source. Merging the looked-up tags in would either be dead weight
// or would make a Google place claim to be an OpenStreetMap one.
@@ -132,9 +132,15 @@ describe('identity resolution', () => {
const out = await make(maps).enrich(1, GOOGLE_REQ);
// The point is that the provider's own record is never written into. Its
// `source` is what collectFacts and the OSM branch of collectDescription
// gate on, so a Google payload carrying OSM tags would either ignore them
// or start claiming to be an OpenStreetMap place.
expect(details).toEqual({ source: 'google', name: 'Hamburg Airport', summary: null });
// Cuisine is an OSM fact and this is a Google place, so it stays out.
expect(out.facts.some((f) => f.kind === 'cuisine')).toBe(false);
// The tags still reach the reader — through the second object, not by
// mutating the first. Google has no concept of cuisine, so without this a
// Google place showed a rating and nothing else.
expect(out.facts.some((f) => f.kind === 'cuisine')).toBe(true);
});
});
@@ -268,3 +274,65 @@ describe('article language choice', () => {
expect(out.description).toMatchObject({ source: 'wikipedia' });
});
});
/**
* ENRICH-097..100 — topping a Google place up from OpenStreetMap.
*
* Google's payload has no cuisine, no wheelchair access, no outdoor seating and
* no menu link. It never had. So a Google place showed a rating and nothing
* else, while the same building in OSM carried all of it — and the lookup that
* finds its wiki tags brings those along at no extra cost.
*/
describe('filling Google gaps from the free sources', () => {
const OSM_TAGS = {
wikidata: 'Q1',
cuisine: 'pizza;italian',
wheelchair: 'limited',
outdoor_seating: 'yes',
'opening_hours': 'Mo-Su 11:30-23:00',
};
const googlePlaceAlsoInOsm = (over: Partial<Record<keyof MapsService, unknown>> = {}) =>
mapsStub({
getMapsKey: vi.fn(() => 'key'),
details: vi.fn(async () => ({ place: { source: 'google', rating: 4.6, rating_count: 4495 } })),
resolveOsmIdentity: vi.fn(async () => ({ tags: OSM_TAGS, osmUrl: null, matchedName: "L'Osteria" })),
...over,
});
it('ENRICH-097: shows the OSM facts a Google place has no concept of', async () => {
const out = await make(googlePlaceAlsoInOsm()).enrich(1, GOOGLE_REQ);
const kinds = out.facts.map((f) => f.kind);
expect(kinds).toContain('cuisine');
expect(kinds).toContain('wheelchair');
expect(kinds).toContain('outdoorSeating');
// Semicolons and underscores are OSM syntax, not something to show a reader.
expect(out.facts.find((f) => f.kind === 'cuisine')?.value).toBe('pizza, italian');
});
it('ENRICH-098: keeps the rating Google reported', async () => {
const out = await make(googlePlaceAlsoInOsm()).enrich(1, GOOGLE_REQ);
expect(out.rating).toEqual({ value: 4.6, count: 4495 });
});
it('ENRICH-099: takes opening hours from OSM when Google reported none', async () => {
const out = await make(googlePlaceAlsoInOsm()).enrich(1, GOOGLE_REQ);
expect(out.hours?.weekdayDescriptions[0]).toContain('11:30-23:00');
});
it('ENRICH-100: the provider wins where both know the same thing', async () => {
// Google's hours are the ones the user searched against and the ones more
// likely to be current for a business.
const maps = googlePlaceAlsoInOsm({
details: vi.fn(async () => ({
place: { source: 'google', opening_hours: ['Monday: 09:00-17:00'], rating: 4.6, rating_count: 10 },
})),
});
const out = await make(maps).enrich(1, GOOGLE_REQ);
expect(out.hours?.weekdayDescriptions).toEqual(['Monday: 09:00-17:00']);
});
})