@domandigital/gbp 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -20,28 +20,192 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ DEFAULT_MAX_PAGES: () => DEFAULT_MAX_PAGES,
24
+ DEFAULT_PUBLISHED_REVIEWS_BASE_URL: () => DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
25
+ DEFAULT_REQUEST_POLICY: () => DEFAULT_REQUEST_POLICY,
26
+ GbpApiError: () => GbpApiError,
27
+ GbpAuthError: () => GbpAuthError,
28
+ GbpError: () => GbpError,
29
+ GbpPaginationError: () => GbpPaginationError,
30
+ GbpPublishedError: () => GbpPublishedError,
31
+ GbpTimeoutError: () => GbpTimeoutError,
32
+ PUBLISHED_REVIEWS_SCHEMA: () => PUBLISHED_REVIEWS_SCHEMA,
23
33
  getBusinessReviews: () => getBusinessReviews,
24
34
  getGoogleOAuthAccessToken: () => getGoogleOAuthAccessToken,
35
+ getPublishedReviews: () => getPublishedReviews,
25
36
  hasGoogleOAuthCredentials: () => hasGoogleOAuthCredentials,
26
- isBusinessProfileConfigured: () => isBusinessProfileConfigured
37
+ invalidateAccessToken: () => invalidateAccessToken,
38
+ isBusinessProfileConfigured: () => isBusinessProfileConfigured,
39
+ parseListReviewsResponse: () => parseListReviewsResponse,
40
+ parsePublishedReviews: () => parsePublishedReviews,
41
+ parseRetryAfter: () => parseRetryAfter,
42
+ publishedReviewsUrl: () => publishedReviewsUrl
27
43
  });
28
44
  module.exports = __toCommonJS(index_exports);
29
45
 
46
+ // src/errors.ts
47
+ var GbpError = class extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = new.target.name;
51
+ }
52
+ };
53
+ var GbpAuthError = class extends GbpError {
54
+ constructor(code, status, description) {
55
+ super(
56
+ `Google OAuth token refresh failed: ${status} ${code}${description ? ` (${description})` : ""}` + (code === "invalid_grant" ? ". Reauthorise the Google account and replace GBP_REFRESH_TOKEN." : "")
57
+ );
58
+ this.code = code;
59
+ this.reauthorizationRequired = code === "invalid_grant";
60
+ this.status = status;
61
+ this.description = description;
62
+ }
63
+ };
64
+ var GbpApiError = class extends GbpError {
65
+ constructor(options) {
66
+ super(`Business Profile reviews failed: ${options.status}${options.body ? ` ${options.body}` : ""}`);
67
+ this.operation = options.operation;
68
+ this.status = options.status;
69
+ this.retryable = options.retryable;
70
+ this.attempts = options.attempts;
71
+ this.retryAfterMs = options.retryAfterMs;
72
+ }
73
+ };
74
+ var GbpTimeoutError = class extends GbpError {
75
+ constructor(operation, timeoutMs) {
76
+ super(`${operation} timed out after ${timeoutMs}ms`);
77
+ this.operation = operation;
78
+ this.timeoutMs = timeoutMs;
79
+ }
80
+ };
81
+ var GbpPaginationError = class extends GbpError {
82
+ constructor(reason, pagesFetched) {
83
+ super(
84
+ reason === "repeated_token" ? `Business Profile returned a page token it had already returned, after ${pagesFetched} page(s)` : `Business Profile reviews stopped at the page limit (${pagesFetched} pages)`
85
+ );
86
+ this.reason = reason;
87
+ this.pagesFetched = pagesFetched;
88
+ }
89
+ };
90
+ function excerpt(text, max = 300) {
91
+ const flat = text.replace(/\s+/g, " ").trim();
92
+ return flat.length > max ? `${flat.slice(0, max)}...` : flat;
93
+ }
94
+ var GbpPublishedError = class extends GbpError {
95
+ constructor(reason, client, detail, status) {
96
+ super(`Published reviews for ${client} unusable (${reason}): ${detail}`);
97
+ this.reason = reason;
98
+ this.client = client;
99
+ this.status = status;
100
+ }
101
+ };
102
+
103
+ // src/http.ts
104
+ var DEFAULT_REQUEST_POLICY = {
105
+ timeoutMs: 8e3,
106
+ maxAttempts: 3,
107
+ baseDelayMs: 250,
108
+ maxDelayMs: 5e3,
109
+ maxRetryAfterMs: 3e4
110
+ };
111
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
112
+ function resolvePolicy(options = {}) {
113
+ const policy = { ...DEFAULT_REQUEST_POLICY, ...options };
114
+ const check = (name, min, integer = false) => {
115
+ const value = policy[name];
116
+ if (!Number.isFinite(value) || value < min || integer && !Number.isInteger(value)) {
117
+ throw new RangeError(`request.${name} must be ${integer ? "an integer" : "a number"} >= ${min}, got ${value}`);
118
+ }
119
+ };
120
+ check("timeoutMs", 1);
121
+ check("maxAttempts", 1, true);
122
+ check("baseDelayMs", 0);
123
+ check("maxDelayMs", 0);
124
+ check("maxRetryAfterMs", 0);
125
+ return policy;
126
+ }
127
+ function parseRetryAfter(value, nowMs = Date.now()) {
128
+ if (!value) return null;
129
+ const v = value.trim();
130
+ if (/^\d+(\.\d+)?$/.test(v)) return Math.round(Number.parseFloat(v) * 1e3);
131
+ const at = Date.parse(v);
132
+ return Number.isNaN(at) ? null : Math.max(0, at - nowMs);
133
+ }
134
+ function backoffMs(attempt, policy, random = Math.random) {
135
+ const cap = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
136
+ return Math.floor(random() * cap);
137
+ }
138
+ function sleep(ms, signal) {
139
+ if (ms <= 0) return Promise.resolve();
140
+ return new Promise((resolve, reject) => {
141
+ if (signal?.aborted) return reject(signal.reason);
142
+ const timer = setTimeout(resolve, ms);
143
+ signal?.addEventListener(
144
+ "abort",
145
+ () => {
146
+ clearTimeout(timer);
147
+ reject(signal.reason);
148
+ },
149
+ { once: true }
150
+ );
151
+ });
152
+ }
153
+ async function attemptOnce(url, init, operation, policy) {
154
+ const deadline = new AbortController();
155
+ const timer = setTimeout(() => deadline.abort(new GbpTimeoutError(operation, policy.timeoutMs)), policy.timeoutMs);
156
+ const signal = policy.signal ? AbortSignal.any([policy.signal, deadline.signal]) : deadline.signal;
157
+ try {
158
+ return await fetch(url, { ...init, signal });
159
+ } catch (error) {
160
+ if (deadline.signal.aborted && !policy.signal?.aborted) throw new GbpTimeoutError(operation, policy.timeoutMs);
161
+ throw error;
162
+ } finally {
163
+ clearTimeout(timer);
164
+ }
165
+ }
166
+ async function fetchWithRetry(url, init, operation, policy) {
167
+ for (let attempt = 1; ; attempt++) {
168
+ if (policy.signal?.aborted) throw policy.signal.reason;
169
+ let response;
170
+ try {
171
+ response = await attemptOnce(url, init, operation, policy);
172
+ } catch (error) {
173
+ if (policy.signal?.aborted || attempt >= policy.maxAttempts) throw error;
174
+ await sleep(backoffMs(attempt, policy), policy.signal);
175
+ continue;
176
+ }
177
+ if (response.ok || !RETRYABLE_STATUSES.has(response.status)) return { response, attempts: attempt };
178
+ const retryAfter = parseRetryAfter(response.headers?.get?.("retry-after"));
179
+ if (retryAfter !== null && retryAfter > policy.maxRetryAfterMs) return { response, attempts: attempt, retryAfterMs: retryAfter };
180
+ if (attempt >= policy.maxAttempts) return { response, attempts: attempt };
181
+ await sleep(retryAfter ?? backoffMs(attempt, policy), policy.signal);
182
+ }
183
+ }
184
+ async function readErrorBody(response) {
185
+ try {
186
+ const text = typeof response.text === "function" ? await response.text() : "";
187
+ return text.slice(0, 2048);
188
+ } catch {
189
+ return "";
190
+ }
191
+ }
192
+
30
193
  // src/oauth-client.ts
31
194
  var TOKEN_URI = "https://oauth2.googleapis.com/token";
32
195
  var EXPIRY_SKEW_MS = 60 * 1e3;
196
+ var DEFAULT_LIFETIME_S = 3600;
33
197
  var cached = null;
198
+ var refreshing = null;
34
199
  function hasGoogleOAuthCredentials() {
35
200
  return Boolean(
36
201
  process.env.GBP_CLIENT_ID && process.env.GBP_CLIENT_SECRET && process.env.GBP_REFRESH_TOKEN
37
202
  );
38
203
  }
39
- async function getGoogleOAuthAccessToken() {
40
- if (cached && cached.expiresAt > Date.now()) {
41
- return cached.accessToken;
42
- }
43
- if (!hasGoogleOAuthCredentials()) return null;
44
- const res = await fetch(TOKEN_URI, {
204
+ function invalidateAccessToken(accessToken) {
205
+ if (cached?.accessToken === accessToken) cached = null;
206
+ }
207
+ async function refresh(request) {
208
+ const init = {
45
209
  method: "POST",
46
210
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
47
211
  body: new URLSearchParams({
@@ -49,17 +213,47 @@ async function getGoogleOAuthAccessToken() {
49
213
  client_secret: process.env.GBP_CLIENT_SECRET,
50
214
  refresh_token: process.env.GBP_REFRESH_TOKEN,
51
215
  grant_type: "refresh_token"
52
- })
53
- });
54
- if (!res.ok) {
55
- throw new Error(`Google OAuth token refresh failed: ${res.status} ${await res.text()}`);
56
- }
57
- const data = await res.json();
58
- cached = {
59
- accessToken: data.access_token,
60
- expiresAt: Date.now() + (data.expires_in ?? 3600) * 1e3 - EXPIRY_SKEW_MS
216
+ }),
217
+ cache: "no-store"
61
218
  };
62
- return cached.accessToken;
219
+ const { response } = await fetchWithRetry(TOKEN_URI, init, "oauth.refresh", resolvePolicy(request));
220
+ if (!response.ok) {
221
+ const body = await readErrorBody(response);
222
+ let error;
223
+ let description;
224
+ try {
225
+ const parsed = JSON.parse(body);
226
+ if (typeof parsed.error === "string") error = parsed.error;
227
+ if (typeof parsed.error_description === "string") description = excerpt(parsed.error_description, 200);
228
+ } catch {
229
+ if (/\binvalid_grant\b/.test(body)) error = "invalid_grant";
230
+ else if (/\binvalid_client\b/.test(body)) error = "invalid_client";
231
+ }
232
+ if (error === "invalid_grant") cached = null;
233
+ const code = error === "invalid_grant" || error === "invalid_client" ? error : "token_request_failed";
234
+ throw new GbpAuthError(code, response.status, description ?? (code === "token_request_failed" && body ? excerpt(body, 200) : void 0));
235
+ }
236
+ const data = await response.json();
237
+ if (typeof data.access_token !== "string" || !data.access_token) {
238
+ throw new GbpAuthError("token_request_failed", response.status, "token response had no access_token");
239
+ }
240
+ const lifetime = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0 ? data.expires_in : DEFAULT_LIFETIME_S;
241
+ return { accessToken: data.access_token, expiresAt: Date.now() + lifetime * 1e3 - EXPIRY_SKEW_MS };
242
+ }
243
+ async function getGoogleOAuthAccessToken(options = {}) {
244
+ if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) {
245
+ return cached.accessToken;
246
+ }
247
+ if (!hasGoogleOAuthCredentials()) return null;
248
+ if (!refreshing) {
249
+ refreshing = refresh(options.request).then((token) => {
250
+ cached = token;
251
+ return token;
252
+ }).finally(() => {
253
+ refreshing = null;
254
+ });
255
+ }
256
+ return (await refreshing).accessToken;
63
257
  }
64
258
 
65
259
  // src/reviews.ts
@@ -71,6 +265,7 @@ var STAR_VALUES = {
71
265
  FIVE: 5
72
266
  };
73
267
  var MAX_PAGE_SIZE = 50;
268
+ var DEFAULT_MAX_PAGES = 50;
74
269
  function shuffleArray(arr) {
75
270
  const copy = [...arr];
76
271
  for (let i = copy.length - 1; i > 0; i--) {
@@ -80,19 +275,49 @@ function shuffleArray(arr) {
80
275
  return copy;
81
276
  }
82
277
  function isUsableReview(r, filterMinStars) {
83
- if (!r.comment || !r.starRating) return false;
278
+ if (!r.comment || !r.starRating || !(r.starRating in STAR_VALUES)) return false;
84
279
  if (filterMinStars !== void 0 && STAR_VALUES[r.starRating] < filterMinStars) return false;
85
280
  return true;
86
281
  }
87
282
  var EMPTY = {
88
283
  averageRating: null,
89
- totalReviewCount: 0,
284
+ totalReviewCount: null,
90
285
  reviews: []
91
286
  };
92
287
  function isBusinessProfileConfigured() {
93
288
  return hasGoogleOAuthCredentials() && Boolean(process.env.GOOGLE_BUSINESS_ACCOUNT_ID) && Boolean(process.env.GOOGLE_BUSINESS_LOCATION_ID);
94
289
  }
95
- function toBusinessReview(r) {
290
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
291
+ var str = (v) => typeof v === "string" ? v : void 0;
292
+ function parseReview(v) {
293
+ if (!isRecord(v) || typeof v.reviewId !== "string") return null;
294
+ const reviewer = isRecord(v.reviewer) ? { displayName: str(v.reviewer.displayName), profilePhotoUrl: str(v.reviewer.profilePhotoUrl) } : void 0;
295
+ const reply = isRecord(v.reviewReply) ? {
296
+ comment: str(v.reviewReply.comment),
297
+ updateTime: str(v.reviewReply.updateTime),
298
+ reviewReplyState: str(v.reviewReply.reviewReplyState),
299
+ policyViolation: str(v.reviewReply.policyViolation)
300
+ } : void 0;
301
+ const media = Array.isArray(v.reviewMediaItems) ? v.reviewMediaItems.filter(isRecord).map((m) => ({ thumbnailUrl: str(m.thumbnailUrl), thumbnailLabel: str(m.thumbnailLabel), videoUrl: str(m.videoUrl) })) : void 0;
302
+ return {
303
+ reviewId: v.reviewId,
304
+ reviewer,
305
+ starRating: str(v.starRating),
306
+ comment: str(v.comment),
307
+ createTime: str(v.createTime),
308
+ reviewReply: reply,
309
+ reviewMediaItems: media,
310
+ reviewReplyUrl: str(v.reviewReplyUrl)
311
+ };
312
+ }
313
+ function parseListReviewsResponse(body) {
314
+ if (!isRecord(body)) throw new TypeError("Business Profile reviews response was not an object");
315
+ const reviews = Array.isArray(body.reviews) ? body.reviews.map(parseReview).filter((r) => r !== null) : [];
316
+ const average = typeof body.averageRating === "number" && Number.isFinite(body.averageRating) && body.averageRating >= 0 && body.averageRating <= 5 ? body.averageRating : void 0;
317
+ const total = typeof body.totalReviewCount === "number" && Number.isInteger(body.totalReviewCount) && body.totalReviewCount >= 0 ? body.totalReviewCount : void 0;
318
+ return { reviews, averageRating: average, totalReviewCount: total, nextPageToken: str(body.nextPageToken) || void 0 };
319
+ }
320
+ function toBusinessReview(r, includeUnapprovedReplies) {
96
321
  const review = {
97
322
  id: r.reviewId,
98
323
  author: r.reviewer?.displayName ?? "Anonymous",
@@ -101,15 +326,33 @@ function toBusinessReview(r) {
101
326
  comment: r.comment ?? "",
102
327
  createdAt: r.createTime ?? ""
103
328
  };
104
- if (r.reviewReply?.comment) {
105
- review.reply = { text: r.reviewReply.comment, updatedAt: r.reviewReply.updateTime ?? "" };
329
+ const reply = r.reviewReply;
330
+ if (reply?.comment) {
331
+ const state = reply.reviewReplyState === "PENDING" || reply.reviewReplyState === "REJECTED" || reply.reviewReplyState === "APPROVED" ? reply.reviewReplyState : void 0;
332
+ if (includeUnapprovedReplies || state !== "PENDING" && state !== "REJECTED") {
333
+ review.reply = { text: reply.comment, updatedAt: reply.updateTime ?? "" };
334
+ if (state) review.reply.state = state;
335
+ if (state === "REJECTED" && reply.policyViolation) review.reply.policyViolation = reply.policyViolation;
336
+ }
106
337
  }
338
+ const media = (r.reviewMediaItems ?? []).filter((m) => Boolean(m.thumbnailUrl));
339
+ if (media.length > 0) {
340
+ review.media = media.map((m) => ({
341
+ thumbnailUrl: m.thumbnailUrl,
342
+ ...m.thumbnailLabel ? { label: m.thumbnailLabel } : {},
343
+ ...m.videoUrl ? { videoUrl: m.videoUrl } : {}
344
+ }));
345
+ }
346
+ if (r.reviewReplyUrl) review.replyUrl = r.reviewReplyUrl;
107
347
  return review;
108
348
  }
109
349
  async function getBusinessReviews(options = {}) {
110
- const { limit, filterMinStars, next, order = "shuffle" } = options;
350
+ const { limit, filterMinStars, next, order = "shuffle", includeUnapprovedReplies = false, request } = options;
351
+ const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
352
+ if (!Number.isInteger(maxPages) || maxPages < 1) throw new RangeError(`maxPages must be an integer >= 1, got ${maxPages}`);
111
353
  if (!isBusinessProfileConfigured()) return EMPTY;
112
- const token = await getGoogleOAuthAccessToken();
354
+ const policy = resolvePolicy(request);
355
+ let token = await getGoogleOAuthAccessToken({ request });
113
356
  if (!token) return EMPTY;
114
357
  const account = process.env.GOOGLE_BUSINESS_ACCOUNT_ID;
115
358
  const location = process.env.GOOGLE_BUSINESS_LOCATION_ID;
@@ -117,33 +360,151 @@ async function getBusinessReviews(options = {}) {
117
360
  let averageRating;
118
361
  let totalReviewCount;
119
362
  let pageToken;
120
- do {
121
- const endpoint = `https://mybusiness.googleapis.com/v4/accounts/${account}/locations/${location}/reviews?orderBy=updateTime%20desc&pageSize=${MAX_PAGE_SIZE}` + (pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : "");
122
- const fetchOptions = {
123
- headers: { Authorization: `Bearer ${token}` },
363
+ const seenTokens = /* @__PURE__ */ new Set();
364
+ let pages = 0;
365
+ const fetchPage = (accessToken, endpoint) => {
366
+ const init = {
367
+ headers: { Authorization: `Bearer ${accessToken}` },
124
368
  next: next ?? { revalidate: 3600, tags: ["google-reviews"] }
125
369
  };
126
- const res = await fetch(endpoint, fetchOptions);
127
- if (!res.ok) {
128
- throw new Error(`Business Profile reviews failed: ${res.status} ${await res.text()}`);
370
+ return fetchWithRetry(endpoint, init, "reviews.list", policy);
371
+ };
372
+ for (; ; ) {
373
+ const endpoint = `https://mybusiness.googleapis.com/v4/accounts/${account}/locations/${location}/reviews?orderBy=updateTime%20desc&pageSize=${MAX_PAGE_SIZE}` + (pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : "");
374
+ let attempt = await fetchPage(token, endpoint);
375
+ if (attempt.response.status === 401) {
376
+ invalidateAccessToken(token);
377
+ const fresh = await getGoogleOAuthAccessToken({ forceRefresh: true, request });
378
+ if (!fresh) return EMPTY;
379
+ token = fresh;
380
+ attempt = await fetchPage(token, endpoint);
381
+ }
382
+ const { response, attempts, retryAfterMs } = attempt;
383
+ if (!response.ok) {
384
+ throw new GbpApiError({
385
+ operation: "reviews.list",
386
+ status: response.status,
387
+ retryable: RETRYABLE_STATUSES.has(response.status),
388
+ attempts,
389
+ body: excerpt(await readErrorBody(response)),
390
+ retryAfterMs
391
+ });
129
392
  }
130
- const data = await res.json();
131
- raw.push(...data.reviews ?? []);
132
- averageRating = data.averageRating ?? averageRating;
133
- totalReviewCount = data.totalReviewCount ?? totalReviewCount;
393
+ const data = parseListReviewsResponse(await response.json());
394
+ pages++;
395
+ raw.push(...data.reviews);
396
+ averageRating ?? (averageRating = data.averageRating);
397
+ totalReviewCount ?? (totalReviewCount = data.totalReviewCount);
134
398
  pageToken = data.nextPageToken;
135
- } while (pageToken && (limit === void 0 || raw.filter((r) => isUsableReview(r, filterMinStars)).length < limit));
136
- const reviews = raw.filter((r) => isUsableReview(r, filterMinStars)).map(toBusinessReview).slice(0, limit);
399
+ const enough = limit !== void 0 && raw.filter((r) => isUsableReview(r, filterMinStars)).length >= limit;
400
+ if (!pageToken || enough) break;
401
+ if (seenTokens.has(pageToken)) throw new GbpPaginationError("repeated_token", pages);
402
+ seenTokens.add(pageToken);
403
+ if (pages >= maxPages) throw new GbpPaginationError("page_limit", pages);
404
+ }
405
+ const reviews = raw.filter((r) => isUsableReview(r, filterMinStars)).map((r) => toBusinessReview(r, includeUnapprovedReplies)).slice(0, limit);
137
406
  return {
138
407
  averageRating: averageRating ?? null,
139
- totalReviewCount: totalReviewCount ?? reviews.length,
408
+ totalReviewCount: totalReviewCount ?? null,
140
409
  reviews: order === "api" ? reviews : shuffleArray(reviews)
141
410
  };
142
411
  }
412
+
413
+ // src/published.ts
414
+ var DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
415
+ var PUBLISHED_REVIEWS_SCHEMA = 1;
416
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
417
+ var isTime = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
418
+ function parseReview2(v) {
419
+ if (!isRecord2(v)) return null;
420
+ const { id, author, rating, comment, createdAt, reply } = v;
421
+ if (typeof id !== "string" || typeof author !== "string" || typeof comment !== "string" || !comment) return null;
422
+ if (typeof rating !== "number" || !Number.isInteger(rating) || rating < 1 || rating > 5) return null;
423
+ const review = { id, author, rating, comment, createdAt: isTime(createdAt) ? createdAt : "" };
424
+ if (isRecord2(reply) && typeof reply.text === "string" && reply.text) {
425
+ review.reply = { text: reply.text, updatedAt: isTime(reply.updatedAt) ? reply.updatedAt : "" };
426
+ }
427
+ return review;
428
+ }
429
+ function parsePublishedReviews(body, client) {
430
+ if (!isRecord2(body)) throw new GbpPublishedError("invalid", client, "the file is not an object");
431
+ if (body.schema !== PUBLISHED_REVIEWS_SCHEMA) {
432
+ throw new GbpPublishedError("unknown_schema", client, `schema ${String(body.schema)}`);
433
+ }
434
+ if (body.client !== client) throw new GbpPublishedError("invalid", client, `the file is for ${String(body.client)}`);
435
+ const { rating, count } = body;
436
+ if (rating !== null && (typeof rating !== "number" || !(rating >= 0 && rating <= 5))) {
437
+ throw new GbpPublishedError("invalid", client, "rating out of range");
438
+ }
439
+ if (count !== null && (typeof count !== "number" || !Number.isInteger(count) || count < 0)) {
440
+ throw new GbpPublishedError("invalid", client, "count is not a whole number");
441
+ }
442
+ if (!isTime(body.synced_at) || !isTime(body.published_at) || !Array.isArray(body.reviews)) {
443
+ throw new GbpPublishedError("invalid", client, "synced_at, published_at or reviews missing");
444
+ }
445
+ return {
446
+ averageRating: rating,
447
+ totalReviewCount: count,
448
+ reviews: body.reviews.map(parseReview2).filter((r) => r !== null),
449
+ syncedAt: body.synced_at,
450
+ publishedAt: body.published_at
451
+ };
452
+ }
453
+ function shuffle(arr) {
454
+ const copy = [...arr];
455
+ for (let i = copy.length - 1; i > 0; i--) {
456
+ const j = Math.floor(Math.random() * (i + 1));
457
+ [copy[i], copy[j]] = [copy[j], copy[i]];
458
+ }
459
+ return copy;
460
+ }
461
+ function publishedReviewsUrl(client, baseUrl) {
462
+ if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(client)) throw new RangeError(`not a client slug: ${client}`);
463
+ const base = (baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? DEFAULT_PUBLISHED_REVIEWS_BASE_URL).replace(/\/+$/, "");
464
+ return `${base}/${client}.json`;
465
+ }
466
+ async function getPublishedReviews(options) {
467
+ const { client, filterMinStars, limit, order = "api", next, request } = options;
468
+ const url = publishedReviewsUrl(client, options.baseUrl);
469
+ const init = {
470
+ headers: { Accept: "application/json" },
471
+ next: next ?? { revalidate: 86400, tags: ["google-reviews"] }
472
+ };
473
+ const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
474
+ if (!response.ok) {
475
+ throw new GbpPublishedError("http", client, `${response.status} ${excerpt(await readErrorBody(response), 120)}`.trim(), response.status);
476
+ }
477
+ let body;
478
+ try {
479
+ body = await response.json();
480
+ } catch {
481
+ throw new GbpPublishedError("invalid", client, "the file is not JSON");
482
+ }
483
+ const result = parsePublishedReviews(body, client);
484
+ let reviews = filterMinStars === void 0 ? result.reviews : result.reviews.filter((r) => r.rating >= filterMinStars);
485
+ if (limit !== void 0) reviews = reviews.slice(0, limit);
486
+ return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
487
+ }
143
488
  // Annotate the CommonJS export names for ESM import in node:
144
489
  0 && (module.exports = {
490
+ DEFAULT_MAX_PAGES,
491
+ DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
492
+ DEFAULT_REQUEST_POLICY,
493
+ GbpApiError,
494
+ GbpAuthError,
495
+ GbpError,
496
+ GbpPaginationError,
497
+ GbpPublishedError,
498
+ GbpTimeoutError,
499
+ PUBLISHED_REVIEWS_SCHEMA,
145
500
  getBusinessReviews,
146
501
  getGoogleOAuthAccessToken,
502
+ getPublishedReviews,
147
503
  hasGoogleOAuthCredentials,
148
- isBusinessProfileConfigured
504
+ invalidateAccessToken,
505
+ isBusinessProfileConfigured,
506
+ parseListReviewsResponse,
507
+ parsePublishedReviews,
508
+ parseRetryAfter,
509
+ publishedReviewsUrl
149
510
  });