@domandigital/gbp 0.3.1 → 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 +43 -1
- package/README.md +88 -15
- package/dist/index.cjs +309 -39
- package/dist/index.d.cts +220 -4
- package/dist/index.d.ts +220 -4
- package/dist/index.js +298 -38
- package/package.json +17 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bcc2a83: Reviews survive a bad minute at Google, and stop publishing a count Google never gave.
|
|
8
|
+
|
|
9
|
+
- **Breaking: `totalReviewCount` is now `number | null`.** A missing count used to fall back to the length of the filtered, limited list, which would put a wrong number into anything built from it (an `aggregateRating`, a "120 reviews" badge). Now it is `null`, and so is an unconfigured result. Handle `null` by rendering no number.
|
|
10
|
+
- A 401 drops the cached access token, refreshes once and asks for the same page again; a second 401 throws. Concurrent callers share one token refresh.
|
|
11
|
+
- Every request has an 8 s deadline. 429 and 500/502/503/504 are retried up to three attempts with full-jitter backoff, honouring `Retry-After` up to 30 s. Tunable with `request`.
|
|
12
|
+
- Pagination stops on a repeated page token or after `maxPages` (default 50) with a `GbpPaginationError`, rather than looping.
|
|
13
|
+
- Typed errors: `GbpAuthError` (`reauthorizationRequired` on `invalid_grant`), `GbpApiError`, `GbpTimeoutError`, `GbpPaginationError`, all extending `GbpError`, none carrying a credential.
|
|
14
|
+
- New review fields from Google: attached photos and videos (`media`), the reply link (`replyUrl`), and the reply's moderation `state` with `policyViolation`. Replies Google has not approved are left off unless `includeUnapprovedReplies` is set, so a site never shows a rejected reply.
|
|
15
|
+
- The response is validated: a malformed review is skipped, a malformed page throws.
|
|
16
|
+
|
|
17
|
+
- 3eb1560: CommonJS consumers now get the CommonJS type declarations. The `exports` map put a top-level `types` (the ESM `.d.ts`) ahead of `require`, so TypeScript resolving a `require` under `node16` stopped at the ESM file ("Masquerading as ESM" in @arethetypeswrong/cli). `types` now sits inside `import` and `require` separately. Runtime behaviour is unchanged.
|
|
18
|
+
|
|
19
|
+
Requires Node 22.12 or later (`engines.node`). Node 20 reached end of life on 2026-04-30. Also declares `sideEffects` so bundlers can tree-shake (craft keeps its CSS).
|
|
20
|
+
|
|
3
21
|
All notable changes to `@domandigital/gbp`.
|
|
4
22
|
|
|
5
23
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
@@ -12,6 +30,29 @@ time.
|
|
|
12
30
|
|
|
13
31
|
## [Unreleased]
|
|
14
32
|
|
|
33
|
+
## [0.3.2] - 2026-08-16
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `GetBusinessReviewsOptions.order`, `"shuffle" | "api"`. Default is
|
|
38
|
+
`"shuffle"`, unchanged from prior behaviour. `"api"` preserves the Business
|
|
39
|
+
Profile API's own `updateTime desc` ordering instead.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **`limit` could silently under-return.** Pagination stopped once `raw.length`
|
|
44
|
+
reached `limit`, but the comment/star-rating filter that determines which
|
|
45
|
+
reviews are actually usable ran after the loop. A page that was mostly
|
|
46
|
+
star-only ratings with no comment -- which the API allows -- could satisfy
|
|
47
|
+
the raw count while producing far fewer, or zero, usable reviews, with no
|
|
48
|
+
error and no further pages fetched. The stopping condition now counts
|
|
49
|
+
usable reviews, so `limit` means "give me N reviews you can show."
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- `limit`'s doc comment now says what it actually counts (usable reviews, not
|
|
54
|
+
raw API results), matching the fix above.
|
|
55
|
+
|
|
15
56
|
## [0.3.1] - 2026-08-16
|
|
16
57
|
|
|
17
58
|
### Added
|
|
@@ -97,7 +138,8 @@ time.
|
|
|
97
138
|
`filterMinStars` and a Next.js `next` cache hint. Returns empty results rather
|
|
98
139
|
than throwing when unconfigured, so UI can render unconditionally.
|
|
99
140
|
|
|
100
|
-
[Unreleased]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.
|
|
141
|
+
[Unreleased]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.2...HEAD
|
|
142
|
+
[0.3.2]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.1...v0.3.2
|
|
101
143
|
[0.3.1]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.0...v0.3.1
|
|
102
144
|
[0.3.0]: https://github.com/Doman-Digital/dd-gbp/compare/v0.2.0...v0.3.0
|
|
103
145
|
[0.2.0]: https://github.com/Doman-Digital/dd-gbp/compare/v0.1.0...v0.2.0
|
package/README.md
CHANGED
|
@@ -38,10 +38,28 @@ const reviewNodes = reviews.map((r) =>
|
|
|
38
38
|
);
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
`averageRating` / `totalReviewCount`
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
claiming 7 reviews when Google has 6) is the bug this
|
|
41
|
+
`averageRating` / `totalReviewCount` are Google's own numbers for the whole
|
|
42
|
+
profile, rating-only reviews included. Show them instead of hardcoding a
|
|
43
|
+
rating/count literal in a site's settings file: that literal drifting from the
|
|
44
|
+
real listing (a site claiming 7 reviews when Google has 6) is the bug this
|
|
45
|
+
package exists to kill. Both are `null` when Google did not report them;
|
|
46
|
+
render nothing in that case rather than a number.
|
|
47
|
+
|
|
48
|
+
### Structured data: these reviews earn no stars on the business's own site
|
|
49
|
+
|
|
50
|
+
Google calls a review "self-serving" when a review about a business sits on
|
|
51
|
+
that business's own website, whether written into the markup or through an
|
|
52
|
+
embedded widget. For `LocalBusiness` and `Organization`, Google shows review
|
|
53
|
+
stars only "for sites that capture reviews about other" businesses
|
|
54
|
+
(developers.google.com/search/docs/appearance/structured-data/review-snippet,
|
|
55
|
+
updated 2026-09-08). So `Review` or `AggregateRating` markup built from these
|
|
56
|
+
reviews, on the client's own site, is ineligible for stars.
|
|
57
|
+
|
|
58
|
+
It is not a penalty: Google's announcement of the rule says you do not need to
|
|
59
|
+
remove such markup and "You won't get a manual action just for this"
|
|
60
|
+
(developers.google.com/search/blog/2019/09/making-review-rich-results-more-helpful).
|
|
61
|
+
Showing the reviews on the page is unaffected. Just do not sell or expect the
|
|
62
|
+
stars. `@domandigital/graph`'s `findGraphIssues` can flag the pattern.
|
|
45
63
|
|
|
46
64
|
## Why Business Profile API, not Places API
|
|
47
65
|
|
|
@@ -59,7 +77,7 @@ pnpm add @domandigital/gbp
|
|
|
59
77
|
|
|
60
78
|
Public on npm, Apache-2.0, published with provenance from a tagged release.
|
|
61
79
|
Zero runtime dependencies. ESM and CJS builds ship together, each with its own
|
|
62
|
-
types. Requires Node
|
|
80
|
+
types. Requires Node 22.12 or newer.
|
|
63
81
|
|
|
64
82
|
Upgrading a repo that still pins `github:Doman-Digital/dd-gbp#vX.Y.Z`? Swap it
|
|
65
83
|
for a semver range.
|
|
@@ -122,8 +140,8 @@ Paginates through every review (GBP caps each page at 50) and returns:
|
|
|
122
140
|
|
|
123
141
|
```ts
|
|
124
142
|
interface BusinessReviewsResult {
|
|
125
|
-
averageRating: number | null;
|
|
126
|
-
totalReviewCount: number;
|
|
143
|
+
averageRating: number | null; // Google's, for the whole profile; null if not reported
|
|
144
|
+
totalReviewCount: number | null; // Google's, for the whole profile; null if not reported
|
|
127
145
|
reviews: BusinessReview[];
|
|
128
146
|
}
|
|
129
147
|
|
|
@@ -134,14 +152,26 @@ interface BusinessReview {
|
|
|
134
152
|
rating: number; // 1-5
|
|
135
153
|
comment: string;
|
|
136
154
|
createdAt: string; // ISO
|
|
137
|
-
reply?: {
|
|
155
|
+
reply?: { // no author field -- see below
|
|
156
|
+
text: string;
|
|
157
|
+
updatedAt: string;
|
|
158
|
+
state?: "PENDING" | "REJECTED" | "APPROVED"; // Google's moderation state
|
|
159
|
+
policyViolation?: string; // why, when REJECTED
|
|
160
|
+
};
|
|
161
|
+
media?: { thumbnailUrl: string; label?: string; videoUrl?: string }[]; // photos/videos the reviewer attached
|
|
162
|
+
replyUrl?: string; // Google's URL for replying, for an owner-facing surface
|
|
138
163
|
}
|
|
139
164
|
```
|
|
140
165
|
|
|
166
|
+
`totalReviewCount` and `averageRating` are never computed from the returned
|
|
167
|
+
list. Before 0.4.0, a missing `totalReviewCount` fell back to the length of
|
|
168
|
+
the filtered, limited list, which would have published a wrong count.
|
|
169
|
+
|
|
141
170
|
Options:
|
|
142
171
|
|
|
143
|
-
- `limit?: number` -- stop paginating once this many reviews are
|
|
144
|
-
|
|
172
|
+
- `limit?: number` -- stop paginating once this many *usable* reviews are
|
|
173
|
+
collected (a raw result needs both a comment and a star rating to count, and
|
|
174
|
+
`filterMinStars` narrows it further). Omit to fetch all of them.
|
|
145
175
|
- `filterMinStars?: number` -- drop reviews below this rating. Use for a
|
|
146
176
|
public testimonial feed; omit for an owner-facing surface where the point
|
|
147
177
|
is to see everything. `totalReviewCount` always reflects the location's
|
|
@@ -149,12 +179,52 @@ Options:
|
|
|
149
179
|
- `next?: { revalidate?: number; tags?: string[] }` -- passed straight through
|
|
150
180
|
to `fetch`'s Next.js cache-hint augmentation. A no-op outside Next.js.
|
|
151
181
|
Default: revalidate hourly.
|
|
182
|
+
- `order?: "shuffle" | "api"` -- `"shuffle"` (default) randomizes the returned
|
|
183
|
+
order, applied after `limit`. `"api"` preserves the Business Profile API's
|
|
184
|
+
own ordering (`updateTime desc`, most recent first). Pick `"api"` for
|
|
185
|
+
anything that should read as chronological, e.g. an activity feed; the
|
|
186
|
+
default suits a testimonial grid where a fixed order would look stale.
|
|
187
|
+
- `includeUnapprovedReplies?: boolean` -- default `false`: an owner reply
|
|
188
|
+
Google has marked `PENDING` or `REJECTED` is left off, so a public page never
|
|
189
|
+
shows a reply Google does not. Set `true` for an owner-facing surface, where
|
|
190
|
+
`reply.state` and `reply.policyViolation` say what happened.
|
|
191
|
+
- `maxPages?: number` -- stop after this many pages (default 50, i.e. 2,500
|
|
192
|
+
reviews) rather than loop.
|
|
193
|
+
- `request?: { timeoutMs?, maxAttempts?, baseDelayMs?, maxDelayMs?, maxRetryAfterMs?, signal? }`
|
|
194
|
+
-- deadline and retry policy for every request the call makes. Defaults: 8 s
|
|
195
|
+
per attempt, 3 attempts, full-jitter backoff from 250 ms capped at 5 s, and a
|
|
196
|
+
`Retry-After` of up to 30 s honoured.
|
|
152
197
|
|
|
153
198
|
Never throws on missing configuration -- `isBusinessProfileConfigured()` gates
|
|
154
199
|
internally and returns an empty result, so UI can render unconditionally.
|
|
155
|
-
Does throw on a real
|
|
156
|
-
|
|
157
|
-
|
|
200
|
+
Does throw on a real failure, so a calling route should catch and degrade
|
|
201
|
+
explicitly if it wants zero-downtime behaviour on a Google outage.
|
|
202
|
+
|
|
203
|
+
### Failures, and what each one means
|
|
204
|
+
|
|
205
|
+
Every request has a deadline. A 429 or a transient 500/502/503/504 is retried
|
|
206
|
+
a few times with backoff; Google documents the 429 for quota
|
|
207
|
+
(developers.google.com/my-business/content/limits). Nothing else is retried.
|
|
208
|
+
A 401 means the cached access token is no longer good: it is dropped,
|
|
209
|
+
refreshed once, and the page is asked for again; a second 401 throws.
|
|
210
|
+
|
|
211
|
+
All errors extend `GbpError`, and none carries a credential:
|
|
212
|
+
|
|
213
|
+
| Error | When | What to do |
|
|
214
|
+
|---|---|---|
|
|
215
|
+
| `GbpAuthError` with `reauthorizationRequired: true` (`code: "invalid_grant"`) | Google refused the refresh token | Reauthorise the account and replace `GBP_REFRESH_TOKEN`. Retrying does nothing. |
|
|
216
|
+
| `GbpAuthError` (`code: "invalid_client"`) | The client id or secret is wrong | Fix the environment. |
|
|
217
|
+
| `GbpApiError` | The API returned an error after the retry budget. `status`, `retryable`, `attempts`, and `retryAfterMs` when Google asked for a long wait | Degrade, and try again on the next revalidation. |
|
|
218
|
+
| `GbpTimeoutError` | An attempt passed its deadline | As above. |
|
|
219
|
+
| `GbpPaginationError` | Google repeated a page token, or `maxPages` ran out | Report it: something upstream is wrong. |
|
|
220
|
+
|
|
221
|
+
Refresh tokens stop working when the user revokes access, when a token goes
|
|
222
|
+
unused for six months, when the account passes 100 live refresh tokens for
|
|
223
|
+
the client (the oldest is dropped without warning), and **after 7 days if the
|
|
224
|
+
OAuth app is still in "Testing" status**: publish the app, or expect weekly
|
|
225
|
+
reauthorisation (developers.google.com/identity/protocols/oauth2). Google can
|
|
226
|
+
also delete an OAuth client that goes unused, recoverable for 30 days
|
|
227
|
+
(developers.google.com/identity/protocols/oauth2/web-server).
|
|
158
228
|
|
|
159
229
|
### Why a reply has no author field
|
|
160
230
|
|
|
@@ -174,5 +244,8 @@ True once all five env vars above are set.
|
|
|
174
244
|
|
|
175
245
|
Lower-level OAuth primitives, exported in case a consumer needs the raw
|
|
176
246
|
access token for another Business Profile endpoint this package doesn't wrap
|
|
177
|
-
(e.g. business hours
|
|
178
|
-
|
|
247
|
+
(e.g. business hours). The token is cached in-process and refreshed a minute
|
|
248
|
+
before expiry; concurrent callers share one refresh.
|
|
249
|
+
`getGoogleOAuthAccessToken({ forceRefresh: true })` refreshes now, and
|
|
250
|
+
`invalidateAccessToken(token)` drops a token an API has rejected (only if it is
|
|
251
|
+
still the cached one).
|
package/dist/index.cjs
CHANGED
|
@@ -20,28 +20,178 @@ 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_REQUEST_POLICY: () => DEFAULT_REQUEST_POLICY,
|
|
25
|
+
GbpApiError: () => GbpApiError,
|
|
26
|
+
GbpAuthError: () => GbpAuthError,
|
|
27
|
+
GbpError: () => GbpError,
|
|
28
|
+
GbpPaginationError: () => GbpPaginationError,
|
|
29
|
+
GbpTimeoutError: () => GbpTimeoutError,
|
|
23
30
|
getBusinessReviews: () => getBusinessReviews,
|
|
24
31
|
getGoogleOAuthAccessToken: () => getGoogleOAuthAccessToken,
|
|
25
32
|
hasGoogleOAuthCredentials: () => hasGoogleOAuthCredentials,
|
|
26
|
-
|
|
33
|
+
invalidateAccessToken: () => invalidateAccessToken,
|
|
34
|
+
isBusinessProfileConfigured: () => isBusinessProfileConfigured,
|
|
35
|
+
parseListReviewsResponse: () => parseListReviewsResponse,
|
|
36
|
+
parseRetryAfter: () => parseRetryAfter
|
|
27
37
|
});
|
|
28
38
|
module.exports = __toCommonJS(index_exports);
|
|
29
39
|
|
|
40
|
+
// src/errors.ts
|
|
41
|
+
var GbpError = class extends Error {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = new.target.name;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var GbpAuthError = class extends GbpError {
|
|
48
|
+
constructor(code, status, description) {
|
|
49
|
+
super(
|
|
50
|
+
`Google OAuth token refresh failed: ${status} ${code}${description ? ` (${description})` : ""}` + (code === "invalid_grant" ? ". Reauthorise the Google account and replace GBP_REFRESH_TOKEN." : "")
|
|
51
|
+
);
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.reauthorizationRequired = code === "invalid_grant";
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.description = description;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var GbpApiError = class extends GbpError {
|
|
59
|
+
constructor(options) {
|
|
60
|
+
super(`Business Profile reviews failed: ${options.status}${options.body ? ` ${options.body}` : ""}`);
|
|
61
|
+
this.operation = options.operation;
|
|
62
|
+
this.status = options.status;
|
|
63
|
+
this.retryable = options.retryable;
|
|
64
|
+
this.attempts = options.attempts;
|
|
65
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
var GbpTimeoutError = class extends GbpError {
|
|
69
|
+
constructor(operation, timeoutMs) {
|
|
70
|
+
super(`${operation} timed out after ${timeoutMs}ms`);
|
|
71
|
+
this.operation = operation;
|
|
72
|
+
this.timeoutMs = timeoutMs;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
var GbpPaginationError = class extends GbpError {
|
|
76
|
+
constructor(reason, pagesFetched) {
|
|
77
|
+
super(
|
|
78
|
+
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)`
|
|
79
|
+
);
|
|
80
|
+
this.reason = reason;
|
|
81
|
+
this.pagesFetched = pagesFetched;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
function excerpt(text, max = 300) {
|
|
85
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
86
|
+
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/http.ts
|
|
90
|
+
var DEFAULT_REQUEST_POLICY = {
|
|
91
|
+
timeoutMs: 8e3,
|
|
92
|
+
maxAttempts: 3,
|
|
93
|
+
baseDelayMs: 250,
|
|
94
|
+
maxDelayMs: 5e3,
|
|
95
|
+
maxRetryAfterMs: 3e4
|
|
96
|
+
};
|
|
97
|
+
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
98
|
+
function resolvePolicy(options = {}) {
|
|
99
|
+
const policy = { ...DEFAULT_REQUEST_POLICY, ...options };
|
|
100
|
+
const check = (name, min, integer = false) => {
|
|
101
|
+
const value = policy[name];
|
|
102
|
+
if (!Number.isFinite(value) || value < min || integer && !Number.isInteger(value)) {
|
|
103
|
+
throw new RangeError(`request.${name} must be ${integer ? "an integer" : "a number"} >= ${min}, got ${value}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
check("timeoutMs", 1);
|
|
107
|
+
check("maxAttempts", 1, true);
|
|
108
|
+
check("baseDelayMs", 0);
|
|
109
|
+
check("maxDelayMs", 0);
|
|
110
|
+
check("maxRetryAfterMs", 0);
|
|
111
|
+
return policy;
|
|
112
|
+
}
|
|
113
|
+
function parseRetryAfter(value, nowMs = Date.now()) {
|
|
114
|
+
if (!value) return null;
|
|
115
|
+
const v = value.trim();
|
|
116
|
+
if (/^\d+(\.\d+)?$/.test(v)) return Math.round(Number.parseFloat(v) * 1e3);
|
|
117
|
+
const at = Date.parse(v);
|
|
118
|
+
return Number.isNaN(at) ? null : Math.max(0, at - nowMs);
|
|
119
|
+
}
|
|
120
|
+
function backoffMs(attempt, policy, random = Math.random) {
|
|
121
|
+
const cap = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
|
|
122
|
+
return Math.floor(random() * cap);
|
|
123
|
+
}
|
|
124
|
+
function sleep(ms, signal) {
|
|
125
|
+
if (ms <= 0) return Promise.resolve();
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
128
|
+
const timer = setTimeout(resolve, ms);
|
|
129
|
+
signal?.addEventListener(
|
|
130
|
+
"abort",
|
|
131
|
+
() => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
reject(signal.reason);
|
|
134
|
+
},
|
|
135
|
+
{ once: true }
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function attemptOnce(url, init, operation, policy) {
|
|
140
|
+
const deadline = new AbortController();
|
|
141
|
+
const timer = setTimeout(() => deadline.abort(new GbpTimeoutError(operation, policy.timeoutMs)), policy.timeoutMs);
|
|
142
|
+
const signal = policy.signal ? AbortSignal.any([policy.signal, deadline.signal]) : deadline.signal;
|
|
143
|
+
try {
|
|
144
|
+
return await fetch(url, { ...init, signal });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (deadline.signal.aborted && !policy.signal?.aborted) throw new GbpTimeoutError(operation, policy.timeoutMs);
|
|
147
|
+
throw error;
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function fetchWithRetry(url, init, operation, policy) {
|
|
153
|
+
for (let attempt = 1; ; attempt++) {
|
|
154
|
+
if (policy.signal?.aborted) throw policy.signal.reason;
|
|
155
|
+
let response;
|
|
156
|
+
try {
|
|
157
|
+
response = await attemptOnce(url, init, operation, policy);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (policy.signal?.aborted || attempt >= policy.maxAttempts) throw error;
|
|
160
|
+
await sleep(backoffMs(attempt, policy), policy.signal);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (response.ok || !RETRYABLE_STATUSES.has(response.status)) return { response, attempts: attempt };
|
|
164
|
+
const retryAfter = parseRetryAfter(response.headers?.get?.("retry-after"));
|
|
165
|
+
if (retryAfter !== null && retryAfter > policy.maxRetryAfterMs) return { response, attempts: attempt, retryAfterMs: retryAfter };
|
|
166
|
+
if (attempt >= policy.maxAttempts) return { response, attempts: attempt };
|
|
167
|
+
await sleep(retryAfter ?? backoffMs(attempt, policy), policy.signal);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function readErrorBody(response) {
|
|
171
|
+
try {
|
|
172
|
+
const text = typeof response.text === "function" ? await response.text() : "";
|
|
173
|
+
return text.slice(0, 2048);
|
|
174
|
+
} catch {
|
|
175
|
+
return "";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
30
179
|
// src/oauth-client.ts
|
|
31
180
|
var TOKEN_URI = "https://oauth2.googleapis.com/token";
|
|
32
181
|
var EXPIRY_SKEW_MS = 60 * 1e3;
|
|
182
|
+
var DEFAULT_LIFETIME_S = 3600;
|
|
33
183
|
var cached = null;
|
|
184
|
+
var refreshing = null;
|
|
34
185
|
function hasGoogleOAuthCredentials() {
|
|
35
186
|
return Boolean(
|
|
36
187
|
process.env.GBP_CLIENT_ID && process.env.GBP_CLIENT_SECRET && process.env.GBP_REFRESH_TOKEN
|
|
37
188
|
);
|
|
38
189
|
}
|
|
39
|
-
|
|
40
|
-
if (cached
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const res = await fetch(TOKEN_URI, {
|
|
190
|
+
function invalidateAccessToken(accessToken) {
|
|
191
|
+
if (cached?.accessToken === accessToken) cached = null;
|
|
192
|
+
}
|
|
193
|
+
async function refresh(request) {
|
|
194
|
+
const init = {
|
|
45
195
|
method: "POST",
|
|
46
196
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
47
197
|
body: new URLSearchParams({
|
|
@@ -49,17 +199,47 @@ async function getGoogleOAuthAccessToken() {
|
|
|
49
199
|
client_secret: process.env.GBP_CLIENT_SECRET,
|
|
50
200
|
refresh_token: process.env.GBP_REFRESH_TOKEN,
|
|
51
201
|
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
|
|
202
|
+
}),
|
|
203
|
+
cache: "no-store"
|
|
61
204
|
};
|
|
62
|
-
|
|
205
|
+
const { response } = await fetchWithRetry(TOKEN_URI, init, "oauth.refresh", resolvePolicy(request));
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
const body = await readErrorBody(response);
|
|
208
|
+
let error;
|
|
209
|
+
let description;
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(body);
|
|
212
|
+
if (typeof parsed.error === "string") error = parsed.error;
|
|
213
|
+
if (typeof parsed.error_description === "string") description = excerpt(parsed.error_description, 200);
|
|
214
|
+
} catch {
|
|
215
|
+
if (/\binvalid_grant\b/.test(body)) error = "invalid_grant";
|
|
216
|
+
else if (/\binvalid_client\b/.test(body)) error = "invalid_client";
|
|
217
|
+
}
|
|
218
|
+
if (error === "invalid_grant") cached = null;
|
|
219
|
+
const code = error === "invalid_grant" || error === "invalid_client" ? error : "token_request_failed";
|
|
220
|
+
throw new GbpAuthError(code, response.status, description ?? (code === "token_request_failed" && body ? excerpt(body, 200) : void 0));
|
|
221
|
+
}
|
|
222
|
+
const data = await response.json();
|
|
223
|
+
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
224
|
+
throw new GbpAuthError("token_request_failed", response.status, "token response had no access_token");
|
|
225
|
+
}
|
|
226
|
+
const lifetime = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0 ? data.expires_in : DEFAULT_LIFETIME_S;
|
|
227
|
+
return { accessToken: data.access_token, expiresAt: Date.now() + lifetime * 1e3 - EXPIRY_SKEW_MS };
|
|
228
|
+
}
|
|
229
|
+
async function getGoogleOAuthAccessToken(options = {}) {
|
|
230
|
+
if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) {
|
|
231
|
+
return cached.accessToken;
|
|
232
|
+
}
|
|
233
|
+
if (!hasGoogleOAuthCredentials()) return null;
|
|
234
|
+
if (!refreshing) {
|
|
235
|
+
refreshing = refresh(options.request).then((token) => {
|
|
236
|
+
cached = token;
|
|
237
|
+
return token;
|
|
238
|
+
}).finally(() => {
|
|
239
|
+
refreshing = null;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return (await refreshing).accessToken;
|
|
63
243
|
}
|
|
64
244
|
|
|
65
245
|
// src/reviews.ts
|
|
@@ -71,6 +251,7 @@ var STAR_VALUES = {
|
|
|
71
251
|
FIVE: 5
|
|
72
252
|
};
|
|
73
253
|
var MAX_PAGE_SIZE = 50;
|
|
254
|
+
var DEFAULT_MAX_PAGES = 50;
|
|
74
255
|
function shuffleArray(arr) {
|
|
75
256
|
const copy = [...arr];
|
|
76
257
|
for (let i = copy.length - 1; i > 0; i--) {
|
|
@@ -79,15 +260,50 @@ function shuffleArray(arr) {
|
|
|
79
260
|
}
|
|
80
261
|
return copy;
|
|
81
262
|
}
|
|
263
|
+
function isUsableReview(r, filterMinStars) {
|
|
264
|
+
if (!r.comment || !r.starRating || !(r.starRating in STAR_VALUES)) return false;
|
|
265
|
+
if (filterMinStars !== void 0 && STAR_VALUES[r.starRating] < filterMinStars) return false;
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
82
268
|
var EMPTY = {
|
|
83
269
|
averageRating: null,
|
|
84
|
-
totalReviewCount:
|
|
270
|
+
totalReviewCount: null,
|
|
85
271
|
reviews: []
|
|
86
272
|
};
|
|
87
273
|
function isBusinessProfileConfigured() {
|
|
88
274
|
return hasGoogleOAuthCredentials() && Boolean(process.env.GOOGLE_BUSINESS_ACCOUNT_ID) && Boolean(process.env.GOOGLE_BUSINESS_LOCATION_ID);
|
|
89
275
|
}
|
|
90
|
-
|
|
276
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
277
|
+
var str = (v) => typeof v === "string" ? v : void 0;
|
|
278
|
+
function parseReview(v) {
|
|
279
|
+
if (!isRecord(v) || typeof v.reviewId !== "string") return null;
|
|
280
|
+
const reviewer = isRecord(v.reviewer) ? { displayName: str(v.reviewer.displayName), profilePhotoUrl: str(v.reviewer.profilePhotoUrl) } : void 0;
|
|
281
|
+
const reply = isRecord(v.reviewReply) ? {
|
|
282
|
+
comment: str(v.reviewReply.comment),
|
|
283
|
+
updateTime: str(v.reviewReply.updateTime),
|
|
284
|
+
reviewReplyState: str(v.reviewReply.reviewReplyState),
|
|
285
|
+
policyViolation: str(v.reviewReply.policyViolation)
|
|
286
|
+
} : void 0;
|
|
287
|
+
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;
|
|
288
|
+
return {
|
|
289
|
+
reviewId: v.reviewId,
|
|
290
|
+
reviewer,
|
|
291
|
+
starRating: str(v.starRating),
|
|
292
|
+
comment: str(v.comment),
|
|
293
|
+
createTime: str(v.createTime),
|
|
294
|
+
reviewReply: reply,
|
|
295
|
+
reviewMediaItems: media,
|
|
296
|
+
reviewReplyUrl: str(v.reviewReplyUrl)
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function parseListReviewsResponse(body) {
|
|
300
|
+
if (!isRecord(body)) throw new TypeError("Business Profile reviews response was not an object");
|
|
301
|
+
const reviews = Array.isArray(body.reviews) ? body.reviews.map(parseReview).filter((r) => r !== null) : [];
|
|
302
|
+
const average = typeof body.averageRating === "number" && Number.isFinite(body.averageRating) && body.averageRating >= 0 && body.averageRating <= 5 ? body.averageRating : void 0;
|
|
303
|
+
const total = typeof body.totalReviewCount === "number" && Number.isInteger(body.totalReviewCount) && body.totalReviewCount >= 0 ? body.totalReviewCount : void 0;
|
|
304
|
+
return { reviews, averageRating: average, totalReviewCount: total, nextPageToken: str(body.nextPageToken) || void 0 };
|
|
305
|
+
}
|
|
306
|
+
function toBusinessReview(r, includeUnapprovedReplies) {
|
|
91
307
|
const review = {
|
|
92
308
|
id: r.reviewId,
|
|
93
309
|
author: r.reviewer?.displayName ?? "Anonymous",
|
|
@@ -96,15 +312,33 @@ function toBusinessReview(r) {
|
|
|
96
312
|
comment: r.comment ?? "",
|
|
97
313
|
createdAt: r.createTime ?? ""
|
|
98
314
|
};
|
|
99
|
-
|
|
100
|
-
|
|
315
|
+
const reply = r.reviewReply;
|
|
316
|
+
if (reply?.comment) {
|
|
317
|
+
const state = reply.reviewReplyState === "PENDING" || reply.reviewReplyState === "REJECTED" || reply.reviewReplyState === "APPROVED" ? reply.reviewReplyState : void 0;
|
|
318
|
+
if (includeUnapprovedReplies || state !== "PENDING" && state !== "REJECTED") {
|
|
319
|
+
review.reply = { text: reply.comment, updatedAt: reply.updateTime ?? "" };
|
|
320
|
+
if (state) review.reply.state = state;
|
|
321
|
+
if (state === "REJECTED" && reply.policyViolation) review.reply.policyViolation = reply.policyViolation;
|
|
322
|
+
}
|
|
101
323
|
}
|
|
324
|
+
const media = (r.reviewMediaItems ?? []).filter((m) => Boolean(m.thumbnailUrl));
|
|
325
|
+
if (media.length > 0) {
|
|
326
|
+
review.media = media.map((m) => ({
|
|
327
|
+
thumbnailUrl: m.thumbnailUrl,
|
|
328
|
+
...m.thumbnailLabel ? { label: m.thumbnailLabel } : {},
|
|
329
|
+
...m.videoUrl ? { videoUrl: m.videoUrl } : {}
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
if (r.reviewReplyUrl) review.replyUrl = r.reviewReplyUrl;
|
|
102
333
|
return review;
|
|
103
334
|
}
|
|
104
335
|
async function getBusinessReviews(options = {}) {
|
|
105
|
-
const { limit, filterMinStars, next } = options;
|
|
336
|
+
const { limit, filterMinStars, next, order = "shuffle", includeUnapprovedReplies = false, request } = options;
|
|
337
|
+
const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
|
|
338
|
+
if (!Number.isInteger(maxPages) || maxPages < 1) throw new RangeError(`maxPages must be an integer >= 1, got ${maxPages}`);
|
|
106
339
|
if (!isBusinessProfileConfigured()) return EMPTY;
|
|
107
|
-
const
|
|
340
|
+
const policy = resolvePolicy(request);
|
|
341
|
+
let token = await getGoogleOAuthAccessToken({ request });
|
|
108
342
|
if (!token) return EMPTY;
|
|
109
343
|
const account = process.env.GOOGLE_BUSINESS_ACCOUNT_ID;
|
|
110
344
|
const location = process.env.GOOGLE_BUSINESS_LOCATION_ID;
|
|
@@ -112,33 +346,69 @@ async function getBusinessReviews(options = {}) {
|
|
|
112
346
|
let averageRating;
|
|
113
347
|
let totalReviewCount;
|
|
114
348
|
let pageToken;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
349
|
+
const seenTokens = /* @__PURE__ */ new Set();
|
|
350
|
+
let pages = 0;
|
|
351
|
+
const fetchPage = (accessToken, endpoint) => {
|
|
352
|
+
const init = {
|
|
353
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
119
354
|
next: next ?? { revalidate: 3600, tags: ["google-reviews"] }
|
|
120
355
|
};
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
356
|
+
return fetchWithRetry(endpoint, init, "reviews.list", policy);
|
|
357
|
+
};
|
|
358
|
+
for (; ; ) {
|
|
359
|
+
const endpoint = `https://mybusiness.googleapis.com/v4/accounts/${account}/locations/${location}/reviews?orderBy=updateTime%20desc&pageSize=${MAX_PAGE_SIZE}` + (pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : "");
|
|
360
|
+
let attempt = await fetchPage(token, endpoint);
|
|
361
|
+
if (attempt.response.status === 401) {
|
|
362
|
+
invalidateAccessToken(token);
|
|
363
|
+
const fresh = await getGoogleOAuthAccessToken({ forceRefresh: true, request });
|
|
364
|
+
if (!fresh) return EMPTY;
|
|
365
|
+
token = fresh;
|
|
366
|
+
attempt = await fetchPage(token, endpoint);
|
|
367
|
+
}
|
|
368
|
+
const { response, attempts, retryAfterMs } = attempt;
|
|
369
|
+
if (!response.ok) {
|
|
370
|
+
throw new GbpApiError({
|
|
371
|
+
operation: "reviews.list",
|
|
372
|
+
status: response.status,
|
|
373
|
+
retryable: RETRYABLE_STATUSES.has(response.status),
|
|
374
|
+
attempts,
|
|
375
|
+
body: excerpt(await readErrorBody(response)),
|
|
376
|
+
retryAfterMs
|
|
377
|
+
});
|
|
124
378
|
}
|
|
125
|
-
const data = await
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
379
|
+
const data = parseListReviewsResponse(await response.json());
|
|
380
|
+
pages++;
|
|
381
|
+
raw.push(...data.reviews);
|
|
382
|
+
averageRating ?? (averageRating = data.averageRating);
|
|
383
|
+
totalReviewCount ?? (totalReviewCount = data.totalReviewCount);
|
|
129
384
|
pageToken = data.nextPageToken;
|
|
130
|
-
|
|
131
|
-
|
|
385
|
+
const enough = limit !== void 0 && raw.filter((r) => isUsableReview(r, filterMinStars)).length >= limit;
|
|
386
|
+
if (!pageToken || enough) break;
|
|
387
|
+
if (seenTokens.has(pageToken)) throw new GbpPaginationError("repeated_token", pages);
|
|
388
|
+
seenTokens.add(pageToken);
|
|
389
|
+
if (pages >= maxPages) throw new GbpPaginationError("page_limit", pages);
|
|
390
|
+
}
|
|
391
|
+
const reviews = raw.filter((r) => isUsableReview(r, filterMinStars)).map((r) => toBusinessReview(r, includeUnapprovedReplies)).slice(0, limit);
|
|
132
392
|
return {
|
|
133
393
|
averageRating: averageRating ?? null,
|
|
134
|
-
totalReviewCount: totalReviewCount ??
|
|
135
|
-
reviews: shuffleArray(reviews)
|
|
394
|
+
totalReviewCount: totalReviewCount ?? null,
|
|
395
|
+
reviews: order === "api" ? reviews : shuffleArray(reviews)
|
|
136
396
|
};
|
|
137
397
|
}
|
|
138
398
|
// Annotate the CommonJS export names for ESM import in node:
|
|
139
399
|
0 && (module.exports = {
|
|
400
|
+
DEFAULT_MAX_PAGES,
|
|
401
|
+
DEFAULT_REQUEST_POLICY,
|
|
402
|
+
GbpApiError,
|
|
403
|
+
GbpAuthError,
|
|
404
|
+
GbpError,
|
|
405
|
+
GbpPaginationError,
|
|
406
|
+
GbpTimeoutError,
|
|
140
407
|
getBusinessReviews,
|
|
141
408
|
getGoogleOAuthAccessToken,
|
|
142
409
|
hasGoogleOAuthCredentials,
|
|
143
|
-
|
|
410
|
+
invalidateAccessToken,
|
|
411
|
+
isBusinessProfileConfigured,
|
|
412
|
+
parseListReviewsResponse,
|
|
413
|
+
parseRetryAfter
|
|
144
414
|
});
|