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

+6
View File
@@ -53,3 +53,9 @@ MEDIA_DIR=./media
# Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo # Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo
# Externe Player laden nur nach Klick und ausdruecklicher Zustimmung. # Externe Player laden nur nach Klick und ausdruecklicher Zustimmung.
EXTERNAL_VIDEO_PROVIDER=none EXTERNAL_VIDEO_PROVIDER=none
# --- Google-Rezensionen (optional) ------------------------------------------
# "Places API (New)" aktivieren, Abrechnung an, API-Schluessel (kein OAuth).
# Ohne Werte wird der Rezensionen-Abschnitt ausgeblendet.
GOOGLE_PLACES_API_KEY=
GOOGLE_PLACE_ID=
+3
View File
@@ -16,6 +16,9 @@ stringData:
SMTP_HOST: "smtp.example.org" SMTP_HOST: "smtp.example.org"
SMTP_USER: "benutzer" SMTP_USER: "benutzer"
SMTP_PASSWORD: "passwort" SMTP_PASSWORD: "passwort"
# Optional: Google-Rezensionen (Places API New). Leer lassen = Abschnitt aus.
GOOGLE_PLACES_API_KEY: ""
GOOGLE_PLACE_ID: ""
--- ---
# Zugang zur SMB-Freigabe (fuer 03-storage.yaml) # Zugang zur SMB-Freigabe (fuer 03-storage.yaml)
apiVersion: v1 apiVersion: v1
+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;
}
}