@ainyc/canonry 3.1.1 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/index.html CHANGED
@@ -12,8 +12,8 @@
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-Bk6pxbs2.js"></script>
16
- <link rel="stylesheet" crossorigin href="./assets/index-qR7US7tB.css">
15
+ <script type="module" crossorigin src="./assets/index-D6R1HCPk.js"></script>
16
+ <link rel="stylesheet" crossorigin href="./assets/index-DZm9xxNs.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -62,7 +62,7 @@ import {
62
62
  visibilityStateFromAnswerMentioned,
63
63
  windowCutoff,
64
64
  wordpressEnvSchema
65
- } from "./chunk-5X3TJ5BC.js";
65
+ } from "./chunk-TUXS2Y6C.js";
66
66
  import {
67
67
  IntelligenceService,
68
68
  agentMemory,
@@ -100,7 +100,7 @@ import {
100
100
  runs,
101
101
  schedules,
102
102
  usageCounters
103
- } from "./chunk-NEDRCOOL.js";
103
+ } from "./chunk-UQH5SKM2.js";
104
104
 
105
105
  // src/telemetry.ts
106
106
  import crypto from "crypto";
@@ -5252,7 +5252,7 @@ var routeCatalog = [
5252
5252
  {
5253
5253
  method: "get",
5254
5254
  path: "/api/v1/projects/{name}/ga/traffic",
5255
- summary: "Get GA4 landing page traffic and AI referral sources",
5255
+ summary: "Get GA4 landing page traffic, channel breakdown, and AI referral landing pages",
5256
5256
  tags: ["ga4"],
5257
5257
  parameters: [nameParameter, limitQueryParameter, analyticsWindowParameter],
5258
5258
  responses: {
@@ -5264,7 +5264,7 @@ var routeCatalog = [
5264
5264
  {
5265
5265
  method: "get",
5266
5266
  path: "/api/v1/projects/{name}/ga/ai-referral-history",
5267
- summary: "Get AI referral sessions per day grouped by source",
5267
+ summary: "Get AI referral sessions per day grouped by source and landing page",
5268
5268
  tags: ["ga4"],
5269
5269
  parameters: [nameParameter, analyticsWindowParameter],
5270
5270
  responses: {
@@ -7072,7 +7072,8 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
7072
7072
  dimensions: [
7073
7073
  { name: "date" },
7074
7074
  { name: sourceDim },
7075
- { name: mediumDim }
7075
+ { name: mediumDim },
7076
+ { name: "landingPagePlusQueryString" }
7076
7077
  ],
7077
7078
  metrics: [
7078
7079
  { name: "sessions" },
@@ -7097,6 +7098,7 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
7097
7098
  date: row.dimensionValues[0].value,
7098
7099
  source: row.dimensionValues[1].value,
7099
7100
  medium: row.dimensionValues[2].value,
7101
+ landingPage: row.dimensionValues[3]?.value ?? "(not set)",
7100
7102
  sessions: parseInt(row.metricValues[0].value, 10) || 0,
7101
7103
  users: parseInt(row.metricValues[1].value, 10) || 0,
7102
7104
  sourceDimension: dimLabel
@@ -7109,7 +7111,7 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
7109
7111
  }
7110
7112
  const deduped = /* @__PURE__ */ new Map();
7111
7113
  for (const row of rows) {
7112
- const key = `${row.date}::${row.source}::${row.medium}::${row.sourceDimension}`;
7114
+ const key = `${row.date}::${row.source}::${row.medium}::${row.sourceDimension}::${row.landingPage}`;
7113
7115
  const existing = deduped.get(key);
7114
7116
  if (!existing) {
7115
7117
  deduped.set(key, row);
@@ -8692,6 +8694,13 @@ function gaLog(level, action, ctx) {
8692
8694
  const stream = level === "error" ? process.stderr : process.stdout;
8693
8695
  stream.write(JSON.stringify(entry) + "\n");
8694
8696
  }
8697
+ function formatSharePct(numerator, total) {
8698
+ if (total <= 0 || numerator <= 0) return "0%";
8699
+ const pct = numerator / total * 100;
8700
+ const rounded = Math.round(pct);
8701
+ if (rounded === 0) return "<1%";
8702
+ return `${rounded}%`;
8703
+ }
8695
8704
  async function refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, oauthConn) {
8696
8705
  const expiresAt = oauthConn.tokenExpiresAt ? new Date(oauthConn.tokenExpiresAt).getTime() : 0;
8697
8706
  const fiveMinutes = 5 * 60 * 1e3;
@@ -8964,6 +8973,8 @@ async function ga4Routes(app, opts) {
8964
8973
  source: row.source,
8965
8974
  medium: row.medium,
8966
8975
  sourceDimension: row.sourceDimension,
8976
+ landingPage: row.landingPage,
8977
+ landingPageNormalized: normalizeUrlPath(row.landingPage),
8967
8978
  sessions: row.sessions,
8968
8979
  users: row.users,
8969
8980
  syncedAt: now,
@@ -9080,16 +9091,35 @@ async function ga4Routes(app, opts) {
9080
9091
  sessions: sql5`SUM(${gaAiReferrals.sessions})`,
9081
9092
  users: sql5`SUM(${gaAiReferrals.users})`
9082
9093
  }).from(gaAiReferrals).where(and8(...aiConditions)).groupBy(gaAiReferrals.source, gaAiReferrals.medium, gaAiReferrals.sourceDimension).orderBy(sql5`SUM(${gaAiReferrals.sessions}) DESC`).all();
9094
+ const aiReferralLandingPages = app.db.select({
9095
+ source: gaAiReferrals.source,
9096
+ medium: gaAiReferrals.medium,
9097
+ sourceDimension: gaAiReferrals.sourceDimension,
9098
+ landingPage: sql5`COALESCE(${gaAiReferrals.landingPageNormalized}, ${gaAiReferrals.landingPage})`,
9099
+ sessions: sql5`SUM(${gaAiReferrals.sessions})`,
9100
+ users: sql5`SUM(${gaAiReferrals.users})`
9101
+ }).from(gaAiReferrals).where(and8(...aiConditions)).groupBy(
9102
+ gaAiReferrals.source,
9103
+ gaAiReferrals.medium,
9104
+ gaAiReferrals.sourceDimension,
9105
+ sql5`COALESCE(${gaAiReferrals.landingPageNormalized}, ${gaAiReferrals.landingPage})`
9106
+ ).orderBy(sql5`SUM(${gaAiReferrals.sessions}) DESC`).all();
9083
9107
  const aiDeduped = app.db.select({
9084
- sessions: sql5`SUM(max_sessions)`,
9085
- users: sql5`SUM(max_users)`
9108
+ sessions: sql5`COALESCE(SUM(max_sessions), 0)`,
9109
+ users: sql5`COALESCE(SUM(max_users), 0)`
9086
9110
  }).from(
9087
9111
  sql5`(
9088
9112
  SELECT date, source, medium,
9089
- MAX(sessions) AS max_sessions,
9090
- MAX(users) AS max_users
9091
- FROM ga_ai_referrals
9092
- WHERE project_id = ${project.id}${cutoffDate ? sql5` AND date >= ${cutoffDate}` : sql5``}
9113
+ MAX(dimension_sessions) AS max_sessions,
9114
+ MAX(dimension_users) AS max_users
9115
+ FROM (
9116
+ SELECT date, source, medium, source_dimension,
9117
+ SUM(sessions) AS dimension_sessions,
9118
+ SUM(users) AS dimension_users
9119
+ FROM ga_ai_referrals
9120
+ WHERE project_id = ${project.id}${cutoffDate ? sql5` AND date >= ${cutoffDate}` : sql5``}
9121
+ GROUP BY date, source, medium, source_dimension
9122
+ )
9093
9123
  GROUP BY date, source, medium
9094
9124
  )`
9095
9125
  ).get();
@@ -9130,6 +9160,14 @@ async function ga4Routes(app, opts) {
9130
9160
  sessions: r.sessions ?? 0,
9131
9161
  users: r.users ?? 0
9132
9162
  })),
9163
+ aiReferralLandingPages: aiReferralLandingPages.map((r) => ({
9164
+ source: r.source,
9165
+ medium: r.medium,
9166
+ sourceDimension: r.sourceDimension,
9167
+ landingPage: r.landingPage,
9168
+ sessions: r.sessions ?? 0,
9169
+ users: r.users ?? 0
9170
+ })),
9133
9171
  aiSessionsDeduped: aiDeduped?.sessions ?? 0,
9134
9172
  aiUsersDeduped: aiDeduped?.users ?? 0,
9135
9173
  aiSessionsBySession: aiBySession?.sessions ?? 0,
@@ -9148,6 +9186,11 @@ async function ga4Routes(app, opts) {
9148
9186
  aiSharePctBySession: total > 0 ? Math.round((aiBySession?.sessions ?? 0) / total * 100) : 0,
9149
9187
  directSharePct: total > 0 ? Math.round(totalDirectSessions / total * 100) : 0,
9150
9188
  socialSharePct: total > 0 ? Math.round((socialTotals?.sessions ?? 0) / total * 100) : 0,
9189
+ organicSharePctDisplay: formatSharePct(summaryRow?.totalOrganicSessions ?? 0, total),
9190
+ aiSharePctDisplay: formatSharePct(aiDeduped?.sessions ?? 0, total),
9191
+ aiSharePctBySessionDisplay: formatSharePct(aiBySession?.sessions ?? 0, total),
9192
+ directSharePctDisplay: formatSharePct(totalDirectSessions, total),
9193
+ socialSharePctDisplay: formatSharePct(socialTotals?.sessions ?? 0, total),
9151
9194
  lastSyncedAt: latestSync?.syncedAt ?? null,
9152
9195
  periodStart: (() => {
9153
9196
  const start = cutoffDate ?? summaryMeta?.periodStart ?? null;
@@ -9168,10 +9211,17 @@ async function ga4Routes(app, opts) {
9168
9211
  date: gaAiReferrals.date,
9169
9212
  source: gaAiReferrals.source,
9170
9213
  medium: gaAiReferrals.medium,
9214
+ landingPage: sql5`COALESCE(${gaAiReferrals.landingPageNormalized}, ${gaAiReferrals.landingPage})`,
9171
9215
  sourceDimension: gaAiReferrals.sourceDimension,
9172
- sessions: gaAiReferrals.sessions,
9173
- users: gaAiReferrals.users
9174
- }).from(gaAiReferrals).where(and8(...conditions)).orderBy(gaAiReferrals.date).all();
9216
+ sessions: sql5`SUM(${gaAiReferrals.sessions})`,
9217
+ users: sql5`SUM(${gaAiReferrals.users})`
9218
+ }).from(gaAiReferrals).where(and8(...conditions)).groupBy(
9219
+ gaAiReferrals.date,
9220
+ gaAiReferrals.source,
9221
+ gaAiReferrals.medium,
9222
+ gaAiReferrals.sourceDimension,
9223
+ sql5`COALESCE(${gaAiReferrals.landingPageNormalized}, ${gaAiReferrals.landingPage})`
9224
+ ).orderBy(gaAiReferrals.date).all();
9175
9225
  return rows;
9176
9226
  });
9177
9227
  app.get("/projects/:name/ga/social-referral-history", async (request, _reply) => {
@@ -1287,6 +1287,14 @@ var ga4AiReferralDtoSchema = z12.object({
1287
1287
  users: z12.number(),
1288
1288
  sourceDimension: ga4SourceDimensionSchema
1289
1289
  });
1290
+ var ga4AiReferralLandingPageDtoSchema = z12.object({
1291
+ source: z12.string(),
1292
+ medium: z12.string(),
1293
+ sourceDimension: ga4SourceDimensionSchema,
1294
+ landingPage: z12.string(),
1295
+ sessions: z12.number(),
1296
+ users: z12.number()
1297
+ });
1290
1298
  var ga4SocialReferralDtoSchema = z12.object({
1291
1299
  source: z12.string(),
1292
1300
  medium: z12.string(),
@@ -1310,6 +1318,7 @@ var ga4TrafficSummaryDtoSchema = z12.object({
1310
1318
  users: z12.number()
1311
1319
  })),
1312
1320
  aiReferrals: z12.array(ga4AiReferralDtoSchema),
1321
+ aiReferralLandingPages: z12.array(ga4AiReferralLandingPageDtoSchema),
1313
1322
  /** Deduped AI session total: MAX(sessions) per date+source+medium across attribution dimensions, then summed. Cross-cutting: can overlap with Direct/Organic/Social via firstUserSource. */
1314
1323
  aiSessionsDeduped: z12.number(),
1315
1324
  /** Deduped AI user total: MAX(users) per date+source+medium across attribution dimensions, then summed. */
@@ -1333,12 +1342,23 @@ var ga4TrafficSummaryDtoSchema = z12.object({
1333
1342
  directSharePct: z12.number(),
1334
1343
  /** Social sessions as a percentage of total sessions (0–100, rounded). */
1335
1344
  socialSharePct: z12.number(),
1345
+ /** Display string for organicSharePct ('10%' or '<1%' when there are sessions but the rounded pct is 0). */
1346
+ organicSharePctDisplay: z12.string(),
1347
+ /** Display string for aiSharePct ('5%' or '<1%' when there are sessions but the rounded pct is 0). */
1348
+ aiSharePctDisplay: z12.string(),
1349
+ /** Display string for aiSharePctBySession ('5%' or '<1%' when there are sessions but the rounded pct is 0). */
1350
+ aiSharePctBySessionDisplay: z12.string(),
1351
+ /** Display string for directSharePct ('20%' or '<1%' when there are sessions but the rounded pct is 0). */
1352
+ directSharePctDisplay: z12.string(),
1353
+ /** Display string for socialSharePct ('15%' or '<1%' when there are sessions but the rounded pct is 0). */
1354
+ socialSharePctDisplay: z12.string(),
1336
1355
  lastSyncedAt: z12.string().nullable()
1337
1356
  });
1338
1357
  var ga4AiReferralHistoryEntrySchema = z12.object({
1339
1358
  date: z12.string(),
1340
1359
  source: z12.string(),
1341
1360
  medium: z12.string(),
1361
+ landingPage: z12.string(),
1342
1362
  sessions: z12.number(),
1343
1363
  users: z12.number(),
1344
1364
  /** Which GA4 dimension this row came from: session (sessionSource), first_user (firstUserSource), or manual_utm (utm_source parameter) */
@@ -344,6 +344,8 @@ var gaAiReferrals = sqliteTable("ga_ai_referrals", {
344
344
  medium: text("medium").notNull(),
345
345
  /** Which GA4 dimension produced this row: 'session' | 'first_user' | 'manual_utm' */
346
346
  sourceDimension: text("source_dimension").notNull().default("session"),
347
+ landingPage: text("landing_page").notNull().default("(not set)"),
348
+ landingPageNormalized: text("landing_page_normalized"),
347
349
  sessions: integer("sessions").notNull().default(0),
348
350
  users: integer("users").notNull().default(0),
349
351
  syncedAt: text("synced_at").notNull(),
@@ -351,7 +353,8 @@ var gaAiReferrals = sqliteTable("ga_ai_referrals", {
351
353
  }, (table) => [
352
354
  index("idx_ga_ai_ref_project_date").on(table.projectId, table.date),
353
355
  index("idx_ga_ai_ref_source").on(table.source),
354
- uniqueIndex("idx_ga_ai_ref_unique_v2").on(table.projectId, table.date, table.source, table.medium, table.sourceDimension),
356
+ index("idx_ga_ai_ref_landing_page").on(table.projectId, table.date, table.landingPageNormalized),
357
+ uniqueIndex("idx_ga_ai_ref_unique_v3").on(table.projectId, table.date, table.source, table.medium, table.sourceDimension, table.landingPage),
355
358
  index("idx_ga_ai_ref_run").on(table.syncRunId)
356
359
  ]);
357
360
  var gaSocialReferrals = sqliteTable("ga_social_referrals", {
@@ -1081,7 +1084,20 @@ var MIGRATIONS = [
1081
1084
  // separate commit. Unblocks an honest channel breakdown for the project
1082
1085
  // dashboard (organic / social / direct / known-AI) — see
1083
1086
  // plans/ai-attribution-research.md scope A.
1084
- `ALTER TABLE ga_traffic_snapshots ADD COLUMN direct_sessions INTEGER`
1087
+ `ALTER TABLE ga_traffic_snapshots ADD COLUMN direct_sessions INTEGER`,
1088
+ // v46: Landing-page breakdown for GA4 known-AI referral rows. The raw
1089
+ // landing_page participates in the unique key so distinct query strings can
1090
+ // be ingested without collision; API reads group by landing_page_normalized.
1091
+ // Default '(not set)' matches GA4's own sentinel for missing dimension
1092
+ // values, so legacy rows surface as the same bucket new ingestion uses
1093
+ // when GA4 returns nothing.
1094
+ `ALTER TABLE ga_ai_referrals ADD COLUMN landing_page TEXT NOT NULL DEFAULT '(not set)'`,
1095
+ `ALTER TABLE ga_ai_referrals ADD COLUMN landing_page_normalized TEXT`,
1096
+ `DROP INDEX IF EXISTS idx_ga_ai_ref_unique_v2`,
1097
+ `CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_landing_page
1098
+ ON ga_ai_referrals(project_id, date, landing_page_normalized)`,
1099
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_ai_ref_unique_v3
1100
+ ON ga_ai_referrals(project_id, date, source, medium, source_dimension, landing_page)`
1085
1101
  ];
1086
1102
  function isDuplicateColumnError(err) {
1087
1103
  if (!(err instanceof Error)) return false;
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  setGoogleAuthConfig,
18
18
  showFirstRunNotice,
19
19
  trackEvent
20
- } from "./chunk-VUP7AQTD.js";
20
+ } from "./chunk-QJPPK4WW.js";
21
21
  import {
22
22
  CcReleaseSyncStatuses,
23
23
  CheckScopes,
@@ -45,18 +45,19 @@ import {
45
45
  saveConfig,
46
46
  saveConfigPatch,
47
47
  usageError
48
- } from "./chunk-5X3TJ5BC.js";
48
+ } from "./chunk-TUXS2Y6C.js";
49
49
  import {
50
50
  apiKeys,
51
51
  competitors,
52
52
  createClient,
53
+ gaAiReferrals,
53
54
  gaTrafficSnapshots,
54
55
  migrate,
55
56
  parseJsonColumn,
56
57
  projects,
57
58
  querySnapshots,
58
59
  runs
59
- } from "./chunk-NEDRCOOL.js";
60
+ } from "./chunk-UQH5SKM2.js";
60
61
  import "./chunk-MLKGABMK.js";
61
62
 
62
63
  // src/cli.ts
@@ -375,8 +376,80 @@ async function backfillNormalizedPathsCommand(opts) {
375
376
  console.log(` Updated: ${updated}`);
376
377
  console.log(` Unchanged: ${unchanged}`);
377
378
  }
379
+ function backfillAiReferralPaths(db, opts) {
380
+ const baseConditions = [];
381
+ if (opts?.projectId) {
382
+ baseConditions.push(eq(gaAiReferrals.projectId, opts.projectId));
383
+ }
384
+ const rows = db.select({
385
+ id: gaAiReferrals.id,
386
+ landingPage: gaAiReferrals.landingPage,
387
+ landingPageNormalized: gaAiReferrals.landingPageNormalized
388
+ }).from(gaAiReferrals).where(baseConditions.length > 0 ? and(...baseConditions) : void 0).all();
389
+ let updated = 0;
390
+ let unchanged = 0;
391
+ if (rows.length > 0) {
392
+ db.transaction((tx) => {
393
+ for (const row of rows) {
394
+ const next = normalizeUrlPath(row.landingPage);
395
+ if (next === null) {
396
+ unchanged++;
397
+ continue;
398
+ }
399
+ if (row.landingPageNormalized === next) {
400
+ unchanged++;
401
+ continue;
402
+ }
403
+ tx.update(gaAiReferrals).set({ landingPageNormalized: next }).where(eq(gaAiReferrals.id, row.id)).run();
404
+ updated++;
405
+ }
406
+ });
407
+ }
408
+ return { examined: rows.length, updated, unchanged };
409
+ }
410
+ async function backfillAiReferralPathsCommand(opts) {
411
+ const config = loadConfig();
412
+ const db = createClient(config.database);
413
+ migrate(db);
414
+ const projectFilter = opts?.project?.trim();
415
+ let projectId;
416
+ if (projectFilter) {
417
+ const project = db.select({ id: projects.id }).from(projects).where(eq(projects.name, projectFilter)).get();
418
+ if (!project) {
419
+ const result2 = {
420
+ project: projectFilter,
421
+ examined: 0,
422
+ updated: 0,
423
+ unchanged: 0
424
+ };
425
+ if (opts?.format === "json") {
426
+ console.log(JSON.stringify(result2, null, 2));
427
+ return;
428
+ }
429
+ console.log(`Backfill ai-referral-paths: project "${projectFilter}" not found.`);
430
+ return;
431
+ }
432
+ projectId = project.id;
433
+ }
434
+ const { examined, updated, unchanged } = backfillAiReferralPaths(db, { projectId });
435
+ const result = {
436
+ project: projectFilter ?? null,
437
+ examined,
438
+ updated,
439
+ unchanged
440
+ };
441
+ if (opts?.format === "json") {
442
+ console.log(JSON.stringify(result, null, 2));
443
+ return;
444
+ }
445
+ console.log("AI referral landing-page backfill complete.\n");
446
+ if (projectFilter) console.log(` Project: ${projectFilter}`);
447
+ console.log(` Examined: ${examined}`);
448
+ console.log(` Updated: ${updated}`);
449
+ console.log(` Unchanged: ${unchanged}`);
450
+ }
378
451
  async function backfillInsightsCommand(project, opts) {
379
- const { IntelligenceService } = await import("./intelligence-service-6S5YKANX.js");
452
+ const { IntelligenceService } = await import("./intelligence-service-GYY23WHR.js");
380
453
  const config = loadConfig();
381
454
  const db = createClient(config.database);
382
455
  migrate(db);
@@ -586,14 +659,28 @@ var BACKFILL_CLI_COMMANDS = [
586
659
  });
587
660
  }
588
661
  },
662
+ {
663
+ path: ["backfill", "ai-referral-paths"],
664
+ usage: "canonry backfill ai-referral-paths [--project <name>] [--format json]",
665
+ options: {
666
+ project: stringOption()
667
+ },
668
+ allowPositionals: false,
669
+ run: async (input) => {
670
+ await backfillAiReferralPathsCommand({
671
+ project: getString(input.values, "project"),
672
+ format: input.format
673
+ });
674
+ }
675
+ },
589
676
  {
590
677
  path: ["backfill"],
591
- usage: "canonry backfill <answer-visibility|insights|normalized-paths> [options]",
678
+ usage: "canonry backfill <answer-visibility|insights|normalized-paths|ai-referral-paths> [options]",
592
679
  run: async (input) => {
593
680
  unknownSubcommand(input.positionals[0], {
594
681
  command: "backfill",
595
- usage: "canonry backfill <answer-visibility|insights|normalized-paths> [options]",
596
- available: ["answer-visibility", "insights", "normalized-paths"]
682
+ usage: "canonry backfill <answer-visibility|insights|normalized-paths|ai-referral-paths> [options]",
683
+ available: ["answer-visibility", "insights", "normalized-paths", "ai-referral-paths"]
597
684
  });
598
685
  }
599
686
  }
@@ -1876,7 +1963,7 @@ async function gaTraffic(project, opts) {
1876
1963
  console.log(JSON.stringify(result, null, 2));
1877
1964
  return;
1878
1965
  }
1879
- if (result.topPages.length === 0 && result.aiReferrals.length === 0 && result.socialReferrals.length === 0) {
1966
+ if (result.topPages.length === 0 && result.aiReferrals.length === 0 && result.aiReferralLandingPages.length === 0 && result.socialReferrals.length === 0) {
1880
1967
  if (!result.lastSyncedAt) {
1881
1968
  console.log('No GA4 traffic data. Run "canonry ga sync <project>" first.');
1882
1969
  } else {
@@ -1907,6 +1994,21 @@ async function gaTraffic(project, opts) {
1907
1994
  }
1908
1995
  console.log();
1909
1996
  }
1997
+ if (result.aiReferralLandingPages.length > 0) {
1998
+ const attrWidth = 12;
1999
+ console.log(" AI REFERRAL LANDING PAGES");
2000
+ console.log(` ${"LANDING PAGE".padEnd(30)} ${"SOURCE".padEnd(25)} ${"ATTRIBUTION".padEnd(attrWidth)} ${"SESSIONS".padEnd(10)}${"USERS".padEnd(8)}`);
2001
+ console.log(` ${"\u2500".repeat(30)} ${"\u2500".repeat(25)} ${"\u2500".repeat(attrWidth)} ${"\u2500".repeat(10)}${"\u2500".repeat(8)}`);
2002
+ for (const row of result.aiReferralLandingPages) {
2003
+ const dimLabel = row.sourceDimension === "first_user" ? "first-visit" : row.sourceDimension === "manual_utm" ? "utm" : "session";
2004
+ const page = row.landingPage.length > 30 ? row.landingPage.slice(0, 27) + "..." : row.landingPage;
2005
+ const source = row.source.length > 25 ? row.source.slice(0, 22) + "..." : row.source;
2006
+ console.log(
2007
+ ` ${page.padEnd(30)} ${source.padEnd(25)} ${dimLabel.padEnd(attrWidth)} ${String(row.sessions).padEnd(10)}${String(row.users).padEnd(8)}`
2008
+ );
2009
+ }
2010
+ console.log();
2011
+ }
1910
2012
  if (result.socialReferrals.length > 0) {
1911
2013
  const chanWidth = 12;
1912
2014
  if (result.socialSessions > 0) {
@@ -2120,7 +2222,13 @@ async function gaAttribution(project, opts) {
2120
2222
  socialSharePct: traffic.socialSharePct,
2121
2223
  organicSharePct: traffic.organicSharePct,
2122
2224
  directSharePct: traffic.directSharePct,
2225
+ organicSharePctDisplay: traffic.organicSharePctDisplay,
2226
+ aiSharePctDisplay: traffic.aiSharePctDisplay,
2227
+ aiSharePctBySessionDisplay: traffic.aiSharePctBySessionDisplay,
2228
+ socialSharePctDisplay: traffic.socialSharePctDisplay,
2229
+ directSharePctDisplay: traffic.directSharePctDisplay,
2123
2230
  aiReferrals: traffic.aiReferrals,
2231
+ aiReferralLandingPages: traffic.aiReferralLandingPages,
2124
2232
  socialReferrals: traffic.socialReferrals,
2125
2233
  trend
2126
2234
  }, null, 2));
@@ -2136,10 +2244,10 @@ async function gaAttribution(project, opts) {
2136
2244
  console.log(` Total Users: ${traffic.totalUsers}`);
2137
2245
  console.log();
2138
2246
  console.log(" CHANNEL BREAKDOWN 7d trend 30d trend");
2139
- console.log(` Organic Search: ${String(traffic.totalOrganicSessions).padEnd(6)} (${String(traffic.organicSharePct).padStart(2)}%) ${fmtTrend(trend.organic.trend7dPct).padEnd(12)} ${fmtTrend(trend.organic.trend30dPct)}`);
2140
- console.log(` Social: ${String(traffic.socialSessions).padEnd(6)} (${String(traffic.socialSharePct).padStart(2)}%) ${fmtTrend(trend.social.trend7dPct).padEnd(12)} ${fmtTrend(trend.social.trend30dPct)}`);
2141
- console.log(` Direct: ${String(traffic.totalDirectSessions).padEnd(6)} (${String(traffic.directSharePct).padStart(2)}%) ${fmtTrend(trend.direct.trend7dPct).padEnd(12)} ${fmtTrend(trend.direct.trend30dPct)}`);
2142
- console.log(` AI Referrals: ${String(traffic.aiSessionsBySession).padEnd(6)} (${String(traffic.aiSharePctBySession).padStart(2)}%) ${fmtTrend(trend.ai.trend7dPct).padEnd(12)} ${fmtTrend(trend.ai.trend30dPct)} (lower bound \u2014 sessionSource only; referrer-stripped traffic falls under Direct)`);
2247
+ console.log(` Organic Search: ${String(traffic.totalOrganicSessions).padEnd(6)} (${traffic.organicSharePctDisplay.padStart(4)}) ${fmtTrend(trend.organic.trend7dPct).padEnd(12)} ${fmtTrend(trend.organic.trend30dPct)}`);
2248
+ console.log(` Social: ${String(traffic.socialSessions).padEnd(6)} (${traffic.socialSharePctDisplay.padStart(4)}) ${fmtTrend(trend.social.trend7dPct).padEnd(12)} ${fmtTrend(trend.social.trend30dPct)}`);
2249
+ console.log(` Direct: ${String(traffic.totalDirectSessions).padEnd(6)} (${traffic.directSharePctDisplay.padStart(4)}) ${fmtTrend(trend.direct.trend7dPct).padEnd(12)} ${fmtTrend(trend.direct.trend30dPct)}`);
2250
+ console.log(` AI Referrals: ${String(traffic.aiSessionsBySession).padEnd(6)} (${traffic.aiSharePctBySessionDisplay.padStart(4)}) ${fmtTrend(trend.ai.trend7dPct).padEnd(12)} ${fmtTrend(trend.ai.trend30dPct)} (lower bound \u2014 sessionSource only; referrer-stripped traffic falls under Direct)`);
2143
2251
  const otherSessions2 = traffic.totalSessions - traffic.totalOrganicSessions - traffic.aiSessionsBySession - traffic.socialSessions - traffic.totalDirectSessions;
2144
2252
  if (otherSessions2 > 0) {
2145
2253
  const otherPct = traffic.totalSessions > 0 ? Math.round(otherSessions2 / traffic.totalSessions * 100) : 0;
@@ -2182,7 +2290,13 @@ async function gaAttribution(project, opts) {
2182
2290
  socialSharePct: traffic.socialSharePct,
2183
2291
  organicSharePct: traffic.organicSharePct,
2184
2292
  directSharePct: traffic.directSharePct,
2293
+ organicSharePctDisplay: traffic.organicSharePctDisplay,
2294
+ aiSharePctDisplay: traffic.aiSharePctDisplay,
2295
+ aiSharePctBySessionDisplay: traffic.aiSharePctBySessionDisplay,
2296
+ socialSharePctDisplay: traffic.socialSharePctDisplay,
2297
+ directSharePctDisplay: traffic.directSharePctDisplay,
2185
2298
  aiReferrals: traffic.aiReferrals,
2299
+ aiReferralLandingPages: traffic.aiReferralLandingPages,
2186
2300
  socialReferrals: traffic.socialReferrals,
2187
2301
  periodStart: traffic.periodStart,
2188
2302
  periodEnd: traffic.periodEnd
@@ -2199,10 +2313,10 @@ async function gaAttribution(project, opts) {
2199
2313
  console.log(` Total Users: ${traffic.totalUsers}`);
2200
2314
  console.log();
2201
2315
  console.log(" CHANNEL BREAKDOWN");
2202
- console.log(` Organic Search: ${traffic.totalOrganicSessions} sessions (${traffic.organicSharePct}%)`);
2203
- console.log(` Social: ${traffic.socialSessions} sessions (${traffic.socialSharePct}%)`);
2204
- console.log(` Direct: ${traffic.totalDirectSessions} sessions (${traffic.directSharePct}%)`);
2205
- console.log(` AI Referrals: ${traffic.aiSessionsBySession} sessions (${traffic.aiSharePctBySession}%) (lower bound \u2014 sessionSource only; referrer-stripped traffic falls under Direct)`);
2316
+ console.log(` Organic Search: ${traffic.totalOrganicSessions} sessions (${traffic.organicSharePctDisplay})`);
2317
+ console.log(` Social: ${traffic.socialSessions} sessions (${traffic.socialSharePctDisplay})`);
2318
+ console.log(` Direct: ${traffic.totalDirectSessions} sessions (${traffic.directSharePctDisplay})`);
2319
+ console.log(` AI Referrals: ${traffic.aiSessionsBySession} sessions (${traffic.aiSharePctBySessionDisplay}) (lower bound \u2014 sessionSource only; referrer-stripped traffic falls under Direct)`);
2206
2320
  const otherSessions = traffic.totalSessions - traffic.totalOrganicSessions - traffic.aiSessionsBySession - traffic.socialSessions - traffic.totalDirectSessions;
2207
2321
  if (otherSessions > 0) {
2208
2322
  const otherPct = traffic.totalSessions > 0 ? Math.round(otherSessions / traffic.totalSessions * 100) : 0;
@@ -2216,6 +2330,16 @@ async function gaAttribution(project, opts) {
2216
2330
  console.log(` ${ref.source.padEnd(25)} ${String(ref.sessions).padEnd(8)} sessions (${dimLabel})`);
2217
2331
  }
2218
2332
  }
2333
+ if (traffic.aiReferralLandingPages.length > 0) {
2334
+ console.log();
2335
+ console.log(" AI LANDING PAGES");
2336
+ for (const row of traffic.aiReferralLandingPages.slice(0, 10)) {
2337
+ const dimLabel = row.sourceDimension === "first_user" ? "first-visit" : row.sourceDimension === "manual_utm" ? "utm" : "session";
2338
+ const page = row.landingPage.length > 30 ? row.landingPage.slice(0, 27) + "..." : row.landingPage;
2339
+ const source = row.source.length > 22 ? row.source.slice(0, 19) + "..." : row.source;
2340
+ console.log(` ${page.padEnd(30)} ${source.padEnd(22)} ${String(row.sessions).padEnd(8)} sessions (${dimLabel})`);
2341
+ }
2342
+ }
2219
2343
  if (traffic.socialReferrals.length > 0) {
2220
2344
  console.log();
2221
2345
  console.log(" SOCIAL SOURCES");
@@ -7287,6 +7411,18 @@ async function serveCommand(format = "text") {
7287
7411
  } catch (err) {
7288
7412
  const msg = err instanceof Error ? err.message : String(err);
7289
7413
  process.stderr.write(`warning: normalized-path backfill skipped: ${msg}
7414
+ `);
7415
+ }
7416
+ try {
7417
+ const result = backfillAiReferralPaths(db);
7418
+ if (result.updated > 0 && format === "text") {
7419
+ console.log(
7420
+ `Migrated ${result.updated} GA AI referral row${result.updated === 1 ? "" : "s"} to canonical form.`
7421
+ );
7422
+ }
7423
+ } catch (err) {
7424
+ const msg = err instanceof Error ? err.message : String(err);
7425
+ process.stderr.write(`warning: ai-referral-paths backfill skipped: ${msg}
7290
7426
  `);
7291
7427
  }
7292
7428
  const app = await createServer({ config, db });
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  createServer
3
- } from "./chunk-VUP7AQTD.js";
3
+ } from "./chunk-QJPPK4WW.js";
4
4
  import {
5
5
  loadConfig
6
- } from "./chunk-5X3TJ5BC.js";
7
- import "./chunk-NEDRCOOL.js";
6
+ } from "./chunk-TUXS2Y6C.js";
7
+ import "./chunk-UQH5SKM2.js";
8
8
  import "./chunk-MLKGABMK.js";
9
9
  export {
10
10
  createServer,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  IntelligenceService
3
- } from "./chunk-NEDRCOOL.js";
3
+ } from "./chunk-UQH5SKM2.js";
4
4
  import "./chunk-MLKGABMK.js";
5
5
  export {
6
6
  IntelligenceService
package/dist/mcp.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  CliError,
3
3
  canonryMcpTools,
4
4
  createApiClient
5
- } from "./chunk-5X3TJ5BC.js";
5
+ } from "./chunk-TUXS2Y6C.js";
6
6
  import "./chunk-MLKGABMK.js";
7
7
 
8
8
  // src/mcp/cli.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainyc/canonry",
3
- "version": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "type": "module",
5
5
  "description": "Agent-first open-source AEO operating platform - track how answer engines cite your domain",
6
6
  "license": "FSL-1.1-ALv2",
@@ -59,21 +59,21 @@
59
59
  "@types/node-cron": "^3.0.11",
60
60
  "tsup": "^8.5.1",
61
61
  "tsx": "^4.19.0",
62
- "@ainyc/canonry-api-routes": "0.0.0",
63
62
  "@ainyc/canonry-config": "0.0.0",
64
- "@ainyc/canonry-contracts": "0.0.0",
65
- "@ainyc/canonry-intelligence": "0.0.0",
66
- "@ainyc/canonry-integration-bing": "0.0.0",
63
+ "@ainyc/canonry-api-routes": "0.0.0",
67
64
  "@ainyc/canonry-db": "0.0.0",
68
- "@ainyc/canonry-integration-commoncrawl": "0.0.0",
65
+ "@ainyc/canonry-intelligence": "0.0.0",
69
66
  "@ainyc/canonry-integration-google": "0.0.0",
70
- "@ainyc/canonry-provider-cdp": "0.0.0",
71
67
  "@ainyc/canonry-integration-wordpress": "0.0.0",
68
+ "@ainyc/canonry-contracts": "0.0.0",
69
+ "@ainyc/canonry-integration-commoncrawl": "0.0.0",
70
+ "@ainyc/canonry-provider-cdp": "0.0.0",
71
+ "@ainyc/canonry-integration-bing": "0.0.0",
72
72
  "@ainyc/canonry-provider-claude": "0.0.0",
73
- "@ainyc/canonry-provider-local": "0.0.0",
74
73
  "@ainyc/canonry-provider-gemini": "0.0.0",
75
- "@ainyc/canonry-provider-openai": "0.0.0",
76
- "@ainyc/canonry-provider-perplexity": "0.0.0"
74
+ "@ainyc/canonry-provider-local": "0.0.0",
75
+ "@ainyc/canonry-provider-perplexity": "0.0.0",
76
+ "@ainyc/canonry-provider-openai": "0.0.0"
77
77
  },
78
78
  "scripts": {
79
79
  "build": "tsx scripts/copy-agent-assets.ts && tsup && tsx build-web.ts",