@canonry/canonry 4.133.3 → 4.134.1
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/README.md +0 -13
- package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +3 -2
- package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +32 -3
- package/assets/assets/{AuditHistoryPanel-DTHQGvkW.js → AuditHistoryPanel-BW7LVMSZ.js} +1 -1
- package/assets/assets/{BacklinksPage-DuPI3wt9.js → BacklinksPage-DU65axM1.js} +1 -1
- package/assets/assets/{ChartPrimitives-T8mxOObc.js → ChartPrimitives-CUZJ7nUb.js} +1 -1
- package/assets/assets/{HistoryPage-T1W40xKG.js → HistoryPage-BswdEXC5.js} +1 -1
- package/assets/assets/ProjectPage-CttTuKKo.js +8 -0
- package/assets/assets/{RunRow-nHr4wW9B.js → RunRow-CBxbfubI.js} +1 -1
- package/assets/assets/{RunsPage-Cbusy_84.js → RunsPage-BQxA05ua.js} +1 -1
- package/assets/assets/{SettingsPage-BVcbl4gn.js → SettingsPage-krEusjc9.js} +1 -1
- package/assets/assets/{TrafficPage-CZ4NTWI6.js → TrafficPage-DMhcA9lg.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-xW-KvZY1.js → TrafficSourceDetailPage-Dm0Gj0HS.js} +1 -1
- package/assets/assets/{arrow-left-flskyFPK.js → arrow-left-_1YaUPbl.js} +1 -1
- package/assets/assets/{extract-error-message-BHfCTm0H.js → extract-error-message-D4XKG37U.js} +1 -1
- package/assets/assets/{index-CQzGDGMB.js → index-V1C3c7nO.js} +100 -100
- package/assets/assets/{trash-2-LGfqtSzZ.js → trash-2-D3LWIGDJ.js} +1 -1
- package/assets/index.html +1 -1
- package/dist/{chunk-A6BYMSZL.js → chunk-BSU2MI3Y.js} +79 -4
- package/dist/{chunk-YRRQFOAH.js → chunk-CF5WTWXI.js} +257 -2
- package/dist/{chunk-HENMGNLT.js → chunk-DVJGFY3J.js} +684 -74
- package/dist/{chunk-6ON6GUAL.js → chunk-NWVPRV7A.js} +68 -6
- package/dist/cli.js +113 -5
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-OSLFG5S2.js → intelligence-service-NO4FJUYD.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +9 -9
- package/assets/assets/ProjectPage-DYwM8-Rp.js +0 -8
|
@@ -19,6 +19,9 @@ import {
|
|
|
19
19
|
AdsHistoricalCampaignRollupStatuses,
|
|
20
20
|
AdsInsightLevels,
|
|
21
21
|
AdsIntegrityDecisions,
|
|
22
|
+
AdsLiveDeliveryBases,
|
|
23
|
+
AdsLiveEntityTypes,
|
|
24
|
+
AdsLivePresences,
|
|
22
25
|
AdsOperationKinds,
|
|
23
26
|
AdsOperationStates,
|
|
24
27
|
AdsOperationStepStates,
|
|
@@ -96,6 +99,8 @@ import {
|
|
|
96
99
|
adsImageUploadRequestSchema,
|
|
97
100
|
adsInsightLevelSchema,
|
|
98
101
|
adsInsightsResponseSchema,
|
|
102
|
+
adsLiveDeliveryDtoSchema,
|
|
103
|
+
adsLiveDeliveryQuerySchema,
|
|
99
104
|
adsOperationReconcileResponseSchema,
|
|
100
105
|
adsOperationResponseSchema,
|
|
101
106
|
adsOperationStepDtoSchema,
|
|
@@ -183,6 +188,7 @@ import {
|
|
|
183
188
|
discoverySessionDetailDtoSchema,
|
|
184
189
|
discoverySessionDtoSchema,
|
|
185
190
|
doctorReportSchema,
|
|
191
|
+
dollarsToMicros,
|
|
186
192
|
domainClassificationsResponseDtoSchema,
|
|
187
193
|
effectiveBrandNames,
|
|
188
194
|
effectiveDomains,
|
|
@@ -199,9 +205,11 @@ import {
|
|
|
199
205
|
formatDateRange,
|
|
200
206
|
formatDeltaCopy,
|
|
201
207
|
formatIsoDate,
|
|
208
|
+
formatIsoDateInTimeZone,
|
|
202
209
|
formatNumber,
|
|
203
210
|
formatRatio,
|
|
204
211
|
formatWindowCountDelta,
|
|
212
|
+
ga4AiReferralDailyDtoSchema,
|
|
205
213
|
ga4AiReferralHistoryEntrySchema,
|
|
206
214
|
ga4SessionHistoryEntrySchema,
|
|
207
215
|
ga4SocialReferralHistoryEntrySchema,
|
|
@@ -242,6 +250,7 @@ import {
|
|
|
242
250
|
internalError,
|
|
243
251
|
isBrowserProvider,
|
|
244
252
|
isReadOnlyKey,
|
|
253
|
+
isoDateDaysBeforeInTimeZone,
|
|
245
254
|
keywordDtoSchema,
|
|
246
255
|
keywordGenerateRequestSchema,
|
|
247
256
|
latestProjectRunDtoSchema,
|
|
@@ -356,7 +365,7 @@ import {
|
|
|
356
365
|
wordpressSchemaDeployResultDtoSchema,
|
|
357
366
|
wordpressSchemaStatusResultDtoSchema,
|
|
358
367
|
wordpressStatusDtoSchema
|
|
359
|
-
} from "./chunk-
|
|
368
|
+
} from "./chunk-CF5WTWXI.js";
|
|
360
369
|
|
|
361
370
|
// src/intelligence-service.ts
|
|
362
371
|
import { eq as eq44, desc as desc21, asc as asc9, and as and35, ne as ne6, or as or10, inArray as inArray15, gte as gte12, lte as lte8 } from "drizzle-orm";
|
|
@@ -1462,6 +1471,8 @@ var researchRunQueries = sqliteTable("research_run_queries", {
|
|
|
1462
1471
|
groundingSources: text("grounding_sources", { mode: "json" }).$type().notNull().default([]),
|
|
1463
1472
|
citedDomains: text("cited_domains", { mode: "json" }).$type().notNull().default([]),
|
|
1464
1473
|
searchQueries: text("search_queries", { mode: "json" }).$type().notNull().default([]),
|
|
1474
|
+
namedCompetitors: text("named_competitors", { mode: "json" }).$type().notNull().default([]),
|
|
1475
|
+
citedCompetitorDomains: text("cited_competitor_domains", { mode: "json" }).$type().notNull().default([]),
|
|
1465
1476
|
answerMentioned: integer("answer_mentioned", { mode: "boolean" }),
|
|
1466
1477
|
citationState: text("citation_state"),
|
|
1467
1478
|
rawResponse: text("raw_response", { mode: "json" }).$type(),
|
|
@@ -4266,6 +4277,16 @@ var MIGRATION_VERSIONS = [
|
|
|
4266
4277
|
updated_at TEXT NOT NULL
|
|
4267
4278
|
)`
|
|
4268
4279
|
]
|
|
4280
|
+
},
|
|
4281
|
+
{
|
|
4282
|
+
// Keep research batches useful as a competitor-comparison workspace while
|
|
4283
|
+
// retaining their strict isolation from tracked visibility snapshots.
|
|
4284
|
+
version: 110,
|
|
4285
|
+
name: "research-query-named-and-cited-competitors",
|
|
4286
|
+
statements: [
|
|
4287
|
+
`ALTER TABLE research_run_queries ADD COLUMN named_competitors TEXT NOT NULL DEFAULT '[]'`,
|
|
4288
|
+
`ALTER TABLE research_run_queries ADD COLUMN cited_competitor_domains TEXT NOT NULL DEFAULT '[]'`
|
|
4289
|
+
]
|
|
4269
4290
|
}
|
|
4270
4291
|
];
|
|
4271
4292
|
function rebuildBacklinkTableWithSource(tx, table) {
|
|
@@ -17392,6 +17413,7 @@ var SCHEMA_TABLE = {
|
|
|
17392
17413
|
AdsDisconnectResponse: adsDisconnectResponseSchema,
|
|
17393
17414
|
AdsGeoSearchResponse: adsGeoSearchResponseSchema,
|
|
17394
17415
|
AdsInsightsResponse: adsInsightsResponseSchema,
|
|
17416
|
+
AdsLiveDeliveryDto: adsLiveDeliveryDtoSchema,
|
|
17395
17417
|
AdsOperationReconcileResponse: adsOperationReconcileResponseSchema,
|
|
17396
17418
|
AdsOperationResponse: adsOperationResponseSchema,
|
|
17397
17419
|
AdsSummaryDto: adsSummaryDtoSchema,
|
|
@@ -17441,6 +17463,7 @@ var SCHEMA_TABLE = {
|
|
|
17441
17463
|
ResearchRunDetailDto: researchRunDetailSchema,
|
|
17442
17464
|
ResearchRunListDto: researchRunListSchema,
|
|
17443
17465
|
DoctorReportDto: doctorReportSchema,
|
|
17466
|
+
GA4AiReferralDailyDto: ga4AiReferralDailyDtoSchema,
|
|
17444
17467
|
GA4AiReferralHistoryEntry: ga4AiReferralHistoryEntrySchema,
|
|
17445
17468
|
GA4MeasurementAnalysisDto: gaMeasurementAnalysisDtoSchema,
|
|
17446
17469
|
GA4SessionHistoryEntry: ga4SessionHistoryEntrySchema,
|
|
@@ -20395,6 +20418,35 @@ var routeCatalog = [
|
|
|
20395
20418
|
404: errorResponse("Project not found.")
|
|
20396
20419
|
}
|
|
20397
20420
|
},
|
|
20421
|
+
{
|
|
20422
|
+
method: "get",
|
|
20423
|
+
path: "/api/v1/projects/{name}/ads/live-delivery",
|
|
20424
|
+
summary: "Live provider read of ads status and metrics, with the local snapshot delta",
|
|
20425
|
+
description: "Calls the OpenAI Ads API right now with the stored connection and returns the provider's current status and metrics unaggregated, alongside the corresponding local snapshot values and an explicit per-entity delta. Dates on both sides are the ad account's local calendar dates. The first day of the window is measured from 00:00 in the account timezone on both sides, so it is a whole day either way and a difference on it is real; the current local day is in progress on both sides, so a difference there is snapshot staleness. Read-only: it never creates, updates, pauses, or activates anything, and it never waits for a sync run. The window counts calendar days in the account timezone, not fixed 24-hour periods, so a daylight-saving transition cannot move the first day. The walk is bounded by per-level caps and a budget of 40 reader calls (see bounds). Admission has two rules: a project may have only one live read running at a time (a second request is refused for as long as the first is actually walking, which can exceed any interval), and consecutive reads are spaced by at least one minute. The interval is counted as soon as the provider is called, so a read that fails upstream still holds it; only a read rejected before the provider was called leaves the next one free. A reader call is not one HTTP request: each list or insight read auto-paginates up to 100 pages, so the worst-case upstream cost of a single request is about 4000 HTTP requests, reported as bounds.maxUpstreamHttpRequests.",
|
|
20426
|
+
tags: ["ads"],
|
|
20427
|
+
parameters: [
|
|
20428
|
+
nameParameter,
|
|
20429
|
+
{
|
|
20430
|
+
name: "campaignId",
|
|
20431
|
+
in: "query",
|
|
20432
|
+
description: "Scope the walk to a single campaign.",
|
|
20433
|
+
schema: stringSchema
|
|
20434
|
+
},
|
|
20435
|
+
{
|
|
20436
|
+
name: "lookbackDays",
|
|
20437
|
+
in: "query",
|
|
20438
|
+
description: "Metrics window in calendar days of the ad account timezone (1-30, default 7).",
|
|
20439
|
+
schema: integerSchema
|
|
20440
|
+
}
|
|
20441
|
+
],
|
|
20442
|
+
responses: {
|
|
20443
|
+
200: jsonResponse("Live provider state and snapshot delta returned.", "AdsLiveDeliveryDto"),
|
|
20444
|
+
400: errorResponse("Invalid query, or no ads connection for this project."),
|
|
20445
|
+
404: errorResponse("Project not found."),
|
|
20446
|
+
429: errorResponse("Another live read for this project is still running, or the minimum interval since the last provider read has not elapsed. error.details.reason says which."),
|
|
20447
|
+
502: errorResponse("The OpenAI Ads API read failed.")
|
|
20448
|
+
}
|
|
20449
|
+
},
|
|
20398
20450
|
{
|
|
20399
20451
|
method: "post",
|
|
20400
20452
|
path: "/api/v1/projects/{name}/bing/connect",
|
|
@@ -21155,7 +21207,8 @@ var routeCatalog = [
|
|
|
21155
21207
|
{
|
|
21156
21208
|
method: "get",
|
|
21157
21209
|
path: "/api/v1/projects/{name}/ga/ai-referral-history",
|
|
21158
|
-
summary: "Get AI referral
|
|
21210
|
+
summary: "Get raw AI referral detail rows per day, landing page, and attribution dimension",
|
|
21211
|
+
description: "Detail rows, not totals. One row per landing page per attribution dimension, so a single day of one source is many rows and each is commonly worth one session. Use /ga/ai-referral-daily for per-date or per-source session counts.",
|
|
21159
21212
|
tags: ["ga4"],
|
|
21160
21213
|
parameters: [nameParameter, analyticsWindowParameter],
|
|
21161
21214
|
responses: {
|
|
@@ -21164,6 +21217,19 @@ var routeCatalog = [
|
|
|
21164
21217
|
404: errorResponse("Project not found.")
|
|
21165
21218
|
}
|
|
21166
21219
|
},
|
|
21220
|
+
{
|
|
21221
|
+
method: "get",
|
|
21222
|
+
path: "/api/v1/projects/{name}/ga/ai-referral-daily",
|
|
21223
|
+
summary: "Get AI referral sessions per day and per source",
|
|
21224
|
+
description: "Sums landing pages within one attribution dimension and never across dimensions, so totalSessions equals the aiSessionsDeduped reported by /ga/traffic for the same window. Sessions only: GA counts users as a distinct count at the grain requested, so an AI-referral user total cannot be summed from these rows and no un-dimensioned AI-referral fetch exists to supply one.",
|
|
21225
|
+
tags: ["ga4"],
|
|
21226
|
+
parameters: [nameParameter, analyticsWindowParameter],
|
|
21227
|
+
responses: {
|
|
21228
|
+
200: jsonResponse("AI referral daily series returned.", "GA4AiReferralDailyDto"),
|
|
21229
|
+
400: errorResponse("GA4 is not connected."),
|
|
21230
|
+
404: errorResponse("Project not found.")
|
|
21231
|
+
}
|
|
21232
|
+
},
|
|
21167
21233
|
{
|
|
21168
21234
|
method: "get",
|
|
21169
21235
|
path: "/api/v1/projects/{name}/ga/social-referral-history",
|
|
@@ -28465,6 +28531,116 @@ function registerAdsActivationRoutes(app, opts) {
|
|
|
28465
28531
|
});
|
|
28466
28532
|
}
|
|
28467
28533
|
|
|
28534
|
+
// ../api-routes/src/ads-live-delivery.ts
|
|
28535
|
+
function liveComparisonWindow(fetchedAtMs, lookbackDays, timezone) {
|
|
28536
|
+
const fetchedAtIso = new Date(fetchedAtMs).toISOString();
|
|
28537
|
+
return {
|
|
28538
|
+
startDate: isoDateDaysBeforeInTimeZone(fetchedAtIso, lookbackDays, timezone),
|
|
28539
|
+
endDate: formatIsoDateInTimeZone(fetchedAtIso, timezone)
|
|
28540
|
+
};
|
|
28541
|
+
}
|
|
28542
|
+
var EMPTY_METRIC_VALUES = {
|
|
28543
|
+
impressions: 0,
|
|
28544
|
+
clicks: 0,
|
|
28545
|
+
spendMicros: 0,
|
|
28546
|
+
conversions: 0
|
|
28547
|
+
};
|
|
28548
|
+
function presenceOf(live, stored) {
|
|
28549
|
+
if (live && stored) return AdsLivePresences.both;
|
|
28550
|
+
if (live) return AdsLivePresences["live-only"];
|
|
28551
|
+
return AdsLivePresences["stored-only"];
|
|
28552
|
+
}
|
|
28553
|
+
function fieldDelta(field, live, stored) {
|
|
28554
|
+
return live === stored ? null : { field, live, stored };
|
|
28555
|
+
}
|
|
28556
|
+
function buildFieldDeltas(live, stored) {
|
|
28557
|
+
if (!live || !stored) return [];
|
|
28558
|
+
const deltas = [
|
|
28559
|
+
fieldDelta("status", live.status, stored.status),
|
|
28560
|
+
fieldDelta("reviewStatus", live.reviewStatus, stored.reviewStatus),
|
|
28561
|
+
fieldDelta("name", live.name, stored.name)
|
|
28562
|
+
];
|
|
28563
|
+
return deltas.filter((delta) => delta !== null);
|
|
28564
|
+
}
|
|
28565
|
+
function foldLiveRows(rows) {
|
|
28566
|
+
let values = EMPTY_METRIC_VALUES;
|
|
28567
|
+
for (const row of rows) {
|
|
28568
|
+
values = {
|
|
28569
|
+
impressions: values.impressions + Math.round(row.impressions ?? 0),
|
|
28570
|
+
clicks: values.clicks + Math.round(row.clicks ?? 0),
|
|
28571
|
+
spendMicros: values.spendMicros + dollarsToMicros(row.spend ?? 0),
|
|
28572
|
+
conversions: values.conversions + Math.round(row.conversions ?? 0)
|
|
28573
|
+
};
|
|
28574
|
+
}
|
|
28575
|
+
return values;
|
|
28576
|
+
}
|
|
28577
|
+
function sameMetrics(a, b) {
|
|
28578
|
+
return a.impressions === b.impressions && a.clicks === b.clicks && a.spendMicros === b.spendMicros && a.conversions === b.conversions;
|
|
28579
|
+
}
|
|
28580
|
+
function buildMetricDeltas(liveRows, storedRows, window) {
|
|
28581
|
+
if (liveRows === null && storedRows === null) return null;
|
|
28582
|
+
const liveByDate = /* @__PURE__ */ new Map();
|
|
28583
|
+
for (const row of liveRows ?? []) {
|
|
28584
|
+
if (row.date === null) continue;
|
|
28585
|
+
const bucket = liveByDate.get(row.date) ?? [];
|
|
28586
|
+
bucket.push(row);
|
|
28587
|
+
liveByDate.set(row.date, bucket);
|
|
28588
|
+
}
|
|
28589
|
+
const storedByDate = /* @__PURE__ */ new Map();
|
|
28590
|
+
for (const row of storedRows ?? []) {
|
|
28591
|
+
if (row.date < window.startDate || row.date > window.endDate) continue;
|
|
28592
|
+
storedByDate.set(row.date, row);
|
|
28593
|
+
}
|
|
28594
|
+
const dates = [.../* @__PURE__ */ new Set([...liveByDate.keys(), ...storedByDate.keys()])].sort();
|
|
28595
|
+
return dates.map((date) => {
|
|
28596
|
+
const liveBucket = liveByDate.get(date);
|
|
28597
|
+
const storedRow = storedByDate.get(date);
|
|
28598
|
+
const live = liveBucket ? foldLiveRows(liveBucket) : null;
|
|
28599
|
+
const stored = storedRow ? {
|
|
28600
|
+
impressions: storedRow.impressions,
|
|
28601
|
+
clicks: storedRow.clicks,
|
|
28602
|
+
spendMicros: storedRow.spendMicros,
|
|
28603
|
+
conversions: storedRow.conversions
|
|
28604
|
+
} : null;
|
|
28605
|
+
const drifted = live === null || stored === null || !sameMetrics(live, stored);
|
|
28606
|
+
return { date, live, stored, drifted };
|
|
28607
|
+
});
|
|
28608
|
+
}
|
|
28609
|
+
function buildLiveEntityComparison(observation, window) {
|
|
28610
|
+
const presence = presenceOf(observation.live, observation.stored);
|
|
28611
|
+
const fieldDeltas = buildFieldDeltas(observation.live, observation.stored);
|
|
28612
|
+
const metricDeltas = buildMetricDeltas(observation.liveMetrics, observation.storedMetrics, window);
|
|
28613
|
+
const drifted = presence !== AdsLivePresences.both || fieldDeltas.length > 0 || (metricDeltas?.some((delta) => delta.drifted) ?? false);
|
|
28614
|
+
return {
|
|
28615
|
+
entityType: observation.entityType,
|
|
28616
|
+
id: observation.id,
|
|
28617
|
+
parentId: observation.parentId,
|
|
28618
|
+
presence,
|
|
28619
|
+
live: observation.live,
|
|
28620
|
+
stored: observation.stored,
|
|
28621
|
+
fieldDeltas,
|
|
28622
|
+
liveMetrics: observation.liveMetrics,
|
|
28623
|
+
metricDeltas,
|
|
28624
|
+
drifted
|
|
28625
|
+
};
|
|
28626
|
+
}
|
|
28627
|
+
function summarizeLiveDrift(entities) {
|
|
28628
|
+
let driftedEntities = 0;
|
|
28629
|
+
let statusDrifted = 0;
|
|
28630
|
+
let metricsDrifted = 0;
|
|
28631
|
+
for (const entity of entities) {
|
|
28632
|
+
if (entity.drifted) driftedEntities += 1;
|
|
28633
|
+
if (entity.fieldDeltas.some((delta) => delta.field === "status")) statusDrifted += 1;
|
|
28634
|
+
if (entity.metricDeltas?.some((delta) => delta.drifted)) metricsDrifted += 1;
|
|
28635
|
+
}
|
|
28636
|
+
return {
|
|
28637
|
+
entitiesCompared: entities.length,
|
|
28638
|
+
driftedEntities,
|
|
28639
|
+
statusDrifted,
|
|
28640
|
+
metricsDrifted
|
|
28641
|
+
};
|
|
28642
|
+
}
|
|
28643
|
+
|
|
28468
28644
|
// ../api-routes/src/ads.ts
|
|
28469
28645
|
function runSourceDto(row) {
|
|
28470
28646
|
const parsed = runStatusSchema.safeParse(row.status);
|
|
@@ -28599,6 +28775,12 @@ function storedActivityAssessment(snapshotStatus, rollupStatus, rollupImpression
|
|
|
28599
28775
|
}
|
|
28600
28776
|
}
|
|
28601
28777
|
}
|
|
28778
|
+
var ADS_LIVE_MAX_CAMPAIGNS = 5;
|
|
28779
|
+
var ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN = 10;
|
|
28780
|
+
var ADS_LIVE_MAX_ADS_PER_AD_GROUP = 20;
|
|
28781
|
+
var ADS_LIVE_MAX_READER_CALLS = 40;
|
|
28782
|
+
var DEFAULT_ADS_LIVE_MAX_PAGES_PER_READER_CALL = 100;
|
|
28783
|
+
var DEFAULT_ADS_LIVE_DELIVERY_MIN_INTERVAL_MS = 6e4;
|
|
28602
28784
|
var DEFAULT_RECONCILE_SWEEP_INTERVAL_MS = 6e4;
|
|
28603
28785
|
var DEFAULT_RECONCILE_PENDING_STALE_MS = 5 * 6e4;
|
|
28604
28786
|
var DEFAULT_RECONCILE_BACKOFF_BASE_MS = 5 * 6e4;
|
|
@@ -28961,6 +29143,47 @@ async function executeAdsRead(surface, read) {
|
|
|
28961
29143
|
});
|
|
28962
29144
|
}
|
|
28963
29145
|
}
|
|
29146
|
+
function resolveAdsLiveDeliveryReader(opts, projectName) {
|
|
29147
|
+
const apiKey = opts.adsCredentialStore?.getConnection(projectName)?.apiKey;
|
|
29148
|
+
if (!apiKey) {
|
|
29149
|
+
throw validationError("No OpenAI Ads API key configured for this project");
|
|
29150
|
+
}
|
|
29151
|
+
if (!opts.adsLiveDeliveryReader) {
|
|
29152
|
+
throw validationError("Ads live delivery reads are not configured for this deployment");
|
|
29153
|
+
}
|
|
29154
|
+
return { apiKey, reader: opts.adsLiveDeliveryReader };
|
|
29155
|
+
}
|
|
29156
|
+
function upstreamStatusOf(error) {
|
|
29157
|
+
return typeof error === "object" && error !== null && "status" in error && typeof error.status === "number" && Number.isInteger(error.status) ? error.status : null;
|
|
29158
|
+
}
|
|
29159
|
+
function liveReadFailure(surface, entityId, error) {
|
|
29160
|
+
return { surface, entityId, upstreamStatus: upstreamStatusOf(error) };
|
|
29161
|
+
}
|
|
29162
|
+
function liveReadProviderError(surface, error) {
|
|
29163
|
+
const upstreamStatus = upstreamStatusOf(error);
|
|
29164
|
+
return providerError(
|
|
29165
|
+
`OpenAI Ads API ${surface} read failed`,
|
|
29166
|
+
upstreamStatus === null ? void 0 : { upstreamStatus }
|
|
29167
|
+
);
|
|
29168
|
+
}
|
|
29169
|
+
function liveEntityState(entity) {
|
|
29170
|
+
return {
|
|
29171
|
+
name: entity.name,
|
|
29172
|
+
status: entity.status,
|
|
29173
|
+
reviewStatus: entity.reviewStatus,
|
|
29174
|
+
mode: entity.mode,
|
|
29175
|
+
updatedAt: entity.updatedAt
|
|
29176
|
+
};
|
|
29177
|
+
}
|
|
29178
|
+
function storedEntityState(row) {
|
|
29179
|
+
return {
|
|
29180
|
+
name: row.name,
|
|
29181
|
+
status: row.status,
|
|
29182
|
+
reviewStatus: row.reviewStatus ?? null,
|
|
29183
|
+
upstreamUpdatedAt: row.upstreamUpdatedAt,
|
|
29184
|
+
syncedAt: row.syncedAt
|
|
29185
|
+
};
|
|
29186
|
+
}
|
|
28964
29187
|
async function executeAdsOperation(app, request, input) {
|
|
28965
29188
|
const hash = requestHash({ kind: input.kind, entityType: input.entityType, payload: input.payload });
|
|
28966
29189
|
const existing = readOperationByKey(app, input.projectId, input.operationKey);
|
|
@@ -29599,6 +29822,21 @@ async function adsRoutes(app, opts) {
|
|
|
29599
29822
|
opts.adsAccountVerificationCacheTtlMs ?? DEFAULT_ADS_ACCOUNT_VERIFICATION_CACHE_TTL_MS
|
|
29600
29823
|
);
|
|
29601
29824
|
const verificationCache = /* @__PURE__ */ new Map();
|
|
29825
|
+
const liveDeliveryMinIntervalMs = Math.max(
|
|
29826
|
+
0,
|
|
29827
|
+
opts.adsLiveDeliveryMinIntervalMs ?? DEFAULT_ADS_LIVE_DELIVERY_MIN_INTERVAL_MS
|
|
29828
|
+
);
|
|
29829
|
+
const liveDeliveryMaxPagesPerReaderCall = Math.max(
|
|
29830
|
+
1,
|
|
29831
|
+
opts.adsLiveDeliveryMaxPagesPerReaderCall ?? DEFAULT_ADS_LIVE_MAX_PAGES_PER_READER_CALL
|
|
29832
|
+
);
|
|
29833
|
+
const liveDeliveryInFlight = /* @__PURE__ */ new Set();
|
|
29834
|
+
const liveDeliveryLastAttemptAtMs = /* @__PURE__ */ new Map();
|
|
29835
|
+
const pruneLiveDeliveryAttempts = (nowMs) => {
|
|
29836
|
+
for (const [projectId, lastAtMs] of liveDeliveryLastAttemptAtMs) {
|
|
29837
|
+
if (nowMs - lastAtMs >= liveDeliveryMinIntervalMs) liveDeliveryLastAttemptAtMs.delete(projectId);
|
|
29838
|
+
}
|
|
29839
|
+
};
|
|
29602
29840
|
const reconcilePolicy = {
|
|
29603
29841
|
pendingMinIdleMs: pendingStaleMs,
|
|
29604
29842
|
backoffBaseMs,
|
|
@@ -30378,6 +30616,264 @@ async function adsRoutes(app, opts) {
|
|
|
30378
30616
|
};
|
|
30379
30617
|
return response;
|
|
30380
30618
|
});
|
|
30619
|
+
app.get("/projects/:name/ads/live-delivery", async (request) => {
|
|
30620
|
+
const project = resolveProject(app.db, request.params.name);
|
|
30621
|
+
const parsed = adsLiveDeliveryQuerySchema.safeParse(request.query);
|
|
30622
|
+
if (!parsed.success) {
|
|
30623
|
+
throw validationError("Invalid ads live delivery query", { issues: parsed.error.issues });
|
|
30624
|
+
}
|
|
30625
|
+
const { campaignId, lookbackDays } = parsed.data;
|
|
30626
|
+
const connection = app.db.select().from(adsConnections).where(eq28(adsConnections.projectId, project.id)).get();
|
|
30627
|
+
if (!connection) {
|
|
30628
|
+
throw validationError('No ads connection for this project. Run "canonry ads connect" first.');
|
|
30629
|
+
}
|
|
30630
|
+
const { apiKey, reader } = resolveAdsLiveDeliveryReader(opts, project.name);
|
|
30631
|
+
const verifyAdsAccount = opts.verifyAdsAccount;
|
|
30632
|
+
if (!verifyAdsAccount) {
|
|
30633
|
+
throw validationError("Ads account verification is not configured for this deployment");
|
|
30634
|
+
}
|
|
30635
|
+
const fetchedAtMs = Date.now();
|
|
30636
|
+
const lastAttemptAtMs = liveDeliveryLastAttemptAtMs.get(project.id);
|
|
30637
|
+
const remainingIntervalMs = liveDeliveryMinIntervalMs > 0 && lastAttemptAtMs !== void 0 ? lastAttemptAtMs + liveDeliveryMinIntervalMs - fetchedAtMs : 0;
|
|
30638
|
+
if (liveDeliveryInFlight.has(project.id)) {
|
|
30639
|
+
throw quotaExceeded("OpenAI Ads live delivery reads", {
|
|
30640
|
+
reason: "read-in-flight",
|
|
30641
|
+
minIntervalMs: liveDeliveryMinIntervalMs,
|
|
30642
|
+
// A LOWER bound, and only when the interval itself still blocks. How
|
|
30643
|
+
// much longer the running walk needs is unknowable from here, so once
|
|
30644
|
+
// it has outlived the interval there is no honest number to give and
|
|
30645
|
+
// the field is absent rather than guessed.
|
|
30646
|
+
...remainingIntervalMs > 0 ? { retryAfterMs: remainingIntervalMs } : {}
|
|
30647
|
+
});
|
|
30648
|
+
}
|
|
30649
|
+
if (remainingIntervalMs > 0) {
|
|
30650
|
+
throw quotaExceeded("OpenAI Ads live delivery reads", {
|
|
30651
|
+
reason: "min-interval",
|
|
30652
|
+
minIntervalMs: liveDeliveryMinIntervalMs,
|
|
30653
|
+
retryAfterMs: remainingIntervalMs
|
|
30654
|
+
});
|
|
30655
|
+
}
|
|
30656
|
+
liveDeliveryInFlight.add(project.id);
|
|
30657
|
+
const spendLiveDeliveryAttempt = () => {
|
|
30658
|
+
if (liveDeliveryMinIntervalMs <= 0) return;
|
|
30659
|
+
pruneLiveDeliveryAttempts(fetchedAtMs);
|
|
30660
|
+
liveDeliveryLastAttemptAtMs.set(project.id, fetchedAtMs);
|
|
30661
|
+
};
|
|
30662
|
+
try {
|
|
30663
|
+
let readerCalls = 0;
|
|
30664
|
+
let truncated = false;
|
|
30665
|
+
const errors = [];
|
|
30666
|
+
const callProvider = async (surface, entityId, run) => {
|
|
30667
|
+
if (readerCalls >= ADS_LIVE_MAX_READER_CALLS) {
|
|
30668
|
+
truncated = true;
|
|
30669
|
+
return null;
|
|
30670
|
+
}
|
|
30671
|
+
readerCalls += 1;
|
|
30672
|
+
try {
|
|
30673
|
+
return await run();
|
|
30674
|
+
} catch (error) {
|
|
30675
|
+
errors.push(liveReadFailure(surface, entityId, error));
|
|
30676
|
+
return null;
|
|
30677
|
+
}
|
|
30678
|
+
};
|
|
30679
|
+
readerCalls += 1;
|
|
30680
|
+
let account;
|
|
30681
|
+
spendLiveDeliveryAttempt();
|
|
30682
|
+
try {
|
|
30683
|
+
account = await verifyAdsAccount(apiKey);
|
|
30684
|
+
} catch (error) {
|
|
30685
|
+
throw liveReadProviderError("account identity", error);
|
|
30686
|
+
}
|
|
30687
|
+
if (account.id !== connection.adAccountId) {
|
|
30688
|
+
throw validationError("The configured key belongs to a different OpenAI ad account; reconnect this project");
|
|
30689
|
+
}
|
|
30690
|
+
readerCalls += 1;
|
|
30691
|
+
let liveCampaigns;
|
|
30692
|
+
try {
|
|
30693
|
+
liveCampaigns = await reader.listCampaigns(apiKey);
|
|
30694
|
+
} catch (error) {
|
|
30695
|
+
throw liveReadProviderError("campaign list", error);
|
|
30696
|
+
}
|
|
30697
|
+
const scopedCampaigns = campaignId === void 0 ? liveCampaigns : liveCampaigns.filter((campaign) => campaign.id === campaignId);
|
|
30698
|
+
if (scopedCampaigns.length > ADS_LIVE_MAX_CAMPAIGNS) truncated = true;
|
|
30699
|
+
const walkedCampaigns = scopedCampaigns.slice(0, ADS_LIVE_MAX_CAMPAIGNS);
|
|
30700
|
+
const storedCampaignRows = app.db.select().from(adsCampaigns).where(and22(
|
|
30701
|
+
eq28(adsCampaigns.projectId, project.id),
|
|
30702
|
+
campaignId === void 0 ? void 0 : eq28(adsCampaigns.id, campaignId)
|
|
30703
|
+
)).all();
|
|
30704
|
+
const storedAdGroupRows = app.db.select().from(adsAdGroups).where(and22(
|
|
30705
|
+
eq28(adsAdGroups.projectId, project.id),
|
|
30706
|
+
campaignId === void 0 ? void 0 : eq28(adsAdGroups.campaignId, campaignId)
|
|
30707
|
+
)).all();
|
|
30708
|
+
const storedAdGroupIds = new Set(storedAdGroupRows.map((row) => row.id));
|
|
30709
|
+
const storedAdRows = app.db.select().from(adsAds).where(eq28(adsAds.projectId, project.id)).all().filter((row) => campaignId === void 0 || storedAdGroupIds.has(row.adGroupId));
|
|
30710
|
+
const storedInsightRows = app.db.select({
|
|
30711
|
+
level: adsInsightsDaily.level,
|
|
30712
|
+
entityId: adsInsightsDaily.entityId,
|
|
30713
|
+
date: adsInsightsDaily.date,
|
|
30714
|
+
impressions: adsInsightsDaily.impressions,
|
|
30715
|
+
clicks: adsInsightsDaily.clicks,
|
|
30716
|
+
spendMicros: adsInsightsDaily.spendMicros,
|
|
30717
|
+
conversions: adsInsightsDaily.conversions
|
|
30718
|
+
}).from(adsInsightsDaily).where(eq28(adsInsightsDaily.projectId, project.id)).all();
|
|
30719
|
+
const storedCampaignById = new Map(storedCampaignRows.map((row) => [row.id, row]));
|
|
30720
|
+
const storedAdGroupById = new Map(storedAdGroupRows.map((row) => [row.id, row]));
|
|
30721
|
+
const storedAdById = new Map(storedAdRows.map((row) => [row.id, row]));
|
|
30722
|
+
const storedMetricsByEntity = /* @__PURE__ */ new Map();
|
|
30723
|
+
for (const row of storedInsightRows) {
|
|
30724
|
+
const key = `${row.level}:${row.entityId}`;
|
|
30725
|
+
const bucket = storedMetricsByEntity.get(key) ?? [];
|
|
30726
|
+
bucket.push({
|
|
30727
|
+
date: row.date,
|
|
30728
|
+
impressions: row.impressions,
|
|
30729
|
+
clicks: row.clicks,
|
|
30730
|
+
spendMicros: row.spendMicros,
|
|
30731
|
+
conversions: row.conversions
|
|
30732
|
+
});
|
|
30733
|
+
storedMetricsByEntity.set(key, bucket);
|
|
30734
|
+
}
|
|
30735
|
+
const storedMetricsFor = (level, entityId) => storedMetricsByEntity.get(`${level}:${entityId}`) ?? [];
|
|
30736
|
+
const accountTimezone = account.timezone ?? "UTC";
|
|
30737
|
+
const window = liveComparisonWindow(fetchedAtMs, lookbackDays, accountTimezone);
|
|
30738
|
+
const insightsRequest = {
|
|
30739
|
+
startDate: window.startDate,
|
|
30740
|
+
fetchedAtMs,
|
|
30741
|
+
timezone: accountTimezone
|
|
30742
|
+
};
|
|
30743
|
+
const observations = [];
|
|
30744
|
+
const walkedCampaignIds = /* @__PURE__ */ new Set();
|
|
30745
|
+
const walkedAdGroupIds = /* @__PURE__ */ new Set();
|
|
30746
|
+
const walkedAdIds = /* @__PURE__ */ new Set();
|
|
30747
|
+
for (const campaign of walkedCampaigns) {
|
|
30748
|
+
walkedCampaignIds.add(campaign.id);
|
|
30749
|
+
const campaignMetrics = await callProvider(
|
|
30750
|
+
"campaign insights",
|
|
30751
|
+
campaign.id,
|
|
30752
|
+
() => reader.campaignInsights(apiKey, campaign.id, insightsRequest)
|
|
30753
|
+
);
|
|
30754
|
+
observations.push({
|
|
30755
|
+
entityType: AdsLiveEntityTypes.campaign,
|
|
30756
|
+
id: campaign.id,
|
|
30757
|
+
parentId: null,
|
|
30758
|
+
live: liveEntityState(campaign),
|
|
30759
|
+
stored: storedCampaignById.has(campaign.id) ? storedEntityState(storedCampaignById.get(campaign.id)) : null,
|
|
30760
|
+
// A metrics call that did not happen is not evidence about metrics, so
|
|
30761
|
+
// both sides stay null and no delta is claimed.
|
|
30762
|
+
liveMetrics: campaignMetrics,
|
|
30763
|
+
storedMetrics: campaignMetrics === null ? null : storedMetricsFor(AdsInsightLevels.campaign, campaign.id)
|
|
30764
|
+
});
|
|
30765
|
+
const liveAdGroups = await callProvider(
|
|
30766
|
+
"ad group list",
|
|
30767
|
+
campaign.id,
|
|
30768
|
+
() => reader.listAdGroups(apiKey, campaign.id)
|
|
30769
|
+
);
|
|
30770
|
+
if (liveAdGroups === null) continue;
|
|
30771
|
+
if (liveAdGroups.length > ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN) truncated = true;
|
|
30772
|
+
for (const adGroup of liveAdGroups.slice(0, ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN)) {
|
|
30773
|
+
walkedAdGroupIds.add(adGroup.id);
|
|
30774
|
+
const adGroupMetrics = await callProvider(
|
|
30775
|
+
"ad group insights",
|
|
30776
|
+
adGroup.id,
|
|
30777
|
+
() => reader.adGroupInsights(apiKey, adGroup.id, insightsRequest)
|
|
30778
|
+
);
|
|
30779
|
+
observations.push({
|
|
30780
|
+
entityType: AdsLiveEntityTypes.ad_group,
|
|
30781
|
+
id: adGroup.id,
|
|
30782
|
+
parentId: campaign.id,
|
|
30783
|
+
live: liveEntityState(adGroup),
|
|
30784
|
+
stored: storedAdGroupById.has(adGroup.id) ? storedEntityState(storedAdGroupById.get(adGroup.id)) : null,
|
|
30785
|
+
liveMetrics: adGroupMetrics,
|
|
30786
|
+
storedMetrics: adGroupMetrics === null ? null : storedMetricsFor(AdsInsightLevels.ad_group, adGroup.id)
|
|
30787
|
+
});
|
|
30788
|
+
const liveAds = await callProvider(
|
|
30789
|
+
"ad list",
|
|
30790
|
+
adGroup.id,
|
|
30791
|
+
() => reader.listAds(apiKey, adGroup.id)
|
|
30792
|
+
);
|
|
30793
|
+
if (liveAds === null) continue;
|
|
30794
|
+
if (liveAds.length > ADS_LIVE_MAX_ADS_PER_AD_GROUP) truncated = true;
|
|
30795
|
+
for (const ad of liveAds.slice(0, ADS_LIVE_MAX_ADS_PER_AD_GROUP)) {
|
|
30796
|
+
walkedAdIds.add(ad.id);
|
|
30797
|
+
observations.push({
|
|
30798
|
+
entityType: AdsLiveEntityTypes.ad,
|
|
30799
|
+
id: ad.id,
|
|
30800
|
+
parentId: adGroup.id,
|
|
30801
|
+
live: liveEntityState(ad),
|
|
30802
|
+
stored: storedAdById.has(ad.id) ? storedEntityState(storedAdById.get(ad.id)) : null,
|
|
30803
|
+
// The provider has no per-ad insights surface (same reason ads-sync
|
|
30804
|
+
// stores no ad-level rollups), so ads carry status evidence only.
|
|
30805
|
+
liveMetrics: null,
|
|
30806
|
+
storedMetrics: null
|
|
30807
|
+
});
|
|
30808
|
+
}
|
|
30809
|
+
}
|
|
30810
|
+
}
|
|
30811
|
+
if (!truncated && errors.length === 0) {
|
|
30812
|
+
for (const row of storedCampaignRows) {
|
|
30813
|
+
if (walkedCampaignIds.has(row.id)) continue;
|
|
30814
|
+
observations.push({
|
|
30815
|
+
entityType: AdsLiveEntityTypes.campaign,
|
|
30816
|
+
id: row.id,
|
|
30817
|
+
parentId: null,
|
|
30818
|
+
live: null,
|
|
30819
|
+
stored: storedEntityState(row),
|
|
30820
|
+
liveMetrics: null,
|
|
30821
|
+
storedMetrics: null
|
|
30822
|
+
});
|
|
30823
|
+
}
|
|
30824
|
+
for (const row of storedAdGroupRows) {
|
|
30825
|
+
if (walkedAdGroupIds.has(row.id)) continue;
|
|
30826
|
+
observations.push({
|
|
30827
|
+
entityType: AdsLiveEntityTypes.ad_group,
|
|
30828
|
+
id: row.id,
|
|
30829
|
+
parentId: row.campaignId,
|
|
30830
|
+
live: null,
|
|
30831
|
+
stored: storedEntityState(row),
|
|
30832
|
+
liveMetrics: null,
|
|
30833
|
+
storedMetrics: null
|
|
30834
|
+
});
|
|
30835
|
+
}
|
|
30836
|
+
for (const row of storedAdRows) {
|
|
30837
|
+
if (walkedAdIds.has(row.id)) continue;
|
|
30838
|
+
observations.push({
|
|
30839
|
+
entityType: AdsLiveEntityTypes.ad,
|
|
30840
|
+
id: row.id,
|
|
30841
|
+
parentId: row.adGroupId,
|
|
30842
|
+
live: null,
|
|
30843
|
+
stored: storedEntityState(row),
|
|
30844
|
+
liveMetrics: null,
|
|
30845
|
+
storedMetrics: null
|
|
30846
|
+
});
|
|
30847
|
+
}
|
|
30848
|
+
}
|
|
30849
|
+
const entities = observations.map(
|
|
30850
|
+
(observation) => buildLiveEntityComparison(observation, window)
|
|
30851
|
+
);
|
|
30852
|
+
const response = {
|
|
30853
|
+
basis: AdsLiveDeliveryBases.liveProviderRead,
|
|
30854
|
+
fetchedAt: new Date(fetchedAtMs).toISOString(),
|
|
30855
|
+
adAccountId: account.id,
|
|
30856
|
+
storedSnapshotSyncedAt: connection.lastSyncedAt ?? null,
|
|
30857
|
+
metricsWindow: { lookbackDays },
|
|
30858
|
+
bounds: {
|
|
30859
|
+
maxCampaigns: ADS_LIVE_MAX_CAMPAIGNS,
|
|
30860
|
+
maxAdGroupsPerCampaign: ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN,
|
|
30861
|
+
maxAdsPerAdGroup: ADS_LIVE_MAX_ADS_PER_AD_GROUP,
|
|
30862
|
+
maxReaderCalls: ADS_LIVE_MAX_READER_CALLS,
|
|
30863
|
+
readerCalls,
|
|
30864
|
+
maxPagesPerReaderCall: liveDeliveryMaxPagesPerReaderCall,
|
|
30865
|
+
maxUpstreamHttpRequests: ADS_LIVE_MAX_READER_CALLS * liveDeliveryMaxPagesPerReaderCall,
|
|
30866
|
+
truncated
|
|
30867
|
+
},
|
|
30868
|
+
entities,
|
|
30869
|
+
drift: summarizeLiveDrift(entities),
|
|
30870
|
+
errors
|
|
30871
|
+
};
|
|
30872
|
+
return response;
|
|
30873
|
+
} finally {
|
|
30874
|
+
liveDeliveryInFlight.delete(project.id);
|
|
30875
|
+
}
|
|
30876
|
+
});
|
|
30381
30877
|
app.get("/projects/:name/ads/summary", async (request) => {
|
|
30382
30878
|
const project = resolveProject(app.db, request.params.name);
|
|
30383
30879
|
const row = app.db.select().from(adsConnections).where(eq28(adsConnections.projectId, project.id)).get();
|
|
@@ -31322,6 +31818,160 @@ function buildSessionHistory(landingPageDays, dailyTotals) {
|
|
|
31322
31818
|
});
|
|
31323
31819
|
}
|
|
31324
31820
|
|
|
31821
|
+
// ../api-routes/src/ga-ai-referral-aggregation.ts
|
|
31822
|
+
function normalizeAiTrafficClass(value) {
|
|
31823
|
+
return value === AiReferralTrafficClasses.paid ? AiReferralTrafficClasses.paid : AiReferralTrafficClasses.organic;
|
|
31824
|
+
}
|
|
31825
|
+
function emptyAiCounts() {
|
|
31826
|
+
return { sessions: 0, users: 0 };
|
|
31827
|
+
}
|
|
31828
|
+
function resolveWinningDimensions(rows) {
|
|
31829
|
+
const groups = /* @__PURE__ */ new Map();
|
|
31830
|
+
for (const row of rows) {
|
|
31831
|
+
const key = `${row.date}\0${row.source}\0${row.medium}`;
|
|
31832
|
+
let group = groups.get(key);
|
|
31833
|
+
if (!group) {
|
|
31834
|
+
group = { date: row.date, source: row.source, medium: row.medium, byDimension: /* @__PURE__ */ new Map() };
|
|
31835
|
+
groups.set(key, group);
|
|
31836
|
+
}
|
|
31837
|
+
let dim = group.byDimension.get(row.sourceDimension);
|
|
31838
|
+
if (!dim) {
|
|
31839
|
+
dim = { paidSessions: 0, organicSessions: 0 };
|
|
31840
|
+
group.byDimension.set(row.sourceDimension, dim);
|
|
31841
|
+
}
|
|
31842
|
+
const sessions = row.sessions ?? 0;
|
|
31843
|
+
if (normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid) {
|
|
31844
|
+
dim.paidSessions += sessions;
|
|
31845
|
+
} else {
|
|
31846
|
+
dim.organicSessions += sessions;
|
|
31847
|
+
}
|
|
31848
|
+
}
|
|
31849
|
+
const winners = [];
|
|
31850
|
+
for (const group of groups.values()) {
|
|
31851
|
+
const dims = [...group.byDimension.values()];
|
|
31852
|
+
const bestSessions = dims.reduce((best, d) => d.paidSessions + d.organicSessions > best.paidSessions + best.organicSessions ? d : best);
|
|
31853
|
+
winners.push({
|
|
31854
|
+
date: group.date,
|
|
31855
|
+
source: group.source,
|
|
31856
|
+
medium: group.medium,
|
|
31857
|
+
paidSessions: bestSessions.paidSessions,
|
|
31858
|
+
organicSessions: bestSessions.organicSessions
|
|
31859
|
+
});
|
|
31860
|
+
}
|
|
31861
|
+
return winners;
|
|
31862
|
+
}
|
|
31863
|
+
function resolveLegacyWinningUsers(rows) {
|
|
31864
|
+
const groups = /* @__PURE__ */ new Map();
|
|
31865
|
+
for (const row of rows) {
|
|
31866
|
+
const key = `${row.date}\0${row.source}\0${row.medium}`;
|
|
31867
|
+
let byDimension = groups.get(key);
|
|
31868
|
+
if (!byDimension) {
|
|
31869
|
+
byDimension = /* @__PURE__ */ new Map();
|
|
31870
|
+
groups.set(key, byDimension);
|
|
31871
|
+
}
|
|
31872
|
+
let dim = byDimension.get(row.sourceDimension);
|
|
31873
|
+
if (!dim) {
|
|
31874
|
+
dim = { paidUsers: 0, organicUsers: 0 };
|
|
31875
|
+
byDimension.set(row.sourceDimension, dim);
|
|
31876
|
+
}
|
|
31877
|
+
const users = row.users ?? 0;
|
|
31878
|
+
if (normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid) {
|
|
31879
|
+
dim.paidUsers += users;
|
|
31880
|
+
} else {
|
|
31881
|
+
dim.organicUsers += users;
|
|
31882
|
+
}
|
|
31883
|
+
}
|
|
31884
|
+
let paidUsers = 0;
|
|
31885
|
+
let organicUsers = 0;
|
|
31886
|
+
for (const byDimension of groups.values()) {
|
|
31887
|
+
const dims = [...byDimension.values()];
|
|
31888
|
+
const best = dims.reduce((winner, d) => d.paidUsers + d.organicUsers > winner.paidUsers + winner.organicUsers ? d : winner);
|
|
31889
|
+
paidUsers += best.paidUsers;
|
|
31890
|
+
organicUsers += best.organicUsers;
|
|
31891
|
+
}
|
|
31892
|
+
return { paidUsers, organicUsers };
|
|
31893
|
+
}
|
|
31894
|
+
function summarizeAiReferralCounts(rows) {
|
|
31895
|
+
const paidDeduped = emptyAiCounts();
|
|
31896
|
+
const organicDeduped = emptyAiCounts();
|
|
31897
|
+
const paidBySession = emptyAiCounts();
|
|
31898
|
+
const organicBySession = emptyAiCounts();
|
|
31899
|
+
const bySessionChannelGroup = /* @__PURE__ */ new Map();
|
|
31900
|
+
for (const winner of resolveWinningDimensions(rows)) {
|
|
31901
|
+
paidDeduped.sessions += winner.paidSessions;
|
|
31902
|
+
organicDeduped.sessions += winner.organicSessions;
|
|
31903
|
+
}
|
|
31904
|
+
const legacyUsers = resolveLegacyWinningUsers(rows);
|
|
31905
|
+
paidDeduped.users = legacyUsers.paidUsers;
|
|
31906
|
+
organicDeduped.users = legacyUsers.organicUsers;
|
|
31907
|
+
for (const row of rows) {
|
|
31908
|
+
if (row.sourceDimension !== "session") continue;
|
|
31909
|
+
const sessions = row.sessions ?? 0;
|
|
31910
|
+
const users = row.users ?? 0;
|
|
31911
|
+
bySessionChannelGroup.set(
|
|
31912
|
+
row.channelGroup,
|
|
31913
|
+
(bySessionChannelGroup.get(row.channelGroup) ?? 0) + sessions
|
|
31914
|
+
);
|
|
31915
|
+
const bucket = normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid ? paidBySession : organicBySession;
|
|
31916
|
+
bucket.sessions += sessions;
|
|
31917
|
+
bucket.users += users;
|
|
31918
|
+
}
|
|
31919
|
+
return {
|
|
31920
|
+
paidDeduped,
|
|
31921
|
+
organicDeduped,
|
|
31922
|
+
paidBySession,
|
|
31923
|
+
organicBySession,
|
|
31924
|
+
bySessionChannelGroup,
|
|
31925
|
+
deduped: {
|
|
31926
|
+
sessions: paidDeduped.sessions + organicDeduped.sessions,
|
|
31927
|
+
users: paidDeduped.users + organicDeduped.users
|
|
31928
|
+
},
|
|
31929
|
+
bySession: {
|
|
31930
|
+
sessions: paidBySession.sessions + organicBySession.sessions,
|
|
31931
|
+
users: paidBySession.users + organicBySession.users
|
|
31932
|
+
}
|
|
31933
|
+
};
|
|
31934
|
+
}
|
|
31935
|
+
function buildAiReferralDailySeries(rows) {
|
|
31936
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
31937
|
+
const sourceTotals = /* @__PURE__ */ new Map();
|
|
31938
|
+
let totalSessions = 0;
|
|
31939
|
+
let totalPaidSessions = 0;
|
|
31940
|
+
let totalOrganicSessions = 0;
|
|
31941
|
+
for (const winner of resolveWinningDimensions(rows)) {
|
|
31942
|
+
const sessions = winner.paidSessions + winner.organicSessions;
|
|
31943
|
+
let bySource = byDate.get(winner.date);
|
|
31944
|
+
if (!bySource) {
|
|
31945
|
+
bySource = /* @__PURE__ */ new Map();
|
|
31946
|
+
byDate.set(winner.date, bySource);
|
|
31947
|
+
}
|
|
31948
|
+
let bucket = bySource.get(winner.source);
|
|
31949
|
+
if (!bucket) {
|
|
31950
|
+
bucket = { source: winner.source, sessions: 0, paidSessions: 0, organicSessions: 0 };
|
|
31951
|
+
bySource.set(winner.source, bucket);
|
|
31952
|
+
}
|
|
31953
|
+
bucket.sessions += sessions;
|
|
31954
|
+
bucket.paidSessions += winner.paidSessions;
|
|
31955
|
+
bucket.organicSessions += winner.organicSessions;
|
|
31956
|
+
sourceTotals.set(winner.source, (sourceTotals.get(winner.source) ?? 0) + sessions);
|
|
31957
|
+
totalSessions += sessions;
|
|
31958
|
+
totalPaidSessions += winner.paidSessions;
|
|
31959
|
+
totalOrganicSessions += winner.organicSessions;
|
|
31960
|
+
}
|
|
31961
|
+
const days = [...byDate.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, bySource]) => {
|
|
31962
|
+
const sources2 = [...bySource.values()].sort((a, b) => b.sessions - a.sessions || a.source.localeCompare(b.source));
|
|
31963
|
+
return {
|
|
31964
|
+
date,
|
|
31965
|
+
sessions: sources2.reduce((sum, s) => sum + s.sessions, 0),
|
|
31966
|
+
paidSessions: sources2.reduce((sum, s) => sum + s.paidSessions, 0),
|
|
31967
|
+
organicSessions: sources2.reduce((sum, s) => sum + s.organicSessions, 0),
|
|
31968
|
+
bySource: sources2
|
|
31969
|
+
};
|
|
31970
|
+
});
|
|
31971
|
+
const sources = [...sourceTotals.entries()].sort(([aSource, aSessions], [bSource, bSessions]) => bSessions - aSessions || aSource.localeCompare(bSource)).map(([source]) => source);
|
|
31972
|
+
return { days, sources, totalSessions, totalPaidSessions, totalOrganicSessions };
|
|
31973
|
+
}
|
|
31974
|
+
|
|
31325
31975
|
// ../api-routes/src/ga.ts
|
|
31326
31976
|
function gaLog(level, action, ctx) {
|
|
31327
31977
|
const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Routes", action, ...ctx };
|
|
@@ -31378,12 +32028,6 @@ function pickWinningDimension(rows, tupleKey) {
|
|
|
31378
32028
|
}
|
|
31379
32029
|
return [...winners.values()].sort((a, b) => (b.sessions ?? 0) - (a.sessions ?? 0));
|
|
31380
32030
|
}
|
|
31381
|
-
function normalizeAiTrafficClass(value) {
|
|
31382
|
-
return value === AiReferralTrafficClasses.paid ? AiReferralTrafficClasses.paid : AiReferralTrafficClasses.organic;
|
|
31383
|
-
}
|
|
31384
|
-
function emptyAiCounts() {
|
|
31385
|
-
return { sessions: 0, users: 0 };
|
|
31386
|
-
}
|
|
31387
32031
|
function persistAcquisitionMeasurement(db, input) {
|
|
31388
32032
|
const { startDate, endDate } = input.report;
|
|
31389
32033
|
db.transaction((tx) => {
|
|
@@ -31512,70 +32156,6 @@ function persistMeasurementError(db, projectId, component, message, updatedAt) {
|
|
|
31512
32156
|
}
|
|
31513
32157
|
}).run();
|
|
31514
32158
|
}
|
|
31515
|
-
function summarizeAiReferralCounts(rows) {
|
|
31516
|
-
const dedupeGroups = /* @__PURE__ */ new Map();
|
|
31517
|
-
const bySessionChannelGroup = /* @__PURE__ */ new Map();
|
|
31518
|
-
const paidDeduped = emptyAiCounts();
|
|
31519
|
-
const organicDeduped = emptyAiCounts();
|
|
31520
|
-
const paidBySession = emptyAiCounts();
|
|
31521
|
-
const organicBySession = emptyAiCounts();
|
|
31522
|
-
for (const row of rows) {
|
|
31523
|
-
const isPaid = normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid;
|
|
31524
|
-
const sessions = row.sessions ?? 0;
|
|
31525
|
-
const users = row.users ?? 0;
|
|
31526
|
-
const key = `${row.date}\0${row.source}\0${row.medium}`;
|
|
31527
|
-
let byDimension = dedupeGroups.get(key);
|
|
31528
|
-
if (!byDimension) {
|
|
31529
|
-
byDimension = /* @__PURE__ */ new Map();
|
|
31530
|
-
dedupeGroups.set(key, byDimension);
|
|
31531
|
-
}
|
|
31532
|
-
let dim = byDimension.get(row.sourceDimension);
|
|
31533
|
-
if (!dim) {
|
|
31534
|
-
dim = { paidSessions: 0, organicSessions: 0, paidUsers: 0, organicUsers: 0 };
|
|
31535
|
-
byDimension.set(row.sourceDimension, dim);
|
|
31536
|
-
}
|
|
31537
|
-
if (isPaid) {
|
|
31538
|
-
dim.paidSessions += sessions;
|
|
31539
|
-
dim.paidUsers += users;
|
|
31540
|
-
} else {
|
|
31541
|
-
dim.organicSessions += sessions;
|
|
31542
|
-
dim.organicUsers += users;
|
|
31543
|
-
}
|
|
31544
|
-
if (row.sourceDimension === "session") {
|
|
31545
|
-
bySessionChannelGroup.set(
|
|
31546
|
-
row.channelGroup,
|
|
31547
|
-
(bySessionChannelGroup.get(row.channelGroup) ?? 0) + sessions
|
|
31548
|
-
);
|
|
31549
|
-
const sessionBucket = isPaid ? paidBySession : organicBySession;
|
|
31550
|
-
sessionBucket.sessions += sessions;
|
|
31551
|
-
sessionBucket.users += users;
|
|
31552
|
-
}
|
|
31553
|
-
}
|
|
31554
|
-
for (const byDimension of dedupeGroups.values()) {
|
|
31555
|
-
const dims = [...byDimension.values()];
|
|
31556
|
-
const bestSessions = dims.reduce((best, d) => d.paidSessions + d.organicSessions > best.paidSessions + best.organicSessions ? d : best);
|
|
31557
|
-
const bestUsers = dims.reduce((best, d) => d.paidUsers + d.organicUsers > best.paidUsers + best.organicUsers ? d : best);
|
|
31558
|
-
paidDeduped.sessions += bestSessions.paidSessions;
|
|
31559
|
-
organicDeduped.sessions += bestSessions.organicSessions;
|
|
31560
|
-
paidDeduped.users += bestUsers.paidUsers;
|
|
31561
|
-
organicDeduped.users += bestUsers.organicUsers;
|
|
31562
|
-
}
|
|
31563
|
-
return {
|
|
31564
|
-
paidDeduped,
|
|
31565
|
-
organicDeduped,
|
|
31566
|
-
paidBySession,
|
|
31567
|
-
organicBySession,
|
|
31568
|
-
bySessionChannelGroup,
|
|
31569
|
-
deduped: {
|
|
31570
|
-
sessions: paidDeduped.sessions + organicDeduped.sessions,
|
|
31571
|
-
users: paidDeduped.users + organicDeduped.users
|
|
31572
|
-
},
|
|
31573
|
-
bySession: {
|
|
31574
|
-
sessions: paidBySession.sessions + organicBySession.sessions,
|
|
31575
|
-
users: paidBySession.users + organicBySession.users
|
|
31576
|
-
}
|
|
31577
|
-
};
|
|
31578
|
-
}
|
|
31579
32159
|
async function refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, oauthConn) {
|
|
31580
32160
|
const expiresAt = oauthConn.tokenExpiresAt ? new Date(oauthConn.tokenExpiresAt).getTime() : 0;
|
|
31581
32161
|
const fiveMinutes = 5 * 60 * 1e3;
|
|
@@ -32277,6 +32857,23 @@ async function ga4Routes(app, opts) {
|
|
|
32277
32857
|
).orderBy(gaAiReferrals.date).all();
|
|
32278
32858
|
return rows;
|
|
32279
32859
|
});
|
|
32860
|
+
app.get("/projects/:name/ga/ai-referral-daily", async (request, _reply) => {
|
|
32861
|
+
const project = resolveProject(app.db, request.params.name);
|
|
32862
|
+
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
32863
|
+
const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
|
|
32864
|
+
const conditions = [eq31(gaAiReferrals.projectId, project.id)];
|
|
32865
|
+
if (cutoffDate) conditions.push(sql15`${gaAiReferrals.date} >= ${cutoffDate}`);
|
|
32866
|
+
const rows = app.db.select({
|
|
32867
|
+
date: gaAiReferrals.date,
|
|
32868
|
+
source: gaAiReferrals.source,
|
|
32869
|
+
medium: gaAiReferrals.medium,
|
|
32870
|
+
trafficClass: gaAiReferrals.trafficClass,
|
|
32871
|
+
sourceDimension: gaAiReferrals.sourceDimension,
|
|
32872
|
+
channelGroup: gaAiReferrals.channelGroup,
|
|
32873
|
+
sessions: gaAiReferrals.sessions
|
|
32874
|
+
}).from(gaAiReferrals).where(and25(...conditions)).all();
|
|
32875
|
+
return buildAiReferralDailySeries(rows);
|
|
32876
|
+
});
|
|
32280
32877
|
app.get("/projects/:name/ga/social-referral-history", async (request, _reply) => {
|
|
32281
32878
|
const project = resolveProject(app.db, request.params.name);
|
|
32282
32879
|
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
@@ -43406,7 +44003,7 @@ function serializeRun(row) {
|
|
|
43406
44003
|
return { id: row.id, projectId: row.projectId, status: row.status, provider: row.provider, requestedModel: row.requestedModel, resolvedModel: row.resolvedModel, location: row.location ?? null, totalQueries: row.totalQueries, completedQueries: row.completedQueries, failedQueries: row.failedQueries, error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, createdAt: row.createdAt };
|
|
43407
44004
|
}
|
|
43408
44005
|
function serializeQuery(row) {
|
|
43409
|
-
return { id: row.id, position: row.position, query: row.queryText, status: row.status, requestedModel: row.requestedModel, resolvedModel: row.resolvedModel, servedModel: row.servedModel, answerText: row.answerText, groundingSources: row.groundingSources, citedDomains: row.citedDomains, searchQueries: row.searchQueries, answerMentioned: row.answerMentioned, citationState: row.citationState, error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, createdAt: row.createdAt };
|
|
44006
|
+
return { id: row.id, position: row.position, query: row.queryText, status: row.status, requestedModel: row.requestedModel, resolvedModel: row.resolvedModel, servedModel: row.servedModel, answerText: row.answerText, groundingSources: row.groundingSources, citedDomains: row.citedDomains, searchQueries: row.searchQueries, namedCompetitors: row.namedCompetitors, citedCompetitorDomains: row.citedCompetitorDomains, answerMentioned: row.answerMentioned, citationState: row.citationState, error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, createdAt: row.createdAt };
|
|
43410
44007
|
}
|
|
43411
44008
|
|
|
43412
44009
|
// ../api-routes/src/index.ts
|
|
@@ -43527,6 +44124,9 @@ async function apiRoutes(app, opts) {
|
|
|
43527
44124
|
adsCredentialStore: opts.adsCredentialStore,
|
|
43528
44125
|
verifyAdsAccount: opts.verifyAdsAccount,
|
|
43529
44126
|
adsReader: opts.adsReader,
|
|
44127
|
+
adsLiveDeliveryReader: opts.adsLiveDeliveryReader,
|
|
44128
|
+
adsLiveDeliveryMinIntervalMs: opts.adsLiveDeliveryMinIntervalMs,
|
|
44129
|
+
adsLiveDeliveryMaxPagesPerReaderCall: opts.adsLiveDeliveryMaxPagesPerReaderCall,
|
|
43530
44130
|
onAdsSyncRequested: opts.onAdsSyncRequested,
|
|
43531
44131
|
adsOperator: opts.adsOperator,
|
|
43532
44132
|
adsReconcileSweepIntervalMs: opts.adsReconcileSweepIntervalMs,
|
|
@@ -43870,6 +44470,15 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
|
|
|
43870
44470
|
}
|
|
43871
44471
|
return [...overlapSet];
|
|
43872
44472
|
}
|
|
44473
|
+
function computeCitedCompetitorDomains(citedDomains, competitorDomains) {
|
|
44474
|
+
const citedCompetitors = /* @__PURE__ */ new Set();
|
|
44475
|
+
for (const citedDomain of citedDomains) {
|
|
44476
|
+
for (const competitorDomain of competitorDomains) {
|
|
44477
|
+
if (domainMatches(citedDomain, competitorDomain)) citedCompetitors.add(competitorDomain);
|
|
44478
|
+
}
|
|
44479
|
+
}
|
|
44480
|
+
return [...citedCompetitors];
|
|
44481
|
+
}
|
|
43873
44482
|
function escapeRegExp5(value) {
|
|
43874
44483
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
43875
44484
|
}
|
|
@@ -43930,7 +44539,7 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
|
|
|
43930
44539
|
for (const pattern of candidatePatterns) {
|
|
43931
44540
|
let match;
|
|
43932
44541
|
while ((match = pattern.exec(answerText)) !== null) {
|
|
43933
|
-
const candidate = cleanCandidateName(match[1]
|
|
44542
|
+
const candidate = cleanCandidateName(match[1]);
|
|
43934
44543
|
const candidateKey = brandKeyFromText(candidate);
|
|
43935
44544
|
if (!candidateKey) continue;
|
|
43936
44545
|
if (genericKeys.has(candidateKey)) continue;
|
|
@@ -44645,6 +45254,7 @@ export {
|
|
|
44645
45254
|
classifyAiUserFetch,
|
|
44646
45255
|
determineCitationState,
|
|
44647
45256
|
computeCompetitorOverlap,
|
|
45257
|
+
computeCitedCompetitorDomains,
|
|
44648
45258
|
extractRecommendedCompetitors,
|
|
44649
45259
|
getPlaceDetails,
|
|
44650
45260
|
hashPlaceDetails,
|