posterly-mcp-server 0.19.4 → 0.19.5

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 CHANGED
@@ -93,7 +93,7 @@ Add the same server definition to your Cursor MCP settings:
93
93
 
94
94
  ## Available tools
95
95
 
96
- `posterly-mcp-server@0.19.0` exposes 49 tools:
96
+ `posterly-mcp-server@0.19.5` exposes 49 tools:
97
97
 
98
98
  - `whoami`
99
99
  - `list_accounts`
@@ -165,8 +165,10 @@ export interface AccountAnalyticsSummary {
165
165
  total_likes?: number | null;
166
166
  platform_metrics?: Record<string, number | null | undefined>;
167
167
  display_metrics?: Array<{
168
+ key?: string;
168
169
  label: string;
169
170
  value: number | null | undefined;
171
+ unit?: string;
170
172
  }>;
171
173
  }
172
174
  export interface AccountAnalyticsSnapshot {
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
+ declare const presentationSchema: z.ZodOptional<z.ZodEnum<["compact", "table", "json"]>>;
4
+ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
3
5
  export declare const getAccountAnalyticsTool: {
4
6
  name: string;
5
7
  description: string;
@@ -7,18 +9,23 @@ export declare const getAccountAnalyticsTool: {
7
9
  account_id: z.ZodNumber;
8
10
  from: z.ZodOptional<z.ZodString>;
9
11
  to: z.ZodOptional<z.ZodString>;
12
+ presentation: z.ZodOptional<z.ZodEnum<["compact", "table", "json"]>>;
10
13
  }, "strip", z.ZodTypeAny, {
11
14
  account_id: number;
12
15
  from?: string | undefined;
13
16
  to?: string | undefined;
17
+ presentation?: "compact" | "table" | "json" | undefined;
14
18
  }, {
15
19
  account_id: number;
16
20
  from?: string | undefined;
17
21
  to?: string | undefined;
22
+ presentation?: "compact" | "table" | "json" | undefined;
18
23
  }>;
19
24
  execute(client: PosterlyClient, input: {
20
25
  account_id: number;
21
26
  from?: string;
22
27
  to?: string;
28
+ presentation?: AnalyticsPresentation;
23
29
  }): Promise<string>;
24
30
  };
31
+ export {};
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
+ const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
2
3
  export const getAccountAnalyticsTool = {
3
4
  name: 'get_account_analytics',
4
- description: 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels such as Google Business Profile Views, Search Views, Maps Views, Customer Actions, and Posts.',
5
+ description: 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
5
6
  inputSchema: z.object({
6
7
  account_id: z
7
8
  .number()
@@ -14,74 +15,138 @@ export const getAccountAnalyticsTool = {
14
15
  .string()
15
16
  .optional()
16
17
  .describe('End date (ISO date). Defaults to today.'),
18
+ presentation: presentationSchema.describe('Output style: compact bullets, Markdown table, or raw JSON for client-side chart/card rendering.'),
17
19
  }),
18
20
  async execute(client, input) {
19
21
  const result = await client.getAccountAnalytics(input);
20
- const { account, range, summary, snapshots } = result;
21
- const accountLabel = account.platform === 'google_business'
22
- ? `${account.username} (Google Business, id ${account.id})`
23
- : `@${account.username} (${account.platform}, id ${account.id})`;
24
- const lines = [
25
- `Analytics for ${accountLabel}`,
26
- `Range: ${range.from} → ${range.to} (${snapshots.length} daily snapshots)`,
27
- '',
28
- 'Summary:',
29
- ];
30
- const nativeRows = getNativeMetricRows(account.platform, summary);
31
- if (nativeRows.length > 0) {
32
- for (const [label, value] of nativeRows) {
33
- pushMetric(lines, label, value);
34
- }
35
- }
36
- else if (account.platform === 'pinterest') {
37
- pushGenericSummary(lines, summary);
38
- pushMetric(lines, 'Outbound clicks', summary.total_website_clicks);
39
- }
40
- else if (account.platform === 'youtube') {
41
- pushGenericSummary(lines, summary);
42
- pushMetric(lines, 'Watch minutes', summary.total_watch_minutes);
43
- pushMetric(lines, 'Likes', summary.total_likes);
44
- }
45
- else {
46
- pushGenericSummary(lines, summary);
47
- }
48
- return lines.join('\n');
22
+ return formatAccountAnalytics(result, input.presentation || 'compact');
49
23
  },
50
24
  };
51
- function getNativeMetricRows(platform, summary) {
52
- // API-provided display_metrics is the contract for platform-native dashboards.
53
- // Add future non-generic analytics platforms there so MCP output never guesses
54
- // from internal storage field names.
55
- const apiRows = (summary.display_metrics || [])
56
- .filter((metric) => metric?.label)
57
- .map((metric) => [metric.label, metric.value]);
58
- if (apiRows.length > 0)
59
- return apiRows;
25
+ function formatAccountAnalytics(result, presentation) {
26
+ const { account, range, summary, snapshots } = result;
27
+ const rows = accountMetricRows(summary, account.platform);
28
+ const insights = analyticsInsights(summary, account.platform);
29
+ if (presentation === 'json') {
30
+ return JSON.stringify({
31
+ type: 'account_analytics',
32
+ account,
33
+ range,
34
+ snapshot_count: snapshots.length,
35
+ metrics: rows.map(([label, value]) => ({ label, value })),
36
+ insights,
37
+ summary,
38
+ snapshots,
39
+ }, null, 2);
40
+ }
41
+ if (presentation === 'table') {
42
+ return [
43
+ `**📊 Analytics for ${accountLabel(account.username)} (${account.id})**`,
44
+ `_${account.platform} · ${range.from} → ${range.to}_`,
45
+ '',
46
+ '**Account**',
47
+ markdownTable(['Field', 'Value'], [
48
+ ['Account ID', String(account.id)],
49
+ ['Daily snapshots', formatNumber(snapshots.length)],
50
+ ]),
51
+ '',
52
+ '**Metrics**',
53
+ markdownTable(['Metric', 'Value'], rows),
54
+ '',
55
+ '**Quick read**',
56
+ insights.map((insight) => `- ${insight}`).join('\n'),
57
+ ].join('\n');
58
+ }
59
+ return [
60
+ `**📊 Analytics for ${accountLabel(account.username)} (${account.id})**`,
61
+ `_${account.platform} · ${range.from} → ${range.to}_`,
62
+ '',
63
+ '**Account**',
64
+ `- **Account ID:** ${account.id}`,
65
+ `- **Daily snapshots:** ${formatNumber(snapshots.length)}`,
66
+ '',
67
+ '**Key metrics**',
68
+ rows.map(([label, value]) => `- **${label}:** ${value}`).join('\n'),
69
+ '',
70
+ '**Quick read**',
71
+ insights.map((insight) => `- ${insight}`).join('\n'),
72
+ ].join('\n');
73
+ }
74
+ function accountMetricRows(summary, platform) {
75
+ const nativeRows = nativeMetricRows(summary);
76
+ if (nativeRows.length > 0)
77
+ return nativeRows;
60
78
  if (platform === 'google_business') {
61
79
  const metrics = summary.platform_metrics || {};
62
80
  return [
63
- ['Profile Views', metrics.profile_views ?? summary.total_views],
64
- ['Search Views', metrics.search_views ?? summary.total_reach],
65
- ['Maps Views', metrics.maps_views ?? summary.total_profile_views],
66
- ['Customer Actions', metrics.customer_actions ?? summary.total_accounts_engaged],
67
- ['Posts', metrics.posts ?? summary.current_media_count],
81
+ ['Profile Views', formatNumber(metrics.profile_views ?? summary.total_views)],
82
+ ['Search Views', formatNumber(metrics.search_views ?? summary.total_reach)],
83
+ ['Maps Views', formatNumber(metrics.maps_views ?? summary.total_profile_views)],
84
+ ['Customer Actions', formatNumber(metrics.customer_actions ?? summary.total_accounts_engaged)],
85
+ ['Posts', formatNumber(metrics.posts ?? summary.current_media_count)],
68
86
  ];
69
87
  }
70
- return [];
88
+ const rows = genericMetricRows(summary);
89
+ if (platform === 'pinterest')
90
+ rows.push(['Outbound clicks', formatNumber(summary.total_website_clicks)]);
91
+ if (platform === 'youtube') {
92
+ rows.push(['Watch minutes', formatNumber(summary.total_watch_minutes)]);
93
+ rows.push(['Likes', formatNumber(summary.total_likes)]);
94
+ }
95
+ return rows;
96
+ }
97
+ function nativeMetricRows(summary) {
98
+ return (summary.display_metrics || [])
99
+ .filter((metric) => metric?.label)
100
+ .map((metric) => [metric.label, formatNumber(metric.value)]);
101
+ }
102
+ function genericMetricRows(summary) {
103
+ return [
104
+ ['Followers', `${formatNumber(summary.current_followers)} (${formatDelta(summary.followers_change)} in range)`],
105
+ ['Follows gained / lost', `+${formatNumber(summary.total_follows_gained)} / -${formatNumber(summary.total_follows_lost)}`],
106
+ ['Total reach', formatNumber(summary.total_reach)],
107
+ ['Total views', formatNumber(summary.total_views)],
108
+ ['Profile views', formatNumber(summary.total_profile_views)],
109
+ ['Accounts engaged', formatNumber(summary.total_accounts_engaged)],
110
+ ['Engagement rate by reach', formatPercent(summary.engagement_rate)],
111
+ ['Engagement rate by followers', formatPercent(summary.engagement_rate_by_followers)],
112
+ ];
113
+ }
114
+ function analyticsInsights(summary, platform) {
115
+ const insights = [];
116
+ if (platform === 'google_business' && (summary.display_metrics?.length || 0) > 0) {
117
+ insights.push('Google Business metrics are shown with dashboard-native labels.');
118
+ }
119
+ if ((summary.followers_change || 0) > 0)
120
+ insights.push(`Audience grew by ${formatDelta(summary.followers_change)} followers in this range.`);
121
+ if ((summary.followers_change || 0) < 0)
122
+ insights.push(`Audience dipped by ${formatDelta(summary.followers_change)} followers in this range.`);
123
+ if (summary.engagement_rate != null && platform !== 'google_business')
124
+ insights.push(`Engagement by reach is ${formatPercent(summary.engagement_rate)}.`);
125
+ if (platform === 'youtube' && summary.total_watch_minutes != null)
126
+ insights.push(`${formatNumber(summary.total_watch_minutes)} total watch minutes were recorded.`);
127
+ return insights.length ? insights : ['No standout movement detected in the returned period.'];
71
128
  }
72
129
  function formatDelta(n) {
73
130
  return n >= 0 ? `+${n.toLocaleString()}` : n.toLocaleString();
74
131
  }
75
- function pushGenericSummary(lines, summary) {
76
- lines.push(`• Followers: ${summary.current_followers.toLocaleString()} (${formatDelta(summary.followers_change)} in range)`, `• Follows gained / lost: +${summary.total_follows_gained} / -${summary.total_follows_lost}`);
77
- pushMetric(lines, 'Total reach', summary.total_reach);
78
- pushMetric(lines, 'Total views', summary.total_views);
79
- pushMetric(lines, 'Profile views', summary.total_profile_views);
80
- pushMetric(lines, 'Total accounts engaged', summary.total_accounts_engaged);
81
- lines.push(`• Engagement rate (by reach): ${summary.engagement_rate}%`, `• Engagement rate (by followers): ${summary.engagement_rate_by_followers}%`);
132
+ function formatNumber(value) {
133
+ return value == null ? 'n/a' : value.toLocaleString();
82
134
  }
83
- function pushMetric(lines, label, value) {
84
- if (value != null) {
85
- lines.push(`• ${label}: ${value.toLocaleString()}`);
86
- }
135
+ function formatPercent(value) {
136
+ return value == null ? 'n/a' : `${formatNumber(value)}%`;
137
+ }
138
+ function accountLabel(username) {
139
+ if (!username)
140
+ return 'account';
141
+ return username.startsWith('@') || /\s/.test(username) ? username : `@${username}`;
142
+ }
143
+ function markdownTable(headers, rows) {
144
+ return [
145
+ `| ${headers.map(escapeCell).join(' | ')} |`,
146
+ `| ${headers.map(() => '---').join(' | ')} |`,
147
+ ...rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`),
148
+ ].join('\n');
149
+ }
150
+ function escapeCell(value) {
151
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
87
152
  }
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
+ declare const presentationSchema: z.ZodOptional<z.ZodEnum<["compact", "table", "json"]>>;
4
+ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
3
5
  export declare const getPostAnalyticsTool: {
4
6
  name: string;
5
7
  description: string;
@@ -9,18 +11,21 @@ export declare const getPostAnalyticsTool: {
9
11
  to: z.ZodOptional<z.ZodString>;
10
12
  limit: z.ZodOptional<z.ZodNumber>;
11
13
  offset: z.ZodOptional<z.ZodNumber>;
14
+ presentation: z.ZodOptional<z.ZodEnum<["compact", "table", "json"]>>;
12
15
  }, "strip", z.ZodTypeAny, {
13
16
  account_id: number;
14
17
  limit?: number | undefined;
15
18
  offset?: number | undefined;
16
19
  from?: string | undefined;
17
20
  to?: string | undefined;
21
+ presentation?: "compact" | "table" | "json" | undefined;
18
22
  }, {
19
23
  account_id: number;
20
24
  limit?: number | undefined;
21
25
  offset?: number | undefined;
22
26
  from?: string | undefined;
23
27
  to?: string | undefined;
28
+ presentation?: "compact" | "table" | "json" | undefined;
24
29
  }>;
25
30
  execute(client: PosterlyClient, input: {
26
31
  account_id: number;
@@ -28,5 +33,7 @@ export declare const getPostAnalyticsTool: {
28
33
  to?: string;
29
34
  limit?: number;
30
35
  offset?: number;
36
+ presentation?: AnalyticsPresentation;
31
37
  }): Promise<string>;
32
38
  };
39
+ export {};
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
+ const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
2
3
  export const getPostAnalyticsTool = {
3
4
  name: 'get_post_analytics',
4
- description: 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first.',
5
+ description: 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
5
6
  inputSchema: z.object({
6
7
  account_id: z
7
8
  .number()
@@ -21,61 +22,139 @@ export const getPostAnalyticsTool = {
21
22
  .optional()
22
23
  .describe('Number of posts to return (default 50, max 200)'),
23
24
  offset: z.number().min(0).optional().describe('Pagination offset'),
25
+ presentation: presentationSchema.describe('Output style: compact bullets, Markdown table, or raw JSON for client-side chart/card rendering.'),
24
26
  }),
25
27
  async execute(client, input) {
26
28
  const result = await client.getPostAnalytics(input);
27
- const { account, range, posts, total } = result;
28
- if (posts.length === 0) {
29
- return `No analytics found for @${account.username} (${account.platform}) between ${range.from} and ${range.to}.`;
30
- }
31
- const lines = [
32
- `Post analytics for @${account.username} (${account.platform})`,
33
- `Range: ${range.from} → ${range.to} • Showing ${posts.length} of ${total}`,
34
- '',
35
- ];
36
- for (const p of posts) {
37
- const postedAt = p.posted_at
38
- ? new Date(p.posted_at).toLocaleString()
39
- : 'unknown date';
40
- const caption = p.caption_snippet
41
- ? p.caption_snippet.length > 60
42
- ? `${p.caption_snippet.slice(0, 60)}…`
43
- : p.caption_snippet
44
- : '(no caption)';
45
- const metrics = [
46
- `${p.likes} likes`,
47
- `${p.comments} comments`,
48
- `${p.reach.toLocaleString()} reach`,
49
- ];
50
- if (p.impressions)
51
- metrics.push(`${p.impressions.toLocaleString()} impressions`);
52
- if (p.saved)
53
- metrics.push(`${p.saved} saved`);
54
- if (p.shares)
55
- metrics.push(`${p.shares} shares`);
56
- if (p.plays)
57
- metrics.push(`${p.plays.toLocaleString()} plays`);
58
- if (account.platform === 'youtube' && p.total_watch_time_ms) {
59
- metrics.push(`${Math.round(p.total_watch_time_ms / 60000).toLocaleString()} watch minutes`);
60
- }
61
- if (account.platform === 'youtube' && p.avg_watch_time_ms) {
62
- metrics.push(`${Math.round(p.avg_watch_time_ms / 1000)}s avg view`);
63
- }
64
- if (p.url_link_clicks) {
65
- metrics.push(account.platform === 'pinterest'
66
- ? `${p.url_link_clicks.toLocaleString()} outbound clicks`
67
- : `${p.url_link_clicks.toLocaleString()} link clicks`);
68
- }
69
- if (p.user_profile_clicks) {
70
- metrics.push(account.platform === 'pinterest'
71
- ? `${p.user_profile_clicks.toLocaleString()} pin clicks`
72
- : `${p.user_profile_clicks.toLocaleString()} profile clicks`);
73
- }
74
- lines.push(`• [${postedAt}] ${caption}`);
75
- lines.push(` ${metrics.join(' • ')}`);
76
- if (p.permalink)
77
- lines.push(` ${p.permalink}`);
78
- }
79
- return lines.join('\n');
29
+ return formatPostAnalytics(result, input.presentation || 'compact');
80
30
  },
81
31
  };
32
+ function formatPostAnalytics(result, presentation) {
33
+ const { account, range, posts, total } = result;
34
+ if (posts.length === 0) {
35
+ return `No analytics found for ${accountLabel(account.username)} (${account.platform}) between ${range.from} and ${range.to}.`;
36
+ }
37
+ const totals = posts.reduce((acc, post) => {
38
+ acc.likes += post.likes || 0;
39
+ acc.comments += post.comments || 0;
40
+ acc.reach += post.reach || 0;
41
+ acc.impressions += post.impressions || 0;
42
+ acc.shares += post.shares || 0;
43
+ acc.saves += post.saved || 0;
44
+ return acc;
45
+ }, { likes: 0, comments: 0, reach: 0, impressions: 0, shares: 0, saves: 0 });
46
+ if (presentation === 'json') {
47
+ return JSON.stringify({
48
+ type: 'post_analytics',
49
+ account,
50
+ range,
51
+ returned: {
52
+ count: posts.length,
53
+ total,
54
+ },
55
+ totals,
56
+ posts,
57
+ }, null, 2);
58
+ }
59
+ if (presentation === 'table') {
60
+ return [
61
+ `**📈 Post analytics for ${accountLabel(account.username)} (${account.id})**`,
62
+ `_${account.platform} · ${range.from} → ${range.to} · showing ${posts.length} of ${total}_`,
63
+ '',
64
+ '**Returned totals**',
65
+ markdownTable(['Likes', 'Comments', 'Reach', 'Impressions', 'Shares', 'Saves'], [[
66
+ formatNumber(totals.likes),
67
+ formatNumber(totals.comments),
68
+ formatNumber(totals.reach),
69
+ formatNumber(totals.impressions),
70
+ formatNumber(totals.shares),
71
+ formatNumber(totals.saves),
72
+ ]]),
73
+ '',
74
+ '**Posts**',
75
+ markdownTable(['Posted', 'Post', 'Caption', 'Reach', 'Likes', 'Comments', 'Extras'], posts.map((post) => [
76
+ dateTime(post.posted_at),
77
+ post.id ? String(post.id) : post.platform_media_id,
78
+ compact(post.caption_snippet, 70) || '(no caption)',
79
+ formatNumber(post.reach),
80
+ formatNumber(post.likes),
81
+ formatNumber(post.comments),
82
+ postExtras(account.platform, post),
83
+ ])),
84
+ ].join('\n');
85
+ }
86
+ const lines = [
87
+ `**📈 Post analytics for ${accountLabel(account.username)} (${account.id})**`,
88
+ `_${account.platform} · ${range.from} → ${range.to} · showing ${posts.length} of ${total}_`,
89
+ '',
90
+ '**Returned totals**',
91
+ `- **Likes:** ${formatNumber(totals.likes)}`,
92
+ `- **Comments:** ${formatNumber(totals.comments)}`,
93
+ `- **Reach:** ${formatNumber(totals.reach)}`,
94
+ `- **Impressions:** ${formatNumber(totals.impressions)}`,
95
+ `- **Shares:** ${formatNumber(totals.shares)}`,
96
+ `- **Saves:** ${formatNumber(totals.saves)}`,
97
+ '',
98
+ '**Posts**',
99
+ ];
100
+ posts.forEach((post, index) => {
101
+ lines.push(`${index + 1}. **${compact(post.caption_snippet, 70) || '(no caption)'}**`);
102
+ lines.push(` Posted: ${dateTime(post.posted_at)}`);
103
+ lines.push(` Post ID: ${post.id ? String(post.id) : post.platform_media_id}`);
104
+ lines.push(` Reach: ${formatNumber(post.reach)}`);
105
+ lines.push(` Likes: ${formatNumber(post.likes)}`);
106
+ lines.push(` Comments: ${formatNumber(post.comments)}`);
107
+ lines.push(` Extras: ${postExtras(account.platform, post)}`);
108
+ });
109
+ return lines.join('\n');
110
+ }
111
+ function postExtras(platform, post) {
112
+ const metrics = [];
113
+ if (post.impressions)
114
+ metrics.push(`${formatNumber(post.impressions)} impressions`);
115
+ if (post.saved)
116
+ metrics.push(`${formatNumber(post.saved)} saved`);
117
+ if (post.shares)
118
+ metrics.push(`${formatNumber(post.shares)} shares`);
119
+ if (post.plays)
120
+ metrics.push(`${formatNumber(post.plays)} plays`);
121
+ if (platform === 'youtube' && post.total_watch_time_ms)
122
+ metrics.push(`${formatNumber(Math.round(post.total_watch_time_ms / 60000))} watch minutes`);
123
+ if (platform === 'youtube' && post.avg_watch_time_ms)
124
+ metrics.push(`${formatNumber(Math.round(post.avg_watch_time_ms / 1000))}s avg view`);
125
+ if (post.url_link_clicks)
126
+ metrics.push(platform === 'pinterest' ? `${formatNumber(post.url_link_clicks)} outbound clicks` : `${formatNumber(post.url_link_clicks)} link clicks`);
127
+ if (post.user_profile_clicks)
128
+ metrics.push(platform === 'pinterest' ? `${formatNumber(post.user_profile_clicks)} pin clicks` : `${formatNumber(post.user_profile_clicks)} profile clicks`);
129
+ if (post.permalink)
130
+ metrics.push(post.permalink);
131
+ return metrics.join(' · ') || 'n/a';
132
+ }
133
+ function accountLabel(username) {
134
+ if (!username)
135
+ return 'account';
136
+ return username.startsWith('@') || /\s/.test(username) ? username : `@${username}`;
137
+ }
138
+ function dateTime(value) {
139
+ if (!value)
140
+ return 'n/a';
141
+ const date = new Date(value);
142
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
143
+ }
144
+ function compact(value, maxLength) {
145
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
146
+ return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text;
147
+ }
148
+ function formatNumber(value) {
149
+ return value == null ? 'n/a' : value.toLocaleString();
150
+ }
151
+ function markdownTable(headers, rows) {
152
+ return [
153
+ `| ${headers.map(escapeCell).join(' | ')} |`,
154
+ `| ${headers.map(() => '---').join(' | ')} |`,
155
+ ...rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`),
156
+ ].join('\n');
157
+ }
158
+ function escapeCell(value) {
159
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.19.4",
3
+ "version": "0.19.5",
4
4
  "description": "MCP server for posterly — schedule social media posts from Claude Desktop",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.poster.ly/mcp",
@@ -152,7 +152,7 @@ export interface AccountAnalyticsSummary {
152
152
  total_watch_minutes?: number | null;
153
153
  total_likes?: number | null;
154
154
  platform_metrics?: Record<string, number | null | undefined>;
155
- display_metrics?: Array<{ label: string; value: number | null | undefined }>;
155
+ display_metrics?: Array<{ key?: string; label: string; value: number | null | undefined; unit?: string }>;
156
156
  }
157
157
 
158
158
  export interface AccountAnalyticsSnapshot {
@@ -1,10 +1,14 @@
1
1
  import { z } from 'zod';
2
- import type { AccountAnalyticsSummary, PosterlyClient } from '../lib/api-client.js';
2
+ import type { AccountAnalyticsResponse, AccountAnalyticsSummary, PosterlyClient } from '../lib/api-client.js';
3
+
4
+ const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
5
+ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
6
+ type MetricRow = [string, string];
3
7
 
4
8
  export const getAccountAnalyticsTool = {
5
9
  name: 'get_account_analytics',
6
10
  description:
7
- 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels such as Google Business Profile Views, Search Views, Maps Views, Customer Actions, and Posts.',
11
+ 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
8
12
  inputSchema: z.object({
9
13
  account_id: z
10
14
  .number()
@@ -17,92 +21,151 @@ export const getAccountAnalyticsTool = {
17
21
  .string()
18
22
  .optional()
19
23
  .describe('End date (ISO date). Defaults to today.'),
24
+ presentation: presentationSchema.describe('Output style: compact bullets, Markdown table, or raw JSON for client-side chart/card rendering.'),
20
25
  }),
21
26
 
22
27
  async execute(
23
28
  client: PosterlyClient,
24
- input: { account_id: number; from?: string; to?: string }
29
+ input: { account_id: number; from?: string; to?: string; presentation?: AnalyticsPresentation }
25
30
  ) {
26
31
  const result = await client.getAccountAnalytics(input);
27
- const { account, range, summary, snapshots } = result;
32
+ return formatAccountAnalytics(result, input.presentation || 'compact');
33
+ },
34
+ };
28
35
 
29
- const accountLabel = account.platform === 'google_business'
30
- ? `${account.username} (Google Business, id ${account.id})`
31
- : `@${account.username} (${account.platform}, id ${account.id})`;
36
+ function formatAccountAnalytics(result: AccountAnalyticsResponse, presentation: Exclude<AnalyticsPresentation, undefined>): string {
37
+ const { account, range, summary, snapshots } = result;
38
+ const rows = accountMetricRows(summary, account.platform);
39
+ const insights = analyticsInsights(summary, account.platform);
32
40
 
33
- const lines: string[] = [
34
- `Analytics for ${accountLabel}`,
35
- `Range: ${range.from} → ${range.to} (${snapshots.length} daily snapshots)`,
36
- '',
37
- 'Summary:',
38
- ];
41
+ if (presentation === 'json') {
42
+ return JSON.stringify({
43
+ type: 'account_analytics',
44
+ account,
45
+ range,
46
+ snapshot_count: snapshots.length,
47
+ metrics: rows.map(([label, value]) => ({ label, value })),
48
+ insights,
49
+ summary,
50
+ snapshots,
51
+ }, null, 2);
52
+ }
39
53
 
40
- const nativeRows = getNativeMetricRows(account.platform, summary);
41
- if (nativeRows.length > 0) {
42
- for (const [label, value] of nativeRows) {
43
- pushMetric(lines, label, value);
44
- }
45
- } else if (account.platform === 'pinterest') {
46
- pushGenericSummary(lines, summary);
47
- pushMetric(lines, 'Outbound clicks', summary.total_website_clicks);
48
- } else if (account.platform === 'youtube') {
49
- pushGenericSummary(lines, summary);
50
- pushMetric(lines, 'Watch minutes', summary.total_watch_minutes);
51
- pushMetric(lines, 'Likes', summary.total_likes);
52
- } else {
53
- pushGenericSummary(lines, summary);
54
- }
55
-
56
- return lines.join('\n');
57
- },
58
- };
54
+ if (presentation === 'table') {
55
+ return [
56
+ `**📊 Analytics for ${accountLabel(account.username)} (${account.id})**`,
57
+ `_${account.platform} · ${range.from} → ${range.to}_`,
58
+ '',
59
+ '**Account**',
60
+ markdownTable(['Field', 'Value'], [
61
+ ['Account ID', String(account.id)],
62
+ ['Daily snapshots', formatNumber(snapshots.length)],
63
+ ]),
64
+ '',
65
+ '**Metrics**',
66
+ markdownTable(['Metric', 'Value'], rows),
67
+ '',
68
+ '**Quick read**',
69
+ insights.map((insight) => `- ${insight}`).join('\n'),
70
+ ].join('\n');
71
+ }
59
72
 
60
- type MetricRow = [label: string, value: number | null | undefined];
73
+ return [
74
+ `**📊 Analytics for ${accountLabel(account.username)} (${account.id})**`,
75
+ `_${account.platform} · ${range.from} → ${range.to}_`,
76
+ '',
77
+ '**Account**',
78
+ `- **Account ID:** ${account.id}`,
79
+ `- **Daily snapshots:** ${formatNumber(snapshots.length)}`,
80
+ '',
81
+ '**Key metrics**',
82
+ rows.map(([label, value]) => `- **${label}:** ${value}`).join('\n'),
83
+ '',
84
+ '**Quick read**',
85
+ insights.map((insight) => `- ${insight}`).join('\n'),
86
+ ].join('\n');
87
+ }
61
88
 
62
- function getNativeMetricRows(platform: string, summary: AccountAnalyticsSummary): MetricRow[] {
63
- // API-provided display_metrics is the contract for platform-native dashboards.
64
- // Add future non-generic analytics platforms there so MCP output never guesses
65
- // from internal storage field names.
66
- const apiRows = (summary.display_metrics || [])
67
- .filter((metric) => metric?.label)
68
- .map((metric) => [metric.label, metric.value] as MetricRow);
69
- if (apiRows.length > 0) return apiRows;
89
+ function accountMetricRows(summary: AccountAnalyticsSummary, platform: string): MetricRow[] {
90
+ const nativeRows = nativeMetricRows(summary);
91
+ if (nativeRows.length > 0) return nativeRows;
70
92
 
71
93
  if (platform === 'google_business') {
72
94
  const metrics = summary.platform_metrics || {};
73
95
  return [
74
- ['Profile Views', metrics.profile_views ?? summary.total_views],
75
- ['Search Views', metrics.search_views ?? summary.total_reach],
76
- ['Maps Views', metrics.maps_views ?? summary.total_profile_views],
77
- ['Customer Actions', metrics.customer_actions ?? summary.total_accounts_engaged],
78
- ['Posts', metrics.posts ?? summary.current_media_count],
96
+ ['Profile Views', formatNumber(metrics.profile_views ?? summary.total_views)],
97
+ ['Search Views', formatNumber(metrics.search_views ?? summary.total_reach)],
98
+ ['Maps Views', formatNumber(metrics.maps_views ?? summary.total_profile_views)],
99
+ ['Customer Actions', formatNumber(metrics.customer_actions ?? summary.total_accounts_engaged)],
100
+ ['Posts', formatNumber(metrics.posts ?? summary.current_media_count)],
79
101
  ];
80
102
  }
81
103
 
82
- return [];
104
+ const rows = genericMetricRows(summary);
105
+ if (platform === 'pinterest') rows.push(['Outbound clicks', formatNumber(summary.total_website_clicks)]);
106
+ if (platform === 'youtube') {
107
+ rows.push(['Watch minutes', formatNumber(summary.total_watch_minutes)]);
108
+ rows.push(['Likes', formatNumber(summary.total_likes)]);
109
+ }
110
+ return rows;
111
+ }
112
+
113
+ function nativeMetricRows(summary: AccountAnalyticsSummary): MetricRow[] {
114
+ return (summary.display_metrics || [])
115
+ .filter((metric) => metric?.label)
116
+ .map((metric) => [metric.label, formatNumber(metric.value)]);
117
+ }
118
+
119
+ function genericMetricRows(summary: AccountAnalyticsSummary): MetricRow[] {
120
+ return [
121
+ ['Followers', `${formatNumber(summary.current_followers)} (${formatDelta(summary.followers_change)} in range)`],
122
+ ['Follows gained / lost', `+${formatNumber(summary.total_follows_gained)} / -${formatNumber(summary.total_follows_lost)}`],
123
+ ['Total reach', formatNumber(summary.total_reach)],
124
+ ['Total views', formatNumber(summary.total_views)],
125
+ ['Profile views', formatNumber(summary.total_profile_views)],
126
+ ['Accounts engaged', formatNumber(summary.total_accounts_engaged)],
127
+ ['Engagement rate by reach', formatPercent(summary.engagement_rate)],
128
+ ['Engagement rate by followers', formatPercent(summary.engagement_rate_by_followers)],
129
+ ];
130
+ }
131
+
132
+ function analyticsInsights(summary: AccountAnalyticsSummary, platform: string): string[] {
133
+ const insights: string[] = [];
134
+ if (platform === 'google_business' && (summary.display_metrics?.length || 0) > 0) {
135
+ insights.push('Google Business metrics are shown with dashboard-native labels.');
136
+ }
137
+ if ((summary.followers_change || 0) > 0) insights.push(`Audience grew by ${formatDelta(summary.followers_change)} followers in this range.`);
138
+ if ((summary.followers_change || 0) < 0) insights.push(`Audience dipped by ${formatDelta(summary.followers_change)} followers in this range.`);
139
+ if (summary.engagement_rate != null && platform !== 'google_business') insights.push(`Engagement by reach is ${formatPercent(summary.engagement_rate)}.`);
140
+ if (platform === 'youtube' && summary.total_watch_minutes != null) insights.push(`${formatNumber(summary.total_watch_minutes)} total watch minutes were recorded.`);
141
+ return insights.length ? insights : ['No standout movement detected in the returned period.'];
83
142
  }
84
143
 
85
144
  function formatDelta(n: number): string {
86
145
  return n >= 0 ? `+${n.toLocaleString()}` : n.toLocaleString();
87
146
  }
88
147
 
89
- function pushGenericSummary(lines: string[], summary: AccountAnalyticsSummary) {
90
- lines.push(
91
- `• Followers: ${summary.current_followers.toLocaleString()} (${formatDelta(summary.followers_change)} in range)`,
92
- `• Follows gained / lost: +${summary.total_follows_gained} / -${summary.total_follows_lost}`
93
- );
94
- pushMetric(lines, 'Total reach', summary.total_reach);
95
- pushMetric(lines, 'Total views', summary.total_views);
96
- pushMetric(lines, 'Profile views', summary.total_profile_views);
97
- pushMetric(lines, 'Total accounts engaged', summary.total_accounts_engaged);
98
- lines.push(
99
- `• Engagement rate (by reach): ${summary.engagement_rate}%`,
100
- `• Engagement rate (by followers): ${summary.engagement_rate_by_followers}%`
101
- );
148
+ function formatNumber(value: number | null | undefined): string {
149
+ return value == null ? 'n/a' : value.toLocaleString();
102
150
  }
103
151
 
104
- function pushMetric(lines: string[], label: string, value: number | null | undefined) {
105
- if (value != null) {
106
- lines.push(`• ${label}: ${value.toLocaleString()}`);
107
- }
152
+ function formatPercent(value: number | null | undefined): string {
153
+ return value == null ? 'n/a' : `${formatNumber(value)}%`;
154
+ }
155
+
156
+ function accountLabel(username: string): string {
157
+ if (!username) return 'account';
158
+ return username.startsWith('@') || /\s/.test(username) ? username : `@${username}`;
159
+ }
160
+
161
+ function markdownTable(headers: string[], rows: MetricRow[]): string {
162
+ return [
163
+ `| ${headers.map(escapeCell).join(' | ')} |`,
164
+ `| ${headers.map(() => '---').join(' | ')} |`,
165
+ ...rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`),
166
+ ].join('\n');
167
+ }
168
+
169
+ function escapeCell(value: string): string {
170
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
108
171
  }
@@ -1,10 +1,13 @@
1
1
  import { z } from 'zod';
2
- import type { PosterlyClient } from '../lib/api-client.js';
2
+ import type { PostAnalyticsResponse, PostAnalyticsRow, PosterlyClient } from '../lib/api-client.js';
3
+
4
+ const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
5
+ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
3
6
 
4
7
  export const getPostAnalyticsTool = {
5
8
  name: 'get_post_analytics',
6
9
  description:
7
- 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first.',
10
+ 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
8
11
  inputSchema: z.object({
9
12
  account_id: z
10
13
  .number()
@@ -24,6 +27,7 @@ export const getPostAnalyticsTool = {
24
27
  .optional()
25
28
  .describe('Number of posts to return (default 50, max 200)'),
26
29
  offset: z.number().min(0).optional().describe('Pagination offset'),
30
+ presentation: presentationSchema.describe('Output style: compact bullets, Markdown table, or raw JSON for client-side chart/card rendering.'),
27
31
  }),
28
32
 
29
33
  async execute(
@@ -34,65 +38,143 @@ export const getPostAnalyticsTool = {
34
38
  to?: string;
35
39
  limit?: number;
36
40
  offset?: number;
41
+ presentation?: AnalyticsPresentation;
37
42
  }
38
43
  ) {
39
44
  const result = await client.getPostAnalytics(input);
40
- const { account, range, posts, total } = result;
45
+ return formatPostAnalytics(result, input.presentation || 'compact');
46
+ },
47
+ };
41
48
 
42
- if (posts.length === 0) {
43
- return `No analytics found for @${account.username} (${account.platform}) between ${range.from} and ${range.to}.`;
44
- }
49
+ function formatPostAnalytics(result: PostAnalyticsResponse, presentation: Exclude<AnalyticsPresentation, undefined>): string {
50
+ const { account, range, posts, total } = result;
51
+
52
+ if (posts.length === 0) {
53
+ return `No analytics found for ${accountLabel(account.username)} (${account.platform}) between ${range.from} and ${range.to}.`;
54
+ }
45
55
 
46
- const lines: string[] = [
47
- `Post analytics for @${account.username} (${account.platform})`,
48
- `Range: ${range.from} ${range.to} • Showing ${posts.length} of ${total}`,
56
+ const totals = posts.reduce((acc, post) => {
57
+ acc.likes += post.likes || 0;
58
+ acc.comments += post.comments || 0;
59
+ acc.reach += post.reach || 0;
60
+ acc.impressions += post.impressions || 0;
61
+ acc.shares += post.shares || 0;
62
+ acc.saves += post.saved || 0;
63
+ return acc;
64
+ }, { likes: 0, comments: 0, reach: 0, impressions: 0, shares: 0, saves: 0 });
65
+
66
+ if (presentation === 'json') {
67
+ return JSON.stringify({
68
+ type: 'post_analytics',
69
+ account,
70
+ range,
71
+ returned: {
72
+ count: posts.length,
73
+ total,
74
+ },
75
+ totals,
76
+ posts,
77
+ }, null, 2);
78
+ }
79
+
80
+ if (presentation === 'table') {
81
+ return [
82
+ `**📈 Post analytics for ${accountLabel(account.username)} (${account.id})**`,
83
+ `_${account.platform} · ${range.from} → ${range.to} · showing ${posts.length} of ${total}_`,
49
84
  '',
50
- ];
51
-
52
- for (const p of posts) {
53
- const postedAt = p.posted_at
54
- ? new Date(p.posted_at).toLocaleString()
55
- : 'unknown date';
56
- const caption = p.caption_snippet
57
- ? p.caption_snippet.length > 60
58
- ? `${p.caption_snippet.slice(0, 60)}…`
59
- : p.caption_snippet
60
- : '(no caption)';
61
- const metrics = [
62
- `${p.likes} likes`,
63
- `${p.comments} comments`,
64
- `${p.reach.toLocaleString()} reach`,
65
- ];
66
- if (p.impressions) metrics.push(`${p.impressions.toLocaleString()} impressions`);
67
- if (p.saved) metrics.push(`${p.saved} saved`);
68
- if (p.shares) metrics.push(`${p.shares} shares`);
69
- if (p.plays) metrics.push(`${p.plays.toLocaleString()} plays`);
70
- if (account.platform === 'youtube' && p.total_watch_time_ms) {
71
- metrics.push(`${Math.round(p.total_watch_time_ms / 60000).toLocaleString()} watch minutes`);
72
- }
73
- if (account.platform === 'youtube' && p.avg_watch_time_ms) {
74
- metrics.push(`${Math.round(p.avg_watch_time_ms / 1000)}s avg view`);
75
- }
76
- if (p.url_link_clicks) {
77
- metrics.push(
78
- account.platform === 'pinterest'
79
- ? `${p.url_link_clicks.toLocaleString()} outbound clicks`
80
- : `${p.url_link_clicks.toLocaleString()} link clicks`
81
- );
82
- }
83
- if (p.user_profile_clicks) {
84
- metrics.push(
85
- account.platform === 'pinterest'
86
- ? `${p.user_profile_clicks.toLocaleString()} pin clicks`
87
- : `${p.user_profile_clicks.toLocaleString()} profile clicks`
88
- );
89
- }
90
-
91
- lines.push(`• [${postedAt}] ${caption}`);
92
- lines.push(` ${metrics.join(' • ')}`);
93
- if (p.permalink) lines.push(` ${p.permalink}`);
94
- }
85
+ '**Returned totals**',
86
+ markdownTable(['Likes', 'Comments', 'Reach', 'Impressions', 'Shares', 'Saves'], [[
87
+ formatNumber(totals.likes),
88
+ formatNumber(totals.comments),
89
+ formatNumber(totals.reach),
90
+ formatNumber(totals.impressions),
91
+ formatNumber(totals.shares),
92
+ formatNumber(totals.saves),
93
+ ]]),
94
+ '',
95
+ '**Posts**',
96
+ markdownTable(['Posted', 'Post', 'Caption', 'Reach', 'Likes', 'Comments', 'Extras'], posts.map((post) => [
97
+ dateTime(post.posted_at),
98
+ post.id ? String(post.id) : post.platform_media_id,
99
+ compact(post.caption_snippet, 70) || '(no caption)',
100
+ formatNumber(post.reach),
101
+ formatNumber(post.likes),
102
+ formatNumber(post.comments),
103
+ postExtras(account.platform, post),
104
+ ])),
105
+ ].join('\n');
106
+ }
95
107
 
96
- return lines.join('\n');
97
- },
98
- };
108
+ const lines: string[] = [
109
+ `**📈 Post analytics for ${accountLabel(account.username)} (${account.id})**`,
110
+ `_${account.platform} · ${range.from} → ${range.to} · showing ${posts.length} of ${total}_`,
111
+ '',
112
+ '**Returned totals**',
113
+ `- **Likes:** ${formatNumber(totals.likes)}`,
114
+ `- **Comments:** ${formatNumber(totals.comments)}`,
115
+ `- **Reach:** ${formatNumber(totals.reach)}`,
116
+ `- **Impressions:** ${formatNumber(totals.impressions)}`,
117
+ `- **Shares:** ${formatNumber(totals.shares)}`,
118
+ `- **Saves:** ${formatNumber(totals.saves)}`,
119
+ '',
120
+ '**Posts**',
121
+ ];
122
+
123
+ posts.forEach((post, index) => {
124
+ lines.push(`${index + 1}. **${compact(post.caption_snippet, 70) || '(no caption)'}**`);
125
+ lines.push(` Posted: ${dateTime(post.posted_at)}`);
126
+ lines.push(` Post ID: ${post.id ? String(post.id) : post.platform_media_id}`);
127
+ lines.push(` Reach: ${formatNumber(post.reach)}`);
128
+ lines.push(` Likes: ${formatNumber(post.likes)}`);
129
+ lines.push(` Comments: ${formatNumber(post.comments)}`);
130
+ lines.push(` Extras: ${postExtras(account.platform, post)}`);
131
+ });
132
+
133
+ return lines.join('\n');
134
+ }
135
+
136
+ function postExtras(platform: string, post: PostAnalyticsRow): string {
137
+ const metrics: string[] = [];
138
+ if (post.impressions) metrics.push(`${formatNumber(post.impressions)} impressions`);
139
+ if (post.saved) metrics.push(`${formatNumber(post.saved)} saved`);
140
+ if (post.shares) metrics.push(`${formatNumber(post.shares)} shares`);
141
+ if (post.plays) metrics.push(`${formatNumber(post.plays)} plays`);
142
+ if (platform === 'youtube' && post.total_watch_time_ms) metrics.push(`${formatNumber(Math.round(post.total_watch_time_ms / 60000))} watch minutes`);
143
+ if (platform === 'youtube' && post.avg_watch_time_ms) metrics.push(`${formatNumber(Math.round(post.avg_watch_time_ms / 1000))}s avg view`);
144
+ if (post.url_link_clicks) metrics.push(platform === 'pinterest' ? `${formatNumber(post.url_link_clicks)} outbound clicks` : `${formatNumber(post.url_link_clicks)} link clicks`);
145
+ if (post.user_profile_clicks) metrics.push(platform === 'pinterest' ? `${formatNumber(post.user_profile_clicks)} pin clicks` : `${formatNumber(post.user_profile_clicks)} profile clicks`);
146
+ if (post.permalink) metrics.push(post.permalink);
147
+ return metrics.join(' · ') || 'n/a';
148
+ }
149
+
150
+ function accountLabel(username: string): string {
151
+ if (!username) return 'account';
152
+ return username.startsWith('@') || /\s/.test(username) ? username : `@${username}`;
153
+ }
154
+
155
+ function dateTime(value: string | null): string {
156
+ if (!value) return 'n/a';
157
+ const date = new Date(value);
158
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
159
+ }
160
+
161
+ function compact(value: string | null, maxLength: number): string {
162
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
163
+ return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text;
164
+ }
165
+
166
+ function formatNumber(value: number | null | undefined): string {
167
+ return value == null ? 'n/a' : value.toLocaleString();
168
+ }
169
+
170
+ function markdownTable(headers: string[], rows: string[][]): string {
171
+ return [
172
+ `| ${headers.map(escapeCell).join(' | ')} |`,
173
+ `| ${headers.map(() => '---').join(' | ')} |`,
174
+ ...rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`),
175
+ ].join('\n');
176
+ }
177
+
178
+ function escapeCell(value: string): string {
179
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
180
+ }