@domandigital/gbp 0.5.0 → 0.7.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 +22 -0
- package/README.md +65 -2
- package/dist/index.cjs +220 -11
- package/dist/index.d.cts +150 -6
- package/dist/index.d.ts +150 -6
- package/dist/index.js +212 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 59e6d85: Read a client's Facebook Page recommendations from the file Doman Digital publishes, so a site needs no Meta token.
|
|
8
|
+
|
|
9
|
+
- **`getPublishedFacebookRecommendations({ client })`** fetches `https://files.domandigital.co.uk/reviews/<client>.facebook.json`, which the portal writes daily from the Page token it already holds.
|
|
10
|
+
- Returns Meta's own `rating` (null when Meta gives none), `count` (every recommendation), `recommends` (the positive ones) and `reviews`: positive recommendations with words, with `id`, `comment` and `createdAt`. There is no author, because Meta does not return one.
|
|
11
|
+
- Throws `GbpPublishedError` like `getPublishedReviews`, so the caller keeps its last good copy. Refuses the Google file, another client's file and a count that does not add up.
|
|
12
|
+
|
|
13
|
+
## 0.6.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
- **`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.
|
|
20
|
+
- 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`.
|
|
21
|
+
- After `invalid_grant` or `invalid_client` they skip the token request for `authRetryMs` (default five minutes) and serve the cache or fallback straight away.
|
|
22
|
+
- **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.
|
|
23
|
+
- 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`.
|
|
24
|
+
|
|
3
25
|
## 0.5.0
|
|
4
26
|
|
|
5
27
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -90,6 +90,25 @@ is no copy at all. Never fall back to a hardcoded number.
|
|
|
90
90
|
A site that reads this file needs none of the `GBP_*` or `GOOGLE_BUSINESS_*`
|
|
91
91
|
variables below.
|
|
92
92
|
|
|
93
|
+
### Facebook recommendations
|
|
94
|
+
|
|
95
|
+
The portal also publishes each client's Facebook Page recommendations beside
|
|
96
|
+
the Google file, at `<client-slug>.facebook.json`:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { getPublishedFacebookRecommendations } from "@domandigital/gbp";
|
|
100
|
+
|
|
101
|
+
const { rating, count, recommends, reviews } = await getPublishedFacebookRecommendations({
|
|
102
|
+
client: "rmp-electrical",
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Facebook has recommendations, not star reviews. `count` is every
|
|
107
|
+
recommendation on the Page, `recommends` the positive ones, and `rating` is
|
|
108
|
+
Meta's own overall figure (null when Meta gives none). `reviews` holds the
|
|
109
|
+
positive recommendations that have words. There is no author, because Meta
|
|
110
|
+
does not return one. It throws on failure like `getPublishedReviews`.
|
|
111
|
+
|
|
93
112
|
## Why Business Profile API, not Places API
|
|
94
113
|
|
|
95
114
|
Places API's `Review` object has no field for the business's reply, full
|
|
@@ -227,7 +246,45 @@ Options:
|
|
|
227
246
|
Never throws on missing configuration -- `isBusinessProfileConfigured()` gates
|
|
228
247
|
internally and returns an empty result, so UI can render unconditionally.
|
|
229
248
|
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
|
|
249
|
+
explicitly if it wants zero-downtime behaviour on a Google outage, or use
|
|
250
|
+
`getBusinessReviewsSafe`, which does that for it.
|
|
251
|
+
|
|
252
|
+
### `getBusinessReviewsSafe(options?)` and `getPublishedReviewsSafe(options)`: reviews that never throw
|
|
253
|
+
|
|
254
|
+
Use these in a page render. They take the same options as `getBusinessReviews`
|
|
255
|
+
and `getPublishedReviews`, catch every failure, and serve in order: the last
|
|
256
|
+
good result this process fetched for the same options (up to `maxStaleMs`,
|
|
257
|
+
default 30 days), then your `fallback`, then an empty result, which has a
|
|
258
|
+
`null` rating and count and an empty `reviews` list. `source` says which:
|
|
259
|
+
`"live"`, `"cache"`, `"fallback"` or `"empty"`, and `error` holds the failure
|
|
260
|
+
when there was one.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
import * as Sentry from "@sentry/nextjs";
|
|
264
|
+
import { getBusinessReviewsSafe } from "@domandigital/gbp";
|
|
265
|
+
|
|
266
|
+
const { averageRating, totalReviewCount, reviews, source } = await getBusinessReviewsSafe({
|
|
267
|
+
filterMinStars: 4,
|
|
268
|
+
next: { revalidate: 86400, tags: ["google-reviews"] },
|
|
269
|
+
// A snapshot, or a loader for one (a KV read). Served as given.
|
|
270
|
+
fallback: () => kv.get(LAST_KNOWN_GOOD_KEY),
|
|
271
|
+
report: (error, { fingerprint, context, transient }) =>
|
|
272
|
+
Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
|
|
273
|
+
});
|
|
274
|
+
if (source === "live") await kv.set(LAST_KNOWN_GOOD_KEY, { averageRating, totalReviewCount, reviews });
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
- `report` runs at most once per kind of failure per `reportIntervalMs`
|
|
278
|
+
(default one hour) in each process, not on every request. It is told how
|
|
279
|
+
many it held back since the last report (`suppressed`). Without `report`, one
|
|
280
|
+
`console.warn` per window.
|
|
281
|
+
- After `invalid_grant` or `invalid_client` the live call is skipped for
|
|
282
|
+
`authRetryMs` (default five minutes), because retrying cannot fix it.
|
|
283
|
+
- A `fallback` is served as given: `limit` and `filterMinStars` are not
|
|
284
|
+
applied to it. A loader that throws counts as no fallback.
|
|
285
|
+
|
|
286
|
+
A static build that should refuse to publish rather than ship stale or empty
|
|
287
|
+
reviews should keep using the throwing functions.
|
|
231
288
|
|
|
232
289
|
### Failures, and what each one means
|
|
233
290
|
|
|
@@ -237,7 +294,13 @@ a few times with backoff; Google documents the 429 for quota
|
|
|
237
294
|
A 401 means the cached access token is no longer good: it is dropped,
|
|
238
295
|
refreshed once, and the page is asked for again; a second 401 throws.
|
|
239
296
|
|
|
240
|
-
All errors extend `GbpError`, and none carries a credential
|
|
297
|
+
All errors extend `GbpError`, and none carries a credential. Each message is
|
|
298
|
+
short and stable (`GBP token refresh failed: invalid_grant`, `GBP reviews.list
|
|
299
|
+
failed: 5xx`), so an error tracker files every occurrence under one issue.
|
|
300
|
+
What varies, such as the status, Google's description or a body excerpt, is in
|
|
301
|
+
`error.context`. `error.code` names the failure, `error.fingerprint` is
|
|
302
|
+
`["gbp", code]` to pass to Sentry, and `error.transient` says whether it may
|
|
303
|
+
clear without anyone acting. Every 5xx shares the code `api_5xx`.
|
|
241
304
|
|
|
242
305
|
| Error | When | What to do |
|
|
243
306
|
|---|---|---|
|
package/dist/index.cjs
CHANGED
|
@@ -20,8 +20,11 @@ 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,
|
|
24
26
|
DEFAULT_PUBLISHED_REVIEWS_BASE_URL: () => DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
|
|
27
|
+
DEFAULT_REPORT_INTERVAL_MS: () => DEFAULT_REPORT_INTERVAL_MS,
|
|
25
28
|
DEFAULT_REQUEST_POLICY: () => DEFAULT_REQUEST_POLICY,
|
|
26
29
|
GbpApiError: () => GbpApiError,
|
|
27
30
|
GbpAuthError: () => GbpAuthError,
|
|
@@ -31,31 +34,44 @@ __export(index_exports, {
|
|
|
31
34
|
GbpTimeoutError: () => GbpTimeoutError,
|
|
32
35
|
PUBLISHED_REVIEWS_SCHEMA: () => PUBLISHED_REVIEWS_SCHEMA,
|
|
33
36
|
getBusinessReviews: () => getBusinessReviews,
|
|
37
|
+
getBusinessReviewsSafe: () => getBusinessReviewsSafe,
|
|
34
38
|
getGoogleOAuthAccessToken: () => getGoogleOAuthAccessToken,
|
|
39
|
+
getPublishedFacebookRecommendations: () => getPublishedFacebookRecommendations,
|
|
35
40
|
getPublishedReviews: () => getPublishedReviews,
|
|
41
|
+
getPublishedReviewsSafe: () => getPublishedReviewsSafe,
|
|
36
42
|
hasGoogleOAuthCredentials: () => hasGoogleOAuthCredentials,
|
|
37
43
|
invalidateAccessToken: () => invalidateAccessToken,
|
|
38
44
|
isBusinessProfileConfigured: () => isBusinessProfileConfigured,
|
|
39
45
|
parseListReviewsResponse: () => parseListReviewsResponse,
|
|
46
|
+
parsePublishedFacebook: () => parsePublishedFacebook,
|
|
40
47
|
parsePublishedReviews: () => parsePublishedReviews,
|
|
41
48
|
parseRetryAfter: () => parseRetryAfter,
|
|
49
|
+
publishedFacebookUrl: () => publishedFacebookUrl,
|
|
42
50
|
publishedReviewsUrl: () => publishedReviewsUrl
|
|
43
51
|
});
|
|
44
52
|
module.exports = __toCommonJS(index_exports);
|
|
45
53
|
|
|
46
54
|
// src/errors.ts
|
|
47
55
|
var GbpError = class extends Error {
|
|
48
|
-
constructor(message) {
|
|
56
|
+
constructor(message, code = "unknown", transient = false, context = {}) {
|
|
49
57
|
super(message);
|
|
50
58
|
this.name = new.target.name;
|
|
59
|
+
this.code = code;
|
|
60
|
+
this.transient = transient;
|
|
61
|
+
this.context = context;
|
|
62
|
+
}
|
|
63
|
+
/** `["gbp", code]`: one error-tracker issue per kind of failure. */
|
|
64
|
+
get fingerprint() {
|
|
65
|
+
return ["gbp", this.code];
|
|
51
66
|
}
|
|
52
67
|
};
|
|
53
68
|
var GbpAuthError = class extends GbpError {
|
|
54
69
|
constructor(code, status, description) {
|
|
55
|
-
super(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
super(`GBP token refresh failed: ${code}`, code, code === "token_request_failed" && status >= 500, {
|
|
71
|
+
status,
|
|
72
|
+
...description ? { description } : {},
|
|
73
|
+
...code === "invalid_grant" ? { remedy: "Reauthorise the Google account and replace GBP_REFRESH_TOKEN." } : {}
|
|
74
|
+
});
|
|
59
75
|
this.reauthorizationRequired = code === "invalid_grant";
|
|
60
76
|
this.status = status;
|
|
61
77
|
this.description = description;
|
|
@@ -63,7 +79,15 @@ var GbpAuthError = class extends GbpError {
|
|
|
63
79
|
};
|
|
64
80
|
var GbpApiError = class extends GbpError {
|
|
65
81
|
constructor(options) {
|
|
66
|
-
|
|
82
|
+
const server = options.status >= 500;
|
|
83
|
+
super(`GBP ${options.operation} failed: ${server ? "5xx" : options.status}`, server ? "api_5xx" : `api_${options.status}`, options.retryable, {
|
|
84
|
+
operation: options.operation,
|
|
85
|
+
status: options.status,
|
|
86
|
+
attempts: options.attempts,
|
|
87
|
+
...options.body ? { body: options.body } : {},
|
|
88
|
+
...options.retryAfterMs !== void 0 ? { retryAfterMs: options.retryAfterMs } : {}
|
|
89
|
+
});
|
|
90
|
+
this.body = options.body;
|
|
67
91
|
this.operation = options.operation;
|
|
68
92
|
this.status = options.status;
|
|
69
93
|
this.retryable = options.retryable;
|
|
@@ -73,7 +97,7 @@ var GbpApiError = class extends GbpError {
|
|
|
73
97
|
};
|
|
74
98
|
var GbpTimeoutError = class extends GbpError {
|
|
75
99
|
constructor(operation, timeoutMs) {
|
|
76
|
-
super(
|
|
100
|
+
super(`GBP ${operation} timed out`, "timeout", true, { operation, timeoutMs });
|
|
77
101
|
this.operation = operation;
|
|
78
102
|
this.timeoutMs = timeoutMs;
|
|
79
103
|
}
|
|
@@ -81,7 +105,10 @@ var GbpTimeoutError = class extends GbpError {
|
|
|
81
105
|
var GbpPaginationError = class extends GbpError {
|
|
82
106
|
constructor(reason, pagesFetched) {
|
|
83
107
|
super(
|
|
84
|
-
reason === "repeated_token" ?
|
|
108
|
+
reason === "repeated_token" ? "GBP reviews.list returned a page token it had already returned" : "GBP reviews.list stopped at the page limit",
|
|
109
|
+
`pagination_${reason}`,
|
|
110
|
+
false,
|
|
111
|
+
{ pagesFetched }
|
|
85
112
|
);
|
|
86
113
|
this.reason = reason;
|
|
87
114
|
this.pagesFetched = pagesFetched;
|
|
@@ -92,8 +119,13 @@ function excerpt(text, max = 300) {
|
|
|
92
119
|
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
|
|
93
120
|
}
|
|
94
121
|
var GbpPublishedError = class extends GbpError {
|
|
95
|
-
constructor(reason, client, detail, status) {
|
|
96
|
-
|
|
122
|
+
constructor(reason, client, detail, status, body) {
|
|
123
|
+
const transient = reason === "http" && status !== void 0 && (status === 429 || status >= 500);
|
|
124
|
+
super(`Published reviews for ${client} unusable (${reason}): ${detail}`, `published_${reason}`, transient, {
|
|
125
|
+
client,
|
|
126
|
+
...status !== void 0 ? { status } : {},
|
|
127
|
+
...body ? { body } : {}
|
|
128
|
+
});
|
|
97
129
|
this.reason = reason;
|
|
98
130
|
this.client = client;
|
|
99
131
|
this.status = status;
|
|
@@ -472,7 +504,8 @@ async function getPublishedReviews(options) {
|
|
|
472
504
|
};
|
|
473
505
|
const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
|
|
474
506
|
if (!response.ok) {
|
|
475
|
-
|
|
507
|
+
const body2 = excerpt(await readErrorBody(response), 120);
|
|
508
|
+
throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
|
|
476
509
|
}
|
|
477
510
|
let body;
|
|
478
511
|
try {
|
|
@@ -485,10 +518,181 @@ async function getPublishedReviews(options) {
|
|
|
485
518
|
if (limit !== void 0) reviews = reviews.slice(0, limit);
|
|
486
519
|
return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
|
|
487
520
|
}
|
|
521
|
+
|
|
522
|
+
// src/facebook-published.ts
|
|
523
|
+
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
524
|
+
var isTime2 = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
|
|
525
|
+
var isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
526
|
+
function parsePublishedFacebook(body, client) {
|
|
527
|
+
if (!isRecord3(body)) throw new GbpPublishedError("invalid", client, "the file is not an object");
|
|
528
|
+
if (body.schema !== PUBLISHED_REVIEWS_SCHEMA) throw new GbpPublishedError("unknown_schema", client, `schema ${String(body.schema)}`);
|
|
529
|
+
if (body.client !== client || body.source !== "facebook") {
|
|
530
|
+
throw new GbpPublishedError("invalid", client, `the file is for ${String(body.client)} (${String(body.source)})`);
|
|
531
|
+
}
|
|
532
|
+
const { rating, count, recommends } = body;
|
|
533
|
+
if (rating !== null && (typeof rating !== "number" || !(rating >= 0 && rating <= 5))) {
|
|
534
|
+
throw new GbpPublishedError("invalid", client, "rating out of range");
|
|
535
|
+
}
|
|
536
|
+
if (!isCount(count) || !isCount(recommends) || recommends > count) {
|
|
537
|
+
throw new GbpPublishedError("invalid", client, "count or recommends is not a whole number within the count");
|
|
538
|
+
}
|
|
539
|
+
if (!isTime2(body.synced_at) || !isTime2(body.published_at) || !Array.isArray(body.reviews)) {
|
|
540
|
+
throw new GbpPublishedError("invalid", client, "synced_at, published_at or reviews missing");
|
|
541
|
+
}
|
|
542
|
+
const reviews = body.reviews.flatMap(
|
|
543
|
+
(v) => isRecord3(v) && typeof v.id === "string" && typeof v.comment === "string" && v.comment && isTime2(v.createdAt) ? [{ id: v.id, comment: v.comment, createdAt: v.createdAt }] : []
|
|
544
|
+
);
|
|
545
|
+
return { rating, count, recommends, reviews, syncedAt: body.synced_at, publishedAt: body.published_at };
|
|
546
|
+
}
|
|
547
|
+
function publishedFacebookUrl(client, baseUrl) {
|
|
548
|
+
if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(client)) throw new RangeError(`not a client slug: ${client}`);
|
|
549
|
+
const base = (baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? DEFAULT_PUBLISHED_REVIEWS_BASE_URL).replace(/\/+$/, "");
|
|
550
|
+
return `${base}/${client}.facebook.json`;
|
|
551
|
+
}
|
|
552
|
+
async function getPublishedFacebookRecommendations(options) {
|
|
553
|
+
const { client, limit, next, request } = options;
|
|
554
|
+
const url = publishedFacebookUrl(client, options.baseUrl);
|
|
555
|
+
const init = {
|
|
556
|
+
headers: { Accept: "application/json" },
|
|
557
|
+
next: next ?? { revalidate: 86400, tags: ["facebook-reviews"] }
|
|
558
|
+
};
|
|
559
|
+
const { response } = await fetchWithRetry(url, init, "reviews.facebook", resolvePolicy(request));
|
|
560
|
+
if (!response.ok) {
|
|
561
|
+
const body2 = excerpt(await readErrorBody(response), 120);
|
|
562
|
+
throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
|
|
563
|
+
}
|
|
564
|
+
let body;
|
|
565
|
+
try {
|
|
566
|
+
body = await response.json();
|
|
567
|
+
} catch {
|
|
568
|
+
throw new GbpPublishedError("invalid", client, "the file is not JSON");
|
|
569
|
+
}
|
|
570
|
+
const result = parsePublishedFacebook(body, client);
|
|
571
|
+
return limit === void 0 ? result : { ...result, reviews: result.reviews.slice(0, limit) };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/safe.ts
|
|
575
|
+
var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
|
|
576
|
+
var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
577
|
+
var DEFAULT_AUTH_RETRY_MS = 5 * 60 * 1e3;
|
|
578
|
+
var lastGood = /* @__PURE__ */ new Map();
|
|
579
|
+
var reported = /* @__PURE__ */ new Map();
|
|
580
|
+
var authBlockedUntil = /* @__PURE__ */ new Map();
|
|
581
|
+
function toGbpError(error) {
|
|
582
|
+
if (error instanceof GbpError) return error;
|
|
583
|
+
const name = error instanceof Error ? error.name : typeof error;
|
|
584
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
585
|
+
const wrapped = new GbpError("GBP reviews failed: unexpected error", "unexpected", true, { name, message: excerpt(message, 200) });
|
|
586
|
+
wrapped.cause = error;
|
|
587
|
+
return wrapped;
|
|
588
|
+
}
|
|
589
|
+
function isAuthFailure(error) {
|
|
590
|
+
return error.code === "invalid_grant" || error.code === "invalid_client";
|
|
591
|
+
}
|
|
592
|
+
function defaultReport(error, report) {
|
|
593
|
+
console.warn(`[gbp] ${error.message}; serving ${report.served}`, report.context);
|
|
594
|
+
}
|
|
595
|
+
async function loadFallback(fallback) {
|
|
596
|
+
if (fallback === void 0) return null;
|
|
597
|
+
try {
|
|
598
|
+
return (typeof fallback === "function" ? await fallback() : fallback) ?? null;
|
|
599
|
+
} catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function reportOnce(error, served, options) {
|
|
604
|
+
const now = Date.now();
|
|
605
|
+
const window = options.reportIntervalMs ?? DEFAULT_REPORT_INTERVAL_MS;
|
|
606
|
+
const last = reported.get(error.code);
|
|
607
|
+
if (last && now - last.at < window) {
|
|
608
|
+
last.suppressed++;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
reported.set(error.code, { at: now, suppressed: 0 });
|
|
612
|
+
try {
|
|
613
|
+
(options.report ?? defaultReport)(error, {
|
|
614
|
+
fingerprint: error.fingerprint,
|
|
615
|
+
code: error.code,
|
|
616
|
+
transient: error.transient,
|
|
617
|
+
context: error.context,
|
|
618
|
+
served,
|
|
619
|
+
suppressed: last?.suppressed ?? 0
|
|
620
|
+
});
|
|
621
|
+
} catch {
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function safely(key, load, empty, options) {
|
|
625
|
+
const now = Date.now();
|
|
626
|
+
let error;
|
|
627
|
+
const blocked = authBlockedUntil.get(key);
|
|
628
|
+
if (blocked && blocked.until > now) {
|
|
629
|
+
error = blocked.error;
|
|
630
|
+
} else {
|
|
631
|
+
try {
|
|
632
|
+
const result = await load();
|
|
633
|
+
lastGood.set(key, { result, at: Date.now() });
|
|
634
|
+
authBlockedUntil.delete(key);
|
|
635
|
+
return { ...result, source: "live" };
|
|
636
|
+
} catch (caught) {
|
|
637
|
+
error = toGbpError(caught);
|
|
638
|
+
const retryMs = options.authRetryMs ?? DEFAULT_AUTH_RETRY_MS;
|
|
639
|
+
if (isAuthFailure(error) && retryMs > 0) authBlockedUntil.set(key, { until: Date.now() + retryMs, error });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
let cached2 = lastGood.get(key);
|
|
643
|
+
if (cached2 && now - cached2.at > (options.maxStaleMs ?? DEFAULT_MAX_STALE_MS)) {
|
|
644
|
+
lastGood.delete(key);
|
|
645
|
+
cached2 = void 0;
|
|
646
|
+
}
|
|
647
|
+
let served;
|
|
648
|
+
if (cached2) {
|
|
649
|
+
served = { ...cached2.result, reviews: [...cached2.result.reviews], source: "cache", error };
|
|
650
|
+
} else {
|
|
651
|
+
const fallback = await loadFallback(options.fallback);
|
|
652
|
+
served = fallback ? { ...fallback, source: "fallback", error } : { ...empty(), error };
|
|
653
|
+
}
|
|
654
|
+
reportOnce(error, served.source, options);
|
|
655
|
+
return served;
|
|
656
|
+
}
|
|
657
|
+
function getBusinessReviewsSafe(options = {}) {
|
|
658
|
+
const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
|
|
659
|
+
const key = JSON.stringify([
|
|
660
|
+
"business",
|
|
661
|
+
process.env.GOOGLE_BUSINESS_LOCATION_ID ?? null,
|
|
662
|
+
fetchOptions.limit ?? null,
|
|
663
|
+
fetchOptions.filterMinStars ?? null,
|
|
664
|
+
fetchOptions.includeUnapprovedReplies ?? false
|
|
665
|
+
]);
|
|
666
|
+
return safely(
|
|
667
|
+
key,
|
|
668
|
+
() => getBusinessReviews(fetchOptions),
|
|
669
|
+
() => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
|
|
670
|
+
{ fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
function getPublishedReviewsSafe(options) {
|
|
674
|
+
const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
|
|
675
|
+
const key = JSON.stringify([
|
|
676
|
+
"published",
|
|
677
|
+
fetchOptions.client,
|
|
678
|
+
fetchOptions.baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? null,
|
|
679
|
+
fetchOptions.limit ?? null,
|
|
680
|
+
fetchOptions.filterMinStars ?? null
|
|
681
|
+
]);
|
|
682
|
+
return safely(
|
|
683
|
+
key,
|
|
684
|
+
() => getPublishedReviews(fetchOptions),
|
|
685
|
+
() => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
|
|
686
|
+
{ fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
|
|
687
|
+
);
|
|
688
|
+
}
|
|
488
689
|
// Annotate the CommonJS export names for ESM import in node:
|
|
489
690
|
0 && (module.exports = {
|
|
691
|
+
DEFAULT_AUTH_RETRY_MS,
|
|
490
692
|
DEFAULT_MAX_PAGES,
|
|
693
|
+
DEFAULT_MAX_STALE_MS,
|
|
491
694
|
DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
|
|
695
|
+
DEFAULT_REPORT_INTERVAL_MS,
|
|
492
696
|
DEFAULT_REQUEST_POLICY,
|
|
493
697
|
GbpApiError,
|
|
494
698
|
GbpAuthError,
|
|
@@ -498,13 +702,18 @@ async function getPublishedReviews(options) {
|
|
|
498
702
|
GbpTimeoutError,
|
|
499
703
|
PUBLISHED_REVIEWS_SCHEMA,
|
|
500
704
|
getBusinessReviews,
|
|
705
|
+
getBusinessReviewsSafe,
|
|
501
706
|
getGoogleOAuthAccessToken,
|
|
707
|
+
getPublishedFacebookRecommendations,
|
|
502
708
|
getPublishedReviews,
|
|
709
|
+
getPublishedReviewsSafe,
|
|
503
710
|
hasGoogleOAuthCredentials,
|
|
504
711
|
invalidateAccessToken,
|
|
505
712
|
isBusinessProfileConfigured,
|
|
506
713
|
parseListReviewsResponse,
|
|
714
|
+
parsePublishedFacebook,
|
|
507
715
|
parsePublishedReviews,
|
|
508
716
|
parseRetryAfter,
|
|
717
|
+
publishedFacebookUrl,
|
|
509
718
|
publishedReviewsUrl
|
|
510
719
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -282,18 +282,72 @@ declare function publishedReviewsUrl(client: string, baseUrl?: string): string;
|
|
|
282
282
|
*/
|
|
283
283
|
declare function getPublishedReviews(options: GetPublishedReviewsOptions): Promise<PublishedReviewsResult>;
|
|
284
284
|
|
|
285
|
+
interface FacebookRecommendation {
|
|
286
|
+
id: string;
|
|
287
|
+
comment: string;
|
|
288
|
+
/** ISO 8601. */
|
|
289
|
+
createdAt: string;
|
|
290
|
+
}
|
|
291
|
+
interface PublishedFacebookResult {
|
|
292
|
+
/** Meta's overall rating out of 5, or null when Meta gives none. */
|
|
293
|
+
rating: number | null;
|
|
294
|
+
/** Every recommendation on the Page, positive and negative. */
|
|
295
|
+
count: number;
|
|
296
|
+
/** The positive ones. */
|
|
297
|
+
recommends: number;
|
|
298
|
+
/** Positive recommendations with words, newest first. */
|
|
299
|
+
reviews: FacebookRecommendation[];
|
|
300
|
+
/** When the portal last read the Page in full. */
|
|
301
|
+
syncedAt: string;
|
|
302
|
+
publishedAt: string;
|
|
303
|
+
}
|
|
304
|
+
interface GetPublishedFacebookOptions {
|
|
305
|
+
/** The client's slug, e.g. `"rmp-electrical"`. */
|
|
306
|
+
client: string;
|
|
307
|
+
/** Default: `GBP_REVIEWS_BASE_URL`, else the Doman Digital address. */
|
|
308
|
+
baseUrl?: string;
|
|
309
|
+
/** Max recommendations to return. Default: all of them. */
|
|
310
|
+
limit?: number;
|
|
311
|
+
/** Cache hint passed to `fetch` for Next.js. Default: revalidate daily, tag `facebook-reviews`. */
|
|
312
|
+
next?: {
|
|
313
|
+
revalidate?: number;
|
|
314
|
+
tags?: string[];
|
|
315
|
+
};
|
|
316
|
+
request?: GbpRequestOptions;
|
|
317
|
+
}
|
|
318
|
+
/** Validate a published Facebook file. A malformed entry is skipped; a malformed file throws. */
|
|
319
|
+
declare function parsePublishedFacebook(body: unknown, client: string): PublishedFacebookResult;
|
|
320
|
+
/** The URL a client's Facebook file is read from. */
|
|
321
|
+
declare function publishedFacebookUrl(client: string, baseUrl?: string): string;
|
|
322
|
+
/** Fetch a client's published Facebook recommendations. Throws {@link GbpPublishedError} or `GbpTimeoutError`. */
|
|
323
|
+
declare function getPublishedFacebookRecommendations(options: GetPublishedFacebookOptions): Promise<PublishedFacebookResult>;
|
|
324
|
+
|
|
285
325
|
/**
|
|
286
326
|
* Typed failures, so a site can tell "reauthorise the Google account" from
|
|
287
327
|
* "Google is having a bad minute" from "our code looped", and log the
|
|
288
328
|
* difference without ever logging a credential.
|
|
289
329
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* excerpt
|
|
330
|
+
* Every message is short and stable: the same failure always reads the same,
|
|
331
|
+
* so an error tracker groups it as one issue rather than one per response
|
|
332
|
+
* body. What varies (status, Google's description, a body excerpt) sits in
|
|
333
|
+
* `context`, and `fingerprint` is `["gbp", code]` for a tracker that takes
|
|
334
|
+
* one (Sentry's `captureException(error, { fingerprint, extra: context })`).
|
|
335
|
+
*
|
|
336
|
+
* No message or context here carries a client id, client secret, refresh
|
|
337
|
+
* token, access token or review text. Bodies from Google are cut to a short,
|
|
338
|
+
* bounded excerpt.
|
|
293
339
|
*/
|
|
294
340
|
/** Base class: `instanceof GbpError` catches every failure this package throws. */
|
|
295
341
|
declare class GbpError extends Error {
|
|
296
|
-
|
|
342
|
+
/** Short, stable name for the failure, e.g. `invalid_grant` or `api_5xx`. */
|
|
343
|
+
readonly code: string;
|
|
344
|
+
/** True when the same request may well work later without anyone changing anything. */
|
|
345
|
+
readonly transient: boolean;
|
|
346
|
+
/** What varies between occurrences: status, Google's description, a body excerpt. Never a credential. */
|
|
347
|
+
readonly context: Record<string, unknown>;
|
|
348
|
+
constructor(message: string, code?: string, transient?: boolean, context?: Record<string, unknown>);
|
|
349
|
+
/** `["gbp", code]`: one error-tracker issue per kind of failure. */
|
|
350
|
+
get fingerprint(): string[];
|
|
297
351
|
}
|
|
298
352
|
type GbpAuthErrorCode = "invalid_grant" | "invalid_client" | "token_request_failed";
|
|
299
353
|
/**
|
|
@@ -319,6 +373,8 @@ declare class GbpApiError extends GbpError {
|
|
|
319
373
|
readonly attempts: number;
|
|
320
374
|
/** Set when Google asked for a longer wait than this package will sleep through. */
|
|
321
375
|
readonly retryAfterMs?: number;
|
|
376
|
+
/** A short excerpt of Google's error body. In `context` too, never in the message. */
|
|
377
|
+
readonly body?: string;
|
|
322
378
|
constructor(options: {
|
|
323
379
|
operation: string;
|
|
324
380
|
status: number;
|
|
@@ -354,7 +410,95 @@ declare class GbpPublishedError extends GbpError {
|
|
|
354
410
|
readonly reason: "http" | "invalid" | "unknown_schema";
|
|
355
411
|
readonly client: string;
|
|
356
412
|
readonly status?: number;
|
|
357
|
-
constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number);
|
|
413
|
+
constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number, body?: string);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Reviews that never throw into a page render.
|
|
418
|
+
*
|
|
419
|
+
* `getBusinessReviews` and `getPublishedReviews` throw on a real failure, so a
|
|
420
|
+
* caller can choose what to do. Most callers want the same thing: keep showing
|
|
421
|
+
* what they showed last time, tell someone once, and carry on. A dead refresh
|
|
422
|
+
* token (`invalid_grant`, every 7 days while the OAuth app is in Testing) or a
|
|
423
|
+
* Google 503 otherwise reaches every server render, and a site that reports
|
|
424
|
+
* each one sends thousands of events about one fault.
|
|
425
|
+
*
|
|
426
|
+
* The `...Safe` functions here catch every failure and serve, in order:
|
|
427
|
+
*
|
|
428
|
+
* 1. the last good result this process fetched for the same options
|
|
429
|
+
* (`source: "cache"`), up to `maxStaleMs` old;
|
|
430
|
+
* 2. the caller's `fallback`, a snapshot or a loader for one such as a KV
|
|
431
|
+
* read (`source: "fallback"`);
|
|
432
|
+
* 3. an empty result: no rating, no count, no reviews (`source: "empty"`).
|
|
433
|
+
*
|
|
434
|
+
* Each kind of failure is reported at most once per `reportIntervalMs` per
|
|
435
|
+
* process, through `report`. After an auth failure no amount of retrying
|
|
436
|
+
* helps, so the live call is skipped for `authRetryMs` and the cache or
|
|
437
|
+
* fallback is served straight away.
|
|
438
|
+
*/
|
|
439
|
+
|
|
440
|
+
/** Where a safe result came from. */
|
|
441
|
+
type ReviewsSource = "live" | "cache" | "fallback" | "empty";
|
|
442
|
+
/** A result from a `...Safe` function: the reviews, where they came from, and the failure if there was one. */
|
|
443
|
+
type SafeReviewsResult<T extends BusinessReviewsResult = BusinessReviewsResult> = T & {
|
|
444
|
+
source: ReviewsSource;
|
|
445
|
+
/** The failure behind a `cache`, `fallback` or `empty` result. */
|
|
446
|
+
error?: GbpError;
|
|
447
|
+
};
|
|
448
|
+
/** What `report` is told alongside the error. */
|
|
449
|
+
interface GbpFailureReport {
|
|
450
|
+
/** `["gbp", code]`. Pass it to Sentry as `fingerprint` so every occurrence is one issue. */
|
|
451
|
+
fingerprint: string[];
|
|
452
|
+
code: string;
|
|
453
|
+
/** True when the failure may clear by itself (a 5xx, a timeout); false when a person has to act. */
|
|
454
|
+
transient: boolean;
|
|
455
|
+
/** Status, Google's description, a body excerpt. Never a credential. Pass it as Sentry `extra`. */
|
|
456
|
+
context: Record<string, unknown>;
|
|
457
|
+
/** What the page was given instead. */
|
|
458
|
+
served: Exclude<ReviewsSource, "live">;
|
|
459
|
+
/** Failures of this kind left unreported since the last report, because they fell inside the window. */
|
|
460
|
+
suppressed: number;
|
|
358
461
|
}
|
|
462
|
+
interface SafeReviewsOptions<F extends BusinessReviewsResult = BusinessReviewsResult> {
|
|
463
|
+
/**
|
|
464
|
+
* Served when there is no cached result: a snapshot, or a function that loads one (a KV read, say). A loader that
|
|
465
|
+
* throws or returns nothing falls through to an empty result. Served as given: `limit` and `filterMinStars` are
|
|
466
|
+
* not applied to it.
|
|
467
|
+
*/
|
|
468
|
+
fallback?: F | (() => F | null | undefined | Promise<F | null | undefined>);
|
|
469
|
+
/**
|
|
470
|
+
* Called at most once per kind of failure per `reportIntervalMs`, per process. Default: one `console.warn`.
|
|
471
|
+
*
|
|
472
|
+
* ```ts
|
|
473
|
+
* report: (error, { fingerprint, context, transient }) =>
|
|
474
|
+
* Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
|
|
475
|
+
* ```
|
|
476
|
+
*/
|
|
477
|
+
report?: (error: GbpError, report: GbpFailureReport) => void;
|
|
478
|
+
/** Default one hour. */
|
|
479
|
+
reportIntervalMs?: number;
|
|
480
|
+
/** Longest a cached result is served after the last good fetch. Default 30 days. */
|
|
481
|
+
maxStaleMs?: number;
|
|
482
|
+
/** After an auth failure, skip the live call for this long. Default five minutes. `0` turns it off. */
|
|
483
|
+
authRetryMs?: number;
|
|
484
|
+
}
|
|
485
|
+
declare const DEFAULT_REPORT_INTERVAL_MS: number;
|
|
486
|
+
declare const DEFAULT_MAX_STALE_MS: number;
|
|
487
|
+
declare const DEFAULT_AUTH_RETRY_MS: number;
|
|
488
|
+
/**
|
|
489
|
+
* `getBusinessReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
|
|
490
|
+
* empty result, and reports the failure at most once per window. `source` says which.
|
|
491
|
+
*/
|
|
492
|
+
declare function getBusinessReviewsSafe(options?: GetBusinessReviewsOptions & SafeReviewsOptions): Promise<SafeReviewsResult>;
|
|
493
|
+
/** A published result, or a fallback or empty one, which carries no sync times. */
|
|
494
|
+
type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Partial<Pick<PublishedReviewsResult, "syncedAt" | "publishedAt">>>;
|
|
495
|
+
/**
|
|
496
|
+
* `getPublishedReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
|
|
497
|
+
* empty result, and reports the failure at most once per window. `source` says which.
|
|
498
|
+
*
|
|
499
|
+
* Where a framework keeps the last good page when a render throws (a static build that refuses to publish), the
|
|
500
|
+
* throwing `getPublishedReviews` keeps that behaviour; use this where a throw would reach the visitor.
|
|
501
|
+
*/
|
|
502
|
+
declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
|
|
359
503
|
|
|
360
|
-
export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_MAX_PAGES, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, getBusinessReviews, getGoogleOAuthAccessToken, getPublishedReviews, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
|
|
504
|
+
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, type FacebookRecommendation, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedFacebookOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedFacebookResult, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedFacebookRecommendations, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedFacebook, parsePublishedReviews, parseRetryAfter, publishedFacebookUrl, publishedReviewsUrl };
|
package/dist/index.d.ts
CHANGED
|
@@ -282,18 +282,72 @@ declare function publishedReviewsUrl(client: string, baseUrl?: string): string;
|
|
|
282
282
|
*/
|
|
283
283
|
declare function getPublishedReviews(options: GetPublishedReviewsOptions): Promise<PublishedReviewsResult>;
|
|
284
284
|
|
|
285
|
+
interface FacebookRecommendation {
|
|
286
|
+
id: string;
|
|
287
|
+
comment: string;
|
|
288
|
+
/** ISO 8601. */
|
|
289
|
+
createdAt: string;
|
|
290
|
+
}
|
|
291
|
+
interface PublishedFacebookResult {
|
|
292
|
+
/** Meta's overall rating out of 5, or null when Meta gives none. */
|
|
293
|
+
rating: number | null;
|
|
294
|
+
/** Every recommendation on the Page, positive and negative. */
|
|
295
|
+
count: number;
|
|
296
|
+
/** The positive ones. */
|
|
297
|
+
recommends: number;
|
|
298
|
+
/** Positive recommendations with words, newest first. */
|
|
299
|
+
reviews: FacebookRecommendation[];
|
|
300
|
+
/** When the portal last read the Page in full. */
|
|
301
|
+
syncedAt: string;
|
|
302
|
+
publishedAt: string;
|
|
303
|
+
}
|
|
304
|
+
interface GetPublishedFacebookOptions {
|
|
305
|
+
/** The client's slug, e.g. `"rmp-electrical"`. */
|
|
306
|
+
client: string;
|
|
307
|
+
/** Default: `GBP_REVIEWS_BASE_URL`, else the Doman Digital address. */
|
|
308
|
+
baseUrl?: string;
|
|
309
|
+
/** Max recommendations to return. Default: all of them. */
|
|
310
|
+
limit?: number;
|
|
311
|
+
/** Cache hint passed to `fetch` for Next.js. Default: revalidate daily, tag `facebook-reviews`. */
|
|
312
|
+
next?: {
|
|
313
|
+
revalidate?: number;
|
|
314
|
+
tags?: string[];
|
|
315
|
+
};
|
|
316
|
+
request?: GbpRequestOptions;
|
|
317
|
+
}
|
|
318
|
+
/** Validate a published Facebook file. A malformed entry is skipped; a malformed file throws. */
|
|
319
|
+
declare function parsePublishedFacebook(body: unknown, client: string): PublishedFacebookResult;
|
|
320
|
+
/** The URL a client's Facebook file is read from. */
|
|
321
|
+
declare function publishedFacebookUrl(client: string, baseUrl?: string): string;
|
|
322
|
+
/** Fetch a client's published Facebook recommendations. Throws {@link GbpPublishedError} or `GbpTimeoutError`. */
|
|
323
|
+
declare function getPublishedFacebookRecommendations(options: GetPublishedFacebookOptions): Promise<PublishedFacebookResult>;
|
|
324
|
+
|
|
285
325
|
/**
|
|
286
326
|
* Typed failures, so a site can tell "reauthorise the Google account" from
|
|
287
327
|
* "Google is having a bad minute" from "our code looped", and log the
|
|
288
328
|
* difference without ever logging a credential.
|
|
289
329
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* excerpt
|
|
330
|
+
* Every message is short and stable: the same failure always reads the same,
|
|
331
|
+
* so an error tracker groups it as one issue rather than one per response
|
|
332
|
+
* body. What varies (status, Google's description, a body excerpt) sits in
|
|
333
|
+
* `context`, and `fingerprint` is `["gbp", code]` for a tracker that takes
|
|
334
|
+
* one (Sentry's `captureException(error, { fingerprint, extra: context })`).
|
|
335
|
+
*
|
|
336
|
+
* No message or context here carries a client id, client secret, refresh
|
|
337
|
+
* token, access token or review text. Bodies from Google are cut to a short,
|
|
338
|
+
* bounded excerpt.
|
|
293
339
|
*/
|
|
294
340
|
/** Base class: `instanceof GbpError` catches every failure this package throws. */
|
|
295
341
|
declare class GbpError extends Error {
|
|
296
|
-
|
|
342
|
+
/** Short, stable name for the failure, e.g. `invalid_grant` or `api_5xx`. */
|
|
343
|
+
readonly code: string;
|
|
344
|
+
/** True when the same request may well work later without anyone changing anything. */
|
|
345
|
+
readonly transient: boolean;
|
|
346
|
+
/** What varies between occurrences: status, Google's description, a body excerpt. Never a credential. */
|
|
347
|
+
readonly context: Record<string, unknown>;
|
|
348
|
+
constructor(message: string, code?: string, transient?: boolean, context?: Record<string, unknown>);
|
|
349
|
+
/** `["gbp", code]`: one error-tracker issue per kind of failure. */
|
|
350
|
+
get fingerprint(): string[];
|
|
297
351
|
}
|
|
298
352
|
type GbpAuthErrorCode = "invalid_grant" | "invalid_client" | "token_request_failed";
|
|
299
353
|
/**
|
|
@@ -319,6 +373,8 @@ declare class GbpApiError extends GbpError {
|
|
|
319
373
|
readonly attempts: number;
|
|
320
374
|
/** Set when Google asked for a longer wait than this package will sleep through. */
|
|
321
375
|
readonly retryAfterMs?: number;
|
|
376
|
+
/** A short excerpt of Google's error body. In `context` too, never in the message. */
|
|
377
|
+
readonly body?: string;
|
|
322
378
|
constructor(options: {
|
|
323
379
|
operation: string;
|
|
324
380
|
status: number;
|
|
@@ -354,7 +410,95 @@ declare class GbpPublishedError extends GbpError {
|
|
|
354
410
|
readonly reason: "http" | "invalid" | "unknown_schema";
|
|
355
411
|
readonly client: string;
|
|
356
412
|
readonly status?: number;
|
|
357
|
-
constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number);
|
|
413
|
+
constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number, body?: string);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Reviews that never throw into a page render.
|
|
418
|
+
*
|
|
419
|
+
* `getBusinessReviews` and `getPublishedReviews` throw on a real failure, so a
|
|
420
|
+
* caller can choose what to do. Most callers want the same thing: keep showing
|
|
421
|
+
* what they showed last time, tell someone once, and carry on. A dead refresh
|
|
422
|
+
* token (`invalid_grant`, every 7 days while the OAuth app is in Testing) or a
|
|
423
|
+
* Google 503 otherwise reaches every server render, and a site that reports
|
|
424
|
+
* each one sends thousands of events about one fault.
|
|
425
|
+
*
|
|
426
|
+
* The `...Safe` functions here catch every failure and serve, in order:
|
|
427
|
+
*
|
|
428
|
+
* 1. the last good result this process fetched for the same options
|
|
429
|
+
* (`source: "cache"`), up to `maxStaleMs` old;
|
|
430
|
+
* 2. the caller's `fallback`, a snapshot or a loader for one such as a KV
|
|
431
|
+
* read (`source: "fallback"`);
|
|
432
|
+
* 3. an empty result: no rating, no count, no reviews (`source: "empty"`).
|
|
433
|
+
*
|
|
434
|
+
* Each kind of failure is reported at most once per `reportIntervalMs` per
|
|
435
|
+
* process, through `report`. After an auth failure no amount of retrying
|
|
436
|
+
* helps, so the live call is skipped for `authRetryMs` and the cache or
|
|
437
|
+
* fallback is served straight away.
|
|
438
|
+
*/
|
|
439
|
+
|
|
440
|
+
/** Where a safe result came from. */
|
|
441
|
+
type ReviewsSource = "live" | "cache" | "fallback" | "empty";
|
|
442
|
+
/** A result from a `...Safe` function: the reviews, where they came from, and the failure if there was one. */
|
|
443
|
+
type SafeReviewsResult<T extends BusinessReviewsResult = BusinessReviewsResult> = T & {
|
|
444
|
+
source: ReviewsSource;
|
|
445
|
+
/** The failure behind a `cache`, `fallback` or `empty` result. */
|
|
446
|
+
error?: GbpError;
|
|
447
|
+
};
|
|
448
|
+
/** What `report` is told alongside the error. */
|
|
449
|
+
interface GbpFailureReport {
|
|
450
|
+
/** `["gbp", code]`. Pass it to Sentry as `fingerprint` so every occurrence is one issue. */
|
|
451
|
+
fingerprint: string[];
|
|
452
|
+
code: string;
|
|
453
|
+
/** True when the failure may clear by itself (a 5xx, a timeout); false when a person has to act. */
|
|
454
|
+
transient: boolean;
|
|
455
|
+
/** Status, Google's description, a body excerpt. Never a credential. Pass it as Sentry `extra`. */
|
|
456
|
+
context: Record<string, unknown>;
|
|
457
|
+
/** What the page was given instead. */
|
|
458
|
+
served: Exclude<ReviewsSource, "live">;
|
|
459
|
+
/** Failures of this kind left unreported since the last report, because they fell inside the window. */
|
|
460
|
+
suppressed: number;
|
|
358
461
|
}
|
|
462
|
+
interface SafeReviewsOptions<F extends BusinessReviewsResult = BusinessReviewsResult> {
|
|
463
|
+
/**
|
|
464
|
+
* Served when there is no cached result: a snapshot, or a function that loads one (a KV read, say). A loader that
|
|
465
|
+
* throws or returns nothing falls through to an empty result. Served as given: `limit` and `filterMinStars` are
|
|
466
|
+
* not applied to it.
|
|
467
|
+
*/
|
|
468
|
+
fallback?: F | (() => F | null | undefined | Promise<F | null | undefined>);
|
|
469
|
+
/**
|
|
470
|
+
* Called at most once per kind of failure per `reportIntervalMs`, per process. Default: one `console.warn`.
|
|
471
|
+
*
|
|
472
|
+
* ```ts
|
|
473
|
+
* report: (error, { fingerprint, context, transient }) =>
|
|
474
|
+
* Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
|
|
475
|
+
* ```
|
|
476
|
+
*/
|
|
477
|
+
report?: (error: GbpError, report: GbpFailureReport) => void;
|
|
478
|
+
/** Default one hour. */
|
|
479
|
+
reportIntervalMs?: number;
|
|
480
|
+
/** Longest a cached result is served after the last good fetch. Default 30 days. */
|
|
481
|
+
maxStaleMs?: number;
|
|
482
|
+
/** After an auth failure, skip the live call for this long. Default five minutes. `0` turns it off. */
|
|
483
|
+
authRetryMs?: number;
|
|
484
|
+
}
|
|
485
|
+
declare const DEFAULT_REPORT_INTERVAL_MS: number;
|
|
486
|
+
declare const DEFAULT_MAX_STALE_MS: number;
|
|
487
|
+
declare const DEFAULT_AUTH_RETRY_MS: number;
|
|
488
|
+
/**
|
|
489
|
+
* `getBusinessReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
|
|
490
|
+
* empty result, and reports the failure at most once per window. `source` says which.
|
|
491
|
+
*/
|
|
492
|
+
declare function getBusinessReviewsSafe(options?: GetBusinessReviewsOptions & SafeReviewsOptions): Promise<SafeReviewsResult>;
|
|
493
|
+
/** A published result, or a fallback or empty one, which carries no sync times. */
|
|
494
|
+
type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Partial<Pick<PublishedReviewsResult, "syncedAt" | "publishedAt">>>;
|
|
495
|
+
/**
|
|
496
|
+
* `getPublishedReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
|
|
497
|
+
* empty result, and reports the failure at most once per window. `source` says which.
|
|
498
|
+
*
|
|
499
|
+
* Where a framework keeps the last good page when a render throws (a static build that refuses to publish), the
|
|
500
|
+
* throwing `getPublishedReviews` keeps that behaviour; use this where a throw would reach the visitor.
|
|
501
|
+
*/
|
|
502
|
+
declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
|
|
359
503
|
|
|
360
|
-
export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_MAX_PAGES, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, getBusinessReviews, getGoogleOAuthAccessToken, getPublishedReviews, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
|
|
504
|
+
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, type FacebookRecommendation, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedFacebookOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedFacebookResult, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedFacebookRecommendations, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedFacebook, parsePublishedReviews, parseRetryAfter, publishedFacebookUrl, 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;
|
|
@@ -47,8 +66,13 @@ function excerpt(text, max = 300) {
|
|
|
47
66
|
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
|
|
48
67
|
}
|
|
49
68
|
var GbpPublishedError = class extends GbpError {
|
|
50
|
-
constructor(reason, client, detail, status) {
|
|
51
|
-
|
|
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
|
+
});
|
|
52
76
|
this.reason = reason;
|
|
53
77
|
this.client = client;
|
|
54
78
|
this.status = status;
|
|
@@ -427,7 +451,8 @@ async function getPublishedReviews(options) {
|
|
|
427
451
|
};
|
|
428
452
|
const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
|
|
429
453
|
if (!response.ok) {
|
|
430
|
-
|
|
454
|
+
const body2 = excerpt(await readErrorBody(response), 120);
|
|
455
|
+
throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
|
|
431
456
|
}
|
|
432
457
|
let body;
|
|
433
458
|
try {
|
|
@@ -440,9 +465,180 @@ async function getPublishedReviews(options) {
|
|
|
440
465
|
if (limit !== void 0) reviews = reviews.slice(0, limit);
|
|
441
466
|
return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
|
|
442
467
|
}
|
|
468
|
+
|
|
469
|
+
// src/facebook-published.ts
|
|
470
|
+
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
471
|
+
var isTime2 = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
|
|
472
|
+
var isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
473
|
+
function parsePublishedFacebook(body, client) {
|
|
474
|
+
if (!isRecord3(body)) throw new GbpPublishedError("invalid", client, "the file is not an object");
|
|
475
|
+
if (body.schema !== PUBLISHED_REVIEWS_SCHEMA) throw new GbpPublishedError("unknown_schema", client, `schema ${String(body.schema)}`);
|
|
476
|
+
if (body.client !== client || body.source !== "facebook") {
|
|
477
|
+
throw new GbpPublishedError("invalid", client, `the file is for ${String(body.client)} (${String(body.source)})`);
|
|
478
|
+
}
|
|
479
|
+
const { rating, count, recommends } = body;
|
|
480
|
+
if (rating !== null && (typeof rating !== "number" || !(rating >= 0 && rating <= 5))) {
|
|
481
|
+
throw new GbpPublishedError("invalid", client, "rating out of range");
|
|
482
|
+
}
|
|
483
|
+
if (!isCount(count) || !isCount(recommends) || recommends > count) {
|
|
484
|
+
throw new GbpPublishedError("invalid", client, "count or recommends is not a whole number within the count");
|
|
485
|
+
}
|
|
486
|
+
if (!isTime2(body.synced_at) || !isTime2(body.published_at) || !Array.isArray(body.reviews)) {
|
|
487
|
+
throw new GbpPublishedError("invalid", client, "synced_at, published_at or reviews missing");
|
|
488
|
+
}
|
|
489
|
+
const reviews = body.reviews.flatMap(
|
|
490
|
+
(v) => isRecord3(v) && typeof v.id === "string" && typeof v.comment === "string" && v.comment && isTime2(v.createdAt) ? [{ id: v.id, comment: v.comment, createdAt: v.createdAt }] : []
|
|
491
|
+
);
|
|
492
|
+
return { rating, count, recommends, reviews, syncedAt: body.synced_at, publishedAt: body.published_at };
|
|
493
|
+
}
|
|
494
|
+
function publishedFacebookUrl(client, baseUrl) {
|
|
495
|
+
if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(client)) throw new RangeError(`not a client slug: ${client}`);
|
|
496
|
+
const base = (baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? DEFAULT_PUBLISHED_REVIEWS_BASE_URL).replace(/\/+$/, "");
|
|
497
|
+
return `${base}/${client}.facebook.json`;
|
|
498
|
+
}
|
|
499
|
+
async function getPublishedFacebookRecommendations(options) {
|
|
500
|
+
const { client, limit, next, request } = options;
|
|
501
|
+
const url = publishedFacebookUrl(client, options.baseUrl);
|
|
502
|
+
const init = {
|
|
503
|
+
headers: { Accept: "application/json" },
|
|
504
|
+
next: next ?? { revalidate: 86400, tags: ["facebook-reviews"] }
|
|
505
|
+
};
|
|
506
|
+
const { response } = await fetchWithRetry(url, init, "reviews.facebook", resolvePolicy(request));
|
|
507
|
+
if (!response.ok) {
|
|
508
|
+
const body2 = excerpt(await readErrorBody(response), 120);
|
|
509
|
+
throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
|
|
510
|
+
}
|
|
511
|
+
let body;
|
|
512
|
+
try {
|
|
513
|
+
body = await response.json();
|
|
514
|
+
} catch {
|
|
515
|
+
throw new GbpPublishedError("invalid", client, "the file is not JSON");
|
|
516
|
+
}
|
|
517
|
+
const result = parsePublishedFacebook(body, client);
|
|
518
|
+
return limit === void 0 ? result : { ...result, reviews: result.reviews.slice(0, limit) };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/safe.ts
|
|
522
|
+
var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
|
|
523
|
+
var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
524
|
+
var DEFAULT_AUTH_RETRY_MS = 5 * 60 * 1e3;
|
|
525
|
+
var lastGood = /* @__PURE__ */ new Map();
|
|
526
|
+
var reported = /* @__PURE__ */ new Map();
|
|
527
|
+
var authBlockedUntil = /* @__PURE__ */ new Map();
|
|
528
|
+
function toGbpError(error) {
|
|
529
|
+
if (error instanceof GbpError) return error;
|
|
530
|
+
const name = error instanceof Error ? error.name : typeof error;
|
|
531
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
532
|
+
const wrapped = new GbpError("GBP reviews failed: unexpected error", "unexpected", true, { name, message: excerpt(message, 200) });
|
|
533
|
+
wrapped.cause = error;
|
|
534
|
+
return wrapped;
|
|
535
|
+
}
|
|
536
|
+
function isAuthFailure(error) {
|
|
537
|
+
return error.code === "invalid_grant" || error.code === "invalid_client";
|
|
538
|
+
}
|
|
539
|
+
function defaultReport(error, report) {
|
|
540
|
+
console.warn(`[gbp] ${error.message}; serving ${report.served}`, report.context);
|
|
541
|
+
}
|
|
542
|
+
async function loadFallback(fallback) {
|
|
543
|
+
if (fallback === void 0) return null;
|
|
544
|
+
try {
|
|
545
|
+
return (typeof fallback === "function" ? await fallback() : fallback) ?? null;
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function reportOnce(error, served, options) {
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
const window = options.reportIntervalMs ?? DEFAULT_REPORT_INTERVAL_MS;
|
|
553
|
+
const last = reported.get(error.code);
|
|
554
|
+
if (last && now - last.at < window) {
|
|
555
|
+
last.suppressed++;
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
reported.set(error.code, { at: now, suppressed: 0 });
|
|
559
|
+
try {
|
|
560
|
+
(options.report ?? defaultReport)(error, {
|
|
561
|
+
fingerprint: error.fingerprint,
|
|
562
|
+
code: error.code,
|
|
563
|
+
transient: error.transient,
|
|
564
|
+
context: error.context,
|
|
565
|
+
served,
|
|
566
|
+
suppressed: last?.suppressed ?? 0
|
|
567
|
+
});
|
|
568
|
+
} catch {
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async function safely(key, load, empty, options) {
|
|
572
|
+
const now = Date.now();
|
|
573
|
+
let error;
|
|
574
|
+
const blocked = authBlockedUntil.get(key);
|
|
575
|
+
if (blocked && blocked.until > now) {
|
|
576
|
+
error = blocked.error;
|
|
577
|
+
} else {
|
|
578
|
+
try {
|
|
579
|
+
const result = await load();
|
|
580
|
+
lastGood.set(key, { result, at: Date.now() });
|
|
581
|
+
authBlockedUntil.delete(key);
|
|
582
|
+
return { ...result, source: "live" };
|
|
583
|
+
} catch (caught) {
|
|
584
|
+
error = toGbpError(caught);
|
|
585
|
+
const retryMs = options.authRetryMs ?? DEFAULT_AUTH_RETRY_MS;
|
|
586
|
+
if (isAuthFailure(error) && retryMs > 0) authBlockedUntil.set(key, { until: Date.now() + retryMs, error });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
let cached2 = lastGood.get(key);
|
|
590
|
+
if (cached2 && now - cached2.at > (options.maxStaleMs ?? DEFAULT_MAX_STALE_MS)) {
|
|
591
|
+
lastGood.delete(key);
|
|
592
|
+
cached2 = void 0;
|
|
593
|
+
}
|
|
594
|
+
let served;
|
|
595
|
+
if (cached2) {
|
|
596
|
+
served = { ...cached2.result, reviews: [...cached2.result.reviews], source: "cache", error };
|
|
597
|
+
} else {
|
|
598
|
+
const fallback = await loadFallback(options.fallback);
|
|
599
|
+
served = fallback ? { ...fallback, source: "fallback", error } : { ...empty(), error };
|
|
600
|
+
}
|
|
601
|
+
reportOnce(error, served.source, options);
|
|
602
|
+
return served;
|
|
603
|
+
}
|
|
604
|
+
function getBusinessReviewsSafe(options = {}) {
|
|
605
|
+
const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
|
|
606
|
+
const key = JSON.stringify([
|
|
607
|
+
"business",
|
|
608
|
+
process.env.GOOGLE_BUSINESS_LOCATION_ID ?? null,
|
|
609
|
+
fetchOptions.limit ?? null,
|
|
610
|
+
fetchOptions.filterMinStars ?? null,
|
|
611
|
+
fetchOptions.includeUnapprovedReplies ?? false
|
|
612
|
+
]);
|
|
613
|
+
return safely(
|
|
614
|
+
key,
|
|
615
|
+
() => getBusinessReviews(fetchOptions),
|
|
616
|
+
() => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
|
|
617
|
+
{ fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
function getPublishedReviewsSafe(options) {
|
|
621
|
+
const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
|
|
622
|
+
const key = JSON.stringify([
|
|
623
|
+
"published",
|
|
624
|
+
fetchOptions.client,
|
|
625
|
+
fetchOptions.baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? null,
|
|
626
|
+
fetchOptions.limit ?? null,
|
|
627
|
+
fetchOptions.filterMinStars ?? null
|
|
628
|
+
]);
|
|
629
|
+
return safely(
|
|
630
|
+
key,
|
|
631
|
+
() => getPublishedReviews(fetchOptions),
|
|
632
|
+
() => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
|
|
633
|
+
{ fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
|
|
634
|
+
);
|
|
635
|
+
}
|
|
443
636
|
export {
|
|
637
|
+
DEFAULT_AUTH_RETRY_MS,
|
|
444
638
|
DEFAULT_MAX_PAGES,
|
|
639
|
+
DEFAULT_MAX_STALE_MS,
|
|
445
640
|
DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
|
|
641
|
+
DEFAULT_REPORT_INTERVAL_MS,
|
|
446
642
|
DEFAULT_REQUEST_POLICY,
|
|
447
643
|
GbpApiError,
|
|
448
644
|
GbpAuthError,
|
|
@@ -452,13 +648,18 @@ export {
|
|
|
452
648
|
GbpTimeoutError,
|
|
453
649
|
PUBLISHED_REVIEWS_SCHEMA,
|
|
454
650
|
getBusinessReviews,
|
|
651
|
+
getBusinessReviewsSafe,
|
|
455
652
|
getGoogleOAuthAccessToken,
|
|
653
|
+
getPublishedFacebookRecommendations,
|
|
456
654
|
getPublishedReviews,
|
|
655
|
+
getPublishedReviewsSafe,
|
|
457
656
|
hasGoogleOAuthCredentials,
|
|
458
657
|
invalidateAccessToken,
|
|
459
658
|
isBusinessProfileConfigured,
|
|
460
659
|
parseListReviewsResponse,
|
|
660
|
+
parsePublishedFacebook,
|
|
461
661
|
parsePublishedReviews,
|
|
462
662
|
parseRetryAfter,
|
|
663
|
+
publishedFacebookUrl,
|
|
463
664
|
publishedReviewsUrl
|
|
464
665
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domandigital/gbp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|