@domandigital/gbp 0.6.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 CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 0.6.0
4
14
 
5
15
  ### 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
package/dist/index.cjs CHANGED
@@ -36,14 +36,17 @@ __export(index_exports, {
36
36
  getBusinessReviews: () => getBusinessReviews,
37
37
  getBusinessReviewsSafe: () => getBusinessReviewsSafe,
38
38
  getGoogleOAuthAccessToken: () => getGoogleOAuthAccessToken,
39
+ getPublishedFacebookRecommendations: () => getPublishedFacebookRecommendations,
39
40
  getPublishedReviews: () => getPublishedReviews,
40
41
  getPublishedReviewsSafe: () => getPublishedReviewsSafe,
41
42
  hasGoogleOAuthCredentials: () => hasGoogleOAuthCredentials,
42
43
  invalidateAccessToken: () => invalidateAccessToken,
43
44
  isBusinessProfileConfigured: () => isBusinessProfileConfigured,
44
45
  parseListReviewsResponse: () => parseListReviewsResponse,
46
+ parsePublishedFacebook: () => parsePublishedFacebook,
45
47
  parsePublishedReviews: () => parsePublishedReviews,
46
48
  parseRetryAfter: () => parseRetryAfter,
49
+ publishedFacebookUrl: () => publishedFacebookUrl,
47
50
  publishedReviewsUrl: () => publishedReviewsUrl
48
51
  });
49
52
  module.exports = __toCommonJS(index_exports);
@@ -516,6 +519,58 @@ async function getPublishedReviews(options) {
516
519
  return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
517
520
  }
518
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
+
519
574
  // src/safe.ts
520
575
  var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
521
576
  var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -649,13 +704,16 @@ function getPublishedReviewsSafe(options) {
649
704
  getBusinessReviews,
650
705
  getBusinessReviewsSafe,
651
706
  getGoogleOAuthAccessToken,
707
+ getPublishedFacebookRecommendations,
652
708
  getPublishedReviews,
653
709
  getPublishedReviewsSafe,
654
710
  hasGoogleOAuthCredentials,
655
711
  invalidateAccessToken,
656
712
  isBusinessProfileConfigured,
657
713
  parseListReviewsResponse,
714
+ parsePublishedFacebook,
658
715
  parsePublishedReviews,
659
716
  parseRetryAfter,
717
+ publishedFacebookUrl,
660
718
  publishedReviewsUrl
661
719
  });
package/dist/index.d.cts CHANGED
@@ -282,6 +282,46 @@ 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
@@ -461,4 +501,4 @@ type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Part
461
501
  */
462
502
  declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
463
503
 
464
- export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_AUTH_RETRY_MS, DEFAULT_MAX_PAGES, DEFAULT_MAX_STALE_MS, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REPORT_INTERVAL_MS, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
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,6 +282,46 @@ 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
@@ -461,4 +501,4 @@ type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Part
461
501
  */
462
502
  declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
463
503
 
464
- export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_AUTH_RETRY_MS, DEFAULT_MAX_PAGES, DEFAULT_MAX_STALE_MS, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REPORT_INTERVAL_MS, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
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
@@ -466,6 +466,58 @@ async function getPublishedReviews(options) {
466
466
  return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
467
467
  }
468
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
+
469
521
  // src/safe.ts
470
522
  var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
471
523
  var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -598,13 +650,16 @@ export {
598
650
  getBusinessReviews,
599
651
  getBusinessReviewsSafe,
600
652
  getGoogleOAuthAccessToken,
653
+ getPublishedFacebookRecommendations,
601
654
  getPublishedReviews,
602
655
  getPublishedReviewsSafe,
603
656
  hasGoogleOAuthCredentials,
604
657
  invalidateAccessToken,
605
658
  isBusinessProfileConfigured,
606
659
  parseListReviewsResponse,
660
+ parsePublishedFacebook,
607
661
  parsePublishedReviews,
608
662
  parseRetryAfter,
663
+ publishedFacebookUrl,
609
664
  publishedReviewsUrl
610
665
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/gbp",
3
- "version": "0.6.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",