@domandigital/gbp 0.4.0 → 0.6.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 +23 -0
- package/README.md +75 -2
- package/dist/index.cjs +257 -10
- package/dist/index.d.cts +162 -5
- package/dist/index.d.ts +162 -5
- package/dist/index.js +245 -9
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7437827: A dead Google token or a Google 503 no longer reaches the page, and no longer files a new error-tracker issue every time.
|
|
8
|
+
|
|
9
|
+
- **`getBusinessReviewsSafe(options)` and `getPublishedReviewsSafe(options)`** never throw. On any failure they serve the last good result this process fetched (up to `maxStaleMs`, default 30 days), then your `fallback` (a snapshot, or a loader such as a KV read), then an empty result. `source` says which (`live`, `cache`, `fallback`, `empty`) and `error` holds the failure.
|
|
10
|
+
- They report each kind of failure at most once per `reportIntervalMs` (default one hour) per process, through `report(error, { fingerprint, context, transient, served, suppressed })`. Pass `fingerprint` and `context` straight to `Sentry.captureException`.
|
|
11
|
+
- After `invalid_grant` or `invalid_client` they skip the token request for `authRetryMs` (default five minutes) and serve the cache or fallback straight away.
|
|
12
|
+
- **Error messages are now short and stable**, with what varies moved to `error.context`: `GBP token refresh failed: invalid_grant` (was `Google OAuth token refresh failed: 400 invalid_grant (...)`), `GBP reviews.list failed: 5xx` (was the status plus Google's body), `GBP reviews.list timed out`. A published-file HTTP failure no longer puts the body in its message. Anything matching on the old message text needs updating; match on `error.code` instead.
|
|
13
|
+
- Every `GbpError` has `code`, `transient`, `context` and `fingerprint` (`["gbp", code]`). Every 5xx shares the code `api_5xx`, so a Google outage is one issue rather than one per status. `GbpApiError` also has `body`.
|
|
14
|
+
|
|
15
|
+
## 0.5.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 4588886: Read reviews from the file Doman Digital publishes, so a site needs no Google token.
|
|
20
|
+
|
|
21
|
+
- **`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.
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
- Defaults to newest first (`order: "api"`) and a daily Next.js revalidate tagged `google-reviews`.
|
|
25
|
+
|
|
3
26
|
## 0.4.0
|
|
4
27
|
|
|
5
28
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -61,6 +61,35 @@ remove such markup and "You won't get a manual action just for this"
|
|
|
61
61
|
Showing the reviews on the page is unaffected. Just do not sell or expect the
|
|
62
62
|
stars. `@domandigital/graph`'s `findGraphIssues` can flag the pattern.
|
|
63
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.
|
|
92
|
+
|
|
64
93
|
## Why Business Profile API, not Places API
|
|
65
94
|
|
|
66
95
|
Places API's `Review` object has no field for the business's reply, full
|
|
@@ -198,7 +227,45 @@ Options:
|
|
|
198
227
|
Never throws on missing configuration -- `isBusinessProfileConfigured()` gates
|
|
199
228
|
internally and returns an empty result, so UI can render unconditionally.
|
|
200
229
|
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
|
|
230
|
+
explicitly if it wants zero-downtime behaviour on a Google outage, or use
|
|
231
|
+
`getBusinessReviewsSafe`, which does that for it.
|
|
232
|
+
|
|
233
|
+
### `getBusinessReviewsSafe(options?)` and `getPublishedReviewsSafe(options)`: reviews that never throw
|
|
234
|
+
|
|
235
|
+
Use these in a page render. They take the same options as `getBusinessReviews`
|
|
236
|
+
and `getPublishedReviews`, catch every failure, and serve in order: the last
|
|
237
|
+
good result this process fetched for the same options (up to `maxStaleMs`,
|
|
238
|
+
default 30 days), then your `fallback`, then an empty result, which has a
|
|
239
|
+
`null` rating and count and an empty `reviews` list. `source` says which:
|
|
240
|
+
`"live"`, `"cache"`, `"fallback"` or `"empty"`, and `error` holds the failure
|
|
241
|
+
when there was one.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import * as Sentry from "@sentry/nextjs";
|
|
245
|
+
import { getBusinessReviewsSafe } from "@domandigital/gbp";
|
|
246
|
+
|
|
247
|
+
const { averageRating, totalReviewCount, reviews, source } = await getBusinessReviewsSafe({
|
|
248
|
+
filterMinStars: 4,
|
|
249
|
+
next: { revalidate: 86400, tags: ["google-reviews"] },
|
|
250
|
+
// A snapshot, or a loader for one (a KV read). Served as given.
|
|
251
|
+
fallback: () => kv.get(LAST_KNOWN_GOOD_KEY),
|
|
252
|
+
report: (error, { fingerprint, context, transient }) =>
|
|
253
|
+
Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
|
|
254
|
+
});
|
|
255
|
+
if (source === "live") await kv.set(LAST_KNOWN_GOOD_KEY, { averageRating, totalReviewCount, reviews });
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
- `report` runs at most once per kind of failure per `reportIntervalMs`
|
|
259
|
+
(default one hour) in each process, not on every request. It is told how
|
|
260
|
+
many it held back since the last report (`suppressed`). Without `report`, one
|
|
261
|
+
`console.warn` per window.
|
|
262
|
+
- After `invalid_grant` or `invalid_client` the live call is skipped for
|
|
263
|
+
`authRetryMs` (default five minutes), because retrying cannot fix it.
|
|
264
|
+
- A `fallback` is served as given: `limit` and `filterMinStars` are not
|
|
265
|
+
applied to it. A loader that throws counts as no fallback.
|
|
266
|
+
|
|
267
|
+
A static build that should refuse to publish rather than ship stale or empty
|
|
268
|
+
reviews should keep using the throwing functions.
|
|
202
269
|
|
|
203
270
|
### Failures, and what each one means
|
|
204
271
|
|
|
@@ -208,7 +275,13 @@ a few times with backoff; Google documents the 429 for quota
|
|
|
208
275
|
A 401 means the cached access token is no longer good: it is dropped,
|
|
209
276
|
refreshed once, and the page is asked for again; a second 401 throws.
|
|
210
277
|
|
|
211
|
-
All errors extend `GbpError`, and none carries a credential
|
|
278
|
+
All errors extend `GbpError`, and none carries a credential. Each message is
|
|
279
|
+
short and stable (`GBP token refresh failed: invalid_grant`, `GBP reviews.list
|
|
280
|
+
failed: 5xx`), so an error tracker files every occurrence under one issue.
|
|
281
|
+
What varies, such as the status, Google's description or a body excerpt, is in
|
|
282
|
+
`error.context`. `error.code` names the failure, `error.fingerprint` is
|
|
283
|
+
`["gbp", code]` to pass to Sentry, and `error.transient` says whether it may
|
|
284
|
+
clear without anyone acting. Every 5xx shares the code `api_5xx`.
|
|
212
285
|
|
|
213
286
|
| Error | When | What to do |
|
|
214
287
|
|---|---|---|
|
package/dist/index.cjs
CHANGED
|
@@ -20,36 +20,55 @@ 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_AUTH_RETRY_MS: () => DEFAULT_AUTH_RETRY_MS,
|
|
23
24
|
DEFAULT_MAX_PAGES: () => DEFAULT_MAX_PAGES,
|
|
25
|
+
DEFAULT_MAX_STALE_MS: () => DEFAULT_MAX_STALE_MS,
|
|
26
|
+
DEFAULT_PUBLISHED_REVIEWS_BASE_URL: () => DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
|
|
27
|
+
DEFAULT_REPORT_INTERVAL_MS: () => DEFAULT_REPORT_INTERVAL_MS,
|
|
24
28
|
DEFAULT_REQUEST_POLICY: () => DEFAULT_REQUEST_POLICY,
|
|
25
29
|
GbpApiError: () => GbpApiError,
|
|
26
30
|
GbpAuthError: () => GbpAuthError,
|
|
27
31
|
GbpError: () => GbpError,
|
|
28
32
|
GbpPaginationError: () => GbpPaginationError,
|
|
33
|
+
GbpPublishedError: () => GbpPublishedError,
|
|
29
34
|
GbpTimeoutError: () => GbpTimeoutError,
|
|
35
|
+
PUBLISHED_REVIEWS_SCHEMA: () => PUBLISHED_REVIEWS_SCHEMA,
|
|
30
36
|
getBusinessReviews: () => getBusinessReviews,
|
|
37
|
+
getBusinessReviewsSafe: () => getBusinessReviewsSafe,
|
|
31
38
|
getGoogleOAuthAccessToken: () => getGoogleOAuthAccessToken,
|
|
39
|
+
getPublishedReviews: () => getPublishedReviews,
|
|
40
|
+
getPublishedReviewsSafe: () => getPublishedReviewsSafe,
|
|
32
41
|
hasGoogleOAuthCredentials: () => hasGoogleOAuthCredentials,
|
|
33
42
|
invalidateAccessToken: () => invalidateAccessToken,
|
|
34
43
|
isBusinessProfileConfigured: () => isBusinessProfileConfigured,
|
|
35
44
|
parseListReviewsResponse: () => parseListReviewsResponse,
|
|
36
|
-
|
|
45
|
+
parsePublishedReviews: () => parsePublishedReviews,
|
|
46
|
+
parseRetryAfter: () => parseRetryAfter,
|
|
47
|
+
publishedReviewsUrl: () => publishedReviewsUrl
|
|
37
48
|
});
|
|
38
49
|
module.exports = __toCommonJS(index_exports);
|
|
39
50
|
|
|
40
51
|
// src/errors.ts
|
|
41
52
|
var GbpError = class extends Error {
|
|
42
|
-
constructor(message) {
|
|
53
|
+
constructor(message, code = "unknown", transient = false, context = {}) {
|
|
43
54
|
super(message);
|
|
44
55
|
this.name = new.target.name;
|
|
56
|
+
this.code = code;
|
|
57
|
+
this.transient = transient;
|
|
58
|
+
this.context = context;
|
|
59
|
+
}
|
|
60
|
+
/** `["gbp", code]`: one error-tracker issue per kind of failure. */
|
|
61
|
+
get fingerprint() {
|
|
62
|
+
return ["gbp", this.code];
|
|
45
63
|
}
|
|
46
64
|
};
|
|
47
65
|
var GbpAuthError = class extends GbpError {
|
|
48
66
|
constructor(code, status, description) {
|
|
49
|
-
super(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
super(`GBP token refresh failed: ${code}`, code, code === "token_request_failed" && status >= 500, {
|
|
68
|
+
status,
|
|
69
|
+
...description ? { description } : {},
|
|
70
|
+
...code === "invalid_grant" ? { remedy: "Reauthorise the Google account and replace GBP_REFRESH_TOKEN." } : {}
|
|
71
|
+
});
|
|
53
72
|
this.reauthorizationRequired = code === "invalid_grant";
|
|
54
73
|
this.status = status;
|
|
55
74
|
this.description = description;
|
|
@@ -57,7 +76,15 @@ var GbpAuthError = class extends GbpError {
|
|
|
57
76
|
};
|
|
58
77
|
var GbpApiError = class extends GbpError {
|
|
59
78
|
constructor(options) {
|
|
60
|
-
|
|
79
|
+
const server = options.status >= 500;
|
|
80
|
+
super(`GBP ${options.operation} failed: ${server ? "5xx" : options.status}`, server ? "api_5xx" : `api_${options.status}`, options.retryable, {
|
|
81
|
+
operation: options.operation,
|
|
82
|
+
status: options.status,
|
|
83
|
+
attempts: options.attempts,
|
|
84
|
+
...options.body ? { body: options.body } : {},
|
|
85
|
+
...options.retryAfterMs !== void 0 ? { retryAfterMs: options.retryAfterMs } : {}
|
|
86
|
+
});
|
|
87
|
+
this.body = options.body;
|
|
61
88
|
this.operation = options.operation;
|
|
62
89
|
this.status = options.status;
|
|
63
90
|
this.retryable = options.retryable;
|
|
@@ -67,7 +94,7 @@ var GbpApiError = class extends GbpError {
|
|
|
67
94
|
};
|
|
68
95
|
var GbpTimeoutError = class extends GbpError {
|
|
69
96
|
constructor(operation, timeoutMs) {
|
|
70
|
-
super(
|
|
97
|
+
super(`GBP ${operation} timed out`, "timeout", true, { operation, timeoutMs });
|
|
71
98
|
this.operation = operation;
|
|
72
99
|
this.timeoutMs = timeoutMs;
|
|
73
100
|
}
|
|
@@ -75,7 +102,10 @@ var GbpTimeoutError = class extends GbpError {
|
|
|
75
102
|
var GbpPaginationError = class extends GbpError {
|
|
76
103
|
constructor(reason, pagesFetched) {
|
|
77
104
|
super(
|
|
78
|
-
reason === "repeated_token" ?
|
|
105
|
+
reason === "repeated_token" ? "GBP reviews.list returned a page token it had already returned" : "GBP reviews.list stopped at the page limit",
|
|
106
|
+
`pagination_${reason}`,
|
|
107
|
+
false,
|
|
108
|
+
{ pagesFetched }
|
|
79
109
|
);
|
|
80
110
|
this.reason = reason;
|
|
81
111
|
this.pagesFetched = pagesFetched;
|
|
@@ -85,6 +115,19 @@ function excerpt(text, max = 300) {
|
|
|
85
115
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
86
116
|
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
|
|
87
117
|
}
|
|
118
|
+
var GbpPublishedError = class extends GbpError {
|
|
119
|
+
constructor(reason, client, detail, status, body) {
|
|
120
|
+
const transient = reason === "http" && status !== void 0 && (status === 429 || status >= 500);
|
|
121
|
+
super(`Published reviews for ${client} unusable (${reason}): ${detail}`, `published_${reason}`, transient, {
|
|
122
|
+
client,
|
|
123
|
+
...status !== void 0 ? { status } : {},
|
|
124
|
+
...body ? { body } : {}
|
|
125
|
+
});
|
|
126
|
+
this.reason = reason;
|
|
127
|
+
this.client = client;
|
|
128
|
+
this.status = status;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
88
131
|
|
|
89
132
|
// src/http.ts
|
|
90
133
|
var DEFAULT_REQUEST_POLICY = {
|
|
@@ -395,20 +438,224 @@ async function getBusinessReviews(options = {}) {
|
|
|
395
438
|
reviews: order === "api" ? reviews : shuffleArray(reviews)
|
|
396
439
|
};
|
|
397
440
|
}
|
|
441
|
+
|
|
442
|
+
// src/published.ts
|
|
443
|
+
var DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
|
|
444
|
+
var PUBLISHED_REVIEWS_SCHEMA = 1;
|
|
445
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
446
|
+
var isTime = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
|
|
447
|
+
function parseReview2(v) {
|
|
448
|
+
if (!isRecord2(v)) return null;
|
|
449
|
+
const { id, author, rating, comment, createdAt, reply } = v;
|
|
450
|
+
if (typeof id !== "string" || typeof author !== "string" || typeof comment !== "string" || !comment) return null;
|
|
451
|
+
if (typeof rating !== "number" || !Number.isInteger(rating) || rating < 1 || rating > 5) return null;
|
|
452
|
+
const review = { id, author, rating, comment, createdAt: isTime(createdAt) ? createdAt : "" };
|
|
453
|
+
if (isRecord2(reply) && typeof reply.text === "string" && reply.text) {
|
|
454
|
+
review.reply = { text: reply.text, updatedAt: isTime(reply.updatedAt) ? reply.updatedAt : "" };
|
|
455
|
+
}
|
|
456
|
+
return review;
|
|
457
|
+
}
|
|
458
|
+
function parsePublishedReviews(body, client) {
|
|
459
|
+
if (!isRecord2(body)) throw new GbpPublishedError("invalid", client, "the file is not an object");
|
|
460
|
+
if (body.schema !== PUBLISHED_REVIEWS_SCHEMA) {
|
|
461
|
+
throw new GbpPublishedError("unknown_schema", client, `schema ${String(body.schema)}`);
|
|
462
|
+
}
|
|
463
|
+
if (body.client !== client) throw new GbpPublishedError("invalid", client, `the file is for ${String(body.client)}`);
|
|
464
|
+
const { rating, count } = body;
|
|
465
|
+
if (rating !== null && (typeof rating !== "number" || !(rating >= 0 && rating <= 5))) {
|
|
466
|
+
throw new GbpPublishedError("invalid", client, "rating out of range");
|
|
467
|
+
}
|
|
468
|
+
if (count !== null && (typeof count !== "number" || !Number.isInteger(count) || count < 0)) {
|
|
469
|
+
throw new GbpPublishedError("invalid", client, "count is not a whole number");
|
|
470
|
+
}
|
|
471
|
+
if (!isTime(body.synced_at) || !isTime(body.published_at) || !Array.isArray(body.reviews)) {
|
|
472
|
+
throw new GbpPublishedError("invalid", client, "synced_at, published_at or reviews missing");
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
averageRating: rating,
|
|
476
|
+
totalReviewCount: count,
|
|
477
|
+
reviews: body.reviews.map(parseReview2).filter((r) => r !== null),
|
|
478
|
+
syncedAt: body.synced_at,
|
|
479
|
+
publishedAt: body.published_at
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function shuffle(arr) {
|
|
483
|
+
const copy = [...arr];
|
|
484
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
485
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
486
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
487
|
+
}
|
|
488
|
+
return copy;
|
|
489
|
+
}
|
|
490
|
+
function publishedReviewsUrl(client, baseUrl) {
|
|
491
|
+
if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(client)) throw new RangeError(`not a client slug: ${client}`);
|
|
492
|
+
const base = (baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? DEFAULT_PUBLISHED_REVIEWS_BASE_URL).replace(/\/+$/, "");
|
|
493
|
+
return `${base}/${client}.json`;
|
|
494
|
+
}
|
|
495
|
+
async function getPublishedReviews(options) {
|
|
496
|
+
const { client, filterMinStars, limit, order = "api", next, request } = options;
|
|
497
|
+
const url = publishedReviewsUrl(client, options.baseUrl);
|
|
498
|
+
const init = {
|
|
499
|
+
headers: { Accept: "application/json" },
|
|
500
|
+
next: next ?? { revalidate: 86400, tags: ["google-reviews"] }
|
|
501
|
+
};
|
|
502
|
+
const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
|
|
503
|
+
if (!response.ok) {
|
|
504
|
+
const body2 = excerpt(await readErrorBody(response), 120);
|
|
505
|
+
throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
|
|
506
|
+
}
|
|
507
|
+
let body;
|
|
508
|
+
try {
|
|
509
|
+
body = await response.json();
|
|
510
|
+
} catch {
|
|
511
|
+
throw new GbpPublishedError("invalid", client, "the file is not JSON");
|
|
512
|
+
}
|
|
513
|
+
const result = parsePublishedReviews(body, client);
|
|
514
|
+
let reviews = filterMinStars === void 0 ? result.reviews : result.reviews.filter((r) => r.rating >= filterMinStars);
|
|
515
|
+
if (limit !== void 0) reviews = reviews.slice(0, limit);
|
|
516
|
+
return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/safe.ts
|
|
520
|
+
var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
|
|
521
|
+
var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
522
|
+
var DEFAULT_AUTH_RETRY_MS = 5 * 60 * 1e3;
|
|
523
|
+
var lastGood = /* @__PURE__ */ new Map();
|
|
524
|
+
var reported = /* @__PURE__ */ new Map();
|
|
525
|
+
var authBlockedUntil = /* @__PURE__ */ new Map();
|
|
526
|
+
function toGbpError(error) {
|
|
527
|
+
if (error instanceof GbpError) return error;
|
|
528
|
+
const name = error instanceof Error ? error.name : typeof error;
|
|
529
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
530
|
+
const wrapped = new GbpError("GBP reviews failed: unexpected error", "unexpected", true, { name, message: excerpt(message, 200) });
|
|
531
|
+
wrapped.cause = error;
|
|
532
|
+
return wrapped;
|
|
533
|
+
}
|
|
534
|
+
function isAuthFailure(error) {
|
|
535
|
+
return error.code === "invalid_grant" || error.code === "invalid_client";
|
|
536
|
+
}
|
|
537
|
+
function defaultReport(error, report) {
|
|
538
|
+
console.warn(`[gbp] ${error.message}; serving ${report.served}`, report.context);
|
|
539
|
+
}
|
|
540
|
+
async function loadFallback(fallback) {
|
|
541
|
+
if (fallback === void 0) return null;
|
|
542
|
+
try {
|
|
543
|
+
return (typeof fallback === "function" ? await fallback() : fallback) ?? null;
|
|
544
|
+
} catch {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function reportOnce(error, served, options) {
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
const window = options.reportIntervalMs ?? DEFAULT_REPORT_INTERVAL_MS;
|
|
551
|
+
const last = reported.get(error.code);
|
|
552
|
+
if (last && now - last.at < window) {
|
|
553
|
+
last.suppressed++;
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
reported.set(error.code, { at: now, suppressed: 0 });
|
|
557
|
+
try {
|
|
558
|
+
(options.report ?? defaultReport)(error, {
|
|
559
|
+
fingerprint: error.fingerprint,
|
|
560
|
+
code: error.code,
|
|
561
|
+
transient: error.transient,
|
|
562
|
+
context: error.context,
|
|
563
|
+
served,
|
|
564
|
+
suppressed: last?.suppressed ?? 0
|
|
565
|
+
});
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async function safely(key, load, empty, options) {
|
|
570
|
+
const now = Date.now();
|
|
571
|
+
let error;
|
|
572
|
+
const blocked = authBlockedUntil.get(key);
|
|
573
|
+
if (blocked && blocked.until > now) {
|
|
574
|
+
error = blocked.error;
|
|
575
|
+
} else {
|
|
576
|
+
try {
|
|
577
|
+
const result = await load();
|
|
578
|
+
lastGood.set(key, { result, at: Date.now() });
|
|
579
|
+
authBlockedUntil.delete(key);
|
|
580
|
+
return { ...result, source: "live" };
|
|
581
|
+
} catch (caught) {
|
|
582
|
+
error = toGbpError(caught);
|
|
583
|
+
const retryMs = options.authRetryMs ?? DEFAULT_AUTH_RETRY_MS;
|
|
584
|
+
if (isAuthFailure(error) && retryMs > 0) authBlockedUntil.set(key, { until: Date.now() + retryMs, error });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
let cached2 = lastGood.get(key);
|
|
588
|
+
if (cached2 && now - cached2.at > (options.maxStaleMs ?? DEFAULT_MAX_STALE_MS)) {
|
|
589
|
+
lastGood.delete(key);
|
|
590
|
+
cached2 = void 0;
|
|
591
|
+
}
|
|
592
|
+
let served;
|
|
593
|
+
if (cached2) {
|
|
594
|
+
served = { ...cached2.result, reviews: [...cached2.result.reviews], source: "cache", error };
|
|
595
|
+
} else {
|
|
596
|
+
const fallback = await loadFallback(options.fallback);
|
|
597
|
+
served = fallback ? { ...fallback, source: "fallback", error } : { ...empty(), error };
|
|
598
|
+
}
|
|
599
|
+
reportOnce(error, served.source, options);
|
|
600
|
+
return served;
|
|
601
|
+
}
|
|
602
|
+
function getBusinessReviewsSafe(options = {}) {
|
|
603
|
+
const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
|
|
604
|
+
const key = JSON.stringify([
|
|
605
|
+
"business",
|
|
606
|
+
process.env.GOOGLE_BUSINESS_LOCATION_ID ?? null,
|
|
607
|
+
fetchOptions.limit ?? null,
|
|
608
|
+
fetchOptions.filterMinStars ?? null,
|
|
609
|
+
fetchOptions.includeUnapprovedReplies ?? false
|
|
610
|
+
]);
|
|
611
|
+
return safely(
|
|
612
|
+
key,
|
|
613
|
+
() => getBusinessReviews(fetchOptions),
|
|
614
|
+
() => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
|
|
615
|
+
{ fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
function getPublishedReviewsSafe(options) {
|
|
619
|
+
const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
|
|
620
|
+
const key = JSON.stringify([
|
|
621
|
+
"published",
|
|
622
|
+
fetchOptions.client,
|
|
623
|
+
fetchOptions.baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? null,
|
|
624
|
+
fetchOptions.limit ?? null,
|
|
625
|
+
fetchOptions.filterMinStars ?? null
|
|
626
|
+
]);
|
|
627
|
+
return safely(
|
|
628
|
+
key,
|
|
629
|
+
() => getPublishedReviews(fetchOptions),
|
|
630
|
+
() => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
|
|
631
|
+
{ fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
|
|
632
|
+
);
|
|
633
|
+
}
|
|
398
634
|
// Annotate the CommonJS export names for ESM import in node:
|
|
399
635
|
0 && (module.exports = {
|
|
636
|
+
DEFAULT_AUTH_RETRY_MS,
|
|
400
637
|
DEFAULT_MAX_PAGES,
|
|
638
|
+
DEFAULT_MAX_STALE_MS,
|
|
639
|
+
DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
|
|
640
|
+
DEFAULT_REPORT_INTERVAL_MS,
|
|
401
641
|
DEFAULT_REQUEST_POLICY,
|
|
402
642
|
GbpApiError,
|
|
403
643
|
GbpAuthError,
|
|
404
644
|
GbpError,
|
|
405
645
|
GbpPaginationError,
|
|
646
|
+
GbpPublishedError,
|
|
406
647
|
GbpTimeoutError,
|
|
648
|
+
PUBLISHED_REVIEWS_SCHEMA,
|
|
407
649
|
getBusinessReviews,
|
|
650
|
+
getBusinessReviewsSafe,
|
|
408
651
|
getGoogleOAuthAccessToken,
|
|
652
|
+
getPublishedReviews,
|
|
653
|
+
getPublishedReviewsSafe,
|
|
409
654
|
hasGoogleOAuthCredentials,
|
|
410
655
|
invalidateAccessToken,
|
|
411
656
|
isBusinessProfileConfigured,
|
|
412
657
|
parseListReviewsResponse,
|
|
413
|
-
|
|
658
|
+
parsePublishedReviews,
|
|
659
|
+
parseRetryAfter,
|
|
660
|
+
publishedReviewsUrl
|
|
414
661
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -240,18 +240,74 @@ declare function parseListReviewsResponse(body: unknown): ReviewsPage;
|
|
|
240
240
|
*/
|
|
241
241
|
declare function getBusinessReviews(options?: GetBusinessReviewsOptions): Promise<BusinessReviewsResult>;
|
|
242
242
|
|
|
243
|
+
/** Where Doman Digital's portal publishes review files. */
|
|
244
|
+
declare const DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
|
|
245
|
+
/** The file schema this reader understands. A newer file is refused rather than misread. */
|
|
246
|
+
declare const PUBLISHED_REVIEWS_SCHEMA = 1;
|
|
247
|
+
interface GetPublishedReviewsOptions {
|
|
248
|
+
/** The client's slug, e.g. `"chair-and-blade"`. */
|
|
249
|
+
client: string;
|
|
250
|
+
/** Default: `GBP_REVIEWS_BASE_URL` from the environment, else {@link DEFAULT_PUBLISHED_REVIEWS_BASE_URL}. */
|
|
251
|
+
baseUrl?: string;
|
|
252
|
+
/** Raise the published floor (four stars). Values at or below four change nothing. */
|
|
253
|
+
filterMinStars?: number;
|
|
254
|
+
/** Max reviews to return. Default: all of them. */
|
|
255
|
+
limit?: number;
|
|
256
|
+
/** `"api"` (default): newest first, as published. `"shuffle"`: random order, applied after `limit`. */
|
|
257
|
+
order?: ReviewOrder;
|
|
258
|
+
/** Cache hint passed to `fetch` for Next.js. Default: revalidate daily, tag `google-reviews`. */
|
|
259
|
+
next?: {
|
|
260
|
+
revalidate?: number;
|
|
261
|
+
tags?: string[];
|
|
262
|
+
};
|
|
263
|
+
/** Deadline and retry policy. */
|
|
264
|
+
request?: GbpRequestOptions;
|
|
265
|
+
}
|
|
266
|
+
interface PublishedReviewsResult extends BusinessReviewsResult {
|
|
267
|
+
/** When the collector last read Google in full. */
|
|
268
|
+
syncedAt: string;
|
|
269
|
+
/** When the file was written. */
|
|
270
|
+
publishedAt: string;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Validate a published file. A malformed review is skipped; a malformed file, a file for another client or a schema
|
|
274
|
+
* this reader does not know throws {@link GbpPublishedError}.
|
|
275
|
+
*/
|
|
276
|
+
declare function parsePublishedReviews(body: unknown, client: string): PublishedReviewsResult;
|
|
277
|
+
/** The URL a client's file is read from. */
|
|
278
|
+
declare function publishedReviewsUrl(client: string, baseUrl?: string): string;
|
|
279
|
+
/**
|
|
280
|
+
* Fetch a client's published reviews. Retries 429 and transient 5xx within a deadline (see `request`), then throws:
|
|
281
|
+
* {@link GbpPublishedError} for a missing or malformed file, `GbpTimeoutError` for a deadline.
|
|
282
|
+
*/
|
|
283
|
+
declare function getPublishedReviews(options: GetPublishedReviewsOptions): Promise<PublishedReviewsResult>;
|
|
284
|
+
|
|
243
285
|
/**
|
|
244
286
|
* Typed failures, so a site can tell "reauthorise the Google account" from
|
|
245
287
|
* "Google is having a bad minute" from "our code looped", and log the
|
|
246
288
|
* difference without ever logging a credential.
|
|
247
289
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* excerpt
|
|
290
|
+
* Every message is short and stable: the same failure always reads the same,
|
|
291
|
+
* so an error tracker groups it as one issue rather than one per response
|
|
292
|
+
* body. What varies (status, Google's description, a body excerpt) sits in
|
|
293
|
+
* `context`, and `fingerprint` is `["gbp", code]` for a tracker that takes
|
|
294
|
+
* one (Sentry's `captureException(error, { fingerprint, extra: context })`).
|
|
295
|
+
*
|
|
296
|
+
* No message or context here carries a client id, client secret, refresh
|
|
297
|
+
* token, access token or review text. Bodies from Google are cut to a short,
|
|
298
|
+
* bounded excerpt.
|
|
251
299
|
*/
|
|
252
300
|
/** Base class: `instanceof GbpError` catches every failure this package throws. */
|
|
253
301
|
declare class GbpError extends Error {
|
|
254
|
-
|
|
302
|
+
/** Short, stable name for the failure, e.g. `invalid_grant` or `api_5xx`. */
|
|
303
|
+
readonly code: string;
|
|
304
|
+
/** True when the same request may well work later without anyone changing anything. */
|
|
305
|
+
readonly transient: boolean;
|
|
306
|
+
/** What varies between occurrences: status, Google's description, a body excerpt. Never a credential. */
|
|
307
|
+
readonly context: Record<string, unknown>;
|
|
308
|
+
constructor(message: string, code?: string, transient?: boolean, context?: Record<string, unknown>);
|
|
309
|
+
/** `["gbp", code]`: one error-tracker issue per kind of failure. */
|
|
310
|
+
get fingerprint(): string[];
|
|
255
311
|
}
|
|
256
312
|
type GbpAuthErrorCode = "invalid_grant" | "invalid_client" | "token_request_failed";
|
|
257
313
|
/**
|
|
@@ -277,6 +333,8 @@ declare class GbpApiError extends GbpError {
|
|
|
277
333
|
readonly attempts: number;
|
|
278
334
|
/** Set when Google asked for a longer wait than this package will sleep through. */
|
|
279
335
|
readonly retryAfterMs?: number;
|
|
336
|
+
/** A short excerpt of Google's error body. In `context` too, never in the message. */
|
|
337
|
+
readonly body?: string;
|
|
280
338
|
constructor(options: {
|
|
281
339
|
operation: string;
|
|
282
340
|
status: number;
|
|
@@ -303,5 +361,104 @@ declare class GbpPaginationError extends GbpError {
|
|
|
303
361
|
readonly pagesFetched: number;
|
|
304
362
|
constructor(reason: "repeated_token" | "page_limit", pagesFetched: number);
|
|
305
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* A published reviews file could not be used: the address answered an error (`http`, with `status`), the file was
|
|
366
|
+
* malformed or for another client (`invalid`), or it uses a schema this version does not read (`unknown_schema`,
|
|
367
|
+
* upgrade the package). The caller keeps the copy it already has.
|
|
368
|
+
*/
|
|
369
|
+
declare class GbpPublishedError extends GbpError {
|
|
370
|
+
readonly reason: "http" | "invalid" | "unknown_schema";
|
|
371
|
+
readonly client: string;
|
|
372
|
+
readonly status?: number;
|
|
373
|
+
constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number, body?: string);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Reviews that never throw into a page render.
|
|
378
|
+
*
|
|
379
|
+
* `getBusinessReviews` and `getPublishedReviews` throw on a real failure, so a
|
|
380
|
+
* caller can choose what to do. Most callers want the same thing: keep showing
|
|
381
|
+
* what they showed last time, tell someone once, and carry on. A dead refresh
|
|
382
|
+
* token (`invalid_grant`, every 7 days while the OAuth app is in Testing) or a
|
|
383
|
+
* Google 503 otherwise reaches every server render, and a site that reports
|
|
384
|
+
* each one sends thousands of events about one fault.
|
|
385
|
+
*
|
|
386
|
+
* The `...Safe` functions here catch every failure and serve, in order:
|
|
387
|
+
*
|
|
388
|
+
* 1. the last good result this process fetched for the same options
|
|
389
|
+
* (`source: "cache"`), up to `maxStaleMs` old;
|
|
390
|
+
* 2. the caller's `fallback`, a snapshot or a loader for one such as a KV
|
|
391
|
+
* read (`source: "fallback"`);
|
|
392
|
+
* 3. an empty result: no rating, no count, no reviews (`source: "empty"`).
|
|
393
|
+
*
|
|
394
|
+
* Each kind of failure is reported at most once per `reportIntervalMs` per
|
|
395
|
+
* process, through `report`. After an auth failure no amount of retrying
|
|
396
|
+
* helps, so the live call is skipped for `authRetryMs` and the cache or
|
|
397
|
+
* fallback is served straight away.
|
|
398
|
+
*/
|
|
399
|
+
|
|
400
|
+
/** Where a safe result came from. */
|
|
401
|
+
type ReviewsSource = "live" | "cache" | "fallback" | "empty";
|
|
402
|
+
/** A result from a `...Safe` function: the reviews, where they came from, and the failure if there was one. */
|
|
403
|
+
type SafeReviewsResult<T extends BusinessReviewsResult = BusinessReviewsResult> = T & {
|
|
404
|
+
source: ReviewsSource;
|
|
405
|
+
/** The failure behind a `cache`, `fallback` or `empty` result. */
|
|
406
|
+
error?: GbpError;
|
|
407
|
+
};
|
|
408
|
+
/** What `report` is told alongside the error. */
|
|
409
|
+
interface GbpFailureReport {
|
|
410
|
+
/** `["gbp", code]`. Pass it to Sentry as `fingerprint` so every occurrence is one issue. */
|
|
411
|
+
fingerprint: string[];
|
|
412
|
+
code: string;
|
|
413
|
+
/** True when the failure may clear by itself (a 5xx, a timeout); false when a person has to act. */
|
|
414
|
+
transient: boolean;
|
|
415
|
+
/** Status, Google's description, a body excerpt. Never a credential. Pass it as Sentry `extra`. */
|
|
416
|
+
context: Record<string, unknown>;
|
|
417
|
+
/** What the page was given instead. */
|
|
418
|
+
served: Exclude<ReviewsSource, "live">;
|
|
419
|
+
/** Failures of this kind left unreported since the last report, because they fell inside the window. */
|
|
420
|
+
suppressed: number;
|
|
421
|
+
}
|
|
422
|
+
interface SafeReviewsOptions<F extends BusinessReviewsResult = BusinessReviewsResult> {
|
|
423
|
+
/**
|
|
424
|
+
* Served when there is no cached result: a snapshot, or a function that loads one (a KV read, say). A loader that
|
|
425
|
+
* throws or returns nothing falls through to an empty result. Served as given: `limit` and `filterMinStars` are
|
|
426
|
+
* not applied to it.
|
|
427
|
+
*/
|
|
428
|
+
fallback?: F | (() => F | null | undefined | Promise<F | null | undefined>);
|
|
429
|
+
/**
|
|
430
|
+
* Called at most once per kind of failure per `reportIntervalMs`, per process. Default: one `console.warn`.
|
|
431
|
+
*
|
|
432
|
+
* ```ts
|
|
433
|
+
* report: (error, { fingerprint, context, transient }) =>
|
|
434
|
+
* Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
|
|
435
|
+
* ```
|
|
436
|
+
*/
|
|
437
|
+
report?: (error: GbpError, report: GbpFailureReport) => void;
|
|
438
|
+
/** Default one hour. */
|
|
439
|
+
reportIntervalMs?: number;
|
|
440
|
+
/** Longest a cached result is served after the last good fetch. Default 30 days. */
|
|
441
|
+
maxStaleMs?: number;
|
|
442
|
+
/** After an auth failure, skip the live call for this long. Default five minutes. `0` turns it off. */
|
|
443
|
+
authRetryMs?: number;
|
|
444
|
+
}
|
|
445
|
+
declare const DEFAULT_REPORT_INTERVAL_MS: number;
|
|
446
|
+
declare const DEFAULT_MAX_STALE_MS: number;
|
|
447
|
+
declare const DEFAULT_AUTH_RETRY_MS: number;
|
|
448
|
+
/**
|
|
449
|
+
* `getBusinessReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
|
|
450
|
+
* empty result, and reports the failure at most once per window. `source` says which.
|
|
451
|
+
*/
|
|
452
|
+
declare function getBusinessReviewsSafe(options?: GetBusinessReviewsOptions & SafeReviewsOptions): Promise<SafeReviewsResult>;
|
|
453
|
+
/** A published result, or a fallback or empty one, which carries no sync times. */
|
|
454
|
+
type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Partial<Pick<PublishedReviewsResult, "syncedAt" | "publishedAt">>>;
|
|
455
|
+
/**
|
|
456
|
+
* `getPublishedReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
|
|
457
|
+
* empty result, and reports the failure at most once per window. `source` says which.
|
|
458
|
+
*
|
|
459
|
+
* Where a framework keeps the last good page when a render throws (a static build that refuses to publish), the
|
|
460
|
+
* throwing `getPublishedReviews` keeps that behaviour; use this where a throw would reach the visitor.
|
|
461
|
+
*/
|
|
462
|
+
declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
|
|
306
463
|
|
|
307
|
-
export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_MAX_PAGES, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, GbpPaginationError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, getBusinessReviews, getGoogleOAuthAccessToken, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parseRetryAfter };
|
|
464
|
+
export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_AUTH_RETRY_MS, DEFAULT_MAX_PAGES, DEFAULT_MAX_STALE_MS, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REPORT_INTERVAL_MS, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
|
package/dist/index.d.ts
CHANGED
|
@@ -240,18 +240,74 @@ declare function parseListReviewsResponse(body: unknown): ReviewsPage;
|
|
|
240
240
|
*/
|
|
241
241
|
declare function getBusinessReviews(options?: GetBusinessReviewsOptions): Promise<BusinessReviewsResult>;
|
|
242
242
|
|
|
243
|
+
/** Where Doman Digital's portal publishes review files. */
|
|
244
|
+
declare const DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
|
|
245
|
+
/** The file schema this reader understands. A newer file is refused rather than misread. */
|
|
246
|
+
declare const PUBLISHED_REVIEWS_SCHEMA = 1;
|
|
247
|
+
interface GetPublishedReviewsOptions {
|
|
248
|
+
/** The client's slug, e.g. `"chair-and-blade"`. */
|
|
249
|
+
client: string;
|
|
250
|
+
/** Default: `GBP_REVIEWS_BASE_URL` from the environment, else {@link DEFAULT_PUBLISHED_REVIEWS_BASE_URL}. */
|
|
251
|
+
baseUrl?: string;
|
|
252
|
+
/** Raise the published floor (four stars). Values at or below four change nothing. */
|
|
253
|
+
filterMinStars?: number;
|
|
254
|
+
/** Max reviews to return. Default: all of them. */
|
|
255
|
+
limit?: number;
|
|
256
|
+
/** `"api"` (default): newest first, as published. `"shuffle"`: random order, applied after `limit`. */
|
|
257
|
+
order?: ReviewOrder;
|
|
258
|
+
/** Cache hint passed to `fetch` for Next.js. Default: revalidate daily, tag `google-reviews`. */
|
|
259
|
+
next?: {
|
|
260
|
+
revalidate?: number;
|
|
261
|
+
tags?: string[];
|
|
262
|
+
};
|
|
263
|
+
/** Deadline and retry policy. */
|
|
264
|
+
request?: GbpRequestOptions;
|
|
265
|
+
}
|
|
266
|
+
interface PublishedReviewsResult extends BusinessReviewsResult {
|
|
267
|
+
/** When the collector last read Google in full. */
|
|
268
|
+
syncedAt: string;
|
|
269
|
+
/** When the file was written. */
|
|
270
|
+
publishedAt: string;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Validate a published file. A malformed review is skipped; a malformed file, a file for another client or a schema
|
|
274
|
+
* this reader does not know throws {@link GbpPublishedError}.
|
|
275
|
+
*/
|
|
276
|
+
declare function parsePublishedReviews(body: unknown, client: string): PublishedReviewsResult;
|
|
277
|
+
/** The URL a client's file is read from. */
|
|
278
|
+
declare function publishedReviewsUrl(client: string, baseUrl?: string): string;
|
|
279
|
+
/**
|
|
280
|
+
* Fetch a client's published reviews. Retries 429 and transient 5xx within a deadline (see `request`), then throws:
|
|
281
|
+
* {@link GbpPublishedError} for a missing or malformed file, `GbpTimeoutError` for a deadline.
|
|
282
|
+
*/
|
|
283
|
+
declare function getPublishedReviews(options: GetPublishedReviewsOptions): Promise<PublishedReviewsResult>;
|
|
284
|
+
|
|
243
285
|
/**
|
|
244
286
|
* Typed failures, so a site can tell "reauthorise the Google account" from
|
|
245
287
|
* "Google is having a bad minute" from "our code looped", and log the
|
|
246
288
|
* difference without ever logging a credential.
|
|
247
289
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* excerpt
|
|
290
|
+
* Every message is short and stable: the same failure always reads the same,
|
|
291
|
+
* so an error tracker groups it as one issue rather than one per response
|
|
292
|
+
* body. What varies (status, Google's description, a body excerpt) sits in
|
|
293
|
+
* `context`, and `fingerprint` is `["gbp", code]` for a tracker that takes
|
|
294
|
+
* one (Sentry's `captureException(error, { fingerprint, extra: context })`).
|
|
295
|
+
*
|
|
296
|
+
* No message or context here carries a client id, client secret, refresh
|
|
297
|
+
* token, access token or review text. Bodies from Google are cut to a short,
|
|
298
|
+
* bounded excerpt.
|
|
251
299
|
*/
|
|
252
300
|
/** Base class: `instanceof GbpError` catches every failure this package throws. */
|
|
253
301
|
declare class GbpError extends Error {
|
|
254
|
-
|
|
302
|
+
/** Short, stable name for the failure, e.g. `invalid_grant` or `api_5xx`. */
|
|
303
|
+
readonly code: string;
|
|
304
|
+
/** True when the same request may well work later without anyone changing anything. */
|
|
305
|
+
readonly transient: boolean;
|
|
306
|
+
/** What varies between occurrences: status, Google's description, a body excerpt. Never a credential. */
|
|
307
|
+
readonly context: Record<string, unknown>;
|
|
308
|
+
constructor(message: string, code?: string, transient?: boolean, context?: Record<string, unknown>);
|
|
309
|
+
/** `["gbp", code]`: one error-tracker issue per kind of failure. */
|
|
310
|
+
get fingerprint(): string[];
|
|
255
311
|
}
|
|
256
312
|
type GbpAuthErrorCode = "invalid_grant" | "invalid_client" | "token_request_failed";
|
|
257
313
|
/**
|
|
@@ -277,6 +333,8 @@ declare class GbpApiError extends GbpError {
|
|
|
277
333
|
readonly attempts: number;
|
|
278
334
|
/** Set when Google asked for a longer wait than this package will sleep through. */
|
|
279
335
|
readonly retryAfterMs?: number;
|
|
336
|
+
/** A short excerpt of Google's error body. In `context` too, never in the message. */
|
|
337
|
+
readonly body?: string;
|
|
280
338
|
constructor(options: {
|
|
281
339
|
operation: string;
|
|
282
340
|
status: number;
|
|
@@ -303,5 +361,104 @@ declare class GbpPaginationError extends GbpError {
|
|
|
303
361
|
readonly pagesFetched: number;
|
|
304
362
|
constructor(reason: "repeated_token" | "page_limit", pagesFetched: number);
|
|
305
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* A published reviews file could not be used: the address answered an error (`http`, with `status`), the file was
|
|
366
|
+
* malformed or for another client (`invalid`), or it uses a schema this version does not read (`unknown_schema`,
|
|
367
|
+
* upgrade the package). The caller keeps the copy it already has.
|
|
368
|
+
*/
|
|
369
|
+
declare class GbpPublishedError extends GbpError {
|
|
370
|
+
readonly reason: "http" | "invalid" | "unknown_schema";
|
|
371
|
+
readonly client: string;
|
|
372
|
+
readonly status?: number;
|
|
373
|
+
constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number, body?: string);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Reviews that never throw into a page render.
|
|
378
|
+
*
|
|
379
|
+
* `getBusinessReviews` and `getPublishedReviews` throw on a real failure, so a
|
|
380
|
+
* caller can choose what to do. Most callers want the same thing: keep showing
|
|
381
|
+
* what they showed last time, tell someone once, and carry on. A dead refresh
|
|
382
|
+
* token (`invalid_grant`, every 7 days while the OAuth app is in Testing) or a
|
|
383
|
+
* Google 503 otherwise reaches every server render, and a site that reports
|
|
384
|
+
* each one sends thousands of events about one fault.
|
|
385
|
+
*
|
|
386
|
+
* The `...Safe` functions here catch every failure and serve, in order:
|
|
387
|
+
*
|
|
388
|
+
* 1. the last good result this process fetched for the same options
|
|
389
|
+
* (`source: "cache"`), up to `maxStaleMs` old;
|
|
390
|
+
* 2. the caller's `fallback`, a snapshot or a loader for one such as a KV
|
|
391
|
+
* read (`source: "fallback"`);
|
|
392
|
+
* 3. an empty result: no rating, no count, no reviews (`source: "empty"`).
|
|
393
|
+
*
|
|
394
|
+
* Each kind of failure is reported at most once per `reportIntervalMs` per
|
|
395
|
+
* process, through `report`. After an auth failure no amount of retrying
|
|
396
|
+
* helps, so the live call is skipped for `authRetryMs` and the cache or
|
|
397
|
+
* fallback is served straight away.
|
|
398
|
+
*/
|
|
399
|
+
|
|
400
|
+
/** Where a safe result came from. */
|
|
401
|
+
type ReviewsSource = "live" | "cache" | "fallback" | "empty";
|
|
402
|
+
/** A result from a `...Safe` function: the reviews, where they came from, and the failure if there was one. */
|
|
403
|
+
type SafeReviewsResult<T extends BusinessReviewsResult = BusinessReviewsResult> = T & {
|
|
404
|
+
source: ReviewsSource;
|
|
405
|
+
/** The failure behind a `cache`, `fallback` or `empty` result. */
|
|
406
|
+
error?: GbpError;
|
|
407
|
+
};
|
|
408
|
+
/** What `report` is told alongside the error. */
|
|
409
|
+
interface GbpFailureReport {
|
|
410
|
+
/** `["gbp", code]`. Pass it to Sentry as `fingerprint` so every occurrence is one issue. */
|
|
411
|
+
fingerprint: string[];
|
|
412
|
+
code: string;
|
|
413
|
+
/** True when the failure may clear by itself (a 5xx, a timeout); false when a person has to act. */
|
|
414
|
+
transient: boolean;
|
|
415
|
+
/** Status, Google's description, a body excerpt. Never a credential. Pass it as Sentry `extra`. */
|
|
416
|
+
context: Record<string, unknown>;
|
|
417
|
+
/** What the page was given instead. */
|
|
418
|
+
served: Exclude<ReviewsSource, "live">;
|
|
419
|
+
/** Failures of this kind left unreported since the last report, because they fell inside the window. */
|
|
420
|
+
suppressed: number;
|
|
421
|
+
}
|
|
422
|
+
interface SafeReviewsOptions<F extends BusinessReviewsResult = BusinessReviewsResult> {
|
|
423
|
+
/**
|
|
424
|
+
* Served when there is no cached result: a snapshot, or a function that loads one (a KV read, say). A loader that
|
|
425
|
+
* throws or returns nothing falls through to an empty result. Served as given: `limit` and `filterMinStars` are
|
|
426
|
+
* not applied to it.
|
|
427
|
+
*/
|
|
428
|
+
fallback?: F | (() => F | null | undefined | Promise<F | null | undefined>);
|
|
429
|
+
/**
|
|
430
|
+
* Called at most once per kind of failure per `reportIntervalMs`, per process. Default: one `console.warn`.
|
|
431
|
+
*
|
|
432
|
+
* ```ts
|
|
433
|
+
* report: (error, { fingerprint, context, transient }) =>
|
|
434
|
+
* Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
|
|
435
|
+
* ```
|
|
436
|
+
*/
|
|
437
|
+
report?: (error: GbpError, report: GbpFailureReport) => void;
|
|
438
|
+
/** Default one hour. */
|
|
439
|
+
reportIntervalMs?: number;
|
|
440
|
+
/** Longest a cached result is served after the last good fetch. Default 30 days. */
|
|
441
|
+
maxStaleMs?: number;
|
|
442
|
+
/** After an auth failure, skip the live call for this long. Default five minutes. `0` turns it off. */
|
|
443
|
+
authRetryMs?: number;
|
|
444
|
+
}
|
|
445
|
+
declare const DEFAULT_REPORT_INTERVAL_MS: number;
|
|
446
|
+
declare const DEFAULT_MAX_STALE_MS: number;
|
|
447
|
+
declare const DEFAULT_AUTH_RETRY_MS: number;
|
|
448
|
+
/**
|
|
449
|
+
* `getBusinessReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
|
|
450
|
+
* empty result, and reports the failure at most once per window. `source` says which.
|
|
451
|
+
*/
|
|
452
|
+
declare function getBusinessReviewsSafe(options?: GetBusinessReviewsOptions & SafeReviewsOptions): Promise<SafeReviewsResult>;
|
|
453
|
+
/** A published result, or a fallback or empty one, which carries no sync times. */
|
|
454
|
+
type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Partial<Pick<PublishedReviewsResult, "syncedAt" | "publishedAt">>>;
|
|
455
|
+
/**
|
|
456
|
+
* `getPublishedReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
|
|
457
|
+
* empty result, and reports the failure at most once per window. `source` says which.
|
|
458
|
+
*
|
|
459
|
+
* Where a framework keeps the last good page when a render throws (a static build that refuses to publish), the
|
|
460
|
+
* throwing `getPublishedReviews` keeps that behaviour; use this where a throw would reach the visitor.
|
|
461
|
+
*/
|
|
462
|
+
declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
|
|
306
463
|
|
|
307
|
-
export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_MAX_PAGES, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, GbpPaginationError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, getBusinessReviews, getGoogleOAuthAccessToken, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parseRetryAfter };
|
|
464
|
+
export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_AUTH_RETRY_MS, DEFAULT_MAX_PAGES, DEFAULT_MAX_STALE_MS, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REPORT_INTERVAL_MS, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
// src/errors.ts
|
|
2
2
|
var GbpError = class extends Error {
|
|
3
|
-
constructor(message) {
|
|
3
|
+
constructor(message, code = "unknown", transient = false, context = {}) {
|
|
4
4
|
super(message);
|
|
5
5
|
this.name = new.target.name;
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.transient = transient;
|
|
8
|
+
this.context = context;
|
|
9
|
+
}
|
|
10
|
+
/** `["gbp", code]`: one error-tracker issue per kind of failure. */
|
|
11
|
+
get fingerprint() {
|
|
12
|
+
return ["gbp", this.code];
|
|
6
13
|
}
|
|
7
14
|
};
|
|
8
15
|
var GbpAuthError = class extends GbpError {
|
|
9
16
|
constructor(code, status, description) {
|
|
10
|
-
super(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
super(`GBP token refresh failed: ${code}`, code, code === "token_request_failed" && status >= 500, {
|
|
18
|
+
status,
|
|
19
|
+
...description ? { description } : {},
|
|
20
|
+
...code === "invalid_grant" ? { remedy: "Reauthorise the Google account and replace GBP_REFRESH_TOKEN." } : {}
|
|
21
|
+
});
|
|
14
22
|
this.reauthorizationRequired = code === "invalid_grant";
|
|
15
23
|
this.status = status;
|
|
16
24
|
this.description = description;
|
|
@@ -18,7 +26,15 @@ var GbpAuthError = class extends GbpError {
|
|
|
18
26
|
};
|
|
19
27
|
var GbpApiError = class extends GbpError {
|
|
20
28
|
constructor(options) {
|
|
21
|
-
|
|
29
|
+
const server = options.status >= 500;
|
|
30
|
+
super(`GBP ${options.operation} failed: ${server ? "5xx" : options.status}`, server ? "api_5xx" : `api_${options.status}`, options.retryable, {
|
|
31
|
+
operation: options.operation,
|
|
32
|
+
status: options.status,
|
|
33
|
+
attempts: options.attempts,
|
|
34
|
+
...options.body ? { body: options.body } : {},
|
|
35
|
+
...options.retryAfterMs !== void 0 ? { retryAfterMs: options.retryAfterMs } : {}
|
|
36
|
+
});
|
|
37
|
+
this.body = options.body;
|
|
22
38
|
this.operation = options.operation;
|
|
23
39
|
this.status = options.status;
|
|
24
40
|
this.retryable = options.retryable;
|
|
@@ -28,7 +44,7 @@ var GbpApiError = class extends GbpError {
|
|
|
28
44
|
};
|
|
29
45
|
var GbpTimeoutError = class extends GbpError {
|
|
30
46
|
constructor(operation, timeoutMs) {
|
|
31
|
-
super(
|
|
47
|
+
super(`GBP ${operation} timed out`, "timeout", true, { operation, timeoutMs });
|
|
32
48
|
this.operation = operation;
|
|
33
49
|
this.timeoutMs = timeoutMs;
|
|
34
50
|
}
|
|
@@ -36,7 +52,10 @@ var GbpTimeoutError = class extends GbpError {
|
|
|
36
52
|
var GbpPaginationError = class extends GbpError {
|
|
37
53
|
constructor(reason, pagesFetched) {
|
|
38
54
|
super(
|
|
39
|
-
reason === "repeated_token" ?
|
|
55
|
+
reason === "repeated_token" ? "GBP reviews.list returned a page token it had already returned" : "GBP reviews.list stopped at the page limit",
|
|
56
|
+
`pagination_${reason}`,
|
|
57
|
+
false,
|
|
58
|
+
{ pagesFetched }
|
|
40
59
|
);
|
|
41
60
|
this.reason = reason;
|
|
42
61
|
this.pagesFetched = pagesFetched;
|
|
@@ -46,6 +65,19 @@ function excerpt(text, max = 300) {
|
|
|
46
65
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
47
66
|
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
|
|
48
67
|
}
|
|
68
|
+
var GbpPublishedError = class extends GbpError {
|
|
69
|
+
constructor(reason, client, detail, status, body) {
|
|
70
|
+
const transient = reason === "http" && status !== void 0 && (status === 429 || status >= 500);
|
|
71
|
+
super(`Published reviews for ${client} unusable (${reason}): ${detail}`, `published_${reason}`, transient, {
|
|
72
|
+
client,
|
|
73
|
+
...status !== void 0 ? { status } : {},
|
|
74
|
+
...body ? { body } : {}
|
|
75
|
+
});
|
|
76
|
+
this.reason = reason;
|
|
77
|
+
this.client = client;
|
|
78
|
+
this.status = status;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
49
81
|
|
|
50
82
|
// src/http.ts
|
|
51
83
|
var DEFAULT_REQUEST_POLICY = {
|
|
@@ -356,19 +388,223 @@ async function getBusinessReviews(options = {}) {
|
|
|
356
388
|
reviews: order === "api" ? reviews : shuffleArray(reviews)
|
|
357
389
|
};
|
|
358
390
|
}
|
|
391
|
+
|
|
392
|
+
// src/published.ts
|
|
393
|
+
var DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
|
|
394
|
+
var PUBLISHED_REVIEWS_SCHEMA = 1;
|
|
395
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
396
|
+
var isTime = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
|
|
397
|
+
function parseReview2(v) {
|
|
398
|
+
if (!isRecord2(v)) return null;
|
|
399
|
+
const { id, author, rating, comment, createdAt, reply } = v;
|
|
400
|
+
if (typeof id !== "string" || typeof author !== "string" || typeof comment !== "string" || !comment) return null;
|
|
401
|
+
if (typeof rating !== "number" || !Number.isInteger(rating) || rating < 1 || rating > 5) return null;
|
|
402
|
+
const review = { id, author, rating, comment, createdAt: isTime(createdAt) ? createdAt : "" };
|
|
403
|
+
if (isRecord2(reply) && typeof reply.text === "string" && reply.text) {
|
|
404
|
+
review.reply = { text: reply.text, updatedAt: isTime(reply.updatedAt) ? reply.updatedAt : "" };
|
|
405
|
+
}
|
|
406
|
+
return review;
|
|
407
|
+
}
|
|
408
|
+
function parsePublishedReviews(body, client) {
|
|
409
|
+
if (!isRecord2(body)) throw new GbpPublishedError("invalid", client, "the file is not an object");
|
|
410
|
+
if (body.schema !== PUBLISHED_REVIEWS_SCHEMA) {
|
|
411
|
+
throw new GbpPublishedError("unknown_schema", client, `schema ${String(body.schema)}`);
|
|
412
|
+
}
|
|
413
|
+
if (body.client !== client) throw new GbpPublishedError("invalid", client, `the file is for ${String(body.client)}`);
|
|
414
|
+
const { rating, count } = body;
|
|
415
|
+
if (rating !== null && (typeof rating !== "number" || !(rating >= 0 && rating <= 5))) {
|
|
416
|
+
throw new GbpPublishedError("invalid", client, "rating out of range");
|
|
417
|
+
}
|
|
418
|
+
if (count !== null && (typeof count !== "number" || !Number.isInteger(count) || count < 0)) {
|
|
419
|
+
throw new GbpPublishedError("invalid", client, "count is not a whole number");
|
|
420
|
+
}
|
|
421
|
+
if (!isTime(body.synced_at) || !isTime(body.published_at) || !Array.isArray(body.reviews)) {
|
|
422
|
+
throw new GbpPublishedError("invalid", client, "synced_at, published_at or reviews missing");
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
averageRating: rating,
|
|
426
|
+
totalReviewCount: count,
|
|
427
|
+
reviews: body.reviews.map(parseReview2).filter((r) => r !== null),
|
|
428
|
+
syncedAt: body.synced_at,
|
|
429
|
+
publishedAt: body.published_at
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function shuffle(arr) {
|
|
433
|
+
const copy = [...arr];
|
|
434
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
435
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
436
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
437
|
+
}
|
|
438
|
+
return copy;
|
|
439
|
+
}
|
|
440
|
+
function publishedReviewsUrl(client, baseUrl) {
|
|
441
|
+
if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(client)) throw new RangeError(`not a client slug: ${client}`);
|
|
442
|
+
const base = (baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? DEFAULT_PUBLISHED_REVIEWS_BASE_URL).replace(/\/+$/, "");
|
|
443
|
+
return `${base}/${client}.json`;
|
|
444
|
+
}
|
|
445
|
+
async function getPublishedReviews(options) {
|
|
446
|
+
const { client, filterMinStars, limit, order = "api", next, request } = options;
|
|
447
|
+
const url = publishedReviewsUrl(client, options.baseUrl);
|
|
448
|
+
const init = {
|
|
449
|
+
headers: { Accept: "application/json" },
|
|
450
|
+
next: next ?? { revalidate: 86400, tags: ["google-reviews"] }
|
|
451
|
+
};
|
|
452
|
+
const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
|
|
453
|
+
if (!response.ok) {
|
|
454
|
+
const body2 = excerpt(await readErrorBody(response), 120);
|
|
455
|
+
throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
|
|
456
|
+
}
|
|
457
|
+
let body;
|
|
458
|
+
try {
|
|
459
|
+
body = await response.json();
|
|
460
|
+
} catch {
|
|
461
|
+
throw new GbpPublishedError("invalid", client, "the file is not JSON");
|
|
462
|
+
}
|
|
463
|
+
const result = parsePublishedReviews(body, client);
|
|
464
|
+
let reviews = filterMinStars === void 0 ? result.reviews : result.reviews.filter((r) => r.rating >= filterMinStars);
|
|
465
|
+
if (limit !== void 0) reviews = reviews.slice(0, limit);
|
|
466
|
+
return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/safe.ts
|
|
470
|
+
var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
|
|
471
|
+
var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
472
|
+
var DEFAULT_AUTH_RETRY_MS = 5 * 60 * 1e3;
|
|
473
|
+
var lastGood = /* @__PURE__ */ new Map();
|
|
474
|
+
var reported = /* @__PURE__ */ new Map();
|
|
475
|
+
var authBlockedUntil = /* @__PURE__ */ new Map();
|
|
476
|
+
function toGbpError(error) {
|
|
477
|
+
if (error instanceof GbpError) return error;
|
|
478
|
+
const name = error instanceof Error ? error.name : typeof error;
|
|
479
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
480
|
+
const wrapped = new GbpError("GBP reviews failed: unexpected error", "unexpected", true, { name, message: excerpt(message, 200) });
|
|
481
|
+
wrapped.cause = error;
|
|
482
|
+
return wrapped;
|
|
483
|
+
}
|
|
484
|
+
function isAuthFailure(error) {
|
|
485
|
+
return error.code === "invalid_grant" || error.code === "invalid_client";
|
|
486
|
+
}
|
|
487
|
+
function defaultReport(error, report) {
|
|
488
|
+
console.warn(`[gbp] ${error.message}; serving ${report.served}`, report.context);
|
|
489
|
+
}
|
|
490
|
+
async function loadFallback(fallback) {
|
|
491
|
+
if (fallback === void 0) return null;
|
|
492
|
+
try {
|
|
493
|
+
return (typeof fallback === "function" ? await fallback() : fallback) ?? null;
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function reportOnce(error, served, options) {
|
|
499
|
+
const now = Date.now();
|
|
500
|
+
const window = options.reportIntervalMs ?? DEFAULT_REPORT_INTERVAL_MS;
|
|
501
|
+
const last = reported.get(error.code);
|
|
502
|
+
if (last && now - last.at < window) {
|
|
503
|
+
last.suppressed++;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
reported.set(error.code, { at: now, suppressed: 0 });
|
|
507
|
+
try {
|
|
508
|
+
(options.report ?? defaultReport)(error, {
|
|
509
|
+
fingerprint: error.fingerprint,
|
|
510
|
+
code: error.code,
|
|
511
|
+
transient: error.transient,
|
|
512
|
+
context: error.context,
|
|
513
|
+
served,
|
|
514
|
+
suppressed: last?.suppressed ?? 0
|
|
515
|
+
});
|
|
516
|
+
} catch {
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function safely(key, load, empty, options) {
|
|
520
|
+
const now = Date.now();
|
|
521
|
+
let error;
|
|
522
|
+
const blocked = authBlockedUntil.get(key);
|
|
523
|
+
if (blocked && blocked.until > now) {
|
|
524
|
+
error = blocked.error;
|
|
525
|
+
} else {
|
|
526
|
+
try {
|
|
527
|
+
const result = await load();
|
|
528
|
+
lastGood.set(key, { result, at: Date.now() });
|
|
529
|
+
authBlockedUntil.delete(key);
|
|
530
|
+
return { ...result, source: "live" };
|
|
531
|
+
} catch (caught) {
|
|
532
|
+
error = toGbpError(caught);
|
|
533
|
+
const retryMs = options.authRetryMs ?? DEFAULT_AUTH_RETRY_MS;
|
|
534
|
+
if (isAuthFailure(error) && retryMs > 0) authBlockedUntil.set(key, { until: Date.now() + retryMs, error });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
let cached2 = lastGood.get(key);
|
|
538
|
+
if (cached2 && now - cached2.at > (options.maxStaleMs ?? DEFAULT_MAX_STALE_MS)) {
|
|
539
|
+
lastGood.delete(key);
|
|
540
|
+
cached2 = void 0;
|
|
541
|
+
}
|
|
542
|
+
let served;
|
|
543
|
+
if (cached2) {
|
|
544
|
+
served = { ...cached2.result, reviews: [...cached2.result.reviews], source: "cache", error };
|
|
545
|
+
} else {
|
|
546
|
+
const fallback = await loadFallback(options.fallback);
|
|
547
|
+
served = fallback ? { ...fallback, source: "fallback", error } : { ...empty(), error };
|
|
548
|
+
}
|
|
549
|
+
reportOnce(error, served.source, options);
|
|
550
|
+
return served;
|
|
551
|
+
}
|
|
552
|
+
function getBusinessReviewsSafe(options = {}) {
|
|
553
|
+
const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
|
|
554
|
+
const key = JSON.stringify([
|
|
555
|
+
"business",
|
|
556
|
+
process.env.GOOGLE_BUSINESS_LOCATION_ID ?? null,
|
|
557
|
+
fetchOptions.limit ?? null,
|
|
558
|
+
fetchOptions.filterMinStars ?? null,
|
|
559
|
+
fetchOptions.includeUnapprovedReplies ?? false
|
|
560
|
+
]);
|
|
561
|
+
return safely(
|
|
562
|
+
key,
|
|
563
|
+
() => getBusinessReviews(fetchOptions),
|
|
564
|
+
() => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
|
|
565
|
+
{ fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
function getPublishedReviewsSafe(options) {
|
|
569
|
+
const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
|
|
570
|
+
const key = JSON.stringify([
|
|
571
|
+
"published",
|
|
572
|
+
fetchOptions.client,
|
|
573
|
+
fetchOptions.baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? null,
|
|
574
|
+
fetchOptions.limit ?? null,
|
|
575
|
+
fetchOptions.filterMinStars ?? null
|
|
576
|
+
]);
|
|
577
|
+
return safely(
|
|
578
|
+
key,
|
|
579
|
+
() => getPublishedReviews(fetchOptions),
|
|
580
|
+
() => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
|
|
581
|
+
{ fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
|
|
582
|
+
);
|
|
583
|
+
}
|
|
359
584
|
export {
|
|
585
|
+
DEFAULT_AUTH_RETRY_MS,
|
|
360
586
|
DEFAULT_MAX_PAGES,
|
|
587
|
+
DEFAULT_MAX_STALE_MS,
|
|
588
|
+
DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
|
|
589
|
+
DEFAULT_REPORT_INTERVAL_MS,
|
|
361
590
|
DEFAULT_REQUEST_POLICY,
|
|
362
591
|
GbpApiError,
|
|
363
592
|
GbpAuthError,
|
|
364
593
|
GbpError,
|
|
365
594
|
GbpPaginationError,
|
|
595
|
+
GbpPublishedError,
|
|
366
596
|
GbpTimeoutError,
|
|
597
|
+
PUBLISHED_REVIEWS_SCHEMA,
|
|
367
598
|
getBusinessReviews,
|
|
599
|
+
getBusinessReviewsSafe,
|
|
368
600
|
getGoogleOAuthAccessToken,
|
|
601
|
+
getPublishedReviews,
|
|
602
|
+
getPublishedReviewsSafe,
|
|
369
603
|
hasGoogleOAuthCredentials,
|
|
370
604
|
invalidateAccessToken,
|
|
371
605
|
isBusinessProfileConfigured,
|
|
372
606
|
parseListReviewsResponse,
|
|
373
|
-
|
|
607
|
+
parsePublishedReviews,
|
|
608
|
+
parseRetryAfter,
|
|
609
|
+
publishedReviewsUrl
|
|
374
610
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domandigital/gbp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@types/node": "^20",
|
|
33
33
|
"tsup": "^8.3.5",
|
|
34
34
|
"typescript": "^5.6.3",
|
|
35
|
-
"vitest": "^
|
|
35
|
+
"vitest": "^4.0.0"
|
|
36
36
|
},
|
|
37
37
|
"engines": {
|
|
38
38
|
"node": ">=22.12"
|