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:
parent
df3dbb63c4
commit
f86bd9ce33
@ -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.
|
- ```` ```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.
|
- 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.
|
- 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.
|
- 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.
|
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`.
|
6. Append 2–3 dated lessons to `history.md`.
|
||||||
|
|||||||
36
.claude/skills/competitor-scan/SKILL.md
Normal file
36
.claude/skills/competitor-scan/SKILL.md
Normal 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.
|
||||||
@ -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); }
|
.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 { 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; }
|
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); }
|
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); }
|
||||||
|
|||||||
@ -46,9 +46,12 @@ function snapshotOf(b) {
|
|||||||
return {
|
return {
|
||||||
date: today(), followers: b.followers_count ?? null, following: b.follows_count ?? null, mediaCount: b.media_count ?? null,
|
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),
|
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) {
|
function addSnapshot(entry, snap) {
|
||||||
entry.snapshots = (entry.snapshots || []).filter((s) => s.date !== snap.date);
|
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.
|
// 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 }));
|
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);
|
checkClient(client);
|
||||||
const u = handle(username);
|
const u = handle(username);
|
||||||
if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name.");
|
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 own = clientLink(client);
|
||||||
const b = await lookup(await viewerIgId(client), u);
|
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.");
|
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));
|
addSnapshot(entry, snapshotOf(b));
|
||||||
c.accounts.push(entry);
|
c.accounts.push(entry);
|
||||||
write(db);
|
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 recent = media.filter((m) => m.at && now - new Date(m.at) <= 28 * 86400000);
|
||||||
const last12 = media.slice(0, 12);
|
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.
|
// 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 = median(last12.map(engagement));
|
||||||
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 = {}, byType = {};
|
||||||
const mix = {};
|
for (const m of media.slice(0, 25)) { mix[m.type] = (mix[m.type] || 0) + 1; (byType[m.type] ||= []).push(engagement(m)); }
|
||||||
for (const m of media.slice(0, 25)) mix[m.type] = (mix[m.type] || 0) + 1;
|
// 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 monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10);
|
||||||
const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null;
|
const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null;
|
||||||
return {
|
return {
|
||||||
@ -131,9 +136,16 @@ function stats({ followers, media, history }) {
|
|||||||
avgEngagement: avgEng,
|
avgEngagement: avgEng,
|
||||||
engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null,
|
engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null,
|
||||||
formatMix: mix,
|
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),
|
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.
|
* 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 accounts = (read()[client]?.accounts || []).map((a) => {
|
||||||
const latest = a.snapshots?.at(-1) || {};
|
const latest = a.snapshots?.at(-1) || {};
|
||||||
return {
|
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 || [] }),
|
...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 })),
|
series: (a.snapshots || []).filter((s) => s.followers != null).map((s) => ({ date: s.date, followers: s.followers })),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
let you = null;
|
let you = null;
|
||||||
if (own) {
|
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) };
|
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 };
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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("") },
|
{ client: z.string(), username: z.string(), note: z.string().default("") },
|
||||||
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
|
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.",
|
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)));
|
{ client: z.string() }, wrap(async ({ client }) => brandkit.get(client)));
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,7 @@ export function listDocs(slug) {
|
|||||||
for (const f of fs.readdirSync(dir)) {
|
for (const f of fs.readdirSync(dir)) {
|
||||||
if (/^audit-.*\.md$/.test(f)) add(f, "audit");
|
if (/^audit-.*\.md$/.test(f)) add(f, "audit");
|
||||||
else if (/^profile-check-.*\.md$/.test(f)) add(f, "profile-check");
|
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)) {
|
for (const [kind, sub] of Object.entries(DOC_DIRS)) {
|
||||||
const d = path.join(dir, sub);
|
const d = path.join(dir, sub);
|
||||||
|
|||||||
@ -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.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.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.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)));
|
app.post("/api/competitors/refresh", h(async (req) => competitors.refresh(String(req.body?.client || "") || undefined)));
|
||||||
|
|
||||||
// ---------- published posts and their numbers ----------
|
// ---------- published posts and their numbers ----------
|
||||||
|
|||||||
@ -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).`;
|
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}` },
|
"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` },
|
"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) },
|
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:*)",
|
"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__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__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__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",
|
"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.
|
// 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);
|
const before = docStamp(job.slug);
|
||||||
log(job, "info", `Started: ${job.prompt.split("\n")[0]}`);
|
log(job, "info", `Started: ${job.prompt.split("\n")[0]}`);
|
||||||
// Report charts are drawn from the stored numbers, so bring them up to date first.
|
// 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."); }
|
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.`); }
|
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) {
|
if (job.slug) {
|
||||||
const changed = listDocs(job.slug).filter((d) => d.kind !== "setup" && before.get(d.path) !== d.updatedAt);
|
const changed = listDocs(job.slug).filter((d) => d.kind !== "setup" && before.get(d.path) !== d.updatedAt);
|
||||||
job.files = changed.map((d) => d.path);
|
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 {
|
try {
|
||||||
toPdf(path.join(safeSlug(job.slug), d.path));
|
toPdf(path.join(safeSlug(job.slug), d.path));
|
||||||
log(job, "info", `PDF exported: ${d.path.replace(/\.md$/, ".pdf")}`);
|
log(job, "info", `PDF exported: ${d.path.replace(/\.md$/, ".pdf")}`);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
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 { 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 { api } from "../api.js";
|
||||||
import { Badge, Button, cx, Empty, ErrorNote, Field, fmt, fmtDate, Panel, Spinner, timeAgo } from "../ui.jsx";
|
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() {
|
export default function Competitors() {
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const [clients, setClients] = useState(null);
|
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(() => { 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]);
|
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]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
useEffect(() => { setData(null); setError(null); load(); }, [load]);
|
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 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 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>)}
|
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{data?.competitors.length > 0 && <Button icon={RefreshCw} loading={busy === "refresh"} onClick={refresh}>Refresh</Button>}
|
{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>
|
<Button variant="primary" icon={Plus} onClick={() => setAdding(true)} disabled={data?.competitors.length >= 10}>Add competitor</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ErrorNote error={error} />
|
<ErrorNote error={error} />
|
||||||
|
|
||||||
|
<Suggestions items={suggestions} busy={busy} onAdd={accept} onDismiss={dismiss} full={data?.competitors.length >= 10} />
|
||||||
|
|
||||||
{!data ? (!error && <Spinner />) : !data.competitors.length ? (
|
{!data ? (!error && <Spinner />) : !data.competitors.length ? (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<Empty icon={Swords} title="No competitors tracked yet" action={<Button variant="primary" icon={Plus} onClick={() => setAdding(true)}>Add competitor</Button>}>
|
<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>}>
|
||||||
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.
|
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>
|
</Empty>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "
|
|||||||
import { dirname, extname, join, resolve, sep } from "node:path";
|
import { dirname, extname, join, resolve, sep } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Marked } from "marked";
|
import { Marked } from "marked";
|
||||||
|
import { compare, listSuggestions } from "../../connector/src/competitors.js";
|
||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
const DATA_DIR = process.env.QUORLA_DASHBOARD_DATA || join(root, "data", "dashboard");
|
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 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 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 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 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,
|
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 })) };
|
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) {
|
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>`;
|
<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>`);
|
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 ----------
|
// ---------- 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()));
|
const rowsOf = (body) => body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => l.replace(/^[-*]\s+/, "").split("|").map((s) => s.trim()));
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user