@ainyc/canonry 4.115.1 → 4.117.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/README.md +0 -1
- package/assets/agent-workspace/skills/aero/references/reporting.md +10 -0
- package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +6 -0
- package/assets/assets/{BacklinksPage-yUb8oJuq.js → BacklinksPage-Bkafm10m.js} +1 -1
- package/assets/assets/{ChartPrimitives-CdQM3YDG.js → ChartPrimitives-NV_GgqFn.js} +1 -1
- package/assets/assets/ProjectPage-0-NnRxys.js +7 -0
- package/assets/assets/{RunRow-BU_x7EcP.js → RunRow-df0d24S6.js} +1 -1
- package/assets/assets/{RunsPage-CFIBWaO7.js → RunsPage-CU6-4YTM.js} +1 -1
- package/assets/assets/{SettingsPage-PyMryUad.js → SettingsPage-BKw6B6cd.js} +1 -1
- package/assets/assets/{TrafficPage-471ypAdQ.js → TrafficPage-CzpiYHKj.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-CI7TEQge.js → TrafficSourceDetailPage-CC5nuqM1.js} +1 -1
- package/assets/assets/{arrow-left-D2N9u-0i.js → arrow-left-CbQcW2pL.js} +1 -1
- package/assets/assets/{extract-error-message-CAdsqKp3.js → extract-error-message-CvqIZ-le.js} +1 -1
- package/assets/assets/index-LjcWHaNZ.js +210 -0
- package/assets/assets/{trash-2-UgQrN5Np.js → trash-2-3J2u0woR.js} +1 -1
- package/assets/index.html +1 -1
- package/dist/{chunk-Z3OGDQHB.js → chunk-FVVKNMZW.js} +514 -52
- package/dist/{chunk-6LOKMEL3.js → chunk-IAJ5TQ4S.js} +1438 -1234
- package/dist/{chunk-CW4JISBL.js → chunk-PJATAPZW.js} +37 -1
- package/dist/{chunk-B2SNXXAJ.js → chunk-ZZEA774R.js} +4 -4
- package/dist/cli.js +98 -5
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-4AWB2V7U.js → intelligence-service-J7G4CMI5.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +6 -6
- package/assets/assets/ProjectPage-CHj9fvTs.js +0 -7
- package/assets/assets/index-CoL5HENE.js +0 -210
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
AGENT_PROVIDER_IDS,
|
|
3
3
|
AI_ENGINE_DOMAINS,
|
|
4
4
|
AI_PROVIDER_INFRA_DOMAINS,
|
|
5
|
+
AiReferralTrafficClasses,
|
|
5
6
|
AppError,
|
|
6
7
|
BacklinkSources,
|
|
7
8
|
CcReleaseSyncStatuses,
|
|
@@ -20,7 +21,6 @@ import {
|
|
|
20
21
|
DiscoveryBuckets,
|
|
21
22
|
DiscoveryCompetitorTypes,
|
|
22
23
|
DiscoverySessionStatuses,
|
|
23
|
-
GA4AiReferralTrafficClasses,
|
|
24
24
|
RunKinds,
|
|
25
25
|
RunStatuses,
|
|
26
26
|
RunTriggers,
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
adsSyncResponseSchema,
|
|
51
51
|
agentProvidersResponseDtoSchema,
|
|
52
52
|
aggregateHarvestedQueries,
|
|
53
|
+
aiReferralClassCounts,
|
|
53
54
|
apiKeyDtoSchema,
|
|
54
55
|
apiKeyListDtoSchema,
|
|
55
56
|
applyHarvestSemanticNovelty,
|
|
@@ -87,8 +88,8 @@ import {
|
|
|
87
88
|
citationStateSchema,
|
|
88
89
|
citationStateToCited,
|
|
89
90
|
citationVisibilityResponseSchema,
|
|
91
|
+
classifyAiReferralTrafficClass,
|
|
90
92
|
classifyCitedSurface,
|
|
91
|
-
classifyGa4AiReferralTrafficClass,
|
|
92
93
|
classifySkillFile,
|
|
93
94
|
classifySurfaceFromCategory,
|
|
94
95
|
classifyTrafficPath,
|
|
@@ -132,6 +133,7 @@ import {
|
|
|
132
133
|
filterBrandedSeedCandidates,
|
|
133
134
|
findDuplicateLocationLabels,
|
|
134
135
|
forbidden,
|
|
136
|
+
formatAiReferralClassSummary,
|
|
135
137
|
formatAverageDelta,
|
|
136
138
|
formatDate,
|
|
137
139
|
formatDateRange,
|
|
@@ -171,6 +173,7 @@ import {
|
|
|
171
173
|
gscSitemapListResponseDtoSchema,
|
|
172
174
|
gscUrlInspectionDtoSchema,
|
|
173
175
|
hasLocationLabel,
|
|
176
|
+
hostOf,
|
|
174
177
|
indexingRequestResponseDtoSchema,
|
|
175
178
|
internalError,
|
|
176
179
|
isReadOnlyKey,
|
|
@@ -257,8 +260,10 @@ import {
|
|
|
257
260
|
trafficSyncResponseSchema,
|
|
258
261
|
unsupportedKind,
|
|
259
262
|
validationError,
|
|
263
|
+
visibilityCompareDtoSchema,
|
|
260
264
|
visibilityStateFromAnswerMentioned,
|
|
261
265
|
visibilityStatsDtoSchema,
|
|
266
|
+
wilsonInterval,
|
|
262
267
|
windowCutoff,
|
|
263
268
|
winnabilityClassLabel,
|
|
264
269
|
winnabilityClassSchema,
|
|
@@ -275,7 +280,7 @@ import {
|
|
|
275
280
|
wordpressSchemaDeployResultDtoSchema,
|
|
276
281
|
wordpressSchemaStatusResultDtoSchema,
|
|
277
282
|
wordpressStatusDtoSchema
|
|
278
|
-
} from "./chunk-
|
|
283
|
+
} from "./chunk-IAJ5TQ4S.js";
|
|
279
284
|
|
|
280
285
|
// src/intelligence-service.ts
|
|
281
286
|
import { eq as eq38, desc as desc18, asc as asc6, and as and30, ne as ne5, or as or6, inArray as inArray14, gte as gte7, lte as lte4 } from "drizzle-orm";
|
|
@@ -1100,6 +1105,17 @@ var aiReferralEventsHourly = sqliteTable("ai_referral_events_hourly", {
|
|
|
1100
1105
|
landingPathNormalized: text("landing_path_normalized").notNull(),
|
|
1101
1106
|
status: integer("status").notNull(),
|
|
1102
1107
|
sessionsOrHits: integer("sessions_or_hits").notNull().default(0),
|
|
1108
|
+
/**
|
|
1109
|
+
* Paid/organic splits the MEASURE, not the primary key. The paid marker lives
|
|
1110
|
+
* in the request's UTM tags, which `landing_path_normalized` strips, so one
|
|
1111
|
+
* bucket can legitimately hold both kinds and the class is NOT functionally
|
|
1112
|
+
* determined by the key. Unclassified arrivals are the residual
|
|
1113
|
+
* `sessions_or_hits - paid - organic`, which leaves rows written before the
|
|
1114
|
+
* ingest classifier shipped (both columns 0) visibly unknown rather than
|
|
1115
|
+
* silently organic — the regression migration v95 shipped on the GA4 side.
|
|
1116
|
+
*/
|
|
1117
|
+
paidSessionsOrHits: integer("paid_sessions_or_hits").notNull().default(0),
|
|
1118
|
+
organicSessionsOrHits: integer("organic_sessions_or_hits").notNull().default(0),
|
|
1103
1119
|
usersEstimated: integer("users_estimated"),
|
|
1104
1120
|
createdAt: text("created_at").notNull(),
|
|
1105
1121
|
updatedAt: text("updated_at").notNull()
|
|
@@ -3512,7 +3528,7 @@ var MIGRATION_VERSIONS = [
|
|
|
3512
3528
|
//
|
|
3513
3529
|
// Every row is re-classified, not only the "unclassified" ones: v95's
|
|
3514
3530
|
// default is indistinguishable from a genuinely-organic classification, so
|
|
3515
|
-
// there is no way to tell the two apart. `
|
|
3531
|
+
// there is no way to tell the two apart. `classifyAiReferralTrafficClass`
|
|
3516
3532
|
// is pure and deterministic, so re-running it over already-correct rows
|
|
3517
3533
|
// writes nothing, which is what makes this version safe to replay.
|
|
3518
3534
|
version: 96,
|
|
@@ -3521,6 +3537,27 @@ var MIGRATION_VERSIONS = [
|
|
|
3521
3537
|
run: (tx) => {
|
|
3522
3538
|
backfillGaAiReferralTrafficClass(tx);
|
|
3523
3539
|
}
|
|
3540
|
+
},
|
|
3541
|
+
{
|
|
3542
|
+
// Splits the server-side AI-referral MEASURE by traffic class rather than
|
|
3543
|
+
// adding a class column. Paid-ness lives in the request's UTM tags, which
|
|
3544
|
+
// `landing_path_normalized` strips, so one hourly bucket can legitimately
|
|
3545
|
+
// hold both paid and organic arrivals: a class column outside the 8-column
|
|
3546
|
+
// primary key would silently stamp a mixed bucket with one label, and
|
|
3547
|
+
// SQLite cannot extend a composite key with ADD COLUMN anyway.
|
|
3548
|
+
//
|
|
3549
|
+
// Deliberately NO backfill. The discriminator was never persisted — the
|
|
3550
|
+
// request's query string is dropped before the hourly bucket and before
|
|
3551
|
+
// `raw_event_samples` — so no honest class exists for historical rows.
|
|
3552
|
+
// Leaving both counters at 0 surfaces their whole `sessions_or_hits` as the
|
|
3553
|
+
// unknown residual. An older binary writing rows after a downgrade lands in
|
|
3554
|
+
// that residual too, which is the truth about those rows.
|
|
3555
|
+
version: 97,
|
|
3556
|
+
name: "server-side-ai-referral-traffic-class",
|
|
3557
|
+
statements: [
|
|
3558
|
+
`ALTER TABLE ai_referral_events_hourly ADD COLUMN paid_sessions_or_hits INTEGER NOT NULL DEFAULT 0`,
|
|
3559
|
+
`ALTER TABLE ai_referral_events_hourly ADD COLUMN organic_sessions_or_hits INTEGER NOT NULL DEFAULT 0`
|
|
3560
|
+
]
|
|
3524
3561
|
}
|
|
3525
3562
|
];
|
|
3526
3563
|
function rebuildBacklinkTableWithSource(tx, table) {
|
|
@@ -3615,7 +3652,7 @@ function backfillGaAiReferralTrafficClass(tx) {
|
|
|
3615
3652
|
));
|
|
3616
3653
|
let updated = 0;
|
|
3617
3654
|
for (const row of rows) {
|
|
3618
|
-
const next =
|
|
3655
|
+
const next = classifyAiReferralTrafficClass({
|
|
3619
3656
|
source: row.source,
|
|
3620
3657
|
medium: row.medium,
|
|
3621
3658
|
channelGroup: row.channel_group,
|
|
@@ -5613,8 +5650,8 @@ var METRIC_LABELS = {
|
|
|
5613
5650
|
WEBSITE_CLICKS: "Website clicks",
|
|
5614
5651
|
CALL_CLICKS: "Call clicks"
|
|
5615
5652
|
};
|
|
5616
|
-
function metricLabel(
|
|
5617
|
-
return METRIC_LABELS[
|
|
5653
|
+
function metricLabel(metric2) {
|
|
5654
|
+
return METRIC_LABELS[metric2] ?? metric2;
|
|
5618
5655
|
}
|
|
5619
5656
|
function formatAmenityList(amenities) {
|
|
5620
5657
|
if (amenities.length === 1) return amenities[0];
|
|
@@ -5708,12 +5745,12 @@ function analyzeGbp(signals) {
|
|
|
5708
5745
|
}
|
|
5709
5746
|
function pickWorstMetricDrop(loc) {
|
|
5710
5747
|
let worst = null;
|
|
5711
|
-
for (const
|
|
5712
|
-
const delta = loc.metricDeltaPct[
|
|
5713
|
-
const prior = loc.metricPrior7d[
|
|
5748
|
+
for (const metric2 of GBP_HEADLINE_METRICS) {
|
|
5749
|
+
const delta = loc.metricDeltaPct[metric2];
|
|
5750
|
+
const prior = loc.metricPrior7d[metric2] ?? 0;
|
|
5714
5751
|
if (delta == null || delta > -GBP_METRIC_DROP_PCT || prior < GBP_METRIC_MIN_BASELINE) continue;
|
|
5715
5752
|
if (!worst || delta < worst.deltaPct) {
|
|
5716
|
-
worst = { metric, deltaPct: delta, recent: loc.metricRecent7d[
|
|
5753
|
+
worst = { metric: metric2, deltaPct: delta, recent: loc.metricRecent7d[metric2] ?? 0, prior };
|
|
5717
5754
|
}
|
|
5718
5755
|
}
|
|
5719
5756
|
return worst;
|
|
@@ -8507,7 +8544,7 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
|
|
|
8507
8544
|
});
|
|
8508
8545
|
if (eligible.length > 0) usable = eligible;
|
|
8509
8546
|
}
|
|
8510
|
-
const
|
|
8547
|
+
const metric2 = computeProviderMetric(usable);
|
|
8511
8548
|
const queryCount = new Set(usable.map((s) => s.queryId)).size;
|
|
8512
8549
|
const byProvider = {};
|
|
8513
8550
|
for (const provider of new Set(usable.map((s) => s.provider))) {
|
|
@@ -8516,12 +8553,12 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
|
|
|
8516
8553
|
buckets.push({
|
|
8517
8554
|
startDate: startISO,
|
|
8518
8555
|
endDate: endISO,
|
|
8519
|
-
citationRate:
|
|
8520
|
-
cited:
|
|
8521
|
-
total:
|
|
8556
|
+
citationRate: metric2.citationRate,
|
|
8557
|
+
cited: metric2.cited,
|
|
8558
|
+
total: metric2.total,
|
|
8522
8559
|
queryCount,
|
|
8523
|
-
mentionRate:
|
|
8524
|
-
mentionedCount:
|
|
8560
|
+
mentionRate: metric2.mentionRate,
|
|
8561
|
+
mentionedCount: metric2.mentionedCount,
|
|
8525
8562
|
mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors),
|
|
8526
8563
|
byProvider
|
|
8527
8564
|
});
|
|
@@ -11272,6 +11309,7 @@ function renderServerActivity(report, audience) {
|
|
|
11272
11309
|
if (!copy) return "";
|
|
11273
11310
|
return `<span class="tone-${deltaTone(d.deltaPct)}">${escapeHtml(copy)}</span>`;
|
|
11274
11311
|
};
|
|
11312
|
+
const referralSubtitle = [formatDelta(sa.referralArrivals, "sessions"), escapeHtml(sa.referralArrivalsClassSummary)].filter(Boolean).join(" \xB7 ");
|
|
11275
11313
|
if (isClient) {
|
|
11276
11314
|
const crawlerRequests = {
|
|
11277
11315
|
current: sa.verifiedCrawlerHits.current + sa.unverifiedCrawlerHits.current,
|
|
@@ -11310,7 +11348,7 @@ function renderServerActivity(report, audience) {
|
|
|
11310
11348
|
<div class="metric">
|
|
11311
11349
|
<div class="label">AI referral sessions</div>
|
|
11312
11350
|
<div class="value">${formatNumber(sa.referralArrivals.current)}</div>
|
|
11313
|
-
<div class="subtitle">${
|
|
11351
|
+
<div class="subtitle">${referralSubtitle}</div>
|
|
11314
11352
|
</div>
|
|
11315
11353
|
</div>
|
|
11316
11354
|
${clientOperatorRows ? `<div class="chart-card"><h3>By AI tool</h3>
|
|
@@ -11379,7 +11417,7 @@ function renderServerActivity(report, audience) {
|
|
|
11379
11417
|
<div class="metric">
|
|
11380
11418
|
<div class="label">AI-referral sessions (${windowLabel})</div>
|
|
11381
11419
|
<div class="value">${formatNumber(sa.referralArrivals.current)}</div>
|
|
11382
|
-
<div class="subtitle">${
|
|
11420
|
+
<div class="subtitle">${referralSubtitle}</div>
|
|
11383
11421
|
</div>
|
|
11384
11422
|
</div>
|
|
11385
11423
|
${trendChart}
|
|
@@ -11905,7 +11943,7 @@ function safeNum(value) {
|
|
|
11905
11943
|
return 0;
|
|
11906
11944
|
}
|
|
11907
11945
|
function isPaidAiTrafficClass(value) {
|
|
11908
|
-
return value ===
|
|
11946
|
+
return value === AiReferralTrafficClasses.paid;
|
|
11909
11947
|
}
|
|
11910
11948
|
function categorizeQuery(query, projectBrandNames, canonicalDomain) {
|
|
11911
11949
|
return categorizeQueryByIntent(query, buildBrandTokens(canonicalDomain, projectBrandNames));
|
|
@@ -12298,16 +12336,21 @@ function buildServerActivity(db, projectId, windowDays) {
|
|
|
12298
12336
|
)
|
|
12299
12337
|
).get()?.total ?? 0
|
|
12300
12338
|
);
|
|
12301
|
-
const sumReferrals = (windowStartIso, windowEndIso, exclusiveEnd = false) =>
|
|
12302
|
-
db.select({
|
|
12339
|
+
const sumReferrals = (windowStartIso, windowEndIso, exclusiveEnd = false) => {
|
|
12340
|
+
const row = db.select({
|
|
12341
|
+
total: sql7`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)`,
|
|
12342
|
+
paid: sql7`COALESCE(SUM(${aiReferralEventsHourly.paidSessionsOrHits}), 0)`,
|
|
12343
|
+
organic: sql7`COALESCE(SUM(${aiReferralEventsHourly.organicSessionsOrHits}), 0)`
|
|
12344
|
+
}).from(aiReferralEventsHourly).where(
|
|
12303
12345
|
and12(
|
|
12304
12346
|
eq15(aiReferralEventsHourly.projectId, projectId),
|
|
12305
12347
|
nonSubresourceReferralPathCondition(),
|
|
12306
12348
|
gte2(aiReferralEventsHourly.tsHour, windowStartIso),
|
|
12307
12349
|
exclusiveEnd ? lt(aiReferralEventsHourly.tsHour, windowEndIso) : lte(aiReferralEventsHourly.tsHour, windowEndIso)
|
|
12308
12350
|
)
|
|
12309
|
-
).get()
|
|
12310
|
-
|
|
12351
|
+
).get();
|
|
12352
|
+
return aiReferralClassCounts(Number(row?.total ?? 0), Number(row?.paid ?? 0), Number(row?.organic ?? 0));
|
|
12353
|
+
};
|
|
12311
12354
|
const sumUserFetches = (windowStartIso, windowEndIso, exclusiveEnd = false) => Number(
|
|
12312
12355
|
db.select({ total: sql7`COALESCE(SUM(${aiUserFetchEventsHourly.hits}), 0)` }).from(aiUserFetchEventsHourly).where(
|
|
12313
12356
|
and12(
|
|
@@ -12505,7 +12548,7 @@ function buildServerActivity(db, projectId, windowDays) {
|
|
|
12505
12548
|
return {
|
|
12506
12549
|
windowStart: headlineStart,
|
|
12507
12550
|
windowEnd: headlineEnd,
|
|
12508
|
-
hasData: verifiedCurrent + unverifiedCurrent + userFetchCurrent + referralCurrent + verifiedPrior + unverifiedPrior + userFetchPrior + referralPrior > 0 || byOperator.length > 0 || topCrawledPaths.length > 0 || referralProducts.length > 0,
|
|
12551
|
+
hasData: verifiedCurrent + unverifiedCurrent + userFetchCurrent + referralCurrent.total + verifiedPrior + unverifiedPrior + userFetchPrior + referralPrior.total > 0 || byOperator.length > 0 || topCrawledPaths.length > 0 || referralProducts.length > 0,
|
|
12509
12552
|
verifiedCrawlerHits: {
|
|
12510
12553
|
current: verifiedCurrent,
|
|
12511
12554
|
prior: verifiedPrior,
|
|
@@ -12522,10 +12565,28 @@ function buildServerActivity(db, projectId, windowDays) {
|
|
|
12522
12565
|
deltaPct: deltaPercent(userFetchCurrent, userFetchPrior)
|
|
12523
12566
|
},
|
|
12524
12567
|
referralArrivals: {
|
|
12525
|
-
current: referralCurrent,
|
|
12526
|
-
prior: referralPrior,
|
|
12527
|
-
deltaPct: deltaPercent(referralCurrent, referralPrior)
|
|
12568
|
+
current: referralCurrent.total,
|
|
12569
|
+
prior: referralPrior.total,
|
|
12570
|
+
deltaPct: deltaPercent(referralCurrent.total, referralPrior.total)
|
|
12571
|
+
},
|
|
12572
|
+
referralArrivalsByClass: {
|
|
12573
|
+
paid: {
|
|
12574
|
+
current: referralCurrent.paid,
|
|
12575
|
+
prior: referralPrior.paid,
|
|
12576
|
+
deltaPct: deltaPercent(referralCurrent.paid, referralPrior.paid)
|
|
12577
|
+
},
|
|
12578
|
+
organic: {
|
|
12579
|
+
current: referralCurrent.organic,
|
|
12580
|
+
prior: referralPrior.organic,
|
|
12581
|
+
deltaPct: deltaPercent(referralCurrent.organic, referralPrior.organic)
|
|
12582
|
+
},
|
|
12583
|
+
unclassified: {
|
|
12584
|
+
current: referralCurrent.unknown,
|
|
12585
|
+
prior: referralPrior.unknown,
|
|
12586
|
+
deltaPct: deltaPercent(referralCurrent.unknown, referralPrior.unknown)
|
|
12587
|
+
}
|
|
12528
12588
|
},
|
|
12589
|
+
referralArrivalsClassSummary: formatAiReferralClassSummary(referralCurrent),
|
|
12529
12590
|
byOperator,
|
|
12530
12591
|
topCrawledPaths,
|
|
12531
12592
|
referralProducts,
|
|
@@ -13573,6 +13634,253 @@ function normalizeDomain2(domain) {
|
|
|
13573
13634
|
|
|
13574
13635
|
// ../api-routes/src/visibility-stats.ts
|
|
13575
13636
|
import { and as and14, desc as desc8, eq as eq17, inArray as inArray8 } from "drizzle-orm";
|
|
13637
|
+
|
|
13638
|
+
// ../api-routes/src/visibility-compare.ts
|
|
13639
|
+
var VISIBILITY_COMPARE_MIN_RUNS = 5;
|
|
13640
|
+
function basketPairKey(queryId, provider) {
|
|
13641
|
+
return JSON.stringify([queryId, provider]);
|
|
13642
|
+
}
|
|
13643
|
+
function citedHostMatches(citedHost, competitorHost) {
|
|
13644
|
+
return citedHost === competitorHost || citedHost.endsWith(`.${competitorHost}`);
|
|
13645
|
+
}
|
|
13646
|
+
function restrict(snapshots, attribution, pairs) {
|
|
13647
|
+
const out = [];
|
|
13648
|
+
for (const snap of snapshots) {
|
|
13649
|
+
const resolved = resolveCurrentQuery(attribution, snap);
|
|
13650
|
+
if (!resolved) continue;
|
|
13651
|
+
if (!pairs.has(basketPairKey(resolved.id, snap.provider))) continue;
|
|
13652
|
+
out.push({ ...snap, queryId: resolved.id });
|
|
13653
|
+
}
|
|
13654
|
+
return out;
|
|
13655
|
+
}
|
|
13656
|
+
function observed(snapshots, attribution) {
|
|
13657
|
+
const queryIds = /* @__PURE__ */ new Set();
|
|
13658
|
+
const providers = /* @__PURE__ */ new Set();
|
|
13659
|
+
for (const snap of snapshots) {
|
|
13660
|
+
const resolved = resolveCurrentQuery(attribution, snap);
|
|
13661
|
+
if (!resolved) continue;
|
|
13662
|
+
queryIds.add(resolved.id);
|
|
13663
|
+
providers.add(snap.provider);
|
|
13664
|
+
}
|
|
13665
|
+
return { queryIds, providers };
|
|
13666
|
+
}
|
|
13667
|
+
function observedPairs(snapshots, attribution, queryIds) {
|
|
13668
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
13669
|
+
for (const snap of snapshots) {
|
|
13670
|
+
const resolved = resolveCurrentQuery(attribution, snap);
|
|
13671
|
+
if (!resolved || !queryIds.has(resolved.id)) continue;
|
|
13672
|
+
const pair = { queryId: resolved.id, provider: snap.provider };
|
|
13673
|
+
pairs.set(basketPairKey(pair.queryId, pair.provider), pair);
|
|
13674
|
+
}
|
|
13675
|
+
return pairs;
|
|
13676
|
+
}
|
|
13677
|
+
function period(numerator, denominator) {
|
|
13678
|
+
const ci = wilsonInterval(numerator, denominator);
|
|
13679
|
+
return {
|
|
13680
|
+
point: denominator > 0 ? Math.round(numerator / denominator * 1e4) / 1e4 : null,
|
|
13681
|
+
ciLow: ci ? ci.low : null,
|
|
13682
|
+
ciHigh: ci ? ci.high : null,
|
|
13683
|
+
numerator,
|
|
13684
|
+
denominator
|
|
13685
|
+
};
|
|
13686
|
+
}
|
|
13687
|
+
function ciOverlap(a, b) {
|
|
13688
|
+
if (a.ciLow === null || a.ciHigh === null || b.ciLow === null || b.ciHigh === null) return true;
|
|
13689
|
+
return a.ciLow <= b.ciHigh && b.ciLow <= a.ciHigh;
|
|
13690
|
+
}
|
|
13691
|
+
function metric(key, label, driftRobust, from, to, continuityBlock) {
|
|
13692
|
+
const verdict = continuityBlock ?? (from.denominator === 0 || to.denominator === 0 ? "insufficient-data" : ciOverlap(from, to) ? "within-noise" : "moved");
|
|
13693
|
+
const direction = from.point === null || to.point === null ? null : to.point > from.point ? "up" : to.point < from.point ? "down" : "flat";
|
|
13694
|
+
const rateRatio = from.point === null || from.point === 0 || to.point === null ? null : Math.round(to.point / from.point * 100) / 100;
|
|
13695
|
+
return { key, label, driftRobust, from, to, rateRatio, direction, verdict };
|
|
13696
|
+
}
|
|
13697
|
+
function countPeriod(snaps, competitors2) {
|
|
13698
|
+
let checked = 0;
|
|
13699
|
+
let mentioned = 0;
|
|
13700
|
+
let cited = 0;
|
|
13701
|
+
let competitorCited = 0;
|
|
13702
|
+
const perProvider = /* @__PURE__ */ new Map();
|
|
13703
|
+
const models = /* @__PURE__ */ new Map();
|
|
13704
|
+
const modelUnknownProviders = /* @__PURE__ */ new Set();
|
|
13705
|
+
const mentionedQueries = /* @__PURE__ */ new Set();
|
|
13706
|
+
const competitorHosts = competitors2.map((c) => hostOf(c.domain)).filter((h) => h !== null && h.length > 0);
|
|
13707
|
+
for (const snap of snaps) {
|
|
13708
|
+
const isMentioned = snap.answerMentioned === true;
|
|
13709
|
+
if (snap.answerMentioned === true || snap.answerMentioned === false) checked += 1;
|
|
13710
|
+
if (isMentioned) {
|
|
13711
|
+
mentioned += 1;
|
|
13712
|
+
mentionedQueries.add(snap.queryId);
|
|
13713
|
+
}
|
|
13714
|
+
const isCited = snap.citationState === CitationStates.cited;
|
|
13715
|
+
if (isCited) cited += 1;
|
|
13716
|
+
const pp = perProvider.get(snap.provider) ?? { checked: 0, mentioned: 0, cited: 0 };
|
|
13717
|
+
if (snap.answerMentioned === true || snap.answerMentioned === false) pp.checked += 1;
|
|
13718
|
+
if (isMentioned) pp.mentioned += 1;
|
|
13719
|
+
if (isCited) pp.cited += 1;
|
|
13720
|
+
perProvider.set(snap.provider, pp);
|
|
13721
|
+
if (snap.model) {
|
|
13722
|
+
const set = models.get(snap.provider) ?? /* @__PURE__ */ new Set();
|
|
13723
|
+
set.add(snap.model);
|
|
13724
|
+
models.set(snap.provider, set);
|
|
13725
|
+
} else {
|
|
13726
|
+
modelUnknownProviders.add(snap.provider);
|
|
13727
|
+
}
|
|
13728
|
+
if (competitorHosts.length > 0 && snap.citedDomains.length > 0) {
|
|
13729
|
+
const citedHosts = snap.citedDomains.map((d) => hostOf(d)).filter((h) => h !== null && h.length > 0);
|
|
13730
|
+
for (const compHost of competitorHosts) {
|
|
13731
|
+
if (citedHosts.some((ch) => citedHostMatches(ch, compHost))) competitorCited += 1;
|
|
13732
|
+
}
|
|
13733
|
+
}
|
|
13734
|
+
}
|
|
13735
|
+
const mentionShare = buildMentionShare(
|
|
13736
|
+
snaps.map((s) => ({ projectMentioned: s.answerMentioned === true, answerText: s.answerText })),
|
|
13737
|
+
{ competitors: competitors2 }
|
|
13738
|
+
);
|
|
13739
|
+
return {
|
|
13740
|
+
checked,
|
|
13741
|
+
mentioned,
|
|
13742
|
+
total: snaps.length,
|
|
13743
|
+
cited,
|
|
13744
|
+
projectCited: cited,
|
|
13745
|
+
competitorCited,
|
|
13746
|
+
queriesMentioned: mentionedQueries.size,
|
|
13747
|
+
perProvider,
|
|
13748
|
+
models,
|
|
13749
|
+
modelUnknownProviders,
|
|
13750
|
+
mentionShare,
|
|
13751
|
+
competitors: mentionShare.breakdown.perCompetitor.map((c) => ({ domain: c.domain, mentions: c.mentionSnapshots }))
|
|
13752
|
+
};
|
|
13753
|
+
}
|
|
13754
|
+
function computeVisibilityCompare(input) {
|
|
13755
|
+
const attribution = buildQueryAttribution(input.queries);
|
|
13756
|
+
const fromObs = observed(input.from.snapshots, attribution);
|
|
13757
|
+
const toObs = observed(input.to.snapshots, attribution);
|
|
13758
|
+
const queriesObservedBoth = new Set([...fromObs.queryIds].filter((q) => toObs.queryIds.has(q)));
|
|
13759
|
+
const fromPairs = observedPairs(input.from.snapshots, attribution, queriesObservedBoth);
|
|
13760
|
+
const toPairs = observedPairs(input.to.snapshots, attribution, queriesObservedBoth);
|
|
13761
|
+
const pairsBoth = new Map([...fromPairs].filter(([key]) => toPairs.has(key)));
|
|
13762
|
+
const candidateProviders = new Set([...pairsBoth.values()].map((pair) => pair.provider));
|
|
13763
|
+
const fromCandidateSnaps = restrict(input.from.snapshots, attribution, pairsBoth);
|
|
13764
|
+
const toCandidateSnaps = restrict(input.to.snapshots, attribution, pairsBoth);
|
|
13765
|
+
const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors);
|
|
13766
|
+
const toCandidateCounts = countPeriod(toCandidateSnaps, input.competitors);
|
|
13767
|
+
const continuityProviders = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
|
|
13768
|
+
const fromModels = [...fromCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
|
|
13769
|
+
const toModels = [...toCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
|
|
13770
|
+
const fromModelUnknown = fromCandidateCounts.modelUnknownProviders.has(provider) || fromModels.length === 0;
|
|
13771
|
+
const toModelUnknown = toCandidateCounts.modelUnknownProviders.has(provider) || toModels.length === 0;
|
|
13772
|
+
const status = fromModelUnknown || toModelUnknown ? "model-unknown" : fromModels.length === 1 && toModels.length === 1 && fromModels[0] === toModels[0] ? "included" : "model-discontinuous";
|
|
13773
|
+
return { provider, status, fromModels, toModels };
|
|
13774
|
+
});
|
|
13775
|
+
const providersBoth = new Set(
|
|
13776
|
+
continuityProviders.filter((provider) => provider.status === "included").map((provider) => provider.provider)
|
|
13777
|
+
);
|
|
13778
|
+
const continuityStatus = candidateProviders.size === 0 ? "insufficient-data" : providersBoth.size > 0 ? "comparable" : continuityProviders.every((provider) => provider.status === "model-unknown") ? "model-unknown" : "model-discontinuous";
|
|
13779
|
+
const continuityBlock = continuityStatus === "model-discontinuous" || continuityStatus === "model-unknown" ? continuityStatus : null;
|
|
13780
|
+
const comparedPairs = new Map([...pairsBoth].filter(([, pair]) => providersBoth.has(pair.provider)));
|
|
13781
|
+
const queriesBoth = new Set([...comparedPairs.values()].map((pair) => pair.queryId));
|
|
13782
|
+
const excludedProviders = [.../* @__PURE__ */ new Set([...fromObs.providers, ...toObs.providers])].filter((p) => !providersBoth.has(p)).sort((a, b) => a.localeCompare(b));
|
|
13783
|
+
const fromSnaps = restrict(input.from.snapshots, attribution, comparedPairs);
|
|
13784
|
+
const toSnaps = restrict(input.to.snapshots, attribution, comparedPairs);
|
|
13785
|
+
const fromCounts = countPeriod(fromSnaps, input.competitors);
|
|
13786
|
+
const toCounts = countPeriod(toSnaps, input.competitors);
|
|
13787
|
+
const shareCounts = (c) => ({
|
|
13788
|
+
proj: c.mentionShare.breakdown.projectMentionSnapshots,
|
|
13789
|
+
comp: c.mentionShare.breakdown.competitorMentionSnapshots
|
|
13790
|
+
});
|
|
13791
|
+
const fromShare = shareCounts(fromCounts);
|
|
13792
|
+
const toShare = shareCounts(toCounts);
|
|
13793
|
+
const metrics = [
|
|
13794
|
+
metric(
|
|
13795
|
+
"mention-share-of-voice",
|
|
13796
|
+
"Named share of voice",
|
|
13797
|
+
true,
|
|
13798
|
+
period(fromShare.proj, fromShare.proj + fromShare.comp),
|
|
13799
|
+
period(toShare.proj, toShare.proj + toShare.comp),
|
|
13800
|
+
continuityBlock
|
|
13801
|
+
),
|
|
13802
|
+
// Share of voice is undefined without a competitive frame: with zero
|
|
13803
|
+
// configured competitors the denominator degenerates to the project's own
|
|
13804
|
+
// count and the metric would fabricate a 100%. buildMentionShare already
|
|
13805
|
+
// refuses that on the mention side ("reporting 100% would mislead");
|
|
13806
|
+
// degrade the cited side identically to a 0/0 period -> insufficient-data.
|
|
13807
|
+
metric(
|
|
13808
|
+
"cited-share-of-voice",
|
|
13809
|
+
"Cited share of voice",
|
|
13810
|
+
true,
|
|
13811
|
+
input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : period(0, 0),
|
|
13812
|
+
input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : period(0, 0),
|
|
13813
|
+
continuityBlock
|
|
13814
|
+
),
|
|
13815
|
+
metric(
|
|
13816
|
+
"mention-rate",
|
|
13817
|
+
"Named rate",
|
|
13818
|
+
false,
|
|
13819
|
+
period(fromCounts.mentioned, fromCounts.checked),
|
|
13820
|
+
period(toCounts.mentioned, toCounts.checked),
|
|
13821
|
+
continuityBlock
|
|
13822
|
+
),
|
|
13823
|
+
metric(
|
|
13824
|
+
"cited-rate",
|
|
13825
|
+
"Cited rate",
|
|
13826
|
+
false,
|
|
13827
|
+
period(fromCounts.cited, fromCounts.total),
|
|
13828
|
+
period(toCounts.cited, toCounts.total),
|
|
13829
|
+
continuityBlock
|
|
13830
|
+
)
|
|
13831
|
+
];
|
|
13832
|
+
const modelChanges = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
|
|
13833
|
+
const fromModels = [...fromCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
|
|
13834
|
+
const toModels = [...toCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
|
|
13835
|
+
return { provider, fromModels, toModels };
|
|
13836
|
+
}).filter(
|
|
13837
|
+
(c) => c.fromModels.length > 0 && c.toModels.length > 0 && JSON.stringify(c.fromModels) !== JSON.stringify(c.toModels)
|
|
13838
|
+
);
|
|
13839
|
+
const byProvider = [...providersBoth].sort((a, b) => a.localeCompare(b)).map((provider) => ({
|
|
13840
|
+
provider,
|
|
13841
|
+
from: fromCounts.perProvider.get(provider) ?? { checked: 0, mentioned: 0, cited: 0 },
|
|
13842
|
+
to: toCounts.perProvider.get(provider) ?? { checked: 0, mentioned: 0, cited: 0 }
|
|
13843
|
+
}));
|
|
13844
|
+
return {
|
|
13845
|
+
project: input.project,
|
|
13846
|
+
from: {
|
|
13847
|
+
month: input.from.month,
|
|
13848
|
+
since: input.from.since,
|
|
13849
|
+
until: input.from.until,
|
|
13850
|
+
runCount: input.from.runCount,
|
|
13851
|
+
lowRunCount: input.from.runCount < VISIBILITY_COMPARE_MIN_RUNS
|
|
13852
|
+
},
|
|
13853
|
+
to: {
|
|
13854
|
+
month: input.to.month,
|
|
13855
|
+
since: input.to.since,
|
|
13856
|
+
until: input.to.until,
|
|
13857
|
+
runCount: input.to.runCount,
|
|
13858
|
+
lowRunCount: input.to.runCount < VISIBILITY_COMPARE_MIN_RUNS
|
|
13859
|
+
},
|
|
13860
|
+
basket: {
|
|
13861
|
+
queryCount: queriesBoth.size,
|
|
13862
|
+
excludedFromOnly: [...fromObs.queryIds].filter((q) => !queriesBoth.has(q)).length,
|
|
13863
|
+
excludedToOnly: [...toObs.queryIds].filter((q) => !queriesBoth.has(q)).length,
|
|
13864
|
+
providers: [...providersBoth].sort((a, b) => a.localeCompare(b)),
|
|
13865
|
+
excludedProviders
|
|
13866
|
+
},
|
|
13867
|
+
metrics,
|
|
13868
|
+
queriesMentioned: {
|
|
13869
|
+
from: { count: fromCounts.queriesMentioned, of: queriesBoth.size },
|
|
13870
|
+
to: { count: toCounts.queriesMentioned, of: queriesBoth.size }
|
|
13871
|
+
},
|
|
13872
|
+
byProvider,
|
|
13873
|
+
modelChanges,
|
|
13874
|
+
continuity: {
|
|
13875
|
+
status: continuityStatus,
|
|
13876
|
+
comparedProviders: [...providersBoth].sort((a, b) => a.localeCompare(b)),
|
|
13877
|
+
providers: continuityProviders
|
|
13878
|
+
},
|
|
13879
|
+
competitors: { from: fromCounts.competitors, to: toCounts.competitors }
|
|
13880
|
+
};
|
|
13881
|
+
}
|
|
13882
|
+
|
|
13883
|
+
// ../api-routes/src/visibility-stats.ts
|
|
13576
13884
|
function round42(value) {
|
|
13577
13885
|
return Math.round(value * 1e4) / 1e4;
|
|
13578
13886
|
}
|
|
@@ -13791,6 +14099,62 @@ async function visibilityStatsRoutes(app) {
|
|
|
13791
14099
|
};
|
|
13792
14100
|
return reply.send(response);
|
|
13793
14101
|
});
|
|
14102
|
+
app.get("/projects/:name/visibility-compare", async (request, reply) => {
|
|
14103
|
+
const project = resolveProject(app.db, request.params.name);
|
|
14104
|
+
const { from: fromRaw, to: toRaw } = request.query;
|
|
14105
|
+
if (fromRaw === void 0 || fromRaw === "") throw validationError('"from" (YYYY-MM) is required');
|
|
14106
|
+
if (toRaw === void 0 || toRaw === "") throw validationError('"to" (YYYY-MM) is required');
|
|
14107
|
+
let fromBounds;
|
|
14108
|
+
let toBounds;
|
|
14109
|
+
try {
|
|
14110
|
+
fromBounds = calendarMonthBounds(fromRaw);
|
|
14111
|
+
} catch (err) {
|
|
14112
|
+
throw validationError(err instanceof RangeError ? `"from": ${err.message}` : '"from" must be in YYYY-MM format');
|
|
14113
|
+
}
|
|
14114
|
+
try {
|
|
14115
|
+
toBounds = calendarMonthBounds(toRaw);
|
|
14116
|
+
} catch (err) {
|
|
14117
|
+
throw validationError(err instanceof RangeError ? `"to": ${err.message}` : '"to" must be in YYYY-MM format');
|
|
14118
|
+
}
|
|
14119
|
+
if (Date.parse(fromBounds.since) >= Date.parse(toBounds.since)) {
|
|
14120
|
+
throw validationError('"from" must be a month strictly before "to"');
|
|
14121
|
+
}
|
|
14122
|
+
const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq17(queries.projectId, project.id)).all();
|
|
14123
|
+
const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq17(competitors.projectId, project.id)).all();
|
|
14124
|
+
const competitorInputs = competitorRows.map((c) => ({
|
|
14125
|
+
domain: c.domain,
|
|
14126
|
+
brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
|
|
14127
|
+
}));
|
|
14128
|
+
const loadMonth = (bounds) => {
|
|
14129
|
+
const sinceMs = Date.parse(bounds.since);
|
|
14130
|
+
const untilMs = Date.parse(bounds.until);
|
|
14131
|
+
const monthRuns = app.db.select({ id: runs.id, createdAt: runs.createdAt, status: runs.status }).from(runs).where(and14(eq17(runs.projectId, project.id), eq17(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).all().filter(
|
|
14132
|
+
(r) => (r.status === RunStatuses.completed || r.status === RunStatuses.partial) && Date.parse(r.createdAt) >= sinceMs && Date.parse(r.createdAt) <= untilMs
|
|
14133
|
+
);
|
|
14134
|
+
const runIds = monthRuns.map((r) => r.id);
|
|
14135
|
+
const snapshots = runIds.length > 0 && projectQueries.length > 0 ? app.db.select({
|
|
14136
|
+
queryId: querySnapshots.queryId,
|
|
14137
|
+
queryText: querySnapshots.queryText,
|
|
14138
|
+
provider: querySnapshots.provider,
|
|
14139
|
+
model: querySnapshots.model,
|
|
14140
|
+
citationState: querySnapshots.citationState,
|
|
14141
|
+
answerMentioned: querySnapshots.answerMentioned,
|
|
14142
|
+
answerText: querySnapshots.answerText,
|
|
14143
|
+
citedDomains: querySnapshots.citedDomains
|
|
14144
|
+
}).from(querySnapshots).where(inArray8(querySnapshots.runId, runIds)).all() : [];
|
|
14145
|
+
return { runCount: monthRuns.length, snapshots };
|
|
14146
|
+
};
|
|
14147
|
+
const fromMonth = loadMonth(fromBounds);
|
|
14148
|
+
const toMonth = loadMonth(toBounds);
|
|
14149
|
+
const dto = computeVisibilityCompare({
|
|
14150
|
+
project: project.name,
|
|
14151
|
+
queries: projectQueries,
|
|
14152
|
+
competitors: competitorInputs,
|
|
14153
|
+
from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
|
|
14154
|
+
to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
|
|
14155
|
+
});
|
|
14156
|
+
return reply.send(dto);
|
|
14157
|
+
});
|
|
13794
14158
|
}
|
|
13795
14159
|
|
|
13796
14160
|
// ../api-routes/src/composites.ts
|
|
@@ -14596,6 +14960,7 @@ var SCHEMA_TABLE = {
|
|
|
14596
14960
|
TrafficSourceListResponse: trafficSourceListResponseSchema,
|
|
14597
14961
|
TrafficStatusResponse: trafficStatusResponseSchema,
|
|
14598
14962
|
TrafficSyncResponse: trafficSyncResponseSchema,
|
|
14963
|
+
VisibilityCompareDto: visibilityCompareDtoSchema,
|
|
14599
14964
|
VisibilityStatsDto: visibilityStatsDtoSchema,
|
|
14600
14965
|
WordpressAuditPageDto: wordpressAuditPageDtoSchema,
|
|
14601
14966
|
WordpressBulkMetaResultDto: wordpressBulkMetaResultDtoSchema,
|
|
@@ -14872,6 +15237,20 @@ var monthQueryParameter = {
|
|
|
14872
15237
|
description: `Aggregate a single calendar month (YYYY-MM), expanded to that month's inclusive UTC bounds. Mutually exclusive with "since"/"until"/"lastRuns".`,
|
|
14873
15238
|
schema: stringSchema
|
|
14874
15239
|
};
|
|
15240
|
+
var compareFromQueryParameter = {
|
|
15241
|
+
name: "from",
|
|
15242
|
+
in: "query",
|
|
15243
|
+
required: true,
|
|
15244
|
+
description: 'Earlier calendar month (YYYY-MM) \u2014 the baseline period. Must be strictly before "to".',
|
|
15245
|
+
schema: stringSchema
|
|
15246
|
+
};
|
|
15247
|
+
var compareToQueryParameter = {
|
|
15248
|
+
name: "to",
|
|
15249
|
+
in: "query",
|
|
15250
|
+
required: true,
|
|
15251
|
+
description: 'Later calendar month (YYYY-MM) \u2014 the reporting period compared against "from".',
|
|
15252
|
+
schema: stringSchema
|
|
15253
|
+
};
|
|
14875
15254
|
var shareOfVoiceQueryParameter = {
|
|
14876
15255
|
name: "shareOfVoice",
|
|
14877
15256
|
in: "query",
|
|
@@ -15647,6 +16026,19 @@ var routeCatalog = [
|
|
|
15647
16026
|
404: errorResponse("Project not found.")
|
|
15648
16027
|
}
|
|
15649
16028
|
},
|
|
16029
|
+
{
|
|
16030
|
+
method: "get",
|
|
16031
|
+
path: "/api/v1/projects/{name}/visibility-compare",
|
|
16032
|
+
summary: "Compare AEO visibility month over month",
|
|
16033
|
+
description: "Statistically honest month-over-month AEO comparison in one call. PRIMARY metric is share of voice (brand vs competitor mentions in the same answers), which is less exposed to broad model-wide naming propensity than an absolute rate but never bypasses model continuity. Rates are pooled per-snapshot over each month (invariant to sweep count), restricted to query/provider pairs present in BOTH months, and then restricted again to providers with exactly one known, identical configured model id in both months. `continuity` reports every provider and its model evidence; changed, mixed mid-month, or legacy-unknown models are excluded. When no provider remains, metrics return a continuity-blocked verdict rather than a directional call. `from` must be a month strictly before `to`. A silent upstream version bump under an unchanged configured id remains undetectable.",
|
|
16034
|
+
tags: ["analytics"],
|
|
16035
|
+
parameters: [nameParameter, compareFromQueryParameter, compareToQueryParameter],
|
|
16036
|
+
responses: {
|
|
16037
|
+
200: jsonResponse("Month-over-month visibility comparison returned.", "VisibilityCompareDto"),
|
|
16038
|
+
400: errorResponse("Invalid or missing from/to months."),
|
|
16039
|
+
404: errorResponse("Project not found.")
|
|
16040
|
+
}
|
|
16041
|
+
},
|
|
15650
16042
|
{
|
|
15651
16043
|
method: "get",
|
|
15652
16044
|
path: "/api/v1/projects/{name}/snapshots/diff",
|
|
@@ -19458,12 +19850,12 @@ function computeWindowDelta(rows, referenceDate) {
|
|
|
19458
19850
|
}
|
|
19459
19851
|
const deltaPct = {};
|
|
19460
19852
|
const metrics = /* @__PURE__ */ new Set([...Object.keys(recent7d), ...Object.keys(prior7d)]);
|
|
19461
|
-
for (const
|
|
19462
|
-
const recent = recent7d[
|
|
19463
|
-
const prior = prior7d[
|
|
19464
|
-
recent7d[
|
|
19465
|
-
prior7d[
|
|
19466
|
-
deltaPct[
|
|
19853
|
+
for (const metric2 of metrics) {
|
|
19854
|
+
const recent = recent7d[metric2] ?? 0;
|
|
19855
|
+
const prior = prior7d[metric2] ?? 0;
|
|
19856
|
+
recent7d[metric2] = recent;
|
|
19857
|
+
prior7d[metric2] = prior;
|
|
19858
|
+
deltaPct[metric2] = prior === 0 ? null : Math.round((recent - prior) / prior * 100);
|
|
19467
19859
|
}
|
|
19468
19860
|
return { recent7d, prior7d, deltaPct };
|
|
19469
19861
|
}
|
|
@@ -20551,7 +20943,7 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
|
20551
20943
|
date: row.dimensionValues[0].value,
|
|
20552
20944
|
source,
|
|
20553
20945
|
medium,
|
|
20554
|
-
trafficClass:
|
|
20946
|
+
trafficClass: classifyAiReferralTrafficClass({ source, medium, channelGroup, landingPage }),
|
|
20555
20947
|
channelGroup,
|
|
20556
20948
|
landingPage,
|
|
20557
20949
|
sessions: parseInt(row.metricValues[0].value, 10) || 0,
|
|
@@ -23527,7 +23919,7 @@ function pickWinningDimension(rows, tupleKey) {
|
|
|
23527
23919
|
return [...winners.values()].sort((a, b) => (b.sessions ?? 0) - (a.sessions ?? 0));
|
|
23528
23920
|
}
|
|
23529
23921
|
function normalizeAiTrafficClass(value) {
|
|
23530
|
-
return value ===
|
|
23922
|
+
return value === AiReferralTrafficClasses.paid ? AiReferralTrafficClasses.paid : AiReferralTrafficClasses.organic;
|
|
23531
23923
|
}
|
|
23532
23924
|
function emptyAiCounts() {
|
|
23533
23925
|
return { sessions: 0, users: 0 };
|
|
@@ -23540,7 +23932,7 @@ function summarizeAiReferralCounts(rows) {
|
|
|
23540
23932
|
const paidBySession = emptyAiCounts();
|
|
23541
23933
|
const organicBySession = emptyAiCounts();
|
|
23542
23934
|
for (const row of rows) {
|
|
23543
|
-
const isPaid = normalizeAiTrafficClass(row.trafficClass) ===
|
|
23935
|
+
const isPaid = normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid;
|
|
23544
23936
|
const sessions = row.sessions ?? 0;
|
|
23545
23937
|
const users = row.users ?? 0;
|
|
23546
23938
|
const key = `${row.date}\0${row.source}\0${row.medium}`;
|
|
@@ -23872,7 +24264,7 @@ async function ga4Routes(app, opts) {
|
|
|
23872
24264
|
date: row.date,
|
|
23873
24265
|
source: row.source,
|
|
23874
24266
|
medium: row.medium,
|
|
23875
|
-
trafficClass: row.trafficClass ??
|
|
24267
|
+
trafficClass: row.trafficClass ?? classifyAiReferralTrafficClass({
|
|
23876
24268
|
source: row.source,
|
|
23877
24269
|
medium: row.medium,
|
|
23878
24270
|
channelGroup: row.channelGroup,
|
|
@@ -29650,6 +30042,33 @@ function utmSourceFromUrl(value) {
|
|
|
29650
30042
|
return null;
|
|
29651
30043
|
}
|
|
29652
30044
|
}
|
|
30045
|
+
function paramsFromQuery(queryString) {
|
|
30046
|
+
if (!queryString) return null;
|
|
30047
|
+
const params = new URLSearchParams(queryString);
|
|
30048
|
+
return [...params.keys()].length > 0 ? params : null;
|
|
30049
|
+
}
|
|
30050
|
+
function paramsFromUrl(value) {
|
|
30051
|
+
if (!value) return null;
|
|
30052
|
+
try {
|
|
30053
|
+
return paramsFromQuery(new URL(value).search.replace(/^\?/, ""));
|
|
30054
|
+
} catch {
|
|
30055
|
+
return null;
|
|
30056
|
+
}
|
|
30057
|
+
}
|
|
30058
|
+
function classifyAiReferralTrafficClassFromEvent(event) {
|
|
30059
|
+
for (const params of [paramsFromQuery(event.queryString), paramsFromUrl(event.referer)]) {
|
|
30060
|
+
if (!params) continue;
|
|
30061
|
+
const trafficClass = classifyAiReferralTrafficClass({
|
|
30062
|
+
source: params.get("utm_source"),
|
|
30063
|
+
medium: params.get("utm_medium"),
|
|
30064
|
+
channelGroup: null,
|
|
30065
|
+
// Carries utm_campaign / utm_content / utm_term to the shared rules.
|
|
30066
|
+
landingPage: `/?${params.toString()}`
|
|
30067
|
+
});
|
|
30068
|
+
if (trafficClass === AiReferralTrafficClasses.paid) return AiReferralTrafficClasses.paid;
|
|
30069
|
+
}
|
|
30070
|
+
return AiReferralTrafficClasses.organic;
|
|
30071
|
+
}
|
|
29653
30072
|
function isSelfTraffic(event) {
|
|
29654
30073
|
const userAgent = event.userAgent?.trim();
|
|
29655
30074
|
if (!userAgent) return false;
|
|
@@ -29693,6 +30112,7 @@ function classifyAiUserFetch(event) {
|
|
|
29693
30112
|
return null;
|
|
29694
30113
|
}
|
|
29695
30114
|
function classifyAiReferral(event) {
|
|
30115
|
+
const trafficClass = classifyAiReferralTrafficClassFromEvent(event);
|
|
29696
30116
|
const refererHost = hostFromUrl(event.referer);
|
|
29697
30117
|
if (refererHost) {
|
|
29698
30118
|
const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => hostMatches(refererHost, candidate.domain));
|
|
@@ -29701,7 +30121,8 @@ function classifyAiReferral(event) {
|
|
|
29701
30121
|
operator: rule.operator,
|
|
29702
30122
|
product: rule.product,
|
|
29703
30123
|
sourceDomain: refererHost,
|
|
29704
|
-
evidenceType: "referer"
|
|
30124
|
+
evidenceType: "referer",
|
|
30125
|
+
trafficClass
|
|
29705
30126
|
};
|
|
29706
30127
|
}
|
|
29707
30128
|
}
|
|
@@ -29713,7 +30134,8 @@ function classifyAiReferral(event) {
|
|
|
29713
30134
|
operator: rule.operator,
|
|
29714
30135
|
product: rule.product,
|
|
29715
30136
|
sourceDomain: utmSource,
|
|
29716
|
-
evidenceType: "utm"
|
|
30137
|
+
evidenceType: "utm",
|
|
30138
|
+
trafficClass
|
|
29717
30139
|
};
|
|
29718
30140
|
}
|
|
29719
30141
|
}
|
|
@@ -29725,7 +30147,8 @@ function classifyAiReferral(event) {
|
|
|
29725
30147
|
operator: rule.operator,
|
|
29726
30148
|
product: rule.product,
|
|
29727
30149
|
sourceDomain: refererUtmSource,
|
|
29728
|
-
evidenceType: "referer-utm"
|
|
30150
|
+
evidenceType: "referer-utm",
|
|
30151
|
+
trafficClass
|
|
29729
30152
|
};
|
|
29730
30153
|
}
|
|
29731
30154
|
}
|
|
@@ -29828,8 +30251,10 @@ function evidenceRank(evidenceType) {
|
|
|
29828
30251
|
return 1;
|
|
29829
30252
|
}
|
|
29830
30253
|
}
|
|
29831
|
-
function
|
|
29832
|
-
|
|
30254
|
+
function mergeReferralSession(current, next) {
|
|
30255
|
+
const winner = evidenceRank(next.evidenceType) > evidenceRank(current.evidenceType) ? next : current;
|
|
30256
|
+
const paid = current.trafficClass === AiReferralTrafficClasses.paid || next.trafficClass === AiReferralTrafficClasses.paid;
|
|
30257
|
+
return { ...winner, trafficClass: paid ? AiReferralTrafficClasses.paid : winner.trafficClass };
|
|
29833
30258
|
}
|
|
29834
30259
|
function sortCrawlerBuckets(a, b) {
|
|
29835
30260
|
return a.tsHour.localeCompare(b.tsHour) || a.botId.localeCompare(b.botId) || a.pathNormalized.localeCompare(b.pathNormalized) || String(a.status).localeCompare(String(b.status));
|
|
@@ -29862,6 +30287,8 @@ function buildTrafficProbeReport(events, options = {}) {
|
|
|
29862
30287
|
let crawlerHits = 0;
|
|
29863
30288
|
let aiUserFetchHits = 0;
|
|
29864
30289
|
let aiReferralHits = 0;
|
|
30290
|
+
let aiReferralPaidSessions = 0;
|
|
30291
|
+
let aiReferralOrganicSessions = 0;
|
|
29865
30292
|
let unknownHits = 0;
|
|
29866
30293
|
const samples = [];
|
|
29867
30294
|
for (const event of includedEvents) {
|
|
@@ -29943,11 +30370,12 @@ function buildTrafficProbeReport(events, options = {}) {
|
|
|
29943
30370
|
sourceDomain: aiReferral.sourceDomain,
|
|
29944
30371
|
evidenceType: aiReferral.evidenceType,
|
|
29945
30372
|
landingPathNormalized,
|
|
29946
|
-
status: event.status
|
|
30373
|
+
status: event.status,
|
|
30374
|
+
trafficClass: aiReferral.trafficClass
|
|
29947
30375
|
};
|
|
29948
30376
|
const key = aiReferralSessionKey(event, aiReferral, landingPathNormalized, aiReferralSessionWindowMs);
|
|
29949
30377
|
const existing = aiReferralSessions.get(key);
|
|
29950
|
-
aiReferralSessions.set(key, existing ?
|
|
30378
|
+
aiReferralSessions.set(key, existing ? mergeReferralSession(existing, session) : session);
|
|
29951
30379
|
}
|
|
29952
30380
|
}
|
|
29953
30381
|
if (!crawler && !aiUserFetch && !aiReferral) unknownHits += 1;
|
|
@@ -29975,13 +30403,21 @@ function buildTrafficProbeReport(events, options = {}) {
|
|
|
29975
30403
|
session.landingPathNormalized,
|
|
29976
30404
|
session.status ?? "null"
|
|
29977
30405
|
].join(" ");
|
|
30406
|
+
const { trafficClass, ...bucketFields } = session;
|
|
30407
|
+
const paid = trafficClass === AiReferralTrafficClasses.paid;
|
|
30408
|
+
if (paid) aiReferralPaidSessions += 1;
|
|
30409
|
+
else aiReferralOrganicSessions += 1;
|
|
29978
30410
|
const existing = aiReferralBuckets.get(key);
|
|
29979
30411
|
if (existing) {
|
|
29980
30412
|
existing.hits += 1;
|
|
30413
|
+
if (paid) existing.paidHits += 1;
|
|
30414
|
+
else existing.organicHits += 1;
|
|
29981
30415
|
} else {
|
|
29982
30416
|
aiReferralBuckets.set(key, {
|
|
29983
|
-
...
|
|
29984
|
-
hits: 1
|
|
30417
|
+
...bucketFields,
|
|
30418
|
+
hits: 1,
|
|
30419
|
+
paidHits: paid ? 1 : 0,
|
|
30420
|
+
organicHits: paid ? 0 : 1
|
|
29985
30421
|
});
|
|
29986
30422
|
}
|
|
29987
30423
|
incrementBucket(topAiReferrers, session.sourceDomain, {
|
|
@@ -30000,6 +30436,8 @@ function buildTrafficProbeReport(events, options = {}) {
|
|
|
30000
30436
|
crawlerHits,
|
|
30001
30437
|
aiUserFetchHits,
|
|
30002
30438
|
aiReferralSessions: aiReferralSessions.size,
|
|
30439
|
+
aiReferralPaidSessions,
|
|
30440
|
+
aiReferralOrganicSessions,
|
|
30003
30441
|
aiReferralHits,
|
|
30004
30442
|
unknownHits
|
|
30005
30443
|
},
|
|
@@ -30761,6 +31199,8 @@ async function runBackfillTask(options) {
|
|
|
30761
31199
|
landingPathNormalized: bucket.landingPathNormalized,
|
|
30762
31200
|
status: bucket.status ?? 0,
|
|
30763
31201
|
sessionsOrHits: bucket.hits,
|
|
31202
|
+
paidSessionsOrHits: bucket.paidHits,
|
|
31203
|
+
organicSessionsOrHits: bucket.organicHits,
|
|
30764
31204
|
usersEstimated: null,
|
|
30765
31205
|
createdAt: finishedAt,
|
|
30766
31206
|
updatedAt: finishedAt
|
|
@@ -31508,6 +31948,8 @@ async function trafficRoutes(app, opts) {
|
|
|
31508
31948
|
landingPathNormalized: bucket.landingPathNormalized,
|
|
31509
31949
|
status,
|
|
31510
31950
|
sessionsOrHits: bucket.hits,
|
|
31951
|
+
paidSessionsOrHits: bucket.paidHits,
|
|
31952
|
+
organicSessionsOrHits: bucket.organicHits,
|
|
31511
31953
|
usersEstimated: null,
|
|
31512
31954
|
createdAt: finishedAt,
|
|
31513
31955
|
updatedAt: finishedAt
|
|
@@ -31522,8 +31964,13 @@ async function trafficRoutes(app, opts) {
|
|
|
31522
31964
|
aiReferralEventsHourly.landingPathNormalized,
|
|
31523
31965
|
aiReferralEventsHourly.status
|
|
31524
31966
|
],
|
|
31967
|
+
// All three counters accumulate. The class splits the measure, so a
|
|
31968
|
+
// colliding bucket adds its paid and organic arrivals to the ones
|
|
31969
|
+
// already there rather than overwriting a single label.
|
|
31525
31970
|
set: {
|
|
31526
31971
|
sessionsOrHits: sql12`${aiReferralEventsHourly.sessionsOrHits} + ${bucket.hits}`,
|
|
31972
|
+
paidSessionsOrHits: sql12`${aiReferralEventsHourly.paidSessionsOrHits} + ${bucket.paidHits}`,
|
|
31973
|
+
organicSessionsOrHits: sql12`${aiReferralEventsHourly.organicSessionsOrHits} + ${bucket.organicHits}`,
|
|
31527
31974
|
updatedAt: finishedAt
|
|
31528
31975
|
}
|
|
31529
31976
|
}).run();
|
|
@@ -31975,7 +32422,7 @@ async function trafficRoutes(app, opts) {
|
|
|
31975
32422
|
let crawlerTotal = 0;
|
|
31976
32423
|
let crawlerSegments = { content: 0, sitemap: 0, robots: 0, asset: 0, other: 0 };
|
|
31977
32424
|
let aiUserFetchTotal = 0;
|
|
31978
|
-
let
|
|
32425
|
+
let aiReferralCounts = aiReferralClassCounts(0, 0, 0);
|
|
31979
32426
|
if (kind === "all" || kind === TrafficEventKinds.crawler) {
|
|
31980
32427
|
const crawlerFilters = [
|
|
31981
32428
|
eq28(crawlerEventsHourly.projectId, project.id),
|
|
@@ -32041,10 +32488,19 @@ async function trafficRoutes(app, opts) {
|
|
|
32041
32488
|
];
|
|
32042
32489
|
if (sourceIdParam) aiFilters.push(eq28(aiReferralEventsHourly.sourceId, sourceIdParam));
|
|
32043
32490
|
const aiWhere = and24(...aiFilters);
|
|
32044
|
-
const total = app.db.select({
|
|
32045
|
-
|
|
32491
|
+
const total = app.db.select({
|
|
32492
|
+
total: sql12`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)`,
|
|
32493
|
+
paid: sql12`COALESCE(SUM(${aiReferralEventsHourly.paidSessionsOrHits}), 0)`,
|
|
32494
|
+
organic: sql12`COALESCE(SUM(${aiReferralEventsHourly.organicSessionsOrHits}), 0)`
|
|
32495
|
+
}).from(aiReferralEventsHourly).where(aiWhere).get();
|
|
32496
|
+
aiReferralCounts = aiReferralClassCounts(
|
|
32497
|
+
Number(total?.total ?? 0),
|
|
32498
|
+
Number(total?.paid ?? 0),
|
|
32499
|
+
Number(total?.organic ?? 0)
|
|
32500
|
+
);
|
|
32046
32501
|
const rows = app.db.select().from(aiReferralEventsHourly).where(aiWhere).orderBy(desc15(aiReferralEventsHourly.tsHour)).limit(limit).all();
|
|
32047
32502
|
for (const r of rows) {
|
|
32503
|
+
const counts2 = aiReferralClassCounts(r.sessionsOrHits, r.paidSessionsOrHits, r.organicSessionsOrHits);
|
|
32048
32504
|
events.push({
|
|
32049
32505
|
kind: TrafficEventKinds["ai-referral"],
|
|
32050
32506
|
sourceId: r.sourceId,
|
|
@@ -32055,7 +32511,10 @@ async function trafficRoutes(app, opts) {
|
|
|
32055
32511
|
evidenceType: r.evidenceType,
|
|
32056
32512
|
landingPathNormalized: r.landingPathNormalized,
|
|
32057
32513
|
status: r.status,
|
|
32058
|
-
hits:
|
|
32514
|
+
hits: counts2.total,
|
|
32515
|
+
paidHits: counts2.paid,
|
|
32516
|
+
organicHits: counts2.organic,
|
|
32517
|
+
unknownHits: counts2.unknown
|
|
32059
32518
|
});
|
|
32060
32519
|
}
|
|
32061
32520
|
}
|
|
@@ -32070,7 +32529,10 @@ async function trafficRoutes(app, opts) {
|
|
|
32070
32529
|
crawlerInfraHits: sumInfraHits(crawlerSegments),
|
|
32071
32530
|
crawlerSegments,
|
|
32072
32531
|
aiUserFetchHits: aiUserFetchTotal,
|
|
32073
|
-
aiReferralHits:
|
|
32532
|
+
aiReferralHits: aiReferralCounts.total,
|
|
32533
|
+
aiReferralPaidHits: aiReferralCounts.paid,
|
|
32534
|
+
aiReferralOrganicHits: aiReferralCounts.organic,
|
|
32535
|
+
aiReferralUnknownHits: aiReferralCounts.unknown
|
|
32074
32536
|
},
|
|
32075
32537
|
events: trimmed
|
|
32076
32538
|
};
|