@ainyc/canonry 3.5.0 → 3.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/index.html CHANGED
@@ -12,7 +12,7 @@
12
12
  <link rel="icon" type="image/png" sizes="32x32" href="./favicon-32.png" />
13
13
  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
14
14
  <title>Canonry</title>
15
- <script type="module" crossorigin src="./assets/index-CFtdvSnQ.js"></script>
15
+ <script type="module" crossorigin src="./assets/index-B5VNd16q.js"></script>
16
16
  <link rel="stylesheet" crossorigin href="./assets/index-BfwQqd05.css">
17
17
  </head>
18
18
  <body>
@@ -4,7 +4,7 @@ import {
4
4
  configExists,
5
5
  loadConfig,
6
6
  saveConfigPatch
7
- } from "./chunk-ZYESHCMF.js";
7
+ } from "./chunk-GLPZ5NVP.js";
8
8
  import {
9
9
  IntelligenceService,
10
10
  MIN_TREND_POINTS,
@@ -31,6 +31,7 @@ import {
31
31
  gaSocialReferrals,
32
32
  gaTrafficSnapshots,
33
33
  gaTrafficSummaries,
34
+ gaTrafficWindowSummaries,
34
35
  groupInsights,
35
36
  gscCoverageSnapshots,
36
37
  gscSearchData,
@@ -48,7 +49,7 @@ import {
48
49
  runs,
49
50
  schedules,
50
51
  usageCounters
51
- } from "./chunk-K33FVWFW.js";
52
+ } from "./chunk-W463NVVC.js";
52
53
  import {
53
54
  AGENT_MEMORY_VALUE_MAX_BYTES,
54
55
  AGENT_PROVIDER_IDS,
@@ -63,6 +64,7 @@ import {
63
64
  RunKinds,
64
65
  RunStatuses,
65
66
  RunTriggers,
67
+ absolutizeProjectUrl,
66
68
  agentBusy,
67
69
  agentMemoryDeleteRequestSchema,
68
70
  agentMemoryUpsertRequestSchema,
@@ -110,7 +112,7 @@ import {
110
112
  visibilityStateFromAnswerMentioned,
111
113
  windowCutoff,
112
114
  wordpressEnvSchema
113
- } from "./chunk-VIUWGDDU.js";
115
+ } from "./chunk-RDX6GBWM.js";
114
116
 
115
117
  // src/telemetry.ts
116
118
  import crypto from "crypto";
@@ -2782,7 +2784,12 @@ function renderExecutiveSummary(report) {
2782
2784
  <span>${escapeHtml(f.detail)}</span>
2783
2785
  </div>`).join("")}</div>` : "";
2784
2786
  return section(
2785
- { id: "executive-summary", eyebrow: "Section 1", title: "Executive Summary" },
2787
+ {
2788
+ id: "executive-summary",
2789
+ eyebrow: "Section 1",
2790
+ title: "Executive Summary",
2791
+ intro: "Top-line citation rate with trend versus the prior run, plus the most actionable findings from the latest visibility sweep."
2792
+ },
2786
2793
  metricsHtml + findingsHtml
2787
2794
  );
2788
2795
  }
@@ -2841,15 +2848,11 @@ function renderCitationScorecard(report) {
2841
2848
  ${renderCitationMatrix(report.citationScorecard)}
2842
2849
  `;
2843
2850
  return section(
2844
- { id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Per-keyword \xD7 per-provider citation matrix from the latest visibility sweep." },
2851
+ { id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Whether your domain appeared in each AI engine\u2019s source list for every tracked keyword in the latest sweep \u2014 a cell turns green when your domain was cited, red when it was not, and gray when no snapshot exists for that pair." },
2845
2852
  body
2846
2853
  );
2847
2854
  }
2848
- function renderCompetitorBars(landscape, canonical) {
2849
- const data = [
2850
- { label: canonical, count: landscape.projectCitationCount, isProject: true },
2851
- ...landscape.competitors.map((c) => ({ label: c.domain, count: c.citationCount, isProject: false }))
2852
- ];
2855
+ function renderLandscapeBars(data, heading, ariaLabel) {
2853
2856
  if (data.length <= 1) return "";
2854
2857
  const max = Math.max(...data.map((d) => d.count), 1);
2855
2858
  const width = 600;
@@ -2866,22 +2869,43 @@ function renderCompetitorBars(landscape, canonical) {
2866
2869
  <text x="${labelWidth + w + 6}" y="${y + 13}" fill="${COLORS.text}" font-size="11">${d.count}</text>`;
2867
2870
  }).join("");
2868
2871
  return `<div class="chart-card">
2869
- <h3>Citations per domain</h3>
2870
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Citations per domain bar chart">
2872
+ <h3>${escapeHtml(heading)}</h3>
2873
+ <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml(ariaLabel)}">
2871
2874
  ${bars}
2872
2875
  </svg>
2873
2876
  </div>`;
2874
2877
  }
2878
+ function renderCompetitorBars(landscape, canonical) {
2879
+ const data = [
2880
+ { label: canonical, count: landscape.projectCitationCount, isProject: true },
2881
+ ...landscape.competitors.map((c) => ({ label: c.domain, count: c.citationCount, isProject: false }))
2882
+ ];
2883
+ return renderLandscapeBars(data, "Citations per domain", "Citations per domain bar chart");
2884
+ }
2885
+ function renderMentionBars(landscape, canonical) {
2886
+ const data = [
2887
+ { label: canonical, count: landscape.projectMentionCount, isProject: true },
2888
+ ...landscape.competitors.map((c) => ({ label: c.domain, count: c.mentionCount, isProject: false }))
2889
+ ];
2890
+ return renderLandscapeBars(data, "Mentions per domain", "Mentions per domain bar chart");
2891
+ }
2875
2892
  function renderCompetitorLandscape(report) {
2876
2893
  const competitors2 = report.competitorLandscape.competitors;
2877
- if (competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0) {
2894
+ const mentionLandscape = report.mentionLandscape;
2895
+ const noCitationData = competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0;
2896
+ const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0;
2897
+ if (noCitationData && noMentionData) {
2878
2898
  return section(
2879
2899
  { id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape" },
2880
2900
  renderEmpty("No competitor data yet. Add competitors and run a visibility sweep.")
2881
2901
  );
2882
2902
  }
2903
+ const mentionByDomain = new Map(mentionLandscape.competitors.map((m) => [m.domain, m]));
2883
2904
  const rows = competitors2.map((c) => {
2884
2905
  const tone = pressureTone(c.pressureLabel);
2906
+ const mention = mentionByDomain.get(c.domain);
2907
+ const mentionCount = mention?.mentionCount ?? 0;
2908
+ const mentionTotal = mention?.totalCount ?? mentionLandscape.totalAnswerSnapshots;
2885
2909
  const pagesDisclosure = c.theirCitedPages.length > 0 ? `<details class="cited-pages"><summary>${c.theirCitedPages.length} cited URL${c.theirCitedPages.length > 1 ? "s" : ""}</summary>
2886
2910
  <ul>${c.theirCitedPages.map((p) => `<li><a href="${escapeHtml(p.url)}">${escapeHtml(p.url)}</a> <span class="cited-for">${escapeHtml(p.citedFor.join(", "))}</span></li>`).join("")}</ul>
2887
2911
  </details>` : "";
@@ -2889,17 +2913,26 @@ function renderCompetitorLandscape(report) {
2889
2913
  <td>${escapeHtml(c.domain)}</td>
2890
2914
  <td><span class="badge tone-${tone}">${escapeHtml(c.pressureLabel)}</span></td>
2891
2915
  <td class="numeric">${c.citationCount} / ${c.totalCount}</td>
2916
+ <td class="numeric">${mentionCount} / ${mentionTotal}</td>
2892
2917
  <td class="numeric">${c.sharePct}%</td>
2893
2918
  <td>${escapeHtml(c.citedKeywords.slice(0, 5).join(", "))}${c.citedKeywords.length > 5 ? "\u2026" : ""}${pagesDisclosure}</td>
2894
2919
  </tr>`;
2895
2920
  }).join("");
2896
2921
  const table = competitors2.length > 0 ? `<table class="report-table">
2897
- <thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric">SOV</th><th>Cited keywords</th></tr></thead>
2922
+ <thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric">Mentions</th><th class="numeric">SOV</th><th>Cited keywords</th></tr></thead>
2898
2923
  <tbody>${rows}</tbody>
2899
2924
  </table>` : renderEmpty("No competitors configured.");
2925
+ const citationBars = renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain);
2926
+ const mentionBars = renderMentionBars(mentionLandscape, report.meta.project.canonicalDomain);
2927
+ const charts = citationBars && mentionBars ? `<div class="chart-grid">${citationBars}${mentionBars}</div>` : `${citationBars}${mentionBars}`;
2900
2928
  return section(
2901
- { id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape", intro: "Where tracked competitors appear in AI answers compared to your domain." },
2902
- `${renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain)}${table}`
2929
+ {
2930
+ id: "competitor-landscape",
2931
+ eyebrow: "Section 3",
2932
+ title: "Competitor Landscape",
2933
+ intro: "Where tracked competitors appear in AI answers compared to your domain \u2014 both in source citations and in the answer text itself."
2934
+ },
2935
+ `${charts}${table}`
2903
2936
  );
2904
2937
  }
2905
2938
  function renderDonut(buckets) {
@@ -2946,7 +2979,7 @@ function renderAiSourceOrigin(report) {
2946
2979
  const origin = report.aiSourceOrigin;
2947
2980
  if (origin.categories.length === 0 && origin.topDomains.length === 0) {
2948
2981
  return section(
2949
- { id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin" },
2982
+ { id: "ai-source-origin", eyebrow: "Section 4", title: "AI Citation Sources" },
2950
2983
  renderEmpty("No source data yet. Run a visibility sweep first.")
2951
2984
  );
2952
2985
  }
@@ -2961,7 +2994,12 @@ function renderAiSourceOrigin(report) {
2961
2994
  <tbody>${rows}</tbody>
2962
2995
  </table>` : "";
2963
2996
  return section(
2964
- { id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin", intro: "Where AI answers pull from, aggregated across the latest sweep." },
2997
+ {
2998
+ id: "ai-source-origin",
2999
+ eyebrow: "Section 4",
3000
+ title: "AI Citation Sources",
3001
+ intro: "Every external website AI engines cited as a source for your tracked keywords in the latest sweep \u2014 categorized by site type (Reddit, YouTube, news, etc.) on the left and ranked by citation count on the right. Your own domains are excluded; tracked competitors are flagged."
3002
+ },
2965
3003
  `${renderDonut(origin.categories)}${table}`
2966
3004
  );
2967
3005
  }
@@ -3040,7 +3078,7 @@ function renderGsc(report) {
3040
3078
  </div>`);
3041
3079
  }
3042
3080
  return section(
3043
- { id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro: "Top queries, category breakdown, and traffic trend from Google Search Console." },
3081
+ { id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro: "Your site\u2019s performance in Google\u2019s regular (non-AI) search results \u2014 top queries that drove impressions, intent breakdown, and the click trend, sourced from Google Search Console for the most recent sync window." },
3044
3082
  `<div class="metric-grid">
3045
3083
  <div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
3046
3084
  <div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
@@ -3085,7 +3123,7 @@ function renderGa(report) {
3085
3123
  <td class="numeric">${c.sharePct}%</td>
3086
3124
  </tr>`).join("");
3087
3125
  return section(
3088
- { id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `Sessions and users for ${formatDate(ga.periodStart)} \u2192 ${formatDate(ga.periodEnd)}.` },
3126
+ { id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `Total sessions and users on your site between ${formatDate(ga.periodStart)} and ${formatDate(ga.periodEnd)}, with the top landing pages and channel breakdown \u2014 sourced from Google Analytics 4.` },
3089
3127
  `<div class="metric-grid">
3090
3128
  <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
3091
3129
  <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
@@ -3126,7 +3164,7 @@ function renderSocial(report) {
3126
3164
  <td class="numeric">${formatNumber(c.sessions)}</td>
3127
3165
  </tr>`).join("");
3128
3166
  return section(
3129
- { id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "Paid vs organic split with top campaigns." },
3167
+ { id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "Sessions on your site sent by social platforms (LinkedIn, Facebook, X, etc.) \u2014 paid versus organic split and the top campaigns that drove them. Sourced from Google Analytics 4." },
3130
3168
  `<div class="metric-grid">
3131
3169
  <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
3132
3170
  <div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
@@ -3173,7 +3211,7 @@ function renderAiReferrals(report) {
3173
3211
  "AI referral sessions over time"
3174
3212
  );
3175
3213
  return section(
3176
- { id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "Sessions sent from AI answer engines." },
3214
+ { id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "Sessions on your site referred by AI answer engines (ChatGPT, Perplexity, Claude, Copilot, Gemini, etc.) \u2014 broken down by referrer with a daily trend and the top landing pages. Sourced from Google Analytics 4." },
3177
3215
  `<div class="metric-grid">
3178
3216
  <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
3179
3217
  <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
@@ -3219,7 +3257,7 @@ function renderIndexingHealth(report) {
3219
3257
  }).join("");
3220
3258
  const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
3221
3259
  return section(
3222
- { id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `Source: ${ih.provider === "google" ? "Google Search Console" : "Bing Webmaster Tools"}.` },
3260
+ { id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `What share of your tracked URLs are currently indexed in ${ih.provider === "google" ? "Google" : "Bing"} \u2014 sourced from ${ih.provider === "google" ? "Google Search Console URL Inspection" : "Bing Webmaster Tools URL Inspection"}. Pages absent from the index can\u2019t be retrieved by AI engines either.` },
3223
3261
  `<div class="metric-grid">
3224
3262
  <div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
3225
3263
  <div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
@@ -3259,7 +3297,7 @@ function renderCitationsTrend(report) {
3259
3297
  <td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
3260
3298
  </tr>`).join("");
3261
3299
  return section(
3262
- { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Per-run citation rate across the project history." },
3300
+ { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation rate across every visibility sweep \u2014 the share of (keyword \xD7 provider) pairs in each run where your domain appeared in the source list, with a per-provider breakdown beneath." },
3263
3301
  `${chart}
3264
3302
  <div class="chart-card"><h3>Run-by-run breakdown</h3>
3265
3303
  <table class="report-table">
@@ -3290,7 +3328,7 @@ function renderInsights(report) {
3290
3328
  </tr>`;
3291
3329
  }).join("");
3292
3330
  return section(
3293
- { id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Priority-ordered findings from the most recent runs." },
3331
+ { id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Regressions (citations lost), gains (citations won), and opportunities surfaced by the intelligence engine across the most recent sweeps \u2014 ordered by severity and recurrence." },
3294
3332
  `<table class="report-table">
3295
3333
  <thead><tr><th>Severity</th><th>Title</th><th>Keyword</th><th>Provider</th><th>Recommendation</th></tr></thead>
3296
3334
  <tbody>${rows}</tbody>
@@ -3300,8 +3338,9 @@ function renderInsights(report) {
3300
3338
  function renderOpportunities(report) {
3301
3339
  const opps = report.contentOpportunities;
3302
3340
  if (opps.length === 0) return "";
3341
+ const canonical = report.meta.project.canonicalDomain;
3303
3342
  const rows = opps.slice(0, 10).map((o) => {
3304
- const ourPage = o.ourBestPage ? `<a href="${escapeHtml(o.ourBestPage.url)}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">\u2014</span>';
3343
+ const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">\u2014</span>';
3305
3344
  const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
3306
3345
  return `<tr>
3307
3346
  <td>${escapeHtml(o.query)}</td>
@@ -3318,7 +3357,7 @@ function renderOpportunities(report) {
3318
3357
  id: "content-opportunities",
3319
3358
  eyebrow: "Section 12",
3320
3359
  title: "Content Opportunities",
3321
- intro: "Ranked, action-typed targets from the content recommendation engine. Top 10 shown."
3360
+ intro: "Queries where you have search demand or competitor citation pressure but aren\u2019t winning AI citations. Each row carries a suggested action (create / refresh / expand / add-schema). Top 10 shown."
3322
3361
  },
3323
3362
  `<table class="report-table">
3324
3363
  <thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Our page</th><th>Winning competitor</th><th>Demand</th><th>Confidence</th></tr></thead>
@@ -3330,7 +3369,7 @@ function renderRecommendedNextSteps(report) {
3330
3369
  const steps = report.recommendedNextSteps;
3331
3370
  if (steps.length === 0) {
3332
3371
  return section(
3333
- { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps" },
3372
+ { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
3334
3373
  renderEmpty("No outstanding actions.")
3335
3374
  );
3336
3375
  }
@@ -3341,7 +3380,7 @@ function renderRecommendedNextSteps(report) {
3341
3380
  <span class="rationale">${escapeHtml(s.rationale)}</span>
3342
3381
  </div>`).join("");
3343
3382
  return section(
3344
- { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps" },
3383
+ { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
3345
3384
  `<div class="steps">${items}</div>`
3346
3385
  );
3347
3386
  }
@@ -3817,6 +3856,56 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
3817
3856
  competitorRows.sort((a, b) => b.citationCount - a.citationCount);
3818
3857
  return { projectCitationCount, competitors: competitorRows };
3819
3858
  }
3859
+ function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName, projectDomains, keywordLookup) {
3860
+ let projectMentionCount = 0;
3861
+ let totalAnswerSnapshots = 0;
3862
+ const competitorMap = /* @__PURE__ */ new Map();
3863
+ for (const c of competitorDomains) {
3864
+ competitorMap.set(c, { count: 0, keywords: /* @__PURE__ */ new Set() });
3865
+ }
3866
+ for (const snap of snapshots) {
3867
+ const text = snap.answerText;
3868
+ if (!text) continue;
3869
+ totalAnswerSnapshots++;
3870
+ const kw = keywordLookup.byId.get(snap.keywordId);
3871
+ const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
3872
+ text,
3873
+ projectDisplayName,
3874
+ projectDomains
3875
+ );
3876
+ if (projectMentioned) projectMentionCount++;
3877
+ for (const competitor of competitorDomains) {
3878
+ const brand = brandLabelFromDomain(competitor);
3879
+ const mentioned = determineAnswerMentioned(text, brand, [competitor]);
3880
+ if (mentioned) {
3881
+ const entry = competitorMap.get(competitor);
3882
+ entry.count++;
3883
+ if (kw) entry.keywords.add(kw);
3884
+ }
3885
+ }
3886
+ }
3887
+ const totalMentionedSlots = projectMentionCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
3888
+ const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
3889
+ const ratio = totalAnswerSnapshots > 0 ? data.count / totalAnswerSnapshots : 0;
3890
+ let pressureLabel = "None";
3891
+ if (data.count > 0) {
3892
+ if (ratio >= 0.5) pressureLabel = "High";
3893
+ else if (ratio >= 0.2) pressureLabel = "Moderate";
3894
+ else pressureLabel = "Low";
3895
+ }
3896
+ const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : 0;
3897
+ return {
3898
+ domain,
3899
+ mentionCount: data.count,
3900
+ totalCount: totalAnswerSnapshots,
3901
+ pressureLabel,
3902
+ mentionedKeywords: [...data.keywords].sort(),
3903
+ sharePct
3904
+ };
3905
+ });
3906
+ competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
3907
+ return { projectMentionCount, totalAnswerSnapshots, competitors: competitorRows };
3908
+ }
3820
3909
  function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains) {
3821
3910
  const categoryCounts = /* @__PURE__ */ new Map();
3822
3911
  const domainCounts = /* @__PURE__ */ new Map();
@@ -4261,6 +4350,13 @@ function buildProjectReport(db, projectName) {
4261
4350
  projectDomains,
4262
4351
  keywordLookup
4263
4352
  );
4353
+ const mentionLandscape = buildMentionLandscape(
4354
+ latestSnapshots,
4355
+ competitorDomains,
4356
+ project.displayName,
4357
+ projectDomains,
4358
+ keywordLookup
4359
+ );
4264
4360
  const aiSourceOrigin = buildAiSourceOrigin(latestSnapshots, projectDomains, competitorDomains);
4265
4361
  const trackedKeywords = [...keywordLookup.byId.values()];
4266
4362
  const gscSection = buildGscSection(
@@ -4353,6 +4449,7 @@ function buildProjectReport(db, projectName) {
4353
4449
  },
4354
4450
  citationScorecard,
4355
4451
  competitorLandscape,
4452
+ mentionLandscape,
4356
4453
  aiSourceOrigin,
4357
4454
  gsc: gscSection,
4358
4455
  ga: gaSection,
@@ -7131,7 +7228,7 @@ var routeCatalog = [
7131
7228
  path: "/api/v1/projects/{name}/report",
7132
7229
  summary: "Aggregated client-facing AEO report",
7133
7230
  tags: ["report"],
7134
- description: "Bundles every section the canonry-report HTML output needs (executive summary, citation scorecard, competitor landscape, AI source origin, GSC, GA4, social/AI referrals, indexing health, citations trend, insights, and recommended next steps) into a single JSON payload. Backs `canonry report <project>`.",
7231
+ 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>`.",
7135
7232
  parameters: [nameParameter],
7136
7233
  responses: {
7137
7234
  200: { description: "Report returned." },
@@ -8823,6 +8920,66 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
8823
8920
  ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
8824
8921
  return summary;
8825
8922
  }
8923
+ var WINDOW_DAYS = { "7d": 7, "30d": 30, "90d": 90 };
8924
+ async function fetchWindowSummary(accessToken, propertyId, windowKey) {
8925
+ validateAccessToken2(accessToken);
8926
+ validatePropertyId(propertyId);
8927
+ const days = WINDOW_DAYS[windowKey];
8928
+ if (!days) {
8929
+ throw new GA4ApiError(`Unsupported windowKey "${windowKey}" \u2014 must be 7d, 30d, or 90d`, 400);
8930
+ }
8931
+ const endDate = /* @__PURE__ */ new Date();
8932
+ const startDate = /* @__PURE__ */ new Date();
8933
+ startDate.setDate(startDate.getDate() - days);
8934
+ const dateRange = { startDate: formatDate2(startDate), endDate: formatDate2(endDate) };
8935
+ ga4Log("info", "fetch-window-summary.start", { propertyId, windowKey, days });
8936
+ const batchRes = await batchRunReports(accessToken, propertyId, [
8937
+ {
8938
+ dateRanges: [dateRange],
8939
+ dimensions: [],
8940
+ metrics: [{ name: "sessions" }, { name: "totalUsers" }],
8941
+ limit: 1
8942
+ },
8943
+ {
8944
+ dateRanges: [dateRange],
8945
+ dimensions: [],
8946
+ metrics: [{ name: "sessions" }],
8947
+ dimensionFilter: {
8948
+ filter: {
8949
+ fieldName: "sessionDefaultChannelGrouping",
8950
+ stringFilter: { matchType: "EXACT", value: "Organic Search" }
8951
+ }
8952
+ },
8953
+ limit: 1
8954
+ },
8955
+ {
8956
+ dateRanges: [dateRange],
8957
+ dimensions: [],
8958
+ metrics: [{ name: "sessions" }],
8959
+ dimensionFilter: {
8960
+ filter: {
8961
+ fieldName: "sessionDefaultChannelGrouping",
8962
+ stringFilter: { matchType: "EXACT", value: "Direct" }
8963
+ }
8964
+ },
8965
+ limit: 1
8966
+ }
8967
+ ]);
8968
+ const totalRow = batchRes[0]?.rows?.[0];
8969
+ const organicRow = batchRes[1]?.rows?.[0];
8970
+ const directRow = batchRes[2]?.rows?.[0];
8971
+ const summary = {
8972
+ windowKey,
8973
+ periodStart: formatDate2(startDate),
8974
+ periodEnd: formatDate2(endDate),
8975
+ totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
8976
+ totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
8977
+ totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0,
8978
+ totalDirectSessions: parseInt(directRow?.metricValues[0]?.value ?? "0", 10) || 0
8979
+ };
8980
+ ga4Log("info", "fetch-window-summary.done", { propertyId, ...summary });
8981
+ return summary;
8982
+ }
8826
8983
  async function fetchAiReferrals(accessToken, propertyId, days) {
8827
8984
  validateAccessToken2(accessToken);
8828
8985
  validatePropertyId(propertyId);
@@ -10695,13 +10852,18 @@ async function ga4Routes(app, opts) {
10695
10852
  let rows = [];
10696
10853
  let aiReferrals = [];
10697
10854
  let socialReferrals = [];
10698
- const fetches = [fetchAggregateSummary(accessToken, propertyId, days)];
10855
+ const WINDOW_KEYS = ["7d", "30d", "90d"];
10856
+ const fetches = [
10857
+ fetchAggregateSummary(accessToken, propertyId, days),
10858
+ ...WINDOW_KEYS.map((w) => fetchWindowSummary(accessToken, propertyId, w))
10859
+ ];
10699
10860
  if (syncTraffic) fetches.push(fetchTrafficByLandingPage(accessToken, propertyId, days));
10700
10861
  if (syncAi) fetches.push(fetchAiReferrals(accessToken, propertyId, days));
10701
10862
  if (syncSocial) fetches.push(fetchSocialReferrals(accessToken, propertyId, days));
10702
10863
  const results = await Promise.all(fetches);
10703
10864
  const summary = results[0];
10704
- let idx = 1;
10865
+ const windowSummaries = results.slice(1, 1 + WINDOW_KEYS.length);
10866
+ let idx = 1 + WINDOW_KEYS.length;
10705
10867
  if (syncTraffic) {
10706
10868
  rows = results[idx++];
10707
10869
  }
@@ -10798,6 +10960,22 @@ async function ga4Routes(app, opts) {
10798
10960
  syncedAt: now,
10799
10961
  syncRunId: runId
10800
10962
  }).run();
10963
+ tx.delete(gaTrafficWindowSummaries).where(eq21(gaTrafficWindowSummaries.projectId, project.id)).run();
10964
+ for (const ws of windowSummaries) {
10965
+ tx.insert(gaTrafficWindowSummaries).values({
10966
+ id: crypto16.randomUUID(),
10967
+ projectId: project.id,
10968
+ windowKey: ws.windowKey,
10969
+ periodStart: ws.periodStart,
10970
+ periodEnd: ws.periodEnd,
10971
+ totalSessions: ws.totalSessions,
10972
+ totalOrganicSessions: ws.totalOrganicSessions,
10973
+ totalDirectSessions: ws.totalDirectSessions,
10974
+ totalUsers: ws.totalUsers,
10975
+ syncedAt: now,
10976
+ syncRunId: runId
10977
+ }).run();
10978
+ }
10801
10979
  }
10802
10980
  });
10803
10981
  app.db.update(runs).set({ status: RunStatuses.completed, finishedAt: now }).where(eq21(runs.id, runId)).run();
@@ -10846,16 +11024,28 @@ async function ga4Routes(app, opts) {
10846
11024
  if (cutoffDate) aiConditions.push(sql5`${gaAiReferrals.date} >= ${cutoffDate}`);
10847
11025
  const socialConditions = [eq21(gaSocialReferrals.projectId, project.id)];
10848
11026
  if (cutoffDate) socialConditions.push(sql5`${gaSocialReferrals.date} >= ${cutoffDate}`);
10849
- const summaryRow = cutoffDate ? app.db.select({
11027
+ const windowSummaryRow = cutoffDate ? app.db.select({
11028
+ totalSessions: gaTrafficWindowSummaries.totalSessions,
11029
+ totalOrganicSessions: gaTrafficWindowSummaries.totalOrganicSessions,
11030
+ totalDirectSessions: gaTrafficWindowSummaries.totalDirectSessions,
11031
+ totalUsers: gaTrafficWindowSummaries.totalUsers
11032
+ }).from(gaTrafficWindowSummaries).where(
11033
+ and9(
11034
+ eq21(gaTrafficWindowSummaries.projectId, project.id),
11035
+ eq21(gaTrafficWindowSummaries.windowKey, window)
11036
+ )
11037
+ ).get() : null;
11038
+ const snapshotTotalsRow = cutoffDate && !windowSummaryRow ? app.db.select({
10850
11039
  totalSessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.sessions}), 0)`,
10851
11040
  totalOrganicSessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.organicSessions}), 0)`,
10852
11041
  totalUsers: sql5`COALESCE(SUM(${gaTrafficSnapshots.users}), 0)`
10853
- }).from(gaTrafficSnapshots).where(and9(...snapshotConditions)).get() : app.db.select({
11042
+ }).from(gaTrafficSnapshots).where(and9(...snapshotConditions)).get() : null;
11043
+ const summaryRow = cutoffDate ? windowSummaryRow ?? snapshotTotalsRow : app.db.select({
10854
11044
  totalSessions: gaTrafficSummaries.totalSessions,
10855
11045
  totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
10856
11046
  totalUsers: gaTrafficSummaries.totalUsers
10857
11047
  }).from(gaTrafficSummaries).where(eq21(gaTrafficSummaries.projectId, project.id)).get();
10858
- const directTotalRow = app.db.select({
11048
+ const directTotalRow = windowSummaryRow ? { totalDirectSessions: windowSummaryRow.totalDirectSessions } : app.db.select({
10859
11049
  totalDirectSessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.directSessions}), 0)`
10860
11050
  }).from(gaTrafficSnapshots).where(and9(...snapshotConditions)).get();
10861
11051
  const summaryMeta = app.db.select({
@@ -10,7 +10,7 @@ import {
10
10
  projectUpsertRequestSchema,
11
11
  runTriggerRequestSchema,
12
12
  scheduleUpsertRequestSchema
13
- } from "./chunk-VIUWGDDU.js";
13
+ } from "./chunk-RDX6GBWM.js";
14
14
 
15
15
  // src/config.ts
16
16
  import fs from "fs";
@@ -1135,7 +1135,7 @@ var canonryMcpTools = [
1135
1135
  defineTool({
1136
1136
  name: "canonry_report",
1137
1137
  title: "Get aggregated AEO report",
1138
- description: "Returns the full client-facing AEO report bundle for a project \u2014 executive summary, per-keyword \xD7 per-provider citation matrix, competitor landscape, AI source origin, GSC/GA4 performance, social and AI referrals, indexing health, citations trend, prioritized insights, and recommended next steps. Same payload `canonry report <project>` consumes to render the self-contained HTML.",
1138
+ description: "Returns the full client-facing AEO report bundle for a project \u2014 executive summary, per-keyword \xD7 per-provider citation matrix, competitor landscape, AI citation sources, GSC/GA4 performance, social and AI referrals, indexing health, citations trend, prioritized insights, and recommended next steps. Same payload `canonry report <project>` consumes to render the self-contained HTML.",
1139
1139
  access: "read",
1140
1140
  tier: "monitoring",
1141
1141
  inputSchema: projectInputSchema,
@@ -1711,6 +1711,17 @@ function dropTrailingSlash(path) {
1711
1711
  }
1712
1712
  return path;
1713
1713
  }
1714
+ function absolutizeProjectUrl(url, canonicalDomain) {
1715
+ if (!url) return "";
1716
+ const trimmed = url.trim();
1717
+ if (!trimmed) return "";
1718
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
1719
+ if (trimmed.startsWith("//")) return `https:${trimmed}`;
1720
+ const host = canonicalDomain.trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
1721
+ if (!host) return trimmed;
1722
+ if (trimmed.startsWith("/")) return `https://${host}${trimmed}`;
1723
+ return `https://${host}/${trimmed}`;
1724
+ }
1714
1725
  function normalizeUrlPath(input) {
1715
1726
  if (input == null) return null;
1716
1727
  let trimmed = input.trim();
@@ -1910,6 +1921,7 @@ export {
1910
1921
  CheckScopes,
1911
1922
  CheckCategories,
1912
1923
  summarizeCheckResults,
1924
+ absolutizeProjectUrl,
1913
1925
  normalizeUrlPath,
1914
1926
  emptyCitationVisibility,
1915
1927
  citationStateToCited,
@@ -2,7 +2,7 @@ import {
2
2
  ContentActions,
3
3
  RunKinds,
4
4
  __export
5
- } from "./chunk-VIUWGDDU.js";
5
+ } from "./chunk-RDX6GBWM.js";
6
6
 
7
7
  // src/intelligence-service.ts
8
8
  import { eq, desc, asc, and, or, inArray } from "drizzle-orm";
@@ -33,6 +33,7 @@ __export(schema_exports, {
33
33
  gaSocialReferrals: () => gaSocialReferrals,
34
34
  gaTrafficSnapshots: () => gaTrafficSnapshots,
35
35
  gaTrafficSummaries: () => gaTrafficSummaries,
36
+ gaTrafficWindowSummaries: () => gaTrafficWindowSummaries,
36
37
  googleConnections: () => googleConnections,
37
38
  gscCoverageSnapshots: () => gscCoverageSnapshots,
38
39
  gscSearchData: () => gscSearchData,
@@ -392,6 +393,23 @@ var gaTrafficSummaries = sqliteTable("ga_traffic_summaries", {
392
393
  index("idx_ga_summary_project").on(table.projectId),
393
394
  index("idx_ga_summary_run").on(table.syncRunId)
394
395
  ]);
396
+ var gaTrafficWindowSummaries = sqliteTable("ga_traffic_window_summaries", {
397
+ id: text("id").primaryKey(),
398
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
399
+ /** '7d' | '30d' | '90d' */
400
+ windowKey: text("window_key").notNull(),
401
+ periodStart: text("period_start").notNull(),
402
+ periodEnd: text("period_end").notNull(),
403
+ totalSessions: integer("total_sessions").notNull().default(0),
404
+ totalOrganicSessions: integer("total_organic_sessions").notNull().default(0),
405
+ totalDirectSessions: integer("total_direct_sessions").notNull().default(0),
406
+ totalUsers: integer("total_users").notNull().default(0),
407
+ syncedAt: text("synced_at").notNull(),
408
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" })
409
+ }, (table) => [
410
+ uniqueIndex("idx_ga_window_summary_unique").on(table.projectId, table.windowKey),
411
+ index("idx_ga_window_summary_run").on(table.syncRunId)
412
+ ]);
395
413
  var usageCounters = sqliteTable("usage_counters", {
396
414
  id: text("id").primaryKey(),
397
415
  scope: text("scope").notNull(),
@@ -1332,6 +1350,29 @@ var MIGRATION_VERSIONS = [
1332
1350
  `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_ai_ref_unique_v3
1333
1351
  ON ga_ai_referrals(project_id, date, source, medium, source_dimension, landing_page)`
1334
1352
  ]
1353
+ },
1354
+ {
1355
+ version: 47,
1356
+ name: "ga-traffic-window-summaries",
1357
+ statements: [
1358
+ `CREATE TABLE IF NOT EXISTS ga_traffic_window_summaries (
1359
+ id TEXT PRIMARY KEY,
1360
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1361
+ window_key TEXT NOT NULL,
1362
+ period_start TEXT NOT NULL,
1363
+ period_end TEXT NOT NULL,
1364
+ total_sessions INTEGER NOT NULL DEFAULT 0,
1365
+ total_organic_sessions INTEGER NOT NULL DEFAULT 0,
1366
+ total_direct_sessions INTEGER NOT NULL DEFAULT 0,
1367
+ total_users INTEGER NOT NULL DEFAULT 0,
1368
+ synced_at TEXT NOT NULL,
1369
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE
1370
+ )`,
1371
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_window_summary_unique
1372
+ ON ga_traffic_window_summaries(project_id, window_key)`,
1373
+ `CREATE INDEX IF NOT EXISTS idx_ga_window_summary_run
1374
+ ON ga_traffic_window_summaries(sync_run_id)`
1375
+ ]
1335
1376
  }
1336
1377
  ];
1337
1378
  function isDuplicateColumnError(err) {
@@ -2348,6 +2389,7 @@ export {
2348
2389
  gaAiReferrals,
2349
2390
  gaSocialReferrals,
2350
2391
  gaTrafficSummaries,
2392
+ gaTrafficWindowSummaries,
2351
2393
  usageCounters,
2352
2394
  insights,
2353
2395
  healthSnapshots,