@ainyc/canonry 4.2.2 → 4.8.0
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-C2ZxtVjD.js +302 -0
- package/assets/assets/{index-D7T5wSBj.css → index-DAS6pOry.css} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-SR7TGHHG.js → chunk-5KIFQH52.js} +2 -2
- package/dist/{chunk-T2I6AO7D.js → chunk-IJEP6LB4.js} +155 -1
- package/dist/{chunk-HJZY4EOE.js → chunk-O4VXWABZ.js} +1365 -214
- package/dist/{chunk-7YSI4GFA.js → chunk-QEPFB7UW.js} +57 -5
- package/dist/cli.js +20 -10
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-CQGAXKKN.js → intelligence-service-X7CBN7S4.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +9 -9
- package/assets/assets/index-DoJfQkim.js +0 -302
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
configExists,
|
|
5
5
|
loadConfig,
|
|
6
6
|
saveConfigPatch
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-5KIFQH52.js";
|
|
8
8
|
import {
|
|
9
9
|
DEFAULT_RUN_HISTORY_LIMIT,
|
|
10
10
|
IntelligenceService,
|
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
runs,
|
|
62
62
|
schedules,
|
|
63
63
|
usageCounters
|
|
64
|
-
} from "./chunk-
|
|
64
|
+
} from "./chunk-QEPFB7UW.js";
|
|
65
65
|
import {
|
|
66
66
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
67
67
|
AGENT_PROVIDER_IDS,
|
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
RunStatuses,
|
|
78
78
|
RunTriggers,
|
|
79
79
|
absolutizeProjectUrl,
|
|
80
|
+
actionConfidenceLabel,
|
|
80
81
|
agentBusy,
|
|
81
82
|
agentMemoryDeleteRequestSchema,
|
|
82
83
|
agentMemoryUpsertRequestSchema,
|
|
@@ -89,12 +90,14 @@ import {
|
|
|
89
90
|
categoryLabel,
|
|
90
91
|
citationStateToCited,
|
|
91
92
|
competitorBatchRequestSchema,
|
|
93
|
+
contentActionLabel,
|
|
92
94
|
deliveryFailed,
|
|
93
95
|
determineAnswerMentioned,
|
|
94
96
|
effectiveDomains,
|
|
95
97
|
emptyCitationVisibility,
|
|
96
98
|
extractAnswerMentions,
|
|
97
99
|
findDuplicateLocationLabels,
|
|
100
|
+
getProviderLocationHandling,
|
|
98
101
|
hasLocationLabel,
|
|
99
102
|
internalError,
|
|
100
103
|
isAgentProviderId,
|
|
@@ -113,6 +116,11 @@ import {
|
|
|
113
116
|
providerError,
|
|
114
117
|
queryGenerateRequestSchema,
|
|
115
118
|
registrableDomain,
|
|
119
|
+
reportActionCategoryLabel,
|
|
120
|
+
reportActionTone,
|
|
121
|
+
reportConfidenceLabel,
|
|
122
|
+
reportHorizonLabel,
|
|
123
|
+
reportSeverityLabel,
|
|
116
124
|
resolveConfigSpecQueries,
|
|
117
125
|
resolveSnapshotRequestQueries,
|
|
118
126
|
runInProgress,
|
|
@@ -127,7 +135,7 @@ import {
|
|
|
127
135
|
visibilityStateFromAnswerMentioned,
|
|
128
136
|
windowCutoff,
|
|
129
137
|
wordpressEnvSchema
|
|
130
|
-
} from "./chunk-
|
|
138
|
+
} from "./chunk-IJEP6LB4.js";
|
|
131
139
|
|
|
132
140
|
// src/telemetry.ts
|
|
133
141
|
import crypto from "crypto";
|
|
@@ -2620,12 +2628,43 @@ function formatLandingPageHtml(raw) {
|
|
|
2620
2628
|
function formatDate(iso) {
|
|
2621
2629
|
if (!iso) return "\u2014";
|
|
2622
2630
|
try {
|
|
2623
|
-
const
|
|
2624
|
-
|
|
2631
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
2632
|
+
const options = { month: "short", day: "numeric", year: "numeric" };
|
|
2633
|
+
const d = dateOnly && dateOnly[1] && dateOnly[2] && dateOnly[3] ? new Date(Date.UTC(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]))) : new Date(iso);
|
|
2634
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
2635
|
+
return d.toLocaleDateString("en-US", dateOnly ? { ...options, timeZone: "UTC" } : options);
|
|
2625
2636
|
} catch {
|
|
2626
2637
|
return iso;
|
|
2627
2638
|
}
|
|
2628
2639
|
}
|
|
2640
|
+
function formatDateRange(start, end) {
|
|
2641
|
+
if (!start && !end) return "";
|
|
2642
|
+
if (start && end) return `${formatDate(start)} \u2192 ${formatDate(end)}`;
|
|
2643
|
+
return formatDate(start || end);
|
|
2644
|
+
}
|
|
2645
|
+
function gscDateRange(report) {
|
|
2646
|
+
const summary = report.executiveSummary.gsc;
|
|
2647
|
+
const gsc = report.gsc;
|
|
2648
|
+
const start = summary?.periodStart || gsc?.periodStart || gsc?.trend[0]?.date || "";
|
|
2649
|
+
const end = summary?.periodEnd || gsc?.periodEnd || gsc?.trend.at(-1)?.date || "";
|
|
2650
|
+
return formatDateRange(start, end);
|
|
2651
|
+
}
|
|
2652
|
+
function pluralize(count, singular, plural = `${singular}s`) {
|
|
2653
|
+
return count === 1 ? singular : plural;
|
|
2654
|
+
}
|
|
2655
|
+
function compactInlineList(items, limit = 3) {
|
|
2656
|
+
const visible = items.slice(0, limit);
|
|
2657
|
+
const more = items.length - visible.length;
|
|
2658
|
+
return `${visible.join(", ")}${more > 0 ? `, +${more} more` : ""}`;
|
|
2659
|
+
}
|
|
2660
|
+
function renderProofChips(items, limit = 3) {
|
|
2661
|
+
if (items.length === 0) return "";
|
|
2662
|
+
const visible = items.slice(0, limit);
|
|
2663
|
+
const more = items.length - visible.length;
|
|
2664
|
+
const chips = visible.map((item) => `<span class="proof-chip">${escapeHtml(item)}</span>`);
|
|
2665
|
+
if (more > 0) chips.push(`<span class="proof-chip">+${more} more</span>`);
|
|
2666
|
+
return `<div class="proof-chips">${chips.join("")}</div>`;
|
|
2667
|
+
}
|
|
2629
2668
|
function pressureTone(label) {
|
|
2630
2669
|
if (label === "High") return "negative";
|
|
2631
2670
|
if (label === "Moderate") return "caution";
|
|
@@ -2672,7 +2711,7 @@ body {
|
|
|
2672
2711
|
font-size: 32px;
|
|
2673
2712
|
font-weight: 700;
|
|
2674
2713
|
margin: 0 0 8px;
|
|
2675
|
-
letter-spacing:
|
|
2714
|
+
letter-spacing: 0;
|
|
2676
2715
|
}
|
|
2677
2716
|
.header .subtitle {
|
|
2678
2717
|
color: ${COLORS.textMuted};
|
|
@@ -2680,7 +2719,7 @@ body {
|
|
|
2680
2719
|
}
|
|
2681
2720
|
.eyebrow {
|
|
2682
2721
|
text-transform: uppercase;
|
|
2683
|
-
letter-spacing: 0
|
|
2722
|
+
letter-spacing: 0;
|
|
2684
2723
|
font-size: 10px;
|
|
2685
2724
|
color: ${COLORS.textFaint};
|
|
2686
2725
|
font-weight: 600;
|
|
@@ -2693,11 +2732,75 @@ section.report-section h2 {
|
|
|
2693
2732
|
font-size: 22px;
|
|
2694
2733
|
font-weight: 700;
|
|
2695
2734
|
margin: 0 0 24px;
|
|
2696
|
-
letter-spacing:
|
|
2735
|
+
letter-spacing: 0;
|
|
2697
2736
|
}
|
|
2698
2737
|
section.report-section .section-intro {
|
|
2699
2738
|
color: ${COLORS.textMuted};
|
|
2700
2739
|
margin-bottom: 24px;
|
|
2740
|
+
max-width: 760px;
|
|
2741
|
+
}
|
|
2742
|
+
.executive-hero {
|
|
2743
|
+
display: grid;
|
|
2744
|
+
grid-template-columns: minmax(0, 1.35fr) minmax(240px, 0.65fr);
|
|
2745
|
+
gap: 16px;
|
|
2746
|
+
margin-bottom: 16px;
|
|
2747
|
+
}
|
|
2748
|
+
.headline-card {
|
|
2749
|
+
background: #111827;
|
|
2750
|
+
border: 1px solid ${COLORS.border};
|
|
2751
|
+
border-radius: 8px;
|
|
2752
|
+
padding: 28px;
|
|
2753
|
+
min-height: 220px;
|
|
2754
|
+
display: flex;
|
|
2755
|
+
flex-direction: column;
|
|
2756
|
+
justify-content: space-between;
|
|
2757
|
+
}
|
|
2758
|
+
.headline-card .hero-kicker {
|
|
2759
|
+
color: ${COLORS.textMuted};
|
|
2760
|
+
font-size: 12px;
|
|
2761
|
+
font-weight: 600;
|
|
2762
|
+
text-transform: uppercase;
|
|
2763
|
+
letter-spacing: 0;
|
|
2764
|
+
}
|
|
2765
|
+
.headline-card .hero-title {
|
|
2766
|
+
font-size: 44px;
|
|
2767
|
+
line-height: 1.05;
|
|
2768
|
+
font-weight: 800;
|
|
2769
|
+
letter-spacing: 0;
|
|
2770
|
+
margin: 18px 0;
|
|
2771
|
+
}
|
|
2772
|
+
.headline-card .hero-subtitle {
|
|
2773
|
+
color: ${COLORS.textMuted};
|
|
2774
|
+
font-size: 15px;
|
|
2775
|
+
max-width: 620px;
|
|
2776
|
+
}
|
|
2777
|
+
.hero-proof-grid {
|
|
2778
|
+
display: grid;
|
|
2779
|
+
gap: 12px;
|
|
2780
|
+
}
|
|
2781
|
+
.hero-proof {
|
|
2782
|
+
background: ${COLORS.surface};
|
|
2783
|
+
border: 1px solid ${COLORS.border};
|
|
2784
|
+
border-radius: 8px;
|
|
2785
|
+
padding: 18px;
|
|
2786
|
+
}
|
|
2787
|
+
.hero-proof .mini-label {
|
|
2788
|
+
color: ${COLORS.textFaint};
|
|
2789
|
+
font-size: 10px;
|
|
2790
|
+
font-weight: 600;
|
|
2791
|
+
text-transform: uppercase;
|
|
2792
|
+
letter-spacing: 0;
|
|
2793
|
+
margin-bottom: 8px;
|
|
2794
|
+
}
|
|
2795
|
+
.hero-proof .mini-value {
|
|
2796
|
+
font-size: 30px;
|
|
2797
|
+
line-height: 1;
|
|
2798
|
+
font-weight: 800;
|
|
2799
|
+
}
|
|
2800
|
+
.hero-proof .mini-copy {
|
|
2801
|
+
color: ${COLORS.textMuted};
|
|
2802
|
+
font-size: 12px;
|
|
2803
|
+
margin-top: 8px;
|
|
2701
2804
|
}
|
|
2702
2805
|
.metric-grid {
|
|
2703
2806
|
display: grid;
|
|
@@ -2712,7 +2815,7 @@ section.report-section .section-intro {
|
|
|
2712
2815
|
}
|
|
2713
2816
|
.metric .label {
|
|
2714
2817
|
text-transform: uppercase;
|
|
2715
|
-
letter-spacing: 0
|
|
2818
|
+
letter-spacing: 0;
|
|
2716
2819
|
font-size: 10px;
|
|
2717
2820
|
color: ${COLORS.textFaint};
|
|
2718
2821
|
font-weight: 600;
|
|
@@ -2721,7 +2824,7 @@ section.report-section .section-intro {
|
|
|
2721
2824
|
.metric .value {
|
|
2722
2825
|
font-size: 28px;
|
|
2723
2826
|
font-weight: 700;
|
|
2724
|
-
letter-spacing:
|
|
2827
|
+
letter-spacing: 0;
|
|
2725
2828
|
}
|
|
2726
2829
|
.metric .delta {
|
|
2727
2830
|
font-size: 12px;
|
|
@@ -2746,6 +2849,57 @@ section.report-section .section-intro {
|
|
|
2746
2849
|
.finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
2747
2850
|
.finding strong { display: block; margin-bottom: 4px; }
|
|
2748
2851
|
.finding span { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
2852
|
+
.market-scope-card { margin-top: 16px; }
|
|
2853
|
+
.market-scope-grid {
|
|
2854
|
+
display: grid;
|
|
2855
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
2856
|
+
gap: 12px;
|
|
2857
|
+
}
|
|
2858
|
+
.scope-tile {
|
|
2859
|
+
background: #09090b;
|
|
2860
|
+
border: 1px solid ${COLORS.border};
|
|
2861
|
+
border-radius: 8px;
|
|
2862
|
+
padding: 14px;
|
|
2863
|
+
}
|
|
2864
|
+
.scope-tile .scope-label {
|
|
2865
|
+
color: ${COLORS.textFaint};
|
|
2866
|
+
font-size: 10px;
|
|
2867
|
+
font-weight: 600;
|
|
2868
|
+
text-transform: uppercase;
|
|
2869
|
+
letter-spacing: 0;
|
|
2870
|
+
margin-bottom: 8px;
|
|
2871
|
+
}
|
|
2872
|
+
.scope-tile .scope-value {
|
|
2873
|
+
font-size: 18px;
|
|
2874
|
+
line-height: 1.2;
|
|
2875
|
+
font-weight: 700;
|
|
2876
|
+
}
|
|
2877
|
+
.scope-tile .scope-copy {
|
|
2878
|
+
color: ${COLORS.textMuted};
|
|
2879
|
+
font-size: 12px;
|
|
2880
|
+
margin-top: 8px;
|
|
2881
|
+
}
|
|
2882
|
+
.scope-warning {
|
|
2883
|
+
margin-top: 12px;
|
|
2884
|
+
border: 1px solid ${COLORS.caution}55;
|
|
2885
|
+
background: ${COLORS.caution}14;
|
|
2886
|
+
border-radius: 8px;
|
|
2887
|
+
padding: 12px 14px;
|
|
2888
|
+
color: ${COLORS.textMuted};
|
|
2889
|
+
font-size: 13px;
|
|
2890
|
+
}
|
|
2891
|
+
.scope-warning strong { color: ${COLORS.text}; display: block; margin-bottom: 4px; }
|
|
2892
|
+
.source-origin-headline { margin: 0 0 12px; font-size: 14px; color: ${COLORS.text}; }
|
|
2893
|
+
.source-origin-headline strong { color: ${COLORS.text}; }
|
|
2894
|
+
.source-bars { display: flex; flex-direction: column; gap: 6px; }
|
|
2895
|
+
.source-bar-row { display: grid; grid-template-columns: 220px 1fr 90px; align-items: center; gap: 12px; font-size: 13px; }
|
|
2896
|
+
.source-bar-label { color: ${COLORS.textMuted}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2897
|
+
.source-bar-track { height: 14px; background: ${COLORS.border}; border-radius: 3px; overflow: hidden; }
|
|
2898
|
+
.source-bar-fill { height: 100%; border-radius: 3px; }
|
|
2899
|
+
.source-bar-value { color: ${COLORS.text}; text-align: right; font-variant-numeric: tabular-nums; }
|
|
2900
|
+
.source-bar-pct { color: ${COLORS.textFaint}; font-size: 11px; }
|
|
2901
|
+
.driver-list { margin: 0; padding-left: 16px; font-size: 12px; color: ${COLORS.textMuted}; }
|
|
2902
|
+
.driver-list li { margin: 2px 0; }
|
|
2749
2903
|
table.report-table {
|
|
2750
2904
|
width: 100%;
|
|
2751
2905
|
border-collapse: collapse;
|
|
@@ -2756,18 +2910,24 @@ table.report-table th, table.report-table td {
|
|
|
2756
2910
|
padding: 10px 12px;
|
|
2757
2911
|
border-bottom: 1px solid ${COLORS.border};
|
|
2758
2912
|
vertical-align: top;
|
|
2759
|
-
overflow-wrap:
|
|
2760
|
-
|
|
2913
|
+
overflow-wrap: break-word;
|
|
2914
|
+
hyphens: auto;
|
|
2761
2915
|
}
|
|
2762
2916
|
table.report-table th {
|
|
2763
2917
|
font-weight: 600;
|
|
2764
2918
|
color: ${COLORS.textMuted};
|
|
2765
2919
|
text-transform: uppercase;
|
|
2766
|
-
letter-spacing: 0
|
|
2920
|
+
letter-spacing: 0;
|
|
2767
2921
|
font-size: 10px;
|
|
2768
2922
|
}
|
|
2769
2923
|
table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
2770
2924
|
table.report-table td.page-cell { max-width: 0; }
|
|
2925
|
+
table.insights-table { table-layout: fixed; }
|
|
2926
|
+
table.insights-table th.col-severity, table.insights-table td.col-severity { width: 96px; }
|
|
2927
|
+
table.insights-table th.col-query, table.insights-table td.col-query { width: 18%; }
|
|
2928
|
+
table.insights-table th.col-provider, table.insights-table td.col-provider { width: 88px; }
|
|
2929
|
+
table.insights-table th.col-title, table.insights-table td.col-title { width: 28%; }
|
|
2930
|
+
table.insights-table th.col-recommendation, table.insights-table td.col-recommendation { width: auto; }
|
|
2771
2931
|
table.report-table td.page-cell .page-path {
|
|
2772
2932
|
display: block;
|
|
2773
2933
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
@@ -2860,12 +3020,159 @@ table.report-table td .badge {
|
|
|
2860
3020
|
.step .horizon {
|
|
2861
3021
|
text-transform: uppercase;
|
|
2862
3022
|
font-size: 10px;
|
|
2863
|
-
letter-spacing: 0
|
|
3023
|
+
letter-spacing: 0;
|
|
2864
3024
|
color: ${COLORS.textFaint};
|
|
2865
3025
|
font-weight: 600;
|
|
2866
3026
|
}
|
|
2867
3027
|
.step .title { font-weight: 600; }
|
|
2868
3028
|
.step .rationale { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
3029
|
+
.action-card-grid {
|
|
3030
|
+
display: grid;
|
|
3031
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
3032
|
+
gap: 16px;
|
|
3033
|
+
}
|
|
3034
|
+
.action-card {
|
|
3035
|
+
background: ${COLORS.surface};
|
|
3036
|
+
border: 1px solid ${COLORS.border};
|
|
3037
|
+
border-radius: 8px;
|
|
3038
|
+
padding: 18px;
|
|
3039
|
+
display: flex;
|
|
3040
|
+
flex-direction: column;
|
|
3041
|
+
gap: 12px;
|
|
3042
|
+
}
|
|
3043
|
+
.action-card .action-head {
|
|
3044
|
+
display: grid;
|
|
3045
|
+
grid-template-columns: 42px 1fr;
|
|
3046
|
+
gap: 12px;
|
|
3047
|
+
align-items: start;
|
|
3048
|
+
}
|
|
3049
|
+
.action-card .action-rank {
|
|
3050
|
+
border: 1px solid ${COLORS.border};
|
|
3051
|
+
border-radius: 8px;
|
|
3052
|
+
height: 42px;
|
|
3053
|
+
display: flex;
|
|
3054
|
+
align-items: center;
|
|
3055
|
+
justify-content: center;
|
|
3056
|
+
font-size: 16px;
|
|
3057
|
+
font-weight: 800;
|
|
3058
|
+
color: ${COLORS.text};
|
|
3059
|
+
background: #09090b;
|
|
3060
|
+
}
|
|
3061
|
+
.action-card .action-meta {
|
|
3062
|
+
display: flex;
|
|
3063
|
+
flex-wrap: wrap;
|
|
3064
|
+
gap: 8px;
|
|
3065
|
+
}
|
|
3066
|
+
.action-card h3 {
|
|
3067
|
+
font-size: 16px;
|
|
3068
|
+
margin: 8px 0 0;
|
|
3069
|
+
}
|
|
3070
|
+
.action-card p {
|
|
3071
|
+
margin: 0;
|
|
3072
|
+
color: ${COLORS.textMuted};
|
|
3073
|
+
}
|
|
3074
|
+
.action-card ul {
|
|
3075
|
+
margin: 0 0 12px;
|
|
3076
|
+
padding-left: 18px;
|
|
3077
|
+
color: ${COLORS.textMuted};
|
|
3078
|
+
font-size: 13px;
|
|
3079
|
+
}
|
|
3080
|
+
.action-card li { margin: 4px 0; }
|
|
3081
|
+
.proof-chips {
|
|
3082
|
+
display: flex;
|
|
3083
|
+
flex-wrap: wrap;
|
|
3084
|
+
gap: 8px;
|
|
3085
|
+
}
|
|
3086
|
+
.proof-chip {
|
|
3087
|
+
border: 1px solid ${COLORS.border};
|
|
3088
|
+
border-radius: 8px;
|
|
3089
|
+
padding: 6px 8px;
|
|
3090
|
+
color: ${COLORS.textMuted};
|
|
3091
|
+
font-size: 12px;
|
|
3092
|
+
background: #09090b;
|
|
3093
|
+
}
|
|
3094
|
+
.action-details {
|
|
3095
|
+
color: ${COLORS.textMuted};
|
|
3096
|
+
font-size: 12px;
|
|
3097
|
+
}
|
|
3098
|
+
.action-details summary {
|
|
3099
|
+
cursor: pointer;
|
|
3100
|
+
color: ${COLORS.text};
|
|
3101
|
+
font-weight: 600;
|
|
3102
|
+
}
|
|
3103
|
+
.action-card .success-metric {
|
|
3104
|
+
color: ${COLORS.text};
|
|
3105
|
+
font-size: 13px;
|
|
3106
|
+
border-top: 1px solid ${COLORS.border};
|
|
3107
|
+
padding-top: 10px;
|
|
3108
|
+
margin-top: 12px;
|
|
3109
|
+
}
|
|
3110
|
+
.client-notes {
|
|
3111
|
+
margin-top: 18px;
|
|
3112
|
+
display: grid;
|
|
3113
|
+
gap: 8px;
|
|
3114
|
+
}
|
|
3115
|
+
.client-note {
|
|
3116
|
+
color: ${COLORS.textMuted};
|
|
3117
|
+
font-size: 13px;
|
|
3118
|
+
background: ${COLORS.surface};
|
|
3119
|
+
border: 1px solid ${COLORS.border};
|
|
3120
|
+
border-radius: 8px;
|
|
3121
|
+
padding: 10px 12px;
|
|
3122
|
+
}
|
|
3123
|
+
.diagnostics-grid {
|
|
3124
|
+
display: grid;
|
|
3125
|
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
3126
|
+
gap: 12px;
|
|
3127
|
+
}
|
|
3128
|
+
.diagnostic-card {
|
|
3129
|
+
background: ${COLORS.surface};
|
|
3130
|
+
border: 1px solid ${COLORS.border};
|
|
3131
|
+
border-left-width: 3px;
|
|
3132
|
+
border-radius: 8px;
|
|
3133
|
+
padding: 14px 16px;
|
|
3134
|
+
}
|
|
3135
|
+
.diagnostic-card h3 { font-size: 14px; margin: 0 0 6px; }
|
|
3136
|
+
.diagnostic-card p { margin: 0 0 8px; color: ${COLORS.textMuted}; font-size: 13px; }
|
|
3137
|
+
.diagnostic-card ul { margin: 0; padding-left: 16px; color: ${COLORS.textMuted}; font-size: 12px; }
|
|
3138
|
+
.diagnostic-card .proof-chips { margin-top: 10px; }
|
|
3139
|
+
.diagnostic-card.tone-positive { border-left-color: ${COLORS.positive}; }
|
|
3140
|
+
.diagnostic-card.tone-caution { border-left-color: ${COLORS.caution}; }
|
|
3141
|
+
.diagnostic-card.tone-negative { border-left-color: ${COLORS.negative}; }
|
|
3142
|
+
.diagnostic-card.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
3143
|
+
.opportunity-grid {
|
|
3144
|
+
display: grid;
|
|
3145
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
3146
|
+
gap: 12px;
|
|
3147
|
+
margin-bottom: 16px;
|
|
3148
|
+
}
|
|
3149
|
+
.opportunity-card {
|
|
3150
|
+
background: ${COLORS.surface};
|
|
3151
|
+
border: 1px solid ${COLORS.border};
|
|
3152
|
+
border-radius: 8px;
|
|
3153
|
+
padding: 16px;
|
|
3154
|
+
}
|
|
3155
|
+
.opportunity-card .opportunity-score {
|
|
3156
|
+
font-size: 32px;
|
|
3157
|
+
line-height: 1;
|
|
3158
|
+
font-weight: 800;
|
|
3159
|
+
margin-bottom: 10px;
|
|
3160
|
+
}
|
|
3161
|
+
.opportunity-card .opportunity-score-suffix {
|
|
3162
|
+
font-size: 14px;
|
|
3163
|
+
font-weight: 600;
|
|
3164
|
+
color: ${COLORS.textFaint};
|
|
3165
|
+
margin-left: 4px;
|
|
3166
|
+
}
|
|
3167
|
+
.opportunity-card h3 {
|
|
3168
|
+
font-size: 14px;
|
|
3169
|
+
margin: 0 0 8px;
|
|
3170
|
+
}
|
|
3171
|
+
.opportunity-card p {
|
|
3172
|
+
color: ${COLORS.textMuted};
|
|
3173
|
+
font-size: 12px;
|
|
3174
|
+
margin: 0;
|
|
3175
|
+
}
|
|
2869
3176
|
.footer {
|
|
2870
3177
|
margin-top: 96px;
|
|
2871
3178
|
padding-top: 24px;
|
|
@@ -2874,6 +3181,14 @@ table.report-table td .badge {
|
|
|
2874
3181
|
color: ${COLORS.textFaint};
|
|
2875
3182
|
font-size: 12px;
|
|
2876
3183
|
}
|
|
3184
|
+
@media (max-width: 760px) {
|
|
3185
|
+
.container { padding: 32px 16px 72px; }
|
|
3186
|
+
.executive-hero { grid-template-columns: 1fr; }
|
|
3187
|
+
.headline-card .hero-title { font-size: 34px; }
|
|
3188
|
+
.source-bar-row { grid-template-columns: 1fr; gap: 6px; }
|
|
3189
|
+
.source-bar-value { text-align: left; }
|
|
3190
|
+
.chart-grid { grid-template-columns: 1fr; }
|
|
3191
|
+
}
|
|
2877
3192
|
@media print {
|
|
2878
3193
|
body { background: white; color: black; }
|
|
2879
3194
|
section.report-section { break-inside: avoid; }
|
|
@@ -2890,15 +3205,163 @@ function section(opts, body) {
|
|
|
2890
3205
|
function renderEmpty(message) {
|
|
2891
3206
|
return `<div class="empty-state">${escapeHtml(message)}</div>`;
|
|
2892
3207
|
}
|
|
3208
|
+
function locationDisplay(location) {
|
|
3209
|
+
if (!location) return "";
|
|
3210
|
+
const place = [location.city, location.region, location.country].filter(Boolean).join(", ");
|
|
3211
|
+
return place ? `${location.label} (${place})` : location.label;
|
|
3212
|
+
}
|
|
3213
|
+
function renderHeaderLocationFragment(location) {
|
|
3214
|
+
if (!location) return " \xB7 No market set";
|
|
3215
|
+
return ` \xB7 Market: ${escapeHtml(locationDisplay(location))}`;
|
|
3216
|
+
}
|
|
3217
|
+
var REPORT_INTENT_STOPWORDS = /* @__PURE__ */ new Set([
|
|
3218
|
+
"a",
|
|
3219
|
+
"an",
|
|
3220
|
+
"and",
|
|
3221
|
+
"for",
|
|
3222
|
+
"from",
|
|
3223
|
+
"in",
|
|
3224
|
+
"near",
|
|
3225
|
+
"of",
|
|
3226
|
+
"on",
|
|
3227
|
+
"or",
|
|
3228
|
+
"the",
|
|
3229
|
+
"to"
|
|
3230
|
+
]);
|
|
3231
|
+
function reportIntentModifiers(report) {
|
|
3232
|
+
const location = report.meta.location;
|
|
3233
|
+
if (!location) return /* @__PURE__ */ new Set();
|
|
3234
|
+
return new Set(
|
|
3235
|
+
[location.label, location.city, location.region, location.country].flatMap(tokenizeReportIntent).map(normalizeReportIntentToken).filter(Boolean)
|
|
3236
|
+
);
|
|
3237
|
+
}
|
|
3238
|
+
function dedupeReportActions(report, actions) {
|
|
3239
|
+
const modifiers = reportIntentModifiers(report);
|
|
3240
|
+
if (actions.length <= 1 || modifiers.size === 0) return [...actions];
|
|
3241
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3242
|
+
const result = [];
|
|
3243
|
+
for (const action of actions) {
|
|
3244
|
+
if (action.category !== "content") {
|
|
3245
|
+
result.push(action);
|
|
3246
|
+
continue;
|
|
3247
|
+
}
|
|
3248
|
+
const key = reportIntentKey(extractActionQuery(action), modifiers);
|
|
3249
|
+
if (!key || seen.has(key)) continue;
|
|
3250
|
+
seen.add(key);
|
|
3251
|
+
result.push(action);
|
|
3252
|
+
}
|
|
3253
|
+
return result;
|
|
3254
|
+
}
|
|
3255
|
+
function dedupeReportOpportunities(report) {
|
|
3256
|
+
const modifiers = reportIntentModifiers(report);
|
|
3257
|
+
const opportunities = report.contentOpportunities;
|
|
3258
|
+
if (opportunities.length <= 1 || modifiers.size === 0) return opportunities;
|
|
3259
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3260
|
+
return opportunities.filter((opportunity) => {
|
|
3261
|
+
const key = reportIntentKey(opportunity.query, modifiers);
|
|
3262
|
+
if (!key || seen.has(key)) return false;
|
|
3263
|
+
seen.add(key);
|
|
3264
|
+
return true;
|
|
3265
|
+
});
|
|
3266
|
+
}
|
|
3267
|
+
function extractActionQuery(action) {
|
|
3268
|
+
return action.title.match(/"([^"]+)"/)?.[1] ?? action.successMetric.match(/"([^"]+)"/)?.[1] ?? action.title;
|
|
3269
|
+
}
|
|
3270
|
+
function reportIntentKey(value, modifiers) {
|
|
3271
|
+
const tokens = tokenizeReportIntent(value).map(normalizeReportIntentToken).filter(Boolean).filter((token) => !REPORT_INTENT_STOPWORDS.has(token)).filter((token) => !modifiers.has(token));
|
|
3272
|
+
return [...new Set(tokens)].sort().join(" ");
|
|
3273
|
+
}
|
|
3274
|
+
function tokenizeReportIntent(value) {
|
|
3275
|
+
return value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
3276
|
+
}
|
|
3277
|
+
function normalizeReportIntentToken(token) {
|
|
3278
|
+
if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
|
|
3279
|
+
if (token.length > 4 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
|
|
3280
|
+
return token;
|
|
3281
|
+
}
|
|
3282
|
+
function renderLocationCard(report) {
|
|
3283
|
+
const location = report.meta.location;
|
|
3284
|
+
const handling = report.meta.providerLocationHandling;
|
|
3285
|
+
if (!location && handling.length === 0) return "";
|
|
3286
|
+
const otherLocations = location?.otherConfiguredLabels ?? [];
|
|
3287
|
+
const weakLocationProviders = handling.filter((h) => h.treatment === "ignored" || h.treatment === "browser-geo").map((h) => h.provider);
|
|
3288
|
+
const marketValue = location ? locationDisplay(location) : "No market set";
|
|
3289
|
+
const notIncluded = otherLocations.length > 0 ? compactInlineList(otherLocations, 4) : "None";
|
|
3290
|
+
const interpretation = location ? otherLocations.length > 0 ? `${otherLocations.length} configured ${pluralize(otherLocations.length, "market")} still ${otherLocations.length === 1 ? "needs" : "need"} a matching sweep before cross-market recommendations.` : "Single-market report; findings can be read as the current market view." : "No geographic hint was attached to this sweep; read findings as default-market or national results.";
|
|
3291
|
+
const providerCopy = handling.length > 0 ? weakLocationProviders.length > 0 ? `${weakLocationProviders.length} ${pluralize(weakLocationProviders.length, "provider")} need a closer location check.` : `${handling.length} ${pluralize(handling.length, "provider")} received the market context.` : "No provider-level location metadata is available for this report.";
|
|
3292
|
+
const warning = weakLocationProviders.length > 0 ? `<div class="scope-warning">
|
|
3293
|
+
<strong>Location handling needs review</strong>
|
|
3294
|
+
${escapeHtml(compactInlineList(weakLocationProviders, 4))} used weak or indirect market handling. Treat provider-level differences cautiously.
|
|
3295
|
+
</div>` : "";
|
|
3296
|
+
return `<div class="chart-card market-scope-card">
|
|
3297
|
+
<h3>Market Scope</h3>
|
|
3298
|
+
<div class="market-scope-grid">
|
|
3299
|
+
<div class="scope-tile">
|
|
3300
|
+
<div class="scope-label">Current sweep</div>
|
|
3301
|
+
<div class="scope-value">${escapeHtml(marketValue)}</div>
|
|
3302
|
+
<div class="scope-copy">All findings below are scoped to this run.</div>
|
|
3303
|
+
</div>
|
|
3304
|
+
<div class="scope-tile">
|
|
3305
|
+
<div class="scope-label">Not included</div>
|
|
3306
|
+
<div class="scope-value">${escapeHtml(notIncluded)}</div>
|
|
3307
|
+
<div class="scope-copy">${escapeHtml(interpretation)}</div>
|
|
3308
|
+
</div>
|
|
3309
|
+
<div class="scope-tile">
|
|
3310
|
+
<div class="scope-label">Provider context</div>
|
|
3311
|
+
<div class="scope-value">${handling.length > 0 ? formatNumber(handling.length) : "\u2014"}</div>
|
|
3312
|
+
<div class="scope-copy">${escapeHtml(providerCopy)}</div>
|
|
3313
|
+
</div>
|
|
3314
|
+
</div>
|
|
3315
|
+
${warning}
|
|
3316
|
+
</div>`;
|
|
3317
|
+
}
|
|
2893
3318
|
function renderExecutiveSummary(report) {
|
|
2894
3319
|
const s = report.executiveSummary;
|
|
2895
3320
|
const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
|
|
2896
3321
|
const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
|
|
3322
|
+
const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
|
|
3323
|
+
const citedFragment = s.totalQueryCount > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
|
|
3324
|
+
const mentionedFragment = s.totalQueryCount > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
|
|
3325
|
+
const headlineTitle = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` : "No AI citation data yet";
|
|
3326
|
+
const headlineSubtitle = s.totalQueryCount > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a visibility sweep to populate the first citation and mention baseline.";
|
|
3327
|
+
const priorityActions = report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan;
|
|
3328
|
+
const actionCount = dedupeReportActions(report, priorityActions).length;
|
|
3329
|
+
const heroHtml = `<div class="executive-hero">
|
|
3330
|
+
<div class="headline-card">
|
|
3331
|
+
<div>
|
|
3332
|
+
<div class="hero-kicker">Latest AI visibility sweep</div>
|
|
3333
|
+
<div class="hero-title">${escapeHtml(headlineTitle)}</div>
|
|
3334
|
+
</div>
|
|
3335
|
+
<div class="hero-subtitle">${escapeHtml(headlineSubtitle)}</div>
|
|
3336
|
+
</div>
|
|
3337
|
+
<div class="hero-proof-grid">
|
|
3338
|
+
<div class="hero-proof">
|
|
3339
|
+
<div class="mini-label">Citation trend</div>
|
|
3340
|
+
<div class="mini-value tone-${trendTone}">${escapeHtml(trendLabel)}</div>
|
|
3341
|
+
<div class="mini-copy">${escapeHtml(citedFragment)}</div>
|
|
3342
|
+
</div>
|
|
3343
|
+
<div class="hero-proof">
|
|
3344
|
+
<div class="mini-label">Mention coverage</div>
|
|
3345
|
+
<div class="mini-value">${s.mentionRate}%</div>
|
|
3346
|
+
<div class="mini-copy">${escapeHtml(mentionedFragment)}</div>
|
|
3347
|
+
</div>
|
|
3348
|
+
<div class="hero-proof">
|
|
3349
|
+
<div class="mini-label">Prioritized actions</div>
|
|
3350
|
+
<div class="mini-value">${formatNumber(actionCount)}</div>
|
|
3351
|
+
<div class="mini-copy">Sorted for agency follow-up.</div>
|
|
3352
|
+
</div>
|
|
3353
|
+
</div>
|
|
3354
|
+
</div>`;
|
|
2897
3355
|
const metrics = [
|
|
2898
3356
|
{
|
|
2899
3357
|
label: "Citation rate",
|
|
2900
3358
|
value: `${s.citationRate}%`,
|
|
2901
|
-
delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
|
|
3359
|
+
delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${citedFragment} \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
|
|
3360
|
+
},
|
|
3361
|
+
{
|
|
3362
|
+
label: "Mention rate",
|
|
3363
|
+
value: `${s.mentionRate}%`,
|
|
3364
|
+
delta: mentionedFragment
|
|
2902
3365
|
},
|
|
2903
3366
|
{
|
|
2904
3367
|
label: "Queries tracked",
|
|
@@ -2907,10 +3370,11 @@ function renderExecutiveSummary(report) {
|
|
|
2907
3370
|
}
|
|
2908
3371
|
];
|
|
2909
3372
|
if (s.gsc) {
|
|
3373
|
+
const dateRange = gscDateRange(report);
|
|
2910
3374
|
metrics.push({
|
|
2911
3375
|
label: "GSC clicks",
|
|
2912
3376
|
value: formatNumber(s.gsc.clicks),
|
|
2913
|
-
delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR`
|
|
3377
|
+
delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR${dateRange ? ` \xB7 ${escapeHtml(dateRange)}` : ""}`
|
|
2914
3378
|
});
|
|
2915
3379
|
}
|
|
2916
3380
|
if (s.ga) {
|
|
@@ -2932,14 +3396,15 @@ function renderExecutiveSummary(report) {
|
|
|
2932
3396
|
<strong>${escapeHtml(f.title)}</strong>
|
|
2933
3397
|
<span>${escapeHtml(f.detail)}</span>
|
|
2934
3398
|
</div>`).join("")}</div>` : "";
|
|
3399
|
+
const locationHtml = renderLocationCard(report);
|
|
2935
3400
|
return section(
|
|
2936
3401
|
{
|
|
2937
3402
|
id: "executive-summary",
|
|
2938
3403
|
eyebrow: "Section 1",
|
|
2939
3404
|
title: "Executive Summary",
|
|
2940
|
-
intro: "
|
|
3405
|
+
intro: "Citation = source list. Mention = answer text. They are independent signals."
|
|
2941
3406
|
},
|
|
2942
|
-
metricsHtml + findingsHtml
|
|
3407
|
+
heroHtml + metricsHtml + findingsHtml + locationHtml
|
|
2943
3408
|
);
|
|
2944
3409
|
}
|
|
2945
3410
|
function renderProviderBars(rates) {
|
|
@@ -2977,16 +3442,16 @@ function renderCitationMatrix(scorecard) {
|
|
|
2977
3442
|
const cells = scorecard.providers.map((_, pi) => {
|
|
2978
3443
|
const cell = scorecard.matrix[qi]?.[pi];
|
|
2979
3444
|
if (!cell) {
|
|
2980
|
-
return '<td><span class="cell-pending">\u2014</span></td>';
|
|
2981
|
-
}
|
|
2982
|
-
if (cell.citationState === CitationStates.cited) {
|
|
2983
|
-
return '<td><span class="cell-cited">Cited</span></td>';
|
|
3445
|
+
return '<td><span class="cell-pending">\u2014 \u2014</span></td>';
|
|
2984
3446
|
}
|
|
2985
|
-
|
|
3447
|
+
const citedGlyph = cell.citationState === CitationStates.cited ? '<span class="cell-cited">C</span>' : '<span class="cell-not-cited">c</span>';
|
|
3448
|
+
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>';
|
|
3449
|
+
return `<td>${citedGlyph} ${mentionedGlyph}</td>`;
|
|
2986
3450
|
}).join("");
|
|
2987
3451
|
return `<tr><td>${escapeHtml(q)}</td>${cells}</tr>`;
|
|
2988
3452
|
}).join("");
|
|
2989
|
-
|
|
3453
|
+
const legend = '<p class="section-intro" style="margin-top:0;font-size:11px;">Legend: <span class="cell-cited">C</span>/<span class="cell-not-cited">c</span> = cited/not, <span class="cell-cited">M</span>/<span class="cell-not-cited">m</span> = mentioned/not, <span class="cell-pending">\u2013</span> = no data.</p>';
|
|
3454
|
+
return `${legend}<table class="report-table">
|
|
2990
3455
|
<thead><tr><th>Query</th>${headers}</tr></thead>
|
|
2991
3456
|
<tbody>${rows}</tbody>
|
|
2992
3457
|
</table>`;
|
|
@@ -2997,7 +3462,7 @@ function renderCitationScorecard(report) {
|
|
|
2997
3462
|
${renderCitationMatrix(report.citationScorecard)}
|
|
2998
3463
|
`;
|
|
2999
3464
|
return section(
|
|
3000
|
-
{ id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "
|
|
3465
|
+
{ id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Provider-by-provider citation and mention coverage for the latest sweep." },
|
|
3001
3466
|
body
|
|
3002
3467
|
);
|
|
3003
3468
|
}
|
|
@@ -3079,49 +3544,65 @@ function renderCompetitorLandscape(report) {
|
|
|
3079
3544
|
id: "competitor-landscape",
|
|
3080
3545
|
eyebrow: "Section 3",
|
|
3081
3546
|
title: "Competitor Landscape",
|
|
3082
|
-
intro: "
|
|
3547
|
+
intro: "Who AI engines cite and mention instead of the client."
|
|
3083
3548
|
},
|
|
3084
3549
|
`${charts}${table}`
|
|
3085
3550
|
);
|
|
3086
3551
|
}
|
|
3087
|
-
|
|
3552
|
+
var SOURCE_CATEGORY_TONE = {
|
|
3553
|
+
competitor: "negative",
|
|
3554
|
+
directory: "caution",
|
|
3555
|
+
forum: "caution",
|
|
3556
|
+
news: "neutral",
|
|
3557
|
+
reference: "neutral",
|
|
3558
|
+
blog: "neutral",
|
|
3559
|
+
social: "neutral",
|
|
3560
|
+
video: "neutral",
|
|
3561
|
+
ecommerce: "neutral",
|
|
3562
|
+
academic: "neutral",
|
|
3563
|
+
other: "neutral"
|
|
3564
|
+
};
|
|
3565
|
+
function renderCategoryBars(buckets) {
|
|
3088
3566
|
if (buckets.length === 0) return "";
|
|
3089
3567
|
const total = buckets.reduce((s, b) => s + b.count, 0);
|
|
3090
3568
|
if (total === 0) return "";
|
|
3091
|
-
const
|
|
3092
|
-
const
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
const y2 = cy + Math.sin(endAngle) * r;
|
|
3106
|
-
const ix1 = cx + Math.cos(endAngle) * innerR;
|
|
3107
|
-
const iy1 = cy + Math.sin(endAngle) * innerR;
|
|
3108
|
-
const ix2 = cx + Math.cos(startAngle) * innerR;
|
|
3109
|
-
const iy2 = cy + Math.sin(startAngle) * innerR;
|
|
3110
|
-
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
|
|
3111
|
-
const color = COLORS.series[i % COLORS.series.length];
|
|
3112
|
-
if (b.count > 0) {
|
|
3113
|
-
slices.push(`<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z" fill="${color}" />`);
|
|
3114
|
-
legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
|
|
3115
|
-
}
|
|
3116
|
-
});
|
|
3569
|
+
const max = Math.max(...buckets.map((b) => b.count), 1);
|
|
3570
|
+
const rows = buckets.map((b) => {
|
|
3571
|
+
const pct = b.count / max * 100;
|
|
3572
|
+
const tone = SOURCE_CATEGORY_TONE[b.category] ?? "neutral";
|
|
3573
|
+
const color = tone === "negative" ? COLORS.negative : tone === "caution" ? COLORS.caution : COLORS.accent;
|
|
3574
|
+
return `
|
|
3575
|
+
<div class="source-bar-row">
|
|
3576
|
+
<div class="source-bar-label">${escapeHtml(b.label)}</div>
|
|
3577
|
+
<div class="source-bar-track">
|
|
3578
|
+
<div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
|
|
3579
|
+
</div>
|
|
3580
|
+
<div class="source-bar-value">${b.count} <span class="source-bar-pct">(${b.sharePct}%)</span></div>
|
|
3581
|
+
</div>`;
|
|
3582
|
+
}).join("");
|
|
3117
3583
|
return `<div class="chart-card">
|
|
3118
|
-
<h3>
|
|
3119
|
-
<div
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3584
|
+
<h3>By source type</h3>
|
|
3585
|
+
<div class="source-bars">${rows}</div>
|
|
3586
|
+
</div>`;
|
|
3587
|
+
}
|
|
3588
|
+
function renderShareBars(heading, rows, countLabel) {
|
|
3589
|
+
const visibleRows = rows.filter((r) => r.count > 0 || r.sharePct > 0);
|
|
3590
|
+
if (visibleRows.length === 0) return "";
|
|
3591
|
+
const bars = visibleRows.map((r, index) => {
|
|
3592
|
+
const pct = Math.max(0, Math.min(100, r.sharePct));
|
|
3593
|
+
const color = r.color ?? COLORS.series[index % COLORS.series.length];
|
|
3594
|
+
return `
|
|
3595
|
+
<div class="source-bar-row">
|
|
3596
|
+
<div class="source-bar-label">${escapeHtml(r.label)}</div>
|
|
3597
|
+
<div class="source-bar-track">
|
|
3598
|
+
<div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
|
|
3599
|
+
</div>
|
|
3600
|
+
<div class="source-bar-value">${formatNumber(r.count)} <span class="source-bar-pct">${escapeHtml(countLabel)} \xB7 ${r.sharePct}%</span></div>
|
|
3601
|
+
</div>`;
|
|
3602
|
+
}).join("");
|
|
3603
|
+
return `<div class="chart-card">
|
|
3604
|
+
<h3>${escapeHtml(heading)}</h3>
|
|
3605
|
+
<div class="source-bars">${bars}</div>
|
|
3125
3606
|
</div>`;
|
|
3126
3607
|
}
|
|
3127
3608
|
function renderAiSourceOrigin(report) {
|
|
@@ -3132,24 +3613,28 @@ function renderAiSourceOrigin(report) {
|
|
|
3132
3613
|
renderEmpty("No source data yet. Run a visibility sweep first.")
|
|
3133
3614
|
);
|
|
3134
3615
|
}
|
|
3616
|
+
const competitorBucket = origin.categories.find((c) => c.category === "competitor");
|
|
3617
|
+
const headlineFragment = competitorBucket ? `<p class="source-origin-headline"><strong>${competitorBucket.sharePct}%</strong> of citations went to tracked competitors (${competitorBucket.count} of ${origin.categories.reduce((s, c) => s + c.count, 0)}).</p>` : "";
|
|
3135
3618
|
const rows = origin.topDomains.map((d) => `
|
|
3136
3619
|
<tr>
|
|
3137
3620
|
<td>${escapeHtml(d.domain)}</td>
|
|
3138
3621
|
<td class="numeric">${d.count}</td>
|
|
3139
|
-
<td>${d.isCompetitor ? '<span class="badge tone-negative">
|
|
3622
|
+
<td>${d.isCompetitor ? '<span class="badge tone-negative">Tracked competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
|
|
3140
3623
|
</tr>`).join("");
|
|
3141
|
-
const table = origin.topDomains.length > 0 ? `<
|
|
3142
|
-
<
|
|
3143
|
-
|
|
3144
|
-
|
|
3624
|
+
const table = origin.topDomains.length > 0 ? `<div class="chart-card"><h3>Top sources</h3>
|
|
3625
|
+
<table class="report-table">
|
|
3626
|
+
<thead><tr><th>Domain</th><th class="numeric">Citations</th><th>Tag</th></tr></thead>
|
|
3627
|
+
<tbody>${rows}</tbody>
|
|
3628
|
+
</table>
|
|
3629
|
+
</div>` : "";
|
|
3145
3630
|
return section(
|
|
3146
3631
|
{
|
|
3147
3632
|
id: "ai-source-origin",
|
|
3148
3633
|
eyebrow: "Section 4",
|
|
3149
3634
|
title: "AI Citation Sources",
|
|
3150
|
-
intro: "
|
|
3635
|
+
intro: "External domains AI engines trusted most in the latest sweep."
|
|
3151
3636
|
},
|
|
3152
|
-
`${
|
|
3637
|
+
`${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
|
|
3153
3638
|
);
|
|
3154
3639
|
}
|
|
3155
3640
|
function renderLineChart(points, color, title, height = 200) {
|
|
@@ -3201,13 +3686,16 @@ function renderGsc(report) {
|
|
|
3201
3686
|
<td class="numeric">${q.avgPosition.toFixed(1)}</td>
|
|
3202
3687
|
<td><span class="badge tone-neutral">${escapeHtml(q.category)}</span></td>
|
|
3203
3688
|
</tr>`).join("");
|
|
3204
|
-
const
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3689
|
+
const categoryBars = renderShareBars(
|
|
3690
|
+
"Search demand by intent",
|
|
3691
|
+
gsc.categoryBreakdown.map((c, index) => ({
|
|
3692
|
+
label: c.category,
|
|
3693
|
+
count: c.clicks,
|
|
3694
|
+
sharePct: c.sharePct,
|
|
3695
|
+
color: COLORS.series[index % COLORS.series.length]
|
|
3696
|
+
})),
|
|
3697
|
+
"clicks"
|
|
3698
|
+
);
|
|
3211
3699
|
const trendChart = renderLineChart(
|
|
3212
3700
|
gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
|
|
3213
3701
|
COLORS.accent,
|
|
@@ -3216,18 +3704,19 @@ function renderGsc(report) {
|
|
|
3216
3704
|
const crossoverBlocks = [];
|
|
3217
3705
|
if (gsc.trackedButNoGsc.length > 0) {
|
|
3218
3706
|
crossoverBlocks.push(`<div class="chart-card"><h3>AEO queries without search demand</h3>
|
|
3219
|
-
<p class="section-intro">
|
|
3220
|
-
|
|
3707
|
+
<p class="section-intro">Review whether these still belong in the tracking set.</p>
|
|
3708
|
+
${renderProofChips(gsc.trackedButNoGsc, 6)}
|
|
3221
3709
|
</div>`);
|
|
3222
3710
|
}
|
|
3223
3711
|
if (gsc.gscButNotTracked.length > 0) {
|
|
3224
3712
|
crossoverBlocks.push(`<div class="chart-card"><h3>Search queries you should track</h3>
|
|
3225
|
-
<p class="section-intro">
|
|
3226
|
-
|
|
3713
|
+
<p class="section-intro">High-impression candidates to add to AEO tracking.</p>
|
|
3714
|
+
${renderProofChips(gsc.gscButNotTracked, 6)}
|
|
3227
3715
|
</div>`);
|
|
3228
3716
|
}
|
|
3717
|
+
const dateRange = gscDateRange(report);
|
|
3229
3718
|
return section(
|
|
3230
|
-
{ id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro:
|
|
3719
|
+
{ id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro: `Search demand signals to compare against AI visibility${dateRange ? ` for ${dateRange}` : ""}.` },
|
|
3231
3720
|
`<div class="metric-grid">
|
|
3232
3721
|
<div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
|
|
3233
3722
|
<div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
|
|
@@ -3241,12 +3730,7 @@ function renderGsc(report) {
|
|
|
3241
3730
|
<tbody>${rows}</tbody>
|
|
3242
3731
|
</table>
|
|
3243
3732
|
</div>
|
|
3244
|
-
|
|
3245
|
-
<table class="report-table">
|
|
3246
|
-
<thead><tr><th>Category</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">Share</th></tr></thead>
|
|
3247
|
-
<tbody>${breakdownRows}</tbody>
|
|
3248
|
-
</table>
|
|
3249
|
-
</div>
|
|
3733
|
+
${categoryBars}
|
|
3250
3734
|
${crossoverBlocks.join("\n")}`
|
|
3251
3735
|
);
|
|
3252
3736
|
}
|
|
@@ -3265,14 +3749,18 @@ function renderGa(report) {
|
|
|
3265
3749
|
<td class="numeric">${formatNumber(p.users)}</td>
|
|
3266
3750
|
<td class="numeric">${formatNumber(p.organicSessions)}</td>
|
|
3267
3751
|
</tr>`).join("");
|
|
3268
|
-
const
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3752
|
+
const channelBars = renderShareBars(
|
|
3753
|
+
"Channel mix",
|
|
3754
|
+
ga.channelBreakdown.map((c, index) => ({
|
|
3755
|
+
label: c.channel,
|
|
3756
|
+
count: c.sessions,
|
|
3757
|
+
sharePct: c.sharePct,
|
|
3758
|
+
color: COLORS.series[index % COLORS.series.length]
|
|
3759
|
+
})),
|
|
3760
|
+
"sessions"
|
|
3761
|
+
);
|
|
3274
3762
|
return section(
|
|
3275
|
-
{ id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `
|
|
3763
|
+
{ id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `Site traffic from ${formatDate(ga.periodStart)} to ${formatDate(ga.periodEnd)}.` },
|
|
3276
3764
|
`<div class="metric-grid">
|
|
3277
3765
|
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
|
|
3278
3766
|
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
|
|
@@ -3284,12 +3772,7 @@ function renderGa(report) {
|
|
|
3284
3772
|
<tbody>${pageRows}</tbody>
|
|
3285
3773
|
</table>
|
|
3286
3774
|
</div>
|
|
3287
|
-
|
|
3288
|
-
<table class="report-table">
|
|
3289
|
-
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
3290
|
-
<tbody>${channelRows}</tbody>
|
|
3291
|
-
</table>
|
|
3292
|
-
</div>`
|
|
3775
|
+
${channelBars}`
|
|
3293
3776
|
);
|
|
3294
3777
|
}
|
|
3295
3778
|
function renderSocial(report) {
|
|
@@ -3300,12 +3783,16 @@ function renderSocial(report) {
|
|
|
3300
3783
|
renderEmpty("No social referral data yet.")
|
|
3301
3784
|
);
|
|
3302
3785
|
}
|
|
3303
|
-
const
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3786
|
+
const channelBars = renderShareBars(
|
|
3787
|
+
"Social channel mix",
|
|
3788
|
+
social.channels.map((c, index) => ({
|
|
3789
|
+
label: c.channelGroup,
|
|
3790
|
+
count: c.sessions,
|
|
3791
|
+
sharePct: c.sharePct,
|
|
3792
|
+
color: COLORS.series[index % COLORS.series.length]
|
|
3793
|
+
})),
|
|
3794
|
+
"sessions"
|
|
3795
|
+
);
|
|
3309
3796
|
const campaignRows = social.topCampaigns.map((c) => `
|
|
3310
3797
|
<tr>
|
|
3311
3798
|
<td>${escapeHtml(c.source)}</td>
|
|
@@ -3313,18 +3800,13 @@ function renderSocial(report) {
|
|
|
3313
3800
|
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
3314
3801
|
</tr>`).join("");
|
|
3315
3802
|
return section(
|
|
3316
|
-
{ id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "
|
|
3803
|
+
{ id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "Social traffic split by channel and campaign." },
|
|
3317
3804
|
`<div class="metric-grid">
|
|
3318
3805
|
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
|
|
3319
3806
|
<div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
|
|
3320
3807
|
<div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
|
|
3321
3808
|
</div>
|
|
3322
|
-
|
|
3323
|
-
<table class="report-table">
|
|
3324
|
-
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
3325
|
-
<tbody>${channelRows}</tbody>
|
|
3326
|
-
</table>
|
|
3327
|
-
</div>
|
|
3809
|
+
${channelBars}
|
|
3328
3810
|
<div class="chart-card"><h3>Top campaigns</h3>
|
|
3329
3811
|
<table class="report-table">
|
|
3330
3812
|
<thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
|
|
@@ -3341,13 +3823,16 @@ function renderAiReferrals(report) {
|
|
|
3341
3823
|
renderEmpty("No AI referral traffic detected yet.")
|
|
3342
3824
|
);
|
|
3343
3825
|
}
|
|
3344
|
-
const
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3826
|
+
const sourceBars = renderShareBars(
|
|
3827
|
+
"AI sessions by source",
|
|
3828
|
+
ai.bySource.map((s, index) => ({
|
|
3829
|
+
label: s.source,
|
|
3830
|
+
count: s.sessions,
|
|
3831
|
+
sharePct: s.sharePct,
|
|
3832
|
+
color: COLORS.series[(index + 2) % COLORS.series.length]
|
|
3833
|
+
})),
|
|
3834
|
+
"sessions"
|
|
3835
|
+
);
|
|
3351
3836
|
const pageRows = ai.topLandingPages.map((p) => `
|
|
3352
3837
|
<tr>
|
|
3353
3838
|
<td class="page-cell">${formatLandingPageHtml(p.page)}</td>
|
|
@@ -3360,18 +3845,13 @@ function renderAiReferrals(report) {
|
|
|
3360
3845
|
"AI referral sessions over time"
|
|
3361
3846
|
);
|
|
3362
3847
|
return section(
|
|
3363
|
-
{ id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "
|
|
3848
|
+
{ id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "Traffic arriving from AI answer engines." },
|
|
3364
3849
|
`<div class="metric-grid">
|
|
3365
3850
|
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
|
|
3366
3851
|
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
|
|
3367
3852
|
</div>
|
|
3368
3853
|
${trendChart}
|
|
3369
|
-
|
|
3370
|
-
<table class="report-table">
|
|
3371
|
-
<thead><tr><th>Source</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Share</th></tr></thead>
|
|
3372
|
-
<tbody>${sourceRows}</tbody>
|
|
3373
|
-
</table>
|
|
3374
|
-
</div>
|
|
3854
|
+
${sourceBars}
|
|
3375
3855
|
<div class="chart-card"><h3>Top AI landing pages</h3>
|
|
3376
3856
|
<table class="report-table">
|
|
3377
3857
|
<thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th></tr></thead>
|
|
@@ -3406,7 +3886,7 @@ function renderIndexingHealth(report) {
|
|
|
3406
3886
|
}).join("");
|
|
3407
3887
|
const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
|
|
3408
3888
|
return section(
|
|
3409
|
-
{ id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `
|
|
3889
|
+
{ id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `Pages absent from ${ih.provider === "google" ? "Google" : "Bing"} are harder for AI engines to retrieve.` },
|
|
3410
3890
|
`<div class="metric-grid">
|
|
3411
3891
|
<div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
|
|
3412
3892
|
<div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
|
|
@@ -3442,15 +3922,15 @@ function renderCitationsTrend(report) {
|
|
|
3442
3922
|
const rows = trend.map((t) => `
|
|
3443
3923
|
<tr>
|
|
3444
3924
|
<td>${formatDate(t.date)}</td>
|
|
3445
|
-
<td class="numeric">${t.citationRate}
|
|
3925
|
+
<td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
|
|
3446
3926
|
<td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
|
|
3447
3927
|
</tr>`).join("");
|
|
3448
3928
|
return section(
|
|
3449
|
-
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation
|
|
3929
|
+
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation coverage across completed visibility sweeps." },
|
|
3450
3930
|
`${chart}
|
|
3451
3931
|
<div class="chart-card"><h3>Run-by-run breakdown</h3>
|
|
3452
3932
|
<table class="report-table">
|
|
3453
|
-
<thead><tr><th>Run</th><th class="numeric">
|
|
3933
|
+
<thead><tr><th>Run</th><th class="numeric">Cited queries</th><th>Per-provider rates</th></tr></thead>
|
|
3454
3934
|
<tbody>${rows}</tbody>
|
|
3455
3935
|
</table>
|
|
3456
3936
|
</div>`
|
|
@@ -3469,36 +3949,51 @@ function renderInsights(report) {
|
|
|
3469
3949
|
const tone = severityTone(i.severity);
|
|
3470
3950
|
const countChip = count > 1 ? ` <span class="badge tone-neutral">\xD7 ${count}</span>` : "";
|
|
3471
3951
|
return `<tr>
|
|
3472
|
-
<td><span class="badge tone-${tone}">${escapeHtml(i.severity)}</span></td>
|
|
3473
|
-
<td>${escapeHtml(i.title)}${countChip}</td>
|
|
3474
|
-
<td>${escapeHtml(i.query)}</td>
|
|
3475
|
-
<td>${escapeHtml(i.provider)}</td>
|
|
3476
|
-
<td>${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
|
|
3952
|
+
<td class="col-severity"><span class="badge tone-${tone}">${escapeHtml(reportSeverityLabel(i.severity))}</span></td>
|
|
3953
|
+
<td class="col-title">${escapeHtml(i.title)}${countChip}</td>
|
|
3954
|
+
<td class="col-query">${escapeHtml(i.query)}</td>
|
|
3955
|
+
<td class="col-provider">${escapeHtml(i.provider)}</td>
|
|
3956
|
+
<td class="col-recommendation">${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
|
|
3477
3957
|
</tr>`;
|
|
3478
3958
|
}).join("");
|
|
3479
3959
|
return section(
|
|
3480
|
-
{ id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Regressions
|
|
3481
|
-
`<table class="report-table">
|
|
3482
|
-
<thead><tr
|
|
3960
|
+
{ id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Regressions, gains, and recurring alerts ordered by severity." },
|
|
3961
|
+
`<table class="report-table insights-table">
|
|
3962
|
+
<thead><tr>
|
|
3963
|
+
<th class="col-severity">Severity</th>
|
|
3964
|
+
<th class="col-title">Title</th>
|
|
3965
|
+
<th class="col-query">Query</th>
|
|
3966
|
+
<th class="col-provider">Provider</th>
|
|
3967
|
+
<th class="col-recommendation">Recommendation</th>
|
|
3968
|
+
</tr></thead>
|
|
3483
3969
|
<tbody>${rows}</tbody>
|
|
3484
3970
|
</table>`
|
|
3485
3971
|
);
|
|
3486
3972
|
}
|
|
3487
3973
|
function renderOpportunities(report) {
|
|
3488
|
-
const opps = report
|
|
3974
|
+
const opps = dedupeReportOpportunities(report);
|
|
3489
3975
|
if (opps.length === 0) return "";
|
|
3490
3976
|
const canonical = report.meta.project.canonicalDomain;
|
|
3977
|
+
const highlights = `<div class="opportunity-grid">
|
|
3978
|
+
${opps.slice(0, 3).map((o) => `<article class="opportunity-card">
|
|
3979
|
+
<div class="opportunity-score" title="Opportunity score (0\u2013100, higher = stronger)">${Math.round(o.score)}<span class="opportunity-score-suffix">/100</span></div>
|
|
3980
|
+
<h3>${escapeHtml(o.query)}</h3>
|
|
3981
|
+
<p>${escapeHtml(contentActionLabel(o.action))} \xB7 ${escapeHtml(actionConfidenceLabel(o.actionConfidence))} confidence</p>
|
|
3982
|
+
${renderProofChips(o.drivers, 2)}
|
|
3983
|
+
</article>`).join("")}
|
|
3984
|
+
</div>`;
|
|
3491
3985
|
const rows = opps.slice(0, 10).map((o) => {
|
|
3492
|
-
const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited"
|
|
3986
|
+
const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
|
|
3493
3987
|
const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
|
|
3988
|
+
const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml(d)}</li>`).join("")}</ul>` : '<span class="cell-not-cited">No driver signal yet</span>';
|
|
3494
3989
|
return `<tr>
|
|
3495
3990
|
<td>${escapeHtml(o.query)}</td>
|
|
3496
|
-
<td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
|
|
3497
|
-
<td class="numeric">${Math.round(o.score)}</td>
|
|
3991
|
+
<td><span class="badge tone-neutral">${escapeHtml(contentActionLabel(o.action))}</span></td>
|
|
3992
|
+
<td class="numeric" title="Opportunity score (0\u2013100)">${Math.round(o.score)}</td>
|
|
3993
|
+
<td>${drivers}</td>
|
|
3498
3994
|
<td>${ourPage}</td>
|
|
3499
3995
|
<td>${winning}</td>
|
|
3500
|
-
<td><span class="badge tone-neutral">${escapeHtml(o.
|
|
3501
|
-
<td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
|
|
3996
|
+
<td><span class="badge tone-neutral">${escapeHtml(actionConfidenceLabel(o.actionConfidence))}</span></td>
|
|
3502
3997
|
</tr>`;
|
|
3503
3998
|
}).join("");
|
|
3504
3999
|
return section(
|
|
@@ -3506,10 +4001,36 @@ function renderOpportunities(report) {
|
|
|
3506
4001
|
id: "content-opportunities",
|
|
3507
4002
|
eyebrow: "Section 12",
|
|
3508
4003
|
title: "Content Opportunities",
|
|
3509
|
-
intro: "Queries where
|
|
4004
|
+
intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger."
|
|
4005
|
+
},
|
|
4006
|
+
`${highlights}<table class="report-table">
|
|
4007
|
+
<thead><tr><th>Query</th><th>Action</th><th class="numeric" title="Opportunity score (0\u2013100)">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
|
|
4008
|
+
<tbody>${rows}</tbody>
|
|
4009
|
+
</table>`
|
|
4010
|
+
);
|
|
4011
|
+
}
|
|
4012
|
+
function renderContentGaps(report) {
|
|
4013
|
+
const gaps = report.contentGaps;
|
|
4014
|
+
if (gaps.length === 0) return "";
|
|
4015
|
+
const rows = gaps.slice(0, 10).map((g) => {
|
|
4016
|
+
const competitorList = g.competitorDomains.slice(0, 5).map(escapeHtml).join(", ");
|
|
4017
|
+
const more = g.competitorDomains.length > 5 ? `, +${g.competitorDomains.length - 5} more` : "";
|
|
4018
|
+
return `<tr>
|
|
4019
|
+
<td>${escapeHtml(g.query)}</td>
|
|
4020
|
+
<td class="numeric">${g.competitorCount}</td>
|
|
4021
|
+
<td>${competitorList}${more}</td>
|
|
4022
|
+
<td class="numeric">${Math.round(g.missRate * 100)}%</td>
|
|
4023
|
+
</tr>`;
|
|
4024
|
+
}).join("");
|
|
4025
|
+
return section(
|
|
4026
|
+
{
|
|
4027
|
+
id: "content-gaps",
|
|
4028
|
+
eyebrow: "Section 13",
|
|
4029
|
+
title: "Content Gaps",
|
|
4030
|
+
intro: "Tracked queries where competitors are cited and the client is missing."
|
|
3510
4031
|
},
|
|
3511
4032
|
`<table class="report-table">
|
|
3512
|
-
<thead><tr><th>Query</th><th
|
|
4033
|
+
<thead><tr><th>Query</th><th class="numeric">Competitors cited</th><th>Domains</th><th class="numeric">Miss rate</th></tr></thead>
|
|
3513
4034
|
<tbody>${rows}</tbody>
|
|
3514
4035
|
</table>`
|
|
3515
4036
|
);
|
|
@@ -3518,7 +4039,7 @@ function renderRecommendedNextSteps(report) {
|
|
|
3518
4039
|
const steps = report.recommendedNextSteps;
|
|
3519
4040
|
if (steps.length === 0) {
|
|
3520
4041
|
return section(
|
|
3521
|
-
{ id: "recommended-next-steps", eyebrow: "Section
|
|
4042
|
+
{ id: "recommended-next-steps", eyebrow: "Section 14", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
|
|
3522
4043
|
renderEmpty("No outstanding actions.")
|
|
3523
4044
|
);
|
|
3524
4045
|
}
|
|
@@ -3529,17 +4050,157 @@ function renderRecommendedNextSteps(report) {
|
|
|
3529
4050
|
<span class="rationale">${escapeHtml(s.rationale)}</span>
|
|
3530
4051
|
</div>`).join("");
|
|
3531
4052
|
return section(
|
|
3532
|
-
{ id: "recommended-next-steps", eyebrow: "Section
|
|
4053
|
+
{ id: "recommended-next-steps", eyebrow: "Section 14", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
|
|
3533
4054
|
`<div class="steps">${items}</div>`
|
|
3534
4055
|
);
|
|
3535
4056
|
}
|
|
4057
|
+
function actionAudienceMatches(action, audience) {
|
|
4058
|
+
return action.audience === "both" || action.audience === audience;
|
|
4059
|
+
}
|
|
4060
|
+
function renderActionCards(actions) {
|
|
4061
|
+
if (actions.length === 0) return renderEmpty("No prioritized actions yet.");
|
|
4062
|
+
return `<div class="action-card-grid">
|
|
4063
|
+
${actions.map((action, idx) => {
|
|
4064
|
+
const tone = reportActionTone(action);
|
|
4065
|
+
const why = action.why.length > 0 ? `<ul>${action.why.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
|
|
4066
|
+
const evidence = action.evidence.length > 0 ? `<ul>${action.evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
|
|
4067
|
+
const proof = renderProofChips(action.evidence.length > 0 ? action.evidence : action.why, 3);
|
|
4068
|
+
const details = why || evidence ? `<details class="action-details">
|
|
4069
|
+
<summary>Evidence details</summary>
|
|
4070
|
+
${why ? `<div><strong>Why</strong>${why}</div>` : ""}
|
|
4071
|
+
${evidence ? `<div><strong>Evidence</strong>${evidence}</div>` : ""}
|
|
4072
|
+
</details>` : "";
|
|
4073
|
+
return `<article class="action-card">
|
|
4074
|
+
<div class="action-head">
|
|
4075
|
+
<div class="action-rank" title="Impact rank \u2014 1 is the highest-leverage action">${idx + 1}</div>
|
|
4076
|
+
<div>
|
|
4077
|
+
<div class="action-meta">
|
|
4078
|
+
<span class="badge tone-${tone}">${escapeHtml(reportHorizonLabel(action.horizon))}</span>
|
|
4079
|
+
<span class="badge tone-neutral">${escapeHtml(reportActionCategoryLabel(action.category))}</span>
|
|
4080
|
+
<span class="badge tone-neutral">${escapeHtml(reportConfidenceLabel(action.confidence))} confidence</span>
|
|
4081
|
+
</div>
|
|
4082
|
+
<h3>${escapeHtml(action.title)}</h3>
|
|
4083
|
+
</div>
|
|
4084
|
+
</div>
|
|
4085
|
+
<p>${escapeHtml(action.action)}</p>
|
|
4086
|
+
${proof}
|
|
4087
|
+
${details}
|
|
4088
|
+
<div class="success-metric"><strong>Win condition:</strong> ${escapeHtml(action.successMetric)}</div>
|
|
4089
|
+
</article>`;
|
|
4090
|
+
}).join("")}
|
|
4091
|
+
</div>`;
|
|
4092
|
+
}
|
|
4093
|
+
function renderAudienceActionPlan(report, audience) {
|
|
4094
|
+
const rawActions = audience === "client" ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter((a) => actionAudienceMatches(a, audience));
|
|
4095
|
+
const actions = dedupeReportActions(report, rawActions);
|
|
4096
|
+
return section(
|
|
4097
|
+
{
|
|
4098
|
+
id: audience === "client" ? "client-action-plan" : "agency-action-plan",
|
|
4099
|
+
eyebrow: audience === "client" ? "Client actions" : "Agency actions",
|
|
4100
|
+
title: audience === "client" ? "What We Recommend Next" : "Agency Action Plan",
|
|
4101
|
+
intro: audience === "client" ? "The short list to approve and execute." : "The highest-leverage work, sorted by urgency and evidence strength."
|
|
4102
|
+
},
|
|
4103
|
+
renderActionCards(actions)
|
|
4104
|
+
);
|
|
4105
|
+
}
|
|
4106
|
+
function renderClientSummary(report) {
|
|
4107
|
+
const s = report.executiveSummary;
|
|
4108
|
+
const metrics = `<div class="metric-grid">
|
|
4109
|
+
<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>
|
|
4110
|
+
<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>
|
|
4111
|
+
<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>
|
|
4112
|
+
</div>`;
|
|
4113
|
+
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>` : "";
|
|
4114
|
+
return section(
|
|
4115
|
+
{
|
|
4116
|
+
id: "client-summary",
|
|
4117
|
+
eyebrow: "Client summary",
|
|
4118
|
+
title: "How You're Appearing",
|
|
4119
|
+
intro: report.clientSummary.overview
|
|
4120
|
+
},
|
|
4121
|
+
`<div class="chart-card">
|
|
4122
|
+
<h3>${escapeHtml(report.clientSummary.headline)}</h3>
|
|
4123
|
+
<p class="source-origin-headline">${escapeHtml(report.clientSummary.overview)}</p>
|
|
4124
|
+
</div>
|
|
4125
|
+
${metrics}
|
|
4126
|
+
${notes}`
|
|
4127
|
+
);
|
|
4128
|
+
}
|
|
4129
|
+
function renderClientEvidenceSummary(report) {
|
|
4130
|
+
const evidenceCards = [];
|
|
4131
|
+
if (report.aiSourceOrigin.topDomains.length > 0) {
|
|
4132
|
+
evidenceCards.push(`<div class="diagnostic-card tone-neutral">
|
|
4133
|
+
<h3>Sources AI engines trust</h3>
|
|
4134
|
+
<p>These domains appeared most often as cited sources outside your owned domain.</p>
|
|
4135
|
+
<ul>${report.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `<li>${escapeHtml(d.domain)}: ${formatNumber(d.count)} citation${d.count === 1 ? "" : "s"}</li>`).join("")}</ul>
|
|
4136
|
+
</div>`);
|
|
4137
|
+
}
|
|
4138
|
+
if (report.gsc) {
|
|
4139
|
+
evidenceCards.push(`<div class="diagnostic-card tone-neutral">
|
|
4140
|
+
<h3>Search demand</h3>
|
|
4141
|
+
<p>Search Console shows ${formatNumber(report.gsc.totalImpressions)} impressions and ${formatNumber(report.gsc.totalClicks)} clicks in the report window.</p>
|
|
4142
|
+
<ul>${report.gsc.topQueries.slice(0, 5).map((q) => `<li>${escapeHtml(q.query)}: ${formatNumber(q.impressions)} impressions</li>`).join("")}</ul>
|
|
4143
|
+
</div>`);
|
|
4144
|
+
}
|
|
4145
|
+
if (report.indexingHealth) {
|
|
4146
|
+
const tone = report.indexingHealth.indexedPct >= 90 ? "positive" : report.indexingHealth.indexedPct >= 70 ? "caution" : "negative";
|
|
4147
|
+
evidenceCards.push(`<div class="diagnostic-card tone-${tone}">
|
|
4148
|
+
<h3>Indexing readiness</h3>
|
|
4149
|
+
<p>${report.indexingHealth.indexedPct}% of inspected URLs are indexed.</p>
|
|
4150
|
+
<ul><li>${formatNumber(report.indexingHealth.indexed)} indexed</li><li>${formatNumber(report.indexingHealth.notIndexed)} not indexed</li></ul>
|
|
4151
|
+
</div>`);
|
|
4152
|
+
}
|
|
4153
|
+
const opportunities = dedupeReportOpportunities(report);
|
|
4154
|
+
if (opportunities.length > 0) {
|
|
4155
|
+
evidenceCards.push(`<div class="diagnostic-card tone-caution">
|
|
4156
|
+
<h3>Content opportunities</h3>
|
|
4157
|
+
<p>Canonry found topics where better content could improve AI citations.</p>
|
|
4158
|
+
<ul>${opportunities.slice(0, 5).map((o) => `<li>${escapeHtml(o.query)}: ${escapeHtml(o.action)} (${Math.round(o.score)})</li>`).join("")}</ul>
|
|
4159
|
+
</div>`);
|
|
4160
|
+
}
|
|
4161
|
+
return section(
|
|
4162
|
+
{
|
|
4163
|
+
id: "client-evidence-summary",
|
|
4164
|
+
eyebrow: "Evidence",
|
|
4165
|
+
title: "Why This Is The Plan",
|
|
4166
|
+
intro: "A concise evidence view for the client summary. The agency report keeps the full matrices and detailed tables."
|
|
4167
|
+
},
|
|
4168
|
+
evidenceCards.length > 0 ? `<div class="diagnostics-grid">${evidenceCards.join("")}</div>` : renderEmpty("No supporting evidence sections are populated yet.")
|
|
4169
|
+
);
|
|
4170
|
+
}
|
|
4171
|
+
function renderAgencyDiagnostics(report) {
|
|
4172
|
+
const diagnostics = report.agencyDiagnostics.diagnostics.filter((d) => d.title !== "Location caveat");
|
|
4173
|
+
const body = diagnostics.length > 0 ? `<div class="diagnostics-grid">
|
|
4174
|
+
${diagnostics.map((d) => `<div class="diagnostic-card tone-${d.severity}">
|
|
4175
|
+
<h3>${escapeHtml(d.title)}</h3>
|
|
4176
|
+
<p>${escapeHtml(d.detail)}</p>
|
|
4177
|
+
${renderProofChips(d.evidence, 3)}
|
|
4178
|
+
</div>`).join("")}
|
|
4179
|
+
</div>` : renderEmpty("No agency diagnostics available yet.");
|
|
4180
|
+
return section(
|
|
4181
|
+
{
|
|
4182
|
+
id: "agency-diagnostics",
|
|
4183
|
+
eyebrow: "Agency diagnostics",
|
|
4184
|
+
title: "Technical Diagnostics",
|
|
4185
|
+
intro: "Fast-read operator flags behind the action plan."
|
|
4186
|
+
},
|
|
4187
|
+
body
|
|
4188
|
+
);
|
|
4189
|
+
}
|
|
3536
4190
|
function escapeJsonForScript(json) {
|
|
3537
4191
|
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
3538
4192
|
}
|
|
3539
4193
|
function renderReportHtml(report, opts = {}) {
|
|
3540
|
-
const
|
|
3541
|
-
const
|
|
4194
|
+
const audience = opts.audience ?? "agency";
|
|
4195
|
+
const title = opts.title ?? `Canonry ${audience} report \u2014 ${report.meta.project.displayName}`;
|
|
4196
|
+
const sections = audience === "client" ? [
|
|
4197
|
+
renderClientSummary(report),
|
|
4198
|
+
renderAudienceActionPlan(report, "client"),
|
|
4199
|
+
renderClientEvidenceSummary(report)
|
|
4200
|
+
].join("\n") : [
|
|
3542
4201
|
renderExecutiveSummary(report),
|
|
4202
|
+
renderAudienceActionPlan(report, "agency"),
|
|
4203
|
+
renderAgencyDiagnostics(report),
|
|
3543
4204
|
renderCitationScorecard(report),
|
|
3544
4205
|
renderCompetitorLandscape(report),
|
|
3545
4206
|
renderAiSourceOrigin(report),
|
|
@@ -3551,6 +4212,7 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3551
4212
|
renderCitationsTrend(report),
|
|
3552
4213
|
renderInsights(report),
|
|
3553
4214
|
renderOpportunities(report),
|
|
4215
|
+
renderContentGaps(report),
|
|
3554
4216
|
renderRecommendedNextSteps(report)
|
|
3555
4217
|
].join("\n");
|
|
3556
4218
|
const json = escapeJsonForScript(JSON.stringify(report));
|
|
@@ -3565,9 +4227,9 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3565
4227
|
<body>
|
|
3566
4228
|
<div class="container">
|
|
3567
4229
|
<header class="header">
|
|
3568
|
-
<div class="eyebrow"
|
|
4230
|
+
<div class="eyebrow">${audience === "client" ? "AEO Client Summary" : "AEO Agency Report"}</div>
|
|
3569
4231
|
<h1>${escapeHtml(report.meta.project.displayName)}</h1>
|
|
3570
|
-
<div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
|
|
4232
|
+
<div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())}${renderHeaderLocationFragment(report.meta.location)} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
|
|
3571
4233
|
</header>
|
|
3572
4234
|
${sections}
|
|
3573
4235
|
<footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
|
|
@@ -3580,7 +4242,7 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3580
4242
|
// ../api-routes/src/content-data.ts
|
|
3581
4243
|
import { and as and3, eq as eq12, desc as desc5, inArray as inArray3 } from "drizzle-orm";
|
|
3582
4244
|
var RECENT_RUNS_WINDOW = 5;
|
|
3583
|
-
function loadOrchestratorInput(db, project) {
|
|
4245
|
+
function loadOrchestratorInput(db, project, locationFilter = void 0) {
|
|
3584
4246
|
const projectId = project.id;
|
|
3585
4247
|
const ownDomain = normalizeDomain(project.canonicalDomain);
|
|
3586
4248
|
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
@@ -3589,7 +4251,7 @@ function loadOrchestratorInput(db, project) {
|
|
|
3589
4251
|
const candidateQueryStrings = trackedQueries.filter(isBlogShapedQuery);
|
|
3590
4252
|
const trackedCompetitors = listCompetitorDomains(db, projectId).map(normalizeDomain);
|
|
3591
4253
|
const competitorSet = new Set(trackedCompetitors);
|
|
3592
|
-
const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW);
|
|
4254
|
+
const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW, locationFilter);
|
|
3593
4255
|
const latestRunId = recentRunIds[0] ?? "";
|
|
3594
4256
|
const latestRunTimestamp = latestRunId ? lookupRunTimestamp(db, latestRunId) : "";
|
|
3595
4257
|
const candidateQueries = buildCandidateQueries({
|
|
@@ -3614,6 +4276,7 @@ function loadOrchestratorInput(db, project) {
|
|
|
3614
4276
|
ownDomain,
|
|
3615
4277
|
competitors: trackedCompetitors,
|
|
3616
4278
|
candidateQueries,
|
|
4279
|
+
queryIntentModifiers: buildQueryIntentModifiers(project, locationFilter),
|
|
3617
4280
|
inventory,
|
|
3618
4281
|
wpSchemaAudit: /* @__PURE__ */ new Map(),
|
|
3619
4282
|
gaTrafficByPage,
|
|
@@ -3623,6 +4286,74 @@ function loadOrchestratorInput(db, project) {
|
|
|
3623
4286
|
inProgressActions: /* @__PURE__ */ new Map()
|
|
3624
4287
|
};
|
|
3625
4288
|
}
|
|
4289
|
+
function buildQueryIntentModifiers(project, locationFilter) {
|
|
4290
|
+
if (locationFilter === void 0 || locationFilter === null) return [];
|
|
4291
|
+
const locations = parseJsonColumn(project.locations, []);
|
|
4292
|
+
const currentLocation = locations.find((location) => location.label === locationFilter);
|
|
4293
|
+
const raw = currentLocation ? [
|
|
4294
|
+
currentLocation.label,
|
|
4295
|
+
currentLocation.city,
|
|
4296
|
+
currentLocation.region,
|
|
4297
|
+
regionAbbreviation(currentLocation.region),
|
|
4298
|
+
currentLocation.country
|
|
4299
|
+
] : [locationFilter];
|
|
4300
|
+
return [...new Set(raw.map((value) => value.trim().toLowerCase()).filter(Boolean))];
|
|
4301
|
+
}
|
|
4302
|
+
function regionAbbreviation(region) {
|
|
4303
|
+
return US_REGION_ABBREVIATIONS[region.trim().toLowerCase()] ?? "";
|
|
4304
|
+
}
|
|
4305
|
+
var US_REGION_ABBREVIATIONS = {
|
|
4306
|
+
alabama: "al",
|
|
4307
|
+
alaska: "ak",
|
|
4308
|
+
arizona: "az",
|
|
4309
|
+
arkansas: "ar",
|
|
4310
|
+
california: "ca",
|
|
4311
|
+
colorado: "co",
|
|
4312
|
+
connecticut: "ct",
|
|
4313
|
+
delaware: "de",
|
|
4314
|
+
florida: "fl",
|
|
4315
|
+
georgia: "ga",
|
|
4316
|
+
hawaii: "hi",
|
|
4317
|
+
idaho: "id",
|
|
4318
|
+
illinois: "il",
|
|
4319
|
+
indiana: "in",
|
|
4320
|
+
iowa: "ia",
|
|
4321
|
+
kansas: "ks",
|
|
4322
|
+
kentucky: "ky",
|
|
4323
|
+
louisiana: "la",
|
|
4324
|
+
maine: "me",
|
|
4325
|
+
maryland: "md",
|
|
4326
|
+
massachusetts: "ma",
|
|
4327
|
+
michigan: "mi",
|
|
4328
|
+
minnesota: "mn",
|
|
4329
|
+
mississippi: "ms",
|
|
4330
|
+
missouri: "mo",
|
|
4331
|
+
montana: "mt",
|
|
4332
|
+
nebraska: "ne",
|
|
4333
|
+
nevada: "nv",
|
|
4334
|
+
"new hampshire": "nh",
|
|
4335
|
+
"new jersey": "nj",
|
|
4336
|
+
"new mexico": "nm",
|
|
4337
|
+
"new york": "ny",
|
|
4338
|
+
"north carolina": "nc",
|
|
4339
|
+
"north dakota": "nd",
|
|
4340
|
+
ohio: "oh",
|
|
4341
|
+
oklahoma: "ok",
|
|
4342
|
+
oregon: "or",
|
|
4343
|
+
pennsylvania: "pa",
|
|
4344
|
+
"rhode island": "ri",
|
|
4345
|
+
"south carolina": "sc",
|
|
4346
|
+
"south dakota": "sd",
|
|
4347
|
+
tennessee: "tn",
|
|
4348
|
+
texas: "tx",
|
|
4349
|
+
utah: "ut",
|
|
4350
|
+
vermont: "vt",
|
|
4351
|
+
virginia: "va",
|
|
4352
|
+
washington: "wa",
|
|
4353
|
+
"west virginia": "wv",
|
|
4354
|
+
wisconsin: "wi",
|
|
4355
|
+
wyoming: "wy"
|
|
4356
|
+
};
|
|
3626
4357
|
function listQueries(db, projectId) {
|
|
3627
4358
|
const rows = db.select({ text: queries.query }).from(queries).where(eq12(queries.projectId, projectId)).all();
|
|
3628
4359
|
return rows.map((r) => r.text);
|
|
@@ -3631,8 +4362,8 @@ function listCompetitorDomains(db, projectId) {
|
|
|
3631
4362
|
const rows = db.select({ domain: competitors.domain }).from(competitors).where(eq12(competitors.projectId, projectId)).all();
|
|
3632
4363
|
return rows.map((r) => r.domain);
|
|
3633
4364
|
}
|
|
3634
|
-
function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
|
|
3635
|
-
const rows = db.select({ id: runs.id }).from(runs).where(
|
|
4365
|
+
function listRecentAnswerVisibilityRunIds(db, projectId, limit, locationFilter) {
|
|
4366
|
+
const rows = db.select({ id: runs.id, location: runs.location }).from(runs).where(
|
|
3636
4367
|
and3(
|
|
3637
4368
|
eq12(runs.projectId, projectId),
|
|
3638
4369
|
eq12(runs.kind, RunKinds["answer-visibility"]),
|
|
@@ -3641,8 +4372,9 @@ function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
|
|
|
3641
4372
|
// no usable evidence.
|
|
3642
4373
|
inArray3(runs.status, [RunStatuses.completed, RunStatuses.partial])
|
|
3643
4374
|
)
|
|
3644
|
-
).orderBy(desc5(runs.createdAt)).
|
|
3645
|
-
|
|
4375
|
+
).orderBy(desc5(runs.createdAt)).all();
|
|
4376
|
+
const filtered = locationFilter === void 0 ? rows : rows.filter((r) => (r.location ?? null) === locationFilter);
|
|
4377
|
+
return filtered.slice(0, limit).map((r) => r.id);
|
|
3646
4378
|
}
|
|
3647
4379
|
function lookupRunTimestamp(db, runId) {
|
|
3648
4380
|
const row = db.select({ createdAt: runs.createdAt }).from(runs).where(eq12(runs.id, runId)).get();
|
|
@@ -3860,6 +4592,14 @@ var TOP_LANDING_PAGES_LIMIT = 20;
|
|
|
3860
4592
|
var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
|
|
3861
4593
|
var TOP_CAMPAIGN_LIMIT = 10;
|
|
3862
4594
|
var INSIGHT_LOOKBACK_RUNS = 5;
|
|
4595
|
+
var REPORT_WINDOW_DAYS = 30;
|
|
4596
|
+
function windowStartDate(endDate, windowDays) {
|
|
4597
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(endDate);
|
|
4598
|
+
if (!m) return endDate;
|
|
4599
|
+
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
4600
|
+
d.setUTCDate(d.getUTCDate() - (windowDays - 1));
|
|
4601
|
+
return d.toISOString().slice(0, 10);
|
|
4602
|
+
}
|
|
3863
4603
|
function safeNum(value) {
|
|
3864
4604
|
if (typeof value === "number") return value;
|
|
3865
4605
|
if (typeof value === "string") {
|
|
@@ -3895,7 +4635,12 @@ function loadQueryLookup(db, projectId) {
|
|
|
3895
4635
|
return { byId };
|
|
3896
4636
|
}
|
|
3897
4637
|
function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, trackedQueries) {
|
|
3898
|
-
const
|
|
4638
|
+
const allRows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
|
|
4639
|
+
if (allRows.length === 0) return null;
|
|
4640
|
+
let maxDate = "";
|
|
4641
|
+
for (const r of allRows) if (r.date > maxDate) maxDate = r.date;
|
|
4642
|
+
const startDate = windowStartDate(maxDate, REPORT_WINDOW_DAYS);
|
|
4643
|
+
const rows = allRows.filter((r) => r.date >= startDate && r.date <= maxDate);
|
|
3899
4644
|
if (rows.length === 0) return null;
|
|
3900
4645
|
let totalClicks = 0;
|
|
3901
4646
|
let totalImpressions = 0;
|
|
@@ -3941,11 +4686,15 @@ function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, tra
|
|
|
3941
4686
|
sharePct: totalClicks > 0 ? Math.round(agg.clicks / totalClicks * 100) : 0
|
|
3942
4687
|
})).sort((a, b) => b.clicks - a.clicks);
|
|
3943
4688
|
const trend = [...trendAgg.entries()].map(([date, agg]) => ({ date, clicks: agg.clicks, impressions: agg.impressions })).sort((a, b) => a.date.localeCompare(b.date));
|
|
4689
|
+
const periodStart = trend[0]?.date ?? "";
|
|
4690
|
+
const periodEnd = trend.at(-1)?.date ?? "";
|
|
3944
4691
|
const trackedSet = new Set(trackedQueries.map((q) => q.toLowerCase()));
|
|
3945
4692
|
const gscQuerySet = new Set([...queryAgg.keys()].map((q) => q.toLowerCase()));
|
|
3946
4693
|
const trackedButNoGsc = trackedQueries.filter((q) => !gscQuerySet.has(q.toLowerCase())).sort();
|
|
3947
4694
|
const gscButNotTracked = [...queryAgg.entries()].filter(([q]) => !trackedSet.has(q.toLowerCase())).filter(([q]) => categorizeQuery(q, projectDisplayName, canonicalDomain) !== "brand").sort((a, b) => b[1].impressions - a[1].impressions).map(([q]) => q).slice(0, TOP_QUERIES_LIMIT);
|
|
3948
4695
|
return {
|
|
4696
|
+
periodStart,
|
|
4697
|
+
periodEnd,
|
|
3949
4698
|
totalClicks,
|
|
3950
4699
|
totalImpressions,
|
|
3951
4700
|
ctr,
|
|
@@ -3958,14 +4707,24 @@ function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, tra
|
|
|
3958
4707
|
};
|
|
3959
4708
|
}
|
|
3960
4709
|
function buildGaSection(db, projectId) {
|
|
3961
|
-
const
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
4710
|
+
const windowSummary = db.select().from(gaTrafficWindowSummaries).where(
|
|
4711
|
+
and4(
|
|
4712
|
+
eq13(gaTrafficWindowSummaries.projectId, projectId),
|
|
4713
|
+
eq13(gaTrafficWindowSummaries.windowKey, "30d")
|
|
4714
|
+
)
|
|
4715
|
+
).limit(1).get();
|
|
4716
|
+
const fallbackSummary = windowSummary ? null : db.select().from(gaTrafficSummaries).where(eq13(gaTrafficSummaries.projectId, projectId)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
|
|
4717
|
+
const allSnapshotRows = db.select().from(gaTrafficSnapshots).where(eq13(gaTrafficSnapshots.projectId, projectId)).all();
|
|
4718
|
+
if (!windowSummary && !fallbackSummary && allSnapshotRows.length === 0) return null;
|
|
4719
|
+
let snapshotMaxDate = "";
|
|
4720
|
+
for (const r of allSnapshotRows) if (r.date > snapshotMaxDate) snapshotMaxDate = r.date;
|
|
4721
|
+
const snapshotStartDate = snapshotMaxDate ? windowStartDate(snapshotMaxDate, REPORT_WINDOW_DAYS) : "";
|
|
4722
|
+
const snapshotRows = snapshotStartDate ? allSnapshotRows.filter((r) => r.date >= snapshotStartDate && r.date <= snapshotMaxDate) : allSnapshotRows;
|
|
4723
|
+
const totalSessions = windowSummary?.totalSessions ?? fallbackSummary?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
|
|
4724
|
+
const totalUsers = windowSummary?.totalUsers ?? fallbackSummary?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
|
|
4725
|
+
const totalOrganicSessions = windowSummary?.totalOrganicSessions ?? fallbackSummary?.totalOrganicSessions ?? snapshotRows.reduce((s, r) => s + r.organicSessions, 0);
|
|
3967
4726
|
const pageAgg = /* @__PURE__ */ new Map();
|
|
3968
|
-
let directSessions = 0;
|
|
4727
|
+
let directSessions = windowSummary?.totalDirectSessions ?? 0;
|
|
3969
4728
|
for (const r of snapshotRows) {
|
|
3970
4729
|
const page = r.landingPageNormalized ?? r.landingPage;
|
|
3971
4730
|
const existing = pageAgg.get(page) ?? { sessions: 0, users: 0, organic: 0 };
|
|
@@ -3973,7 +4732,7 @@ function buildGaSection(db, projectId) {
|
|
|
3973
4732
|
existing.users += r.users;
|
|
3974
4733
|
existing.organic += r.organicSessions;
|
|
3975
4734
|
pageAgg.set(page, existing);
|
|
3976
|
-
if (r.directSessions != null) directSessions += r.directSessions;
|
|
4735
|
+
if (!windowSummary && r.directSessions != null) directSessions += r.directSessions;
|
|
3977
4736
|
}
|
|
3978
4737
|
const topLandingPages = [...pageAgg.entries()].map(([page, data]) => ({
|
|
3979
4738
|
page,
|
|
@@ -4001,12 +4760,14 @@ function buildGaSection(db, projectId) {
|
|
|
4001
4760
|
}
|
|
4002
4761
|
}
|
|
4003
4762
|
}
|
|
4763
|
+
const periodStart = windowSummary?.periodStart ?? (snapshotStartDate || fallbackSummary?.periodStart || "");
|
|
4764
|
+
const periodEnd = windowSummary?.periodEnd ?? (snapshotMaxDate || fallbackSummary?.periodEnd || "");
|
|
4004
4765
|
return {
|
|
4005
4766
|
totalSessions,
|
|
4006
4767
|
totalUsers,
|
|
4007
4768
|
totalOrganicSessions,
|
|
4008
|
-
periodStart
|
|
4009
|
-
periodEnd
|
|
4769
|
+
periodStart,
|
|
4770
|
+
periodEnd,
|
|
4010
4771
|
topLandingPages,
|
|
4011
4772
|
channelBreakdown
|
|
4012
4773
|
};
|
|
@@ -4129,27 +4890,33 @@ function buildIndexingHealth(db, projectId) {
|
|
|
4129
4890
|
}
|
|
4130
4891
|
return null;
|
|
4131
4892
|
}
|
|
4132
|
-
function buildCitationsTrend(db, projectId, queryLookup) {
|
|
4133
|
-
const visibilityRuns = db.select().from(runs).where(and4(eq13(runs.projectId, projectId), eq13(runs.kind, RunKinds["answer-visibility"]))).all();
|
|
4893
|
+
function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
|
|
4894
|
+
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);
|
|
4895
|
+
const totalQueries = queryLookup.byId.size;
|
|
4134
4896
|
const points = [];
|
|
4135
4897
|
for (const run of visibilityRuns) {
|
|
4136
4898
|
if (run.status !== RunStatuses.completed) continue;
|
|
4137
4899
|
const snaps = loadSnapshotsForRun(db, run.id);
|
|
4138
4900
|
if (snaps.length === 0) continue;
|
|
4139
|
-
|
|
4901
|
+
const citedQueryIds = /* @__PURE__ */ new Set();
|
|
4902
|
+
const mentionedQueryIds = /* @__PURE__ */ new Set();
|
|
4140
4903
|
let considered = 0;
|
|
4141
4904
|
const providerCounts = /* @__PURE__ */ new Map();
|
|
4142
4905
|
for (const snap of snaps) {
|
|
4143
4906
|
if (!queryLookup.byId.has(snap.queryId)) continue;
|
|
4144
4907
|
considered++;
|
|
4145
|
-
if (snap.citationState === CitationStates.cited)
|
|
4908
|
+
if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
|
|
4909
|
+
if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
|
|
4146
4910
|
const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
|
|
4147
4911
|
counts.total++;
|
|
4148
4912
|
if (snap.citationState === CitationStates.cited) counts.cited++;
|
|
4149
4913
|
providerCounts.set(snap.provider, counts);
|
|
4150
4914
|
}
|
|
4151
4915
|
if (considered === 0) continue;
|
|
4152
|
-
const
|
|
4916
|
+
const citedQueryCount = citedQueryIds.size;
|
|
4917
|
+
const mentionedQueryCount = mentionedQueryIds.size;
|
|
4918
|
+
const citationRate = totalQueries > 0 ? Math.round(citedQueryCount / totalQueries * 100) : 0;
|
|
4919
|
+
const mentionRate = totalQueries > 0 ? Math.round(mentionedQueryCount / totalQueries * 100) : 0;
|
|
4153
4920
|
const providerRates = [...providerCounts.entries()].map(([provider, counts]) => ({
|
|
4154
4921
|
provider,
|
|
4155
4922
|
citationRate: counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0
|
|
@@ -4158,20 +4925,24 @@ function buildCitationsTrend(db, projectId, queryLookup) {
|
|
|
4158
4925
|
runId: run.id,
|
|
4159
4926
|
date: run.finishedAt ?? run.createdAt,
|
|
4160
4927
|
citationRate,
|
|
4928
|
+
citedQueryCount,
|
|
4929
|
+
totalQueryCount: totalQueries,
|
|
4930
|
+
mentionRate,
|
|
4931
|
+
mentionedQueryCount,
|
|
4161
4932
|
providerRates
|
|
4162
4933
|
});
|
|
4163
4934
|
}
|
|
4164
4935
|
points.sort((a, b) => a.date.localeCompare(b.date));
|
|
4165
4936
|
return points;
|
|
4166
4937
|
}
|
|
4167
|
-
function buildInsightList(db, projectId) {
|
|
4168
|
-
const recentRunIds = db.select({ id: runs.id }).from(runs).where(
|
|
4938
|
+
function buildInsightList(db, projectId, locationFilter) {
|
|
4939
|
+
const recentRunIds = db.select({ id: runs.id, location: runs.location }).from(runs).where(
|
|
4169
4940
|
and4(
|
|
4170
4941
|
eq13(runs.projectId, projectId),
|
|
4171
4942
|
eq13(runs.kind, RunKinds["answer-visibility"]),
|
|
4172
4943
|
or2(eq13(runs.status, RunStatuses.completed), eq13(runs.status, RunStatuses.partial))
|
|
4173
4944
|
)
|
|
4174
|
-
).orderBy(desc6(runs.createdAt)).
|
|
4945
|
+
).orderBy(desc6(runs.createdAt)).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter).slice(0, INSIGHT_LOOKBACK_RUNS).map((r) => r.id);
|
|
4175
4946
|
if (recentRunIds.length === 0) return [];
|
|
4176
4947
|
const rows = db.select().from(insights).where(and4(eq13(insights.projectId, projectId), inArray4(insights.runId, recentRunIds))).orderBy(desc6(insights.createdAt)).all();
|
|
4177
4948
|
const severityRank = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
@@ -4243,7 +5014,7 @@ function buildRecommendedNextSteps(insightList) {
|
|
|
4243
5014
|
}
|
|
4244
5015
|
return steps;
|
|
4245
5016
|
}
|
|
4246
|
-
function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
|
|
5017
|
+
function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
|
|
4247
5018
|
const findings = [];
|
|
4248
5019
|
if (trendsPoints.length > 0) {
|
|
4249
5020
|
const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
|
|
@@ -4266,8 +5037,10 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
|
|
|
4266
5037
|
break;
|
|
4267
5038
|
}
|
|
4268
5039
|
}
|
|
5040
|
+
const queryNoun = totalQueryCount === 1 ? "query" : "queries";
|
|
5041
|
+
const ratioFragment = totalQueryCount > 0 ? ` (${citedQueryCount} of ${totalQueryCount} ${queryNoun} cited)` : "";
|
|
4269
5042
|
findings.push({
|
|
4270
|
-
title: `Citation rate at ${citationRate}
|
|
5043
|
+
title: `Citation rate at ${citationRate}%${ratioFragment}`,
|
|
4271
5044
|
detail,
|
|
4272
5045
|
tone
|
|
4273
5046
|
});
|
|
@@ -4290,6 +5063,317 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
|
|
|
4290
5063
|
}
|
|
4291
5064
|
return findings.slice(0, 5);
|
|
4292
5065
|
}
|
|
5066
|
+
function buildLocationMeta(runLocationLabel, configuredLocations) {
|
|
5067
|
+
if (!runLocationLabel) return null;
|
|
5068
|
+
const match = configuredLocations.find((loc) => loc.label === runLocationLabel);
|
|
5069
|
+
const others = configuredLocations.map((loc) => loc.label).filter((label) => label !== runLocationLabel);
|
|
5070
|
+
return {
|
|
5071
|
+
label: runLocationLabel,
|
|
5072
|
+
city: match?.city ?? "",
|
|
5073
|
+
region: match?.region ?? "",
|
|
5074
|
+
country: match?.country ?? "",
|
|
5075
|
+
otherConfiguredLabels: others
|
|
5076
|
+
};
|
|
5077
|
+
}
|
|
5078
|
+
function buildProviderLocationHandling(providersInRun) {
|
|
5079
|
+
return [...providersInRun].sort().map((provider) => {
|
|
5080
|
+
const handling = getProviderLocationHandling(provider);
|
|
5081
|
+
return {
|
|
5082
|
+
provider,
|
|
5083
|
+
treatment: handling.treatment,
|
|
5084
|
+
description: handling.description
|
|
5085
|
+
};
|
|
5086
|
+
});
|
|
5087
|
+
}
|
|
5088
|
+
function compactList(items, limit = 3) {
|
|
5089
|
+
const visible = items.slice(0, limit);
|
|
5090
|
+
const extra = items.length - visible.length;
|
|
5091
|
+
return extra > 0 ? `${visible.join(", ")}, +${extra} more` : visible.join(", ");
|
|
5092
|
+
}
|
|
5093
|
+
function contentActionVerb(action) {
|
|
5094
|
+
switch (action) {
|
|
5095
|
+
case "create":
|
|
5096
|
+
return "Create";
|
|
5097
|
+
case "expand":
|
|
5098
|
+
return "Expand";
|
|
5099
|
+
case "refresh":
|
|
5100
|
+
return "Refresh";
|
|
5101
|
+
case "add-schema":
|
|
5102
|
+
return "Add schema to";
|
|
5103
|
+
}
|
|
5104
|
+
}
|
|
5105
|
+
function confidenceFromEvidence(count) {
|
|
5106
|
+
if (count >= 3) return "high";
|
|
5107
|
+
if (count >= 1) return "medium";
|
|
5108
|
+
return "low";
|
|
5109
|
+
}
|
|
5110
|
+
function actionAudienceMatches2(action, audience) {
|
|
5111
|
+
return action.audience === "both" || action.audience === audience;
|
|
5112
|
+
}
|
|
5113
|
+
function buildReportActionPlan(input) {
|
|
5114
|
+
const actions = [];
|
|
5115
|
+
if (input.competitorDomains.length === 0 && input.aiSourceOrigin.topDomains.length > 0) {
|
|
5116
|
+
const topDomains = input.aiSourceOrigin.topDomains.slice(0, 5);
|
|
5117
|
+
actions.push({
|
|
5118
|
+
audience: "both",
|
|
5119
|
+
priority: 10,
|
|
5120
|
+
horizon: "immediate",
|
|
5121
|
+
category: "competitors",
|
|
5122
|
+
title: "Define the competitor set Canonry should benchmark against",
|
|
5123
|
+
action: "Review the recurring external source domains and add the true competitors before the next sweep.",
|
|
5124
|
+
why: [
|
|
5125
|
+
"The report can identify repeated external sources, but it cannot separate competitors from publishers until competitors are configured.",
|
|
5126
|
+
"A clean competitor set makes future share-of-voice and content-gap reporting easier to explain to clients."
|
|
5127
|
+
],
|
|
5128
|
+
evidence: topDomains.map((d) => `${d.domain} appeared in ${d.count} cited source${d.count === 1 ? "" : "s"}`),
|
|
5129
|
+
successMetric: "Next report separates tracked competitors from independent source domains in the competitor landscape.",
|
|
5130
|
+
confidence: confidenceFromEvidence(topDomains.length)
|
|
5131
|
+
});
|
|
5132
|
+
}
|
|
5133
|
+
for (const [index, opportunity] of input.contentOpportunities.slice(0, 2).entries()) {
|
|
5134
|
+
const verb = contentActionVerb(opportunity.action);
|
|
5135
|
+
const target = opportunity.ourBestPage?.url ?? `a new page for "${opportunity.query}"`;
|
|
5136
|
+
const evidence = [
|
|
5137
|
+
`Opportunity score ${Math.round(opportunity.score)} with ${opportunity.actionConfidence} confidence`,
|
|
5138
|
+
`Demand source: ${opportunity.demandSource}`
|
|
5139
|
+
];
|
|
5140
|
+
if (opportunity.winningCompetitor) {
|
|
5141
|
+
evidence.push(`${opportunity.winningCompetitor.domain} is the current winning cited source`);
|
|
5142
|
+
}
|
|
5143
|
+
if (opportunity.ourBestPage) {
|
|
5144
|
+
evidence.push(`Best matching owned page: ${opportunity.ourBestPage.url}`);
|
|
5145
|
+
} else {
|
|
5146
|
+
evidence.push("No matching owned page was found");
|
|
5147
|
+
}
|
|
5148
|
+
actions.push({
|
|
5149
|
+
audience: "both",
|
|
5150
|
+
priority: 20 + index,
|
|
5151
|
+
horizon: opportunity.actionConfidence === "high" ? "short-term" : "medium-term",
|
|
5152
|
+
category: "content",
|
|
5153
|
+
title: `${verb} content for "${opportunity.query}"`,
|
|
5154
|
+
action: opportunity.ourBestPage ? `${verb} ${target} so it directly answers the tracked query and cites the strongest supporting evidence.` : `${verb} ${target} that directly answers the query and earns citations from AI answer engines.`,
|
|
5155
|
+
why: opportunity.drivers.length > 0 ? opportunity.drivers : ["Canonry ranked this as a content opportunity from search-demand and citation evidence."],
|
|
5156
|
+
evidence,
|
|
5157
|
+
successMetric: `A future sweep cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`,
|
|
5158
|
+
confidence: opportunity.actionConfidence
|
|
5159
|
+
});
|
|
5160
|
+
}
|
|
5161
|
+
if (input.indexingHealth && input.indexingHealth.total > 0 && input.indexingHealth.indexedPct < 70) {
|
|
5162
|
+
const ih = input.indexingHealth;
|
|
5163
|
+
const evidence = [
|
|
5164
|
+
`${ih.indexedPct}% indexed (${ih.indexed}/${ih.total})`,
|
|
5165
|
+
`${ih.notIndexed} not indexed${ih.deindexed > 0 ? `, ${ih.deindexed} deindexed` : ""}`
|
|
5166
|
+
];
|
|
5167
|
+
actions.push({
|
|
5168
|
+
audience: "both",
|
|
5169
|
+
priority: 30,
|
|
5170
|
+
horizon: "immediate",
|
|
5171
|
+
category: "indexing",
|
|
5172
|
+
title: "Fix indexing coverage before expanding the content plan",
|
|
5173
|
+
action: "Audit the not-indexed tracked URLs, resolve crawl/index blockers, and resubmit priority pages.",
|
|
5174
|
+
why: [
|
|
5175
|
+
"Pages missing from the search index are less likely to be retrieved or cited by AI answer engines.",
|
|
5176
|
+
"Indexing issues can hide otherwise strong content from both search and AI systems."
|
|
5177
|
+
],
|
|
5178
|
+
evidence,
|
|
5179
|
+
successMetric: "Indexed share moves above 80% for tracked URLs and priority pages are eligible for retrieval.",
|
|
5180
|
+
confidence: ih.total >= 5 ? "high" : "medium"
|
|
5181
|
+
});
|
|
5182
|
+
}
|
|
5183
|
+
const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
|
|
5184
|
+
if (zeroCitationProviders.length > 0) {
|
|
5185
|
+
actions.push({
|
|
5186
|
+
audience: "agency",
|
|
5187
|
+
priority: 40,
|
|
5188
|
+
horizon: "short-term",
|
|
5189
|
+
category: "provider",
|
|
5190
|
+
title: "Diagnose providers with zero citations",
|
|
5191
|
+
action: "Inspect zero-citation provider answers and compare their cited domains against the pages currently available on the client site.",
|
|
5192
|
+
why: [
|
|
5193
|
+
"Provider-level misses show where one model family is not retrieving the client even when others might.",
|
|
5194
|
+
"This points the agency toward provider-specific evidence gaps instead of a generic content recommendation."
|
|
5195
|
+
],
|
|
5196
|
+
evidence: zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount} cited query-provider pairs`),
|
|
5197
|
+
successMetric: "At least one zero-citation provider cites the client on a priority query in a later sweep.",
|
|
5198
|
+
confidence: "high"
|
|
5199
|
+
});
|
|
5200
|
+
}
|
|
5201
|
+
if (input.gsc && (input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0)) {
|
|
5202
|
+
const evidence = [];
|
|
5203
|
+
if (input.gsc.trackedButNoGsc.length > 0) {
|
|
5204
|
+
evidence.push(`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`);
|
|
5205
|
+
}
|
|
5206
|
+
if (input.gsc.gscButNotTracked.length > 0) {
|
|
5207
|
+
evidence.push(`Search demand not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`);
|
|
5208
|
+
}
|
|
5209
|
+
actions.push({
|
|
5210
|
+
audience: "agency",
|
|
5211
|
+
priority: 50,
|
|
5212
|
+
horizon: "short-term",
|
|
5213
|
+
category: "search-demand",
|
|
5214
|
+
title: "Align tracked AEO queries with search demand",
|
|
5215
|
+
action: "Prune or relabel tracked queries with no search demand and add high-impression non-brand GSC queries to the AEO tracking set.",
|
|
5216
|
+
why: [
|
|
5217
|
+
"The strongest report actions come from overlap between real search demand and AI citation gaps.",
|
|
5218
|
+
"Mismatch here can make the client report feel interesting but hard to act on."
|
|
5219
|
+
],
|
|
5220
|
+
evidence,
|
|
5221
|
+
successMetric: "Next report has fewer no-demand tracked queries and includes the highest-impression non-brand GSC candidates.",
|
|
5222
|
+
confidence: evidence.length > 1 ? "high" : "medium"
|
|
5223
|
+
});
|
|
5224
|
+
}
|
|
5225
|
+
if (input.contentGaps.length > 0) {
|
|
5226
|
+
const topGap = input.contentGaps[0];
|
|
5227
|
+
actions.push({
|
|
5228
|
+
audience: "agency",
|
|
5229
|
+
priority: 60,
|
|
5230
|
+
horizon: "medium-term",
|
|
5231
|
+
category: "content",
|
|
5232
|
+
title: "Close competitor-cited content gaps",
|
|
5233
|
+
action: "Map the top missing queries to owned pages or new briefs, starting with the gaps where multiple competitors are already cited.",
|
|
5234
|
+
why: [
|
|
5235
|
+
"These are explicit places where AI engines found competitor sources but not the client.",
|
|
5236
|
+
"They are stronger evidence than a generic topic list because the model is already retrieving competing content."
|
|
5237
|
+
],
|
|
5238
|
+
evidence: [
|
|
5239
|
+
`"${topGap.query}" missed at ${Math.round(topGap.missRate * 100)}% with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
|
|
5240
|
+
`Cited competitors: ${compactList(topGap.competitorDomains)}`
|
|
5241
|
+
],
|
|
5242
|
+
successMetric: "The top content-gap query moves from missed to cited or mentioned after the recommended content work ships.",
|
|
5243
|
+
confidence: topGap.competitorCount >= 2 ? "high" : "medium"
|
|
5244
|
+
});
|
|
5245
|
+
}
|
|
5246
|
+
if (input.reportLocation && input.reportLocation.otherConfiguredLabels.length > 0) {
|
|
5247
|
+
const ignoredProviders = input.providerLocationHandling.filter((p) => p.treatment === "ignored" || p.treatment === "browser-geo").map((p) => p.provider);
|
|
5248
|
+
const evidence = [
|
|
5249
|
+
`Current report location: ${input.reportLocation.label}`,
|
|
5250
|
+
`Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`
|
|
5251
|
+
];
|
|
5252
|
+
if (ignoredProviders.length > 0) {
|
|
5253
|
+
evidence.push(`Providers with weak/indirect location handling: ${compactList(ignoredProviders)}`);
|
|
5254
|
+
}
|
|
5255
|
+
actions.push({
|
|
5256
|
+
audience: "agency",
|
|
5257
|
+
priority: 70,
|
|
5258
|
+
horizon: "medium-term",
|
|
5259
|
+
category: "location",
|
|
5260
|
+
title: "Keep location-scoped reporting separate by market",
|
|
5261
|
+
action: "Run and compare separate sweeps for each configured location before making market-level recommendations.",
|
|
5262
|
+
why: [
|
|
5263
|
+
"A multi-location client can appear differently by market.",
|
|
5264
|
+
"Keeping each report location-scoped avoids mixing Florida and Michigan evidence in the same client story."
|
|
5265
|
+
],
|
|
5266
|
+
evidence,
|
|
5267
|
+
successMetric: "Each configured market has its own current sweep and trend before cross-market decisions are made.",
|
|
5268
|
+
confidence: "high"
|
|
5269
|
+
});
|
|
5270
|
+
}
|
|
5271
|
+
if (actions.length === 0) {
|
|
5272
|
+
actions.push({
|
|
5273
|
+
audience: "both",
|
|
5274
|
+
priority: 90,
|
|
5275
|
+
horizon: "short-term",
|
|
5276
|
+
category: "monitoring",
|
|
5277
|
+
title: "Keep monitoring citation and mention coverage",
|
|
5278
|
+
action: "Run the next scheduled visibility sweep and watch for citation gains, losses, and provider-specific misses.",
|
|
5279
|
+
why: [
|
|
5280
|
+
"No urgent corrective action surfaced from the current evidence.",
|
|
5281
|
+
"AEO performance is directional; repeated sweeps are needed before overreacting to a single sample."
|
|
5282
|
+
],
|
|
5283
|
+
evidence: ["No critical insights, content gaps, indexing blockers, or provider-zero issues were detected in this report."],
|
|
5284
|
+
successMetric: "Coverage stays stable or improves across the next trend window.",
|
|
5285
|
+
confidence: "medium"
|
|
5286
|
+
});
|
|
5287
|
+
}
|
|
5288
|
+
return actions.sort((a, b) => a.priority - b.priority).slice(0, 10);
|
|
5289
|
+
}
|
|
5290
|
+
function trendSentence(trend) {
|
|
5291
|
+
switch (trend) {
|
|
5292
|
+
case "up":
|
|
5293
|
+
return "Citation coverage improved versus the prior comparable sweep.";
|
|
5294
|
+
case "down":
|
|
5295
|
+
return "Citation coverage declined versus the prior comparable sweep.";
|
|
5296
|
+
case "flat":
|
|
5297
|
+
return "Citation coverage is flat versus the prior comparable sweep.";
|
|
5298
|
+
case "unknown":
|
|
5299
|
+
return "There is not enough comparable run history yet to call a trend.";
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
function buildClientSummary(reportLike) {
|
|
5303
|
+
const s = reportLike.executiveSummary;
|
|
5304
|
+
const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
|
|
5305
|
+
const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a visibility sweep yet";
|
|
5306
|
+
const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is cited on ${s.citationRate}% of tracked queries and mentioned on ${s.mentionRate}% of tracked queries. ${trendSentence(s.trend)}` : "Canonry needs at least one completed visibility sweep before it can summarize how the brand appears in AI answers.";
|
|
5307
|
+
const confidenceNotes = [];
|
|
5308
|
+
if (s.totalQueryCount === 0) {
|
|
5309
|
+
confidenceNotes.push("Confidence is low until the first tracked query sweep completes.");
|
|
5310
|
+
} else if (s.totalQueryCount < 5) {
|
|
5311
|
+
confidenceNotes.push("Directional read: the tracked query set is still small, so each query has outsized impact on the percentage.");
|
|
5312
|
+
}
|
|
5313
|
+
if (isTrendBaseline(reportLike.citationsTrend)) {
|
|
5314
|
+
confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable sweeps are needed for a stable trend.`);
|
|
5315
|
+
}
|
|
5316
|
+
if (!reportLike.gsc) {
|
|
5317
|
+
confidenceNotes.push("Search Console is not connected, so content recommendations lean more heavily on citation and competitor evidence.");
|
|
5318
|
+
}
|
|
5319
|
+
if (reportLike.reportLocation) {
|
|
5320
|
+
confidenceNotes.push(`This summary is scoped to the ${reportLike.reportLocation.label} run location.`);
|
|
5321
|
+
}
|
|
5322
|
+
return {
|
|
5323
|
+
headline,
|
|
5324
|
+
overview,
|
|
5325
|
+
actionItems: reportLike.actionPlan.filter((a) => actionAudienceMatches2(a, "client")).slice(0, 3),
|
|
5326
|
+
confidenceNotes
|
|
5327
|
+
};
|
|
5328
|
+
}
|
|
5329
|
+
function buildAgencyDiagnostics(input) {
|
|
5330
|
+
const diagnostics = [];
|
|
5331
|
+
const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
|
|
5332
|
+
diagnostics.push({
|
|
5333
|
+
title: "Provider citation coverage",
|
|
5334
|
+
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.",
|
|
5335
|
+
severity: zeroCitationProviders.length > 0 ? "negative" : "positive",
|
|
5336
|
+
evidence: zeroCitationProviders.length > 0 ? zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount}`) : input.citationScorecard.providerRates.map((p) => `${p.provider}: ${p.citedCount}/${p.totalCount}`)
|
|
5337
|
+
});
|
|
5338
|
+
diagnostics.push({
|
|
5339
|
+
title: "AI source domains",
|
|
5340
|
+
detail: input.aiSourceOrigin.topDomains.length > 0 ? "Repeated external source domains show what AI engines are currently trusting for this topic set." : "No external source-domain evidence is available from the latest sweep yet.",
|
|
5341
|
+
severity: input.aiSourceOrigin.topDomains.length > 0 ? "neutral" : "caution",
|
|
5342
|
+
evidence: input.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `${d.domain}: ${d.count}`)
|
|
5343
|
+
});
|
|
5344
|
+
if (input.gsc) {
|
|
5345
|
+
diagnostics.push({
|
|
5346
|
+
title: "GSC query mismatch",
|
|
5347
|
+
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.",
|
|
5348
|
+
severity: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "caution" : "positive",
|
|
5349
|
+
evidence: [
|
|
5350
|
+
...input.gsc.trackedButNoGsc.length > 0 ? [`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`] : [],
|
|
5351
|
+
...input.gsc.gscButNotTracked.length > 0 ? [`GSC queries not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`] : []
|
|
5352
|
+
]
|
|
5353
|
+
});
|
|
5354
|
+
}
|
|
5355
|
+
if (input.indexingHealth) {
|
|
5356
|
+
diagnostics.push({
|
|
5357
|
+
title: "Indexing health",
|
|
5358
|
+
detail: `${input.indexingHealth.indexedPct}% of inspected URLs are indexed in ${input.indexingHealth.provider ?? "the connected provider"}.`,
|
|
5359
|
+
severity: input.indexingHealth.indexedPct >= 90 ? "positive" : input.indexingHealth.indexedPct >= 70 ? "caution" : "negative",
|
|
5360
|
+
evidence: [
|
|
5361
|
+
`${input.indexingHealth.indexed}/${input.indexingHealth.total} indexed`,
|
|
5362
|
+
`${input.indexingHealth.notIndexed} not indexed`
|
|
5363
|
+
]
|
|
5364
|
+
});
|
|
5365
|
+
}
|
|
5366
|
+
diagnostics.push({
|
|
5367
|
+
title: "Content opportunity pipeline",
|
|
5368
|
+
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.",
|
|
5369
|
+
severity: input.contentOpportunities.length > 0 ? "caution" : "neutral",
|
|
5370
|
+
evidence: input.contentOpportunities.slice(0, 3).map((o) => `${o.query}: ${o.action} (${Math.round(o.score)})`)
|
|
5371
|
+
});
|
|
5372
|
+
return {
|
|
5373
|
+
priorities: input.actionPlan.filter((a) => actionAudienceMatches2(a, "agency")).slice(0, 6),
|
|
5374
|
+
diagnostics
|
|
5375
|
+
};
|
|
5376
|
+
}
|
|
4293
5377
|
function buildProjectReport(db, projectName) {
|
|
4294
5378
|
const project = resolveProject(db, projectName);
|
|
4295
5379
|
const queryLookup = loadQueryLookup(db, project.id);
|
|
@@ -4299,6 +5383,7 @@ function buildProjectReport(db, projectName) {
|
|
|
4299
5383
|
(r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
|
|
4300
5384
|
) ?? visibilityRuns[0];
|
|
4301
5385
|
const latestSnapshots = latestRun ? loadSnapshotsForRun(db, latestRun.id) : [];
|
|
5386
|
+
const latestRunLocation = latestRun?.location ?? null;
|
|
4302
5387
|
const competitorRows = db.select().from(competitors).where(eq13(competitors.projectId, project.id)).all();
|
|
4303
5388
|
const competitorDomains = competitorRows.map((c) => c.domain);
|
|
4304
5389
|
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
@@ -4330,9 +5415,9 @@ function buildProjectReport(db, projectName) {
|
|
|
4330
5415
|
const socialSection = buildSocialReferrals(db, project.id);
|
|
4331
5416
|
const aiReferralsSection = buildAiReferrals(db, project.id);
|
|
4332
5417
|
const indexingHealthSection = buildIndexingHealth(db, project.id);
|
|
4333
|
-
const citationsTrend = buildCitationsTrend(db, project.id, queryLookup);
|
|
4334
|
-
const insightList = buildInsightList(db, project.id);
|
|
4335
|
-
const orchestratorInput = loadOrchestratorInput(db, project);
|
|
5418
|
+
const citationsTrend = buildCitationsTrend(db, project.id, queryLookup, latestRunLocation);
|
|
5419
|
+
const insightList = buildInsightList(db, project.id, latestRunLocation);
|
|
5420
|
+
const orchestratorInput = loadOrchestratorInput(db, project, latestRunLocation);
|
|
4336
5421
|
const contentOpportunities = buildContentTargetRows(orchestratorInput);
|
|
4337
5422
|
const contentGaps = buildContentGapRows(orchestratorInput);
|
|
4338
5423
|
const groundingSources = buildContentSourceRows(orchestratorInput);
|
|
@@ -4341,14 +5426,18 @@ function buildProjectReport(db, projectName) {
|
|
|
4341
5426
|
contentOpportunities,
|
|
4342
5427
|
insightDerivedSteps
|
|
4343
5428
|
);
|
|
4344
|
-
|
|
4345
|
-
|
|
5429
|
+
const totalQueryCount = queryLookup.byId.size;
|
|
5430
|
+
const citedQueryIds = /* @__PURE__ */ new Set();
|
|
5431
|
+
const mentionedQueryIds = /* @__PURE__ */ new Set();
|
|
4346
5432
|
for (const snap of latestSnapshots) {
|
|
4347
5433
|
if (!queryLookup.byId.has(snap.queryId)) continue;
|
|
4348
|
-
|
|
4349
|
-
if (snap.
|
|
5434
|
+
if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
|
|
5435
|
+
if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
|
|
4350
5436
|
}
|
|
4351
|
-
const
|
|
5437
|
+
const citedQueryCount = citedQueryIds.size;
|
|
5438
|
+
const mentionedQueryCount = mentionedQueryIds.size;
|
|
5439
|
+
const citationRate = totalQueryCount > 0 ? Math.round(citedQueryCount / totalQueryCount * 100) : 0;
|
|
5440
|
+
const mentionRate = totalQueryCount > 0 ? Math.round(mentionedQueryCount / totalQueryCount * 100) : 0;
|
|
4352
5441
|
const trendBaseline = isTrendBaseline(citationsTrend);
|
|
4353
5442
|
const latestPoint = citationsTrend.at(-1);
|
|
4354
5443
|
const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
|
|
@@ -4365,6 +5454,8 @@ function buildProjectReport(db, projectName) {
|
|
|
4365
5454
|
}
|
|
4366
5455
|
const findings = buildExecutiveFindings(
|
|
4367
5456
|
citationRate,
|
|
5457
|
+
citedQueryCount,
|
|
5458
|
+
totalQueryCount,
|
|
4368
5459
|
trend,
|
|
4369
5460
|
citationsTrend,
|
|
4370
5461
|
trendBaseline,
|
|
@@ -4373,6 +5464,68 @@ function buildProjectReport(db, projectName) {
|
|
|
4373
5464
|
);
|
|
4374
5465
|
const periodStart = citationsTrend[0]?.date ?? null;
|
|
4375
5466
|
const periodEnd = citationsTrend.at(-1)?.date ?? null;
|
|
5467
|
+
const configuredLocations = parseJsonColumn(project.locations, []);
|
|
5468
|
+
const reportLocation = buildLocationMeta(latestRun?.location ?? null, configuredLocations);
|
|
5469
|
+
const providerLocationHandling = reportLocation ? buildProviderLocationHandling(citationScorecard.providers) : [];
|
|
5470
|
+
const executiveSummary = {
|
|
5471
|
+
citationRate,
|
|
5472
|
+
citedQueryCount,
|
|
5473
|
+
totalQueryCount,
|
|
5474
|
+
mentionRate,
|
|
5475
|
+
mentionedQueryCount,
|
|
5476
|
+
trend,
|
|
5477
|
+
queryCount: queryLookup.byId.size,
|
|
5478
|
+
competitorCount: competitorDomains.length,
|
|
5479
|
+
providerCount: citationScorecard.providers.length,
|
|
5480
|
+
gsc: gscSection ? {
|
|
5481
|
+
clicks: gscSection.totalClicks,
|
|
5482
|
+
impressions: gscSection.totalImpressions,
|
|
5483
|
+
ctr: gscSection.ctr,
|
|
5484
|
+
avgPosition: gscSection.avgPosition,
|
|
5485
|
+
periodStart: gscSection.periodStart,
|
|
5486
|
+
periodEnd: gscSection.periodEnd
|
|
5487
|
+
} : null,
|
|
5488
|
+
ga: gaSection ? {
|
|
5489
|
+
sessions: gaSection.totalSessions,
|
|
5490
|
+
users: gaSection.totalUsers,
|
|
5491
|
+
periodStart: gaSection.periodStart,
|
|
5492
|
+
periodEnd: gaSection.periodEnd
|
|
5493
|
+
} : null,
|
|
5494
|
+
findings
|
|
5495
|
+
};
|
|
5496
|
+
const actionPlan = buildReportActionPlan({
|
|
5497
|
+
canonicalDomain: project.canonicalDomain,
|
|
5498
|
+
competitorDomains,
|
|
5499
|
+
citationScorecard,
|
|
5500
|
+
aiSourceOrigin,
|
|
5501
|
+
gsc: gscSection,
|
|
5502
|
+
indexingHealth: indexingHealthSection,
|
|
5503
|
+
contentOpportunities,
|
|
5504
|
+
contentGaps,
|
|
5505
|
+
reportLocation,
|
|
5506
|
+
providerLocationHandling
|
|
5507
|
+
});
|
|
5508
|
+
const clientSummary = buildClientSummary({
|
|
5509
|
+
canonicalDomain: project.canonicalDomain,
|
|
5510
|
+
reportLocation,
|
|
5511
|
+
executiveSummary,
|
|
5512
|
+
citationsTrend,
|
|
5513
|
+
gsc: gscSection,
|
|
5514
|
+
actionPlan
|
|
5515
|
+
});
|
|
5516
|
+
const agencyDiagnostics = buildAgencyDiagnostics({
|
|
5517
|
+
canonicalDomain: project.canonicalDomain,
|
|
5518
|
+
competitorDomains,
|
|
5519
|
+
citationScorecard,
|
|
5520
|
+
aiSourceOrigin,
|
|
5521
|
+
gsc: gscSection,
|
|
5522
|
+
indexingHealth: indexingHealthSection,
|
|
5523
|
+
contentOpportunities,
|
|
5524
|
+
contentGaps,
|
|
5525
|
+
reportLocation,
|
|
5526
|
+
providerLocationHandling,
|
|
5527
|
+
actionPlan
|
|
5528
|
+
});
|
|
4376
5529
|
return {
|
|
4377
5530
|
meta: {
|
|
4378
5531
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -4384,29 +5537,12 @@ function buildProjectReport(db, projectName) {
|
|
|
4384
5537
|
country: project.country,
|
|
4385
5538
|
language: project.language
|
|
4386
5539
|
},
|
|
5540
|
+
location: reportLocation,
|
|
5541
|
+
providerLocationHandling,
|
|
4387
5542
|
periodStart,
|
|
4388
5543
|
periodEnd
|
|
4389
5544
|
},
|
|
4390
|
-
executiveSummary
|
|
4391
|
-
citationRate,
|
|
4392
|
-
trend,
|
|
4393
|
-
queryCount: queryLookup.byId.size,
|
|
4394
|
-
competitorCount: competitorDomains.length,
|
|
4395
|
-
providerCount: citationScorecard.providers.length,
|
|
4396
|
-
gsc: gscSection ? {
|
|
4397
|
-
clicks: gscSection.totalClicks,
|
|
4398
|
-
impressions: gscSection.totalImpressions,
|
|
4399
|
-
ctr: gscSection.ctr,
|
|
4400
|
-
avgPosition: gscSection.avgPosition
|
|
4401
|
-
} : null,
|
|
4402
|
-
ga: gaSection ? {
|
|
4403
|
-
sessions: gaSection.totalSessions,
|
|
4404
|
-
users: gaSection.totalUsers,
|
|
4405
|
-
periodStart: gaSection.periodStart,
|
|
4406
|
-
periodEnd: gaSection.periodEnd
|
|
4407
|
-
} : null,
|
|
4408
|
-
findings
|
|
4409
|
-
},
|
|
5545
|
+
executiveSummary,
|
|
4410
5546
|
citationScorecard,
|
|
4411
5547
|
competitorLandscape,
|
|
4412
5548
|
mentionLandscape,
|
|
@@ -4419,14 +5555,22 @@ function buildProjectReport(db, projectName) {
|
|
|
4419
5555
|
citationsTrend,
|
|
4420
5556
|
insights: insightList,
|
|
4421
5557
|
recommendedNextSteps,
|
|
5558
|
+
actionPlan,
|
|
5559
|
+
clientSummary,
|
|
5560
|
+
agencyDiagnostics,
|
|
4422
5561
|
contentOpportunities,
|
|
4423
5562
|
contentGaps,
|
|
4424
5563
|
groundingSources
|
|
4425
5564
|
};
|
|
4426
5565
|
}
|
|
4427
|
-
function
|
|
5566
|
+
function parseReportAudience(value) {
|
|
5567
|
+
if (value === void 0 || value === "agency") return "agency";
|
|
5568
|
+
if (value === "client") return "client";
|
|
5569
|
+
throw validationError('"audience" must be "agency" or "client"');
|
|
5570
|
+
}
|
|
5571
|
+
function reportFilenameFor(project, generatedAt, audience) {
|
|
4428
5572
|
const date = generatedAt.slice(0, 10);
|
|
4429
|
-
return `canonry-report-${project.name}-${date}.html`;
|
|
5573
|
+
return `canonry-report-${project.name}-${audience}-${date}.html`;
|
|
4430
5574
|
}
|
|
4431
5575
|
async function reportRoutes(app) {
|
|
4432
5576
|
app.get("/projects/:name/report", async (request, reply) => {
|
|
@@ -4434,9 +5578,10 @@ async function reportRoutes(app) {
|
|
|
4434
5578
|
return reply.send(dto);
|
|
4435
5579
|
});
|
|
4436
5580
|
app.get("/projects/:name/report.html", async (request, reply) => {
|
|
5581
|
+
const audience = parseReportAudience(request.query.audience);
|
|
4437
5582
|
const dto = buildProjectReport(app.db, request.params.name);
|
|
4438
|
-
const html = renderReportHtml(dto);
|
|
4439
|
-
const filename = reportFilenameFor(dto.meta.project, dto.meta.generatedAt);
|
|
5583
|
+
const html = renderReportHtml(dto, { audience });
|
|
5584
|
+
const filename = reportFilenameFor(dto.meta.project, dto.meta.generatedAt, audience);
|
|
4440
5585
|
reply.header("Content-Type", "text/html; charset=utf-8");
|
|
4441
5586
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
|
4442
5587
|
return reply.send(html);
|
|
@@ -5294,6 +6439,12 @@ var locationQueryParameter = {
|
|
|
5294
6439
|
description: "Filter by location label. Use an empty value to request locationless results.",
|
|
5295
6440
|
schema: stringSchema
|
|
5296
6441
|
};
|
|
6442
|
+
var reportAudienceQueryParameter = {
|
|
6443
|
+
name: "audience",
|
|
6444
|
+
in: "query",
|
|
6445
|
+
description: "HTML report audience mode. Defaults to agency.",
|
|
6446
|
+
schema: { type: "string", enum: ["agency", "client"] }
|
|
6447
|
+
};
|
|
5297
6448
|
var analyticsWindowParameter = {
|
|
5298
6449
|
name: "window",
|
|
5299
6450
|
in: "query",
|
|
@@ -7514,9 +8665,9 @@ var routeCatalog = [
|
|
|
7514
8665
|
{
|
|
7515
8666
|
method: "get",
|
|
7516
8667
|
path: "/api/v1/projects/{name}/report",
|
|
7517
|
-
summary: "Aggregated
|
|
8668
|
+
summary: "Aggregated canonical AEO report",
|
|
7518
8669
|
tags: ["report"],
|
|
7519
|
-
description: "Bundles every section the canonry-report HTML output needs (executive summary, citation scorecard, competitor landscape \u2014 citation + mention landscapes, AI citation sources, GSC, GA4, social/AI referrals, indexing health, citations trend, insights, and recommended next steps) into a single JSON payload. Backs `canonry report <project
|
|
8670
|
+
description: "Bundles every section the canonry-report HTML output needs (executive summary, client summary, agency diagnostics, action plan, citation scorecard, competitor landscape \u2014 citation + mention landscapes, AI citation sources, GSC, GA4, social/AI referrals, indexing health, citations trend, insights, and recommended next steps) into a single canonical JSON payload. Backs `canonry report <project>` and MCP report reads.",
|
|
7520
8671
|
parameters: [nameParameter],
|
|
7521
8672
|
responses: {
|
|
7522
8673
|
200: { description: "Report returned." },
|
|
@@ -7528,8 +8679,8 @@ var routeCatalog = [
|
|
|
7528
8679
|
path: "/api/v1/projects/{name}/report.html",
|
|
7529
8680
|
summary: "Standalone HTML AEO report",
|
|
7530
8681
|
tags: ["report"],
|
|
7531
|
-
description: "Server-rendered self-contained HTML version of the project report. Same data as `/projects/{name}/report` (JSON), rendered through the canonry HTML report renderer. Returns `text/html` with `Content-Disposition: attachment` so browsers download it as `canonry-report-<project>-YYYY-MM-DD.html`. Open in a browser and Print \u2192 Save as PDF for a PDF copy.",
|
|
7532
|
-
parameters: [nameParameter],
|
|
8682
|
+
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.",
|
|
8683
|
+
parameters: [nameParameter, reportAudienceQueryParameter],
|
|
7533
8684
|
responses: {
|
|
7534
8685
|
200: { description: "HTML report returned." },
|
|
7535
8686
|
404: { description: "Project not found." }
|