fix: Google-Rezensionen auf "Places API (New)" umgestellt
Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid",
obwohl der Schluessel keinerlei Einschraenkung traegt.
Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt
maps.googleapis.com/maps/api/place/details/json auf, und Google laesst
die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten
Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht
den Schluessel, sondern die API — wer der Meldung folgt, sucht
zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als
"Legacy-Hinweis" bereits vorhergesagt.
Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des
Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen
landet) und einer engen X-Goog-FieldMask — die API rechnet nach
angeforderten Feldern ab.
Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es
tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am
Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt.
Zwei Verhaltensaenderungen, beide unvermeidbar:
- Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf
Rezensionen in Googles eigener Reihenfolge.
- Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne
Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte
Zuordnung zur Quelle.
README und .env.example nennen jetzt alle vier noetigen Schritte und
weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den
Abruf blockiert — er laeuft serverseitig.
Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt
einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt
je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen
60 der voreingestellt 100 PostgreSQL-Verbindungen.
tsc: 0 Fehler. vitest: 336/336 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
9b10a9f0c5
commit
6639da130e
5 files changed
+159
-67
No files matched your search
+5
-1
@@ -62,7 +62,11 @@ SITE_ADDRESS=
|
|||||||
|
|
||||||
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
|
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
|
||||||
# automatisch die statischen Beispiel-Stimmen als Fallback.
|
# automatisch die statischen Beispiel-Stimmen als Fallback.
|
||||||
# API-Key: Google-Cloud-Projekt mit aktivierter "Places API".
|
# API-Key: Google-Cloud-Projekt mit aktivierter "Places API (New)" — NICHT
|
||||||
|
# die gleichnamige alte API, die in neuen Projekten nicht mehr aktivierbar
|
||||||
|
# ist. Ausserdem noetig: Abrechnung am Projekt aktiv, und der Wert muss ein
|
||||||
|
# API-Schluessel sein (ein OAuth-Client funktioniert nicht). Keine
|
||||||
|
# Referrer-Beschraenkung setzen — der Abruf erfolgt serverseitig.
|
||||||
# Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id
|
# Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id
|
||||||
GOOGLE_PLACES_API_KEY=
|
GOOGLE_PLACES_API_KEY=
|
||||||
GOOGLE_PLACE_ID=
|
GOOGLE_PLACE_ID=
|
||||||
|
|||||||
@@ -209,22 +209,32 @@ Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag),
|
|||||||
|
|
||||||
Die Bewertungen-Sektion der Startseite zeigt echte Google-Rezensionen, sobald `GOOGLE_PLACES_API_KEY` (Secret) und `GOOGLE_PLACE_ID` (ConfigMap) gesetzt sind. Ohne diese Werte greifen die statischen Beispiel-Stimmen.
|
Die Bewertungen-Sektion der Startseite zeigt echte Google-Rezensionen, sobald `GOOGLE_PLACES_API_KEY` (Secret) und `GOOGLE_PLACE_ID` (ConfigMap) gesetzt sind. Ohne diese Werte greifen die statischen Beispiel-Stimmen.
|
||||||
|
|
||||||
**Einrichtung:** Google-Cloud-Projekt anlegen, darin die **Places API** aktivieren, einen API-Schlüssel erzeugen und ihn auf diese eine API einschränken. Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher nicht; sinnvoll ist eine IP-Beschränkung auf die ausgehende Adresse des Clusters. Die Place-ID des eigenen Eintrags findet sich über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id).
|
**Verwendet wird die `Places API (New)`** (`places.googleapis.com/v1`), nicht der alte `place/details`-Endpunkt. Das ist keine Geschmacksfrage: Google lässt die alte „Places API" in seit März 2025 angelegten Cloud-Projekten gar nicht mehr aktivieren.
|
||||||
|
|
||||||
**Fehler werden jetzt im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen:
|
**Einrichtung — alle vier Schritte sind nötig:**
|
||||||
|
|
||||||
|
1. **API aktivieren:** In der Cloud Console unter *APIs & Dienste → Bibliothek* nach `Places API (New)` suchen und aktivieren. Es gibt dort zwei fast gleich heißende Einträge — die alte „Places API" ist die falsche, und die „Maps JavaScript API" hilft hier ebenfalls nicht.
|
||||||
|
2. **Abrechnung aktivieren:** Ohne hinterlegtes Rechnungskonto antwortet auch die neue API mit einem Berechtigungsfehler. Das kostenlose Kontingent deckt diese Nutzung (ein Abruf pro Stunde) bei Weitem ab.
|
||||||
|
3. **API-Schlüssel erzeugen:** *APIs & Dienste → Anmeldedaten → Anmeldedaten erstellen → API-Schlüssel*. Ein **OAuth-Client ist etwas anderes** und funktioniert hier nicht — er ist für Anmeldungen im Namen eines Nutzers gedacht, nicht für Serverabrufe.
|
||||||
|
4. **Werte setzen:** Schlüssel als `GOOGLE_PLACES_API_KEY` (Secret), Place-ID als `GOOGLE_PLACE_ID` (ConfigMap). Die Place-ID des eigenen Eintrags findet sich über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id).
|
||||||
|
|
||||||
|
Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher **nicht** und würde den Abruf blockieren. Sinnvoll ist stattdessen eine API-Beschränkung auf „Places API (New)" und optional eine IP-Beschränkung auf die ausgehende Adresse des Clusters.
|
||||||
|
|
||||||
|
**Fehler werden im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen:
|
||||||
|
|
||||||
| Meldung im Log | Bedeutung |
|
| Meldung im Log | Bedeutung |
|
||||||
|----------------|-----------|
|
|----------------|-----------|
|
||||||
| `REQUEST_DENIED` | Schlüssel ungültig, oder die Places API ist im Projekt nicht aktiviert |
|
| `… API … not enabled` / `SERVICE_DISABLED` | „Places API (New)" ist im Projekt nicht aktiviert |
|
||||||
| `INVALID_REQUEST` | Parameter falsch |
|
| `The provided API key is invalid` | Meist: alter Endpunkt oder alte API — bei dieser Fassung des Codes eher ein tatsächlich falscher Schlüssel |
|
||||||
| `NOT_FOUND` / `ZERO_RESULTS` | Die Place-ID passt zu keinem Eintrag |
|
| `billing … not enabled` | Kein Rechnungskonto am Projekt |
|
||||||
| `OVER_QUERY_LIMIT` | Kontingent erschöpft oder keine Abrechnung hinterlegt |
|
| `HTTP 404` | Die Place-ID passt zu keinem Eintrag |
|
||||||
|
| `API keys with referer restrictions …` | Der Schlüssel ist auf HTTP-Referrer beschränkt; für Serverabrufe untauglich |
|
||||||
|
|
||||||
Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die Kosten niedrig hält, und nach fünf Sekunden abgebrochen, damit eine nicht antwortende Google-API nicht die Startseite aufhält.
|
Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die Kosten niedrig hält, und nach fünf Sekunden abgebrochen, damit eine nicht antwortende Google-API nicht die Startseite aufhält.
|
||||||
|
|
||||||
**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) werden übersprungen, sie wären sonst leere Sprechblasen.
|
**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) oder ohne Namen werden übersprungen: die einen wären leere Sprechblasen, den anderen fehlte die verlangte Quellenangabe.
|
||||||
|
|
||||||
> **Legacy-Hinweis:** Genutzt wird der klassische `place/details`-Endpunkt. Google führt ihn als *Legacy* und stellt neue Projekte auf die Places API (New) um (`places.googleapis.com/v1`). Meldet das Log dauerhaft `REQUEST_DENIED`, obwohl Schlüssel und Abrechnung stimmen, ist das der wahrscheinliche Grund — dann ist die Umstellung auf die neue API nötig.
|
> **Hinweis zur Sortierung:** Der alte Endpunkt kannte `reviews_sort=newest`. Die neue API bietet das nicht an und liefert bis zu fünf Rezensionen in Googles eigener Reihenfolge.
|
||||||
|
|
||||||
## E-Mail-Versand
|
## E-Mail-Versand
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ data:
|
|||||||
|
|
||||||
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
|
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
|
||||||
# implizites TLS.
|
# implizites TLS.
|
||||||
|
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
|
||||||
|
#
|
||||||
|
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
|
||||||
|
# in 05-app-deployment.yaml. Drei Instanzen mit je 20 belegen 60 der
|
||||||
|
# voreingestellt 100 Verbindungen von PostgreSQL — daneben brauchen der
|
||||||
|
# Aufraeum-CronJob, die Sicherung und gelegentlich eine Konsole noch
|
||||||
|
# welche. Entweder diesen Wert senken oder max_connections anheben.
|
||||||
|
DATABASE_POOL_SIZE: "20"
|
||||||
|
|
||||||
SMTP_PORT: "587"
|
SMTP_PORT: "587"
|
||||||
|
|
||||||
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
|
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
|
||||||
|
|||||||
+24
-2
@@ -18,7 +18,29 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
|||||||
* bei mehreren Instanzen multipliziert sich dieser Wert und will neu bedacht
|
* bei mehreren Instanzen multipliziert sich dieser Wert und will neu bedacht
|
||||||
* werden.
|
* werden.
|
||||||
*/
|
*/
|
||||||
const POOL_SIZE = 20;
|
const DEFAULT_POOL_SIZE = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Wert gilt PRO INSTANZ. Wer die Anwendung auf mehrere Instanzen
|
||||||
|
* hochzieht, multipliziert damit auch die Verbindungen: drei Instanzen mit je
|
||||||
|
* 20 belegen 60 der voreingestellt 100 Verbindungen von PostgreSQL, und
|
||||||
|
* daneben brauchen der Aufraeum-CronJob, die Sicherung und gelegentlich eine
|
||||||
|
* Konsole noch welche. Deshalb einstellbar statt fest verdrahtet — beim
|
||||||
|
* Hochskalieren ist das der Wert, der mitwandern muss.
|
||||||
|
*/
|
||||||
|
function poolSize(): number {
|
||||||
|
const raw = process.env.DATABASE_POOL_SIZE?.trim();
|
||||||
|
if (!raw) return DEFAULT_POOL_SIZE;
|
||||||
|
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||||
|
console.error(
|
||||||
|
`[db] DATABASE_POOL_SIZE="${raw}" ist keine ganze Zahl groesser 0 — es wird mit ${DEFAULT_POOL_SIZE} gearbeitet.`
|
||||||
|
);
|
||||||
|
return DEFAULT_POOL_SIZE;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wie lange eine Anfrage auf eine freie Verbindung wartet, bevor sie
|
* Wie lange eine Anfrage auf eine freie Verbindung wartet, bevor sie
|
||||||
@@ -52,7 +74,7 @@ export function createDbAdapter(): PrismaPg | PrismaMariaDb {
|
|||||||
}
|
}
|
||||||
return new PrismaPg({
|
return new PrismaPg({
|
||||||
connectionString,
|
connectionString,
|
||||||
max: POOL_SIZE,
|
max: poolSize(),
|
||||||
// Ohne Zeitgrenze wartet eine Anfrage unbegrenzt auf eine freie
|
// Ohne Zeitgrenze wartet eine Anfrage unbegrenzt auf eine freie
|
||||||
// Verbindung. Unter Andrang staut sich dann alles lautlos auf, statt dass
|
// Verbindung. Unter Andrang staut sich dann alles lautlos auf, statt dass
|
||||||
// irgendwo ein Fehler sichtbar wuerde.
|
// irgendwo ein Fehler sichtbar wuerde.
|
||||||
|
|||||||
+103
-56
@@ -12,35 +12,71 @@ export interface GoogleReview {
|
|||||||
profilePhotoUrl: string | null;
|
profilePhotoUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google antwortet auch bei Fehlern mit HTTP 200 — die Aussage steckt in "status". */
|
|
||||||
const GOOGLE_TIMEOUT_MS = 5000;
|
const GOOGLE_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
interface PlacesDetailsResponse {
|
/**
|
||||||
result?: {
|
* Nur diese Felder werden angefordert. Die Places API rechnet nach
|
||||||
rating?: number;
|
* angeforderten Feldern ab — eine Maske mit "*" waere teurer und lieferte
|
||||||
user_ratings_total?: number;
|
* Daten, die diese Seite nie anzeigt.
|
||||||
reviews?: {
|
*/
|
||||||
author_name: string;
|
const FIELD_MASK = "rating,userRatingCount,reviews";
|
||||||
rating: number;
|
|
||||||
|
interface PlacesTextV1 {
|
||||||
text?: string;
|
text?: string;
|
||||||
relative_time_description: string;
|
}
|
||||||
author_url?: string;
|
|
||||||
profile_photo_url?: string;
|
interface PlacesReviewV1 {
|
||||||
}[];
|
rating?: number;
|
||||||
|
relativePublishTimeDescription?: string;
|
||||||
|
text?: PlacesTextV1;
|
||||||
|
originalText?: PlacesTextV1;
|
||||||
|
authorAttribution?: {
|
||||||
|
displayName?: string;
|
||||||
|
uri?: string;
|
||||||
|
photoUri?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlacesDetailsV1Response {
|
||||||
|
rating?: number;
|
||||||
|
userRatingCount?: number;
|
||||||
|
reviews?: PlacesReviewV1[];
|
||||||
|
/** Nur im Fehlerfall gesetzt — anders als frueher antwortet die API dann auch mit einem Fehlerstatus. */
|
||||||
|
error?: {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
status?: string;
|
||||||
};
|
};
|
||||||
status: string;
|
|
||||||
error_message?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ruft aktuelle Google-Rezensionen fuer den in GOOGLE_PLACE_ID hinterlegten
|
* Ruft aktuelle Google-Rezensionen fuer den in GOOGLE_PLACE_ID hinterlegten
|
||||||
* Google-Maps-Eintrag ab (Places API, Legacy "Place Details"-Endpoint).
|
* Google-Maps-Eintrag ab. Ohne konfigurierte Zugangsdaten wird null
|
||||||
* Ohne konfigurierte Zugangsdaten wird null zurueckgegeben, sodass die
|
* zurueckgegeben, sodass die Startseite auf die statischen Beispiel-Stimmen
|
||||||
* Startseite auf die statischen Beispiel-Stimmen zurueckfaellt.
|
* zurueckfaellt.
|
||||||
*
|
*
|
||||||
* Setup: Google-Cloud-Projekt mit aktivierter "Places API", API-Key als
|
* Verwendet wird die "Places API (New)" (places.googleapis.com/v1), NICHT der
|
||||||
* GOOGLE_PLACES_API_KEY, die Place-ID des Google-Maps-Eintrags als
|
* alte Endpunkt maps.googleapis.com/maps/api/place/details/json. Das ist kein
|
||||||
* GOOGLE_PLACE_ID (zu finden ueber https://developers.google.com/maps/documentation/places/web-service/place-id).
|
* Geschmacksurteil: Google laesst die alte "Places API" in seit Maerz 2025
|
||||||
|
* angelegten Cloud-Projekten gar nicht mehr aktivieren. Ein Aufruf des alten
|
||||||
|
* Endpunkts wird dort beantwortet mit
|
||||||
|
*
|
||||||
|
* REQUEST_DENIED — The provided API key is invalid.
|
||||||
|
*
|
||||||
|
* also mit einer Aussage ueber den Schluessel, obwohl der Schluessel in
|
||||||
|
* Ordnung ist und keinerlei Einschraenkung traegt. Wer dieser Meldung folgt,
|
||||||
|
* sucht den Fehler zwangslaeufig an der falschen Stelle.
|
||||||
|
*
|
||||||
|
* Einrichtung (siehe auch README, Abschnitt "Google-Rezensionen"):
|
||||||
|
* 1. In der Google Cloud Console die API "Places API (New)" aktivieren —
|
||||||
|
* nicht die gleichnamige alte, nicht die Maps JavaScript API.
|
||||||
|
* 2. Abrechnung fuer das Projekt aktivieren; ohne sie antwortet auch die
|
||||||
|
* neue API mit einem Berechtigungsfehler.
|
||||||
|
* 3. Unter "Anmeldedaten" einen API-SCHLUESSEL erzeugen. Ein OAuth-Client
|
||||||
|
* ist etwas anderes und funktioniert hier nicht.
|
||||||
|
* 4. Schluessel als GOOGLE_PLACES_API_KEY setzen, die Place-ID als
|
||||||
|
* GOOGLE_PLACE_ID (zu finden ueber
|
||||||
|
* https://developers.google.com/maps/documentation/places/web-service/place-id).
|
||||||
*/
|
*/
|
||||||
export async function fetchGoogleReviews(): Promise<{
|
export async function fetchGoogleReviews(): Promise<{
|
||||||
reviews: GoogleReview[];
|
reviews: GoogleReview[];
|
||||||
@@ -51,60 +87,71 @@ export async function fetchGoogleReviews(): Promise<{
|
|||||||
const placeId = process.env.GOOGLE_PLACE_ID;
|
const placeId = process.env.GOOGLE_PLACE_ID;
|
||||||
if (!apiKey || !placeId) return null;
|
if (!apiKey || !placeId) return null;
|
||||||
|
|
||||||
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
|
// Die Place-ID wird mal als "ChIJ..." und mal als "places/ChIJ..." notiert —
|
||||||
url.searchParams.set("place_id", placeId);
|
// der Pfad braucht sie genau einmal mit Praefix.
|
||||||
// "reviews" (Plural!) — mit "review" antwortet Google mit INVALID_REQUEST,
|
const resourceName = placeId.startsWith("places/") ? placeId : `places/${placeId}`;
|
||||||
// und weil jeder Fehlerfall hier still auf die Beispiel-Stimmen
|
|
||||||
// zurueckfaellt, sah die Startseite dabei voellig normal aus. Genau so
|
const url = new URL(`https://places.googleapis.com/v1/${resourceName}`);
|
||||||
// blieb der Tippfehler unbemerkt.
|
url.searchParams.set("languageCode", "de");
|
||||||
url.searchParams.set("fields", "reviews,rating,user_ratings_total");
|
|
||||||
url.searchParams.set("language", "de");
|
|
||||||
url.searchParams.set("reviews_sort", "newest");
|
|
||||||
url.searchParams.set("key", apiKey);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten,
|
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten, ohne
|
||||||
// ohne die Rezensionen spuerbar veralten zu lassen.
|
// die Rezensionen spuerbar veralten zu lassen.
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
// Der Schluessel gehoert bei dieser API in den Header, nicht in die
|
||||||
|
// Adresszeile — so steht er nicht in fremden Zugriffsprotokollen.
|
||||||
|
"X-Goog-Api-Key": apiKey,
|
||||||
|
"X-Goog-FieldMask": FIELD_MASK,
|
||||||
|
},
|
||||||
next: { revalidate: 3600 },
|
next: { revalidate: 3600 },
|
||||||
// Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden
|
// Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden
|
||||||
// fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich.
|
// fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich.
|
||||||
signal: AbortSignal.timeout(GOOGLE_TIMEOUT_MS),
|
signal: AbortSignal.timeout(GOOGLE_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
console.warn(`[google-reviews] Places API antwortete mit HTTP ${response.status}.`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as PlacesDetailsResponse;
|
const data = (await response.json().catch(() => null)) as PlacesDetailsV1Response | null;
|
||||||
if (data.status !== "OK" || !data.result) {
|
|
||||||
// Laut melden statt still zurueckfallen: "REQUEST_DENIED" (Schluessel
|
if (!response.ok || data?.error) {
|
||||||
// ungueltig oder Places API nicht aktiviert), "INVALID_REQUEST"
|
// Laut melden statt still zurueckfallen: ein falscher Schluessel, eine
|
||||||
// (Parameter falsch) und "NOT_FOUND" (Place-ID stimmt nicht) sehen auf
|
// nicht aktivierte API und eine unbekannte Place-ID sehen auf der
|
||||||
// der Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
|
// Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
|
||||||
|
const detail = data?.error?.message ?? `HTTP ${response.status}`;
|
||||||
console.warn(
|
console.warn(
|
||||||
`[google-reviews] Places API meldet "${data.status}"${data.error_message ? `: ${data.error_message}` : ""}.`
|
`[google-reviews] Places API (New) hat den Abruf abgelehnt: ${detail}. Zu pruefen: Ist "Places API (New)" im Google-Cloud-Projekt aktiviert, ist die Abrechnung aktiv, und ist GOOGLE_PLACES_API_KEY ein API-Schluessel (kein OAuth-Client)?`
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!data) {
|
||||||
|
console.warn("[google-reviews] Antwort der Places API war kein gueltiges JSON.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const reviews: GoogleReview[] = (data.result.reviews ?? [])
|
const reviews: GoogleReview[] = (data.reviews ?? [])
|
||||||
|
.map((review) => {
|
||||||
|
// "text" ist die (ggf. uebersetzte) Fassung in languageCode,
|
||||||
|
// "originalText" die Originalsprache. Fehlt die Uebersetzung, ist das
|
||||||
|
// Original immer noch besser als gar kein Zitat.
|
||||||
|
const text = (review.text?.text ?? review.originalText?.text ?? "").trim();
|
||||||
|
const author = review.authorAttribution?.displayName?.trim() ?? "";
|
||||||
|
return {
|
||||||
|
author,
|
||||||
|
rating: review.rating ?? 0,
|
||||||
|
text,
|
||||||
|
relativeTime: review.relativePublishTimeDescription ?? "",
|
||||||
|
authorUrl: review.authorAttribution?.uri ?? null,
|
||||||
|
profilePhotoUrl: review.authorAttribution?.photoUri ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
// Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der
|
// Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der
|
||||||
// Startseite waeren sie eine leere Sprechblase.
|
// Startseite waeren sie eine leere Sprechblase. Ohne Namen fehlt zudem
|
||||||
.filter((r) => r.text?.trim())
|
// die von den Nutzungsbedingungen verlangte Zuordnung zur Quelle.
|
||||||
.map((r) => ({
|
.filter((review) => review.text.length > 0 && review.author.length > 0);
|
||||||
author: r.author_name,
|
|
||||||
rating: r.rating,
|
|
||||||
text: r.text!.trim(),
|
|
||||||
relativeTime: r.relative_time_description,
|
|
||||||
authorUrl: r.author_url ?? null,
|
|
||||||
profilePhotoUrl: r.profile_photo_url ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reviews,
|
reviews,
|
||||||
rating: data.result.rating ?? null,
|
rating: data.rating ?? null,
|
||||||
totalCount: data.result.user_ratings_total ?? null,
|
totalCount: data.userRatingCount ?? null,
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —
|
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —
|
||||||
|
|||||||
Reference in new issue
Block a user