@ainyc/canonry 4.8.0 → 4.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/assets/index-BdAFw2Gy.js +302 -0
- package/assets/assets/{index-DAS6pOry.css → index-CGXCbiM_.css} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-5KIFQH52.js → chunk-GAC7BSL6.js} +1 -1
- package/dist/{chunk-IJEP6LB4.js → chunk-GPJ3GLOE.js} +69 -0
- package/dist/{chunk-O4VXWABZ.js → chunk-TNW6Z3TW.js} +275 -122
- package/dist/{chunk-QEPFB7UW.js → chunk-XRDZ26OZ.js} +1 -1
- package/dist/cli.js +5 -5
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-X7CBN7S4.js → intelligence-service-TFDEKAOM.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +7 -7
- package/assets/assets/index-C2ZxtVjD.js +0 -302
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
configExists,
|
|
5
5
|
loadConfig,
|
|
6
6
|
saveConfigPatch
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-GAC7BSL6.js";
|
|
8
8
|
import {
|
|
9
9
|
DEFAULT_RUN_HISTORY_LIMIT,
|
|
10
10
|
IntelligenceService,
|
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
runs,
|
|
62
62
|
schedules,
|
|
63
63
|
usageCounters
|
|
64
|
-
} from "./chunk-
|
|
64
|
+
} from "./chunk-XRDZ26OZ.js";
|
|
65
65
|
import {
|
|
66
66
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
67
67
|
AGENT_PROVIDER_IDS,
|
|
@@ -91,6 +91,8 @@ import {
|
|
|
91
91
|
citationStateToCited,
|
|
92
92
|
competitorBatchRequestSchema,
|
|
93
93
|
contentActionLabel,
|
|
94
|
+
dedupeReportActions,
|
|
95
|
+
dedupeReportOpportunities,
|
|
94
96
|
deliveryFailed,
|
|
95
97
|
determineAnswerMentioned,
|
|
96
98
|
effectiveDomains,
|
|
@@ -135,7 +137,7 @@ import {
|
|
|
135
137
|
visibilityStateFromAnswerMentioned,
|
|
136
138
|
windowCutoff,
|
|
137
139
|
wordpressEnvSchema
|
|
138
|
-
} from "./chunk-
|
|
140
|
+
} from "./chunk-GPJ3GLOE.js";
|
|
139
141
|
|
|
140
142
|
// src/telemetry.ts
|
|
141
143
|
import crypto from "crypto";
|
|
@@ -3214,71 +3216,6 @@ function renderHeaderLocationFragment(location) {
|
|
|
3214
3216
|
if (!location) return " \xB7 No market set";
|
|
3215
3217
|
return ` \xB7 Market: ${escapeHtml(locationDisplay(location))}`;
|
|
3216
3218
|
}
|
|
3217
|
-
var REPORT_INTENT_STOPWORDS = /* @__PURE__ */ new Set([
|
|
3218
|
-
"a",
|
|
3219
|
-
"an",
|
|
3220
|
-
"and",
|
|
3221
|
-
"for",
|
|
3222
|
-
"from",
|
|
3223
|
-
"in",
|
|
3224
|
-
"near",
|
|
3225
|
-
"of",
|
|
3226
|
-
"on",
|
|
3227
|
-
"or",
|
|
3228
|
-
"the",
|
|
3229
|
-
"to"
|
|
3230
|
-
]);
|
|
3231
|
-
function reportIntentModifiers(report) {
|
|
3232
|
-
const location = report.meta.location;
|
|
3233
|
-
if (!location) return /* @__PURE__ */ new Set();
|
|
3234
|
-
return new Set(
|
|
3235
|
-
[location.label, location.city, location.region, location.country].flatMap(tokenizeReportIntent).map(normalizeReportIntentToken).filter(Boolean)
|
|
3236
|
-
);
|
|
3237
|
-
}
|
|
3238
|
-
function dedupeReportActions(report, actions) {
|
|
3239
|
-
const modifiers = reportIntentModifiers(report);
|
|
3240
|
-
if (actions.length <= 1 || modifiers.size === 0) return [...actions];
|
|
3241
|
-
const seen = /* @__PURE__ */ new Set();
|
|
3242
|
-
const result = [];
|
|
3243
|
-
for (const action of actions) {
|
|
3244
|
-
if (action.category !== "content") {
|
|
3245
|
-
result.push(action);
|
|
3246
|
-
continue;
|
|
3247
|
-
}
|
|
3248
|
-
const key = reportIntentKey(extractActionQuery(action), modifiers);
|
|
3249
|
-
if (!key || seen.has(key)) continue;
|
|
3250
|
-
seen.add(key);
|
|
3251
|
-
result.push(action);
|
|
3252
|
-
}
|
|
3253
|
-
return result;
|
|
3254
|
-
}
|
|
3255
|
-
function dedupeReportOpportunities(report) {
|
|
3256
|
-
const modifiers = reportIntentModifiers(report);
|
|
3257
|
-
const opportunities = report.contentOpportunities;
|
|
3258
|
-
if (opportunities.length <= 1 || modifiers.size === 0) return opportunities;
|
|
3259
|
-
const seen = /* @__PURE__ */ new Set();
|
|
3260
|
-
return opportunities.filter((opportunity) => {
|
|
3261
|
-
const key = reportIntentKey(opportunity.query, modifiers);
|
|
3262
|
-
if (!key || seen.has(key)) return false;
|
|
3263
|
-
seen.add(key);
|
|
3264
|
-
return true;
|
|
3265
|
-
});
|
|
3266
|
-
}
|
|
3267
|
-
function extractActionQuery(action) {
|
|
3268
|
-
return action.title.match(/"([^"]+)"/)?.[1] ?? action.successMetric.match(/"([^"]+)"/)?.[1] ?? action.title;
|
|
3269
|
-
}
|
|
3270
|
-
function reportIntentKey(value, modifiers) {
|
|
3271
|
-
const tokens = tokenizeReportIntent(value).map(normalizeReportIntentToken).filter(Boolean).filter((token) => !REPORT_INTENT_STOPWORDS.has(token)).filter((token) => !modifiers.has(token));
|
|
3272
|
-
return [...new Set(tokens)].sort().join(" ");
|
|
3273
|
-
}
|
|
3274
|
-
function tokenizeReportIntent(value) {
|
|
3275
|
-
return value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
3276
|
-
}
|
|
3277
|
-
function normalizeReportIntentToken(token) {
|
|
3278
|
-
if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
|
|
3279
|
-
if (token.length > 4 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
|
|
3280
|
-
return token;
|
|
3281
|
-
}
|
|
3282
3219
|
function renderLocationCard(report) {
|
|
3283
3220
|
const location = report.meta.location;
|
|
3284
3221
|
const handling = report.meta.providerLocationHandling;
|
|
@@ -3287,7 +3224,7 @@ function renderLocationCard(report) {
|
|
|
3287
3224
|
const weakLocationProviders = handling.filter((h) => h.treatment === "ignored" || h.treatment === "browser-geo").map((h) => h.provider);
|
|
3288
3225
|
const marketValue = location ? locationDisplay(location) : "No market set";
|
|
3289
3226
|
const notIncluded = otherLocations.length > 0 ? compactInlineList(otherLocations, 4) : "None";
|
|
3290
|
-
const interpretation = location ? otherLocations.length > 0 ? `${otherLocations.length} configured ${pluralize(otherLocations.length, "market")} still ${otherLocations.length === 1 ? "needs" : "need"} a matching
|
|
3227
|
+
const interpretation = location ? otherLocations.length > 0 ? `${otherLocations.length} configured ${pluralize(otherLocations.length, "market")} still ${otherLocations.length === 1 ? "needs" : "need"} a matching check before cross-market recommendations.` : "Single-market report; findings can be read as the current market view." : "No geographic hint was attached to this check; read findings as default-market or national results.";
|
|
3291
3228
|
const providerCopy = handling.length > 0 ? weakLocationProviders.length > 0 ? `${weakLocationProviders.length} ${pluralize(weakLocationProviders.length, "provider")} need a closer location check.` : `${handling.length} ${pluralize(handling.length, "provider")} received the market context.` : "No provider-level location metadata is available for this report.";
|
|
3292
3229
|
const warning = weakLocationProviders.length > 0 ? `<div class="scope-warning">
|
|
3293
3230
|
<strong>Location handling needs review</strong>
|
|
@@ -3297,7 +3234,7 @@ function renderLocationCard(report) {
|
|
|
3297
3234
|
<h3>Market Scope</h3>
|
|
3298
3235
|
<div class="market-scope-grid">
|
|
3299
3236
|
<div class="scope-tile">
|
|
3300
|
-
<div class="scope-label">Current
|
|
3237
|
+
<div class="scope-label">Current check</div>
|
|
3301
3238
|
<div class="scope-value">${escapeHtml(marketValue)}</div>
|
|
3302
3239
|
<div class="scope-copy">All findings below are scoped to this run.</div>
|
|
3303
3240
|
</div>
|
|
@@ -3323,13 +3260,13 @@ function renderExecutiveSummary(report) {
|
|
|
3323
3260
|
const citedFragment = s.totalQueryCount > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
|
|
3324
3261
|
const mentionedFragment = s.totalQueryCount > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
|
|
3325
3262
|
const headlineTitle = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` : "No AI citation data yet";
|
|
3326
|
-
const headlineSubtitle = s.totalQueryCount > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a
|
|
3263
|
+
const headlineSubtitle = s.totalQueryCount > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a check to populate the first citation and mention baseline.";
|
|
3327
3264
|
const priorityActions = report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan;
|
|
3328
3265
|
const actionCount = dedupeReportActions(report, priorityActions).length;
|
|
3329
3266
|
const heroHtml = `<div class="executive-hero">
|
|
3330
3267
|
<div class="headline-card">
|
|
3331
3268
|
<div>
|
|
3332
|
-
<div class="hero-kicker">Latest AI visibility
|
|
3269
|
+
<div class="hero-kicker">Latest AI visibility check</div>
|
|
3333
3270
|
<div class="hero-title">${escapeHtml(headlineTitle)}</div>
|
|
3334
3271
|
</div>
|
|
3335
3272
|
<div class="hero-subtitle">${escapeHtml(headlineSubtitle)}</div>
|
|
@@ -3407,6 +3344,107 @@ function renderExecutiveSummary(report) {
|
|
|
3407
3344
|
heroHtml + metricsHtml + findingsHtml + locationHtml
|
|
3408
3345
|
);
|
|
3409
3346
|
}
|
|
3347
|
+
function deltaToneClass(direction) {
|
|
3348
|
+
if (direction === "up") return "tone-positive";
|
|
3349
|
+
if (direction === "down") return "tone-negative";
|
|
3350
|
+
return "";
|
|
3351
|
+
}
|
|
3352
|
+
function deltaArrow(direction) {
|
|
3353
|
+
if (direction === "up") return "\u2191";
|
|
3354
|
+
if (direction === "down") return "\u2193";
|
|
3355
|
+
return "\u2192";
|
|
3356
|
+
}
|
|
3357
|
+
function renderRateDeltaTile(label, delta, unit) {
|
|
3358
|
+
if (!delta) {
|
|
3359
|
+
return `<div class="metric"><div class="label">${escapeHtml(label)}</div><div class="value">\u2014</div><div class="delta">No prior data</div></div>`;
|
|
3360
|
+
}
|
|
3361
|
+
const valueSuffix = unit === "%" ? "%" : "";
|
|
3362
|
+
const deltaSign = delta.deltaAbs > 0 ? "+" : "";
|
|
3363
|
+
const deltaText = `${deltaSign}${delta.deltaAbs.toFixed(unit === "%" ? 1 : 0)}${valueSuffix} vs ${delta.prior}${valueSuffix}`;
|
|
3364
|
+
return `<div class="metric">
|
|
3365
|
+
<div class="label">${escapeHtml(label)}</div>
|
|
3366
|
+
<div class="value ${deltaToneClass(delta.direction)}">${delta.current}${valueSuffix} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
|
|
3367
|
+
<div class="delta">${deltaText}</div>
|
|
3368
|
+
</div>`;
|
|
3369
|
+
}
|
|
3370
|
+
function renderTrafficDeltaTile(label, delta, countLabel) {
|
|
3371
|
+
if (!delta) {
|
|
3372
|
+
return `<div class="metric"><div class="label">${escapeHtml(label)}</div><div class="value">\u2014</div><div class="delta">Not enough trend data</div></div>`;
|
|
3373
|
+
}
|
|
3374
|
+
const deltaSign = delta.deltaAbs > 0 ? "+" : "";
|
|
3375
|
+
const deltaText = `${deltaSign}${formatNumber(delta.deltaAbs)} ${countLabel} vs prior ${WHATS_CHANGED_PERIOD_DAYS} days`;
|
|
3376
|
+
return `<div class="metric">
|
|
3377
|
+
<div class="label">${escapeHtml(label)}</div>
|
|
3378
|
+
<div class="value ${deltaToneClass(delta.direction)}">${formatNumber(delta.current)} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
|
|
3379
|
+
<div class="delta">${deltaText}</div>
|
|
3380
|
+
</div>`;
|
|
3381
|
+
}
|
|
3382
|
+
var WHATS_CHANGED_PERIOD_DAYS = 14;
|
|
3383
|
+
function renderProviderMovements(movements) {
|
|
3384
|
+
const meaningful = movements.filter((m) => m.direction !== "flat");
|
|
3385
|
+
if (meaningful.length === 0) return "";
|
|
3386
|
+
const rows = meaningful.map((m) => {
|
|
3387
|
+
const sign = m.deltaAbs > 0 ? "+" : "";
|
|
3388
|
+
return `<tr>
|
|
3389
|
+
<td>${escapeHtml(m.provider)}</td>
|
|
3390
|
+
<td class="numeric">${m.prior}%</td>
|
|
3391
|
+
<td class="numeric">${m.current}%</td>
|
|
3392
|
+
<td class="numeric ${deltaToneClass(m.direction)}">${sign}${m.deltaAbs.toFixed(1)}% ${deltaArrow(m.direction)}</td>
|
|
3393
|
+
</tr>`;
|
|
3394
|
+
}).join("");
|
|
3395
|
+
return `<div class="chart-card"><h3>AI engine movements</h3>
|
|
3396
|
+
<table class="report-table">
|
|
3397
|
+
<thead><tr><th>Engine</th><th class="numeric">Prior</th><th class="numeric">Current</th><th class="numeric">Change</th></tr></thead>
|
|
3398
|
+
<tbody>${rows}</tbody>
|
|
3399
|
+
</table>
|
|
3400
|
+
</div>`;
|
|
3401
|
+
}
|
|
3402
|
+
function renderWinsLosses(insights2, heading, emptyMessage) {
|
|
3403
|
+
if (insights2.length === 0) {
|
|
3404
|
+
return `<div class="chart-card"><h3>${escapeHtml(heading)}</h3>
|
|
3405
|
+
<p class="section-intro">${escapeHtml(emptyMessage)}</p>
|
|
3406
|
+
</div>`;
|
|
3407
|
+
}
|
|
3408
|
+
const rows = insights2.map((i) => {
|
|
3409
|
+
const tone = severityTone(i.severity);
|
|
3410
|
+
const countChip = i.instanceCount > 1 ? ` <span class="badge tone-neutral">\xD7 ${i.instanceCount}</span>` : "";
|
|
3411
|
+
return `<tr>
|
|
3412
|
+
<td><span class="badge tone-${tone}">${escapeHtml(reportSeverityLabel(i.severity))}</span></td>
|
|
3413
|
+
<td>${escapeHtml(i.title)}${countChip}</td>
|
|
3414
|
+
<td>${escapeHtml(i.query)}</td>
|
|
3415
|
+
<td>${escapeHtml(i.provider)}</td>
|
|
3416
|
+
</tr>`;
|
|
3417
|
+
}).join("");
|
|
3418
|
+
return `<div class="chart-card"><h3>${escapeHtml(heading)}</h3>
|
|
3419
|
+
<table class="report-table">
|
|
3420
|
+
<thead><tr><th>Severity</th><th>Title</th><th>Query</th><th>Provider</th></tr></thead>
|
|
3421
|
+
<tbody>${rows}</tbody>
|
|
3422
|
+
</table>
|
|
3423
|
+
</div>`;
|
|
3424
|
+
}
|
|
3425
|
+
function renderWhatsChanged(report) {
|
|
3426
|
+
const w = report.whatsChanged;
|
|
3427
|
+
if (!w.enoughHistory && !w.gscClicksDelta && !w.aiReferralsDelta && w.wins.length === 0 && w.regressions.length === 0) {
|
|
3428
|
+
return section(
|
|
3429
|
+
{ id: "whats-changed", eyebrow: "Section 2", title: "What's Changed", intro: w.headline },
|
|
3430
|
+
renderEmpty("Trends will appear after a few more checks.")
|
|
3431
|
+
);
|
|
3432
|
+
}
|
|
3433
|
+
const rateTiles = `<div class="metric-grid">
|
|
3434
|
+
${renderRateDeltaTile("Citation rate", w.citationRate, "%")}
|
|
3435
|
+
${renderRateDeltaTile("Mention rate", w.mentionRate, "%")}
|
|
3436
|
+
${renderRateDeltaTile("Cited queries", w.citedQueryCount, "count")}
|
|
3437
|
+
${renderTrafficDeltaTile("GSC clicks", w.gscClicksDelta, "clicks")}
|
|
3438
|
+
${renderTrafficDeltaTile("AI referral sessions", w.aiReferralsDelta, "sessions")}
|
|
3439
|
+
</div>`;
|
|
3440
|
+
const movements = renderProviderMovements(w.providerMovements);
|
|
3441
|
+
const wins = renderWinsLosses(w.wins, "Wins", "No new gains in the latest check.");
|
|
3442
|
+
const regressions = renderWinsLosses(w.regressions, "Regressions", "No new regressions in the latest check.");
|
|
3443
|
+
return section(
|
|
3444
|
+
{ id: "whats-changed", eyebrow: "Section 2", title: "What's Changed", intro: w.headline },
|
|
3445
|
+
`${rateTiles}${movements}${wins}${regressions}`
|
|
3446
|
+
);
|
|
3447
|
+
}
|
|
3410
3448
|
function renderProviderBars(rates) {
|
|
3411
3449
|
if (rates.length === 0) return "";
|
|
3412
3450
|
const max = Math.max(...rates.map((r) => r.citationRate), 100);
|
|
@@ -3435,7 +3473,7 @@ function renderProviderBars(rates) {
|
|
|
3435
3473
|
}
|
|
3436
3474
|
function renderCitationMatrix(scorecard) {
|
|
3437
3475
|
if (scorecard.queries.length === 0 || scorecard.providers.length === 0) {
|
|
3438
|
-
return renderEmpty("Run a
|
|
3476
|
+
return renderEmpty("Run a check to populate the citation matrix.");
|
|
3439
3477
|
}
|
|
3440
3478
|
const headers = scorecard.providers.map((p) => `<th>${escapeHtml(p)}</th>`).join("");
|
|
3441
3479
|
const rows = scorecard.queries.map((q, qi) => {
|
|
@@ -3462,7 +3500,7 @@ function renderCitationScorecard(report) {
|
|
|
3462
3500
|
${renderCitationMatrix(report.citationScorecard)}
|
|
3463
3501
|
`;
|
|
3464
3502
|
return section(
|
|
3465
|
-
{ id: "citation-scorecard", eyebrow: "Section
|
|
3503
|
+
{ id: "citation-scorecard", eyebrow: "Section 3", title: "Citation Scorecard", intro: "Per-engine citation and mention coverage from the latest check." },
|
|
3466
3504
|
body
|
|
3467
3505
|
);
|
|
3468
3506
|
}
|
|
@@ -3510,8 +3548,8 @@ function renderCompetitorLandscape(report) {
|
|
|
3510
3548
|
const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0;
|
|
3511
3549
|
if (noCitationData && noMentionData) {
|
|
3512
3550
|
return section(
|
|
3513
|
-
{ id: "competitor-landscape", eyebrow: "Section
|
|
3514
|
-
renderEmpty("No competitor data yet. Add competitors and run a
|
|
3551
|
+
{ id: "competitor-landscape", eyebrow: "Section 4", title: "Competitor Landscape" },
|
|
3552
|
+
renderEmpty("No competitor data yet. Add competitors and run a check.")
|
|
3515
3553
|
);
|
|
3516
3554
|
}
|
|
3517
3555
|
const mentionByDomain = new Map(mentionLandscape.competitors.map((m) => [m.domain, m]));
|
|
@@ -3542,7 +3580,7 @@ function renderCompetitorLandscape(report) {
|
|
|
3542
3580
|
return section(
|
|
3543
3581
|
{
|
|
3544
3582
|
id: "competitor-landscape",
|
|
3545
|
-
eyebrow: "Section
|
|
3583
|
+
eyebrow: "Section 4",
|
|
3546
3584
|
title: "Competitor Landscape",
|
|
3547
3585
|
intro: "Who AI engines cite and mention instead of the client."
|
|
3548
3586
|
},
|
|
@@ -3609,8 +3647,8 @@ function renderAiSourceOrigin(report) {
|
|
|
3609
3647
|
const origin = report.aiSourceOrigin;
|
|
3610
3648
|
if (origin.categories.length === 0 && origin.topDomains.length === 0) {
|
|
3611
3649
|
return section(
|
|
3612
|
-
{ id: "ai-source-origin", eyebrow: "Section
|
|
3613
|
-
renderEmpty("No source data yet. Run a
|
|
3650
|
+
{ id: "ai-source-origin", eyebrow: "Section 5", title: "AI Citation Sources" },
|
|
3651
|
+
renderEmpty("No source data yet. Run a check first.")
|
|
3614
3652
|
);
|
|
3615
3653
|
}
|
|
3616
3654
|
const competitorBucket = origin.categories.find((c) => c.category === "competitor");
|
|
@@ -3630,9 +3668,9 @@ function renderAiSourceOrigin(report) {
|
|
|
3630
3668
|
return section(
|
|
3631
3669
|
{
|
|
3632
3670
|
id: "ai-source-origin",
|
|
3633
|
-
eyebrow: "Section
|
|
3671
|
+
eyebrow: "Section 5",
|
|
3634
3672
|
title: "AI Citation Sources",
|
|
3635
|
-
intro: "External domains AI engines
|
|
3673
|
+
intro: "External domains AI engines cited most in the latest check."
|
|
3636
3674
|
},
|
|
3637
3675
|
`${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
|
|
3638
3676
|
);
|
|
@@ -3673,7 +3711,7 @@ function renderGsc(report) {
|
|
|
3673
3711
|
const gsc = report.gsc;
|
|
3674
3712
|
if (!gsc) {
|
|
3675
3713
|
return section(
|
|
3676
|
-
{ id: "gsc", eyebrow: "Section
|
|
3714
|
+
{ id: "gsc", eyebrow: "Section 6", title: "GSC Performance" },
|
|
3677
3715
|
renderEmpty("Connect Google Search Console to populate this section.")
|
|
3678
3716
|
);
|
|
3679
3717
|
}
|
|
@@ -3716,7 +3754,7 @@ function renderGsc(report) {
|
|
|
3716
3754
|
}
|
|
3717
3755
|
const dateRange = gscDateRange(report);
|
|
3718
3756
|
return section(
|
|
3719
|
-
{ id: "gsc", eyebrow: "Section
|
|
3757
|
+
{ id: "gsc", eyebrow: "Section 6", title: "GSC Performance", intro: `Search demand signals to compare against AI visibility${dateRange ? ` for ${dateRange}` : ""}.` },
|
|
3720
3758
|
`<div class="metric-grid">
|
|
3721
3759
|
<div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
|
|
3722
3760
|
<div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
|
|
@@ -3738,7 +3776,7 @@ function renderGa(report) {
|
|
|
3738
3776
|
const ga = report.ga;
|
|
3739
3777
|
if (!ga) {
|
|
3740
3778
|
return section(
|
|
3741
|
-
{ id: "ga", eyebrow: "Section
|
|
3779
|
+
{ id: "ga", eyebrow: "Section 7", title: "GA4 Traffic" },
|
|
3742
3780
|
renderEmpty("Connect Google Analytics 4 to populate this section.")
|
|
3743
3781
|
);
|
|
3744
3782
|
}
|
|
@@ -3760,7 +3798,7 @@ function renderGa(report) {
|
|
|
3760
3798
|
"sessions"
|
|
3761
3799
|
);
|
|
3762
3800
|
return section(
|
|
3763
|
-
{ id: "ga", eyebrow: "Section
|
|
3801
|
+
{ id: "ga", eyebrow: "Section 7", title: "GA4 Traffic", intro: `Site traffic from ${formatDate(ga.periodStart)} to ${formatDate(ga.periodEnd)}.` },
|
|
3764
3802
|
`<div class="metric-grid">
|
|
3765
3803
|
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
|
|
3766
3804
|
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
|
|
@@ -3779,7 +3817,7 @@ function renderSocial(report) {
|
|
|
3779
3817
|
const social = report.socialReferrals;
|
|
3780
3818
|
if (!social) {
|
|
3781
3819
|
return section(
|
|
3782
|
-
{ id: "social-referrals", eyebrow: "Section
|
|
3820
|
+
{ id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals" },
|
|
3783
3821
|
renderEmpty("No social referral data yet.")
|
|
3784
3822
|
);
|
|
3785
3823
|
}
|
|
@@ -3800,7 +3838,7 @@ function renderSocial(report) {
|
|
|
3800
3838
|
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
3801
3839
|
</tr>`).join("");
|
|
3802
3840
|
return section(
|
|
3803
|
-
{ id: "social-referrals", eyebrow: "Section
|
|
3841
|
+
{ id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals", intro: "Social traffic split by channel and campaign." },
|
|
3804
3842
|
`<div class="metric-grid">
|
|
3805
3843
|
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
|
|
3806
3844
|
<div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
|
|
@@ -3819,7 +3857,7 @@ function renderAiReferrals(report) {
|
|
|
3819
3857
|
const ai = report.aiReferrals;
|
|
3820
3858
|
if (!ai) {
|
|
3821
3859
|
return section(
|
|
3822
|
-
{ id: "ai-referrals", eyebrow: "Section
|
|
3860
|
+
{ id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic" },
|
|
3823
3861
|
renderEmpty("No AI referral traffic detected yet.")
|
|
3824
3862
|
);
|
|
3825
3863
|
}
|
|
@@ -3845,7 +3883,7 @@ function renderAiReferrals(report) {
|
|
|
3845
3883
|
"AI referral sessions over time"
|
|
3846
3884
|
);
|
|
3847
3885
|
return section(
|
|
3848
|
-
{ id: "ai-referrals", eyebrow: "Section
|
|
3886
|
+
{ id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic", intro: "Traffic arriving from AI answer engines." },
|
|
3849
3887
|
`<div class="metric-grid">
|
|
3850
3888
|
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
|
|
3851
3889
|
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
|
|
@@ -3864,7 +3902,7 @@ function renderIndexingHealth(report) {
|
|
|
3864
3902
|
const ih = report.indexingHealth;
|
|
3865
3903
|
if (!ih) {
|
|
3866
3904
|
return section(
|
|
3867
|
-
{ id: "indexing-health", eyebrow: "Section
|
|
3905
|
+
{ id: "indexing-health", eyebrow: "Section 10", title: "Indexing Health" },
|
|
3868
3906
|
renderEmpty("Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.")
|
|
3869
3907
|
);
|
|
3870
3908
|
}
|
|
@@ -3886,7 +3924,7 @@ function renderIndexingHealth(report) {
|
|
|
3886
3924
|
}).join("");
|
|
3887
3925
|
const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
|
|
3888
3926
|
return section(
|
|
3889
|
-
{ id: "indexing-health", eyebrow: "Section
|
|
3927
|
+
{ id: "indexing-health", eyebrow: "Section 10", title: "Indexing Health", intro: `Pages absent from ${ih.provider === "google" ? "Google" : "Bing"} are harder for AI engines to retrieve.` },
|
|
3890
3928
|
`<div class="metric-grid">
|
|
3891
3929
|
<div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
|
|
3892
3930
|
<div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
|
|
@@ -3903,14 +3941,14 @@ function renderCitationsTrend(report) {
|
|
|
3903
3941
|
const trend = report.citationsTrend;
|
|
3904
3942
|
if (trend.length === 0) {
|
|
3905
3943
|
return section(
|
|
3906
|
-
{ id: "citations-trend", eyebrow: "Section
|
|
3907
|
-
renderEmpty("Run multiple
|
|
3944
|
+
{ id: "citations-trend", eyebrow: "Section 11", title: "Citations Over Time" },
|
|
3945
|
+
renderEmpty("Run multiple checks to see a trend.")
|
|
3908
3946
|
);
|
|
3909
3947
|
}
|
|
3910
3948
|
if (isTrendBaseline(trend)) {
|
|
3911
3949
|
return section(
|
|
3912
|
-
{ id: "citations-trend", eyebrow: "Section
|
|
3913
|
-
renderEmpty(`
|
|
3950
|
+
{ id: "citations-trend", eyebrow: "Section 11", title: "Citations Over Time" },
|
|
3951
|
+
renderEmpty(`Building baseline (${trend.length} of ${MIN_TREND_POINTS} checks completed). Trend will appear once more checks are recorded.`)
|
|
3914
3952
|
);
|
|
3915
3953
|
}
|
|
3916
3954
|
const chart = renderLineChart(
|
|
@@ -3926,11 +3964,11 @@ function renderCitationsTrend(report) {
|
|
|
3926
3964
|
<td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
|
|
3927
3965
|
</tr>`).join("");
|
|
3928
3966
|
return section(
|
|
3929
|
-
{ id: "citations-trend", eyebrow: "Section
|
|
3967
|
+
{ id: "citations-trend", eyebrow: "Section 11", title: "Citations Over Time", intro: "Citation coverage across recent checks." },
|
|
3930
3968
|
`${chart}
|
|
3931
|
-
<div class="chart-card"><h3>
|
|
3969
|
+
<div class="chart-card"><h3>Check-by-check breakdown</h3>
|
|
3932
3970
|
<table class="report-table">
|
|
3933
|
-
<thead><tr><th>
|
|
3971
|
+
<thead><tr><th>Check</th><th class="numeric">Cited queries</th><th>Per-engine rates</th></tr></thead>
|
|
3934
3972
|
<tbody>${rows}</tbody>
|
|
3935
3973
|
</table>
|
|
3936
3974
|
</div>`
|
|
@@ -3940,8 +3978,8 @@ function renderInsights(report) {
|
|
|
3940
3978
|
const list = report.insights;
|
|
3941
3979
|
if (list.length === 0) {
|
|
3942
3980
|
return section(
|
|
3943
|
-
{ id: "insights", eyebrow: "Section
|
|
3944
|
-
renderEmpty("No insights yet \u2014 run a
|
|
3981
|
+
{ id: "insights", eyebrow: "Section 12", title: "Insights & Alerts" },
|
|
3982
|
+
renderEmpty("No insights yet \u2014 run a check to generate alerts.")
|
|
3945
3983
|
);
|
|
3946
3984
|
}
|
|
3947
3985
|
const haveDeduped = list.every((i) => typeof i.instanceCount === "number");
|
|
@@ -3957,7 +3995,7 @@ function renderInsights(report) {
|
|
|
3957
3995
|
</tr>`;
|
|
3958
3996
|
}).join("");
|
|
3959
3997
|
return section(
|
|
3960
|
-
{ id: "insights", eyebrow: "Section
|
|
3998
|
+
{ id: "insights", eyebrow: "Section 12", title: "Insights & Alerts", intro: "Regressions, gains, and recurring alerts ordered by severity." },
|
|
3961
3999
|
`<table class="report-table insights-table">
|
|
3962
4000
|
<thead><tr>
|
|
3963
4001
|
<th class="col-severity">Severity</th>
|
|
@@ -3999,7 +4037,7 @@ function renderOpportunities(report) {
|
|
|
3999
4037
|
return section(
|
|
4000
4038
|
{
|
|
4001
4039
|
id: "content-opportunities",
|
|
4002
|
-
eyebrow: "Section
|
|
4040
|
+
eyebrow: "Section 13",
|
|
4003
4041
|
title: "Content Opportunities",
|
|
4004
4042
|
intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger."
|
|
4005
4043
|
},
|
|
@@ -4025,7 +4063,7 @@ function renderContentGaps(report) {
|
|
|
4025
4063
|
return section(
|
|
4026
4064
|
{
|
|
4027
4065
|
id: "content-gaps",
|
|
4028
|
-
eyebrow: "Section
|
|
4066
|
+
eyebrow: "Section 14",
|
|
4029
4067
|
title: "Content Gaps",
|
|
4030
4068
|
intro: "Tracked queries where competitors are cited and the client is missing."
|
|
4031
4069
|
},
|
|
@@ -4039,7 +4077,7 @@ function renderRecommendedNextSteps(report) {
|
|
|
4039
4077
|
const steps = report.recommendedNextSteps;
|
|
4040
4078
|
if (steps.length === 0) {
|
|
4041
4079
|
return section(
|
|
4042
|
-
{ id: "recommended-next-steps", eyebrow: "Section
|
|
4080
|
+
{ id: "recommended-next-steps", eyebrow: "Section 15", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
|
|
4043
4081
|
renderEmpty("No outstanding actions.")
|
|
4044
4082
|
);
|
|
4045
4083
|
}
|
|
@@ -4050,7 +4088,7 @@ function renderRecommendedNextSteps(report) {
|
|
|
4050
4088
|
<span class="rationale">${escapeHtml(s.rationale)}</span>
|
|
4051
4089
|
</div>`).join("");
|
|
4052
4090
|
return section(
|
|
4053
|
-
{ id: "recommended-next-steps", eyebrow: "Section
|
|
4091
|
+
{ id: "recommended-next-steps", eyebrow: "Section 15", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
|
|
4054
4092
|
`<div class="steps">${items}</div>`
|
|
4055
4093
|
);
|
|
4056
4094
|
}
|
|
@@ -4195,10 +4233,12 @@ function renderReportHtml(report, opts = {}) {
|
|
|
4195
4233
|
const title = opts.title ?? `Canonry ${audience} report \u2014 ${report.meta.project.displayName}`;
|
|
4196
4234
|
const sections = audience === "client" ? [
|
|
4197
4235
|
renderClientSummary(report),
|
|
4236
|
+
renderWhatsChanged(report),
|
|
4198
4237
|
renderAudienceActionPlan(report, "client"),
|
|
4199
4238
|
renderClientEvidenceSummary(report)
|
|
4200
4239
|
].join("\n") : [
|
|
4201
4240
|
renderExecutiveSummary(report),
|
|
4241
|
+
renderWhatsChanged(report),
|
|
4202
4242
|
renderAudienceActionPlan(report, "agency"),
|
|
4203
4243
|
renderAgencyDiagnostics(report),
|
|
4204
4244
|
renderCitationScorecard(report),
|
|
@@ -5020,7 +5060,7 @@ function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount,
|
|
|
5020
5060
|
const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
|
|
5021
5061
|
let detail;
|
|
5022
5062
|
if (trendBaseline) {
|
|
5023
|
-
detail = `
|
|
5063
|
+
detail = `Building baseline (${trendsPoints.length} of ${MIN_TREND_POINTS} checks completed).`;
|
|
5024
5064
|
} else {
|
|
5025
5065
|
switch (trend) {
|
|
5026
5066
|
case "up":
|
|
@@ -5120,7 +5160,7 @@ function buildReportActionPlan(input) {
|
|
|
5120
5160
|
horizon: "immediate",
|
|
5121
5161
|
category: "competitors",
|
|
5122
5162
|
title: "Define the competitor set Canonry should benchmark against",
|
|
5123
|
-
action: "Review the recurring external source domains and add the true competitors before the next
|
|
5163
|
+
action: "Review the recurring external source domains and add the true competitors before the next check.",
|
|
5124
5164
|
why: [
|
|
5125
5165
|
"The report can identify repeated external sources, but it cannot separate competitors from publishers until competitors are configured.",
|
|
5126
5166
|
"A clean competitor set makes future share-of-voice and content-gap reporting easier to explain to clients."
|
|
@@ -5154,7 +5194,7 @@ function buildReportActionPlan(input) {
|
|
|
5154
5194
|
action: opportunity.ourBestPage ? `${verb} ${target} so it directly answers the tracked query and cites the strongest supporting evidence.` : `${verb} ${target} that directly answers the query and earns citations from AI answer engines.`,
|
|
5155
5195
|
why: opportunity.drivers.length > 0 ? opportunity.drivers : ["Canonry ranked this as a content opportunity from search-demand and citation evidence."],
|
|
5156
5196
|
evidence,
|
|
5157
|
-
successMetric: `A future
|
|
5197
|
+
successMetric: `A future check cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`,
|
|
5158
5198
|
confidence: opportunity.actionConfidence
|
|
5159
5199
|
});
|
|
5160
5200
|
}
|
|
@@ -5194,7 +5234,7 @@ function buildReportActionPlan(input) {
|
|
|
5194
5234
|
"This points the agency toward provider-specific evidence gaps instead of a generic content recommendation."
|
|
5195
5235
|
],
|
|
5196
5236
|
evidence: zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount} cited query-provider pairs`),
|
|
5197
|
-
successMetric: "At least one zero-citation
|
|
5237
|
+
successMetric: "At least one zero-citation engine cites the client on a priority query in a later check.",
|
|
5198
5238
|
confidence: "high"
|
|
5199
5239
|
});
|
|
5200
5240
|
}
|
|
@@ -5258,13 +5298,13 @@ function buildReportActionPlan(input) {
|
|
|
5258
5298
|
horizon: "medium-term",
|
|
5259
5299
|
category: "location",
|
|
5260
5300
|
title: "Keep location-scoped reporting separate by market",
|
|
5261
|
-
action: "Run and compare separate
|
|
5301
|
+
action: "Run and compare separate checks for each configured location before making market-level recommendations.",
|
|
5262
5302
|
why: [
|
|
5263
5303
|
"A multi-location client can appear differently by market.",
|
|
5264
5304
|
"Keeping each report location-scoped avoids mixing Florida and Michigan evidence in the same client story."
|
|
5265
5305
|
],
|
|
5266
5306
|
evidence,
|
|
5267
|
-
successMetric: "Each configured market has its own current
|
|
5307
|
+
successMetric: "Each configured market has its own current check and trend before cross-market decisions are made.",
|
|
5268
5308
|
confidence: "high"
|
|
5269
5309
|
});
|
|
5270
5310
|
}
|
|
@@ -5275,10 +5315,10 @@ function buildReportActionPlan(input) {
|
|
|
5275
5315
|
horizon: "short-term",
|
|
5276
5316
|
category: "monitoring",
|
|
5277
5317
|
title: "Keep monitoring citation and mention coverage",
|
|
5278
|
-
action: "Run the next scheduled
|
|
5318
|
+
action: "Run the next scheduled check and watch for citation gains, losses, and engine-specific misses.",
|
|
5279
5319
|
why: [
|
|
5280
5320
|
"No urgent corrective action surfaced from the current evidence.",
|
|
5281
|
-
"AEO performance is directional; repeated
|
|
5321
|
+
"AEO performance is directional; repeated checks are needed before overreacting to a single sample."
|
|
5282
5322
|
],
|
|
5283
5323
|
evidence: ["No critical insights, content gaps, indexing blockers, or provider-zero issues were detected in this report."],
|
|
5284
5324
|
successMetric: "Coverage stays stable or improves across the next trend window.",
|
|
@@ -5290,11 +5330,11 @@ function buildReportActionPlan(input) {
|
|
|
5290
5330
|
function trendSentence(trend) {
|
|
5291
5331
|
switch (trend) {
|
|
5292
5332
|
case "up":
|
|
5293
|
-
return "Citation coverage improved versus the prior comparable
|
|
5333
|
+
return "Citation coverage improved versus the prior comparable check.";
|
|
5294
5334
|
case "down":
|
|
5295
|
-
return "Citation coverage declined versus the prior comparable
|
|
5335
|
+
return "Citation coverage declined versus the prior comparable check.";
|
|
5296
5336
|
case "flat":
|
|
5297
|
-
return "Citation coverage is flat versus the prior comparable
|
|
5337
|
+
return "Citation coverage is flat versus the prior comparable check.";
|
|
5298
5338
|
case "unknown":
|
|
5299
5339
|
return "There is not enough comparable run history yet to call a trend.";
|
|
5300
5340
|
}
|
|
@@ -5302,16 +5342,16 @@ function trendSentence(trend) {
|
|
|
5302
5342
|
function buildClientSummary(reportLike) {
|
|
5303
5343
|
const s = reportLike.executiveSummary;
|
|
5304
5344
|
const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
|
|
5305
|
-
const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a
|
|
5306
|
-
const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is cited on ${s.citationRate}% of tracked queries and mentioned on ${s.mentionRate}% of tracked queries. ${trendSentence(s.trend)}` : "
|
|
5345
|
+
const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a check yet";
|
|
5346
|
+
const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is cited on ${s.citationRate}% of tracked queries and mentioned on ${s.mentionRate}% of tracked queries. ${trendSentence(s.trend)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
|
|
5307
5347
|
const confidenceNotes = [];
|
|
5308
5348
|
if (s.totalQueryCount === 0) {
|
|
5309
|
-
confidenceNotes.push("Confidence is low until the first tracked query
|
|
5349
|
+
confidenceNotes.push("Confidence is low until the first tracked query check completes.");
|
|
5310
5350
|
} else if (s.totalQueryCount < 5) {
|
|
5311
5351
|
confidenceNotes.push("Directional read: the tracked query set is still small, so each query has outsized impact on the percentage.");
|
|
5312
5352
|
}
|
|
5313
5353
|
if (isTrendBaseline(reportLike.citationsTrend)) {
|
|
5314
|
-
confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable
|
|
5354
|
+
confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable checks are needed for a stable trend.`);
|
|
5315
5355
|
}
|
|
5316
5356
|
if (!reportLike.gsc) {
|
|
5317
5357
|
confidenceNotes.push("Search Console is not connected, so content recommendations lean more heavily on citation and competitor evidence.");
|
|
@@ -5331,13 +5371,13 @@ function buildAgencyDiagnostics(input) {
|
|
|
5331
5371
|
const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
|
|
5332
5372
|
diagnostics.push({
|
|
5333
5373
|
title: "Provider citation coverage",
|
|
5334
|
-
detail: zeroCitationProviders.length > 0 ? `${zeroCitationProviders.length}
|
|
5374
|
+
detail: zeroCitationProviders.length > 0 ? `${zeroCitationProviders.length} engine${zeroCitationProviders.length === 1 ? "" : "s"} returned zero client citations in the latest check.` : "Every provider with completed snapshots produced at least one client citation or no provider data is available yet.",
|
|
5335
5375
|
severity: zeroCitationProviders.length > 0 ? "negative" : "positive",
|
|
5336
5376
|
evidence: zeroCitationProviders.length > 0 ? zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount}`) : input.citationScorecard.providerRates.map((p) => `${p.provider}: ${p.citedCount}/${p.totalCount}`)
|
|
5337
5377
|
});
|
|
5338
5378
|
diagnostics.push({
|
|
5339
5379
|
title: "AI source domains",
|
|
5340
|
-
detail: input.aiSourceOrigin.topDomains.length > 0 ? "Repeated external source domains show what AI engines are currently trusting for this topic set." : "No external source-domain evidence is available from the latest
|
|
5380
|
+
detail: input.aiSourceOrigin.topDomains.length > 0 ? "Repeated external source domains show what AI engines are currently trusting for this topic set." : "No external source-domain evidence is available from the latest check yet.",
|
|
5341
5381
|
severity: input.aiSourceOrigin.topDomains.length > 0 ? "neutral" : "caution",
|
|
5342
5382
|
evidence: input.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `${d.domain}: ${d.count}`)
|
|
5343
5383
|
});
|
|
@@ -5374,6 +5414,112 @@ function buildAgencyDiagnostics(input) {
|
|
|
5374
5414
|
diagnostics
|
|
5375
5415
|
};
|
|
5376
5416
|
}
|
|
5417
|
+
var WHATS_CHANGED_PERIOD_DAYS2 = 14;
|
|
5418
|
+
var WHATS_CHANGED_MIN_TREND_POINTS = WHATS_CHANGED_PERIOD_DAYS2 * 2;
|
|
5419
|
+
var WIN_REGRESSION_LIMIT = 5;
|
|
5420
|
+
function rateDirection(delta, threshold = 0.5) {
|
|
5421
|
+
if (delta > threshold) return "up";
|
|
5422
|
+
if (delta < -threshold) return "down";
|
|
5423
|
+
return "flat";
|
|
5424
|
+
}
|
|
5425
|
+
function periodOverPeriodDelta(trend) {
|
|
5426
|
+
if (trend.length < WHATS_CHANGED_MIN_TREND_POINTS) return null;
|
|
5427
|
+
const tail = trend.slice(-WHATS_CHANGED_PERIOD_DAYS2);
|
|
5428
|
+
const prior = trend.slice(-WHATS_CHANGED_PERIOD_DAYS2 * 2, -WHATS_CHANGED_PERIOD_DAYS2);
|
|
5429
|
+
const current = tail.reduce((s, p) => s + p.value, 0);
|
|
5430
|
+
const priorTotal = prior.reduce((s, p) => s + p.value, 0);
|
|
5431
|
+
const deltaAbs = current - priorTotal;
|
|
5432
|
+
return {
|
|
5433
|
+
current,
|
|
5434
|
+
prior: priorTotal,
|
|
5435
|
+
deltaAbs,
|
|
5436
|
+
direction: rateDirection(deltaAbs, 0)
|
|
5437
|
+
};
|
|
5438
|
+
}
|
|
5439
|
+
function buildWhatsChangedHeadline(citation, gscClicks, aiReferrals, enoughHistory, trendLength) {
|
|
5440
|
+
if (!enoughHistory) {
|
|
5441
|
+
return `Building baseline (${trendLength} of ${MIN_TREND_POINTS} checks completed). Trends appear after a few more checks.`;
|
|
5442
|
+
}
|
|
5443
|
+
const parts = [];
|
|
5444
|
+
if (citation) {
|
|
5445
|
+
const arrow = citation.direction === "up" ? "\u2191" : citation.direction === "down" ? "\u2193" : "\u2192";
|
|
5446
|
+
const verb = citation.direction === "up" ? "rose" : citation.direction === "down" ? "fell" : "held";
|
|
5447
|
+
parts.push(`Citation rate ${verb} ${citation.prior}% ${arrow} ${citation.current}%`);
|
|
5448
|
+
}
|
|
5449
|
+
if (aiReferrals && aiReferrals.direction !== "flat") {
|
|
5450
|
+
const arrow = aiReferrals.direction === "up" ? "\u2191" : "\u2193";
|
|
5451
|
+
parts.push(`AI referrals ${arrow}${Math.abs(aiReferrals.deltaAbs)} sessions vs prior 14 days`);
|
|
5452
|
+
} else if (gscClicks && gscClicks.direction !== "flat") {
|
|
5453
|
+
const arrow = gscClicks.direction === "up" ? "\u2191" : "\u2193";
|
|
5454
|
+
parts.push(`GSC clicks ${arrow}${Math.abs(gscClicks.deltaAbs)} vs prior 14 days`);
|
|
5455
|
+
}
|
|
5456
|
+
return parts.length > 0 ? `${parts.join(" \xB7 ")}.` : "No meaningful movement vs the prior period.";
|
|
5457
|
+
}
|
|
5458
|
+
function buildWhatsChanged(input) {
|
|
5459
|
+
const { citationsTrend, gsc, aiReferrals, insights: insightList } = input;
|
|
5460
|
+
const baseline = isTrendBaseline(citationsTrend);
|
|
5461
|
+
const latest = citationsTrend.at(-1);
|
|
5462
|
+
const prior = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
|
|
5463
|
+
const enoughHistory = !baseline && latest !== void 0 && prior !== void 0;
|
|
5464
|
+
const citationRate = enoughHistory ? {
|
|
5465
|
+
current: latest.citationRate,
|
|
5466
|
+
prior: prior.citationRate,
|
|
5467
|
+
deltaAbs: latest.citationRate - prior.citationRate,
|
|
5468
|
+
direction: rateDirection(latest.citationRate - prior.citationRate)
|
|
5469
|
+
} : null;
|
|
5470
|
+
const mentionRate = enoughHistory ? {
|
|
5471
|
+
current: latest.mentionRate,
|
|
5472
|
+
prior: prior.mentionRate,
|
|
5473
|
+
deltaAbs: latest.mentionRate - prior.mentionRate,
|
|
5474
|
+
direction: rateDirection(latest.mentionRate - prior.mentionRate)
|
|
5475
|
+
} : null;
|
|
5476
|
+
const citedQueryCount = enoughHistory ? {
|
|
5477
|
+
current: latest.citedQueryCount,
|
|
5478
|
+
prior: prior.citedQueryCount,
|
|
5479
|
+
deltaAbs: latest.citedQueryCount - prior.citedQueryCount,
|
|
5480
|
+
direction: rateDirection(latest.citedQueryCount - prior.citedQueryCount, 0)
|
|
5481
|
+
} : null;
|
|
5482
|
+
const providerMovements = [];
|
|
5483
|
+
if (enoughHistory) {
|
|
5484
|
+
const priorByProvider = new Map(prior.providerRates.map((p) => [p.provider, p.citationRate]));
|
|
5485
|
+
for (const cur of latest.providerRates) {
|
|
5486
|
+
const priorRate = priorByProvider.get(cur.provider);
|
|
5487
|
+
if (priorRate === void 0) continue;
|
|
5488
|
+
const deltaAbs = cur.citationRate - priorRate;
|
|
5489
|
+
providerMovements.push({
|
|
5490
|
+
provider: cur.provider,
|
|
5491
|
+
current: cur.citationRate,
|
|
5492
|
+
prior: priorRate,
|
|
5493
|
+
deltaAbs,
|
|
5494
|
+
direction: rateDirection(deltaAbs)
|
|
5495
|
+
});
|
|
5496
|
+
}
|
|
5497
|
+
providerMovements.sort((a, b) => Math.abs(b.deltaAbs) - Math.abs(a.deltaAbs));
|
|
5498
|
+
}
|
|
5499
|
+
const gscClicksDelta = gsc ? periodOverPeriodDelta(gsc.trend.map((t) => ({ date: t.date, value: t.clicks }))) : null;
|
|
5500
|
+
const aiReferralsDelta = aiReferrals ? periodOverPeriodDelta(aiReferrals.trend.map((t) => ({ date: t.date, value: t.sessions }))) : null;
|
|
5501
|
+
const wins = insightList.filter((i) => i.type === "gain").slice(0, WIN_REGRESSION_LIMIT);
|
|
5502
|
+
const regressions = insightList.filter((i) => i.type === "regression").slice(0, WIN_REGRESSION_LIMIT);
|
|
5503
|
+
const headline = buildWhatsChangedHeadline(
|
|
5504
|
+
citationRate,
|
|
5505
|
+
gscClicksDelta,
|
|
5506
|
+
aiReferralsDelta,
|
|
5507
|
+
enoughHistory,
|
|
5508
|
+
citationsTrend.length
|
|
5509
|
+
);
|
|
5510
|
+
return {
|
|
5511
|
+
enoughHistory,
|
|
5512
|
+
headline,
|
|
5513
|
+
citationRate,
|
|
5514
|
+
mentionRate,
|
|
5515
|
+
citedQueryCount,
|
|
5516
|
+
gscClicksDelta,
|
|
5517
|
+
aiReferralsDelta,
|
|
5518
|
+
providerMovements,
|
|
5519
|
+
wins,
|
|
5520
|
+
regressions
|
|
5521
|
+
};
|
|
5522
|
+
}
|
|
5377
5523
|
function buildProjectReport(db, projectName) {
|
|
5378
5524
|
const project = resolveProject(db, projectName);
|
|
5379
5525
|
const queryLookup = loadQueryLookup(db, project.id);
|
|
@@ -5426,6 +5572,12 @@ function buildProjectReport(db, projectName) {
|
|
|
5426
5572
|
contentOpportunities,
|
|
5427
5573
|
insightDerivedSteps
|
|
5428
5574
|
);
|
|
5575
|
+
const whatsChanged = buildWhatsChanged({
|
|
5576
|
+
citationsTrend,
|
|
5577
|
+
gsc: gscSection,
|
|
5578
|
+
aiReferrals: aiReferralsSection,
|
|
5579
|
+
insights: insightList
|
|
5580
|
+
});
|
|
5429
5581
|
const totalQueryCount = queryLookup.byId.size;
|
|
5430
5582
|
const citedQueryIds = /* @__PURE__ */ new Set();
|
|
5431
5583
|
const mentionedQueryIds = /* @__PURE__ */ new Set();
|
|
@@ -5553,6 +5705,7 @@ function buildProjectReport(db, projectName) {
|
|
|
5553
5705
|
aiReferrals: aiReferralsSection,
|
|
5554
5706
|
indexingHealth: indexingHealthSection,
|
|
5555
5707
|
citationsTrend,
|
|
5708
|
+
whatsChanged,
|
|
5556
5709
|
insights: insightList,
|
|
5557
5710
|
recommendedNextSteps,
|
|
5558
5711
|
actionPlan,
|