- Places API (New): competitor scans find local rivals on Google Maps and the server reads real ratings/review counts by place id; client's own listing fills the "You" row; recent reviews for praise/complaint themes; 30-day cache - Google Trends via DataForSEO: rising/top related searches and % change per keyword in the client's region; used by trend-scan and to check gap demand - Instagram Hashtag Search: top/recent posts under a niche hashtag (tool + Competitors page panel), counts Instagram's 30 tags/week limit - Server settings → Research: keys plus on/off switches for each source (hashtag search off until Meta approves Public Content Access) - Reports mark website-sourced ratings as unverified; skill takes handles only from a business's own site Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
14 KiB
JavaScript
257 lines
14 KiB
JavaScript
// 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";
|
|
import * as places from "./places.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. */
|
|
export 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(mediaOf),
|
|
};
|
|
}
|
|
const tagsOf = (caption) => [...new Set((String(caption || "").match(/#[\p{L}\p{N}_]+/gu) || []).map((t) => t.toLowerCase()))].slice(0, 30);
|
|
const typeOf = (m) => (m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image");
|
|
const mediaOf = (m) => ({ id: m.id, at: m.timestamp, likes: m.like_count ?? null, comments: m.comments_count ?? null, type: typeOf(m), permalink: m.permalink, caption: (m.caption || "").slice(0, 200), tags: tagsOf(m.caption) });
|
|
function addSnapshot(entry, snap) {
|
|
entry.snapshots = (entry.snapshots || []).filter((s) => s.date !== snap.date);
|
|
// Keep full media only on the latest snapshot; older ones keep the counts for the growth chart.
|
|
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 = "", kind = "local", google = null } = {}) {
|
|
checkClient(client);
|
|
const u = handle(username);
|
|
if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name.");
|
|
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, kind: KINDS.includes(kind) ? kind : "local", note: String(note).slice(0, 300), google, addedBy: by, addedAt: new Date().toISOString(), snapshots: [] };
|
|
addSnapshot(entry, snapshotOf(b));
|
|
c.accounts.push(entry);
|
|
write(db);
|
|
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}`); }
|
|
// Cached for 30 days, so this calls Google about once a month per account.
|
|
if (a.google?.placeId && places.enabled()) { try { a.google = await places.ratingOf(a.google.placeId); } catch {} }
|
|
}
|
|
}
|
|
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 avgEng = median(last12.map(engagement));
|
|
const mix = {}, byType = {};
|
|
for (const m of media.slice(0, 25)) { mix[m.type] = (mix[m.type] || 0) + 1; (byType[m.type] ||= []).push(engagement(m)); }
|
|
// Hashtags on the account's 8 best posts: what the rivals that win actually use.
|
|
const tags = {};
|
|
for (const m of [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 8)) for (const t of m.tags || []) tags[t] = (tags[t] || 0) + 1;
|
|
const monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10);
|
|
const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null;
|
|
return {
|
|
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,
|
|
engagementByFormat: Object.fromEntries(Object.entries(byType).map(([k, v]) => [k, median(v)])),
|
|
topTags: Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 12).map(([t]) => t),
|
|
allTags: [...new Set(media.flatMap((m) => m.tags || []))],
|
|
topPosts: [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 3),
|
|
};
|
|
}
|
|
function median(xs) {
|
|
const s = [...xs].sort((x, y) => x - y);
|
|
return s.length ? Math.round(s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : null;
|
|
}
|
|
|
|
/**
|
|
* Comparison for a client: their own account (from the dashboard snapshot passed in) plus every tracked competitor.
|
|
* 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, kind: a.kind || "local", google: a.google || null, note: a.note, addedBy: a.addedBy, addedAt: a.addedAt, lastChecked: latest.date || null,
|
|
...stats({ followers: latest.followers ?? null, media: latest.media || [], history: a.snapshots || [] }),
|
|
series: (a.snapshots || []).filter((s) => s.followers != null).map((s) => ({ date: s.date, followers: s.followers })),
|
|
};
|
|
});
|
|
let you = null;
|
|
if (own) {
|
|
const media = (own.posts || []).map(mediaOf);
|
|
you = { username: own.username, own: true, google: read()[client]?.self?.google || null, ...stats({ followers: own.followers ?? null, media, history: own.history || [] }), series: (own.history || []).filter((h) => h.followers != null) };
|
|
}
|
|
return { you, competitors: accounts };
|
|
}
|
|
|
|
// ---------- AI suggestions: accounts a run found; a person adds or dismisses each one ----------
|
|
const KINDS = ["local", "benchmark"];
|
|
const clip = (s, n) => String(s || "").slice(0, n);
|
|
const suggestionView = ({ snapshot, ...s }) => ({ ...s, ...stats({ followers: snapshot?.followers ?? null, media: snapshot?.media || [], history: [] }), bio: snapshot?.bio || "" });
|
|
|
|
/** Google rating for a candidate: checked through Places when a place id is given, else what the AI read on the web (unverified). */
|
|
async function googleOf(s) {
|
|
if (s.placeId && places.enabled()) { try { return await places.ratingOf(s.placeId); } catch {} }
|
|
return s.google && Number.isFinite(+s.google.rating) ? { rating: +s.google.rating, reviews: Number.isFinite(+s.google.reviews) ? +s.google.reviews : null, verified: false } : null;
|
|
}
|
|
|
|
/** The client's own Google listing (by place id), stored so reports can put the client in the Google comparison. */
|
|
export async function setClientPlace(client, placeId) {
|
|
checkClient(client);
|
|
const google = await places.ratingOf(placeId);
|
|
const db = read();
|
|
(db[client] ||= { accounts: [] }).self = { google };
|
|
write(db);
|
|
return google;
|
|
}
|
|
|
|
/**
|
|
* Check each candidate on Instagram and store the real ones as suggestions.
|
|
* list: [{ username, kind: "local" | "benchmark", why, placeId?, 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 = await googleOf(s);
|
|
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;
|
|
}
|