@ainyc/canonry 1.24.0 → 1.24.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,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-YuErKoaN.js"></script>
15
+ <script type="module" crossorigin src="./assets/index-BUzyFRxr.js"></script>
16
16
  <link rel="stylesheet" crossorigin href="./assets/index-BidvmvWJ.css">
17
17
  </head>
18
18
  <body>
@@ -763,6 +763,7 @@ __export(schema_exports, {
763
763
  competitors: () => competitors,
764
764
  gaConnections: () => gaConnections,
765
765
  gaTrafficSnapshots: () => gaTrafficSnapshots,
766
+ gaTrafficSummaries: () => gaTrafficSummaries,
766
767
  googleConnections: () => googleConnections,
767
768
  gscCoverageSnapshots: () => gscCoverageSnapshots,
768
769
  gscSearchData: () => gscSearchData,
@@ -1027,6 +1028,18 @@ var gaTrafficSnapshots = sqliteTable("ga_traffic_snapshots", {
1027
1028
  index("idx_ga_traffic_project_date").on(table.projectId, table.date),
1028
1029
  index("idx_ga_traffic_page").on(table.landingPage)
1029
1030
  ]);
1031
+ var gaTrafficSummaries = sqliteTable("ga_traffic_summaries", {
1032
+ id: text("id").primaryKey(),
1033
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
1034
+ periodStart: text("period_start").notNull(),
1035
+ periodEnd: text("period_end").notNull(),
1036
+ totalSessions: integer("total_sessions").notNull().default(0),
1037
+ totalOrganicSessions: integer("total_organic_sessions").notNull().default(0),
1038
+ totalUsers: integer("total_users").notNull().default(0),
1039
+ syncedAt: text("synced_at").notNull()
1040
+ }, (table) => [
1041
+ index("idx_ga_summary_project").on(table.projectId)
1042
+ ]);
1030
1043
  var usageCounters = sqliteTable("usage_counters", {
1031
1044
  id: text("id").primaryKey(),
1032
1045
  scope: text("scope").notNull(),
@@ -1328,7 +1341,19 @@ var MIGRATIONS = [
1328
1341
  synced_at TEXT NOT NULL
1329
1342
  )`,
1330
1343
  `CREATE INDEX IF NOT EXISTS idx_ga_traffic_project_date ON ga_traffic_snapshots(project_id, date)`,
1331
- `CREATE INDEX IF NOT EXISTS idx_ga_traffic_page ON ga_traffic_snapshots(landing_page)`
1344
+ `CREATE INDEX IF NOT EXISTS idx_ga_traffic_page ON ga_traffic_snapshots(landing_page)`,
1345
+ // v14: GA4 aggregate summaries — stores true unique user count per sync period
1346
+ `CREATE TABLE IF NOT EXISTS ga_traffic_summaries (
1347
+ id TEXT PRIMARY KEY,
1348
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1349
+ period_start TEXT NOT NULL,
1350
+ period_end TEXT NOT NULL,
1351
+ total_sessions INTEGER NOT NULL DEFAULT 0,
1352
+ total_organic_sessions INTEGER NOT NULL DEFAULT 0,
1353
+ total_users INTEGER NOT NULL DEFAULT 0,
1354
+ synced_at TEXT NOT NULL
1355
+ )`,
1356
+ `CREATE INDEX IF NOT EXISTS idx_ga_summary_project ON ga_traffic_summaries(project_id)`
1332
1357
  ];
1333
1358
  function migrate(db) {
1334
1359
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -6927,6 +6952,36 @@ async function runReport(accessToken, propertyId, request) {
6927
6952
  }
6928
6953
  return await res.json();
6929
6954
  }
6955
+ async function batchRunReports(accessToken, propertyId, requests) {
6956
+ const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:batchRunReports`;
6957
+ const res = await fetch(url, {
6958
+ method: "POST",
6959
+ headers: {
6960
+ "Authorization": `Bearer ${accessToken}`,
6961
+ "Content-Type": "application/json"
6962
+ },
6963
+ body: JSON.stringify({ requests })
6964
+ });
6965
+ if (res.status === 401 || res.status === 403) {
6966
+ const body = await res.text().catch(() => "");
6967
+ ga4Log("error", "batch-report.auth-failed", { propertyId, httpStatus: res.status });
6968
+ throw new GA4ApiError(
6969
+ `GA4 API authentication failed \u2014 check service account permissions. ${body}`,
6970
+ res.status
6971
+ );
6972
+ }
6973
+ if (res.status === 429) {
6974
+ ga4Log("error", "batch-report.rate-limited", { propertyId });
6975
+ throw new GA4ApiError("GA4 API rate limit exceeded", 429);
6976
+ }
6977
+ if (!res.ok) {
6978
+ const body = await res.text();
6979
+ ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
6980
+ throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6981
+ }
6982
+ const data = await res.json();
6983
+ return data.reports;
6984
+ }
6930
6985
  function formatDate(d) {
6931
6986
  return d.toISOString().split("T")[0];
6932
6987
  }
@@ -7017,6 +7072,45 @@ async function verifyConnection(clientEmail, privateKey, propertyId) {
7017
7072
  });
7018
7073
  return true;
7019
7074
  }
7075
+ async function fetchAggregateSummary(accessToken, propertyId, days) {
7076
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
7077
+ const endDate = /* @__PURE__ */ new Date();
7078
+ const startDate = /* @__PURE__ */ new Date();
7079
+ startDate.setDate(startDate.getDate() - syncDays);
7080
+ ga4Log("info", "fetch-aggregate.start", { propertyId, days: syncDays });
7081
+ const dateRange = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
7082
+ const batchRes = await batchRunReports(accessToken, propertyId, [
7083
+ {
7084
+ dateRanges: [dateRange],
7085
+ dimensions: [],
7086
+ metrics: [{ name: "sessions" }, { name: "totalUsers" }],
7087
+ limit: 1
7088
+ },
7089
+ {
7090
+ dateRanges: [dateRange],
7091
+ dimensions: [],
7092
+ metrics: [{ name: "sessions" }],
7093
+ dimensionFilter: {
7094
+ filter: {
7095
+ fieldName: "sessionDefaultChannelGrouping",
7096
+ stringFilter: { matchType: "EXACT", value: "Organic Search" }
7097
+ }
7098
+ },
7099
+ limit: 1
7100
+ }
7101
+ ]);
7102
+ const totalRow = batchRes[0]?.rows?.[0];
7103
+ const organicRow = batchRes[1]?.rows?.[0];
7104
+ const summary = {
7105
+ periodStart: formatDate(startDate),
7106
+ periodEnd: formatDate(endDate),
7107
+ totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
7108
+ totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
7109
+ totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0
7110
+ };
7111
+ ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
7112
+ return summary;
7113
+ }
7020
7114
 
7021
7115
  // ../api-routes/src/ga.ts
7022
7116
  function gaLog(level, action, ctx) {
@@ -7101,6 +7195,7 @@ async function ga4Routes(app, opts) {
7101
7195
  return reply.status(err.statusCode).send(err.toJSON());
7102
7196
  }
7103
7197
  app.db.delete(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).run();
7198
+ app.db.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
7104
7199
  store.deleteConnection(project.name);
7105
7200
  writeAuditLog(app.db, {
7106
7201
  projectId: project.id,
@@ -7149,19 +7244,24 @@ async function ga4Routes(app, opts) {
7149
7244
  return reply.status(err.statusCode).send(err.toJSON());
7150
7245
  }
7151
7246
  let rows;
7247
+ let summary;
7152
7248
  try {
7153
- rows = await fetchTrafficByLandingPage(accessToken, conn.propertyId, days);
7249
+ ;
7250
+ [rows, summary] = await Promise.all([
7251
+ fetchTrafficByLandingPage(accessToken, conn.propertyId, days),
7252
+ fetchAggregateSummary(accessToken, conn.propertyId, days)
7253
+ ]);
7154
7254
  } catch (e) {
7155
7255
  const msg = e instanceof Error ? e.message : String(e);
7156
7256
  gaLog("error", "sync.fetch-failed", { projectId: project.id, error: msg });
7157
7257
  throw e;
7158
7258
  }
7159
7259
  const now = (/* @__PURE__ */ new Date()).toISOString();
7160
- if (rows.length > 0) {
7161
- const dates = rows.map((r) => r.date);
7162
- const minDate = dates.reduce((a, b) => a < b ? a : b);
7163
- const maxDate = dates.reduce((a, b) => a > b ? a : b);
7164
- app.db.transaction((tx) => {
7260
+ app.db.transaction((tx) => {
7261
+ if (rows.length > 0) {
7262
+ const dates = rows.map((r) => r.date);
7263
+ const minDate = dates.reduce((a, b) => a < b ? a : b);
7264
+ const maxDate = dates.reduce((a, b) => a > b ? a : b);
7165
7265
  tx.delete(gaTrafficSnapshots).where(
7166
7266
  and6(
7167
7267
  eq16(gaTrafficSnapshots.projectId, project.id),
@@ -7181,9 +7281,20 @@ async function ga4Routes(app, opts) {
7181
7281
  syncedAt: now
7182
7282
  }).run();
7183
7283
  }
7184
- });
7185
- }
7186
- gaLog("info", "sync.complete", { projectId: project.id, rowCount: rows.length, days });
7284
+ }
7285
+ tx.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
7286
+ tx.insert(gaTrafficSummaries).values({
7287
+ id: crypto16.randomUUID(),
7288
+ projectId: project.id,
7289
+ periodStart: summary.periodStart,
7290
+ periodEnd: summary.periodEnd,
7291
+ totalSessions: summary.totalSessions,
7292
+ totalOrganicSessions: summary.totalOrganicSessions,
7293
+ totalUsers: summary.totalUsers,
7294
+ syncedAt: now
7295
+ }).run();
7296
+ });
7297
+ gaLog("info", "sync.complete", { projectId: project.id, rowCount: rows.length, days, totalUsers: summary.totalUsers });
7187
7298
  return {
7188
7299
  synced: true,
7189
7300
  rowCount: rows.length,
@@ -7201,11 +7312,11 @@ async function ga4Routes(app, opts) {
7201
7312
  return reply.status(err.statusCode).send(err.toJSON());
7202
7313
  }
7203
7314
  const limit = Math.max(1, Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 500));
7204
- const totals = app.db.select({
7205
- totalSessions: sql3`SUM(${gaTrafficSnapshots.sessions})`,
7206
- totalOrganicSessions: sql3`SUM(${gaTrafficSnapshots.organicSessions})`,
7207
- totalUsers: sql3`SUM(${gaTrafficSnapshots.users})`
7208
- }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).get();
7315
+ const summary = app.db.select({
7316
+ totalSessions: gaTrafficSummaries.totalSessions,
7317
+ totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
7318
+ totalUsers: gaTrafficSummaries.totalUsers
7319
+ }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).get();
7209
7320
  const rows = app.db.select({
7210
7321
  landingPage: gaTrafficSnapshots.landingPage,
7211
7322
  sessions: sql3`SUM(${gaTrafficSnapshots.sessions})`,
@@ -7214,9 +7325,9 @@ async function ga4Routes(app, opts) {
7214
7325
  }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql3`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
7215
7326
  const latestSync = app.db.select({ syncedAt: gaTrafficSnapshots.syncedAt }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).orderBy(desc6(gaTrafficSnapshots.syncedAt)).limit(1).get();
7216
7327
  return {
7217
- totalSessions: totals?.totalSessions ?? 0,
7218
- totalOrganicSessions: totals?.totalOrganicSessions ?? 0,
7219
- totalUsers: totals?.totalUsers ?? 0,
7328
+ totalSessions: summary?.totalSessions ?? 0,
7329
+ totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
7330
+ totalUsers: summary?.totalUsers ?? 0,
7220
7331
  topPages: rows.map((r) => ({
7221
7332
  landingPage: r.landingPage,
7222
7333
  sessions: r.sessions ?? 0,
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  setGoogleAuthConfig,
20
20
  showFirstRunNotice,
21
21
  trackEvent
22
- } from "./chunk-Q5REKIL6.js";
22
+ } from "./chunk-QMUO2JYU.js";
23
23
 
24
24
  // src/cli.ts
25
25
  import { pathToFileURL } from "url";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createServer,
3
3
  loadConfig
4
- } from "./chunk-Q5REKIL6.js";
4
+ } from "./chunk-QMUO2JYU.js";
5
5
  export {
6
6
  createServer,
7
7
  loadConfig
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainyc/canonry",
3
- "version": "1.24.0",
3
+ "version": "1.24.1",
4
4
  "type": "module",
5
5
  "description": "The ultimate open-source AEO monitoring tool - track how answer engines cite your domain",
6
6
  "license": "FSL-1.1-ALv2",
@@ -55,14 +55,14 @@
55
55
  "@ainyc/canonry-api-routes": "0.0.0",
56
56
  "@ainyc/canonry-config": "0.0.0",
57
57
  "@ainyc/canonry-contracts": "0.0.0",
58
- "@ainyc/canonry-db": "0.0.0",
59
58
  "@ainyc/canonry-provider-claude": "0.0.0",
60
- "@ainyc/canonry-provider-gemini": "0.0.0",
59
+ "@ainyc/canonry-db": "0.0.0",
61
60
  "@ainyc/canonry-provider-cdp": "0.0.0",
62
61
  "@ainyc/canonry-provider-local": "0.0.0",
63
62
  "@ainyc/canonry-integration-bing": "0.0.0",
64
- "@ainyc/canonry-provider-openai": "0.0.0",
65
63
  "@ainyc/canonry-integration-google": "0.0.0",
64
+ "@ainyc/canonry-provider-openai": "0.0.0",
65
+ "@ainyc/canonry-provider-gemini": "0.0.0",
66
66
  "@ainyc/canonry-provider-perplexity": "0.0.0"
67
67
  },
68
68
  "scripts": {