@ainyc/canonry 2.14.0 → 2.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/index.html CHANGED
@@ -12,7 +12,7 @@
12
12
  <link rel="icon" type="image/png" sizes="32x32" href="./favicon-32.png" />
13
13
  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
14
14
  <title>Canonry</title>
15
- <script type="module" crossorigin src="./assets/index-BwFUCV6e.js"></script>
15
+ <script type="module" crossorigin src="./assets/index-D1v6Q-fS.js"></script>
16
16
  <link rel="stylesheet" crossorigin href="./assets/index-U2SLimrz.css">
17
17
  </head>
18
18
  <body>
@@ -1298,11 +1298,15 @@ var ga4SocialReferralDtoSchema = z12.object({
1298
1298
  var ga4TrafficSummaryDtoSchema = z12.object({
1299
1299
  totalSessions: z12.number(),
1300
1300
  totalOrganicSessions: z12.number(),
1301
+ /** Direct-channel sessions (sessions with no source — bookmarks, typed URLs, AI-driven traffic with stripped referrer). 0 for legacy rows from before the column was added. */
1302
+ totalDirectSessions: z12.number(),
1301
1303
  totalUsers: z12.number(),
1302
1304
  topPages: z12.array(z12.object({
1303
1305
  landingPage: z12.string(),
1304
1306
  sessions: z12.number(),
1305
1307
  organicSessions: z12.number(),
1308
+ /** Per-page Direct-channel sessions. 0 for legacy rows. */
1309
+ directSessions: z12.number(),
1306
1310
  users: z12.number()
1307
1311
  })),
1308
1312
  aiReferrals: z12.array(ga4AiReferralDtoSchema),
@@ -1319,6 +1323,8 @@ var ga4TrafficSummaryDtoSchema = z12.object({
1319
1323
  organicSharePct: z12.number(),
1320
1324
  /** Deduped AI sessions as a percentage of total sessions (0–100, rounded). */
1321
1325
  aiSharePct: z12.number(),
1326
+ /** Direct-channel sessions as a percentage of total sessions (0–100, rounded). */
1327
+ directSharePct: z12.number(),
1322
1328
  /** Social sessions as a percentage of total sessions (0–100, rounded). */
1323
1329
  socialSharePct: z12.number(),
1324
1330
  lastSyncedAt: z12.string().nullable()
@@ -1790,6 +1796,97 @@ function summarizeCheckResults(results) {
1790
1796
  return summary;
1791
1797
  }
1792
1798
 
1799
+ // ../contracts/src/url-normalize.ts
1800
+ var STRIP_KEYS = /* @__PURE__ */ new Set([
1801
+ // Click identifiers
1802
+ "fbclid",
1803
+ "gclid",
1804
+ "msclkid",
1805
+ "ttclid",
1806
+ "li_fat_id",
1807
+ "igshid",
1808
+ "yclid",
1809
+ "dclid",
1810
+ "gbraid",
1811
+ "wbraid",
1812
+ // Mailchimp
1813
+ "mc_cid",
1814
+ "mc_eid",
1815
+ // Google Analytics linkers
1816
+ "_ga",
1817
+ "_gl",
1818
+ // Google Tag Manager debug
1819
+ "gtm_latency",
1820
+ "gtm_debug"
1821
+ ]);
1822
+ function shouldStrip(key) {
1823
+ if (STRIP_KEYS.has(key)) return true;
1824
+ if (key.startsWith("utm_")) return true;
1825
+ return false;
1826
+ }
1827
+ function parseQuery(query) {
1828
+ if (query === "") return [];
1829
+ return query.split("&").map((pair) => {
1830
+ const eq = pair.indexOf("=");
1831
+ if (eq === -1) return { key: pair, value: null };
1832
+ return { key: pair.slice(0, eq), value: pair.slice(eq + 1) };
1833
+ });
1834
+ }
1835
+ function encodeQuery(pairs) {
1836
+ return pairs.map((p) => p.value === null ? p.key : `${p.key}=${p.value}`).join("&");
1837
+ }
1838
+ function collapseRootIndex(path2) {
1839
+ if (path2 === "/index.html" || path2 === "/index.php") return "/";
1840
+ return path2;
1841
+ }
1842
+ function dropTrailingSlash(path2) {
1843
+ if (path2.length > 1 && path2.endsWith("/")) {
1844
+ return path2.replace(/\/+$/, "");
1845
+ }
1846
+ return path2;
1847
+ }
1848
+ function normalizeUrlPath(input) {
1849
+ if (input == null) return null;
1850
+ const trimmed = input.trim();
1851
+ if (trimmed === "") return null;
1852
+ if (trimmed === "(not set)") return null;
1853
+ let pathPart;
1854
+ let queryPart;
1855
+ if (/^https?:\/\//i.test(trimmed)) {
1856
+ let url;
1857
+ try {
1858
+ url = new URL(trimmed);
1859
+ } catch {
1860
+ return null;
1861
+ }
1862
+ pathPart = url.pathname || "/";
1863
+ queryPart = url.search.startsWith("?") ? url.search.slice(1) : url.search;
1864
+ } else {
1865
+ let raw = trimmed;
1866
+ const hashIdx = raw.indexOf("#");
1867
+ if (hashIdx !== -1) raw = raw.slice(0, hashIdx);
1868
+ const qIdx = raw.indexOf("?");
1869
+ if (qIdx === -1) {
1870
+ pathPart = raw;
1871
+ queryPart = "";
1872
+ } else {
1873
+ pathPart = raw.slice(0, qIdx);
1874
+ queryPart = raw.slice(qIdx + 1);
1875
+ }
1876
+ }
1877
+ if (pathPart === "") pathPart = "/";
1878
+ pathPart = collapseRootIndex(pathPart);
1879
+ pathPart = dropTrailingSlash(pathPart);
1880
+ const pairs = parseQuery(queryPart).filter((p) => !shouldStrip(p.key));
1881
+ pairs.sort((a, b) => {
1882
+ if (a.key < b.key) return -1;
1883
+ if (a.key > b.key) return 1;
1884
+ return 0;
1885
+ });
1886
+ if (pairs.length === 0) return pathPart;
1887
+ return `${pathPart}?${encodeQuery(pairs)}`;
1888
+ }
1889
+
1793
1890
  // src/client.ts
1794
1891
  function createApiClient() {
1795
1892
  const config = loadConfig();
@@ -2522,6 +2619,7 @@ export {
2522
2619
  CheckScopes,
2523
2620
  CheckCategories,
2524
2621
  summarizeCheckResults,
2622
+ normalizeUrlPath,
2525
2623
  createApiClient,
2526
2624
  ApiClient
2527
2625
  };
@@ -39,6 +39,7 @@ import {
39
39
  locationContextSchema,
40
40
  missingDependency,
41
41
  normalizeProjectDomain,
42
+ normalizeUrlPath,
42
43
  notFound,
43
44
  notImplemented,
44
45
  parseRunError,
@@ -59,7 +60,7 @@ import {
59
60
  visibilityStateFromAnswerMentioned,
60
61
  windowCutoff,
61
62
  wordpressEnvSchema
62
- } from "./chunk-RNMMN2WI.js";
63
+ } from "./chunk-7VWSR5F6.js";
63
64
  import {
64
65
  IntelligenceService,
65
66
  agentMemory,
@@ -97,7 +98,7 @@ import {
97
98
  runs,
98
99
  schedules,
99
100
  usageCounters
100
- } from "./chunk-UM6RDSRJ.js";
101
+ } from "./chunk-NEDRCOOL.js";
101
102
 
102
103
  // src/telemetry.ts
103
104
  import crypto from "crypto";
@@ -6661,6 +6662,8 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6661
6662
  sessions: parseInt(row.metricValues[0].value, 10) || 0,
6662
6663
  organicSessions: 0,
6663
6664
  // populated by organic-only pass below
6665
+ directSessions: 0,
6666
+ // populated by direct-only pass below
6664
6667
  users: parseInt(row.metricValues[1].value, 10) || 0
6665
6668
  }));
6666
6669
  rows.push(...pageRows);
@@ -6696,9 +6699,38 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6696
6699
  organicOffset += (organicResponse.rows ?? []).length;
6697
6700
  if ((organicResponse.rows ?? []).length < 1e4 || organicOffset >= total) break;
6698
6701
  }
6702
+ const directMap = /* @__PURE__ */ new Map();
6703
+ let directOffset = 0;
6704
+ let directPageCount = 0;
6705
+ while (directPageCount < GA4_MAX_PAGES) {
6706
+ directPageCount++;
6707
+ const directRequest = {
6708
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6709
+ dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
6710
+ metrics: [{ name: "sessions" }],
6711
+ dimensionFilter: {
6712
+ filter: {
6713
+ fieldName: "sessionDefaultChannelGrouping",
6714
+ stringFilter: { matchType: "EXACT", value: "Direct" }
6715
+ }
6716
+ },
6717
+ limit: 1e4,
6718
+ offset: directOffset
6719
+ };
6720
+ const directResponse = await runReport(accessToken, propertyId, directRequest);
6721
+ if (!directResponse) break;
6722
+ for (const row of directResponse.rows ?? []) {
6723
+ const key = `${row.dimensionValues[0].value}::${row.dimensionValues[1].value}`;
6724
+ directMap.set(key, parseInt(row.metricValues[0].value, 10) || 0);
6725
+ }
6726
+ const total = directResponse.rowCount ?? 0;
6727
+ directOffset += (directResponse.rows ?? []).length;
6728
+ if ((directResponse.rows ?? []).length < 1e4 || directOffset >= total) break;
6729
+ }
6699
6730
  for (const row of rows) {
6700
6731
  const key = `${row.date}::${row.landingPage}`;
6701
6732
  row.organicSessions = organicMap.get(key) ?? 0;
6733
+ row.directSessions = directMap.get(key) ?? 0;
6702
6734
  }
6703
6735
  for (const row of rows) {
6704
6736
  if (row.date.length === 8 && !row.date.includes("-")) {
@@ -8661,8 +8693,10 @@ async function ga4Routes(app, opts) {
8661
8693
  projectId: project.id,
8662
8694
  date: row.date,
8663
8695
  landingPage: row.landingPage,
8696
+ landingPageNormalized: normalizeUrlPath(row.landingPage),
8664
8697
  sessions: row.sessions,
8665
8698
  organicSessions: row.organicSessions,
8699
+ directSessions: row.directSessions,
8666
8700
  users: row.users,
8667
8701
  syncedAt: now,
8668
8702
  syncRunId: runId
@@ -8780,16 +8814,20 @@ async function ga4Routes(app, opts) {
8780
8814
  totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
8781
8815
  totalUsers: gaTrafficSummaries.totalUsers
8782
8816
  }).from(gaTrafficSummaries).where(eq19(gaTrafficSummaries.projectId, project.id)).get();
8817
+ const directTotalRow = app.db.select({
8818
+ totalDirectSessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.directSessions}), 0)`
8819
+ }).from(gaTrafficSnapshots).where(and8(...snapshotConditions)).get();
8783
8820
  const summaryMeta = app.db.select({
8784
8821
  periodStart: gaTrafficSummaries.periodStart,
8785
8822
  periodEnd: gaTrafficSummaries.periodEnd
8786
8823
  }).from(gaTrafficSummaries).where(eq19(gaTrafficSummaries.projectId, project.id)).get();
8787
8824
  const rows = app.db.select({
8788
- landingPage: gaTrafficSnapshots.landingPage,
8825
+ landingPage: sql5`COALESCE(${gaTrafficSnapshots.landingPageNormalized}, ${gaTrafficSnapshots.landingPage})`,
8789
8826
  sessions: sql5`SUM(${gaTrafficSnapshots.sessions})`,
8790
8827
  organicSessions: sql5`SUM(${gaTrafficSnapshots.organicSessions})`,
8828
+ directSessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.directSessions}), 0)`,
8791
8829
  users: sql5`SUM(${gaTrafficSnapshots.users})`
8792
- }).from(gaTrafficSnapshots).where(and8(...snapshotConditions)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql5`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
8830
+ }).from(gaTrafficSnapshots).where(and8(...snapshotConditions)).groupBy(sql5`COALESCE(${gaTrafficSnapshots.landingPageNormalized}, ${gaTrafficSnapshots.landingPage})`).orderBy(sql5`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
8793
8831
  const aiReferrals = app.db.select({
8794
8832
  source: gaAiReferrals.source,
8795
8833
  medium: gaAiReferrals.medium,
@@ -8823,14 +8861,17 @@ async function ga4Routes(app, opts) {
8823
8861
  }).from(gaSocialReferrals).where(and8(...socialConditions)).get();
8824
8862
  const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq19(gaTrafficSummaries.projectId, project.id)).orderBy(desc9(gaTrafficSummaries.syncedAt)).limit(1).get();
8825
8863
  const total = summaryRow?.totalSessions ?? 0;
8864
+ const totalDirectSessions = directTotalRow?.totalDirectSessions ?? 0;
8826
8865
  return {
8827
8866
  totalSessions: total,
8828
8867
  totalOrganicSessions: summaryRow?.totalOrganicSessions ?? 0,
8868
+ totalDirectSessions,
8829
8869
  totalUsers: summaryRow?.totalUsers ?? 0,
8830
8870
  topPages: rows.map((r) => ({
8831
8871
  landingPage: r.landingPage,
8832
8872
  sessions: r.sessions ?? 0,
8833
8873
  organicSessions: r.organicSessions ?? 0,
8874
+ directSessions: r.directSessions ?? 0,
8834
8875
  users: r.users ?? 0
8835
8876
  })),
8836
8877
  aiReferrals: aiReferrals.map((r) => ({
@@ -8853,6 +8894,7 @@ async function ga4Routes(app, opts) {
8853
8894
  socialUsers: socialTotals?.users ?? 0,
8854
8895
  organicSharePct: total > 0 ? Math.round((summaryRow?.totalOrganicSessions ?? 0) / total * 100) : 0,
8855
8896
  aiSharePct: total > 0 ? Math.round((aiDeduped?.sessions ?? 0) / total * 100) : 0,
8897
+ directSharePct: total > 0 ? Math.round(totalDirectSessions / total * 100) : 0,
8856
8898
  socialSharePct: total > 0 ? Math.round((socialTotals?.sessions ?? 0) / total * 100) : 0,
8857
8899
  lastSyncedAt: latestSync?.syncedAt ?? null,
8858
8900
  periodStart: (() => {
@@ -9046,11 +9088,11 @@ async function ga4Routes(app, opts) {
9046
9088
  const project = resolveProject(app.db, request.params.name);
9047
9089
  requireGa4Connection(opts, project.name, project.canonicalDomain);
9048
9090
  const trafficPages = app.db.select({
9049
- landingPage: gaTrafficSnapshots.landingPage,
9091
+ landingPage: sql5`COALESCE(${gaTrafficSnapshots.landingPageNormalized}, ${gaTrafficSnapshots.landingPage})`,
9050
9092
  sessions: sql5`SUM(${gaTrafficSnapshots.sessions})`,
9051
9093
  organicSessions: sql5`SUM(${gaTrafficSnapshots.organicSessions})`,
9052
9094
  users: sql5`SUM(${gaTrafficSnapshots.users})`
9053
- }).from(gaTrafficSnapshots).where(eq19(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql5`SUM(${gaTrafficSnapshots.sessions}) DESC`).all();
9095
+ }).from(gaTrafficSnapshots).where(eq19(gaTrafficSnapshots.projectId, project.id)).groupBy(sql5`COALESCE(${gaTrafficSnapshots.landingPageNormalized}, ${gaTrafficSnapshots.landingPage})`).orderBy(sql5`SUM(${gaTrafficSnapshots.sessions}) DESC`).all();
9054
9096
  return {
9055
9097
  pages: trafficPages.map((r) => ({
9056
9098
  landingPage: r.landingPage,
@@ -310,14 +310,30 @@ var gaTrafficSnapshots = sqliteTable("ga_traffic_snapshots", {
310
310
  projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
311
311
  date: text("date").notNull(),
312
312
  landingPage: text("landing_page").notNull(),
313
+ /**
314
+ * Canonicalized form of `landingPage` produced by `normalizeUrlPath()` in
315
+ * `@ainyc/canonry-contracts`. Nullable so existing rows survive migration;
316
+ * new GA4 sync writes populate it. Per-page aggregations should
317
+ * `GROUP BY COALESCE(landing_page_normalized, landing_page)` so
318
+ * partially-backfilled state still aggregates correctly.
319
+ */
320
+ landingPageNormalized: text("landing_page_normalized"),
313
321
  sessions: integer("sessions").notNull().default(0),
314
322
  organicSessions: integer("organic_sessions").notNull().default(0),
323
+ /**
324
+ * Per-page Direct channel sessions. Nullable so existing rows survive
325
+ * the migration; new GA4 sync writes populate it. Distinct from
326
+ * `sessions - organicSessions` because that residual lumps Direct
327
+ * together with social, referral, paid, and email.
328
+ */
329
+ directSessions: integer("direct_sessions"),
315
330
  users: integer("users").notNull().default(0),
316
331
  syncedAt: text("synced_at").notNull(),
317
332
  syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" })
318
333
  }, (table) => [
319
334
  index("idx_ga_traffic_project_date").on(table.projectId, table.date),
320
335
  index("idx_ga_traffic_page").on(table.landingPage),
336
+ index("idx_ga_traffic_page_normalized").on(table.projectId, table.date, table.landingPageNormalized),
321
337
  index("idx_ga_traffic_run").on(table.syncRunId)
322
338
  ]);
323
339
  var gaAiReferrals = sqliteTable("ga_ai_referrals", {
@@ -1049,7 +1065,23 @@ var MIGRATIONS = [
1049
1065
  WHEN discovery_date IS NOT NULL THEN 0
1050
1066
  ELSE NULL
1051
1067
  END
1052
- WHERE created_at < '2026-04-22T00:00:00Z'`
1068
+ WHERE created_at < '2026-04-22T00:00:00Z'`,
1069
+ // v44: Canonicalized landing-page column for ga_traffic_snapshots.
1070
+ // Populated by GA4 sync via normalizeUrlPath() in
1071
+ // @ainyc/canonry-contracts. Nullable; existing rows are filled in by
1072
+ // `canonry backfill normalized-paths`. Read queries should
1073
+ // `GROUP BY COALESCE(landing_page_normalized, landing_page)` so
1074
+ // partially-backfilled state still aggregates correctly.
1075
+ // See plans/ai-attribution-research.md "Step 1 — data hygiene".
1076
+ `ALTER TABLE ga_traffic_snapshots ADD COLUMN landing_page_normalized TEXT`,
1077
+ `CREATE INDEX IF NOT EXISTS idx_ga_traffic_page_normalized
1078
+ ON ga_traffic_snapshots(project_id, date, landing_page_normalized)`,
1079
+ // v45: Per-page Direct channel sessions on ga_traffic_snapshots. Nullable
1080
+ // so existing rows survive; populated by the GA4 sync writer in a
1081
+ // separate commit. Unblocks an honest channel breakdown for the project
1082
+ // dashboard (organic / social / direct / known-AI) — see
1083
+ // plans/ai-attribution-research.md scope A.
1084
+ `ALTER TABLE ga_traffic_snapshots ADD COLUMN direct_sessions INTEGER`
1053
1085
  ];
1054
1086
  function isDuplicateColumnError(err) {
1055
1087
  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-LD7Y4K4G.js";
20
+ } from "./chunk-CILBPOHB.js";
21
21
  import {
22
22
  CcReleaseSyncStatuses,
23
23
  CheckScopes,
@@ -37,6 +37,7 @@ import {
37
37
  getConfigPath,
38
38
  isEndpointMissing,
39
39
  loadConfig,
40
+ normalizeUrlPath,
40
41
  notificationEventSchema,
41
42
  printCliError,
42
43
  providerQuotaPolicySchema,
@@ -44,17 +45,18 @@ import {
44
45
  saveConfig,
45
46
  saveConfigPatch,
46
47
  usageError
47
- } from "./chunk-RNMMN2WI.js";
48
+ } from "./chunk-7VWSR5F6.js";
48
49
  import {
49
50
  apiKeys,
50
51
  competitors,
51
52
  createClient,
53
+ gaTrafficSnapshots,
52
54
  migrate,
53
55
  parseJsonColumn,
54
56
  projects,
55
57
  querySnapshots,
56
58
  runs
57
- } from "./chunk-UM6RDSRJ.js";
59
+ } from "./chunk-NEDRCOOL.js";
58
60
  import "./chunk-MLKGABMK.js";
59
61
 
60
62
  // src/cli.ts
@@ -160,7 +162,7 @@ Usage: ${spec.usage}`, {
160
162
  }
161
163
 
162
164
  // src/commands/backfill.ts
163
- import { and, eq, inArray } from "drizzle-orm";
165
+ import { and, eq, inArray, isNull } from "drizzle-orm";
164
166
  var SNAPSHOT_BATCH_SIZE = 500;
165
167
  async function backfillAnswerVisibilityCommand(opts) {
166
168
  const config = loadConfig();
@@ -301,8 +303,75 @@ async function backfillAnswerVisibilityCommand(opts) {
301
303
  console.log(` Reparsed: ${reparsed}`);
302
304
  console.log(` Errors: ${providerErrors}`);
303
305
  }
306
+ function backfillNormalizedPaths(db, opts) {
307
+ const baseConditions = [isNull(gaTrafficSnapshots.landingPageNormalized)];
308
+ if (opts?.projectId) {
309
+ baseConditions.push(eq(gaTrafficSnapshots.projectId, opts.projectId));
310
+ }
311
+ const rows = db.select({
312
+ id: gaTrafficSnapshots.id,
313
+ landingPage: gaTrafficSnapshots.landingPage
314
+ }).from(gaTrafficSnapshots).where(and(...baseConditions)).all();
315
+ let updated = 0;
316
+ let unchanged = 0;
317
+ if (rows.length > 0) {
318
+ db.transaction((tx) => {
319
+ for (const row of rows) {
320
+ const next = normalizeUrlPath(row.landingPage);
321
+ if (next === null) {
322
+ unchanged++;
323
+ continue;
324
+ }
325
+ tx.update(gaTrafficSnapshots).set({ landingPageNormalized: next }).where(eq(gaTrafficSnapshots.id, row.id)).run();
326
+ updated++;
327
+ }
328
+ });
329
+ }
330
+ return { examined: rows.length, updated, unchanged };
331
+ }
332
+ async function backfillNormalizedPathsCommand(opts) {
333
+ const config = loadConfig();
334
+ const db = createClient(config.database);
335
+ migrate(db);
336
+ const projectFilter = opts?.project?.trim();
337
+ let projectId;
338
+ if (projectFilter) {
339
+ const project = db.select({ id: projects.id }).from(projects).where(eq(projects.name, projectFilter)).get();
340
+ if (!project) {
341
+ const result2 = {
342
+ project: projectFilter,
343
+ examined: 0,
344
+ updated: 0,
345
+ unchanged: 0
346
+ };
347
+ if (opts?.format === "json") {
348
+ console.log(JSON.stringify(result2, null, 2));
349
+ return;
350
+ }
351
+ console.log(`Backfill normalized-paths: project "${projectFilter}" not found.`);
352
+ return;
353
+ }
354
+ projectId = project.id;
355
+ }
356
+ const { examined, updated, unchanged } = backfillNormalizedPaths(db, { projectId });
357
+ const result = {
358
+ project: projectFilter ?? null,
359
+ examined,
360
+ updated,
361
+ unchanged
362
+ };
363
+ if (opts?.format === "json") {
364
+ console.log(JSON.stringify(result, null, 2));
365
+ return;
366
+ }
367
+ console.log("Normalized-path backfill complete.\n");
368
+ if (projectFilter) console.log(` Project: ${projectFilter}`);
369
+ console.log(` Examined: ${examined}`);
370
+ console.log(` Updated: ${updated}`);
371
+ console.log(` Unchanged: ${unchanged}`);
372
+ }
304
373
  async function backfillInsightsCommand(project, opts) {
305
- const { IntelligenceService } = await import("./intelligence-service-54F3NGPM.js");
374
+ const { IntelligenceService } = await import("./intelligence-service-6S5YKANX.js");
306
375
  const config = loadConfig();
307
376
  const db = createClient(config.database);
308
377
  migrate(db);
@@ -498,14 +567,28 @@ var BACKFILL_CLI_COMMANDS = [
498
567
  });
499
568
  }
500
569
  },
570
+ {
571
+ path: ["backfill", "normalized-paths"],
572
+ usage: "canonry backfill normalized-paths [--project <name>] [--format json]",
573
+ options: {
574
+ project: stringOption()
575
+ },
576
+ allowPositionals: false,
577
+ run: async (input) => {
578
+ await backfillNormalizedPathsCommand({
579
+ project: getString(input.values, "project"),
580
+ format: input.format
581
+ });
582
+ }
583
+ },
501
584
  {
502
585
  path: ["backfill"],
503
- usage: "canonry backfill <answer-visibility|insights> [options]",
586
+ usage: "canonry backfill <answer-visibility|insights|normalized-paths> [options]",
504
587
  run: async (input) => {
505
588
  unknownSubcommand(input.positionals[0], {
506
589
  command: "backfill",
507
- usage: "canonry backfill <answer-visibility|insights> [options]",
508
- available: ["answer-visibility", "insights"]
590
+ usage: "canonry backfill <answer-visibility|insights|normalized-paths> [options]",
591
+ available: ["answer-visibility", "insights", "normalized-paths"]
509
592
  });
510
593
  }
511
594
  }
@@ -7072,6 +7155,18 @@ async function serveCommand(format = "text") {
7072
7155
  config.port = port;
7073
7156
  const db = createClient(config.database);
7074
7157
  migrate(db);
7158
+ try {
7159
+ const result = backfillNormalizedPaths(db);
7160
+ if (result.updated > 0 && format === "text") {
7161
+ console.log(
7162
+ `Migrated ${result.updated} GA landing-page row${result.updated === 1 ? "" : "s"} to canonical form.`
7163
+ );
7164
+ }
7165
+ } catch (err) {
7166
+ const msg = err instanceof Error ? err.message : String(err);
7167
+ process.stderr.write(`warning: normalized-path backfill skipped: ${msg}
7168
+ `);
7169
+ }
7075
7170
  const app = await createServer({ config, db });
7076
7171
  let shuttingDown = false;
7077
7172
  const shutdown = (signal) => {
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  createServer
3
- } from "./chunk-LD7Y4K4G.js";
3
+ } from "./chunk-CILBPOHB.js";
4
4
  import {
5
5
  loadConfig
6
- } from "./chunk-RNMMN2WI.js";
7
- import "./chunk-UM6RDSRJ.js";
6
+ } from "./chunk-7VWSR5F6.js";
7
+ import "./chunk-NEDRCOOL.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-UM6RDSRJ.js";
3
+ } from "./chunk-NEDRCOOL.js";
4
4
  import "./chunk-MLKGABMK.js";
5
5
  export {
6
6
  IntelligenceService
package/dist/mcp.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  projectUpsertRequestSchema,
11
11
  runTriggerRequestSchema,
12
12
  scheduleUpsertRequestSchema
13
- } from "./chunk-RNMMN2WI.js";
13
+ } from "./chunk-7VWSR5F6.js";
14
14
  import "./chunk-MLKGABMK.js";
15
15
 
16
16
  // src/mcp/cli.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainyc/canonry",
3
- "version": "2.14.0",
3
+ "version": "2.14.2",
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",
@@ -60,20 +60,20 @@
60
60
  "tsup": "^8.5.1",
61
61
  "tsx": "^4.19.0",
62
62
  "@ainyc/canonry-config": "0.0.0",
63
- "@ainyc/canonry-api-routes": "0.0.0",
64
63
  "@ainyc/canonry-contracts": "0.0.0",
65
64
  "@ainyc/canonry-db": "0.0.0",
65
+ "@ainyc/canonry-api-routes": "0.0.0",
66
66
  "@ainyc/canonry-intelligence": "0.0.0",
67
- "@ainyc/canonry-integration-bing": "0.0.0",
68
67
  "@ainyc/canonry-integration-commoncrawl": "0.0.0",
69
68
  "@ainyc/canonry-integration-google": "0.0.0",
70
- "@ainyc/canonry-integration-wordpress": "0.0.0",
69
+ "@ainyc/canonry-integration-bing": "0.0.0",
71
70
  "@ainyc/canonry-provider-cdp": "0.0.0",
72
- "@ainyc/canonry-provider-claude": "0.0.0",
73
- "@ainyc/canonry-provider-gemini": "0.0.0",
71
+ "@ainyc/canonry-integration-wordpress": "0.0.0",
74
72
  "@ainyc/canonry-provider-local": "0.0.0",
75
73
  "@ainyc/canonry-provider-perplexity": "0.0.0",
76
- "@ainyc/canonry-provider-openai": "0.0.0"
74
+ "@ainyc/canonry-provider-gemini": "0.0.0",
75
+ "@ainyc/canonry-provider-openai": "0.0.0",
76
+ "@ainyc/canonry-provider-claude": "0.0.0"
77
77
  },
78
78
  "scripts": {
79
79
  "build": "tsx scripts/copy-agent-assets.ts && tsup && tsx build-web.ts",