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