@ainyc/canonry 1.32.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -816,6 +816,7 @@ var querySnapshotDtoSchema = z8.object({
816
816
  citedDomains: z8.array(z8.string()).default([]),
817
817
  competitorOverlap: z8.array(z8.string()).default([]),
818
818
  recommendedCompetitors: z8.array(z8.string()).default([]),
819
+ matchedTerms: z8.array(z8.string()).default([]),
819
820
  groundingSources: z8.array(groundingSourceSchema).default([]),
820
821
  searchQueries: z8.array(z8.string()).default([]),
821
822
  model: z8.string().nullable().optional(),
@@ -1093,34 +1094,51 @@ var GENERIC_TOKENS = /* @__PURE__ */ new Set([
1093
1094
  "systems",
1094
1095
  "tech"
1095
1096
  ]);
1096
- function determineAnswerMentioned(answerText, displayName, domains) {
1097
- if (!answerText) return false;
1097
+ function extractAnswerMentions(answerText, displayName, domains) {
1098
+ if (!answerText) return { mentioned: false, matchedTerms: [] };
1099
+ const matchedTerms = [];
1098
1100
  const lowerAnswer = answerText.toLowerCase();
1099
1101
  for (const domain of domains) {
1100
1102
  const normalizedDomain = normalizeProjectDomain(domain);
1101
1103
  if (!normalizedDomain || !normalizedDomain.includes(".")) continue;
1102
- if (domainMentioned(lowerAnswer, normalizedDomain)) return true;
1104
+ if (domainMentioned(lowerAnswer, normalizedDomain)) {
1105
+ matchedTerms.push(normalizedDomain);
1106
+ }
1103
1107
  }
1104
1108
  const normalizedDisplayName = normalizeText(displayName);
1105
1109
  if (normalizedDisplayName && normalizeText(answerText).includes(normalizedDisplayName)) {
1106
- return true;
1110
+ matchedTerms.push(displayName);
1107
1111
  }
1108
1112
  const tokens = collectDistinctiveTokens(displayName, domains);
1109
- if (tokens.length === 0) return false;
1110
- let matches = 0;
1113
+ let tokenMatches = 0;
1114
+ const matchedTokens = [];
1111
1115
  for (const token of tokens) {
1112
1116
  if (new RegExp(`\\b${escapeRegExp(token)}\\b`).test(lowerAnswer)) {
1113
- matches++;
1117
+ tokenMatches++;
1118
+ matchedTokens.push(token);
1114
1119
  }
1115
1120
  }
1116
- if (tokens.length === 1) {
1117
- return matches >= 1;
1121
+ const tokenThresholdMet = tokens.length > 0 && (tokens.length === 1 && tokenMatches >= 1 || tokenMatches >= Math.min(2, tokens.length));
1122
+ if (tokenThresholdMet) {
1123
+ matchedTerms.push(...matchedTokens);
1118
1124
  }
1119
- return matches >= Math.min(2, tokens.length);
1125
+ const unique = [...new Set(matchedTerms)];
1126
+ const domainMatches3 = unique.filter((t) => t.includes("."));
1127
+ const dedupedFinal = unique.filter((term) => {
1128
+ if (term.includes(".")) return true;
1129
+ return !domainMatches3.some((d) => d.toLowerCase().startsWith(term.toLowerCase() + "."));
1130
+ });
1131
+ return { mentioned: dedupedFinal.length > 0, matchedTerms: dedupedFinal };
1132
+ }
1133
+ function determineAnswerMentioned(answerText, displayName, domains) {
1134
+ return extractAnswerMentions(answerText, displayName, domains).mentioned;
1120
1135
  }
1121
1136
  function visibilityStateFromAnswerMentioned(answerMentioned) {
1122
1137
  return answerMentioned ? "visible" : "not-visible";
1123
1138
  }
1139
+ function brandKeyFromText(value) {
1140
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
1141
+ }
1124
1142
  function domainMentioned(lowerAnswer, normalizedDomain) {
1125
1143
  const escapedDomain = escapeRegExp(normalizedDomain.toLowerCase());
1126
1144
  const patterns = [
@@ -1930,17 +1948,27 @@ function writeAuditLog(db, entry) {
1930
1948
  createdAt: now
1931
1949
  }).run();
1932
1950
  }
1933
- function resolveSnapshotAnswerMentioned(snapshot, project) {
1951
+ function resolveSnapshotMentionResult(snapshot, project) {
1952
+ if (snapshot.answerText) {
1953
+ const domains = effectiveDomains({
1954
+ canonicalDomain: project.canonicalDomain,
1955
+ ownedDomains: normalizeOwnedDomains(project.ownedDomains)
1956
+ });
1957
+ return extractAnswerMentions(snapshot.answerText, project.displayName, domains);
1958
+ }
1934
1959
  if (typeof snapshot.answerMentioned === "boolean") {
1935
- return snapshot.answerMentioned;
1960
+ return { mentioned: snapshot.answerMentioned, matchedTerms: [] };
1936
1961
  }
1937
- return determineAnswerMentioned(snapshot.answerText, project.displayName, effectiveDomains({
1938
- canonicalDomain: project.canonicalDomain,
1939
- ownedDomains: normalizeOwnedDomains(project.ownedDomains)
1940
- }));
1962
+ return { mentioned: false, matchedTerms: [] };
1963
+ }
1964
+ function resolveSnapshotAnswerMentioned(snapshot, project) {
1965
+ return resolveSnapshotMentionResult(snapshot, project).mentioned;
1941
1966
  }
1942
1967
  function resolveSnapshotVisibilityState(snapshot, project) {
1943
- return visibilityStateFromAnswerMentioned(resolveSnapshotAnswerMentioned(snapshot, project));
1968
+ return visibilityStateFromAnswerMentioned(resolveSnapshotMentionResult(snapshot, project).mentioned);
1969
+ }
1970
+ function resolveSnapshotMatchedTerms(snapshot, project) {
1971
+ return resolveSnapshotMentionResult(snapshot, project).matchedTerms;
1944
1972
  }
1945
1973
  function normalizeOwnedDomains(value) {
1946
1974
  if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
@@ -2645,6 +2673,7 @@ async function runRoutes(app, opts) {
2645
2673
  citedDomains: parseJsonColumn(s.citedDomains, []),
2646
2674
  competitorOverlap: parseJsonColumn(s.competitorOverlap, []),
2647
2675
  recommendedCompetitors: parseJsonColumn(s.recommendedCompetitors, []),
2676
+ matchedTerms: project ? resolveSnapshotMatchedTerms(s, project) : [],
2648
2677
  model: s.model ?? rawParsed.model,
2649
2678
  location: s.location,
2650
2679
  groundingSources: rawParsed.groundingSources,
@@ -6220,7 +6249,7 @@ function formatNotification(row) {
6220
6249
  }
6221
6250
 
6222
6251
  // ../api-routes/src/google.ts
6223
- import crypto13 from "crypto";
6252
+ import crypto14 from "crypto";
6224
6253
  import { eq as eq13, and as and2, desc as desc4, sql as sql3 } from "drizzle-orm";
6225
6254
 
6226
6255
  // ../integration-google/src/constants.ts
@@ -6408,90 +6437,421 @@ async function inspectUrl(accessToken, inspectionUrl, siteUrl) {
6408
6437
  );
6409
6438
  }
6410
6439
 
6411
- // ../api-routes/src/google.ts
6412
- function signState(payload, secret) {
6413
- return crypto13.createHmac("sha256", secret).update(payload).digest("hex");
6440
+ // ../integration-google-analytics/src/ga4-client.ts
6441
+ import crypto13 from "crypto";
6442
+
6443
+ // ../integration-google-analytics/src/constants.ts
6444
+ var GA4_DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
6445
+ var GA4_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
6446
+ var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
6447
+ var GA4_DEFAULT_SYNC_DAYS = 30;
6448
+ var GA4_MAX_SYNC_DAYS = 90;
6449
+
6450
+ // ../integration-google-analytics/src/types.ts
6451
+ var GA4ApiError = class extends Error {
6452
+ status;
6453
+ constructor(message, status) {
6454
+ super(message);
6455
+ this.name = "GA4ApiError";
6456
+ this.status = status;
6457
+ }
6458
+ };
6459
+
6460
+ // ../integration-google-analytics/src/ga4-client.ts
6461
+ function ga4Log(level, action, ctx) {
6462
+ const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Client", action, ...ctx };
6463
+ const stream = level === "error" ? process.stderr : process.stdout;
6464
+ stream.write(JSON.stringify(entry) + "\n");
6414
6465
  }
6415
- function buildSignedState(data, secret) {
6416
- const payload = JSON.stringify(data);
6417
- const sig = signState(payload, secret);
6418
- return Buffer.from(JSON.stringify({ payload, sig })).toString("base64url");
6466
+ function createServiceAccountJwt(clientEmail, privateKey, scope) {
6467
+ const now = Math.floor(Date.now() / 1e3);
6468
+ const header = { alg: "RS256", typ: "JWT" };
6469
+ const payload = {
6470
+ iss: clientEmail,
6471
+ scope,
6472
+ aud: GOOGLE_TOKEN_URL2,
6473
+ iat: now,
6474
+ exp: now + 3600
6475
+ // 1 hour
6476
+ };
6477
+ const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
6478
+ const headerB64 = encode(header);
6479
+ const payloadB64 = encode(payload);
6480
+ const signingInput = `${headerB64}.${payloadB64}`;
6481
+ const sign = crypto13.createSign("RSA-SHA256");
6482
+ sign.update(signingInput);
6483
+ const signature = sign.sign(privateKey, "base64url");
6484
+ return `${signingInput}.${signature}`;
6419
6485
  }
6420
- function verifySignedState(encoded, secret) {
6421
- try {
6422
- const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
6423
- const expected = signState(payload, secret);
6424
- if (!crypto13.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
6425
- return JSON.parse(payload);
6426
- } catch {
6427
- return null;
6486
+ async function getAccessToken(clientEmail, privateKey) {
6487
+ const jwt = createServiceAccountJwt(clientEmail, privateKey, GA4_SCOPE);
6488
+ const res = await fetch(GOOGLE_TOKEN_URL2, {
6489
+ method: "POST",
6490
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
6491
+ body: new URLSearchParams({
6492
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
6493
+ assertion: jwt
6494
+ })
6495
+ });
6496
+ if (!res.ok) {
6497
+ const body = await res.text().catch(() => "");
6498
+ ga4Log("error", "token.failed", { httpStatus: res.status, responseBody: body });
6499
+ throw new GA4ApiError(`Failed to get access token: ${body}`, res.status);
6428
6500
  }
6501
+ const data = await res.json();
6502
+ return data.access_token;
6429
6503
  }
6430
- async function getValidToken(store, domain, connectionType, clientId, clientSecret) {
6431
- const conn = store.getConnection(domain, connectionType);
6432
- if (!conn) {
6433
- throw notFound("Google connection", connectionType);
6504
+ async function runReport(accessToken, propertyId, request) {
6505
+ const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:runReport`;
6506
+ const res = await fetch(url, {
6507
+ method: "POST",
6508
+ headers: {
6509
+ "Authorization": `Bearer ${accessToken}`,
6510
+ "Content-Type": "application/json"
6511
+ },
6512
+ body: JSON.stringify(request)
6513
+ });
6514
+ if (res.status === 401 || res.status === 403) {
6515
+ const body = await res.text().catch(() => "");
6516
+ let detail = "";
6517
+ try {
6518
+ const parsed = JSON.parse(body);
6519
+ if (parsed.error?.status === "SERVICE_DISABLED") {
6520
+ detail = " The Google Analytics Data API is not enabled for this GCP project. Enable it at: https://console.developers.google.com/apis/api/analyticsdata.googleapis.com/overview";
6521
+ } else if (parsed.error?.message) {
6522
+ detail = ` ${parsed.error.message}`;
6523
+ }
6524
+ } catch {
6525
+ if (body.length < 200) detail = ` ${body}`;
6526
+ }
6527
+ ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status, responseBody: body });
6528
+ throw new GA4ApiError(
6529
+ `GA4 API authentication failed \u2014 check service account permissions.${detail}`,
6530
+ res.status
6531
+ );
6434
6532
  }
6435
- if (!conn.accessToken || !conn.refreshToken) {
6436
- throw validationError("Google connection is incomplete \u2014 please reconnect");
6533
+ if (res.status === 429) {
6534
+ ga4Log("error", "report.rate-limited", { propertyId });
6535
+ throw new GA4ApiError("GA4 API rate limit exceeded", 429);
6437
6536
  }
6438
- const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
6439
- const fiveMinutes = 5 * 60 * 1e3;
6440
- if (Date.now() > expiresAt - fiveMinutes) {
6441
- const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
6442
- const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
6443
- const updated = store.updateConnection(domain, connectionType, {
6444
- accessToken: tokens.access_token,
6445
- tokenExpiresAt: newExpiresAt,
6446
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6447
- });
6448
- return {
6449
- accessToken: tokens.access_token,
6450
- connectionId: `${domain}:${connectionType}`,
6451
- propertyId: updated?.propertyId ?? conn.propertyId ?? null
6452
- };
6537
+ if (!res.ok) {
6538
+ const body = await res.text();
6539
+ ga4Log("error", "report.error", { propertyId, httpStatus: res.status, responseBody: body });
6540
+ throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6453
6541
  }
6454
- return {
6455
- accessToken: conn.accessToken,
6456
- connectionId: `${domain}:${connectionType}`,
6457
- propertyId: conn.propertyId ?? null
6458
- };
6542
+ return await res.json();
6459
6543
  }
6460
- async function googleRoutes(app, opts) {
6461
- const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
6462
- function getAuthConfig() {
6463
- return opts.getGoogleAuthConfig?.() ?? {};
6544
+ async function batchRunReports(accessToken, propertyId, requests) {
6545
+ const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:batchRunReports`;
6546
+ const res = await fetch(url, {
6547
+ method: "POST",
6548
+ headers: {
6549
+ "Authorization": `Bearer ${accessToken}`,
6550
+ "Content-Type": "application/json"
6551
+ },
6552
+ body: JSON.stringify({ requests })
6553
+ });
6554
+ if (res.status === 401 || res.status === 403) {
6555
+ const body = await res.text().catch(() => "");
6556
+ ga4Log("error", "batch-report.auth-failed", { propertyId, httpStatus: res.status });
6557
+ throw new GA4ApiError(
6558
+ `GA4 API authentication failed \u2014 check service account permissions. ${body}`,
6559
+ res.status
6560
+ );
6464
6561
  }
6465
- function requireConnectionStore(reply) {
6466
- if (opts.googleConnectionStore) return opts.googleConnectionStore;
6467
- const err = validationError("Google auth storage is not configured for this deployment");
6468
- reply.status(err.statusCode).send(err.toJSON());
6469
- return null;
6562
+ if (res.status === 429) {
6563
+ ga4Log("error", "batch-report.rate-limited", { propertyId });
6564
+ throw new GA4ApiError("GA4 API rate limit exceeded", 429);
6470
6565
  }
6471
- app.get("/projects/:name/google/connections", async (request) => {
6472
- const project = resolveProject(app.db, request.params.name);
6473
- const conns = opts.googleConnectionStore?.listConnections(project.canonicalDomain) ?? [];
6474
- return conns.map((connection) => ({
6475
- id: `${connection.domain}:${connection.connectionType}`,
6476
- domain: connection.domain,
6477
- connectionType: connection.connectionType,
6478
- propertyId: connection.propertyId ?? null,
6479
- sitemapUrl: connection.sitemapUrl ?? null,
6480
- scopes: connection.scopes ?? [],
6481
- createdAt: connection.createdAt,
6482
- updatedAt: connection.updatedAt
6566
+ if (!res.ok) {
6567
+ const body = await res.text();
6568
+ ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
6569
+ throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6570
+ }
6571
+ const data = await res.json();
6572
+ return data.reports;
6573
+ }
6574
+ function formatDate(d) {
6575
+ return d.toISOString().split("T")[0];
6576
+ }
6577
+ var AI_REFERRAL_SOURCE_FILTERS = [
6578
+ { matchType: "CONTAINS", value: "perplexity" },
6579
+ { matchType: "CONTAINS", value: "gemini" },
6580
+ { matchType: "CONTAINS", value: "chatgpt" },
6581
+ { matchType: "CONTAINS", value: "openai" },
6582
+ { matchType: "CONTAINS", value: "claude" },
6583
+ { matchType: "CONTAINS", value: "anthropic" },
6584
+ { matchType: "CONTAINS", value: "copilot" }
6585
+ ];
6586
+ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6587
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
6588
+ const endDate = /* @__PURE__ */ new Date();
6589
+ const startDate = /* @__PURE__ */ new Date();
6590
+ startDate.setDate(startDate.getDate() - syncDays);
6591
+ ga4Log("info", "fetch-traffic.start", { propertyId, days: syncDays });
6592
+ const PAGE_SIZE = 1e4;
6593
+ const rows = [];
6594
+ let offset = 0;
6595
+ while (true) {
6596
+ const request = {
6597
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6598
+ dimensions: [
6599
+ { name: "date" },
6600
+ { name: "landingPagePlusQueryString" }
6601
+ ],
6602
+ metrics: [
6603
+ { name: "sessions" },
6604
+ { name: "totalUsers" }
6605
+ ],
6606
+ limit: PAGE_SIZE,
6607
+ offset
6608
+ };
6609
+ const response = await runReport(accessToken, propertyId, request);
6610
+ const pageRows = (response.rows ?? []).map((row) => ({
6611
+ date: row.dimensionValues[0].value,
6612
+ landingPage: row.dimensionValues[1].value,
6613
+ sessions: parseInt(row.metricValues[0].value, 10) || 0,
6614
+ organicSessions: 0,
6615
+ // populated by organic-only pass below
6616
+ users: parseInt(row.metricValues[1].value, 10) || 0
6483
6617
  }));
6484
- });
6485
- app.post("/projects/:name/google/connect", async (request, reply) => {
6486
- const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
6487
- if (!googleClientId || !googleClientSecret) {
6488
- const err = validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
6489
- return reply.status(err.statusCode).send(err.toJSON());
6490
- }
6491
- const { type, propertyId, publicUrl } = request.body ?? {};
6492
- if (!type || type !== "gsc" && type !== "ga4") {
6493
- const err = validationError('type must be "gsc" or "ga4"');
6494
- return reply.status(err.statusCode).send(err.toJSON());
6618
+ rows.push(...pageRows);
6619
+ const totalRows = response.rowCount ?? 0;
6620
+ offset += pageRows.length;
6621
+ if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
6622
+ }
6623
+ const organicMap = /* @__PURE__ */ new Map();
6624
+ let organicOffset = 0;
6625
+ while (true) {
6626
+ const organicRequest = {
6627
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6628
+ dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
6629
+ metrics: [{ name: "sessions" }],
6630
+ dimensionFilter: {
6631
+ filter: {
6632
+ fieldName: "sessionDefaultChannelGrouping",
6633
+ stringFilter: { matchType: "EXACT", value: "Organic Search" }
6634
+ }
6635
+ },
6636
+ limit: 1e4,
6637
+ offset: organicOffset
6638
+ };
6639
+ const organicResponse = await runReport(accessToken, propertyId, organicRequest);
6640
+ for (const row of organicResponse.rows ?? []) {
6641
+ const key = `${row.dimensionValues[0].value}::${row.dimensionValues[1].value}`;
6642
+ organicMap.set(key, parseInt(row.metricValues[0].value, 10) || 0);
6643
+ }
6644
+ const total = organicResponse.rowCount ?? 0;
6645
+ organicOffset += (organicResponse.rows ?? []).length;
6646
+ if ((organicResponse.rows ?? []).length < 1e4 || organicOffset >= total) break;
6647
+ }
6648
+ for (const row of rows) {
6649
+ const key = `${row.date}::${row.landingPage}`;
6650
+ row.organicSessions = organicMap.get(key) ?? 0;
6651
+ }
6652
+ for (const row of rows) {
6653
+ if (row.date.length === 8 && !row.date.includes("-")) {
6654
+ row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
6655
+ }
6656
+ }
6657
+ ga4Log("info", "fetch-traffic.done", { propertyId, rowCount: rows.length });
6658
+ return rows;
6659
+ }
6660
+ async function verifyConnection(clientEmail, privateKey, propertyId) {
6661
+ const accessToken = await getAccessToken(clientEmail, privateKey);
6662
+ return verifyConnectionWithToken(accessToken, propertyId);
6663
+ }
6664
+ async function verifyConnectionWithToken(accessToken, propertyId) {
6665
+ const endDate = /* @__PURE__ */ new Date();
6666
+ const startDate = /* @__PURE__ */ new Date();
6667
+ startDate.setDate(startDate.getDate() - 1);
6668
+ await runReport(accessToken, propertyId, {
6669
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6670
+ dimensions: [{ name: "date" }],
6671
+ metrics: [{ name: "sessions" }],
6672
+ limit: 1
6673
+ });
6674
+ return true;
6675
+ }
6676
+ async function fetchAggregateSummary(accessToken, propertyId, days) {
6677
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
6678
+ const endDate = /* @__PURE__ */ new Date();
6679
+ const startDate = /* @__PURE__ */ new Date();
6680
+ startDate.setDate(startDate.getDate() - syncDays);
6681
+ ga4Log("info", "fetch-aggregate.start", { propertyId, days: syncDays });
6682
+ const dateRange = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
6683
+ const batchRes = await batchRunReports(accessToken, propertyId, [
6684
+ {
6685
+ dateRanges: [dateRange],
6686
+ dimensions: [],
6687
+ metrics: [{ name: "sessions" }, { name: "totalUsers" }],
6688
+ limit: 1
6689
+ },
6690
+ {
6691
+ dateRanges: [dateRange],
6692
+ dimensions: [],
6693
+ metrics: [{ name: "sessions" }],
6694
+ dimensionFilter: {
6695
+ filter: {
6696
+ fieldName: "sessionDefaultChannelGrouping",
6697
+ stringFilter: { matchType: "EXACT", value: "Organic Search" }
6698
+ }
6699
+ },
6700
+ limit: 1
6701
+ }
6702
+ ]);
6703
+ const totalRow = batchRes[0]?.rows?.[0];
6704
+ const organicRow = batchRes[1]?.rows?.[0];
6705
+ const summary = {
6706
+ periodStart: formatDate(startDate),
6707
+ periodEnd: formatDate(endDate),
6708
+ totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
6709
+ totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
6710
+ totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0
6711
+ };
6712
+ ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
6713
+ return summary;
6714
+ }
6715
+ async function fetchAiReferrals(accessToken, propertyId, days) {
6716
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
6717
+ const endDate = /* @__PURE__ */ new Date();
6718
+ const startDate = /* @__PURE__ */ new Date();
6719
+ startDate.setDate(startDate.getDate() - syncDays);
6720
+ ga4Log("info", "fetch-ai-referrals.start", { propertyId, days: syncDays });
6721
+ const PAGE_SIZE = 1e3;
6722
+ const rows = [];
6723
+ let offset = 0;
6724
+ while (true) {
6725
+ const request = {
6726
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6727
+ dimensions: [
6728
+ { name: "date" },
6729
+ { name: "sessionSource" },
6730
+ { name: "sessionMedium" }
6731
+ ],
6732
+ metrics: [
6733
+ { name: "sessions" },
6734
+ { name: "totalUsers" }
6735
+ ],
6736
+ dimensionFilter: {
6737
+ orGroup: {
6738
+ expressions: AI_REFERRAL_SOURCE_FILTERS.map(({ matchType, value }) => ({
6739
+ filter: {
6740
+ fieldName: "sessionSource",
6741
+ stringFilter: { matchType, value }
6742
+ }
6743
+ }))
6744
+ }
6745
+ },
6746
+ limit: PAGE_SIZE,
6747
+ offset
6748
+ };
6749
+ const response = await runReport(accessToken, propertyId, request);
6750
+ const pageRows = (response.rows ?? []).map((row) => ({
6751
+ date: row.dimensionValues[0].value,
6752
+ source: row.dimensionValues[1].value,
6753
+ medium: row.dimensionValues[2].value,
6754
+ sessions: parseInt(row.metricValues[0].value, 10) || 0,
6755
+ users: parseInt(row.metricValues[1].value, 10) || 0
6756
+ }));
6757
+ rows.push(...pageRows);
6758
+ const totalRows = response.rowCount ?? 0;
6759
+ offset += pageRows.length;
6760
+ if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
6761
+ }
6762
+ for (const row of rows) {
6763
+ if (row.date.length === 8 && !row.date.includes("-")) {
6764
+ row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
6765
+ }
6766
+ }
6767
+ ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: rows.length });
6768
+ return rows;
6769
+ }
6770
+
6771
+ // ../api-routes/src/google.ts
6772
+ function signState(payload, secret) {
6773
+ return crypto14.createHmac("sha256", secret).update(payload).digest("hex");
6774
+ }
6775
+ function buildSignedState(data, secret) {
6776
+ const payload = JSON.stringify(data);
6777
+ const sig = signState(payload, secret);
6778
+ return Buffer.from(JSON.stringify({ payload, sig })).toString("base64url");
6779
+ }
6780
+ function verifySignedState(encoded, secret) {
6781
+ try {
6782
+ const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
6783
+ const expected = signState(payload, secret);
6784
+ if (!crypto14.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
6785
+ return JSON.parse(payload);
6786
+ } catch {
6787
+ return null;
6788
+ }
6789
+ }
6790
+ async function getValidToken(store, domain, connectionType, clientId, clientSecret) {
6791
+ const conn = store.getConnection(domain, connectionType);
6792
+ if (!conn) {
6793
+ throw notFound("Google connection", connectionType);
6794
+ }
6795
+ if (!conn.accessToken || !conn.refreshToken) {
6796
+ throw validationError("Google connection is incomplete \u2014 please reconnect");
6797
+ }
6798
+ const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
6799
+ const fiveMinutes = 5 * 60 * 1e3;
6800
+ if (Date.now() > expiresAt - fiveMinutes) {
6801
+ const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
6802
+ const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
6803
+ const updated = store.updateConnection(domain, connectionType, {
6804
+ accessToken: tokens.access_token,
6805
+ tokenExpiresAt: newExpiresAt,
6806
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6807
+ });
6808
+ return {
6809
+ accessToken: tokens.access_token,
6810
+ connectionId: `${domain}:${connectionType}`,
6811
+ propertyId: updated?.propertyId ?? conn.propertyId ?? null
6812
+ };
6813
+ }
6814
+ return {
6815
+ accessToken: conn.accessToken,
6816
+ connectionId: `${domain}:${connectionType}`,
6817
+ propertyId: conn.propertyId ?? null
6818
+ };
6819
+ }
6820
+ async function googleRoutes(app, opts) {
6821
+ const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
6822
+ function getAuthConfig() {
6823
+ return opts.getGoogleAuthConfig?.() ?? {};
6824
+ }
6825
+ function requireConnectionStore(reply) {
6826
+ if (opts.googleConnectionStore) return opts.googleConnectionStore;
6827
+ const err = validationError("Google auth storage is not configured for this deployment");
6828
+ reply.status(err.statusCode).send(err.toJSON());
6829
+ return null;
6830
+ }
6831
+ app.get("/projects/:name/google/connections", async (request) => {
6832
+ const project = resolveProject(app.db, request.params.name);
6833
+ const conns = opts.googleConnectionStore?.listConnections(project.canonicalDomain) ?? [];
6834
+ return conns.map((connection) => ({
6835
+ id: `${connection.domain}:${connection.connectionType}`,
6836
+ domain: connection.domain,
6837
+ connectionType: connection.connectionType,
6838
+ propertyId: connection.propertyId ?? null,
6839
+ sitemapUrl: connection.sitemapUrl ?? null,
6840
+ scopes: connection.scopes ?? [],
6841
+ createdAt: connection.createdAt,
6842
+ updatedAt: connection.updatedAt
6843
+ }));
6844
+ });
6845
+ app.post("/projects/:name/google/connect", async (request, reply) => {
6846
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
6847
+ if (!googleClientId || !googleClientSecret) {
6848
+ const err = validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
6849
+ return reply.status(err.statusCode).send(err.toJSON());
6850
+ }
6851
+ const { type, propertyId, publicUrl } = request.body ?? {};
6852
+ if (!type || type !== "gsc" && type !== "ga4") {
6853
+ const err = validationError('type must be "gsc" or "ga4"');
6854
+ return reply.status(err.statusCode).send(err.toJSON());
6495
6855
  }
6496
6856
  const project = resolveProject(app.db, request.params.name);
6497
6857
  let redirectUri;
@@ -6504,7 +6864,7 @@ async function googleRoutes(app, opts) {
6504
6864
  const host = request.headers.host ?? "localhost:4100";
6505
6865
  redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
6506
6866
  }
6507
- const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [];
6867
+ const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [GA4_SCOPE];
6508
6868
  const stateEncoded = buildSignedState(
6509
6869
  { domain: project.canonicalDomain, type, propertyId, redirectUri },
6510
6870
  stateSecret
@@ -6648,7 +7008,7 @@ async function googleRoutes(app, opts) {
6648
7008
  return reply.status(err.statusCode).send(err.toJSON());
6649
7009
  }
6650
7010
  const now = (/* @__PURE__ */ new Date()).toISOString();
6651
- const runId = crypto13.randomUUID();
7011
+ const runId = crypto14.randomUUID();
6652
7012
  app.db.insert(runs).values({
6653
7013
  id: runId,
6654
7014
  projectId: project.id,
@@ -6710,7 +7070,7 @@ async function googleRoutes(app, opts) {
6710
7070
  const mob = ir.mobileUsabilityResult;
6711
7071
  const rich = ir.richResultsResult;
6712
7072
  const now = (/* @__PURE__ */ new Date()).toISOString();
6713
- const id = crypto13.randomUUID();
7073
+ const id = crypto14.randomUUID();
6714
7074
  app.db.insert(gscUrlInspections).values({
6715
7075
  id,
6716
7076
  projectId: project.id,
@@ -6950,7 +7310,7 @@ async function googleRoutes(app, opts) {
6950
7310
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6951
7311
  });
6952
7312
  const now = (/* @__PURE__ */ new Date()).toISOString();
6953
- const runId = crypto13.randomUUID();
7313
+ const runId = crypto14.randomUUID();
6954
7314
  app.db.insert(runs).values({
6955
7315
  id: runId,
6956
7316
  projectId: project.id,
@@ -6979,7 +7339,7 @@ async function googleRoutes(app, opts) {
6979
7339
  return reply.status(err.statusCode).send(err.toJSON());
6980
7340
  }
6981
7341
  const now = (/* @__PURE__ */ new Date()).toISOString();
6982
- const runId = crypto13.randomUUID();
7342
+ const runId = crypto14.randomUUID();
6983
7343
  app.db.insert(runs).values({
6984
7344
  id: runId,
6985
7345
  projectId: project.id,
@@ -7121,7 +7481,7 @@ async function googleRoutes(app, opts) {
7121
7481
  }
7122
7482
 
7123
7483
  // ../api-routes/src/bing.ts
7124
- import crypto14 from "crypto";
7484
+ import crypto15 from "crypto";
7125
7485
  import { eq as eq14, and as and3, desc as desc5 } from "drizzle-orm";
7126
7486
 
7127
7487
  // ../integration-bing/src/constants.ts
@@ -7468,7 +7828,7 @@ async function bingRoutes(app, opts) {
7468
7828
  throw e;
7469
7829
  }
7470
7830
  const now = (/* @__PURE__ */ new Date()).toISOString();
7471
- const id = crypto14.randomUUID();
7831
+ const id = crypto15.randomUUID();
7472
7832
  const httpCode = result.HttpStatus ?? result.HttpCode ?? null;
7473
7833
  let derivedInIndex = null;
7474
7834
  if (result.InIndex != null) {
@@ -7684,492 +8044,243 @@ async function cdpRoutes(app, opts) {
7684
8044
  async (request, reply) => {
7685
8045
  const project = resolveProject(app.db, request.params.name);
7686
8046
  const { runId } = request.params;
7687
- const run = app.db.select().from(runs).where(and4(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
7688
- if (!run) {
7689
- const err = notFound("Run", runId);
7690
- return reply.code(err.statusCode).send(err.toJSON());
7691
- }
7692
- const snapshots = app.db.select({
7693
- id: querySnapshots.id,
7694
- keywordId: querySnapshots.keywordId,
7695
- provider: querySnapshots.provider,
7696
- citationState: querySnapshots.citationState,
7697
- citedDomains: querySnapshots.citedDomains,
7698
- screenshotPath: querySnapshots.screenshotPath,
7699
- rawResponse: querySnapshots.rawResponse
7700
- }).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
7701
- const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, project.id)).all();
7702
- const keywordMap = new Map(keywordRows.map((k) => [k.id, k.keyword]));
7703
- const byKeyword = /* @__PURE__ */ new Map();
7704
- for (const snap of snapshots) {
7705
- const kwName = keywordMap.get(snap.keywordId) ?? snap.keywordId;
7706
- if (!byKeyword.has(snap.keywordId)) {
7707
- byKeyword.set(snap.keywordId, { keyword: kwName, api: null, browser: null });
7708
- }
7709
- const entry = byKeyword.get(snap.keywordId);
7710
- if (snap.provider === "cdp:chatgpt") {
7711
- entry.browser = snap;
7712
- } else if (snap.provider === "openai") {
7713
- entry.api = snap;
7714
- }
7715
- }
7716
- let agreed = 0;
7717
- let apiOnlyCited = 0;
7718
- let browserOnlyCited = 0;
7719
- let disagreed = 0;
7720
- let total = 0;
7721
- const keywordResults = [...byKeyword.values()].map(({ keyword, api, browser }) => {
7722
- total++;
7723
- const apiCited = api?.citationState === "cited";
7724
- const browserCited = browser?.citationState === "cited";
7725
- let agreement;
7726
- if (!api && !browser) {
7727
- agreement = "no-data";
7728
- } else if (!api) {
7729
- agreement = "no-api";
7730
- } else if (!browser) {
7731
- agreement = "no-browser";
7732
- } else if (apiCited && browserCited) {
7733
- agreement = "agree-cited";
7734
- agreed++;
7735
- } else if (!apiCited && !browserCited) {
7736
- agreement = "agree-not-cited";
7737
- agreed++;
7738
- } else if (apiCited && !browserCited) {
7739
- agreement = "api-only-cited";
7740
- apiOnlyCited++;
7741
- disagreed++;
7742
- } else {
7743
- agreement = "browser-only-cited";
7744
- browserOnlyCited++;
7745
- disagreed++;
7746
- }
7747
- const parseGroundingSources2 = (snap) => {
7748
- if (!snap?.rawResponse) return [];
7749
- try {
7750
- const raw = JSON.parse(snap.rawResponse);
7751
- return raw.groundingSources ?? [];
7752
- } catch {
7753
- return [];
7754
- }
7755
- };
7756
- return {
7757
- keyword,
7758
- api: api ? {
7759
- provider: api.provider,
7760
- citationState: api.citationState,
7761
- citedDomains: JSON.parse(api.citedDomains || "[]"),
7762
- groundingSources: parseGroundingSources2(api)
7763
- } : null,
7764
- browser: browser ? {
7765
- provider: browser.provider,
7766
- citationState: browser.citationState,
7767
- citedDomains: JSON.parse(browser.citedDomains || "[]"),
7768
- groundingSources: parseGroundingSources2(browser),
7769
- screenshotUrl: browser.screenshotPath ? `/api/v1/screenshots/${browser.id}` : void 0
7770
- } : null,
7771
- agreement
7772
- };
7773
- });
7774
- return reply.send({
7775
- summary: { total, agreed, apiOnly: apiOnlyCited, browserOnly: browserOnlyCited, disagreed },
7776
- keywords: keywordResults
7777
- });
7778
- }
7779
- );
7780
- }
7781
-
7782
- // ../api-routes/src/ga.ts
7783
- import crypto16 from "crypto";
7784
- import { eq as eq16, desc as desc6, and as and5, sql as sql4 } from "drizzle-orm";
7785
-
7786
- // ../integration-google-analytics/src/ga4-client.ts
7787
- import crypto15 from "crypto";
7788
-
7789
- // ../integration-google-analytics/src/constants.ts
7790
- var GA4_DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
7791
- var GA4_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
7792
- var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
7793
- var GA4_DEFAULT_SYNC_DAYS = 30;
7794
- var GA4_MAX_SYNC_DAYS = 90;
7795
-
7796
- // ../integration-google-analytics/src/types.ts
7797
- var GA4ApiError = class extends Error {
7798
- status;
7799
- constructor(message, status) {
7800
- super(message);
7801
- this.name = "GA4ApiError";
7802
- this.status = status;
7803
- }
7804
- };
7805
-
7806
- // ../integration-google-analytics/src/ga4-client.ts
7807
- function ga4Log(level, action, ctx) {
7808
- const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Client", action, ...ctx };
7809
- const stream = level === "error" ? process.stderr : process.stdout;
7810
- stream.write(JSON.stringify(entry) + "\n");
7811
- }
7812
- function createServiceAccountJwt(clientEmail, privateKey, scope) {
7813
- const now = Math.floor(Date.now() / 1e3);
7814
- const header = { alg: "RS256", typ: "JWT" };
7815
- const payload = {
7816
- iss: clientEmail,
7817
- scope,
7818
- aud: GOOGLE_TOKEN_URL2,
7819
- iat: now,
7820
- exp: now + 3600
7821
- // 1 hour
7822
- };
7823
- const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
7824
- const headerB64 = encode(header);
7825
- const payloadB64 = encode(payload);
7826
- const signingInput = `${headerB64}.${payloadB64}`;
7827
- const sign = crypto15.createSign("RSA-SHA256");
7828
- sign.update(signingInput);
7829
- const signature = sign.sign(privateKey, "base64url");
7830
- return `${signingInput}.${signature}`;
7831
- }
7832
- async function getAccessToken(clientEmail, privateKey) {
7833
- const jwt = createServiceAccountJwt(clientEmail, privateKey, GA4_SCOPE);
7834
- const res = await fetch(GOOGLE_TOKEN_URL2, {
7835
- method: "POST",
7836
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
7837
- body: new URLSearchParams({
7838
- grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
7839
- assertion: jwt
7840
- })
7841
- });
7842
- if (!res.ok) {
7843
- const body = await res.text().catch(() => "");
7844
- ga4Log("error", "token.failed", { httpStatus: res.status, responseBody: body });
7845
- throw new GA4ApiError(`Failed to get access token: ${body}`, res.status);
7846
- }
7847
- const data = await res.json();
7848
- return data.access_token;
7849
- }
7850
- async function runReport(accessToken, propertyId, request) {
7851
- const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:runReport`;
7852
- const res = await fetch(url, {
7853
- method: "POST",
7854
- headers: {
7855
- "Authorization": `Bearer ${accessToken}`,
7856
- "Content-Type": "application/json"
7857
- },
7858
- body: JSON.stringify(request)
7859
- });
7860
- if (res.status === 401 || res.status === 403) {
7861
- const body = await res.text().catch(() => "");
7862
- let detail = "";
7863
- try {
7864
- const parsed = JSON.parse(body);
7865
- if (parsed.error?.status === "SERVICE_DISABLED") {
7866
- detail = " The Google Analytics Data API is not enabled for this GCP project. Enable it at: https://console.developers.google.com/apis/api/analyticsdata.googleapis.com/overview";
7867
- } else if (parsed.error?.message) {
7868
- detail = ` ${parsed.error.message}`;
7869
- }
7870
- } catch {
7871
- if (body.length < 200) detail = ` ${body}`;
7872
- }
7873
- ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status, responseBody: body });
7874
- throw new GA4ApiError(
7875
- `GA4 API authentication failed \u2014 check service account permissions.${detail}`,
7876
- res.status
7877
- );
7878
- }
7879
- if (res.status === 429) {
7880
- ga4Log("error", "report.rate-limited", { propertyId });
7881
- throw new GA4ApiError("GA4 API rate limit exceeded", 429);
7882
- }
7883
- if (!res.ok) {
7884
- const body = await res.text();
7885
- ga4Log("error", "report.error", { propertyId, httpStatus: res.status, responseBody: body });
7886
- throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
7887
- }
7888
- return await res.json();
7889
- }
7890
- async function batchRunReports(accessToken, propertyId, requests) {
7891
- const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:batchRunReports`;
7892
- const res = await fetch(url, {
7893
- method: "POST",
7894
- headers: {
7895
- "Authorization": `Bearer ${accessToken}`,
7896
- "Content-Type": "application/json"
7897
- },
7898
- body: JSON.stringify({ requests })
7899
- });
7900
- if (res.status === 401 || res.status === 403) {
7901
- const body = await res.text().catch(() => "");
7902
- ga4Log("error", "batch-report.auth-failed", { propertyId, httpStatus: res.status });
7903
- throw new GA4ApiError(
7904
- `GA4 API authentication failed \u2014 check service account permissions. ${body}`,
7905
- res.status
7906
- );
7907
- }
7908
- if (res.status === 429) {
7909
- ga4Log("error", "batch-report.rate-limited", { propertyId });
7910
- throw new GA4ApiError("GA4 API rate limit exceeded", 429);
7911
- }
7912
- if (!res.ok) {
7913
- const body = await res.text();
7914
- ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
7915
- throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
7916
- }
7917
- const data = await res.json();
7918
- return data.reports;
7919
- }
7920
- function formatDate(d) {
7921
- return d.toISOString().split("T")[0];
7922
- }
7923
- var AI_REFERRAL_SOURCE_FILTERS = [
7924
- { matchType: "CONTAINS", value: "perplexity" },
7925
- { matchType: "CONTAINS", value: "gemini" },
7926
- { matchType: "CONTAINS", value: "chatgpt" },
7927
- { matchType: "CONTAINS", value: "openai" },
7928
- { matchType: "CONTAINS", value: "claude" },
7929
- { matchType: "CONTAINS", value: "anthropic" },
7930
- { matchType: "CONTAINS", value: "copilot" }
7931
- ];
7932
- async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
7933
- const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
7934
- const endDate = /* @__PURE__ */ new Date();
7935
- const startDate = /* @__PURE__ */ new Date();
7936
- startDate.setDate(startDate.getDate() - syncDays);
7937
- ga4Log("info", "fetch-traffic.start", { propertyId, days: syncDays });
7938
- const PAGE_SIZE = 1e4;
7939
- const rows = [];
7940
- let offset = 0;
7941
- while (true) {
7942
- const request = {
7943
- dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
7944
- dimensions: [
7945
- { name: "date" },
7946
- { name: "landingPagePlusQueryString" }
7947
- ],
7948
- metrics: [
7949
- { name: "sessions" },
7950
- { name: "totalUsers" }
7951
- ],
7952
- limit: PAGE_SIZE,
7953
- offset
7954
- };
7955
- const response = await runReport(accessToken, propertyId, request);
7956
- const pageRows = (response.rows ?? []).map((row) => ({
7957
- date: row.dimensionValues[0].value,
7958
- landingPage: row.dimensionValues[1].value,
7959
- sessions: parseInt(row.metricValues[0].value, 10) || 0,
7960
- organicSessions: 0,
7961
- // populated by organic-only pass below
7962
- users: parseInt(row.metricValues[1].value, 10) || 0
7963
- }));
7964
- rows.push(...pageRows);
7965
- const totalRows = response.rowCount ?? 0;
7966
- offset += pageRows.length;
7967
- if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
7968
- }
7969
- const organicMap = /* @__PURE__ */ new Map();
7970
- let organicOffset = 0;
7971
- while (true) {
7972
- const organicRequest = {
7973
- dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
7974
- dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
7975
- metrics: [{ name: "sessions" }],
7976
- dimensionFilter: {
7977
- filter: {
7978
- fieldName: "sessionDefaultChannelGrouping",
7979
- stringFilter: { matchType: "EXACT", value: "Organic Search" }
7980
- }
7981
- },
7982
- limit: 1e4,
7983
- offset: organicOffset
7984
- };
7985
- const organicResponse = await runReport(accessToken, propertyId, organicRequest);
7986
- for (const row of organicResponse.rows ?? []) {
7987
- const key = `${row.dimensionValues[0].value}::${row.dimensionValues[1].value}`;
7988
- organicMap.set(key, parseInt(row.metricValues[0].value, 10) || 0);
7989
- }
7990
- const total = organicResponse.rowCount ?? 0;
7991
- organicOffset += (organicResponse.rows ?? []).length;
7992
- if ((organicResponse.rows ?? []).length < 1e4 || organicOffset >= total) break;
7993
- }
7994
- for (const row of rows) {
7995
- const key = `${row.date}::${row.landingPage}`;
7996
- row.organicSessions = organicMap.get(key) ?? 0;
7997
- }
7998
- for (const row of rows) {
7999
- if (row.date.length === 8 && !row.date.includes("-")) {
8000
- row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
8001
- }
8002
- }
8003
- ga4Log("info", "fetch-traffic.done", { propertyId, rowCount: rows.length });
8004
- return rows;
8005
- }
8006
- async function verifyConnection(clientEmail, privateKey, propertyId) {
8007
- const accessToken = await getAccessToken(clientEmail, privateKey);
8008
- const endDate = /* @__PURE__ */ new Date();
8009
- const startDate = /* @__PURE__ */ new Date();
8010
- startDate.setDate(startDate.getDate() - 1);
8011
- await runReport(accessToken, propertyId, {
8012
- dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
8013
- dimensions: [{ name: "date" }],
8014
- metrics: [{ name: "sessions" }],
8015
- limit: 1
8016
- });
8017
- return true;
8018
- }
8019
- async function fetchAggregateSummary(accessToken, propertyId, days) {
8020
- const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
8021
- const endDate = /* @__PURE__ */ new Date();
8022
- const startDate = /* @__PURE__ */ new Date();
8023
- startDate.setDate(startDate.getDate() - syncDays);
8024
- ga4Log("info", "fetch-aggregate.start", { propertyId, days: syncDays });
8025
- const dateRange = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
8026
- const batchRes = await batchRunReports(accessToken, propertyId, [
8027
- {
8028
- dateRanges: [dateRange],
8029
- dimensions: [],
8030
- metrics: [{ name: "sessions" }, { name: "totalUsers" }],
8031
- limit: 1
8032
- },
8033
- {
8034
- dateRanges: [dateRange],
8035
- dimensions: [],
8036
- metrics: [{ name: "sessions" }],
8037
- dimensionFilter: {
8038
- filter: {
8039
- fieldName: "sessionDefaultChannelGrouping",
8040
- stringFilter: { matchType: "EXACT", value: "Organic Search" }
8047
+ const run = app.db.select().from(runs).where(and4(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
8048
+ if (!run) {
8049
+ const err = notFound("Run", runId);
8050
+ return reply.code(err.statusCode).send(err.toJSON());
8051
+ }
8052
+ const snapshots = app.db.select({
8053
+ id: querySnapshots.id,
8054
+ keywordId: querySnapshots.keywordId,
8055
+ provider: querySnapshots.provider,
8056
+ citationState: querySnapshots.citationState,
8057
+ citedDomains: querySnapshots.citedDomains,
8058
+ screenshotPath: querySnapshots.screenshotPath,
8059
+ rawResponse: querySnapshots.rawResponse
8060
+ }).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
8061
+ const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, project.id)).all();
8062
+ const keywordMap = new Map(keywordRows.map((k) => [k.id, k.keyword]));
8063
+ const byKeyword = /* @__PURE__ */ new Map();
8064
+ for (const snap of snapshots) {
8065
+ const kwName = keywordMap.get(snap.keywordId) ?? snap.keywordId;
8066
+ if (!byKeyword.has(snap.keywordId)) {
8067
+ byKeyword.set(snap.keywordId, { keyword: kwName, api: null, browser: null });
8041
8068
  }
8042
- },
8043
- limit: 1
8044
- }
8045
- ]);
8046
- const totalRow = batchRes[0]?.rows?.[0];
8047
- const organicRow = batchRes[1]?.rows?.[0];
8048
- const summary = {
8049
- periodStart: formatDate(startDate),
8050
- periodEnd: formatDate(endDate),
8051
- totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
8052
- totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
8053
- totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0
8054
- };
8055
- ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
8056
- return summary;
8057
- }
8058
- async function fetchAiReferrals(accessToken, propertyId, days) {
8059
- const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
8060
- const endDate = /* @__PURE__ */ new Date();
8061
- const startDate = /* @__PURE__ */ new Date();
8062
- startDate.setDate(startDate.getDate() - syncDays);
8063
- ga4Log("info", "fetch-ai-referrals.start", { propertyId, days: syncDays });
8064
- const PAGE_SIZE = 1e3;
8065
- const rows = [];
8066
- let offset = 0;
8067
- while (true) {
8068
- const request = {
8069
- dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
8070
- dimensions: [
8071
- { name: "date" },
8072
- { name: "sessionSource" },
8073
- { name: "sessionMedium" }
8074
- ],
8075
- metrics: [
8076
- { name: "sessions" },
8077
- { name: "totalUsers" }
8078
- ],
8079
- dimensionFilter: {
8080
- orGroup: {
8081
- expressions: AI_REFERRAL_SOURCE_FILTERS.map(({ matchType, value }) => ({
8082
- filter: {
8083
- fieldName: "sessionSource",
8084
- stringFilter: { matchType, value }
8085
- }
8086
- }))
8069
+ const entry = byKeyword.get(snap.keywordId);
8070
+ if (snap.provider === "cdp:chatgpt") {
8071
+ entry.browser = snap;
8072
+ } else if (snap.provider === "openai") {
8073
+ entry.api = snap;
8087
8074
  }
8088
- },
8089
- limit: PAGE_SIZE,
8090
- offset
8091
- };
8092
- const response = await runReport(accessToken, propertyId, request);
8093
- const pageRows = (response.rows ?? []).map((row) => ({
8094
- date: row.dimensionValues[0].value,
8095
- source: row.dimensionValues[1].value,
8096
- medium: row.dimensionValues[2].value,
8097
- sessions: parseInt(row.metricValues[0].value, 10) || 0,
8098
- users: parseInt(row.metricValues[1].value, 10) || 0
8099
- }));
8100
- rows.push(...pageRows);
8101
- const totalRows = response.rowCount ?? 0;
8102
- offset += pageRows.length;
8103
- if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
8104
- }
8105
- for (const row of rows) {
8106
- if (row.date.length === 8 && !row.date.includes("-")) {
8107
- row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
8075
+ }
8076
+ let agreed = 0;
8077
+ let apiOnlyCited = 0;
8078
+ let browserOnlyCited = 0;
8079
+ let disagreed = 0;
8080
+ let total = 0;
8081
+ const keywordResults = [...byKeyword.values()].map(({ keyword, api, browser }) => {
8082
+ total++;
8083
+ const apiCited = api?.citationState === "cited";
8084
+ const browserCited = browser?.citationState === "cited";
8085
+ let agreement;
8086
+ if (!api && !browser) {
8087
+ agreement = "no-data";
8088
+ } else if (!api) {
8089
+ agreement = "no-api";
8090
+ } else if (!browser) {
8091
+ agreement = "no-browser";
8092
+ } else if (apiCited && browserCited) {
8093
+ agreement = "agree-cited";
8094
+ agreed++;
8095
+ } else if (!apiCited && !browserCited) {
8096
+ agreement = "agree-not-cited";
8097
+ agreed++;
8098
+ } else if (apiCited && !browserCited) {
8099
+ agreement = "api-only-cited";
8100
+ apiOnlyCited++;
8101
+ disagreed++;
8102
+ } else {
8103
+ agreement = "browser-only-cited";
8104
+ browserOnlyCited++;
8105
+ disagreed++;
8106
+ }
8107
+ const parseGroundingSources2 = (snap) => {
8108
+ if (!snap?.rawResponse) return [];
8109
+ try {
8110
+ const raw = JSON.parse(snap.rawResponse);
8111
+ return raw.groundingSources ?? [];
8112
+ } catch {
8113
+ return [];
8114
+ }
8115
+ };
8116
+ return {
8117
+ keyword,
8118
+ api: api ? {
8119
+ provider: api.provider,
8120
+ citationState: api.citationState,
8121
+ citedDomains: JSON.parse(api.citedDomains || "[]"),
8122
+ groundingSources: parseGroundingSources2(api)
8123
+ } : null,
8124
+ browser: browser ? {
8125
+ provider: browser.provider,
8126
+ citationState: browser.citationState,
8127
+ citedDomains: JSON.parse(browser.citedDomains || "[]"),
8128
+ groundingSources: parseGroundingSources2(browser),
8129
+ screenshotUrl: browser.screenshotPath ? `/api/v1/screenshots/${browser.id}` : void 0
8130
+ } : null,
8131
+ agreement
8132
+ };
8133
+ });
8134
+ return reply.send({
8135
+ summary: { total, agreed, apiOnly: apiOnlyCited, browserOnly: browserOnlyCited, disagreed },
8136
+ keywords: keywordResults
8137
+ });
8108
8138
  }
8109
- }
8110
- ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: rows.length });
8111
- return rows;
8139
+ );
8112
8140
  }
8113
8141
 
8114
8142
  // ../api-routes/src/ga.ts
8143
+ import crypto16 from "crypto";
8144
+ import { eq as eq16, desc as desc6, and as and5, sql as sql4 } from "drizzle-orm";
8115
8145
  function gaLog(level, action, ctx) {
8116
8146
  const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Routes", action, ...ctx };
8117
8147
  const stream = level === "error" ? process.stderr : process.stdout;
8118
8148
  stream.write(JSON.stringify(entry) + "\n");
8119
8149
  }
8120
- async function ga4Routes(app, opts) {
8121
- function requireCredentialStore(reply) {
8122
- if (opts.ga4CredentialStore) return opts.ga4CredentialStore;
8123
- const err = validationError("GA4 credential storage is not configured for this deployment");
8124
- reply.status(err.statusCode).send(err.toJSON());
8125
- return null;
8150
+ async function refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, oauthConn) {
8151
+ const expiresAt = oauthConn.tokenExpiresAt ? new Date(oauthConn.tokenExpiresAt).getTime() : 0;
8152
+ const fiveMinutes = 5 * 60 * 1e3;
8153
+ if (Date.now() > expiresAt - fiveMinutes) {
8154
+ if (!authConfig.clientId || !authConfig.clientSecret) {
8155
+ throw validationError("Google OAuth client credentials are not configured \u2014 cannot refresh GA4 token.");
8156
+ }
8157
+ const tokens = await refreshAccessToken(authConfig.clientId, authConfig.clientSecret, oauthConn.refreshToken);
8158
+ const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
8159
+ googleStore.updateConnection(canonicalDomain, "ga4", {
8160
+ accessToken: tokens.access_token,
8161
+ tokenExpiresAt: newExpiresAt,
8162
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
8163
+ });
8164
+ return tokens.access_token;
8126
8165
  }
8127
- app.post("/projects/:name/ga/connect", async (request, reply) => {
8128
- const store = requireCredentialStore(reply);
8129
- if (!store) return;
8166
+ return oauthConn.accessToken;
8167
+ }
8168
+ async function resolveGa4AccessToken(opts, projectName, canonicalDomain) {
8169
+ const saConn = opts.ga4CredentialStore?.getConnection(projectName);
8170
+ if (saConn?.clientEmail && saConn?.privateKey && saConn?.propertyId) {
8171
+ const token = await getAccessToken(saConn.clientEmail, saConn.privateKey);
8172
+ return { accessToken: token, propertyId: saConn.propertyId };
8173
+ }
8174
+ const googleStore = opts.googleConnectionStore;
8175
+ const authConfig = opts.getGoogleAuthConfig?.();
8176
+ if (!googleStore || !authConfig) {
8177
+ throw validationError(
8178
+ 'No GA4 credentials found. Run "canonry ga connect <project> --key-file <path>" or "canonry google connect <project> --type ga4" to authenticate.'
8179
+ );
8180
+ }
8181
+ const oauthConn = googleStore.getConnection(canonicalDomain, "ga4");
8182
+ if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
8183
+ throw validationError(
8184
+ 'No GA4 credentials found. Run "canonry ga connect <project> --key-file <path>" or "canonry google connect <project> --type ga4" to authenticate.'
8185
+ );
8186
+ }
8187
+ if (!oauthConn.propertyId) {
8188
+ throw validationError(
8189
+ 'GA4 property ID not set. Run "canonry ga set-property <project> <propertyId>" to configure it.'
8190
+ );
8191
+ }
8192
+ const accessToken = await refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, {
8193
+ accessToken: oauthConn.accessToken,
8194
+ refreshToken: oauthConn.refreshToken,
8195
+ tokenExpiresAt: oauthConn.tokenExpiresAt
8196
+ });
8197
+ return { accessToken, propertyId: oauthConn.propertyId };
8198
+ }
8199
+ function requireGa4Connection(opts, projectName, canonicalDomain) {
8200
+ const saConn = opts.ga4CredentialStore?.getConnection(projectName);
8201
+ const oauthConn = opts.googleConnectionStore?.getConnection(canonicalDomain, "ga4");
8202
+ if (!saConn && !(oauthConn?.accessToken && oauthConn?.propertyId)) {
8203
+ throw validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
8204
+ }
8205
+ }
8206
+ async function ga4Routes(app, opts) {
8207
+ app.post("/projects/:name/ga/connect", async (request, _reply) => {
8130
8208
  const project = resolveProject(app.db, request.params.name);
8131
8209
  const { propertyId, keyJson } = request.body ?? {};
8132
8210
  if (!propertyId || typeof propertyId !== "string") {
8133
- const err = validationError("propertyId is required");
8134
- return reply.status(err.statusCode).send(err.toJSON());
8211
+ throw validationError("propertyId is required");
8135
8212
  }
8136
- let clientEmail;
8137
- let privateKey;
8138
8213
  if (keyJson && typeof keyJson === "string") {
8214
+ if (!opts.ga4CredentialStore) {
8215
+ throw validationError("GA4 credential storage is not configured for this deployment");
8216
+ }
8217
+ let parsed;
8139
8218
  try {
8140
- const parsed = JSON.parse(keyJson);
8141
- if (!parsed.client_email || !parsed.private_key) {
8142
- const err = validationError("Service account JSON must contain client_email and private_key");
8143
- return reply.status(err.statusCode).send(err.toJSON());
8144
- }
8145
- clientEmail = parsed.client_email;
8146
- privateKey = parsed.private_key;
8219
+ parsed = JSON.parse(keyJson);
8147
8220
  } catch {
8148
- const err = validationError("Invalid JSON in keyJson");
8149
- return reply.status(err.statusCode).send(err.toJSON());
8221
+ throw validationError("Invalid JSON in keyJson");
8150
8222
  }
8151
- } else {
8152
- const err = validationError("keyJson is required");
8153
- return reply.status(err.statusCode).send(err.toJSON());
8223
+ if (!parsed.client_email || !parsed.private_key) {
8224
+ throw validationError("Service account JSON must contain client_email and private_key");
8225
+ }
8226
+ const clientEmail = parsed.client_email;
8227
+ const privateKey = parsed.private_key;
8228
+ try {
8229
+ await verifyConnection(clientEmail, privateKey, propertyId);
8230
+ gaLog("info", "connect.verified.service-account", { projectId: project.id, propertyId });
8231
+ } catch (e) {
8232
+ const msg = e instanceof Error ? e.message : String(e);
8233
+ gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
8234
+ throw validationError(`Failed to verify GA4 credentials: ${msg}`);
8235
+ }
8236
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8237
+ const existing = opts.ga4CredentialStore.getConnection(project.name);
8238
+ opts.ga4CredentialStore.upsertConnection({
8239
+ projectName: project.name,
8240
+ propertyId,
8241
+ clientEmail,
8242
+ privateKey,
8243
+ createdAt: existing?.createdAt ?? now,
8244
+ updatedAt: now
8245
+ });
8246
+ writeAuditLog(app.db, {
8247
+ projectId: project.id,
8248
+ actor: "api",
8249
+ action: "ga4.connected",
8250
+ entityType: "ga_connection",
8251
+ entityId: propertyId
8252
+ });
8253
+ return { connected: true, propertyId, authMethod: "service-account", clientEmail };
8254
+ }
8255
+ const googleStore = opts.googleConnectionStore;
8256
+ const authConfig = opts.getGoogleAuthConfig?.();
8257
+ if (!googleStore || !authConfig) {
8258
+ throw validationError(
8259
+ 'No service account key provided and OAuth storage is not configured. Pass --key-file or run "canonry google connect <project> --type ga4" first.'
8260
+ );
8261
+ }
8262
+ const oauthConn = googleStore.getConnection(project.canonicalDomain, "ga4");
8263
+ if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
8264
+ throw validationError(
8265
+ 'No GA4 OAuth token found. Run "canonry google connect <project> --type ga4" first, or pass --key-file to use a service account.'
8266
+ );
8154
8267
  }
8268
+ const accessToken = await refreshOAuthTokenIfNeeded(googleStore, authConfig, project.canonicalDomain, {
8269
+ accessToken: oauthConn.accessToken,
8270
+ refreshToken: oauthConn.refreshToken,
8271
+ tokenExpiresAt: oauthConn.tokenExpiresAt
8272
+ });
8155
8273
  try {
8156
- await verifyConnection(clientEmail, privateKey, propertyId);
8157
- gaLog("info", "connect.verified", { projectId: project.id, propertyId });
8274
+ await verifyConnectionWithToken(accessToken, propertyId);
8275
+ gaLog("info", "connect.verified.oauth", { projectId: project.id, propertyId });
8158
8276
  } catch (e) {
8159
8277
  const msg = e instanceof Error ? e.message : String(e);
8160
- gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
8161
- const err = validationError(`Failed to verify GA4 credentials: ${msg}`);
8162
- return reply.status(err.statusCode).send(err.toJSON());
8278
+ gaLog("error", "connect.verify-failed.oauth", { projectId: project.id, propertyId, error: msg });
8279
+ throw validationError(`Failed to verify GA4 access: ${msg}`);
8163
8280
  }
8164
- const now = (/* @__PURE__ */ new Date()).toISOString();
8165
- const existing = store.getConnection(project.name);
8166
- store.upsertConnection({
8167
- projectName: project.name,
8281
+ googleStore.updateConnection(project.canonicalDomain, "ga4", {
8168
8282
  propertyId,
8169
- clientEmail,
8170
- privateKey,
8171
- createdAt: existing?.createdAt ?? now,
8172
- updatedAt: now
8283
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
8173
8284
  });
8174
8285
  writeAuditLog(app.db, {
8175
8286
  projectId: project.id,
@@ -8178,80 +8289,62 @@ async function ga4Routes(app, opts) {
8178
8289
  entityType: "ga_connection",
8179
8290
  entityId: propertyId
8180
8291
  });
8181
- return {
8182
- connected: true,
8183
- propertyId,
8184
- clientEmail
8185
- };
8292
+ return { connected: true, propertyId, authMethod: "oauth" };
8186
8293
  });
8187
8294
  app.delete("/projects/:name/ga/disconnect", async (request, reply) => {
8188
- const store = requireCredentialStore(reply);
8189
- if (!store) return;
8190
8295
  const project = resolveProject(app.db, request.params.name);
8191
- const conn = store.getConnection(project.name);
8192
- if (!conn) {
8193
- const err = notFound("GA4 connection", project.name);
8194
- return reply.status(err.statusCode).send(err.toJSON());
8296
+ const saConn = opts.ga4CredentialStore?.getConnection(project.name);
8297
+ const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
8298
+ if (!saConn && !oauthConn) {
8299
+ throw notFound("GA4 connection", project.name);
8195
8300
  }
8196
8301
  app.db.delete(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).run();
8197
8302
  app.db.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
8198
8303
  app.db.delete(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).run();
8199
- store.deleteConnection(project.name);
8304
+ const propertyId = saConn?.propertyId ?? oauthConn?.propertyId ?? null;
8305
+ opts.ga4CredentialStore?.deleteConnection(project.name);
8306
+ opts.googleConnectionStore?.deleteConnection(project.canonicalDomain, "ga4");
8200
8307
  writeAuditLog(app.db, {
8201
8308
  projectId: project.id,
8202
8309
  actor: "api",
8203
8310
  action: "ga4.disconnected",
8204
8311
  entityType: "ga_connection",
8205
- entityId: conn.propertyId
8312
+ entityId: propertyId
8206
8313
  });
8207
8314
  return reply.status(204).send();
8208
8315
  });
8209
- app.get("/projects/:name/ga/status", async (request, reply) => {
8210
- const store = requireCredentialStore(reply);
8211
- if (!store) return;
8316
+ app.get("/projects/:name/ga/status", async (request, _reply) => {
8212
8317
  const project = resolveProject(app.db, request.params.name);
8213
- const conn = store.getConnection(project.name);
8214
- if (!conn) {
8215
- return { connected: false, propertyId: null, clientEmail: null, lastSyncedAt: null };
8318
+ const saConn = opts.ga4CredentialStore?.getConnection(project.name);
8319
+ const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
8320
+ const connected = !!(saConn || oauthConn?.accessToken && oauthConn?.propertyId);
8321
+ if (!connected) {
8322
+ return { connected: false, propertyId: null, clientEmail: null, authMethod: null, lastSyncedAt: null };
8216
8323
  }
8217
8324
  const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
8218
8325
  return {
8219
8326
  connected: true,
8220
- propertyId: conn.propertyId,
8221
- clientEmail: conn.clientEmail,
8327
+ propertyId: saConn?.propertyId ?? oauthConn?.propertyId ?? null,
8328
+ clientEmail: saConn?.clientEmail ?? null,
8329
+ authMethod: saConn ? "service-account" : "oauth",
8222
8330
  lastSyncedAt: latestSync?.syncedAt ?? null,
8223
- createdAt: conn.createdAt,
8224
- updatedAt: conn.updatedAt
8331
+ createdAt: saConn?.createdAt ?? oauthConn?.createdAt ?? null,
8332
+ updatedAt: saConn?.updatedAt ?? oauthConn?.updatedAt ?? null
8225
8333
  };
8226
8334
  });
8227
- app.post("/projects/:name/ga/sync", async (request, reply) => {
8228
- const store = requireCredentialStore(reply);
8229
- if (!store) return;
8335
+ app.post("/projects/:name/ga/sync", async (request, _reply) => {
8230
8336
  const project = resolveProject(app.db, request.params.name);
8231
- const conn = store.getConnection(project.name);
8232
- if (!conn) {
8233
- const err = validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
8234
- return reply.status(err.statusCode).send(err.toJSON());
8235
- }
8236
8337
  const days = request.body?.days ?? 30;
8237
- let accessToken;
8238
- try {
8239
- accessToken = await getAccessToken(conn.clientEmail, conn.privateKey);
8240
- } catch (e) {
8241
- const msg = e instanceof Error ? e.message : String(e);
8242
- gaLog("error", "sync.auth-failed", { projectId: project.id, error: msg });
8243
- const err = validationError(`GA4 authentication failed: ${msg}`);
8244
- return reply.status(err.statusCode).send(err.toJSON());
8245
- }
8338
+ const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
8246
8339
  let rows;
8247
8340
  let summary;
8248
8341
  let aiReferrals;
8249
8342
  try {
8250
8343
  ;
8251
8344
  [rows, summary, aiReferrals] = await Promise.all([
8252
- fetchTrafficByLandingPage(accessToken, conn.propertyId, days),
8253
- fetchAggregateSummary(accessToken, conn.propertyId, days),
8254
- fetchAiReferrals(accessToken, conn.propertyId, days)
8345
+ fetchTrafficByLandingPage(accessToken, propertyId, days),
8346
+ fetchAggregateSummary(accessToken, propertyId, days),
8347
+ fetchAiReferrals(accessToken, propertyId, days)
8255
8348
  ]);
8256
8349
  } catch (e) {
8257
8350
  const msg = e instanceof Error ? e.message : String(e);
@@ -8329,15 +8422,9 @@ async function ga4Routes(app, opts) {
8329
8422
  syncedAt: now
8330
8423
  };
8331
8424
  });
8332
- app.get("/projects/:name/ga/traffic", async (request, reply) => {
8333
- const store = requireCredentialStore(reply);
8334
- if (!store) return;
8425
+ app.get("/projects/:name/ga/traffic", async (request, _reply) => {
8335
8426
  const project = resolveProject(app.db, request.params.name);
8336
- const conn = store.getConnection(project.name);
8337
- if (!conn) {
8338
- const err = validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
8339
- return reply.status(err.statusCode).send(err.toJSON());
8340
- }
8427
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8341
8428
  const limit = Math.max(1, Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 500));
8342
8429
  const summary = app.db.select({
8343
8430
  totalSessions: gaTrafficSummaries.totalSessions,
@@ -8376,15 +8463,9 @@ async function ga4Routes(app, opts) {
8376
8463
  lastSyncedAt: latestSync?.syncedAt ?? null
8377
8464
  };
8378
8465
  });
8379
- app.get("/projects/:name/ga/coverage", async (request, reply) => {
8380
- const store = requireCredentialStore(reply);
8381
- if (!store) return;
8466
+ app.get("/projects/:name/ga/coverage", async (request, _reply) => {
8382
8467
  const project = resolveProject(app.db, request.params.name);
8383
- const conn = store.getConnection(project.name);
8384
- if (!conn) {
8385
- const err = validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
8386
- return reply.status(err.statusCode).send(err.toJSON());
8387
- }
8468
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8388
8469
  const trafficPages = app.db.select({
8389
8470
  landingPage: gaTrafficSnapshots.landingPage,
8390
8471
  sessions: sql4`SUM(${gaTrafficSnapshots.sessions})`,
@@ -10141,7 +10222,9 @@ async function apiRoutes(app, opts) {
10141
10222
  onCdpConfigure: opts.onCdpConfigure
10142
10223
  });
10143
10224
  await api.register(ga4Routes, {
10144
- ga4CredentialStore: opts.ga4CredentialStore
10225
+ ga4CredentialStore: opts.ga4CredentialStore,
10226
+ googleConnectionStore: opts.googleConnectionStore,
10227
+ getGoogleAuthConfig: opts.getGoogleAuthConfig
10145
10228
  });
10146
10229
  }, { prefix: opts.routePrefix ?? "/api/v1" });
10147
10230
  }
@@ -11280,7 +11363,7 @@ var CDPConnectionManager = class {
11280
11363
  await sleep(1500);
11281
11364
  } catch (err) {
11282
11365
  throw new CDPProviderError(
11283
- "CDP_CONNECTION_REFUSED",
11366
+ "CDP_TARGET_SELECTOR_FAILED",
11284
11367
  `Failed to navigate to ${target.newConversationUrl}: ${err instanceof Error ? err.message : String(err)}`
11285
11368
  );
11286
11369
  }
@@ -11769,10 +11852,11 @@ async function healthcheck5(config) {
11769
11852
  async function executeTrackedQuery5(input) {
11770
11853
  const model = input.config.model ?? DEFAULT_MODEL5;
11771
11854
  const client = new OpenAI3({ apiKey: input.config.apiKey, baseURL: BASE_URL });
11855
+ const prompt = buildPrompt4(input.keyword, input.location);
11772
11856
  const response = await client.chat.completions.create({
11773
11857
  model,
11774
11858
  messages: [
11775
- { role: "user", content: input.keyword }
11859
+ { role: "user", content: prompt }
11776
11860
  ]
11777
11861
  });
11778
11862
  const rawResponse = responseToRecord4(response);
@@ -11800,6 +11884,12 @@ function normalizeResult6(raw) {
11800
11884
  searchQueries: raw.searchQueries
11801
11885
  };
11802
11886
  }
11887
+ function buildPrompt4(keyword, location) {
11888
+ if (location) {
11889
+ return `${keyword} (searching from ${location.city}, ${location.region}, ${location.country})`;
11890
+ }
11891
+ return keyword;
11892
+ }
11803
11893
  function extractCitations(rawResponse) {
11804
11894
  if (Array.isArray(rawResponse.citations)) {
11805
11895
  return rawResponse.citations.filter((c) => typeof c === "string");
@@ -12678,9 +12768,6 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
12678
12768
  function cleanCandidateName(candidate) {
12679
12769
  return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
12680
12770
  }
12681
- function brandKeyFromText(value) {
12682
- return value.toLowerCase().replace(/[^a-z0-9]/g, "");
12683
- }
12684
12771
  function collectBrandKeysFromDomain(domain) {
12685
12772
  const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
12686
12773
  const labels = hostname.split(".").filter(Boolean);