@domandigital/gbp 0.3.2 → 0.4.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,158 @@
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
+
50
+ // src/http.ts
51
+ var DEFAULT_REQUEST_POLICY = {
52
+ timeoutMs: 8e3,
53
+ maxAttempts: 3,
54
+ baseDelayMs: 250,
55
+ maxDelayMs: 5e3,
56
+ maxRetryAfterMs: 3e4
57
+ };
58
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
59
+ function resolvePolicy(options = {}) {
60
+ const policy = { ...DEFAULT_REQUEST_POLICY, ...options };
61
+ const check = (name, min, integer = false) => {
62
+ const value = policy[name];
63
+ if (!Number.isFinite(value) || value < min || integer && !Number.isInteger(value)) {
64
+ throw new RangeError(`request.${name} must be ${integer ? "an integer" : "a number"} >= ${min}, got ${value}`);
65
+ }
66
+ };
67
+ check("timeoutMs", 1);
68
+ check("maxAttempts", 1, true);
69
+ check("baseDelayMs", 0);
70
+ check("maxDelayMs", 0);
71
+ check("maxRetryAfterMs", 0);
72
+ return policy;
73
+ }
74
+ function parseRetryAfter(value, nowMs = Date.now()) {
75
+ if (!value) return null;
76
+ const v = value.trim();
77
+ if (/^\d+(\.\d+)?$/.test(v)) return Math.round(Number.parseFloat(v) * 1e3);
78
+ const at = Date.parse(v);
79
+ return Number.isNaN(at) ? null : Math.max(0, at - nowMs);
80
+ }
81
+ function backoffMs(attempt, policy, random = Math.random) {
82
+ const cap = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
83
+ return Math.floor(random() * cap);
84
+ }
85
+ function sleep(ms, signal) {
86
+ if (ms <= 0) return Promise.resolve();
87
+ return new Promise((resolve, reject) => {
88
+ if (signal?.aborted) return reject(signal.reason);
89
+ const timer = setTimeout(resolve, ms);
90
+ signal?.addEventListener(
91
+ "abort",
92
+ () => {
93
+ clearTimeout(timer);
94
+ reject(signal.reason);
95
+ },
96
+ { once: true }
97
+ );
98
+ });
99
+ }
100
+ async function attemptOnce(url, init, operation, policy) {
101
+ const deadline = new AbortController();
102
+ const timer = setTimeout(() => deadline.abort(new GbpTimeoutError(operation, policy.timeoutMs)), policy.timeoutMs);
103
+ const signal = policy.signal ? AbortSignal.any([policy.signal, deadline.signal]) : deadline.signal;
104
+ try {
105
+ return await fetch(url, { ...init, signal });
106
+ } catch (error) {
107
+ if (deadline.signal.aborted && !policy.signal?.aborted) throw new GbpTimeoutError(operation, policy.timeoutMs);
108
+ throw error;
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
113
+ async function fetchWithRetry(url, init, operation, policy) {
114
+ for (let attempt = 1; ; attempt++) {
115
+ if (policy.signal?.aborted) throw policy.signal.reason;
116
+ let response;
117
+ try {
118
+ response = await attemptOnce(url, init, operation, policy);
119
+ } catch (error) {
120
+ if (policy.signal?.aborted || attempt >= policy.maxAttempts) throw error;
121
+ await sleep(backoffMs(attempt, policy), policy.signal);
122
+ continue;
123
+ }
124
+ if (response.ok || !RETRYABLE_STATUSES.has(response.status)) return { response, attempts: attempt };
125
+ const retryAfter = parseRetryAfter(response.headers?.get?.("retry-after"));
126
+ if (retryAfter !== null && retryAfter > policy.maxRetryAfterMs) return { response, attempts: attempt, retryAfterMs: retryAfter };
127
+ if (attempt >= policy.maxAttempts) return { response, attempts: attempt };
128
+ await sleep(retryAfter ?? backoffMs(attempt, policy), policy.signal);
129
+ }
130
+ }
131
+ async function readErrorBody(response) {
132
+ try {
133
+ const text = typeof response.text === "function" ? await response.text() : "";
134
+ return text.slice(0, 2048);
135
+ } catch {
136
+ return "";
137
+ }
138
+ }
139
+
1
140
  // src/oauth-client.ts
2
141
  var TOKEN_URI = "https://oauth2.googleapis.com/token";
3
142
  var EXPIRY_SKEW_MS = 60 * 1e3;
143
+ var DEFAULT_LIFETIME_S = 3600;
4
144
  var cached = null;
145
+ var refreshing = null;
5
146
  function hasGoogleOAuthCredentials() {
6
147
  return Boolean(
7
148
  process.env.GBP_CLIENT_ID && process.env.GBP_CLIENT_SECRET && process.env.GBP_REFRESH_TOKEN
8
149
  );
9
150
  }
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, {
151
+ function invalidateAccessToken(accessToken) {
152
+ if (cached?.accessToken === accessToken) cached = null;
153
+ }
154
+ async function refresh(request) {
155
+ const init = {
16
156
  method: "POST",
17
157
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
18
158
  body: new URLSearchParams({
@@ -20,17 +160,47 @@ async function getGoogleOAuthAccessToken() {
20
160
  client_secret: process.env.GBP_CLIENT_SECRET,
21
161
  refresh_token: process.env.GBP_REFRESH_TOKEN,
22
162
  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
163
+ }),
164
+ cache: "no-store"
32
165
  };
33
- return cached.accessToken;
166
+ const { response } = await fetchWithRetry(TOKEN_URI, init, "oauth.refresh", resolvePolicy(request));
167
+ if (!response.ok) {
168
+ const body = await readErrorBody(response);
169
+ let error;
170
+ let description;
171
+ try {
172
+ const parsed = JSON.parse(body);
173
+ if (typeof parsed.error === "string") error = parsed.error;
174
+ if (typeof parsed.error_description === "string") description = excerpt(parsed.error_description, 200);
175
+ } catch {
176
+ if (/\binvalid_grant\b/.test(body)) error = "invalid_grant";
177
+ else if (/\binvalid_client\b/.test(body)) error = "invalid_client";
178
+ }
179
+ if (error === "invalid_grant") cached = null;
180
+ const code = error === "invalid_grant" || error === "invalid_client" ? error : "token_request_failed";
181
+ throw new GbpAuthError(code, response.status, description ?? (code === "token_request_failed" && body ? excerpt(body, 200) : void 0));
182
+ }
183
+ const data = await response.json();
184
+ if (typeof data.access_token !== "string" || !data.access_token) {
185
+ throw new GbpAuthError("token_request_failed", response.status, "token response had no access_token");
186
+ }
187
+ const lifetime = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0 ? data.expires_in : DEFAULT_LIFETIME_S;
188
+ return { accessToken: data.access_token, expiresAt: Date.now() + lifetime * 1e3 - EXPIRY_SKEW_MS };
189
+ }
190
+ async function getGoogleOAuthAccessToken(options = {}) {
191
+ if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) {
192
+ return cached.accessToken;
193
+ }
194
+ if (!hasGoogleOAuthCredentials()) return null;
195
+ if (!refreshing) {
196
+ refreshing = refresh(options.request).then((token) => {
197
+ cached = token;
198
+ return token;
199
+ }).finally(() => {
200
+ refreshing = null;
201
+ });
202
+ }
203
+ return (await refreshing).accessToken;
34
204
  }
35
205
 
36
206
  // src/reviews.ts
@@ -42,6 +212,7 @@ var STAR_VALUES = {
42
212
  FIVE: 5
43
213
  };
44
214
  var MAX_PAGE_SIZE = 50;
215
+ var DEFAULT_MAX_PAGES = 50;
45
216
  function shuffleArray(arr) {
46
217
  const copy = [...arr];
47
218
  for (let i = copy.length - 1; i > 0; i--) {
@@ -51,19 +222,49 @@ function shuffleArray(arr) {
51
222
  return copy;
52
223
  }
53
224
  function isUsableReview(r, filterMinStars) {
54
- if (!r.comment || !r.starRating) return false;
225
+ if (!r.comment || !r.starRating || !(r.starRating in STAR_VALUES)) return false;
55
226
  if (filterMinStars !== void 0 && STAR_VALUES[r.starRating] < filterMinStars) return false;
56
227
  return true;
57
228
  }
58
229
  var EMPTY = {
59
230
  averageRating: null,
60
- totalReviewCount: 0,
231
+ totalReviewCount: null,
61
232
  reviews: []
62
233
  };
63
234
  function isBusinessProfileConfigured() {
64
235
  return hasGoogleOAuthCredentials() && Boolean(process.env.GOOGLE_BUSINESS_ACCOUNT_ID) && Boolean(process.env.GOOGLE_BUSINESS_LOCATION_ID);
65
236
  }
66
- function toBusinessReview(r) {
237
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
238
+ var str = (v) => typeof v === "string" ? v : void 0;
239
+ function parseReview(v) {
240
+ if (!isRecord(v) || typeof v.reviewId !== "string") return null;
241
+ const reviewer = isRecord(v.reviewer) ? { displayName: str(v.reviewer.displayName), profilePhotoUrl: str(v.reviewer.profilePhotoUrl) } : void 0;
242
+ const reply = isRecord(v.reviewReply) ? {
243
+ comment: str(v.reviewReply.comment),
244
+ updateTime: str(v.reviewReply.updateTime),
245
+ reviewReplyState: str(v.reviewReply.reviewReplyState),
246
+ policyViolation: str(v.reviewReply.policyViolation)
247
+ } : void 0;
248
+ 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;
249
+ return {
250
+ reviewId: v.reviewId,
251
+ reviewer,
252
+ starRating: str(v.starRating),
253
+ comment: str(v.comment),
254
+ createTime: str(v.createTime),
255
+ reviewReply: reply,
256
+ reviewMediaItems: media,
257
+ reviewReplyUrl: str(v.reviewReplyUrl)
258
+ };
259
+ }
260
+ function parseListReviewsResponse(body) {
261
+ if (!isRecord(body)) throw new TypeError("Business Profile reviews response was not an object");
262
+ const reviews = Array.isArray(body.reviews) ? body.reviews.map(parseReview).filter((r) => r !== null) : [];
263
+ const average = typeof body.averageRating === "number" && Number.isFinite(body.averageRating) && body.averageRating >= 0 && body.averageRating <= 5 ? body.averageRating : void 0;
264
+ const total = typeof body.totalReviewCount === "number" && Number.isInteger(body.totalReviewCount) && body.totalReviewCount >= 0 ? body.totalReviewCount : void 0;
265
+ return { reviews, averageRating: average, totalReviewCount: total, nextPageToken: str(body.nextPageToken) || void 0 };
266
+ }
267
+ function toBusinessReview(r, includeUnapprovedReplies) {
67
268
  const review = {
68
269
  id: r.reviewId,
69
270
  author: r.reviewer?.displayName ?? "Anonymous",
@@ -72,15 +273,33 @@ function toBusinessReview(r) {
72
273
  comment: r.comment ?? "",
73
274
  createdAt: r.createTime ?? ""
74
275
  };
75
- if (r.reviewReply?.comment) {
76
- review.reply = { text: r.reviewReply.comment, updatedAt: r.reviewReply.updateTime ?? "" };
276
+ const reply = r.reviewReply;
277
+ if (reply?.comment) {
278
+ const state = reply.reviewReplyState === "PENDING" || reply.reviewReplyState === "REJECTED" || reply.reviewReplyState === "APPROVED" ? reply.reviewReplyState : void 0;
279
+ if (includeUnapprovedReplies || state !== "PENDING" && state !== "REJECTED") {
280
+ review.reply = { text: reply.comment, updatedAt: reply.updateTime ?? "" };
281
+ if (state) review.reply.state = state;
282
+ if (state === "REJECTED" && reply.policyViolation) review.reply.policyViolation = reply.policyViolation;
283
+ }
77
284
  }
285
+ const media = (r.reviewMediaItems ?? []).filter((m) => Boolean(m.thumbnailUrl));
286
+ if (media.length > 0) {
287
+ review.media = media.map((m) => ({
288
+ thumbnailUrl: m.thumbnailUrl,
289
+ ...m.thumbnailLabel ? { label: m.thumbnailLabel } : {},
290
+ ...m.videoUrl ? { videoUrl: m.videoUrl } : {}
291
+ }));
292
+ }
293
+ if (r.reviewReplyUrl) review.replyUrl = r.reviewReplyUrl;
78
294
  return review;
79
295
  }
80
296
  async function getBusinessReviews(options = {}) {
81
- const { limit, filterMinStars, next, order = "shuffle" } = options;
297
+ const { limit, filterMinStars, next, order = "shuffle", includeUnapprovedReplies = false, request } = options;
298
+ const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
299
+ if (!Number.isInteger(maxPages) || maxPages < 1) throw new RangeError(`maxPages must be an integer >= 1, got ${maxPages}`);
82
300
  if (!isBusinessProfileConfigured()) return EMPTY;
83
- const token = await getGoogleOAuthAccessToken();
301
+ const policy = resolvePolicy(request);
302
+ let token = await getGoogleOAuthAccessToken({ request });
84
303
  if (!token) return EMPTY;
85
304
  const account = process.env.GOOGLE_BUSINESS_ACCOUNT_ID;
86
305
  const location = process.env.GOOGLE_BUSINESS_LOCATION_ID;
@@ -88,32 +307,68 @@ async function getBusinessReviews(options = {}) {
88
307
  let averageRating;
89
308
  let totalReviewCount;
90
309
  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}` },
310
+ const seenTokens = /* @__PURE__ */ new Set();
311
+ let pages = 0;
312
+ const fetchPage = (accessToken, endpoint) => {
313
+ const init = {
314
+ headers: { Authorization: `Bearer ${accessToken}` },
95
315
  next: next ?? { revalidate: 3600, tags: ["google-reviews"] }
96
316
  };
97
- const res = await fetch(endpoint, fetchOptions);
98
- if (!res.ok) {
99
- throw new Error(`Business Profile reviews failed: ${res.status} ${await res.text()}`);
317
+ return fetchWithRetry(endpoint, init, "reviews.list", policy);
318
+ };
319
+ for (; ; ) {
320
+ const endpoint = `https://mybusiness.googleapis.com/v4/accounts/${account}/locations/${location}/reviews?orderBy=updateTime%20desc&pageSize=${MAX_PAGE_SIZE}` + (pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : "");
321
+ let attempt = await fetchPage(token, endpoint);
322
+ if (attempt.response.status === 401) {
323
+ invalidateAccessToken(token);
324
+ const fresh = await getGoogleOAuthAccessToken({ forceRefresh: true, request });
325
+ if (!fresh) return EMPTY;
326
+ token = fresh;
327
+ attempt = await fetchPage(token, endpoint);
328
+ }
329
+ const { response, attempts, retryAfterMs } = attempt;
330
+ if (!response.ok) {
331
+ throw new GbpApiError({
332
+ operation: "reviews.list",
333
+ status: response.status,
334
+ retryable: RETRYABLE_STATUSES.has(response.status),
335
+ attempts,
336
+ body: excerpt(await readErrorBody(response)),
337
+ retryAfterMs
338
+ });
100
339
  }
101
- const data = await res.json();
102
- raw.push(...data.reviews ?? []);
103
- averageRating = data.averageRating ?? averageRating;
104
- totalReviewCount = data.totalReviewCount ?? totalReviewCount;
340
+ const data = parseListReviewsResponse(await response.json());
341
+ pages++;
342
+ raw.push(...data.reviews);
343
+ averageRating ?? (averageRating = data.averageRating);
344
+ totalReviewCount ?? (totalReviewCount = data.totalReviewCount);
105
345
  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);
346
+ const enough = limit !== void 0 && raw.filter((r) => isUsableReview(r, filterMinStars)).length >= limit;
347
+ if (!pageToken || enough) break;
348
+ if (seenTokens.has(pageToken)) throw new GbpPaginationError("repeated_token", pages);
349
+ seenTokens.add(pageToken);
350
+ if (pages >= maxPages) throw new GbpPaginationError("page_limit", pages);
351
+ }
352
+ const reviews = raw.filter((r) => isUsableReview(r, filterMinStars)).map((r) => toBusinessReview(r, includeUnapprovedReplies)).slice(0, limit);
108
353
  return {
109
354
  averageRating: averageRating ?? null,
110
- totalReviewCount: totalReviewCount ?? reviews.length,
355
+ totalReviewCount: totalReviewCount ?? null,
111
356
  reviews: order === "api" ? reviews : shuffleArray(reviews)
112
357
  };
113
358
  }
114
359
  export {
360
+ DEFAULT_MAX_PAGES,
361
+ DEFAULT_REQUEST_POLICY,
362
+ GbpApiError,
363
+ GbpAuthError,
364
+ GbpError,
365
+ GbpPaginationError,
366
+ GbpTimeoutError,
115
367
  getBusinessReviews,
116
368
  getGoogleOAuthAccessToken,
117
369
  hasGoogleOAuthCredentials,
118
- isBusinessProfileConfigured
370
+ invalidateAccessToken,
371
+ isBusinessProfileConfigured,
372
+ parseListReviewsResponse,
373
+ parseRetryAfter
119
374
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/gbp",
3
- "version": "0.3.2",
3
+ "version": "0.4.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,12 +28,6 @@
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",
@@ -38,19 +35,24 @@
38
35
  "vitest": "^2.1.9"
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
+ }