@ainyc/canonry 1.33.0 → 1.35.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-B5Cg2H9M.css → index-B9SBdBOm.css} +1 -1
- package/assets/assets/index-DyipkdOb.js +281 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-WRNSBFNQ.js → chunk-ETP5IOHC.js} +712 -609
- package/dist/cli.js +56 -19
- package/dist/index.js +1 -1
- package/package.json +5 -5
- package/assets/assets/index-BiIPqZJb.js +0 -246
|
@@ -1074,6 +1074,13 @@ var ga4TrafficSummaryDtoSchema = z11.object({
|
|
|
1074
1074
|
aiReferrals: z11.array(ga4AiReferralDtoSchema),
|
|
1075
1075
|
lastSyncedAt: z11.string().nullable()
|
|
1076
1076
|
});
|
|
1077
|
+
var ga4AiReferralHistoryEntrySchema = z11.object({
|
|
1078
|
+
date: z11.string(),
|
|
1079
|
+
source: z11.string(),
|
|
1080
|
+
medium: z11.string(),
|
|
1081
|
+
sessions: z11.number(),
|
|
1082
|
+
users: z11.number()
|
|
1083
|
+
});
|
|
1077
1084
|
|
|
1078
1085
|
// ../contracts/src/answer-visibility.ts
|
|
1079
1086
|
var GENERIC_TOKENS = /* @__PURE__ */ new Set([
|
|
@@ -1123,7 +1130,12 @@ function extractAnswerMentions(answerText, displayName, domains) {
|
|
|
1123
1130
|
matchedTerms.push(...matchedTokens);
|
|
1124
1131
|
}
|
|
1125
1132
|
const unique = [...new Set(matchedTerms)];
|
|
1126
|
-
|
|
1133
|
+
const domainMatches3 = unique.filter((t) => t.includes("."));
|
|
1134
|
+
const dedupedFinal = unique.filter((term) => {
|
|
1135
|
+
if (term.includes(".")) return true;
|
|
1136
|
+
return !domainMatches3.some((d) => d.toLowerCase().startsWith(term.toLowerCase() + "."));
|
|
1137
|
+
});
|
|
1138
|
+
return { mentioned: dedupedFinal.length > 0, matchedTerms: dedupedFinal };
|
|
1127
1139
|
}
|
|
1128
1140
|
function determineAnswerMentioned(answerText, displayName, domains) {
|
|
1129
1141
|
return extractAnswerMentions(answerText, displayName, domains).mentioned;
|
|
@@ -1131,6 +1143,9 @@ function determineAnswerMentioned(answerText, displayName, domains) {
|
|
|
1131
1143
|
function visibilityStateFromAnswerMentioned(answerMentioned) {
|
|
1132
1144
|
return answerMentioned ? "visible" : "not-visible";
|
|
1133
1145
|
}
|
|
1146
|
+
function brandKeyFromText(value) {
|
|
1147
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1148
|
+
}
|
|
1134
1149
|
function domainMentioned(lowerAnswer, normalizedDomain) {
|
|
1135
1150
|
const escapedDomain = escapeRegExp(normalizedDomain.toLowerCase());
|
|
1136
1151
|
const patterns = [
|
|
@@ -5746,6 +5761,18 @@ var routeCatalog = [
|
|
|
5746
5761
|
404: { description: "Project not found." }
|
|
5747
5762
|
}
|
|
5748
5763
|
},
|
|
5764
|
+
{
|
|
5765
|
+
method: "get",
|
|
5766
|
+
path: "/api/v1/projects/{name}/ga/ai-referral-history",
|
|
5767
|
+
summary: "Get AI referral sessions per day grouped by source",
|
|
5768
|
+
tags: ["ga4"],
|
|
5769
|
+
parameters: [nameParameter],
|
|
5770
|
+
responses: {
|
|
5771
|
+
200: { description: "AI referral history returned." },
|
|
5772
|
+
400: { description: "GA4 is not connected." },
|
|
5773
|
+
404: { description: "Project not found." }
|
|
5774
|
+
}
|
|
5775
|
+
},
|
|
5749
5776
|
{
|
|
5750
5777
|
method: "get",
|
|
5751
5778
|
path: "/api/v1/projects/{name}/ga/coverage",
|
|
@@ -6241,7 +6268,7 @@ function formatNotification(row) {
|
|
|
6241
6268
|
}
|
|
6242
6269
|
|
|
6243
6270
|
// ../api-routes/src/google.ts
|
|
6244
|
-
import
|
|
6271
|
+
import crypto14 from "crypto";
|
|
6245
6272
|
import { eq as eq13, and as and2, desc as desc4, sql as sql3 } from "drizzle-orm";
|
|
6246
6273
|
|
|
6247
6274
|
// ../integration-google/src/constants.ts
|
|
@@ -6255,6 +6282,7 @@ var GSC_MAX_ROWS_PER_REQUEST = 25e3;
|
|
|
6255
6282
|
var GSC_DATA_LAG_DAYS = 3;
|
|
6256
6283
|
var INDEXING_API_BASE = "https://indexing.googleapis.com/v3";
|
|
6257
6284
|
var INDEXING_API_DAILY_LIMIT = 200;
|
|
6285
|
+
var GOOGLE_REQUEST_TIMEOUT_MS = 3e4;
|
|
6258
6286
|
|
|
6259
6287
|
// ../integration-google/src/types.ts
|
|
6260
6288
|
var GoogleAuthError = class extends Error {
|
|
@@ -6295,7 +6323,8 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
|
|
|
6295
6323
|
code,
|
|
6296
6324
|
redirect_uri: redirectUri,
|
|
6297
6325
|
grant_type: "authorization_code"
|
|
6298
|
-
})
|
|
6326
|
+
}),
|
|
6327
|
+
signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
|
|
6299
6328
|
});
|
|
6300
6329
|
if (!res.ok) {
|
|
6301
6330
|
const body = await res.text();
|
|
@@ -6312,7 +6341,8 @@ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
|
|
|
6312
6341
|
client_secret: clientSecret,
|
|
6313
6342
|
refresh_token: currentRefreshToken,
|
|
6314
6343
|
grant_type: "refresh_token"
|
|
6315
|
-
})
|
|
6344
|
+
}),
|
|
6345
|
+
signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
|
|
6316
6346
|
});
|
|
6317
6347
|
if (!res.ok) {
|
|
6318
6348
|
const body = await res.text();
|
|
@@ -6336,7 +6366,8 @@ async function gscFetch(accessToken, url, opts) {
|
|
|
6336
6366
|
const res = await fetch(url, {
|
|
6337
6367
|
method,
|
|
6338
6368
|
headers,
|
|
6339
|
-
body: opts?.body != null ? JSON.stringify(opts.body) : void 0
|
|
6369
|
+
body: opts?.body != null ? JSON.stringify(opts.body) : void 0,
|
|
6370
|
+
signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
|
|
6340
6371
|
});
|
|
6341
6372
|
if (res.status === 401) {
|
|
6342
6373
|
const body = await res.text().catch(() => "");
|
|
@@ -6429,89 +6460,424 @@ async function inspectUrl(accessToken, inspectionUrl, siteUrl) {
|
|
|
6429
6460
|
);
|
|
6430
6461
|
}
|
|
6431
6462
|
|
|
6432
|
-
// ../
|
|
6433
|
-
|
|
6434
|
-
|
|
6463
|
+
// ../integration-google-analytics/src/ga4-client.ts
|
|
6464
|
+
import crypto13 from "crypto";
|
|
6465
|
+
|
|
6466
|
+
// ../integration-google-analytics/src/constants.ts
|
|
6467
|
+
var GA4_DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
|
|
6468
|
+
var GA4_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
|
|
6469
|
+
var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
|
|
6470
|
+
var GA4_DEFAULT_SYNC_DAYS = 30;
|
|
6471
|
+
var GA4_MAX_SYNC_DAYS = 90;
|
|
6472
|
+
var GA4_REQUEST_TIMEOUT_MS = 3e4;
|
|
6473
|
+
|
|
6474
|
+
// ../integration-google-analytics/src/types.ts
|
|
6475
|
+
var GA4ApiError = class extends Error {
|
|
6476
|
+
status;
|
|
6477
|
+
constructor(message, status) {
|
|
6478
|
+
super(message);
|
|
6479
|
+
this.name = "GA4ApiError";
|
|
6480
|
+
this.status = status;
|
|
6481
|
+
}
|
|
6482
|
+
};
|
|
6483
|
+
|
|
6484
|
+
// ../integration-google-analytics/src/ga4-client.ts
|
|
6485
|
+
function ga4Log(level, action, ctx) {
|
|
6486
|
+
const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Client", action, ...ctx };
|
|
6487
|
+
const stream = level === "error" ? process.stderr : process.stdout;
|
|
6488
|
+
stream.write(JSON.stringify(entry) + "\n");
|
|
6435
6489
|
}
|
|
6436
|
-
function
|
|
6437
|
-
const
|
|
6438
|
-
const
|
|
6439
|
-
|
|
6490
|
+
function createServiceAccountJwt(clientEmail, privateKey, scope) {
|
|
6491
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
6492
|
+
const header = { alg: "RS256", typ: "JWT" };
|
|
6493
|
+
const payload = {
|
|
6494
|
+
iss: clientEmail,
|
|
6495
|
+
scope,
|
|
6496
|
+
aud: GOOGLE_TOKEN_URL2,
|
|
6497
|
+
iat: now,
|
|
6498
|
+
exp: now + 3600
|
|
6499
|
+
// 1 hour
|
|
6500
|
+
};
|
|
6501
|
+
const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
|
|
6502
|
+
const headerB64 = encode(header);
|
|
6503
|
+
const payloadB64 = encode(payload);
|
|
6504
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
6505
|
+
const sign = crypto13.createSign("RSA-SHA256");
|
|
6506
|
+
sign.update(signingInput);
|
|
6507
|
+
const signature = sign.sign(privateKey, "base64url");
|
|
6508
|
+
return `${signingInput}.${signature}`;
|
|
6440
6509
|
}
|
|
6441
|
-
function
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6510
|
+
async function getAccessToken(clientEmail, privateKey) {
|
|
6511
|
+
const jwt = createServiceAccountJwt(clientEmail, privateKey, GA4_SCOPE);
|
|
6512
|
+
const res = await fetch(GOOGLE_TOKEN_URL2, {
|
|
6513
|
+
method: "POST",
|
|
6514
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
6515
|
+
body: new URLSearchParams({
|
|
6516
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
6517
|
+
assertion: jwt
|
|
6518
|
+
}),
|
|
6519
|
+
signal: AbortSignal.timeout(GA4_REQUEST_TIMEOUT_MS)
|
|
6520
|
+
});
|
|
6521
|
+
if (!res.ok) {
|
|
6522
|
+
const body = await res.text().catch(() => "");
|
|
6523
|
+
ga4Log("error", "token.failed", { httpStatus: res.status, responseBody: body });
|
|
6524
|
+
throw new GA4ApiError(`Failed to get access token: ${body}`, res.status);
|
|
6449
6525
|
}
|
|
6526
|
+
const data = await res.json();
|
|
6527
|
+
return data.access_token;
|
|
6450
6528
|
}
|
|
6451
|
-
async function
|
|
6452
|
-
const
|
|
6453
|
-
|
|
6454
|
-
|
|
6529
|
+
async function runReport(accessToken, propertyId, request) {
|
|
6530
|
+
const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:runReport`;
|
|
6531
|
+
const res = await fetch(url, {
|
|
6532
|
+
method: "POST",
|
|
6533
|
+
headers: {
|
|
6534
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
6535
|
+
"Content-Type": "application/json"
|
|
6536
|
+
},
|
|
6537
|
+
body: JSON.stringify(request),
|
|
6538
|
+
signal: AbortSignal.timeout(GA4_REQUEST_TIMEOUT_MS)
|
|
6539
|
+
});
|
|
6540
|
+
if (res.status === 401 || res.status === 403) {
|
|
6541
|
+
const body = await res.text().catch(() => "");
|
|
6542
|
+
let detail = "";
|
|
6543
|
+
try {
|
|
6544
|
+
const parsed = JSON.parse(body);
|
|
6545
|
+
if (parsed.error?.status === "SERVICE_DISABLED") {
|
|
6546
|
+
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";
|
|
6547
|
+
} else if (parsed.error?.message) {
|
|
6548
|
+
detail = ` ${parsed.error.message}`;
|
|
6549
|
+
}
|
|
6550
|
+
} catch {
|
|
6551
|
+
if (body.length < 200) detail = ` ${body}`;
|
|
6552
|
+
}
|
|
6553
|
+
ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status, responseBody: body });
|
|
6554
|
+
throw new GA4ApiError(
|
|
6555
|
+
`GA4 API authentication failed \u2014 check service account permissions.${detail}`,
|
|
6556
|
+
res.status
|
|
6557
|
+
);
|
|
6455
6558
|
}
|
|
6456
|
-
if (
|
|
6457
|
-
|
|
6559
|
+
if (res.status === 429) {
|
|
6560
|
+
ga4Log("error", "report.rate-limited", { propertyId });
|
|
6561
|
+
throw new GA4ApiError("GA4 API rate limit exceeded", 429);
|
|
6458
6562
|
}
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
6464
|
-
const updated = store.updateConnection(domain, connectionType, {
|
|
6465
|
-
accessToken: tokens.access_token,
|
|
6466
|
-
tokenExpiresAt: newExpiresAt,
|
|
6467
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6468
|
-
});
|
|
6469
|
-
return {
|
|
6470
|
-
accessToken: tokens.access_token,
|
|
6471
|
-
connectionId: `${domain}:${connectionType}`,
|
|
6472
|
-
propertyId: updated?.propertyId ?? conn.propertyId ?? null
|
|
6473
|
-
};
|
|
6563
|
+
if (!res.ok) {
|
|
6564
|
+
const body = await res.text();
|
|
6565
|
+
ga4Log("error", "report.error", { propertyId, httpStatus: res.status, responseBody: body });
|
|
6566
|
+
throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
|
|
6474
6567
|
}
|
|
6475
|
-
return
|
|
6476
|
-
accessToken: conn.accessToken,
|
|
6477
|
-
connectionId: `${domain}:${connectionType}`,
|
|
6478
|
-
propertyId: conn.propertyId ?? null
|
|
6479
|
-
};
|
|
6568
|
+
return await res.json();
|
|
6480
6569
|
}
|
|
6481
|
-
async function
|
|
6482
|
-
const
|
|
6483
|
-
|
|
6484
|
-
|
|
6570
|
+
async function batchRunReports(accessToken, propertyId, requests) {
|
|
6571
|
+
const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:batchRunReports`;
|
|
6572
|
+
const res = await fetch(url, {
|
|
6573
|
+
method: "POST",
|
|
6574
|
+
headers: {
|
|
6575
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
6576
|
+
"Content-Type": "application/json"
|
|
6577
|
+
},
|
|
6578
|
+
body: JSON.stringify({ requests }),
|
|
6579
|
+
signal: AbortSignal.timeout(GA4_REQUEST_TIMEOUT_MS)
|
|
6580
|
+
});
|
|
6581
|
+
if (res.status === 401 || res.status === 403) {
|
|
6582
|
+
const body = await res.text().catch(() => "");
|
|
6583
|
+
ga4Log("error", "batch-report.auth-failed", { propertyId, httpStatus: res.status });
|
|
6584
|
+
throw new GA4ApiError(
|
|
6585
|
+
`GA4 API authentication failed \u2014 check service account permissions. ${body}`,
|
|
6586
|
+
res.status
|
|
6587
|
+
);
|
|
6485
6588
|
}
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
reply.status(err.statusCode).send(err.toJSON());
|
|
6490
|
-
return null;
|
|
6589
|
+
if (res.status === 429) {
|
|
6590
|
+
ga4Log("error", "batch-report.rate-limited", { propertyId });
|
|
6591
|
+
throw new GA4ApiError("GA4 API rate limit exceeded", 429);
|
|
6491
6592
|
}
|
|
6492
|
-
|
|
6493
|
-
const
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6593
|
+
if (!res.ok) {
|
|
6594
|
+
const body = await res.text();
|
|
6595
|
+
ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
|
|
6596
|
+
throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
|
|
6597
|
+
}
|
|
6598
|
+
const data = await res.json();
|
|
6599
|
+
return data.reports;
|
|
6600
|
+
}
|
|
6601
|
+
function formatDate(d) {
|
|
6602
|
+
return d.toISOString().split("T")[0];
|
|
6603
|
+
}
|
|
6604
|
+
var AI_REFERRAL_SOURCE_FILTERS = [
|
|
6605
|
+
{ matchType: "CONTAINS", value: "perplexity" },
|
|
6606
|
+
{ matchType: "CONTAINS", value: "gemini" },
|
|
6607
|
+
{ matchType: "CONTAINS", value: "chatgpt" },
|
|
6608
|
+
{ matchType: "CONTAINS", value: "openai" },
|
|
6609
|
+
{ matchType: "CONTAINS", value: "claude" },
|
|
6610
|
+
{ matchType: "CONTAINS", value: "anthropic" },
|
|
6611
|
+
{ matchType: "CONTAINS", value: "copilot" }
|
|
6612
|
+
];
|
|
6613
|
+
async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
6614
|
+
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
6615
|
+
const endDate = /* @__PURE__ */ new Date();
|
|
6616
|
+
const startDate = /* @__PURE__ */ new Date();
|
|
6617
|
+
startDate.setDate(startDate.getDate() - syncDays);
|
|
6618
|
+
ga4Log("info", "fetch-traffic.start", { propertyId, days: syncDays });
|
|
6619
|
+
const PAGE_SIZE = 1e4;
|
|
6620
|
+
const rows = [];
|
|
6621
|
+
let offset = 0;
|
|
6622
|
+
while (true) {
|
|
6623
|
+
const request = {
|
|
6624
|
+
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6625
|
+
dimensions: [
|
|
6626
|
+
{ name: "date" },
|
|
6627
|
+
{ name: "landingPagePlusQueryString" }
|
|
6628
|
+
],
|
|
6629
|
+
metrics: [
|
|
6630
|
+
{ name: "sessions" },
|
|
6631
|
+
{ name: "totalUsers" }
|
|
6632
|
+
],
|
|
6633
|
+
limit: PAGE_SIZE,
|
|
6634
|
+
offset
|
|
6635
|
+
};
|
|
6636
|
+
const response = await runReport(accessToken, propertyId, request);
|
|
6637
|
+
const pageRows = (response.rows ?? []).map((row) => ({
|
|
6638
|
+
date: row.dimensionValues[0].value,
|
|
6639
|
+
landingPage: row.dimensionValues[1].value,
|
|
6640
|
+
sessions: parseInt(row.metricValues[0].value, 10) || 0,
|
|
6641
|
+
organicSessions: 0,
|
|
6642
|
+
// populated by organic-only pass below
|
|
6643
|
+
users: parseInt(row.metricValues[1].value, 10) || 0
|
|
6504
6644
|
}));
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
if (
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6645
|
+
rows.push(...pageRows);
|
|
6646
|
+
const totalRows = response.rowCount ?? 0;
|
|
6647
|
+
offset += pageRows.length;
|
|
6648
|
+
if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
|
|
6649
|
+
}
|
|
6650
|
+
const organicMap = /* @__PURE__ */ new Map();
|
|
6651
|
+
let organicOffset = 0;
|
|
6652
|
+
while (true) {
|
|
6653
|
+
const organicRequest = {
|
|
6654
|
+
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6655
|
+
dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
|
|
6656
|
+
metrics: [{ name: "sessions" }],
|
|
6657
|
+
dimensionFilter: {
|
|
6658
|
+
filter: {
|
|
6659
|
+
fieldName: "sessionDefaultChannelGrouping",
|
|
6660
|
+
stringFilter: { matchType: "EXACT", value: "Organic Search" }
|
|
6661
|
+
}
|
|
6662
|
+
},
|
|
6663
|
+
limit: 1e4,
|
|
6664
|
+
offset: organicOffset
|
|
6665
|
+
};
|
|
6666
|
+
const organicResponse = await runReport(accessToken, propertyId, organicRequest);
|
|
6667
|
+
for (const row of organicResponse.rows ?? []) {
|
|
6668
|
+
const key = `${row.dimensionValues[0].value}::${row.dimensionValues[1].value}`;
|
|
6669
|
+
organicMap.set(key, parseInt(row.metricValues[0].value, 10) || 0);
|
|
6670
|
+
}
|
|
6671
|
+
const total = organicResponse.rowCount ?? 0;
|
|
6672
|
+
organicOffset += (organicResponse.rows ?? []).length;
|
|
6673
|
+
if ((organicResponse.rows ?? []).length < 1e4 || organicOffset >= total) break;
|
|
6674
|
+
}
|
|
6675
|
+
for (const row of rows) {
|
|
6676
|
+
const key = `${row.date}::${row.landingPage}`;
|
|
6677
|
+
row.organicSessions = organicMap.get(key) ?? 0;
|
|
6678
|
+
}
|
|
6679
|
+
for (const row of rows) {
|
|
6680
|
+
if (row.date.length === 8 && !row.date.includes("-")) {
|
|
6681
|
+
row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
|
|
6682
|
+
}
|
|
6683
|
+
}
|
|
6684
|
+
ga4Log("info", "fetch-traffic.done", { propertyId, rowCount: rows.length });
|
|
6685
|
+
return rows;
|
|
6686
|
+
}
|
|
6687
|
+
async function verifyConnection(clientEmail, privateKey, propertyId) {
|
|
6688
|
+
const accessToken = await getAccessToken(clientEmail, privateKey);
|
|
6689
|
+
return verifyConnectionWithToken(accessToken, propertyId);
|
|
6690
|
+
}
|
|
6691
|
+
async function verifyConnectionWithToken(accessToken, propertyId) {
|
|
6692
|
+
const endDate = /* @__PURE__ */ new Date();
|
|
6693
|
+
const startDate = /* @__PURE__ */ new Date();
|
|
6694
|
+
startDate.setDate(startDate.getDate() - 1);
|
|
6695
|
+
await runReport(accessToken, propertyId, {
|
|
6696
|
+
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6697
|
+
dimensions: [{ name: "date" }],
|
|
6698
|
+
metrics: [{ name: "sessions" }],
|
|
6699
|
+
limit: 1
|
|
6700
|
+
});
|
|
6701
|
+
return true;
|
|
6702
|
+
}
|
|
6703
|
+
async function fetchAggregateSummary(accessToken, propertyId, days) {
|
|
6704
|
+
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
6705
|
+
const endDate = /* @__PURE__ */ new Date();
|
|
6706
|
+
const startDate = /* @__PURE__ */ new Date();
|
|
6707
|
+
startDate.setDate(startDate.getDate() - syncDays);
|
|
6708
|
+
ga4Log("info", "fetch-aggregate.start", { propertyId, days: syncDays });
|
|
6709
|
+
const dateRange = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
|
|
6710
|
+
const batchRes = await batchRunReports(accessToken, propertyId, [
|
|
6711
|
+
{
|
|
6712
|
+
dateRanges: [dateRange],
|
|
6713
|
+
dimensions: [],
|
|
6714
|
+
metrics: [{ name: "sessions" }, { name: "totalUsers" }],
|
|
6715
|
+
limit: 1
|
|
6716
|
+
},
|
|
6717
|
+
{
|
|
6718
|
+
dateRanges: [dateRange],
|
|
6719
|
+
dimensions: [],
|
|
6720
|
+
metrics: [{ name: "sessions" }],
|
|
6721
|
+
dimensionFilter: {
|
|
6722
|
+
filter: {
|
|
6723
|
+
fieldName: "sessionDefaultChannelGrouping",
|
|
6724
|
+
stringFilter: { matchType: "EXACT", value: "Organic Search" }
|
|
6725
|
+
}
|
|
6726
|
+
},
|
|
6727
|
+
limit: 1
|
|
6728
|
+
}
|
|
6729
|
+
]);
|
|
6730
|
+
const totalRow = batchRes[0]?.rows?.[0];
|
|
6731
|
+
const organicRow = batchRes[1]?.rows?.[0];
|
|
6732
|
+
const summary = {
|
|
6733
|
+
periodStart: formatDate(startDate),
|
|
6734
|
+
periodEnd: formatDate(endDate),
|
|
6735
|
+
totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
|
|
6736
|
+
totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
|
|
6737
|
+
totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0
|
|
6738
|
+
};
|
|
6739
|
+
ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
|
|
6740
|
+
return summary;
|
|
6741
|
+
}
|
|
6742
|
+
async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
6743
|
+
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
6744
|
+
const endDate = /* @__PURE__ */ new Date();
|
|
6745
|
+
const startDate = /* @__PURE__ */ new Date();
|
|
6746
|
+
startDate.setDate(startDate.getDate() - syncDays);
|
|
6747
|
+
ga4Log("info", "fetch-ai-referrals.start", { propertyId, days: syncDays });
|
|
6748
|
+
const PAGE_SIZE = 1e3;
|
|
6749
|
+
const rows = [];
|
|
6750
|
+
let offset = 0;
|
|
6751
|
+
while (true) {
|
|
6752
|
+
const request = {
|
|
6753
|
+
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6754
|
+
dimensions: [
|
|
6755
|
+
{ name: "date" },
|
|
6756
|
+
{ name: "sessionSource" },
|
|
6757
|
+
{ name: "sessionMedium" }
|
|
6758
|
+
],
|
|
6759
|
+
metrics: [
|
|
6760
|
+
{ name: "sessions" },
|
|
6761
|
+
{ name: "totalUsers" }
|
|
6762
|
+
],
|
|
6763
|
+
dimensionFilter: {
|
|
6764
|
+
orGroup: {
|
|
6765
|
+
expressions: AI_REFERRAL_SOURCE_FILTERS.map(({ matchType, value }) => ({
|
|
6766
|
+
filter: {
|
|
6767
|
+
fieldName: "sessionSource",
|
|
6768
|
+
stringFilter: { matchType, value }
|
|
6769
|
+
}
|
|
6770
|
+
}))
|
|
6771
|
+
}
|
|
6772
|
+
},
|
|
6773
|
+
limit: PAGE_SIZE,
|
|
6774
|
+
offset
|
|
6775
|
+
};
|
|
6776
|
+
const response = await runReport(accessToken, propertyId, request);
|
|
6777
|
+
const pageRows = (response.rows ?? []).map((row) => ({
|
|
6778
|
+
date: row.dimensionValues[0].value,
|
|
6779
|
+
source: row.dimensionValues[1].value,
|
|
6780
|
+
medium: row.dimensionValues[2].value,
|
|
6781
|
+
sessions: parseInt(row.metricValues[0].value, 10) || 0,
|
|
6782
|
+
users: parseInt(row.metricValues[1].value, 10) || 0
|
|
6783
|
+
}));
|
|
6784
|
+
rows.push(...pageRows);
|
|
6785
|
+
const totalRows = response.rowCount ?? 0;
|
|
6786
|
+
offset += pageRows.length;
|
|
6787
|
+
if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
|
|
6788
|
+
}
|
|
6789
|
+
for (const row of rows) {
|
|
6790
|
+
if (row.date.length === 8 && !row.date.includes("-")) {
|
|
6791
|
+
row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
|
|
6792
|
+
}
|
|
6793
|
+
}
|
|
6794
|
+
ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: rows.length });
|
|
6795
|
+
return rows;
|
|
6796
|
+
}
|
|
6797
|
+
|
|
6798
|
+
// ../api-routes/src/google.ts
|
|
6799
|
+
function signState(payload, secret) {
|
|
6800
|
+
return crypto14.createHmac("sha256", secret).update(payload).digest("hex");
|
|
6801
|
+
}
|
|
6802
|
+
function buildSignedState(data, secret) {
|
|
6803
|
+
const payload = JSON.stringify(data);
|
|
6804
|
+
const sig = signState(payload, secret);
|
|
6805
|
+
return Buffer.from(JSON.stringify({ payload, sig })).toString("base64url");
|
|
6806
|
+
}
|
|
6807
|
+
function verifySignedState(encoded, secret) {
|
|
6808
|
+
try {
|
|
6809
|
+
const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
|
|
6810
|
+
const expected = signState(payload, secret);
|
|
6811
|
+
if (!crypto14.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
|
|
6812
|
+
return JSON.parse(payload);
|
|
6813
|
+
} catch {
|
|
6814
|
+
return null;
|
|
6815
|
+
}
|
|
6816
|
+
}
|
|
6817
|
+
async function getValidToken(store, domain, connectionType, clientId, clientSecret) {
|
|
6818
|
+
const conn = store.getConnection(domain, connectionType);
|
|
6819
|
+
if (!conn) {
|
|
6820
|
+
throw notFound("Google connection", connectionType);
|
|
6821
|
+
}
|
|
6822
|
+
if (!conn.accessToken || !conn.refreshToken) {
|
|
6823
|
+
throw validationError("Google connection is incomplete \u2014 please reconnect");
|
|
6824
|
+
}
|
|
6825
|
+
const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
|
|
6826
|
+
const fiveMinutes = 5 * 60 * 1e3;
|
|
6827
|
+
if (Date.now() > expiresAt - fiveMinutes) {
|
|
6828
|
+
const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
|
|
6829
|
+
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
6830
|
+
const updated = store.updateConnection(domain, connectionType, {
|
|
6831
|
+
accessToken: tokens.access_token,
|
|
6832
|
+
tokenExpiresAt: newExpiresAt,
|
|
6833
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6834
|
+
});
|
|
6835
|
+
return {
|
|
6836
|
+
accessToken: tokens.access_token,
|
|
6837
|
+
connectionId: `${domain}:${connectionType}`,
|
|
6838
|
+
propertyId: updated?.propertyId ?? conn.propertyId ?? null
|
|
6839
|
+
};
|
|
6840
|
+
}
|
|
6841
|
+
return {
|
|
6842
|
+
accessToken: conn.accessToken,
|
|
6843
|
+
connectionId: `${domain}:${connectionType}`,
|
|
6844
|
+
propertyId: conn.propertyId ?? null
|
|
6845
|
+
};
|
|
6846
|
+
}
|
|
6847
|
+
async function googleRoutes(app, opts) {
|
|
6848
|
+
const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
|
|
6849
|
+
function getAuthConfig() {
|
|
6850
|
+
return opts.getGoogleAuthConfig?.() ?? {};
|
|
6851
|
+
}
|
|
6852
|
+
function requireConnectionStore(reply) {
|
|
6853
|
+
if (opts.googleConnectionStore) return opts.googleConnectionStore;
|
|
6854
|
+
const err = validationError("Google auth storage is not configured for this deployment");
|
|
6855
|
+
reply.status(err.statusCode).send(err.toJSON());
|
|
6856
|
+
return null;
|
|
6857
|
+
}
|
|
6858
|
+
app.get("/projects/:name/google/connections", async (request) => {
|
|
6859
|
+
const project = resolveProject(app.db, request.params.name);
|
|
6860
|
+
const conns = opts.googleConnectionStore?.listConnections(project.canonicalDomain) ?? [];
|
|
6861
|
+
return conns.map((connection) => ({
|
|
6862
|
+
id: `${connection.domain}:${connection.connectionType}`,
|
|
6863
|
+
domain: connection.domain,
|
|
6864
|
+
connectionType: connection.connectionType,
|
|
6865
|
+
propertyId: connection.propertyId ?? null,
|
|
6866
|
+
sitemapUrl: connection.sitemapUrl ?? null,
|
|
6867
|
+
scopes: connection.scopes ?? [],
|
|
6868
|
+
createdAt: connection.createdAt,
|
|
6869
|
+
updatedAt: connection.updatedAt
|
|
6870
|
+
}));
|
|
6871
|
+
});
|
|
6872
|
+
app.post("/projects/:name/google/connect", async (request, reply) => {
|
|
6873
|
+
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
6874
|
+
if (!googleClientId || !googleClientSecret) {
|
|
6875
|
+
const err = validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
|
|
6876
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
6877
|
+
}
|
|
6878
|
+
const { type, propertyId, publicUrl } = request.body ?? {};
|
|
6879
|
+
if (!type || type !== "gsc" && type !== "ga4") {
|
|
6880
|
+
const err = validationError('type must be "gsc" or "ga4"');
|
|
6515
6881
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
6516
6882
|
}
|
|
6517
6883
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -6525,7 +6891,7 @@ async function googleRoutes(app, opts) {
|
|
|
6525
6891
|
const host = request.headers.host ?? "localhost:4100";
|
|
6526
6892
|
redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
|
|
6527
6893
|
}
|
|
6528
|
-
const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [];
|
|
6894
|
+
const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [GA4_SCOPE];
|
|
6529
6895
|
const stateEncoded = buildSignedState(
|
|
6530
6896
|
{ domain: project.canonicalDomain, type, propertyId, redirectUri },
|
|
6531
6897
|
stateSecret
|
|
@@ -6669,7 +7035,7 @@ async function googleRoutes(app, opts) {
|
|
|
6669
7035
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
6670
7036
|
}
|
|
6671
7037
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6672
|
-
const runId =
|
|
7038
|
+
const runId = crypto14.randomUUID();
|
|
6673
7039
|
app.db.insert(runs).values({
|
|
6674
7040
|
id: runId,
|
|
6675
7041
|
projectId: project.id,
|
|
@@ -6731,7 +7097,7 @@ async function googleRoutes(app, opts) {
|
|
|
6731
7097
|
const mob = ir.mobileUsabilityResult;
|
|
6732
7098
|
const rich = ir.richResultsResult;
|
|
6733
7099
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6734
|
-
const id =
|
|
7100
|
+
const id = crypto14.randomUUID();
|
|
6735
7101
|
app.db.insert(gscUrlInspections).values({
|
|
6736
7102
|
id,
|
|
6737
7103
|
projectId: project.id,
|
|
@@ -6971,7 +7337,7 @@ async function googleRoutes(app, opts) {
|
|
|
6971
7337
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6972
7338
|
});
|
|
6973
7339
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6974
|
-
const runId =
|
|
7340
|
+
const runId = crypto14.randomUUID();
|
|
6975
7341
|
app.db.insert(runs).values({
|
|
6976
7342
|
id: runId,
|
|
6977
7343
|
projectId: project.id,
|
|
@@ -7000,7 +7366,7 @@ async function googleRoutes(app, opts) {
|
|
|
7000
7366
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
7001
7367
|
}
|
|
7002
7368
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7003
|
-
const runId =
|
|
7369
|
+
const runId = crypto14.randomUUID();
|
|
7004
7370
|
app.db.insert(runs).values({
|
|
7005
7371
|
id: runId,
|
|
7006
7372
|
projectId: project.id,
|
|
@@ -7142,13 +7508,14 @@ async function googleRoutes(app, opts) {
|
|
|
7142
7508
|
}
|
|
7143
7509
|
|
|
7144
7510
|
// ../api-routes/src/bing.ts
|
|
7145
|
-
import
|
|
7511
|
+
import crypto15 from "crypto";
|
|
7146
7512
|
import { eq as eq14, and as and3, desc as desc5 } from "drizzle-orm";
|
|
7147
7513
|
|
|
7148
7514
|
// ../integration-bing/src/constants.ts
|
|
7149
7515
|
var BING_WMT_API_BASE = "https://ssl.bing.com/webmaster/api.svc/json";
|
|
7150
7516
|
var BING_SUBMIT_URL_BATCH_LIMIT = 500;
|
|
7151
7517
|
var BING_SUBMIT_URL_DAILY_LIMIT = 1e4;
|
|
7518
|
+
var BING_REQUEST_TIMEOUT_MS = 3e4;
|
|
7152
7519
|
|
|
7153
7520
|
// ../integration-bing/src/types.ts
|
|
7154
7521
|
var BingApiError = class extends Error {
|
|
@@ -7176,7 +7543,8 @@ async function bingFetch(apiKey, endpoint, opts) {
|
|
|
7176
7543
|
const res = await fetch(url, {
|
|
7177
7544
|
method,
|
|
7178
7545
|
headers,
|
|
7179
|
-
body: opts?.body != null ? JSON.stringify(opts.body) : void 0
|
|
7546
|
+
body: opts?.body != null ? JSON.stringify(opts.body) : void 0,
|
|
7547
|
+
signal: AbortSignal.timeout(BING_REQUEST_TIMEOUT_MS)
|
|
7180
7548
|
});
|
|
7181
7549
|
if (res.status === 401 || res.status === 403) {
|
|
7182
7550
|
const body = await res.text().catch(() => "");
|
|
@@ -7489,7 +7857,7 @@ async function bingRoutes(app, opts) {
|
|
|
7489
7857
|
throw e;
|
|
7490
7858
|
}
|
|
7491
7859
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7492
|
-
const id =
|
|
7860
|
+
const id = crypto15.randomUUID();
|
|
7493
7861
|
const httpCode = result.HttpStatus ?? result.HttpCode ?? null;
|
|
7494
7862
|
let derivedInIndex = null;
|
|
7495
7863
|
if (result.InIndex != null) {
|
|
@@ -7706,491 +8074,242 @@ async function cdpRoutes(app, opts) {
|
|
|
7706
8074
|
const project = resolveProject(app.db, request.params.name);
|
|
7707
8075
|
const { runId } = request.params;
|
|
7708
8076
|
const run = app.db.select().from(runs).where(and4(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
|
|
7709
|
-
if (!run) {
|
|
7710
|
-
const err = notFound("Run", runId);
|
|
7711
|
-
return reply.code(err.statusCode).send(err.toJSON());
|
|
7712
|
-
}
|
|
7713
|
-
const snapshots = app.db.select({
|
|
7714
|
-
id: querySnapshots.id,
|
|
7715
|
-
keywordId: querySnapshots.keywordId,
|
|
7716
|
-
provider: querySnapshots.provider,
|
|
7717
|
-
citationState: querySnapshots.citationState,
|
|
7718
|
-
citedDomains: querySnapshots.citedDomains,
|
|
7719
|
-
screenshotPath: querySnapshots.screenshotPath,
|
|
7720
|
-
rawResponse: querySnapshots.rawResponse
|
|
7721
|
-
}).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
|
|
7722
|
-
const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, project.id)).all();
|
|
7723
|
-
const keywordMap = new Map(keywordRows.map((k) => [k.id, k.keyword]));
|
|
7724
|
-
const byKeyword = /* @__PURE__ */ new Map();
|
|
7725
|
-
for (const snap of snapshots) {
|
|
7726
|
-
const kwName = keywordMap.get(snap.keywordId) ?? snap.keywordId;
|
|
7727
|
-
if (!byKeyword.has(snap.keywordId)) {
|
|
7728
|
-
byKeyword.set(snap.keywordId, { keyword: kwName, api: null, browser: null });
|
|
7729
|
-
}
|
|
7730
|
-
const entry = byKeyword.get(snap.keywordId);
|
|
7731
|
-
if (snap.provider === "cdp:chatgpt") {
|
|
7732
|
-
entry.browser = snap;
|
|
7733
|
-
} else if (snap.provider === "openai") {
|
|
7734
|
-
entry.api = snap;
|
|
7735
|
-
}
|
|
7736
|
-
}
|
|
7737
|
-
let agreed = 0;
|
|
7738
|
-
let apiOnlyCited = 0;
|
|
7739
|
-
let browserOnlyCited = 0;
|
|
7740
|
-
let disagreed = 0;
|
|
7741
|
-
let total = 0;
|
|
7742
|
-
const keywordResults = [...byKeyword.values()].map(({ keyword, api, browser }) => {
|
|
7743
|
-
total++;
|
|
7744
|
-
const apiCited = api?.citationState === "cited";
|
|
7745
|
-
const browserCited = browser?.citationState === "cited";
|
|
7746
|
-
let agreement;
|
|
7747
|
-
if (!api && !browser) {
|
|
7748
|
-
agreement = "no-data";
|
|
7749
|
-
} else if (!api) {
|
|
7750
|
-
agreement = "no-api";
|
|
7751
|
-
} else if (!browser) {
|
|
7752
|
-
agreement = "no-browser";
|
|
7753
|
-
} else if (apiCited && browserCited) {
|
|
7754
|
-
agreement = "agree-cited";
|
|
7755
|
-
agreed++;
|
|
7756
|
-
} else if (!apiCited && !browserCited) {
|
|
7757
|
-
agreement = "agree-not-cited";
|
|
7758
|
-
agreed++;
|
|
7759
|
-
} else if (apiCited && !browserCited) {
|
|
7760
|
-
agreement = "api-only-cited";
|
|
7761
|
-
apiOnlyCited++;
|
|
7762
|
-
disagreed++;
|
|
7763
|
-
} else {
|
|
7764
|
-
agreement = "browser-only-cited";
|
|
7765
|
-
browserOnlyCited++;
|
|
7766
|
-
disagreed++;
|
|
7767
|
-
}
|
|
7768
|
-
const parseGroundingSources2 = (snap) => {
|
|
7769
|
-
if (!snap?.rawResponse) return [];
|
|
7770
|
-
try {
|
|
7771
|
-
const raw = JSON.parse(snap.rawResponse);
|
|
7772
|
-
return raw.groundingSources ?? [];
|
|
7773
|
-
} catch {
|
|
7774
|
-
return [];
|
|
7775
|
-
}
|
|
7776
|
-
};
|
|
7777
|
-
return {
|
|
7778
|
-
keyword,
|
|
7779
|
-
api: api ? {
|
|
7780
|
-
provider: api.provider,
|
|
7781
|
-
citationState: api.citationState,
|
|
7782
|
-
citedDomains: JSON.parse(api.citedDomains || "[]"),
|
|
7783
|
-
groundingSources: parseGroundingSources2(api)
|
|
7784
|
-
} : null,
|
|
7785
|
-
browser: browser ? {
|
|
7786
|
-
provider: browser.provider,
|
|
7787
|
-
citationState: browser.citationState,
|
|
7788
|
-
citedDomains: JSON.parse(browser.citedDomains || "[]"),
|
|
7789
|
-
groundingSources: parseGroundingSources2(browser),
|
|
7790
|
-
screenshotUrl: browser.screenshotPath ? `/api/v1/screenshots/${browser.id}` : void 0
|
|
7791
|
-
} : null,
|
|
7792
|
-
agreement
|
|
7793
|
-
};
|
|
7794
|
-
});
|
|
7795
|
-
return reply.send({
|
|
7796
|
-
summary: { total, agreed, apiOnly: apiOnlyCited, browserOnly: browserOnlyCited, disagreed },
|
|
7797
|
-
keywords: keywordResults
|
|
7798
|
-
});
|
|
7799
|
-
}
|
|
7800
|
-
);
|
|
7801
|
-
}
|
|
7802
|
-
|
|
7803
|
-
// ../api-routes/src/ga.ts
|
|
7804
|
-
import crypto16 from "crypto";
|
|
7805
|
-
import { eq as eq16, desc as desc6, and as and5, sql as sql4 } from "drizzle-orm";
|
|
7806
|
-
|
|
7807
|
-
// ../integration-google-analytics/src/ga4-client.ts
|
|
7808
|
-
import crypto15 from "crypto";
|
|
7809
|
-
|
|
7810
|
-
// ../integration-google-analytics/src/constants.ts
|
|
7811
|
-
var GA4_DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
|
|
7812
|
-
var GA4_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
|
|
7813
|
-
var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
|
|
7814
|
-
var GA4_DEFAULT_SYNC_DAYS = 30;
|
|
7815
|
-
var GA4_MAX_SYNC_DAYS = 90;
|
|
7816
|
-
|
|
7817
|
-
// ../integration-google-analytics/src/types.ts
|
|
7818
|
-
var GA4ApiError = class extends Error {
|
|
7819
|
-
status;
|
|
7820
|
-
constructor(message, status) {
|
|
7821
|
-
super(message);
|
|
7822
|
-
this.name = "GA4ApiError";
|
|
7823
|
-
this.status = status;
|
|
7824
|
-
}
|
|
7825
|
-
};
|
|
7826
|
-
|
|
7827
|
-
// ../integration-google-analytics/src/ga4-client.ts
|
|
7828
|
-
function ga4Log(level, action, ctx) {
|
|
7829
|
-
const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Client", action, ...ctx };
|
|
7830
|
-
const stream = level === "error" ? process.stderr : process.stdout;
|
|
7831
|
-
stream.write(JSON.stringify(entry) + "\n");
|
|
7832
|
-
}
|
|
7833
|
-
function createServiceAccountJwt(clientEmail, privateKey, scope) {
|
|
7834
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
7835
|
-
const header = { alg: "RS256", typ: "JWT" };
|
|
7836
|
-
const payload = {
|
|
7837
|
-
iss: clientEmail,
|
|
7838
|
-
scope,
|
|
7839
|
-
aud: GOOGLE_TOKEN_URL2,
|
|
7840
|
-
iat: now,
|
|
7841
|
-
exp: now + 3600
|
|
7842
|
-
// 1 hour
|
|
7843
|
-
};
|
|
7844
|
-
const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
|
|
7845
|
-
const headerB64 = encode(header);
|
|
7846
|
-
const payloadB64 = encode(payload);
|
|
7847
|
-
const signingInput = `${headerB64}.${payloadB64}`;
|
|
7848
|
-
const sign = crypto15.createSign("RSA-SHA256");
|
|
7849
|
-
sign.update(signingInput);
|
|
7850
|
-
const signature = sign.sign(privateKey, "base64url");
|
|
7851
|
-
return `${signingInput}.${signature}`;
|
|
7852
|
-
}
|
|
7853
|
-
async function getAccessToken(clientEmail, privateKey) {
|
|
7854
|
-
const jwt = createServiceAccountJwt(clientEmail, privateKey, GA4_SCOPE);
|
|
7855
|
-
const res = await fetch(GOOGLE_TOKEN_URL2, {
|
|
7856
|
-
method: "POST",
|
|
7857
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
7858
|
-
body: new URLSearchParams({
|
|
7859
|
-
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
7860
|
-
assertion: jwt
|
|
7861
|
-
})
|
|
7862
|
-
});
|
|
7863
|
-
if (!res.ok) {
|
|
7864
|
-
const body = await res.text().catch(() => "");
|
|
7865
|
-
ga4Log("error", "token.failed", { httpStatus: res.status, responseBody: body });
|
|
7866
|
-
throw new GA4ApiError(`Failed to get access token: ${body}`, res.status);
|
|
7867
|
-
}
|
|
7868
|
-
const data = await res.json();
|
|
7869
|
-
return data.access_token;
|
|
7870
|
-
}
|
|
7871
|
-
async function runReport(accessToken, propertyId, request) {
|
|
7872
|
-
const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:runReport`;
|
|
7873
|
-
const res = await fetch(url, {
|
|
7874
|
-
method: "POST",
|
|
7875
|
-
headers: {
|
|
7876
|
-
"Authorization": `Bearer ${accessToken}`,
|
|
7877
|
-
"Content-Type": "application/json"
|
|
7878
|
-
},
|
|
7879
|
-
body: JSON.stringify(request)
|
|
7880
|
-
});
|
|
7881
|
-
if (res.status === 401 || res.status === 403) {
|
|
7882
|
-
const body = await res.text().catch(() => "");
|
|
7883
|
-
let detail = "";
|
|
7884
|
-
try {
|
|
7885
|
-
const parsed = JSON.parse(body);
|
|
7886
|
-
if (parsed.error?.status === "SERVICE_DISABLED") {
|
|
7887
|
-
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";
|
|
7888
|
-
} else if (parsed.error?.message) {
|
|
7889
|
-
detail = ` ${parsed.error.message}`;
|
|
7890
|
-
}
|
|
7891
|
-
} catch {
|
|
7892
|
-
if (body.length < 200) detail = ` ${body}`;
|
|
7893
|
-
}
|
|
7894
|
-
ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status, responseBody: body });
|
|
7895
|
-
throw new GA4ApiError(
|
|
7896
|
-
`GA4 API authentication failed \u2014 check service account permissions.${detail}`,
|
|
7897
|
-
res.status
|
|
7898
|
-
);
|
|
7899
|
-
}
|
|
7900
|
-
if (res.status === 429) {
|
|
7901
|
-
ga4Log("error", "report.rate-limited", { propertyId });
|
|
7902
|
-
throw new GA4ApiError("GA4 API rate limit exceeded", 429);
|
|
7903
|
-
}
|
|
7904
|
-
if (!res.ok) {
|
|
7905
|
-
const body = await res.text();
|
|
7906
|
-
ga4Log("error", "report.error", { propertyId, httpStatus: res.status, responseBody: body });
|
|
7907
|
-
throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
|
|
7908
|
-
}
|
|
7909
|
-
return await res.json();
|
|
7910
|
-
}
|
|
7911
|
-
async function batchRunReports(accessToken, propertyId, requests) {
|
|
7912
|
-
const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:batchRunReports`;
|
|
7913
|
-
const res = await fetch(url, {
|
|
7914
|
-
method: "POST",
|
|
7915
|
-
headers: {
|
|
7916
|
-
"Authorization": `Bearer ${accessToken}`,
|
|
7917
|
-
"Content-Type": "application/json"
|
|
7918
|
-
},
|
|
7919
|
-
body: JSON.stringify({ requests })
|
|
7920
|
-
});
|
|
7921
|
-
if (res.status === 401 || res.status === 403) {
|
|
7922
|
-
const body = await res.text().catch(() => "");
|
|
7923
|
-
ga4Log("error", "batch-report.auth-failed", { propertyId, httpStatus: res.status });
|
|
7924
|
-
throw new GA4ApiError(
|
|
7925
|
-
`GA4 API authentication failed \u2014 check service account permissions. ${body}`,
|
|
7926
|
-
res.status
|
|
7927
|
-
);
|
|
7928
|
-
}
|
|
7929
|
-
if (res.status === 429) {
|
|
7930
|
-
ga4Log("error", "batch-report.rate-limited", { propertyId });
|
|
7931
|
-
throw new GA4ApiError("GA4 API rate limit exceeded", 429);
|
|
7932
|
-
}
|
|
7933
|
-
if (!res.ok) {
|
|
7934
|
-
const body = await res.text();
|
|
7935
|
-
ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
|
|
7936
|
-
throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
|
|
7937
|
-
}
|
|
7938
|
-
const data = await res.json();
|
|
7939
|
-
return data.reports;
|
|
7940
|
-
}
|
|
7941
|
-
function formatDate(d) {
|
|
7942
|
-
return d.toISOString().split("T")[0];
|
|
7943
|
-
}
|
|
7944
|
-
var AI_REFERRAL_SOURCE_FILTERS = [
|
|
7945
|
-
{ matchType: "CONTAINS", value: "perplexity" },
|
|
7946
|
-
{ matchType: "CONTAINS", value: "gemini" },
|
|
7947
|
-
{ matchType: "CONTAINS", value: "chatgpt" },
|
|
7948
|
-
{ matchType: "CONTAINS", value: "openai" },
|
|
7949
|
-
{ matchType: "CONTAINS", value: "claude" },
|
|
7950
|
-
{ matchType: "CONTAINS", value: "anthropic" },
|
|
7951
|
-
{ matchType: "CONTAINS", value: "copilot" }
|
|
7952
|
-
];
|
|
7953
|
-
async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
7954
|
-
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
7955
|
-
const endDate = /* @__PURE__ */ new Date();
|
|
7956
|
-
const startDate = /* @__PURE__ */ new Date();
|
|
7957
|
-
startDate.setDate(startDate.getDate() - syncDays);
|
|
7958
|
-
ga4Log("info", "fetch-traffic.start", { propertyId, days: syncDays });
|
|
7959
|
-
const PAGE_SIZE = 1e4;
|
|
7960
|
-
const rows = [];
|
|
7961
|
-
let offset = 0;
|
|
7962
|
-
while (true) {
|
|
7963
|
-
const request = {
|
|
7964
|
-
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
7965
|
-
dimensions: [
|
|
7966
|
-
{ name: "date" },
|
|
7967
|
-
{ name: "landingPagePlusQueryString" }
|
|
7968
|
-
],
|
|
7969
|
-
metrics: [
|
|
7970
|
-
{ name: "sessions" },
|
|
7971
|
-
{ name: "totalUsers" }
|
|
7972
|
-
],
|
|
7973
|
-
limit: PAGE_SIZE,
|
|
7974
|
-
offset
|
|
7975
|
-
};
|
|
7976
|
-
const response = await runReport(accessToken, propertyId, request);
|
|
7977
|
-
const pageRows = (response.rows ?? []).map((row) => ({
|
|
7978
|
-
date: row.dimensionValues[0].value,
|
|
7979
|
-
landingPage: row.dimensionValues[1].value,
|
|
7980
|
-
sessions: parseInt(row.metricValues[0].value, 10) || 0,
|
|
7981
|
-
organicSessions: 0,
|
|
7982
|
-
// populated by organic-only pass below
|
|
7983
|
-
users: parseInt(row.metricValues[1].value, 10) || 0
|
|
7984
|
-
}));
|
|
7985
|
-
rows.push(...pageRows);
|
|
7986
|
-
const totalRows = response.rowCount ?? 0;
|
|
7987
|
-
offset += pageRows.length;
|
|
7988
|
-
if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
|
|
7989
|
-
}
|
|
7990
|
-
const organicMap = /* @__PURE__ */ new Map();
|
|
7991
|
-
let organicOffset = 0;
|
|
7992
|
-
while (true) {
|
|
7993
|
-
const organicRequest = {
|
|
7994
|
-
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
7995
|
-
dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
|
|
7996
|
-
metrics: [{ name: "sessions" }],
|
|
7997
|
-
dimensionFilter: {
|
|
7998
|
-
filter: {
|
|
7999
|
-
fieldName: "sessionDefaultChannelGrouping",
|
|
8000
|
-
stringFilter: { matchType: "EXACT", value: "Organic Search" }
|
|
8001
|
-
}
|
|
8002
|
-
},
|
|
8003
|
-
limit: 1e4,
|
|
8004
|
-
offset: organicOffset
|
|
8005
|
-
};
|
|
8006
|
-
const organicResponse = await runReport(accessToken, propertyId, organicRequest);
|
|
8007
|
-
for (const row of organicResponse.rows ?? []) {
|
|
8008
|
-
const key = `${row.dimensionValues[0].value}::${row.dimensionValues[1].value}`;
|
|
8009
|
-
organicMap.set(key, parseInt(row.metricValues[0].value, 10) || 0);
|
|
8010
|
-
}
|
|
8011
|
-
const total = organicResponse.rowCount ?? 0;
|
|
8012
|
-
organicOffset += (organicResponse.rows ?? []).length;
|
|
8013
|
-
if ((organicResponse.rows ?? []).length < 1e4 || organicOffset >= total) break;
|
|
8014
|
-
}
|
|
8015
|
-
for (const row of rows) {
|
|
8016
|
-
const key = `${row.date}::${row.landingPage}`;
|
|
8017
|
-
row.organicSessions = organicMap.get(key) ?? 0;
|
|
8018
|
-
}
|
|
8019
|
-
for (const row of rows) {
|
|
8020
|
-
if (row.date.length === 8 && !row.date.includes("-")) {
|
|
8021
|
-
row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
|
|
8022
|
-
}
|
|
8023
|
-
}
|
|
8024
|
-
ga4Log("info", "fetch-traffic.done", { propertyId, rowCount: rows.length });
|
|
8025
|
-
return rows;
|
|
8026
|
-
}
|
|
8027
|
-
async function verifyConnection(clientEmail, privateKey, propertyId) {
|
|
8028
|
-
const accessToken = await getAccessToken(clientEmail, privateKey);
|
|
8029
|
-
const endDate = /* @__PURE__ */ new Date();
|
|
8030
|
-
const startDate = /* @__PURE__ */ new Date();
|
|
8031
|
-
startDate.setDate(startDate.getDate() - 1);
|
|
8032
|
-
await runReport(accessToken, propertyId, {
|
|
8033
|
-
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
8034
|
-
dimensions: [{ name: "date" }],
|
|
8035
|
-
metrics: [{ name: "sessions" }],
|
|
8036
|
-
limit: 1
|
|
8037
|
-
});
|
|
8038
|
-
return true;
|
|
8039
|
-
}
|
|
8040
|
-
async function fetchAggregateSummary(accessToken, propertyId, days) {
|
|
8041
|
-
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
8042
|
-
const endDate = /* @__PURE__ */ new Date();
|
|
8043
|
-
const startDate = /* @__PURE__ */ new Date();
|
|
8044
|
-
startDate.setDate(startDate.getDate() - syncDays);
|
|
8045
|
-
ga4Log("info", "fetch-aggregate.start", { propertyId, days: syncDays });
|
|
8046
|
-
const dateRange = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
|
|
8047
|
-
const batchRes = await batchRunReports(accessToken, propertyId, [
|
|
8048
|
-
{
|
|
8049
|
-
dateRanges: [dateRange],
|
|
8050
|
-
dimensions: [],
|
|
8051
|
-
metrics: [{ name: "sessions" }, { name: "totalUsers" }],
|
|
8052
|
-
limit: 1
|
|
8053
|
-
},
|
|
8054
|
-
{
|
|
8055
|
-
dateRanges: [dateRange],
|
|
8056
|
-
dimensions: [],
|
|
8057
|
-
metrics: [{ name: "sessions" }],
|
|
8058
|
-
dimensionFilter: {
|
|
8059
|
-
filter: {
|
|
8060
|
-
fieldName: "sessionDefaultChannelGrouping",
|
|
8061
|
-
stringFilter: { matchType: "EXACT", value: "Organic Search" }
|
|
8077
|
+
if (!run) {
|
|
8078
|
+
const err = notFound("Run", runId);
|
|
8079
|
+
return reply.code(err.statusCode).send(err.toJSON());
|
|
8080
|
+
}
|
|
8081
|
+
const snapshots = app.db.select({
|
|
8082
|
+
id: querySnapshots.id,
|
|
8083
|
+
keywordId: querySnapshots.keywordId,
|
|
8084
|
+
provider: querySnapshots.provider,
|
|
8085
|
+
citationState: querySnapshots.citationState,
|
|
8086
|
+
citedDomains: querySnapshots.citedDomains,
|
|
8087
|
+
screenshotPath: querySnapshots.screenshotPath,
|
|
8088
|
+
rawResponse: querySnapshots.rawResponse
|
|
8089
|
+
}).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
|
|
8090
|
+
const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, project.id)).all();
|
|
8091
|
+
const keywordMap = new Map(keywordRows.map((k) => [k.id, k.keyword]));
|
|
8092
|
+
const byKeyword = /* @__PURE__ */ new Map();
|
|
8093
|
+
for (const snap of snapshots) {
|
|
8094
|
+
const kwName = keywordMap.get(snap.keywordId) ?? snap.keywordId;
|
|
8095
|
+
if (!byKeyword.has(snap.keywordId)) {
|
|
8096
|
+
byKeyword.set(snap.keywordId, { keyword: kwName, api: null, browser: null });
|
|
8062
8097
|
}
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
const organicRow = batchRes[1]?.rows?.[0];
|
|
8069
|
-
const summary = {
|
|
8070
|
-
periodStart: formatDate(startDate),
|
|
8071
|
-
periodEnd: formatDate(endDate),
|
|
8072
|
-
totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
|
|
8073
|
-
totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
|
|
8074
|
-
totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0
|
|
8075
|
-
};
|
|
8076
|
-
ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
|
|
8077
|
-
return summary;
|
|
8078
|
-
}
|
|
8079
|
-
async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
8080
|
-
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
8081
|
-
const endDate = /* @__PURE__ */ new Date();
|
|
8082
|
-
const startDate = /* @__PURE__ */ new Date();
|
|
8083
|
-
startDate.setDate(startDate.getDate() - syncDays);
|
|
8084
|
-
ga4Log("info", "fetch-ai-referrals.start", { propertyId, days: syncDays });
|
|
8085
|
-
const PAGE_SIZE = 1e3;
|
|
8086
|
-
const rows = [];
|
|
8087
|
-
let offset = 0;
|
|
8088
|
-
while (true) {
|
|
8089
|
-
const request = {
|
|
8090
|
-
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
8091
|
-
dimensions: [
|
|
8092
|
-
{ name: "date" },
|
|
8093
|
-
{ name: "sessionSource" },
|
|
8094
|
-
{ name: "sessionMedium" }
|
|
8095
|
-
],
|
|
8096
|
-
metrics: [
|
|
8097
|
-
{ name: "sessions" },
|
|
8098
|
-
{ name: "totalUsers" }
|
|
8099
|
-
],
|
|
8100
|
-
dimensionFilter: {
|
|
8101
|
-
orGroup: {
|
|
8102
|
-
expressions: AI_REFERRAL_SOURCE_FILTERS.map(({ matchType, value }) => ({
|
|
8103
|
-
filter: {
|
|
8104
|
-
fieldName: "sessionSource",
|
|
8105
|
-
stringFilter: { matchType, value }
|
|
8106
|
-
}
|
|
8107
|
-
}))
|
|
8098
|
+
const entry = byKeyword.get(snap.keywordId);
|
|
8099
|
+
if (snap.provider === "cdp:chatgpt") {
|
|
8100
|
+
entry.browser = snap;
|
|
8101
|
+
} else if (snap.provider === "openai") {
|
|
8102
|
+
entry.api = snap;
|
|
8108
8103
|
}
|
|
8109
|
-
}
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8124
|
-
|
|
8125
|
-
|
|
8126
|
-
|
|
8127
|
-
|
|
8128
|
-
|
|
8104
|
+
}
|
|
8105
|
+
let agreed = 0;
|
|
8106
|
+
let apiOnlyCited = 0;
|
|
8107
|
+
let browserOnlyCited = 0;
|
|
8108
|
+
let disagreed = 0;
|
|
8109
|
+
let total = 0;
|
|
8110
|
+
const keywordResults = [...byKeyword.values()].map(({ keyword, api, browser }) => {
|
|
8111
|
+
total++;
|
|
8112
|
+
const apiCited = api?.citationState === "cited";
|
|
8113
|
+
const browserCited = browser?.citationState === "cited";
|
|
8114
|
+
let agreement;
|
|
8115
|
+
if (!api && !browser) {
|
|
8116
|
+
agreement = "no-data";
|
|
8117
|
+
} else if (!api) {
|
|
8118
|
+
agreement = "no-api";
|
|
8119
|
+
} else if (!browser) {
|
|
8120
|
+
agreement = "no-browser";
|
|
8121
|
+
} else if (apiCited && browserCited) {
|
|
8122
|
+
agreement = "agree-cited";
|
|
8123
|
+
agreed++;
|
|
8124
|
+
} else if (!apiCited && !browserCited) {
|
|
8125
|
+
agreement = "agree-not-cited";
|
|
8126
|
+
agreed++;
|
|
8127
|
+
} else if (apiCited && !browserCited) {
|
|
8128
|
+
agreement = "api-only-cited";
|
|
8129
|
+
apiOnlyCited++;
|
|
8130
|
+
disagreed++;
|
|
8131
|
+
} else {
|
|
8132
|
+
agreement = "browser-only-cited";
|
|
8133
|
+
browserOnlyCited++;
|
|
8134
|
+
disagreed++;
|
|
8135
|
+
}
|
|
8136
|
+
const parseGroundingSources2 = (snap) => {
|
|
8137
|
+
if (!snap?.rawResponse) return [];
|
|
8138
|
+
try {
|
|
8139
|
+
const raw = JSON.parse(snap.rawResponse);
|
|
8140
|
+
return raw.groundingSources ?? [];
|
|
8141
|
+
} catch {
|
|
8142
|
+
return [];
|
|
8143
|
+
}
|
|
8144
|
+
};
|
|
8145
|
+
return {
|
|
8146
|
+
keyword,
|
|
8147
|
+
api: api ? {
|
|
8148
|
+
provider: api.provider,
|
|
8149
|
+
citationState: api.citationState,
|
|
8150
|
+
citedDomains: JSON.parse(api.citedDomains || "[]"),
|
|
8151
|
+
groundingSources: parseGroundingSources2(api)
|
|
8152
|
+
} : null,
|
|
8153
|
+
browser: browser ? {
|
|
8154
|
+
provider: browser.provider,
|
|
8155
|
+
citationState: browser.citationState,
|
|
8156
|
+
citedDomains: JSON.parse(browser.citedDomains || "[]"),
|
|
8157
|
+
groundingSources: parseGroundingSources2(browser),
|
|
8158
|
+
screenshotUrl: browser.screenshotPath ? `/api/v1/screenshots/${browser.id}` : void 0
|
|
8159
|
+
} : null,
|
|
8160
|
+
agreement
|
|
8161
|
+
};
|
|
8162
|
+
});
|
|
8163
|
+
return reply.send({
|
|
8164
|
+
summary: { total, agreed, apiOnly: apiOnlyCited, browserOnly: browserOnlyCited, disagreed },
|
|
8165
|
+
keywords: keywordResults
|
|
8166
|
+
});
|
|
8129
8167
|
}
|
|
8130
|
-
|
|
8131
|
-
ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: rows.length });
|
|
8132
|
-
return rows;
|
|
8168
|
+
);
|
|
8133
8169
|
}
|
|
8134
8170
|
|
|
8135
8171
|
// ../api-routes/src/ga.ts
|
|
8172
|
+
import crypto16 from "crypto";
|
|
8173
|
+
import { eq as eq16, desc as desc6, and as and5, sql as sql4 } from "drizzle-orm";
|
|
8136
8174
|
function gaLog(level, action, ctx) {
|
|
8137
8175
|
const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Routes", action, ...ctx };
|
|
8138
8176
|
const stream = level === "error" ? process.stderr : process.stdout;
|
|
8139
8177
|
stream.write(JSON.stringify(entry) + "\n");
|
|
8140
8178
|
}
|
|
8141
|
-
async function
|
|
8142
|
-
|
|
8143
|
-
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8179
|
+
async function refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, oauthConn) {
|
|
8180
|
+
const expiresAt = oauthConn.tokenExpiresAt ? new Date(oauthConn.tokenExpiresAt).getTime() : 0;
|
|
8181
|
+
const fiveMinutes = 5 * 60 * 1e3;
|
|
8182
|
+
if (Date.now() > expiresAt - fiveMinutes) {
|
|
8183
|
+
if (!authConfig.clientId || !authConfig.clientSecret) {
|
|
8184
|
+
throw validationError("Google OAuth client credentials are not configured \u2014 cannot refresh GA4 token.");
|
|
8185
|
+
}
|
|
8186
|
+
const tokens = await refreshAccessToken(authConfig.clientId, authConfig.clientSecret, oauthConn.refreshToken);
|
|
8187
|
+
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
8188
|
+
googleStore.updateConnection(canonicalDomain, "ga4", {
|
|
8189
|
+
accessToken: tokens.access_token,
|
|
8190
|
+
tokenExpiresAt: newExpiresAt,
|
|
8191
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
8192
|
+
});
|
|
8193
|
+
return tokens.access_token;
|
|
8147
8194
|
}
|
|
8148
|
-
|
|
8149
|
-
|
|
8150
|
-
|
|
8195
|
+
return oauthConn.accessToken;
|
|
8196
|
+
}
|
|
8197
|
+
async function resolveGa4AccessToken(opts, projectName, canonicalDomain) {
|
|
8198
|
+
const saConn = opts.ga4CredentialStore?.getConnection(projectName);
|
|
8199
|
+
if (saConn?.clientEmail && saConn?.privateKey && saConn?.propertyId) {
|
|
8200
|
+
const token = await getAccessToken(saConn.clientEmail, saConn.privateKey);
|
|
8201
|
+
return { accessToken: token, propertyId: saConn.propertyId };
|
|
8202
|
+
}
|
|
8203
|
+
const googleStore = opts.googleConnectionStore;
|
|
8204
|
+
const authConfig = opts.getGoogleAuthConfig?.();
|
|
8205
|
+
if (!googleStore || !authConfig) {
|
|
8206
|
+
throw validationError(
|
|
8207
|
+
'No GA4 credentials found. Run "canonry ga connect <project> --key-file <path>" or "canonry google connect <project> --type ga4" to authenticate.'
|
|
8208
|
+
);
|
|
8209
|
+
}
|
|
8210
|
+
const oauthConn = googleStore.getConnection(canonicalDomain, "ga4");
|
|
8211
|
+
if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
|
|
8212
|
+
throw validationError(
|
|
8213
|
+
'No GA4 credentials found. Run "canonry ga connect <project> --key-file <path>" or "canonry google connect <project> --type ga4" to authenticate.'
|
|
8214
|
+
);
|
|
8215
|
+
}
|
|
8216
|
+
if (!oauthConn.propertyId) {
|
|
8217
|
+
throw validationError(
|
|
8218
|
+
'GA4 property ID not set. Run "canonry ga set-property <project> <propertyId>" to configure it.'
|
|
8219
|
+
);
|
|
8220
|
+
}
|
|
8221
|
+
const accessToken = await refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, {
|
|
8222
|
+
accessToken: oauthConn.accessToken,
|
|
8223
|
+
refreshToken: oauthConn.refreshToken,
|
|
8224
|
+
tokenExpiresAt: oauthConn.tokenExpiresAt
|
|
8225
|
+
});
|
|
8226
|
+
return { accessToken, propertyId: oauthConn.propertyId };
|
|
8227
|
+
}
|
|
8228
|
+
function requireGa4Connection(opts, projectName, canonicalDomain) {
|
|
8229
|
+
const saConn = opts.ga4CredentialStore?.getConnection(projectName);
|
|
8230
|
+
const oauthConn = opts.googleConnectionStore?.getConnection(canonicalDomain, "ga4");
|
|
8231
|
+
if (!saConn && !(oauthConn?.accessToken && oauthConn?.propertyId)) {
|
|
8232
|
+
throw validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
|
|
8233
|
+
}
|
|
8234
|
+
}
|
|
8235
|
+
async function ga4Routes(app, opts) {
|
|
8236
|
+
app.post("/projects/:name/ga/connect", async (request, _reply) => {
|
|
8151
8237
|
const project = resolveProject(app.db, request.params.name);
|
|
8152
8238
|
const { propertyId, keyJson } = request.body ?? {};
|
|
8153
8239
|
if (!propertyId || typeof propertyId !== "string") {
|
|
8154
|
-
|
|
8155
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
8240
|
+
throw validationError("propertyId is required");
|
|
8156
8241
|
}
|
|
8157
|
-
let clientEmail;
|
|
8158
|
-
let privateKey;
|
|
8159
8242
|
if (keyJson && typeof keyJson === "string") {
|
|
8243
|
+
if (!opts.ga4CredentialStore) {
|
|
8244
|
+
throw validationError("GA4 credential storage is not configured for this deployment");
|
|
8245
|
+
}
|
|
8246
|
+
let parsed;
|
|
8160
8247
|
try {
|
|
8161
|
-
|
|
8162
|
-
if (!parsed.client_email || !parsed.private_key) {
|
|
8163
|
-
const err = validationError("Service account JSON must contain client_email and private_key");
|
|
8164
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
8165
|
-
}
|
|
8166
|
-
clientEmail = parsed.client_email;
|
|
8167
|
-
privateKey = parsed.private_key;
|
|
8248
|
+
parsed = JSON.parse(keyJson);
|
|
8168
8249
|
} catch {
|
|
8169
|
-
|
|
8170
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
8250
|
+
throw validationError("Invalid JSON in keyJson");
|
|
8171
8251
|
}
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8252
|
+
if (!parsed.client_email || !parsed.private_key) {
|
|
8253
|
+
throw validationError("Service account JSON must contain client_email and private_key");
|
|
8254
|
+
}
|
|
8255
|
+
const clientEmail = parsed.client_email;
|
|
8256
|
+
const privateKey = parsed.private_key;
|
|
8257
|
+
try {
|
|
8258
|
+
await verifyConnection(clientEmail, privateKey, propertyId);
|
|
8259
|
+
gaLog("info", "connect.verified.service-account", { projectId: project.id, propertyId });
|
|
8260
|
+
} catch (e) {
|
|
8261
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
8262
|
+
gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
|
|
8263
|
+
throw validationError(`Failed to verify GA4 credentials: ${msg}`);
|
|
8264
|
+
}
|
|
8265
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
8266
|
+
const existing = opts.ga4CredentialStore.getConnection(project.name);
|
|
8267
|
+
opts.ga4CredentialStore.upsertConnection({
|
|
8268
|
+
projectName: project.name,
|
|
8269
|
+
propertyId,
|
|
8270
|
+
clientEmail,
|
|
8271
|
+
privateKey,
|
|
8272
|
+
createdAt: existing?.createdAt ?? now,
|
|
8273
|
+
updatedAt: now
|
|
8274
|
+
});
|
|
8275
|
+
writeAuditLog(app.db, {
|
|
8276
|
+
projectId: project.id,
|
|
8277
|
+
actor: "api",
|
|
8278
|
+
action: "ga4.connected",
|
|
8279
|
+
entityType: "ga_connection",
|
|
8280
|
+
entityId: propertyId
|
|
8281
|
+
});
|
|
8282
|
+
return { connected: true, propertyId, authMethod: "service-account", clientEmail };
|
|
8283
|
+
}
|
|
8284
|
+
const googleStore = opts.googleConnectionStore;
|
|
8285
|
+
const authConfig = opts.getGoogleAuthConfig?.();
|
|
8286
|
+
if (!googleStore || !authConfig) {
|
|
8287
|
+
throw validationError(
|
|
8288
|
+
'No service account key provided and OAuth storage is not configured. Pass --key-file or run "canonry google connect <project> --type ga4" first.'
|
|
8289
|
+
);
|
|
8175
8290
|
}
|
|
8291
|
+
const oauthConn = googleStore.getConnection(project.canonicalDomain, "ga4");
|
|
8292
|
+
if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
|
|
8293
|
+
throw validationError(
|
|
8294
|
+
'No GA4 OAuth token found. Run "canonry google connect <project> --type ga4" first, or pass --key-file to use a service account.'
|
|
8295
|
+
);
|
|
8296
|
+
}
|
|
8297
|
+
const accessToken = await refreshOAuthTokenIfNeeded(googleStore, authConfig, project.canonicalDomain, {
|
|
8298
|
+
accessToken: oauthConn.accessToken,
|
|
8299
|
+
refreshToken: oauthConn.refreshToken,
|
|
8300
|
+
tokenExpiresAt: oauthConn.tokenExpiresAt
|
|
8301
|
+
});
|
|
8176
8302
|
try {
|
|
8177
|
-
await
|
|
8178
|
-
gaLog("info", "connect.verified", { projectId: project.id, propertyId });
|
|
8303
|
+
await verifyConnectionWithToken(accessToken, propertyId);
|
|
8304
|
+
gaLog("info", "connect.verified.oauth", { projectId: project.id, propertyId });
|
|
8179
8305
|
} catch (e) {
|
|
8180
8306
|
const msg = e instanceof Error ? e.message : String(e);
|
|
8181
|
-
gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
|
|
8182
|
-
|
|
8183
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
8307
|
+
gaLog("error", "connect.verify-failed.oauth", { projectId: project.id, propertyId, error: msg });
|
|
8308
|
+
throw validationError(`Failed to verify GA4 access: ${msg}`);
|
|
8184
8309
|
}
|
|
8185
|
-
|
|
8186
|
-
const existing = store.getConnection(project.name);
|
|
8187
|
-
store.upsertConnection({
|
|
8188
|
-
projectName: project.name,
|
|
8310
|
+
googleStore.updateConnection(project.canonicalDomain, "ga4", {
|
|
8189
8311
|
propertyId,
|
|
8190
|
-
|
|
8191
|
-
privateKey,
|
|
8192
|
-
createdAt: existing?.createdAt ?? now,
|
|
8193
|
-
updatedAt: now
|
|
8312
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
8194
8313
|
});
|
|
8195
8314
|
writeAuditLog(app.db, {
|
|
8196
8315
|
projectId: project.id,
|
|
@@ -8199,80 +8318,62 @@ async function ga4Routes(app, opts) {
|
|
|
8199
8318
|
entityType: "ga_connection",
|
|
8200
8319
|
entityId: propertyId
|
|
8201
8320
|
});
|
|
8202
|
-
return {
|
|
8203
|
-
connected: true,
|
|
8204
|
-
propertyId,
|
|
8205
|
-
clientEmail
|
|
8206
|
-
};
|
|
8321
|
+
return { connected: true, propertyId, authMethod: "oauth" };
|
|
8207
8322
|
});
|
|
8208
8323
|
app.delete("/projects/:name/ga/disconnect", async (request, reply) => {
|
|
8209
|
-
const store = requireCredentialStore(reply);
|
|
8210
|
-
if (!store) return;
|
|
8211
8324
|
const project = resolveProject(app.db, request.params.name);
|
|
8212
|
-
const
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8325
|
+
const saConn = opts.ga4CredentialStore?.getConnection(project.name);
|
|
8326
|
+
const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
|
|
8327
|
+
if (!saConn && !oauthConn) {
|
|
8328
|
+
throw notFound("GA4 connection", project.name);
|
|
8216
8329
|
}
|
|
8217
8330
|
app.db.delete(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).run();
|
|
8218
8331
|
app.db.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
|
|
8219
8332
|
app.db.delete(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).run();
|
|
8220
|
-
|
|
8333
|
+
const propertyId = saConn?.propertyId ?? oauthConn?.propertyId ?? null;
|
|
8334
|
+
opts.ga4CredentialStore?.deleteConnection(project.name);
|
|
8335
|
+
opts.googleConnectionStore?.deleteConnection(project.canonicalDomain, "ga4");
|
|
8221
8336
|
writeAuditLog(app.db, {
|
|
8222
8337
|
projectId: project.id,
|
|
8223
8338
|
actor: "api",
|
|
8224
8339
|
action: "ga4.disconnected",
|
|
8225
8340
|
entityType: "ga_connection",
|
|
8226
|
-
entityId:
|
|
8341
|
+
entityId: propertyId
|
|
8227
8342
|
});
|
|
8228
8343
|
return reply.status(204).send();
|
|
8229
8344
|
});
|
|
8230
|
-
app.get("/projects/:name/ga/status", async (request,
|
|
8231
|
-
const store = requireCredentialStore(reply);
|
|
8232
|
-
if (!store) return;
|
|
8345
|
+
app.get("/projects/:name/ga/status", async (request, _reply) => {
|
|
8233
8346
|
const project = resolveProject(app.db, request.params.name);
|
|
8234
|
-
const
|
|
8235
|
-
|
|
8236
|
-
|
|
8347
|
+
const saConn = opts.ga4CredentialStore?.getConnection(project.name);
|
|
8348
|
+
const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
|
|
8349
|
+
const connected = !!(saConn || oauthConn?.accessToken && oauthConn?.propertyId);
|
|
8350
|
+
if (!connected) {
|
|
8351
|
+
return { connected: false, propertyId: null, clientEmail: null, authMethod: null, lastSyncedAt: null };
|
|
8237
8352
|
}
|
|
8238
8353
|
const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
|
|
8239
8354
|
return {
|
|
8240
8355
|
connected: true,
|
|
8241
|
-
propertyId:
|
|
8242
|
-
clientEmail:
|
|
8356
|
+
propertyId: saConn?.propertyId ?? oauthConn?.propertyId ?? null,
|
|
8357
|
+
clientEmail: saConn?.clientEmail ?? null,
|
|
8358
|
+
authMethod: saConn ? "service-account" : "oauth",
|
|
8243
8359
|
lastSyncedAt: latestSync?.syncedAt ?? null,
|
|
8244
|
-
createdAt:
|
|
8245
|
-
updatedAt:
|
|
8360
|
+
createdAt: saConn?.createdAt ?? oauthConn?.createdAt ?? null,
|
|
8361
|
+
updatedAt: saConn?.updatedAt ?? oauthConn?.updatedAt ?? null
|
|
8246
8362
|
};
|
|
8247
8363
|
});
|
|
8248
|
-
app.post("/projects/:name/ga/sync", async (request,
|
|
8249
|
-
const store = requireCredentialStore(reply);
|
|
8250
|
-
if (!store) return;
|
|
8364
|
+
app.post("/projects/:name/ga/sync", async (request, _reply) => {
|
|
8251
8365
|
const project = resolveProject(app.db, request.params.name);
|
|
8252
|
-
const conn = store.getConnection(project.name);
|
|
8253
|
-
if (!conn) {
|
|
8254
|
-
const err = validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
|
|
8255
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
8256
|
-
}
|
|
8257
8366
|
const days = request.body?.days ?? 30;
|
|
8258
|
-
|
|
8259
|
-
try {
|
|
8260
|
-
accessToken = await getAccessToken(conn.clientEmail, conn.privateKey);
|
|
8261
|
-
} catch (e) {
|
|
8262
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
8263
|
-
gaLog("error", "sync.auth-failed", { projectId: project.id, error: msg });
|
|
8264
|
-
const err = validationError(`GA4 authentication failed: ${msg}`);
|
|
8265
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
8266
|
-
}
|
|
8367
|
+
const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
|
|
8267
8368
|
let rows;
|
|
8268
8369
|
let summary;
|
|
8269
8370
|
let aiReferrals;
|
|
8270
8371
|
try {
|
|
8271
8372
|
;
|
|
8272
8373
|
[rows, summary, aiReferrals] = await Promise.all([
|
|
8273
|
-
fetchTrafficByLandingPage(accessToken,
|
|
8274
|
-
fetchAggregateSummary(accessToken,
|
|
8275
|
-
fetchAiReferrals(accessToken,
|
|
8374
|
+
fetchTrafficByLandingPage(accessToken, propertyId, days),
|
|
8375
|
+
fetchAggregateSummary(accessToken, propertyId, days),
|
|
8376
|
+
fetchAiReferrals(accessToken, propertyId, days)
|
|
8276
8377
|
]);
|
|
8277
8378
|
} catch (e) {
|
|
8278
8379
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -8350,15 +8451,9 @@ async function ga4Routes(app, opts) {
|
|
|
8350
8451
|
syncedAt: now
|
|
8351
8452
|
};
|
|
8352
8453
|
});
|
|
8353
|
-
app.get("/projects/:name/ga/traffic", async (request,
|
|
8354
|
-
const store = requireCredentialStore(reply);
|
|
8355
|
-
if (!store) return;
|
|
8454
|
+
app.get("/projects/:name/ga/traffic", async (request, _reply) => {
|
|
8356
8455
|
const project = resolveProject(app.db, request.params.name);
|
|
8357
|
-
|
|
8358
|
-
if (!conn) {
|
|
8359
|
-
const err = validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
|
|
8360
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
8361
|
-
}
|
|
8456
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
8362
8457
|
const limit = Math.max(1, Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 500));
|
|
8363
8458
|
const summary = app.db.select({
|
|
8364
8459
|
totalSessions: gaTrafficSummaries.totalSessions,
|
|
@@ -8397,15 +8492,21 @@ async function ga4Routes(app, opts) {
|
|
|
8397
8492
|
lastSyncedAt: latestSync?.syncedAt ?? null
|
|
8398
8493
|
};
|
|
8399
8494
|
});
|
|
8400
|
-
app.get("/projects/:name/ga/
|
|
8401
|
-
const store = requireCredentialStore(reply);
|
|
8402
|
-
if (!store) return;
|
|
8495
|
+
app.get("/projects/:name/ga/ai-referral-history", async (request, _reply) => {
|
|
8403
8496
|
const project = resolveProject(app.db, request.params.name);
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
8407
|
-
|
|
8408
|
-
|
|
8497
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
8498
|
+
const rows = app.db.select({
|
|
8499
|
+
date: gaAiReferrals.date,
|
|
8500
|
+
source: gaAiReferrals.source,
|
|
8501
|
+
medium: gaAiReferrals.medium,
|
|
8502
|
+
sessions: gaAiReferrals.sessions,
|
|
8503
|
+
users: gaAiReferrals.users
|
|
8504
|
+
}).from(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).orderBy(gaAiReferrals.date).all();
|
|
8505
|
+
return rows;
|
|
8506
|
+
});
|
|
8507
|
+
app.get("/projects/:name/ga/coverage", async (request, _reply) => {
|
|
8508
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8509
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
8409
8510
|
const trafficPages = app.db.select({
|
|
8410
8511
|
landingPage: gaTrafficSnapshots.landingPage,
|
|
8411
8512
|
sessions: sql4`SUM(${gaTrafficSnapshots.sessions})`,
|
|
@@ -8546,6 +8647,8 @@ function parseSchemaPageEntry(entry) {
|
|
|
8546
8647
|
|
|
8547
8648
|
// ../integration-wordpress/src/wordpress-client.ts
|
|
8548
8649
|
import crypto17 from "crypto";
|
|
8650
|
+
var WP_REQUEST_TIMEOUT_MS = 3e4;
|
|
8651
|
+
var WP_FETCH_TEXT_TIMEOUT_MS = 15e3;
|
|
8549
8652
|
var PAGE_FIELDS = "id,slug,status,link,modified,modified_gmt,title,content,meta";
|
|
8550
8653
|
var PAGE_LIST_FIELDS = "id,slug,status,link,modified,modified_gmt,title";
|
|
8551
8654
|
var VERIFY_PAGE_FIELDS = "id,status";
|
|
@@ -8602,7 +8705,8 @@ async function fetchJson(connection, siteUrl, path7, init) {
|
|
|
8602
8705
|
"Authorization": `Basic ${encodeBasicAuth(connection.username, connection.appPassword)}`,
|
|
8603
8706
|
...init?.body != null ? { "Content-Type": "application/json" } : {},
|
|
8604
8707
|
...init?.headers ?? {}
|
|
8605
|
-
}
|
|
8708
|
+
},
|
|
8709
|
+
signal: AbortSignal.timeout(WP_REQUEST_TIMEOUT_MS)
|
|
8606
8710
|
});
|
|
8607
8711
|
if (res.status === 401 || res.status === 403) {
|
|
8608
8712
|
const text2 = await res.text().catch(() => "");
|
|
@@ -8645,7 +8749,7 @@ async function fetchPageCollectionSummary(connection, siteUrl, options) {
|
|
|
8645
8749
|
}
|
|
8646
8750
|
async function fetchText(url) {
|
|
8647
8751
|
try {
|
|
8648
|
-
const res = await fetch(url);
|
|
8752
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(WP_FETCH_TEXT_TIMEOUT_MS) });
|
|
8649
8753
|
if (!res.ok) return null;
|
|
8650
8754
|
return await res.text();
|
|
8651
8755
|
} catch {
|
|
@@ -10162,7 +10266,9 @@ async function apiRoutes(app, opts) {
|
|
|
10162
10266
|
onCdpConfigure: opts.onCdpConfigure
|
|
10163
10267
|
});
|
|
10164
10268
|
await api.register(ga4Routes, {
|
|
10165
|
-
ga4CredentialStore: opts.ga4CredentialStore
|
|
10269
|
+
ga4CredentialStore: opts.ga4CredentialStore,
|
|
10270
|
+
googleConnectionStore: opts.googleConnectionStore,
|
|
10271
|
+
getGoogleAuthConfig: opts.getGoogleAuthConfig
|
|
10166
10272
|
});
|
|
10167
10273
|
}, { prefix: opts.routePrefix ?? "/api/v1" });
|
|
10168
10274
|
}
|
|
@@ -12706,9 +12812,6 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
|
|
|
12706
12812
|
function cleanCandidateName(candidate) {
|
|
12707
12813
|
return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
|
|
12708
12814
|
}
|
|
12709
|
-
function brandKeyFromText(value) {
|
|
12710
|
-
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
12711
|
-
}
|
|
12712
12815
|
function collectBrandKeysFromDomain(domain) {
|
|
12713
12816
|
const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
|
|
12714
12817
|
const labels = hostname.split(".").filter(Boolean);
|