@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/CHANGELOG.md +29 -0
- package/README.md +109 -13
- package/dist/index.cjs +400 -39
- package/dist/index.d.cts +263 -3
- package/dist/index.d.ts +263 -3
- package/dist/index.js +383 -38
- package/package.json +18 -16
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
|
-
|
|
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
|
-
|
|
40
|
-
if (cached
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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 ??
|
|
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
|
-
|
|
504
|
+
invalidateAccessToken,
|
|
505
|
+
isBusinessProfileConfigured,
|
|
506
|
+
parseListReviewsResponse,
|
|
507
|
+
parsePublishedReviews,
|
|
508
|
+
parseRetryAfter,
|
|
509
|
+
publishedReviewsUrl
|
|
149
510
|
});
|