@ainyc/canonry 4.115.0 → 4.115.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.
Files changed (24) hide show
  1. package/assets/assets/{BacklinksPage-DhXsZ8Sb.js → BacklinksPage-yUb8oJuq.js} +1 -1
  2. package/assets/assets/{ChartPrimitives-Cdj0P8uG.js → ChartPrimitives-CdQM3YDG.js} +1 -1
  3. package/assets/assets/ProjectPage-CHj9fvTs.js +7 -0
  4. package/assets/assets/{RunRow-GJ_2CEif.js → RunRow-BU_x7EcP.js} +1 -1
  5. package/assets/assets/{RunsPage-upaDiLFK.js → RunsPage-CFIBWaO7.js} +1 -1
  6. package/assets/assets/{SettingsPage-FbmEaN16.js → SettingsPage-PyMryUad.js} +1 -1
  7. package/assets/assets/{TrafficPage-Di0H5JRu.js → TrafficPage-471ypAdQ.js} +1 -1
  8. package/assets/assets/{TrafficSourceDetailPage-Bc2mxXSV.js → TrafficSourceDetailPage-CI7TEQge.js} +1 -1
  9. package/assets/assets/{arrow-left-yOCIy3_x.js → arrow-left-D2N9u-0i.js} +1 -1
  10. package/assets/assets/{extract-error-message-tCNIzznL.js → extract-error-message-CAdsqKp3.js} +1 -1
  11. package/assets/assets/{index-Bb5yUz7k.css → index-B4R1-8hR.css} +1 -1
  12. package/assets/assets/{index-D0ZdnFQf.js → index-CoL5HENE.js} +66 -66
  13. package/assets/assets/{trash-2-gRyTO-PS.js → trash-2-UgQrN5Np.js} +1 -1
  14. package/assets/index.html +2 -2
  15. package/dist/{chunk-VEUBAZRG.js → chunk-6LOKMEL3.js} +101 -0
  16. package/dist/{chunk-PGVGHGIX.js → chunk-B2SNXXAJ.js} +4 -4
  17. package/dist/{chunk-MO7GPMNV.js → chunk-CW4JISBL.js} +1 -1
  18. package/dist/{chunk-BXNFOGDF.js → chunk-Z3OGDQHB.js} +268 -63
  19. package/dist/cli.js +52 -8
  20. package/dist/index.js +4 -4
  21. package/dist/{intelligence-service-2C7H4C6A.js → intelligence-service-4AWB2V7U.js} +2 -2
  22. package/dist/mcp.js +2 -2
  23. package/package.json +10 -10
  24. package/assets/assets/ProjectPage-DAWtJQ3w.js +0 -7
@@ -20,6 +20,7 @@ import {
20
20
  DiscoveryBuckets,
21
21
  DiscoveryCompetitorTypes,
22
22
  DiscoverySessionStatuses,
23
+ GA4AiReferralTrafficClasses,
23
24
  RunKinds,
24
25
  RunStatuses,
25
26
  RunTriggers,
@@ -87,6 +88,7 @@ import {
87
88
  citationStateToCited,
88
89
  citationVisibilityResponseSchema,
89
90
  classifyCitedSurface,
91
+ classifyGa4AiReferralTrafficClass,
90
92
  classifySkillFile,
91
93
  classifySurfaceFromCategory,
92
94
  classifyTrafficPath,
@@ -273,7 +275,7 @@ import {
273
275
  wordpressSchemaDeployResultDtoSchema,
274
276
  wordpressSchemaStatusResultDtoSchema,
275
277
  wordpressStatusDtoSchema
276
- } from "./chunk-VEUBAZRG.js";
278
+ } from "./chunk-6LOKMEL3.js";
277
279
 
278
280
  // src/intelligence-service.ts
279
281
  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";
@@ -758,6 +760,7 @@ var gaAiReferrals = sqliteTable("ga_ai_referrals", {
758
760
  date: text("date").notNull(),
759
761
  source: text("source").notNull(),
760
762
  medium: text("medium").notNull(),
763
+ trafficClass: text("traffic_class").$type().notNull().default("organic"),
761
764
  /** Which GA4 dimension produced this row: 'session' | 'first_user' | 'manual_utm' */
762
765
  sourceDimension: text("source_dimension").notNull().default("session"),
763
766
  /** GA4 default channel group for the session (e.g. 'Referral', 'Organic Social'). */
@@ -772,6 +775,7 @@ var gaAiReferrals = sqliteTable("ga_ai_referrals", {
772
775
  index("idx_ga_ai_ref_project_date").on(table.projectId, table.date),
773
776
  index("idx_ga_ai_ref_source").on(table.source),
774
777
  index("idx_ga_ai_ref_landing_page").on(table.projectId, table.date, table.landingPageNormalized),
778
+ index("idx_ga_ai_ref_traffic_class").on(table.projectId, table.date, table.trafficClass),
775
779
  uniqueIndex("idx_ga_ai_ref_unique_v4").on(table.projectId, table.date, table.source, table.medium, table.sourceDimension, table.channelGroup, table.landingPage),
776
780
  index("idx_ga_ai_ref_run").on(table.syncRunId)
777
781
  ]);
@@ -3482,6 +3486,41 @@ var MIGRATION_VERSIONS = [
3482
3486
  // post-truncation). Additive + nullable (downgrade-safe).
3483
3487
  `ALTER TABLE discovery_sessions ADD COLUMN canonical_count INTEGER`
3484
3488
  ]
3489
+ },
3490
+ {
3491
+ version: 95,
3492
+ name: "ga-ai-referral-traffic-class",
3493
+ statements: [],
3494
+ run: (tx) => {
3495
+ if (!tableExists(tx, "ga_ai_referrals")) return;
3496
+ if (!columnExists(tx, "ga_ai_referrals", "traffic_class")) {
3497
+ tx.run(sql.raw(`ALTER TABLE ga_ai_referrals ADD COLUMN traffic_class TEXT NOT NULL DEFAULT 'organic'`));
3498
+ }
3499
+ tx.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_traffic_class ON ga_ai_referrals(project_id, date, traffic_class)`));
3500
+ }
3501
+ },
3502
+ {
3503
+ // v95 added `ga_ai_referrals.traffic_class` with `DEFAULT 'organic'` and did
3504
+ // not classify the rows that already existed. Every historical AI referral
3505
+ // was therefore stamped 'organic', including paid ChatGPT-ads traffic
3506
+ // carrying `medium='cpc'` / `channel_group='Paid Other'`. That silently
3507
+ // reports purchased sessions as organic wins.
3508
+ //
3509
+ // The class is a pure function of columns already stored on each row, so
3510
+ // re-derive it here instead of making an operator delete and re-fetch
3511
+ // identical data from GA4.
3512
+ //
3513
+ // Every row is re-classified, not only the "unclassified" ones: v95's
3514
+ // default is indistinguishable from a genuinely-organic classification, so
3515
+ // there is no way to tell the two apart. `classifyGa4AiReferralTrafficClass`
3516
+ // is pure and deterministic, so re-running it over already-correct rows
3517
+ // writes nothing, which is what makes this version safe to replay.
3518
+ version: 96,
3519
+ name: "ga-ai-referral-traffic-class-backfill",
3520
+ statements: [],
3521
+ run: (tx) => {
3522
+ backfillGaAiReferralTrafficClass(tx);
3523
+ }
3485
3524
  }
3486
3525
  ];
3487
3526
  function rebuildBacklinkTableWithSource(tx, table) {
@@ -3568,6 +3607,26 @@ function tableIsEmpty(db, table) {
3568
3607
  const rows = db.all(sql.raw(`SELECT COUNT(*) as c FROM ${table}`));
3569
3608
  return (rows[0]?.c ?? 0) === 0;
3570
3609
  }
3610
+ function backfillGaAiReferralTrafficClass(tx) {
3611
+ if (!tableExists(tx, "ga_ai_referrals")) return 0;
3612
+ if (!columnExists(tx, "ga_ai_referrals", "traffic_class")) return 0;
3613
+ const rows = tx.all(sql.raw(
3614
+ `SELECT id, source, medium, channel_group, landing_page, traffic_class FROM ga_ai_referrals`
3615
+ ));
3616
+ let updated = 0;
3617
+ for (const row of rows) {
3618
+ const next = classifyGa4AiReferralTrafficClass({
3619
+ source: row.source,
3620
+ medium: row.medium,
3621
+ channelGroup: row.channel_group,
3622
+ landingPage: row.landing_page
3623
+ });
3624
+ if (next === row.traffic_class) continue;
3625
+ tx.run(sql`UPDATE ga_ai_referrals SET traffic_class = ${next} WHERE id = ${row.id}`);
3626
+ updated += 1;
3627
+ }
3628
+ return updated;
3629
+ }
3571
3630
  function hasLegacyQuerySchema(db) {
3572
3631
  return tableExists(db, "keywords") || columnExists(db, "query_snapshots", "keyword_id") || columnExists(db, "insights", "keyword");
3573
3632
  }
@@ -8137,7 +8196,7 @@ async function analyticsRoutes(app) {
8137
8196
  const project = resolveProject(app.db, request.params.name);
8138
8197
  const window = parseWindow(request.query.window);
8139
8198
  const cutoff = windowCutoff(window);
8140
- const projectRuns = app.db.select().from(runs).where(and7(eq10(runs.projectId, project.id), notProbeRun())).orderBy(runs.createdAt).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
8199
+ const projectRuns = app.db.select().from(runs).where(and7(eq10(runs.projectId, project.id), eq10(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).orderBy(runs.createdAt).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
8141
8200
  if (projectRuns.length === 0) {
8142
8201
  return reply.send({
8143
8202
  window,
@@ -8189,14 +8248,14 @@ async function analyticsRoutes(app) {
8189
8248
  const project = resolveProject(app.db, request.params.name);
8190
8249
  const window = parseWindow(request.query.window);
8191
8250
  const cutoff = windowCutoff(window);
8192
- const completedRuns = app.db.select().from(runs).where(and7(eq10(runs.projectId, project.id), notProbeRun())).orderBy(desc3(runs.createdAt), desc3(runs.id)).all().filter((r) => r.status === "completed" || r.status === "partial");
8251
+ const completedRuns = app.db.select().from(runs).where(and7(eq10(runs.projectId, project.id), eq10(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).orderBy(desc3(runs.createdAt), desc3(runs.id)).all().filter((r) => r.status === "completed" || r.status === "partial");
8193
8252
  const latestGroup = groupRunsByCreatedAt(completedRuns)[0] ?? [];
8194
8253
  const latestGroupRunIds = latestGroup.map((r) => r.id);
8195
8254
  const latestRun = pickGroupRepresentative(latestGroup);
8196
8255
  if (!latestRun) {
8197
8256
  return reply.send({ cited: [], gap: [], uncited: [], mentionedQueries: [], mentionGap: [], notMentioned: [], runId: "", window });
8198
8257
  }
8199
- const windowRuns = app.db.select().from(runs).where(and7(eq10(runs.projectId, project.id), notProbeRun())).orderBy(runs.createdAt).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
8258
+ const windowRuns = app.db.select().from(runs).where(and7(eq10(runs.projectId, project.id), eq10(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).orderBy(runs.createdAt).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
8200
8259
  const windowRunIds = windowRuns.map((r) => r.id);
8201
8260
  const runIdToCreatedAt = new Map(windowRuns.map((r) => [r.id, r.createdAt]));
8202
8261
  const consistencyMap = /* @__PURE__ */ new Map();
@@ -8327,7 +8386,7 @@ async function analyticsRoutes(app) {
8327
8386
  const mapped = surfaceClassFromCompetitorType(row.competitorType);
8328
8387
  if (mapped) storedSurfaceClasses.set(normalizeProjectDomain(row.domain), mapped);
8329
8388
  }
8330
- const windowRuns = app.db.select().from(runs).where(and7(eq10(runs.projectId, project.id), notProbeRun())).orderBy(desc3(runs.createdAt), desc3(runs.id)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
8389
+ const windowRuns = app.db.select().from(runs).where(and7(eq10(runs.projectId, project.id), eq10(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).orderBy(desc3(runs.createdAt), desc3(runs.id)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
8331
8390
  if (windowRuns.length === 0) {
8332
8391
  return reply.send({
8333
8392
  overall: [],
@@ -11845,6 +11904,9 @@ function safeNum(value) {
11845
11904
  }
11846
11905
  return 0;
11847
11906
  }
11907
+ function isPaidAiTrafficClass(value) {
11908
+ return value === GA4AiReferralTrafficClasses.paid;
11909
+ }
11848
11910
  function categorizeQuery(query, projectBrandNames, canonicalDomain) {
11849
11911
  return categorizeQueryByIntent(query, buildBrandTokens(canonicalDomain, projectBrandNames));
11850
11912
  }
@@ -11999,14 +12061,40 @@ function buildGaSection(db, projectId, windowDays) {
11999
12061
  users: data.users,
12000
12062
  organicSessions: data.organic
12001
12063
  })).sort((a, b) => b.sessions - a.sessions).slice(0, TOP_LANDING_PAGES_LIMIT);
12064
+ const aiConditions = [
12065
+ eq15(gaAiReferrals.projectId, projectId),
12066
+ eq15(gaAiReferrals.sourceDimension, "session")
12067
+ ];
12068
+ if (snapshotStartDate && snapshotMaxDate) {
12069
+ aiConditions.push(gte2(gaAiReferrals.date, snapshotStartDate));
12070
+ aiConditions.push(lte(gaAiReferrals.date, snapshotMaxDate));
12071
+ }
12072
+ const aiSessionRows = db.select({
12073
+ trafficClass: gaAiReferrals.trafficClass,
12074
+ channelGroup: gaAiReferrals.channelGroup,
12075
+ sessions: sql7`COALESCE(SUM(${gaAiReferrals.sessions}), 0)`
12076
+ }).from(gaAiReferrals).where(and12(...aiConditions)).groupBy(gaAiReferrals.trafficClass, gaAiReferrals.channelGroup).all();
12077
+ let paidAiSessions = 0;
12078
+ let organicAiSessions = 0;
12079
+ let aiOrganicOverlap = 0;
12080
+ let aiDirectOverlap = 0;
12081
+ for (const row of aiSessionRows) {
12082
+ const sessions = Number(row.sessions ?? 0);
12083
+ if (isPaidAiTrafficClass(row.trafficClass)) paidAiSessions += sessions;
12084
+ else organicAiSessions += sessions;
12085
+ if (row.channelGroup === "Organic Search") aiOrganicOverlap += sessions;
12086
+ if (row.channelGroup === "Direct") aiDirectOverlap += sessions;
12087
+ }
12002
12088
  const channelBreakdown = [];
12003
12089
  if (totalSessions > 0) {
12004
- const organic = totalOrganicSessions;
12005
- const direct = directSessions;
12006
- const other = Math.max(totalSessions - organic - direct, 0);
12090
+ const organic = Math.max(0, totalOrganicSessions - Math.min(totalOrganicSessions, aiOrganicOverlap));
12091
+ const direct = Math.max(0, directSessions - Math.min(directSessions, aiDirectOverlap));
12092
+ const other = Math.max(totalSessions - organic - direct - paidAiSessions - organicAiSessions, 0);
12007
12093
  const buckets = [
12008
12094
  { channel: "Organic Search", sessions: organic },
12009
12095
  { channel: "Direct", sessions: direct },
12096
+ { channel: "Paid AI", sessions: paidAiSessions },
12097
+ { channel: "Organic AI referrals", sessions: organicAiSessions },
12010
12098
  { channel: "Other", sessions: other }
12011
12099
  ];
12012
12100
  for (const b of buckets) {
@@ -12093,15 +12181,34 @@ function buildAiReferrals(db, projectId) {
12093
12181
  );
12094
12182
  let total = 0;
12095
12183
  let totalUsers = 0;
12184
+ let paidSessions = 0;
12185
+ let paidUsers = 0;
12186
+ let organicSessions = 0;
12187
+ let organicUsers = 0;
12096
12188
  const sourceAgg = /* @__PURE__ */ new Map();
12097
12189
  const trendAgg = /* @__PURE__ */ new Map();
12098
12190
  const pageAgg = /* @__PURE__ */ new Map();
12099
12191
  for (const r of dedupedRows) {
12100
12192
  total += r.sessions;
12101
12193
  totalUsers += r.users;
12102
- const s = sourceAgg.get(r.source) ?? { sessions: 0, users: 0 };
12194
+ const paid = isPaidAiTrafficClass(r.trafficClass);
12195
+ if (paid) {
12196
+ paidSessions += r.sessions;
12197
+ paidUsers += r.users;
12198
+ } else {
12199
+ organicSessions += r.sessions;
12200
+ organicUsers += r.users;
12201
+ }
12202
+ const s = sourceAgg.get(r.source) ?? {
12203
+ sessions: 0,
12204
+ users: 0,
12205
+ paidSessions: 0,
12206
+ organicSessions: 0
12207
+ };
12103
12208
  s.sessions += r.sessions;
12104
12209
  s.users += r.users;
12210
+ if (paid) s.paidSessions += r.sessions;
12211
+ else s.organicSessions += r.sessions;
12105
12212
  sourceAgg.set(r.source, s);
12106
12213
  trendAgg.set(r.date, (trendAgg.get(r.date) ?? 0) + r.sessions);
12107
12214
  const page = r.landingPageNormalized ?? r.landingPage;
@@ -12114,11 +12221,23 @@ function buildAiReferrals(db, projectId) {
12114
12221
  source,
12115
12222
  sessions: data.sessions,
12116
12223
  users: data.users,
12224
+ paidSessions: data.paidSessions,
12225
+ organicSessions: data.organicSessions,
12117
12226
  sharePct: total > 0 ? Math.round(data.sessions / total * 100) : 0
12118
12227
  })).sort((a, b) => b.sessions - a.sessions);
12119
12228
  const trend = [...trendAgg.entries()].map(([date, sessions]) => ({ date, sessions })).sort((a, b) => a.date.localeCompare(b.date));
12120
12229
  const topLandingPages = [...pageAgg.entries()].map(([page, data]) => ({ page, sessions: data.sessions, users: data.users })).sort((a, b) => b.sessions - a.sessions).slice(0, TOP_AI_REFERRAL_PAGES_LIMIT);
12121
- return { totalSessions: total, totalUsers, bySource, trend, topLandingPages };
12230
+ return {
12231
+ totalSessions: total,
12232
+ totalUsers,
12233
+ paidSessions,
12234
+ paidUsers,
12235
+ organicSessions,
12236
+ organicUsers,
12237
+ bySource,
12238
+ trend,
12239
+ topLandingPages
12240
+ };
12122
12241
  }
12123
12242
  function nonSubresourceReferralPathCondition() {
12124
12243
  return sql7`
@@ -20423,16 +20542,23 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
20423
20542
  };
20424
20543
  const response = await runReport(accessToken, propertyId, request);
20425
20544
  if (!response) break;
20426
- const pageRows = (response.rows ?? []).map((row) => ({
20427
- date: row.dimensionValues[0].value,
20428
- source: row.dimensionValues[1].value,
20429
- medium: row.dimensionValues[2].value,
20430
- channelGroup: row.dimensionValues[3]?.value ?? "(not set)",
20431
- landingPage: row.dimensionValues[4]?.value ?? "(not set)",
20432
- sessions: parseInt(row.metricValues[0].value, 10) || 0,
20433
- users: parseInt(row.metricValues[1].value, 10) || 0,
20434
- sourceDimension: dimLabel
20435
- }));
20545
+ const pageRows = (response.rows ?? []).map((row) => {
20546
+ const source = row.dimensionValues[1].value;
20547
+ const medium = row.dimensionValues[2].value;
20548
+ const channelGroup = row.dimensionValues[3]?.value ?? "(not set)";
20549
+ const landingPage = row.dimensionValues[4]?.value ?? "(not set)";
20550
+ return {
20551
+ date: row.dimensionValues[0].value,
20552
+ source,
20553
+ medium,
20554
+ trafficClass: classifyGa4AiReferralTrafficClass({ source, medium, channelGroup, landingPage }),
20555
+ channelGroup,
20556
+ landingPage,
20557
+ sessions: parseInt(row.metricValues[0].value, 10) || 0,
20558
+ users: parseInt(row.metricValues[1].value, 10) || 0,
20559
+ sourceDimension: dimLabel
20560
+ };
20561
+ });
20436
20562
  rows.push(...pageRows);
20437
20563
  const totalRows = response.rowCount ?? 0;
20438
20564
  offset += pageRows.length;
@@ -23400,6 +23526,76 @@ function pickWinningDimension(rows, tupleKey) {
23400
23526
  }
23401
23527
  return [...winners.values()].sort((a, b) => (b.sessions ?? 0) - (a.sessions ?? 0));
23402
23528
  }
23529
+ function normalizeAiTrafficClass(value) {
23530
+ return value === GA4AiReferralTrafficClasses.paid ? GA4AiReferralTrafficClasses.paid : GA4AiReferralTrafficClasses.organic;
23531
+ }
23532
+ function emptyAiCounts() {
23533
+ return { sessions: 0, users: 0 };
23534
+ }
23535
+ function summarizeAiReferralCounts(rows) {
23536
+ const dedupeGroups = /* @__PURE__ */ new Map();
23537
+ const bySessionChannelGroup = /* @__PURE__ */ new Map();
23538
+ const paidDeduped = emptyAiCounts();
23539
+ const organicDeduped = emptyAiCounts();
23540
+ const paidBySession = emptyAiCounts();
23541
+ const organicBySession = emptyAiCounts();
23542
+ for (const row of rows) {
23543
+ const isPaid = normalizeAiTrafficClass(row.trafficClass) === GA4AiReferralTrafficClasses.paid;
23544
+ const sessions = row.sessions ?? 0;
23545
+ const users = row.users ?? 0;
23546
+ const key = `${row.date}\0${row.source}\0${row.medium}`;
23547
+ let byDimension = dedupeGroups.get(key);
23548
+ if (!byDimension) {
23549
+ byDimension = /* @__PURE__ */ new Map();
23550
+ dedupeGroups.set(key, byDimension);
23551
+ }
23552
+ let dim = byDimension.get(row.sourceDimension);
23553
+ if (!dim) {
23554
+ dim = { paidSessions: 0, organicSessions: 0, paidUsers: 0, organicUsers: 0 };
23555
+ byDimension.set(row.sourceDimension, dim);
23556
+ }
23557
+ if (isPaid) {
23558
+ dim.paidSessions += sessions;
23559
+ dim.paidUsers += users;
23560
+ } else {
23561
+ dim.organicSessions += sessions;
23562
+ dim.organicUsers += users;
23563
+ }
23564
+ if (row.sourceDimension === "session") {
23565
+ bySessionChannelGroup.set(
23566
+ row.channelGroup,
23567
+ (bySessionChannelGroup.get(row.channelGroup) ?? 0) + sessions
23568
+ );
23569
+ const sessionBucket = isPaid ? paidBySession : organicBySession;
23570
+ sessionBucket.sessions += sessions;
23571
+ sessionBucket.users += users;
23572
+ }
23573
+ }
23574
+ for (const byDimension of dedupeGroups.values()) {
23575
+ const dims = [...byDimension.values()];
23576
+ const bestSessions = dims.reduce((best, d) => d.paidSessions + d.organicSessions > best.paidSessions + best.organicSessions ? d : best);
23577
+ const bestUsers = dims.reduce((best, d) => d.paidUsers + d.organicUsers > best.paidUsers + best.organicUsers ? d : best);
23578
+ paidDeduped.sessions += bestSessions.paidSessions;
23579
+ organicDeduped.sessions += bestSessions.organicSessions;
23580
+ paidDeduped.users += bestUsers.paidUsers;
23581
+ organicDeduped.users += bestUsers.organicUsers;
23582
+ }
23583
+ return {
23584
+ paidDeduped,
23585
+ organicDeduped,
23586
+ paidBySession,
23587
+ organicBySession,
23588
+ bySessionChannelGroup,
23589
+ deduped: {
23590
+ sessions: paidDeduped.sessions + organicDeduped.sessions,
23591
+ users: paidDeduped.users + organicDeduped.users
23592
+ },
23593
+ bySession: {
23594
+ sessions: paidBySession.sessions + organicBySession.sessions,
23595
+ users: paidBySession.users + organicBySession.users
23596
+ }
23597
+ };
23598
+ }
23403
23599
  async function refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, oauthConn) {
23404
23600
  const expiresAt = oauthConn.tokenExpiresAt ? new Date(oauthConn.tokenExpiresAt).getTime() : 0;
23405
23601
  const fiveMinutes = 5 * 60 * 1e3;
@@ -23676,6 +23872,12 @@ async function ga4Routes(app, opts) {
23676
23872
  date: row.date,
23677
23873
  source: row.source,
23678
23874
  medium: row.medium,
23875
+ trafficClass: row.trafficClass ?? classifyGa4AiReferralTrafficClass({
23876
+ source: row.source,
23877
+ medium: row.medium,
23878
+ channelGroup: row.channelGroup,
23879
+ landingPage: row.landingPage
23880
+ }),
23679
23881
  sourceDimension: row.sourceDimension,
23680
23882
  channelGroup: row.channelGroup,
23681
23883
  landingPage: row.landingPage,
@@ -23825,13 +24027,15 @@ async function ga4Routes(app, opts) {
23825
24027
  const aiReferralRows = app.db.select({
23826
24028
  source: gaAiReferrals.source,
23827
24029
  medium: gaAiReferrals.medium,
24030
+ trafficClass: gaAiReferrals.trafficClass,
23828
24031
  sourceDimension: gaAiReferrals.sourceDimension,
23829
24032
  sessions: sql10`SUM(${gaAiReferrals.sessions})`,
23830
24033
  users: sql10`SUM(${gaAiReferrals.users})`
23831
- }).from(gaAiReferrals).where(and21(...aiConditions)).groupBy(gaAiReferrals.source, gaAiReferrals.medium, gaAiReferrals.sourceDimension).all();
24034
+ }).from(gaAiReferrals).where(and21(...aiConditions)).groupBy(gaAiReferrals.source, gaAiReferrals.medium, gaAiReferrals.trafficClass, gaAiReferrals.sourceDimension).all();
23832
24035
  const aiReferralLandingPageRows = app.db.select({
23833
24036
  source: gaAiReferrals.source,
23834
24037
  medium: gaAiReferrals.medium,
24038
+ trafficClass: gaAiReferrals.trafficClass,
23835
24039
  sourceDimension: gaAiReferrals.sourceDimension,
23836
24040
  landingPage: sql10`COALESCE(${gaAiReferrals.landingPageNormalized}, ${gaAiReferrals.landingPage})`,
23837
24041
  sessions: sql10`SUM(${gaAiReferrals.sessions})`,
@@ -23839,48 +24043,29 @@ async function ga4Routes(app, opts) {
23839
24043
  }).from(gaAiReferrals).where(and21(...aiConditions)).groupBy(
23840
24044
  gaAiReferrals.source,
23841
24045
  gaAiReferrals.medium,
24046
+ gaAiReferrals.trafficClass,
23842
24047
  gaAiReferrals.sourceDimension,
23843
24048
  sql10`COALESCE(${gaAiReferrals.landingPageNormalized}, ${gaAiReferrals.landingPage})`
23844
24049
  ).all();
24050
+ const aiRowsForTotals = app.db.select({
24051
+ date: gaAiReferrals.date,
24052
+ source: gaAiReferrals.source,
24053
+ medium: gaAiReferrals.medium,
24054
+ trafficClass: gaAiReferrals.trafficClass,
24055
+ sourceDimension: gaAiReferrals.sourceDimension,
24056
+ channelGroup: gaAiReferrals.channelGroup,
24057
+ sessions: gaAiReferrals.sessions,
24058
+ users: gaAiReferrals.users
24059
+ }).from(gaAiReferrals).where(and21(...aiConditions)).all();
23845
24060
  const aiReferrals = pickWinningDimension(
23846
24061
  aiReferralRows,
23847
- (r) => `${r.source}\0${r.medium}`
24062
+ (r) => `${r.source}\0${r.medium}\0${r.trafficClass}`
23848
24063
  );
23849
24064
  const aiReferralLandingPages = pickWinningDimension(
23850
24065
  aiReferralLandingPageRows,
23851
- (r) => `${r.source}\0${r.medium}\0${r.landingPage}`
24066
+ (r) => `${r.source}\0${r.medium}\0${r.trafficClass}\0${r.landingPage}`
23852
24067
  );
23853
- const aiDeduped = app.db.select({
23854
- sessions: sql10`COALESCE(SUM(max_sessions), 0)`,
23855
- users: sql10`COALESCE(SUM(max_users), 0)`
23856
- }).from(
23857
- sql10`(
23858
- SELECT date, source, medium,
23859
- MAX(dimension_sessions) AS max_sessions,
23860
- MAX(dimension_users) AS max_users
23861
- FROM (
23862
- SELECT date, source, medium, source_dimension,
23863
- SUM(sessions) AS dimension_sessions,
23864
- SUM(users) AS dimension_users
23865
- FROM ga_ai_referrals
23866
- WHERE project_id = ${project.id}${cutoffDate ? sql10` AND date >= ${cutoffDate}` : sql10``}
23867
- GROUP BY date, source, medium, source_dimension
23868
- )
23869
- GROUP BY date, source, medium
23870
- )`
23871
- ).get();
23872
- const aiBySessionRows = app.db.select({
23873
- channelGroup: gaAiReferrals.channelGroup,
23874
- sessions: sql10`COALESCE(SUM(${gaAiReferrals.sessions}), 0)`,
23875
- users: sql10`COALESCE(SUM(${gaAiReferrals.users}), 0)`
23876
- }).from(gaAiReferrals).where(and21(...aiConditions, eq26(gaAiReferrals.sourceDimension, "session"))).groupBy(gaAiReferrals.channelGroup).all();
23877
- const aiSessionsByChannelGroup = /* @__PURE__ */ new Map();
23878
- let aiBySessionUsers = 0;
23879
- for (const row of aiBySessionRows) {
23880
- aiSessionsByChannelGroup.set(row.channelGroup, row.sessions ?? 0);
23881
- aiBySessionUsers += row.users ?? 0;
23882
- }
23883
- const aiBySessionSessions = [...aiSessionsByChannelGroup.values()].reduce((sum, sessions) => sum + sessions, 0);
24068
+ const aiSummary = summarizeAiReferralCounts(aiRowsForTotals);
23884
24069
  const socialReferrals = app.db.select({
23885
24070
  source: gaSocialReferrals.source,
23886
24071
  medium: gaSocialReferrals.medium,
@@ -23902,7 +24087,7 @@ async function ga4Routes(app, opts) {
23902
24087
  organicSessions: totalOrganicSessions,
23903
24088
  socialSessions,
23904
24089
  directSessions: totalDirectSessions,
23905
- aiSessionsByChannelGroup
24090
+ aiSessionsByChannelGroup: aiSummary.bySessionChannelGroup
23906
24091
  });
23907
24092
  return {
23908
24093
  totalSessions: total,
@@ -23919,6 +24104,7 @@ async function ga4Routes(app, opts) {
23919
24104
  aiReferrals: aiReferrals.map((r) => ({
23920
24105
  source: r.source,
23921
24106
  medium: r.medium,
24107
+ trafficClass: normalizeAiTrafficClass(r.trafficClass),
23922
24108
  sourceDimension: r.sourceDimension,
23923
24109
  sessions: r.sessions ?? 0,
23924
24110
  users: r.users ?? 0
@@ -23926,15 +24112,24 @@ async function ga4Routes(app, opts) {
23926
24112
  aiReferralLandingPages: aiReferralLandingPages.map((r) => ({
23927
24113
  source: r.source,
23928
24114
  medium: r.medium,
24115
+ trafficClass: normalizeAiTrafficClass(r.trafficClass),
23929
24116
  sourceDimension: r.sourceDimension,
23930
24117
  landingPage: r.landingPage,
23931
24118
  sessions: r.sessions ?? 0,
23932
24119
  users: r.users ?? 0
23933
24120
  })),
23934
- aiSessionsDeduped: aiDeduped?.sessions ?? 0,
23935
- aiUsersDeduped: aiDeduped?.users ?? 0,
23936
- aiSessionsBySession: aiBySessionSessions,
23937
- aiUsersBySession: aiBySessionUsers,
24121
+ aiSessionsDeduped: aiSummary.deduped.sessions,
24122
+ aiUsersDeduped: aiSummary.deduped.users,
24123
+ paidAiSessionsDeduped: aiSummary.paidDeduped.sessions,
24124
+ paidAiUsersDeduped: aiSummary.paidDeduped.users,
24125
+ organicAiSessionsDeduped: aiSummary.organicDeduped.sessions,
24126
+ organicAiUsersDeduped: aiSummary.organicDeduped.users,
24127
+ aiSessionsBySession: aiSummary.bySession.sessions,
24128
+ aiUsersBySession: aiSummary.bySession.users,
24129
+ paidAiSessionsBySession: aiSummary.paidBySession.sessions,
24130
+ paidAiUsersBySession: aiSummary.paidBySession.users,
24131
+ organicAiSessionsBySession: aiSummary.organicBySession.sessions,
24132
+ organicAiUsersBySession: aiSummary.organicBySession.users,
23938
24133
  socialReferrals: socialReferrals.map((r) => ({
23939
24134
  source: r.source,
23940
24135
  medium: r.medium,
@@ -23946,16 +24141,24 @@ async function ga4Routes(app, opts) {
23946
24141
  socialUsers: socialTotals?.users ?? 0,
23947
24142
  channelBreakdown,
23948
24143
  organicSharePct: total > 0 ? Math.round(totalOrganicSessions / total * 100) : 0,
23949
- aiSharePct: total > 0 ? Math.round((aiDeduped?.sessions ?? 0) / total * 100) : 0,
23950
- aiSharePctBySession: total > 0 ? Math.round(aiBySessionSessions / total * 100) : 0,
24144
+ aiSharePct: total > 0 ? Math.round(aiSummary.deduped.sessions / total * 100) : 0,
24145
+ aiSharePctBySession: total > 0 ? Math.round(aiSummary.bySession.sessions / total * 100) : 0,
24146
+ paidAiSharePct: total > 0 ? Math.round(aiSummary.paidDeduped.sessions / total * 100) : 0,
24147
+ paidAiSharePctBySession: total > 0 ? Math.round(aiSummary.paidBySession.sessions / total * 100) : 0,
24148
+ organicAiSharePct: total > 0 ? Math.round(aiSummary.organicDeduped.sessions / total * 100) : 0,
24149
+ organicAiSharePctBySession: total > 0 ? Math.round(aiSummary.organicBySession.sessions / total * 100) : 0,
23951
24150
  directSharePct: total > 0 ? Math.round(totalDirectSessions / total * 100) : 0,
23952
24151
  socialSharePct: total > 0 ? Math.round(socialSessions / total * 100) : 0,
23953
24152
  otherSessions: channelBreakdown.other.sessions,
23954
24153
  otherSharePct: channelBreakdown.other.sharePct,
23955
24154
  otherSharePctDisplay: channelBreakdown.other.sharePctDisplay,
23956
24155
  organicSharePctDisplay: formatSharePct(totalOrganicSessions, total),
23957
- aiSharePctDisplay: formatSharePct(aiDeduped?.sessions ?? 0, total),
23958
- aiSharePctBySessionDisplay: formatSharePct(aiBySessionSessions, total),
24156
+ aiSharePctDisplay: formatSharePct(aiSummary.deduped.sessions, total),
24157
+ aiSharePctBySessionDisplay: formatSharePct(aiSummary.bySession.sessions, total),
24158
+ paidAiSharePctDisplay: formatSharePct(aiSummary.paidDeduped.sessions, total),
24159
+ paidAiSharePctBySessionDisplay: formatSharePct(aiSummary.paidBySession.sessions, total),
24160
+ organicAiSharePctDisplay: formatSharePct(aiSummary.organicDeduped.sessions, total),
24161
+ organicAiSharePctBySessionDisplay: formatSharePct(aiSummary.organicBySession.sessions, total),
23959
24162
  directSharePctDisplay: formatSharePct(totalDirectSessions, total),
23960
24163
  socialSharePctDisplay: formatSharePct(socialSessions, total),
23961
24164
  lastSyncedAt: latestSync?.syncedAt ?? null,
@@ -23978,6 +24181,7 @@ async function ga4Routes(app, opts) {
23978
24181
  date: gaAiReferrals.date,
23979
24182
  source: gaAiReferrals.source,
23980
24183
  medium: gaAiReferrals.medium,
24184
+ trafficClass: gaAiReferrals.trafficClass,
23981
24185
  landingPage: sql10`COALESCE(${gaAiReferrals.landingPageNormalized}, ${gaAiReferrals.landingPage})`,
23982
24186
  sourceDimension: gaAiReferrals.sourceDimension,
23983
24187
  sessions: sql10`SUM(${gaAiReferrals.sessions})`,
@@ -23986,6 +24190,7 @@ async function ga4Routes(app, opts) {
23986
24190
  gaAiReferrals.date,
23987
24191
  gaAiReferrals.source,
23988
24192
  gaAiReferrals.medium,
24193
+ gaAiReferrals.trafficClass,
23989
24194
  gaAiReferrals.sourceDimension,
23990
24195
  sql10`COALESCE(${gaAiReferrals.landingPageNormalized}, ${gaAiReferrals.landingPage})`
23991
24196
  ).orderBy(gaAiReferrals.date).all();