Competitor scan: AI finds rivals, Instagram verifies, visual report

- competitor-scan skill + run type: web search for local rivals and
  niche benchmarks, competitor_suggest checks each on Instagram
- Suggestions inbox on Competitors page (Track / Dismiss)
- Report blocks: leaderboard, size-vs-engagement map, format lift,
  market's best posts, hashtag gap, Google ratings
- Stats: engagement by format, top hashtags; audit adds leaderboard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-27 13:44:37 -04:00
parent df3dbb63c4
commit f86bd9ce33
10 changed files with 364 additions and 20 deletions

View File

@ -45,6 +45,7 @@ description: Audit one client's Instagram and Facebook using live data and write
- ```` ```mc:screenshot ```` first line a shot id (`ig-profile`, `ig-grid`, `fb-page`, `gbp`…), then one short note per line about what the screenshot shows. Skip it if that shot wasn't uploaded.
- Callouts: `> [!WIN] Title`, `> [!FIX] Title`, `> [!RISK] Title`, `> [!NOTE] Title`, then the text on the next line. Use them for the headline finding of each section, not every paragraph.
- Client order: Summary + `mc:score` → Your numbers (`mc:kpis`, `mc:reach`) → Where people drop off (`mc:funnel`, `mc:followers`) → What's working (`[!WIN]`, screenshot, `mc:formats`) → What we're changing (`mc:rhythm`, `mc:top-posts`, `[!FIX]`/`[!RISK]`) → Next 30 days (`mc:actions`) → What we need from you (checklist). The internal version uses the same blocks plus the internal sections.
- If `competitors` returns tracked accounts, add ```` ```mc:rivals ```` (leaderboard vs competitors) after "Your numbers". The other competitor blocks (`mc:rivals-map`, `-formats`, `-posts`, `-tags`, `-google`) belong in the `competitor-scan` report.
- Each data block can be used once per report. If a block shows "no account data stored yet", refresh the client's numbers in the dashboard and re-export rather than writing numbers by hand.
5. If brand.md is mostly empty, draft it from what you saw (tone, pillars, visuals) and mark it DRAFT for the user to confirm.
6. Append 2–3 dated lessons to `history.md`.

View File

@ -0,0 +1,36 @@
---
name: competitor-scan
description: Find a client's real competitors (local rivals and niche benchmarks), check each on Instagram, compare them with the client and write a visual competitive report with the gaps the client can own. Use for a new client, quarterly, or "who is doing better than us and why".
---
Goal: tell the client who they're actually up against, who's winning and *why*, and the 3–5 openings nobody in their market is using. Numbers come from Instagram via the tools, never from memory or guesses.
1. **Know the client.** Read `clients/<slug>/client.json`, `brand.md`, `goals.md`, `constraints.md` and any `audit-*` file. Note: niche, exact offer, city/area (and radius that matters: a clinic competes within ~10 km, an online brand nationally), price level, ideal customer. Call `competitors` to see who's already tracked, so you don't suggest them again.
2. **Search for candidates** with WebSearch (8–15 searches, no more):
- Local rivals: "<service> <city>", "best <service> near <neighbourhood>", Google Maps / Yelp / directory listings, "<service> <city> instagram". For each business note its Google rating and review count if the search result shows them.
- Benchmarks: accounts in the same niche (other cities or countries) known for strong content, e.g. "best <niche> instagram accounts", industry roundups.
- Find each business's Instagram handle from its website or listing (WebFetch the site's homepage and look for instagram.com links). Never guess a handle.
- Aim for 6–10 local rivals and 3–5 benchmarks.
3. **Verify and save** with `competitor_suggest` (one call, up to 15 accounts): `kind` local or benchmark, a one-line `why` a business owner would understand ("Same street, same price range, 4.8★ from 310 reviews"), and `google` rating/reviews when you found them. The tool checks each on Instagram and returns the real numbers; skipped ones (private, personal or wrong handle) are listed with the reason. Don't retry a skipped handle with variations.
4. **Analyse** using only what the tool returned (and `competitors` for already-tracked ones) plus the client's own numbers (`instagram_insights`, `instagram_recent_posts`):
- Who's winning: engagement rate (engagement per follower) matters more than follower count; a small account with high engagement beats a big quiet one. Posting rhythm. Follower growth where tracked.
- Why they win: format that works for them (`engagementByFormat`), what their top posts have in common (topic, hook, people on camera, offers, local references), hashtags their best posts use (`topTags`) that the client doesn't.
- Gaps: topics, formats or angles none of the rivals cover well, which fit the client's strengths. These are the most valuable part of the report.
- Local businesses: compare Google rating and review counts too; that's often where customers decide.
5. **Write** `clients/<slug>/competitors-YYYY-MM.md`, client-ready (plain language, no tools or file names, no other clients). Use the visual blocks described in `.claude/skills/account-audit/SKILL.md` (front matter, callouts, `mc:score`, `mc:actions`) plus these competitor blocks, all drawn from the verified data (empty fenced blocks, never type numbers in them):
- ```` ```mc:rivals ```` leaderboard: client vs every competitor, ranked by engagement rate.
- ```` ```mc:rivals-map ```` size vs engagement map: who punches above their weight.
- ```` ```mc:rivals-formats ```` format mix and what each format earns per post.
- ```` ```mc:rivals-tags ```` hashtags the winners use that the client doesn't.
- ```` ```mc:rivals-posts ```` the best posts in the market, as an idea bank.
- ```` ```mc:rivals-google ```` Google rating and reviews (local businesses).
Order: Summary (3 sentences: where the client stands, who's leading and why, the biggest opening) → `mc:rivals` → `mc:rivals-map` → **Who's winning and why** (one `> [!NOTE]` per top 2–3 rivals: what they do that works) → `mc:rivals-formats` → `mc:rivals-posts` → `mc:rivals-tags` → `mc:rivals-google` if local → **Openings nobody owns** (`> [!WIN]` per gap, 3–5) → **What we'll do** (`mc:actions`, 5 items, owner + when) → **Who to track** (list the suggested accounts and say the team will confirm them).
Never copy a rival's content or suggest misleading comparisons; learn the pattern, not the post.
6. Export the PDF: `node tools/report-pdf/build.mjs clients/<slug>/competitors-YYYY-MM.md`. Append 1–2 dated lessons to `history.md`.
7. Final message: the PDF path, how many accounts were suggested vs skipped, and a reminder that someone must add or dismiss the suggestions on the Competitors page so they're tracked from now on.

View File

@ -336,4 +336,50 @@ ol.mc-actions li::before { content: counter(act); display: grid; place-items: ce
.mc-shot img { width: 100%; border-radius: 10px; border: 1px solid var(--mc-line); }
ol.mc-shot-notes { list-style: none; counter-reset: note; padding: 0; margin: 0; display: grid; gap: 8px; }
ol.mc-shot-notes li { counter-increment: note; position: relative; padding-left: 26px; font-size: 8.8pt; line-height: 1.45; margin: 0; }
/* Competitors */
:root { --mc-you: #fc6d45; --mc-you-soft: #fff1ec; }
.mc-chip.is-you { background: var(--mc-you); color: #fff; }
.mc-chip.is-bench { background: #e6f4fb; color: #1f7aa8; }
.mc-chip.is-new { background: var(--mc-warn-soft); color: #9a6a0c; }
table.mc-lb { margin: 0; font-size: 8.6pt; }
table.mc-lb th { background: transparent; border-bottom: 1px solid var(--mc-line); padding: 4px 8px; }
table.mc-lb td { padding: 7px 8px; vertical-align: middle; }
table.mc-lb td:first-child { font-weight: 800; color: var(--mc-muted); width: 24px; }
table.mc-lb td.num { white-space: nowrap; font-weight: 600; }
table.mc-lb tr.is-you td { background: var(--mc-you-soft); }
table.mc-lb tr.is-you td:first-child { color: var(--mc-you); border-left: 3px solid var(--mc-you); }
.mc-lb-name { font-weight: 650; color: var(--mc-charcoal); }
.mc-lb-name .mc-chip { margin: 0 0 0 4px; vertical-align: 1px; }
.mc-lb-handle, .mc-lb-sub { font-size: 7.3pt; color: var(--mc-muted); font-weight: 400; }
.mc-lb-sub.is-good { color: var(--mc-good); }
.mc-lb-sub.is-bad { color: var(--mc-bad); }
.mc-lb-bar { position: relative; height: 18px; min-width: 130px; border-radius: 5px; background: var(--mc-tint); }
.mc-lb-bar div { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 5px; background: linear-gradient(90deg, var(--mc-blue), var(--mc-sky)); min-width: 3px; }
tr.is-you .mc-lb-bar div { background: var(--mc-you); }
.mc-lb-bar span { position: relative; padding-left: 7px; line-height: 18px; font-size: 7.8pt; font-weight: 700; color: var(--mc-charcoal); }
.mc-quad { font: 700 7.5px var(--mc-head); fill: #b3b9c4; letter-spacing: 0.6px; text-transform: uppercase; }
.mc-dot-l { font: 600 7.5px var(--mc-body); fill: var(--mc-gray); }
.mc-dot-l.is-you { fill: var(--mc-you); font-weight: 800; font-size: 9px; }
.mc-fm { display: grid; gap: 5px; }
.mc-fm-row { display: grid; grid-template-columns: 130px 1fr; gap: 10px; align-items: center; font-size: 8pt; }
.mc-fm-row.is-you .mc-fm-l { color: var(--mc-you); font-weight: 800; }
.mc-fm-l { color: var(--mc-gray); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mc-fm-bar { display: flex; height: 12px; border-radius: 6px; overflow: hidden; background: var(--mc-tint); }
.mc-fm-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; font-size: 7.6pt; color: var(--mc-gray); }
.mc-fm-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
.mc-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.mc-tag { padding: 4px 10px; border-radius: 14px; background: var(--mc-tint); color: var(--mc-blue-deep); font-size: 8pt; font-weight: 600; }
.mc-tag.is-hot { background: var(--mc-blue); color: #fff; }
.mc-tag b { margin-left: 6px; padding: 0 5px; border-radius: 8px; background: rgba(255, 255, 255, 0.25); font-size: 7pt; }
.mc-ideas { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.mc-idea { padding: 9px 11px; border-radius: 10px; background: var(--mc-tint); break-inside: avoid; }
.mc-gg { display: grid; gap: 6px; }
.mc-gg-row { display: grid; grid-template-columns: 150px 48px 1fr 70px; gap: 8px; align-items: center; font-size: 8.3pt; }
.mc-gg-row.is-you .mc-gg-l { color: var(--mc-you); font-weight: 800; }
.mc-gg-l { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mc-gg-star { color: #c98a00; font-weight: 700; }
.mc-gg-bar { height: 10px; border-radius: 5px; background: var(--mc-tint); overflow: hidden; }
.mc-gg-bar div { height: 100%; border-radius: 5px; background: #f2b42c; }
.mc-gg-n { font-size: 7.6pt; color: var(--mc-gray); text-align: right; }
ol.mc-shot-notes li::before { content: counter(note); position: absolute; left: 0; top: 0; width: 18px; height: 18px; border-radius: 50%; background: var(--mc-charcoal); color: #fff; display: grid; place-items: center; font: 700 8pt var(--mc-head); }

View File

@ -46,9 +46,12 @@ function snapshotOf(b) {
return {
date: today(), followers: b.followers_count ?? null, following: b.follows_count ?? null, mediaCount: b.media_count ?? null,
name: b.name || null, bio: (b.biography || "").slice(0, 300),
media: (b.media || []).map((m) => ({ id: m.id, at: m.timestamp, likes: m.like_count ?? null, comments: m.comments_count ?? null, type: m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image", permalink: m.permalink, caption: (m.caption || "").slice(0, 200) })),
media: (b.media || []).map(mediaOf),
};
}
const tagsOf = (caption) => [...new Set((String(caption || "").match(/#[\p{L}\p{N}_]+/gu) || []).map((t) => t.toLowerCase()))].slice(0, 30);
const typeOf = (m) => (m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image");
const mediaOf = (m) => ({ id: m.id, at: m.timestamp, likes: m.like_count ?? null, comments: m.comments_count ?? null, type: typeOf(m), permalink: m.permalink, caption: (m.caption || "").slice(0, 200), tags: tagsOf(m.caption) });
function addSnapshot(entry, snap) {
entry.snapshots = (entry.snapshots || []).filter((s) => s.date !== snap.date);
// Keep full media only on the latest snapshot; older ones keep the counts for the growth chart.
@ -63,7 +66,7 @@ export function listTracked(client) {
return (read()[client]?.accounts || []).map(({ snapshots, ...a }) => ({ ...a, lastChecked: snapshots?.at(-1)?.date || null }));
}
export async function add(client, username, { by = "team", source = "manual", note = "" } = {}) {
export async function add(client, username, { by = "team", source = "manual", note = "", kind = "local", google = null } = {}) {
checkClient(client);
const u = handle(username);
if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name.");
@ -74,7 +77,7 @@ export async function add(client, username, { by = "team", source = "manual", no
const own = clientLink(client);
const b = await lookup(await viewerIgId(client), u);
if (own.igId && String(b.id) === String(own.igId)) throw err("That's the client's own account.");
const entry = { username: u, name: b.name || null, source, note: String(note).slice(0, 300), addedBy: by, addedAt: new Date().toISOString(), snapshots: [] };
const entry = { username: u, name: b.name || null, source, kind: KINDS.includes(kind) ? kind : "local", note: String(note).slice(0, 300), google, addedBy: by, addedAt: new Date().toISOString(), snapshots: [] };
addSnapshot(entry, snapshotOf(b));
c.accounts.push(entry);
write(db);
@ -117,10 +120,12 @@ function stats({ followers, media, history }) {
const recent = media.filter((m) => m.at && now - new Date(m.at) <= 28 * 86400000);
const last12 = media.slice(0, 12);
// Median, not mean: one viral post would otherwise make a typical post look many times better than it is.
const sorted = last12.map(engagement).sort((x, y) => x - y);
const avgEng = sorted.length ? Math.round(sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2) : null;
const mix = {};
for (const m of media.slice(0, 25)) mix[m.type] = (mix[m.type] || 0) + 1;
const avgEng = median(last12.map(engagement));
const mix = {}, byType = {};
for (const m of media.slice(0, 25)) { mix[m.type] = (mix[m.type] || 0) + 1; (byType[m.type] ||= []).push(engagement(m)); }
// Hashtags on the account's 8 best posts: what the rivals that win actually use.
const tags = {};
for (const m of [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 8)) for (const t of m.tags || []) tags[t] = (tags[t] || 0) + 1;
const monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10);
const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null;
return {
@ -131,9 +136,16 @@ function stats({ followers, media, history }) {
avgEngagement: avgEng,
engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null,
formatMix: mix,
engagementByFormat: Object.fromEntries(Object.entries(byType).map(([k, v]) => [k, median(v)])),
topTags: Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 12).map(([t]) => t),
allTags: [...new Set(media.flatMap((m) => m.tags || []))],
topPosts: [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 3),
};
}
function median(xs) {
const s = [...xs].sort((x, y) => x - y);
return s.length ? Math.round(s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : null;
}
/**
* Comparison for a client: their own account (from the dashboard snapshot passed in) plus every tracked competitor.
@ -144,15 +156,82 @@ export function compare(client, own) {
const accounts = (read()[client]?.accounts || []).map((a) => {
const latest = a.snapshots?.at(-1) || {};
return {
username: a.username, name: a.name, source: a.source, note: a.note, addedBy: a.addedBy, addedAt: a.addedAt, lastChecked: latest.date || null,
username: a.username, name: a.name, source: a.source, kind: a.kind || "local", google: a.google || null, note: a.note, addedBy: a.addedBy, addedAt: a.addedAt, lastChecked: latest.date || null,
...stats({ followers: latest.followers ?? null, media: latest.media || [], history: a.snapshots || [] }),
series: (a.snapshots || []).filter((s) => s.followers != null).map((s) => ({ date: s.date, followers: s.followers })),
};
});
let you = null;
if (own) {
const media = (own.posts || []).map((p) => ({ id: p.id, at: p.timestamp, likes: p.like_count ?? null, comments: p.comments_count ?? null, type: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image", permalink: p.permalink, caption: (p.caption || "").slice(0, 200) }));
const media = (own.posts || []).map(mediaOf);
you = { username: own.username, own: true, ...stats({ followers: own.followers ?? null, media, history: own.history || [] }), series: (own.history || []).filter((h) => h.followers != null) };
}
return { you, competitors: accounts };
}
// ---------- AI suggestions: accounts a run found; a person adds or dismisses each one ----------
const KINDS = ["local", "benchmark"];
const clip = (s, n) => String(s || "").slice(0, n);
const suggestionView = ({ snapshot, ...s }) => ({ ...s, ...stats({ followers: snapshot?.followers ?? null, media: snapshot?.media || [], history: [] }), bio: snapshot?.bio || "" });
/**
* Check each candidate on Instagram and store the real ones as suggestions.
* list: [{ username, kind: "local" | "benchmark", why, google: { rating, reviews } }]
* Returns the verified numbers (so the report quotes Instagram, not guesses) and why the rest were skipped.
*/
export async function suggest(client, list, { by = "agent" } = {}) {
checkClient(client);
const igId = await viewerIgId(client);
const own = clientLink(client);
const known = read()[client] || {};
const checked = [], skipped = [];
for (const s of (list || []).slice(0, 15)) {
const u = handle(s.username);
if (!/^[a-z0-9._]{1,30}$/.test(u)) { skipped.push({ username: s.username, reason: "not an Instagram handle" }); continue; }
if (known.accounts?.some((a) => a.username === u)) { skipped.push({ username: u, reason: "already tracked" }); continue; }
if (known.suggestions?.some((x) => x.username === u && x.status === "dismissed")) { skipped.push({ username: u, reason: "the team dismissed it before" }); continue; }
try {
const b = await lookup(igId, u);
if (own.igId && String(b.id) === String(own.igId)) { skipped.push({ username: u, reason: "the client's own account" }); continue; }
const g = s.google && Number.isFinite(+s.google.rating) ? { rating: +s.google.rating, reviews: Number.isFinite(+s.google.reviews) ? +s.google.reviews : null } : null;
checked.push({ username: u, name: b.name || null, kind: KINDS.includes(s.kind) ? s.kind : "local", why: clip(s.why, 300), google: g, by, at: new Date().toISOString(), status: "new", snapshot: snapshotOf(b) });
} catch (e) {
skipped.push({ username: u, reason: e.message });
}
}
// Re-read before writing: lookups take a while and the nightly refresh may have saved in between.
const db = read();
const c = (db[client] ||= { accounts: [] });
c.suggestions = [...(c.suggestions || []).filter((x) => !checked.some((n) => n.username === x.username)), ...checked];
write(db);
return { checked: checked.map(suggestionView).map(({ allTags, ...v }) => v), skipped };
}
export function listSuggestions(client) {
checkClient(client);
return (read()[client]?.suggestions || []).filter((s) => s.status === "new").map(suggestionView);
}
function setSuggestion(client, username, status) {
const db = read(), u = handle(username);
const s = db[client]?.suggestions?.find((x) => x.username === u);
if (!s) throw err("Suggestion not found.", 404);
s.status = status;
write(db);
return s;
}
export function dismissSuggestion(client, username) {
checkClient(client);
setSuggestion(client, username, "dismissed");
return { ok: true };
}
export async function acceptSuggestion(client, username, { by = "team" } = {}) {
checkClient(client);
const s = (read()[client]?.suggestions || []).find((x) => x.username === handle(username));
if (!s) throw err("Suggestion not found.", 404);
const added = await add(client, s.username, { by, source: "suggested", note: s.why, kind: s.kind, google: s.google });
setSuggestion(client, s.username, "added");
return added;
}

View File

@ -215,6 +215,10 @@ server.tool("competitor_add", "Start tracking an Instagram competitor for a clie
{ client: z.string(), username: z.string(), note: z.string().default("") },
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
server.tool("competitor_suggest", "Suggest Instagram competitors for a client. Each handle is checked on Instagram (public Business/Creator only) and saved as a suggestion the team adds or dismisses on the Competitors page; nothing is tracked until a person adds it. Returns the real numbers for each (followers, posts/week, median engagement, engagement rate, format mix, engagement by format, top hashtags, top posts) and why any were skipped. kind: local = a business the client competes with for the same customers; benchmark = same niche elsewhere, worth learning from.",
{ client: z.string(), accounts: z.array(z.object({ username: z.string(), kind: z.enum(["local", "benchmark"]), why: z.string(), google: z.object({ rating: z.number(), reviews: z.number().optional() }).optional() })).max(15) },
wrap(async ({ client, accounts }) => competitors.suggest(client, accounts, { by: "agent" })));
server.tool("brand_kit", "A client's saved hashtag sets, approved phrases/CTAs and words to avoid. Read it before writing captions for that client.",
{ client: z.string() }, wrap(async ({ client }) => brandkit.get(client)));

View File

@ -61,6 +61,7 @@ export function listDocs(slug) {
for (const f of fs.readdirSync(dir)) {
if (/^audit-.*\.md$/.test(f)) add(f, "audit");
else if (/^profile-check-.*\.md$/.test(f)) add(f, "profile-check");
else if (/^competitors-.*\.md$/.test(f)) add(f, "competitors");
}
for (const [kind, sub] of Object.entries(DOC_DIRS)) {
const d = path.join(dir, sub);

View File

@ -493,6 +493,9 @@ function ownForCompare(slug) {
app.get("/api/competitors", h(async (req) => { const c = String(req.query.client || ""); clients.safeSlug(c); return competitors.compare(c, ownForCompare(c)); }));
app.post("/api/competitors", h(async (req) => ({ added: await competitors.add(String(req.body?.client || ""), req.body?.username, { by: who(req), note: req.body?.note || "" }) })));
app.delete("/api/competitors/:client/:username", h(async (req) => { competitors.remove(req.params.client, req.params.username); return { ok: true }; }));
app.get("/api/competitors/:client/suggestions", h(async (req) => ({ suggestions: competitors.listSuggestions(req.params.client) })));
app.post("/api/competitors/:client/suggestions/:username/add", h(async (req) => ({ added: await competitors.acceptSuggestion(req.params.client, req.params.username, { by: who(req) }) })));
app.post("/api/competitors/:client/suggestions/:username/dismiss", h(async (req) => competitors.dismissSuggestion(req.params.client, req.params.username)));
app.post("/api/competitors/refresh", h(async (req) => competitors.refresh(String(req.body?.client || "") || undefined)));
// ---------- published posts and their numbers ----------

View File

@ -24,6 +24,7 @@ export const JOB_TYPES = {
Plan all of ${month}, not just next week: every week that starts in ${month}, one calendar file per week (clients/${c.slug}/calendar/YYYY-Www.md). Check content_plan first so nothing is doubled, and add every planned post to the dashboard Calendar with plan_add (status idea or brief).`;
} },
"weekly-report": { label: "Weekly report", needsClient: true, prompt: (c) => `/weekly-report ${c.name}` },
"competitor-scan": { label: "Find & compare competitors", needsClient: true, prompt: (c) => `/competitor-scan ${c.name}` },
"assign-tasks": { label: "Assign this week's tasks", needsClient: false, prompt: () => `/assign-tasks` },
custom: { label: "Custom request", needsClient: false, prompt: (c, text) => (c ? `Client: ${c.name} (clients/${c.slug}/).\n\n${text}` : text) },
};
@ -40,7 +41,7 @@ const READ_TOOLS = [
"Bash(node tools/report-pdf/build.mjs:*)",
"mcp__social-manager__list_accounts", "mcp__social-manager__instagram_insights", "mcp__social-manager__instagram_recent_posts",
"mcp__social-manager__instagram_comments", "mcp__social-manager__facebook_page_insights", "mcp__social-manager__facebook_recent_posts",
"mcp__social-manager__audience_demographics", "mcp__social-manager__instagram_mentions", "mcp__social-manager__competitor_lookup", "mcp__social-manager__competitors",
"mcp__social-manager__audience_demographics", "mcp__social-manager__instagram_mentions", "mcp__social-manager__competitor_lookup", "mcp__social-manager__competitors", "mcp__social-manager__competitor_suggest",
"mcp__social-manager__list_connections", "mcp__social-manager__suggested_post_times", "mcp__social-manager__content_library", "mcp__social-manager__brand_kit",
"mcp__social-manager__caption_check", "mcp__social-manager__content_plan",
// Calendar items are plans for the team (idea → brief → …), never posts: nothing is scheduled or published from a run.
@ -173,7 +174,7 @@ async function run(job) {
const before = docStamp(job.slug);
log(job, "info", `Started: ${job.prompt.split("\n")[0]}`);
// Report charts are drawn from the stored numbers, so bring them up to date first.
if (job.slug && /^\/(account-audit|weekly-report)/.test(job.prompt)) {
if (job.slug && /^\/(account-audit|weekly-report|competitor-scan)/.test(job.prompt)) {
try { await refreshSnapshot(job.slug); log(job, "info", "Refreshed the account numbers for the charts."); }
catch (e) { log(job, "warn", `Couldn't refresh account numbers (${e.message}); charts use the last stored ones.`); }
}
@ -223,7 +224,7 @@ async function run(job) {
if (job.slug) {
const changed = listDocs(job.slug).filter((d) => d.kind !== "setup" && before.get(d.path) !== d.updatedAt);
job.files = changed.map((d) => d.path);
for (const d of changed.filter((x) => (x.kind === "audit" || x.kind === "report") && (!x.pdf || x.pdf.stale))) {
for (const d of changed.filter((x) => (x.kind === "audit" || x.kind === "report" || x.kind === "competitors") && (!x.pdf || x.pdf.stale))) {
try {
toPdf(path.join(safeSlug(job.slug), d.path));
log(job, "info", `PDF exported: ${d.path.replace(/\.md$/, ".pdf")}`);

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Bar, BarChart, CartesianGrid, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { ExternalLink, Plus, RefreshCw, Swords, Trash2 } from "lucide-react";
import { Check, ExternalLink, Plus, RefreshCw, Sparkles, Swords, Trash2, X } from "lucide-react";
import { api } from "../api.js";
import { Badge, Button, cx, Empty, ErrorNote, Field, fmt, fmtDate, Panel, Spinner, timeAgo } from "../ui.jsx";
@ -65,6 +65,39 @@ function AddDialog({ client, onClose, onAdded }) {
);
}
/** Accounts an AI run found and checked on Instagram; nothing is tracked until a person adds it. */
function Suggestions({ items, busy, onAdd, onDismiss, full }) {
if (!items?.length) return null;
return (
<Panel title={`Suggested by the competitor scan (${items.length})`} subtitle="Checked on Instagram. Add the ones that really compete for this client's customers; dismissed accounts aren't suggested again.">
<ul className="grid md:grid-cols-2 xl:grid-cols-3 gap-3">
{items.map((s) => (
<li key={s.username} className="rounded-lg border border-line p-3.5 flex flex-col gap-2 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<a href={`https://www.instagram.com/${s.username}/`} target="_blank" rel="noreferrer" className="font-semibold text-ink hover:underline">@{s.username}</a>
{s.name && <div className="text-[12px] text-muted truncate">{s.name}</div>}
</div>
<Badge tone={s.kind === "benchmark" ? "neutral" : "brand"}>{s.kind === "benchmark" ? "Benchmark" : "Local rival"}</Badge>
</div>
{s.why && <p className="text-[12.5px] text-ink-2 leading-relaxed">{s.why}</p>}
<dl className="grid grid-cols-3 gap-2 text-[12px]">
<div><dt className="text-muted">Followers</dt><dd className="num font-semibold">{fmt(s.followers)}</dd></div>
<div><dt className="text-muted">Posts / wk</dt><dd className="num font-semibold">{s.postsPerWeek ?? "—"}</dd></div>
<div><dt className="text-muted">Engagement</dt><dd className="num font-semibold">{s.engagementRate == null ? "—" : `${s.engagementRate}%`}</dd></div>
</dl>
{s.google?.rating && <div className="text-[12px] text-muted">Google ★ {s.google.rating}{s.google.reviews != null ? ` · ${fmt(s.google.reviews)} reviews` : ""}</div>}
<div className="flex gap-2 mt-auto pt-1">
<Button size="sm" variant="primary" icon={Check} loading={busy === `add-${s.username}`} disabled={full} title={full ? "Tracking is full (10). Remove one first." : undefined} onClick={() => onAdd(s.username)}>Track</Button>
<Button size="sm" icon={X} loading={busy === `dis-${s.username}`} onClick={() => onDismiss(s.username)}>Dismiss</Button>
</div>
</li>
))}
</ul>
</Panel>
);
}
export default function Competitors() {
const [params, setParams] = useSearchParams();
const [clients, setClients] = useState(null);
@ -76,8 +109,16 @@ export default function Competitors() {
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, []);
useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
const load = useCallback(() => (client ? api(`/api/competitors?client=${encodeURIComponent(client)}`).then(setData).catch(setError) : Promise.resolve()), [client]);
useEffect(() => { setData(null); setError(null); load(); }, [load]);
const [suggestions, setSuggestions] = useState([]);
const navigate = useNavigate();
const load = useCallback(() => (client ? Promise.all([
api(`/api/competitors?client=${encodeURIComponent(client)}`).then(setData),
api(`/api/competitors/${encodeURIComponent(client)}/suggestions`).then((r) => setSuggestions(r.suggestions)),
]).catch(setError) : Promise.resolve()), [client]);
useEffect(() => { setData(null); setSuggestions([]); setError(null); load(); }, [load]);
const scan = () => run("scan", async () => { const r = await api("/api/jobs", { method: "POST", body: { type: "competitor-scan", slug: client } }); navigate(`/runs/${r.job.id}`); });
const accept = (u) => run(`add-${u}`, () => api(`/api/competitors/${client}/suggestions/${encodeURIComponent(u)}/add`, { method: "POST" }));
const dismiss = (u) => run(`dis-${u}`, () => api(`/api/competitors/${client}/suggestions/${encodeURIComponent(u)}/dismiss`, { method: "POST" }));
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); await load(); } catch (e) { setError(e); } setBusy(null); };
const refresh = () => run("refresh", async () => { const r = await api("/api/competitors/refresh", { method: "POST", body: { client } }); if (r.errors?.length) throw new Error(r.errors.join(" · ")); });
@ -101,16 +142,19 @@ export default function Competitors() {
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
{data?.competitors.length > 0 && <Button icon={RefreshCw} loading={busy === "refresh"} onClick={refresh}>Refresh</Button>}
<Button icon={Sparkles} loading={busy === "scan"} onClick={scan} title="AI run: searches the market, checks each account on Instagram and writes a competitor report">Find competitors</Button>
<Button variant="primary" icon={Plus} onClick={() => setAdding(true)} disabled={data?.competitors.length >= 10}>Add competitor</Button>
</div>
</header>
<ErrorNote error={error} />
<Suggestions items={suggestions} busy={busy} onAdd={accept} onDismiss={dismiss} full={data?.competitors.length >= 10} />
{!data ? (!error && <Spinner />) : !data.competitors.length ? (
<div className="panel">
<Empty icon={Swords} title="No competitors tracked yet" action={<Button variant="primary" icon={Plus} onClick={() => setAdding(true)}>Add competitor</Button>}>
Add 3–5 accounts this client actually competes with: same area, same customers. Quorla then tracks their followers, posting rhythm, engagement and best posts every night.
<Empty icon={Swords} title="No competitors tracked yet" action={<div className="flex gap-2 justify-center"><Button variant="primary" icon={Sparkles} loading={busy === "scan"} onClick={scan}>Find competitors</Button><Button icon={Plus} onClick={() => setAdding(true)}>Add by hand</Button></div>}>
Let the AI search this client's market, check every account on Instagram and write a competitor report, or add accounts you already know. Quorla then tracks their followers, posting rhythm, engagement and best posts every night.
</Empty>
</div>
) : (

View File

@ -10,6 +10,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "
import { dirname, extname, join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { Marked } from "marked";
import { compare, listSuggestions } from "../../connector/src/competitors.js";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const DATA_DIR = process.env.QUORLA_DASHBOARD_DATA || join(root, "data", "dashboard");
@ -17,7 +18,9 @@ const DAY = 86400000;
const C = { blue: "#3a7bbf", sky: "#52ace0", ink: "#232327", gray: "#8a8f99", line: "#e3e7ee", good: "#2e9e6b", warn: "#d99a1e", bad: "#d0543f" };
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const num = (n) => (n == null ? "—" : n >= 10000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : Math.round(n).toLocaleString("en-CA"));
const num = (n) => (n == null ? "—" : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 10000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : Math.round(n).toLocaleString("en-CA"));
const rate = (r) => (r.avgEngagement != null && r.followers ? (r.avgEngagement / r.followers) * 100 : null); // unrounded, for big accounts
const rateText = (v) => (v == null ? "—" : `${v >= 1 ? v.toFixed(1) : Number(v.toPrecision(2))}%`);
const pct = (a, b) => (b ? Math.round((a / b) * 100) : null);
const day = (d) => new Date(d).toLocaleDateString("en-CA", { month: "short", day: "numeric", timeZone: "UTC" });
const plain = (s) => String(s || "").normalize("NFKC").replace(/\s+/g, " ").trim(); // 𝐁𝐨𝐥𝐝 unicode → Bold
@ -48,8 +51,28 @@ function freshData(slug) {
saves: p.insights?.saved ?? 0, shares: p.insights?.shares ?? 0,
})),
};
const competition = competitionData(slug, snap);
const fb = snap.fb && { name: snap.fb.page?.name, followers: snap.fb.page?.followers_count, totals: snap.fb.totals || {}, posts: (snap.fb.posts || []).map((p) => ({ at: p.created_time })) };
return { slug, asOf: snap.fetchedAt, ig, fb };
return { slug, asOf: snap.fetchedAt, ig, fb, competition };
}
/** The client plus tracked and suggested competitors, with the numbers the rival blocks need. */
function competitionData(slug, snap) {
try {
const history = (readFileSync(join(DATA_DIR, "history", `${slug}.jsonl`), "utf8").trim().split("\n").filter(Boolean)).map((l) => JSON.parse(l));
const own = snap.ig && { username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count ?? null, posts: snap.ig.posts || [], history: history.map((h) => ({ date: h.date, followers: h.igFollowers })) };
const { you, competitors } = compare(slug, own);
const keep = (a, role) => ({
role, username: a.username, name: a.name || null, kind: a.kind || "local", why: a.why || a.note || "", google: a.google || null,
followers: a.followers, postsPerWeek: a.postsPerWeek, avgEngagement: a.avgEngagement, engagementRate: a.engagementRate, growth30: a.growth30 ?? null,
formatMix: a.formatMix || {}, engagementByFormat: a.engagementByFormat || {}, topTags: a.topTags || [], allTags: a.allTags || [],
topPosts: (a.topPosts || []).map((p) => ({ at: p.at, type: p.type, likes: p.likes, comments: p.comments, link: p.permalink, caption: plain(p.caption).slice(0, 140) })),
});
const rows = [...(you ? [keep(you, "you")] : []), ...competitors.map((c) => keep(c, "tracked")), ...listSuggestions(slug).map((s) => keep(s, "suggested"))];
return rows.length > 1 ? rows : null;
} catch {
return null;
}
}
export function reportData(mdPath) {
@ -192,8 +215,114 @@ const BLOCKS = {
<div class="mc-post-nums"><b>${num(p.reach)}</b> reached · ${num(p.saves + p.shares)} saved or shared${p.reach ? ` (${((p.saves + p.shares) / p.reach * 100).toFixed(1)} per 100)` : ""}</div></li>`;
return card("mc-wide", "Best and weakest posts", `last ${posts.length} posts, by people reached`, `<div class="mc-posts"><div><h4 class="is-good">Best ${n}</h4><ol>${sorted.slice(0, n).map(item).join("")}</ol></div><div><h4 class="is-bad">Weakest ${n}</h4><ol>${sorted.slice(-n).reverse().map(item).join("")}</ol></div></div>`);
},
// ---------- competitors ----------
rivals(d) {
const rows = ranked(d);
if (!rows) return noData("Competitors");
const max = Math.max(...rows.map((r) => r.engagementRate || 0), 0.01);
const youAt = rows.findIndex((r) => r.role === "you");
const body = `<table class="mc-lb"><thead><tr><th>#</th><th>Account</th><th>Followers</th><th>Posts / week</th><th>Typical post</th><th>Engagement rate</th></tr></thead><tbody>${rows.map((r, i) => `
<tr class="${r.role === "you" ? "is-you" : ""}"><td class="mc-lb-rank">${i + 1}</td>
<td><div class="mc-lb-name">${esc(r.name || `@${r.username}`)} ${tag(r)}</div><div class="mc-lb-handle">@${esc(r.username)}</div></td>
<td class="num">${num(r.followers)}${r.growth30 != null ? `<div class="mc-lb-sub ${r.growth30 < 0 ? "is-bad" : "is-good"}">${r.growth30 >= 0 ? "▲" : "▼"} ${num(Math.abs(r.growth30))} / 30d</div>` : ""}</td>
<td class="num">${r.postsPerWeek ?? "—"}</td>
<td class="num">${num(r.avgEngagement)}<div class="mc-lb-sub">likes + comments</div></td>
<td><div class="mc-lb-bar"><div style="width:${(((r.engagementRate || 0) / max) * 100).toFixed(1)}%"></div><span>${rateText(r.engagementRate)}</span></div></td></tr>`).join("")}</tbody></table>`;
const foot = youAt >= 0 ? `You rank <b>${youAt + 1} of ${rows.length}</b> on engagement rate: likes and comments on a typical post, divided by followers. It's the fairest way to compare a small account with a big one.` : "";
return card("mc-wide", "Who's winning on Instagram", "ranked by engagement rate", body, foot);
},
"rivals-map"(d) {
const rows = (ranked(d) || []).filter((r) => r.followers > 0 && r.engagementRate != null);
if (rows.length < 3) return noData("Competitor map");
const W = 640, H = 300, L = 44, R = 16, T = 16, B = 34;
const lx = rows.map((r) => Math.log10(r.followers)), ys = rows.map((r) => r.engagementRate);
const x0 = Math.floor(Math.min(...lx) * 2) / 2, x1 = Math.ceil(Math.max(...lx) * 2) / 2 || x0 + 1, y1 = Math.max(...ys) * 1.15 || 1;
const X = (r) => L + ((Math.log10(r.followers) - x0) / (x1 - x0 || 1)) * (W - L - R), Y = (v) => T + (H - T - B) * (1 - v / y1);
const med = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
const mx = L + ((med(lx) - x0) / (x1 - x0 || 1)) * (W - L - R), my = Y(med(ys));
const ticks = []; for (let t = Math.ceil(x0); t <= x1; t++) ticks.push(t);
const axis = ticks.map((t) => { const x = L + ((t - x0) / (x1 - x0 || 1)) * (W - L - R); return `<line x1="${x}" x2="${x}" y1="${T}" y2="${H - B}" stroke="${C.line}"/><text x="${x}" y="${H - B + 14}" text-anchor="middle" class="mc-ax">${num(10 ** t)}</text>`; }).join("");
const quad = (x, y, anchor, text) => `<text x="${x}" y="${y}" text-anchor="${anchor}" class="mc-quad">${text}</text>`;
const placed = []; // label positions, nudged down when two would overlap
const labelY = (x, y) => { while (placed.some((p) => Math.abs(p.x - x) < 90 && Math.abs(p.y - y) < 10)) y += 10; placed.push({ x, y }); return y; };
const dots = rows.map((r) => {
const rad = 5 + Math.min(10, (r.postsPerWeek || 0) * 1.6), you = r.role === "you";
const fill = you ? "#fc6d45" : r.kind === "benchmark" ? C.sky : C.blue;
const left = X(r) > W * 0.72; // label on the left near the right edge
return `<g><circle cx="${X(r).toFixed(1)}" cy="${Y(r.engagementRate).toFixed(1)}" r="${rad.toFixed(1)}" fill="${fill}" fill-opacity="${you ? 0.95 : 0.55}" stroke="#fff" stroke-width="1.5"/>
<text x="${(X(r) + (left ? -(rad + 3) : rad + 3)).toFixed(1)}" y="${labelY(X(r), Y(r.engagementRate) + 3).toFixed(1)}" text-anchor="${left ? "end" : "start"}" class="mc-dot-l${you ? " is-you" : ""}">${you ? "You" : `@${esc(r.username.slice(0, 18))}`}</text></g>`;
}).join("");
const svg = `<svg viewBox="0 0 ${W} ${H}" class="mc-chart" role="img" aria-label="Followers against engagement rate for the client and competitors">
${axis}<line x1="${mx}" x2="${mx}" y1="${T}" y2="${H - B}" stroke="${C.gray}" stroke-dasharray="3 4"/><line x1="${L}" x2="${W - R}" y1="${my}" y2="${my}" stroke="${C.gray}" stroke-dasharray="3 4"/>
${quad(L + 6, T + 10, "start", "Small but loved")}${quad(W - R - 4, T + 10, "end", "Big and engaged")}${quad(L + 6, H - B - 6, "start", "Small and quiet")}${quad(W - R - 4, H - B - 6, "end", "Big but quiet")}
${dots}<text x="${(L + W - R) / 2}" y="${H - 3}" text-anchor="middle" class="mc-ax">Followers →</text><text x="12" y="${(T + H - B) / 2}" transform="rotate(-90 12 ${(T + H - B) / 2})" text-anchor="middle" class="mc-ax">Engagement rate →</text></svg>`;
return card("mc-wide", "Size vs engagement", `bigger dot = posts more often · <span style="color:#fc6d45">●</span> you <span style="color:${C.blue}">●</span> local <span style="color:${C.sky}">●</span> benchmark`, svg,
"Accounts top-left get more attention per follower than their size suggests: their content is what to learn from.");
},
"rivals-formats"(d) {
const rows = ranked(d);
if (!rows) return noData("Formats");
const kinds = ["Reel", "Carousel", "Image", "Video"], col = { Reel: C.blue, Carousel: C.sky, Image: "#b9c3d1", Video: "#7a86c2" };
// Each format against the account's own typical post, so a 2M-follower brand doesn't drown out a local shop.
const market = {};
rows.filter((r) => r.role !== "you" && r.avgEngagement).forEach((r) => Object.entries(r.engagementByFormat).forEach(([k, v]) => (market[k] ||= []).push(v / r.avgEngagement)));
const med = (xs) => (xs?.length ? [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] : null);
const you = rows.find((r) => r.role === "you");
const best = kinds.filter((k) => market[k]?.length).sort((a, b) => med(market[b]) - med(market[a]))[0];
const lines = rows.map((r) => {
const total = Object.values(r.formatMix).reduce((a, b) => a + b, 0) || 1;
return `<div class="mc-fm-row${r.role === "you" ? " is-you" : ""}"><span class="mc-fm-l">${r.role === "you" ? "You" : `@${esc(r.username.slice(0, 20))}`}</span><div class="mc-fm-bar">${kinds.filter((k) => r.formatMix[k]).map((k) => `<div style="width:${((r.formatMix[k] / total) * 100).toFixed(1)}%;background:${col[k]}" title="${k}"></div>`).join("")}</div></div>`;
}).join("");
const legend = kinds.map((k) => `<span><i style="background:${col[k]}"></i>${k}s${market[k]?.length ? ` · ${med(market[k]).toFixed(1)}× a typical post` : ""}</span>`).join("");
const foot = best && you ? `For your competitors, ${best.toLowerCase()}s do ${med(market[best]).toFixed(1)}× better than their typical post. ${you.formatMix[best] ? `They're ${Math.round((you.formatMix[best] / (Object.values(you.formatMix).reduce((a, b) => a + b, 0) || 1)) * 100)}% of what you post.` : "You don't post any yet."}` : "";
return card("mc-wide", "What everyone posts, and what earns engagement", "last 25 posts per account", `<div class="mc-fm">${lines}</div><div class="mc-fm-legend">${legend}</div>`, foot);
},
"rivals-tags"(d) {
const rows = ranked(d);
if (!rows) return noData("Hashtags");
const you = rows.find((r) => r.role === "you"), mine = new Set(you?.allTags || []);
const count = {};
rows.filter((r) => r.role !== "you").slice(0, 8).forEach((r) => r.topTags.forEach((t) => (count[t] = (count[t] || 0) + 1)));
const tags = Object.entries(count).filter(([t]) => !mine.has(t)).sort((a, b) => b[1] - a[1]).slice(0, 18);
if (!tags.length) return noData("Hashtags");
return card("", "Hashtags on the best posts in your market that you don't use", "from rivals' highest-engagement posts",
`<div class="mc-tags">${tags.map(([t, n]) => `<span class="mc-tag${n > 1 ? " is-hot" : ""}">${esc(t)}${n > 1 ? `<b>${n}</b>` : ""}</span>`).join("")}</div>`,
"A number means several competitors use it on their best posts. Use the ones that describe your area and service; skip anything that isn't you.");
},
"rivals-posts"(d) {
const rows = (ranked(d) || []).filter((r) => r.role !== "you" && r.followers);
const posts = rows.flatMap((r) => r.topPosts.map((p) => ({ ...p, r, eng: (p.likes || 0) + (p.comments || 0), rate: (((p.likes || 0) + (p.comments || 0)) / r.followers) * 100 })));
if (!posts.length) return noData("Competitor posts");
const top = posts.sort((a, b) => b.rate - a.rate).slice(0, 6);
return card("mc-wide", "Best posts in your market", "ranked by engagement for the account's size",
`<div class="mc-ideas">${top.map((p) => `<div class="mc-idea"><div class="mc-post-meta"><span class="mc-chip">${p.type}</span>@${esc(p.r.username)} · ${day(p.at)}${p.link ? ` · <a href="${esc(p.link)}">open</a>` : ""}</div>
<div class="mc-post-cap">${esc(p.caption.slice(0, 110) || "(no caption)")}${p.caption.length > 110 ? "…" : ""}</div>
<div class="mc-post-nums"><b>${num(p.eng)}</b> likes + comments · ${p.rate.toFixed(1)}% of their followers</div></div>`).join("")}</div>`);
},
"rivals-google"(d) {
const rows = (d?.competition || []).filter((r) => r.google?.rating);
if (!rows.length) return noData("Google ratings");
const sorted = [...rows].sort((a, b) => (b.google.reviews || 0) - (a.google.reviews || 0)), maxRev = Math.max(...rows.map((r) => r.google.reviews || 0), 1);
return card("", "Google rating and reviews", "where local customers often decide",
`<div class="mc-gg">${sorted.map((r) => `<div class="mc-gg-row${r.role === "you" ? " is-you" : ""}"><span class="mc-gg-l">${r.role === "you" ? "You" : esc(r.name || `@${r.username}`)}</span>
<span class="mc-gg-star">★ ${r.google.rating.toFixed(1)}</span><div class="mc-gg-bar"><div style="width:${(((r.google.reviews || 0) / maxRev) * 100).toFixed(1)}%"></div></div><span class="mc-gg-n">${num(r.google.reviews)} reviews</span></div>`).join("")}</div>`);
},
};
/** Client + competitors sorted by engagement rate (highest first); null without competitors. */
function ranked(d) {
const rows = d?.competition;
if (!rows || rows.length < 2) return null;
return rows.map((r) => ({ ...r, engagementRate: rate(r) ?? r.engagementRate })).sort((a, b) => (b.engagementRate ?? -1) - (a.engagementRate ?? -1));
}
const tag = (r) => (r.role === "you" ? `<span class="mc-chip is-you">You</span>` : `<span class="mc-chip${r.kind === "benchmark" ? " is-bench" : ""}">${r.kind === "benchmark" ? "Benchmark" : "Local"}</span>${r.role === "suggested" ? `<span class="mc-chip is-new">New</span>` : ""}`);
// ---------- written blocks ----------
const rowsOf = (body) => body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => l.replace(/^[-*]\s+/, "").split("|").map((s) => s.trim()));