metatroncubeswdev f86bd9ce33 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>
2026-10-27 13:44:37 -04:00

398 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Visual report blocks: the AI writes small fenced markers, this file draws them as inline SVG/HTML.
// Charts are drawn from the stored account numbers (never from figures typed by the AI) and frozen
// next to the report as <report>.data.json the first time it renders, so a re-export shows the same numbers.
//
// Data blocks: ```mc:kpis ```mc:reach ```mc:funnel ```mc:followers ```mc:formats ```mc:rhythm ```mc:top-posts
// Written blocks: ```mc:score (Area | 6/10 | why) ```mc:actions (Action | Owner | When)
// ```mc:screenshot (first line: shot id, then one note per line)
// Callouts: > [!WIN] title / [!FIX] / [!RISK] / [!NOTE]
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
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");
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 >= 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
const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
let uid = 0;
// ---------- data ----------
function slugFor(mdPath) {
const parts = resolve(mdPath).split(sep);
const i = parts.lastIndexOf("clients");
return i >= 0 ? parts[i + 1] : null;
}
function freshData(slug) {
const snap = readJson(join(DATA_DIR, "snapshots", `${slug}.json`));
if (!snap) return null;
const typeOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : "Image");
const ig = snap.ig && {
username: snap.ig.profile?.username,
followers: snap.ig.profile?.followers_count,
totals: snap.ig.totals || {},
dailyReach: snap.ig.dailyReach || [],
dailyFollowers: snap.ig.dailyFollowers || [],
derived: snap.ig.derived || {},
posts: (snap.ig.posts || []).map((p) => ({
at: p.timestamp, type: typeOf(p), caption: plain(p.caption).slice(0, 140), link: p.permalink,
reach: p.insights?.reach ?? null, views: p.insights?.views ?? null, likes: p.like_count ?? 0, comments: p.comments_count ?? 0,
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, 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) {
const frozen = resolve(mdPath).replace(/\.md$/i, ".data.json");
if (existsSync(frozen)) return readJson(frozen);
const slug = slugFor(mdPath);
const data = slug && freshData(slug);
if (data) try { writeFileSync(frozen, JSON.stringify(data)); } catch { /* read-only folder: render live */ }
return data;
}
// ---------- small SVG pieces ----------
function spark(values, color = C.blue, w = 96, h = 26) {
if (values.length < 2) return "";
const max = Math.max(...values, 1), step = w / (values.length - 1);
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(h - 2 - (v / max) * (h - 4)).toFixed(1)}`).join(" ");
return `<svg class="mc-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>`;
}
function ring(score, size = 92) {
const r = size / 2 - 7, c = 2 * Math.PI * r, f = Math.max(0, Math.min(1, score / 10));
const col = score < 4 ? C.bad : score < 7 ? C.warn : C.good;
return `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">
<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${C.line}" stroke-width="8"/>
<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round" stroke-dasharray="${(c * f).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${size / 2} ${size / 2})"/>
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="middle" class="mc-ring-num">${score.toFixed(1)}</text></svg>`;
}
const card = (cls, title, sub, body, foot = "") =>
`<figure class="mc-card ${cls}">${title ? `<figcaption><span class="mc-card-title">${esc(title)}</span>${sub ? `<span class="mc-card-sub">${sub}</span>` : ""}</figcaption>` : ""}${body}${foot ? `<p class="mc-card-foot">${foot}</p>` : ""}</figure>`;
const noData = (what) => `<p class="mc-nodata">${esc(what)}: no account data stored yet.</p>`;
// ---------- data blocks ----------
const BLOCKS = {
kpis(d) {
const ig = d?.ig;
if (!ig) return noData("Key numbers");
const t = ig.totals, net = (t.new_follows ?? 0) - (t.unfollows ?? 0);
const reachRatio = pct(t.reach, ig.followers);
const tiles = [
{ v: num(ig.followers), l: "Instagram followers", n: t.new_follows != null ? `${net >= 0 ? "▲" : "▼"} ${num(Math.abs(net))} net this month` : "", tone: net < 0 ? "bad" : "good" },
{ v: num(t.reach), l: "People reached", n: reachRatio != null ? `${reachRatio}% of followers` : "", tone: reachRatio != null && reachRatio < 20 ? "bad" : "", s: ig.dailyReach.map((x) => x.value) },
{ v: num(t.total_interactions), l: "Likes, comments, saves, shares", n: t.reach ? `${pct(t.total_interactions, t.reach)} per 100 reached` : "" },
{ v: num(ig.derived.postsLast28), l: "Posts in 28 days", n: ig.derived.daysSinceLastPost != null ? `last post ${ig.derived.daysSinceLastPost} days ago` : "" },
{ v: num(t.profile_links_taps), l: "Taps on the website link", n: t.profile_links_taps ? "" : "nobody reached the website", tone: t.profile_links_taps ? "" : "bad" },
...(d.fb ? [{ v: num(d.fb.followers), l: "Facebook followers", n: d.fb.totals.page_total_media_view_unique != null ? `${num(d.fb.totals.page_total_media_view_unique)} people saw posts` : "" }] : []),
];
return `<section class="mc-kpigrid">${tiles.map((k) => `<div class="mc-kpi2 ${k.tone ? `is-${k.tone}` : ""}"><div class="mc-kpi2-top"><span class="mc-kpi2-v">${esc(k.v)}</span>${k.s ? spark(k.s) : ""}</div><div class="mc-kpi2-l">${esc(k.l)}</div>${k.n ? `<div class="mc-kpi2-n">${esc(k.n)}</div>` : ""}</div>`).join("")}</section>`;
},
reach(d) {
const s = d?.ig?.dailyReach || [];
if (s.length < 2) return noData("Daily reach");
const W = 640, H = 180, L = 34, R = 10, T = 12, B = 34, id = `g${++uid}`;
const max = Math.max(...s.map((x) => x.value), 1), yMax = Math.ceil(max / 5) * 5 || 5;
const x = (i) => L + (i * (W - L - R)) / (s.length - 1), y = (v) => T + (H - T - B) * (1 - v / yMax);
const line = s.map((p, i) => `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(" ");
const grid = [0, 0.5, 1].map((f) => `<line x1="${L}" x2="${W - R}" y1="${y(yMax * f)}" y2="${y(yMax * f)}" stroke="${C.line}"/><text x="${L - 6}" y="${y(yMax * f) + 3}" text-anchor="end" class="mc-ax">${num(yMax * f)}</text>`).join("");
const dates = s.map((p) => p.date);
const postDays = new Set((d.ig.posts || []).map((p) => p.at.slice(0, 10)));
const marks = dates.map((dt, i) => (postDays.has(dt) ? `<path d="M${x(i)} ${H - B + 8} l4 6 h-8 z" fill="${C.blue}"/>` : "")).join("");
const labels = [0, Math.floor((s.length - 1) / 2), s.length - 1].map((i) => `<text x="${x(i)}" y="${H - 4}" text-anchor="${i === 0 ? "start" : i === s.length - 1 ? "end" : "middle"}" class="mc-ax">${day(dates[i])}</text>`).join("");
const best = s.reduce((a, b) => (b.value > a.value ? b : a));
const svg = `<svg viewBox="0 0 ${W} ${H}" class="mc-chart" role="img" aria-label="Daily people reached over the last ${s.length} days">
<defs><linearGradient id="${id}" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${C.sky}" stop-opacity=".45"/><stop offset="1" stop-color="${C.sky}" stop-opacity="0"/></linearGradient></defs>
${grid}<polygon points="${x(0)},${y(0)} ${line} ${x(s.length - 1)},${y(0)}" fill="url(#${id})"/><polyline points="${line}" fill="none" stroke="${C.blue}" stroke-width="2" stroke-linejoin="round"/>${marks}${labels}</svg>`;
return card("", "People reached each day", `▲ marks a day with a post · busiest day ${day(best.date)} (${num(best.value)})`, svg);
},
funnel(d) {
const t = d?.ig?.totals;
if (!t) return noData("Funnel");
const steps = [["Saw a post or reel", t.views], ["Different people reached", t.reach], ["Liked, commented, saved or shared", t.accounts_engaged], ["Tapped the website link", t.profile_links_taps]].filter((s) => s[1] != null);
const max = Math.max(...steps.map((s) => s[1]), 1);
let leak = -1, worst = 101;
steps.forEach((s, i) => { if (i && steps[i - 1][1]) { const r = pct(s[1], steps[i - 1][1]); if (r < worst) { worst = r; leak = i; } } });
const rows = steps.map(([label, v], i) => {
const w = Math.max(2, Math.sqrt(v / max) * 100);
const conv = i && steps[i - 1][1] ? `<span class="mc-fun-conv ${i === leak ? "is-bad" : ""}">${pct(v, steps[i - 1][1])}% kept${i === leak ? " · biggest drop" : ""}</span>` : "";
return `<div class="mc-fun-row">${conv}<div class="mc-fun-bar" style="width:${w.toFixed(1)}%;background:${i === leak ? C.bad : i % 2 ? C.sky : C.blue}"><span>${num(v)}</span></div><div class="mc-fun-l">${esc(label)}</div></div>`;
}).join("");
return card("", "From seeing a post to visiting the website", "last 28 days, Instagram", `<div class="mc-fun">${rows}</div>`);
},
followers(d) {
const t = d?.ig?.totals;
if (t?.new_follows == null) return noData("Followers gained and lost");
const gained = t.new_follows, lost = t.unfollows ?? 0, net = gained - lost, max = Math.max(gained, lost, 1);
const bar = (v, col, label) => `<div class="mc-flow-row"><span class="mc-flow-l">${label}</span><div class="mc-flow-track"><div style="width:${((v / max) * 100).toFixed(1)}%;background:${col}"></div></div><span class="mc-flow-v">${num(v)}</span></div>`;
const daily = (d.ig.dailyFollowers || []).map((x) => x.value);
return card("", "Followers gained vs lost", "last 28 days", `<div class="mc-flow"><div class="mc-flow-net ${net < 0 ? "is-bad" : "is-good"}"><b>${net > 0 ? "+" : ""}${num(net)}</b><span>net change</span></div><div class="mc-flow-bars">${bar(gained, C.good, "New")}${bar(lost, C.bad, "Unfollowed")}${daily.length > 1 ? `<div class="mc-flow-spark">${spark(daily, C.good, 220, 30)}<span>new followers per day</span></div>` : ""}</div></div>`,
net < 0 ? "More people left than joined: the content isn't matching the audience the account has." : "");
},
formats(d) {
const posts = (d?.ig?.posts || []).filter((p) => p.reach != null && Date.parse(d.asOf) - Date.parse(p.at) > 2 * DAY);
if (!posts.length) return noData("Formats");
const types = ["Reel", "Carousel", "Image"].map((type) => {
const ps = posts.filter((p) => p.type === type);
const avg = ps.length ? ps.reduce((a, p) => a + p.reach, 0) / ps.length : 0;
const act = ps.length ? ps.reduce((a, p) => a + p.saves + p.shares, 0) / ps.length : 0;
return { type, n: ps.length, share: pct(ps.length, posts.length), avg, act };
}).filter((t) => t.n);
const maxAvg = Math.max(...types.map((t) => t.avg), 1);
const rows = types.map((t) => `<tr><td>${t.type}s</td>
<td><div class="mc-pair"><div class="mc-pair-bar" style="width:${t.share}%;background:#c9d6e6"></div><span>${t.share}% of posts</span></div></td>
<td><div class="mc-pair"><div class="mc-pair-bar" style="width:${((t.avg / maxAvg) * 100).toFixed(1)}%;background:${C.sky}"></div><span>${num(t.avg)} reached per post</span></div></td></tr>`).join("");
const best = types.reduce((a, b) => (b.avg > a.avg ? b : a)), most = types.reduce((a, b) => (b.n > a.n ? b : a));
const foot = best.type !== most.type && most.avg ? `${best.type}s reach ${(best.avg / most.avg).toFixed(1)}× more people per post than ${most.type.toLowerCase()}s, but ${most.type.toLowerCase()}s are ${most.share}% of what gets posted.` : "";
return card("", "What gets posted vs what reaches people", `last ${posts.length} posts`, `<table class="mc-pairs"><tbody>${rows}</tbody></table>`, foot);
},
rhythm(d) {
const posts = d?.ig?.posts?.length ? d.ig.posts : d?.fb?.posts || [];
if (!posts.length) return noData("Posting rhythm");
const end = new Date(d.asOf); end.setUTCHours(0, 0, 0, 0);
const monday = new Date(+end - (((end.getUTCDay() + 6) % 7) + 7 * 7) * DAY); // Monday, 8 weeks ago
const byDay = new Map();
posts.forEach((p) => { const k = p.at.slice(0, 10); byDay.set(k, (byDay.get(k) || 0) + 1); });
const weeks = [];
for (let w = monday; w <= end; w = new Date(+w + 7 * DAY)) weeks.push(w);
const cells = weeks.map((w) => `<div class="mc-rh-week"><span class="mc-rh-wl">${day(w)}</span>${[...Array(7)].map((_, i) => {
const dt = new Date(+w + i * DAY), k = dt.toISOString().slice(0, 10), n = byDay.get(k) || 0;
return `<i class="${dt > end ? "is-future" : n ? "is-on" : ""}" title="${k}${n ? `: ${n} post${n > 1 ? "s" : ""}` : ""}"></i>`;
}).join("")}</div>`).join("");
const inRange = posts.filter((p) => Date.parse(p.at) >= +monday).length;
const dv = d.ig?.derived || {};
return card("", "Posting rhythm, last 8 weeks", "one square per day · Mon → Sun",
`<div class="mc-rh"><div class="mc-rh-grid"><div class="mc-rh-week mc-rh-head"><span class="mc-rh-wl"></span>${"MTWTFSS".split("").map((c) => `<b>${c}</b>`).join("")}</div>${cells}</div>
<div class="mc-rh-stats"><div><b>${(inRange / weeks.length).toFixed(1)}</b><span>posts a week</span></div>${dv.longestGapDays != null ? `<div><b>${dv.longestGapDays}</b><span>days, longest gap</span></div>` : ""}${dv.daysSinceLastPost != null ? `<div><b>${dv.daysSinceLastPost}</b><span>days since last post</span></div>` : ""}</div></div>`);
},
"top-posts"(d) {
const posts = (d?.ig?.posts || []).filter((p) => p.reach != null && Date.parse(d.asOf) - Date.parse(p.at) > 2 * DAY);
if (posts.length < 2) return noData("Top posts");
const sorted = [...posts].sort((a, b) => b.reach - a.reach), max = sorted[0].reach || 1, n = Math.min(3, Math.floor(posts.length / 2));
const item = (p) => `<li><div class="mc-post-meta"><span class="mc-chip">${p.type}</span>${day(p.at)}${p.link ? ` · <a href="${esc(p.link)}">open</a>` : ""}</div>
<div class="mc-post-cap">${esc(p.caption.slice(0, 90) || "(no caption)")}${p.caption.length > 90 ? "…" : ""}</div>
<div class="mc-post-bar"><div style="width:${((p.reach / max) * 100).toFixed(1)}%"></div></div>
<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()));
function scoreBlock(body) {
const rows = rowsOf(body).map(([area, score, why]) => ({ area, why, s: Math.max(0, Math.min(10, parseFloat(score) || 0)) }));
if (!rows.length) return "";
const avg = rows.reduce((a, r) => a + r.s, 0) / rows.length;
const tone = (s) => (s < 4 ? C.bad : s < 7 ? C.warn : C.good);
return `<section class="mc-score"><div class="mc-score-ring">${ring(avg)}<span>overall, out of 10</span></div><div class="mc-score-rows">${rows.map((r) => `
<div class="mc-score-row"><div class="mc-score-head"><b>${esc(r.area)}</b><span style="color:${tone(r.s)}">${r.s}/10</span></div>
<div class="mc-score-track"><div style="width:${r.s * 10}%;background:${tone(r.s)}"></div></div>${r.why ? `<p>${esc(r.why)}</p>` : ""}</div>`).join("")}</div></section>`;
}
function actionsBlock(body) {
const rows = rowsOf(body);
if (!rows.length) return "";
return `<ol class="mc-actions">${rows.map(([what, owner = "", when = ""]) => {
const who = /client|you/i.test(owner) ? "client" : "us";
return `<li><div class="mc-act-what">${esc(what).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")}</div><div class="mc-act-meta">${owner ? `<span class="mc-owner is-${who}">${esc(owner)}</span>` : ""}${when ? `<span class="mc-when">${esc(when)}</span>` : ""}</div></li>`;
}).join("")}</ol>`;
}
const LEGACY_SHOTS = { "ig-profile": "instagram-profile", "fb-page": "facebook-page" };
function screenshotBlock(body, ctx) {
const [id = "", ...notes] = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const dir = join(dirname(ctx.mdPath), "screenshots");
if (!existsSync(dir)) return "";
const months = readdirSync(dir).filter((m) => /^\d{4}-\d{2}$/.test(m)).sort().reverse();
const want = [id, LEGACY_SHOTS[id]].filter(Boolean);
const find = (folder) => existsSync(folder) && readdirSync(folder).find((f) => want.some((w) => f.startsWith(`${w}.`)) && /\.(png|jpe?g|webp)$/i.test(f));
let file = null;
for (const m of months) { const f = find(join(dir, m)); if (f) { file = join(dir, m, f); break; } }
if (!file) { const f = find(dir); file = f && join(dir, f); }
if (!file || statSync(file).size > 6 * 1024 * 1024) return ctx.internal ? `<p class="mc-nodata">Screenshot "${esc(id)}" not uploaded yet.</p>` : "";
const mime = { ".png": "image/png", ".webp": "image/webp" }[extname(file).toLowerCase()] || "image/jpeg";
const img = `<img src="data:${mime};base64,${readFileSync(file).toString("base64")}" alt="${esc(id)} screenshot">`;
return `<figure class="mc-shot ${notes.length ? "" : "is-solo"}">${img}${notes.length ? `<ol class="mc-shot-notes">${notes.map((n) => `<li>${esc(n.replace(/^\d+[.)]\s*|^[-*]\s+/, ""))}</li>`).join("")}</ol>` : ""}</figure>`;
}
// ---------- markdown ----------
const ALERTS = { WIN: ["good", "What's working"], FIX: ["warn", "To fix"], RISK: ["bad", "Risk"], NOTE: ["info", "Note"] };
export function renderMarkdown(md, ctx) {
const data = () => (ctx._data === undefined ? (ctx._data = reportData(ctx.mdPath)) : ctx._data);
const marked = new Marked({ gfm: true, breaks: true });
marked.use({
renderer: {
code(code, info) {
const kind = String(info || "").trim().match(/^mc:([\w-]+)/)?.[1];
if (!kind) return false;
if (BLOCKS[kind]) return BLOCKS[kind](data());
if (kind === "score") return scoreBlock(code);
if (kind === "actions") return actionsBlock(code);
if (kind === "screenshot") return screenshotBlock(code, ctx);
return ctx.internal ? `<p class="mc-nodata">Unknown block mc:${esc(kind)}</p>` : "";
},
blockquote(quote) {
const m = quote.match(/^\s*<p>\[!(WIN|FIX|RISK|NOTE)\]\s*([^<]*)(?:<br>)?/);
if (!m) return false;
const [tone, fallback] = ALERTS[m[1]];
const rest = quote.slice(m[0].length).replace(/^\s*<\/p>/, "");
return `<aside class="mc-alert is-${tone}"><div class="mc-alert-title">${m[2].trim() || fallback}</div>${rest.startsWith("<") ? rest : `<p>${rest}`}</aside>`;
},
},
});
return marked.parse(md);
}
export const asOfLabel = (mdPath) => {
const d = reportData(mdPath);
return d?.asOf ? `Account data as of ${new Date(d.asOf).toLocaleDateString("en-CA", { month: "long", day: "numeric", year: "numeric" })}` : "";
};