@ainyc/canonry 1.41.0 → 1.45.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--ev1Bjls.css +1 -0
- package/assets/assets/index-BsF7MVAu.js +281 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-TKMBOLZB.js → chunk-B4EP44AR.js} +490 -49
- package/dist/{chunk-EUBC5EGC.js → chunk-SVPQUYTG.js} +59 -1
- package/dist/cli.js +363 -27
- package/dist/index.js +2 -2
- package/dist/{intelligence-service-DXGTIRF5.js → intelligence-service-TXWOESFH.js} +1 -1
- package/package.json +5 -5
- package/assets/assets/index-Djm1st6N.css +0 -1
- package/assets/assets/index-iy29Cmx8.js +0 -281
|
@@ -2,10 +2,12 @@ import {
|
|
|
2
2
|
IntelligenceService,
|
|
3
3
|
apiKeys,
|
|
4
4
|
auditLog,
|
|
5
|
+
bingCoverageSnapshots,
|
|
5
6
|
bingUrlInspections,
|
|
6
7
|
competitors,
|
|
7
8
|
createLogger,
|
|
8
9
|
gaAiReferrals,
|
|
10
|
+
gaSocialReferrals,
|
|
9
11
|
gaTrafficSnapshots,
|
|
10
12
|
gaTrafficSummaries,
|
|
11
13
|
gscCoverageSnapshots,
|
|
@@ -21,7 +23,7 @@ import {
|
|
|
21
23
|
runs,
|
|
22
24
|
schedules,
|
|
23
25
|
usageCounters
|
|
24
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-SVPQUYTG.js";
|
|
25
27
|
|
|
26
28
|
// src/config.ts
|
|
27
29
|
import fs from "fs";
|
|
@@ -650,6 +652,12 @@ var bingKeywordStatsDtoSchema = z6.object({
|
|
|
650
652
|
ctr: z6.number(),
|
|
651
653
|
averagePosition: z6.number()
|
|
652
654
|
});
|
|
655
|
+
var bingCoverageSnapshotDtoSchema = z6.object({
|
|
656
|
+
date: z6.string(),
|
|
657
|
+
indexed: z6.number(),
|
|
658
|
+
notIndexed: z6.number(),
|
|
659
|
+
unknown: z6.number()
|
|
660
|
+
});
|
|
653
661
|
var bingSubmitResultDtoSchema = z6.object({
|
|
654
662
|
url: z6.string(),
|
|
655
663
|
status: z6.enum(["success", "error"]),
|
|
@@ -1101,6 +1109,14 @@ var ga4AiReferralDtoSchema = z12.object({
|
|
|
1101
1109
|
users: z12.number(),
|
|
1102
1110
|
sourceDimension: ga4SourceDimensionSchema
|
|
1103
1111
|
});
|
|
1112
|
+
var ga4SocialReferralDtoSchema = z12.object({
|
|
1113
|
+
source: z12.string(),
|
|
1114
|
+
medium: z12.string(),
|
|
1115
|
+
sessions: z12.number(),
|
|
1116
|
+
users: z12.number(),
|
|
1117
|
+
/** GA4 default channel group (e.g. 'Organic Social', 'Paid Social') */
|
|
1118
|
+
channelGroup: z12.string()
|
|
1119
|
+
});
|
|
1104
1120
|
var ga4TrafficSummaryDtoSchema = z12.object({
|
|
1105
1121
|
totalSessions: z12.number(),
|
|
1106
1122
|
totalOrganicSessions: z12.number(),
|
|
@@ -1116,6 +1132,17 @@ var ga4TrafficSummaryDtoSchema = z12.object({
|
|
|
1116
1132
|
aiSessionsDeduped: z12.number(),
|
|
1117
1133
|
/** Deduped AI user total: MAX(users) per date+source+medium across attribution dimensions, then summed. */
|
|
1118
1134
|
aiUsersDeduped: z12.number(),
|
|
1135
|
+
socialReferrals: z12.array(ga4SocialReferralDtoSchema),
|
|
1136
|
+
/** Total social sessions (session-scoped, no cross-dimension dedup needed). */
|
|
1137
|
+
socialSessions: z12.number(),
|
|
1138
|
+
/** Total social users (session-scoped, no cross-dimension dedup needed). */
|
|
1139
|
+
socialUsers: z12.number(),
|
|
1140
|
+
/** Organic sessions as a percentage of total sessions (0–100, rounded). */
|
|
1141
|
+
organicSharePct: z12.number(),
|
|
1142
|
+
/** Deduped AI sessions as a percentage of total sessions (0–100, rounded). */
|
|
1143
|
+
aiSharePct: z12.number(),
|
|
1144
|
+
/** Social sessions as a percentage of total sessions (0–100, rounded). */
|
|
1145
|
+
socialSharePct: z12.number(),
|
|
1119
1146
|
lastSyncedAt: z12.string().nullable()
|
|
1120
1147
|
});
|
|
1121
1148
|
var ga4AiReferralHistoryEntrySchema = z12.object({
|
|
@@ -1127,6 +1154,15 @@ var ga4AiReferralHistoryEntrySchema = z12.object({
|
|
|
1127
1154
|
/** Which GA4 dimension this row came from: session (sessionSource), first_user (firstUserSource), or manual_utm (utm_source parameter) */
|
|
1128
1155
|
sourceDimension: ga4SourceDimensionSchema
|
|
1129
1156
|
});
|
|
1157
|
+
var ga4SocialReferralHistoryEntrySchema = z12.object({
|
|
1158
|
+
date: z12.string(),
|
|
1159
|
+
source: z12.string(),
|
|
1160
|
+
medium: z12.string(),
|
|
1161
|
+
sessions: z12.number(),
|
|
1162
|
+
users: z12.number(),
|
|
1163
|
+
/** GA4 default channel group (e.g. 'Organic Social', 'Paid Social') */
|
|
1164
|
+
channelGroup: z12.string()
|
|
1165
|
+
});
|
|
1130
1166
|
var ga4SessionHistoryEntrySchema = z12.object({
|
|
1131
1167
|
date: z12.string(),
|
|
1132
1168
|
sessions: z12.number(),
|
|
@@ -4712,6 +4748,18 @@ var routeCatalog = [
|
|
|
4712
4748
|
404: { description: "Project not found." }
|
|
4713
4749
|
}
|
|
4714
4750
|
},
|
|
4751
|
+
{
|
|
4752
|
+
method: "get",
|
|
4753
|
+
path: "/api/v1/projects/{name}/bing/coverage/history",
|
|
4754
|
+
summary: "Get Bing coverage history snapshots",
|
|
4755
|
+
tags: ["bing"],
|
|
4756
|
+
parameters: [nameParameter, limitQueryParameter],
|
|
4757
|
+
responses: {
|
|
4758
|
+
200: { description: "Bing coverage history returned." },
|
|
4759
|
+
400: { description: "Bing is not configured for this project." },
|
|
4760
|
+
404: { description: "Project not found." }
|
|
4761
|
+
}
|
|
4762
|
+
},
|
|
4715
4763
|
{
|
|
4716
4764
|
method: "get",
|
|
4717
4765
|
path: "/api/v1/projects/{name}/bing/inspections",
|
|
@@ -5296,6 +5344,42 @@ var routeCatalog = [
|
|
|
5296
5344
|
404: { description: "Project not found." }
|
|
5297
5345
|
}
|
|
5298
5346
|
},
|
|
5347
|
+
{
|
|
5348
|
+
method: "get",
|
|
5349
|
+
path: "/api/v1/projects/{name}/ga/social-referral-history",
|
|
5350
|
+
summary: "Get social media referral sessions per day grouped by source",
|
|
5351
|
+
tags: ["ga4"],
|
|
5352
|
+
parameters: [nameParameter],
|
|
5353
|
+
responses: {
|
|
5354
|
+
200: { description: "Social referral history returned." },
|
|
5355
|
+
400: { description: "GA4 is not connected." },
|
|
5356
|
+
404: { description: "Project not found." }
|
|
5357
|
+
}
|
|
5358
|
+
},
|
|
5359
|
+
{
|
|
5360
|
+
method: "get",
|
|
5361
|
+
path: "/api/v1/projects/{name}/ga/social-referral-trend",
|
|
5362
|
+
summary: "Get social referral trend (7d/30d) with biggest mover",
|
|
5363
|
+
tags: ["ga4"],
|
|
5364
|
+
parameters: [nameParameter],
|
|
5365
|
+
responses: {
|
|
5366
|
+
200: { description: "Social referral trend returned." },
|
|
5367
|
+
400: { description: "GA4 is not connected." },
|
|
5368
|
+
404: { description: "Project not found." }
|
|
5369
|
+
}
|
|
5370
|
+
},
|
|
5371
|
+
{
|
|
5372
|
+
method: "get",
|
|
5373
|
+
path: "/api/v1/projects/{name}/ga/attribution-trend",
|
|
5374
|
+
summary: "Get per-channel attribution trends (7d/30d) for organic, AI, and social",
|
|
5375
|
+
tags: ["ga4"],
|
|
5376
|
+
parameters: [nameParameter],
|
|
5377
|
+
responses: {
|
|
5378
|
+
200: { description: "Attribution trend returned." },
|
|
5379
|
+
400: { description: "GA4 is not connected." },
|
|
5380
|
+
404: { description: "Project not found." }
|
|
5381
|
+
}
|
|
5382
|
+
},
|
|
5299
5383
|
{
|
|
5300
5384
|
method: "get",
|
|
5301
5385
|
path: "/api/v1/projects/{name}/ga/session-history",
|
|
@@ -5891,6 +5975,7 @@ var GSC_DATA_LAG_DAYS = 3;
|
|
|
5891
5975
|
var INDEXING_API_BASE = "https://indexing.googleapis.com/v3";
|
|
5892
5976
|
var INDEXING_API_DAILY_LIMIT = 200;
|
|
5893
5977
|
var GOOGLE_REQUEST_TIMEOUT_MS = 3e4;
|
|
5978
|
+
var GSC_MAX_PAGES = 40;
|
|
5894
5979
|
|
|
5895
5980
|
// ../integration-google/src/types.ts
|
|
5896
5981
|
var GoogleAuthError = class extends Error {
|
|
@@ -5989,7 +6074,15 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
|
|
|
5989
6074
|
});
|
|
5990
6075
|
if (!res.ok) {
|
|
5991
6076
|
const body = await res.text();
|
|
5992
|
-
|
|
6077
|
+
let detail = "";
|
|
6078
|
+
try {
|
|
6079
|
+
const parsed = JSON.parse(body);
|
|
6080
|
+
if (parsed.error) detail = parsed.error;
|
|
6081
|
+
if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
|
|
6082
|
+
} catch {
|
|
6083
|
+
detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
|
|
6084
|
+
}
|
|
6085
|
+
throw new GoogleAuthError(`Token exchange failed (${res.status}): ${detail}`);
|
|
5993
6086
|
}
|
|
5994
6087
|
return await res.json();
|
|
5995
6088
|
}
|
|
@@ -6010,7 +6103,15 @@ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
|
|
|
6010
6103
|
});
|
|
6011
6104
|
if (!res.ok) {
|
|
6012
6105
|
const body = await res.text();
|
|
6013
|
-
|
|
6106
|
+
let detail = "";
|
|
6107
|
+
try {
|
|
6108
|
+
const parsed = JSON.parse(body);
|
|
6109
|
+
if (parsed.error) detail = parsed.error;
|
|
6110
|
+
if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
|
|
6111
|
+
} catch {
|
|
6112
|
+
detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
|
|
6113
|
+
}
|
|
6114
|
+
throw new GoogleAuthError(`Token refresh failed (${res.status}): ${detail}`);
|
|
6014
6115
|
}
|
|
6015
6116
|
return await res.json();
|
|
6016
6117
|
}
|
|
@@ -6057,6 +6158,18 @@ function validateUrl(urlParam) {
|
|
|
6057
6158
|
throw new GoogleApiError("URL must be a valid URL", 400);
|
|
6058
6159
|
}
|
|
6059
6160
|
}
|
|
6161
|
+
function validateDate(date, label) {
|
|
6162
|
+
if (!date || typeof date !== "string" || date.trim().length === 0) {
|
|
6163
|
+
throw new GoogleApiError(`${label} is required and must be a non-empty string`, 400);
|
|
6164
|
+
}
|
|
6165
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
6166
|
+
throw new GoogleApiError(`${label} must be in YYYY-MM-DD format, got "${date}"`, 400);
|
|
6167
|
+
}
|
|
6168
|
+
const parsed = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
6169
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
6170
|
+
throw new GoogleApiError(`${label} is not a valid date, got "${date}"`, 400);
|
|
6171
|
+
}
|
|
6172
|
+
}
|
|
6060
6173
|
function gscClientLog(level, action, ctx) {
|
|
6061
6174
|
const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GscClient", action, ...ctx };
|
|
6062
6175
|
const stream = level === "error" ? process.stderr : process.stdout;
|
|
@@ -6085,7 +6198,8 @@ async function gscFetch(accessToken, url, opts) {
|
|
|
6085
6198
|
if (!res.ok) {
|
|
6086
6199
|
const body = await res.text();
|
|
6087
6200
|
gscClientLog("error", "http.error", { url, method, httpStatus: res.status });
|
|
6088
|
-
|
|
6201
|
+
const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
|
|
6202
|
+
throw new GoogleApiError(`GSC API error (${res.status}): ${detail}`, res.status);
|
|
6089
6203
|
}
|
|
6090
6204
|
return await res.json();
|
|
6091
6205
|
}
|
|
@@ -6110,10 +6224,16 @@ async function listSitemaps(accessToken, siteUrl) {
|
|
|
6110
6224
|
async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
|
|
6111
6225
|
validateAccessToken(accessToken);
|
|
6112
6226
|
validateSiteUrl(siteUrl);
|
|
6227
|
+
validateDate(opts.startDate, "startDate");
|
|
6228
|
+
validateDate(opts.endDate, "endDate");
|
|
6113
6229
|
const allRows = [];
|
|
6114
6230
|
let startRow = 0;
|
|
6115
6231
|
const dimensions = opts.dimensions ?? ["query", "page", "country", "device", "date"];
|
|
6116
6232
|
for (; ; ) {
|
|
6233
|
+
if (startRow >= GSC_MAX_ROWS_PER_REQUEST * GSC_MAX_PAGES) {
|
|
6234
|
+
gscClientLog("error", "pagination.safety-limit", { siteUrl, startRow, maxRows: GSC_MAX_ROWS_PER_REQUEST * GSC_MAX_PAGES });
|
|
6235
|
+
break;
|
|
6236
|
+
}
|
|
6117
6237
|
const requestBody = {
|
|
6118
6238
|
startDate: opts.startDate,
|
|
6119
6239
|
endDate: opts.endDate,
|
|
@@ -6183,6 +6303,7 @@ var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
|
|
|
6183
6303
|
var GA4_DEFAULT_SYNC_DAYS = 30;
|
|
6184
6304
|
var GA4_MAX_SYNC_DAYS = 90;
|
|
6185
6305
|
var GA4_REQUEST_TIMEOUT_MS = 3e4;
|
|
6306
|
+
var GA4_MAX_PAGES = 50;
|
|
6186
6307
|
|
|
6187
6308
|
// ../integration-google-analytics/src/types.ts
|
|
6188
6309
|
var GA4ApiError = class extends Error {
|
|
@@ -6268,7 +6389,8 @@ async function getAccessToken(clientEmail, privateKey) {
|
|
|
6268
6389
|
if (!res.ok) {
|
|
6269
6390
|
const body = await res.text().catch(() => "");
|
|
6270
6391
|
ga4Log("error", "token.failed", { httpStatus: res.status });
|
|
6271
|
-
|
|
6392
|
+
const detail = body.length <= 200 ? body : `${body.slice(0, 200)}... [truncated]`;
|
|
6393
|
+
throw new GA4ApiError(`Failed to get access token: ${detail}`, res.status);
|
|
6272
6394
|
}
|
|
6273
6395
|
const data = await res.json();
|
|
6274
6396
|
return data.access_token;
|
|
@@ -6310,7 +6432,8 @@ async function runReport(accessToken, propertyId, request) {
|
|
|
6310
6432
|
if (!res.ok) {
|
|
6311
6433
|
const body = await res.text();
|
|
6312
6434
|
ga4Log("error", "report.error", { propertyId, httpStatus: res.status });
|
|
6313
|
-
|
|
6435
|
+
const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
|
|
6436
|
+
throw new GA4ApiError(`GA4 API error (${res.status}): ${detail}`, res.status);
|
|
6314
6437
|
}
|
|
6315
6438
|
return await res.json();
|
|
6316
6439
|
}
|
|
@@ -6340,7 +6463,8 @@ async function batchRunReports(accessToken, propertyId, requests) {
|
|
|
6340
6463
|
if (!res.ok) {
|
|
6341
6464
|
const body = await res.text();
|
|
6342
6465
|
ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
|
|
6343
|
-
|
|
6466
|
+
const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
|
|
6467
|
+
throw new GA4ApiError(`GA4 API error (${res.status}): ${detail}`, res.status);
|
|
6344
6468
|
}
|
|
6345
6469
|
const data = await res.json();
|
|
6346
6470
|
return data.reports;
|
|
@@ -6371,7 +6495,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
6371
6495
|
const PAGE_SIZE = 1e4;
|
|
6372
6496
|
const rows = [];
|
|
6373
6497
|
let offset = 0;
|
|
6374
|
-
|
|
6498
|
+
let pageCount = 0;
|
|
6499
|
+
while (pageCount < GA4_MAX_PAGES) {
|
|
6500
|
+
pageCount++;
|
|
6375
6501
|
const request = {
|
|
6376
6502
|
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6377
6503
|
dimensions: [
|
|
@@ -6401,7 +6527,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
6401
6527
|
}
|
|
6402
6528
|
const organicMap = /* @__PURE__ */ new Map();
|
|
6403
6529
|
let organicOffset = 0;
|
|
6404
|
-
|
|
6530
|
+
let organicPageCount = 0;
|
|
6531
|
+
while (organicPageCount < GA4_MAX_PAGES) {
|
|
6532
|
+
organicPageCount++;
|
|
6405
6533
|
const organicRequest = {
|
|
6406
6534
|
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6407
6535
|
dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
|
|
@@ -6515,7 +6643,9 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
|
6515
6643
|
];
|
|
6516
6644
|
for (const [sourceDim, mediumDim, dimLabel] of dimensionPairs) {
|
|
6517
6645
|
let offset = 0;
|
|
6518
|
-
|
|
6646
|
+
let aiRefPageCount = 0;
|
|
6647
|
+
while (aiRefPageCount < GA4_MAX_PAGES) {
|
|
6648
|
+
aiRefPageCount++;
|
|
6519
6649
|
const request = {
|
|
6520
6650
|
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6521
6651
|
dimensions: [
|
|
@@ -6578,6 +6708,66 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
|
6578
6708
|
ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: dedupedRows.length });
|
|
6579
6709
|
return dedupedRows;
|
|
6580
6710
|
}
|
|
6711
|
+
var SOCIAL_CHANNEL_GROUPS = ["Organic Social", "Paid Social"];
|
|
6712
|
+
async function fetchSocialReferrals(accessToken, propertyId, days) {
|
|
6713
|
+
validateAccessToken2(accessToken);
|
|
6714
|
+
validatePropertyId(propertyId);
|
|
6715
|
+
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
6716
|
+
const endDate = /* @__PURE__ */ new Date();
|
|
6717
|
+
const startDate = /* @__PURE__ */ new Date();
|
|
6718
|
+
startDate.setDate(startDate.getDate() - syncDays);
|
|
6719
|
+
ga4Log("info", "fetch-social-referrals.start", { propertyId, days: syncDays });
|
|
6720
|
+
const PAGE_SIZE = 1e3;
|
|
6721
|
+
const rows = [];
|
|
6722
|
+
let offset = 0;
|
|
6723
|
+
while (true) {
|
|
6724
|
+
const request = {
|
|
6725
|
+
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6726
|
+
dimensions: [
|
|
6727
|
+
{ name: "date" },
|
|
6728
|
+
{ name: "sessionSource" },
|
|
6729
|
+
{ name: "sessionMedium" },
|
|
6730
|
+
{ name: "sessionDefaultChannelGroup" }
|
|
6731
|
+
],
|
|
6732
|
+
metrics: [
|
|
6733
|
+
{ name: "sessions" },
|
|
6734
|
+
{ name: "totalUsers" }
|
|
6735
|
+
],
|
|
6736
|
+
dimensionFilter: {
|
|
6737
|
+
orGroup: {
|
|
6738
|
+
expressions: SOCIAL_CHANNEL_GROUPS.map((value) => ({
|
|
6739
|
+
filter: {
|
|
6740
|
+
fieldName: "sessionDefaultChannelGroup",
|
|
6741
|
+
stringFilter: { matchType: "EXACT", 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
|
+
channelGroup: row.dimensionValues[3].value
|
|
6757
|
+
}));
|
|
6758
|
+
rows.push(...pageRows);
|
|
6759
|
+
const totalRows = response.rowCount ?? 0;
|
|
6760
|
+
offset += pageRows.length;
|
|
6761
|
+
if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
|
|
6762
|
+
}
|
|
6763
|
+
for (const row of rows) {
|
|
6764
|
+
if (row.date.length === 8 && !row.date.includes("-")) {
|
|
6765
|
+
row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
|
|
6766
|
+
}
|
|
6767
|
+
}
|
|
6768
|
+
ga4Log("info", "fetch-social-referrals.done", { propertyId, rowCount: rows.length });
|
|
6769
|
+
return rows;
|
|
6770
|
+
}
|
|
6581
6771
|
|
|
6582
6772
|
// ../api-routes/src/google.ts
|
|
6583
6773
|
function signState(payload, secret) {
|
|
@@ -7380,7 +7570,8 @@ async function bingFetch(apiKey, endpoint, opts) {
|
|
|
7380
7570
|
if (!res.ok) {
|
|
7381
7571
|
const body = await res.text();
|
|
7382
7572
|
bingClientLog("error", "http.error", { endpoint, method, httpStatus: res.status });
|
|
7383
|
-
|
|
7573
|
+
const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
|
|
7574
|
+
throw new BingApiError(`Bing API error (${res.status}): ${detail}`, res.status);
|
|
7384
7575
|
}
|
|
7385
7576
|
const text = await res.text();
|
|
7386
7577
|
if (!text || text.trim() === "") {
|
|
@@ -7422,6 +7613,12 @@ async function submitUrlBatch(apiKey, siteUrl, urls) {
|
|
|
7422
7613
|
validateApiKey(apiKey);
|
|
7423
7614
|
validateSiteUrl2(siteUrl);
|
|
7424
7615
|
validateUrls(urls);
|
|
7616
|
+
if (urls.length > BING_SUBMIT_URL_DAILY_LIMIT) {
|
|
7617
|
+
throw new BingApiError(
|
|
7618
|
+
`URL batch exceeds daily limit of ${BING_SUBMIT_URL_DAILY_LIMIT}. Got ${urls.length} URLs.`,
|
|
7619
|
+
400
|
|
7620
|
+
);
|
|
7621
|
+
}
|
|
7425
7622
|
for (let i = 0; i < urls.length; i += BING_SUBMIT_URL_BATCH_LIMIT) {
|
|
7426
7623
|
const batch = urls.slice(i, i + BING_SUBMIT_URL_BATCH_LIMIT);
|
|
7427
7624
|
await bingFetch(apiKey, "SubmitUrlbatch", {
|
|
@@ -7624,6 +7821,22 @@ async function bingRoutes(app, opts) {
|
|
|
7624
7821
|
anchorCount: r.anchorCount ?? null,
|
|
7625
7822
|
discoveryDate: r.discoveryDate ?? null
|
|
7626
7823
|
});
|
|
7824
|
+
if (total > 0) {
|
|
7825
|
+
const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
7826
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7827
|
+
app.db.insert(bingCoverageSnapshots).values({
|
|
7828
|
+
id: crypto15.randomUUID(),
|
|
7829
|
+
projectId: project.id,
|
|
7830
|
+
date: snapshotDate,
|
|
7831
|
+
indexed,
|
|
7832
|
+
notIndexed,
|
|
7833
|
+
unknown,
|
|
7834
|
+
createdAt: now
|
|
7835
|
+
}).onConflictDoUpdate({
|
|
7836
|
+
target: [bingCoverageSnapshots.projectId, bingCoverageSnapshots.date],
|
|
7837
|
+
set: { indexed, notIndexed, unknown, createdAt: now }
|
|
7838
|
+
}).run();
|
|
7839
|
+
}
|
|
7627
7840
|
return {
|
|
7628
7841
|
summary: {
|
|
7629
7842
|
total,
|
|
@@ -7638,6 +7851,20 @@ async function bingRoutes(app, opts) {
|
|
|
7638
7851
|
unknown: unknownUrls.map(formatRow)
|
|
7639
7852
|
};
|
|
7640
7853
|
});
|
|
7854
|
+
app.get("/projects/:name/bing/coverage/history", async (request, reply) => {
|
|
7855
|
+
const store = requireConnectionStore(reply);
|
|
7856
|
+
if (!store) return;
|
|
7857
|
+
const project = resolveProject(app.db, request.params.name);
|
|
7858
|
+
const parsed = parseInt(request.query.limit ?? "90", 10);
|
|
7859
|
+
const limit = Number.isNaN(parsed) || parsed <= 0 ? 90 : parsed;
|
|
7860
|
+
const rows = app.db.select().from(bingCoverageSnapshots).where(eq15(bingCoverageSnapshots.projectId, project.id)).orderBy(desc6(bingCoverageSnapshots.date)).limit(limit).all();
|
|
7861
|
+
return rows.map((r) => ({
|
|
7862
|
+
date: r.date,
|
|
7863
|
+
indexed: r.indexed,
|
|
7864
|
+
notIndexed: r.notIndexed,
|
|
7865
|
+
unknown: r.unknown
|
|
7866
|
+
}));
|
|
7867
|
+
});
|
|
7641
7868
|
app.get("/projects/:name/bing/inspections", async (request, reply) => {
|
|
7642
7869
|
const store = requireConnectionStore(reply);
|
|
7643
7870
|
if (!store) return;
|
|
@@ -8163,6 +8390,7 @@ async function ga4Routes(app, opts) {
|
|
|
8163
8390
|
app.db.delete(gaTrafficSnapshots).where(eq17(gaTrafficSnapshots.projectId, project.id)).run();
|
|
8164
8391
|
app.db.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
|
|
8165
8392
|
app.db.delete(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).run();
|
|
8393
|
+
app.db.delete(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).run();
|
|
8166
8394
|
const propertyId = saConn?.propertyId ?? oauthConn?.propertyId ?? null;
|
|
8167
8395
|
opts.ga4CredentialStore?.deleteConnection(project.name);
|
|
8168
8396
|
opts.googleConnectionStore?.deleteConnection(project.canonicalDomain, "ga4");
|
|
@@ -8197,17 +8425,37 @@ async function ga4Routes(app, opts) {
|
|
|
8197
8425
|
app.post("/projects/:name/ga/sync", async (request, _reply) => {
|
|
8198
8426
|
const project = resolveProject(app.db, request.params.name);
|
|
8199
8427
|
const days = request.body?.days ?? 30;
|
|
8428
|
+
const only = request.body?.only;
|
|
8429
|
+
const validOnlyValues = ["traffic", "ai", "social"];
|
|
8430
|
+
if (only !== void 0 && !validOnlyValues.includes(only)) {
|
|
8431
|
+
throw validationError(`Invalid "only" value "${only}". Must be one of: ${validOnlyValues.join(", ")}`);
|
|
8432
|
+
}
|
|
8433
|
+
const syncTraffic = !only || only === "traffic";
|
|
8434
|
+
const syncAi = !only || only === "ai";
|
|
8435
|
+
const syncSocial = !only || only === "social";
|
|
8436
|
+
const syncSummary = !only;
|
|
8200
8437
|
const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
|
|
8201
|
-
let rows;
|
|
8438
|
+
let rows = [];
|
|
8202
8439
|
let summary;
|
|
8203
|
-
let aiReferrals;
|
|
8440
|
+
let aiReferrals = [];
|
|
8441
|
+
let socialReferrals = [];
|
|
8204
8442
|
try {
|
|
8205
|
-
;
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
]
|
|
8443
|
+
const fetches = [fetchAggregateSummary(accessToken, propertyId, days)];
|
|
8444
|
+
if (syncTraffic) fetches.push(fetchTrafficByLandingPage(accessToken, propertyId, days));
|
|
8445
|
+
if (syncAi) fetches.push(fetchAiReferrals(accessToken, propertyId, days));
|
|
8446
|
+
if (syncSocial) fetches.push(fetchSocialReferrals(accessToken, propertyId, days));
|
|
8447
|
+
const results = await Promise.all(fetches);
|
|
8448
|
+
summary = results[0];
|
|
8449
|
+
let idx = 1;
|
|
8450
|
+
if (syncTraffic) {
|
|
8451
|
+
rows = results[idx++];
|
|
8452
|
+
}
|
|
8453
|
+
if (syncAi) {
|
|
8454
|
+
aiReferrals = results[idx++];
|
|
8455
|
+
}
|
|
8456
|
+
if (syncSocial) {
|
|
8457
|
+
socialReferrals = results[idx++];
|
|
8458
|
+
}
|
|
8211
8459
|
} catch (e) {
|
|
8212
8460
|
const msg = e instanceof Error ? e.message : String(e);
|
|
8213
8461
|
gaLog("error", "sync.fetch-failed", { projectId: project.id, error: msg });
|
|
@@ -8215,14 +8463,14 @@ async function ga4Routes(app, opts) {
|
|
|
8215
8463
|
}
|
|
8216
8464
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
8217
8465
|
app.db.transaction((tx) => {
|
|
8218
|
-
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8466
|
+
if (syncTraffic) {
|
|
8467
|
+
tx.delete(gaTrafficSnapshots).where(
|
|
8468
|
+
and6(
|
|
8469
|
+
eq17(gaTrafficSnapshots.projectId, project.id),
|
|
8470
|
+
sql3`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
|
|
8471
|
+
sql3`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
|
|
8472
|
+
)
|
|
8473
|
+
).run();
|
|
8226
8474
|
for (const row of rows) {
|
|
8227
8475
|
tx.insert(gaTrafficSnapshots).values({
|
|
8228
8476
|
id: crypto16.randomUUID(),
|
|
@@ -8236,14 +8484,14 @@ async function ga4Routes(app, opts) {
|
|
|
8236
8484
|
}).run();
|
|
8237
8485
|
}
|
|
8238
8486
|
}
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8487
|
+
if (syncAi) {
|
|
8488
|
+
tx.delete(gaAiReferrals).where(
|
|
8489
|
+
and6(
|
|
8490
|
+
eq17(gaAiReferrals.projectId, project.id),
|
|
8491
|
+
sql3`${gaAiReferrals.date} >= ${summary.periodStart}`,
|
|
8492
|
+
sql3`${gaAiReferrals.date} <= ${summary.periodEnd}`
|
|
8493
|
+
)
|
|
8494
|
+
).run();
|
|
8247
8495
|
for (const row of aiReferrals) {
|
|
8248
8496
|
tx.insert(gaAiReferrals).values({
|
|
8249
8497
|
id: crypto16.randomUUID(),
|
|
@@ -8258,31 +8506,60 @@ async function ga4Routes(app, opts) {
|
|
|
8258
8506
|
}).run();
|
|
8259
8507
|
}
|
|
8260
8508
|
}
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
|
|
8267
|
-
|
|
8268
|
-
|
|
8269
|
-
|
|
8270
|
-
|
|
8271
|
-
|
|
8509
|
+
if (syncSocial) {
|
|
8510
|
+
tx.delete(gaSocialReferrals).where(
|
|
8511
|
+
and6(
|
|
8512
|
+
eq17(gaSocialReferrals.projectId, project.id),
|
|
8513
|
+
sql3`${gaSocialReferrals.date} >= ${summary.periodStart}`,
|
|
8514
|
+
sql3`${gaSocialReferrals.date} <= ${summary.periodEnd}`
|
|
8515
|
+
)
|
|
8516
|
+
).run();
|
|
8517
|
+
for (const row of socialReferrals) {
|
|
8518
|
+
tx.insert(gaSocialReferrals).values({
|
|
8519
|
+
id: crypto16.randomUUID(),
|
|
8520
|
+
projectId: project.id,
|
|
8521
|
+
date: row.date,
|
|
8522
|
+
source: row.source,
|
|
8523
|
+
medium: row.medium,
|
|
8524
|
+
channelGroup: row.channelGroup,
|
|
8525
|
+
sessions: row.sessions,
|
|
8526
|
+
users: row.users,
|
|
8527
|
+
syncedAt: now
|
|
8528
|
+
}).run();
|
|
8529
|
+
}
|
|
8530
|
+
}
|
|
8531
|
+
if (syncSummary) {
|
|
8532
|
+
tx.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
|
|
8533
|
+
tx.insert(gaTrafficSummaries).values({
|
|
8534
|
+
id: crypto16.randomUUID(),
|
|
8535
|
+
projectId: project.id,
|
|
8536
|
+
periodStart: summary.periodStart,
|
|
8537
|
+
periodEnd: summary.periodEnd,
|
|
8538
|
+
totalSessions: summary.totalSessions,
|
|
8539
|
+
totalOrganicSessions: summary.totalOrganicSessions,
|
|
8540
|
+
totalUsers: summary.totalUsers,
|
|
8541
|
+
syncedAt: now
|
|
8542
|
+
}).run();
|
|
8543
|
+
}
|
|
8272
8544
|
});
|
|
8545
|
+
const syncedComponents = only ? [only, ...only !== "social" && only !== "ai" && only !== "traffic" ? [] : []] : void 0;
|
|
8273
8546
|
gaLog("info", "sync.complete", {
|
|
8274
8547
|
projectId: project.id,
|
|
8275
8548
|
rowCount: rows.length,
|
|
8276
8549
|
aiReferralCount: aiReferrals.length,
|
|
8550
|
+
socialReferralCount: socialReferrals.length,
|
|
8277
8551
|
days,
|
|
8278
|
-
totalUsers: summary.totalUsers
|
|
8552
|
+
totalUsers: summary.totalUsers,
|
|
8553
|
+
...only ? { only } : {}
|
|
8279
8554
|
});
|
|
8280
8555
|
return {
|
|
8281
8556
|
synced: true,
|
|
8282
8557
|
rowCount: rows.length,
|
|
8283
8558
|
aiReferralCount: aiReferrals.length,
|
|
8559
|
+
socialReferralCount: socialReferrals.length,
|
|
8284
8560
|
days,
|
|
8285
|
-
syncedAt: now
|
|
8561
|
+
syncedAt: now,
|
|
8562
|
+
...syncedComponents ? { syncedComponents } : {}
|
|
8286
8563
|
};
|
|
8287
8564
|
});
|
|
8288
8565
|
app.get("/projects/:name/ga/traffic", async (request, _reply) => {
|
|
@@ -8320,9 +8597,21 @@ async function ga4Routes(app, opts) {
|
|
|
8320
8597
|
GROUP BY date, source, medium
|
|
8321
8598
|
)`
|
|
8322
8599
|
).get();
|
|
8600
|
+
const socialReferrals = app.db.select({
|
|
8601
|
+
source: gaSocialReferrals.source,
|
|
8602
|
+
medium: gaSocialReferrals.medium,
|
|
8603
|
+
channelGroup: gaSocialReferrals.channelGroup,
|
|
8604
|
+
sessions: sql3`SUM(${gaSocialReferrals.sessions})`,
|
|
8605
|
+
users: sql3`SUM(${gaSocialReferrals.users})`
|
|
8606
|
+
}).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).groupBy(gaSocialReferrals.source, gaSocialReferrals.medium, gaSocialReferrals.channelGroup).orderBy(sql3`SUM(${gaSocialReferrals.sessions}) DESC`).all();
|
|
8607
|
+
const socialTotals = app.db.select({
|
|
8608
|
+
sessions: sql3`SUM(${gaSocialReferrals.sessions})`,
|
|
8609
|
+
users: sql3`SUM(${gaSocialReferrals.users})`
|
|
8610
|
+
}).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).get();
|
|
8323
8611
|
const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).orderBy(desc7(gaTrafficSummaries.syncedAt)).limit(1).get();
|
|
8612
|
+
const total = summary?.totalSessions ?? 0;
|
|
8324
8613
|
return {
|
|
8325
|
-
totalSessions:
|
|
8614
|
+
totalSessions: total,
|
|
8326
8615
|
totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
|
|
8327
8616
|
totalUsers: summary?.totalUsers ?? 0,
|
|
8328
8617
|
topPages: rows.map((r) => ({
|
|
@@ -8340,6 +8629,18 @@ async function ga4Routes(app, opts) {
|
|
|
8340
8629
|
})),
|
|
8341
8630
|
aiSessionsDeduped: aiDeduped?.sessions ?? 0,
|
|
8342
8631
|
aiUsersDeduped: aiDeduped?.users ?? 0,
|
|
8632
|
+
socialReferrals: socialReferrals.map((r) => ({
|
|
8633
|
+
source: r.source,
|
|
8634
|
+
medium: r.medium,
|
|
8635
|
+
channelGroup: r.channelGroup,
|
|
8636
|
+
sessions: r.sessions ?? 0,
|
|
8637
|
+
users: r.users ?? 0
|
|
8638
|
+
})),
|
|
8639
|
+
socialSessions: socialTotals?.sessions ?? 0,
|
|
8640
|
+
socialUsers: socialTotals?.users ?? 0,
|
|
8641
|
+
organicSharePct: total > 0 ? Math.round((summary?.totalOrganicSessions ?? 0) / total * 100) : 0,
|
|
8642
|
+
aiSharePct: total > 0 ? Math.round((aiDeduped?.sessions ?? 0) / total * 100) : 0,
|
|
8643
|
+
socialSharePct: total > 0 ? Math.round((socialTotals?.sessions ?? 0) / total * 100) : 0,
|
|
8343
8644
|
lastSyncedAt: latestSync?.syncedAt ?? null
|
|
8344
8645
|
};
|
|
8345
8646
|
});
|
|
@@ -8356,6 +8657,146 @@ async function ga4Routes(app, opts) {
|
|
|
8356
8657
|
}).from(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).orderBy(gaAiReferrals.date).all();
|
|
8357
8658
|
return rows;
|
|
8358
8659
|
});
|
|
8660
|
+
app.get("/projects/:name/ga/social-referral-history", async (request, _reply) => {
|
|
8661
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8662
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
8663
|
+
const rows = app.db.select({
|
|
8664
|
+
date: gaSocialReferrals.date,
|
|
8665
|
+
source: gaSocialReferrals.source,
|
|
8666
|
+
medium: gaSocialReferrals.medium,
|
|
8667
|
+
channelGroup: gaSocialReferrals.channelGroup,
|
|
8668
|
+
sessions: gaSocialReferrals.sessions,
|
|
8669
|
+
users: gaSocialReferrals.users
|
|
8670
|
+
}).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).orderBy(gaSocialReferrals.date).all();
|
|
8671
|
+
return rows;
|
|
8672
|
+
});
|
|
8673
|
+
app.get("/projects/:name/ga/social-referral-trend", async (request, _reply) => {
|
|
8674
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8675
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
8676
|
+
const today = /* @__PURE__ */ new Date();
|
|
8677
|
+
const fmt = (d) => d.toISOString().split("T")[0];
|
|
8678
|
+
const daysAgo2 = (n) => {
|
|
8679
|
+
const d = new Date(today);
|
|
8680
|
+
d.setDate(d.getDate() - n);
|
|
8681
|
+
return fmt(d);
|
|
8682
|
+
};
|
|
8683
|
+
const sumSocial = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaSocialReferrals.sessions}), 0)` }).from(gaSocialReferrals).where(and6(
|
|
8684
|
+
eq17(gaSocialReferrals.projectId, project.id),
|
|
8685
|
+
sql3`${gaSocialReferrals.date} >= ${from}`,
|
|
8686
|
+
sql3`${gaSocialReferrals.date} < ${to}`
|
|
8687
|
+
)).get();
|
|
8688
|
+
const current7d = sumSocial(daysAgo2(7), fmt(today));
|
|
8689
|
+
const prev7d = sumSocial(daysAgo2(14), daysAgo2(7));
|
|
8690
|
+
const current30d = sumSocial(daysAgo2(30), fmt(today));
|
|
8691
|
+
const prev30d = sumSocial(daysAgo2(60), daysAgo2(30));
|
|
8692
|
+
const pct = (cur, prev) => prev === 0 ? null : Math.round((cur - prev) / prev * 100);
|
|
8693
|
+
const sourceCurrent = app.db.select({
|
|
8694
|
+
source: gaSocialReferrals.source,
|
|
8695
|
+
sessions: sql3`SUM(${gaSocialReferrals.sessions})`
|
|
8696
|
+
}).from(gaSocialReferrals).where(and6(
|
|
8697
|
+
eq17(gaSocialReferrals.projectId, project.id),
|
|
8698
|
+
sql3`${gaSocialReferrals.date} >= ${daysAgo2(7)}`,
|
|
8699
|
+
sql3`${gaSocialReferrals.date} < ${fmt(today)}`
|
|
8700
|
+
)).groupBy(gaSocialReferrals.source).all();
|
|
8701
|
+
const sourcePrev = app.db.select({
|
|
8702
|
+
source: gaSocialReferrals.source,
|
|
8703
|
+
sessions: sql3`SUM(${gaSocialReferrals.sessions})`
|
|
8704
|
+
}).from(gaSocialReferrals).where(and6(
|
|
8705
|
+
eq17(gaSocialReferrals.projectId, project.id),
|
|
8706
|
+
sql3`${gaSocialReferrals.date} >= ${daysAgo2(14)}`,
|
|
8707
|
+
sql3`${gaSocialReferrals.date} < ${daysAgo2(7)}`
|
|
8708
|
+
)).groupBy(gaSocialReferrals.source).all();
|
|
8709
|
+
const prevMap = new Map(sourcePrev.map((r) => [r.source, r.sessions]));
|
|
8710
|
+
let biggestMover = null;
|
|
8711
|
+
let maxDelta = 0;
|
|
8712
|
+
for (const row of sourceCurrent) {
|
|
8713
|
+
const prev = prevMap.get(row.source) ?? 0;
|
|
8714
|
+
const delta = Math.abs(row.sessions - prev);
|
|
8715
|
+
if (delta > maxDelta) {
|
|
8716
|
+
maxDelta = delta;
|
|
8717
|
+
biggestMover = {
|
|
8718
|
+
source: row.source,
|
|
8719
|
+
sessions7d: row.sessions,
|
|
8720
|
+
sessionsPrev7d: prev,
|
|
8721
|
+
changePct: pct(row.sessions, prev) ?? (row.sessions > 0 ? 100 : 0)
|
|
8722
|
+
};
|
|
8723
|
+
}
|
|
8724
|
+
}
|
|
8725
|
+
return {
|
|
8726
|
+
socialSessions7d: current7d?.sessions ?? 0,
|
|
8727
|
+
socialSessionsPrev7d: prev7d?.sessions ?? 0,
|
|
8728
|
+
trend7dPct: pct(current7d?.sessions ?? 0, prev7d?.sessions ?? 0),
|
|
8729
|
+
socialSessions30d: current30d?.sessions ?? 0,
|
|
8730
|
+
socialSessionsPrev30d: prev30d?.sessions ?? 0,
|
|
8731
|
+
trend30dPct: pct(current30d?.sessions ?? 0, prev30d?.sessions ?? 0),
|
|
8732
|
+
biggestMover
|
|
8733
|
+
};
|
|
8734
|
+
});
|
|
8735
|
+
app.get("/projects/:name/ga/attribution-trend", async (request, _reply) => {
|
|
8736
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8737
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
8738
|
+
const today = /* @__PURE__ */ new Date();
|
|
8739
|
+
const fmt = (d) => d.toISOString().split("T")[0];
|
|
8740
|
+
const daysAgo2 = (n) => {
|
|
8741
|
+
const d = new Date(today);
|
|
8742
|
+
d.setDate(d.getDate() - n);
|
|
8743
|
+
return fmt(d);
|
|
8744
|
+
};
|
|
8745
|
+
const pct = (cur, prev) => prev === 0 ? null : Math.round((cur - prev) / prev * 100);
|
|
8746
|
+
const sumTotal = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaTrafficSnapshots.sessions}), 0)` }).from(gaTrafficSnapshots).where(and6(eq17(gaTrafficSnapshots.projectId, project.id), sql3`${gaTrafficSnapshots.date} >= ${from}`, sql3`${gaTrafficSnapshots.date} < ${to}`)).get();
|
|
8747
|
+
const sumOrganic = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaTrafficSnapshots.organicSessions}), 0)` }).from(gaTrafficSnapshots).where(and6(eq17(gaTrafficSnapshots.projectId, project.id), sql3`${gaTrafficSnapshots.date} >= ${from}`, sql3`${gaTrafficSnapshots.date} < ${to}`)).get();
|
|
8748
|
+
const sumAi = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
|
|
8749
|
+
SELECT date, source, medium, MAX(sessions) AS max_sessions
|
|
8750
|
+
FROM ga_ai_referrals
|
|
8751
|
+
WHERE project_id = ${project.id} AND date >= ${from} AND date < ${to}
|
|
8752
|
+
GROUP BY date, source, medium
|
|
8753
|
+
)`).get();
|
|
8754
|
+
const sumSocial = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaSocialReferrals.sessions}), 0)` }).from(gaSocialReferrals).where(and6(eq17(gaSocialReferrals.projectId, project.id), sql3`${gaSocialReferrals.date} >= ${from}`, sql3`${gaSocialReferrals.date} < ${to}`)).get();
|
|
8755
|
+
const todayStr = fmt(today);
|
|
8756
|
+
const buildTrend = (sum) => {
|
|
8757
|
+
const c7 = sum(daysAgo2(7), todayStr)?.sessions ?? 0;
|
|
8758
|
+
const p7 = sum(daysAgo2(14), daysAgo2(7))?.sessions ?? 0;
|
|
8759
|
+
const c30 = sum(daysAgo2(30), todayStr)?.sessions ?? 0;
|
|
8760
|
+
const p30 = sum(daysAgo2(60), daysAgo2(30))?.sessions ?? 0;
|
|
8761
|
+
return { sessions7d: c7, sessionsPrev7d: p7, trend7dPct: pct(c7, p7), sessions30d: c30, sessionsPrev30d: p30, trend30dPct: pct(c30, p30) };
|
|
8762
|
+
};
|
|
8763
|
+
const aiSourceCurrent = app.db.select({ source: sql3`source`, sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
|
|
8764
|
+
SELECT date, source, medium, MAX(sessions) AS max_sessions
|
|
8765
|
+
FROM ga_ai_referrals
|
|
8766
|
+
WHERE project_id = ${project.id} AND date >= ${daysAgo2(7)} AND date < ${todayStr}
|
|
8767
|
+
GROUP BY date, source, medium
|
|
8768
|
+
)`).groupBy(sql3`source`).all();
|
|
8769
|
+
const aiSourcePrev = app.db.select({ source: sql3`source`, sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
|
|
8770
|
+
SELECT date, source, medium, MAX(sessions) AS max_sessions
|
|
8771
|
+
FROM ga_ai_referrals
|
|
8772
|
+
WHERE project_id = ${project.id} AND date >= ${daysAgo2(14)} AND date < ${daysAgo2(7)}
|
|
8773
|
+
GROUP BY date, source, medium
|
|
8774
|
+
)`).groupBy(sql3`source`).all();
|
|
8775
|
+
const findBiggestMover = (current, prev) => {
|
|
8776
|
+
const prevMap = new Map(prev.map((r) => [r.source, r.sessions]));
|
|
8777
|
+
let mover = null;
|
|
8778
|
+
let maxDelta = 0;
|
|
8779
|
+
for (const row of current) {
|
|
8780
|
+
const p = prevMap.get(row.source) ?? 0;
|
|
8781
|
+
const delta = Math.abs(row.sessions - p);
|
|
8782
|
+
if (delta > maxDelta) {
|
|
8783
|
+
maxDelta = delta;
|
|
8784
|
+
mover = { source: row.source, sessions7d: row.sessions, sessionsPrev7d: p, changePct: pct(row.sessions, p) ?? (row.sessions > 0 ? 100 : 0) };
|
|
8785
|
+
}
|
|
8786
|
+
}
|
|
8787
|
+
return mover;
|
|
8788
|
+
};
|
|
8789
|
+
const socialSourceCurrent = app.db.select({ source: gaSocialReferrals.source, sessions: sql3`SUM(${gaSocialReferrals.sessions})` }).from(gaSocialReferrals).where(and6(eq17(gaSocialReferrals.projectId, project.id), sql3`${gaSocialReferrals.date} >= ${daysAgo2(7)}`, sql3`${gaSocialReferrals.date} < ${todayStr}`)).groupBy(gaSocialReferrals.source).all();
|
|
8790
|
+
const socialSourcePrev = app.db.select({ source: gaSocialReferrals.source, sessions: sql3`SUM(${gaSocialReferrals.sessions})` }).from(gaSocialReferrals).where(and6(eq17(gaSocialReferrals.projectId, project.id), sql3`${gaSocialReferrals.date} >= ${daysAgo2(14)}`, sql3`${gaSocialReferrals.date} < ${daysAgo2(7)}`)).groupBy(gaSocialReferrals.source).all();
|
|
8791
|
+
return {
|
|
8792
|
+
total: buildTrend(sumTotal),
|
|
8793
|
+
organic: buildTrend(sumOrganic),
|
|
8794
|
+
ai: buildTrend(sumAi),
|
|
8795
|
+
social: buildTrend(sumSocial),
|
|
8796
|
+
aiBiggestMover: findBiggestMover(aiSourceCurrent, aiSourcePrev),
|
|
8797
|
+
socialBiggestMover: findBiggestMover(socialSourceCurrent, socialSourcePrev)
|
|
8798
|
+
};
|
|
8799
|
+
});
|
|
8359
8800
|
app.get("/projects/:name/ga/session-history", async (request, _reply) => {
|
|
8360
8801
|
const project = resolveProject(app.db, request.params.name);
|
|
8361
8802
|
requireGa4Connection(opts, project.name, project.canonicalDomain);
|