@ainyc/canonry 4.2.2 → 4.7.2
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/agent-workspace/skills/aero/references/reporting.md +2 -1
- package/assets/agent-workspace/skills/canonry-setup/references/canonry-cli.md +1 -1
- package/assets/assets/index-Ca3kZYGw.js +302 -0
- package/assets/assets/{index-D7T5wSBj.css → index-DAS6pOry.css} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-HJZY4EOE.js → chunk-DVTPGC6O.js} +816 -119
- package/dist/{chunk-7YSI4GFA.js → chunk-OOADR2Q5.js} +10 -4
- package/dist/{chunk-SR7TGHHG.js → chunk-VDEMEI64.js} +2 -2
- package/dist/{chunk-T2I6AO7D.js → chunk-XAW66QUX.js} +77 -1
- package/dist/cli.js +20 -10
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-CQGAXKKN.js → intelligence-service-ABHO5HHA.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +8 -8
- package/assets/assets/index-DoJfQkim.js +0 -302
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
configExists,
|
|
5
5
|
loadConfig,
|
|
6
6
|
saveConfigPatch
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-VDEMEI64.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-OOADR2Q5.js";
|
|
65
65
|
import {
|
|
66
66
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
67
67
|
AGENT_PROVIDER_IDS,
|
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
emptyCitationVisibility,
|
|
96
96
|
extractAnswerMentions,
|
|
97
97
|
findDuplicateLocationLabels,
|
|
98
|
+
getProviderLocationHandling,
|
|
98
99
|
hasLocationLabel,
|
|
99
100
|
internalError,
|
|
100
101
|
isAgentProviderId,
|
|
@@ -113,6 +114,7 @@ import {
|
|
|
113
114
|
providerError,
|
|
114
115
|
queryGenerateRequestSchema,
|
|
115
116
|
registrableDomain,
|
|
117
|
+
reportActionTone,
|
|
116
118
|
resolveConfigSpecQueries,
|
|
117
119
|
resolveSnapshotRequestQueries,
|
|
118
120
|
runInProgress,
|
|
@@ -127,7 +129,7 @@ import {
|
|
|
127
129
|
visibilityStateFromAnswerMentioned,
|
|
128
130
|
windowCutoff,
|
|
129
131
|
wordpressEnvSchema
|
|
130
|
-
} from "./chunk-
|
|
132
|
+
} from "./chunk-XAW66QUX.js";
|
|
131
133
|
|
|
132
134
|
// src/telemetry.ts
|
|
133
135
|
import crypto from "crypto";
|
|
@@ -2746,6 +2748,21 @@ section.report-section .section-intro {
|
|
|
2746
2748
|
.finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
2747
2749
|
.finding strong { display: block; margin-bottom: 4px; }
|
|
2748
2750
|
.finding span { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
2751
|
+
.location-card { margin-top: 16px; }
|
|
2752
|
+
.location-card .location-line { margin: 0 0 12px; font-size: 13px; color: ${COLORS.text}; }
|
|
2753
|
+
.location-card .location-line strong { color: ${COLORS.text}; }
|
|
2754
|
+
.location-card .location-line .cell-pending { font-size: 12px; }
|
|
2755
|
+
.source-origin-headline { margin: 0 0 12px; font-size: 14px; color: ${COLORS.text}; }
|
|
2756
|
+
.source-origin-headline strong { color: ${COLORS.text}; }
|
|
2757
|
+
.source-bars { display: flex; flex-direction: column; gap: 6px; }
|
|
2758
|
+
.source-bar-row { display: grid; grid-template-columns: 220px 1fr 90px; align-items: center; gap: 12px; font-size: 13px; }
|
|
2759
|
+
.source-bar-label { color: ${COLORS.textMuted}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2760
|
+
.source-bar-track { height: 14px; background: ${COLORS.border}; border-radius: 3px; overflow: hidden; }
|
|
2761
|
+
.source-bar-fill { height: 100%; border-radius: 3px; }
|
|
2762
|
+
.source-bar-value { color: ${COLORS.text}; text-align: right; font-variant-numeric: tabular-nums; }
|
|
2763
|
+
.source-bar-pct { color: ${COLORS.textFaint}; font-size: 11px; }
|
|
2764
|
+
.driver-list { margin: 0; padding-left: 16px; font-size: 12px; color: ${COLORS.textMuted}; }
|
|
2765
|
+
.driver-list li { margin: 2px 0; }
|
|
2749
2766
|
table.report-table {
|
|
2750
2767
|
width: 100%;
|
|
2751
2768
|
border-collapse: collapse;
|
|
@@ -2866,6 +2883,77 @@ table.report-table td .badge {
|
|
|
2866
2883
|
}
|
|
2867
2884
|
.step .title { font-weight: 600; }
|
|
2868
2885
|
.step .rationale { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
2886
|
+
.action-card-grid {
|
|
2887
|
+
display: grid;
|
|
2888
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
2889
|
+
gap: 16px;
|
|
2890
|
+
}
|
|
2891
|
+
.action-card {
|
|
2892
|
+
background: ${COLORS.surface};
|
|
2893
|
+
border: 1px solid ${COLORS.border};
|
|
2894
|
+
border-radius: 8px;
|
|
2895
|
+
padding: 18px 20px;
|
|
2896
|
+
}
|
|
2897
|
+
.action-card .action-meta {
|
|
2898
|
+
display: flex;
|
|
2899
|
+
flex-wrap: wrap;
|
|
2900
|
+
gap: 8px;
|
|
2901
|
+
margin-bottom: 10px;
|
|
2902
|
+
}
|
|
2903
|
+
.action-card h3 {
|
|
2904
|
+
font-size: 16px;
|
|
2905
|
+
margin: 0 0 8px;
|
|
2906
|
+
}
|
|
2907
|
+
.action-card p {
|
|
2908
|
+
margin: 0 0 12px;
|
|
2909
|
+
color: ${COLORS.textMuted};
|
|
2910
|
+
}
|
|
2911
|
+
.action-card ul {
|
|
2912
|
+
margin: 0 0 12px;
|
|
2913
|
+
padding-left: 18px;
|
|
2914
|
+
color: ${COLORS.textMuted};
|
|
2915
|
+
font-size: 13px;
|
|
2916
|
+
}
|
|
2917
|
+
.action-card li { margin: 4px 0; }
|
|
2918
|
+
.action-card .success-metric {
|
|
2919
|
+
color: ${COLORS.text};
|
|
2920
|
+
font-size: 13px;
|
|
2921
|
+
border-top: 1px solid ${COLORS.border};
|
|
2922
|
+
padding-top: 10px;
|
|
2923
|
+
margin-top: 12px;
|
|
2924
|
+
}
|
|
2925
|
+
.client-notes {
|
|
2926
|
+
margin-top: 18px;
|
|
2927
|
+
display: grid;
|
|
2928
|
+
gap: 8px;
|
|
2929
|
+
}
|
|
2930
|
+
.client-note {
|
|
2931
|
+
color: ${COLORS.textMuted};
|
|
2932
|
+
font-size: 13px;
|
|
2933
|
+
background: ${COLORS.surface};
|
|
2934
|
+
border: 1px solid ${COLORS.border};
|
|
2935
|
+
border-radius: 8px;
|
|
2936
|
+
padding: 10px 12px;
|
|
2937
|
+
}
|
|
2938
|
+
.diagnostics-grid {
|
|
2939
|
+
display: grid;
|
|
2940
|
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
2941
|
+
gap: 12px;
|
|
2942
|
+
}
|
|
2943
|
+
.diagnostic-card {
|
|
2944
|
+
background: ${COLORS.surface};
|
|
2945
|
+
border: 1px solid ${COLORS.border};
|
|
2946
|
+
border-left-width: 3px;
|
|
2947
|
+
border-radius: 8px;
|
|
2948
|
+
padding: 14px 16px;
|
|
2949
|
+
}
|
|
2950
|
+
.diagnostic-card h3 { font-size: 14px; margin: 0 0 6px; }
|
|
2951
|
+
.diagnostic-card p { margin: 0 0 8px; color: ${COLORS.textMuted}; font-size: 13px; }
|
|
2952
|
+
.diagnostic-card ul { margin: 0; padding-left: 16px; color: ${COLORS.textMuted}; font-size: 12px; }
|
|
2953
|
+
.diagnostic-card.tone-positive { border-left-color: ${COLORS.positive}; }
|
|
2954
|
+
.diagnostic-card.tone-caution { border-left-color: ${COLORS.caution}; }
|
|
2955
|
+
.diagnostic-card.tone-negative { border-left-color: ${COLORS.negative}; }
|
|
2956
|
+
.diagnostic-card.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
2869
2957
|
.footer {
|
|
2870
2958
|
margin-top: 96px;
|
|
2871
2959
|
padding-top: 24px;
|
|
@@ -2890,15 +2978,68 @@ function section(opts, body) {
|
|
|
2890
2978
|
function renderEmpty(message) {
|
|
2891
2979
|
return `<div class="empty-state">${escapeHtml(message)}</div>`;
|
|
2892
2980
|
}
|
|
2981
|
+
function locationDisplay(location) {
|
|
2982
|
+
if (!location) return "";
|
|
2983
|
+
const place = [location.city, location.region, location.country].filter(Boolean).join(", ");
|
|
2984
|
+
return place ? `${location.label} (${place})` : location.label;
|
|
2985
|
+
}
|
|
2986
|
+
function renderHeaderLocationFragment(location) {
|
|
2987
|
+
if (!location) return " \xB7 No location set";
|
|
2988
|
+
return ` \xB7 Location: ${escapeHtml(locationDisplay(location))}`;
|
|
2989
|
+
}
|
|
2990
|
+
function renderLocationCard(report) {
|
|
2991
|
+
const location = report.meta.location;
|
|
2992
|
+
const handling = report.meta.providerLocationHandling;
|
|
2993
|
+
if (!location && handling.length === 0) return "";
|
|
2994
|
+
const treatmentTone = {
|
|
2995
|
+
"request-param": "positive",
|
|
2996
|
+
prompt: "positive",
|
|
2997
|
+
"browser-geo": "caution",
|
|
2998
|
+
ignored: "negative"
|
|
2999
|
+
};
|
|
3000
|
+
const treatmentLabel = {
|
|
3001
|
+
"request-param": "Request parameter",
|
|
3002
|
+
prompt: "Prompt-injected",
|
|
3003
|
+
"browser-geo": "Browser geo",
|
|
3004
|
+
ignored: "Ignored"
|
|
3005
|
+
};
|
|
3006
|
+
const locationLine = location ? `<p class="location-line"><strong>Location for this run:</strong> ${escapeHtml(locationDisplay(location))}${location.otherConfiguredLabels.length > 0 ? ` <span class="cell-pending">\u2014 other configured locations (${location.otherConfiguredLabels.map(escapeHtml).join(", ")}) need their own sweep to compare</span>` : ""}</p>` : `<p class="location-line"><strong>Location for this run:</strong> none \u2014 providers received the queries verbatim with no geographic hint.</p>`;
|
|
3007
|
+
const handlingRows = handling.length > 0 ? handling.map((h) => {
|
|
3008
|
+
const tone = treatmentTone[h.treatment] ?? "neutral";
|
|
3009
|
+
const label = treatmentLabel[h.treatment] ?? h.treatment;
|
|
3010
|
+
return `<tr>
|
|
3011
|
+
<td>${escapeHtml(h.provider)}</td>
|
|
3012
|
+
<td><span class="badge tone-${tone}">${escapeHtml(label)}</span></td>
|
|
3013
|
+
<td>${escapeHtml(h.description)}</td>
|
|
3014
|
+
</tr>`;
|
|
3015
|
+
}).join("") : "";
|
|
3016
|
+
const handlingTable = handlingRows ? `<table class="report-table">
|
|
3017
|
+
<thead><tr><th>Provider</th><th>Treatment</th><th>How the location reached the model</th></tr></thead>
|
|
3018
|
+
<tbody>${handlingRows}</tbody>
|
|
3019
|
+
</table>` : "";
|
|
3020
|
+
return `<div class="chart-card location-card">
|
|
3021
|
+
<h3>Location handling</h3>
|
|
3022
|
+
${locationLine}
|
|
3023
|
+
${handlingTable}
|
|
3024
|
+
</div>`;
|
|
3025
|
+
}
|
|
2893
3026
|
function renderExecutiveSummary(report) {
|
|
2894
3027
|
const s = report.executiveSummary;
|
|
2895
3028
|
const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
|
|
2896
3029
|
const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
|
|
3030
|
+
const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
|
|
3031
|
+
const citedFragment = s.totalQueryCount > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
|
|
3032
|
+
const mentionedFragment = s.totalQueryCount > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
|
|
2897
3033
|
const metrics = [
|
|
2898
3034
|
{
|
|
2899
3035
|
label: "Citation rate",
|
|
2900
3036
|
value: `${s.citationRate}%`,
|
|
2901
|
-
delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
|
|
3037
|
+
delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${citedFragment} \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
|
|
3038
|
+
},
|
|
3039
|
+
{
|
|
3040
|
+
label: "Mention rate",
|
|
3041
|
+
value: `${s.mentionRate}%`,
|
|
3042
|
+
delta: mentionedFragment
|
|
2902
3043
|
},
|
|
2903
3044
|
{
|
|
2904
3045
|
label: "Queries tracked",
|
|
@@ -2932,14 +3073,15 @@ function renderExecutiveSummary(report) {
|
|
|
2932
3073
|
<strong>${escapeHtml(f.title)}</strong>
|
|
2933
3074
|
<span>${escapeHtml(f.detail)}</span>
|
|
2934
3075
|
</div>`).join("")}</div>` : "";
|
|
3076
|
+
const locationHtml = renderLocationCard(report);
|
|
2935
3077
|
return section(
|
|
2936
3078
|
{
|
|
2937
3079
|
id: "executive-summary",
|
|
2938
3080
|
eyebrow: "Section 1",
|
|
2939
3081
|
title: "Executive Summary",
|
|
2940
|
-
intro: "
|
|
3082
|
+
intro: "Two independent signals: Citation rate = share of tracked queries where your domain appeared in the source list the AI used. Mention rate = share of tracked queries where your brand or domain appeared in the answer text itself. A model can mention you without citing your domain, or cite your domain without naming you in the prose. Both are computed per-query so they stay comparable when provider count changes."
|
|
2941
3083
|
},
|
|
2942
|
-
metricsHtml + findingsHtml
|
|
3084
|
+
metricsHtml + findingsHtml + locationHtml
|
|
2943
3085
|
);
|
|
2944
3086
|
}
|
|
2945
3087
|
function renderProviderBars(rates) {
|
|
@@ -2977,16 +3119,16 @@ function renderCitationMatrix(scorecard) {
|
|
|
2977
3119
|
const cells = scorecard.providers.map((_, pi) => {
|
|
2978
3120
|
const cell = scorecard.matrix[qi]?.[pi];
|
|
2979
3121
|
if (!cell) {
|
|
2980
|
-
return '<td><span class="cell-pending">\u2014</span></td>';
|
|
2981
|
-
}
|
|
2982
|
-
if (cell.citationState === CitationStates.cited) {
|
|
2983
|
-
return '<td><span class="cell-cited">Cited</span></td>';
|
|
3122
|
+
return '<td><span class="cell-pending">\u2014 \u2014</span></td>';
|
|
2984
3123
|
}
|
|
2985
|
-
|
|
3124
|
+
const citedGlyph = cell.citationState === CitationStates.cited ? '<span class="cell-cited">C</span>' : '<span class="cell-not-cited">c</span>';
|
|
3125
|
+
const mentionedGlyph = cell.answerMentioned === true ? '<span class="cell-cited">M</span>' : cell.answerMentioned === false ? '<span class="cell-not-cited">m</span>' : '<span class="cell-pending">\u2013</span>';
|
|
3126
|
+
return `<td>${citedGlyph} ${mentionedGlyph}</td>`;
|
|
2986
3127
|
}).join("");
|
|
2987
3128
|
return `<tr><td>${escapeHtml(q)}</td>${cells}</tr>`;
|
|
2988
3129
|
}).join("");
|
|
2989
|
-
|
|
3130
|
+
const legend = '<p class="section-intro" style="margin-top:0;font-size:11px;">Each cell shows two flags \u2014 <span class="cell-cited">C</span>/<span class="cell-not-cited">c</span> = cited / not cited (your domain in the source list), <span class="cell-cited">M</span>/<span class="cell-not-cited">m</span> = mentioned / not mentioned (your brand in the answer text), <span class="cell-pending">\u2013</span> = no data.</p>';
|
|
3131
|
+
return `${legend}<table class="report-table">
|
|
2990
3132
|
<thead><tr><th>Query</th>${headers}</tr></thead>
|
|
2991
3133
|
<tbody>${rows}</tbody>
|
|
2992
3134
|
</table>`;
|
|
@@ -2997,7 +3139,7 @@ function renderCitationScorecard(report) {
|
|
|
2997
3139
|
${renderCitationMatrix(report.citationScorecard)}
|
|
2998
3140
|
`;
|
|
2999
3141
|
return section(
|
|
3000
|
-
{ id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "
|
|
3142
|
+
{ id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Per (query \xD7 provider) view of both signals \u2014 citations (your domain in the source list) and mentions (your brand in the answer text) \u2014 for every tracked query in the latest sweep." },
|
|
3001
3143
|
body
|
|
3002
3144
|
);
|
|
3003
3145
|
}
|
|
@@ -3084,44 +3226,40 @@ function renderCompetitorLandscape(report) {
|
|
|
3084
3226
|
`${charts}${table}`
|
|
3085
3227
|
);
|
|
3086
3228
|
}
|
|
3087
|
-
|
|
3229
|
+
var SOURCE_CATEGORY_TONE = {
|
|
3230
|
+
competitor: "negative",
|
|
3231
|
+
directory: "caution",
|
|
3232
|
+
forum: "caution",
|
|
3233
|
+
news: "neutral",
|
|
3234
|
+
reference: "neutral",
|
|
3235
|
+
blog: "neutral",
|
|
3236
|
+
social: "neutral",
|
|
3237
|
+
video: "neutral",
|
|
3238
|
+
ecommerce: "neutral",
|
|
3239
|
+
academic: "neutral",
|
|
3240
|
+
other: "neutral"
|
|
3241
|
+
};
|
|
3242
|
+
function renderCategoryBars(buckets) {
|
|
3088
3243
|
if (buckets.length === 0) return "";
|
|
3089
3244
|
const total = buckets.reduce((s, b) => s + b.count, 0);
|
|
3090
3245
|
if (total === 0) return "";
|
|
3091
|
-
const
|
|
3092
|
-
const
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
const y2 = cy + Math.sin(endAngle) * r;
|
|
3106
|
-
const ix1 = cx + Math.cos(endAngle) * innerR;
|
|
3107
|
-
const iy1 = cy + Math.sin(endAngle) * innerR;
|
|
3108
|
-
const ix2 = cx + Math.cos(startAngle) * innerR;
|
|
3109
|
-
const iy2 = cy + Math.sin(startAngle) * innerR;
|
|
3110
|
-
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
|
|
3111
|
-
const color = COLORS.series[i % COLORS.series.length];
|
|
3112
|
-
if (b.count > 0) {
|
|
3113
|
-
slices.push(`<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z" fill="${color}" />`);
|
|
3114
|
-
legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
|
|
3115
|
-
}
|
|
3116
|
-
});
|
|
3246
|
+
const max = Math.max(...buckets.map((b) => b.count), 1);
|
|
3247
|
+
const rows = buckets.map((b) => {
|
|
3248
|
+
const pct = b.count / max * 100;
|
|
3249
|
+
const tone = SOURCE_CATEGORY_TONE[b.category] ?? "neutral";
|
|
3250
|
+
const color = tone === "negative" ? COLORS.negative : tone === "caution" ? COLORS.caution : COLORS.accent;
|
|
3251
|
+
return `
|
|
3252
|
+
<div class="source-bar-row">
|
|
3253
|
+
<div class="source-bar-label">${escapeHtml(b.label)}</div>
|
|
3254
|
+
<div class="source-bar-track">
|
|
3255
|
+
<div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
|
|
3256
|
+
</div>
|
|
3257
|
+
<div class="source-bar-value">${b.count} <span class="source-bar-pct">(${b.sharePct}%)</span></div>
|
|
3258
|
+
</div>`;
|
|
3259
|
+
}).join("");
|
|
3117
3260
|
return `<div class="chart-card">
|
|
3118
|
-
<h3>
|
|
3119
|
-
<div
|
|
3120
|
-
<svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="AI source category donut chart">
|
|
3121
|
-
${slices.join("")}
|
|
3122
|
-
</svg>
|
|
3123
|
-
<div class="legend" style="flex-direction:column;align-items:flex-start;gap:6px;">${legend.join("")}</div>
|
|
3124
|
-
</div>
|
|
3261
|
+
<h3>By source type</h3>
|
|
3262
|
+
<div class="source-bars">${rows}</div>
|
|
3125
3263
|
</div>`;
|
|
3126
3264
|
}
|
|
3127
3265
|
function renderAiSourceOrigin(report) {
|
|
@@ -3132,24 +3270,28 @@ function renderAiSourceOrigin(report) {
|
|
|
3132
3270
|
renderEmpty("No source data yet. Run a visibility sweep first.")
|
|
3133
3271
|
);
|
|
3134
3272
|
}
|
|
3273
|
+
const competitorBucket = origin.categories.find((c) => c.category === "competitor");
|
|
3274
|
+
const headlineFragment = competitorBucket ? `<p class="source-origin-headline"><strong>${competitorBucket.sharePct}%</strong> of citations went to tracked competitors (${competitorBucket.count} of ${origin.categories.reduce((s, c) => s + c.count, 0)}).</p>` : "";
|
|
3135
3275
|
const rows = origin.topDomains.map((d) => `
|
|
3136
3276
|
<tr>
|
|
3137
3277
|
<td>${escapeHtml(d.domain)}</td>
|
|
3138
3278
|
<td class="numeric">${d.count}</td>
|
|
3139
|
-
<td>${d.isCompetitor ? '<span class="badge tone-negative">
|
|
3279
|
+
<td>${d.isCompetitor ? '<span class="badge tone-negative">Tracked competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
|
|
3140
3280
|
</tr>`).join("");
|
|
3141
|
-
const table = origin.topDomains.length > 0 ? `<
|
|
3142
|
-
<
|
|
3143
|
-
|
|
3144
|
-
|
|
3281
|
+
const table = origin.topDomains.length > 0 ? `<div class="chart-card"><h3>Top sources</h3>
|
|
3282
|
+
<table class="report-table">
|
|
3283
|
+
<thead><tr><th>Domain</th><th class="numeric">Citations</th><th>Tag</th></tr></thead>
|
|
3284
|
+
<tbody>${rows}</tbody>
|
|
3285
|
+
</table>
|
|
3286
|
+
</div>` : "";
|
|
3145
3287
|
return section(
|
|
3146
3288
|
{
|
|
3147
3289
|
id: "ai-source-origin",
|
|
3148
3290
|
eyebrow: "Section 4",
|
|
3149
3291
|
title: "AI Citation Sources",
|
|
3150
|
-
intro: "Every external website AI engines cited as a source for your tracked
|
|
3292
|
+
intro: "Every external website AI engines cited as a source for your tracked queries in the latest sweep, ranked by citation count. Tracked competitors are pulled into their own bucket so you can see how much of the AI\u2019s answer came from rivals; the remaining buckets cover directories, forums, news, and other site types. Your own domains are excluded."
|
|
3151
3293
|
},
|
|
3152
|
-
`${
|
|
3294
|
+
`${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
|
|
3153
3295
|
);
|
|
3154
3296
|
}
|
|
3155
3297
|
function renderLineChart(points, color, title, height = 200) {
|
|
@@ -3442,15 +3584,15 @@ function renderCitationsTrend(report) {
|
|
|
3442
3584
|
const rows = trend.map((t) => `
|
|
3443
3585
|
<tr>
|
|
3444
3586
|
<td>${formatDate(t.date)}</td>
|
|
3445
|
-
<td class="numeric">${t.citationRate}
|
|
3587
|
+
<td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
|
|
3446
3588
|
<td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
|
|
3447
3589
|
</tr>`).join("");
|
|
3448
3590
|
return section(
|
|
3449
|
-
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation rate across every visibility sweep \u2014 the share of
|
|
3591
|
+
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation rate across every visibility sweep \u2014 the share of tracked queries cited by at least one provider, with a per-provider breakdown beneath. Computed per-query so the headline stays comparable across runs that ran a different mix of providers." },
|
|
3450
3592
|
`${chart}
|
|
3451
3593
|
<div class="chart-card"><h3>Run-by-run breakdown</h3>
|
|
3452
3594
|
<table class="report-table">
|
|
3453
|
-
<thead><tr><th>Run</th><th class="numeric">
|
|
3595
|
+
<thead><tr><th>Run</th><th class="numeric">Cited queries</th><th>Per-provider rates</th></tr></thead>
|
|
3454
3596
|
<tbody>${rows}</tbody>
|
|
3455
3597
|
</table>
|
|
3456
3598
|
</div>`
|
|
@@ -3489,15 +3631,16 @@ function renderOpportunities(report) {
|
|
|
3489
3631
|
if (opps.length === 0) return "";
|
|
3490
3632
|
const canonical = report.meta.project.canonicalDomain;
|
|
3491
3633
|
const rows = opps.slice(0, 10).map((o) => {
|
|
3492
|
-
const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited"
|
|
3634
|
+
const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
|
|
3493
3635
|
const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
|
|
3636
|
+
const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml(d)}</li>`).join("")}</ul>` : '<span class="cell-not-cited">No driver signal yet</span>';
|
|
3494
3637
|
return `<tr>
|
|
3495
3638
|
<td>${escapeHtml(o.query)}</td>
|
|
3496
3639
|
<td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
|
|
3497
3640
|
<td class="numeric">${Math.round(o.score)}</td>
|
|
3641
|
+
<td>${drivers}</td>
|
|
3498
3642
|
<td>${ourPage}</td>
|
|
3499
3643
|
<td>${winning}</td>
|
|
3500
|
-
<td><span class="badge tone-neutral">${escapeHtml(o.demandSource)}</span></td>
|
|
3501
3644
|
<td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
|
|
3502
3645
|
</tr>`;
|
|
3503
3646
|
}).join("");
|
|
@@ -3506,10 +3649,36 @@ function renderOpportunities(report) {
|
|
|
3506
3649
|
id: "content-opportunities",
|
|
3507
3650
|
eyebrow: "Section 12",
|
|
3508
3651
|
title: "Content Opportunities",
|
|
3509
|
-
intro: "Queries where you have search demand or competitor citation pressure but aren\u2019t winning AI citations. Each row
|
|
3652
|
+
intro: "Queries where you have search demand or competitor citation pressure but aren\u2019t winning AI citations. Each row pairs a suggested action (create / refresh / expand / add-schema) with the signals driving the score, the best matching page on your domain, and the competitor URL the AI most often cites. Top 10 shown."
|
|
3510
3653
|
},
|
|
3511
3654
|
`<table class="report-table">
|
|
3512
|
-
<thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Our page</th><th>Winning competitor</th><th>
|
|
3655
|
+
<thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
|
|
3656
|
+
<tbody>${rows}</tbody>
|
|
3657
|
+
</table>`
|
|
3658
|
+
);
|
|
3659
|
+
}
|
|
3660
|
+
function renderContentGaps(report) {
|
|
3661
|
+
const gaps = report.contentGaps;
|
|
3662
|
+
if (gaps.length === 0) return "";
|
|
3663
|
+
const rows = gaps.slice(0, 10).map((g) => {
|
|
3664
|
+
const competitorList = g.competitorDomains.slice(0, 5).map(escapeHtml).join(", ");
|
|
3665
|
+
const more = g.competitorDomains.length > 5 ? `, +${g.competitorDomains.length - 5} more` : "";
|
|
3666
|
+
return `<tr>
|
|
3667
|
+
<td>${escapeHtml(g.query)}</td>
|
|
3668
|
+
<td class="numeric">${g.competitorCount}</td>
|
|
3669
|
+
<td>${competitorList}${more}</td>
|
|
3670
|
+
<td class="numeric">${Math.round(g.missRate * 100)}%</td>
|
|
3671
|
+
</tr>`;
|
|
3672
|
+
}).join("");
|
|
3673
|
+
return section(
|
|
3674
|
+
{
|
|
3675
|
+
id: "content-gaps",
|
|
3676
|
+
eyebrow: "Section 13",
|
|
3677
|
+
title: "Content Gaps",
|
|
3678
|
+
intro: 'Tracked queries where multiple competitors are cited by AI engines but you are not \u2014 explicit "they are answering, you are missing" signal. Sorted by recent miss rate, then by number of competitors cited. Top 10 shown.'
|
|
3679
|
+
},
|
|
3680
|
+
`<table class="report-table">
|
|
3681
|
+
<thead><tr><th>Query</th><th class="numeric">Competitors cited</th><th>Domains</th><th class="numeric">Miss rate</th></tr></thead>
|
|
3513
3682
|
<tbody>${rows}</tbody>
|
|
3514
3683
|
</table>`
|
|
3515
3684
|
);
|
|
@@ -3518,7 +3687,7 @@ function renderRecommendedNextSteps(report) {
|
|
|
3518
3687
|
const steps = report.recommendedNextSteps;
|
|
3519
3688
|
if (steps.length === 0) {
|
|
3520
3689
|
return section(
|
|
3521
|
-
{ id: "recommended-next-steps", eyebrow: "Section
|
|
3690
|
+
{ id: "recommended-next-steps", eyebrow: "Section 14", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
|
|
3522
3691
|
renderEmpty("No outstanding actions.")
|
|
3523
3692
|
);
|
|
3524
3693
|
}
|
|
@@ -3529,17 +3698,144 @@ function renderRecommendedNextSteps(report) {
|
|
|
3529
3698
|
<span class="rationale">${escapeHtml(s.rationale)}</span>
|
|
3530
3699
|
</div>`).join("");
|
|
3531
3700
|
return section(
|
|
3532
|
-
{ id: "recommended-next-steps", eyebrow: "Section
|
|
3701
|
+
{ id: "recommended-next-steps", eyebrow: "Section 14", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
|
|
3533
3702
|
`<div class="steps">${items}</div>`
|
|
3534
3703
|
);
|
|
3535
3704
|
}
|
|
3705
|
+
function actionAudienceMatches(action, audience) {
|
|
3706
|
+
return action.audience === "both" || action.audience === audience;
|
|
3707
|
+
}
|
|
3708
|
+
function renderActionCards(actions) {
|
|
3709
|
+
if (actions.length === 0) return renderEmpty("No prioritized actions yet.");
|
|
3710
|
+
return `<div class="action-card-grid">
|
|
3711
|
+
${actions.map((action) => {
|
|
3712
|
+
const tone = reportActionTone(action);
|
|
3713
|
+
const why = action.why.length > 0 ? `<ul>${action.why.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
|
|
3714
|
+
const evidence = action.evidence.length > 0 ? `<ul>${action.evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
|
|
3715
|
+
return `<article class="action-card">
|
|
3716
|
+
<div class="action-meta">
|
|
3717
|
+
<span class="badge tone-${tone}">${escapeHtml(action.horizon)}</span>
|
|
3718
|
+
<span class="badge tone-neutral">${escapeHtml(action.category)}</span>
|
|
3719
|
+
<span class="badge tone-neutral">${escapeHtml(action.confidence)} confidence</span>
|
|
3720
|
+
</div>
|
|
3721
|
+
<h3>${escapeHtml(action.title)}</h3>
|
|
3722
|
+
<p>${escapeHtml(action.action)}</p>
|
|
3723
|
+
${why ? `<div><strong>Why</strong>${why}</div>` : ""}
|
|
3724
|
+
${evidence ? `<div><strong>Evidence</strong>${evidence}</div>` : ""}
|
|
3725
|
+
<div class="success-metric"><strong>Success metric:</strong> ${escapeHtml(action.successMetric)}</div>
|
|
3726
|
+
</article>`;
|
|
3727
|
+
}).join("")}
|
|
3728
|
+
</div>`;
|
|
3729
|
+
}
|
|
3730
|
+
function renderAudienceActionPlan(report, audience) {
|
|
3731
|
+
const actions = audience === "client" ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter((a) => actionAudienceMatches(a, audience));
|
|
3732
|
+
return section(
|
|
3733
|
+
{
|
|
3734
|
+
id: audience === "client" ? "client-action-plan" : "agency-action-plan",
|
|
3735
|
+
eyebrow: audience === "client" ? "Client actions" : "Agency actions",
|
|
3736
|
+
title: audience === "client" ? "What We Recommend Next" : "Agency Action Plan",
|
|
3737
|
+
intro: audience === "client" ? "Polished next steps the client can understand, backed by concise evidence from the report." : "Technical priorities pulled from the canonical action plan, sorted by urgency and evidence strength."
|
|
3738
|
+
},
|
|
3739
|
+
renderActionCards(actions)
|
|
3740
|
+
);
|
|
3741
|
+
}
|
|
3742
|
+
function renderClientSummary(report) {
|
|
3743
|
+
const s = report.executiveSummary;
|
|
3744
|
+
const metrics = `<div class="metric-grid">
|
|
3745
|
+
<div class="metric"><div class="label">Citation coverage</div><div class="value">${s.citationRate}%</div><div class="delta">${s.citedQueryCount}/${s.totalQueryCount} tracked queries cited</div></div>
|
|
3746
|
+
<div class="metric"><div class="label">Mention coverage</div><div class="value">${s.mentionRate}%</div><div class="delta">${s.mentionedQueryCount}/${s.totalQueryCount} tracked queries mentioned</div></div>
|
|
3747
|
+
<div class="metric"><div class="label">Providers checked</div><div class="value">${formatNumber(s.providerCount)}</div><div class="delta">${formatNumber(s.queryCount)} tracked queries</div></div>
|
|
3748
|
+
</div>`;
|
|
3749
|
+
const notes = report.clientSummary.confidenceNotes.length > 0 ? `<div class="client-notes">${report.clientSummary.confidenceNotes.map((note) => `<div class="client-note">${escapeHtml(note)}</div>`).join("")}</div>` : "";
|
|
3750
|
+
return section(
|
|
3751
|
+
{
|
|
3752
|
+
id: "client-summary",
|
|
3753
|
+
eyebrow: "Client summary",
|
|
3754
|
+
title: "How You're Appearing",
|
|
3755
|
+
intro: report.clientSummary.overview
|
|
3756
|
+
},
|
|
3757
|
+
`<div class="chart-card">
|
|
3758
|
+
<h3>${escapeHtml(report.clientSummary.headline)}</h3>
|
|
3759
|
+
<p class="source-origin-headline">${escapeHtml(report.clientSummary.overview)}</p>
|
|
3760
|
+
</div>
|
|
3761
|
+
${metrics}
|
|
3762
|
+
${notes}`
|
|
3763
|
+
);
|
|
3764
|
+
}
|
|
3765
|
+
function renderClientEvidenceSummary(report) {
|
|
3766
|
+
const evidenceCards = [];
|
|
3767
|
+
if (report.aiSourceOrigin.topDomains.length > 0) {
|
|
3768
|
+
evidenceCards.push(`<div class="diagnostic-card tone-neutral">
|
|
3769
|
+
<h3>Sources AI engines trust</h3>
|
|
3770
|
+
<p>These domains appeared most often as cited sources outside your owned domain.</p>
|
|
3771
|
+
<ul>${report.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `<li>${escapeHtml(d.domain)}: ${formatNumber(d.count)} citation${d.count === 1 ? "" : "s"}</li>`).join("")}</ul>
|
|
3772
|
+
</div>`);
|
|
3773
|
+
}
|
|
3774
|
+
if (report.gsc) {
|
|
3775
|
+
evidenceCards.push(`<div class="diagnostic-card tone-neutral">
|
|
3776
|
+
<h3>Search demand</h3>
|
|
3777
|
+
<p>Search Console shows ${formatNumber(report.gsc.totalImpressions)} impressions and ${formatNumber(report.gsc.totalClicks)} clicks in the report window.</p>
|
|
3778
|
+
<ul>${report.gsc.topQueries.slice(0, 5).map((q) => `<li>${escapeHtml(q.query)}: ${formatNumber(q.impressions)} impressions</li>`).join("")}</ul>
|
|
3779
|
+
</div>`);
|
|
3780
|
+
}
|
|
3781
|
+
if (report.indexingHealth) {
|
|
3782
|
+
const tone = report.indexingHealth.indexedPct >= 90 ? "positive" : report.indexingHealth.indexedPct >= 70 ? "caution" : "negative";
|
|
3783
|
+
evidenceCards.push(`<div class="diagnostic-card tone-${tone}">
|
|
3784
|
+
<h3>Indexing readiness</h3>
|
|
3785
|
+
<p>${report.indexingHealth.indexedPct}% of inspected URLs are indexed.</p>
|
|
3786
|
+
<ul><li>${formatNumber(report.indexingHealth.indexed)} indexed</li><li>${formatNumber(report.indexingHealth.notIndexed)} not indexed</li></ul>
|
|
3787
|
+
</div>`);
|
|
3788
|
+
}
|
|
3789
|
+
if (report.contentOpportunities.length > 0) {
|
|
3790
|
+
evidenceCards.push(`<div class="diagnostic-card tone-caution">
|
|
3791
|
+
<h3>Content opportunities</h3>
|
|
3792
|
+
<p>Canonry found topics where better content could improve AI citations.</p>
|
|
3793
|
+
<ul>${report.contentOpportunities.slice(0, 5).map((o) => `<li>${escapeHtml(o.query)}: ${escapeHtml(o.action)} (${Math.round(o.score)})</li>`).join("")}</ul>
|
|
3794
|
+
</div>`);
|
|
3795
|
+
}
|
|
3796
|
+
return section(
|
|
3797
|
+
{
|
|
3798
|
+
id: "client-evidence-summary",
|
|
3799
|
+
eyebrow: "Evidence",
|
|
3800
|
+
title: "Why This Is The Plan",
|
|
3801
|
+
intro: "A concise evidence view for the client summary. The agency report keeps the full matrices and detailed tables."
|
|
3802
|
+
},
|
|
3803
|
+
evidenceCards.length > 0 ? `<div class="diagnostics-grid">${evidenceCards.join("")}</div>` : renderEmpty("No supporting evidence sections are populated yet.")
|
|
3804
|
+
);
|
|
3805
|
+
}
|
|
3806
|
+
function renderAgencyDiagnostics(report) {
|
|
3807
|
+
const diagnostics = report.agencyDiagnostics.diagnostics;
|
|
3808
|
+
const body = diagnostics.length > 0 ? `<div class="diagnostics-grid">
|
|
3809
|
+
${diagnostics.map((d) => `<div class="diagnostic-card tone-${d.severity}">
|
|
3810
|
+
<h3>${escapeHtml(d.title)}</h3>
|
|
3811
|
+
<p>${escapeHtml(d.detail)}</p>
|
|
3812
|
+
${d.evidence.length > 0 ? `<ul>${d.evidence.map((e) => `<li>${escapeHtml(e)}</li>`).join("")}</ul>` : ""}
|
|
3813
|
+
</div>`).join("")}
|
|
3814
|
+
</div>` : renderEmpty("No agency diagnostics available yet.");
|
|
3815
|
+
return section(
|
|
3816
|
+
{
|
|
3817
|
+
id: "agency-diagnostics",
|
|
3818
|
+
eyebrow: "Agency diagnostics",
|
|
3819
|
+
title: "Technical Diagnostics",
|
|
3820
|
+
intro: "Operator-facing diagnostics for content, provider, source-domain, search-demand, indexing, and location follow-up."
|
|
3821
|
+
},
|
|
3822
|
+
body
|
|
3823
|
+
);
|
|
3824
|
+
}
|
|
3536
3825
|
function escapeJsonForScript(json) {
|
|
3537
3826
|
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
3538
3827
|
}
|
|
3539
3828
|
function renderReportHtml(report, opts = {}) {
|
|
3540
|
-
const
|
|
3541
|
-
const
|
|
3829
|
+
const audience = opts.audience ?? "agency";
|
|
3830
|
+
const title = opts.title ?? `Canonry ${audience} report \u2014 ${report.meta.project.displayName}`;
|
|
3831
|
+
const sections = audience === "client" ? [
|
|
3832
|
+
renderClientSummary(report),
|
|
3833
|
+
renderAudienceActionPlan(report, "client"),
|
|
3834
|
+
renderClientEvidenceSummary(report)
|
|
3835
|
+
].join("\n") : [
|
|
3542
3836
|
renderExecutiveSummary(report),
|
|
3837
|
+
renderAudienceActionPlan(report, "agency"),
|
|
3838
|
+
renderAgencyDiagnostics(report),
|
|
3543
3839
|
renderCitationScorecard(report),
|
|
3544
3840
|
renderCompetitorLandscape(report),
|
|
3545
3841
|
renderAiSourceOrigin(report),
|
|
@@ -3551,6 +3847,7 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3551
3847
|
renderCitationsTrend(report),
|
|
3552
3848
|
renderInsights(report),
|
|
3553
3849
|
renderOpportunities(report),
|
|
3850
|
+
renderContentGaps(report),
|
|
3554
3851
|
renderRecommendedNextSteps(report)
|
|
3555
3852
|
].join("\n");
|
|
3556
3853
|
const json = escapeJsonForScript(JSON.stringify(report));
|
|
@@ -3565,9 +3862,9 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3565
3862
|
<body>
|
|
3566
3863
|
<div class="container">
|
|
3567
3864
|
<header class="header">
|
|
3568
|
-
<div class="eyebrow"
|
|
3865
|
+
<div class="eyebrow">${audience === "client" ? "AEO Client Summary" : "AEO Agency Report"}</div>
|
|
3569
3866
|
<h1>${escapeHtml(report.meta.project.displayName)}</h1>
|
|
3570
|
-
<div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
|
|
3867
|
+
<div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())}${renderHeaderLocationFragment(report.meta.location)} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
|
|
3571
3868
|
</header>
|
|
3572
3869
|
${sections}
|
|
3573
3870
|
<footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
|
|
@@ -3580,7 +3877,7 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3580
3877
|
// ../api-routes/src/content-data.ts
|
|
3581
3878
|
import { and as and3, eq as eq12, desc as desc5, inArray as inArray3 } from "drizzle-orm";
|
|
3582
3879
|
var RECENT_RUNS_WINDOW = 5;
|
|
3583
|
-
function loadOrchestratorInput(db, project) {
|
|
3880
|
+
function loadOrchestratorInput(db, project, locationFilter = void 0) {
|
|
3584
3881
|
const projectId = project.id;
|
|
3585
3882
|
const ownDomain = normalizeDomain(project.canonicalDomain);
|
|
3586
3883
|
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
@@ -3589,7 +3886,7 @@ function loadOrchestratorInput(db, project) {
|
|
|
3589
3886
|
const candidateQueryStrings = trackedQueries.filter(isBlogShapedQuery);
|
|
3590
3887
|
const trackedCompetitors = listCompetitorDomains(db, projectId).map(normalizeDomain);
|
|
3591
3888
|
const competitorSet = new Set(trackedCompetitors);
|
|
3592
|
-
const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW);
|
|
3889
|
+
const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW, locationFilter);
|
|
3593
3890
|
const latestRunId = recentRunIds[0] ?? "";
|
|
3594
3891
|
const latestRunTimestamp = latestRunId ? lookupRunTimestamp(db, latestRunId) : "";
|
|
3595
3892
|
const candidateQueries = buildCandidateQueries({
|
|
@@ -3631,8 +3928,8 @@ function listCompetitorDomains(db, projectId) {
|
|
|
3631
3928
|
const rows = db.select({ domain: competitors.domain }).from(competitors).where(eq12(competitors.projectId, projectId)).all();
|
|
3632
3929
|
return rows.map((r) => r.domain);
|
|
3633
3930
|
}
|
|
3634
|
-
function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
|
|
3635
|
-
const rows = db.select({ id: runs.id }).from(runs).where(
|
|
3931
|
+
function listRecentAnswerVisibilityRunIds(db, projectId, limit, locationFilter) {
|
|
3932
|
+
const rows = db.select({ id: runs.id, location: runs.location }).from(runs).where(
|
|
3636
3933
|
and3(
|
|
3637
3934
|
eq12(runs.projectId, projectId),
|
|
3638
3935
|
eq12(runs.kind, RunKinds["answer-visibility"]),
|
|
@@ -3641,8 +3938,9 @@ function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
|
|
|
3641
3938
|
// no usable evidence.
|
|
3642
3939
|
inArray3(runs.status, [RunStatuses.completed, RunStatuses.partial])
|
|
3643
3940
|
)
|
|
3644
|
-
).orderBy(desc5(runs.createdAt)).
|
|
3645
|
-
|
|
3941
|
+
).orderBy(desc5(runs.createdAt)).all();
|
|
3942
|
+
const filtered = locationFilter === void 0 ? rows : rows.filter((r) => (r.location ?? null) === locationFilter);
|
|
3943
|
+
return filtered.slice(0, limit).map((r) => r.id);
|
|
3646
3944
|
}
|
|
3647
3945
|
function lookupRunTimestamp(db, runId) {
|
|
3648
3946
|
const row = db.select({ createdAt: runs.createdAt }).from(runs).where(eq12(runs.id, runId)).get();
|
|
@@ -4129,27 +4427,33 @@ function buildIndexingHealth(db, projectId) {
|
|
|
4129
4427
|
}
|
|
4130
4428
|
return null;
|
|
4131
4429
|
}
|
|
4132
|
-
function buildCitationsTrend(db, projectId, queryLookup) {
|
|
4133
|
-
const visibilityRuns = db.select().from(runs).where(and4(eq13(runs.projectId, projectId), eq13(runs.kind, RunKinds["answer-visibility"]))).all();
|
|
4430
|
+
function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
|
|
4431
|
+
const visibilityRuns = db.select().from(runs).where(and4(eq13(runs.projectId, projectId), eq13(runs.kind, RunKinds["answer-visibility"]))).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter);
|
|
4432
|
+
const totalQueries = queryLookup.byId.size;
|
|
4134
4433
|
const points = [];
|
|
4135
4434
|
for (const run of visibilityRuns) {
|
|
4136
4435
|
if (run.status !== RunStatuses.completed) continue;
|
|
4137
4436
|
const snaps = loadSnapshotsForRun(db, run.id);
|
|
4138
4437
|
if (snaps.length === 0) continue;
|
|
4139
|
-
|
|
4438
|
+
const citedQueryIds = /* @__PURE__ */ new Set();
|
|
4439
|
+
const mentionedQueryIds = /* @__PURE__ */ new Set();
|
|
4140
4440
|
let considered = 0;
|
|
4141
4441
|
const providerCounts = /* @__PURE__ */ new Map();
|
|
4142
4442
|
for (const snap of snaps) {
|
|
4143
4443
|
if (!queryLookup.byId.has(snap.queryId)) continue;
|
|
4144
4444
|
considered++;
|
|
4145
|
-
if (snap.citationState === CitationStates.cited)
|
|
4445
|
+
if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
|
|
4446
|
+
if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
|
|
4146
4447
|
const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
|
|
4147
4448
|
counts.total++;
|
|
4148
4449
|
if (snap.citationState === CitationStates.cited) counts.cited++;
|
|
4149
4450
|
providerCounts.set(snap.provider, counts);
|
|
4150
4451
|
}
|
|
4151
4452
|
if (considered === 0) continue;
|
|
4152
|
-
const
|
|
4453
|
+
const citedQueryCount = citedQueryIds.size;
|
|
4454
|
+
const mentionedQueryCount = mentionedQueryIds.size;
|
|
4455
|
+
const citationRate = totalQueries > 0 ? Math.round(citedQueryCount / totalQueries * 100) : 0;
|
|
4456
|
+
const mentionRate = totalQueries > 0 ? Math.round(mentionedQueryCount / totalQueries * 100) : 0;
|
|
4153
4457
|
const providerRates = [...providerCounts.entries()].map(([provider, counts]) => ({
|
|
4154
4458
|
provider,
|
|
4155
4459
|
citationRate: counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0
|
|
@@ -4158,20 +4462,24 @@ function buildCitationsTrend(db, projectId, queryLookup) {
|
|
|
4158
4462
|
runId: run.id,
|
|
4159
4463
|
date: run.finishedAt ?? run.createdAt,
|
|
4160
4464
|
citationRate,
|
|
4465
|
+
citedQueryCount,
|
|
4466
|
+
totalQueryCount: totalQueries,
|
|
4467
|
+
mentionRate,
|
|
4468
|
+
mentionedQueryCount,
|
|
4161
4469
|
providerRates
|
|
4162
4470
|
});
|
|
4163
4471
|
}
|
|
4164
4472
|
points.sort((a, b) => a.date.localeCompare(b.date));
|
|
4165
4473
|
return points;
|
|
4166
4474
|
}
|
|
4167
|
-
function buildInsightList(db, projectId) {
|
|
4168
|
-
const recentRunIds = db.select({ id: runs.id }).from(runs).where(
|
|
4475
|
+
function buildInsightList(db, projectId, locationFilter) {
|
|
4476
|
+
const recentRunIds = db.select({ id: runs.id, location: runs.location }).from(runs).where(
|
|
4169
4477
|
and4(
|
|
4170
4478
|
eq13(runs.projectId, projectId),
|
|
4171
4479
|
eq13(runs.kind, RunKinds["answer-visibility"]),
|
|
4172
4480
|
or2(eq13(runs.status, RunStatuses.completed), eq13(runs.status, RunStatuses.partial))
|
|
4173
4481
|
)
|
|
4174
|
-
).orderBy(desc6(runs.createdAt)).
|
|
4482
|
+
).orderBy(desc6(runs.createdAt)).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter).slice(0, INSIGHT_LOOKBACK_RUNS).map((r) => r.id);
|
|
4175
4483
|
if (recentRunIds.length === 0) return [];
|
|
4176
4484
|
const rows = db.select().from(insights).where(and4(eq13(insights.projectId, projectId), inArray4(insights.runId, recentRunIds))).orderBy(desc6(insights.createdAt)).all();
|
|
4177
4485
|
const severityRank = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
@@ -4243,7 +4551,7 @@ function buildRecommendedNextSteps(insightList) {
|
|
|
4243
4551
|
}
|
|
4244
4552
|
return steps;
|
|
4245
4553
|
}
|
|
4246
|
-
function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
|
|
4554
|
+
function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
|
|
4247
4555
|
const findings = [];
|
|
4248
4556
|
if (trendsPoints.length > 0) {
|
|
4249
4557
|
const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
|
|
@@ -4266,8 +4574,10 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
|
|
|
4266
4574
|
break;
|
|
4267
4575
|
}
|
|
4268
4576
|
}
|
|
4577
|
+
const queryNoun = totalQueryCount === 1 ? "query" : "queries";
|
|
4578
|
+
const ratioFragment = totalQueryCount > 0 ? ` (${citedQueryCount} of ${totalQueryCount} ${queryNoun} cited)` : "";
|
|
4269
4579
|
findings.push({
|
|
4270
|
-
title: `Citation rate at ${citationRate}
|
|
4580
|
+
title: `Citation rate at ${citationRate}%${ratioFragment}`,
|
|
4271
4581
|
detail,
|
|
4272
4582
|
tone
|
|
4273
4583
|
});
|
|
@@ -4290,6 +4600,328 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
|
|
|
4290
4600
|
}
|
|
4291
4601
|
return findings.slice(0, 5);
|
|
4292
4602
|
}
|
|
4603
|
+
function buildLocationMeta(runLocationLabel, configuredLocations) {
|
|
4604
|
+
if (!runLocationLabel) return null;
|
|
4605
|
+
const match = configuredLocations.find((loc) => loc.label === runLocationLabel);
|
|
4606
|
+
const others = configuredLocations.map((loc) => loc.label).filter((label) => label !== runLocationLabel);
|
|
4607
|
+
return {
|
|
4608
|
+
label: runLocationLabel,
|
|
4609
|
+
city: match?.city ?? "",
|
|
4610
|
+
region: match?.region ?? "",
|
|
4611
|
+
country: match?.country ?? "",
|
|
4612
|
+
otherConfiguredLabels: others
|
|
4613
|
+
};
|
|
4614
|
+
}
|
|
4615
|
+
function buildProviderLocationHandling(providersInRun) {
|
|
4616
|
+
return [...providersInRun].sort().map((provider) => {
|
|
4617
|
+
const handling = getProviderLocationHandling(provider);
|
|
4618
|
+
return {
|
|
4619
|
+
provider,
|
|
4620
|
+
treatment: handling.treatment,
|
|
4621
|
+
description: handling.description
|
|
4622
|
+
};
|
|
4623
|
+
});
|
|
4624
|
+
}
|
|
4625
|
+
function compactList(items, limit = 3) {
|
|
4626
|
+
const visible = items.slice(0, limit);
|
|
4627
|
+
const extra = items.length - visible.length;
|
|
4628
|
+
return extra > 0 ? `${visible.join(", ")}, +${extra} more` : visible.join(", ");
|
|
4629
|
+
}
|
|
4630
|
+
function contentActionVerb(action) {
|
|
4631
|
+
switch (action) {
|
|
4632
|
+
case "create":
|
|
4633
|
+
return "Create";
|
|
4634
|
+
case "expand":
|
|
4635
|
+
return "Expand";
|
|
4636
|
+
case "refresh":
|
|
4637
|
+
return "Refresh";
|
|
4638
|
+
case "add-schema":
|
|
4639
|
+
return "Add schema to";
|
|
4640
|
+
}
|
|
4641
|
+
}
|
|
4642
|
+
function confidenceFromEvidence(count) {
|
|
4643
|
+
if (count >= 3) return "high";
|
|
4644
|
+
if (count >= 1) return "medium";
|
|
4645
|
+
return "low";
|
|
4646
|
+
}
|
|
4647
|
+
function actionAudienceMatches2(action, audience) {
|
|
4648
|
+
return action.audience === "both" || action.audience === audience;
|
|
4649
|
+
}
|
|
4650
|
+
function buildReportActionPlan(input) {
|
|
4651
|
+
const actions = [];
|
|
4652
|
+
if (input.competitorDomains.length === 0 && input.aiSourceOrigin.topDomains.length > 0) {
|
|
4653
|
+
const topDomains = input.aiSourceOrigin.topDomains.slice(0, 5);
|
|
4654
|
+
actions.push({
|
|
4655
|
+
audience: "both",
|
|
4656
|
+
priority: 10,
|
|
4657
|
+
horizon: "immediate",
|
|
4658
|
+
category: "competitors",
|
|
4659
|
+
title: "Define the competitor set Canonry should benchmark against",
|
|
4660
|
+
action: "Review the recurring external source domains and add the true competitors before the next sweep.",
|
|
4661
|
+
why: [
|
|
4662
|
+
"The report can identify repeated external sources, but it cannot separate competitors from publishers until competitors are configured.",
|
|
4663
|
+
"A clean competitor set makes future share-of-voice and content-gap reporting easier to explain to clients."
|
|
4664
|
+
],
|
|
4665
|
+
evidence: topDomains.map((d) => `${d.domain} appeared in ${d.count} cited source${d.count === 1 ? "" : "s"}`),
|
|
4666
|
+
successMetric: "Next report separates tracked competitors from independent source domains in the competitor landscape.",
|
|
4667
|
+
confidence: confidenceFromEvidence(topDomains.length)
|
|
4668
|
+
});
|
|
4669
|
+
}
|
|
4670
|
+
for (const [index, opportunity] of input.contentOpportunities.slice(0, 2).entries()) {
|
|
4671
|
+
const verb = contentActionVerb(opportunity.action);
|
|
4672
|
+
const target = opportunity.ourBestPage?.url ?? `a new page for "${opportunity.query}"`;
|
|
4673
|
+
const evidence = [
|
|
4674
|
+
`Opportunity score ${Math.round(opportunity.score)} with ${opportunity.actionConfidence} confidence`,
|
|
4675
|
+
`Demand source: ${opportunity.demandSource}`
|
|
4676
|
+
];
|
|
4677
|
+
if (opportunity.winningCompetitor) {
|
|
4678
|
+
evidence.push(`${opportunity.winningCompetitor.domain} is the current winning cited source`);
|
|
4679
|
+
}
|
|
4680
|
+
if (opportunity.ourBestPage) {
|
|
4681
|
+
evidence.push(`Best matching owned page: ${opportunity.ourBestPage.url}`);
|
|
4682
|
+
} else {
|
|
4683
|
+
evidence.push("No matching owned page was found");
|
|
4684
|
+
}
|
|
4685
|
+
actions.push({
|
|
4686
|
+
audience: "both",
|
|
4687
|
+
priority: 20 + index,
|
|
4688
|
+
horizon: opportunity.actionConfidence === "high" ? "short-term" : "medium-term",
|
|
4689
|
+
category: "content",
|
|
4690
|
+
title: `${verb} content for "${opportunity.query}"`,
|
|
4691
|
+
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.`,
|
|
4692
|
+
why: opportunity.drivers.length > 0 ? opportunity.drivers : ["Canonry ranked this as a content opportunity from search-demand and citation evidence."],
|
|
4693
|
+
evidence,
|
|
4694
|
+
successMetric: `A future sweep cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`,
|
|
4695
|
+
confidence: opportunity.actionConfidence
|
|
4696
|
+
});
|
|
4697
|
+
}
|
|
4698
|
+
if (input.indexingHealth && input.indexingHealth.total > 0 && input.indexingHealth.indexedPct < 70) {
|
|
4699
|
+
const ih = input.indexingHealth;
|
|
4700
|
+
const evidence = [
|
|
4701
|
+
`${ih.indexedPct}% indexed (${ih.indexed}/${ih.total})`,
|
|
4702
|
+
`${ih.notIndexed} not indexed${ih.deindexed > 0 ? `, ${ih.deindexed} deindexed` : ""}`
|
|
4703
|
+
];
|
|
4704
|
+
actions.push({
|
|
4705
|
+
audience: "both",
|
|
4706
|
+
priority: 30,
|
|
4707
|
+
horizon: "immediate",
|
|
4708
|
+
category: "indexing",
|
|
4709
|
+
title: "Fix indexing coverage before expanding the content plan",
|
|
4710
|
+
action: "Audit the not-indexed tracked URLs, resolve crawl/index blockers, and resubmit priority pages.",
|
|
4711
|
+
why: [
|
|
4712
|
+
"Pages missing from the search index are less likely to be retrieved or cited by AI answer engines.",
|
|
4713
|
+
"Indexing issues can hide otherwise strong content from both search and AI systems."
|
|
4714
|
+
],
|
|
4715
|
+
evidence,
|
|
4716
|
+
successMetric: "Indexed share moves above 80% for tracked URLs and priority pages are eligible for retrieval.",
|
|
4717
|
+
confidence: ih.total >= 5 ? "high" : "medium"
|
|
4718
|
+
});
|
|
4719
|
+
}
|
|
4720
|
+
const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
|
|
4721
|
+
if (zeroCitationProviders.length > 0) {
|
|
4722
|
+
actions.push({
|
|
4723
|
+
audience: "agency",
|
|
4724
|
+
priority: 40,
|
|
4725
|
+
horizon: "short-term",
|
|
4726
|
+
category: "provider",
|
|
4727
|
+
title: "Diagnose providers with zero citations",
|
|
4728
|
+
action: "Inspect zero-citation provider answers and compare their cited domains against the pages currently available on the client site.",
|
|
4729
|
+
why: [
|
|
4730
|
+
"Provider-level misses show where one model family is not retrieving the client even when others might.",
|
|
4731
|
+
"This points the agency toward provider-specific evidence gaps instead of a generic content recommendation."
|
|
4732
|
+
],
|
|
4733
|
+
evidence: zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount} cited query-provider pairs`),
|
|
4734
|
+
successMetric: "At least one zero-citation provider cites the client on a priority query in a later sweep.",
|
|
4735
|
+
confidence: "high"
|
|
4736
|
+
});
|
|
4737
|
+
}
|
|
4738
|
+
if (input.gsc && (input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0)) {
|
|
4739
|
+
const evidence = [];
|
|
4740
|
+
if (input.gsc.trackedButNoGsc.length > 0) {
|
|
4741
|
+
evidence.push(`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`);
|
|
4742
|
+
}
|
|
4743
|
+
if (input.gsc.gscButNotTracked.length > 0) {
|
|
4744
|
+
evidence.push(`Search demand not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`);
|
|
4745
|
+
}
|
|
4746
|
+
actions.push({
|
|
4747
|
+
audience: "agency",
|
|
4748
|
+
priority: 50,
|
|
4749
|
+
horizon: "short-term",
|
|
4750
|
+
category: "search-demand",
|
|
4751
|
+
title: "Align tracked AEO queries with search demand",
|
|
4752
|
+
action: "Prune or relabel tracked queries with no search demand and add high-impression non-brand GSC queries to the AEO tracking set.",
|
|
4753
|
+
why: [
|
|
4754
|
+
"The strongest report actions come from overlap between real search demand and AI citation gaps.",
|
|
4755
|
+
"Mismatch here can make the client report feel interesting but hard to act on."
|
|
4756
|
+
],
|
|
4757
|
+
evidence,
|
|
4758
|
+
successMetric: "Next report has fewer no-demand tracked queries and includes the highest-impression non-brand GSC candidates.",
|
|
4759
|
+
confidence: evidence.length > 1 ? "high" : "medium"
|
|
4760
|
+
});
|
|
4761
|
+
}
|
|
4762
|
+
if (input.contentGaps.length > 0) {
|
|
4763
|
+
const topGap = input.contentGaps[0];
|
|
4764
|
+
actions.push({
|
|
4765
|
+
audience: "agency",
|
|
4766
|
+
priority: 60,
|
|
4767
|
+
horizon: "medium-term",
|
|
4768
|
+
category: "content",
|
|
4769
|
+
title: "Close competitor-cited content gaps",
|
|
4770
|
+
action: "Map the top missing queries to owned pages or new briefs, starting with the gaps where multiple competitors are already cited.",
|
|
4771
|
+
why: [
|
|
4772
|
+
"These are explicit places where AI engines found competitor sources but not the client.",
|
|
4773
|
+
"They are stronger evidence than a generic topic list because the model is already retrieving competing content."
|
|
4774
|
+
],
|
|
4775
|
+
evidence: [
|
|
4776
|
+
`"${topGap.query}" missed at ${Math.round(topGap.missRate * 100)}% with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
|
|
4777
|
+
`Cited competitors: ${compactList(topGap.competitorDomains)}`
|
|
4778
|
+
],
|
|
4779
|
+
successMetric: "The top content-gap query moves from missed to cited or mentioned after the recommended content work ships.",
|
|
4780
|
+
confidence: topGap.competitorCount >= 2 ? "high" : "medium"
|
|
4781
|
+
});
|
|
4782
|
+
}
|
|
4783
|
+
if (input.reportLocation && input.reportLocation.otherConfiguredLabels.length > 0) {
|
|
4784
|
+
const ignoredProviders = input.providerLocationHandling.filter((p) => p.treatment === "ignored" || p.treatment === "browser-geo").map((p) => p.provider);
|
|
4785
|
+
const evidence = [
|
|
4786
|
+
`Current report location: ${input.reportLocation.label}`,
|
|
4787
|
+
`Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`
|
|
4788
|
+
];
|
|
4789
|
+
if (ignoredProviders.length > 0) {
|
|
4790
|
+
evidence.push(`Providers with weak/indirect location handling: ${compactList(ignoredProviders)}`);
|
|
4791
|
+
}
|
|
4792
|
+
actions.push({
|
|
4793
|
+
audience: "agency",
|
|
4794
|
+
priority: 70,
|
|
4795
|
+
horizon: "medium-term",
|
|
4796
|
+
category: "location",
|
|
4797
|
+
title: "Keep location-scoped reporting separate by market",
|
|
4798
|
+
action: "Run and compare separate sweeps for each configured location before making market-level recommendations.",
|
|
4799
|
+
why: [
|
|
4800
|
+
"A multi-location client can appear differently by market.",
|
|
4801
|
+
"Keeping each report location-scoped avoids mixing Florida and Michigan evidence in the same client story."
|
|
4802
|
+
],
|
|
4803
|
+
evidence,
|
|
4804
|
+
successMetric: "Each configured market has its own current sweep and trend before cross-market decisions are made.",
|
|
4805
|
+
confidence: "high"
|
|
4806
|
+
});
|
|
4807
|
+
}
|
|
4808
|
+
if (actions.length === 0) {
|
|
4809
|
+
actions.push({
|
|
4810
|
+
audience: "both",
|
|
4811
|
+
priority: 90,
|
|
4812
|
+
horizon: "short-term",
|
|
4813
|
+
category: "monitoring",
|
|
4814
|
+
title: "Keep monitoring citation and mention coverage",
|
|
4815
|
+
action: "Run the next scheduled visibility sweep and watch for citation gains, losses, and provider-specific misses.",
|
|
4816
|
+
why: [
|
|
4817
|
+
"No urgent corrective action surfaced from the current evidence.",
|
|
4818
|
+
"AEO performance is directional; repeated sweeps are needed before overreacting to a single sample."
|
|
4819
|
+
],
|
|
4820
|
+
evidence: ["No critical insights, content gaps, indexing blockers, or provider-zero issues were detected in this report."],
|
|
4821
|
+
successMetric: "Coverage stays stable or improves across the next trend window.",
|
|
4822
|
+
confidence: "medium"
|
|
4823
|
+
});
|
|
4824
|
+
}
|
|
4825
|
+
return actions.sort((a, b) => a.priority - b.priority).slice(0, 10);
|
|
4826
|
+
}
|
|
4827
|
+
function trendSentence(trend) {
|
|
4828
|
+
switch (trend) {
|
|
4829
|
+
case "up":
|
|
4830
|
+
return "Citation coverage improved versus the prior comparable sweep.";
|
|
4831
|
+
case "down":
|
|
4832
|
+
return "Citation coverage declined versus the prior comparable sweep.";
|
|
4833
|
+
case "flat":
|
|
4834
|
+
return "Citation coverage is flat versus the prior comparable sweep.";
|
|
4835
|
+
case "unknown":
|
|
4836
|
+
return "There is not enough comparable run history yet to call a trend.";
|
|
4837
|
+
}
|
|
4838
|
+
}
|
|
4839
|
+
function buildClientSummary(reportLike) {
|
|
4840
|
+
const s = reportLike.executiveSummary;
|
|
4841
|
+
const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
|
|
4842
|
+
const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a visibility sweep yet";
|
|
4843
|
+
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)}` : "Canonry needs at least one completed visibility sweep before it can summarize how the brand appears in AI answers.";
|
|
4844
|
+
const confidenceNotes = [];
|
|
4845
|
+
if (s.totalQueryCount === 0) {
|
|
4846
|
+
confidenceNotes.push("Confidence is low until the first tracked query sweep completes.");
|
|
4847
|
+
} else if (s.totalQueryCount < 5) {
|
|
4848
|
+
confidenceNotes.push("Directional read: the tracked query set is still small, so each query has outsized impact on the percentage.");
|
|
4849
|
+
}
|
|
4850
|
+
if (isTrendBaseline(reportLike.citationsTrend)) {
|
|
4851
|
+
confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable sweeps are needed for a stable trend.`);
|
|
4852
|
+
}
|
|
4853
|
+
if (!reportLike.gsc) {
|
|
4854
|
+
confidenceNotes.push("Search Console is not connected, so content recommendations lean more heavily on citation and competitor evidence.");
|
|
4855
|
+
}
|
|
4856
|
+
if (reportLike.reportLocation) {
|
|
4857
|
+
confidenceNotes.push(`This summary is scoped to the ${reportLike.reportLocation.label} run location.`);
|
|
4858
|
+
}
|
|
4859
|
+
return {
|
|
4860
|
+
headline,
|
|
4861
|
+
overview,
|
|
4862
|
+
actionItems: reportLike.actionPlan.filter((a) => actionAudienceMatches2(a, "client")).slice(0, 3),
|
|
4863
|
+
confidenceNotes
|
|
4864
|
+
};
|
|
4865
|
+
}
|
|
4866
|
+
function buildAgencyDiagnostics(input) {
|
|
4867
|
+
const diagnostics = [];
|
|
4868
|
+
const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
|
|
4869
|
+
diagnostics.push({
|
|
4870
|
+
title: "Provider citation coverage",
|
|
4871
|
+
detail: zeroCitationProviders.length > 0 ? `${zeroCitationProviders.length} provider${zeroCitationProviders.length === 1 ? "" : "s"} returned zero client citations in the latest sweep.` : "Every provider with completed snapshots produced at least one client citation or no provider data is available yet.",
|
|
4872
|
+
severity: zeroCitationProviders.length > 0 ? "negative" : "positive",
|
|
4873
|
+
evidence: zeroCitationProviders.length > 0 ? zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount}`) : input.citationScorecard.providerRates.map((p) => `${p.provider}: ${p.citedCount}/${p.totalCount}`)
|
|
4874
|
+
});
|
|
4875
|
+
diagnostics.push({
|
|
4876
|
+
title: "AI source domains",
|
|
4877
|
+
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 sweep yet.",
|
|
4878
|
+
severity: input.aiSourceOrigin.topDomains.length > 0 ? "neutral" : "caution",
|
|
4879
|
+
evidence: input.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `${d.domain}: ${d.count}`)
|
|
4880
|
+
});
|
|
4881
|
+
if (input.gsc) {
|
|
4882
|
+
diagnostics.push({
|
|
4883
|
+
title: "GSC query mismatch",
|
|
4884
|
+
detail: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "The tracked AEO query set and real search demand are not fully aligned." : "Tracked AEO queries and high-impression non-brand GSC queries are aligned for the current window.",
|
|
4885
|
+
severity: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "caution" : "positive",
|
|
4886
|
+
evidence: [
|
|
4887
|
+
...input.gsc.trackedButNoGsc.length > 0 ? [`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`] : [],
|
|
4888
|
+
...input.gsc.gscButNotTracked.length > 0 ? [`GSC queries not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`] : []
|
|
4889
|
+
]
|
|
4890
|
+
});
|
|
4891
|
+
}
|
|
4892
|
+
if (input.indexingHealth) {
|
|
4893
|
+
diagnostics.push({
|
|
4894
|
+
title: "Indexing health",
|
|
4895
|
+
detail: `${input.indexingHealth.indexedPct}% of inspected URLs are indexed in ${input.indexingHealth.provider ?? "the connected provider"}.`,
|
|
4896
|
+
severity: input.indexingHealth.indexedPct >= 90 ? "positive" : input.indexingHealth.indexedPct >= 70 ? "caution" : "negative",
|
|
4897
|
+
evidence: [
|
|
4898
|
+
`${input.indexingHealth.indexed}/${input.indexingHealth.total} indexed`,
|
|
4899
|
+
`${input.indexingHealth.notIndexed} not indexed`
|
|
4900
|
+
]
|
|
4901
|
+
});
|
|
4902
|
+
}
|
|
4903
|
+
diagnostics.push({
|
|
4904
|
+
title: "Content opportunity pipeline",
|
|
4905
|
+
detail: input.contentOpportunities.length > 0 ? `${input.contentOpportunities.length} ranked content opportunit${input.contentOpportunities.length === 1 ? "y" : "ies"} and ${input.contentGaps.length} content gap${input.contentGaps.length === 1 ? "" : "s"} are available.` : "No ranked content opportunities are available from the current evidence.",
|
|
4906
|
+
severity: input.contentOpportunities.length > 0 ? "caution" : "neutral",
|
|
4907
|
+
evidence: input.contentOpportunities.slice(0, 3).map((o) => `${o.query}: ${o.action} (${Math.round(o.score)})`)
|
|
4908
|
+
});
|
|
4909
|
+
if (input.reportLocation) {
|
|
4910
|
+
diagnostics.push({
|
|
4911
|
+
title: "Location caveat",
|
|
4912
|
+
detail: input.reportLocation.otherConfiguredLabels.length > 0 ? "This report is scoped to the latest run location; other configured locations need separate interpretation." : "This report is scoped to one configured location.",
|
|
4913
|
+
severity: input.reportLocation.otherConfiguredLabels.length > 0 ? "caution" : "neutral",
|
|
4914
|
+
evidence: [
|
|
4915
|
+
`Current location: ${input.reportLocation.label}`,
|
|
4916
|
+
...input.reportLocation.otherConfiguredLabels.length > 0 ? [`Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`] : []
|
|
4917
|
+
]
|
|
4918
|
+
});
|
|
4919
|
+
}
|
|
4920
|
+
return {
|
|
4921
|
+
priorities: input.actionPlan.filter((a) => actionAudienceMatches2(a, "agency")).slice(0, 6),
|
|
4922
|
+
diagnostics
|
|
4923
|
+
};
|
|
4924
|
+
}
|
|
4293
4925
|
function buildProjectReport(db, projectName) {
|
|
4294
4926
|
const project = resolveProject(db, projectName);
|
|
4295
4927
|
const queryLookup = loadQueryLookup(db, project.id);
|
|
@@ -4299,6 +4931,7 @@ function buildProjectReport(db, projectName) {
|
|
|
4299
4931
|
(r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
|
|
4300
4932
|
) ?? visibilityRuns[0];
|
|
4301
4933
|
const latestSnapshots = latestRun ? loadSnapshotsForRun(db, latestRun.id) : [];
|
|
4934
|
+
const latestRunLocation = latestRun?.location ?? null;
|
|
4302
4935
|
const competitorRows = db.select().from(competitors).where(eq13(competitors.projectId, project.id)).all();
|
|
4303
4936
|
const competitorDomains = competitorRows.map((c) => c.domain);
|
|
4304
4937
|
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
@@ -4330,9 +4963,9 @@ function buildProjectReport(db, projectName) {
|
|
|
4330
4963
|
const socialSection = buildSocialReferrals(db, project.id);
|
|
4331
4964
|
const aiReferralsSection = buildAiReferrals(db, project.id);
|
|
4332
4965
|
const indexingHealthSection = buildIndexingHealth(db, project.id);
|
|
4333
|
-
const citationsTrend = buildCitationsTrend(db, project.id, queryLookup);
|
|
4334
|
-
const insightList = buildInsightList(db, project.id);
|
|
4335
|
-
const orchestratorInput = loadOrchestratorInput(db, project);
|
|
4966
|
+
const citationsTrend = buildCitationsTrend(db, project.id, queryLookup, latestRunLocation);
|
|
4967
|
+
const insightList = buildInsightList(db, project.id, latestRunLocation);
|
|
4968
|
+
const orchestratorInput = loadOrchestratorInput(db, project, latestRunLocation);
|
|
4336
4969
|
const contentOpportunities = buildContentTargetRows(orchestratorInput);
|
|
4337
4970
|
const contentGaps = buildContentGapRows(orchestratorInput);
|
|
4338
4971
|
const groundingSources = buildContentSourceRows(orchestratorInput);
|
|
@@ -4341,14 +4974,18 @@ function buildProjectReport(db, projectName) {
|
|
|
4341
4974
|
contentOpportunities,
|
|
4342
4975
|
insightDerivedSteps
|
|
4343
4976
|
);
|
|
4344
|
-
|
|
4345
|
-
|
|
4977
|
+
const totalQueryCount = queryLookup.byId.size;
|
|
4978
|
+
const citedQueryIds = /* @__PURE__ */ new Set();
|
|
4979
|
+
const mentionedQueryIds = /* @__PURE__ */ new Set();
|
|
4346
4980
|
for (const snap of latestSnapshots) {
|
|
4347
4981
|
if (!queryLookup.byId.has(snap.queryId)) continue;
|
|
4348
|
-
|
|
4349
|
-
if (snap.
|
|
4982
|
+
if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
|
|
4983
|
+
if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
|
|
4350
4984
|
}
|
|
4351
|
-
const
|
|
4985
|
+
const citedQueryCount = citedQueryIds.size;
|
|
4986
|
+
const mentionedQueryCount = mentionedQueryIds.size;
|
|
4987
|
+
const citationRate = totalQueryCount > 0 ? Math.round(citedQueryCount / totalQueryCount * 100) : 0;
|
|
4988
|
+
const mentionRate = totalQueryCount > 0 ? Math.round(mentionedQueryCount / totalQueryCount * 100) : 0;
|
|
4352
4989
|
const trendBaseline = isTrendBaseline(citationsTrend);
|
|
4353
4990
|
const latestPoint = citationsTrend.at(-1);
|
|
4354
4991
|
const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
|
|
@@ -4365,6 +5002,8 @@ function buildProjectReport(db, projectName) {
|
|
|
4365
5002
|
}
|
|
4366
5003
|
const findings = buildExecutiveFindings(
|
|
4367
5004
|
citationRate,
|
|
5005
|
+
citedQueryCount,
|
|
5006
|
+
totalQueryCount,
|
|
4368
5007
|
trend,
|
|
4369
5008
|
citationsTrend,
|
|
4370
5009
|
trendBaseline,
|
|
@@ -4373,6 +5012,66 @@ function buildProjectReport(db, projectName) {
|
|
|
4373
5012
|
);
|
|
4374
5013
|
const periodStart = citationsTrend[0]?.date ?? null;
|
|
4375
5014
|
const periodEnd = citationsTrend.at(-1)?.date ?? null;
|
|
5015
|
+
const configuredLocations = parseJsonColumn(project.locations, []);
|
|
5016
|
+
const reportLocation = buildLocationMeta(latestRun?.location ?? null, configuredLocations);
|
|
5017
|
+
const providerLocationHandling = reportLocation ? buildProviderLocationHandling(citationScorecard.providers) : [];
|
|
5018
|
+
const executiveSummary = {
|
|
5019
|
+
citationRate,
|
|
5020
|
+
citedQueryCount,
|
|
5021
|
+
totalQueryCount,
|
|
5022
|
+
mentionRate,
|
|
5023
|
+
mentionedQueryCount,
|
|
5024
|
+
trend,
|
|
5025
|
+
queryCount: queryLookup.byId.size,
|
|
5026
|
+
competitorCount: competitorDomains.length,
|
|
5027
|
+
providerCount: citationScorecard.providers.length,
|
|
5028
|
+
gsc: gscSection ? {
|
|
5029
|
+
clicks: gscSection.totalClicks,
|
|
5030
|
+
impressions: gscSection.totalImpressions,
|
|
5031
|
+
ctr: gscSection.ctr,
|
|
5032
|
+
avgPosition: gscSection.avgPosition
|
|
5033
|
+
} : null,
|
|
5034
|
+
ga: gaSection ? {
|
|
5035
|
+
sessions: gaSection.totalSessions,
|
|
5036
|
+
users: gaSection.totalUsers,
|
|
5037
|
+
periodStart: gaSection.periodStart,
|
|
5038
|
+
periodEnd: gaSection.periodEnd
|
|
5039
|
+
} : null,
|
|
5040
|
+
findings
|
|
5041
|
+
};
|
|
5042
|
+
const actionPlan = buildReportActionPlan({
|
|
5043
|
+
canonicalDomain: project.canonicalDomain,
|
|
5044
|
+
competitorDomains,
|
|
5045
|
+
citationScorecard,
|
|
5046
|
+
aiSourceOrigin,
|
|
5047
|
+
gsc: gscSection,
|
|
5048
|
+
indexingHealth: indexingHealthSection,
|
|
5049
|
+
contentOpportunities,
|
|
5050
|
+
contentGaps,
|
|
5051
|
+
reportLocation,
|
|
5052
|
+
providerLocationHandling
|
|
5053
|
+
});
|
|
5054
|
+
const clientSummary = buildClientSummary({
|
|
5055
|
+
canonicalDomain: project.canonicalDomain,
|
|
5056
|
+
reportLocation,
|
|
5057
|
+
executiveSummary,
|
|
5058
|
+
citationsTrend,
|
|
5059
|
+
gsc: gscSection,
|
|
5060
|
+
actionPlan
|
|
5061
|
+
});
|
|
5062
|
+
const agencyDiagnostics = buildAgencyDiagnostics({
|
|
5063
|
+
canonicalDomain: project.canonicalDomain,
|
|
5064
|
+
competitorDomains,
|
|
5065
|
+
citationScorecard,
|
|
5066
|
+
aiSourceOrigin,
|
|
5067
|
+
gsc: gscSection,
|
|
5068
|
+
indexingHealth: indexingHealthSection,
|
|
5069
|
+
contentOpportunities,
|
|
5070
|
+
contentGaps,
|
|
5071
|
+
reportLocation,
|
|
5072
|
+
providerLocationHandling,
|
|
5073
|
+
actionPlan
|
|
5074
|
+
});
|
|
4376
5075
|
return {
|
|
4377
5076
|
meta: {
|
|
4378
5077
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -4384,29 +5083,12 @@ function buildProjectReport(db, projectName) {
|
|
|
4384
5083
|
country: project.country,
|
|
4385
5084
|
language: project.language
|
|
4386
5085
|
},
|
|
5086
|
+
location: reportLocation,
|
|
5087
|
+
providerLocationHandling,
|
|
4387
5088
|
periodStart,
|
|
4388
5089
|
periodEnd
|
|
4389
5090
|
},
|
|
4390
|
-
executiveSummary
|
|
4391
|
-
citationRate,
|
|
4392
|
-
trend,
|
|
4393
|
-
queryCount: queryLookup.byId.size,
|
|
4394
|
-
competitorCount: competitorDomains.length,
|
|
4395
|
-
providerCount: citationScorecard.providers.length,
|
|
4396
|
-
gsc: gscSection ? {
|
|
4397
|
-
clicks: gscSection.totalClicks,
|
|
4398
|
-
impressions: gscSection.totalImpressions,
|
|
4399
|
-
ctr: gscSection.ctr,
|
|
4400
|
-
avgPosition: gscSection.avgPosition
|
|
4401
|
-
} : null,
|
|
4402
|
-
ga: gaSection ? {
|
|
4403
|
-
sessions: gaSection.totalSessions,
|
|
4404
|
-
users: gaSection.totalUsers,
|
|
4405
|
-
periodStart: gaSection.periodStart,
|
|
4406
|
-
periodEnd: gaSection.periodEnd
|
|
4407
|
-
} : null,
|
|
4408
|
-
findings
|
|
4409
|
-
},
|
|
5091
|
+
executiveSummary,
|
|
4410
5092
|
citationScorecard,
|
|
4411
5093
|
competitorLandscape,
|
|
4412
5094
|
mentionLandscape,
|
|
@@ -4419,14 +5101,22 @@ function buildProjectReport(db, projectName) {
|
|
|
4419
5101
|
citationsTrend,
|
|
4420
5102
|
insights: insightList,
|
|
4421
5103
|
recommendedNextSteps,
|
|
5104
|
+
actionPlan,
|
|
5105
|
+
clientSummary,
|
|
5106
|
+
agencyDiagnostics,
|
|
4422
5107
|
contentOpportunities,
|
|
4423
5108
|
contentGaps,
|
|
4424
5109
|
groundingSources
|
|
4425
5110
|
};
|
|
4426
5111
|
}
|
|
4427
|
-
function
|
|
5112
|
+
function parseReportAudience(value) {
|
|
5113
|
+
if (value === void 0 || value === "agency") return "agency";
|
|
5114
|
+
if (value === "client") return "client";
|
|
5115
|
+
throw validationError('"audience" must be "agency" or "client"');
|
|
5116
|
+
}
|
|
5117
|
+
function reportFilenameFor(project, generatedAt, audience) {
|
|
4428
5118
|
const date = generatedAt.slice(0, 10);
|
|
4429
|
-
return `canonry-report-${project.name}-${date}.html`;
|
|
5119
|
+
return `canonry-report-${project.name}-${audience}-${date}.html`;
|
|
4430
5120
|
}
|
|
4431
5121
|
async function reportRoutes(app) {
|
|
4432
5122
|
app.get("/projects/:name/report", async (request, reply) => {
|
|
@@ -4434,9 +5124,10 @@ async function reportRoutes(app) {
|
|
|
4434
5124
|
return reply.send(dto);
|
|
4435
5125
|
});
|
|
4436
5126
|
app.get("/projects/:name/report.html", async (request, reply) => {
|
|
5127
|
+
const audience = parseReportAudience(request.query.audience);
|
|
4437
5128
|
const dto = buildProjectReport(app.db, request.params.name);
|
|
4438
|
-
const html = renderReportHtml(dto);
|
|
4439
|
-
const filename = reportFilenameFor(dto.meta.project, dto.meta.generatedAt);
|
|
5129
|
+
const html = renderReportHtml(dto, { audience });
|
|
5130
|
+
const filename = reportFilenameFor(dto.meta.project, dto.meta.generatedAt, audience);
|
|
4440
5131
|
reply.header("Content-Type", "text/html; charset=utf-8");
|
|
4441
5132
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
|
4442
5133
|
return reply.send(html);
|
|
@@ -5294,6 +5985,12 @@ var locationQueryParameter = {
|
|
|
5294
5985
|
description: "Filter by location label. Use an empty value to request locationless results.",
|
|
5295
5986
|
schema: stringSchema
|
|
5296
5987
|
};
|
|
5988
|
+
var reportAudienceQueryParameter = {
|
|
5989
|
+
name: "audience",
|
|
5990
|
+
in: "query",
|
|
5991
|
+
description: "HTML report audience mode. Defaults to agency.",
|
|
5992
|
+
schema: { type: "string", enum: ["agency", "client"] }
|
|
5993
|
+
};
|
|
5297
5994
|
var analyticsWindowParameter = {
|
|
5298
5995
|
name: "window",
|
|
5299
5996
|
in: "query",
|
|
@@ -7514,9 +8211,9 @@ var routeCatalog = [
|
|
|
7514
8211
|
{
|
|
7515
8212
|
method: "get",
|
|
7516
8213
|
path: "/api/v1/projects/{name}/report",
|
|
7517
|
-
summary: "Aggregated
|
|
8214
|
+
summary: "Aggregated canonical AEO report",
|
|
7518
8215
|
tags: ["report"],
|
|
7519
|
-
description: "Bundles every section the canonry-report HTML output needs (executive summary, citation scorecard, competitor landscape \u2014 citation + mention landscapes, AI citation sources, GSC, GA4, social/AI referrals, indexing health, citations trend, insights, and recommended next steps) into a single JSON payload. Backs `canonry report <project
|
|
8216
|
+
description: "Bundles every section the canonry-report HTML output needs (executive summary, client summary, agency diagnostics, action plan, citation scorecard, competitor landscape \u2014 citation + mention landscapes, AI citation sources, GSC, GA4, social/AI referrals, indexing health, citations trend, insights, and recommended next steps) into a single canonical JSON payload. Backs `canonry report <project>` and MCP report reads.",
|
|
7520
8217
|
parameters: [nameParameter],
|
|
7521
8218
|
responses: {
|
|
7522
8219
|
200: { description: "Report returned." },
|
|
@@ -7528,8 +8225,8 @@ var routeCatalog = [
|
|
|
7528
8225
|
path: "/api/v1/projects/{name}/report.html",
|
|
7529
8226
|
summary: "Standalone HTML AEO report",
|
|
7530
8227
|
tags: ["report"],
|
|
7531
|
-
description: "Server-rendered self-contained HTML version of the project report. Same data as `/projects/{name}/report` (JSON), rendered through the canonry HTML report renderer. Returns `text/html` with `Content-Disposition: attachment` so browsers download it as `canonry-report-<project>-YYYY-MM-DD.html`. Open in a browser and Print \u2192 Save as PDF for a PDF copy.",
|
|
7532
|
-
parameters: [nameParameter],
|
|
8228
|
+
description: "Server-rendered self-contained HTML version of the project report. Same data as `/projects/{name}/report` (JSON), rendered through the canonry HTML report renderer in agency or client mode. Returns `text/html` with `Content-Disposition: attachment` so browsers download it as `canonry-report-<project>-<audience>-YYYY-MM-DD.html`. Open in a browser and Print \u2192 Save as PDF for a PDF copy.",
|
|
8229
|
+
parameters: [nameParameter, reportAudienceQueryParameter],
|
|
7533
8230
|
responses: {
|
|
7534
8231
|
200: { description: "HTML report returned." },
|
|
7535
8232
|
404: { description: "Project not found." }
|