posterly-mcp-server 0.33.2 → 0.33.3
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/README.md +1 -1
- package/dist/lib/api-client.d.ts +25 -1
- package/dist/lib/api-client.js +4 -0
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/tools/list-google-business-reviews.d.ts +8 -0
- package/dist/tools/list-google-business-reviews.js +87 -7
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/lib/api-client.ts +28 -1
- package/src/lib/version.ts +1 -1
- package/src/tools/list-google-business-reviews.ts +101 -8
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ Add the same server definition to your Cursor MCP settings:
|
|
|
122
122
|
|
|
123
123
|
## Available tools
|
|
124
124
|
|
|
125
|
-
`posterly-mcp-server@0.33.
|
|
125
|
+
`posterly-mcp-server@0.33.3` exposes 77 tools.
|
|
126
126
|
|
|
127
127
|
Public setup tools work before `POSTERLY_API_KEY` exists:
|
|
128
128
|
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -568,10 +568,32 @@ export interface GoogleBusinessReview {
|
|
|
568
568
|
socialAccountId?: number;
|
|
569
569
|
account_id?: number;
|
|
570
570
|
}
|
|
571
|
+
export interface GoogleBusinessLocationReviewSummary {
|
|
572
|
+
id: number;
|
|
573
|
+
location_id: string | null;
|
|
574
|
+
name: string | null;
|
|
575
|
+
business_name: string | null;
|
|
576
|
+
workspace_id: string | null;
|
|
577
|
+
/** Google's own review count for this location, or null when Google didn't report one. */
|
|
578
|
+
total_review_count: number | null;
|
|
579
|
+
average_rating: number | null;
|
|
580
|
+
fetched_review_count: number;
|
|
581
|
+
/** Why this location's reviews are missing or incomplete, else null. */
|
|
582
|
+
error: string | null;
|
|
583
|
+
}
|
|
571
584
|
export interface GoogleBusinessReviewsResponse {
|
|
572
585
|
reviews: GoogleBusinessReview[];
|
|
586
|
+
/** Reviews matching the filters across every page, not just the ones returned. */
|
|
573
587
|
total: number;
|
|
574
|
-
|
|
588
|
+
returned?: number;
|
|
589
|
+
limit?: number;
|
|
590
|
+
offset?: number;
|
|
591
|
+
has_more?: boolean;
|
|
592
|
+
/** Reviews fetched from Google before rating/unanswered filters. */
|
|
593
|
+
fetched?: number;
|
|
594
|
+
/** Google's reported review count summed across the selected locations. */
|
|
595
|
+
total_review_count?: number | null;
|
|
596
|
+
locations: Array<GoogleBusinessLocationReviewSummary | Record<string, unknown>>;
|
|
575
597
|
}
|
|
576
598
|
export interface GoogleBusinessMediaItem {
|
|
577
599
|
name: string;
|
|
@@ -1051,6 +1073,8 @@ export declare class PosterlyClient {
|
|
|
1051
1073
|
location_id?: string;
|
|
1052
1074
|
rating?: number;
|
|
1053
1075
|
unanswered?: boolean;
|
|
1076
|
+
limit?: number;
|
|
1077
|
+
offset?: number;
|
|
1054
1078
|
}): Promise<GoogleBusinessReviewsResponse>;
|
|
1055
1079
|
getGoogleBusinessReviewLink(params: {
|
|
1056
1080
|
workspace_id?: string;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -364,6 +364,10 @@ export class PosterlyClient {
|
|
|
364
364
|
searchParams.set('rating', String(params.rating));
|
|
365
365
|
if (params?.unanswered)
|
|
366
366
|
searchParams.set('unanswered', 'true');
|
|
367
|
+
if (params?.limit)
|
|
368
|
+
searchParams.set('limit', String(params.limit));
|
|
369
|
+
if (params?.offset)
|
|
370
|
+
searchParams.set('offset', String(params.offset));
|
|
367
371
|
const qs = searchParams.toString();
|
|
368
372
|
return this.request('GET', `/google-business/reviews${qs ? `?${qs}` : ''}`);
|
|
369
373
|
}
|
package/dist/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const POSTERLY_MCP_VERSION = "0.33.
|
|
1
|
+
export declare const POSTERLY_MCP_VERSION = "0.33.3";
|
package/dist/lib/version.js
CHANGED
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
// tool set (minus the intentional pre-auth signup tools that only this stdio
|
|
6
6
|
// package exposes). `npm run check:mcp-parity` enforces both the version match
|
|
7
7
|
// and the tool-list match, and runs in the pre-commit hook.
|
|
8
|
-
export const POSTERLY_MCP_VERSION = '0.33.
|
|
8
|
+
export const POSTERLY_MCP_VERSION = '0.33.3';
|
|
@@ -9,15 +9,21 @@ export declare const listGoogleBusinessReviewsTool: {
|
|
|
9
9
|
location_id: z.ZodOptional<z.ZodString>;
|
|
10
10
|
rating: z.ZodOptional<z.ZodNumber>;
|
|
11
11
|
unanswered: z.ZodOptional<z.ZodBoolean>;
|
|
12
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
offset: z.ZodOptional<z.ZodNumber>;
|
|
12
14
|
}, "strip", z.ZodTypeAny, {
|
|
13
15
|
workspace_id?: string | undefined;
|
|
14
16
|
account_id?: number | undefined;
|
|
17
|
+
limit?: number | undefined;
|
|
18
|
+
offset?: number | undefined;
|
|
15
19
|
location_id?: string | undefined;
|
|
16
20
|
rating?: number | undefined;
|
|
17
21
|
unanswered?: boolean | undefined;
|
|
18
22
|
}, {
|
|
19
23
|
workspace_id?: string | undefined;
|
|
20
24
|
account_id?: number | undefined;
|
|
25
|
+
limit?: number | undefined;
|
|
26
|
+
offset?: number | undefined;
|
|
21
27
|
location_id?: string | undefined;
|
|
22
28
|
rating?: number | undefined;
|
|
23
29
|
unanswered?: boolean | undefined;
|
|
@@ -28,5 +34,7 @@ export declare const listGoogleBusinessReviewsTool: {
|
|
|
28
34
|
location_id?: string;
|
|
29
35
|
rating?: number;
|
|
30
36
|
unanswered?: boolean;
|
|
37
|
+
limit?: number;
|
|
38
|
+
offset?: number;
|
|
31
39
|
}): Promise<string>;
|
|
32
40
|
};
|
|
@@ -1,28 +1,108 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { formatDate } from '../lib/format.js';
|
|
3
|
+
/** Matches GOOGLE_BUSINESS_REVIEWS_MAX_LIMIT in lib/api-v1-google-business.ts. */
|
|
4
|
+
const MAX_LIMIT = 1000;
|
|
2
5
|
export const listGoogleBusinessReviewsTool = {
|
|
3
6
|
name: 'list_google_business_reviews',
|
|
4
|
-
description: 'List Google Business Profile reviews for one location/account or every accessible GBP location. Supports rating and unanswered-only filters.',
|
|
7
|
+
description: 'List Google Business Profile reviews for one location/account or every accessible GBP location. Pages through all reviews Google has (not just the first 50), returns the full untruncated review text with its date, and reports each location\'s total review count. Supports rating and unanswered-only filters plus limit/offset paging.',
|
|
5
8
|
inputSchema: z.object({
|
|
6
9
|
workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
|
|
7
10
|
account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
|
|
8
11
|
location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
|
|
9
12
|
rating: z.number().int().min(1).max(5).optional().describe('Filter to a 1-5 star rating.'),
|
|
10
13
|
unanswered: z.boolean().optional().describe('Only return reviews without owner replies.'),
|
|
14
|
+
limit: z
|
|
15
|
+
.number()
|
|
16
|
+
.int()
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(MAX_LIMIT)
|
|
19
|
+
.optional()
|
|
20
|
+
.describe(`Maximum reviews to return in this call (default 200, max ${MAX_LIMIT}). Every returned review is listed in full.`),
|
|
21
|
+
offset: z
|
|
22
|
+
.number()
|
|
23
|
+
.int()
|
|
24
|
+
.min(0)
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Skip this many reviews before returning results. Use with limit to page through a location with more reviews than one call returns.'),
|
|
11
27
|
}),
|
|
12
28
|
async execute(client, input) {
|
|
13
29
|
const result = await client.listGoogleBusinessReviews(input);
|
|
30
|
+
const locations = (result.locations || []);
|
|
31
|
+
const offset = result.offset ?? 0;
|
|
14
32
|
if (result.reviews.length === 0) {
|
|
15
|
-
|
|
33
|
+
// Distinguish "no reviews exist" from "we couldn't read them" — a silent
|
|
34
|
+
// zero used to be indistinguishable from a broken connection.
|
|
35
|
+
const lines = ['No Google Business reviews found matching your filters.'];
|
|
36
|
+
const locationLines = locations.map((location) => formatLocationLine(location));
|
|
37
|
+
if (locationLines.length > 0) {
|
|
38
|
+
lines.push('', 'Locations checked:', ...locationLines);
|
|
39
|
+
}
|
|
40
|
+
return lines.join('\n');
|
|
16
41
|
}
|
|
17
|
-
const
|
|
18
|
-
|
|
42
|
+
const header = [`Google Business reviews (showing ${result.reviews.length} of ${result.total} matching`];
|
|
43
|
+
if (offset > 0)
|
|
44
|
+
header.push(`, from offset ${offset}`);
|
|
45
|
+
if (typeof result.total_review_count === 'number') {
|
|
46
|
+
header.push(`; ${result.total_review_count} total on Google`);
|
|
47
|
+
}
|
|
48
|
+
header.push('):');
|
|
49
|
+
const lines = [header.join('')];
|
|
50
|
+
for (const location of locations) {
|
|
51
|
+
if (location.error) {
|
|
52
|
+
lines.push(`! ${location.business_name || location.name || location.location_id || 'location'}: ${location.error}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
lines.push('');
|
|
56
|
+
for (const review of result.reviews) {
|
|
19
57
|
const name = review.reviewer?.displayName || 'Anonymous';
|
|
20
58
|
const rating = review.starRating ? `${review.starRating} stars` : 'unrated';
|
|
21
59
|
const location = review.businessName || review.locationName || review.locationId || 'location';
|
|
22
|
-
const
|
|
23
|
-
const replied = review.reviewReply ? '
|
|
24
|
-
lines.push(`- ${name} (${rating}, ${location}
|
|
60
|
+
const date = formatReviewDate(review);
|
|
61
|
+
const replied = review.reviewReply ? 'replied' : 'unanswered';
|
|
62
|
+
lines.push(`- ${name} (${rating}, ${date}, ${location}, ${replied})`);
|
|
63
|
+
// Full review text, no truncation — collapse whitespace only so the
|
|
64
|
+
// bullet list stays readable for multi-paragraph reviews.
|
|
65
|
+
if (review.comment) {
|
|
66
|
+
lines.push(` ${review.comment.replace(/\s+/g, ' ').trim()}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (result.has_more) {
|
|
70
|
+
const nextOffset = offset + result.reviews.length;
|
|
71
|
+
lines.push('', `More reviews available — call again with offset: ${nextOffset} to continue.`);
|
|
25
72
|
}
|
|
73
|
+
lines.push('', 'Locations:', ...locations.map((location) => formatLocationLine(location)));
|
|
26
74
|
return lines.join('\n');
|
|
27
75
|
},
|
|
28
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* When a review was written, plus when it was last edited if that differs.
|
|
79
|
+
* The list is ordered by last activity (updateTime), so showing only the
|
|
80
|
+
* original date would make an edited review look out of order.
|
|
81
|
+
*/
|
|
82
|
+
function formatReviewDate(review) {
|
|
83
|
+
const created = review.createTime || review.updateTime;
|
|
84
|
+
const createdLabel = formatDate(created);
|
|
85
|
+
if (!review.updateTime || !review.createTime)
|
|
86
|
+
return createdLabel;
|
|
87
|
+
const updatedLabel = formatDate(review.updateTime);
|
|
88
|
+
if (updatedLabel === createdLabel)
|
|
89
|
+
return createdLabel;
|
|
90
|
+
return `${createdLabel}, edited ${updatedLabel}`;
|
|
91
|
+
}
|
|
92
|
+
function formatLocationLine(location) {
|
|
93
|
+
const label = location.business_name || location.name || location.location_id || `account ${location.id}`;
|
|
94
|
+
const parts = [];
|
|
95
|
+
if (typeof location.total_review_count === 'number') {
|
|
96
|
+
parts.push(`${location.total_review_count} reviews on Google`);
|
|
97
|
+
}
|
|
98
|
+
else if (!location.error) {
|
|
99
|
+
parts.push('review count not reported by Google');
|
|
100
|
+
}
|
|
101
|
+
if (typeof location.average_rating === 'number') {
|
|
102
|
+
parts.push(`${location.average_rating.toFixed(1)} avg`);
|
|
103
|
+
}
|
|
104
|
+
parts.push(`${location.fetched_review_count} fetched`);
|
|
105
|
+
if (location.error)
|
|
106
|
+
parts.push(`ERROR: ${location.error}`);
|
|
107
|
+
return `- ${label} (account_id ${location.id}): ${parts.join(', ')}`;
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "posterly-mcp-server",
|
|
3
|
-
"version": "0.33.
|
|
3
|
+
"version": "0.33.3",
|
|
4
4
|
"mcpName": "io.github.awpthorp/posterly",
|
|
5
5
|
"description": "MCP server for posterly: schedule and publish social media posts across 18 platforms from any MCP client (Claude, ChatGPT, Cursor, Windsurf, Cline, and more)",
|
|
6
6
|
"license": "MIT",
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.awpthorp/posterly",
|
|
4
4
|
"title": "posterly",
|
|
5
5
|
"description": "Validate, schedule, publish, and analyze social content across 18 platforms with posterly.",
|
|
6
|
-
"version": "0.33.
|
|
6
|
+
"version": "0.33.3",
|
|
7
7
|
"websiteUrl": "https://www.poster.ly/mcp",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/awpthorp/posterly",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
{
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"identifier": "posterly-mcp-server",
|
|
17
|
-
"version": "0.33.
|
|
17
|
+
"version": "0.33.3",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|
package/src/lib/api-client.ts
CHANGED
|
@@ -572,10 +572,33 @@ export interface GoogleBusinessReview {
|
|
|
572
572
|
account_id?: number;
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
+
export interface GoogleBusinessLocationReviewSummary {
|
|
576
|
+
id: number;
|
|
577
|
+
location_id: string | null;
|
|
578
|
+
name: string | null;
|
|
579
|
+
business_name: string | null;
|
|
580
|
+
workspace_id: string | null;
|
|
581
|
+
/** Google's own review count for this location, or null when Google didn't report one. */
|
|
582
|
+
total_review_count: number | null;
|
|
583
|
+
average_rating: number | null;
|
|
584
|
+
fetched_review_count: number;
|
|
585
|
+
/** Why this location's reviews are missing or incomplete, else null. */
|
|
586
|
+
error: string | null;
|
|
587
|
+
}
|
|
588
|
+
|
|
575
589
|
export interface GoogleBusinessReviewsResponse {
|
|
576
590
|
reviews: GoogleBusinessReview[];
|
|
591
|
+
/** Reviews matching the filters across every page, not just the ones returned. */
|
|
577
592
|
total: number;
|
|
578
|
-
|
|
593
|
+
returned?: number;
|
|
594
|
+
limit?: number;
|
|
595
|
+
offset?: number;
|
|
596
|
+
has_more?: boolean;
|
|
597
|
+
/** Reviews fetched from Google before rating/unanswered filters. */
|
|
598
|
+
fetched?: number;
|
|
599
|
+
/** Google's reported review count summed across the selected locations. */
|
|
600
|
+
total_review_count?: number | null;
|
|
601
|
+
locations: Array<GoogleBusinessLocationReviewSummary | Record<string, unknown>>;
|
|
579
602
|
}
|
|
580
603
|
|
|
581
604
|
export interface GoogleBusinessMediaItem {
|
|
@@ -1375,6 +1398,8 @@ export class PosterlyClient {
|
|
|
1375
1398
|
location_id?: string;
|
|
1376
1399
|
rating?: number;
|
|
1377
1400
|
unanswered?: boolean;
|
|
1401
|
+
limit?: number;
|
|
1402
|
+
offset?: number;
|
|
1378
1403
|
}): Promise<GoogleBusinessReviewsResponse> {
|
|
1379
1404
|
const searchParams = new URLSearchParams();
|
|
1380
1405
|
if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
|
|
@@ -1382,6 +1407,8 @@ export class PosterlyClient {
|
|
|
1382
1407
|
if (params?.location_id) searchParams.set('location_id', params.location_id);
|
|
1383
1408
|
if (params?.rating) searchParams.set('rating', String(params.rating));
|
|
1384
1409
|
if (params?.unanswered) searchParams.set('unanswered', 'true');
|
|
1410
|
+
if (params?.limit) searchParams.set('limit', String(params.limit));
|
|
1411
|
+
if (params?.offset) searchParams.set('offset', String(params.offset));
|
|
1385
1412
|
const qs = searchParams.toString();
|
|
1386
1413
|
return this.request('GET', `/google-business/reviews${qs ? `?${qs}` : ''}`);
|
|
1387
1414
|
}
|
package/src/lib/version.ts
CHANGED
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
// tool set (minus the intentional pre-auth signup tools that only this stdio
|
|
6
6
|
// package exposes). `npm run check:mcp-parity` enforces both the version match
|
|
7
7
|
// and the tool-list match, and runs in the pre-commit hook.
|
|
8
|
-
export const POSTERLY_MCP_VERSION = '0.33.
|
|
8
|
+
export const POSTERLY_MCP_VERSION = '0.33.3';
|
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { PosterlyClient } from '../lib/api-client.js';
|
|
2
|
+
import type { PosterlyClient, GoogleBusinessLocationReviewSummary } from '../lib/api-client.js';
|
|
3
|
+
import { formatDate } from '../lib/format.js';
|
|
4
|
+
|
|
5
|
+
/** Matches GOOGLE_BUSINESS_REVIEWS_MAX_LIMIT in lib/api-v1-google-business.ts. */
|
|
6
|
+
const MAX_LIMIT = 1000;
|
|
3
7
|
|
|
4
8
|
export const listGoogleBusinessReviewsTool = {
|
|
5
9
|
name: 'list_google_business_reviews',
|
|
6
10
|
description:
|
|
7
|
-
'List Google Business Profile reviews for one location/account or every accessible GBP location. Supports rating and unanswered-only filters.',
|
|
11
|
+
'List Google Business Profile reviews for one location/account or every accessible GBP location. Pages through all reviews Google has (not just the first 50), returns the full untruncated review text with its date, and reports each location\'s total review count. Supports rating and unanswered-only filters plus limit/offset paging.',
|
|
8
12
|
inputSchema: z.object({
|
|
9
13
|
workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
|
|
10
14
|
account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
|
|
11
15
|
location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
|
|
12
16
|
rating: z.number().int().min(1).max(5).optional().describe('Filter to a 1-5 star rating.'),
|
|
13
17
|
unanswered: z.boolean().optional().describe('Only return reviews without owner replies.'),
|
|
18
|
+
limit: z
|
|
19
|
+
.number()
|
|
20
|
+
.int()
|
|
21
|
+
.min(1)
|
|
22
|
+
.max(MAX_LIMIT)
|
|
23
|
+
.optional()
|
|
24
|
+
.describe(`Maximum reviews to return in this call (default 200, max ${MAX_LIMIT}). Every returned review is listed in full.`),
|
|
25
|
+
offset: z
|
|
26
|
+
.number()
|
|
27
|
+
.int()
|
|
28
|
+
.min(0)
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Skip this many reviews before returning results. Use with limit to page through a location with more reviews than one call returns.'),
|
|
14
31
|
}),
|
|
15
32
|
|
|
16
33
|
async execute(
|
|
@@ -21,23 +38,99 @@ export const listGoogleBusinessReviewsTool = {
|
|
|
21
38
|
location_id?: string;
|
|
22
39
|
rating?: number;
|
|
23
40
|
unanswered?: boolean;
|
|
41
|
+
limit?: number;
|
|
42
|
+
offset?: number;
|
|
24
43
|
},
|
|
25
44
|
) {
|
|
26
45
|
const result = await client.listGoogleBusinessReviews(input);
|
|
46
|
+
const locations = (result.locations || []) as GoogleBusinessLocationReviewSummary[];
|
|
47
|
+
const offset = result.offset ?? 0;
|
|
48
|
+
|
|
27
49
|
if (result.reviews.length === 0) {
|
|
28
|
-
|
|
50
|
+
// Distinguish "no reviews exist" from "we couldn't read them" — a silent
|
|
51
|
+
// zero used to be indistinguishable from a broken connection.
|
|
52
|
+
const lines = ['No Google Business reviews found matching your filters.'];
|
|
53
|
+
const locationLines = locations.map((location) => formatLocationLine(location));
|
|
54
|
+
if (locationLines.length > 0) {
|
|
55
|
+
lines.push('', 'Locations checked:', ...locationLines);
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const header = [`Google Business reviews (showing ${result.reviews.length} of ${result.total} matching`];
|
|
61
|
+
if (offset > 0) header.push(`, from offset ${offset}`);
|
|
62
|
+
if (typeof result.total_review_count === 'number') {
|
|
63
|
+
header.push(`; ${result.total_review_count} total on Google`);
|
|
64
|
+
}
|
|
65
|
+
header.push('):');
|
|
66
|
+
|
|
67
|
+
const lines = [header.join('')];
|
|
68
|
+
|
|
69
|
+
for (const location of locations) {
|
|
70
|
+
if (location.error) {
|
|
71
|
+
lines.push(`! ${location.business_name || location.name || location.location_id || 'location'}: ${location.error}`);
|
|
72
|
+
}
|
|
29
73
|
}
|
|
30
74
|
|
|
31
|
-
|
|
32
|
-
|
|
75
|
+
lines.push('');
|
|
76
|
+
|
|
77
|
+
for (const review of result.reviews) {
|
|
33
78
|
const name = review.reviewer?.displayName || 'Anonymous';
|
|
34
79
|
const rating = review.starRating ? `${review.starRating} stars` : 'unrated';
|
|
35
80
|
const location = review.businessName || review.locationName || review.locationId || 'location';
|
|
36
|
-
const
|
|
37
|
-
const replied = review.reviewReply ? '
|
|
38
|
-
lines.push(`- ${name} (${rating}, ${location}
|
|
81
|
+
const date = formatReviewDate(review);
|
|
82
|
+
const replied = review.reviewReply ? 'replied' : 'unanswered';
|
|
83
|
+
lines.push(`- ${name} (${rating}, ${date}, ${location}, ${replied})`);
|
|
84
|
+
// Full review text, no truncation — collapse whitespace only so the
|
|
85
|
+
// bullet list stays readable for multi-paragraph reviews.
|
|
86
|
+
if (review.comment) {
|
|
87
|
+
lines.push(` ${review.comment.replace(/\s+/g, ' ').trim()}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (result.has_more) {
|
|
92
|
+
const nextOffset = offset + result.reviews.length;
|
|
93
|
+
lines.push('', `More reviews available — call again with offset: ${nextOffset} to continue.`);
|
|
39
94
|
}
|
|
40
95
|
|
|
96
|
+
lines.push('', 'Locations:', ...locations.map((location) => formatLocationLine(location)));
|
|
97
|
+
|
|
41
98
|
return lines.join('\n');
|
|
42
99
|
},
|
|
43
100
|
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* When a review was written, plus when it was last edited if that differs.
|
|
104
|
+
* The list is ordered by last activity (updateTime), so showing only the
|
|
105
|
+
* original date would make an edited review look out of order.
|
|
106
|
+
*/
|
|
107
|
+
function formatReviewDate(review: { createTime?: string; updateTime?: string }): string {
|
|
108
|
+
const created = review.createTime || review.updateTime;
|
|
109
|
+
const createdLabel = formatDate(created);
|
|
110
|
+
if (!review.updateTime || !review.createTime) return createdLabel;
|
|
111
|
+
|
|
112
|
+
const updatedLabel = formatDate(review.updateTime);
|
|
113
|
+
if (updatedLabel === createdLabel) return createdLabel;
|
|
114
|
+
return `${createdLabel}, edited ${updatedLabel}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatLocationLine(location: GoogleBusinessLocationReviewSummary): string {
|
|
118
|
+
const label = location.business_name || location.name || location.location_id || `account ${location.id}`;
|
|
119
|
+
const parts: string[] = [];
|
|
120
|
+
|
|
121
|
+
if (typeof location.total_review_count === 'number') {
|
|
122
|
+
parts.push(`${location.total_review_count} reviews on Google`);
|
|
123
|
+
} else if (!location.error) {
|
|
124
|
+
parts.push('review count not reported by Google');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (typeof location.average_rating === 'number') {
|
|
128
|
+
parts.push(`${location.average_rating.toFixed(1)} avg`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
parts.push(`${location.fetched_review_count} fetched`);
|
|
132
|
+
|
|
133
|
+
if (location.error) parts.push(`ERROR: ${location.error}`);
|
|
134
|
+
|
|
135
|
+
return `- ${label} (account_id ${location.id}): ${parts.join(', ')}`;
|
|
136
|
+
}
|