diff --git a/connector/src/competitors.js b/connector/src/competitors.js new file mode 100644 index 0000000..f9704c3 --- /dev/null +++ b/connector/src/competitors.js @@ -0,0 +1,158 @@ +// Competitors per client: Instagram Business/Creator accounts the team tracks (added by hand or suggested by the AI). +// Each refresh stores one snapshot per account per day (followers, posts, recent media), so growth can be charted. +// Uses Instagram business_discovery through the client's (or the agency's) connected Instagram account. +import fs from "node:fs"; +import path from "node:path"; +import { PROJECT_ROOT } from "./env.js"; +import * as meta from "./meta.js"; + +const FILE = process.env.QUORLA_COMPETITORS_FILE || path.join(PROJECT_ROOT, "data", "competitors.json"); +const CLIENTS = path.join(PROJECT_ROOT, "clients"); +const KEEP_DAYS = 180; +fs.mkdirSync(path.dirname(FILE), { recursive: true }); + +const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; +const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db)); fs.renameSync(tmp, FILE); }; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); +const today = () => new Date().toISOString().slice(0, 10); +const handle = (u) => String(u || "").trim().replace(/^@/, "").replace(/^https?:\/\/(www\.)?instagram\.com\//i, "").replace(/[/?#].*$/, "").toLowerCase(); + +function checkClient(slug) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404); + return slug; +} +function clientLink(slug) { try { return JSON.parse(fs.readFileSync(path.join(CLIENTS, slug, "client.json"), "utf8")); } catch { return {}; } } + +/** An Instagram account id to run business_discovery from: the client's own, else any the agency manages. */ +async function viewerIgId(slug) { + const link = clientLink(slug); + if (link.igId) return link.igId; + const any = (await meta.listAccounts()).find((a) => a.instagram?.id); + if (!any) throw err("No Instagram account is connected to the agency's Meta account, so competitors can't be looked up."); + return any.instagram.id; +} + +async function lookup(igId, username) { + try { + const b = await meta.igBusinessDiscovery(igId, username, { posts: 25 }); + if (!b?.username) throw new Error("not found"); + return b; + } catch (e) { + throw err(`Couldn't read @${username}. Instagram only shares public Business and Creator accounts; check the handle and that the account is public and professional. (${e.message})`); + } +} + +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) })), + }; +} +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. + entry.snapshots = entry.snapshots.map(({ media, ...rest }) => rest); + entry.snapshots.push(snap); + const cutoff = new Date(Date.now() - KEEP_DAYS * 86400000).toISOString().slice(0, 10); + entry.snapshots = entry.snapshots.filter((s) => s.date >= cutoff); +} + +export function listTracked(client) { + checkClient(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 = "" } = {}) { + checkClient(client); + const u = handle(username); + if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name."); + const db = read(); + const c = (db[client] ||= { accounts: [] }); + if (c.accounts.some((a) => a.username === u)) throw err(`@${u} is already tracked for this client.`); + if (c.accounts.length >= 10) throw err("Track up to 10 competitors per client. Remove one first."); + 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: [] }; + addSnapshot(entry, snapshotOf(b)); + c.accounts.push(entry); + write(db); + return { username: u, name: entry.name }; +} + +export function remove(client, username) { + checkClient(client); + const db = read(); + const u = handle(username); + const before = db[client]?.accounts?.length || 0; + if (db[client]) db[client].accounts = db[client].accounts.filter((a) => a.username !== u); + if ((db[client]?.accounts?.length || 0) === before) throw err("Not tracked.", 404); + write(db); +} + +/** Refresh every tracked account for one client (or all clients). One snapshot per account per day. */ +export async function refresh(client) { + const db = read(); + const slugs = client ? [checkClient(client)] : Object.keys(db); + const errors = []; + for (const slug of slugs) { + const c = db[slug]; + if (!c?.accounts?.length) continue; + let igId; + try { igId = await viewerIgId(slug); } catch (e) { errors.push(`${slug}: ${e.message}`); continue; } + for (const a of c.accounts) { + try { const b = await lookup(igId, a.username); a.name = b.name || a.name; addSnapshot(a, snapshotOf(b)); } + catch (e) { errors.push(`${slug} @${a.username}: ${e.message}`); } + } + } + write(db); + return { refreshed: slugs.length, errors }; +} + +const engagement = (m) => (m.likes ?? 0) + (m.comments ?? 0); +/** Numbers for one account from its latest posts and snapshot history. */ +function stats({ followers, media, history }) { + const now = Date.now(); + 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 monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10); + const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null; + return { + followers, + growth30: base && followers != null && base.date !== today() ? followers - base.followers : null, + growthSince: base && base.date !== today() ? base.date : null, + postsPerWeek: media.length ? Math.round((recent.length / 4) * 10) / 10 : null, + avgEngagement: avgEng, + engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null, + formatMix: mix, + topPosts: [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 3), + }; +} + +/** + * Comparison for a client: their own account (from the dashboard snapshot passed in) plus every tracked competitor. + * own: { username, followers, posts:[{id,timestamp,like_count,comments_count,media_type,media_product_type,permalink,caption}], history:[{date, followers}] } + */ +export function compare(client, own) { + checkClient(client); + 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, + ...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) })); + 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 }; +} diff --git a/connector/src/server.js b/connector/src/server.js index 9eb3885..6efb982 100644 --- a/connector/src/server.js +++ b/connector/src/server.js @@ -11,6 +11,7 @@ import * as meta from "./meta.js"; import * as sched from "./schedule.js"; import * as lib from "./library.js"; import * as plan from "./plan.js"; +import * as competitors from "./competitors.js"; import * as extra from "./platforms/index.js"; const server = new McpServer({ name: "social-manager", version: "0.1.0" }); @@ -205,4 +206,12 @@ server.tool("plan_update", "Update a content-plan item (status, date, brief, cap format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).optional(), platforms: z.array(z.string()).optional(), brief: z.string().optional(), caption: z.string().optional(), assignee: z.string().optional() }) }, wrap(async ({ id, patch }) => plan.updateItem(id, patch, "agent"))); +server.tool("competitors", "The Instagram competitors tracked for a client, with followers, growth, posts per week, average engagement, format mix and top posts (the dashboard's Competitors page). Use in audits instead of one-off lookups.", + { client: z.string() }, + wrap(async ({ client }) => competitors.compare(client, null))); + +server.tool("competitor_add", "Start tracking an Instagram competitor for a client (public Business/Creator accounts only). Use for the 3–5 closest local competitors you identify in an audit; say why in note.", + { client: z.string(), username: z.string(), note: z.string().default("") }, + wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note }))); + await server.connect(new StdioServerTransport()); diff --git a/connector/test/competitors.test.mjs b/connector/test/competitors.test.mjs new file mode 100644 index 0000000..ba6a896 --- /dev/null +++ b/connector/test/competitors.test.mjs @@ -0,0 +1,33 @@ +// Competitor comparison maths, offline (no Instagram calls): median engagement, rate, growth, format mix, top posts. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-comp-")); +process.env.QUORLA_COMPETITORS_FILE = path.join(TMP, "competitors.json"); +const { PROJECT_ROOT } = await import("../src/env.js"); +const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json"))); + +const day = (n) => new Date(Date.now() - n * 86400000); +const media = [5, 10, 20, 40, 1000].map((likes, i) => ({ id: `m${i}`, at: day(i * 3).toISOString(), likes, comments: 0, type: i % 2 ? "Reel" : "Image", permalink: `https://x/${i}` })); + +test("compare: median engagement ignores one viral post; growth from history", { skip: !client && "no client folder" }, async () => { + fs.writeFileSync(process.env.QUORLA_COMPETITORS_FILE, JSON.stringify({ + [client]: { accounts: [{ username: "rival", name: "Rival", source: "manual", snapshots: [ + { date: day(20).toISOString().slice(0, 10), followers: 900 }, + { date: day(0).toISOString().slice(0, 10), followers: 1000, media }, + ] }] }, + })); + const c = await import("../src/competitors.js"); + const r = c.compare(client, { username: "me", followers: 500, posts: [], history: [] }).competitors[0]; + assert.equal(r.avgEngagement, 20, "median of 5,10,20,40,1000"); + assert.equal(r.engagementRate, 2); + assert.equal(r.growth30, 100); + assert.equal(r.postsPerWeek, 1.3, "5 posts in 28 days"); + assert.deepEqual(r.formatMix, { Image: 3, Reel: 2 }); + assert.equal(r.topPosts[0].likes, 1000); + assert.equal(c.listTracked(client)[0].username, "rival"); + await assert.rejects(c.add(client, "not a handle!!"), /Instagram handle/); +}); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 1492ac2..f8ac4f3 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -16,6 +16,7 @@ import * as meta from "../../connector/src/meta.js"; import * as sched from "../../connector/src/schedule.js"; import * as lib from "../../connector/src/library.js"; import * as plan from "../../connector/src/plan.js"; +import * as competitors from "../../connector/src/competitors.js"; import * as extra from "../../connector/src/platforms/index.js"; import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js"; import { DATA_DIR as _DD } from "./config.js"; @@ -297,6 +298,20 @@ app.post("/api/plan", h(async (req) => ({ item: plan.createItem(req.body || {}, app.patch("/api/plan/:id", h(async (req) => ({ item: plan.updateItem(req.params.id, req.body || {}, who(req)) }))); app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; })); +// ---------- competitors per client (Instagram) ---------- +function ownForCompare(slug) { + const snap = metrics.readSnapshot(slug); + if (!snap?.ig) return null; + return { + username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count ?? null, posts: snap.ig.posts || [], + history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })), + }; +} +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.post("/api/competitors/refresh", h(async (req) => competitors.refresh(String(req.body?.client || "") || undefined))); + // ---------- published posts and their numbers ---------- app.get("/api/published", h(async (req) => published.feed({ client: req.query.client ? (clients.safeSlug(req.query.client), String(req.query.client)) : undefined, platform: req.query.platform ? String(req.query.platform) : undefined, days: Number(req.query.days) || 30 }))); app.post("/api/published/refresh", h(async () => published.refreshStats())); @@ -383,6 +398,7 @@ function scheduleNightly() { setTimeout(async () => { try { console.log("Nightly refresh:", await metrics.refreshAll()); } catch (e) { console.error("Nightly refresh failed", e); } try { console.log("Post stats:", await published.refreshStats()); } catch (e) { console.error("Post stats failed", e); } + try { console.log("Competitors:", await competitors.refresh()); } catch (e) { console.error("Competitors refresh failed", e); } scheduleNightly(); }, next - new Date()); } diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx index ee639c9..1671f01 100644 --- a/dashboard/web/src/App.jsx +++ b/dashboard/web/src/App.jsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom"; -import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays } from "lucide-react"; +import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords } from "lucide-react"; import { api } from "./api.js"; import { cx, Spinner } from "./ui.jsx"; import Login from "./pages/Login.jsx"; @@ -12,6 +12,7 @@ import Settings from "./pages/Settings.jsx"; import Publishing from "./pages/Publishing.jsx"; import Published from "./pages/Published.jsx"; import Calendar from "./pages/Calendar.jsx"; +import Competitors from "./pages/Competitors.jsx"; import Inbox from "./pages/Inbox.jsx"; import Library from "./pages/Library.jsx"; import Connections from "./pages/Connections.jsx"; @@ -25,6 +26,7 @@ const NAV = [ { to: "/library", label: "Content library", icon: FolderUp }, { to: "/publishing", label: "Publishing", icon: CalendarClock }, { to: "/published", label: "Published", icon: BarChart3 }, + { to: "/competitors", label: "Competitors", icon: Swords }, { to: "/inbox", label: "Inbox", icon: MessageCircle }, { to: "/connections", label: "Connections", icon: Plug }, { to: "/team", label: "Team", icon: Users2 }, @@ -122,6 +124,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/dashboard/web/src/pages/Competitors.jsx b/dashboard/web/src/pages/Competitors.jsx new file mode 100644 index 0000000..d99f983 --- /dev/null +++ b/dashboard/web/src/pages/Competitors.jsx @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useState } from "react"; +import { 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 { api } from "../api.js"; +import { Badge, Button, cx, Empty, ErrorNote, Field, fmt, fmtDate, Panel, Spinner, timeAgo } from "../ui.jsx"; + +const FORMAT_COLORS = { Reel: "#3b3778", Carousel: "#fc6d45", Image: "#a9afba", Video: "#6c63b5" }; + +/** Follower trend scaled to its own min/max, so small and large accounts both show their direction. */ +function Trend({ series }) { + const vals = (series || []).map((s) => s.followers).filter((v) => v != null); + if (vals.length < 2) return builds daily; + const w = 96, h = 26, min = Math.min(...vals), max = Math.max(...vals), span = max - min || 1; + const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * (w - 2) + 1},${h - 1 - ((v - min) / span) * (h - 2)}`).join(" "); + const up = vals.at(-1) >= vals[0]; + return ( + + + + ); +} + +function FormatMix({ mix }) { + const entries = Object.entries(mix || {}); + const total = entries.reduce((s, [, n]) => s + n, 0); + if (!total) return —; + return ( +
+ +
{entries.sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${Math.round((n / total) * 100)}%`).join(" · ")}
+
+ ); +} + +function AddDialog({ client, onClose, onAdded }) { + const [username, setUsername] = useState(""); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const add = async (e) => { + e.preventDefault(); setBusy(true); setError(null); + try { await api("/api/competitors", { method: "POST", body: { client, username, note } }); onAdded(); } + catch (er) { setError(er); setBusy(false); } + }; + return ( +
+
e.stopPropagation()} className="panel w-full max-w-[460px] p-5 space-y-4 shadow-xl"> +

Track a competitor

+ + setUsername(e.target.value)} placeholder="@bakery_down_the_street" autoFocus required /> + + + setNote(e.target.value)} placeholder="Same neighbourhood, similar menu" /> + + +
+ + +
+ +
+ ); +} + +export default function Competitors() { + const [params, setParams] = useSearchParams(); + const [clients, setClients] = useState(null); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + const [busy, setBusy] = useState(null); + const client = params.get("client") || ""; + + 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 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(" · ")); }); + const remove = (u) => run(`rm-${u}`, () => api(`/api/competitors/${client}/${encodeURIComponent(u)}`, { method: "DELETE" })); + + if (!clients) return error ? : ; + const current = clients.find((c) => c.slug === client); + const rows = data ? [...(data.you ? [{ ...data.you, username: data.you.username || current?.name }] : []), ...data.competitors] : []; + const chart = rows.filter((r) => r.engagementRate != null).map((r) => ({ name: `@${r.username}`, rate: r.engagementRate, own: Boolean(r.own) })); + const lastChecked = data?.competitors.map((c) => c.lastChecked).filter(Boolean).sort()[0]; + + return ( +
+
+
+

Competitors

+

How each client's Instagram compares with the accounts they compete with{lastChecked ? ` · checked ${fmtDate(lastChecked)}` : ""}. Numbers refresh nightly.

+
+
+ + {data?.competitors.length > 0 && } + +
+
+ + + + {!data ? (!error && ) : !data.competitors.length ? ( +
+ setAdding(true)}>Add competitor}> + 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. + +
+ ) : ( + <> +
+ + + + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + + + ))} + +
AccountFollowers30-day changeTrendPosts / weekTypical postEngagement rateFormats (last 25)Actions
+
+ @{r.username}{r.own && This client}{r.source === "suggested" && AI suggested} +
+ {r.name && !r.own &&
{r.name}{r.note ? ` · ${r.note}` : ""}
} +
{fmt(r.followers)} 0 ? "text-ok" : r.growth30 < 0 ? "text-bad" : "")}> + {r.growth30 == null ? — : `${r.growth30 > 0 ? "+" : ""}${fmt(r.growth30)}`} + {r.postsPerWeek ?? "—"}{fmt(r.avgEngagement)}{r.engagementRate == null ? "—" : `${r.engagementRate}%`} + + {!r.own && } +
+
+ +
+ +
+ + + + `${v}%`} tick={{ fontSize: 11, fill: "#6b6e78" }} /> + (v.length > 22 ? `${v.slice(0, 21)}…` : v)} /> + [`${v}%`, "Engagement rate"]} /> + + {chart.map((d) => )} + `${v}%`} style={{ fontSize: 11, fill: "#1f2024" }} /> + + + +
+

Coral is this client; navy are competitors.

+
+ + +
+ {data.competitors.map((c) => ( +
+
@{c.username}
+ {!c.topPosts.length ?
No posts read yet.
: ( +
    + {c.topPosts.map((p) => ( +
  • +
    + {p.type} + {p.permalink && } +
    +
    {fmt(p.likes)} likes · {fmt(p.comments)} comments
    +
    {fmtDate(p.at)}
    + {p.caption &&

    {p.caption}

    } +
  • + ))} +
+ )} +
+ ))} +
+
+
+ {!data.you &&

This client's own Instagram isn't linked or hasn't been refreshed yet, so it isn't in the comparison.

} + + )} + + {adding && setAdding(false)} onAdded={() => { setAdding(false); load(); }} />} +
+ ); +}