@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 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
- parseRetryAfter: () => parseRetryAfter
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
- parseRetryAfter
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
- parseRetryAfter
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.4.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": "^2.1.9"
35
+ "vitest": "^4.0.0"
36
36
  },
37
37
  "engines": {
38
38
  "node": ">=22.12"