feat: Google-Rezensionen ueber Places API (New) abrufen

Muster wie Wabenhain (Feldmaske, 1 h Cache, 5 s Timeout, Schluessel im
Header). Ohne Konfiguration oder bei Fehler null, keine Ersatz-Stimmen.
Keine Profilbilder (kein IP-Transfer der Besucherinnen an Google),
Quellenlinks nur als https. Neue Variablen GOOGLE_PLACES_API_KEY und
GOOGLE_PLACE_ID in .env.example und Secret-Vorlage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 17:30:30 +02:00
1 parent 66247cfbf9
commit b4eafbe433
3 files changed
+112

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
/**
* Echte Google-Rezensionen ueber die "Places API (New)" (gleiches Muster wie
* im Wabenhain-Projekt).
*
* - Ohne GOOGLE_PLACES_API_KEY/GOOGLE_PLACE_ID oder bei Fehlern: null. Die
* Seite blendet den Abschnitt dann aus — es gibt bewusst KEINE
* Beispiel-Stimmen als Ersatz (keine erfundenen Testimonials).
* - Profilbilder werden nicht geladen: sie kaemen direkt von Google-Servern
* und wuerden die IP-Adressen der Besucherinnen dorthin uebertragen.
* - Zuordnung zur Quelle (Nutzungsbedingungen): Autorenname, Link zur
* Rezension und zum Google-Maps-Eintrag.
*
* Einrichtung: in der Google Cloud Console "Places API (New)" aktivieren,
* Abrechnung aktivieren, API-Schluessel erzeugen (kein OAuth-Client) und
* GOOGLE_PLACES_API_KEY sowie GOOGLE_PLACE_ID setzen.
*/
export type GoogleReview = {
author: string;
rating: number;
text: string;
relativeTime: string;
authorUrl: string | null;
};
export type GoogleReviewSummary = {
reviews: GoogleReview[];
rating: number | null;
totalCount: number | null;
mapsUrl: string | null;
};
const TIMEOUT_MS = 5000;
// Abgerechnet wird nach angeforderten Feldern — nur das Noetige anfordern.
const FIELD_MASK = "rating,userRatingCount,reviews,googleMapsUri";
type PlacesResponse = {
rating?: number;
userRatingCount?: number;
googleMapsUri?: string;
reviews?: {
rating?: number;
relativePublishTimeDescription?: string;
text?: { text?: string };
originalText?: { text?: string };
authorAttribution?: { displayName?: string; uri?: string };
}[];
error?: { message?: string };
};
function safeHttpsUrl(value: string | undefined): string | null {
if (!value) return null;
try {
const url = new URL(value);
return url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
}
export async function fetchGoogleReviews(): Promise<GoogleReviewSummary | null> {
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
const placeId = process.env.GOOGLE_PLACE_ID;
if (!apiKey || !placeId) return null;
const resource = placeId.startsWith("places/") ? placeId : `places/${placeId}`;
const url = new URL(`https://places.googleapis.com/v1/${encodeURI(resource)}`);
url.searchParams.set("languageCode", "de");
try {
const response = await fetch(url, {
// Schluessel im Header, nicht in der URL (keine fremden Zugriffsprotokolle).
headers: { "X-Goog-Api-Key": apiKey, "X-Goog-FieldMask": FIELD_MASK },
// 1 Stunde Cache: schnell und guenstig, ohne spuerbar zu veralten.
next: { revalidate: 3600 },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const data = (await response.json().catch(() => null)) as PlacesResponse | null;
if (!response.ok || !data || data.error) {
console.warn(`[google-reviews] Abruf abgelehnt: ${data?.error?.message ?? `HTTP ${response.status}`}`);
return null;
}
const reviews = (data.reviews ?? [])
.map((r) => ({
author: r.authorAttribution?.displayName?.trim() ?? "",
rating: Math.max(0, Math.min(5, Math.round(r.rating ?? 0))),
text: (r.text?.text ?? r.originalText?.text ?? "").trim(),
relativeTime: r.relativePublishTimeDescription ?? "",
authorUrl: safeHttpsUrl(r.authorAttribution?.uri),
}))
// Ohne Text keine Stimme, ohne Namen keine Zuordnung zur Quelle.
.filter((r) => r.text.length > 0 && r.author.length > 0);
if (reviews.length === 0) return null;
return {
reviews,
rating: typeof data.rating === "number" ? data.rating : null,
totalCount: typeof data.userRatingCount === "number" ? data.userRatingCount : null,
mapsUrl: safeHttpsUrl(data.googleMapsUri),
};
} catch (error: unknown) {
console.warn(`[google-reviews] Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "unbekannt"}`);
return null;
}
}