- Client page → Brand kit: hashtag sets, approved phrases/CTAs, words to avoid, voice notes - New post: one-click insert of hashtag sets and phrases, live checks while typing - Checks: agency named in a client post (refused when saving; the agency's own account is exempt), avoid list, generic AI-sounding phrases, too many hashtags or emoji, platform length limits - Brand warnings also appear on drafts; MCP tools brand_kit and caption_check for the AI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
72 lines
4.7 KiB
JavaScript
72 lines
4.7 KiB
JavaScript
// Brand kit per client: saved hashtag sets, approved phrases/CTAs, words to avoid.
|
||
// checkCaption() flags problems before a post goes for approval (shared by the dashboard, the queue and the MCP tools).
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { PROJECT_ROOT } from "./env.js";
|
||
|
||
const FILE = process.env.QUORLA_BRANDKIT_FILE || path.join(PROJECT_ROOT, "data", "brandkit.json");
|
||
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
||
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
||
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||
|
||
// Phrases that read as AI-written or generic (project writing rules).
|
||
const GENERIC = ["elevate your", "unlock", "in today's fast-paced world", "look no further", "game-changer", "game changer", "take it to the next level", "dive into", "seamless", "revolutionize", "embark on"];
|
||
// White-label: the agency must never appear in a client's public post.
|
||
const AGENCY_WORDS = ["metatroncube", "metatron cube", "quorla"];
|
||
const LIMITS = { instagram: 2200, facebook: 63206, tiktok: 2200, youtube: 5000, gbp: 1500, linkedin: 3000, threads: 500, pinterest: 500, reddit: 40000 };
|
||
|
||
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;
|
||
}
|
||
const tagList = (v) => [...new Set(String(Array.isArray(v) ? v.join(" ") : v || "").split(/[\s,]+/).map((t) => t.trim()).filter(Boolean).map((t) => `#${t.replace(/^#+/, "")}`).filter((t) => /^#[\p{L}\p{N}_]{1,60}$/u.test(t)))];
|
||
const lines = (v) => (Array.isArray(v) ? v : String(v || "").split(/\n/)).map((x) => String(x).trim()).filter(Boolean).slice(0, 200);
|
||
|
||
export function get(client) {
|
||
checkClient(client);
|
||
const k = read()[client] || {};
|
||
return { hashtagSets: k.hashtagSets || [], phrases: k.phrases || [], avoid: k.avoid || [], notes: k.notes || "", updatedAt: k.updatedAt || null, updatedBy: k.updatedBy || null };
|
||
}
|
||
|
||
export function save(client, patch, by = "team") {
|
||
checkClient(client);
|
||
const db = read();
|
||
const cur = get(client);
|
||
const next = {
|
||
hashtagSets: patch.hashtagSets !== undefined ? (patch.hashtagSets || []).slice(0, 30).map((s) => ({ name: String(s.name || "").trim().slice(0, 60) || "Set", tags: tagList(s.tags).slice(0, 30) })).filter((s) => s.tags.length) : cur.hashtagSets,
|
||
phrases: patch.phrases !== undefined ? lines(patch.phrases).map((x) => x.slice(0, 300)) : cur.phrases,
|
||
avoid: patch.avoid !== undefined ? lines(patch.avoid).map((x) => x.slice(0, 80)) : cur.avoid,
|
||
notes: patch.notes !== undefined ? String(patch.notes || "").slice(0, 3000) : cur.notes,
|
||
updatedAt: new Date().toISOString(), updatedBy: by,
|
||
};
|
||
db[client] = next;
|
||
const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE);
|
||
return next;
|
||
}
|
||
|
||
const EMOJI = /\p{Extended_Pictographic}/gu;
|
||
/**
|
||
* Problems in a caption for this client: { level: "bad" | "warn", text }.
|
||
* bad = must fix (agency named in a client post, over the platform limit); warn = worth a second look.
|
||
*/
|
||
export function checkCaption(client, caption, { platforms = [] } = {}) {
|
||
const text = String(caption || "");
|
||
const low = text.toLowerCase();
|
||
const out = [];
|
||
let kit = { avoid: [] };
|
||
try { kit = get(client); } catch { /* unknown client: generic checks only */ }
|
||
// The agency's own account (its client name contains the agency name) may of course mention itself.
|
||
let ownName = "";
|
||
try { ownName = JSON.parse(fs.readFileSync(path.join(CLIENTS, client, "client.json"), "utf8")).name || ""; } catch { /* no client.json */ }
|
||
const isAgencyItself = AGENCY_WORDS.some((w) => `${ownName} ${client}`.toLowerCase().replace(/[-_]/g, " ").includes(w));
|
||
if (!isAgencyItself) for (const w of AGENCY_WORDS) if (low.includes(w)) out.push({ level: "bad", text: `Mentions “${w}”. Client posts must never name the agency.` });
|
||
for (const p of platforms) if (LIMITS[p] && text.length > LIMITS[p]) out.push({ level: "bad", text: `Too long for ${p}: ${text.length} of ${LIMITS[p]} characters.` });
|
||
for (const w of kit.avoid) if (w && low.includes(w.toLowerCase())) out.push({ level: "warn", text: `Uses “${w}”, which is on this client's avoid list.` });
|
||
for (const g of GENERIC) if (low.includes(g)) out.push({ level: "warn", text: `“${g}” sounds generic. Say it the way the client would.` });
|
||
const tags = text.match(/#[\p{L}\p{N}_]+/gu) || [];
|
||
if (tags.length > 10) out.push({ level: "warn", text: `${tags.length} hashtags. 3–8 relevant ones usually do better.` });
|
||
if ((text.match(EMOJI) || []).length > 8) out.push({ level: "warn", text: "Lots of emoji. Keep a few that add meaning." });
|
||
return out;
|
||
}
|