@ainyc/canonry 4.7.2 → 4.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/assets/index-BdAFw2Gy.js +302 -0
- package/assets/assets/{index-DAS6pOry.css → index-CGXCbiM_.css} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-VDEMEI64.js → chunk-GAC7BSL6.js} +1 -1
- package/dist/{chunk-XAW66QUX.js → chunk-GPJ3GLOE.js} +147 -0
- package/dist/{chunk-DVTPGC6O.js → chunk-TNW6Z3TW.js} +824 -217
- package/dist/{chunk-OOADR2Q5.js → chunk-XRDZ26OZ.js} +48 -2
- package/dist/cli.js +5 -5
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-ABHO5HHA.js → intelligence-service-TFDEKAOM.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +8 -8
- package/assets/assets/index-Ca3kZYGw.js +0 -302
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
configExists,
|
|
5
5
|
loadConfig,
|
|
6
6
|
saveConfigPatch
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-GAC7BSL6.js";
|
|
8
8
|
import {
|
|
9
9
|
DEFAULT_RUN_HISTORY_LIMIT,
|
|
10
10
|
IntelligenceService,
|
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
runs,
|
|
62
62
|
schedules,
|
|
63
63
|
usageCounters
|
|
64
|
-
} from "./chunk-
|
|
64
|
+
} from "./chunk-XRDZ26OZ.js";
|
|
65
65
|
import {
|
|
66
66
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
67
67
|
AGENT_PROVIDER_IDS,
|
|
@@ -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,6 +90,9 @@ import {
|
|
|
89
90
|
categoryLabel,
|
|
90
91
|
citationStateToCited,
|
|
91
92
|
competitorBatchRequestSchema,
|
|
93
|
+
contentActionLabel,
|
|
94
|
+
dedupeReportActions,
|
|
95
|
+
dedupeReportOpportunities,
|
|
92
96
|
deliveryFailed,
|
|
93
97
|
determineAnswerMentioned,
|
|
94
98
|
effectiveDomains,
|
|
@@ -114,7 +118,11 @@ import {
|
|
|
114
118
|
providerError,
|
|
115
119
|
queryGenerateRequestSchema,
|
|
116
120
|
registrableDomain,
|
|
121
|
+
reportActionCategoryLabel,
|
|
117
122
|
reportActionTone,
|
|
123
|
+
reportConfidenceLabel,
|
|
124
|
+
reportHorizonLabel,
|
|
125
|
+
reportSeverityLabel,
|
|
118
126
|
resolveConfigSpecQueries,
|
|
119
127
|
resolveSnapshotRequestQueries,
|
|
120
128
|
runInProgress,
|
|
@@ -129,7 +137,7 @@ import {
|
|
|
129
137
|
visibilityStateFromAnswerMentioned,
|
|
130
138
|
windowCutoff,
|
|
131
139
|
wordpressEnvSchema
|
|
132
|
-
} from "./chunk-
|
|
140
|
+
} from "./chunk-GPJ3GLOE.js";
|
|
133
141
|
|
|
134
142
|
// src/telemetry.ts
|
|
135
143
|
import crypto from "crypto";
|
|
@@ -2622,12 +2630,43 @@ function formatLandingPageHtml(raw) {
|
|
|
2622
2630
|
function formatDate(iso) {
|
|
2623
2631
|
if (!iso) return "\u2014";
|
|
2624
2632
|
try {
|
|
2625
|
-
const
|
|
2626
|
-
|
|
2633
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
2634
|
+
const options = { month: "short", day: "numeric", year: "numeric" };
|
|
2635
|
+
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);
|
|
2636
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
2637
|
+
return d.toLocaleDateString("en-US", dateOnly ? { ...options, timeZone: "UTC" } : options);
|
|
2627
2638
|
} catch {
|
|
2628
2639
|
return iso;
|
|
2629
2640
|
}
|
|
2630
2641
|
}
|
|
2642
|
+
function formatDateRange(start, end) {
|
|
2643
|
+
if (!start && !end) return "";
|
|
2644
|
+
if (start && end) return `${formatDate(start)} \u2192 ${formatDate(end)}`;
|
|
2645
|
+
return formatDate(start || end);
|
|
2646
|
+
}
|
|
2647
|
+
function gscDateRange(report) {
|
|
2648
|
+
const summary = report.executiveSummary.gsc;
|
|
2649
|
+
const gsc = report.gsc;
|
|
2650
|
+
const start = summary?.periodStart || gsc?.periodStart || gsc?.trend[0]?.date || "";
|
|
2651
|
+
const end = summary?.periodEnd || gsc?.periodEnd || gsc?.trend.at(-1)?.date || "";
|
|
2652
|
+
return formatDateRange(start, end);
|
|
2653
|
+
}
|
|
2654
|
+
function pluralize(count, singular, plural = `${singular}s`) {
|
|
2655
|
+
return count === 1 ? singular : plural;
|
|
2656
|
+
}
|
|
2657
|
+
function compactInlineList(items, limit = 3) {
|
|
2658
|
+
const visible = items.slice(0, limit);
|
|
2659
|
+
const more = items.length - visible.length;
|
|
2660
|
+
return `${visible.join(", ")}${more > 0 ? `, +${more} more` : ""}`;
|
|
2661
|
+
}
|
|
2662
|
+
function renderProofChips(items, limit = 3) {
|
|
2663
|
+
if (items.length === 0) return "";
|
|
2664
|
+
const visible = items.slice(0, limit);
|
|
2665
|
+
const more = items.length - visible.length;
|
|
2666
|
+
const chips = visible.map((item) => `<span class="proof-chip">${escapeHtml(item)}</span>`);
|
|
2667
|
+
if (more > 0) chips.push(`<span class="proof-chip">+${more} more</span>`);
|
|
2668
|
+
return `<div class="proof-chips">${chips.join("")}</div>`;
|
|
2669
|
+
}
|
|
2631
2670
|
function pressureTone(label) {
|
|
2632
2671
|
if (label === "High") return "negative";
|
|
2633
2672
|
if (label === "Moderate") return "caution";
|
|
@@ -2674,7 +2713,7 @@ body {
|
|
|
2674
2713
|
font-size: 32px;
|
|
2675
2714
|
font-weight: 700;
|
|
2676
2715
|
margin: 0 0 8px;
|
|
2677
|
-
letter-spacing:
|
|
2716
|
+
letter-spacing: 0;
|
|
2678
2717
|
}
|
|
2679
2718
|
.header .subtitle {
|
|
2680
2719
|
color: ${COLORS.textMuted};
|
|
@@ -2682,7 +2721,7 @@ body {
|
|
|
2682
2721
|
}
|
|
2683
2722
|
.eyebrow {
|
|
2684
2723
|
text-transform: uppercase;
|
|
2685
|
-
letter-spacing: 0
|
|
2724
|
+
letter-spacing: 0;
|
|
2686
2725
|
font-size: 10px;
|
|
2687
2726
|
color: ${COLORS.textFaint};
|
|
2688
2727
|
font-weight: 600;
|
|
@@ -2695,11 +2734,75 @@ section.report-section h2 {
|
|
|
2695
2734
|
font-size: 22px;
|
|
2696
2735
|
font-weight: 700;
|
|
2697
2736
|
margin: 0 0 24px;
|
|
2698
|
-
letter-spacing:
|
|
2737
|
+
letter-spacing: 0;
|
|
2699
2738
|
}
|
|
2700
2739
|
section.report-section .section-intro {
|
|
2701
2740
|
color: ${COLORS.textMuted};
|
|
2702
2741
|
margin-bottom: 24px;
|
|
2742
|
+
max-width: 760px;
|
|
2743
|
+
}
|
|
2744
|
+
.executive-hero {
|
|
2745
|
+
display: grid;
|
|
2746
|
+
grid-template-columns: minmax(0, 1.35fr) minmax(240px, 0.65fr);
|
|
2747
|
+
gap: 16px;
|
|
2748
|
+
margin-bottom: 16px;
|
|
2749
|
+
}
|
|
2750
|
+
.headline-card {
|
|
2751
|
+
background: #111827;
|
|
2752
|
+
border: 1px solid ${COLORS.border};
|
|
2753
|
+
border-radius: 8px;
|
|
2754
|
+
padding: 28px;
|
|
2755
|
+
min-height: 220px;
|
|
2756
|
+
display: flex;
|
|
2757
|
+
flex-direction: column;
|
|
2758
|
+
justify-content: space-between;
|
|
2759
|
+
}
|
|
2760
|
+
.headline-card .hero-kicker {
|
|
2761
|
+
color: ${COLORS.textMuted};
|
|
2762
|
+
font-size: 12px;
|
|
2763
|
+
font-weight: 600;
|
|
2764
|
+
text-transform: uppercase;
|
|
2765
|
+
letter-spacing: 0;
|
|
2766
|
+
}
|
|
2767
|
+
.headline-card .hero-title {
|
|
2768
|
+
font-size: 44px;
|
|
2769
|
+
line-height: 1.05;
|
|
2770
|
+
font-weight: 800;
|
|
2771
|
+
letter-spacing: 0;
|
|
2772
|
+
margin: 18px 0;
|
|
2773
|
+
}
|
|
2774
|
+
.headline-card .hero-subtitle {
|
|
2775
|
+
color: ${COLORS.textMuted};
|
|
2776
|
+
font-size: 15px;
|
|
2777
|
+
max-width: 620px;
|
|
2778
|
+
}
|
|
2779
|
+
.hero-proof-grid {
|
|
2780
|
+
display: grid;
|
|
2781
|
+
gap: 12px;
|
|
2782
|
+
}
|
|
2783
|
+
.hero-proof {
|
|
2784
|
+
background: ${COLORS.surface};
|
|
2785
|
+
border: 1px solid ${COLORS.border};
|
|
2786
|
+
border-radius: 8px;
|
|
2787
|
+
padding: 18px;
|
|
2788
|
+
}
|
|
2789
|
+
.hero-proof .mini-label {
|
|
2790
|
+
color: ${COLORS.textFaint};
|
|
2791
|
+
font-size: 10px;
|
|
2792
|
+
font-weight: 600;
|
|
2793
|
+
text-transform: uppercase;
|
|
2794
|
+
letter-spacing: 0;
|
|
2795
|
+
margin-bottom: 8px;
|
|
2796
|
+
}
|
|
2797
|
+
.hero-proof .mini-value {
|
|
2798
|
+
font-size: 30px;
|
|
2799
|
+
line-height: 1;
|
|
2800
|
+
font-weight: 800;
|
|
2801
|
+
}
|
|
2802
|
+
.hero-proof .mini-copy {
|
|
2803
|
+
color: ${COLORS.textMuted};
|
|
2804
|
+
font-size: 12px;
|
|
2805
|
+
margin-top: 8px;
|
|
2703
2806
|
}
|
|
2704
2807
|
.metric-grid {
|
|
2705
2808
|
display: grid;
|
|
@@ -2714,7 +2817,7 @@ section.report-section .section-intro {
|
|
|
2714
2817
|
}
|
|
2715
2818
|
.metric .label {
|
|
2716
2819
|
text-transform: uppercase;
|
|
2717
|
-
letter-spacing: 0
|
|
2820
|
+
letter-spacing: 0;
|
|
2718
2821
|
font-size: 10px;
|
|
2719
2822
|
color: ${COLORS.textFaint};
|
|
2720
2823
|
font-weight: 600;
|
|
@@ -2723,7 +2826,7 @@ section.report-section .section-intro {
|
|
|
2723
2826
|
.metric .value {
|
|
2724
2827
|
font-size: 28px;
|
|
2725
2828
|
font-weight: 700;
|
|
2726
|
-
letter-spacing:
|
|
2829
|
+
letter-spacing: 0;
|
|
2727
2830
|
}
|
|
2728
2831
|
.metric .delta {
|
|
2729
2832
|
font-size: 12px;
|
|
@@ -2748,10 +2851,46 @@ section.report-section .section-intro {
|
|
|
2748
2851
|
.finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
2749
2852
|
.finding strong { display: block; margin-bottom: 4px; }
|
|
2750
2853
|
.finding span { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
2751
|
-
.
|
|
2752
|
-
.
|
|
2753
|
-
|
|
2754
|
-
|
|
2854
|
+
.market-scope-card { margin-top: 16px; }
|
|
2855
|
+
.market-scope-grid {
|
|
2856
|
+
display: grid;
|
|
2857
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
2858
|
+
gap: 12px;
|
|
2859
|
+
}
|
|
2860
|
+
.scope-tile {
|
|
2861
|
+
background: #09090b;
|
|
2862
|
+
border: 1px solid ${COLORS.border};
|
|
2863
|
+
border-radius: 8px;
|
|
2864
|
+
padding: 14px;
|
|
2865
|
+
}
|
|
2866
|
+
.scope-tile .scope-label {
|
|
2867
|
+
color: ${COLORS.textFaint};
|
|
2868
|
+
font-size: 10px;
|
|
2869
|
+
font-weight: 600;
|
|
2870
|
+
text-transform: uppercase;
|
|
2871
|
+
letter-spacing: 0;
|
|
2872
|
+
margin-bottom: 8px;
|
|
2873
|
+
}
|
|
2874
|
+
.scope-tile .scope-value {
|
|
2875
|
+
font-size: 18px;
|
|
2876
|
+
line-height: 1.2;
|
|
2877
|
+
font-weight: 700;
|
|
2878
|
+
}
|
|
2879
|
+
.scope-tile .scope-copy {
|
|
2880
|
+
color: ${COLORS.textMuted};
|
|
2881
|
+
font-size: 12px;
|
|
2882
|
+
margin-top: 8px;
|
|
2883
|
+
}
|
|
2884
|
+
.scope-warning {
|
|
2885
|
+
margin-top: 12px;
|
|
2886
|
+
border: 1px solid ${COLORS.caution}55;
|
|
2887
|
+
background: ${COLORS.caution}14;
|
|
2888
|
+
border-radius: 8px;
|
|
2889
|
+
padding: 12px 14px;
|
|
2890
|
+
color: ${COLORS.textMuted};
|
|
2891
|
+
font-size: 13px;
|
|
2892
|
+
}
|
|
2893
|
+
.scope-warning strong { color: ${COLORS.text}; display: block; margin-bottom: 4px; }
|
|
2755
2894
|
.source-origin-headline { margin: 0 0 12px; font-size: 14px; color: ${COLORS.text}; }
|
|
2756
2895
|
.source-origin-headline strong { color: ${COLORS.text}; }
|
|
2757
2896
|
.source-bars { display: flex; flex-direction: column; gap: 6px; }
|
|
@@ -2773,18 +2912,24 @@ table.report-table th, table.report-table td {
|
|
|
2773
2912
|
padding: 10px 12px;
|
|
2774
2913
|
border-bottom: 1px solid ${COLORS.border};
|
|
2775
2914
|
vertical-align: top;
|
|
2776
|
-
overflow-wrap:
|
|
2777
|
-
|
|
2915
|
+
overflow-wrap: break-word;
|
|
2916
|
+
hyphens: auto;
|
|
2778
2917
|
}
|
|
2779
2918
|
table.report-table th {
|
|
2780
2919
|
font-weight: 600;
|
|
2781
2920
|
color: ${COLORS.textMuted};
|
|
2782
2921
|
text-transform: uppercase;
|
|
2783
|
-
letter-spacing: 0
|
|
2922
|
+
letter-spacing: 0;
|
|
2784
2923
|
font-size: 10px;
|
|
2785
2924
|
}
|
|
2786
2925
|
table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
2787
2926
|
table.report-table td.page-cell { max-width: 0; }
|
|
2927
|
+
table.insights-table { table-layout: fixed; }
|
|
2928
|
+
table.insights-table th.col-severity, table.insights-table td.col-severity { width: 96px; }
|
|
2929
|
+
table.insights-table th.col-query, table.insights-table td.col-query { width: 18%; }
|
|
2930
|
+
table.insights-table th.col-provider, table.insights-table td.col-provider { width: 88px; }
|
|
2931
|
+
table.insights-table th.col-title, table.insights-table td.col-title { width: 28%; }
|
|
2932
|
+
table.insights-table th.col-recommendation, table.insights-table td.col-recommendation { width: auto; }
|
|
2788
2933
|
table.report-table td.page-cell .page-path {
|
|
2789
2934
|
display: block;
|
|
2790
2935
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
@@ -2877,7 +3022,7 @@ table.report-table td .badge {
|
|
|
2877
3022
|
.step .horizon {
|
|
2878
3023
|
text-transform: uppercase;
|
|
2879
3024
|
font-size: 10px;
|
|
2880
|
-
letter-spacing: 0
|
|
3025
|
+
letter-spacing: 0;
|
|
2881
3026
|
color: ${COLORS.textFaint};
|
|
2882
3027
|
font-weight: 600;
|
|
2883
3028
|
}
|
|
@@ -2892,20 +3037,40 @@ table.report-table td .badge {
|
|
|
2892
3037
|
background: ${COLORS.surface};
|
|
2893
3038
|
border: 1px solid ${COLORS.border};
|
|
2894
3039
|
border-radius: 8px;
|
|
2895
|
-
padding: 18px
|
|
3040
|
+
padding: 18px;
|
|
3041
|
+
display: flex;
|
|
3042
|
+
flex-direction: column;
|
|
3043
|
+
gap: 12px;
|
|
3044
|
+
}
|
|
3045
|
+
.action-card .action-head {
|
|
3046
|
+
display: grid;
|
|
3047
|
+
grid-template-columns: 42px 1fr;
|
|
3048
|
+
gap: 12px;
|
|
3049
|
+
align-items: start;
|
|
3050
|
+
}
|
|
3051
|
+
.action-card .action-rank {
|
|
3052
|
+
border: 1px solid ${COLORS.border};
|
|
3053
|
+
border-radius: 8px;
|
|
3054
|
+
height: 42px;
|
|
3055
|
+
display: flex;
|
|
3056
|
+
align-items: center;
|
|
3057
|
+
justify-content: center;
|
|
3058
|
+
font-size: 16px;
|
|
3059
|
+
font-weight: 800;
|
|
3060
|
+
color: ${COLORS.text};
|
|
3061
|
+
background: #09090b;
|
|
2896
3062
|
}
|
|
2897
3063
|
.action-card .action-meta {
|
|
2898
3064
|
display: flex;
|
|
2899
3065
|
flex-wrap: wrap;
|
|
2900
3066
|
gap: 8px;
|
|
2901
|
-
margin-bottom: 10px;
|
|
2902
3067
|
}
|
|
2903
3068
|
.action-card h3 {
|
|
2904
3069
|
font-size: 16px;
|
|
2905
|
-
margin: 0 0
|
|
3070
|
+
margin: 8px 0 0;
|
|
2906
3071
|
}
|
|
2907
3072
|
.action-card p {
|
|
2908
|
-
margin: 0
|
|
3073
|
+
margin: 0;
|
|
2909
3074
|
color: ${COLORS.textMuted};
|
|
2910
3075
|
}
|
|
2911
3076
|
.action-card ul {
|
|
@@ -2915,6 +3080,28 @@ table.report-table td .badge {
|
|
|
2915
3080
|
font-size: 13px;
|
|
2916
3081
|
}
|
|
2917
3082
|
.action-card li { margin: 4px 0; }
|
|
3083
|
+
.proof-chips {
|
|
3084
|
+
display: flex;
|
|
3085
|
+
flex-wrap: wrap;
|
|
3086
|
+
gap: 8px;
|
|
3087
|
+
}
|
|
3088
|
+
.proof-chip {
|
|
3089
|
+
border: 1px solid ${COLORS.border};
|
|
3090
|
+
border-radius: 8px;
|
|
3091
|
+
padding: 6px 8px;
|
|
3092
|
+
color: ${COLORS.textMuted};
|
|
3093
|
+
font-size: 12px;
|
|
3094
|
+
background: #09090b;
|
|
3095
|
+
}
|
|
3096
|
+
.action-details {
|
|
3097
|
+
color: ${COLORS.textMuted};
|
|
3098
|
+
font-size: 12px;
|
|
3099
|
+
}
|
|
3100
|
+
.action-details summary {
|
|
3101
|
+
cursor: pointer;
|
|
3102
|
+
color: ${COLORS.text};
|
|
3103
|
+
font-weight: 600;
|
|
3104
|
+
}
|
|
2918
3105
|
.action-card .success-metric {
|
|
2919
3106
|
color: ${COLORS.text};
|
|
2920
3107
|
font-size: 13px;
|
|
@@ -2950,10 +3137,44 @@ table.report-table td .badge {
|
|
|
2950
3137
|
.diagnostic-card h3 { font-size: 14px; margin: 0 0 6px; }
|
|
2951
3138
|
.diagnostic-card p { margin: 0 0 8px; color: ${COLORS.textMuted}; font-size: 13px; }
|
|
2952
3139
|
.diagnostic-card ul { margin: 0; padding-left: 16px; color: ${COLORS.textMuted}; font-size: 12px; }
|
|
3140
|
+
.diagnostic-card .proof-chips { margin-top: 10px; }
|
|
2953
3141
|
.diagnostic-card.tone-positive { border-left-color: ${COLORS.positive}; }
|
|
2954
3142
|
.diagnostic-card.tone-caution { border-left-color: ${COLORS.caution}; }
|
|
2955
3143
|
.diagnostic-card.tone-negative { border-left-color: ${COLORS.negative}; }
|
|
2956
3144
|
.diagnostic-card.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
3145
|
+
.opportunity-grid {
|
|
3146
|
+
display: grid;
|
|
3147
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
3148
|
+
gap: 12px;
|
|
3149
|
+
margin-bottom: 16px;
|
|
3150
|
+
}
|
|
3151
|
+
.opportunity-card {
|
|
3152
|
+
background: ${COLORS.surface};
|
|
3153
|
+
border: 1px solid ${COLORS.border};
|
|
3154
|
+
border-radius: 8px;
|
|
3155
|
+
padding: 16px;
|
|
3156
|
+
}
|
|
3157
|
+
.opportunity-card .opportunity-score {
|
|
3158
|
+
font-size: 32px;
|
|
3159
|
+
line-height: 1;
|
|
3160
|
+
font-weight: 800;
|
|
3161
|
+
margin-bottom: 10px;
|
|
3162
|
+
}
|
|
3163
|
+
.opportunity-card .opportunity-score-suffix {
|
|
3164
|
+
font-size: 14px;
|
|
3165
|
+
font-weight: 600;
|
|
3166
|
+
color: ${COLORS.textFaint};
|
|
3167
|
+
margin-left: 4px;
|
|
3168
|
+
}
|
|
3169
|
+
.opportunity-card h3 {
|
|
3170
|
+
font-size: 14px;
|
|
3171
|
+
margin: 0 0 8px;
|
|
3172
|
+
}
|
|
3173
|
+
.opportunity-card p {
|
|
3174
|
+
color: ${COLORS.textMuted};
|
|
3175
|
+
font-size: 12px;
|
|
3176
|
+
margin: 0;
|
|
3177
|
+
}
|
|
2957
3178
|
.footer {
|
|
2958
3179
|
margin-top: 96px;
|
|
2959
3180
|
padding-top: 24px;
|
|
@@ -2962,6 +3183,14 @@ table.report-table td .badge {
|
|
|
2962
3183
|
color: ${COLORS.textFaint};
|
|
2963
3184
|
font-size: 12px;
|
|
2964
3185
|
}
|
|
3186
|
+
@media (max-width: 760px) {
|
|
3187
|
+
.container { padding: 32px 16px 72px; }
|
|
3188
|
+
.executive-hero { grid-template-columns: 1fr; }
|
|
3189
|
+
.headline-card .hero-title { font-size: 34px; }
|
|
3190
|
+
.source-bar-row { grid-template-columns: 1fr; gap: 6px; }
|
|
3191
|
+
.source-bar-value { text-align: left; }
|
|
3192
|
+
.chart-grid { grid-template-columns: 1fr; }
|
|
3193
|
+
}
|
|
2965
3194
|
@media print {
|
|
2966
3195
|
body { background: white; color: black; }
|
|
2967
3196
|
section.report-section { break-inside: avoid; }
|
|
@@ -2984,43 +3213,43 @@ function locationDisplay(location) {
|
|
|
2984
3213
|
return place ? `${location.label} (${place})` : location.label;
|
|
2985
3214
|
}
|
|
2986
3215
|
function renderHeaderLocationFragment(location) {
|
|
2987
|
-
if (!location) return " \xB7 No
|
|
2988
|
-
return ` \xB7
|
|
3216
|
+
if (!location) return " \xB7 No market set";
|
|
3217
|
+
return ` \xB7 Market: ${escapeHtml(locationDisplay(location))}`;
|
|
2989
3218
|
}
|
|
2990
3219
|
function renderLocationCard(report) {
|
|
2991
3220
|
const location = report.meta.location;
|
|
2992
3221
|
const handling = report.meta.providerLocationHandling;
|
|
2993
3222
|
if (!location && handling.length === 0) return "";
|
|
2994
|
-
const
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
};
|
|
3000
|
-
const
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
</
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
<
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
${
|
|
3223
|
+
const otherLocations = location?.otherConfiguredLabels ?? [];
|
|
3224
|
+
const weakLocationProviders = handling.filter((h) => h.treatment === "ignored" || h.treatment === "browser-geo").map((h) => h.provider);
|
|
3225
|
+
const marketValue = location ? locationDisplay(location) : "No market set";
|
|
3226
|
+
const notIncluded = otherLocations.length > 0 ? compactInlineList(otherLocations, 4) : "None";
|
|
3227
|
+
const interpretation = location ? otherLocations.length > 0 ? `${otherLocations.length} configured ${pluralize(otherLocations.length, "market")} still ${otherLocations.length === 1 ? "needs" : "need"} a matching check before cross-market recommendations.` : "Single-market report; findings can be read as the current market view." : "No geographic hint was attached to this check; read findings as default-market or national results.";
|
|
3228
|
+
const providerCopy = handling.length > 0 ? weakLocationProviders.length > 0 ? `${weakLocationProviders.length} ${pluralize(weakLocationProviders.length, "provider")} need a closer location check.` : `${handling.length} ${pluralize(handling.length, "provider")} received the market context.` : "No provider-level location metadata is available for this report.";
|
|
3229
|
+
const warning = weakLocationProviders.length > 0 ? `<div class="scope-warning">
|
|
3230
|
+
<strong>Location handling needs review</strong>
|
|
3231
|
+
${escapeHtml(compactInlineList(weakLocationProviders, 4))} used weak or indirect market handling. Treat provider-level differences cautiously.
|
|
3232
|
+
</div>` : "";
|
|
3233
|
+
return `<div class="chart-card market-scope-card">
|
|
3234
|
+
<h3>Market Scope</h3>
|
|
3235
|
+
<div class="market-scope-grid">
|
|
3236
|
+
<div class="scope-tile">
|
|
3237
|
+
<div class="scope-label">Current check</div>
|
|
3238
|
+
<div class="scope-value">${escapeHtml(marketValue)}</div>
|
|
3239
|
+
<div class="scope-copy">All findings below are scoped to this run.</div>
|
|
3240
|
+
</div>
|
|
3241
|
+
<div class="scope-tile">
|
|
3242
|
+
<div class="scope-label">Not included</div>
|
|
3243
|
+
<div class="scope-value">${escapeHtml(notIncluded)}</div>
|
|
3244
|
+
<div class="scope-copy">${escapeHtml(interpretation)}</div>
|
|
3245
|
+
</div>
|
|
3246
|
+
<div class="scope-tile">
|
|
3247
|
+
<div class="scope-label">Provider context</div>
|
|
3248
|
+
<div class="scope-value">${handling.length > 0 ? formatNumber(handling.length) : "\u2014"}</div>
|
|
3249
|
+
<div class="scope-copy">${escapeHtml(providerCopy)}</div>
|
|
3250
|
+
</div>
|
|
3251
|
+
</div>
|
|
3252
|
+
${warning}
|
|
3024
3253
|
</div>`;
|
|
3025
3254
|
}
|
|
3026
3255
|
function renderExecutiveSummary(report) {
|
|
@@ -3030,6 +3259,36 @@ function renderExecutiveSummary(report) {
|
|
|
3030
3259
|
const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
|
|
3031
3260
|
const citedFragment = s.totalQueryCount > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
|
|
3032
3261
|
const mentionedFragment = s.totalQueryCount > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
|
|
3262
|
+
const headlineTitle = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` : "No AI citation data yet";
|
|
3263
|
+
const headlineSubtitle = s.totalQueryCount > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a check to populate the first citation and mention baseline.";
|
|
3264
|
+
const priorityActions = report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan;
|
|
3265
|
+
const actionCount = dedupeReportActions(report, priorityActions).length;
|
|
3266
|
+
const heroHtml = `<div class="executive-hero">
|
|
3267
|
+
<div class="headline-card">
|
|
3268
|
+
<div>
|
|
3269
|
+
<div class="hero-kicker">Latest AI visibility check</div>
|
|
3270
|
+
<div class="hero-title">${escapeHtml(headlineTitle)}</div>
|
|
3271
|
+
</div>
|
|
3272
|
+
<div class="hero-subtitle">${escapeHtml(headlineSubtitle)}</div>
|
|
3273
|
+
</div>
|
|
3274
|
+
<div class="hero-proof-grid">
|
|
3275
|
+
<div class="hero-proof">
|
|
3276
|
+
<div class="mini-label">Citation trend</div>
|
|
3277
|
+
<div class="mini-value tone-${trendTone}">${escapeHtml(trendLabel)}</div>
|
|
3278
|
+
<div class="mini-copy">${escapeHtml(citedFragment)}</div>
|
|
3279
|
+
</div>
|
|
3280
|
+
<div class="hero-proof">
|
|
3281
|
+
<div class="mini-label">Mention coverage</div>
|
|
3282
|
+
<div class="mini-value">${s.mentionRate}%</div>
|
|
3283
|
+
<div class="mini-copy">${escapeHtml(mentionedFragment)}</div>
|
|
3284
|
+
</div>
|
|
3285
|
+
<div class="hero-proof">
|
|
3286
|
+
<div class="mini-label">Prioritized actions</div>
|
|
3287
|
+
<div class="mini-value">${formatNumber(actionCount)}</div>
|
|
3288
|
+
<div class="mini-copy">Sorted for agency follow-up.</div>
|
|
3289
|
+
</div>
|
|
3290
|
+
</div>
|
|
3291
|
+
</div>`;
|
|
3033
3292
|
const metrics = [
|
|
3034
3293
|
{
|
|
3035
3294
|
label: "Citation rate",
|
|
@@ -3048,10 +3307,11 @@ function renderExecutiveSummary(report) {
|
|
|
3048
3307
|
}
|
|
3049
3308
|
];
|
|
3050
3309
|
if (s.gsc) {
|
|
3310
|
+
const dateRange = gscDateRange(report);
|
|
3051
3311
|
metrics.push({
|
|
3052
3312
|
label: "GSC clicks",
|
|
3053
3313
|
value: formatNumber(s.gsc.clicks),
|
|
3054
|
-
delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR`
|
|
3314
|
+
delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR${dateRange ? ` \xB7 ${escapeHtml(dateRange)}` : ""}`
|
|
3055
3315
|
});
|
|
3056
3316
|
}
|
|
3057
3317
|
if (s.ga) {
|
|
@@ -3079,9 +3339,110 @@ function renderExecutiveSummary(report) {
|
|
|
3079
3339
|
id: "executive-summary",
|
|
3080
3340
|
eyebrow: "Section 1",
|
|
3081
3341
|
title: "Executive Summary",
|
|
3082
|
-
intro: "
|
|
3342
|
+
intro: "Citation = source list. Mention = answer text. They are independent signals."
|
|
3083
3343
|
},
|
|
3084
|
-
metricsHtml + findingsHtml + locationHtml
|
|
3344
|
+
heroHtml + metricsHtml + findingsHtml + locationHtml
|
|
3345
|
+
);
|
|
3346
|
+
}
|
|
3347
|
+
function deltaToneClass(direction) {
|
|
3348
|
+
if (direction === "up") return "tone-positive";
|
|
3349
|
+
if (direction === "down") return "tone-negative";
|
|
3350
|
+
return "";
|
|
3351
|
+
}
|
|
3352
|
+
function deltaArrow(direction) {
|
|
3353
|
+
if (direction === "up") return "\u2191";
|
|
3354
|
+
if (direction === "down") return "\u2193";
|
|
3355
|
+
return "\u2192";
|
|
3356
|
+
}
|
|
3357
|
+
function renderRateDeltaTile(label, delta, unit) {
|
|
3358
|
+
if (!delta) {
|
|
3359
|
+
return `<div class="metric"><div class="label">${escapeHtml(label)}</div><div class="value">\u2014</div><div class="delta">No prior data</div></div>`;
|
|
3360
|
+
}
|
|
3361
|
+
const valueSuffix = unit === "%" ? "%" : "";
|
|
3362
|
+
const deltaSign = delta.deltaAbs > 0 ? "+" : "";
|
|
3363
|
+
const deltaText = `${deltaSign}${delta.deltaAbs.toFixed(unit === "%" ? 1 : 0)}${valueSuffix} vs ${delta.prior}${valueSuffix}`;
|
|
3364
|
+
return `<div class="metric">
|
|
3365
|
+
<div class="label">${escapeHtml(label)}</div>
|
|
3366
|
+
<div class="value ${deltaToneClass(delta.direction)}">${delta.current}${valueSuffix} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
|
|
3367
|
+
<div class="delta">${deltaText}</div>
|
|
3368
|
+
</div>`;
|
|
3369
|
+
}
|
|
3370
|
+
function renderTrafficDeltaTile(label, delta, countLabel) {
|
|
3371
|
+
if (!delta) {
|
|
3372
|
+
return `<div class="metric"><div class="label">${escapeHtml(label)}</div><div class="value">\u2014</div><div class="delta">Not enough trend data</div></div>`;
|
|
3373
|
+
}
|
|
3374
|
+
const deltaSign = delta.deltaAbs > 0 ? "+" : "";
|
|
3375
|
+
const deltaText = `${deltaSign}${formatNumber(delta.deltaAbs)} ${countLabel} vs prior ${WHATS_CHANGED_PERIOD_DAYS} days`;
|
|
3376
|
+
return `<div class="metric">
|
|
3377
|
+
<div class="label">${escapeHtml(label)}</div>
|
|
3378
|
+
<div class="value ${deltaToneClass(delta.direction)}">${formatNumber(delta.current)} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
|
|
3379
|
+
<div class="delta">${deltaText}</div>
|
|
3380
|
+
</div>`;
|
|
3381
|
+
}
|
|
3382
|
+
var WHATS_CHANGED_PERIOD_DAYS = 14;
|
|
3383
|
+
function renderProviderMovements(movements) {
|
|
3384
|
+
const meaningful = movements.filter((m) => m.direction !== "flat");
|
|
3385
|
+
if (meaningful.length === 0) return "";
|
|
3386
|
+
const rows = meaningful.map((m) => {
|
|
3387
|
+
const sign = m.deltaAbs > 0 ? "+" : "";
|
|
3388
|
+
return `<tr>
|
|
3389
|
+
<td>${escapeHtml(m.provider)}</td>
|
|
3390
|
+
<td class="numeric">${m.prior}%</td>
|
|
3391
|
+
<td class="numeric">${m.current}%</td>
|
|
3392
|
+
<td class="numeric ${deltaToneClass(m.direction)}">${sign}${m.deltaAbs.toFixed(1)}% ${deltaArrow(m.direction)}</td>
|
|
3393
|
+
</tr>`;
|
|
3394
|
+
}).join("");
|
|
3395
|
+
return `<div class="chart-card"><h3>AI engine movements</h3>
|
|
3396
|
+
<table class="report-table">
|
|
3397
|
+
<thead><tr><th>Engine</th><th class="numeric">Prior</th><th class="numeric">Current</th><th class="numeric">Change</th></tr></thead>
|
|
3398
|
+
<tbody>${rows}</tbody>
|
|
3399
|
+
</table>
|
|
3400
|
+
</div>`;
|
|
3401
|
+
}
|
|
3402
|
+
function renderWinsLosses(insights2, heading, emptyMessage) {
|
|
3403
|
+
if (insights2.length === 0) {
|
|
3404
|
+
return `<div class="chart-card"><h3>${escapeHtml(heading)}</h3>
|
|
3405
|
+
<p class="section-intro">${escapeHtml(emptyMessage)}</p>
|
|
3406
|
+
</div>`;
|
|
3407
|
+
}
|
|
3408
|
+
const rows = insights2.map((i) => {
|
|
3409
|
+
const tone = severityTone(i.severity);
|
|
3410
|
+
const countChip = i.instanceCount > 1 ? ` <span class="badge tone-neutral">\xD7 ${i.instanceCount}</span>` : "";
|
|
3411
|
+
return `<tr>
|
|
3412
|
+
<td><span class="badge tone-${tone}">${escapeHtml(reportSeverityLabel(i.severity))}</span></td>
|
|
3413
|
+
<td>${escapeHtml(i.title)}${countChip}</td>
|
|
3414
|
+
<td>${escapeHtml(i.query)}</td>
|
|
3415
|
+
<td>${escapeHtml(i.provider)}</td>
|
|
3416
|
+
</tr>`;
|
|
3417
|
+
}).join("");
|
|
3418
|
+
return `<div class="chart-card"><h3>${escapeHtml(heading)}</h3>
|
|
3419
|
+
<table class="report-table">
|
|
3420
|
+
<thead><tr><th>Severity</th><th>Title</th><th>Query</th><th>Provider</th></tr></thead>
|
|
3421
|
+
<tbody>${rows}</tbody>
|
|
3422
|
+
</table>
|
|
3423
|
+
</div>`;
|
|
3424
|
+
}
|
|
3425
|
+
function renderWhatsChanged(report) {
|
|
3426
|
+
const w = report.whatsChanged;
|
|
3427
|
+
if (!w.enoughHistory && !w.gscClicksDelta && !w.aiReferralsDelta && w.wins.length === 0 && w.regressions.length === 0) {
|
|
3428
|
+
return section(
|
|
3429
|
+
{ id: "whats-changed", eyebrow: "Section 2", title: "What's Changed", intro: w.headline },
|
|
3430
|
+
renderEmpty("Trends will appear after a few more checks.")
|
|
3431
|
+
);
|
|
3432
|
+
}
|
|
3433
|
+
const rateTiles = `<div class="metric-grid">
|
|
3434
|
+
${renderRateDeltaTile("Citation rate", w.citationRate, "%")}
|
|
3435
|
+
${renderRateDeltaTile("Mention rate", w.mentionRate, "%")}
|
|
3436
|
+
${renderRateDeltaTile("Cited queries", w.citedQueryCount, "count")}
|
|
3437
|
+
${renderTrafficDeltaTile("GSC clicks", w.gscClicksDelta, "clicks")}
|
|
3438
|
+
${renderTrafficDeltaTile("AI referral sessions", w.aiReferralsDelta, "sessions")}
|
|
3439
|
+
</div>`;
|
|
3440
|
+
const movements = renderProviderMovements(w.providerMovements);
|
|
3441
|
+
const wins = renderWinsLosses(w.wins, "Wins", "No new gains in the latest check.");
|
|
3442
|
+
const regressions = renderWinsLosses(w.regressions, "Regressions", "No new regressions in the latest check.");
|
|
3443
|
+
return section(
|
|
3444
|
+
{ id: "whats-changed", eyebrow: "Section 2", title: "What's Changed", intro: w.headline },
|
|
3445
|
+
`${rateTiles}${movements}${wins}${regressions}`
|
|
3085
3446
|
);
|
|
3086
3447
|
}
|
|
3087
3448
|
function renderProviderBars(rates) {
|
|
@@ -3112,7 +3473,7 @@ function renderProviderBars(rates) {
|
|
|
3112
3473
|
}
|
|
3113
3474
|
function renderCitationMatrix(scorecard) {
|
|
3114
3475
|
if (scorecard.queries.length === 0 || scorecard.providers.length === 0) {
|
|
3115
|
-
return renderEmpty("Run a
|
|
3476
|
+
return renderEmpty("Run a check to populate the citation matrix.");
|
|
3116
3477
|
}
|
|
3117
3478
|
const headers = scorecard.providers.map((p) => `<th>${escapeHtml(p)}</th>`).join("");
|
|
3118
3479
|
const rows = scorecard.queries.map((q, qi) => {
|
|
@@ -3127,7 +3488,7 @@ function renderCitationMatrix(scorecard) {
|
|
|
3127
3488
|
}).join("");
|
|
3128
3489
|
return `<tr><td>${escapeHtml(q)}</td>${cells}</tr>`;
|
|
3129
3490
|
}).join("");
|
|
3130
|
-
const legend = '<p class="section-intro" style="margin-top:0;font-size:11px;">
|
|
3491
|
+
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>';
|
|
3131
3492
|
return `${legend}<table class="report-table">
|
|
3132
3493
|
<thead><tr><th>Query</th>${headers}</tr></thead>
|
|
3133
3494
|
<tbody>${rows}</tbody>
|
|
@@ -3139,7 +3500,7 @@ function renderCitationScorecard(report) {
|
|
|
3139
3500
|
${renderCitationMatrix(report.citationScorecard)}
|
|
3140
3501
|
`;
|
|
3141
3502
|
return section(
|
|
3142
|
-
{ id: "citation-scorecard", eyebrow: "Section
|
|
3503
|
+
{ id: "citation-scorecard", eyebrow: "Section 3", title: "Citation Scorecard", intro: "Per-engine citation and mention coverage from the latest check." },
|
|
3143
3504
|
body
|
|
3144
3505
|
);
|
|
3145
3506
|
}
|
|
@@ -3187,8 +3548,8 @@ function renderCompetitorLandscape(report) {
|
|
|
3187
3548
|
const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0;
|
|
3188
3549
|
if (noCitationData && noMentionData) {
|
|
3189
3550
|
return section(
|
|
3190
|
-
{ id: "competitor-landscape", eyebrow: "Section
|
|
3191
|
-
renderEmpty("No competitor data yet. Add competitors and run a
|
|
3551
|
+
{ id: "competitor-landscape", eyebrow: "Section 4", title: "Competitor Landscape" },
|
|
3552
|
+
renderEmpty("No competitor data yet. Add competitors and run a check.")
|
|
3192
3553
|
);
|
|
3193
3554
|
}
|
|
3194
3555
|
const mentionByDomain = new Map(mentionLandscape.competitors.map((m) => [m.domain, m]));
|
|
@@ -3219,9 +3580,9 @@ function renderCompetitorLandscape(report) {
|
|
|
3219
3580
|
return section(
|
|
3220
3581
|
{
|
|
3221
3582
|
id: "competitor-landscape",
|
|
3222
|
-
eyebrow: "Section
|
|
3583
|
+
eyebrow: "Section 4",
|
|
3223
3584
|
title: "Competitor Landscape",
|
|
3224
|
-
intro: "
|
|
3585
|
+
intro: "Who AI engines cite and mention instead of the client."
|
|
3225
3586
|
},
|
|
3226
3587
|
`${charts}${table}`
|
|
3227
3588
|
);
|
|
@@ -3262,12 +3623,32 @@ function renderCategoryBars(buckets) {
|
|
|
3262
3623
|
<div class="source-bars">${rows}</div>
|
|
3263
3624
|
</div>`;
|
|
3264
3625
|
}
|
|
3626
|
+
function renderShareBars(heading, rows, countLabel) {
|
|
3627
|
+
const visibleRows = rows.filter((r) => r.count > 0 || r.sharePct > 0);
|
|
3628
|
+
if (visibleRows.length === 0) return "";
|
|
3629
|
+
const bars = visibleRows.map((r, index) => {
|
|
3630
|
+
const pct = Math.max(0, Math.min(100, r.sharePct));
|
|
3631
|
+
const color = r.color ?? COLORS.series[index % COLORS.series.length];
|
|
3632
|
+
return `
|
|
3633
|
+
<div class="source-bar-row">
|
|
3634
|
+
<div class="source-bar-label">${escapeHtml(r.label)}</div>
|
|
3635
|
+
<div class="source-bar-track">
|
|
3636
|
+
<div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
|
|
3637
|
+
</div>
|
|
3638
|
+
<div class="source-bar-value">${formatNumber(r.count)} <span class="source-bar-pct">${escapeHtml(countLabel)} \xB7 ${r.sharePct}%</span></div>
|
|
3639
|
+
</div>`;
|
|
3640
|
+
}).join("");
|
|
3641
|
+
return `<div class="chart-card">
|
|
3642
|
+
<h3>${escapeHtml(heading)}</h3>
|
|
3643
|
+
<div class="source-bars">${bars}</div>
|
|
3644
|
+
</div>`;
|
|
3645
|
+
}
|
|
3265
3646
|
function renderAiSourceOrigin(report) {
|
|
3266
3647
|
const origin = report.aiSourceOrigin;
|
|
3267
3648
|
if (origin.categories.length === 0 && origin.topDomains.length === 0) {
|
|
3268
3649
|
return section(
|
|
3269
|
-
{ id: "ai-source-origin", eyebrow: "Section
|
|
3270
|
-
renderEmpty("No source data yet. Run a
|
|
3650
|
+
{ id: "ai-source-origin", eyebrow: "Section 5", title: "AI Citation Sources" },
|
|
3651
|
+
renderEmpty("No source data yet. Run a check first.")
|
|
3271
3652
|
);
|
|
3272
3653
|
}
|
|
3273
3654
|
const competitorBucket = origin.categories.find((c) => c.category === "competitor");
|
|
@@ -3287,9 +3668,9 @@ function renderAiSourceOrigin(report) {
|
|
|
3287
3668
|
return section(
|
|
3288
3669
|
{
|
|
3289
3670
|
id: "ai-source-origin",
|
|
3290
|
-
eyebrow: "Section
|
|
3671
|
+
eyebrow: "Section 5",
|
|
3291
3672
|
title: "AI Citation Sources",
|
|
3292
|
-
intro: "
|
|
3673
|
+
intro: "External domains AI engines cited most in the latest check."
|
|
3293
3674
|
},
|
|
3294
3675
|
`${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
|
|
3295
3676
|
);
|
|
@@ -3330,7 +3711,7 @@ function renderGsc(report) {
|
|
|
3330
3711
|
const gsc = report.gsc;
|
|
3331
3712
|
if (!gsc) {
|
|
3332
3713
|
return section(
|
|
3333
|
-
{ id: "gsc", eyebrow: "Section
|
|
3714
|
+
{ id: "gsc", eyebrow: "Section 6", title: "GSC Performance" },
|
|
3334
3715
|
renderEmpty("Connect Google Search Console to populate this section.")
|
|
3335
3716
|
);
|
|
3336
3717
|
}
|
|
@@ -3343,13 +3724,16 @@ function renderGsc(report) {
|
|
|
3343
3724
|
<td class="numeric">${q.avgPosition.toFixed(1)}</td>
|
|
3344
3725
|
<td><span class="badge tone-neutral">${escapeHtml(q.category)}</span></td>
|
|
3345
3726
|
</tr>`).join("");
|
|
3346
|
-
const
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3727
|
+
const categoryBars = renderShareBars(
|
|
3728
|
+
"Search demand by intent",
|
|
3729
|
+
gsc.categoryBreakdown.map((c, index) => ({
|
|
3730
|
+
label: c.category,
|
|
3731
|
+
count: c.clicks,
|
|
3732
|
+
sharePct: c.sharePct,
|
|
3733
|
+
color: COLORS.series[index % COLORS.series.length]
|
|
3734
|
+
})),
|
|
3735
|
+
"clicks"
|
|
3736
|
+
);
|
|
3353
3737
|
const trendChart = renderLineChart(
|
|
3354
3738
|
gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
|
|
3355
3739
|
COLORS.accent,
|
|
@@ -3358,18 +3742,19 @@ function renderGsc(report) {
|
|
|
3358
3742
|
const crossoverBlocks = [];
|
|
3359
3743
|
if (gsc.trackedButNoGsc.length > 0) {
|
|
3360
3744
|
crossoverBlocks.push(`<div class="chart-card"><h3>AEO queries without search demand</h3>
|
|
3361
|
-
<p class="section-intro">
|
|
3362
|
-
|
|
3745
|
+
<p class="section-intro">Review whether these still belong in the tracking set.</p>
|
|
3746
|
+
${renderProofChips(gsc.trackedButNoGsc, 6)}
|
|
3363
3747
|
</div>`);
|
|
3364
3748
|
}
|
|
3365
3749
|
if (gsc.gscButNotTracked.length > 0) {
|
|
3366
3750
|
crossoverBlocks.push(`<div class="chart-card"><h3>Search queries you should track</h3>
|
|
3367
|
-
<p class="section-intro">
|
|
3368
|
-
|
|
3751
|
+
<p class="section-intro">High-impression candidates to add to AEO tracking.</p>
|
|
3752
|
+
${renderProofChips(gsc.gscButNotTracked, 6)}
|
|
3369
3753
|
</div>`);
|
|
3370
3754
|
}
|
|
3755
|
+
const dateRange = gscDateRange(report);
|
|
3371
3756
|
return section(
|
|
3372
|
-
{ id: "gsc", eyebrow: "Section
|
|
3757
|
+
{ id: "gsc", eyebrow: "Section 6", title: "GSC Performance", intro: `Search demand signals to compare against AI visibility${dateRange ? ` for ${dateRange}` : ""}.` },
|
|
3373
3758
|
`<div class="metric-grid">
|
|
3374
3759
|
<div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
|
|
3375
3760
|
<div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
|
|
@@ -3383,12 +3768,7 @@ function renderGsc(report) {
|
|
|
3383
3768
|
<tbody>${rows}</tbody>
|
|
3384
3769
|
</table>
|
|
3385
3770
|
</div>
|
|
3386
|
-
|
|
3387
|
-
<table class="report-table">
|
|
3388
|
-
<thead><tr><th>Category</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">Share</th></tr></thead>
|
|
3389
|
-
<tbody>${breakdownRows}</tbody>
|
|
3390
|
-
</table>
|
|
3391
|
-
</div>
|
|
3771
|
+
${categoryBars}
|
|
3392
3772
|
${crossoverBlocks.join("\n")}`
|
|
3393
3773
|
);
|
|
3394
3774
|
}
|
|
@@ -3396,7 +3776,7 @@ function renderGa(report) {
|
|
|
3396
3776
|
const ga = report.ga;
|
|
3397
3777
|
if (!ga) {
|
|
3398
3778
|
return section(
|
|
3399
|
-
{ id: "ga", eyebrow: "Section
|
|
3779
|
+
{ id: "ga", eyebrow: "Section 7", title: "GA4 Traffic" },
|
|
3400
3780
|
renderEmpty("Connect Google Analytics 4 to populate this section.")
|
|
3401
3781
|
);
|
|
3402
3782
|
}
|
|
@@ -3407,14 +3787,18 @@ function renderGa(report) {
|
|
|
3407
3787
|
<td class="numeric">${formatNumber(p.users)}</td>
|
|
3408
3788
|
<td class="numeric">${formatNumber(p.organicSessions)}</td>
|
|
3409
3789
|
</tr>`).join("");
|
|
3410
|
-
const
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3790
|
+
const channelBars = renderShareBars(
|
|
3791
|
+
"Channel mix",
|
|
3792
|
+
ga.channelBreakdown.map((c, index) => ({
|
|
3793
|
+
label: c.channel,
|
|
3794
|
+
count: c.sessions,
|
|
3795
|
+
sharePct: c.sharePct,
|
|
3796
|
+
color: COLORS.series[index % COLORS.series.length]
|
|
3797
|
+
})),
|
|
3798
|
+
"sessions"
|
|
3799
|
+
);
|
|
3416
3800
|
return section(
|
|
3417
|
-
{ id: "ga", eyebrow: "Section
|
|
3801
|
+
{ id: "ga", eyebrow: "Section 7", title: "GA4 Traffic", intro: `Site traffic from ${formatDate(ga.periodStart)} to ${formatDate(ga.periodEnd)}.` },
|
|
3418
3802
|
`<div class="metric-grid">
|
|
3419
3803
|
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
|
|
3420
3804
|
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
|
|
@@ -3426,28 +3810,27 @@ function renderGa(report) {
|
|
|
3426
3810
|
<tbody>${pageRows}</tbody>
|
|
3427
3811
|
</table>
|
|
3428
3812
|
</div>
|
|
3429
|
-
|
|
3430
|
-
<table class="report-table">
|
|
3431
|
-
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
3432
|
-
<tbody>${channelRows}</tbody>
|
|
3433
|
-
</table>
|
|
3434
|
-
</div>`
|
|
3813
|
+
${channelBars}`
|
|
3435
3814
|
);
|
|
3436
3815
|
}
|
|
3437
3816
|
function renderSocial(report) {
|
|
3438
3817
|
const social = report.socialReferrals;
|
|
3439
3818
|
if (!social) {
|
|
3440
3819
|
return section(
|
|
3441
|
-
{ id: "social-referrals", eyebrow: "Section
|
|
3820
|
+
{ id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals" },
|
|
3442
3821
|
renderEmpty("No social referral data yet.")
|
|
3443
3822
|
);
|
|
3444
3823
|
}
|
|
3445
|
-
const
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3824
|
+
const channelBars = renderShareBars(
|
|
3825
|
+
"Social channel mix",
|
|
3826
|
+
social.channels.map((c, index) => ({
|
|
3827
|
+
label: c.channelGroup,
|
|
3828
|
+
count: c.sessions,
|
|
3829
|
+
sharePct: c.sharePct,
|
|
3830
|
+
color: COLORS.series[index % COLORS.series.length]
|
|
3831
|
+
})),
|
|
3832
|
+
"sessions"
|
|
3833
|
+
);
|
|
3451
3834
|
const campaignRows = social.topCampaigns.map((c) => `
|
|
3452
3835
|
<tr>
|
|
3453
3836
|
<td>${escapeHtml(c.source)}</td>
|
|
@@ -3455,18 +3838,13 @@ function renderSocial(report) {
|
|
|
3455
3838
|
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
3456
3839
|
</tr>`).join("");
|
|
3457
3840
|
return section(
|
|
3458
|
-
{ id: "social-referrals", eyebrow: "Section
|
|
3841
|
+
{ id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals", intro: "Social traffic split by channel and campaign." },
|
|
3459
3842
|
`<div class="metric-grid">
|
|
3460
3843
|
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
|
|
3461
3844
|
<div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
|
|
3462
3845
|
<div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
|
|
3463
3846
|
</div>
|
|
3464
|
-
|
|
3465
|
-
<table class="report-table">
|
|
3466
|
-
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
3467
|
-
<tbody>${channelRows}</tbody>
|
|
3468
|
-
</table>
|
|
3469
|
-
</div>
|
|
3847
|
+
${channelBars}
|
|
3470
3848
|
<div class="chart-card"><h3>Top campaigns</h3>
|
|
3471
3849
|
<table class="report-table">
|
|
3472
3850
|
<thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
|
|
@@ -3479,17 +3857,20 @@ function renderAiReferrals(report) {
|
|
|
3479
3857
|
const ai = report.aiReferrals;
|
|
3480
3858
|
if (!ai) {
|
|
3481
3859
|
return section(
|
|
3482
|
-
{ id: "ai-referrals", eyebrow: "Section
|
|
3860
|
+
{ id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic" },
|
|
3483
3861
|
renderEmpty("No AI referral traffic detected yet.")
|
|
3484
3862
|
);
|
|
3485
3863
|
}
|
|
3486
|
-
const
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3864
|
+
const sourceBars = renderShareBars(
|
|
3865
|
+
"AI sessions by source",
|
|
3866
|
+
ai.bySource.map((s, index) => ({
|
|
3867
|
+
label: s.source,
|
|
3868
|
+
count: s.sessions,
|
|
3869
|
+
sharePct: s.sharePct,
|
|
3870
|
+
color: COLORS.series[(index + 2) % COLORS.series.length]
|
|
3871
|
+
})),
|
|
3872
|
+
"sessions"
|
|
3873
|
+
);
|
|
3493
3874
|
const pageRows = ai.topLandingPages.map((p) => `
|
|
3494
3875
|
<tr>
|
|
3495
3876
|
<td class="page-cell">${formatLandingPageHtml(p.page)}</td>
|
|
@@ -3502,18 +3883,13 @@ function renderAiReferrals(report) {
|
|
|
3502
3883
|
"AI referral sessions over time"
|
|
3503
3884
|
);
|
|
3504
3885
|
return section(
|
|
3505
|
-
{ id: "ai-referrals", eyebrow: "Section
|
|
3886
|
+
{ id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic", intro: "Traffic arriving from AI answer engines." },
|
|
3506
3887
|
`<div class="metric-grid">
|
|
3507
3888
|
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
|
|
3508
3889
|
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
|
|
3509
3890
|
</div>
|
|
3510
3891
|
${trendChart}
|
|
3511
|
-
|
|
3512
|
-
<table class="report-table">
|
|
3513
|
-
<thead><tr><th>Source</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Share</th></tr></thead>
|
|
3514
|
-
<tbody>${sourceRows}</tbody>
|
|
3515
|
-
</table>
|
|
3516
|
-
</div>
|
|
3892
|
+
${sourceBars}
|
|
3517
3893
|
<div class="chart-card"><h3>Top AI landing pages</h3>
|
|
3518
3894
|
<table class="report-table">
|
|
3519
3895
|
<thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th></tr></thead>
|
|
@@ -3526,7 +3902,7 @@ function renderIndexingHealth(report) {
|
|
|
3526
3902
|
const ih = report.indexingHealth;
|
|
3527
3903
|
if (!ih) {
|
|
3528
3904
|
return section(
|
|
3529
|
-
{ id: "indexing-health", eyebrow: "Section
|
|
3905
|
+
{ id: "indexing-health", eyebrow: "Section 10", title: "Indexing Health" },
|
|
3530
3906
|
renderEmpty("Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.")
|
|
3531
3907
|
);
|
|
3532
3908
|
}
|
|
@@ -3548,7 +3924,7 @@ function renderIndexingHealth(report) {
|
|
|
3548
3924
|
}).join("");
|
|
3549
3925
|
const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
|
|
3550
3926
|
return section(
|
|
3551
|
-
{ id: "indexing-health", eyebrow: "Section
|
|
3927
|
+
{ id: "indexing-health", eyebrow: "Section 10", title: "Indexing Health", intro: `Pages absent from ${ih.provider === "google" ? "Google" : "Bing"} are harder for AI engines to retrieve.` },
|
|
3552
3928
|
`<div class="metric-grid">
|
|
3553
3929
|
<div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
|
|
3554
3930
|
<div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
|
|
@@ -3565,14 +3941,14 @@ function renderCitationsTrend(report) {
|
|
|
3565
3941
|
const trend = report.citationsTrend;
|
|
3566
3942
|
if (trend.length === 0) {
|
|
3567
3943
|
return section(
|
|
3568
|
-
{ id: "citations-trend", eyebrow: "Section
|
|
3569
|
-
renderEmpty("Run multiple
|
|
3944
|
+
{ id: "citations-trend", eyebrow: "Section 11", title: "Citations Over Time" },
|
|
3945
|
+
renderEmpty("Run multiple checks to see a trend.")
|
|
3570
3946
|
);
|
|
3571
3947
|
}
|
|
3572
3948
|
if (isTrendBaseline(trend)) {
|
|
3573
3949
|
return section(
|
|
3574
|
-
{ id: "citations-trend", eyebrow: "Section
|
|
3575
|
-
renderEmpty(`
|
|
3950
|
+
{ id: "citations-trend", eyebrow: "Section 11", title: "Citations Over Time" },
|
|
3951
|
+
renderEmpty(`Building baseline (${trend.length} of ${MIN_TREND_POINTS} checks completed). Trend will appear once more checks are recorded.`)
|
|
3576
3952
|
);
|
|
3577
3953
|
}
|
|
3578
3954
|
const chart = renderLineChart(
|
|
@@ -3588,11 +3964,11 @@ function renderCitationsTrend(report) {
|
|
|
3588
3964
|
<td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
|
|
3589
3965
|
</tr>`).join("");
|
|
3590
3966
|
return section(
|
|
3591
|
-
{ id: "citations-trend", eyebrow: "Section
|
|
3967
|
+
{ id: "citations-trend", eyebrow: "Section 11", title: "Citations Over Time", intro: "Citation coverage across recent checks." },
|
|
3592
3968
|
`${chart}
|
|
3593
|
-
<div class="chart-card"><h3>
|
|
3969
|
+
<div class="chart-card"><h3>Check-by-check breakdown</h3>
|
|
3594
3970
|
<table class="report-table">
|
|
3595
|
-
<thead><tr><th>
|
|
3971
|
+
<thead><tr><th>Check</th><th class="numeric">Cited queries</th><th>Per-engine rates</th></tr></thead>
|
|
3596
3972
|
<tbody>${rows}</tbody>
|
|
3597
3973
|
</table>
|
|
3598
3974
|
</div>`
|
|
@@ -3602,8 +3978,8 @@ function renderInsights(report) {
|
|
|
3602
3978
|
const list = report.insights;
|
|
3603
3979
|
if (list.length === 0) {
|
|
3604
3980
|
return section(
|
|
3605
|
-
{ id: "insights", eyebrow: "Section
|
|
3606
|
-
renderEmpty("No insights yet \u2014 run a
|
|
3981
|
+
{ id: "insights", eyebrow: "Section 12", title: "Insights & Alerts" },
|
|
3982
|
+
renderEmpty("No insights yet \u2014 run a check to generate alerts.")
|
|
3607
3983
|
);
|
|
3608
3984
|
}
|
|
3609
3985
|
const haveDeduped = list.every((i) => typeof i.instanceCount === "number");
|
|
@@ -3611,48 +3987,62 @@ function renderInsights(report) {
|
|
|
3611
3987
|
const tone = severityTone(i.severity);
|
|
3612
3988
|
const countChip = count > 1 ? ` <span class="badge tone-neutral">\xD7 ${count}</span>` : "";
|
|
3613
3989
|
return `<tr>
|
|
3614
|
-
<td><span class="badge tone-${tone}">${escapeHtml(i.severity)}</span></td>
|
|
3615
|
-
<td>${escapeHtml(i.title)}${countChip}</td>
|
|
3616
|
-
<td>${escapeHtml(i.query)}</td>
|
|
3617
|
-
<td>${escapeHtml(i.provider)}</td>
|
|
3618
|
-
<td>${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
|
|
3990
|
+
<td class="col-severity"><span class="badge tone-${tone}">${escapeHtml(reportSeverityLabel(i.severity))}</span></td>
|
|
3991
|
+
<td class="col-title">${escapeHtml(i.title)}${countChip}</td>
|
|
3992
|
+
<td class="col-query">${escapeHtml(i.query)}</td>
|
|
3993
|
+
<td class="col-provider">${escapeHtml(i.provider)}</td>
|
|
3994
|
+
<td class="col-recommendation">${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
|
|
3619
3995
|
</tr>`;
|
|
3620
3996
|
}).join("");
|
|
3621
3997
|
return section(
|
|
3622
|
-
{ id: "insights", eyebrow: "Section
|
|
3623
|
-
`<table class="report-table">
|
|
3624
|
-
<thead><tr
|
|
3998
|
+
{ id: "insights", eyebrow: "Section 12", title: "Insights & Alerts", intro: "Regressions, gains, and recurring alerts ordered by severity." },
|
|
3999
|
+
`<table class="report-table insights-table">
|
|
4000
|
+
<thead><tr>
|
|
4001
|
+
<th class="col-severity">Severity</th>
|
|
4002
|
+
<th class="col-title">Title</th>
|
|
4003
|
+
<th class="col-query">Query</th>
|
|
4004
|
+
<th class="col-provider">Provider</th>
|
|
4005
|
+
<th class="col-recommendation">Recommendation</th>
|
|
4006
|
+
</tr></thead>
|
|
3625
4007
|
<tbody>${rows}</tbody>
|
|
3626
4008
|
</table>`
|
|
3627
4009
|
);
|
|
3628
4010
|
}
|
|
3629
4011
|
function renderOpportunities(report) {
|
|
3630
|
-
const opps = report
|
|
4012
|
+
const opps = dedupeReportOpportunities(report);
|
|
3631
4013
|
if (opps.length === 0) return "";
|
|
3632
4014
|
const canonical = report.meta.project.canonicalDomain;
|
|
4015
|
+
const highlights = `<div class="opportunity-grid">
|
|
4016
|
+
${opps.slice(0, 3).map((o) => `<article class="opportunity-card">
|
|
4017
|
+
<div class="opportunity-score" title="Opportunity score (0\u2013100, higher = stronger)">${Math.round(o.score)}<span class="opportunity-score-suffix">/100</span></div>
|
|
4018
|
+
<h3>${escapeHtml(o.query)}</h3>
|
|
4019
|
+
<p>${escapeHtml(contentActionLabel(o.action))} \xB7 ${escapeHtml(actionConfidenceLabel(o.actionConfidence))} confidence</p>
|
|
4020
|
+
${renderProofChips(o.drivers, 2)}
|
|
4021
|
+
</article>`).join("")}
|
|
4022
|
+
</div>`;
|
|
3633
4023
|
const rows = opps.slice(0, 10).map((o) => {
|
|
3634
4024
|
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>';
|
|
3635
4025
|
const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
|
|
3636
4026
|
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>';
|
|
3637
4027
|
return `<tr>
|
|
3638
4028
|
<td>${escapeHtml(o.query)}</td>
|
|
3639
|
-
<td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
|
|
3640
|
-
<td class="numeric">${Math.round(o.score)}</td>
|
|
4029
|
+
<td><span class="badge tone-neutral">${escapeHtml(contentActionLabel(o.action))}</span></td>
|
|
4030
|
+
<td class="numeric" title="Opportunity score (0\u2013100)">${Math.round(o.score)}</td>
|
|
3641
4031
|
<td>${drivers}</td>
|
|
3642
4032
|
<td>${ourPage}</td>
|
|
3643
4033
|
<td>${winning}</td>
|
|
3644
|
-
<td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
|
|
4034
|
+
<td><span class="badge tone-neutral">${escapeHtml(actionConfidenceLabel(o.actionConfidence))}</span></td>
|
|
3645
4035
|
</tr>`;
|
|
3646
4036
|
}).join("");
|
|
3647
4037
|
return section(
|
|
3648
4038
|
{
|
|
3649
4039
|
id: "content-opportunities",
|
|
3650
|
-
eyebrow: "Section
|
|
4040
|
+
eyebrow: "Section 13",
|
|
3651
4041
|
title: "Content Opportunities",
|
|
3652
|
-
intro: "Queries where
|
|
4042
|
+
intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger."
|
|
3653
4043
|
},
|
|
3654
|
-
|
|
3655
|
-
<thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
|
|
4044
|
+
`${highlights}<table class="report-table">
|
|
4045
|
+
<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>
|
|
3656
4046
|
<tbody>${rows}</tbody>
|
|
3657
4047
|
</table>`
|
|
3658
4048
|
);
|
|
@@ -3673,9 +4063,9 @@ function renderContentGaps(report) {
|
|
|
3673
4063
|
return section(
|
|
3674
4064
|
{
|
|
3675
4065
|
id: "content-gaps",
|
|
3676
|
-
eyebrow: "Section
|
|
4066
|
+
eyebrow: "Section 14",
|
|
3677
4067
|
title: "Content Gaps",
|
|
3678
|
-
intro:
|
|
4068
|
+
intro: "Tracked queries where competitors are cited and the client is missing."
|
|
3679
4069
|
},
|
|
3680
4070
|
`<table class="report-table">
|
|
3681
4071
|
<thead><tr><th>Query</th><th class="numeric">Competitors cited</th><th>Domains</th><th class="numeric">Miss rate</th></tr></thead>
|
|
@@ -3687,7 +4077,7 @@ function renderRecommendedNextSteps(report) {
|
|
|
3687
4077
|
const steps = report.recommendedNextSteps;
|
|
3688
4078
|
if (steps.length === 0) {
|
|
3689
4079
|
return section(
|
|
3690
|
-
{ id: "recommended-next-steps", eyebrow: "Section
|
|
4080
|
+
{ id: "recommended-next-steps", eyebrow: "Section 15", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
|
|
3691
4081
|
renderEmpty("No outstanding actions.")
|
|
3692
4082
|
);
|
|
3693
4083
|
}
|
|
@@ -3698,7 +4088,7 @@ function renderRecommendedNextSteps(report) {
|
|
|
3698
4088
|
<span class="rationale">${escapeHtml(s.rationale)}</span>
|
|
3699
4089
|
</div>`).join("");
|
|
3700
4090
|
return section(
|
|
3701
|
-
{ id: "recommended-next-steps", eyebrow: "Section
|
|
4091
|
+
{ id: "recommended-next-steps", eyebrow: "Section 15", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
|
|
3702
4092
|
`<div class="steps">${items}</div>`
|
|
3703
4093
|
);
|
|
3704
4094
|
}
|
|
@@ -3708,33 +4098,45 @@ function actionAudienceMatches(action, audience) {
|
|
|
3708
4098
|
function renderActionCards(actions) {
|
|
3709
4099
|
if (actions.length === 0) return renderEmpty("No prioritized actions yet.");
|
|
3710
4100
|
return `<div class="action-card-grid">
|
|
3711
|
-
${actions.map((action) => {
|
|
4101
|
+
${actions.map((action, idx) => {
|
|
3712
4102
|
const tone = reportActionTone(action);
|
|
3713
4103
|
const why = action.why.length > 0 ? `<ul>${action.why.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
|
|
3714
4104
|
const evidence = action.evidence.length > 0 ? `<ul>${action.evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
|
|
4105
|
+
const proof = renderProofChips(action.evidence.length > 0 ? action.evidence : action.why, 3);
|
|
4106
|
+
const details = why || evidence ? `<details class="action-details">
|
|
4107
|
+
<summary>Evidence details</summary>
|
|
4108
|
+
${why ? `<div><strong>Why</strong>${why}</div>` : ""}
|
|
4109
|
+
${evidence ? `<div><strong>Evidence</strong>${evidence}</div>` : ""}
|
|
4110
|
+
</details>` : "";
|
|
3715
4111
|
return `<article class="action-card">
|
|
3716
|
-
<div class="action-
|
|
3717
|
-
<
|
|
3718
|
-
<
|
|
3719
|
-
|
|
4112
|
+
<div class="action-head">
|
|
4113
|
+
<div class="action-rank" title="Impact rank \u2014 1 is the highest-leverage action">${idx + 1}</div>
|
|
4114
|
+
<div>
|
|
4115
|
+
<div class="action-meta">
|
|
4116
|
+
<span class="badge tone-${tone}">${escapeHtml(reportHorizonLabel(action.horizon))}</span>
|
|
4117
|
+
<span class="badge tone-neutral">${escapeHtml(reportActionCategoryLabel(action.category))}</span>
|
|
4118
|
+
<span class="badge tone-neutral">${escapeHtml(reportConfidenceLabel(action.confidence))} confidence</span>
|
|
4119
|
+
</div>
|
|
4120
|
+
<h3>${escapeHtml(action.title)}</h3>
|
|
4121
|
+
</div>
|
|
3720
4122
|
</div>
|
|
3721
|
-
<h3>${escapeHtml(action.title)}</h3>
|
|
3722
4123
|
<p>${escapeHtml(action.action)}</p>
|
|
3723
|
-
${
|
|
3724
|
-
${
|
|
3725
|
-
<div class="success-metric"><strong>
|
|
4124
|
+
${proof}
|
|
4125
|
+
${details}
|
|
4126
|
+
<div class="success-metric"><strong>Win condition:</strong> ${escapeHtml(action.successMetric)}</div>
|
|
3726
4127
|
</article>`;
|
|
3727
4128
|
}).join("")}
|
|
3728
4129
|
</div>`;
|
|
3729
4130
|
}
|
|
3730
4131
|
function renderAudienceActionPlan(report, audience) {
|
|
3731
|
-
const
|
|
4132
|
+
const rawActions = audience === "client" ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter((a) => actionAudienceMatches(a, audience));
|
|
4133
|
+
const actions = dedupeReportActions(report, rawActions);
|
|
3732
4134
|
return section(
|
|
3733
4135
|
{
|
|
3734
4136
|
id: audience === "client" ? "client-action-plan" : "agency-action-plan",
|
|
3735
4137
|
eyebrow: audience === "client" ? "Client actions" : "Agency actions",
|
|
3736
4138
|
title: audience === "client" ? "What We Recommend Next" : "Agency Action Plan",
|
|
3737
|
-
intro: audience === "client" ? "
|
|
4139
|
+
intro: audience === "client" ? "The short list to approve and execute." : "The highest-leverage work, sorted by urgency and evidence strength."
|
|
3738
4140
|
},
|
|
3739
4141
|
renderActionCards(actions)
|
|
3740
4142
|
);
|
|
@@ -3786,11 +4188,12 @@ function renderClientEvidenceSummary(report) {
|
|
|
3786
4188
|
<ul><li>${formatNumber(report.indexingHealth.indexed)} indexed</li><li>${formatNumber(report.indexingHealth.notIndexed)} not indexed</li></ul>
|
|
3787
4189
|
</div>`);
|
|
3788
4190
|
}
|
|
3789
|
-
|
|
4191
|
+
const opportunities = dedupeReportOpportunities(report);
|
|
4192
|
+
if (opportunities.length > 0) {
|
|
3790
4193
|
evidenceCards.push(`<div class="diagnostic-card tone-caution">
|
|
3791
4194
|
<h3>Content opportunities</h3>
|
|
3792
4195
|
<p>Canonry found topics where better content could improve AI citations.</p>
|
|
3793
|
-
<ul>${
|
|
4196
|
+
<ul>${opportunities.slice(0, 5).map((o) => `<li>${escapeHtml(o.query)}: ${escapeHtml(o.action)} (${Math.round(o.score)})</li>`).join("")}</ul>
|
|
3794
4197
|
</div>`);
|
|
3795
4198
|
}
|
|
3796
4199
|
return section(
|
|
@@ -3804,12 +4207,12 @@ function renderClientEvidenceSummary(report) {
|
|
|
3804
4207
|
);
|
|
3805
4208
|
}
|
|
3806
4209
|
function renderAgencyDiagnostics(report) {
|
|
3807
|
-
const diagnostics = report.agencyDiagnostics.diagnostics;
|
|
4210
|
+
const diagnostics = report.agencyDiagnostics.diagnostics.filter((d) => d.title !== "Location caveat");
|
|
3808
4211
|
const body = diagnostics.length > 0 ? `<div class="diagnostics-grid">
|
|
3809
4212
|
${diagnostics.map((d) => `<div class="diagnostic-card tone-${d.severity}">
|
|
3810
4213
|
<h3>${escapeHtml(d.title)}</h3>
|
|
3811
4214
|
<p>${escapeHtml(d.detail)}</p>
|
|
3812
|
-
${d.evidence
|
|
4215
|
+
${renderProofChips(d.evidence, 3)}
|
|
3813
4216
|
</div>`).join("")}
|
|
3814
4217
|
</div>` : renderEmpty("No agency diagnostics available yet.");
|
|
3815
4218
|
return section(
|
|
@@ -3817,7 +4220,7 @@ function renderAgencyDiagnostics(report) {
|
|
|
3817
4220
|
id: "agency-diagnostics",
|
|
3818
4221
|
eyebrow: "Agency diagnostics",
|
|
3819
4222
|
title: "Technical Diagnostics",
|
|
3820
|
-
intro: "
|
|
4223
|
+
intro: "Fast-read operator flags behind the action plan."
|
|
3821
4224
|
},
|
|
3822
4225
|
body
|
|
3823
4226
|
);
|
|
@@ -3830,10 +4233,12 @@ function renderReportHtml(report, opts = {}) {
|
|
|
3830
4233
|
const title = opts.title ?? `Canonry ${audience} report \u2014 ${report.meta.project.displayName}`;
|
|
3831
4234
|
const sections = audience === "client" ? [
|
|
3832
4235
|
renderClientSummary(report),
|
|
4236
|
+
renderWhatsChanged(report),
|
|
3833
4237
|
renderAudienceActionPlan(report, "client"),
|
|
3834
4238
|
renderClientEvidenceSummary(report)
|
|
3835
4239
|
].join("\n") : [
|
|
3836
4240
|
renderExecutiveSummary(report),
|
|
4241
|
+
renderWhatsChanged(report),
|
|
3837
4242
|
renderAudienceActionPlan(report, "agency"),
|
|
3838
4243
|
renderAgencyDiagnostics(report),
|
|
3839
4244
|
renderCitationScorecard(report),
|
|
@@ -3911,6 +4316,7 @@ function loadOrchestratorInput(db, project, locationFilter = void 0) {
|
|
|
3911
4316
|
ownDomain,
|
|
3912
4317
|
competitors: trackedCompetitors,
|
|
3913
4318
|
candidateQueries,
|
|
4319
|
+
queryIntentModifiers: buildQueryIntentModifiers(project, locationFilter),
|
|
3914
4320
|
inventory,
|
|
3915
4321
|
wpSchemaAudit: /* @__PURE__ */ new Map(),
|
|
3916
4322
|
gaTrafficByPage,
|
|
@@ -3920,6 +4326,74 @@ function loadOrchestratorInput(db, project, locationFilter = void 0) {
|
|
|
3920
4326
|
inProgressActions: /* @__PURE__ */ new Map()
|
|
3921
4327
|
};
|
|
3922
4328
|
}
|
|
4329
|
+
function buildQueryIntentModifiers(project, locationFilter) {
|
|
4330
|
+
if (locationFilter === void 0 || locationFilter === null) return [];
|
|
4331
|
+
const locations = parseJsonColumn(project.locations, []);
|
|
4332
|
+
const currentLocation = locations.find((location) => location.label === locationFilter);
|
|
4333
|
+
const raw = currentLocation ? [
|
|
4334
|
+
currentLocation.label,
|
|
4335
|
+
currentLocation.city,
|
|
4336
|
+
currentLocation.region,
|
|
4337
|
+
regionAbbreviation(currentLocation.region),
|
|
4338
|
+
currentLocation.country
|
|
4339
|
+
] : [locationFilter];
|
|
4340
|
+
return [...new Set(raw.map((value) => value.trim().toLowerCase()).filter(Boolean))];
|
|
4341
|
+
}
|
|
4342
|
+
function regionAbbreviation(region) {
|
|
4343
|
+
return US_REGION_ABBREVIATIONS[region.trim().toLowerCase()] ?? "";
|
|
4344
|
+
}
|
|
4345
|
+
var US_REGION_ABBREVIATIONS = {
|
|
4346
|
+
alabama: "al",
|
|
4347
|
+
alaska: "ak",
|
|
4348
|
+
arizona: "az",
|
|
4349
|
+
arkansas: "ar",
|
|
4350
|
+
california: "ca",
|
|
4351
|
+
colorado: "co",
|
|
4352
|
+
connecticut: "ct",
|
|
4353
|
+
delaware: "de",
|
|
4354
|
+
florida: "fl",
|
|
4355
|
+
georgia: "ga",
|
|
4356
|
+
hawaii: "hi",
|
|
4357
|
+
idaho: "id",
|
|
4358
|
+
illinois: "il",
|
|
4359
|
+
indiana: "in",
|
|
4360
|
+
iowa: "ia",
|
|
4361
|
+
kansas: "ks",
|
|
4362
|
+
kentucky: "ky",
|
|
4363
|
+
louisiana: "la",
|
|
4364
|
+
maine: "me",
|
|
4365
|
+
maryland: "md",
|
|
4366
|
+
massachusetts: "ma",
|
|
4367
|
+
michigan: "mi",
|
|
4368
|
+
minnesota: "mn",
|
|
4369
|
+
mississippi: "ms",
|
|
4370
|
+
missouri: "mo",
|
|
4371
|
+
montana: "mt",
|
|
4372
|
+
nebraska: "ne",
|
|
4373
|
+
nevada: "nv",
|
|
4374
|
+
"new hampshire": "nh",
|
|
4375
|
+
"new jersey": "nj",
|
|
4376
|
+
"new mexico": "nm",
|
|
4377
|
+
"new york": "ny",
|
|
4378
|
+
"north carolina": "nc",
|
|
4379
|
+
"north dakota": "nd",
|
|
4380
|
+
ohio: "oh",
|
|
4381
|
+
oklahoma: "ok",
|
|
4382
|
+
oregon: "or",
|
|
4383
|
+
pennsylvania: "pa",
|
|
4384
|
+
"rhode island": "ri",
|
|
4385
|
+
"south carolina": "sc",
|
|
4386
|
+
"south dakota": "sd",
|
|
4387
|
+
tennessee: "tn",
|
|
4388
|
+
texas: "tx",
|
|
4389
|
+
utah: "ut",
|
|
4390
|
+
vermont: "vt",
|
|
4391
|
+
virginia: "va",
|
|
4392
|
+
washington: "wa",
|
|
4393
|
+
"west virginia": "wv",
|
|
4394
|
+
wisconsin: "wi",
|
|
4395
|
+
wyoming: "wy"
|
|
4396
|
+
};
|
|
3923
4397
|
function listQueries(db, projectId) {
|
|
3924
4398
|
const rows = db.select({ text: queries.query }).from(queries).where(eq12(queries.projectId, projectId)).all();
|
|
3925
4399
|
return rows.map((r) => r.text);
|
|
@@ -4158,6 +4632,14 @@ var TOP_LANDING_PAGES_LIMIT = 20;
|
|
|
4158
4632
|
var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
|
|
4159
4633
|
var TOP_CAMPAIGN_LIMIT = 10;
|
|
4160
4634
|
var INSIGHT_LOOKBACK_RUNS = 5;
|
|
4635
|
+
var REPORT_WINDOW_DAYS = 30;
|
|
4636
|
+
function windowStartDate(endDate, windowDays) {
|
|
4637
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(endDate);
|
|
4638
|
+
if (!m) return endDate;
|
|
4639
|
+
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
4640
|
+
d.setUTCDate(d.getUTCDate() - (windowDays - 1));
|
|
4641
|
+
return d.toISOString().slice(0, 10);
|
|
4642
|
+
}
|
|
4161
4643
|
function safeNum(value) {
|
|
4162
4644
|
if (typeof value === "number") return value;
|
|
4163
4645
|
if (typeof value === "string") {
|
|
@@ -4193,7 +4675,12 @@ function loadQueryLookup(db, projectId) {
|
|
|
4193
4675
|
return { byId };
|
|
4194
4676
|
}
|
|
4195
4677
|
function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, trackedQueries) {
|
|
4196
|
-
const
|
|
4678
|
+
const allRows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
|
|
4679
|
+
if (allRows.length === 0) return null;
|
|
4680
|
+
let maxDate = "";
|
|
4681
|
+
for (const r of allRows) if (r.date > maxDate) maxDate = r.date;
|
|
4682
|
+
const startDate = windowStartDate(maxDate, REPORT_WINDOW_DAYS);
|
|
4683
|
+
const rows = allRows.filter((r) => r.date >= startDate && r.date <= maxDate);
|
|
4197
4684
|
if (rows.length === 0) return null;
|
|
4198
4685
|
let totalClicks = 0;
|
|
4199
4686
|
let totalImpressions = 0;
|
|
@@ -4239,11 +4726,15 @@ function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, tra
|
|
|
4239
4726
|
sharePct: totalClicks > 0 ? Math.round(agg.clicks / totalClicks * 100) : 0
|
|
4240
4727
|
})).sort((a, b) => b.clicks - a.clicks);
|
|
4241
4728
|
const trend = [...trendAgg.entries()].map(([date, agg]) => ({ date, clicks: agg.clicks, impressions: agg.impressions })).sort((a, b) => a.date.localeCompare(b.date));
|
|
4729
|
+
const periodStart = trend[0]?.date ?? "";
|
|
4730
|
+
const periodEnd = trend.at(-1)?.date ?? "";
|
|
4242
4731
|
const trackedSet = new Set(trackedQueries.map((q) => q.toLowerCase()));
|
|
4243
4732
|
const gscQuerySet = new Set([...queryAgg.keys()].map((q) => q.toLowerCase()));
|
|
4244
4733
|
const trackedButNoGsc = trackedQueries.filter((q) => !gscQuerySet.has(q.toLowerCase())).sort();
|
|
4245
4734
|
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);
|
|
4246
4735
|
return {
|
|
4736
|
+
periodStart,
|
|
4737
|
+
periodEnd,
|
|
4247
4738
|
totalClicks,
|
|
4248
4739
|
totalImpressions,
|
|
4249
4740
|
ctr,
|
|
@@ -4256,14 +4747,24 @@ function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, tra
|
|
|
4256
4747
|
};
|
|
4257
4748
|
}
|
|
4258
4749
|
function buildGaSection(db, projectId) {
|
|
4259
|
-
const
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4750
|
+
const windowSummary = db.select().from(gaTrafficWindowSummaries).where(
|
|
4751
|
+
and4(
|
|
4752
|
+
eq13(gaTrafficWindowSummaries.projectId, projectId),
|
|
4753
|
+
eq13(gaTrafficWindowSummaries.windowKey, "30d")
|
|
4754
|
+
)
|
|
4755
|
+
).limit(1).get();
|
|
4756
|
+
const fallbackSummary = windowSummary ? null : db.select().from(gaTrafficSummaries).where(eq13(gaTrafficSummaries.projectId, projectId)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
|
|
4757
|
+
const allSnapshotRows = db.select().from(gaTrafficSnapshots).where(eq13(gaTrafficSnapshots.projectId, projectId)).all();
|
|
4758
|
+
if (!windowSummary && !fallbackSummary && allSnapshotRows.length === 0) return null;
|
|
4759
|
+
let snapshotMaxDate = "";
|
|
4760
|
+
for (const r of allSnapshotRows) if (r.date > snapshotMaxDate) snapshotMaxDate = r.date;
|
|
4761
|
+
const snapshotStartDate = snapshotMaxDate ? windowStartDate(snapshotMaxDate, REPORT_WINDOW_DAYS) : "";
|
|
4762
|
+
const snapshotRows = snapshotStartDate ? allSnapshotRows.filter((r) => r.date >= snapshotStartDate && r.date <= snapshotMaxDate) : allSnapshotRows;
|
|
4763
|
+
const totalSessions = windowSummary?.totalSessions ?? fallbackSummary?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
|
|
4764
|
+
const totalUsers = windowSummary?.totalUsers ?? fallbackSummary?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
|
|
4765
|
+
const totalOrganicSessions = windowSummary?.totalOrganicSessions ?? fallbackSummary?.totalOrganicSessions ?? snapshotRows.reduce((s, r) => s + r.organicSessions, 0);
|
|
4265
4766
|
const pageAgg = /* @__PURE__ */ new Map();
|
|
4266
|
-
let directSessions = 0;
|
|
4767
|
+
let directSessions = windowSummary?.totalDirectSessions ?? 0;
|
|
4267
4768
|
for (const r of snapshotRows) {
|
|
4268
4769
|
const page = r.landingPageNormalized ?? r.landingPage;
|
|
4269
4770
|
const existing = pageAgg.get(page) ?? { sessions: 0, users: 0, organic: 0 };
|
|
@@ -4271,7 +4772,7 @@ function buildGaSection(db, projectId) {
|
|
|
4271
4772
|
existing.users += r.users;
|
|
4272
4773
|
existing.organic += r.organicSessions;
|
|
4273
4774
|
pageAgg.set(page, existing);
|
|
4274
|
-
if (r.directSessions != null) directSessions += r.directSessions;
|
|
4775
|
+
if (!windowSummary && r.directSessions != null) directSessions += r.directSessions;
|
|
4275
4776
|
}
|
|
4276
4777
|
const topLandingPages = [...pageAgg.entries()].map(([page, data]) => ({
|
|
4277
4778
|
page,
|
|
@@ -4299,12 +4800,14 @@ function buildGaSection(db, projectId) {
|
|
|
4299
4800
|
}
|
|
4300
4801
|
}
|
|
4301
4802
|
}
|
|
4803
|
+
const periodStart = windowSummary?.periodStart ?? (snapshotStartDate || fallbackSummary?.periodStart || "");
|
|
4804
|
+
const periodEnd = windowSummary?.periodEnd ?? (snapshotMaxDate || fallbackSummary?.periodEnd || "");
|
|
4302
4805
|
return {
|
|
4303
4806
|
totalSessions,
|
|
4304
4807
|
totalUsers,
|
|
4305
4808
|
totalOrganicSessions,
|
|
4306
|
-
periodStart
|
|
4307
|
-
periodEnd
|
|
4809
|
+
periodStart,
|
|
4810
|
+
periodEnd,
|
|
4308
4811
|
topLandingPages,
|
|
4309
4812
|
channelBreakdown
|
|
4310
4813
|
};
|
|
@@ -4557,7 +5060,7 @@ function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount,
|
|
|
4557
5060
|
const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
|
|
4558
5061
|
let detail;
|
|
4559
5062
|
if (trendBaseline) {
|
|
4560
|
-
detail = `
|
|
5063
|
+
detail = `Building baseline (${trendsPoints.length} of ${MIN_TREND_POINTS} checks completed).`;
|
|
4561
5064
|
} else {
|
|
4562
5065
|
switch (trend) {
|
|
4563
5066
|
case "up":
|
|
@@ -4657,7 +5160,7 @@ function buildReportActionPlan(input) {
|
|
|
4657
5160
|
horizon: "immediate",
|
|
4658
5161
|
category: "competitors",
|
|
4659
5162
|
title: "Define the competitor set Canonry should benchmark against",
|
|
4660
|
-
action: "Review the recurring external source domains and add the true competitors before the next
|
|
5163
|
+
action: "Review the recurring external source domains and add the true competitors before the next check.",
|
|
4661
5164
|
why: [
|
|
4662
5165
|
"The report can identify repeated external sources, but it cannot separate competitors from publishers until competitors are configured.",
|
|
4663
5166
|
"A clean competitor set makes future share-of-voice and content-gap reporting easier to explain to clients."
|
|
@@ -4691,7 +5194,7 @@ function buildReportActionPlan(input) {
|
|
|
4691
5194
|
action: opportunity.ourBestPage ? `${verb} ${target} so it directly answers the tracked query and cites the strongest supporting evidence.` : `${verb} ${target} that directly answers the query and earns citations from AI answer engines.`,
|
|
4692
5195
|
why: opportunity.drivers.length > 0 ? opportunity.drivers : ["Canonry ranked this as a content opportunity from search-demand and citation evidence."],
|
|
4693
5196
|
evidence,
|
|
4694
|
-
successMetric: `A future
|
|
5197
|
+
successMetric: `A future check cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`,
|
|
4695
5198
|
confidence: opportunity.actionConfidence
|
|
4696
5199
|
});
|
|
4697
5200
|
}
|
|
@@ -4731,7 +5234,7 @@ function buildReportActionPlan(input) {
|
|
|
4731
5234
|
"This points the agency toward provider-specific evidence gaps instead of a generic content recommendation."
|
|
4732
5235
|
],
|
|
4733
5236
|
evidence: zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount} cited query-provider pairs`),
|
|
4734
|
-
successMetric: "At least one zero-citation
|
|
5237
|
+
successMetric: "At least one zero-citation engine cites the client on a priority query in a later check.",
|
|
4735
5238
|
confidence: "high"
|
|
4736
5239
|
});
|
|
4737
5240
|
}
|
|
@@ -4795,13 +5298,13 @@ function buildReportActionPlan(input) {
|
|
|
4795
5298
|
horizon: "medium-term",
|
|
4796
5299
|
category: "location",
|
|
4797
5300
|
title: "Keep location-scoped reporting separate by market",
|
|
4798
|
-
action: "Run and compare separate
|
|
5301
|
+
action: "Run and compare separate checks for each configured location before making market-level recommendations.",
|
|
4799
5302
|
why: [
|
|
4800
5303
|
"A multi-location client can appear differently by market.",
|
|
4801
5304
|
"Keeping each report location-scoped avoids mixing Florida and Michigan evidence in the same client story."
|
|
4802
5305
|
],
|
|
4803
5306
|
evidence,
|
|
4804
|
-
successMetric: "Each configured market has its own current
|
|
5307
|
+
successMetric: "Each configured market has its own current check and trend before cross-market decisions are made.",
|
|
4805
5308
|
confidence: "high"
|
|
4806
5309
|
});
|
|
4807
5310
|
}
|
|
@@ -4812,10 +5315,10 @@ function buildReportActionPlan(input) {
|
|
|
4812
5315
|
horizon: "short-term",
|
|
4813
5316
|
category: "monitoring",
|
|
4814
5317
|
title: "Keep monitoring citation and mention coverage",
|
|
4815
|
-
action: "Run the next scheduled
|
|
5318
|
+
action: "Run the next scheduled check and watch for citation gains, losses, and engine-specific misses.",
|
|
4816
5319
|
why: [
|
|
4817
5320
|
"No urgent corrective action surfaced from the current evidence.",
|
|
4818
|
-
"AEO performance is directional; repeated
|
|
5321
|
+
"AEO performance is directional; repeated checks are needed before overreacting to a single sample."
|
|
4819
5322
|
],
|
|
4820
5323
|
evidence: ["No critical insights, content gaps, indexing blockers, or provider-zero issues were detected in this report."],
|
|
4821
5324
|
successMetric: "Coverage stays stable or improves across the next trend window.",
|
|
@@ -4827,11 +5330,11 @@ function buildReportActionPlan(input) {
|
|
|
4827
5330
|
function trendSentence(trend) {
|
|
4828
5331
|
switch (trend) {
|
|
4829
5332
|
case "up":
|
|
4830
|
-
return "Citation coverage improved versus the prior comparable
|
|
5333
|
+
return "Citation coverage improved versus the prior comparable check.";
|
|
4831
5334
|
case "down":
|
|
4832
|
-
return "Citation coverage declined versus the prior comparable
|
|
5335
|
+
return "Citation coverage declined versus the prior comparable check.";
|
|
4833
5336
|
case "flat":
|
|
4834
|
-
return "Citation coverage is flat versus the prior comparable
|
|
5337
|
+
return "Citation coverage is flat versus the prior comparable check.";
|
|
4835
5338
|
case "unknown":
|
|
4836
5339
|
return "There is not enough comparable run history yet to call a trend.";
|
|
4837
5340
|
}
|
|
@@ -4839,16 +5342,16 @@ function trendSentence(trend) {
|
|
|
4839
5342
|
function buildClientSummary(reportLike) {
|
|
4840
5343
|
const s = reportLike.executiveSummary;
|
|
4841
5344
|
const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
|
|
4842
|
-
const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a
|
|
4843
|
-
const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is cited on ${s.citationRate}% of tracked queries and mentioned on ${s.mentionRate}% of tracked queries. ${trendSentence(s.trend)}` : "
|
|
5345
|
+
const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a check yet";
|
|
5346
|
+
const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is cited on ${s.citationRate}% of tracked queries and mentioned on ${s.mentionRate}% of tracked queries. ${trendSentence(s.trend)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
|
|
4844
5347
|
const confidenceNotes = [];
|
|
4845
5348
|
if (s.totalQueryCount === 0) {
|
|
4846
|
-
confidenceNotes.push("Confidence is low until the first tracked query
|
|
5349
|
+
confidenceNotes.push("Confidence is low until the first tracked query check completes.");
|
|
4847
5350
|
} else if (s.totalQueryCount < 5) {
|
|
4848
5351
|
confidenceNotes.push("Directional read: the tracked query set is still small, so each query has outsized impact on the percentage.");
|
|
4849
5352
|
}
|
|
4850
5353
|
if (isTrendBaseline(reportLike.citationsTrend)) {
|
|
4851
|
-
confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable
|
|
5354
|
+
confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable checks are needed for a stable trend.`);
|
|
4852
5355
|
}
|
|
4853
5356
|
if (!reportLike.gsc) {
|
|
4854
5357
|
confidenceNotes.push("Search Console is not connected, so content recommendations lean more heavily on citation and competitor evidence.");
|
|
@@ -4868,13 +5371,13 @@ function buildAgencyDiagnostics(input) {
|
|
|
4868
5371
|
const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
|
|
4869
5372
|
diagnostics.push({
|
|
4870
5373
|
title: "Provider citation coverage",
|
|
4871
|
-
detail: zeroCitationProviders.length > 0 ? `${zeroCitationProviders.length}
|
|
5374
|
+
detail: zeroCitationProviders.length > 0 ? `${zeroCitationProviders.length} engine${zeroCitationProviders.length === 1 ? "" : "s"} returned zero client citations in the latest check.` : "Every provider with completed snapshots produced at least one client citation or no provider data is available yet.",
|
|
4872
5375
|
severity: zeroCitationProviders.length > 0 ? "negative" : "positive",
|
|
4873
5376
|
evidence: zeroCitationProviders.length > 0 ? zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount}`) : input.citationScorecard.providerRates.map((p) => `${p.provider}: ${p.citedCount}/${p.totalCount}`)
|
|
4874
5377
|
});
|
|
4875
5378
|
diagnostics.push({
|
|
4876
5379
|
title: "AI source domains",
|
|
4877
|
-
detail: input.aiSourceOrigin.topDomains.length > 0 ? "Repeated external source domains show what AI engines are currently trusting for this topic set." : "No external source-domain evidence is available from the latest
|
|
5380
|
+
detail: input.aiSourceOrigin.topDomains.length > 0 ? "Repeated external source domains show what AI engines are currently trusting for this topic set." : "No external source-domain evidence is available from the latest check yet.",
|
|
4878
5381
|
severity: input.aiSourceOrigin.topDomains.length > 0 ? "neutral" : "caution",
|
|
4879
5382
|
evidence: input.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `${d.domain}: ${d.count}`)
|
|
4880
5383
|
});
|
|
@@ -4906,22 +5409,117 @@ function buildAgencyDiagnostics(input) {
|
|
|
4906
5409
|
severity: input.contentOpportunities.length > 0 ? "caution" : "neutral",
|
|
4907
5410
|
evidence: input.contentOpportunities.slice(0, 3).map((o) => `${o.query}: ${o.action} (${Math.round(o.score)})`)
|
|
4908
5411
|
});
|
|
4909
|
-
if (input.reportLocation) {
|
|
4910
|
-
diagnostics.push({
|
|
4911
|
-
title: "Location caveat",
|
|
4912
|
-
detail: input.reportLocation.otherConfiguredLabels.length > 0 ? "This report is scoped to the latest run location; other configured locations need separate interpretation." : "This report is scoped to one configured location.",
|
|
4913
|
-
severity: input.reportLocation.otherConfiguredLabels.length > 0 ? "caution" : "neutral",
|
|
4914
|
-
evidence: [
|
|
4915
|
-
`Current location: ${input.reportLocation.label}`,
|
|
4916
|
-
...input.reportLocation.otherConfiguredLabels.length > 0 ? [`Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`] : []
|
|
4917
|
-
]
|
|
4918
|
-
});
|
|
4919
|
-
}
|
|
4920
5412
|
return {
|
|
4921
5413
|
priorities: input.actionPlan.filter((a) => actionAudienceMatches2(a, "agency")).slice(0, 6),
|
|
4922
5414
|
diagnostics
|
|
4923
5415
|
};
|
|
4924
5416
|
}
|
|
5417
|
+
var WHATS_CHANGED_PERIOD_DAYS2 = 14;
|
|
5418
|
+
var WHATS_CHANGED_MIN_TREND_POINTS = WHATS_CHANGED_PERIOD_DAYS2 * 2;
|
|
5419
|
+
var WIN_REGRESSION_LIMIT = 5;
|
|
5420
|
+
function rateDirection(delta, threshold = 0.5) {
|
|
5421
|
+
if (delta > threshold) return "up";
|
|
5422
|
+
if (delta < -threshold) return "down";
|
|
5423
|
+
return "flat";
|
|
5424
|
+
}
|
|
5425
|
+
function periodOverPeriodDelta(trend) {
|
|
5426
|
+
if (trend.length < WHATS_CHANGED_MIN_TREND_POINTS) return null;
|
|
5427
|
+
const tail = trend.slice(-WHATS_CHANGED_PERIOD_DAYS2);
|
|
5428
|
+
const prior = trend.slice(-WHATS_CHANGED_PERIOD_DAYS2 * 2, -WHATS_CHANGED_PERIOD_DAYS2);
|
|
5429
|
+
const current = tail.reduce((s, p) => s + p.value, 0);
|
|
5430
|
+
const priorTotal = prior.reduce((s, p) => s + p.value, 0);
|
|
5431
|
+
const deltaAbs = current - priorTotal;
|
|
5432
|
+
return {
|
|
5433
|
+
current,
|
|
5434
|
+
prior: priorTotal,
|
|
5435
|
+
deltaAbs,
|
|
5436
|
+
direction: rateDirection(deltaAbs, 0)
|
|
5437
|
+
};
|
|
5438
|
+
}
|
|
5439
|
+
function buildWhatsChangedHeadline(citation, gscClicks, aiReferrals, enoughHistory, trendLength) {
|
|
5440
|
+
if (!enoughHistory) {
|
|
5441
|
+
return `Building baseline (${trendLength} of ${MIN_TREND_POINTS} checks completed). Trends appear after a few more checks.`;
|
|
5442
|
+
}
|
|
5443
|
+
const parts = [];
|
|
5444
|
+
if (citation) {
|
|
5445
|
+
const arrow = citation.direction === "up" ? "\u2191" : citation.direction === "down" ? "\u2193" : "\u2192";
|
|
5446
|
+
const verb = citation.direction === "up" ? "rose" : citation.direction === "down" ? "fell" : "held";
|
|
5447
|
+
parts.push(`Citation rate ${verb} ${citation.prior}% ${arrow} ${citation.current}%`);
|
|
5448
|
+
}
|
|
5449
|
+
if (aiReferrals && aiReferrals.direction !== "flat") {
|
|
5450
|
+
const arrow = aiReferrals.direction === "up" ? "\u2191" : "\u2193";
|
|
5451
|
+
parts.push(`AI referrals ${arrow}${Math.abs(aiReferrals.deltaAbs)} sessions vs prior 14 days`);
|
|
5452
|
+
} else if (gscClicks && gscClicks.direction !== "flat") {
|
|
5453
|
+
const arrow = gscClicks.direction === "up" ? "\u2191" : "\u2193";
|
|
5454
|
+
parts.push(`GSC clicks ${arrow}${Math.abs(gscClicks.deltaAbs)} vs prior 14 days`);
|
|
5455
|
+
}
|
|
5456
|
+
return parts.length > 0 ? `${parts.join(" \xB7 ")}.` : "No meaningful movement vs the prior period.";
|
|
5457
|
+
}
|
|
5458
|
+
function buildWhatsChanged(input) {
|
|
5459
|
+
const { citationsTrend, gsc, aiReferrals, insights: insightList } = input;
|
|
5460
|
+
const baseline = isTrendBaseline(citationsTrend);
|
|
5461
|
+
const latest = citationsTrend.at(-1);
|
|
5462
|
+
const prior = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
|
|
5463
|
+
const enoughHistory = !baseline && latest !== void 0 && prior !== void 0;
|
|
5464
|
+
const citationRate = enoughHistory ? {
|
|
5465
|
+
current: latest.citationRate,
|
|
5466
|
+
prior: prior.citationRate,
|
|
5467
|
+
deltaAbs: latest.citationRate - prior.citationRate,
|
|
5468
|
+
direction: rateDirection(latest.citationRate - prior.citationRate)
|
|
5469
|
+
} : null;
|
|
5470
|
+
const mentionRate = enoughHistory ? {
|
|
5471
|
+
current: latest.mentionRate,
|
|
5472
|
+
prior: prior.mentionRate,
|
|
5473
|
+
deltaAbs: latest.mentionRate - prior.mentionRate,
|
|
5474
|
+
direction: rateDirection(latest.mentionRate - prior.mentionRate)
|
|
5475
|
+
} : null;
|
|
5476
|
+
const citedQueryCount = enoughHistory ? {
|
|
5477
|
+
current: latest.citedQueryCount,
|
|
5478
|
+
prior: prior.citedQueryCount,
|
|
5479
|
+
deltaAbs: latest.citedQueryCount - prior.citedQueryCount,
|
|
5480
|
+
direction: rateDirection(latest.citedQueryCount - prior.citedQueryCount, 0)
|
|
5481
|
+
} : null;
|
|
5482
|
+
const providerMovements = [];
|
|
5483
|
+
if (enoughHistory) {
|
|
5484
|
+
const priorByProvider = new Map(prior.providerRates.map((p) => [p.provider, p.citationRate]));
|
|
5485
|
+
for (const cur of latest.providerRates) {
|
|
5486
|
+
const priorRate = priorByProvider.get(cur.provider);
|
|
5487
|
+
if (priorRate === void 0) continue;
|
|
5488
|
+
const deltaAbs = cur.citationRate - priorRate;
|
|
5489
|
+
providerMovements.push({
|
|
5490
|
+
provider: cur.provider,
|
|
5491
|
+
current: cur.citationRate,
|
|
5492
|
+
prior: priorRate,
|
|
5493
|
+
deltaAbs,
|
|
5494
|
+
direction: rateDirection(deltaAbs)
|
|
5495
|
+
});
|
|
5496
|
+
}
|
|
5497
|
+
providerMovements.sort((a, b) => Math.abs(b.deltaAbs) - Math.abs(a.deltaAbs));
|
|
5498
|
+
}
|
|
5499
|
+
const gscClicksDelta = gsc ? periodOverPeriodDelta(gsc.trend.map((t) => ({ date: t.date, value: t.clicks }))) : null;
|
|
5500
|
+
const aiReferralsDelta = aiReferrals ? periodOverPeriodDelta(aiReferrals.trend.map((t) => ({ date: t.date, value: t.sessions }))) : null;
|
|
5501
|
+
const wins = insightList.filter((i) => i.type === "gain").slice(0, WIN_REGRESSION_LIMIT);
|
|
5502
|
+
const regressions = insightList.filter((i) => i.type === "regression").slice(0, WIN_REGRESSION_LIMIT);
|
|
5503
|
+
const headline = buildWhatsChangedHeadline(
|
|
5504
|
+
citationRate,
|
|
5505
|
+
gscClicksDelta,
|
|
5506
|
+
aiReferralsDelta,
|
|
5507
|
+
enoughHistory,
|
|
5508
|
+
citationsTrend.length
|
|
5509
|
+
);
|
|
5510
|
+
return {
|
|
5511
|
+
enoughHistory,
|
|
5512
|
+
headline,
|
|
5513
|
+
citationRate,
|
|
5514
|
+
mentionRate,
|
|
5515
|
+
citedQueryCount,
|
|
5516
|
+
gscClicksDelta,
|
|
5517
|
+
aiReferralsDelta,
|
|
5518
|
+
providerMovements,
|
|
5519
|
+
wins,
|
|
5520
|
+
regressions
|
|
5521
|
+
};
|
|
5522
|
+
}
|
|
4925
5523
|
function buildProjectReport(db, projectName) {
|
|
4926
5524
|
const project = resolveProject(db, projectName);
|
|
4927
5525
|
const queryLookup = loadQueryLookup(db, project.id);
|
|
@@ -4974,6 +5572,12 @@ function buildProjectReport(db, projectName) {
|
|
|
4974
5572
|
contentOpportunities,
|
|
4975
5573
|
insightDerivedSteps
|
|
4976
5574
|
);
|
|
5575
|
+
const whatsChanged = buildWhatsChanged({
|
|
5576
|
+
citationsTrend,
|
|
5577
|
+
gsc: gscSection,
|
|
5578
|
+
aiReferrals: aiReferralsSection,
|
|
5579
|
+
insights: insightList
|
|
5580
|
+
});
|
|
4977
5581
|
const totalQueryCount = queryLookup.byId.size;
|
|
4978
5582
|
const citedQueryIds = /* @__PURE__ */ new Set();
|
|
4979
5583
|
const mentionedQueryIds = /* @__PURE__ */ new Set();
|
|
@@ -5029,7 +5633,9 @@ function buildProjectReport(db, projectName) {
|
|
|
5029
5633
|
clicks: gscSection.totalClicks,
|
|
5030
5634
|
impressions: gscSection.totalImpressions,
|
|
5031
5635
|
ctr: gscSection.ctr,
|
|
5032
|
-
avgPosition: gscSection.avgPosition
|
|
5636
|
+
avgPosition: gscSection.avgPosition,
|
|
5637
|
+
periodStart: gscSection.periodStart,
|
|
5638
|
+
periodEnd: gscSection.periodEnd
|
|
5033
5639
|
} : null,
|
|
5034
5640
|
ga: gaSection ? {
|
|
5035
5641
|
sessions: gaSection.totalSessions,
|
|
@@ -5099,6 +5705,7 @@ function buildProjectReport(db, projectName) {
|
|
|
5099
5705
|
aiReferrals: aiReferralsSection,
|
|
5100
5706
|
indexingHealth: indexingHealthSection,
|
|
5101
5707
|
citationsTrend,
|
|
5708
|
+
whatsChanged,
|
|
5102
5709
|
insights: insightList,
|
|
5103
5710
|
recommendedNextSteps,
|
|
5104
5711
|
actionPlan,
|