@ainyc/canonry 1.40.1 → 1.44.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-BAzKj_9S.js +281 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-FOWWBLXD.js → chunk-AATIMNOX.js} +39 -2
- package/dist/{chunk-FXHVGU5S.js → chunk-C3LF36DQ.js} +633 -135
- package/dist/cli.js +287 -11
- package/dist/index.js +2 -2
- package/dist/{intelligence-service-PDZOIB7L.js → intelligence-service-36ERONKI.js} +1 -1
- package/package.json +6 -6
- package/assets/assets/index-DU7KOHur.js +0 -281
- package/assets/assets/index-Djm1st6N.css +0 -1
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
competitors,
|
|
7
7
|
createLogger,
|
|
8
8
|
gaAiReferrals,
|
|
9
|
+
gaSocialReferrals,
|
|
9
10
|
gaTrafficSnapshots,
|
|
10
11
|
gaTrafficSummaries,
|
|
11
12
|
gscCoverageSnapshots,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
runs,
|
|
22
23
|
schedules,
|
|
23
24
|
usageCounters
|
|
24
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-AATIMNOX.js";
|
|
25
26
|
|
|
26
27
|
// src/config.ts
|
|
27
28
|
import fs from "fs";
|
|
@@ -978,6 +979,11 @@ var scheduleUpsertRequestSchema = z10.object({
|
|
|
978
979
|
{ message: 'Exactly one of "preset" or "cron" must be provided' }
|
|
979
980
|
);
|
|
980
981
|
|
|
982
|
+
// ../contracts/src/analytics.ts
|
|
983
|
+
import { z as z11 } from "zod";
|
|
984
|
+
var visibilityMetricModeSchema = z11.enum(["answer", "citation"]);
|
|
985
|
+
var VisibilityMetricModes = visibilityMetricModeSchema.enum;
|
|
986
|
+
|
|
981
987
|
// ../contracts/src/source-categories.ts
|
|
982
988
|
var SOURCE_CATEGORY_RULES = [
|
|
983
989
|
// Forums
|
|
@@ -1071,62 +1077,90 @@ function categoryLabel(category) {
|
|
|
1071
1077
|
}
|
|
1072
1078
|
|
|
1073
1079
|
// ../contracts/src/ga.ts
|
|
1074
|
-
import { z as
|
|
1075
|
-
var ga4ConnectionDtoSchema =
|
|
1076
|
-
id:
|
|
1077
|
-
projectId:
|
|
1078
|
-
propertyId:
|
|
1079
|
-
clientEmail:
|
|
1080
|
-
connected:
|
|
1081
|
-
createdAt:
|
|
1082
|
-
updatedAt:
|
|
1080
|
+
import { z as z12 } from "zod";
|
|
1081
|
+
var ga4ConnectionDtoSchema = z12.object({
|
|
1082
|
+
id: z12.string(),
|
|
1083
|
+
projectId: z12.string(),
|
|
1084
|
+
propertyId: z12.string(),
|
|
1085
|
+
clientEmail: z12.string(),
|
|
1086
|
+
connected: z12.boolean(),
|
|
1087
|
+
createdAt: z12.string(),
|
|
1088
|
+
updatedAt: z12.string()
|
|
1083
1089
|
});
|
|
1084
|
-
var ga4TrafficSnapshotDtoSchema =
|
|
1085
|
-
date:
|
|
1086
|
-
landingPage:
|
|
1087
|
-
sessions:
|
|
1088
|
-
organicSessions:
|
|
1089
|
-
users:
|
|
1090
|
+
var ga4TrafficSnapshotDtoSchema = z12.object({
|
|
1091
|
+
date: z12.string(),
|
|
1092
|
+
landingPage: z12.string(),
|
|
1093
|
+
sessions: z12.number(),
|
|
1094
|
+
organicSessions: z12.number(),
|
|
1095
|
+
users: z12.number()
|
|
1090
1096
|
});
|
|
1091
|
-
var ga4SourceDimensionSchema =
|
|
1092
|
-
var ga4AiReferralDtoSchema =
|
|
1093
|
-
source:
|
|
1094
|
-
medium:
|
|
1095
|
-
sessions:
|
|
1096
|
-
users:
|
|
1097
|
+
var ga4SourceDimensionSchema = z12.enum(["session", "first_user", "manual_utm"]);
|
|
1098
|
+
var ga4AiReferralDtoSchema = z12.object({
|
|
1099
|
+
source: z12.string(),
|
|
1100
|
+
medium: z12.string(),
|
|
1101
|
+
sessions: z12.number(),
|
|
1102
|
+
users: z12.number(),
|
|
1097
1103
|
sourceDimension: ga4SourceDimensionSchema
|
|
1098
1104
|
});
|
|
1099
|
-
var
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1105
|
+
var ga4SocialReferralDtoSchema = z12.object({
|
|
1106
|
+
source: z12.string(),
|
|
1107
|
+
medium: z12.string(),
|
|
1108
|
+
sessions: z12.number(),
|
|
1109
|
+
users: z12.number(),
|
|
1110
|
+
/** GA4 default channel group (e.g. 'Organic Social', 'Paid Social') */
|
|
1111
|
+
channelGroup: z12.string()
|
|
1112
|
+
});
|
|
1113
|
+
var ga4TrafficSummaryDtoSchema = z12.object({
|
|
1114
|
+
totalSessions: z12.number(),
|
|
1115
|
+
totalOrganicSessions: z12.number(),
|
|
1116
|
+
totalUsers: z12.number(),
|
|
1117
|
+
topPages: z12.array(z12.object({
|
|
1118
|
+
landingPage: z12.string(),
|
|
1119
|
+
sessions: z12.number(),
|
|
1120
|
+
organicSessions: z12.number(),
|
|
1121
|
+
users: z12.number()
|
|
1108
1122
|
})),
|
|
1109
|
-
aiReferrals:
|
|
1123
|
+
aiReferrals: z12.array(ga4AiReferralDtoSchema),
|
|
1110
1124
|
/** Deduped AI session total: MAX(sessions) per date+source+medium across attribution dimensions, then summed. */
|
|
1111
|
-
aiSessionsDeduped:
|
|
1125
|
+
aiSessionsDeduped: z12.number(),
|
|
1112
1126
|
/** Deduped AI user total: MAX(users) per date+source+medium across attribution dimensions, then summed. */
|
|
1113
|
-
aiUsersDeduped:
|
|
1114
|
-
|
|
1127
|
+
aiUsersDeduped: z12.number(),
|
|
1128
|
+
socialReferrals: z12.array(ga4SocialReferralDtoSchema),
|
|
1129
|
+
/** Total social sessions (session-scoped, no cross-dimension dedup needed). */
|
|
1130
|
+
socialSessions: z12.number(),
|
|
1131
|
+
/** Total social users (session-scoped, no cross-dimension dedup needed). */
|
|
1132
|
+
socialUsers: z12.number(),
|
|
1133
|
+
/** Organic sessions as a percentage of total sessions (0–100, rounded). */
|
|
1134
|
+
organicSharePct: z12.number(),
|
|
1135
|
+
/** Deduped AI sessions as a percentage of total sessions (0–100, rounded). */
|
|
1136
|
+
aiSharePct: z12.number(),
|
|
1137
|
+
/** Social sessions as a percentage of total sessions (0–100, rounded). */
|
|
1138
|
+
socialSharePct: z12.number(),
|
|
1139
|
+
lastSyncedAt: z12.string().nullable()
|
|
1115
1140
|
});
|
|
1116
|
-
var ga4AiReferralHistoryEntrySchema =
|
|
1117
|
-
date:
|
|
1118
|
-
source:
|
|
1119
|
-
medium:
|
|
1120
|
-
sessions:
|
|
1121
|
-
users:
|
|
1141
|
+
var ga4AiReferralHistoryEntrySchema = z12.object({
|
|
1142
|
+
date: z12.string(),
|
|
1143
|
+
source: z12.string(),
|
|
1144
|
+
medium: z12.string(),
|
|
1145
|
+
sessions: z12.number(),
|
|
1146
|
+
users: z12.number(),
|
|
1122
1147
|
/** Which GA4 dimension this row came from: session (sessionSource), first_user (firstUserSource), or manual_utm (utm_source parameter) */
|
|
1123
1148
|
sourceDimension: ga4SourceDimensionSchema
|
|
1124
1149
|
});
|
|
1125
|
-
var
|
|
1126
|
-
date:
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1150
|
+
var ga4SocialReferralHistoryEntrySchema = z12.object({
|
|
1151
|
+
date: z12.string(),
|
|
1152
|
+
source: z12.string(),
|
|
1153
|
+
medium: z12.string(),
|
|
1154
|
+
sessions: z12.number(),
|
|
1155
|
+
users: z12.number(),
|
|
1156
|
+
/** GA4 default channel group (e.g. 'Organic Social', 'Paid Social') */
|
|
1157
|
+
channelGroup: z12.string()
|
|
1158
|
+
});
|
|
1159
|
+
var ga4SessionHistoryEntrySchema = z12.object({
|
|
1160
|
+
date: z12.string(),
|
|
1161
|
+
sessions: z12.number(),
|
|
1162
|
+
organicSessions: z12.number(),
|
|
1163
|
+
users: z12.number()
|
|
1130
1164
|
});
|
|
1131
1165
|
|
|
1132
1166
|
// ../contracts/src/answer-visibility.ts
|
|
@@ -2877,20 +2911,27 @@ async function analyticsRoutes(app) {
|
|
|
2877
2911
|
return reply.send({
|
|
2878
2912
|
window,
|
|
2879
2913
|
buckets: [],
|
|
2880
|
-
overall: { citationRate: 0, cited: 0, total: 0 },
|
|
2914
|
+
overall: { citationRate: 0, cited: 0, total: 0, answerRate: 0, answerMentionedCount: 0 },
|
|
2881
2915
|
byProvider: {},
|
|
2882
2916
|
trend: "stable",
|
|
2917
|
+
answerTrend: "stable",
|
|
2883
2918
|
keywordChanges: []
|
|
2884
2919
|
});
|
|
2885
2920
|
}
|
|
2886
2921
|
const runIds = projectRuns.map((r) => r.id);
|
|
2887
|
-
const
|
|
2922
|
+
const rawSnapshots = app.db.select({
|
|
2888
2923
|
runId: querySnapshots.runId,
|
|
2889
2924
|
keywordId: querySnapshots.keywordId,
|
|
2890
2925
|
provider: querySnapshots.provider,
|
|
2891
2926
|
citationState: querySnapshots.citationState,
|
|
2927
|
+
answerMentioned: querySnapshots.answerMentioned,
|
|
2928
|
+
answerText: querySnapshots.answerText,
|
|
2892
2929
|
createdAt: querySnapshots.createdAt
|
|
2893
2930
|
}).from(querySnapshots).where(inArray2(querySnapshots.runId, runIds)).all();
|
|
2931
|
+
const allSnapshots = rawSnapshots.map((s) => ({
|
|
2932
|
+
...s,
|
|
2933
|
+
resolvedMentioned: resolveSnapshotAnswerMentioned(s, project)
|
|
2934
|
+
}));
|
|
2894
2935
|
const projectKeywords = app.db.select({ id: keywords.id, createdAt: keywords.createdAt }).from(keywords).where(eq10(keywords.projectId, project.id)).all();
|
|
2895
2936
|
const keywordCreatedAt = new Map(projectKeywords.map((k) => [k.id, k.createdAt]));
|
|
2896
2937
|
const overall = computeProviderMetric(allSnapshots);
|
|
@@ -2904,9 +2945,10 @@ async function analyticsRoutes(app) {
|
|
|
2904
2945
|
const spanDays = Math.max(1, Math.ceil((latest.getTime() - earliest.getTime()) / 864e5));
|
|
2905
2946
|
const bucketSize = bucketSizeForSpan(spanDays);
|
|
2906
2947
|
const buckets = computeBuckets(allSnapshots, projectRuns, bucketSize, keywordCreatedAt);
|
|
2907
|
-
const trend = computeTrend(buckets);
|
|
2948
|
+
const trend = computeTrend(buckets, "citationRate");
|
|
2949
|
+
const answerTrend = computeTrend(buckets, "answerRate");
|
|
2908
2950
|
const keywordChanges = computeKeywordChanges(projectKeywords, cutoff);
|
|
2909
|
-
return reply.send({ window, buckets, overall, byProvider, trend, keywordChanges });
|
|
2951
|
+
return reply.send({ window, buckets, overall, byProvider, trend, answerTrend, keywordChanges });
|
|
2910
2952
|
});
|
|
2911
2953
|
app.get("/projects/:name/analytics/gaps", async (request, reply) => {
|
|
2912
2954
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -2914,7 +2956,7 @@ async function analyticsRoutes(app) {
|
|
|
2914
2956
|
const cutoff = windowCutoff(window);
|
|
2915
2957
|
const latestRun = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc3(runs.createdAt)).all().find((r) => r.status === "completed" || r.status === "partial");
|
|
2916
2958
|
if (!latestRun) {
|
|
2917
|
-
return reply.send({ cited: [], gap: [], uncited: [], runId: "", window });
|
|
2959
|
+
return reply.send({ cited: [], gap: [], uncited: [], mentionedKeywords: [], mentionGap: [], notMentioned: [], runId: "", window });
|
|
2918
2960
|
}
|
|
2919
2961
|
const windowRuns = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(runs.createdAt).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
|
|
2920
2962
|
const windowRunIds = windowRuns.map((r) => r.id);
|
|
@@ -2923,25 +2965,34 @@ async function analyticsRoutes(app) {
|
|
|
2923
2965
|
const allWindowSnaps = app.db.select({
|
|
2924
2966
|
keywordId: querySnapshots.keywordId,
|
|
2925
2967
|
runId: querySnapshots.runId,
|
|
2926
|
-
citationState: querySnapshots.citationState
|
|
2968
|
+
citationState: querySnapshots.citationState,
|
|
2969
|
+
answerMentioned: querySnapshots.answerMentioned,
|
|
2970
|
+
answerText: querySnapshots.answerText
|
|
2927
2971
|
}).from(querySnapshots).where(inArray2(querySnapshots.runId, windowRunIds)).all();
|
|
2928
2972
|
for (const s of allWindowSnaps) {
|
|
2929
2973
|
let entry = consistencyMap.get(s.keywordId);
|
|
2930
2974
|
if (!entry) {
|
|
2931
|
-
entry = { citedRuns: /* @__PURE__ */ new Set(), totalRuns: /* @__PURE__ */ new Set() };
|
|
2975
|
+
entry = { citedRuns: /* @__PURE__ */ new Set(), totalRuns: /* @__PURE__ */ new Set(), mentionedRuns: /* @__PURE__ */ new Set() };
|
|
2932
2976
|
consistencyMap.set(s.keywordId, entry);
|
|
2933
2977
|
}
|
|
2934
2978
|
entry.totalRuns.add(s.runId);
|
|
2935
2979
|
if (s.citationState === "cited") entry.citedRuns.add(s.runId);
|
|
2980
|
+
if (resolveSnapshotAnswerMentioned(s, project)) entry.mentionedRuns.add(s.runId);
|
|
2936
2981
|
}
|
|
2937
2982
|
}
|
|
2938
|
-
const
|
|
2983
|
+
const rawSnapshots = app.db.select({
|
|
2939
2984
|
keywordId: querySnapshots.keywordId,
|
|
2940
2985
|
keyword: keywords.keyword,
|
|
2941
2986
|
provider: querySnapshots.provider,
|
|
2942
2987
|
citationState: querySnapshots.citationState,
|
|
2988
|
+
answerMentioned: querySnapshots.answerMentioned,
|
|
2989
|
+
answerText: querySnapshots.answerText,
|
|
2943
2990
|
competitorOverlap: querySnapshots.competitorOverlap
|
|
2944
2991
|
}).from(querySnapshots).leftJoin(keywords, eq10(querySnapshots.keywordId, keywords.id)).where(eq10(querySnapshots.runId, latestRun.id)).all();
|
|
2992
|
+
const snapshots = rawSnapshots.map((s) => ({
|
|
2993
|
+
...s,
|
|
2994
|
+
resolvedMentioned: resolveSnapshotAnswerMentioned(s, project)
|
|
2995
|
+
}));
|
|
2945
2996
|
const byKeyword = /* @__PURE__ */ new Map();
|
|
2946
2997
|
for (const s of snapshots) {
|
|
2947
2998
|
const key = s.keywordId;
|
|
@@ -2952,14 +3003,24 @@ async function analyticsRoutes(app) {
|
|
|
2952
3003
|
const cited = [];
|
|
2953
3004
|
const gap = [];
|
|
2954
3005
|
const uncited = [];
|
|
3006
|
+
const mentionedKeywords = [];
|
|
3007
|
+
const mentionGap = [];
|
|
3008
|
+
const notMentioned = [];
|
|
2955
3009
|
for (const [keywordId, kwSnapshots] of byKeyword) {
|
|
2956
3010
|
const keyword = kwSnapshots[0]?.keyword ?? "";
|
|
2957
3011
|
const citedProviders = kwSnapshots.filter((s) => s.citationState === "cited").map((s) => s.provider);
|
|
3012
|
+
const mentionedProviders = kwSnapshots.filter((s) => s.resolvedMentioned).map((s) => s.provider);
|
|
2958
3013
|
const competitorsCiting = /* @__PURE__ */ new Set();
|
|
2959
3014
|
for (const s of kwSnapshots) {
|
|
2960
3015
|
const overlap = parseJsonColumn(s.competitorOverlap, []);
|
|
2961
3016
|
for (const c of overlap) competitorsCiting.add(c);
|
|
2962
3017
|
}
|
|
3018
|
+
const cons = consistencyMap.get(keywordId);
|
|
3019
|
+
const consistency = {
|
|
3020
|
+
citedRuns: cons?.citedRuns.size ?? 0,
|
|
3021
|
+
totalRuns: cons?.totalRuns.size ?? 0,
|
|
3022
|
+
mentionedRuns: cons?.mentionedRuns.size ?? 0
|
|
3023
|
+
};
|
|
2963
3024
|
let category;
|
|
2964
3025
|
if (citedProviders.length > 0) {
|
|
2965
3026
|
category = "cited";
|
|
@@ -2968,26 +3029,44 @@ async function analyticsRoutes(app) {
|
|
|
2968
3029
|
} else {
|
|
2969
3030
|
category = "uncited";
|
|
2970
3031
|
}
|
|
2971
|
-
const
|
|
2972
|
-
const entry = {
|
|
3032
|
+
const citationEntry = {
|
|
2973
3033
|
keyword,
|
|
2974
3034
|
keywordId,
|
|
2975
3035
|
category,
|
|
2976
3036
|
providers: citedProviders,
|
|
2977
3037
|
competitorsCiting: [...competitorsCiting],
|
|
2978
|
-
consistency
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
3038
|
+
consistency
|
|
3039
|
+
};
|
|
3040
|
+
if (category === "cited") cited.push(citationEntry);
|
|
3041
|
+
else if (category === "gap") gap.push(citationEntry);
|
|
3042
|
+
else uncited.push(citationEntry);
|
|
3043
|
+
let mentionCategory;
|
|
3044
|
+
if (mentionedProviders.length > 0) {
|
|
3045
|
+
mentionCategory = "cited";
|
|
3046
|
+
} else if (competitorsCiting.size > 0) {
|
|
3047
|
+
mentionCategory = "gap";
|
|
3048
|
+
} else {
|
|
3049
|
+
mentionCategory = "uncited";
|
|
3050
|
+
}
|
|
3051
|
+
const mentionEntry = {
|
|
3052
|
+
keyword,
|
|
3053
|
+
keywordId,
|
|
3054
|
+
category: mentionCategory,
|
|
3055
|
+
providers: mentionedProviders,
|
|
3056
|
+
competitorsCiting: [...competitorsCiting],
|
|
3057
|
+
consistency
|
|
2982
3058
|
};
|
|
2983
|
-
if (
|
|
2984
|
-
else if (
|
|
2985
|
-
else
|
|
3059
|
+
if (mentionCategory === "cited") mentionedKeywords.push(mentionEntry);
|
|
3060
|
+
else if (mentionCategory === "gap") mentionGap.push(mentionEntry);
|
|
3061
|
+
else notMentioned.push(mentionEntry);
|
|
2986
3062
|
}
|
|
2987
3063
|
gap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
|
|
2988
3064
|
cited.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
|
2989
3065
|
uncited.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
|
2990
|
-
|
|
3066
|
+
mentionGap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
|
|
3067
|
+
mentionedKeywords.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
|
3068
|
+
notMentioned.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
|
3069
|
+
return reply.send({ cited, gap, uncited, mentionedKeywords, mentionGap, notMentioned, runId: latestRun.id, window });
|
|
2991
3070
|
});
|
|
2992
3071
|
app.get("/projects/:name/analytics/sources", async (request, reply) => {
|
|
2993
3072
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -3070,10 +3149,13 @@ function bucketSizeForSpan(spanDays) {
|
|
|
3070
3149
|
function computeProviderMetric(snapshots) {
|
|
3071
3150
|
const total = snapshots.length;
|
|
3072
3151
|
const cited = snapshots.filter((s) => s.citationState === "cited").length;
|
|
3152
|
+
const answerMentionedCount = snapshots.filter((s) => s.resolvedMentioned).length;
|
|
3073
3153
|
return {
|
|
3074
3154
|
citationRate: total > 0 ? Math.round(cited / total * 1e4) / 1e4 : 0,
|
|
3075
3155
|
cited,
|
|
3076
|
-
total
|
|
3156
|
+
total,
|
|
3157
|
+
answerRate: total > 0 ? Math.round(answerMentionedCount / total * 1e4) / 1e4 : 0,
|
|
3158
|
+
answerMentionedCount
|
|
3077
3159
|
};
|
|
3078
3160
|
}
|
|
3079
3161
|
function computeBuckets(snapshots, projectRuns, bucketDays, keywordCreatedAt) {
|
|
@@ -3106,7 +3188,9 @@ function computeBuckets(snapshots, projectRuns, bucketDays, keywordCreatedAt) {
|
|
|
3106
3188
|
citationRate: metric.citationRate,
|
|
3107
3189
|
cited: metric.cited,
|
|
3108
3190
|
total: metric.total,
|
|
3109
|
-
keywordCount
|
|
3191
|
+
keywordCount,
|
|
3192
|
+
answerRate: metric.answerRate,
|
|
3193
|
+
answerMentionedCount: metric.answerMentionedCount
|
|
3110
3194
|
});
|
|
3111
3195
|
}
|
|
3112
3196
|
start = end;
|
|
@@ -3128,14 +3212,14 @@ function computeKeywordChanges(projectKeywords, cutoff) {
|
|
|
3128
3212
|
label: `+${count} kp`
|
|
3129
3213
|
}));
|
|
3130
3214
|
}
|
|
3131
|
-
function computeTrend(buckets) {
|
|
3215
|
+
function computeTrend(buckets, rateKey) {
|
|
3132
3216
|
const nonEmpty = buckets.filter((b) => b.total > 0);
|
|
3133
3217
|
if (nonEmpty.length < 2) return "stable";
|
|
3134
3218
|
const mid = Math.floor(nonEmpty.length / 2);
|
|
3135
3219
|
const firstHalf = nonEmpty.slice(0, mid);
|
|
3136
3220
|
const secondHalf = nonEmpty.slice(mid);
|
|
3137
|
-
const avgFirst = firstHalf.reduce((s, b) => s + b
|
|
3138
|
-
const avgSecond = secondHalf.reduce((s, b) => s + b
|
|
3221
|
+
const avgFirst = firstHalf.reduce((s, b) => s + b[rateKey], 0) / firstHalf.length;
|
|
3222
|
+
const avgSecond = secondHalf.reduce((s, b) => s + b[rateKey], 0) / secondHalf.length;
|
|
3139
3223
|
const diff = avgSecond - avgFirst;
|
|
3140
3224
|
if (diff > 0.05) return "improving";
|
|
3141
3225
|
if (diff < -0.05) return "declining";
|
|
@@ -5241,6 +5325,42 @@ var routeCatalog = [
|
|
|
5241
5325
|
404: { description: "Project not found." }
|
|
5242
5326
|
}
|
|
5243
5327
|
},
|
|
5328
|
+
{
|
|
5329
|
+
method: "get",
|
|
5330
|
+
path: "/api/v1/projects/{name}/ga/social-referral-history",
|
|
5331
|
+
summary: "Get social media referral sessions per day grouped by source",
|
|
5332
|
+
tags: ["ga4"],
|
|
5333
|
+
parameters: [nameParameter],
|
|
5334
|
+
responses: {
|
|
5335
|
+
200: { description: "Social referral history returned." },
|
|
5336
|
+
400: { description: "GA4 is not connected." },
|
|
5337
|
+
404: { description: "Project not found." }
|
|
5338
|
+
}
|
|
5339
|
+
},
|
|
5340
|
+
{
|
|
5341
|
+
method: "get",
|
|
5342
|
+
path: "/api/v1/projects/{name}/ga/social-referral-trend",
|
|
5343
|
+
summary: "Get social referral trend (7d/30d) with biggest mover",
|
|
5344
|
+
tags: ["ga4"],
|
|
5345
|
+
parameters: [nameParameter],
|
|
5346
|
+
responses: {
|
|
5347
|
+
200: { description: "Social referral trend returned." },
|
|
5348
|
+
400: { description: "GA4 is not connected." },
|
|
5349
|
+
404: { description: "Project not found." }
|
|
5350
|
+
}
|
|
5351
|
+
},
|
|
5352
|
+
{
|
|
5353
|
+
method: "get",
|
|
5354
|
+
path: "/api/v1/projects/{name}/ga/attribution-trend",
|
|
5355
|
+
summary: "Get per-channel attribution trends (7d/30d) for organic, AI, and social",
|
|
5356
|
+
tags: ["ga4"],
|
|
5357
|
+
parameters: [nameParameter],
|
|
5358
|
+
responses: {
|
|
5359
|
+
200: { description: "Attribution trend returned." },
|
|
5360
|
+
400: { description: "GA4 is not connected." },
|
|
5361
|
+
404: { description: "Project not found." }
|
|
5362
|
+
}
|
|
5363
|
+
},
|
|
5244
5364
|
{
|
|
5245
5365
|
method: "get",
|
|
5246
5366
|
path: "/api/v1/projects/{name}/ga/session-history",
|
|
@@ -5836,6 +5956,7 @@ var GSC_DATA_LAG_DAYS = 3;
|
|
|
5836
5956
|
var INDEXING_API_BASE = "https://indexing.googleapis.com/v3";
|
|
5837
5957
|
var INDEXING_API_DAILY_LIMIT = 200;
|
|
5838
5958
|
var GOOGLE_REQUEST_TIMEOUT_MS = 3e4;
|
|
5959
|
+
var GSC_MAX_PAGES = 40;
|
|
5839
5960
|
|
|
5840
5961
|
// ../integration-google/src/types.ts
|
|
5841
5962
|
var GoogleAuthError = class extends Error {
|
|
@@ -5934,7 +6055,15 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
|
|
|
5934
6055
|
});
|
|
5935
6056
|
if (!res.ok) {
|
|
5936
6057
|
const body = await res.text();
|
|
5937
|
-
|
|
6058
|
+
let detail = "";
|
|
6059
|
+
try {
|
|
6060
|
+
const parsed = JSON.parse(body);
|
|
6061
|
+
if (parsed.error) detail = parsed.error;
|
|
6062
|
+
if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
|
|
6063
|
+
} catch {
|
|
6064
|
+
detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
|
|
6065
|
+
}
|
|
6066
|
+
throw new GoogleAuthError(`Token exchange failed (${res.status}): ${detail}`);
|
|
5938
6067
|
}
|
|
5939
6068
|
return await res.json();
|
|
5940
6069
|
}
|
|
@@ -5955,7 +6084,15 @@ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
|
|
|
5955
6084
|
});
|
|
5956
6085
|
if (!res.ok) {
|
|
5957
6086
|
const body = await res.text();
|
|
5958
|
-
|
|
6087
|
+
let detail = "";
|
|
6088
|
+
try {
|
|
6089
|
+
const parsed = JSON.parse(body);
|
|
6090
|
+
if (parsed.error) detail = parsed.error;
|
|
6091
|
+
if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
|
|
6092
|
+
} catch {
|
|
6093
|
+
detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
|
|
6094
|
+
}
|
|
6095
|
+
throw new GoogleAuthError(`Token refresh failed (${res.status}): ${detail}`);
|
|
5959
6096
|
}
|
|
5960
6097
|
return await res.json();
|
|
5961
6098
|
}
|
|
@@ -6002,6 +6139,18 @@ function validateUrl(urlParam) {
|
|
|
6002
6139
|
throw new GoogleApiError("URL must be a valid URL", 400);
|
|
6003
6140
|
}
|
|
6004
6141
|
}
|
|
6142
|
+
function validateDate(date, label) {
|
|
6143
|
+
if (!date || typeof date !== "string" || date.trim().length === 0) {
|
|
6144
|
+
throw new GoogleApiError(`${label} is required and must be a non-empty string`, 400);
|
|
6145
|
+
}
|
|
6146
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
6147
|
+
throw new GoogleApiError(`${label} must be in YYYY-MM-DD format, got "${date}"`, 400);
|
|
6148
|
+
}
|
|
6149
|
+
const parsed = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
6150
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
6151
|
+
throw new GoogleApiError(`${label} is not a valid date, got "${date}"`, 400);
|
|
6152
|
+
}
|
|
6153
|
+
}
|
|
6005
6154
|
function gscClientLog(level, action, ctx) {
|
|
6006
6155
|
const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GscClient", action, ...ctx };
|
|
6007
6156
|
const stream = level === "error" ? process.stderr : process.stdout;
|
|
@@ -6020,19 +6169,18 @@ async function gscFetch(accessToken, url, opts) {
|
|
|
6020
6169
|
signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
|
|
6021
6170
|
});
|
|
6022
6171
|
if (res.status === 401) {
|
|
6023
|
-
|
|
6024
|
-
gscClientLog("error", "http.auth-expired", { url, method, httpStatus: 401, responseBody: body });
|
|
6172
|
+
gscClientLog("error", "http.auth-expired", { url, method, httpStatus: 401 });
|
|
6025
6173
|
throw new GoogleApiError("Access token expired or revoked", 401);
|
|
6026
6174
|
}
|
|
6027
6175
|
if (res.status === 429) {
|
|
6028
|
-
|
|
6029
|
-
gscClientLog("error", "http.rate-limited", { url, method, httpStatus: 429, responseBody: body });
|
|
6176
|
+
gscClientLog("error", "http.rate-limited", { url, method, httpStatus: 429 });
|
|
6030
6177
|
throw new GoogleApiError("Google API rate limit exceeded", 429);
|
|
6031
6178
|
}
|
|
6032
6179
|
if (!res.ok) {
|
|
6033
6180
|
const body = await res.text();
|
|
6034
|
-
gscClientLog("error", "http.error", { url, method, httpStatus: res.status
|
|
6035
|
-
|
|
6181
|
+
gscClientLog("error", "http.error", { url, method, httpStatus: res.status });
|
|
6182
|
+
const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
|
|
6183
|
+
throw new GoogleApiError(`GSC API error (${res.status}): ${detail}`, res.status);
|
|
6036
6184
|
}
|
|
6037
6185
|
return await res.json();
|
|
6038
6186
|
}
|
|
@@ -6057,10 +6205,16 @@ async function listSitemaps(accessToken, siteUrl) {
|
|
|
6057
6205
|
async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
|
|
6058
6206
|
validateAccessToken(accessToken);
|
|
6059
6207
|
validateSiteUrl(siteUrl);
|
|
6208
|
+
validateDate(opts.startDate, "startDate");
|
|
6209
|
+
validateDate(opts.endDate, "endDate");
|
|
6060
6210
|
const allRows = [];
|
|
6061
6211
|
let startRow = 0;
|
|
6062
6212
|
const dimensions = opts.dimensions ?? ["query", "page", "country", "device", "date"];
|
|
6063
6213
|
for (; ; ) {
|
|
6214
|
+
if (startRow >= GSC_MAX_ROWS_PER_REQUEST * GSC_MAX_PAGES) {
|
|
6215
|
+
gscClientLog("error", "pagination.safety-limit", { siteUrl, startRow, maxRows: GSC_MAX_ROWS_PER_REQUEST * GSC_MAX_PAGES });
|
|
6216
|
+
break;
|
|
6217
|
+
}
|
|
6064
6218
|
const requestBody = {
|
|
6065
6219
|
startDate: opts.startDate,
|
|
6066
6220
|
endDate: opts.endDate,
|
|
@@ -6130,6 +6284,7 @@ var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
|
|
|
6130
6284
|
var GA4_DEFAULT_SYNC_DAYS = 30;
|
|
6131
6285
|
var GA4_MAX_SYNC_DAYS = 90;
|
|
6132
6286
|
var GA4_REQUEST_TIMEOUT_MS = 3e4;
|
|
6287
|
+
var GA4_MAX_PAGES = 50;
|
|
6133
6288
|
|
|
6134
6289
|
// ../integration-google-analytics/src/types.ts
|
|
6135
6290
|
var GA4ApiError = class extends Error {
|
|
@@ -6214,8 +6369,9 @@ async function getAccessToken(clientEmail, privateKey) {
|
|
|
6214
6369
|
});
|
|
6215
6370
|
if (!res.ok) {
|
|
6216
6371
|
const body = await res.text().catch(() => "");
|
|
6217
|
-
ga4Log("error", "token.failed", { httpStatus: res.status
|
|
6218
|
-
|
|
6372
|
+
ga4Log("error", "token.failed", { httpStatus: res.status });
|
|
6373
|
+
const detail = body.length <= 200 ? body : `${body.slice(0, 200)}... [truncated]`;
|
|
6374
|
+
throw new GA4ApiError(`Failed to get access token: ${detail}`, res.status);
|
|
6219
6375
|
}
|
|
6220
6376
|
const data = await res.json();
|
|
6221
6377
|
return data.access_token;
|
|
@@ -6244,7 +6400,7 @@ async function runReport(accessToken, propertyId, request) {
|
|
|
6244
6400
|
} catch {
|
|
6245
6401
|
if (body.length < 200) detail = ` ${body}`;
|
|
6246
6402
|
}
|
|
6247
|
-
ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status
|
|
6403
|
+
ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status });
|
|
6248
6404
|
throw new GA4ApiError(
|
|
6249
6405
|
`GA4 API authentication failed \u2014 check service account permissions.${detail}`,
|
|
6250
6406
|
res.status
|
|
@@ -6256,8 +6412,9 @@ async function runReport(accessToken, propertyId, request) {
|
|
|
6256
6412
|
}
|
|
6257
6413
|
if (!res.ok) {
|
|
6258
6414
|
const body = await res.text();
|
|
6259
|
-
ga4Log("error", "report.error", { propertyId, httpStatus: res.status
|
|
6260
|
-
|
|
6415
|
+
ga4Log("error", "report.error", { propertyId, httpStatus: res.status });
|
|
6416
|
+
const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
|
|
6417
|
+
throw new GA4ApiError(`GA4 API error (${res.status}): ${detail}`, res.status);
|
|
6261
6418
|
}
|
|
6262
6419
|
return await res.json();
|
|
6263
6420
|
}
|
|
@@ -6287,7 +6444,8 @@ async function batchRunReports(accessToken, propertyId, requests) {
|
|
|
6287
6444
|
if (!res.ok) {
|
|
6288
6445
|
const body = await res.text();
|
|
6289
6446
|
ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
|
|
6290
|
-
|
|
6447
|
+
const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
|
|
6448
|
+
throw new GA4ApiError(`GA4 API error (${res.status}): ${detail}`, res.status);
|
|
6291
6449
|
}
|
|
6292
6450
|
const data = await res.json();
|
|
6293
6451
|
return data.reports;
|
|
@@ -6318,7 +6476,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
6318
6476
|
const PAGE_SIZE = 1e4;
|
|
6319
6477
|
const rows = [];
|
|
6320
6478
|
let offset = 0;
|
|
6321
|
-
|
|
6479
|
+
let pageCount = 0;
|
|
6480
|
+
while (pageCount < GA4_MAX_PAGES) {
|
|
6481
|
+
pageCount++;
|
|
6322
6482
|
const request = {
|
|
6323
6483
|
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6324
6484
|
dimensions: [
|
|
@@ -6348,7 +6508,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
6348
6508
|
}
|
|
6349
6509
|
const organicMap = /* @__PURE__ */ new Map();
|
|
6350
6510
|
let organicOffset = 0;
|
|
6351
|
-
|
|
6511
|
+
let organicPageCount = 0;
|
|
6512
|
+
while (organicPageCount < GA4_MAX_PAGES) {
|
|
6513
|
+
organicPageCount++;
|
|
6352
6514
|
const organicRequest = {
|
|
6353
6515
|
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6354
6516
|
dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
|
|
@@ -6462,7 +6624,9 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
|
6462
6624
|
];
|
|
6463
6625
|
for (const [sourceDim, mediumDim, dimLabel] of dimensionPairs) {
|
|
6464
6626
|
let offset = 0;
|
|
6465
|
-
|
|
6627
|
+
let aiRefPageCount = 0;
|
|
6628
|
+
while (aiRefPageCount < GA4_MAX_PAGES) {
|
|
6629
|
+
aiRefPageCount++;
|
|
6466
6630
|
const request = {
|
|
6467
6631
|
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6468
6632
|
dimensions: [
|
|
@@ -6525,6 +6689,66 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
|
6525
6689
|
ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: dedupedRows.length });
|
|
6526
6690
|
return dedupedRows;
|
|
6527
6691
|
}
|
|
6692
|
+
var SOCIAL_CHANNEL_GROUPS = ["Organic Social", "Paid Social"];
|
|
6693
|
+
async function fetchSocialReferrals(accessToken, propertyId, days) {
|
|
6694
|
+
validateAccessToken2(accessToken);
|
|
6695
|
+
validatePropertyId(propertyId);
|
|
6696
|
+
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
6697
|
+
const endDate = /* @__PURE__ */ new Date();
|
|
6698
|
+
const startDate = /* @__PURE__ */ new Date();
|
|
6699
|
+
startDate.setDate(startDate.getDate() - syncDays);
|
|
6700
|
+
ga4Log("info", "fetch-social-referrals.start", { propertyId, days: syncDays });
|
|
6701
|
+
const PAGE_SIZE = 1e3;
|
|
6702
|
+
const rows = [];
|
|
6703
|
+
let offset = 0;
|
|
6704
|
+
while (true) {
|
|
6705
|
+
const request = {
|
|
6706
|
+
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
6707
|
+
dimensions: [
|
|
6708
|
+
{ name: "date" },
|
|
6709
|
+
{ name: "sessionSource" },
|
|
6710
|
+
{ name: "sessionMedium" },
|
|
6711
|
+
{ name: "sessionDefaultChannelGroup" }
|
|
6712
|
+
],
|
|
6713
|
+
metrics: [
|
|
6714
|
+
{ name: "sessions" },
|
|
6715
|
+
{ name: "totalUsers" }
|
|
6716
|
+
],
|
|
6717
|
+
dimensionFilter: {
|
|
6718
|
+
orGroup: {
|
|
6719
|
+
expressions: SOCIAL_CHANNEL_GROUPS.map((value) => ({
|
|
6720
|
+
filter: {
|
|
6721
|
+
fieldName: "sessionDefaultChannelGroup",
|
|
6722
|
+
stringFilter: { matchType: "EXACT", value }
|
|
6723
|
+
}
|
|
6724
|
+
}))
|
|
6725
|
+
}
|
|
6726
|
+
},
|
|
6727
|
+
limit: PAGE_SIZE,
|
|
6728
|
+
offset
|
|
6729
|
+
};
|
|
6730
|
+
const response = await runReport(accessToken, propertyId, request);
|
|
6731
|
+
const pageRows = (response.rows ?? []).map((row) => ({
|
|
6732
|
+
date: row.dimensionValues[0].value,
|
|
6733
|
+
source: row.dimensionValues[1].value,
|
|
6734
|
+
medium: row.dimensionValues[2].value,
|
|
6735
|
+
sessions: parseInt(row.metricValues[0].value, 10) || 0,
|
|
6736
|
+
users: parseInt(row.metricValues[1].value, 10) || 0,
|
|
6737
|
+
channelGroup: row.dimensionValues[3].value
|
|
6738
|
+
}));
|
|
6739
|
+
rows.push(...pageRows);
|
|
6740
|
+
const totalRows = response.rowCount ?? 0;
|
|
6741
|
+
offset += pageRows.length;
|
|
6742
|
+
if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
|
|
6743
|
+
}
|
|
6744
|
+
for (const row of rows) {
|
|
6745
|
+
if (row.date.length === 8 && !row.date.includes("-")) {
|
|
6746
|
+
row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
|
|
6747
|
+
}
|
|
6748
|
+
}
|
|
6749
|
+
ga4Log("info", "fetch-social-referrals.done", { propertyId, rowCount: rows.length });
|
|
6750
|
+
return rows;
|
|
6751
|
+
}
|
|
6528
6752
|
|
|
6529
6753
|
// ../api-routes/src/google.ts
|
|
6530
6754
|
function signState(payload, secret) {
|
|
@@ -7317,19 +7541,18 @@ async function bingFetch(apiKey, endpoint, opts) {
|
|
|
7317
7541
|
signal: AbortSignal.timeout(BING_REQUEST_TIMEOUT_MS)
|
|
7318
7542
|
});
|
|
7319
7543
|
if (res.status === 401 || res.status === 403) {
|
|
7320
|
-
|
|
7321
|
-
bingClientLog("error", "http.auth-failed", { endpoint, method, httpStatus: res.status, responseBody: body });
|
|
7544
|
+
bingClientLog("error", "http.auth-failed", { endpoint, method, httpStatus: res.status });
|
|
7322
7545
|
throw new BingApiError("Bing API key is invalid or unauthorized", res.status);
|
|
7323
7546
|
}
|
|
7324
7547
|
if (res.status === 429) {
|
|
7325
|
-
|
|
7326
|
-
bingClientLog("error", "http.rate-limited", { endpoint, method, httpStatus: 429, responseBody: body });
|
|
7548
|
+
bingClientLog("error", "http.rate-limited", { endpoint, method, httpStatus: 429 });
|
|
7327
7549
|
throw new BingApiError("Bing API rate limit exceeded", 429);
|
|
7328
7550
|
}
|
|
7329
7551
|
if (!res.ok) {
|
|
7330
7552
|
const body = await res.text();
|
|
7331
|
-
bingClientLog("error", "http.error", { endpoint, method, httpStatus: res.status
|
|
7332
|
-
|
|
7553
|
+
bingClientLog("error", "http.error", { endpoint, method, httpStatus: res.status });
|
|
7554
|
+
const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
|
|
7555
|
+
throw new BingApiError(`Bing API error (${res.status}): ${detail}`, res.status);
|
|
7333
7556
|
}
|
|
7334
7557
|
const text = await res.text();
|
|
7335
7558
|
if (!text || text.trim() === "") {
|
|
@@ -7371,6 +7594,12 @@ async function submitUrlBatch(apiKey, siteUrl, urls) {
|
|
|
7371
7594
|
validateApiKey(apiKey);
|
|
7372
7595
|
validateSiteUrl2(siteUrl);
|
|
7373
7596
|
validateUrls(urls);
|
|
7597
|
+
if (urls.length > BING_SUBMIT_URL_DAILY_LIMIT) {
|
|
7598
|
+
throw new BingApiError(
|
|
7599
|
+
`URL batch exceeds daily limit of ${BING_SUBMIT_URL_DAILY_LIMIT}. Got ${urls.length} URLs.`,
|
|
7600
|
+
400
|
|
7601
|
+
);
|
|
7602
|
+
}
|
|
7374
7603
|
for (let i = 0; i < urls.length; i += BING_SUBMIT_URL_BATCH_LIMIT) {
|
|
7375
7604
|
const batch = urls.slice(i, i + BING_SUBMIT_URL_BATCH_LIMIT);
|
|
7376
7605
|
await bingFetch(apiKey, "SubmitUrlbatch", {
|
|
@@ -8112,6 +8341,7 @@ async function ga4Routes(app, opts) {
|
|
|
8112
8341
|
app.db.delete(gaTrafficSnapshots).where(eq17(gaTrafficSnapshots.projectId, project.id)).run();
|
|
8113
8342
|
app.db.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
|
|
8114
8343
|
app.db.delete(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).run();
|
|
8344
|
+
app.db.delete(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).run();
|
|
8115
8345
|
const propertyId = saConn?.propertyId ?? oauthConn?.propertyId ?? null;
|
|
8116
8346
|
opts.ga4CredentialStore?.deleteConnection(project.name);
|
|
8117
8347
|
opts.googleConnectionStore?.deleteConnection(project.canonicalDomain, "ga4");
|
|
@@ -8146,17 +8376,37 @@ async function ga4Routes(app, opts) {
|
|
|
8146
8376
|
app.post("/projects/:name/ga/sync", async (request, _reply) => {
|
|
8147
8377
|
const project = resolveProject(app.db, request.params.name);
|
|
8148
8378
|
const days = request.body?.days ?? 30;
|
|
8379
|
+
const only = request.body?.only;
|
|
8380
|
+
const validOnlyValues = ["traffic", "ai", "social"];
|
|
8381
|
+
if (only !== void 0 && !validOnlyValues.includes(only)) {
|
|
8382
|
+
throw validationError(`Invalid "only" value "${only}". Must be one of: ${validOnlyValues.join(", ")}`);
|
|
8383
|
+
}
|
|
8384
|
+
const syncTraffic = !only || only === "traffic";
|
|
8385
|
+
const syncAi = !only || only === "ai";
|
|
8386
|
+
const syncSocial = !only || only === "social";
|
|
8387
|
+
const syncSummary = !only;
|
|
8149
8388
|
const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
|
|
8150
|
-
let rows;
|
|
8389
|
+
let rows = [];
|
|
8151
8390
|
let summary;
|
|
8152
|
-
let aiReferrals;
|
|
8391
|
+
let aiReferrals = [];
|
|
8392
|
+
let socialReferrals = [];
|
|
8153
8393
|
try {
|
|
8154
|
-
;
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
]
|
|
8394
|
+
const fetches = [fetchAggregateSummary(accessToken, propertyId, days)];
|
|
8395
|
+
if (syncTraffic) fetches.push(fetchTrafficByLandingPage(accessToken, propertyId, days));
|
|
8396
|
+
if (syncAi) fetches.push(fetchAiReferrals(accessToken, propertyId, days));
|
|
8397
|
+
if (syncSocial) fetches.push(fetchSocialReferrals(accessToken, propertyId, days));
|
|
8398
|
+
const results = await Promise.all(fetches);
|
|
8399
|
+
summary = results[0];
|
|
8400
|
+
let idx = 1;
|
|
8401
|
+
if (syncTraffic) {
|
|
8402
|
+
rows = results[idx++];
|
|
8403
|
+
}
|
|
8404
|
+
if (syncAi) {
|
|
8405
|
+
aiReferrals = results[idx++];
|
|
8406
|
+
}
|
|
8407
|
+
if (syncSocial) {
|
|
8408
|
+
socialReferrals = results[idx++];
|
|
8409
|
+
}
|
|
8160
8410
|
} catch (e) {
|
|
8161
8411
|
const msg = e instanceof Error ? e.message : String(e);
|
|
8162
8412
|
gaLog("error", "sync.fetch-failed", { projectId: project.id, error: msg });
|
|
@@ -8164,14 +8414,14 @@ async function ga4Routes(app, opts) {
|
|
|
8164
8414
|
}
|
|
8165
8415
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
8166
8416
|
app.db.transaction((tx) => {
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8417
|
+
if (syncTraffic) {
|
|
8418
|
+
tx.delete(gaTrafficSnapshots).where(
|
|
8419
|
+
and6(
|
|
8420
|
+
eq17(gaTrafficSnapshots.projectId, project.id),
|
|
8421
|
+
sql3`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
|
|
8422
|
+
sql3`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
|
|
8423
|
+
)
|
|
8424
|
+
).run();
|
|
8175
8425
|
for (const row of rows) {
|
|
8176
8426
|
tx.insert(gaTrafficSnapshots).values({
|
|
8177
8427
|
id: crypto16.randomUUID(),
|
|
@@ -8185,14 +8435,14 @@ async function ga4Routes(app, opts) {
|
|
|
8185
8435
|
}).run();
|
|
8186
8436
|
}
|
|
8187
8437
|
}
|
|
8188
|
-
|
|
8189
|
-
|
|
8190
|
-
|
|
8191
|
-
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
8195
|
-
|
|
8438
|
+
if (syncAi) {
|
|
8439
|
+
tx.delete(gaAiReferrals).where(
|
|
8440
|
+
and6(
|
|
8441
|
+
eq17(gaAiReferrals.projectId, project.id),
|
|
8442
|
+
sql3`${gaAiReferrals.date} >= ${summary.periodStart}`,
|
|
8443
|
+
sql3`${gaAiReferrals.date} <= ${summary.periodEnd}`
|
|
8444
|
+
)
|
|
8445
|
+
).run();
|
|
8196
8446
|
for (const row of aiReferrals) {
|
|
8197
8447
|
tx.insert(gaAiReferrals).values({
|
|
8198
8448
|
id: crypto16.randomUUID(),
|
|
@@ -8207,31 +8457,60 @@ async function ga4Routes(app, opts) {
|
|
|
8207
8457
|
}).run();
|
|
8208
8458
|
}
|
|
8209
8459
|
}
|
|
8210
|
-
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8217
|
-
|
|
8218
|
-
|
|
8219
|
-
|
|
8220
|
-
|
|
8460
|
+
if (syncSocial) {
|
|
8461
|
+
tx.delete(gaSocialReferrals).where(
|
|
8462
|
+
and6(
|
|
8463
|
+
eq17(gaSocialReferrals.projectId, project.id),
|
|
8464
|
+
sql3`${gaSocialReferrals.date} >= ${summary.periodStart}`,
|
|
8465
|
+
sql3`${gaSocialReferrals.date} <= ${summary.periodEnd}`
|
|
8466
|
+
)
|
|
8467
|
+
).run();
|
|
8468
|
+
for (const row of socialReferrals) {
|
|
8469
|
+
tx.insert(gaSocialReferrals).values({
|
|
8470
|
+
id: crypto16.randomUUID(),
|
|
8471
|
+
projectId: project.id,
|
|
8472
|
+
date: row.date,
|
|
8473
|
+
source: row.source,
|
|
8474
|
+
medium: row.medium,
|
|
8475
|
+
channelGroup: row.channelGroup,
|
|
8476
|
+
sessions: row.sessions,
|
|
8477
|
+
users: row.users,
|
|
8478
|
+
syncedAt: now
|
|
8479
|
+
}).run();
|
|
8480
|
+
}
|
|
8481
|
+
}
|
|
8482
|
+
if (syncSummary) {
|
|
8483
|
+
tx.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
|
|
8484
|
+
tx.insert(gaTrafficSummaries).values({
|
|
8485
|
+
id: crypto16.randomUUID(),
|
|
8486
|
+
projectId: project.id,
|
|
8487
|
+
periodStart: summary.periodStart,
|
|
8488
|
+
periodEnd: summary.periodEnd,
|
|
8489
|
+
totalSessions: summary.totalSessions,
|
|
8490
|
+
totalOrganicSessions: summary.totalOrganicSessions,
|
|
8491
|
+
totalUsers: summary.totalUsers,
|
|
8492
|
+
syncedAt: now
|
|
8493
|
+
}).run();
|
|
8494
|
+
}
|
|
8221
8495
|
});
|
|
8496
|
+
const syncedComponents = only ? [only, ...only !== "social" && only !== "ai" && only !== "traffic" ? [] : []] : void 0;
|
|
8222
8497
|
gaLog("info", "sync.complete", {
|
|
8223
8498
|
projectId: project.id,
|
|
8224
8499
|
rowCount: rows.length,
|
|
8225
8500
|
aiReferralCount: aiReferrals.length,
|
|
8501
|
+
socialReferralCount: socialReferrals.length,
|
|
8226
8502
|
days,
|
|
8227
|
-
totalUsers: summary.totalUsers
|
|
8503
|
+
totalUsers: summary.totalUsers,
|
|
8504
|
+
...only ? { only } : {}
|
|
8228
8505
|
});
|
|
8229
8506
|
return {
|
|
8230
8507
|
synced: true,
|
|
8231
8508
|
rowCount: rows.length,
|
|
8232
8509
|
aiReferralCount: aiReferrals.length,
|
|
8510
|
+
socialReferralCount: socialReferrals.length,
|
|
8233
8511
|
days,
|
|
8234
|
-
syncedAt: now
|
|
8512
|
+
syncedAt: now,
|
|
8513
|
+
...syncedComponents ? { syncedComponents } : {}
|
|
8235
8514
|
};
|
|
8236
8515
|
});
|
|
8237
8516
|
app.get("/projects/:name/ga/traffic", async (request, _reply) => {
|
|
@@ -8269,9 +8548,21 @@ async function ga4Routes(app, opts) {
|
|
|
8269
8548
|
GROUP BY date, source, medium
|
|
8270
8549
|
)`
|
|
8271
8550
|
).get();
|
|
8551
|
+
const socialReferrals = app.db.select({
|
|
8552
|
+
source: gaSocialReferrals.source,
|
|
8553
|
+
medium: gaSocialReferrals.medium,
|
|
8554
|
+
channelGroup: gaSocialReferrals.channelGroup,
|
|
8555
|
+
sessions: sql3`SUM(${gaSocialReferrals.sessions})`,
|
|
8556
|
+
users: sql3`SUM(${gaSocialReferrals.users})`
|
|
8557
|
+
}).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).groupBy(gaSocialReferrals.source, gaSocialReferrals.medium, gaSocialReferrals.channelGroup).orderBy(sql3`SUM(${gaSocialReferrals.sessions}) DESC`).all();
|
|
8558
|
+
const socialTotals = app.db.select({
|
|
8559
|
+
sessions: sql3`SUM(${gaSocialReferrals.sessions})`,
|
|
8560
|
+
users: sql3`SUM(${gaSocialReferrals.users})`
|
|
8561
|
+
}).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).get();
|
|
8272
8562
|
const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).orderBy(desc7(gaTrafficSummaries.syncedAt)).limit(1).get();
|
|
8563
|
+
const total = summary?.totalSessions ?? 0;
|
|
8273
8564
|
return {
|
|
8274
|
-
totalSessions:
|
|
8565
|
+
totalSessions: total,
|
|
8275
8566
|
totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
|
|
8276
8567
|
totalUsers: summary?.totalUsers ?? 0,
|
|
8277
8568
|
topPages: rows.map((r) => ({
|
|
@@ -8289,6 +8580,18 @@ async function ga4Routes(app, opts) {
|
|
|
8289
8580
|
})),
|
|
8290
8581
|
aiSessionsDeduped: aiDeduped?.sessions ?? 0,
|
|
8291
8582
|
aiUsersDeduped: aiDeduped?.users ?? 0,
|
|
8583
|
+
socialReferrals: socialReferrals.map((r) => ({
|
|
8584
|
+
source: r.source,
|
|
8585
|
+
medium: r.medium,
|
|
8586
|
+
channelGroup: r.channelGroup,
|
|
8587
|
+
sessions: r.sessions ?? 0,
|
|
8588
|
+
users: r.users ?? 0
|
|
8589
|
+
})),
|
|
8590
|
+
socialSessions: socialTotals?.sessions ?? 0,
|
|
8591
|
+
socialUsers: socialTotals?.users ?? 0,
|
|
8592
|
+
organicSharePct: total > 0 ? Math.round((summary?.totalOrganicSessions ?? 0) / total * 100) : 0,
|
|
8593
|
+
aiSharePct: total > 0 ? Math.round((aiDeduped?.sessions ?? 0) / total * 100) : 0,
|
|
8594
|
+
socialSharePct: total > 0 ? Math.round((socialTotals?.sessions ?? 0) / total * 100) : 0,
|
|
8292
8595
|
lastSyncedAt: latestSync?.syncedAt ?? null
|
|
8293
8596
|
};
|
|
8294
8597
|
});
|
|
@@ -8305,6 +8608,146 @@ async function ga4Routes(app, opts) {
|
|
|
8305
8608
|
}).from(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).orderBy(gaAiReferrals.date).all();
|
|
8306
8609
|
return rows;
|
|
8307
8610
|
});
|
|
8611
|
+
app.get("/projects/:name/ga/social-referral-history", async (request, _reply) => {
|
|
8612
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8613
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
8614
|
+
const rows = app.db.select({
|
|
8615
|
+
date: gaSocialReferrals.date,
|
|
8616
|
+
source: gaSocialReferrals.source,
|
|
8617
|
+
medium: gaSocialReferrals.medium,
|
|
8618
|
+
channelGroup: gaSocialReferrals.channelGroup,
|
|
8619
|
+
sessions: gaSocialReferrals.sessions,
|
|
8620
|
+
users: gaSocialReferrals.users
|
|
8621
|
+
}).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).orderBy(gaSocialReferrals.date).all();
|
|
8622
|
+
return rows;
|
|
8623
|
+
});
|
|
8624
|
+
app.get("/projects/:name/ga/social-referral-trend", async (request, _reply) => {
|
|
8625
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8626
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
8627
|
+
const today = /* @__PURE__ */ new Date();
|
|
8628
|
+
const fmt = (d) => d.toISOString().split("T")[0];
|
|
8629
|
+
const daysAgo2 = (n) => {
|
|
8630
|
+
const d = new Date(today);
|
|
8631
|
+
d.setDate(d.getDate() - n);
|
|
8632
|
+
return fmt(d);
|
|
8633
|
+
};
|
|
8634
|
+
const sumSocial = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaSocialReferrals.sessions}), 0)` }).from(gaSocialReferrals).where(and6(
|
|
8635
|
+
eq17(gaSocialReferrals.projectId, project.id),
|
|
8636
|
+
sql3`${gaSocialReferrals.date} >= ${from}`,
|
|
8637
|
+
sql3`${gaSocialReferrals.date} < ${to}`
|
|
8638
|
+
)).get();
|
|
8639
|
+
const current7d = sumSocial(daysAgo2(7), fmt(today));
|
|
8640
|
+
const prev7d = sumSocial(daysAgo2(14), daysAgo2(7));
|
|
8641
|
+
const current30d = sumSocial(daysAgo2(30), fmt(today));
|
|
8642
|
+
const prev30d = sumSocial(daysAgo2(60), daysAgo2(30));
|
|
8643
|
+
const pct = (cur, prev) => prev === 0 ? null : Math.round((cur - prev) / prev * 100);
|
|
8644
|
+
const sourceCurrent = app.db.select({
|
|
8645
|
+
source: gaSocialReferrals.source,
|
|
8646
|
+
sessions: sql3`SUM(${gaSocialReferrals.sessions})`
|
|
8647
|
+
}).from(gaSocialReferrals).where(and6(
|
|
8648
|
+
eq17(gaSocialReferrals.projectId, project.id),
|
|
8649
|
+
sql3`${gaSocialReferrals.date} >= ${daysAgo2(7)}`,
|
|
8650
|
+
sql3`${gaSocialReferrals.date} < ${fmt(today)}`
|
|
8651
|
+
)).groupBy(gaSocialReferrals.source).all();
|
|
8652
|
+
const sourcePrev = app.db.select({
|
|
8653
|
+
source: gaSocialReferrals.source,
|
|
8654
|
+
sessions: sql3`SUM(${gaSocialReferrals.sessions})`
|
|
8655
|
+
}).from(gaSocialReferrals).where(and6(
|
|
8656
|
+
eq17(gaSocialReferrals.projectId, project.id),
|
|
8657
|
+
sql3`${gaSocialReferrals.date} >= ${daysAgo2(14)}`,
|
|
8658
|
+
sql3`${gaSocialReferrals.date} < ${daysAgo2(7)}`
|
|
8659
|
+
)).groupBy(gaSocialReferrals.source).all();
|
|
8660
|
+
const prevMap = new Map(sourcePrev.map((r) => [r.source, r.sessions]));
|
|
8661
|
+
let biggestMover = null;
|
|
8662
|
+
let maxDelta = 0;
|
|
8663
|
+
for (const row of sourceCurrent) {
|
|
8664
|
+
const prev = prevMap.get(row.source) ?? 0;
|
|
8665
|
+
const delta = Math.abs(row.sessions - prev);
|
|
8666
|
+
if (delta > maxDelta) {
|
|
8667
|
+
maxDelta = delta;
|
|
8668
|
+
biggestMover = {
|
|
8669
|
+
source: row.source,
|
|
8670
|
+
sessions7d: row.sessions,
|
|
8671
|
+
sessionsPrev7d: prev,
|
|
8672
|
+
changePct: pct(row.sessions, prev) ?? (row.sessions > 0 ? 100 : 0)
|
|
8673
|
+
};
|
|
8674
|
+
}
|
|
8675
|
+
}
|
|
8676
|
+
return {
|
|
8677
|
+
socialSessions7d: current7d?.sessions ?? 0,
|
|
8678
|
+
socialSessionsPrev7d: prev7d?.sessions ?? 0,
|
|
8679
|
+
trend7dPct: pct(current7d?.sessions ?? 0, prev7d?.sessions ?? 0),
|
|
8680
|
+
socialSessions30d: current30d?.sessions ?? 0,
|
|
8681
|
+
socialSessionsPrev30d: prev30d?.sessions ?? 0,
|
|
8682
|
+
trend30dPct: pct(current30d?.sessions ?? 0, prev30d?.sessions ?? 0),
|
|
8683
|
+
biggestMover
|
|
8684
|
+
};
|
|
8685
|
+
});
|
|
8686
|
+
app.get("/projects/:name/ga/attribution-trend", async (request, _reply) => {
|
|
8687
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8688
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
8689
|
+
const today = /* @__PURE__ */ new Date();
|
|
8690
|
+
const fmt = (d) => d.toISOString().split("T")[0];
|
|
8691
|
+
const daysAgo2 = (n) => {
|
|
8692
|
+
const d = new Date(today);
|
|
8693
|
+
d.setDate(d.getDate() - n);
|
|
8694
|
+
return fmt(d);
|
|
8695
|
+
};
|
|
8696
|
+
const pct = (cur, prev) => prev === 0 ? null : Math.round((cur - prev) / prev * 100);
|
|
8697
|
+
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();
|
|
8698
|
+
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();
|
|
8699
|
+
const sumAi = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
|
|
8700
|
+
SELECT date, source, medium, MAX(sessions) AS max_sessions
|
|
8701
|
+
FROM ga_ai_referrals
|
|
8702
|
+
WHERE project_id = ${project.id} AND date >= ${from} AND date < ${to}
|
|
8703
|
+
GROUP BY date, source, medium
|
|
8704
|
+
)`).get();
|
|
8705
|
+
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();
|
|
8706
|
+
const todayStr = fmt(today);
|
|
8707
|
+
const buildTrend = (sum) => {
|
|
8708
|
+
const c7 = sum(daysAgo2(7), todayStr)?.sessions ?? 0;
|
|
8709
|
+
const p7 = sum(daysAgo2(14), daysAgo2(7))?.sessions ?? 0;
|
|
8710
|
+
const c30 = sum(daysAgo2(30), todayStr)?.sessions ?? 0;
|
|
8711
|
+
const p30 = sum(daysAgo2(60), daysAgo2(30))?.sessions ?? 0;
|
|
8712
|
+
return { sessions7d: c7, sessionsPrev7d: p7, trend7dPct: pct(c7, p7), sessions30d: c30, sessionsPrev30d: p30, trend30dPct: pct(c30, p30) };
|
|
8713
|
+
};
|
|
8714
|
+
const aiSourceCurrent = app.db.select({ source: sql3`source`, sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
|
|
8715
|
+
SELECT date, source, medium, MAX(sessions) AS max_sessions
|
|
8716
|
+
FROM ga_ai_referrals
|
|
8717
|
+
WHERE project_id = ${project.id} AND date >= ${daysAgo2(7)} AND date < ${todayStr}
|
|
8718
|
+
GROUP BY date, source, medium
|
|
8719
|
+
)`).groupBy(sql3`source`).all();
|
|
8720
|
+
const aiSourcePrev = app.db.select({ source: sql3`source`, sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
|
|
8721
|
+
SELECT date, source, medium, MAX(sessions) AS max_sessions
|
|
8722
|
+
FROM ga_ai_referrals
|
|
8723
|
+
WHERE project_id = ${project.id} AND date >= ${daysAgo2(14)} AND date < ${daysAgo2(7)}
|
|
8724
|
+
GROUP BY date, source, medium
|
|
8725
|
+
)`).groupBy(sql3`source`).all();
|
|
8726
|
+
const findBiggestMover = (current, prev) => {
|
|
8727
|
+
const prevMap = new Map(prev.map((r) => [r.source, r.sessions]));
|
|
8728
|
+
let mover = null;
|
|
8729
|
+
let maxDelta = 0;
|
|
8730
|
+
for (const row of current) {
|
|
8731
|
+
const p = prevMap.get(row.source) ?? 0;
|
|
8732
|
+
const delta = Math.abs(row.sessions - p);
|
|
8733
|
+
if (delta > maxDelta) {
|
|
8734
|
+
maxDelta = delta;
|
|
8735
|
+
mover = { source: row.source, sessions7d: row.sessions, sessionsPrev7d: p, changePct: pct(row.sessions, p) ?? (row.sessions > 0 ? 100 : 0) };
|
|
8736
|
+
}
|
|
8737
|
+
}
|
|
8738
|
+
return mover;
|
|
8739
|
+
};
|
|
8740
|
+
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();
|
|
8741
|
+
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();
|
|
8742
|
+
return {
|
|
8743
|
+
total: buildTrend(sumTotal),
|
|
8744
|
+
organic: buildTrend(sumOrganic),
|
|
8745
|
+
ai: buildTrend(sumAi),
|
|
8746
|
+
social: buildTrend(sumSocial),
|
|
8747
|
+
aiBiggestMover: findBiggestMover(aiSourceCurrent, aiSourcePrev),
|
|
8748
|
+
socialBiggestMover: findBiggestMover(socialSourceCurrent, socialSourcePrev)
|
|
8749
|
+
};
|
|
8750
|
+
});
|
|
8308
8751
|
app.get("/projects/:name/ga/session-history", async (request, _reply) => {
|
|
8309
8752
|
const project = resolveProject(app.db, request.params.name);
|
|
8310
8753
|
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
@@ -10137,6 +10580,15 @@ async function apiRoutes(app, opts) {
|
|
|
10137
10580
|
import { GoogleGenAI } from "@google/genai";
|
|
10138
10581
|
|
|
10139
10582
|
// ../provider-gemini/src/utils.ts
|
|
10583
|
+
function isRetryableError(err) {
|
|
10584
|
+
if (err != null && typeof err === "object" && "status" in err) {
|
|
10585
|
+
const status = err.status;
|
|
10586
|
+
if (typeof status === "number") {
|
|
10587
|
+
return status >= 500 || status === 429;
|
|
10588
|
+
}
|
|
10589
|
+
}
|
|
10590
|
+
return true;
|
|
10591
|
+
}
|
|
10140
10592
|
async function withRetry(fn, options = {}) {
|
|
10141
10593
|
const { maxRetries = 3, initialDelay = 1e3 } = options;
|
|
10142
10594
|
let lastError;
|
|
@@ -10145,10 +10597,12 @@ async function withRetry(fn, options = {}) {
|
|
|
10145
10597
|
return await fn();
|
|
10146
10598
|
} catch (err) {
|
|
10147
10599
|
lastError = err;
|
|
10148
|
-
if (attempt < maxRetries) {
|
|
10600
|
+
if (attempt < maxRetries && isRetryableError(err)) {
|
|
10149
10601
|
const delay = initialDelay * Math.pow(2, attempt);
|
|
10150
10602
|
console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
|
|
10151
10603
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
10604
|
+
} else {
|
|
10605
|
+
throw err;
|
|
10152
10606
|
}
|
|
10153
10607
|
}
|
|
10154
10608
|
}
|
|
@@ -10510,6 +10964,15 @@ var geminiAdapter = {
|
|
|
10510
10964
|
import OpenAI from "openai";
|
|
10511
10965
|
|
|
10512
10966
|
// ../provider-openai/src/utils.ts
|
|
10967
|
+
function isRetryableError2(err) {
|
|
10968
|
+
if (err != null && typeof err === "object" && "status" in err) {
|
|
10969
|
+
const status = err.status;
|
|
10970
|
+
if (typeof status === "number") {
|
|
10971
|
+
return status >= 500 || status === 429;
|
|
10972
|
+
}
|
|
10973
|
+
}
|
|
10974
|
+
return true;
|
|
10975
|
+
}
|
|
10513
10976
|
async function withRetry2(fn, options = {}) {
|
|
10514
10977
|
const { maxRetries = 3, initialDelay = 1e3 } = options;
|
|
10515
10978
|
let lastError;
|
|
@@ -10518,10 +10981,12 @@ async function withRetry2(fn, options = {}) {
|
|
|
10518
10981
|
return await fn();
|
|
10519
10982
|
} catch (err) {
|
|
10520
10983
|
lastError = err;
|
|
10521
|
-
if (attempt < maxRetries) {
|
|
10984
|
+
if (attempt < maxRetries && isRetryableError2(err)) {
|
|
10522
10985
|
const delay = initialDelay * Math.pow(2, attempt);
|
|
10523
10986
|
console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
|
|
10524
10987
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
10988
|
+
} else {
|
|
10989
|
+
throw err;
|
|
10525
10990
|
}
|
|
10526
10991
|
}
|
|
10527
10992
|
}
|
|
@@ -10843,6 +11308,15 @@ var openaiAdapter = {
|
|
|
10843
11308
|
import Anthropic from "@anthropic-ai/sdk";
|
|
10844
11309
|
|
|
10845
11310
|
// ../provider-claude/src/utils.ts
|
|
11311
|
+
function isRetryableError3(err) {
|
|
11312
|
+
if (err != null && typeof err === "object" && "status" in err) {
|
|
11313
|
+
const status = err.status;
|
|
11314
|
+
if (typeof status === "number") {
|
|
11315
|
+
return status >= 500 || status === 429;
|
|
11316
|
+
}
|
|
11317
|
+
}
|
|
11318
|
+
return true;
|
|
11319
|
+
}
|
|
10846
11320
|
async function withRetry3(fn, options = {}) {
|
|
10847
11321
|
const { maxRetries = 3, initialDelay = 1e3 } = options;
|
|
10848
11322
|
let lastError;
|
|
@@ -10851,10 +11325,12 @@ async function withRetry3(fn, options = {}) {
|
|
|
10851
11325
|
return await fn();
|
|
10852
11326
|
} catch (err) {
|
|
10853
11327
|
lastError = err;
|
|
10854
|
-
if (attempt < maxRetries) {
|
|
11328
|
+
if (attempt < maxRetries && isRetryableError3(err)) {
|
|
10855
11329
|
const delay = initialDelay * Math.pow(2, attempt);
|
|
10856
11330
|
console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
|
|
10857
11331
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
11332
|
+
} else {
|
|
11333
|
+
throw err;
|
|
10858
11334
|
}
|
|
10859
11335
|
}
|
|
10860
11336
|
}
|
|
@@ -11199,6 +11675,15 @@ var claudeAdapter = {
|
|
|
11199
11675
|
import OpenAI2 from "openai";
|
|
11200
11676
|
|
|
11201
11677
|
// ../provider-local/src/utils.ts
|
|
11678
|
+
function isRetryableError4(err) {
|
|
11679
|
+
if (err != null && typeof err === "object" && "status" in err) {
|
|
11680
|
+
const status = err.status;
|
|
11681
|
+
if (typeof status === "number") {
|
|
11682
|
+
return status >= 500 || status === 429;
|
|
11683
|
+
}
|
|
11684
|
+
}
|
|
11685
|
+
return true;
|
|
11686
|
+
}
|
|
11202
11687
|
async function withRetry4(fn, options = {}) {
|
|
11203
11688
|
const { maxRetries = 3, initialDelay = 1e3 } = options;
|
|
11204
11689
|
let lastError;
|
|
@@ -11207,10 +11692,12 @@ async function withRetry4(fn, options = {}) {
|
|
|
11207
11692
|
return await fn();
|
|
11208
11693
|
} catch (err) {
|
|
11209
11694
|
lastError = err;
|
|
11210
|
-
if (attempt < maxRetries) {
|
|
11695
|
+
if (attempt < maxRetries && isRetryableError4(err)) {
|
|
11211
11696
|
const delay = initialDelay * Math.pow(2, attempt);
|
|
11212
11697
|
console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
|
|
11213
11698
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
11699
|
+
} else {
|
|
11700
|
+
throw err;
|
|
11214
11701
|
}
|
|
11215
11702
|
}
|
|
11216
11703
|
}
|
|
@@ -11994,6 +12481,15 @@ var cdpChatgptAdapter = {
|
|
|
11994
12481
|
import OpenAI3 from "openai";
|
|
11995
12482
|
|
|
11996
12483
|
// ../provider-perplexity/src/utils.ts
|
|
12484
|
+
function isRetryableError5(err) {
|
|
12485
|
+
if (err != null && typeof err === "object" && "status" in err) {
|
|
12486
|
+
const status = err.status;
|
|
12487
|
+
if (typeof status === "number") {
|
|
12488
|
+
return status >= 500 || status === 429;
|
|
12489
|
+
}
|
|
12490
|
+
}
|
|
12491
|
+
return true;
|
|
12492
|
+
}
|
|
11997
12493
|
async function withRetry5(fn, options = {}) {
|
|
11998
12494
|
const { maxRetries = 3, initialDelay = 1e3 } = options;
|
|
11999
12495
|
let lastError;
|
|
@@ -12002,10 +12498,12 @@ async function withRetry5(fn, options = {}) {
|
|
|
12002
12498
|
return await fn();
|
|
12003
12499
|
} catch (err) {
|
|
12004
12500
|
lastError = err;
|
|
12005
|
-
if (attempt < maxRetries) {
|
|
12501
|
+
if (attempt < maxRetries && isRetryableError5(err)) {
|
|
12006
12502
|
const delay = initialDelay * Math.pow(2, attempt);
|
|
12007
12503
|
console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
|
|
12008
12504
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
12505
|
+
} else {
|
|
12506
|
+
throw err;
|
|
12009
12507
|
}
|
|
12010
12508
|
}
|
|
12011
12509
|
}
|