@ainyc/canonry 4.1.3 → 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-JV6X6AFT.js → chunk-DVTPGC6O.js} +1087 -348
- package/dist/{chunk-AXMSAMKN.js → chunk-OOADR2Q5.js} +764 -26
- package/dist/{chunk-KCETXLDF.js → chunk-VDEMEI64.js} +19 -7
- package/dist/{chunk-O5JZQUPX.js → chunk-XAW66QUX.js} +87 -2
- package/dist/cli.js +102 -23
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-WPY4PDBU.js → intelligence-service-ABHO5HHA.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +7 -7
- package/assets/assets/index-BbhhYPML.js +0 -302
|
@@ -4,8 +4,9 @@ import {
|
|
|
4
4
|
configExists,
|
|
5
5
|
loadConfig,
|
|
6
6
|
saveConfigPatch
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-VDEMEI64.js";
|
|
8
8
|
import {
|
|
9
|
+
DEFAULT_RUN_HISTORY_LIMIT,
|
|
9
10
|
IntelligenceService,
|
|
10
11
|
MIN_TREND_POINTS,
|
|
11
12
|
agentMemory,
|
|
@@ -16,11 +17,22 @@ import {
|
|
|
16
17
|
backlinkSummaries,
|
|
17
18
|
bingCoverageSnapshots,
|
|
18
19
|
bingUrlInspections,
|
|
20
|
+
buildAiSourceOrigin,
|
|
19
21
|
buildBrandTokens,
|
|
22
|
+
buildCitationScorecard,
|
|
23
|
+
buildCompetitorLandscape,
|
|
24
|
+
buildCompetitorPressureScore,
|
|
20
25
|
buildContentGapRows,
|
|
21
26
|
buildContentSourceRows,
|
|
22
27
|
buildContentTargetRows,
|
|
28
|
+
buildGapQueryScore,
|
|
23
29
|
buildInventory,
|
|
30
|
+
buildMentionLandscape,
|
|
31
|
+
buildMovementSummary,
|
|
32
|
+
buildOverviewCompetitors,
|
|
33
|
+
buildProviderScores,
|
|
34
|
+
buildRunHistory,
|
|
35
|
+
buildVisibilityScore,
|
|
24
36
|
categorizeQueryByIntent,
|
|
25
37
|
ccReleaseSyncs,
|
|
26
38
|
competitors,
|
|
@@ -49,7 +61,7 @@ import {
|
|
|
49
61
|
runs,
|
|
50
62
|
schedules,
|
|
51
63
|
usageCounters
|
|
52
|
-
} from "./chunk-
|
|
64
|
+
} from "./chunk-OOADR2Q5.js";
|
|
53
65
|
import {
|
|
54
66
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
55
67
|
AGENT_PROVIDER_IDS,
|
|
@@ -83,6 +95,7 @@ import {
|
|
|
83
95
|
emptyCitationVisibility,
|
|
84
96
|
extractAnswerMentions,
|
|
85
97
|
findDuplicateLocationLabels,
|
|
98
|
+
getProviderLocationHandling,
|
|
86
99
|
hasLocationLabel,
|
|
87
100
|
internalError,
|
|
88
101
|
isAgentProviderId,
|
|
@@ -101,6 +114,7 @@ import {
|
|
|
101
114
|
providerError,
|
|
102
115
|
queryGenerateRequestSchema,
|
|
103
116
|
registrableDomain,
|
|
117
|
+
reportActionTone,
|
|
104
118
|
resolveConfigSpecQueries,
|
|
105
119
|
resolveSnapshotRequestQueries,
|
|
106
120
|
runInProgress,
|
|
@@ -115,7 +129,7 @@ import {
|
|
|
115
129
|
visibilityStateFromAnswerMentioned,
|
|
116
130
|
windowCutoff,
|
|
117
131
|
wordpressEnvSchema
|
|
118
|
-
} from "./chunk-
|
|
132
|
+
} from "./chunk-XAW66QUX.js";
|
|
119
133
|
|
|
120
134
|
// src/telemetry.ts
|
|
121
135
|
import crypto from "crypto";
|
|
@@ -2734,6 +2748,21 @@ section.report-section .section-intro {
|
|
|
2734
2748
|
.finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
2735
2749
|
.finding strong { display: block; margin-bottom: 4px; }
|
|
2736
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; }
|
|
2737
2766
|
table.report-table {
|
|
2738
2767
|
width: 100%;
|
|
2739
2768
|
border-collapse: collapse;
|
|
@@ -2854,6 +2883,77 @@ table.report-table td .badge {
|
|
|
2854
2883
|
}
|
|
2855
2884
|
.step .title { font-weight: 600; }
|
|
2856
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}; }
|
|
2857
2957
|
.footer {
|
|
2858
2958
|
margin-top: 96px;
|
|
2859
2959
|
padding-top: 24px;
|
|
@@ -2878,15 +2978,68 @@ function section(opts, body) {
|
|
|
2878
2978
|
function renderEmpty(message) {
|
|
2879
2979
|
return `<div class="empty-state">${escapeHtml(message)}</div>`;
|
|
2880
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
|
+
}
|
|
2881
3026
|
function renderExecutiveSummary(report) {
|
|
2882
3027
|
const s = report.executiveSummary;
|
|
2883
3028
|
const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
|
|
2884
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";
|
|
2885
3033
|
const metrics = [
|
|
2886
3034
|
{
|
|
2887
3035
|
label: "Citation rate",
|
|
2888
3036
|
value: `${s.citationRate}%`,
|
|
2889
|
-
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
|
|
2890
3043
|
},
|
|
2891
3044
|
{
|
|
2892
3045
|
label: "Queries tracked",
|
|
@@ -2920,14 +3073,15 @@ function renderExecutiveSummary(report) {
|
|
|
2920
3073
|
<strong>${escapeHtml(f.title)}</strong>
|
|
2921
3074
|
<span>${escapeHtml(f.detail)}</span>
|
|
2922
3075
|
</div>`).join("")}</div>` : "";
|
|
3076
|
+
const locationHtml = renderLocationCard(report);
|
|
2923
3077
|
return section(
|
|
2924
3078
|
{
|
|
2925
3079
|
id: "executive-summary",
|
|
2926
3080
|
eyebrow: "Section 1",
|
|
2927
3081
|
title: "Executive Summary",
|
|
2928
|
-
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."
|
|
2929
3083
|
},
|
|
2930
|
-
metricsHtml + findingsHtml
|
|
3084
|
+
metricsHtml + findingsHtml + locationHtml
|
|
2931
3085
|
);
|
|
2932
3086
|
}
|
|
2933
3087
|
function renderProviderBars(rates) {
|
|
@@ -2965,16 +3119,16 @@ function renderCitationMatrix(scorecard) {
|
|
|
2965
3119
|
const cells = scorecard.providers.map((_, pi) => {
|
|
2966
3120
|
const cell = scorecard.matrix[qi]?.[pi];
|
|
2967
3121
|
if (!cell) {
|
|
2968
|
-
return '<td><span class="cell-pending">\u2014</span></td>';
|
|
3122
|
+
return '<td><span class="cell-pending">\u2014 \u2014</span></td>';
|
|
2969
3123
|
}
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
}
|
|
2973
|
-
return '<td><span class="cell-not-cited">Not cited</span></td>';
|
|
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>`;
|
|
2974
3127
|
}).join("");
|
|
2975
3128
|
return `<tr><td>${escapeHtml(q)}</td>${cells}</tr>`;
|
|
2976
3129
|
}).join("");
|
|
2977
|
-
|
|
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">
|
|
2978
3132
|
<thead><tr><th>Query</th>${headers}</tr></thead>
|
|
2979
3133
|
<tbody>${rows}</tbody>
|
|
2980
3134
|
</table>`;
|
|
@@ -2985,7 +3139,7 @@ function renderCitationScorecard(report) {
|
|
|
2985
3139
|
${renderCitationMatrix(report.citationScorecard)}
|
|
2986
3140
|
`;
|
|
2987
3141
|
return section(
|
|
2988
|
-
{ 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." },
|
|
2989
3143
|
body
|
|
2990
3144
|
);
|
|
2991
3145
|
}
|
|
@@ -3072,44 +3226,40 @@ function renderCompetitorLandscape(report) {
|
|
|
3072
3226
|
`${charts}${table}`
|
|
3073
3227
|
);
|
|
3074
3228
|
}
|
|
3075
|
-
|
|
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) {
|
|
3076
3243
|
if (buckets.length === 0) return "";
|
|
3077
3244
|
const total = buckets.reduce((s, b) => s + b.count, 0);
|
|
3078
3245
|
if (total === 0) return "";
|
|
3079
|
-
const
|
|
3080
|
-
const
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
const y2 = cy + Math.sin(endAngle) * r;
|
|
3094
|
-
const ix1 = cx + Math.cos(endAngle) * innerR;
|
|
3095
|
-
const iy1 = cy + Math.sin(endAngle) * innerR;
|
|
3096
|
-
const ix2 = cx + Math.cos(startAngle) * innerR;
|
|
3097
|
-
const iy2 = cy + Math.sin(startAngle) * innerR;
|
|
3098
|
-
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
|
|
3099
|
-
const color = COLORS.series[i % COLORS.series.length];
|
|
3100
|
-
if (b.count > 0) {
|
|
3101
|
-
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}" />`);
|
|
3102
|
-
legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
|
|
3103
|
-
}
|
|
3104
|
-
});
|
|
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("");
|
|
3105
3260
|
return `<div class="chart-card">
|
|
3106
|
-
<h3>
|
|
3107
|
-
<div
|
|
3108
|
-
<svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="AI source category donut chart">
|
|
3109
|
-
${slices.join("")}
|
|
3110
|
-
</svg>
|
|
3111
|
-
<div class="legend" style="flex-direction:column;align-items:flex-start;gap:6px;">${legend.join("")}</div>
|
|
3112
|
-
</div>
|
|
3261
|
+
<h3>By source type</h3>
|
|
3262
|
+
<div class="source-bars">${rows}</div>
|
|
3113
3263
|
</div>`;
|
|
3114
3264
|
}
|
|
3115
3265
|
function renderAiSourceOrigin(report) {
|
|
@@ -3120,24 +3270,28 @@ function renderAiSourceOrigin(report) {
|
|
|
3120
3270
|
renderEmpty("No source data yet. Run a visibility sweep first.")
|
|
3121
3271
|
);
|
|
3122
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>` : "";
|
|
3123
3275
|
const rows = origin.topDomains.map((d) => `
|
|
3124
3276
|
<tr>
|
|
3125
3277
|
<td>${escapeHtml(d.domain)}</td>
|
|
3126
3278
|
<td class="numeric">${d.count}</td>
|
|
3127
|
-
<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>
|
|
3128
3280
|
</tr>`).join("");
|
|
3129
|
-
const table = origin.topDomains.length > 0 ? `<
|
|
3130
|
-
<
|
|
3131
|
-
|
|
3132
|
-
|
|
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>` : "";
|
|
3133
3287
|
return section(
|
|
3134
3288
|
{
|
|
3135
3289
|
id: "ai-source-origin",
|
|
3136
3290
|
eyebrow: "Section 4",
|
|
3137
3291
|
title: "AI Citation Sources",
|
|
3138
|
-
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."
|
|
3139
3293
|
},
|
|
3140
|
-
`${
|
|
3294
|
+
`${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
|
|
3141
3295
|
);
|
|
3142
3296
|
}
|
|
3143
3297
|
function renderLineChart(points, color, title, height = 200) {
|
|
@@ -3430,15 +3584,15 @@ function renderCitationsTrend(report) {
|
|
|
3430
3584
|
const rows = trend.map((t) => `
|
|
3431
3585
|
<tr>
|
|
3432
3586
|
<td>${formatDate(t.date)}</td>
|
|
3433
|
-
<td class="numeric">${t.citationRate}
|
|
3587
|
+
<td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
|
|
3434
3588
|
<td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
|
|
3435
3589
|
</tr>`).join("");
|
|
3436
3590
|
return section(
|
|
3437
|
-
{ 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." },
|
|
3438
3592
|
`${chart}
|
|
3439
3593
|
<div class="chart-card"><h3>Run-by-run breakdown</h3>
|
|
3440
3594
|
<table class="report-table">
|
|
3441
|
-
<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>
|
|
3442
3596
|
<tbody>${rows}</tbody>
|
|
3443
3597
|
</table>
|
|
3444
3598
|
</div>`
|
|
@@ -3477,15 +3631,16 @@ function renderOpportunities(report) {
|
|
|
3477
3631
|
if (opps.length === 0) return "";
|
|
3478
3632
|
const canonical = report.meta.project.canonicalDomain;
|
|
3479
3633
|
const rows = opps.slice(0, 10).map((o) => {
|
|
3480
|
-
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>';
|
|
3481
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>';
|
|
3482
3637
|
return `<tr>
|
|
3483
3638
|
<td>${escapeHtml(o.query)}</td>
|
|
3484
3639
|
<td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
|
|
3485
3640
|
<td class="numeric">${Math.round(o.score)}</td>
|
|
3641
|
+
<td>${drivers}</td>
|
|
3486
3642
|
<td>${ourPage}</td>
|
|
3487
3643
|
<td>${winning}</td>
|
|
3488
|
-
<td><span class="badge tone-neutral">${escapeHtml(o.demandSource)}</span></td>
|
|
3489
3644
|
<td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
|
|
3490
3645
|
</tr>`;
|
|
3491
3646
|
}).join("");
|
|
@@ -3494,10 +3649,36 @@ function renderOpportunities(report) {
|
|
|
3494
3649
|
id: "content-opportunities",
|
|
3495
3650
|
eyebrow: "Section 12",
|
|
3496
3651
|
title: "Content Opportunities",
|
|
3497
|
-
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."
|
|
3498
3653
|
},
|
|
3499
3654
|
`<table class="report-table">
|
|
3500
|
-
<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>
|
|
3501
3682
|
<tbody>${rows}</tbody>
|
|
3502
3683
|
</table>`
|
|
3503
3684
|
);
|
|
@@ -3506,7 +3687,7 @@ function renderRecommendedNextSteps(report) {
|
|
|
3506
3687
|
const steps = report.recommendedNextSteps;
|
|
3507
3688
|
if (steps.length === 0) {
|
|
3508
3689
|
return section(
|
|
3509
|
-
{ 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." },
|
|
3510
3691
|
renderEmpty("No outstanding actions.")
|
|
3511
3692
|
);
|
|
3512
3693
|
}
|
|
@@ -3517,17 +3698,144 @@ function renderRecommendedNextSteps(report) {
|
|
|
3517
3698
|
<span class="rationale">${escapeHtml(s.rationale)}</span>
|
|
3518
3699
|
</div>`).join("");
|
|
3519
3700
|
return section(
|
|
3520
|
-
{ 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." },
|
|
3521
3702
|
`<div class="steps">${items}</div>`
|
|
3522
3703
|
);
|
|
3523
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
|
+
}
|
|
3524
3825
|
function escapeJsonForScript(json) {
|
|
3525
3826
|
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
3526
3827
|
}
|
|
3527
3828
|
function renderReportHtml(report, opts = {}) {
|
|
3528
|
-
const
|
|
3529
|
-
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") : [
|
|
3530
3836
|
renderExecutiveSummary(report),
|
|
3837
|
+
renderAudienceActionPlan(report, "agency"),
|
|
3838
|
+
renderAgencyDiagnostics(report),
|
|
3531
3839
|
renderCitationScorecard(report),
|
|
3532
3840
|
renderCompetitorLandscape(report),
|
|
3533
3841
|
renderAiSourceOrigin(report),
|
|
@@ -3539,6 +3847,7 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3539
3847
|
renderCitationsTrend(report),
|
|
3540
3848
|
renderInsights(report),
|
|
3541
3849
|
renderOpportunities(report),
|
|
3850
|
+
renderContentGaps(report),
|
|
3542
3851
|
renderRecommendedNextSteps(report)
|
|
3543
3852
|
].join("\n");
|
|
3544
3853
|
const json = escapeJsonForScript(JSON.stringify(report));
|
|
@@ -3553,9 +3862,9 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3553
3862
|
<body>
|
|
3554
3863
|
<div class="container">
|
|
3555
3864
|
<header class="header">
|
|
3556
|
-
<div class="eyebrow"
|
|
3865
|
+
<div class="eyebrow">${audience === "client" ? "AEO Client Summary" : "AEO Agency Report"}</div>
|
|
3557
3866
|
<h1>${escapeHtml(report.meta.project.displayName)}</h1>
|
|
3558
|
-
<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>
|
|
3559
3868
|
</header>
|
|
3560
3869
|
${sections}
|
|
3561
3870
|
<footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
|
|
@@ -3568,7 +3877,7 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3568
3877
|
// ../api-routes/src/content-data.ts
|
|
3569
3878
|
import { and as and3, eq as eq12, desc as desc5, inArray as inArray3 } from "drizzle-orm";
|
|
3570
3879
|
var RECENT_RUNS_WINDOW = 5;
|
|
3571
|
-
function loadOrchestratorInput(db, project) {
|
|
3880
|
+
function loadOrchestratorInput(db, project, locationFilter = void 0) {
|
|
3572
3881
|
const projectId = project.id;
|
|
3573
3882
|
const ownDomain = normalizeDomain(project.canonicalDomain);
|
|
3574
3883
|
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
@@ -3577,7 +3886,7 @@ function loadOrchestratorInput(db, project) {
|
|
|
3577
3886
|
const candidateQueryStrings = trackedQueries.filter(isBlogShapedQuery);
|
|
3578
3887
|
const trackedCompetitors = listCompetitorDomains(db, projectId).map(normalizeDomain);
|
|
3579
3888
|
const competitorSet = new Set(trackedCompetitors);
|
|
3580
|
-
const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW);
|
|
3889
|
+
const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW, locationFilter);
|
|
3581
3890
|
const latestRunId = recentRunIds[0] ?? "";
|
|
3582
3891
|
const latestRunTimestamp = latestRunId ? lookupRunTimestamp(db, latestRunId) : "";
|
|
3583
3892
|
const candidateQueries = buildCandidateQueries({
|
|
@@ -3619,8 +3928,8 @@ function listCompetitorDomains(db, projectId) {
|
|
|
3619
3928
|
const rows = db.select({ domain: competitors.domain }).from(competitors).where(eq12(competitors.projectId, projectId)).all();
|
|
3620
3929
|
return rows.map((r) => r.domain);
|
|
3621
3930
|
}
|
|
3622
|
-
function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
|
|
3623
|
-
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(
|
|
3624
3933
|
and3(
|
|
3625
3934
|
eq12(runs.projectId, projectId),
|
|
3626
3935
|
eq12(runs.kind, RunKinds["answer-visibility"]),
|
|
@@ -3629,8 +3938,9 @@ function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
|
|
|
3629
3938
|
// no usable evidence.
|
|
3630
3939
|
inArray3(runs.status, [RunStatuses.completed, RunStatuses.partial])
|
|
3631
3940
|
)
|
|
3632
|
-
).orderBy(desc5(runs.createdAt)).
|
|
3633
|
-
|
|
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);
|
|
3634
3944
|
}
|
|
3635
3945
|
function lookupRunTimestamp(db, runId) {
|
|
3636
3946
|
const row = db.select({ createdAt: runs.createdAt }).from(runs).where(eq12(runs.id, runId)).get();
|
|
@@ -3846,7 +4156,6 @@ function extractPath(url) {
|
|
|
3846
4156
|
var TOP_QUERIES_LIMIT = 20;
|
|
3847
4157
|
var TOP_LANDING_PAGES_LIMIT = 20;
|
|
3848
4158
|
var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
|
|
3849
|
-
var TOP_SOURCE_DOMAINS_LIMIT = 20;
|
|
3850
4159
|
var TOP_CAMPAIGN_LIMIT = 10;
|
|
3851
4160
|
var INSIGHT_LOOKBACK_RUNS = 5;
|
|
3852
4161
|
function safeNum(value) {
|
|
@@ -3857,14 +4166,6 @@ function safeNum(value) {
|
|
|
3857
4166
|
}
|
|
3858
4167
|
return 0;
|
|
3859
4168
|
}
|
|
3860
|
-
function citedDomainBelongsToProject(citedDomain, projectDomains) {
|
|
3861
|
-
const candidate = normalizeProjectDomain(citedDomain);
|
|
3862
|
-
for (const domain of projectDomains) {
|
|
3863
|
-
const normalized = normalizeProjectDomain(domain);
|
|
3864
|
-
if (candidate === normalized || candidate.endsWith(`.${normalized}`)) return true;
|
|
3865
|
-
}
|
|
3866
|
-
return false;
|
|
3867
|
-
}
|
|
3868
4169
|
function categorizeQuery(query, projectDisplayName, canonicalDomain) {
|
|
3869
4170
|
return categorizeQueryByIntent(query, buildBrandTokens(canonicalDomain, projectDisplayName));
|
|
3870
4171
|
}
|
|
@@ -3891,186 +4192,6 @@ function loadQueryLookup(db, projectId) {
|
|
|
3891
4192
|
for (const row of rows) byId.set(row.id, row.query);
|
|
3892
4193
|
return { byId };
|
|
3893
4194
|
}
|
|
3894
|
-
function buildCitationScorecard(snapshots, queryLookup) {
|
|
3895
|
-
if (snapshots.length === 0) {
|
|
3896
|
-
return { queries: [], providers: [], matrix: [], providerRates: [] };
|
|
3897
|
-
}
|
|
3898
|
-
const querySet = /* @__PURE__ */ new Set();
|
|
3899
|
-
const providerSet = /* @__PURE__ */ new Set();
|
|
3900
|
-
for (const snap of snapshots) {
|
|
3901
|
-
const q = queryLookup.byId.get(snap.queryId);
|
|
3902
|
-
if (!q) continue;
|
|
3903
|
-
querySet.add(q);
|
|
3904
|
-
providerSet.add(snap.provider);
|
|
3905
|
-
}
|
|
3906
|
-
const queryList = [...querySet].sort();
|
|
3907
|
-
const providerList = [...providerSet].sort();
|
|
3908
|
-
const matrix = queryList.map(
|
|
3909
|
-
() => providerList.map(() => null)
|
|
3910
|
-
);
|
|
3911
|
-
const providerCounts = /* @__PURE__ */ new Map();
|
|
3912
|
-
for (const snap of snapshots) {
|
|
3913
|
-
const q = queryLookup.byId.get(snap.queryId);
|
|
3914
|
-
if (!q) continue;
|
|
3915
|
-
const qi = queryList.indexOf(q);
|
|
3916
|
-
const pi = providerList.indexOf(snap.provider);
|
|
3917
|
-
if (qi < 0 || pi < 0) continue;
|
|
3918
|
-
matrix[qi][pi] = {
|
|
3919
|
-
citationState: snap.citationState === CitationStates.cited ? CitationStates.cited : CitationStates["not-cited"],
|
|
3920
|
-
answerMentioned: snap.answerMentioned ?? null,
|
|
3921
|
-
model: snap.model
|
|
3922
|
-
};
|
|
3923
|
-
const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
|
|
3924
|
-
counts.total++;
|
|
3925
|
-
if (snap.citationState === CitationStates.cited) counts.cited++;
|
|
3926
|
-
providerCounts.set(snap.provider, counts);
|
|
3927
|
-
}
|
|
3928
|
-
const providerRates = providerList.map((provider) => {
|
|
3929
|
-
const counts = providerCounts.get(provider) ?? { cited: 0, total: 0 };
|
|
3930
|
-
const citationRate = counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0;
|
|
3931
|
-
return {
|
|
3932
|
-
provider,
|
|
3933
|
-
citedCount: counts.cited,
|
|
3934
|
-
totalCount: counts.total,
|
|
3935
|
-
citationRate
|
|
3936
|
-
};
|
|
3937
|
-
});
|
|
3938
|
-
return { queries: queryList, providers: providerList, matrix, providerRates };
|
|
3939
|
-
}
|
|
3940
|
-
function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, queryLookup) {
|
|
3941
|
-
let projectCitationCount = 0;
|
|
3942
|
-
const competitorMap = /* @__PURE__ */ new Map();
|
|
3943
|
-
for (const c of competitorDomains) {
|
|
3944
|
-
competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
|
|
3945
|
-
}
|
|
3946
|
-
for (const snap of snapshots) {
|
|
3947
|
-
const q = queryLookup.byId.get(snap.queryId);
|
|
3948
|
-
const allDomains = [...snap.citedDomains, ...snap.competitorOverlap];
|
|
3949
|
-
if (allDomains.some((d) => citedDomainBelongsToProject(d, projectDomains))) {
|
|
3950
|
-
projectCitationCount++;
|
|
3951
|
-
}
|
|
3952
|
-
for (const competitor of competitorDomains) {
|
|
3953
|
-
if (allDomains.some((d) => citedDomainBelongsToProject(d, [competitor]))) {
|
|
3954
|
-
const entry = competitorMap.get(competitor);
|
|
3955
|
-
entry.count++;
|
|
3956
|
-
if (q) entry.queries.add(q);
|
|
3957
|
-
}
|
|
3958
|
-
const competitorNorm = normalizeDomain(competitor);
|
|
3959
|
-
for (const gs of snap.groundingSources) {
|
|
3960
|
-
const host = normalizeDomain(extractHostFromUri(gs.uri));
|
|
3961
|
-
if (!host) continue;
|
|
3962
|
-
if (host === competitorNorm || host.endsWith(`.${competitorNorm}`)) {
|
|
3963
|
-
const entry = competitorMap.get(competitor);
|
|
3964
|
-
const pageQueries = entry.pages.get(gs.uri) ?? /* @__PURE__ */ new Set();
|
|
3965
|
-
if (q) pageQueries.add(q);
|
|
3966
|
-
entry.pages.set(gs.uri, pageQueries);
|
|
3967
|
-
}
|
|
3968
|
-
}
|
|
3969
|
-
}
|
|
3970
|
-
}
|
|
3971
|
-
const totalCitedSlots = projectCitationCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
|
|
3972
|
-
const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
|
|
3973
|
-
const total = snapshots.length;
|
|
3974
|
-
const ratio = total > 0 ? data.count / total : 0;
|
|
3975
|
-
let pressureLabel = "None";
|
|
3976
|
-
if (data.count > 0) {
|
|
3977
|
-
if (ratio >= 0.5) pressureLabel = "High";
|
|
3978
|
-
else if (ratio >= 0.2) pressureLabel = "Moderate";
|
|
3979
|
-
else pressureLabel = "Low";
|
|
3980
|
-
}
|
|
3981
|
-
const sharePct = totalCitedSlots > 0 ? Math.round(data.count / totalCitedSlots * 100) : 0;
|
|
3982
|
-
const theirCitedPages = [...data.pages.entries()].map(([url, qs]) => ({ url, citedFor: [...qs].sort() })).sort((a, b) => b.citedFor.length - a.citedFor.length);
|
|
3983
|
-
return {
|
|
3984
|
-
domain,
|
|
3985
|
-
citationCount: data.count,
|
|
3986
|
-
totalCount: total,
|
|
3987
|
-
pressureLabel,
|
|
3988
|
-
citedQueries: [...data.queries].sort(),
|
|
3989
|
-
sharePct,
|
|
3990
|
-
theirCitedPages
|
|
3991
|
-
};
|
|
3992
|
-
});
|
|
3993
|
-
competitorRows.sort((a, b) => b.citationCount - a.citationCount);
|
|
3994
|
-
return { projectCitationCount, competitors: competitorRows };
|
|
3995
|
-
}
|
|
3996
|
-
function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName, projectDomains, queryLookup) {
|
|
3997
|
-
let projectMentionCount = 0;
|
|
3998
|
-
let totalAnswerSnapshots = 0;
|
|
3999
|
-
const competitorMap = /* @__PURE__ */ new Map();
|
|
4000
|
-
for (const c of competitorDomains) {
|
|
4001
|
-
competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set() });
|
|
4002
|
-
}
|
|
4003
|
-
for (const snap of snapshots) {
|
|
4004
|
-
const text = snap.answerText;
|
|
4005
|
-
if (!text) continue;
|
|
4006
|
-
totalAnswerSnapshots++;
|
|
4007
|
-
const q = queryLookup.byId.get(snap.queryId);
|
|
4008
|
-
const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
|
|
4009
|
-
text,
|
|
4010
|
-
projectDisplayName,
|
|
4011
|
-
projectDomains
|
|
4012
|
-
);
|
|
4013
|
-
if (projectMentioned) projectMentionCount++;
|
|
4014
|
-
for (const competitor of competitorDomains) {
|
|
4015
|
-
const brand = brandLabelFromDomain(competitor);
|
|
4016
|
-
const mentioned = determineAnswerMentioned(text, brand, [competitor]);
|
|
4017
|
-
if (mentioned) {
|
|
4018
|
-
const entry = competitorMap.get(competitor);
|
|
4019
|
-
entry.count++;
|
|
4020
|
-
if (q) entry.queries.add(q);
|
|
4021
|
-
}
|
|
4022
|
-
}
|
|
4023
|
-
}
|
|
4024
|
-
const totalMentionedSlots = projectMentionCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
|
|
4025
|
-
const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
|
|
4026
|
-
const ratio = totalAnswerSnapshots > 0 ? data.count / totalAnswerSnapshots : 0;
|
|
4027
|
-
let pressureLabel = "None";
|
|
4028
|
-
if (data.count > 0) {
|
|
4029
|
-
if (ratio >= 0.5) pressureLabel = "High";
|
|
4030
|
-
else if (ratio >= 0.2) pressureLabel = "Moderate";
|
|
4031
|
-
else pressureLabel = "Low";
|
|
4032
|
-
}
|
|
4033
|
-
const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : 0;
|
|
4034
|
-
return {
|
|
4035
|
-
domain,
|
|
4036
|
-
mentionCount: data.count,
|
|
4037
|
-
totalCount: totalAnswerSnapshots,
|
|
4038
|
-
pressureLabel,
|
|
4039
|
-
mentionedQueries: [...data.queries].sort(),
|
|
4040
|
-
sharePct
|
|
4041
|
-
};
|
|
4042
|
-
});
|
|
4043
|
-
competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
|
|
4044
|
-
return { projectMentionCount, totalAnswerSnapshots, competitors: competitorRows };
|
|
4045
|
-
}
|
|
4046
|
-
function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains) {
|
|
4047
|
-
const categoryCounts = /* @__PURE__ */ new Map();
|
|
4048
|
-
const domainCounts = /* @__PURE__ */ new Map();
|
|
4049
|
-
let totalCitations = 0;
|
|
4050
|
-
for (const snap of snapshots) {
|
|
4051
|
-
for (const raw of snap.citedDomains) {
|
|
4052
|
-
if (citedDomainBelongsToProject(raw, projectDomains)) continue;
|
|
4053
|
-
const { category, label, domain } = categorizeSource(raw);
|
|
4054
|
-
const cat = categoryCounts.get(category) ?? { label, count: 0 };
|
|
4055
|
-
cat.count++;
|
|
4056
|
-
categoryCounts.set(category, cat);
|
|
4057
|
-
domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
|
|
4058
|
-
totalCitations++;
|
|
4059
|
-
}
|
|
4060
|
-
}
|
|
4061
|
-
const categories = [...categoryCounts.entries()].map(([category, { label, count }]) => ({
|
|
4062
|
-
category,
|
|
4063
|
-
label,
|
|
4064
|
-
count,
|
|
4065
|
-
sharePct: totalCitations > 0 ? Math.round(count / totalCitations * 100) : 0
|
|
4066
|
-
})).sort((a, b) => b.count - a.count);
|
|
4067
|
-
const topDomains = [...domainCounts.entries()].map(([domain, count]) => ({
|
|
4068
|
-
domain,
|
|
4069
|
-
count,
|
|
4070
|
-
isCompetitor: citedDomainBelongsToProject(domain, competitorDomains)
|
|
4071
|
-
})).sort((a, b) => b.count - a.count).slice(0, TOP_SOURCE_DOMAINS_LIMIT);
|
|
4072
|
-
return { categories, topDomains };
|
|
4073
|
-
}
|
|
4074
4195
|
function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, trackedQueries) {
|
|
4075
4196
|
const rows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
|
|
4076
4197
|
if (rows.length === 0) return null;
|
|
@@ -4306,27 +4427,33 @@ function buildIndexingHealth(db, projectId) {
|
|
|
4306
4427
|
}
|
|
4307
4428
|
return null;
|
|
4308
4429
|
}
|
|
4309
|
-
function buildCitationsTrend(db, projectId, queryLookup) {
|
|
4310
|
-
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;
|
|
4311
4433
|
const points = [];
|
|
4312
4434
|
for (const run of visibilityRuns) {
|
|
4313
4435
|
if (run.status !== RunStatuses.completed) continue;
|
|
4314
4436
|
const snaps = loadSnapshotsForRun(db, run.id);
|
|
4315
4437
|
if (snaps.length === 0) continue;
|
|
4316
|
-
|
|
4438
|
+
const citedQueryIds = /* @__PURE__ */ new Set();
|
|
4439
|
+
const mentionedQueryIds = /* @__PURE__ */ new Set();
|
|
4317
4440
|
let considered = 0;
|
|
4318
4441
|
const providerCounts = /* @__PURE__ */ new Map();
|
|
4319
4442
|
for (const snap of snaps) {
|
|
4320
4443
|
if (!queryLookup.byId.has(snap.queryId)) continue;
|
|
4321
4444
|
considered++;
|
|
4322
|
-
if (snap.citationState === CitationStates.cited)
|
|
4445
|
+
if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
|
|
4446
|
+
if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
|
|
4323
4447
|
const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
|
|
4324
4448
|
counts.total++;
|
|
4325
4449
|
if (snap.citationState === CitationStates.cited) counts.cited++;
|
|
4326
4450
|
providerCounts.set(snap.provider, counts);
|
|
4327
4451
|
}
|
|
4328
4452
|
if (considered === 0) continue;
|
|
4329
|
-
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;
|
|
4330
4457
|
const providerRates = [...providerCounts.entries()].map(([provider, counts]) => ({
|
|
4331
4458
|
provider,
|
|
4332
4459
|
citationRate: counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0
|
|
@@ -4335,20 +4462,24 @@ function buildCitationsTrend(db, projectId, queryLookup) {
|
|
|
4335
4462
|
runId: run.id,
|
|
4336
4463
|
date: run.finishedAt ?? run.createdAt,
|
|
4337
4464
|
citationRate,
|
|
4465
|
+
citedQueryCount,
|
|
4466
|
+
totalQueryCount: totalQueries,
|
|
4467
|
+
mentionRate,
|
|
4468
|
+
mentionedQueryCount,
|
|
4338
4469
|
providerRates
|
|
4339
4470
|
});
|
|
4340
4471
|
}
|
|
4341
4472
|
points.sort((a, b) => a.date.localeCompare(b.date));
|
|
4342
4473
|
return points;
|
|
4343
4474
|
}
|
|
4344
|
-
function buildInsightList(db, projectId) {
|
|
4345
|
-
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(
|
|
4346
4477
|
and4(
|
|
4347
4478
|
eq13(runs.projectId, projectId),
|
|
4348
4479
|
eq13(runs.kind, RunKinds["answer-visibility"]),
|
|
4349
4480
|
or2(eq13(runs.status, RunStatuses.completed), eq13(runs.status, RunStatuses.partial))
|
|
4350
4481
|
)
|
|
4351
|
-
).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);
|
|
4352
4483
|
if (recentRunIds.length === 0) return [];
|
|
4353
4484
|
const rows = db.select().from(insights).where(and4(eq13(insights.projectId, projectId), inArray4(insights.runId, recentRunIds))).orderBy(desc6(insights.createdAt)).all();
|
|
4354
4485
|
const severityRank = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
@@ -4420,7 +4551,7 @@ function buildRecommendedNextSteps(insightList) {
|
|
|
4420
4551
|
}
|
|
4421
4552
|
return steps;
|
|
4422
4553
|
}
|
|
4423
|
-
function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
|
|
4554
|
+
function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
|
|
4424
4555
|
const findings = [];
|
|
4425
4556
|
if (trendsPoints.length > 0) {
|
|
4426
4557
|
const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
|
|
@@ -4443,8 +4574,10 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
|
|
|
4443
4574
|
break;
|
|
4444
4575
|
}
|
|
4445
4576
|
}
|
|
4577
|
+
const queryNoun = totalQueryCount === 1 ? "query" : "queries";
|
|
4578
|
+
const ratioFragment = totalQueryCount > 0 ? ` (${citedQueryCount} of ${totalQueryCount} ${queryNoun} cited)` : "";
|
|
4446
4579
|
findings.push({
|
|
4447
|
-
title: `Citation rate at ${citationRate}
|
|
4580
|
+
title: `Citation rate at ${citationRate}%${ratioFragment}`,
|
|
4448
4581
|
detail,
|
|
4449
4582
|
tone
|
|
4450
4583
|
});
|
|
@@ -4467,6 +4600,328 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
|
|
|
4467
4600
|
}
|
|
4468
4601
|
return findings.slice(0, 5);
|
|
4469
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
|
+
}
|
|
4470
4925
|
function buildProjectReport(db, projectName) {
|
|
4471
4926
|
const project = resolveProject(db, projectName);
|
|
4472
4927
|
const queryLookup = loadQueryLookup(db, project.id);
|
|
@@ -4476,6 +4931,7 @@ function buildProjectReport(db, projectName) {
|
|
|
4476
4931
|
(r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
|
|
4477
4932
|
) ?? visibilityRuns[0];
|
|
4478
4933
|
const latestSnapshots = latestRun ? loadSnapshotsForRun(db, latestRun.id) : [];
|
|
4934
|
+
const latestRunLocation = latestRun?.location ?? null;
|
|
4479
4935
|
const competitorRows = db.select().from(competitors).where(eq13(competitors.projectId, project.id)).all();
|
|
4480
4936
|
const competitorDomains = competitorRows.map((c) => c.domain);
|
|
4481
4937
|
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
@@ -4507,9 +4963,9 @@ function buildProjectReport(db, projectName) {
|
|
|
4507
4963
|
const socialSection = buildSocialReferrals(db, project.id);
|
|
4508
4964
|
const aiReferralsSection = buildAiReferrals(db, project.id);
|
|
4509
4965
|
const indexingHealthSection = buildIndexingHealth(db, project.id);
|
|
4510
|
-
const citationsTrend = buildCitationsTrend(db, project.id, queryLookup);
|
|
4511
|
-
const insightList = buildInsightList(db, project.id);
|
|
4512
|
-
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);
|
|
4513
4969
|
const contentOpportunities = buildContentTargetRows(orchestratorInput);
|
|
4514
4970
|
const contentGaps = buildContentGapRows(orchestratorInput);
|
|
4515
4971
|
const groundingSources = buildContentSourceRows(orchestratorInput);
|
|
@@ -4518,14 +4974,18 @@ function buildProjectReport(db, projectName) {
|
|
|
4518
4974
|
contentOpportunities,
|
|
4519
4975
|
insightDerivedSteps
|
|
4520
4976
|
);
|
|
4521
|
-
|
|
4522
|
-
|
|
4977
|
+
const totalQueryCount = queryLookup.byId.size;
|
|
4978
|
+
const citedQueryIds = /* @__PURE__ */ new Set();
|
|
4979
|
+
const mentionedQueryIds = /* @__PURE__ */ new Set();
|
|
4523
4980
|
for (const snap of latestSnapshots) {
|
|
4524
4981
|
if (!queryLookup.byId.has(snap.queryId)) continue;
|
|
4525
|
-
|
|
4526
|
-
if (snap.
|
|
4982
|
+
if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
|
|
4983
|
+
if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
|
|
4527
4984
|
}
|
|
4528
|
-
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;
|
|
4529
4989
|
const trendBaseline = isTrendBaseline(citationsTrend);
|
|
4530
4990
|
const latestPoint = citationsTrend.at(-1);
|
|
4531
4991
|
const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
|
|
@@ -4542,6 +5002,8 @@ function buildProjectReport(db, projectName) {
|
|
|
4542
5002
|
}
|
|
4543
5003
|
const findings = buildExecutiveFindings(
|
|
4544
5004
|
citationRate,
|
|
5005
|
+
citedQueryCount,
|
|
5006
|
+
totalQueryCount,
|
|
4545
5007
|
trend,
|
|
4546
5008
|
citationsTrend,
|
|
4547
5009
|
trendBaseline,
|
|
@@ -4550,6 +5012,66 @@ function buildProjectReport(db, projectName) {
|
|
|
4550
5012
|
);
|
|
4551
5013
|
const periodStart = citationsTrend[0]?.date ?? null;
|
|
4552
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
|
+
});
|
|
4553
5075
|
return {
|
|
4554
5076
|
meta: {
|
|
4555
5077
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -4561,29 +5083,12 @@ function buildProjectReport(db, projectName) {
|
|
|
4561
5083
|
country: project.country,
|
|
4562
5084
|
language: project.language
|
|
4563
5085
|
},
|
|
5086
|
+
location: reportLocation,
|
|
5087
|
+
providerLocationHandling,
|
|
4564
5088
|
periodStart,
|
|
4565
5089
|
periodEnd
|
|
4566
5090
|
},
|
|
4567
|
-
executiveSummary
|
|
4568
|
-
citationRate,
|
|
4569
|
-
trend,
|
|
4570
|
-
queryCount: queryLookup.byId.size,
|
|
4571
|
-
competitorCount: competitorDomains.length,
|
|
4572
|
-
providerCount: citationScorecard.providers.length,
|
|
4573
|
-
gsc: gscSection ? {
|
|
4574
|
-
clicks: gscSection.totalClicks,
|
|
4575
|
-
impressions: gscSection.totalImpressions,
|
|
4576
|
-
ctr: gscSection.ctr,
|
|
4577
|
-
avgPosition: gscSection.avgPosition
|
|
4578
|
-
} : null,
|
|
4579
|
-
ga: gaSection ? {
|
|
4580
|
-
sessions: gaSection.totalSessions,
|
|
4581
|
-
users: gaSection.totalUsers,
|
|
4582
|
-
periodStart: gaSection.periodStart,
|
|
4583
|
-
periodEnd: gaSection.periodEnd
|
|
4584
|
-
} : null,
|
|
4585
|
-
findings
|
|
4586
|
-
},
|
|
5091
|
+
executiveSummary,
|
|
4587
5092
|
citationScorecard,
|
|
4588
5093
|
competitorLandscape,
|
|
4589
5094
|
mentionLandscape,
|
|
@@ -4596,14 +5101,22 @@ function buildProjectReport(db, projectName) {
|
|
|
4596
5101
|
citationsTrend,
|
|
4597
5102
|
insights: insightList,
|
|
4598
5103
|
recommendedNextSteps,
|
|
5104
|
+
actionPlan,
|
|
5105
|
+
clientSummary,
|
|
5106
|
+
agencyDiagnostics,
|
|
4599
5107
|
contentOpportunities,
|
|
4600
5108
|
contentGaps,
|
|
4601
5109
|
groundingSources
|
|
4602
5110
|
};
|
|
4603
5111
|
}
|
|
4604
|
-
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) {
|
|
4605
5118
|
const date = generatedAt.slice(0, 10);
|
|
4606
|
-
return `canonry-report-${project.name}-${date}.html`;
|
|
5119
|
+
return `canonry-report-${project.name}-${audience}-${date}.html`;
|
|
4607
5120
|
}
|
|
4608
5121
|
async function reportRoutes(app) {
|
|
4609
5122
|
app.get("/projects/:name/report", async (request, reply) => {
|
|
@@ -4611,9 +5124,10 @@ async function reportRoutes(app) {
|
|
|
4611
5124
|
return reply.send(dto);
|
|
4612
5125
|
});
|
|
4613
5126
|
app.get("/projects/:name/report.html", async (request, reply) => {
|
|
5127
|
+
const audience = parseReportAudience(request.query.audience);
|
|
4614
5128
|
const dto = buildProjectReport(app.db, request.params.name);
|
|
4615
|
-
const html = renderReportHtml(dto);
|
|
4616
|
-
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);
|
|
4617
5131
|
reply.header("Content-Type", "text/html; charset=utf-8");
|
|
4618
5132
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
|
4619
5133
|
return reply.send(html);
|
|
@@ -4785,24 +5299,68 @@ function normalizeDomain2(domain) {
|
|
|
4785
5299
|
}
|
|
4786
5300
|
|
|
4787
5301
|
// ../api-routes/src/composites.ts
|
|
4788
|
-
import { eq as eq15, and as and5, desc as desc7, sql as sql3, like, or as or3 } from "drizzle-orm";
|
|
5302
|
+
import { eq as eq15, and as and5, desc as desc7, sql as sql3, like, or as or3, inArray as inArray6 } from "drizzle-orm";
|
|
4789
5303
|
var TOP_INSIGHT_LIMIT = 5;
|
|
4790
5304
|
var SEARCH_HIT_HARD_LIMIT = 50;
|
|
4791
5305
|
var SEARCH_SNIPPET_RADIUS = 80;
|
|
4792
5306
|
async function compositeRoutes(app) {
|
|
4793
5307
|
app.get("/projects/:name/overview", async (request, reply) => {
|
|
4794
5308
|
const project = resolveProject(app.db, request.params.name);
|
|
4795
|
-
const
|
|
4796
|
-
const
|
|
4797
|
-
const
|
|
4798
|
-
const
|
|
5309
|
+
const filterLocation = (request.query.location ?? "").trim() || null;
|
|
5310
|
+
const sinceIso = parseSinceFilter(request.query.since);
|
|
5311
|
+
const allRunsRaw = app.db.select().from(runs).where(eq15(runs.projectId, project.id)).orderBy(desc7(runs.createdAt)).all();
|
|
5312
|
+
const allRuns = allRunsRaw.filter((r) => runMatchesFilters(r, filterLocation, sinceIso));
|
|
5313
|
+
const totalRuns = allRuns.length;
|
|
5314
|
+
const visibilityRuns = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]);
|
|
5315
|
+
const completedVisRuns = visibilityRuns.filter(
|
|
5316
|
+
(r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
|
|
5317
|
+
);
|
|
5318
|
+
const latestVisibilityRun = completedVisRuns[0] ?? null;
|
|
5319
|
+
const previousVisibilityRun = completedVisRuns[1] ?? null;
|
|
5320
|
+
const latestRunRow = allRuns[0] ?? null;
|
|
4799
5321
|
const latestRun = latestRunRow ? { totalRuns, run: summarizeRun(latestRunRow) } : { totalRuns: 0, run: null };
|
|
4800
5322
|
const healthRow = app.db.select().from(healthSnapshots).where(eq15(healthSnapshots.projectId, project.id)).orderBy(desc7(healthSnapshots.createdAt)).limit(1).get();
|
|
4801
5323
|
const health = healthRow ? mapHealthRow2(healthRow) : null;
|
|
4802
5324
|
const insightRows = app.db.select().from(insights).where(eq15(insights.projectId, project.id)).orderBy(desc7(insights.createdAt)).all();
|
|
4803
5325
|
const topInsights = insightRows.filter((row) => !row.dismissed).slice(0, TOP_INSIGHT_LIMIT).map(mapInsightRow2);
|
|
4804
|
-
const
|
|
4805
|
-
const
|
|
5326
|
+
const sparklineRunIds = visibilityRuns.slice(0, DEFAULT_RUN_HISTORY_LIMIT).map((r) => r.id);
|
|
5327
|
+
const snapshotRunIds = new Set(sparklineRunIds);
|
|
5328
|
+
if (latestVisibilityRun) snapshotRunIds.add(latestVisibilityRun.id);
|
|
5329
|
+
if (previousVisibilityRun) snapshotRunIds.add(previousVisibilityRun.id);
|
|
5330
|
+
const snapshotsByRun = loadSnapshotsByRunIds(app, [...snapshotRunIds]);
|
|
5331
|
+
const latestSnapshots = latestVisibilityRun ? snapshotsByRun.get(latestVisibilityRun.id) ?? [] : [];
|
|
5332
|
+
const previousSnapshots = previousVisibilityRun ? snapshotsByRun.get(previousVisibilityRun.id) ?? [] : [];
|
|
5333
|
+
const { queryCounts, providers } = summarizeFromSnapshots(latestSnapshots);
|
|
5334
|
+
const transitions = summarizeTransitionsFromSnapshots(
|
|
5335
|
+
latestSnapshots,
|
|
5336
|
+
previousSnapshots,
|
|
5337
|
+
previousVisibilityRun?.createdAt ?? null
|
|
5338
|
+
);
|
|
5339
|
+
const competitorRows = app.db.select().from(competitors).where(eq15(competitors.projectId, project.id)).all();
|
|
5340
|
+
const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq15(queries.projectId, project.id)).all();
|
|
5341
|
+
const queryLookup = { byId: new Map(projectQueries.map((q) => [q.id, q.query])) };
|
|
5342
|
+
const configuredApiProviders = parseJsonColumn(project.providers, []).filter((p) => !p.startsWith("cdp:"));
|
|
5343
|
+
const scores = {
|
|
5344
|
+
visibility: buildVisibilityScore(latestSnapshots, { configuredApiProviders }),
|
|
5345
|
+
gapQueries: buildGapQueryScore(latestSnapshots),
|
|
5346
|
+
indexCoverage: buildIndexCoverageScore(app, project.id),
|
|
5347
|
+
competitorPressure: buildCompetitorPressureScore(
|
|
5348
|
+
latestSnapshots,
|
|
5349
|
+
competitorRows.map((c) => c.domain),
|
|
5350
|
+
competitorRows.length
|
|
5351
|
+
),
|
|
5352
|
+
runStatus: buildRunStatusScore(allRuns)
|
|
5353
|
+
};
|
|
5354
|
+
const movementSummary = buildMovementSummary(latestSnapshots, previousSnapshots);
|
|
5355
|
+
const providerScores = buildProviderScores(latestSnapshots);
|
|
5356
|
+
const overviewCompetitors = buildOverviewCompetitors(
|
|
5357
|
+
latestSnapshots,
|
|
5358
|
+
competitorRows.map((c) => ({ id: c.id, domain: c.domain })),
|
|
5359
|
+
queryLookup
|
|
5360
|
+
);
|
|
5361
|
+
const attentionItems = buildAttentionItems(insightRows, allRuns);
|
|
5362
|
+
const sparklineRuns = visibilityRuns.slice(0, DEFAULT_RUN_HISTORY_LIMIT).map((r) => ({ id: r.id, createdAt: r.createdAt, status: r.status }));
|
|
5363
|
+
const runHistory = buildRunHistory(sparklineRuns, snapshotsByRun);
|
|
4806
5364
|
const result = {
|
|
4807
5365
|
project: formatProject2(project),
|
|
4808
5366
|
latestRun,
|
|
@@ -4810,7 +5368,15 @@ async function compositeRoutes(app) {
|
|
|
4810
5368
|
topInsights,
|
|
4811
5369
|
queryCounts,
|
|
4812
5370
|
providers,
|
|
4813
|
-
transitions
|
|
5371
|
+
transitions,
|
|
5372
|
+
scores,
|
|
5373
|
+
movementSummary,
|
|
5374
|
+
competitors: overviewCompetitors,
|
|
5375
|
+
providerScores,
|
|
5376
|
+
attentionItems,
|
|
5377
|
+
runHistory,
|
|
5378
|
+
dateRangeLabel: "All time",
|
|
5379
|
+
contextLabel: `${project.country} / ${project.language.toUpperCase()}`
|
|
4814
5380
|
};
|
|
4815
5381
|
return reply.send(result);
|
|
4816
5382
|
});
|
|
@@ -4876,6 +5442,21 @@ async function compositeRoutes(app) {
|
|
|
4876
5442
|
return reply.send(response);
|
|
4877
5443
|
});
|
|
4878
5444
|
}
|
|
5445
|
+
function parseSinceFilter(raw) {
|
|
5446
|
+
if (!raw) return null;
|
|
5447
|
+
const trimmed = raw.trim();
|
|
5448
|
+
if (!trimmed) return null;
|
|
5449
|
+
const parsed = Date.parse(trimmed);
|
|
5450
|
+
if (Number.isNaN(parsed)) {
|
|
5451
|
+
throw validationError('"since" must be an ISO 8601 datetime');
|
|
5452
|
+
}
|
|
5453
|
+
return new Date(parsed).toISOString();
|
|
5454
|
+
}
|
|
5455
|
+
function runMatchesFilters(run, location, sinceIso) {
|
|
5456
|
+
if (location !== null && (run.location ?? "") !== location) return false;
|
|
5457
|
+
if (sinceIso !== null && run.createdAt < sinceIso) return false;
|
|
5458
|
+
return true;
|
|
5459
|
+
}
|
|
4879
5460
|
function clampSearchLimit(raw) {
|
|
4880
5461
|
if (!raw) return 25;
|
|
4881
5462
|
const parsed = Number.parseInt(raw, 10);
|
|
@@ -4901,29 +5482,49 @@ function summarizeRun(run) {
|
|
|
4901
5482
|
createdAt: run.createdAt
|
|
4902
5483
|
};
|
|
4903
5484
|
}
|
|
4904
|
-
function
|
|
5485
|
+
function loadSnapshotsByRunIds(app, runIds) {
|
|
5486
|
+
const result = /* @__PURE__ */ new Map();
|
|
5487
|
+
if (runIds.length === 0) return result;
|
|
5488
|
+
const rows = app.db.select({
|
|
5489
|
+
runId: querySnapshots.runId,
|
|
5490
|
+
queryId: querySnapshots.queryId,
|
|
5491
|
+
provider: querySnapshots.provider,
|
|
5492
|
+
model: querySnapshots.model,
|
|
5493
|
+
citationState: querySnapshots.citationState,
|
|
5494
|
+
competitorOverlap: querySnapshots.competitorOverlap,
|
|
5495
|
+
citedDomains: querySnapshots.citedDomains
|
|
5496
|
+
}).from(querySnapshots).where(inArray6(querySnapshots.runId, [...runIds])).all();
|
|
5497
|
+
for (const row of rows) {
|
|
5498
|
+
const list = result.get(row.runId) ?? [];
|
|
5499
|
+
list.push({
|
|
5500
|
+
queryId: row.queryId,
|
|
5501
|
+
provider: row.provider,
|
|
5502
|
+
model: row.model,
|
|
5503
|
+
citationState: row.citationState,
|
|
5504
|
+
competitorOverlap: parseJsonColumn(row.competitorOverlap, []),
|
|
5505
|
+
citedDomains: parseJsonColumn(row.citedDomains, [])
|
|
5506
|
+
});
|
|
5507
|
+
result.set(row.runId, list);
|
|
5508
|
+
}
|
|
5509
|
+
return result;
|
|
5510
|
+
}
|
|
5511
|
+
function summarizeFromSnapshots(snapshots) {
|
|
4905
5512
|
const empty = {
|
|
4906
5513
|
queryCounts: { totalQueries: 0, citedQueries: 0, notCitedQueries: 0, citedRate: 0 },
|
|
4907
5514
|
providers: []
|
|
4908
5515
|
};
|
|
4909
|
-
if (
|
|
4910
|
-
const rows = app.db.select({
|
|
4911
|
-
queryId: querySnapshots.queryId,
|
|
4912
|
-
provider: querySnapshots.provider,
|
|
4913
|
-
citationState: querySnapshots.citationState
|
|
4914
|
-
}).from(querySnapshots).where(eq15(querySnapshots.runId, run.id)).all();
|
|
4915
|
-
if (rows.length === 0) return empty;
|
|
5516
|
+
if (snapshots.length === 0) return empty;
|
|
4916
5517
|
const perQuery = /* @__PURE__ */ new Map();
|
|
4917
5518
|
const perProvider = /* @__PURE__ */ new Map();
|
|
4918
|
-
for (const
|
|
4919
|
-
const cited =
|
|
4920
|
-
if (!perQuery.has(
|
|
4921
|
-
perQuery.set(
|
|
5519
|
+
for (const snap of snapshots) {
|
|
5520
|
+
const cited = snap.citationState === CitationStates.cited;
|
|
5521
|
+
if (!perQuery.has(snap.queryId) || cited) {
|
|
5522
|
+
perQuery.set(snap.queryId, cited);
|
|
4922
5523
|
}
|
|
4923
|
-
const bucket = perProvider.get(
|
|
5524
|
+
const bucket = perProvider.get(snap.provider) ?? { cited: 0, total: 0 };
|
|
4924
5525
|
bucket.total += 1;
|
|
4925
5526
|
if (cited) bucket.cited += 1;
|
|
4926
|
-
perProvider.set(
|
|
5527
|
+
perProvider.set(snap.provider, bucket);
|
|
4927
5528
|
}
|
|
4928
5529
|
const totalQueries = perQuery.size;
|
|
4929
5530
|
let citedQueries = 0;
|
|
@@ -4943,23 +5544,20 @@ function summarizeLatestRun(app, run) {
|
|
|
4943
5544
|
providers
|
|
4944
5545
|
};
|
|
4945
5546
|
}
|
|
4946
|
-
function
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
for (const row of rows) {
|
|
4956
|
-
const cited = row.citationState === CitationStates.cited;
|
|
4957
|
-
if (!map.has(row.queryId) || cited) map.set(row.queryId, cited);
|
|
5547
|
+
function summarizeTransitionsFromSnapshots(latest, previous, since) {
|
|
5548
|
+
if (!since || previous.length === 0) {
|
|
5549
|
+
return { since: null, gained: 0, lost: 0, emerging: 0 };
|
|
5550
|
+
}
|
|
5551
|
+
const buildMap = (snaps) => {
|
|
5552
|
+
const m = /* @__PURE__ */ new Map();
|
|
5553
|
+
for (const s of snaps) {
|
|
5554
|
+
const cited = s.citationState === CitationStates.cited;
|
|
5555
|
+
if (!m.has(s.queryId) || cited) m.set(s.queryId, cited);
|
|
4958
5556
|
}
|
|
4959
|
-
return
|
|
5557
|
+
return m;
|
|
4960
5558
|
};
|
|
4961
|
-
const latestMap =
|
|
4962
|
-
const previousMap =
|
|
5559
|
+
const latestMap = buildMap(latest);
|
|
5560
|
+
const previousMap = buildMap(previous);
|
|
4963
5561
|
let gained = 0;
|
|
4964
5562
|
let lost = 0;
|
|
4965
5563
|
let emerging = 0;
|
|
@@ -4972,7 +5570,142 @@ function summarizeTransitions(app, latest, previous) {
|
|
|
4972
5570
|
if (latestCited && !previousCited) gained += 1;
|
|
4973
5571
|
else if (!latestCited && previousCited) lost += 1;
|
|
4974
5572
|
}
|
|
4975
|
-
return { since
|
|
5573
|
+
return { since, gained, lost, emerging };
|
|
5574
|
+
}
|
|
5575
|
+
function buildIndexCoverageScore(app, projectId) {
|
|
5576
|
+
const tooltip = "Percentage of inspected URLs currently indexed. Google Search Console is preferred when available, otherwise Bing Webmaster Tools is used.";
|
|
5577
|
+
const empty = {
|
|
5578
|
+
label: "Index Coverage",
|
|
5579
|
+
value: "No data",
|
|
5580
|
+
delta: "Connect GSC or Bing",
|
|
5581
|
+
tone: "neutral",
|
|
5582
|
+
description: "Connect Google Search Console or Bing Webmaster Tools and inspect your sitemap to populate coverage.",
|
|
5583
|
+
tooltip,
|
|
5584
|
+
trend: []
|
|
5585
|
+
};
|
|
5586
|
+
const gscRow = app.db.select().from(gscCoverageSnapshots).where(eq15(gscCoverageSnapshots.projectId, projectId)).orderBy(desc7(gscCoverageSnapshots.date)).limit(1).get();
|
|
5587
|
+
const bingRow = app.db.select().from(bingCoverageSnapshots).where(eq15(bingCoverageSnapshots.projectId, projectId)).orderBy(desc7(bingCoverageSnapshots.date)).limit(1).get();
|
|
5588
|
+
const chosen = pickIndexCoverageRow(gscRow, bingRow);
|
|
5589
|
+
if (!chosen) return empty;
|
|
5590
|
+
const total = chosen.indexed + chosen.notIndexed;
|
|
5591
|
+
if (total === 0) return empty;
|
|
5592
|
+
const deindexed = chosen.provider === "Google" ? countGoogleDeindexedUrls(app, projectId) : 0;
|
|
5593
|
+
const percentage = chosen.indexed / total * 100;
|
|
5594
|
+
const tone = deindexed > 0 ? "negative" : percentage >= 90 ? "positive" : percentage >= 70 ? "caution" : "negative";
|
|
5595
|
+
const notIndexedLabel = chosen.notIndexed === 1 ? "URL is" : "URLs are";
|
|
5596
|
+
const deindexedLabel = deindexed === 1 ? "URL" : "URLs";
|
|
5597
|
+
return {
|
|
5598
|
+
label: "Index Coverage",
|
|
5599
|
+
value: `${Math.round(percentage)}`,
|
|
5600
|
+
delta: `${chosen.provider} \xB7 ${chosen.indexed} of ${total} indexed`,
|
|
5601
|
+
tone,
|
|
5602
|
+
description: deindexed > 0 ? `${deindexed} deindexed ${deindexedLabel} detected in the latest Google Search Console inspection.` : `${chosen.notIndexed} ${notIndexedLabel} not indexed in ${chosen.provider === "Google" ? "Google Search Console" : "Bing Webmaster Tools"}.`,
|
|
5603
|
+
tooltip,
|
|
5604
|
+
trend: [],
|
|
5605
|
+
progress: Math.round(percentage)
|
|
5606
|
+
};
|
|
5607
|
+
}
|
|
5608
|
+
function countGoogleDeindexedUrls(app, projectId) {
|
|
5609
|
+
const rows = app.db.select({
|
|
5610
|
+
url: gscUrlInspections.url,
|
|
5611
|
+
indexingState: gscUrlInspections.indexingState,
|
|
5612
|
+
inspectedAt: gscUrlInspections.inspectedAt
|
|
5613
|
+
}).from(gscUrlInspections).where(eq15(gscUrlInspections.projectId, projectId)).orderBy(desc7(gscUrlInspections.inspectedAt)).all();
|
|
5614
|
+
if (rows.length === 0) return 0;
|
|
5615
|
+
const canonicalUrl = (url) => url.replace(/^http:\/\//, "https://");
|
|
5616
|
+
const historyByUrl = /* @__PURE__ */ new Map();
|
|
5617
|
+
for (const row of rows) {
|
|
5618
|
+
const key = canonicalUrl(row.url);
|
|
5619
|
+
const list = historyByUrl.get(key);
|
|
5620
|
+
if (list) list.push(row);
|
|
5621
|
+
else historyByUrl.set(key, [row]);
|
|
5622
|
+
}
|
|
5623
|
+
let deindexed = 0;
|
|
5624
|
+
for (const history of historyByUrl.values()) {
|
|
5625
|
+
if (history.length < 2) continue;
|
|
5626
|
+
const latest = history[0];
|
|
5627
|
+
const previous = history[1];
|
|
5628
|
+
if (previous.indexingState === "INDEXING_ALLOWED" && latest.indexingState !== "INDEXING_ALLOWED") {
|
|
5629
|
+
deindexed++;
|
|
5630
|
+
}
|
|
5631
|
+
}
|
|
5632
|
+
return deindexed;
|
|
5633
|
+
}
|
|
5634
|
+
function pickIndexCoverageRow(gsc, bing) {
|
|
5635
|
+
if (gsc && gsc.indexed + gsc.notIndexed > 0) {
|
|
5636
|
+
return { provider: "Google", indexed: gsc.indexed, notIndexed: gsc.notIndexed };
|
|
5637
|
+
}
|
|
5638
|
+
if (bing && bing.indexed + bing.notIndexed > 0) {
|
|
5639
|
+
return { provider: "Bing", indexed: bing.indexed, notIndexed: bing.notIndexed };
|
|
5640
|
+
}
|
|
5641
|
+
if (gsc) return { provider: "Google", indexed: gsc.indexed, notIndexed: gsc.notIndexed };
|
|
5642
|
+
if (bing) return { provider: "Bing", indexed: bing.indexed, notIndexed: bing.notIndexed };
|
|
5643
|
+
return null;
|
|
5644
|
+
}
|
|
5645
|
+
function buildRunStatusScore(allRuns) {
|
|
5646
|
+
const tooltip = "Current execution state of visibility sweeps. Shows the status of the most recent run and total run count.";
|
|
5647
|
+
if (allRuns.length === 0) {
|
|
5648
|
+
return {
|
|
5649
|
+
label: "Run Status",
|
|
5650
|
+
value: "None",
|
|
5651
|
+
delta: "No runs yet",
|
|
5652
|
+
tone: "neutral",
|
|
5653
|
+
description: "Trigger a visibility sweep to start tracking.",
|
|
5654
|
+
tooltip,
|
|
5655
|
+
trend: []
|
|
5656
|
+
};
|
|
5657
|
+
}
|
|
5658
|
+
const latestVisibility = allRuns.find((r) => r.kind === RunKinds["answer-visibility"]);
|
|
5659
|
+
const latest = latestVisibility ?? allRuns[0];
|
|
5660
|
+
const value = latest.status === RunStatuses.completed ? "Healthy" : latest.status === RunStatuses.running ? "Running" : latest.status === RunStatuses.queued ? "Queued" : latest.status === RunStatuses.partial ? "Partial" : "Failed";
|
|
5661
|
+
const tone = latest.status === RunStatuses.completed ? "positive" : latest.status === RunStatuses.failed ? "negative" : latest.status === RunStatuses.partial ? "caution" : "neutral";
|
|
5662
|
+
const visibilityRunCount = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]).length;
|
|
5663
|
+
const syncRunCount = allRuns.length - visibilityRunCount;
|
|
5664
|
+
const delta = syncRunCount > 0 ? `${visibilityRunCount} visibility \xB7 ${syncRunCount} sync` : `${visibilityRunCount} visibility run${visibilityRunCount === 1 ? "" : "s"}`;
|
|
5665
|
+
return {
|
|
5666
|
+
label: "Run Status",
|
|
5667
|
+
value,
|
|
5668
|
+
delta,
|
|
5669
|
+
tone,
|
|
5670
|
+
description: `Latest run ${value.toLowerCase()}. ${allRuns.length} total run${allRuns.length === 1 ? "" : "s"}.`,
|
|
5671
|
+
tooltip,
|
|
5672
|
+
trend: []
|
|
5673
|
+
};
|
|
5674
|
+
}
|
|
5675
|
+
var ATTENTION_INSIGHT_LIMIT = 5;
|
|
5676
|
+
function buildAttentionItems(insightRows, allRuns) {
|
|
5677
|
+
const items = [];
|
|
5678
|
+
for (const row of insightRows) {
|
|
5679
|
+
if (row.dismissed) continue;
|
|
5680
|
+
if (row.severity !== "critical" && row.severity !== "high") continue;
|
|
5681
|
+
if (items.length >= ATTENTION_INSIGHT_LIMIT) break;
|
|
5682
|
+
items.push({
|
|
5683
|
+
id: `insight_${row.id}`,
|
|
5684
|
+
tone: row.severity === "critical" ? "negative" : "caution",
|
|
5685
|
+
title: row.title,
|
|
5686
|
+
detail: row.query ? `On query: ${row.query}` : "",
|
|
5687
|
+
actionLabel: row.severity === "critical" ? "Critical" : "High",
|
|
5688
|
+
href: `#insight-${row.id}`
|
|
5689
|
+
});
|
|
5690
|
+
}
|
|
5691
|
+
const sortedRuns = [...allRuns].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
5692
|
+
const latestVisRun = sortedRuns.find((r) => r.kind === RunKinds["answer-visibility"]);
|
|
5693
|
+
const latestSyncRun = sortedRuns.find((r) => r.kind !== RunKinds["answer-visibility"]);
|
|
5694
|
+
if (latestVisRun && latestSyncRun) {
|
|
5695
|
+
const visibilityAge = new Date(latestSyncRun.createdAt).getTime() - new Date(latestVisRun.createdAt).getTime();
|
|
5696
|
+
const ONE_DAY = 24 * 60 * 60 * 1e3;
|
|
5697
|
+
if (visibilityAge > ONE_DAY) {
|
|
5698
|
+
items.push({
|
|
5699
|
+
id: "stale_visibility",
|
|
5700
|
+
tone: "caution",
|
|
5701
|
+
title: "Stale visibility data",
|
|
5702
|
+
detail: `Last visibility sweep was ${latestVisRun.createdAt}; integration syncs have run since.`,
|
|
5703
|
+
actionLabel: "Stale",
|
|
5704
|
+
href: "#runs"
|
|
5705
|
+
});
|
|
5706
|
+
}
|
|
5707
|
+
}
|
|
5708
|
+
return items;
|
|
4976
5709
|
}
|
|
4977
5710
|
function mapInsightRow2(r) {
|
|
4978
5711
|
return {
|
|
@@ -5252,6 +5985,12 @@ var locationQueryParameter = {
|
|
|
5252
5985
|
description: "Filter by location label. Use an empty value to request locationless results.",
|
|
5253
5986
|
schema: stringSchema
|
|
5254
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
|
+
};
|
|
5255
5994
|
var analyticsWindowParameter = {
|
|
5256
5995
|
name: "window",
|
|
5257
5996
|
in: "query",
|
|
@@ -7472,9 +8211,9 @@ var routeCatalog = [
|
|
|
7472
8211
|
{
|
|
7473
8212
|
method: "get",
|
|
7474
8213
|
path: "/api/v1/projects/{name}/report",
|
|
7475
|
-
summary: "Aggregated
|
|
8214
|
+
summary: "Aggregated canonical AEO report",
|
|
7476
8215
|
tags: ["report"],
|
|
7477
|
-
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.",
|
|
7478
8217
|
parameters: [nameParameter],
|
|
7479
8218
|
responses: {
|
|
7480
8219
|
200: { description: "Report returned." },
|
|
@@ -7486,8 +8225,8 @@ var routeCatalog = [
|
|
|
7486
8225
|
path: "/api/v1/projects/{name}/report.html",
|
|
7487
8226
|
summary: "Standalone HTML AEO report",
|
|
7488
8227
|
tags: ["report"],
|
|
7489
|
-
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.",
|
|
7490
|
-
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],
|
|
7491
8230
|
responses: {
|
|
7492
8231
|
200: { description: "HTML report returned." },
|
|
7493
8232
|
404: { description: "Project not found." }
|
|
@@ -17299,7 +18038,7 @@ import crypto19 from "crypto";
|
|
|
17299
18038
|
import fs7 from "fs";
|
|
17300
18039
|
import path9 from "path";
|
|
17301
18040
|
import os4 from "os";
|
|
17302
|
-
import { and as and12, eq as eq23, inArray as
|
|
18041
|
+
import { and as and12, eq as eq23, inArray as inArray7, sql as sql7 } from "drizzle-orm";
|
|
17303
18042
|
|
|
17304
18043
|
// src/citation-utils.ts
|
|
17305
18044
|
function domainMatches(domain, canonicalDomain) {
|
|
@@ -17556,7 +18295,7 @@ var JobRunner = class {
|
|
|
17556
18295
|
this.registry = registry;
|
|
17557
18296
|
}
|
|
17558
18297
|
recoverStaleRuns() {
|
|
17559
|
-
const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(
|
|
18298
|
+
const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray7(runs.status, ["running", "queued"])).all();
|
|
17560
18299
|
if (stale.length === 0) return;
|
|
17561
18300
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
17562
18301
|
for (const run of stale) {
|