@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.
- package/assets/assets/index-BPuIj1DV.js +246 -0
- package/assets/assets/index-CxMuEW6I.css +1 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-5Q6LISDM.js → chunk-4SRBJCNX.js} +710 -623
- package/dist/cli.js +23 -20
- package/dist/index.js +1 -1
- package/package.json +6 -6
- package/assets/assets/index-BuSiyI7z.js +0 -246
- package/assets/assets/index-r6biprHB.css +0 -1
|
@@ -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
|
|
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))
|
|
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
|
-
|
|
1110
|
+
matchedTerms.push(displayName);
|
|
1107
1111
|
}
|
|
1108
1112
|
const tokens = collectDistinctiveTokens(displayName, domains);
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
1117
|
+
tokenMatches++;
|
|
1118
|
+
matchedTokens.push(token);
|
|
1114
1119
|
}
|
|
1115
1120
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1938
|
-
|
|
1939
|
-
|
|
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(
|
|
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
|
|
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
|
-
// ../
|
|
6412
|
-
|
|
6413
|
-
|
|
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
|
|
6416
|
-
const
|
|
6417
|
-
const
|
|
6418
|
-
|
|
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
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
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
|
|
6431
|
-
const
|
|
6432
|
-
|
|
6433
|
-
|
|
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 (
|
|
6436
|
-
|
|
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
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
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
|
|
6461
|
-
const
|
|
6462
|
-
|
|
6463
|
-
|
|
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
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
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
|
-
|
|
6472
|
-
const
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
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
|
-
|
|
6486
|
-
|
|
6487
|
-
if (
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
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
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
|
|
8102
|
-
|
|
8103
|
-
|
|
8104
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
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
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8124
|
-
|
|
8125
|
-
|
|
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
|
-
|
|
8128
|
-
|
|
8129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8149
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
8221
|
+
throw validationError("Invalid JSON in keyJson");
|
|
8150
8222
|
}
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
8165
|
-
const existing = store.getConnection(project.name);
|
|
8166
|
-
store.upsertConnection({
|
|
8167
|
-
projectName: project.name,
|
|
8281
|
+
googleStore.updateConnection(project.canonicalDomain, "ga4", {
|
|
8168
8282
|
propertyId,
|
|
8169
|
-
|
|
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
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
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
|
-
|
|
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:
|
|
8312
|
+
entityId: propertyId
|
|
8206
8313
|
});
|
|
8207
8314
|
return reply.status(204).send();
|
|
8208
8315
|
});
|
|
8209
|
-
app.get("/projects/:name/ga/status", async (request,
|
|
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
|
|
8214
|
-
|
|
8215
|
-
|
|
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:
|
|
8221
|
-
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:
|
|
8224
|
-
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,
|
|
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
|
-
|
|
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,
|
|
8253
|
-
fetchAggregateSummary(accessToken,
|
|
8254
|
-
fetchAiReferrals(accessToken,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
"
|
|
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:
|
|
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);
|