@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4588886: Read reviews from the file Doman Digital publishes, so a site needs no Google token.
|
|
8
|
+
|
|
9
|
+
- **`getPublishedReviews({ client })`** fetches `https://files.domandigital.co.uk/reviews/<client>.json` (override with `baseUrl` or `GBP_REVIEWS_BASE_URL`). One collector reads every listing with the only Google credential and the portal publishes the file each night, so a dead token makes reviews stale, not missing.
|
|
10
|
+
- Same shape as `getBusinessReviews`: Google's own `averageRating` and `totalReviewCount` for the whole listing, plus `reviews` (four and five stars with words, replies Google shows). Adds `syncedAt`, when Google was last read.
|
|
11
|
+
- Throws `GbpPublishedError` (`http`, `invalid`, `unknown_schema`) instead of returning an empty result, so the caller keeps the copy it already has. Retries 429 and transient 5xx within the usual deadline.
|
|
12
|
+
- Defaults to newest first (`order: "api"`) and a daily Next.js revalidate tagged `google-reviews`.
|
|
13
|
+
|
|
14
|
+
## 0.4.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- bcc2a83: Reviews survive a bad minute at Google, and stop publishing a count Google never gave.
|
|
19
|
+
|
|
20
|
+
- **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.
|
|
21
|
+
- 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.
|
|
22
|
+
- 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`.
|
|
23
|
+
- Pagination stops on a repeated page token or after `maxPages` (default 50) with a `GbpPaginationError`, rather than looping.
|
|
24
|
+
- Typed errors: `GbpAuthError` (`reauthorizationRequired` on `invalid_grant`), `GbpApiError`, `GbpTimeoutError`, `GbpPaginationError`, all extending `GbpError`, none carrying a credential.
|
|
25
|
+
- 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.
|
|
26
|
+
- The response is validated: a malformed review is skipped, a malformed page throws.
|
|
27
|
+
|
|
28
|
+
- 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.
|
|
29
|
+
|
|
30
|
+
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).
|
|
31
|
+
|
|
3
32
|
All notable changes to `@domandigital/gbp`.
|
|
4
33
|
|
|
5
34
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
package/README.md
CHANGED
|
@@ -38,10 +38,57 @@ 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.
|
|
63
|
+
|
|
64
|
+
## Reading the published file (no Google token on the site)
|
|
65
|
+
|
|
66
|
+
Client sites should read reviews from the file Doman Digital publishes rather
|
|
67
|
+
than from Google. One collector holds the only Google credential, the portal
|
|
68
|
+
stores what it reads, and each night it writes
|
|
69
|
+
`https://files.domandigital.co.uk/reviews/<client-slug>.json`:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { getPublishedReviews } from "@domandigital/gbp";
|
|
73
|
+
|
|
74
|
+
// Throws when the file is missing or malformed: keep the copy you already have.
|
|
75
|
+
const { averageRating, totalReviewCount, reviews, syncedAt } = await getPublishedReviews({
|
|
76
|
+
client: "chair-and-blade",
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The result has the same shape as `getBusinessReviews`, plus `syncedAt`. The
|
|
81
|
+
file already holds only what a site shows (four and five stars, with words,
|
|
82
|
+
and owner replies Google shows); `filterMinStars` can only raise that floor.
|
|
83
|
+
|
|
84
|
+
It throws `GbpPublishedError` instead of returning an empty result. Catch it
|
|
85
|
+
where the site has an older copy to fall back to (a Next.js fetch keeps its
|
|
86
|
+
last good response; a static build should refuse to publish and leave the
|
|
87
|
+
last deploy live), and render no rating, count or review section when there
|
|
88
|
+
is no copy at all. Never fall back to a hardcoded number.
|
|
89
|
+
|
|
90
|
+
A site that reads this file needs none of the `GBP_*` or `GOOGLE_BUSINESS_*`
|
|
91
|
+
variables below.
|
|
45
92
|
|
|
46
93
|
## Why Business Profile API, not Places API
|
|
47
94
|
|
|
@@ -59,7 +106,7 @@ pnpm add @domandigital/gbp
|
|
|
59
106
|
|
|
60
107
|
Public on npm, Apache-2.0, published with provenance from a tagged release.
|
|
61
108
|
Zero runtime dependencies. ESM and CJS builds ship together, each with its own
|
|
62
|
-
types. Requires Node
|
|
109
|
+
types. Requires Node 22.12 or newer.
|
|
63
110
|
|
|
64
111
|
Upgrading a repo that still pins `github:Doman-Digital/dd-gbp#vX.Y.Z`? Swap it
|
|
65
112
|
for a semver range.
|
|
@@ -122,8 +169,8 @@ Paginates through every review (GBP caps each page at 50) and returns:
|
|
|
122
169
|
|
|
123
170
|
```ts
|
|
124
171
|
interface BusinessReviewsResult {
|
|
125
|
-
averageRating: number | null;
|
|
126
|
-
totalReviewCount: number;
|
|
172
|
+
averageRating: number | null; // Google's, for the whole profile; null if not reported
|
|
173
|
+
totalReviewCount: number | null; // Google's, for the whole profile; null if not reported
|
|
127
174
|
reviews: BusinessReview[];
|
|
128
175
|
}
|
|
129
176
|
|
|
@@ -134,10 +181,21 @@ interface BusinessReview {
|
|
|
134
181
|
rating: number; // 1-5
|
|
135
182
|
comment: string;
|
|
136
183
|
createdAt: string; // ISO
|
|
137
|
-
reply?: {
|
|
184
|
+
reply?: { // no author field -- see below
|
|
185
|
+
text: string;
|
|
186
|
+
updatedAt: string;
|
|
187
|
+
state?: "PENDING" | "REJECTED" | "APPROVED"; // Google's moderation state
|
|
188
|
+
policyViolation?: string; // why, when REJECTED
|
|
189
|
+
};
|
|
190
|
+
media?: { thumbnailUrl: string; label?: string; videoUrl?: string }[]; // photos/videos the reviewer attached
|
|
191
|
+
replyUrl?: string; // Google's URL for replying, for an owner-facing surface
|
|
138
192
|
}
|
|
139
193
|
```
|
|
140
194
|
|
|
195
|
+
`totalReviewCount` and `averageRating` are never computed from the returned
|
|
196
|
+
list. Before 0.4.0, a missing `totalReviewCount` fell back to the length of
|
|
197
|
+
the filtered, limited list, which would have published a wrong count.
|
|
198
|
+
|
|
141
199
|
Options:
|
|
142
200
|
|
|
143
201
|
- `limit?: number` -- stop paginating once this many *usable* reviews are
|
|
@@ -155,12 +213,47 @@ Options:
|
|
|
155
213
|
own ordering (`updateTime desc`, most recent first). Pick `"api"` for
|
|
156
214
|
anything that should read as chronological, e.g. an activity feed; the
|
|
157
215
|
default suits a testimonial grid where a fixed order would look stale.
|
|
216
|
+
- `includeUnapprovedReplies?: boolean` -- default `false`: an owner reply
|
|
217
|
+
Google has marked `PENDING` or `REJECTED` is left off, so a public page never
|
|
218
|
+
shows a reply Google does not. Set `true` for an owner-facing surface, where
|
|
219
|
+
`reply.state` and `reply.policyViolation` say what happened.
|
|
220
|
+
- `maxPages?: number` -- stop after this many pages (default 50, i.e. 2,500
|
|
221
|
+
reviews) rather than loop.
|
|
222
|
+
- `request?: { timeoutMs?, maxAttempts?, baseDelayMs?, maxDelayMs?, maxRetryAfterMs?, signal? }`
|
|
223
|
+
-- deadline and retry policy for every request the call makes. Defaults: 8 s
|
|
224
|
+
per attempt, 3 attempts, full-jitter backoff from 250 ms capped at 5 s, and a
|
|
225
|
+
`Retry-After` of up to 30 s honoured.
|
|
158
226
|
|
|
159
227
|
Never throws on missing configuration -- `isBusinessProfileConfigured()` gates
|
|
160
228
|
internally and returns an empty result, so UI can render unconditionally.
|
|
161
|
-
Does throw on a real
|
|
162
|
-
|
|
163
|
-
|
|
229
|
+
Does throw on a real failure, so a calling route should catch and degrade
|
|
230
|
+
explicitly if it wants zero-downtime behaviour on a Google outage.
|
|
231
|
+
|
|
232
|
+
### Failures, and what each one means
|
|
233
|
+
|
|
234
|
+
Every request has a deadline. A 429 or a transient 500/502/503/504 is retried
|
|
235
|
+
a few times with backoff; Google documents the 429 for quota
|
|
236
|
+
(developers.google.com/my-business/content/limits). Nothing else is retried.
|
|
237
|
+
A 401 means the cached access token is no longer good: it is dropped,
|
|
238
|
+
refreshed once, and the page is asked for again; a second 401 throws.
|
|
239
|
+
|
|
240
|
+
All errors extend `GbpError`, and none carries a credential:
|
|
241
|
+
|
|
242
|
+
| Error | When | What to do |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| `GbpAuthError` with `reauthorizationRequired: true` (`code: "invalid_grant"`) | Google refused the refresh token | Reauthorise the account and replace `GBP_REFRESH_TOKEN`. Retrying does nothing. |
|
|
245
|
+
| `GbpAuthError` (`code: "invalid_client"`) | The client id or secret is wrong | Fix the environment. |
|
|
246
|
+
| `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. |
|
|
247
|
+
| `GbpTimeoutError` | An attempt passed its deadline | As above. |
|
|
248
|
+
| `GbpPaginationError` | Google repeated a page token, or `maxPages` ran out | Report it: something upstream is wrong. |
|
|
249
|
+
|
|
250
|
+
Refresh tokens stop working when the user revokes access, when a token goes
|
|
251
|
+
unused for six months, when the account passes 100 live refresh tokens for
|
|
252
|
+
the client (the oldest is dropped without warning), and **after 7 days if the
|
|
253
|
+
OAuth app is still in "Testing" status**: publish the app, or expect weekly
|
|
254
|
+
reauthorisation (developers.google.com/identity/protocols/oauth2). Google can
|
|
255
|
+
also delete an OAuth client that goes unused, recoverable for 30 days
|
|
256
|
+
(developers.google.com/identity/protocols/oauth2/web-server).
|
|
164
257
|
|
|
165
258
|
### Why a reply has no author field
|
|
166
259
|
|
|
@@ -180,5 +273,8 @@ True once all five env vars above are set.
|
|
|
180
273
|
|
|
181
274
|
Lower-level OAuth primitives, exported in case a consumer needs the raw
|
|
182
275
|
access token for another Business Profile endpoint this package doesn't wrap
|
|
183
|
-
(e.g. business hours
|
|
184
|
-
|
|
276
|
+
(e.g. business hours). The token is cached in-process and refreshed a minute
|
|
277
|
+
before expiry; concurrent callers share one refresh.
|
|
278
|
+
`getGoogleOAuthAccessToken({ forceRefresh: true })` refreshes now, and
|
|
279
|
+
`invalidateAccessToken(token)` drops a token an API has rejected (only if it is
|
|
280
|
+
still the cached one).
|