Competitor and trend reports in Documents, search-trends chart, Open report

- Documents tab lists competitor analyses and trend scans (they were listed
  by the server but hidden in the page)
- Competitors page: "Open report" jumps to the latest competitor report
- New report block mc:search-trends: interest over time per keyword,
  trend %, busiest month and rising searches, from stored Google Trends data

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-27 19:44:10 -04:00
parent c404ea2ef9
commit 9238b8c77a
7 changed files with 69 additions and 11 deletions

View File

@ -31,6 +31,7 @@ Goal: tell the client who they're actually up against, who's winning and *why*,
- ```` ```mc:rivals-tags ```` hashtags the winners use that the client doesn't. - ```` ```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-posts ```` the best posts in the market, as an idea bank.
- ```` ```mc:rivals-google ```` Google rating and reviews (local businesses). - ```` ```mc:rivals-google ```` Google rating and reviews (local businesses).
- ```` ```mc:search-trends ```` search demand for the gaps: first line the exact keywords of one `google_trends` call, comma-separated; second line its location. Only when you called `google_trends`; put it right after the gaps.
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). 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. Never copy a rival's content or suggest misleading comparisons; learn the pattern, not the post.

View File

@ -15,7 +15,7 @@ Works only with official data: the `competitor_lookup` tool (Instagram Business
5. Group into 3–5 patterns. A pattern needs 2+ outliers from different accounts — one viral post is luck, not a trend. 5. Group into 3–5 patterns. A pattern needs 2+ outliers from different accounts — one viral post is luck, not a trend.
6. Check the client's own last 30 posts the same way (`instagram_recent_posts`) to show which of their posts were outliers and why. 6. Check the client's own last 30 posts the same way (`instagram_recent_posts`) to show which of their posts were outliers and why.
6b. **Hashtag top posts** (`hashtag_top_posts`, skip if it says it's switched off): 3–5 specific niche or local hashtags (from the client's best posts and rivals' `topTags`; never generic tags like #food), kind=top. Instagram allows 30 different hashtags per account per week, so reuse last week's tags where you can. Treat a Reel here as an outlier when it clearly beats the other posts under the same tag; group them with the account outliers in step 5. These posts come without a username: link them, don't credit or name the poster. 6b. **Hashtag top posts** (`hashtag_top_posts`, skip if it says it's switched off): 3–5 specific niche or local hashtags (from the client's best posts and rivals' `topTags`; never generic tags like #food), kind=top. Instagram allows 30 different hashtags per account per week, so reuse last week's tags where you can. Treat a Reel here as an outlier when it clearly beats the other posts under the same tag; group them with the account outliers in step 5. These posts come without a username: link them, don't credit or name the poster.
7. **What people are searching for** (`google_trends`, skip if it says it isn't set up): 2–3 calls of up to 5 keywords each, in the client's region (location from `client.json`, e.g. "Ontario,Canada"), `past_12_months`. Use the words customers type ("emergency dentist", "odoo erp"), not industry jargon. Note keywords rising (positive `change`), the rising related searches (new questions to answer in content) and the peak months (post before the peak, not during it). Put these in their own "Search trends" section; they're Google searches, not Instagram trends. 7. **What people are searching for** (`google_trends`, skip if it says it isn't set up): 2–3 calls of up to 5 keywords each, in the client's region (location from `client.json`, e.g. "Ontario,Canada"), `past_12_months`. Use the words customers type ("emergency dentist", "odoo erp"), not industry jargon. Note keywords rising (positive `change`), the rising related searches (new questions to answer in content) and the peak months (post before the peak, not during it). Put these in their own "Search trends" section with one ```` ```mc:search-trends ```` block per call (first line the exact keywords, comma-separated; second line the location; the chart is drawn from the stored answer); they're Google searches, not Instagram trends.
8. Optional: web search "<niche> Instagram trend <month year>" and Meta's own creator/trending pages for audio and formats this month; mark anything you could not verify. 8. Optional: web search "<niche> Instagram trend <month year>" and Meta's own creator/trending pages for audio and formats this month; mark anything you could not verify.
## Output → `clients/<slug>/trends/YYYY-MM-DD.md` ## Output → `clients/<slug>/trends/YYYY-MM-DD.md`

View File

@ -6,7 +6,7 @@ import { CLIENTS_DIR, TEMPLATE_DIR } from "./config.js";
import * as meta from "../../connector/src/meta.js"; import * as meta from "../../connector/src/meta.js";
const SKIP = new Set(["_template"]); const SKIP = new Set(["_template"]);
const DOC_DIRS = { reports: "reports", calendar: "calendar" }; const DOC_DIRS = { reports: "reports", calendar: "calendar", trends: "trends" };
export const slugify = (s) => export const slugify = (s) =>
String(s).toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").replace(/&/g, "and").trim().replace(/[\s_]+/g, "-").replace(/-+/g, "-"); String(s).toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").replace(/&/g, "and").trim().replace(/[\s_]+/g, "-").replace(/-+/g, "-");
@ -66,7 +66,7 @@ export function listDocs(slug) {
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);
if (!fs.existsSync(d)) continue; if (!fs.existsSync(d)) continue;
for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : "calendar"); for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : kind);
} }
for (const f of ["brand.md", "goals.md", "constraints.md", "history.md"]) if (fs.existsSync(path.join(dir, f))) add(f, "setup"); for (const f of ["brand.md", "goals.md", "constraints.md", "history.md"]) if (fs.existsSync(path.join(dir, f))) add(f, "setup");
return docs.sort((a, b) => (a.kind === "setup") - (b.kind === "setup") || b.updatedAt.localeCompare(a.updatedAt)); return docs.sort((a, b) => (a.kind === "setup") - (b.kind === "setup") || b.updatedAt.localeCompare(a.updatedAt));

View File

@ -491,7 +491,11 @@ function ownForCompare(slug) {
history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })), history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })),
}; };
} }
app.get("/api/competitors", h(async (req) => { const c = String(req.query.client || ""); clients.safeSlug(c); return competitors.compare(c, ownForCompare(c)); })); app.get("/api/competitors", h(async (req) => {
const c = String(req.query.client || ""); clients.safeSlug(c);
const report = clients.listDocs(c).filter((d) => d.kind === "competitors").sort((a, b) => b.path.localeCompare(a.path))[0] || null;
return { ...competitors.compare(c, ownForCompare(c)), report: report && { path: report.path, title: report.title, updatedAt: report.updatedAt } };
}));
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.get("/api/competitors/:client/suggestions", h(async (req) => ({ suggestions: competitors.listSuggestions(req.params.client) })));

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react"; import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react";
import { api } from "../api.js"; import { api } from "../api.js";
@ -31,7 +31,8 @@ export default function Client() {
const nav = useNavigate(); const nav = useNavigate();
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [tab, setTab] = useState("performance"); const [params] = useSearchParams();
const [tab, setTab] = useState(params.get("tab") || "performance");
const [runOpen, setRunOpen] = useState(false); const [runOpen, setRunOpen] = useState(false);
const [linkOpen, setLinkOpen] = useState(false); const [linkOpen, setLinkOpen] = useState(false);
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
@ -119,7 +120,7 @@ export default function Client() {
</Empty> </Empty>
</Panel> </Panel>
))} ))}
{tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} /></div>} {tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} openPath={params.get("doc")} /></div>}
{tab === "runs" && <ClientRuns jobs={data.jobs} />} {tab === "runs" && <ClientRuns jobs={data.jobs} />}
{tab === "shots" && <Screenshots slug={slug} clientName={client.name} />} {tab === "shots" && <Screenshots slug={slug} clientName={client.name} />}
{tab === "brand" && <BrandKit slug={slug} />} {tab === "brand" && <BrandKit slug={slug} />}
@ -387,9 +388,9 @@ function FormatBars({ d }) {
} }
// ---------------------------------------------------------------- documents // ---------------------------------------------------------------- documents
const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" }; const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", competitors: "Competitor analysis", trends: "Trend scans", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" };
function Documents({ slug, docs, reload }) { function Documents({ slug, docs, reload, openPath }) {
const [preview, setPreview] = useState(null); const [preview, setPreview] = useState(null);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -403,6 +404,11 @@ function Documents({ slug, docs, reload }) {
setError(e); setError(e);
} }
}; };
// Deep link from other pages (e.g. Competitors → "Open report"): ?tab=docs&doc=<path>
useEffect(() => {
const doc = openPath && docs.find((d) => d.path === openPath);
if (doc) open(doc);
}, [openPath]); // eslint-disable-line react-hooks/exhaustive-deps
const exportPdf = async (doc) => { const exportPdf = async (doc) => {
setBusy(doc.path); setBusy(doc.path);
setError(null); setError(null);

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate, 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 { Check, ExternalLink, Hash, Plus, RefreshCw, Sparkles, Swords, Trash2, X } from "lucide-react"; import { Check, ExternalLink, FileBarChart, Hash, 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";
@ -199,6 +199,7 @@ export default function Competitors() {
<select id="comp-client" aria-label="Client" className="input h-9 w-auto" value={client} onChange={(e) => setParams({ client: e.target.value })}> <select id="comp-client" aria-label="Client" className="input h-9 w-auto" value={client} onChange={(e) => setParams({ client: e.target.value })}>
{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?.report && <Button icon={FileBarChart} title={`Competitor report, updated ${fmtDate(data.report.updatedAt)}`} onClick={() => navigate(`/clients/${client}?tab=docs&doc=${encodeURIComponent(data.report.path)}`)}>Open report</Button>}
{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 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>

View File

@ -5,6 +5,7 @@
// Data blocks: ```mc:kpis ```mc:reach ```mc:funnel ```mc:followers ```mc:formats ```mc:rhythm ```mc:top-posts // Data blocks: ```mc:kpis ```mc:reach ```mc:funnel ```mc:followers ```mc:formats ```mc:rhythm ```mc:top-posts
// Written blocks: ```mc:score (Area | 6/10 | why) ```mc:actions (Action | Owner | When) // Written blocks: ```mc:score (Area | 6/10 | why) ```mc:actions (Action | Owner | When)
// ```mc:screenshot (first line: shot id, then one note per line) // ```mc:screenshot (first line: shot id, then one note per line)
// ```mc:search-trends (first line: keywords of one google_trends call, optional second: location)
// Callouts: > [!WIN] title / [!FIX] / [!RISK] / [!NOTE] // Callouts: > [!WIN] title / [!FIX] / [!RISK] / [!NOTE]
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs"; import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { dirname, extname, join, resolve, sep } from "node:path"; import { dirname, extname, join, resolve, sep } from "node:path";
@ -53,7 +54,16 @@ function freshData(slug) {
}; };
const competition = competitionData(slug, snap); 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, competition }; return { slug, asOf: snap.fetchedAt, ig, fb, competition, trends: trendsData() };
}
/** Google Trends answers the runs fetched in the last week (connector/src/trends.js cache), frozen with the report. */
function trendsData() {
try {
return Object.values(readJson(join(root, "data", "trends-cache.json"))).map((v) => v.data).filter((t) => t?.keywords?.length);
} catch {
return [];
}
} }
/** The client plus tracked and suggested competitors, with the numbers the rival blocks need. */ /** The client plus tracked and suggested competitors, with the numbers the rival blocks need. */
@ -350,6 +360,41 @@ function actionsBlock(body) {
} }
const LEGACY_SHOTS = { "ig-profile": "instagram-profile", "fb-page": "facebook-page" }; const LEGACY_SHOTS = { "ig-profile": "instagram-profile", "fb-page": "facebook-page" };
/**
* ```mc:search-trends first line: the keywords of one google_trends call (comma-separated); optional second line: location.
* Draws interest over time per keyword plus the rising searches, from the stored answer (never typed numbers).
*/
const TREND_COLORS = [C.blue, "#fc6d45", C.good, C.warn, C.sky];
function trendsBlock(body, d) {
const [kwLine = "", locLine = ""] = String(body).split("\n").map((l) => l.trim()).filter(Boolean);
const want = kwLine.toLowerCase().split(",").map((k) => k.trim()).filter(Boolean);
const loc = locLine.toLowerCase();
const sets = (d?.trends || []).filter((t) => !loc || String(t.location).toLowerCase() === loc);
const t = sets.find((s) => want.every((k) => s.keywords.some((x) => x.keyword === k))) || (!want.length ? sets[0] : null);
if (!t) return `<p class="mc-nodata">Search trends${want.length ? ` for ${esc(want.join(", "))}` : ""}: no stored Google Trends answer.</p>`;
const kws = want.length ? t.keywords.filter((k) => want.includes(k.keyword)) : t.keywords;
const n = Math.max(...kws.map((k) => k.series.length));
if (n < 2) return `<p class="mc-nodata">Search trends: not enough data points.</p>`;
const W = 640, H = 190, L = 30, R = 10, T = 12, B = 30;
const x = (i) => L + (i * (W - L - R)) / (n - 1), y = (v) => T + (H - T - B) * (1 - v / 100);
const grid = [0, 50, 100].map((v) => `<line x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}" stroke="${C.line}"/><text x="${L - 6}" y="${y(v) + 3}" text-anchor="end" class="mc-ax">${v}</text>`).join("");
const lines = kws.map((k, i) => `<polyline points="${k.series.map(([, v], j) => `${x(j).toFixed(1)},${y(v).toFixed(1)}`).join(" ")}" fill="none" stroke="${TREND_COLORS[i % 5]}" stroke-width="2" stroke-linejoin="round"/>`).join("");
const dates = kws.find((k) => k.series.length === n).series.map(([from]) => from);
const labels = [0, Math.floor((n - 1) / 2), n - 1].map((i) => `<text x="${x(i)}" y="${H - 6}" text-anchor="${i === 0 ? "start" : i === n - 1 ? "end" : "middle"}" class="mc-ax">${new Date(dates[i]).toLocaleDateString("en-CA", { month: "short", year: "2-digit", timeZone: "UTC" })}</text>`).join("");
const svg = `<svg viewBox="0 0 ${W} ${H}" class="mc-chart" role="img" aria-label="Google search interest over time">${grid}${lines}${labels}</svg>`;
const month = (iso) => (iso ? new Date(iso).toLocaleDateString("en-CA", { month: "short", year: "numeric", timeZone: "UTC" }) : "—");
const rows = kws.map((k, i) => {
const ch = k.change == null ? "—" : `${k.change > 0 ? "▲" : k.change < 0 ? "▼" : ""} ${Math.abs(k.change)}%`;
const tone = k.change == null ? "" : k.change >= 10 ? ` style="color:${C.good}"` : k.change <= -10 ? ` style="color:${C.bad}"` : "";
const rising = (k.rising || []).slice(0, 5).map((r) => `<span class="mc-tag">${esc(r.query)}</span>`).join("");
return `<tr><td><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${TREND_COLORS[i % 5]};margin-right:6px"></span>${esc(k.keyword)}</td><td${tone}><b>${ch}</b></td><td>${month(k.peak?.from)}</td><td><div class="mc-tags">${rising || "—"}</div></td></tr>`;
}).join("");
const table = `<table><thead><tr><th>Search</th><th>Trend</th><th>Busiest</th><th>Rising related searches</th></tr></thead><tbody>${rows}</tbody></table>`;
return card("", "What people are searching for on Google", `${esc(String(t.location).replace(/,\s*/g, ", "))} · interest from 0 to 100, relative to the busiest point · trend = latest quarter of the period vs the one before`, svg + table);
}
function screenshotBlock(body, ctx) { function screenshotBlock(body, ctx) {
const [id = "", ...notes] = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); const [id = "", ...notes] = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const dir = join(dirname(ctx.mdPath), "screenshots"); const dir = join(dirname(ctx.mdPath), "screenshots");
@ -381,6 +426,7 @@ export function renderMarkdown(md, ctx) {
if (kind === "score") return scoreBlock(code); if (kind === "score") return scoreBlock(code);
if (kind === "actions") return actionsBlock(code); if (kind === "actions") return actionsBlock(code);
if (kind === "screenshot") return screenshotBlock(code, ctx); if (kind === "screenshot") return screenshotBlock(code, ctx);
if (kind === "search-trends") return trendsBlock(code, data());
return ctx.internal ? `<p class="mc-nodata">Unknown block mc:${esc(kind)}</p>` : ""; return ctx.internal ? `<p class="mc-nodata">Unknown block mc:${esc(kind)}</p>` : "";
}, },
blockquote(quote) { blockquote(quote) {