@ainyc/canonry 4.115.1 → 4.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +0 -1
  2. package/assets/agent-workspace/skills/aero/references/reporting.md +10 -0
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +6 -0
  4. package/assets/assets/{BacklinksPage-yUb8oJuq.js → BacklinksPage-Bkafm10m.js} +1 -1
  5. package/assets/assets/{ChartPrimitives-CdQM3YDG.js → ChartPrimitives-NV_GgqFn.js} +1 -1
  6. package/assets/assets/ProjectPage-0-NnRxys.js +7 -0
  7. package/assets/assets/{RunRow-BU_x7EcP.js → RunRow-df0d24S6.js} +1 -1
  8. package/assets/assets/{RunsPage-CFIBWaO7.js → RunsPage-CU6-4YTM.js} +1 -1
  9. package/assets/assets/{SettingsPage-PyMryUad.js → SettingsPage-BKw6B6cd.js} +1 -1
  10. package/assets/assets/{TrafficPage-471ypAdQ.js → TrafficPage-CzpiYHKj.js} +1 -1
  11. package/assets/assets/{TrafficSourceDetailPage-CI7TEQge.js → TrafficSourceDetailPage-CC5nuqM1.js} +1 -1
  12. package/assets/assets/{arrow-left-D2N9u-0i.js → arrow-left-CbQcW2pL.js} +1 -1
  13. package/assets/assets/{extract-error-message-CAdsqKp3.js → extract-error-message-CvqIZ-le.js} +1 -1
  14. package/assets/assets/index-LjcWHaNZ.js +210 -0
  15. package/assets/assets/{trash-2-UgQrN5Np.js → trash-2-3J2u0woR.js} +1 -1
  16. package/assets/index.html +1 -1
  17. package/dist/{chunk-Z3OGDQHB.js → chunk-FVVKNMZW.js} +514 -52
  18. package/dist/{chunk-6LOKMEL3.js → chunk-IAJ5TQ4S.js} +1438 -1234
  19. package/dist/{chunk-CW4JISBL.js → chunk-PJATAPZW.js} +37 -1
  20. package/dist/{chunk-B2SNXXAJ.js → chunk-ZZEA774R.js} +4 -4
  21. package/dist/cli.js +98 -5
  22. package/dist/index.js +4 -4
  23. package/dist/{intelligence-service-4AWB2V7U.js → intelligence-service-J7G4CMI5.js} +2 -2
  24. package/dist/mcp.js +2 -2
  25. package/package.json +6 -6
  26. package/assets/assets/ProjectPage-CHj9fvTs.js +0 -7
  27. package/assets/assets/index-CoL5HENE.js +0 -210
@@ -2,6 +2,7 @@ import {
2
2
  AGENT_PROVIDER_IDS,
3
3
  AI_ENGINE_DOMAINS,
4
4
  AI_PROVIDER_INFRA_DOMAINS,
5
+ AiReferralTrafficClasses,
5
6
  AppError,
6
7
  BacklinkSources,
7
8
  CcReleaseSyncStatuses,
@@ -20,7 +21,6 @@ import {
20
21
  DiscoveryBuckets,
21
22
  DiscoveryCompetitorTypes,
22
23
  DiscoverySessionStatuses,
23
- GA4AiReferralTrafficClasses,
24
24
  RunKinds,
25
25
  RunStatuses,
26
26
  RunTriggers,
@@ -50,6 +50,7 @@ import {
50
50
  adsSyncResponseSchema,
51
51
  agentProvidersResponseDtoSchema,
52
52
  aggregateHarvestedQueries,
53
+ aiReferralClassCounts,
53
54
  apiKeyDtoSchema,
54
55
  apiKeyListDtoSchema,
55
56
  applyHarvestSemanticNovelty,
@@ -87,8 +88,8 @@ import {
87
88
  citationStateSchema,
88
89
  citationStateToCited,
89
90
  citationVisibilityResponseSchema,
91
+ classifyAiReferralTrafficClass,
90
92
  classifyCitedSurface,
91
- classifyGa4AiReferralTrafficClass,
92
93
  classifySkillFile,
93
94
  classifySurfaceFromCategory,
94
95
  classifyTrafficPath,
@@ -132,6 +133,7 @@ import {
132
133
  filterBrandedSeedCandidates,
133
134
  findDuplicateLocationLabels,
134
135
  forbidden,
136
+ formatAiReferralClassSummary,
135
137
  formatAverageDelta,
136
138
  formatDate,
137
139
  formatDateRange,
@@ -171,6 +173,7 @@ import {
171
173
  gscSitemapListResponseDtoSchema,
172
174
  gscUrlInspectionDtoSchema,
173
175
  hasLocationLabel,
176
+ hostOf,
174
177
  indexingRequestResponseDtoSchema,
175
178
  internalError,
176
179
  isReadOnlyKey,
@@ -257,8 +260,10 @@ import {
257
260
  trafficSyncResponseSchema,
258
261
  unsupportedKind,
259
262
  validationError,
263
+ visibilityCompareDtoSchema,
260
264
  visibilityStateFromAnswerMentioned,
261
265
  visibilityStatsDtoSchema,
266
+ wilsonInterval,
262
267
  windowCutoff,
263
268
  winnabilityClassLabel,
264
269
  winnabilityClassSchema,
@@ -275,7 +280,7 @@ import {
275
280
  wordpressSchemaDeployResultDtoSchema,
276
281
  wordpressSchemaStatusResultDtoSchema,
277
282
  wordpressStatusDtoSchema
278
- } from "./chunk-6LOKMEL3.js";
283
+ } from "./chunk-IAJ5TQ4S.js";
279
284
 
280
285
  // src/intelligence-service.ts
281
286
  import { eq as eq38, desc as desc18, asc as asc6, and as and30, ne as ne5, or as or6, inArray as inArray14, gte as gte7, lte as lte4 } from "drizzle-orm";
@@ -1100,6 +1105,17 @@ var aiReferralEventsHourly = sqliteTable("ai_referral_events_hourly", {
1100
1105
  landingPathNormalized: text("landing_path_normalized").notNull(),
1101
1106
  status: integer("status").notNull(),
1102
1107
  sessionsOrHits: integer("sessions_or_hits").notNull().default(0),
1108
+ /**
1109
+ * Paid/organic splits the MEASURE, not the primary key. The paid marker lives
1110
+ * in the request's UTM tags, which `landing_path_normalized` strips, so one
1111
+ * bucket can legitimately hold both kinds and the class is NOT functionally
1112
+ * determined by the key. Unclassified arrivals are the residual
1113
+ * `sessions_or_hits - paid - organic`, which leaves rows written before the
1114
+ * ingest classifier shipped (both columns 0) visibly unknown rather than
1115
+ * silently organic — the regression migration v95 shipped on the GA4 side.
1116
+ */
1117
+ paidSessionsOrHits: integer("paid_sessions_or_hits").notNull().default(0),
1118
+ organicSessionsOrHits: integer("organic_sessions_or_hits").notNull().default(0),
1103
1119
  usersEstimated: integer("users_estimated"),
1104
1120
  createdAt: text("created_at").notNull(),
1105
1121
  updatedAt: text("updated_at").notNull()
@@ -3512,7 +3528,7 @@ var MIGRATION_VERSIONS = [
3512
3528
  //
3513
3529
  // Every row is re-classified, not only the "unclassified" ones: v95's
3514
3530
  // default is indistinguishable from a genuinely-organic classification, so
3515
- // there is no way to tell the two apart. `classifyGa4AiReferralTrafficClass`
3531
+ // there is no way to tell the two apart. `classifyAiReferralTrafficClass`
3516
3532
  // is pure and deterministic, so re-running it over already-correct rows
3517
3533
  // writes nothing, which is what makes this version safe to replay.
3518
3534
  version: 96,
@@ -3521,6 +3537,27 @@ var MIGRATION_VERSIONS = [
3521
3537
  run: (tx) => {
3522
3538
  backfillGaAiReferralTrafficClass(tx);
3523
3539
  }
3540
+ },
3541
+ {
3542
+ // Splits the server-side AI-referral MEASURE by traffic class rather than
3543
+ // adding a class column. Paid-ness lives in the request's UTM tags, which
3544
+ // `landing_path_normalized` strips, so one hourly bucket can legitimately
3545
+ // hold both paid and organic arrivals: a class column outside the 8-column
3546
+ // primary key would silently stamp a mixed bucket with one label, and
3547
+ // SQLite cannot extend a composite key with ADD COLUMN anyway.
3548
+ //
3549
+ // Deliberately NO backfill. The discriminator was never persisted — the
3550
+ // request's query string is dropped before the hourly bucket and before
3551
+ // `raw_event_samples` — so no honest class exists for historical rows.
3552
+ // Leaving both counters at 0 surfaces their whole `sessions_or_hits` as the
3553
+ // unknown residual. An older binary writing rows after a downgrade lands in
3554
+ // that residual too, which is the truth about those rows.
3555
+ version: 97,
3556
+ name: "server-side-ai-referral-traffic-class",
3557
+ statements: [
3558
+ `ALTER TABLE ai_referral_events_hourly ADD COLUMN paid_sessions_or_hits INTEGER NOT NULL DEFAULT 0`,
3559
+ `ALTER TABLE ai_referral_events_hourly ADD COLUMN organic_sessions_or_hits INTEGER NOT NULL DEFAULT 0`
3560
+ ]
3524
3561
  }
3525
3562
  ];
3526
3563
  function rebuildBacklinkTableWithSource(tx, table) {
@@ -3615,7 +3652,7 @@ function backfillGaAiReferralTrafficClass(tx) {
3615
3652
  ));
3616
3653
  let updated = 0;
3617
3654
  for (const row of rows) {
3618
- const next = classifyGa4AiReferralTrafficClass({
3655
+ const next = classifyAiReferralTrafficClass({
3619
3656
  source: row.source,
3620
3657
  medium: row.medium,
3621
3658
  channelGroup: row.channel_group,
@@ -5613,8 +5650,8 @@ var METRIC_LABELS = {
5613
5650
  WEBSITE_CLICKS: "Website clicks",
5614
5651
  CALL_CLICKS: "Call clicks"
5615
5652
  };
5616
- function metricLabel(metric) {
5617
- return METRIC_LABELS[metric] ?? metric;
5653
+ function metricLabel(metric2) {
5654
+ return METRIC_LABELS[metric2] ?? metric2;
5618
5655
  }
5619
5656
  function formatAmenityList(amenities) {
5620
5657
  if (amenities.length === 1) return amenities[0];
@@ -5708,12 +5745,12 @@ function analyzeGbp(signals) {
5708
5745
  }
5709
5746
  function pickWorstMetricDrop(loc) {
5710
5747
  let worst = null;
5711
- for (const metric of GBP_HEADLINE_METRICS) {
5712
- const delta = loc.metricDeltaPct[metric];
5713
- const prior = loc.metricPrior7d[metric] ?? 0;
5748
+ for (const metric2 of GBP_HEADLINE_METRICS) {
5749
+ const delta = loc.metricDeltaPct[metric2];
5750
+ const prior = loc.metricPrior7d[metric2] ?? 0;
5714
5751
  if (delta == null || delta > -GBP_METRIC_DROP_PCT || prior < GBP_METRIC_MIN_BASELINE) continue;
5715
5752
  if (!worst || delta < worst.deltaPct) {
5716
- worst = { metric, deltaPct: delta, recent: loc.metricRecent7d[metric] ?? 0, prior };
5753
+ worst = { metric: metric2, deltaPct: delta, recent: loc.metricRecent7d[metric2] ?? 0, prior };
5717
5754
  }
5718
5755
  }
5719
5756
  return worst;
@@ -8507,7 +8544,7 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
8507
8544
  });
8508
8545
  if (eligible.length > 0) usable = eligible;
8509
8546
  }
8510
- const metric = computeProviderMetric(usable);
8547
+ const metric2 = computeProviderMetric(usable);
8511
8548
  const queryCount = new Set(usable.map((s) => s.queryId)).size;
8512
8549
  const byProvider = {};
8513
8550
  for (const provider of new Set(usable.map((s) => s.provider))) {
@@ -8516,12 +8553,12 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
8516
8553
  buckets.push({
8517
8554
  startDate: startISO,
8518
8555
  endDate: endISO,
8519
- citationRate: metric.citationRate,
8520
- cited: metric.cited,
8521
- total: metric.total,
8556
+ citationRate: metric2.citationRate,
8557
+ cited: metric2.cited,
8558
+ total: metric2.total,
8522
8559
  queryCount,
8523
- mentionRate: metric.mentionRate,
8524
- mentionedCount: metric.mentionedCount,
8560
+ mentionRate: metric2.mentionRate,
8561
+ mentionedCount: metric2.mentionedCount,
8525
8562
  mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors),
8526
8563
  byProvider
8527
8564
  });
@@ -11272,6 +11309,7 @@ function renderServerActivity(report, audience) {
11272
11309
  if (!copy) return "";
11273
11310
  return `<span class="tone-${deltaTone(d.deltaPct)}">${escapeHtml(copy)}</span>`;
11274
11311
  };
11312
+ const referralSubtitle = [formatDelta(sa.referralArrivals, "sessions"), escapeHtml(sa.referralArrivalsClassSummary)].filter(Boolean).join(" \xB7 ");
11275
11313
  if (isClient) {
11276
11314
  const crawlerRequests = {
11277
11315
  current: sa.verifiedCrawlerHits.current + sa.unverifiedCrawlerHits.current,
@@ -11310,7 +11348,7 @@ function renderServerActivity(report, audience) {
11310
11348
  <div class="metric">
11311
11349
  <div class="label">AI referral sessions</div>
11312
11350
  <div class="value">${formatNumber(sa.referralArrivals.current)}</div>
11313
- <div class="subtitle">${formatDelta(sa.referralArrivals, "sessions")}</div>
11351
+ <div class="subtitle">${referralSubtitle}</div>
11314
11352
  </div>
11315
11353
  </div>
11316
11354
  ${clientOperatorRows ? `<div class="chart-card"><h3>By AI tool</h3>
@@ -11379,7 +11417,7 @@ function renderServerActivity(report, audience) {
11379
11417
  <div class="metric">
11380
11418
  <div class="label">AI-referral sessions (${windowLabel})</div>
11381
11419
  <div class="value">${formatNumber(sa.referralArrivals.current)}</div>
11382
- <div class="subtitle">${formatDelta(sa.referralArrivals, "sessions")}</div>
11420
+ <div class="subtitle">${referralSubtitle}</div>
11383
11421
  </div>
11384
11422
  </div>
11385
11423
  ${trendChart}
@@ -11905,7 +11943,7 @@ function safeNum(value) {
11905
11943
  return 0;
11906
11944
  }
11907
11945
  function isPaidAiTrafficClass(value) {
11908
- return value === GA4AiReferralTrafficClasses.paid;
11946
+ return value === AiReferralTrafficClasses.paid;
11909
11947
  }
11910
11948
  function categorizeQuery(query, projectBrandNames, canonicalDomain) {
11911
11949
  return categorizeQueryByIntent(query, buildBrandTokens(canonicalDomain, projectBrandNames));
@@ -12298,16 +12336,21 @@ function buildServerActivity(db, projectId, windowDays) {
12298
12336
  )
12299
12337
  ).get()?.total ?? 0
12300
12338
  );
12301
- const sumReferrals = (windowStartIso, windowEndIso, exclusiveEnd = false) => Number(
12302
- db.select({ total: sql7`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
12339
+ const sumReferrals = (windowStartIso, windowEndIso, exclusiveEnd = false) => {
12340
+ const row = db.select({
12341
+ total: sql7`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)`,
12342
+ paid: sql7`COALESCE(SUM(${aiReferralEventsHourly.paidSessionsOrHits}), 0)`,
12343
+ organic: sql7`COALESCE(SUM(${aiReferralEventsHourly.organicSessionsOrHits}), 0)`
12344
+ }).from(aiReferralEventsHourly).where(
12303
12345
  and12(
12304
12346
  eq15(aiReferralEventsHourly.projectId, projectId),
12305
12347
  nonSubresourceReferralPathCondition(),
12306
12348
  gte2(aiReferralEventsHourly.tsHour, windowStartIso),
12307
12349
  exclusiveEnd ? lt(aiReferralEventsHourly.tsHour, windowEndIso) : lte(aiReferralEventsHourly.tsHour, windowEndIso)
12308
12350
  )
12309
- ).get()?.total ?? 0
12310
- );
12351
+ ).get();
12352
+ return aiReferralClassCounts(Number(row?.total ?? 0), Number(row?.paid ?? 0), Number(row?.organic ?? 0));
12353
+ };
12311
12354
  const sumUserFetches = (windowStartIso, windowEndIso, exclusiveEnd = false) => Number(
12312
12355
  db.select({ total: sql7`COALESCE(SUM(${aiUserFetchEventsHourly.hits}), 0)` }).from(aiUserFetchEventsHourly).where(
12313
12356
  and12(
@@ -12505,7 +12548,7 @@ function buildServerActivity(db, projectId, windowDays) {
12505
12548
  return {
12506
12549
  windowStart: headlineStart,
12507
12550
  windowEnd: headlineEnd,
12508
- hasData: verifiedCurrent + unverifiedCurrent + userFetchCurrent + referralCurrent + verifiedPrior + unverifiedPrior + userFetchPrior + referralPrior > 0 || byOperator.length > 0 || topCrawledPaths.length > 0 || referralProducts.length > 0,
12551
+ hasData: verifiedCurrent + unverifiedCurrent + userFetchCurrent + referralCurrent.total + verifiedPrior + unverifiedPrior + userFetchPrior + referralPrior.total > 0 || byOperator.length > 0 || topCrawledPaths.length > 0 || referralProducts.length > 0,
12509
12552
  verifiedCrawlerHits: {
12510
12553
  current: verifiedCurrent,
12511
12554
  prior: verifiedPrior,
@@ -12522,10 +12565,28 @@ function buildServerActivity(db, projectId, windowDays) {
12522
12565
  deltaPct: deltaPercent(userFetchCurrent, userFetchPrior)
12523
12566
  },
12524
12567
  referralArrivals: {
12525
- current: referralCurrent,
12526
- prior: referralPrior,
12527
- deltaPct: deltaPercent(referralCurrent, referralPrior)
12568
+ current: referralCurrent.total,
12569
+ prior: referralPrior.total,
12570
+ deltaPct: deltaPercent(referralCurrent.total, referralPrior.total)
12571
+ },
12572
+ referralArrivalsByClass: {
12573
+ paid: {
12574
+ current: referralCurrent.paid,
12575
+ prior: referralPrior.paid,
12576
+ deltaPct: deltaPercent(referralCurrent.paid, referralPrior.paid)
12577
+ },
12578
+ organic: {
12579
+ current: referralCurrent.organic,
12580
+ prior: referralPrior.organic,
12581
+ deltaPct: deltaPercent(referralCurrent.organic, referralPrior.organic)
12582
+ },
12583
+ unclassified: {
12584
+ current: referralCurrent.unknown,
12585
+ prior: referralPrior.unknown,
12586
+ deltaPct: deltaPercent(referralCurrent.unknown, referralPrior.unknown)
12587
+ }
12528
12588
  },
12589
+ referralArrivalsClassSummary: formatAiReferralClassSummary(referralCurrent),
12529
12590
  byOperator,
12530
12591
  topCrawledPaths,
12531
12592
  referralProducts,
@@ -13573,6 +13634,253 @@ function normalizeDomain2(domain) {
13573
13634
 
13574
13635
  // ../api-routes/src/visibility-stats.ts
13575
13636
  import { and as and14, desc as desc8, eq as eq17, inArray as inArray8 } from "drizzle-orm";
13637
+
13638
+ // ../api-routes/src/visibility-compare.ts
13639
+ var VISIBILITY_COMPARE_MIN_RUNS = 5;
13640
+ function basketPairKey(queryId, provider) {
13641
+ return JSON.stringify([queryId, provider]);
13642
+ }
13643
+ function citedHostMatches(citedHost, competitorHost) {
13644
+ return citedHost === competitorHost || citedHost.endsWith(`.${competitorHost}`);
13645
+ }
13646
+ function restrict(snapshots, attribution, pairs) {
13647
+ const out = [];
13648
+ for (const snap of snapshots) {
13649
+ const resolved = resolveCurrentQuery(attribution, snap);
13650
+ if (!resolved) continue;
13651
+ if (!pairs.has(basketPairKey(resolved.id, snap.provider))) continue;
13652
+ out.push({ ...snap, queryId: resolved.id });
13653
+ }
13654
+ return out;
13655
+ }
13656
+ function observed(snapshots, attribution) {
13657
+ const queryIds = /* @__PURE__ */ new Set();
13658
+ const providers = /* @__PURE__ */ new Set();
13659
+ for (const snap of snapshots) {
13660
+ const resolved = resolveCurrentQuery(attribution, snap);
13661
+ if (!resolved) continue;
13662
+ queryIds.add(resolved.id);
13663
+ providers.add(snap.provider);
13664
+ }
13665
+ return { queryIds, providers };
13666
+ }
13667
+ function observedPairs(snapshots, attribution, queryIds) {
13668
+ const pairs = /* @__PURE__ */ new Map();
13669
+ for (const snap of snapshots) {
13670
+ const resolved = resolveCurrentQuery(attribution, snap);
13671
+ if (!resolved || !queryIds.has(resolved.id)) continue;
13672
+ const pair = { queryId: resolved.id, provider: snap.provider };
13673
+ pairs.set(basketPairKey(pair.queryId, pair.provider), pair);
13674
+ }
13675
+ return pairs;
13676
+ }
13677
+ function period(numerator, denominator) {
13678
+ const ci = wilsonInterval(numerator, denominator);
13679
+ return {
13680
+ point: denominator > 0 ? Math.round(numerator / denominator * 1e4) / 1e4 : null,
13681
+ ciLow: ci ? ci.low : null,
13682
+ ciHigh: ci ? ci.high : null,
13683
+ numerator,
13684
+ denominator
13685
+ };
13686
+ }
13687
+ function ciOverlap(a, b) {
13688
+ if (a.ciLow === null || a.ciHigh === null || b.ciLow === null || b.ciHigh === null) return true;
13689
+ return a.ciLow <= b.ciHigh && b.ciLow <= a.ciHigh;
13690
+ }
13691
+ function metric(key, label, driftRobust, from, to, continuityBlock) {
13692
+ const verdict = continuityBlock ?? (from.denominator === 0 || to.denominator === 0 ? "insufficient-data" : ciOverlap(from, to) ? "within-noise" : "moved");
13693
+ const direction = from.point === null || to.point === null ? null : to.point > from.point ? "up" : to.point < from.point ? "down" : "flat";
13694
+ const rateRatio = from.point === null || from.point === 0 || to.point === null ? null : Math.round(to.point / from.point * 100) / 100;
13695
+ return { key, label, driftRobust, from, to, rateRatio, direction, verdict };
13696
+ }
13697
+ function countPeriod(snaps, competitors2) {
13698
+ let checked = 0;
13699
+ let mentioned = 0;
13700
+ let cited = 0;
13701
+ let competitorCited = 0;
13702
+ const perProvider = /* @__PURE__ */ new Map();
13703
+ const models = /* @__PURE__ */ new Map();
13704
+ const modelUnknownProviders = /* @__PURE__ */ new Set();
13705
+ const mentionedQueries = /* @__PURE__ */ new Set();
13706
+ const competitorHosts = competitors2.map((c) => hostOf(c.domain)).filter((h) => h !== null && h.length > 0);
13707
+ for (const snap of snaps) {
13708
+ const isMentioned = snap.answerMentioned === true;
13709
+ if (snap.answerMentioned === true || snap.answerMentioned === false) checked += 1;
13710
+ if (isMentioned) {
13711
+ mentioned += 1;
13712
+ mentionedQueries.add(snap.queryId);
13713
+ }
13714
+ const isCited = snap.citationState === CitationStates.cited;
13715
+ if (isCited) cited += 1;
13716
+ const pp = perProvider.get(snap.provider) ?? { checked: 0, mentioned: 0, cited: 0 };
13717
+ if (snap.answerMentioned === true || snap.answerMentioned === false) pp.checked += 1;
13718
+ if (isMentioned) pp.mentioned += 1;
13719
+ if (isCited) pp.cited += 1;
13720
+ perProvider.set(snap.provider, pp);
13721
+ if (snap.model) {
13722
+ const set = models.get(snap.provider) ?? /* @__PURE__ */ new Set();
13723
+ set.add(snap.model);
13724
+ models.set(snap.provider, set);
13725
+ } else {
13726
+ modelUnknownProviders.add(snap.provider);
13727
+ }
13728
+ if (competitorHosts.length > 0 && snap.citedDomains.length > 0) {
13729
+ const citedHosts = snap.citedDomains.map((d) => hostOf(d)).filter((h) => h !== null && h.length > 0);
13730
+ for (const compHost of competitorHosts) {
13731
+ if (citedHosts.some((ch) => citedHostMatches(ch, compHost))) competitorCited += 1;
13732
+ }
13733
+ }
13734
+ }
13735
+ const mentionShare = buildMentionShare(
13736
+ snaps.map((s) => ({ projectMentioned: s.answerMentioned === true, answerText: s.answerText })),
13737
+ { competitors: competitors2 }
13738
+ );
13739
+ return {
13740
+ checked,
13741
+ mentioned,
13742
+ total: snaps.length,
13743
+ cited,
13744
+ projectCited: cited,
13745
+ competitorCited,
13746
+ queriesMentioned: mentionedQueries.size,
13747
+ perProvider,
13748
+ models,
13749
+ modelUnknownProviders,
13750
+ mentionShare,
13751
+ competitors: mentionShare.breakdown.perCompetitor.map((c) => ({ domain: c.domain, mentions: c.mentionSnapshots }))
13752
+ };
13753
+ }
13754
+ function computeVisibilityCompare(input) {
13755
+ const attribution = buildQueryAttribution(input.queries);
13756
+ const fromObs = observed(input.from.snapshots, attribution);
13757
+ const toObs = observed(input.to.snapshots, attribution);
13758
+ const queriesObservedBoth = new Set([...fromObs.queryIds].filter((q) => toObs.queryIds.has(q)));
13759
+ const fromPairs = observedPairs(input.from.snapshots, attribution, queriesObservedBoth);
13760
+ const toPairs = observedPairs(input.to.snapshots, attribution, queriesObservedBoth);
13761
+ const pairsBoth = new Map([...fromPairs].filter(([key]) => toPairs.has(key)));
13762
+ const candidateProviders = new Set([...pairsBoth.values()].map((pair) => pair.provider));
13763
+ const fromCandidateSnaps = restrict(input.from.snapshots, attribution, pairsBoth);
13764
+ const toCandidateSnaps = restrict(input.to.snapshots, attribution, pairsBoth);
13765
+ const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors);
13766
+ const toCandidateCounts = countPeriod(toCandidateSnaps, input.competitors);
13767
+ const continuityProviders = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
13768
+ const fromModels = [...fromCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
13769
+ const toModels = [...toCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
13770
+ const fromModelUnknown = fromCandidateCounts.modelUnknownProviders.has(provider) || fromModels.length === 0;
13771
+ const toModelUnknown = toCandidateCounts.modelUnknownProviders.has(provider) || toModels.length === 0;
13772
+ const status = fromModelUnknown || toModelUnknown ? "model-unknown" : fromModels.length === 1 && toModels.length === 1 && fromModels[0] === toModels[0] ? "included" : "model-discontinuous";
13773
+ return { provider, status, fromModels, toModels };
13774
+ });
13775
+ const providersBoth = new Set(
13776
+ continuityProviders.filter((provider) => provider.status === "included").map((provider) => provider.provider)
13777
+ );
13778
+ const continuityStatus = candidateProviders.size === 0 ? "insufficient-data" : providersBoth.size > 0 ? "comparable" : continuityProviders.every((provider) => provider.status === "model-unknown") ? "model-unknown" : "model-discontinuous";
13779
+ const continuityBlock = continuityStatus === "model-discontinuous" || continuityStatus === "model-unknown" ? continuityStatus : null;
13780
+ const comparedPairs = new Map([...pairsBoth].filter(([, pair]) => providersBoth.has(pair.provider)));
13781
+ const queriesBoth = new Set([...comparedPairs.values()].map((pair) => pair.queryId));
13782
+ const excludedProviders = [.../* @__PURE__ */ new Set([...fromObs.providers, ...toObs.providers])].filter((p) => !providersBoth.has(p)).sort((a, b) => a.localeCompare(b));
13783
+ const fromSnaps = restrict(input.from.snapshots, attribution, comparedPairs);
13784
+ const toSnaps = restrict(input.to.snapshots, attribution, comparedPairs);
13785
+ const fromCounts = countPeriod(fromSnaps, input.competitors);
13786
+ const toCounts = countPeriod(toSnaps, input.competitors);
13787
+ const shareCounts = (c) => ({
13788
+ proj: c.mentionShare.breakdown.projectMentionSnapshots,
13789
+ comp: c.mentionShare.breakdown.competitorMentionSnapshots
13790
+ });
13791
+ const fromShare = shareCounts(fromCounts);
13792
+ const toShare = shareCounts(toCounts);
13793
+ const metrics = [
13794
+ metric(
13795
+ "mention-share-of-voice",
13796
+ "Named share of voice",
13797
+ true,
13798
+ period(fromShare.proj, fromShare.proj + fromShare.comp),
13799
+ period(toShare.proj, toShare.proj + toShare.comp),
13800
+ continuityBlock
13801
+ ),
13802
+ // Share of voice is undefined without a competitive frame: with zero
13803
+ // configured competitors the denominator degenerates to the project's own
13804
+ // count and the metric would fabricate a 100%. buildMentionShare already
13805
+ // refuses that on the mention side ("reporting 100% would mislead");
13806
+ // degrade the cited side identically to a 0/0 period -> insufficient-data.
13807
+ metric(
13808
+ "cited-share-of-voice",
13809
+ "Cited share of voice",
13810
+ true,
13811
+ input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : period(0, 0),
13812
+ input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : period(0, 0),
13813
+ continuityBlock
13814
+ ),
13815
+ metric(
13816
+ "mention-rate",
13817
+ "Named rate",
13818
+ false,
13819
+ period(fromCounts.mentioned, fromCounts.checked),
13820
+ period(toCounts.mentioned, toCounts.checked),
13821
+ continuityBlock
13822
+ ),
13823
+ metric(
13824
+ "cited-rate",
13825
+ "Cited rate",
13826
+ false,
13827
+ period(fromCounts.cited, fromCounts.total),
13828
+ period(toCounts.cited, toCounts.total),
13829
+ continuityBlock
13830
+ )
13831
+ ];
13832
+ const modelChanges = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
13833
+ const fromModels = [...fromCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
13834
+ const toModels = [...toCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
13835
+ return { provider, fromModels, toModels };
13836
+ }).filter(
13837
+ (c) => c.fromModels.length > 0 && c.toModels.length > 0 && JSON.stringify(c.fromModels) !== JSON.stringify(c.toModels)
13838
+ );
13839
+ const byProvider = [...providersBoth].sort((a, b) => a.localeCompare(b)).map((provider) => ({
13840
+ provider,
13841
+ from: fromCounts.perProvider.get(provider) ?? { checked: 0, mentioned: 0, cited: 0 },
13842
+ to: toCounts.perProvider.get(provider) ?? { checked: 0, mentioned: 0, cited: 0 }
13843
+ }));
13844
+ return {
13845
+ project: input.project,
13846
+ from: {
13847
+ month: input.from.month,
13848
+ since: input.from.since,
13849
+ until: input.from.until,
13850
+ runCount: input.from.runCount,
13851
+ lowRunCount: input.from.runCount < VISIBILITY_COMPARE_MIN_RUNS
13852
+ },
13853
+ to: {
13854
+ month: input.to.month,
13855
+ since: input.to.since,
13856
+ until: input.to.until,
13857
+ runCount: input.to.runCount,
13858
+ lowRunCount: input.to.runCount < VISIBILITY_COMPARE_MIN_RUNS
13859
+ },
13860
+ basket: {
13861
+ queryCount: queriesBoth.size,
13862
+ excludedFromOnly: [...fromObs.queryIds].filter((q) => !queriesBoth.has(q)).length,
13863
+ excludedToOnly: [...toObs.queryIds].filter((q) => !queriesBoth.has(q)).length,
13864
+ providers: [...providersBoth].sort((a, b) => a.localeCompare(b)),
13865
+ excludedProviders
13866
+ },
13867
+ metrics,
13868
+ queriesMentioned: {
13869
+ from: { count: fromCounts.queriesMentioned, of: queriesBoth.size },
13870
+ to: { count: toCounts.queriesMentioned, of: queriesBoth.size }
13871
+ },
13872
+ byProvider,
13873
+ modelChanges,
13874
+ continuity: {
13875
+ status: continuityStatus,
13876
+ comparedProviders: [...providersBoth].sort((a, b) => a.localeCompare(b)),
13877
+ providers: continuityProviders
13878
+ },
13879
+ competitors: { from: fromCounts.competitors, to: toCounts.competitors }
13880
+ };
13881
+ }
13882
+
13883
+ // ../api-routes/src/visibility-stats.ts
13576
13884
  function round42(value) {
13577
13885
  return Math.round(value * 1e4) / 1e4;
13578
13886
  }
@@ -13791,6 +14099,62 @@ async function visibilityStatsRoutes(app) {
13791
14099
  };
13792
14100
  return reply.send(response);
13793
14101
  });
14102
+ app.get("/projects/:name/visibility-compare", async (request, reply) => {
14103
+ const project = resolveProject(app.db, request.params.name);
14104
+ const { from: fromRaw, to: toRaw } = request.query;
14105
+ if (fromRaw === void 0 || fromRaw === "") throw validationError('"from" (YYYY-MM) is required');
14106
+ if (toRaw === void 0 || toRaw === "") throw validationError('"to" (YYYY-MM) is required');
14107
+ let fromBounds;
14108
+ let toBounds;
14109
+ try {
14110
+ fromBounds = calendarMonthBounds(fromRaw);
14111
+ } catch (err) {
14112
+ throw validationError(err instanceof RangeError ? `"from": ${err.message}` : '"from" must be in YYYY-MM format');
14113
+ }
14114
+ try {
14115
+ toBounds = calendarMonthBounds(toRaw);
14116
+ } catch (err) {
14117
+ throw validationError(err instanceof RangeError ? `"to": ${err.message}` : '"to" must be in YYYY-MM format');
14118
+ }
14119
+ if (Date.parse(fromBounds.since) >= Date.parse(toBounds.since)) {
14120
+ throw validationError('"from" must be a month strictly before "to"');
14121
+ }
14122
+ const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq17(queries.projectId, project.id)).all();
14123
+ const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq17(competitors.projectId, project.id)).all();
14124
+ const competitorInputs = competitorRows.map((c) => ({
14125
+ domain: c.domain,
14126
+ brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
14127
+ }));
14128
+ const loadMonth = (bounds) => {
14129
+ const sinceMs = Date.parse(bounds.since);
14130
+ const untilMs = Date.parse(bounds.until);
14131
+ const monthRuns = app.db.select({ id: runs.id, createdAt: runs.createdAt, status: runs.status }).from(runs).where(and14(eq17(runs.projectId, project.id), eq17(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).all().filter(
14132
+ (r) => (r.status === RunStatuses.completed || r.status === RunStatuses.partial) && Date.parse(r.createdAt) >= sinceMs && Date.parse(r.createdAt) <= untilMs
14133
+ );
14134
+ const runIds = monthRuns.map((r) => r.id);
14135
+ const snapshots = runIds.length > 0 && projectQueries.length > 0 ? app.db.select({
14136
+ queryId: querySnapshots.queryId,
14137
+ queryText: querySnapshots.queryText,
14138
+ provider: querySnapshots.provider,
14139
+ model: querySnapshots.model,
14140
+ citationState: querySnapshots.citationState,
14141
+ answerMentioned: querySnapshots.answerMentioned,
14142
+ answerText: querySnapshots.answerText,
14143
+ citedDomains: querySnapshots.citedDomains
14144
+ }).from(querySnapshots).where(inArray8(querySnapshots.runId, runIds)).all() : [];
14145
+ return { runCount: monthRuns.length, snapshots };
14146
+ };
14147
+ const fromMonth = loadMonth(fromBounds);
14148
+ const toMonth = loadMonth(toBounds);
14149
+ const dto = computeVisibilityCompare({
14150
+ project: project.name,
14151
+ queries: projectQueries,
14152
+ competitors: competitorInputs,
14153
+ from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
14154
+ to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
14155
+ });
14156
+ return reply.send(dto);
14157
+ });
13794
14158
  }
13795
14159
 
13796
14160
  // ../api-routes/src/composites.ts
@@ -14596,6 +14960,7 @@ var SCHEMA_TABLE = {
14596
14960
  TrafficSourceListResponse: trafficSourceListResponseSchema,
14597
14961
  TrafficStatusResponse: trafficStatusResponseSchema,
14598
14962
  TrafficSyncResponse: trafficSyncResponseSchema,
14963
+ VisibilityCompareDto: visibilityCompareDtoSchema,
14599
14964
  VisibilityStatsDto: visibilityStatsDtoSchema,
14600
14965
  WordpressAuditPageDto: wordpressAuditPageDtoSchema,
14601
14966
  WordpressBulkMetaResultDto: wordpressBulkMetaResultDtoSchema,
@@ -14872,6 +15237,20 @@ var monthQueryParameter = {
14872
15237
  description: `Aggregate a single calendar month (YYYY-MM), expanded to that month's inclusive UTC bounds. Mutually exclusive with "since"/"until"/"lastRuns".`,
14873
15238
  schema: stringSchema
14874
15239
  };
15240
+ var compareFromQueryParameter = {
15241
+ name: "from",
15242
+ in: "query",
15243
+ required: true,
15244
+ description: 'Earlier calendar month (YYYY-MM) \u2014 the baseline period. Must be strictly before "to".',
15245
+ schema: stringSchema
15246
+ };
15247
+ var compareToQueryParameter = {
15248
+ name: "to",
15249
+ in: "query",
15250
+ required: true,
15251
+ description: 'Later calendar month (YYYY-MM) \u2014 the reporting period compared against "from".',
15252
+ schema: stringSchema
15253
+ };
14875
15254
  var shareOfVoiceQueryParameter = {
14876
15255
  name: "shareOfVoice",
14877
15256
  in: "query",
@@ -15647,6 +16026,19 @@ var routeCatalog = [
15647
16026
  404: errorResponse("Project not found.")
15648
16027
  }
15649
16028
  },
16029
+ {
16030
+ method: "get",
16031
+ path: "/api/v1/projects/{name}/visibility-compare",
16032
+ summary: "Compare AEO visibility month over month",
16033
+ description: "Statistically honest month-over-month AEO comparison in one call. PRIMARY metric is share of voice (brand vs competitor mentions in the same answers), which is less exposed to broad model-wide naming propensity than an absolute rate but never bypasses model continuity. Rates are pooled per-snapshot over each month (invariant to sweep count), restricted to query/provider pairs present in BOTH months, and then restricted again to providers with exactly one known, identical configured model id in both months. `continuity` reports every provider and its model evidence; changed, mixed mid-month, or legacy-unknown models are excluded. When no provider remains, metrics return a continuity-blocked verdict rather than a directional call. `from` must be a month strictly before `to`. A silent upstream version bump under an unchanged configured id remains undetectable.",
16034
+ tags: ["analytics"],
16035
+ parameters: [nameParameter, compareFromQueryParameter, compareToQueryParameter],
16036
+ responses: {
16037
+ 200: jsonResponse("Month-over-month visibility comparison returned.", "VisibilityCompareDto"),
16038
+ 400: errorResponse("Invalid or missing from/to months."),
16039
+ 404: errorResponse("Project not found.")
16040
+ }
16041
+ },
15650
16042
  {
15651
16043
  method: "get",
15652
16044
  path: "/api/v1/projects/{name}/snapshots/diff",
@@ -19458,12 +19850,12 @@ function computeWindowDelta(rows, referenceDate) {
19458
19850
  }
19459
19851
  const deltaPct = {};
19460
19852
  const metrics = /* @__PURE__ */ new Set([...Object.keys(recent7d), ...Object.keys(prior7d)]);
19461
- for (const metric of metrics) {
19462
- const recent = recent7d[metric] ?? 0;
19463
- const prior = prior7d[metric] ?? 0;
19464
- recent7d[metric] = recent;
19465
- prior7d[metric] = prior;
19466
- deltaPct[metric] = prior === 0 ? null : Math.round((recent - prior) / prior * 100);
19853
+ for (const metric2 of metrics) {
19854
+ const recent = recent7d[metric2] ?? 0;
19855
+ const prior = prior7d[metric2] ?? 0;
19856
+ recent7d[metric2] = recent;
19857
+ prior7d[metric2] = prior;
19858
+ deltaPct[metric2] = prior === 0 ? null : Math.round((recent - prior) / prior * 100);
19467
19859
  }
19468
19860
  return { recent7d, prior7d, deltaPct };
19469
19861
  }
@@ -20551,7 +20943,7 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
20551
20943
  date: row.dimensionValues[0].value,
20552
20944
  source,
20553
20945
  medium,
20554
- trafficClass: classifyGa4AiReferralTrafficClass({ source, medium, channelGroup, landingPage }),
20946
+ trafficClass: classifyAiReferralTrafficClass({ source, medium, channelGroup, landingPage }),
20555
20947
  channelGroup,
20556
20948
  landingPage,
20557
20949
  sessions: parseInt(row.metricValues[0].value, 10) || 0,
@@ -23527,7 +23919,7 @@ function pickWinningDimension(rows, tupleKey) {
23527
23919
  return [...winners.values()].sort((a, b) => (b.sessions ?? 0) - (a.sessions ?? 0));
23528
23920
  }
23529
23921
  function normalizeAiTrafficClass(value) {
23530
- return value === GA4AiReferralTrafficClasses.paid ? GA4AiReferralTrafficClasses.paid : GA4AiReferralTrafficClasses.organic;
23922
+ return value === AiReferralTrafficClasses.paid ? AiReferralTrafficClasses.paid : AiReferralTrafficClasses.organic;
23531
23923
  }
23532
23924
  function emptyAiCounts() {
23533
23925
  return { sessions: 0, users: 0 };
@@ -23540,7 +23932,7 @@ function summarizeAiReferralCounts(rows) {
23540
23932
  const paidBySession = emptyAiCounts();
23541
23933
  const organicBySession = emptyAiCounts();
23542
23934
  for (const row of rows) {
23543
- const isPaid = normalizeAiTrafficClass(row.trafficClass) === GA4AiReferralTrafficClasses.paid;
23935
+ const isPaid = normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid;
23544
23936
  const sessions = row.sessions ?? 0;
23545
23937
  const users = row.users ?? 0;
23546
23938
  const key = `${row.date}\0${row.source}\0${row.medium}`;
@@ -23872,7 +24264,7 @@ async function ga4Routes(app, opts) {
23872
24264
  date: row.date,
23873
24265
  source: row.source,
23874
24266
  medium: row.medium,
23875
- trafficClass: row.trafficClass ?? classifyGa4AiReferralTrafficClass({
24267
+ trafficClass: row.trafficClass ?? classifyAiReferralTrafficClass({
23876
24268
  source: row.source,
23877
24269
  medium: row.medium,
23878
24270
  channelGroup: row.channelGroup,
@@ -29650,6 +30042,33 @@ function utmSourceFromUrl(value) {
29650
30042
  return null;
29651
30043
  }
29652
30044
  }
30045
+ function paramsFromQuery(queryString) {
30046
+ if (!queryString) return null;
30047
+ const params = new URLSearchParams(queryString);
30048
+ return [...params.keys()].length > 0 ? params : null;
30049
+ }
30050
+ function paramsFromUrl(value) {
30051
+ if (!value) return null;
30052
+ try {
30053
+ return paramsFromQuery(new URL(value).search.replace(/^\?/, ""));
30054
+ } catch {
30055
+ return null;
30056
+ }
30057
+ }
30058
+ function classifyAiReferralTrafficClassFromEvent(event) {
30059
+ for (const params of [paramsFromQuery(event.queryString), paramsFromUrl(event.referer)]) {
30060
+ if (!params) continue;
30061
+ const trafficClass = classifyAiReferralTrafficClass({
30062
+ source: params.get("utm_source"),
30063
+ medium: params.get("utm_medium"),
30064
+ channelGroup: null,
30065
+ // Carries utm_campaign / utm_content / utm_term to the shared rules.
30066
+ landingPage: `/?${params.toString()}`
30067
+ });
30068
+ if (trafficClass === AiReferralTrafficClasses.paid) return AiReferralTrafficClasses.paid;
30069
+ }
30070
+ return AiReferralTrafficClasses.organic;
30071
+ }
29653
30072
  function isSelfTraffic(event) {
29654
30073
  const userAgent = event.userAgent?.trim();
29655
30074
  if (!userAgent) return false;
@@ -29693,6 +30112,7 @@ function classifyAiUserFetch(event) {
29693
30112
  return null;
29694
30113
  }
29695
30114
  function classifyAiReferral(event) {
30115
+ const trafficClass = classifyAiReferralTrafficClassFromEvent(event);
29696
30116
  const refererHost = hostFromUrl(event.referer);
29697
30117
  if (refererHost) {
29698
30118
  const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => hostMatches(refererHost, candidate.domain));
@@ -29701,7 +30121,8 @@ function classifyAiReferral(event) {
29701
30121
  operator: rule.operator,
29702
30122
  product: rule.product,
29703
30123
  sourceDomain: refererHost,
29704
- evidenceType: "referer"
30124
+ evidenceType: "referer",
30125
+ trafficClass
29705
30126
  };
29706
30127
  }
29707
30128
  }
@@ -29713,7 +30134,8 @@ function classifyAiReferral(event) {
29713
30134
  operator: rule.operator,
29714
30135
  product: rule.product,
29715
30136
  sourceDomain: utmSource,
29716
- evidenceType: "utm"
30137
+ evidenceType: "utm",
30138
+ trafficClass
29717
30139
  };
29718
30140
  }
29719
30141
  }
@@ -29725,7 +30147,8 @@ function classifyAiReferral(event) {
29725
30147
  operator: rule.operator,
29726
30148
  product: rule.product,
29727
30149
  sourceDomain: refererUtmSource,
29728
- evidenceType: "referer-utm"
30150
+ evidenceType: "referer-utm",
30151
+ trafficClass
29729
30152
  };
29730
30153
  }
29731
30154
  }
@@ -29828,8 +30251,10 @@ function evidenceRank(evidenceType) {
29828
30251
  return 1;
29829
30252
  }
29830
30253
  }
29831
- function strongerReferralEvidence(current, next) {
29832
- return evidenceRank(next.evidenceType) > evidenceRank(current.evidenceType) ? next : current;
30254
+ function mergeReferralSession(current, next) {
30255
+ const winner = evidenceRank(next.evidenceType) > evidenceRank(current.evidenceType) ? next : current;
30256
+ const paid = current.trafficClass === AiReferralTrafficClasses.paid || next.trafficClass === AiReferralTrafficClasses.paid;
30257
+ return { ...winner, trafficClass: paid ? AiReferralTrafficClasses.paid : winner.trafficClass };
29833
30258
  }
29834
30259
  function sortCrawlerBuckets(a, b) {
29835
30260
  return a.tsHour.localeCompare(b.tsHour) || a.botId.localeCompare(b.botId) || a.pathNormalized.localeCompare(b.pathNormalized) || String(a.status).localeCompare(String(b.status));
@@ -29862,6 +30287,8 @@ function buildTrafficProbeReport(events, options = {}) {
29862
30287
  let crawlerHits = 0;
29863
30288
  let aiUserFetchHits = 0;
29864
30289
  let aiReferralHits = 0;
30290
+ let aiReferralPaidSessions = 0;
30291
+ let aiReferralOrganicSessions = 0;
29865
30292
  let unknownHits = 0;
29866
30293
  const samples = [];
29867
30294
  for (const event of includedEvents) {
@@ -29943,11 +30370,12 @@ function buildTrafficProbeReport(events, options = {}) {
29943
30370
  sourceDomain: aiReferral.sourceDomain,
29944
30371
  evidenceType: aiReferral.evidenceType,
29945
30372
  landingPathNormalized,
29946
- status: event.status
30373
+ status: event.status,
30374
+ trafficClass: aiReferral.trafficClass
29947
30375
  };
29948
30376
  const key = aiReferralSessionKey(event, aiReferral, landingPathNormalized, aiReferralSessionWindowMs);
29949
30377
  const existing = aiReferralSessions.get(key);
29950
- aiReferralSessions.set(key, existing ? strongerReferralEvidence(existing, session) : session);
30378
+ aiReferralSessions.set(key, existing ? mergeReferralSession(existing, session) : session);
29951
30379
  }
29952
30380
  }
29953
30381
  if (!crawler && !aiUserFetch && !aiReferral) unknownHits += 1;
@@ -29975,13 +30403,21 @@ function buildTrafficProbeReport(events, options = {}) {
29975
30403
  session.landingPathNormalized,
29976
30404
  session.status ?? "null"
29977
30405
  ].join(" ");
30406
+ const { trafficClass, ...bucketFields } = session;
30407
+ const paid = trafficClass === AiReferralTrafficClasses.paid;
30408
+ if (paid) aiReferralPaidSessions += 1;
30409
+ else aiReferralOrganicSessions += 1;
29978
30410
  const existing = aiReferralBuckets.get(key);
29979
30411
  if (existing) {
29980
30412
  existing.hits += 1;
30413
+ if (paid) existing.paidHits += 1;
30414
+ else existing.organicHits += 1;
29981
30415
  } else {
29982
30416
  aiReferralBuckets.set(key, {
29983
- ...session,
29984
- hits: 1
30417
+ ...bucketFields,
30418
+ hits: 1,
30419
+ paidHits: paid ? 1 : 0,
30420
+ organicHits: paid ? 0 : 1
29985
30421
  });
29986
30422
  }
29987
30423
  incrementBucket(topAiReferrers, session.sourceDomain, {
@@ -30000,6 +30436,8 @@ function buildTrafficProbeReport(events, options = {}) {
30000
30436
  crawlerHits,
30001
30437
  aiUserFetchHits,
30002
30438
  aiReferralSessions: aiReferralSessions.size,
30439
+ aiReferralPaidSessions,
30440
+ aiReferralOrganicSessions,
30003
30441
  aiReferralHits,
30004
30442
  unknownHits
30005
30443
  },
@@ -30761,6 +31199,8 @@ async function runBackfillTask(options) {
30761
31199
  landingPathNormalized: bucket.landingPathNormalized,
30762
31200
  status: bucket.status ?? 0,
30763
31201
  sessionsOrHits: bucket.hits,
31202
+ paidSessionsOrHits: bucket.paidHits,
31203
+ organicSessionsOrHits: bucket.organicHits,
30764
31204
  usersEstimated: null,
30765
31205
  createdAt: finishedAt,
30766
31206
  updatedAt: finishedAt
@@ -31508,6 +31948,8 @@ async function trafficRoutes(app, opts) {
31508
31948
  landingPathNormalized: bucket.landingPathNormalized,
31509
31949
  status,
31510
31950
  sessionsOrHits: bucket.hits,
31951
+ paidSessionsOrHits: bucket.paidHits,
31952
+ organicSessionsOrHits: bucket.organicHits,
31511
31953
  usersEstimated: null,
31512
31954
  createdAt: finishedAt,
31513
31955
  updatedAt: finishedAt
@@ -31522,8 +31964,13 @@ async function trafficRoutes(app, opts) {
31522
31964
  aiReferralEventsHourly.landingPathNormalized,
31523
31965
  aiReferralEventsHourly.status
31524
31966
  ],
31967
+ // All three counters accumulate. The class splits the measure, so a
31968
+ // colliding bucket adds its paid and organic arrivals to the ones
31969
+ // already there rather than overwriting a single label.
31525
31970
  set: {
31526
31971
  sessionsOrHits: sql12`${aiReferralEventsHourly.sessionsOrHits} + ${bucket.hits}`,
31972
+ paidSessionsOrHits: sql12`${aiReferralEventsHourly.paidSessionsOrHits} + ${bucket.paidHits}`,
31973
+ organicSessionsOrHits: sql12`${aiReferralEventsHourly.organicSessionsOrHits} + ${bucket.organicHits}`,
31527
31974
  updatedAt: finishedAt
31528
31975
  }
31529
31976
  }).run();
@@ -31975,7 +32422,7 @@ async function trafficRoutes(app, opts) {
31975
32422
  let crawlerTotal = 0;
31976
32423
  let crawlerSegments = { content: 0, sitemap: 0, robots: 0, asset: 0, other: 0 };
31977
32424
  let aiUserFetchTotal = 0;
31978
- let aiReferralTotal = 0;
32425
+ let aiReferralCounts = aiReferralClassCounts(0, 0, 0);
31979
32426
  if (kind === "all" || kind === TrafficEventKinds.crawler) {
31980
32427
  const crawlerFilters = [
31981
32428
  eq28(crawlerEventsHourly.projectId, project.id),
@@ -32041,10 +32488,19 @@ async function trafficRoutes(app, opts) {
32041
32488
  ];
32042
32489
  if (sourceIdParam) aiFilters.push(eq28(aiReferralEventsHourly.sourceId, sourceIdParam));
32043
32490
  const aiWhere = and24(...aiFilters);
32044
- const total = app.db.select({ total: sql12`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(aiWhere).get();
32045
- aiReferralTotal = Number(total?.total ?? 0);
32491
+ const total = app.db.select({
32492
+ total: sql12`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)`,
32493
+ paid: sql12`COALESCE(SUM(${aiReferralEventsHourly.paidSessionsOrHits}), 0)`,
32494
+ organic: sql12`COALESCE(SUM(${aiReferralEventsHourly.organicSessionsOrHits}), 0)`
32495
+ }).from(aiReferralEventsHourly).where(aiWhere).get();
32496
+ aiReferralCounts = aiReferralClassCounts(
32497
+ Number(total?.total ?? 0),
32498
+ Number(total?.paid ?? 0),
32499
+ Number(total?.organic ?? 0)
32500
+ );
32046
32501
  const rows = app.db.select().from(aiReferralEventsHourly).where(aiWhere).orderBy(desc15(aiReferralEventsHourly.tsHour)).limit(limit).all();
32047
32502
  for (const r of rows) {
32503
+ const counts2 = aiReferralClassCounts(r.sessionsOrHits, r.paidSessionsOrHits, r.organicSessionsOrHits);
32048
32504
  events.push({
32049
32505
  kind: TrafficEventKinds["ai-referral"],
32050
32506
  sourceId: r.sourceId,
@@ -32055,7 +32511,10 @@ async function trafficRoutes(app, opts) {
32055
32511
  evidenceType: r.evidenceType,
32056
32512
  landingPathNormalized: r.landingPathNormalized,
32057
32513
  status: r.status,
32058
- hits: r.sessionsOrHits
32514
+ hits: counts2.total,
32515
+ paidHits: counts2.paid,
32516
+ organicHits: counts2.organic,
32517
+ unknownHits: counts2.unknown
32059
32518
  });
32060
32519
  }
32061
32520
  }
@@ -32070,7 +32529,10 @@ async function trafficRoutes(app, opts) {
32070
32529
  crawlerInfraHits: sumInfraHits(crawlerSegments),
32071
32530
  crawlerSegments,
32072
32531
  aiUserFetchHits: aiUserFetchTotal,
32073
- aiReferralHits: aiReferralTotal
32532
+ aiReferralHits: aiReferralCounts.total,
32533
+ aiReferralPaidHits: aiReferralCounts.paid,
32534
+ aiReferralOrganicHits: aiReferralCounts.organic,
32535
+ aiReferralUnknownHits: aiReferralCounts.unknown
32074
32536
  },
32075
32537
  events: trimmed
32076
32538
  };