quorlasocial/connector/src/competitors.js
metatroncubeswdev c404ea2ef9 Research: Google Places ratings, Google Trends, Instagram hashtag top posts
- 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>
2026-10-27 18:51:54 -04:00

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;
}