@domandigital/gbp 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +29 -0
- package/dist/index.cjs +98 -2
- package/dist/index.d.cts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +91 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4588886: Read reviews from the file Doman Digital publishes, so a site needs no Google token.
|
|
8
|
+
|
|
9
|
+
- **`getPublishedReviews({ client })`** fetches `https://files.domandigital.co.uk/reviews/<client>.json` (override with `baseUrl` or `GBP_REVIEWS_BASE_URL`). One collector reads every listing with the only Google credential and the portal publishes the file each night, so a dead token makes reviews stale, not missing.
|
|
10
|
+
- Same shape as `getBusinessReviews`: Google's own `averageRating` and `totalReviewCount` for the whole listing, plus `reviews` (four and five stars with words, replies Google shows). Adds `syncedAt`, when Google was last read.
|
|
11
|
+
- Throws `GbpPublishedError` (`http`, `invalid`, `unknown_schema`) instead of returning an empty result, so the caller keeps the copy it already has. Retries 429 and transient 5xx within the usual deadline.
|
|
12
|
+
- Defaults to newest first (`order: "api"`) and a daily Next.js revalidate tagged `google-reviews`.
|
|
13
|
+
|
|
3
14
|
## 0.4.0
|
|
4
15
|
|
|
5
16
|
### 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
|
package/dist/index.cjs
CHANGED
|
@@ -21,19 +21,25 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
DEFAULT_MAX_PAGES: () => DEFAULT_MAX_PAGES,
|
|
24
|
+
DEFAULT_PUBLISHED_REVIEWS_BASE_URL: () => DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
|
|
24
25
|
DEFAULT_REQUEST_POLICY: () => DEFAULT_REQUEST_POLICY,
|
|
25
26
|
GbpApiError: () => GbpApiError,
|
|
26
27
|
GbpAuthError: () => GbpAuthError,
|
|
27
28
|
GbpError: () => GbpError,
|
|
28
29
|
GbpPaginationError: () => GbpPaginationError,
|
|
30
|
+
GbpPublishedError: () => GbpPublishedError,
|
|
29
31
|
GbpTimeoutError: () => GbpTimeoutError,
|
|
32
|
+
PUBLISHED_REVIEWS_SCHEMA: () => PUBLISHED_REVIEWS_SCHEMA,
|
|
30
33
|
getBusinessReviews: () => getBusinessReviews,
|
|
31
34
|
getGoogleOAuthAccessToken: () => getGoogleOAuthAccessToken,
|
|
35
|
+
getPublishedReviews: () => getPublishedReviews,
|
|
32
36
|
hasGoogleOAuthCredentials: () => hasGoogleOAuthCredentials,
|
|
33
37
|
invalidateAccessToken: () => invalidateAccessToken,
|
|
34
38
|
isBusinessProfileConfigured: () => isBusinessProfileConfigured,
|
|
35
39
|
parseListReviewsResponse: () => parseListReviewsResponse,
|
|
36
|
-
|
|
40
|
+
parsePublishedReviews: () => parsePublishedReviews,
|
|
41
|
+
parseRetryAfter: () => parseRetryAfter,
|
|
42
|
+
publishedReviewsUrl: () => publishedReviewsUrl
|
|
37
43
|
});
|
|
38
44
|
module.exports = __toCommonJS(index_exports);
|
|
39
45
|
|
|
@@ -85,6 +91,14 @@ function excerpt(text, max = 300) {
|
|
|
85
91
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
86
92
|
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
|
|
87
93
|
}
|
|
94
|
+
var GbpPublishedError = class extends GbpError {
|
|
95
|
+
constructor(reason, client, detail, status) {
|
|
96
|
+
super(`Published reviews for ${client} unusable (${reason}): ${detail}`);
|
|
97
|
+
this.reason = reason;
|
|
98
|
+
this.client = client;
|
|
99
|
+
this.status = status;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
88
102
|
|
|
89
103
|
// src/http.ts
|
|
90
104
|
var DEFAULT_REQUEST_POLICY = {
|
|
@@ -395,20 +409,102 @@ async function getBusinessReviews(options = {}) {
|
|
|
395
409
|
reviews: order === "api" ? reviews : shuffleArray(reviews)
|
|
396
410
|
};
|
|
397
411
|
}
|
|
412
|
+
|
|
413
|
+
// src/published.ts
|
|
414
|
+
var DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
|
|
415
|
+
var PUBLISHED_REVIEWS_SCHEMA = 1;
|
|
416
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
417
|
+
var isTime = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
|
|
418
|
+
function parseReview2(v) {
|
|
419
|
+
if (!isRecord2(v)) return null;
|
|
420
|
+
const { id, author, rating, comment, createdAt, reply } = v;
|
|
421
|
+
if (typeof id !== "string" || typeof author !== "string" || typeof comment !== "string" || !comment) return null;
|
|
422
|
+
if (typeof rating !== "number" || !Number.isInteger(rating) || rating < 1 || rating > 5) return null;
|
|
423
|
+
const review = { id, author, rating, comment, createdAt: isTime(createdAt) ? createdAt : "" };
|
|
424
|
+
if (isRecord2(reply) && typeof reply.text === "string" && reply.text) {
|
|
425
|
+
review.reply = { text: reply.text, updatedAt: isTime(reply.updatedAt) ? reply.updatedAt : "" };
|
|
426
|
+
}
|
|
427
|
+
return review;
|
|
428
|
+
}
|
|
429
|
+
function parsePublishedReviews(body, client) {
|
|
430
|
+
if (!isRecord2(body)) throw new GbpPublishedError("invalid", client, "the file is not an object");
|
|
431
|
+
if (body.schema !== PUBLISHED_REVIEWS_SCHEMA) {
|
|
432
|
+
throw new GbpPublishedError("unknown_schema", client, `schema ${String(body.schema)}`);
|
|
433
|
+
}
|
|
434
|
+
if (body.client !== client) throw new GbpPublishedError("invalid", client, `the file is for ${String(body.client)}`);
|
|
435
|
+
const { rating, count } = body;
|
|
436
|
+
if (rating !== null && (typeof rating !== "number" || !(rating >= 0 && rating <= 5))) {
|
|
437
|
+
throw new GbpPublishedError("invalid", client, "rating out of range");
|
|
438
|
+
}
|
|
439
|
+
if (count !== null && (typeof count !== "number" || !Number.isInteger(count) || count < 0)) {
|
|
440
|
+
throw new GbpPublishedError("invalid", client, "count is not a whole number");
|
|
441
|
+
}
|
|
442
|
+
if (!isTime(body.synced_at) || !isTime(body.published_at) || !Array.isArray(body.reviews)) {
|
|
443
|
+
throw new GbpPublishedError("invalid", client, "synced_at, published_at or reviews missing");
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
averageRating: rating,
|
|
447
|
+
totalReviewCount: count,
|
|
448
|
+
reviews: body.reviews.map(parseReview2).filter((r) => r !== null),
|
|
449
|
+
syncedAt: body.synced_at,
|
|
450
|
+
publishedAt: body.published_at
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function shuffle(arr) {
|
|
454
|
+
const copy = [...arr];
|
|
455
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
456
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
457
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
458
|
+
}
|
|
459
|
+
return copy;
|
|
460
|
+
}
|
|
461
|
+
function publishedReviewsUrl(client, baseUrl) {
|
|
462
|
+
if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(client)) throw new RangeError(`not a client slug: ${client}`);
|
|
463
|
+
const base = (baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? DEFAULT_PUBLISHED_REVIEWS_BASE_URL).replace(/\/+$/, "");
|
|
464
|
+
return `${base}/${client}.json`;
|
|
465
|
+
}
|
|
466
|
+
async function getPublishedReviews(options) {
|
|
467
|
+
const { client, filterMinStars, limit, order = "api", next, request } = options;
|
|
468
|
+
const url = publishedReviewsUrl(client, options.baseUrl);
|
|
469
|
+
const init = {
|
|
470
|
+
headers: { Accept: "application/json" },
|
|
471
|
+
next: next ?? { revalidate: 86400, tags: ["google-reviews"] }
|
|
472
|
+
};
|
|
473
|
+
const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
|
|
474
|
+
if (!response.ok) {
|
|
475
|
+
throw new GbpPublishedError("http", client, `${response.status} ${excerpt(await readErrorBody(response), 120)}`.trim(), response.status);
|
|
476
|
+
}
|
|
477
|
+
let body;
|
|
478
|
+
try {
|
|
479
|
+
body = await response.json();
|
|
480
|
+
} catch {
|
|
481
|
+
throw new GbpPublishedError("invalid", client, "the file is not JSON");
|
|
482
|
+
}
|
|
483
|
+
const result = parsePublishedReviews(body, client);
|
|
484
|
+
let reviews = filterMinStars === void 0 ? result.reviews : result.reviews.filter((r) => r.rating >= filterMinStars);
|
|
485
|
+
if (limit !== void 0) reviews = reviews.slice(0, limit);
|
|
486
|
+
return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
|
|
487
|
+
}
|
|
398
488
|
// Annotate the CommonJS export names for ESM import in node:
|
|
399
489
|
0 && (module.exports = {
|
|
400
490
|
DEFAULT_MAX_PAGES,
|
|
491
|
+
DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
|
|
401
492
|
DEFAULT_REQUEST_POLICY,
|
|
402
493
|
GbpApiError,
|
|
403
494
|
GbpAuthError,
|
|
404
495
|
GbpError,
|
|
405
496
|
GbpPaginationError,
|
|
497
|
+
GbpPublishedError,
|
|
406
498
|
GbpTimeoutError,
|
|
499
|
+
PUBLISHED_REVIEWS_SCHEMA,
|
|
407
500
|
getBusinessReviews,
|
|
408
501
|
getGoogleOAuthAccessToken,
|
|
502
|
+
getPublishedReviews,
|
|
409
503
|
hasGoogleOAuthCredentials,
|
|
410
504
|
invalidateAccessToken,
|
|
411
505
|
isBusinessProfileConfigured,
|
|
412
506
|
parseListReviewsResponse,
|
|
413
|
-
|
|
507
|
+
parsePublishedReviews,
|
|
508
|
+
parseRetryAfter,
|
|
509
|
+
publishedReviewsUrl
|
|
414
510
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -240,6 +240,48 @@ 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
|
|
@@ -303,5 +345,16 @@ declare class GbpPaginationError extends GbpError {
|
|
|
303
345
|
readonly pagesFetched: number;
|
|
304
346
|
constructor(reason: "repeated_token" | "page_limit", pagesFetched: number);
|
|
305
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* A published reviews file could not be used: the address answered an error (`http`, with `status`), the file was
|
|
350
|
+
* malformed or for another client (`invalid`), or it uses a schema this version does not read (`unknown_schema`,
|
|
351
|
+
* upgrade the package). The caller keeps the copy it already has.
|
|
352
|
+
*/
|
|
353
|
+
declare class GbpPublishedError extends GbpError {
|
|
354
|
+
readonly reason: "http" | "invalid" | "unknown_schema";
|
|
355
|
+
readonly client: string;
|
|
356
|
+
readonly status?: number;
|
|
357
|
+
constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number);
|
|
358
|
+
}
|
|
306
359
|
|
|
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 };
|
|
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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -240,6 +240,48 @@ 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
|
|
@@ -303,5 +345,16 @@ declare class GbpPaginationError extends GbpError {
|
|
|
303
345
|
readonly pagesFetched: number;
|
|
304
346
|
constructor(reason: "repeated_token" | "page_limit", pagesFetched: number);
|
|
305
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* A published reviews file could not be used: the address answered an error (`http`, with `status`), the file was
|
|
350
|
+
* malformed or for another client (`invalid`), or it uses a schema this version does not read (`unknown_schema`,
|
|
351
|
+
* upgrade the package). The caller keeps the copy it already has.
|
|
352
|
+
*/
|
|
353
|
+
declare class GbpPublishedError extends GbpError {
|
|
354
|
+
readonly reason: "http" | "invalid" | "unknown_schema";
|
|
355
|
+
readonly client: string;
|
|
356
|
+
readonly status?: number;
|
|
357
|
+
constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number);
|
|
358
|
+
}
|
|
306
359
|
|
|
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 };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -46,6 +46,14 @@ function excerpt(text, max = 300) {
|
|
|
46
46
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
47
47
|
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
|
|
48
48
|
}
|
|
49
|
+
var GbpPublishedError = class extends GbpError {
|
|
50
|
+
constructor(reason, client, detail, status) {
|
|
51
|
+
super(`Published reviews for ${client} unusable (${reason}): ${detail}`);
|
|
52
|
+
this.reason = reason;
|
|
53
|
+
this.client = client;
|
|
54
|
+
this.status = status;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
49
57
|
|
|
50
58
|
// src/http.ts
|
|
51
59
|
var DEFAULT_REQUEST_POLICY = {
|
|
@@ -356,19 +364,101 @@ async function getBusinessReviews(options = {}) {
|
|
|
356
364
|
reviews: order === "api" ? reviews : shuffleArray(reviews)
|
|
357
365
|
};
|
|
358
366
|
}
|
|
367
|
+
|
|
368
|
+
// src/published.ts
|
|
369
|
+
var DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
|
|
370
|
+
var PUBLISHED_REVIEWS_SCHEMA = 1;
|
|
371
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
372
|
+
var isTime = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
|
|
373
|
+
function parseReview2(v) {
|
|
374
|
+
if (!isRecord2(v)) return null;
|
|
375
|
+
const { id, author, rating, comment, createdAt, reply } = v;
|
|
376
|
+
if (typeof id !== "string" || typeof author !== "string" || typeof comment !== "string" || !comment) return null;
|
|
377
|
+
if (typeof rating !== "number" || !Number.isInteger(rating) || rating < 1 || rating > 5) return null;
|
|
378
|
+
const review = { id, author, rating, comment, createdAt: isTime(createdAt) ? createdAt : "" };
|
|
379
|
+
if (isRecord2(reply) && typeof reply.text === "string" && reply.text) {
|
|
380
|
+
review.reply = { text: reply.text, updatedAt: isTime(reply.updatedAt) ? reply.updatedAt : "" };
|
|
381
|
+
}
|
|
382
|
+
return review;
|
|
383
|
+
}
|
|
384
|
+
function parsePublishedReviews(body, client) {
|
|
385
|
+
if (!isRecord2(body)) throw new GbpPublishedError("invalid", client, "the file is not an object");
|
|
386
|
+
if (body.schema !== PUBLISHED_REVIEWS_SCHEMA) {
|
|
387
|
+
throw new GbpPublishedError("unknown_schema", client, `schema ${String(body.schema)}`);
|
|
388
|
+
}
|
|
389
|
+
if (body.client !== client) throw new GbpPublishedError("invalid", client, `the file is for ${String(body.client)}`);
|
|
390
|
+
const { rating, count } = body;
|
|
391
|
+
if (rating !== null && (typeof rating !== "number" || !(rating >= 0 && rating <= 5))) {
|
|
392
|
+
throw new GbpPublishedError("invalid", client, "rating out of range");
|
|
393
|
+
}
|
|
394
|
+
if (count !== null && (typeof count !== "number" || !Number.isInteger(count) || count < 0)) {
|
|
395
|
+
throw new GbpPublishedError("invalid", client, "count is not a whole number");
|
|
396
|
+
}
|
|
397
|
+
if (!isTime(body.synced_at) || !isTime(body.published_at) || !Array.isArray(body.reviews)) {
|
|
398
|
+
throw new GbpPublishedError("invalid", client, "synced_at, published_at or reviews missing");
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
averageRating: rating,
|
|
402
|
+
totalReviewCount: count,
|
|
403
|
+
reviews: body.reviews.map(parseReview2).filter((r) => r !== null),
|
|
404
|
+
syncedAt: body.synced_at,
|
|
405
|
+
publishedAt: body.published_at
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function shuffle(arr) {
|
|
409
|
+
const copy = [...arr];
|
|
410
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
411
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
412
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
413
|
+
}
|
|
414
|
+
return copy;
|
|
415
|
+
}
|
|
416
|
+
function publishedReviewsUrl(client, baseUrl) {
|
|
417
|
+
if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(client)) throw new RangeError(`not a client slug: ${client}`);
|
|
418
|
+
const base = (baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? DEFAULT_PUBLISHED_REVIEWS_BASE_URL).replace(/\/+$/, "");
|
|
419
|
+
return `${base}/${client}.json`;
|
|
420
|
+
}
|
|
421
|
+
async function getPublishedReviews(options) {
|
|
422
|
+
const { client, filterMinStars, limit, order = "api", next, request } = options;
|
|
423
|
+
const url = publishedReviewsUrl(client, options.baseUrl);
|
|
424
|
+
const init = {
|
|
425
|
+
headers: { Accept: "application/json" },
|
|
426
|
+
next: next ?? { revalidate: 86400, tags: ["google-reviews"] }
|
|
427
|
+
};
|
|
428
|
+
const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
|
|
429
|
+
if (!response.ok) {
|
|
430
|
+
throw new GbpPublishedError("http", client, `${response.status} ${excerpt(await readErrorBody(response), 120)}`.trim(), response.status);
|
|
431
|
+
}
|
|
432
|
+
let body;
|
|
433
|
+
try {
|
|
434
|
+
body = await response.json();
|
|
435
|
+
} catch {
|
|
436
|
+
throw new GbpPublishedError("invalid", client, "the file is not JSON");
|
|
437
|
+
}
|
|
438
|
+
const result = parsePublishedReviews(body, client);
|
|
439
|
+
let reviews = filterMinStars === void 0 ? result.reviews : result.reviews.filter((r) => r.rating >= filterMinStars);
|
|
440
|
+
if (limit !== void 0) reviews = reviews.slice(0, limit);
|
|
441
|
+
return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
|
|
442
|
+
}
|
|
359
443
|
export {
|
|
360
444
|
DEFAULT_MAX_PAGES,
|
|
445
|
+
DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
|
|
361
446
|
DEFAULT_REQUEST_POLICY,
|
|
362
447
|
GbpApiError,
|
|
363
448
|
GbpAuthError,
|
|
364
449
|
GbpError,
|
|
365
450
|
GbpPaginationError,
|
|
451
|
+
GbpPublishedError,
|
|
366
452
|
GbpTimeoutError,
|
|
453
|
+
PUBLISHED_REVIEWS_SCHEMA,
|
|
367
454
|
getBusinessReviews,
|
|
368
455
|
getGoogleOAuthAccessToken,
|
|
456
|
+
getPublishedReviews,
|
|
369
457
|
hasGoogleOAuthCredentials,
|
|
370
458
|
invalidateAccessToken,
|
|
371
459
|
isBusinessProfileConfigured,
|
|
372
460
|
parseListReviewsResponse,
|
|
373
|
-
|
|
461
|
+
parsePublishedReviews,
|
|
462
|
+
parseRetryAfter,
|
|
463
|
+
publishedReviewsUrl
|
|
374
464
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domandigital/gbp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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"
|