@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/CHANGELOG.md +18 -0
- package/README.md +80 -13
- package/dist/index.cjs +304 -39
- package/dist/index.d.cts +210 -3
- package/dist/index.d.ts +210 -3
- package/dist/index.js +293 -38
- package/package.json +17 -15
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
|
-
|
|
11
|
-
if (cached
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
+
"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
|
-
"
|
|
14
|
-
|
|
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": ">=
|
|
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-
|
|
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-
|
|
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
|
+
}
|