@omnisocials/mcp-server 1.17.0 → 1.19.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/README.md +12 -1
- package/build/client.d.ts +1 -0
- package/build/client.js +5 -0
- package/build/index.js +1 -1
- package/build/tools/media.js +1 -1
- package/build/tools/posts.js +73 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -218,9 +218,20 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
|
|
|
218
218
|
|
|
219
219
|
## Changelog
|
|
220
220
|
|
|
221
|
+
### 1.19.0 (2026-08-02)
|
|
222
|
+
|
|
223
|
+
- **Alt text now reaches Instagram and LinkedIn:** per-media alt text (`{ url, alt }` / `{ id, alt }` entries) is also delivered to Instagram (image posts and carousel image slides — not Reels/Stories) and LinkedIn (images only — not video or documents), alongside Mastodon, Bluesky, X, and Pinterest.
|
|
224
|
+
- **`duration_seconds` on `get_recent_platform_posts`:** video length in whole seconds where the platform's listing API reports it (currently TikTok and YouTube; `null` for images and platforms that don't expose it) — rendered as a Duration column (m:ss) in the summary table and included in the `Structured data` JSON block.
|
|
225
|
+
|
|
226
|
+
### 1.18.0
|
|
227
|
+
|
|
228
|
+
- **`retry_post`:** retry the failed platforms of a `failed` or partially failed (`warning`) post on the same post — only failed platforms are re-published, succeeded ones never post twice. Async via the publishing queue; poll `get_post` for the outcome. Max 3 retries per platform. Backed by `POST /api/v1/posts/{id}/retry`.
|
|
229
|
+
- **Retry linkage:** posts expose `retry_of` / `retries`, and `get_post` renders them — a `published` post with empty `published_urls` and `retries` set is a resolved failure, not a second publish.
|
|
230
|
+
- **`get_post` now renders per-platform publish errors** (previously dropped) with a pointer to `retry_post`.
|
|
231
|
+
|
|
221
232
|
### 1.17.0
|
|
222
233
|
|
|
223
|
-
- **Per-media alt text (accessibility descriptions):** `media_urls` / `media_ids` entries now accept `{ url, alt }` / `{ id, alt }` objects everywhere, including thread parts. Delivered to Mastodon (media description), Bluesky (embed alt), X (photos/GIFs)
|
|
234
|
+
- **Per-media alt text (accessibility descriptions):** `media_urls` / `media_ids` entries now accept `{ url, alt }` / `{ id, alt }` objects everywhere, including thread parts. Delivered to Mastodon (media description), Bluesky (embed alt), X (media metadata, photos/GIFs only) and Pinterest (pin alt_text fallback). `get_post` reads alt text back.
|
|
224
235
|
|
|
225
236
|
### 1.16.0
|
|
226
237
|
|
package/build/client.d.ts
CHANGED
|
@@ -213,6 +213,7 @@ export declare class OmniSocialsClient {
|
|
|
213
213
|
}): Promise<ApiResponse<unknown>>;
|
|
214
214
|
deletePost(id: string): Promise<ApiResponse<unknown>>;
|
|
215
215
|
publishPost(id: string): Promise<ApiResponse<unknown>>;
|
|
216
|
+
retryPost(id: string): Promise<ApiResponse<unknown>>;
|
|
216
217
|
searchLocations(query: string): Promise<ApiResponse<unknown>>;
|
|
217
218
|
validateLocation(id: string): Promise<ApiResponse<unknown>>;
|
|
218
219
|
searchInstagramAudio(query?: string, type?: "music" | "original_sound"): Promise<ApiResponse<unknown>>;
|
package/build/client.js
CHANGED
|
@@ -219,6 +219,11 @@ export class OmniSocialsClient {
|
|
|
219
219
|
async publishPost(id) {
|
|
220
220
|
return this.request("POST", `/posts/${id}/publish`);
|
|
221
221
|
}
|
|
222
|
+
// Retry the failed platforms of a failed/warning post on the same post.
|
|
223
|
+
// Succeeded platforms are never re-published (server-side idempotency guard).
|
|
224
|
+
async retryPost(id) {
|
|
225
|
+
return this.request("POST", `/posts/${id}/retry`);
|
|
226
|
+
}
|
|
222
227
|
// Locations (Instagram place tagging)
|
|
223
228
|
async searchLocations(query) {
|
|
224
229
|
return this.request("GET", "/locations/search", undefined, { q: query });
|
package/build/index.js
CHANGED
|
@@ -29,7 +29,7 @@ const sessionState = { activeIndex: 0 };
|
|
|
29
29
|
const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
|
|
30
30
|
const server = new McpServer({
|
|
31
31
|
name: "OmniSocials",
|
|
32
|
-
version: "1.
|
|
32
|
+
version: "1.19.0",
|
|
33
33
|
});
|
|
34
34
|
// Register all tools - pass getter function so tools always use the active workspace's client
|
|
35
35
|
registerPostTools(server, getActiveClient);
|
package/build/tools/media.js
CHANGED
|
@@ -142,7 +142,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
142
142
|
if (compatibility && compatibility.compatible === false && compatibility.summary) {
|
|
143
143
|
md += `\n\n⚠️ **${compatibility.summary}** It will still post to your other connected platforms. Ask the user whether to continue before adding it to a post.`;
|
|
144
144
|
}
|
|
145
|
-
md += `\n\nUse this Media ID with \`media_ids\` when creating posts (including inside \`x.thread_parts[].media_ids\`). The public URL above also works anywhere \`media_urls\` is accepted. To attach alt text (an accessibility description), pass \`{ id: "<Media ID>", alt: "..." }\` instead of the bare ID — delivered to Mastodon, Bluesky, X (
|
|
145
|
+
md += `\n\nUse this Media ID with \`media_ids\` when creating posts (including inside \`x.thread_parts[].media_ids\`). The public URL above also works anywhere \`media_urls\` is accepted. To attach alt text (an accessibility description), pass \`{ id: "<Media ID>", alt: "..." }\` instead of the bare ID — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images).`;
|
|
146
146
|
return {
|
|
147
147
|
content: [{ type: "text", text: md }],
|
|
148
148
|
};
|
package/build/tools/posts.js
CHANGED
|
@@ -5,9 +5,11 @@ import { formatDateTime, truncate, capitalize, formatNumber, metricRows } from "
|
|
|
5
5
|
// or an object carrying `alt`: an accessibility description (alt text, max
|
|
6
6
|
// 1500 chars) for that file. Alt text is delivered to Mastodon (media
|
|
7
7
|
// description — the community strongly values alt text), Bluesky (embed alt),
|
|
8
|
-
// X (photos/GIFs only),
|
|
9
|
-
//
|
|
10
|
-
|
|
8
|
+
// X (media metadata, photos/GIFs only), Pinterest (pin alt_text fallback),
|
|
9
|
+
// Instagram (images and carousel image slides — not Reels/Stories, capped at
|
|
10
|
+
// 1000 chars) and LinkedIn (images only — not video or documents); other
|
|
11
|
+
// platforms ignore it for now.
|
|
12
|
+
const ALT_TEXT_DESCRIBE = "Accessibility description (alt text) for this file, max 1500 chars. Delivered to Mastodon (media description), Bluesky (embed alt), X (media metadata, photos/GIFs only), Pinterest (pin alt_text fallback), Instagram (images and carousel image slides — not Reels/Stories) and LinkedIn (images only — not video or documents). Other platforms ignore it.";
|
|
11
13
|
const mediaUrlEntry = z.union([
|
|
12
14
|
z.string(),
|
|
13
15
|
z.object({
|
|
@@ -193,6 +195,14 @@ export function registerPostTools(server, getClient) {
|
|
|
193
195
|
md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
|
|
194
196
|
if (appUrl)
|
|
195
197
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
198
|
+
// Retry linkage: a "published" post with `retries` set and empty
|
|
199
|
+
// published_urls is a resolved failure — the live URLs are on the retry
|
|
200
|
+
// post, so don't count both as separate publishes.
|
|
201
|
+
if (p.retry_of)
|
|
202
|
+
md += `| **Retry of post** | \`${p.retry_of}\` (this post is a retry of that failed post) |\n`;
|
|
203
|
+
if (Array.isArray(p.retries) && p.retries.length) {
|
|
204
|
+
md += `| **Retried by** | ${p.retries.map((r) => `\`${r}\``).join(", ")} (live URLs are on the retry post) |\n`;
|
|
205
|
+
}
|
|
196
206
|
// Media can be a flat array (same set everywhere) or a per-platform
|
|
197
207
|
// object ({ default: [...], instagram: [...] }). The old `p.media.length`
|
|
198
208
|
// check silently dropped the object shape (`.length` is undefined).
|
|
@@ -300,6 +310,21 @@ export function registerPostTools(server, getClient) {
|
|
|
300
310
|
md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${url}\n`;
|
|
301
311
|
}
|
|
302
312
|
}
|
|
313
|
+
// Per-platform publish errors (status failed/warning). Point at
|
|
314
|
+
// retry_post — only the failed platforms are re-published.
|
|
315
|
+
const postErrors = (p.errors && typeof p.errors === "object" && !Array.isArray(p.errors))
|
|
316
|
+
? p.errors
|
|
317
|
+
: {};
|
|
318
|
+
const errorEntries = Object.entries(postErrors);
|
|
319
|
+
if (errorEntries.length) {
|
|
320
|
+
md += `\n\n### Publish Errors\n\n`;
|
|
321
|
+
for (const [platform, message] of errorEntries) {
|
|
322
|
+
md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${message}\n`;
|
|
323
|
+
}
|
|
324
|
+
if (p.status === "failed" || p.status === "warning") {
|
|
325
|
+
md += `\n_Retry the failed platform(s) with \`retry_post\` — platforms that already succeeded are never re-published._\n`;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
303
328
|
// First comments: nested under each platform object by the API. Show the
|
|
304
329
|
// configured text plus, once published, the outcome (posted/failed/skipped).
|
|
305
330
|
const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube"];
|
|
@@ -398,11 +423,11 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
398
423
|
media_ids: z.union([
|
|
399
424
|
z.array(mediaIdEntry),
|
|
400
425
|
z.record(z.string(), z.array(mediaIdEntry)),
|
|
401
|
-
]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X (
|
|
426
|
+
]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images)."),
|
|
402
427
|
media_urls: z.union([
|
|
403
428
|
z.array(mediaUrlEntry),
|
|
404
429
|
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
405
|
-
]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Each entry is a plain URL string or { url, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X (
|
|
430
|
+
]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Each entry is a plain URL string or { url, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB. When using per-platform format, 'default' is the fallback for selected platforms without their own key. Pass an empty array (e.g. facebook: []) to opt a platform out of media. For files over 100 MB (up to 1 GB): upload_media with method 'url' first, then pass the returned media id in `media`."),
|
|
406
431
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
|
|
407
432
|
link_url: z.string().optional().describe("URL to share as a rich preview card on platforms that support link-share posts (LinkedIn and Facebook). The URL renders as a tile with thumbnail / title / description instead of plain text. Ignored on platforms that don't support link shares, and ignored on posts that already have media attached (media wins)."),
|
|
408
433
|
link_title: z.string().optional().describe("Optional title for the link-share preview. LinkedIn uses this when set; Facebook ignores it and fetches OG metadata server-side. Omit to let LinkedIn auto-fetch the page title."),
|
|
@@ -579,11 +604,11 @@ Do NOT call without required media — it will fail.`, {
|
|
|
579
604
|
media_ids: z.union([
|
|
580
605
|
z.array(mediaIdEntry),
|
|
581
606
|
z.record(z.string(), z.array(mediaIdEntry)),
|
|
582
|
-
]).optional().describe("Media IDs from upload — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X (
|
|
607
|
+
]).optional().describe("Media IDs from upload — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images)."),
|
|
583
608
|
media_urls: z.union([
|
|
584
609
|
z.array(mediaUrlEntry),
|
|
585
610
|
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
586
|
-
]).optional().describe("External image/video URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X (
|
|
611
|
+
]).optional().describe("External image/video URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
|
|
587
612
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
|
|
588
613
|
location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue to tag the post's location. Applied to single-image and carousel Instagram feed posts. Use the `search_locations` tool to find a valid ID. Ignored by other platforms."),
|
|
589
614
|
collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time. Ignored by other platforms."),
|
|
@@ -722,11 +747,11 @@ Do NOT call without required media — it will fail.`, {
|
|
|
722
747
|
media_ids: z.union([
|
|
723
748
|
z.array(mediaIdEntry),
|
|
724
749
|
z.record(z.string(), z.array(mediaIdEntry)),
|
|
725
|
-
]).optional().describe("Media IDs — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X (
|
|
750
|
+
]).optional().describe("Media IDs — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images)."),
|
|
726
751
|
media_urls: z.union([
|
|
727
752
|
z.array(mediaUrlEntry),
|
|
728
753
|
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
729
|
-
]).optional().describe("External URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X (
|
|
754
|
+
]).optional().describe("External URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
|
|
730
755
|
location_id: z.string().optional().describe("Instagram only. Facebook Place/Page ID to tag the post's location with. Send an empty string to clear an existing location tag. Ignored by other platforms."),
|
|
731
756
|
collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors. Replaces the existing collaborator list. Send an empty array to clear collaborators. Works on image, carousel, and reel posts — NOT Stories. Ignored by other platforms."),
|
|
732
757
|
user_tags: z.array(z.object({
|
|
@@ -854,7 +879,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
854
879
|
content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Post deleted successfully." }],
|
|
855
880
|
};
|
|
856
881
|
});
|
|
857
|
-
server.tool("publish_post", `Publish a draft or scheduled post immediately. The post will be queued for publishing.
|
|
882
|
+
server.tool("publish_post", `Publish a draft or scheduled post immediately. The post will be queued for publishing. Only draft and scheduled posts can be published — for a failed or partially failed (warning) post use \`retry_post\` instead, which re-publishes only the failed platforms.
|
|
858
883
|
|
|
859
884
|
IMPORTANT: Before publishing, verify the post has all required media. If publishing a story or reel, ensure media was attached when the post was created/updated. If it's missing, use update_post to add media first, or inform the user.`, {
|
|
860
885
|
id: z.string().describe("The post ID to publish"),
|
|
@@ -875,6 +900,31 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
|
|
|
875
900
|
content: [{ type: "text", text: md }],
|
|
876
901
|
};
|
|
877
902
|
});
|
|
903
|
+
server.tool("retry_post", `Retry the failed platforms of a failed or partially failed post — on the same post, no duplicate created. Use when a post has status \`failed\` (every platform failed) or \`warning\` (some failed, some published): only the FAILED platforms are re-published; platforms that already succeeded are never posted again.
|
|
904
|
+
|
|
905
|
+
The retry runs asynchronously (usually within a few minutes). Poll \`get_post\` afterwards: on success the status becomes \`published\` and \`published_urls\` gains the platform's live URL; on another failure the platform's entry reappears under errors. Each platform can be retried at most 3 times — after that, recreate the post. Note \`publish_post\` refuses failed posts; this is the tool for them.`, {
|
|
906
|
+
id: z.string().describe("The failed or partially failed post ID to retry"),
|
|
907
|
+
}, async ({ id }) => {
|
|
908
|
+
const result = await getClient().retryPost(id);
|
|
909
|
+
if (result.error) {
|
|
910
|
+
return {
|
|
911
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
const p = result.data;
|
|
915
|
+
let md = `## Retry Queued\n\n`;
|
|
916
|
+
md += `| Field | Value |\n`;
|
|
917
|
+
md += `|-------|-------|\n`;
|
|
918
|
+
md += `| **ID** | \`${p.id}\` |\n`;
|
|
919
|
+
md += `| **Status** | ${capitalize(p.status || "posting")} |\n`;
|
|
920
|
+
if (Array.isArray(p.platforms) && p.platforms.length) {
|
|
921
|
+
md += `| **Retrying** | ${p.platforms.map((x) => capitalize(x.replace(/_/g, " "))).join(", ")} |\n`;
|
|
922
|
+
}
|
|
923
|
+
md += `\n_Platforms that already succeeded are never re-published. Check \`get_post\` in a minute or two for the outcome._`;
|
|
924
|
+
return {
|
|
925
|
+
content: [{ type: "text", text: md }],
|
|
926
|
+
};
|
|
927
|
+
});
|
|
878
928
|
server.tool("search_locations", `Search for an Instagram location to tag on a post. Use this whenever the user wants to post WITH a place/location (e.g. "post this at my dealership", "tag the café"). It returns matching real venues with their addresses and a location ID.
|
|
879
929
|
|
|
880
930
|
Flow: call this with the place name → present the options to the user → once they pick, pass that result's \`id\` as \`location_id\` on create_post / create_and_publish_post / update_post. Only Instagram supports location tagging.
|
|
@@ -937,7 +987,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
|
|
|
937
987
|
});
|
|
938
988
|
return { content: [{ type: "text", text: md }] };
|
|
939
989
|
});
|
|
940
|
-
server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live, so expect a few seconds of latency. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
|
|
990
|
+
server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live, so expect a few seconds of latency. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
|
|
941
991
|
limit: z
|
|
942
992
|
.string()
|
|
943
993
|
.optional()
|
|
@@ -958,6 +1008,14 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
|
|
|
958
1008
|
const errors = result.errors || {};
|
|
959
1009
|
const errEntries = Object.entries(errors);
|
|
960
1010
|
const clean = (s) => String(s || "").replace(/[|\n\r]+/g, " ").trim();
|
|
1011
|
+
// Video length as m:ss (e.g. 95 → "1:35"); blank for images and
|
|
1012
|
+
// platforms whose listing API reports no duration.
|
|
1013
|
+
const fmtVideoDuration = (secs) => {
|
|
1014
|
+
if (typeof secs !== "number" || !Number.isFinite(secs))
|
|
1015
|
+
return "—";
|
|
1016
|
+
const s = Math.round(secs);
|
|
1017
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
1018
|
+
};
|
|
961
1019
|
if (!posts.length) {
|
|
962
1020
|
const note = result.note || "No recent platform posts found.";
|
|
963
1021
|
const errLines = errEntries.map(([p, m]) => `- ${capitalize(p)}: ${m}`).join("\n");
|
|
@@ -966,8 +1024,8 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
|
|
|
966
1024
|
};
|
|
967
1025
|
}
|
|
968
1026
|
let md = `## Recent platform posts (${posts.length} across ${connected.length} platform${connected.length === 1 ? "" : "s"})\n\n`;
|
|
969
|
-
md += `| # | Platform | Format | Content | Engagement | Metrics | Date |\n`;
|
|
970
|
-
md +=
|
|
1027
|
+
md += `| # | Platform | Format | Duration | Content | Engagement | Metrics | Date |\n`;
|
|
1028
|
+
md += `|---|----------|--------|----------|---------|------------|---------|------|\n`;
|
|
971
1029
|
posts.forEach((p, i) => {
|
|
972
1030
|
const hasMetrics = p.metrics && Object.keys(p.metrics).length > 0;
|
|
973
1031
|
const content = truncate(clean(p.text), 45) || "*(no caption)*";
|
|
@@ -979,7 +1037,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
|
|
|
979
1037
|
.map(([label, n]) => `${formatNumber(n)} ${label.toLowerCase()}`)
|
|
980
1038
|
.join(" · ");
|
|
981
1039
|
const date = p.timestamp ? formatDateTime(p.timestamp) : "—";
|
|
982
|
-
md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${content} | ${eng} | ${detail || "—"} | ${date} |\n`;
|
|
1040
|
+
md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${fmtVideoDuration(p.duration_seconds)} | ${content} | ${eng} | ${detail || "—"} | ${date} |\n`;
|
|
983
1041
|
});
|
|
984
1042
|
if (result.note)
|
|
985
1043
|
md += `\n${result.note}\n`;
|
|
@@ -1001,6 +1059,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
|
|
|
1001
1059
|
text: p.text ?? "",
|
|
1002
1060
|
format: p.format ?? "post",
|
|
1003
1061
|
media_count: p.media_count ?? 1,
|
|
1062
|
+
duration_seconds: p.duration_seconds ?? null,
|
|
1004
1063
|
timestamp: p.timestamp ?? null,
|
|
1005
1064
|
image_url: p.image_url ?? null,
|
|
1006
1065
|
engagement: p.engagement ?? null,
|
package/package.json
CHANGED