@canonry/canonry 4.172.0 → 4.172.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/agent-workspace/skills/aero/references/reporting.md +8 -4
- package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +1 -1
- package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +4 -3
- package/assets/assets/{AuditHistoryPanel-DdZeCGyB.js → AuditHistoryPanel-DnqySUw3.js} +1 -1
- package/assets/assets/{BacklinksPage-C3cA1uZG.js → BacklinksPage-CbcHWD-G.js} +1 -1
- package/assets/assets/{HistoryPage-ILldZ0uo.js → HistoryPage-BYFSyxuB.js} +1 -1
- package/assets/assets/{MeasurementPropertyPage-DdGD-pSU.js → MeasurementPropertyPage-BBnXQPIv.js} +1 -1
- package/assets/assets/ProjectPage-Cq5bHpmk.js +9 -0
- package/assets/assets/{RunRow-BQACorl6.js → RunRow-lfyHktTf.js} +1 -1
- package/assets/assets/{RunsPage-BAt4nfFQ.js → RunsPage-ngnP0Y_D.js} +1 -1
- package/assets/assets/{SettingsPage-C8jJCoe8.js → SettingsPage-DEg74T20.js} +1 -1
- package/assets/assets/{SiteHealthSection-K8UY4Jnr.js → SiteHealthSection-C6ycXwbt.js} +3 -3
- package/assets/assets/{TrafficPage-GuuYfJor.js → TrafficPage-hrJ1IDqA.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-BUpxbbyO.js → TrafficSourceDetailPage-fu7nPl3E.js} +1 -1
- package/assets/assets/{extract-error-message-DhnfPYL6.js → extract-error-message-dcdO9njP.js} +1 -1
- package/assets/assets/index-D1mXm81Z.css +1 -0
- package/assets/assets/{index-DgNO-ho5.js → index-DpQ_-QC_.js} +27 -27
- package/assets/assets/{react-sigma_core.esm.min-B_Td5ND2.js → react-sigma_core.esm.min-DZGxGBSe.js} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-HJ7W2LXI.js → chunk-EGGQSFQL.js} +4758 -4678
- package/dist/{chunk-O226BLC7.js → chunk-PXTSQ67Z.js} +712 -292
- package/dist/{chunk-5KGN224W.js → chunk-TI3SZAVI.js} +4 -4
- package/dist/{chunk-RR4KSDOD.js → chunk-U5JTZM66.js} +2 -2
- package/dist/{chunk-IIFDS3K2.js → chunk-YXDCVM5U.js} +8 -5
- package/dist/cli.js +72 -27
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-KT6K4ODY.js → intelligence-service-H7756PBD.js} +2 -2
- package/dist/mcp.js +3 -3
- package/package.json +10 -10
- package/assets/assets/ProjectPage-BPbmNgA9.js +0 -9
- package/assets/assets/index-BsQGKWA6.css +0 -1
|
@@ -231,6 +231,7 @@ import {
|
|
|
231
231
|
compileBrandAliases,
|
|
232
232
|
compileMeasurementPlan,
|
|
233
233
|
compileMeasurementPlanPreview,
|
|
234
|
+
compileQueryClassifier,
|
|
234
235
|
computeDedupSimilarityStats,
|
|
235
236
|
contentActionLabel,
|
|
236
237
|
contentGapsResponseDtoSchema,
|
|
@@ -277,6 +278,7 @@ import {
|
|
|
277
278
|
escapeLikePattern,
|
|
278
279
|
evaluateModelPointerExposure,
|
|
279
280
|
extractAnswerMentions,
|
|
281
|
+
extractDomainsFromText,
|
|
280
282
|
factorStatusFromScore,
|
|
281
283
|
filterBrandedSeedCandidates,
|
|
282
284
|
filterCapturedCitedUrls,
|
|
@@ -355,6 +357,7 @@ import {
|
|
|
355
357
|
hostMatchesAnyDomain,
|
|
356
358
|
hostMatchesDomain,
|
|
357
359
|
hostOf,
|
|
360
|
+
inclusiveDayCount,
|
|
358
361
|
indexingRequestResponseDtoSchema,
|
|
359
362
|
internalError,
|
|
360
363
|
isBrowserProvider,
|
|
@@ -371,6 +374,7 @@ import {
|
|
|
371
374
|
locationContextSchema,
|
|
372
375
|
loginRequestSchema,
|
|
373
376
|
mapWithConcurrency,
|
|
377
|
+
matchedAliasKeys,
|
|
374
378
|
matcherMatchesText,
|
|
375
379
|
measurementChangesQuerySchema,
|
|
376
380
|
measurementChangesResponseSchema,
|
|
@@ -624,7 +628,7 @@ import {
|
|
|
624
628
|
wordpressSchemaDeployResultDtoSchema,
|
|
625
629
|
wordpressSchemaStatusResultDtoSchema,
|
|
626
630
|
wordpressStatusDtoSchema
|
|
627
|
-
} from "./chunk-
|
|
631
|
+
} from "./chunk-EGGQSFQL.js";
|
|
628
632
|
|
|
629
633
|
// src/intelligence-service.ts
|
|
630
634
|
import { eq as eq60, desc as desc27, asc as asc11, and as and48, ne as ne8, or as or14, inArray as inArray21, gte as gte14, lte as lte12 } from "drizzle-orm";
|
|
@@ -1019,6 +1023,21 @@ var querySnapshots = sqliteTable("query_snapshots", {
|
|
|
1019
1023
|
// observation predates the field, NOT that retrieval did not happen.
|
|
1020
1024
|
retrievalStatus: text("retrieval_status").$type(),
|
|
1021
1025
|
retrievalContract: text("retrieval_contract").$type(),
|
|
1026
|
+
/**
|
|
1027
|
+
* LEGACY MIXED SIGNAL — never read this for a mention or a citation metric.
|
|
1028
|
+
*
|
|
1029
|
+
* The run writer unions three different things into it: cited domains,
|
|
1030
|
+
* grounding-source hosts, and answer-text brand matches. A count taken from it
|
|
1031
|
+
* is therefore neither the citation signal nor the mention signal, and naming
|
|
1032
|
+
* it either one reports a number for one signal under the other's name (see
|
|
1033
|
+
* AGENTS.md "Vocabulary"). It is also frozen at run time against the competitor
|
|
1034
|
+
* set and the alias threshold that existed then, so it drifts from any
|
|
1035
|
+
* read-time recomputation as soon as either changes.
|
|
1036
|
+
*
|
|
1037
|
+
* For citations read `citedDomains`. For mentions match the project/competitor
|
|
1038
|
+
* brand aliases against `answerText` with `packages/contracts/src/brand-matching.ts`.
|
|
1039
|
+
* The column is retained for the raw results export and historical inspection.
|
|
1040
|
+
*/
|
|
1022
1041
|
competitorOverlap: text("competitor_overlap", { mode: "json" }).$type().notNull().default([]),
|
|
1023
1042
|
recommendedCompetitors: text("recommended_competitors", { mode: "json" }).$type().notNull().default([]),
|
|
1024
1043
|
location: text("location"),
|
|
@@ -7247,9 +7266,9 @@ function analyzeCause(regression, currentSnapshots) {
|
|
|
7247
7266
|
const matchingSnaps = currentSnapshots.filter(
|
|
7248
7267
|
(s) => s.query === regression.query && s.provider === regression.provider && !s.cited
|
|
7249
7268
|
);
|
|
7250
|
-
const withCompetitor = matchingSnaps.find((s) => s.
|
|
7269
|
+
const withCompetitor = matchingSnaps.find((s) => s.citedCompetitorDomains?.length);
|
|
7251
7270
|
if (withCompetitor) {
|
|
7252
|
-
const competitor = withCompetitor.
|
|
7271
|
+
const competitor = withCompetitor.citedCompetitorDomains[0];
|
|
7253
7272
|
return {
|
|
7254
7273
|
cause: "competitor_gain",
|
|
7255
7274
|
competitorDomain: competitor,
|
|
@@ -7487,8 +7506,8 @@ function detectPersistentGaps(runs2, threshold = PERSISTENT_GAP_THRESHOLD) {
|
|
|
7487
7506
|
function buildCompetitorQueryMap(run, tracked) {
|
|
7488
7507
|
const result = /* @__PURE__ */ new Map();
|
|
7489
7508
|
for (const snap of run.snapshots) {
|
|
7490
|
-
if (!snap.query || !snap.
|
|
7491
|
-
for (const domain of snap.
|
|
7509
|
+
if (!snap.query || !snap.citedCompetitorDomains || snap.citedCompetitorDomains.length === 0) continue;
|
|
7510
|
+
for (const domain of snap.citedCompetitorDomains) {
|
|
7492
7511
|
if (!tracked.has(domain)) continue;
|
|
7493
7512
|
const existing = result.get(domain) ?? /* @__PURE__ */ new Set();
|
|
7494
7513
|
existing.add(snap.query);
|
|
@@ -7753,6 +7772,7 @@ function calculateActionConfidence(input) {
|
|
|
7753
7772
|
function buildContentTargetRows(input) {
|
|
7754
7773
|
const rows = [];
|
|
7755
7774
|
for (const cq of input.candidateQueries) {
|
|
7775
|
+
const competitiveDomains = competitivePresenceDomains(cq);
|
|
7756
7776
|
const ourPage = resolveOurPage(cq, input.inventory);
|
|
7757
7777
|
const ourPageInGroundingSources = cq.ourCitedInLatestRun;
|
|
7758
7778
|
const ourPageHasSchema = ourPage ? input.wpSchemaAudit.get(ourPage.url) ?? null : null;
|
|
@@ -7763,7 +7783,7 @@ function buildContentTargetRows(input) {
|
|
|
7763
7783
|
});
|
|
7764
7784
|
if (!action) continue;
|
|
7765
7785
|
const hasGsc = cq.gscImpressions > 0;
|
|
7766
|
-
const hasCompetitor =
|
|
7786
|
+
const hasCompetitor = competitiveDomains.length > 0;
|
|
7767
7787
|
if (!hasGsc && !hasCompetitor && !cq.ourCitedInLatestRun) continue;
|
|
7768
7788
|
const aiReferralFactor = computeAiReferralFactor(
|
|
7769
7789
|
input.totalAiReferralSessions,
|
|
@@ -7772,7 +7792,7 @@ function buildContentTargetRows(input) {
|
|
|
7772
7792
|
const scoring = scoreContentTarget({
|
|
7773
7793
|
gscImpressions: cq.gscImpressions,
|
|
7774
7794
|
aiReferralFactor,
|
|
7775
|
-
competitorCount:
|
|
7795
|
+
competitorCount: competitiveDomains.length,
|
|
7776
7796
|
recentMissRate: cq.recentMissRate,
|
|
7777
7797
|
citationCount: cq.competitorCitationCount,
|
|
7778
7798
|
ourCitedRate: cq.ourCitedRate,
|
|
@@ -7783,7 +7803,7 @@ function buildContentTargetRows(input) {
|
|
|
7783
7803
|
hasGsc: cq.gscPage !== null,
|
|
7784
7804
|
gscImpressions: cq.gscImpressions,
|
|
7785
7805
|
runsOfHistory: cq.runsOfHistory,
|
|
7786
|
-
hasCompetitorEvidence:
|
|
7806
|
+
hasCompetitorEvidence: competitiveDomains.length > 0,
|
|
7787
7807
|
hasInventoryMatch: ourPage?.source === "inventory"
|
|
7788
7808
|
});
|
|
7789
7809
|
const targetRef = computeTargetRef({
|
|
@@ -7854,12 +7874,12 @@ function buildContentSourceRows(input) {
|
|
|
7854
7874
|
function buildContentGapRows(input) {
|
|
7855
7875
|
const gaps = [];
|
|
7856
7876
|
for (const cq of input.candidateQueries) {
|
|
7857
|
-
if (cq.
|
|
7877
|
+
if (cq.competitorCitedDomains.length === 0) continue;
|
|
7858
7878
|
if (cq.ourCitedRate >= 1) continue;
|
|
7859
7879
|
gaps.push({
|
|
7860
7880
|
query: cq.query,
|
|
7861
|
-
competitorDomains: cq.
|
|
7862
|
-
competitorCount: cq.
|
|
7881
|
+
competitorDomains: cq.competitorCitedDomains,
|
|
7882
|
+
competitorCount: cq.competitorCitedDomains.length,
|
|
7863
7883
|
missRate: clamp012(cq.recentMissRate),
|
|
7864
7884
|
lastSeenInRunId: input.latestRunId
|
|
7865
7885
|
});
|
|
@@ -7869,6 +7889,12 @@ function buildContentGapRows(input) {
|
|
|
7869
7889
|
return b.competitorCount - a.competitorCount;
|
|
7870
7890
|
});
|
|
7871
7891
|
}
|
|
7892
|
+
function competitivePresenceDomains(cq) {
|
|
7893
|
+
return [.../* @__PURE__ */ new Set([
|
|
7894
|
+
...cq.competitorCitedDomains,
|
|
7895
|
+
...cq.competitorMentionedDomains
|
|
7896
|
+
])];
|
|
7897
|
+
}
|
|
7872
7898
|
function resolveOurPage(cq, inventory) {
|
|
7873
7899
|
if (cq.gscPage && cq.gscPosition !== null) {
|
|
7874
7900
|
return { url: cq.gscPage, position: cq.gscPosition, source: "gsc" };
|
|
@@ -8106,6 +8132,46 @@ function citedDomainBelongsToProject(citedDomain, projectDomains) {
|
|
|
8106
8132
|
return hostMatchesAnyDomain(citedDomain, projectDomains);
|
|
8107
8133
|
}
|
|
8108
8134
|
|
|
8135
|
+
// ../intelligence/src/competitive-signals.ts
|
|
8136
|
+
function compileCompetitiveSignalResolver(competitorDomains) {
|
|
8137
|
+
const identities = [];
|
|
8138
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8139
|
+
for (const candidate of competitorDomains) {
|
|
8140
|
+
const domain = hostOf(candidate);
|
|
8141
|
+
if (!domain || seen.has(domain)) continue;
|
|
8142
|
+
seen.add(domain);
|
|
8143
|
+
const domainBrandKey = brandKeyFromText(brandLabelFromDomain(domain));
|
|
8144
|
+
identities.push({
|
|
8145
|
+
domain,
|
|
8146
|
+
domainBrandKey: domainBrandKey.length >= MIN_DOMAIN_BRAND_KEY_LENGTH ? domainBrandKey : null
|
|
8147
|
+
});
|
|
8148
|
+
}
|
|
8149
|
+
const domainBrandMatcher = compileBrandAliases(
|
|
8150
|
+
identities.filter((identity) => identity.domainBrandKey !== null).map((identity) => brandLabelFromDomain(identity.domain))
|
|
8151
|
+
);
|
|
8152
|
+
return {
|
|
8153
|
+
resolve(evidence) {
|
|
8154
|
+
const citationCandidates = [
|
|
8155
|
+
...evidence.citedDomains ?? [],
|
|
8156
|
+
...(evidence.groundingSources ?? []).map((source) => source.uri)
|
|
8157
|
+
];
|
|
8158
|
+
const answerDomains = extractDomainsFromText(evidence.answerText);
|
|
8159
|
+
const mentionedBrandKeys = matchedAliasKeys(domainBrandMatcher, evidence.answerText);
|
|
8160
|
+
const citedCompetitorDomains = [];
|
|
8161
|
+
const mentionedCompetitorDomains = [];
|
|
8162
|
+
for (const identity of identities) {
|
|
8163
|
+
if (citationCandidates.some((candidate) => hostMatchesDomain(candidate, identity.domain))) {
|
|
8164
|
+
citedCompetitorDomains.push(identity.domain);
|
|
8165
|
+
}
|
|
8166
|
+
if (answerDomains.some((candidate) => hostMatchesDomain(candidate, identity.domain)) || identity.domainBrandKey !== null && mentionedBrandKeys.has(identity.domainBrandKey)) {
|
|
8167
|
+
mentionedCompetitorDomains.push(identity.domain);
|
|
8168
|
+
}
|
|
8169
|
+
}
|
|
8170
|
+
return { citedCompetitorDomains, mentionedCompetitorDomains };
|
|
8171
|
+
}
|
|
8172
|
+
};
|
|
8173
|
+
}
|
|
8174
|
+
|
|
8109
8175
|
// ../intelligence/src/competitor-landscape.ts
|
|
8110
8176
|
function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, queryLookup) {
|
|
8111
8177
|
let projectCitationCount = 0;
|
|
@@ -8113,14 +8179,22 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
|
|
|
8113
8179
|
for (const c of competitorDomains) {
|
|
8114
8180
|
competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
|
|
8115
8181
|
}
|
|
8182
|
+
const competitorResolver = compileCompetitiveSignalResolver(competitorDomains);
|
|
8183
|
+
const projectResolver = compileCompetitiveSignalResolver(projectDomains);
|
|
8116
8184
|
for (const snap of snapshots) {
|
|
8117
8185
|
const q = queryLookup.byId.get(snap.queryId);
|
|
8118
|
-
const
|
|
8119
|
-
|
|
8186
|
+
const evidence = {
|
|
8187
|
+
citedDomains: snap.citedDomains,
|
|
8188
|
+
groundingSources: snap.groundingSources
|
|
8189
|
+
};
|
|
8190
|
+
if (projectResolver.resolve(evidence).citedCompetitorDomains.length > 0) {
|
|
8120
8191
|
projectCitationCount++;
|
|
8121
8192
|
}
|
|
8193
|
+
const citedCompetitors = new Set(
|
|
8194
|
+
competitorResolver.resolve(evidence).citedCompetitorDomains
|
|
8195
|
+
);
|
|
8122
8196
|
for (const competitor of competitorDomains) {
|
|
8123
|
-
if (
|
|
8197
|
+
if (citedCompetitors.has(competitor)) {
|
|
8124
8198
|
const entry = competitorMap.get(competitor);
|
|
8125
8199
|
entry.count++;
|
|
8126
8200
|
if (q) entry.queries.add(q);
|
|
@@ -8162,55 +8236,84 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
|
|
|
8162
8236
|
}
|
|
8163
8237
|
|
|
8164
8238
|
// ../intelligence/src/mention-landscape.ts
|
|
8239
|
+
function emptySection(competitorDomains) {
|
|
8240
|
+
return {
|
|
8241
|
+
projectMentionCount: 0,
|
|
8242
|
+
totalAnswerSnapshots: 0,
|
|
8243
|
+
competitorMap: new Map(competitorDomains.map((c) => [c, { count: 0, queries: /* @__PURE__ */ new Set() }]))
|
|
8244
|
+
};
|
|
8245
|
+
}
|
|
8246
|
+
function toSection(tally) {
|
|
8247
|
+
const totalMentionedSlots = tally.projectMentionCount + [...tally.competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
|
|
8248
|
+
const competitorRows = [...tally.competitorMap.entries()].map(([domain, data]) => {
|
|
8249
|
+
const ratio = tally.totalAnswerSnapshots > 0 ? data.count / tally.totalAnswerSnapshots : 0;
|
|
8250
|
+
let pressureLabel = "None";
|
|
8251
|
+
if (data.count > 0) {
|
|
8252
|
+
if (ratio >= 0.5) pressureLabel = "High";
|
|
8253
|
+
else if (ratio >= 0.2) pressureLabel = "Moderate";
|
|
8254
|
+
else pressureLabel = "Low";
|
|
8255
|
+
}
|
|
8256
|
+
const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : null;
|
|
8257
|
+
return {
|
|
8258
|
+
domain,
|
|
8259
|
+
mentionCount: data.count,
|
|
8260
|
+
totalCount: tally.totalAnswerSnapshots,
|
|
8261
|
+
pressureLabel,
|
|
8262
|
+
mentionedQueries: [...data.queries].sort(),
|
|
8263
|
+
sharePct
|
|
8264
|
+
};
|
|
8265
|
+
});
|
|
8266
|
+
competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
|
|
8267
|
+
return {
|
|
8268
|
+
projectMentionCount: tally.projectMentionCount,
|
|
8269
|
+
totalAnswerSnapshots: tally.totalAnswerSnapshots,
|
|
8270
|
+
competitors: competitorRows
|
|
8271
|
+
};
|
|
8272
|
+
}
|
|
8165
8273
|
function buildMentionLandscape(snapshots, competitorDomains, projectBrandNames, projectDomains, queryLookup) {
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
const
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8274
|
+
const classifier = compileQueryClassifier(projectBrandNames);
|
|
8275
|
+
const nonBrand = emptySection(competitorDomains);
|
|
8276
|
+
const branded = emptySection(competitorDomains);
|
|
8277
|
+
const unclassified = emptySection(competitorDomains);
|
|
8278
|
+
const competitorAliases = new Map(
|
|
8279
|
+
competitorDomains.map((domain) => {
|
|
8280
|
+
const label = brandLabelFromDomain(domain);
|
|
8281
|
+
return [domain, brandKeyFromText(label).length >= MIN_DOMAIN_BRAND_KEY_LENGTH ? [label] : []];
|
|
8282
|
+
})
|
|
8283
|
+
);
|
|
8172
8284
|
for (const snap of snapshots) {
|
|
8173
8285
|
const text2 = snap.answerText;
|
|
8174
8286
|
if (!text2) continue;
|
|
8175
|
-
totalAnswerSnapshots++;
|
|
8176
8287
|
const q = queryLookup.byId.get(snap.queryId);
|
|
8177
|
-
const
|
|
8288
|
+
const queryClass = classifier ? classifier.classify(q ?? null) : null;
|
|
8289
|
+
const tally = queryClass === "branded" ? branded : queryClass === "non-brand" ? nonBrand : unclassified;
|
|
8290
|
+
tally.totalAnswerSnapshots++;
|
|
8291
|
+
const projectMentioned = determineAnswerMentioned(
|
|
8178
8292
|
text2,
|
|
8179
8293
|
[...projectBrandNames],
|
|
8180
8294
|
[...projectDomains]
|
|
8181
8295
|
);
|
|
8182
|
-
if (projectMentioned) projectMentionCount++;
|
|
8296
|
+
if (projectMentioned) tally.projectMentionCount++;
|
|
8183
8297
|
for (const competitor of competitorDomains) {
|
|
8184
|
-
const
|
|
8185
|
-
const mentioned = determineAnswerMentioned(text2,
|
|
8298
|
+
const aliases = competitorAliases.get(competitor) ?? [];
|
|
8299
|
+
const mentioned = determineAnswerMentioned(text2, aliases, [competitor]);
|
|
8186
8300
|
if (mentioned) {
|
|
8187
|
-
const entry = competitorMap.get(competitor);
|
|
8301
|
+
const entry = tally.competitorMap.get(competitor);
|
|
8188
8302
|
entry.count++;
|
|
8189
8303
|
if (q) entry.queries.add(q);
|
|
8190
8304
|
}
|
|
8191
8305
|
}
|
|
8192
8306
|
}
|
|
8193
|
-
const
|
|
8194
|
-
const
|
|
8195
|
-
|
|
8196
|
-
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
8203
|
-
return {
|
|
8204
|
-
domain,
|
|
8205
|
-
mentionCount: data.count,
|
|
8206
|
-
totalCount: totalAnswerSnapshots,
|
|
8207
|
-
pressureLabel,
|
|
8208
|
-
mentionedQueries: [...data.queries].sort(),
|
|
8209
|
-
sharePct
|
|
8210
|
-
};
|
|
8211
|
-
});
|
|
8212
|
-
competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
|
|
8213
|
-
return { projectMentionCount, totalAnswerSnapshots, competitors: competitorRows };
|
|
8307
|
+
const scope = classifier ? "non-brand" : "pooled";
|
|
8308
|
+
const headline = toSection(scope === "non-brand" ? nonBrand : unclassified);
|
|
8309
|
+
return {
|
|
8310
|
+
// Kept at the top level so every existing reader of `projectMentionCount` /
|
|
8311
|
+
// `competitors` gets the competitive figure rather than the pooled one.
|
|
8312
|
+
...headline,
|
|
8313
|
+
scope,
|
|
8314
|
+
nonBrand: headline,
|
|
8315
|
+
branded: toSection(branded)
|
|
8316
|
+
};
|
|
8214
8317
|
}
|
|
8215
8318
|
|
|
8216
8319
|
// ../intelligence/src/ai-source-origin.ts
|
|
@@ -8503,14 +8606,14 @@ function buildGapQueryScore(snapshots) {
|
|
|
8503
8606
|
const byQuery = /* @__PURE__ */ new Map();
|
|
8504
8607
|
for (const snap of snapshots) {
|
|
8505
8608
|
const key = snap.queryId;
|
|
8506
|
-
const current = byQuery.get(key) ?? { cited: false,
|
|
8609
|
+
const current = byQuery.get(key) ?? { cited: false, citedCompetitors: /* @__PURE__ */ new Set() };
|
|
8507
8610
|
if (snap.citationState === CitationStates.cited) current.cited = true;
|
|
8508
|
-
for (const domain of snap.
|
|
8611
|
+
for (const domain of snap.citedCompetitorDomains) current.citedCompetitors.add(domain);
|
|
8509
8612
|
byQuery.set(key, current);
|
|
8510
8613
|
}
|
|
8511
8614
|
const totalCount = byQuery.size;
|
|
8512
8615
|
const gapCount = [...byQuery.values()].filter(
|
|
8513
|
-
(entry) => !entry.cited && entry.
|
|
8616
|
+
(entry) => !entry.cited && entry.citedCompetitors.size > 0
|
|
8514
8617
|
).length;
|
|
8515
8618
|
const gapQueryLabel = gapCount === 1 ? "query" : "queries";
|
|
8516
8619
|
return {
|
|
@@ -8540,14 +8643,14 @@ function buildMentionGapScore(snapshots) {
|
|
|
8540
8643
|
const byQuery = /* @__PURE__ */ new Map();
|
|
8541
8644
|
for (const snap of snapshots) {
|
|
8542
8645
|
const key = snap.queryId;
|
|
8543
|
-
const current = byQuery.get(key) ?? { mentioned: false,
|
|
8646
|
+
const current = byQuery.get(key) ?? { mentioned: false, mentionedCompetitors: /* @__PURE__ */ new Set() };
|
|
8544
8647
|
if (snap.answerMentioned === true) current.mentioned = true;
|
|
8545
|
-
for (const domain of snap.
|
|
8648
|
+
for (const domain of snap.mentionedCompetitorDomains) current.mentionedCompetitors.add(domain);
|
|
8546
8649
|
byQuery.set(key, current);
|
|
8547
8650
|
}
|
|
8548
8651
|
const totalCount = byQuery.size;
|
|
8549
8652
|
const gapCount = [...byQuery.values()].filter(
|
|
8550
|
-
(entry) => !entry.mentioned && entry.
|
|
8653
|
+
(entry) => !entry.mentioned && entry.mentionedCompetitors.size > 0
|
|
8551
8654
|
).length;
|
|
8552
8655
|
const gapQueryLabel = gapCount === 1 ? "query" : "queries";
|
|
8553
8656
|
return {
|
|
@@ -8579,7 +8682,7 @@ function buildCompetitorPressureScore(snapshots, competitorDomains, totalTracked
|
|
|
8579
8682
|
}
|
|
8580
8683
|
let overlapCount = 0;
|
|
8581
8684
|
for (const snap of snapshots) {
|
|
8582
|
-
if (snap.
|
|
8685
|
+
if (snap.citedDomains.some((domain) => hostMatchesAnyDomain(domain, competitorDomains))) {
|
|
8583
8686
|
overlapCount++;
|
|
8584
8687
|
}
|
|
8585
8688
|
}
|
|
@@ -8604,7 +8707,7 @@ function buildOverviewCompetitors(snapshots, competitors2, queryLookup) {
|
|
|
8604
8707
|
return competitors2.map((competitor, index2) => {
|
|
8605
8708
|
const citedQuerySet = /* @__PURE__ */ new Set();
|
|
8606
8709
|
for (const snap of snapshots) {
|
|
8607
|
-
if (snap.
|
|
8710
|
+
if (snap.citedDomains.some((domain) => hostMatchesDomain(domain, competitor.domain))) {
|
|
8608
8711
|
if (snap.queryId) citedQuerySet.add(snap.queryId);
|
|
8609
8712
|
}
|
|
8610
8713
|
}
|
|
@@ -8715,15 +8818,62 @@ function collectProviderKeys(perRun) {
|
|
|
8715
8818
|
}
|
|
8716
8819
|
|
|
8717
8820
|
// ../intelligence/src/mention-share.ts
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8821
|
+
var MIN_BRAND_ALIAS_KEY_LENGTH = 3;
|
|
8822
|
+
function usableBrandAliases(aliases) {
|
|
8823
|
+
return aliases.filter((alias2) => brandKeyFromText(alias2).length >= MIN_BRAND_ALIAS_KEY_LENGTH);
|
|
8824
|
+
}
|
|
8825
|
+
function emptyTally(competitors2) {
|
|
8826
|
+
return {
|
|
8721
8827
|
projectMentionSnapshots: 0,
|
|
8722
|
-
competitorMentionSnapshots: 0,
|
|
8723
|
-
perCompetitor: [],
|
|
8724
8828
|
snapshotsWithAnswerText: 0,
|
|
8725
|
-
snapshotsTotal:
|
|
8829
|
+
snapshotsTotal: 0,
|
|
8830
|
+
competitorCounts: new Map(competitors2.map((c) => [c.domain, 0]))
|
|
8831
|
+
};
|
|
8832
|
+
}
|
|
8833
|
+
function toBreakdown(tally, competitors2) {
|
|
8834
|
+
const competitorMentionSnapshots = [...tally.competitorCounts.values()].reduce((a, b) => a + b, 0);
|
|
8835
|
+
const denom = tally.projectMentionSnapshots + competitorMentionSnapshots;
|
|
8836
|
+
const perCompetitor = competitors2.map((c) => ({
|
|
8837
|
+
domain: c.domain,
|
|
8838
|
+
mentionSnapshots: tally.competitorCounts.get(c.domain) ?? 0,
|
|
8839
|
+
shareOfCompetitiveTotal: competitorMentionSnapshots > 0 ? Math.round((tally.competitorCounts.get(c.domain) ?? 0) / competitorMentionSnapshots * 1e3) / 10 : 0
|
|
8840
|
+
})).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots || (a.domain < b.domain ? -1 : 1));
|
|
8841
|
+
return {
|
|
8842
|
+
projectMentionSnapshots: tally.projectMentionSnapshots,
|
|
8843
|
+
competitorMentionSnapshots,
|
|
8844
|
+
perCompetitor,
|
|
8845
|
+
snapshotsWithAnswerText: tally.snapshotsWithAnswerText,
|
|
8846
|
+
snapshotsTotal: tally.snapshotsTotal,
|
|
8847
|
+
score: denom > 0 ? Math.round(tally.projectMentionSnapshots / denom * 100) : null
|
|
8726
8848
|
};
|
|
8849
|
+
}
|
|
8850
|
+
function buildMentionShare(snapshots, options) {
|
|
8851
|
+
const competitorMatchers = new Map(
|
|
8852
|
+
options.competitors.map((c) => [c.domain, compileBrandAliases(usableBrandAliases([...c.brandTokens]))])
|
|
8853
|
+
);
|
|
8854
|
+
const nonBrand = emptyTally(options.competitors);
|
|
8855
|
+
const branded = emptyTally(options.competitors);
|
|
8856
|
+
const unclassified = emptyTally(options.competitors);
|
|
8857
|
+
for (const snap of snapshots) {
|
|
8858
|
+
const tally = snap.queryClass === "branded" ? branded : snap.queryClass === "non-brand" ? nonBrand : unclassified;
|
|
8859
|
+
tally.snapshotsTotal++;
|
|
8860
|
+
const text2 = snap.answerText ?? "";
|
|
8861
|
+
if (text2.length === 0) continue;
|
|
8862
|
+
tally.snapshotsWithAnswerText++;
|
|
8863
|
+
if (snap.projectMentioned) tally.projectMentionSnapshots++;
|
|
8864
|
+
for (const competitor of options.competitors) {
|
|
8865
|
+
const matcher = competitorMatchers.get(competitor.domain);
|
|
8866
|
+
if (matcher && matcherMatchesText(matcher, text2)) {
|
|
8867
|
+
tally.competitorCounts.set(competitor.domain, (tally.competitorCounts.get(competitor.domain) ?? 0) + 1);
|
|
8868
|
+
}
|
|
8869
|
+
}
|
|
8870
|
+
}
|
|
8871
|
+
const classified = nonBrand.snapshotsTotal + branded.snapshotsTotal;
|
|
8872
|
+
const classificationAvailable = options.classificationAvailable === true || classified > 0;
|
|
8873
|
+
const scope = classificationAvailable ? "non-brand" : "pooled";
|
|
8874
|
+
let headline = toBreakdown(scope === "non-brand" ? nonBrand : unclassified, options.competitors);
|
|
8875
|
+
let brandedBreakdown = toBreakdown(branded, options.competitors);
|
|
8876
|
+
const tooltip = scope === "non-brand" ? "On queries that do NOT contain your name, the % of brand-name-drops in AI answers that are you vs your tracked competitors. Branded queries are excluded on purpose: you are named on almost all of them and a competitor cannot be, so pooling them would hide how you place in your category." : "The % of brand-name-drops in AI answers that are you vs your tracked competitors across all tracked queries. This project has no usable brand alias, so branded and non-brand queries could not be separated and the result is labelled pooled.";
|
|
8727
8877
|
if (snapshots.length === 0) {
|
|
8728
8878
|
return {
|
|
8729
8879
|
label: "Mention Share",
|
|
@@ -8733,10 +8883,14 @@ function buildMentionShare(snapshots, options) {
|
|
|
8733
8883
|
description: "No mention share data yet. Trigger a run to start tracking.",
|
|
8734
8884
|
tooltip,
|
|
8735
8885
|
trend: [],
|
|
8736
|
-
|
|
8886
|
+
scope,
|
|
8887
|
+
breakdown: headline,
|
|
8888
|
+
branded: brandedBreakdown
|
|
8737
8889
|
};
|
|
8738
8890
|
}
|
|
8739
8891
|
if (options.competitors.length === 0) {
|
|
8892
|
+
headline = { ...headline, score: null };
|
|
8893
|
+
brandedBreakdown = { ...brandedBreakdown, score: null };
|
|
8740
8894
|
return {
|
|
8741
8895
|
label: "Mention Share",
|
|
8742
8896
|
value: "Add competitors",
|
|
@@ -8745,83 +8899,95 @@ function buildMentionShare(snapshots, options) {
|
|
|
8745
8899
|
description: "Mention Share is a head-to-head competitive metric \u2014 add tracked competitors to compare brand mention rates.",
|
|
8746
8900
|
tooltip,
|
|
8747
8901
|
trend: [],
|
|
8748
|
-
|
|
8902
|
+
scope,
|
|
8903
|
+
breakdown: headline,
|
|
8904
|
+
branded: brandedBreakdown
|
|
8749
8905
|
};
|
|
8750
8906
|
}
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8907
|
+
const score = headline.score;
|
|
8908
|
+
const denom = headline.projectMentionSnapshots + headline.competitorMentionSnapshots;
|
|
8909
|
+
if (scope === "non-brand" && nonBrand.snapshotsTotal === 0) {
|
|
8910
|
+
return {
|
|
8911
|
+
label: "Mention Share",
|
|
8912
|
+
value: "No non-brand queries",
|
|
8913
|
+
delta: branded.snapshotsTotal > 0 ? `${branded.snapshotsTotal} branded ${branded.snapshotsTotal === 1 ? "snapshot" : "snapshots"} only` : "No answers to score",
|
|
8914
|
+
tone: "neutral",
|
|
8915
|
+
description: "Every tracked query names your brand. Branded queries measure recognition, not competitive placement \u2014 add category queries to measure where you place.",
|
|
8916
|
+
tooltip,
|
|
8917
|
+
trend: [],
|
|
8918
|
+
scope,
|
|
8919
|
+
breakdown: headline,
|
|
8920
|
+
branded: brandedBreakdown
|
|
8921
|
+
};
|
|
8761
8922
|
}
|
|
8762
|
-
|
|
8763
|
-
const
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8923
|
+
if (headline.snapshotsTotal > 0 && headline.snapshotsWithAnswerText === 0) {
|
|
8924
|
+
const brandedAnswers = brandedBreakdown.snapshotsWithAnswerText;
|
|
8925
|
+
return {
|
|
8926
|
+
label: "Mention Share",
|
|
8927
|
+
value: scope === "non-brand" ? "No non-brand answers" : "No answers",
|
|
8928
|
+
delta: scope === "non-brand" && brandedAnswers > 0 ? `${brandedAnswers} branded ${brandedAnswers === 1 ? "answer" : "answers"} only` : "No answers to score",
|
|
8929
|
+
tone: "neutral",
|
|
8930
|
+
description: scope === "non-brand" ? "Non-brand queries are tracked, but no answer text was recorded for them in this run." : "Tracked queries exist, but no answer text was recorded in this run.",
|
|
8931
|
+
tooltip,
|
|
8932
|
+
trend: [],
|
|
8933
|
+
scope,
|
|
8934
|
+
breakdown: headline,
|
|
8935
|
+
branded: brandedBreakdown
|
|
8936
|
+
};
|
|
8937
|
+
}
|
|
8938
|
+
if (score === null) {
|
|
8939
|
+
return {
|
|
8940
|
+
label: "Mention Share",
|
|
8941
|
+
value: "No mentions",
|
|
8942
|
+
delta: scopeLabel(scope, "No brand mentions in this run"),
|
|
8943
|
+
tone: "neutral",
|
|
8944
|
+
description: describe({ scope, score, breakdown: headline, branded: brandedBreakdown }),
|
|
8945
|
+
tooltip,
|
|
8946
|
+
trend: [],
|
|
8947
|
+
scope,
|
|
8948
|
+
breakdown: headline,
|
|
8949
|
+
branded: brandedBreakdown
|
|
8950
|
+
};
|
|
8773
8951
|
}
|
|
8774
|
-
const competitorMentionSnapshots = [...competitorCounts.values()].reduce((a, b) => a + b, 0);
|
|
8775
|
-
const denom = projectMentionSnapshots + competitorMentionSnapshots;
|
|
8776
|
-
const score = denom > 0 ? Math.round(projectMentionSnapshots / denom * 100) : 0;
|
|
8777
|
-
const perCompetitor = options.competitors.map((c) => ({
|
|
8778
|
-
domain: c.domain,
|
|
8779
|
-
mentionSnapshots: competitorCounts.get(c.domain) ?? 0,
|
|
8780
|
-
shareOfCompetitiveTotal: competitorMentionSnapshots > 0 ? Math.round((competitorCounts.get(c.domain) ?? 0) / competitorMentionSnapshots * 1e3) / 10 : 0
|
|
8781
|
-
})).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots);
|
|
8782
|
-
const breakdown = {
|
|
8783
|
-
projectMentionSnapshots,
|
|
8784
|
-
competitorMentionSnapshots,
|
|
8785
|
-
perCompetitor,
|
|
8786
|
-
snapshotsWithAnswerText,
|
|
8787
|
-
snapshotsTotal: snapshots.length
|
|
8788
|
-
};
|
|
8789
|
-
const description = describe({
|
|
8790
|
-
score,
|
|
8791
|
-
projectMentionSnapshots,
|
|
8792
|
-
competitorMentionSnapshots,
|
|
8793
|
-
perCompetitor
|
|
8794
|
-
});
|
|
8795
8952
|
return {
|
|
8796
8953
|
label: "Mention Share",
|
|
8797
|
-
value:
|
|
8798
|
-
delta:
|
|
8799
|
-
tone:
|
|
8800
|
-
description,
|
|
8954
|
+
value: `${score}`,
|
|
8955
|
+
delta: scopeLabel(scope, `${headline.projectMentionSnapshots} of ${denom} brand mentions`),
|
|
8956
|
+
tone: mentionShareTone(score),
|
|
8957
|
+
description: describe({ scope, score, breakdown: headline, branded: brandedBreakdown }),
|
|
8801
8958
|
tooltip,
|
|
8802
8959
|
trend: [],
|
|
8803
|
-
progress:
|
|
8804
|
-
|
|
8960
|
+
progress: score,
|
|
8961
|
+
scope,
|
|
8962
|
+
breakdown: headline,
|
|
8963
|
+
branded: brandedBreakdown
|
|
8805
8964
|
};
|
|
8806
8965
|
}
|
|
8966
|
+
function scopeLabel(scope, body) {
|
|
8967
|
+
return scope === "non-brand" ? `${body} \xB7 non-brand queries` : `${body} \xB7 pooled queries \xB7 classification unavailable`;
|
|
8968
|
+
}
|
|
8807
8969
|
function mentionShareTone(score) {
|
|
8808
8970
|
if (score >= 50) return "positive";
|
|
8809
8971
|
if (score >= 25) return "caution";
|
|
8810
8972
|
return "negative";
|
|
8811
8973
|
}
|
|
8812
8974
|
function describe(parts) {
|
|
8813
|
-
const {
|
|
8975
|
+
const { scope, score, breakdown, branded } = parts;
|
|
8976
|
+
const { projectMentionSnapshots, competitorMentionSnapshots, perCompetitor } = breakdown;
|
|
8977
|
+
const where = scope === "non-brand" ? "on non-brand queries" : "across your tracked queries";
|
|
8978
|
+
const brandedNote = branded.snapshotsWithAnswerText > 0 && branded.score !== null ? ` Separately, you are named in ${branded.projectMentionSnapshots} of ${branded.snapshotsWithAnswerText} answers to queries that contain your name.` : "";
|
|
8814
8979
|
if (projectMentionSnapshots === 0 && competitorMentionSnapshots === 0) {
|
|
8815
|
-
return
|
|
8980
|
+
return `No brand mentions detected for you or your tracked competitors ${where}.${brandedNote}`;
|
|
8816
8981
|
}
|
|
8817
8982
|
if (competitorMentionSnapshots === 0) {
|
|
8818
|
-
return `${projectMentionSnapshots} brand mentions of you
|
|
8983
|
+
return `${projectMentionSnapshots} brand mentions of you and zero competitor mentions ${where} \u2014 you own the conversation.${brandedNote}`;
|
|
8819
8984
|
}
|
|
8820
8985
|
const top = perCompetitor[0];
|
|
8986
|
+
const total = projectMentionSnapshots + competitorMentionSnapshots;
|
|
8821
8987
|
if (!top) {
|
|
8822
|
-
return `${score}% of brand mentions are you (${projectMentionSnapshots} of ${
|
|
8988
|
+
return `${score}% of brand mentions ${where} are you (${projectMentionSnapshots} of ${total}).${brandedNote}`;
|
|
8823
8989
|
}
|
|
8824
|
-
return `${score}% of brand mentions are you. Top competitor: ${top.domain} (${top.mentionSnapshots} mentions)
|
|
8990
|
+
return `${score}% of brand mentions ${where} are you. Top competitor: ${top.domain} (${top.mentionSnapshots} mentions).${brandedNote}`;
|
|
8825
8991
|
}
|
|
8826
8992
|
|
|
8827
8993
|
// ../intelligence/src/suggested-queries.ts
|
|
@@ -13283,6 +13449,8 @@ function loadRunDetail(app, run) {
|
|
|
13283
13449
|
ownedDomains: projects.ownedDomains,
|
|
13284
13450
|
aliases: projects.aliases
|
|
13285
13451
|
}).from(projects).where(eq11(projects.id, run.projectId)).get();
|
|
13452
|
+
const competitorDomains = app.db.select({ domain: competitors.domain }).from(competitors).where(eq11(competitors.projectId, run.projectId)).all().map((row) => row.domain);
|
|
13453
|
+
const competitiveSignalResolver = compileCompetitiveSignalResolver(competitorDomains);
|
|
13286
13454
|
const snapshots = app.db.select({
|
|
13287
13455
|
id: querySnapshots.id,
|
|
13288
13456
|
runId: querySnapshots.runId,
|
|
@@ -13316,6 +13484,14 @@ function loadRunDetail(app, run) {
|
|
|
13316
13484
|
...formatRun(run),
|
|
13317
13485
|
snapshots: snapshots.map((s) => {
|
|
13318
13486
|
const rawParsed = parseSnapshotRawResponse(s.rawResponse);
|
|
13487
|
+
const signalGroundingSources = rawParsed.groundingSources.filter(
|
|
13488
|
+
(source) => typeof source === "object" && source !== null && typeof source.uri === "string"
|
|
13489
|
+
);
|
|
13490
|
+
const competitiveSignals = competitiveSignalResolver.resolve({
|
|
13491
|
+
citedDomains: s.citedDomains,
|
|
13492
|
+
groundingSources: signalGroundingSources,
|
|
13493
|
+
answerText: s.answerText
|
|
13494
|
+
});
|
|
13319
13495
|
const answerMentioned = project ? resolveSnapshotAnswerMentioned(s, project) : s.answerMentioned ?? false;
|
|
13320
13496
|
return {
|
|
13321
13497
|
id: s.id,
|
|
@@ -13336,6 +13512,8 @@ function loadRunDetail(app, run) {
|
|
|
13336
13512
|
sourceCount: s.sourceCount,
|
|
13337
13513
|
resolvedCount: s.resolvedCount,
|
|
13338
13514
|
captureVersion: s.captureVersion,
|
|
13515
|
+
...competitiveSignals,
|
|
13516
|
+
// Legacy mixed signal retained for backwards compatibility.
|
|
13339
13517
|
competitorOverlap: s.competitorOverlap,
|
|
13340
13518
|
recommendedCompetitors: s.recommendedCompetitors,
|
|
13341
13519
|
matchedTerms: project ? resolveSnapshotMatchedTerms(s, project) : [],
|
|
@@ -20164,6 +20342,8 @@ async function historyRoutes(app) {
|
|
|
20164
20342
|
if (projectRuns.length === 0) {
|
|
20165
20343
|
return reply.send({ snapshots: [], total: 0 });
|
|
20166
20344
|
}
|
|
20345
|
+
const competitorDomains = app.db.select({ domain: competitors.domain }).from(competitors).where(eq22(competitors.projectId, project.id)).all().map((row) => row.domain);
|
|
20346
|
+
const competitiveSignalResolver = compileCompetitiveSignalResolver(competitorDomains);
|
|
20167
20347
|
const allSnapshots = app.db.select({
|
|
20168
20348
|
id: querySnapshots.id,
|
|
20169
20349
|
runId: querySnapshots.runId,
|
|
@@ -20183,6 +20363,7 @@ async function historyRoutes(app) {
|
|
|
20183
20363
|
competitorOverlap: querySnapshots.competitorOverlap,
|
|
20184
20364
|
recommendedCompetitors: querySnapshots.recommendedCompetitors,
|
|
20185
20365
|
location: querySnapshots.location,
|
|
20366
|
+
rawResponse: querySnapshots.rawResponse,
|
|
20186
20367
|
createdAt: querySnapshots.createdAt
|
|
20187
20368
|
}).from(querySnapshots).leftJoin(queries, eq22(querySnapshots.queryId, queries.id)).where(inArray8(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc7(querySnapshots.createdAt)).all();
|
|
20188
20369
|
const locationFilter = request.query.location;
|
|
@@ -20190,29 +20371,43 @@ async function historyRoutes(app) {
|
|
|
20190
20371
|
const total = filtered.length;
|
|
20191
20372
|
const paged = filtered.slice(offset, offset + limit);
|
|
20192
20373
|
return reply.send({
|
|
20193
|
-
snapshots: paged.map((s) =>
|
|
20194
|
-
|
|
20195
|
-
|
|
20196
|
-
|
|
20197
|
-
|
|
20198
|
-
|
|
20199
|
-
|
|
20200
|
-
|
|
20201
|
-
|
|
20202
|
-
|
|
20203
|
-
|
|
20204
|
-
|
|
20205
|
-
|
|
20206
|
-
|
|
20207
|
-
|
|
20208
|
-
|
|
20209
|
-
|
|
20210
|
-
|
|
20211
|
-
|
|
20212
|
-
|
|
20213
|
-
|
|
20214
|
-
|
|
20215
|
-
|
|
20374
|
+
snapshots: paged.map((s) => {
|
|
20375
|
+
const rawResponse = parseJsonColumn(s.rawResponse, {});
|
|
20376
|
+
const rawGroundingSources = Array.isArray(rawResponse.groundingSources) ? rawResponse.groundingSources : [];
|
|
20377
|
+
const groundingSources = rawGroundingSources.filter(
|
|
20378
|
+
(source) => typeof source === "object" && source !== null && typeof source.uri === "string"
|
|
20379
|
+
);
|
|
20380
|
+
const competitiveSignals = competitiveSignalResolver.resolve({
|
|
20381
|
+
citedDomains: s.citedDomains,
|
|
20382
|
+
groundingSources,
|
|
20383
|
+
answerText: s.answerText
|
|
20384
|
+
});
|
|
20385
|
+
return {
|
|
20386
|
+
id: s.id,
|
|
20387
|
+
runId: s.runId,
|
|
20388
|
+
queryId: s.queryId,
|
|
20389
|
+
query: s.query,
|
|
20390
|
+
provider: s.provider,
|
|
20391
|
+
model: s.model,
|
|
20392
|
+
citationState: s.citationState,
|
|
20393
|
+
answerMentioned: resolveSnapshotAnswerMentioned(s, project),
|
|
20394
|
+
visibilityState: resolveSnapshotVisibilityState(s, project),
|
|
20395
|
+
mentionState: resolveSnapshotMentionState(s, project),
|
|
20396
|
+
answerText: s.answerText,
|
|
20397
|
+
citedDomains: s.citedDomains,
|
|
20398
|
+
citedUrls: s.citedUrls,
|
|
20399
|
+
captureStatus: s.captureStatus,
|
|
20400
|
+
sourceCount: s.sourceCount,
|
|
20401
|
+
resolvedCount: s.resolvedCount,
|
|
20402
|
+
captureVersion: s.captureVersion,
|
|
20403
|
+
...competitiveSignals,
|
|
20404
|
+
// Legacy mixed signal retained for backwards compatibility.
|
|
20405
|
+
competitorOverlap: s.competitorOverlap,
|
|
20406
|
+
recommendedCompetitors: s.recommendedCompetitors,
|
|
20407
|
+
location: s.location,
|
|
20408
|
+
createdAt: s.createdAt
|
|
20409
|
+
};
|
|
20410
|
+
}),
|
|
20216
20411
|
total
|
|
20217
20412
|
});
|
|
20218
20413
|
});
|
|
@@ -20451,6 +20646,61 @@ function parseOptionalPositiveInt(value, max2) {
|
|
|
20451
20646
|
// ../api-routes/src/analytics.ts
|
|
20452
20647
|
import { and as and17, desc as desc8, eq as eq23, gte as gte4, inArray as inArray9, lt as lt4 } from "drizzle-orm";
|
|
20453
20648
|
|
|
20649
|
+
// ../api-routes/src/mention-share-inputs.ts
|
|
20650
|
+
function mentionShareCompetitorsFromDomains(domains) {
|
|
20651
|
+
return domains.map((domain) => {
|
|
20652
|
+
const exactDomain = hostOf(domain);
|
|
20653
|
+
const domainLabel = brandLabelFromDomain(domain);
|
|
20654
|
+
return {
|
|
20655
|
+
domain,
|
|
20656
|
+
// A short registrable label is too noisy by itself (`AI`), but the full
|
|
20657
|
+
// written domain is operator-approved identity (`ai.com`). Feeding both
|
|
20658
|
+
// through the shared matcher keeps every mention-share surface aligned.
|
|
20659
|
+
brandTokens: usableBrandAliases([
|
|
20660
|
+
...brandKeyFromText(domainLabel).length >= MIN_DOMAIN_BRAND_KEY_LENGTH ? [domainLabel] : [],
|
|
20661
|
+
...exactDomain?.includes(".") ? [exactDomain] : []
|
|
20662
|
+
])
|
|
20663
|
+
};
|
|
20664
|
+
});
|
|
20665
|
+
}
|
|
20666
|
+
function projectQueryClassifier(project) {
|
|
20667
|
+
const classifier = compileQueryClassifier(effectiveBrandNames({
|
|
20668
|
+
displayName: project.displayName ?? null,
|
|
20669
|
+
aliases: project.aliases ?? null,
|
|
20670
|
+
canonicalDomain: project.canonicalDomain ?? null,
|
|
20671
|
+
ownedDomains: project.ownedDomains ?? null
|
|
20672
|
+
}));
|
|
20673
|
+
return classifier ? (queryText) => classifier.classify(queryText) : null;
|
|
20674
|
+
}
|
|
20675
|
+
function buildMentionShareInputs(opts) {
|
|
20676
|
+
const classify = projectQueryClassifier(opts.project);
|
|
20677
|
+
const projectBrandNames = effectiveBrandNames({
|
|
20678
|
+
displayName: opts.project.displayName ?? null,
|
|
20679
|
+
aliases: opts.project.aliases ?? null,
|
|
20680
|
+
canonicalDomain: opts.project.canonicalDomain ?? null,
|
|
20681
|
+
ownedDomains: opts.project.ownedDomains ?? null
|
|
20682
|
+
});
|
|
20683
|
+
const projectDomains = effectiveDomains({
|
|
20684
|
+
canonicalDomain: opts.project.canonicalDomain ?? "",
|
|
20685
|
+
ownedDomains: opts.project.ownedDomains ?? []
|
|
20686
|
+
});
|
|
20687
|
+
return {
|
|
20688
|
+
classified: classify !== null,
|
|
20689
|
+
competitors: mentionShareCompetitorsFromDomains(opts.competitorDomains),
|
|
20690
|
+
snapshots: opts.snapshots.map((snap) => {
|
|
20691
|
+
const queryText = (snap.queryId ? opts.queryTextById?.get(snap.queryId) : void 0) ?? snap.queryText ?? null;
|
|
20692
|
+
return {
|
|
20693
|
+
// Mention share is a current-identity metric. Recompute stored answer
|
|
20694
|
+
// text after an alias/domain rename; only text-less legacy rows need
|
|
20695
|
+
// the persisted run-time boolean as a fallback.
|
|
20696
|
+
projectMentioned: snap.answerText ? determineAnswerMentioned(snap.answerText, projectBrandNames, projectDomains) : snap.answerMentioned === true,
|
|
20697
|
+
answerText: snap.answerText,
|
|
20698
|
+
queryClass: classify ? classify(queryText) : null
|
|
20699
|
+
};
|
|
20700
|
+
})
|
|
20701
|
+
};
|
|
20702
|
+
}
|
|
20703
|
+
|
|
20454
20704
|
// ../api-routes/src/model-evidence.ts
|
|
20455
20705
|
function classifyModelEvidence(values) {
|
|
20456
20706
|
const models = /* @__PURE__ */ new Set();
|
|
@@ -20579,6 +20829,8 @@ function measuredWhole(db, run) {
|
|
|
20579
20829
|
async function analyticsRoutes(app) {
|
|
20580
20830
|
app.get("/projects/:name/analytics/metrics", async (request, reply) => {
|
|
20581
20831
|
const project = resolveProject(app.db, request.params.name);
|
|
20832
|
+
const classifyQuery = projectQueryClassifier(project);
|
|
20833
|
+
const mentionShareScope = classifyQuery ? "non-brand" : "pooled";
|
|
20582
20834
|
const window = parseWindow(request.query.window);
|
|
20583
20835
|
const cutoff = windowCutoff(window);
|
|
20584
20836
|
const projectRuns = app.db.select().from(runs).where(and17(
|
|
@@ -20591,6 +20843,7 @@ async function analyticsRoutes(app) {
|
|
|
20591
20843
|
if (projectRuns.length === 0) {
|
|
20592
20844
|
return reply.send({
|
|
20593
20845
|
window,
|
|
20846
|
+
mentionShareScope,
|
|
20594
20847
|
buckets: [],
|
|
20595
20848
|
overall: { citationRate: 0, cited: 0, total: 0, mentionRate: 0, mentionedCount: 0 },
|
|
20596
20849
|
byProvider: {},
|
|
@@ -20637,16 +20890,16 @@ async function analyticsRoutes(app) {
|
|
|
20637
20890
|
...s,
|
|
20638
20891
|
runCreatedAt: runCreatedAt.get(s.runId),
|
|
20639
20892
|
resolvedMentioned: resolveSnapshotAnswerMentioned(s, project),
|
|
20893
|
+
queryClass: classifyQuery ? classifyQuery((s.queryId ? queryTextById.get(s.queryId) : void 0) ?? s.queryText ?? null) : null,
|
|
20640
20894
|
// Falls back to the id if no text resolves, so two distinct queries can
|
|
20641
20895
|
// never collapse into one member on an empty key. (An orphan only gets
|
|
20642
20896
|
// this far when its text matched the basket, so its key is never empty.)
|
|
20643
20897
|
basketKey: normalizeQueryText(s.queryText ?? (s.queryId ? queryTextById.get(s.queryId) ?? "" : "")) || s.queryId || "",
|
|
20644
20898
|
runBasketRevision: runBasketRevision.get(s.runId) ?? null
|
|
20645
20899
|
}));
|
|
20646
|
-
const mentionShareCompetitors =
|
|
20647
|
-
domain: c.domain
|
|
20648
|
-
|
|
20649
|
-
}));
|
|
20900
|
+
const mentionShareCompetitors = mentionShareCompetitorsFromDomains(
|
|
20901
|
+
app.db.select({ domain: competitors.domain }).from(competitors).where(eq23(competitors.projectId, project.id)).all().map((c) => c.domain)
|
|
20902
|
+
);
|
|
20650
20903
|
const overall = computeProviderMetric(allSnapshots);
|
|
20651
20904
|
const byProvider = {};
|
|
20652
20905
|
const providers = new Set(allSnapshots.map((s) => s.provider));
|
|
@@ -20657,7 +20910,15 @@ async function analyticsRoutes(app) {
|
|
|
20657
20910
|
const latest = new Date(projectRuns[projectRuns.length - 1].createdAt);
|
|
20658
20911
|
const spanDays = Math.max(1, Math.ceil((latest.getTime() - earliest.getTime()) / 864e5));
|
|
20659
20912
|
const bucketSize = bucketSizeForSpan(spanDays);
|
|
20660
|
-
const buckets = computeBuckets(
|
|
20913
|
+
const buckets = computeBuckets(
|
|
20914
|
+
allSnapshots,
|
|
20915
|
+
projectRuns,
|
|
20916
|
+
bucketSize,
|
|
20917
|
+
queryCreatedAt,
|
|
20918
|
+
mentionShareCompetitors,
|
|
20919
|
+
referenceBasket,
|
|
20920
|
+
classifyQuery !== null
|
|
20921
|
+
);
|
|
20661
20922
|
const anchors = {};
|
|
20662
20923
|
const anchorObservedAt = {};
|
|
20663
20924
|
const anchorUnavailable = /* @__PURE__ */ new Set();
|
|
@@ -20821,7 +21082,7 @@ async function analyticsRoutes(app) {
|
|
|
20821
21082
|
}
|
|
20822
21083
|
previousExecutionChecksum = identity.checksum;
|
|
20823
21084
|
}
|
|
20824
|
-
return reply.send({ window, buckets, overall, byProvider, trend, mentionTrend, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
|
|
21085
|
+
return reply.send({ window, mentionShareScope, buckets, overall, byProvider, trend, mentionTrend, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
|
|
20825
21086
|
});
|
|
20826
21087
|
app.get("/projects/:name/analytics/gaps", async (request, reply) => {
|
|
20827
21088
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -20836,6 +21097,10 @@ async function analyticsRoutes(app) {
|
|
|
20836
21097
|
}
|
|
20837
21098
|
const windowRuns = app.db.select().from(runs).where(and17(eq23(runs.projectId, project.id), eq23(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).orderBy(runs.createdAt).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => measuredWhole(app.db, r)).filter((r) => !cutoff || r.createdAt >= cutoff);
|
|
20838
21099
|
const windowRunIds = windowRuns.map((r) => r.id);
|
|
21100
|
+
const competitorDomains = app.db.select({ domain: competitors.domain }).from(competitors).where(eq23(competitors.projectId, project.id)).all().map((c) => c.domain);
|
|
21101
|
+
const competitorMatchers = new Map(
|
|
21102
|
+
mentionShareCompetitorsFromDomains(competitorDomains).map((c) => [c.domain, compileBrandAliases([...c.brandTokens])])
|
|
21103
|
+
);
|
|
20839
21104
|
const runIdToCreatedAt = new Map(windowRuns.map((r) => [r.id, r.createdAt]));
|
|
20840
21105
|
const consistencyMap = /* @__PURE__ */ new Map();
|
|
20841
21106
|
if (windowRunIds.length > 0) {
|
|
@@ -20865,7 +21130,7 @@ async function analyticsRoutes(app) {
|
|
|
20865
21130
|
citationState: querySnapshots.citationState,
|
|
20866
21131
|
answerMentioned: querySnapshots.answerMentioned,
|
|
20867
21132
|
answerText: querySnapshots.answerText,
|
|
20868
|
-
|
|
21133
|
+
citedDomains: querySnapshots.citedDomains
|
|
20869
21134
|
}).from(querySnapshots).leftJoin(queries, eq23(querySnapshots.queryId, queries.id)).where(inArray9(querySnapshots.runId, latestGroupRunIds)).all());
|
|
20870
21135
|
const snapshots = rawSnapshots.map((s) => ({
|
|
20871
21136
|
...s,
|
|
@@ -20889,9 +21154,17 @@ async function analyticsRoutes(app) {
|
|
|
20889
21154
|
const citedProviders = qSnapshots.filter((s) => s.citationState === CitationStates.cited).map((s) => s.provider);
|
|
20890
21155
|
const mentionedProviders = qSnapshots.filter((s) => s.resolvedMentioned).map((s) => s.provider);
|
|
20891
21156
|
const competitorsCiting = /* @__PURE__ */ new Set();
|
|
21157
|
+
const competitorsMentioned = /* @__PURE__ */ new Set();
|
|
20892
21158
|
for (const s of qSnapshots) {
|
|
20893
|
-
const
|
|
20894
|
-
|
|
21159
|
+
for (const domain of s.citedDomains) {
|
|
21160
|
+
const match = competitorDomains.find((c) => hostMatchesDomain(domain, c));
|
|
21161
|
+
if (match) competitorsCiting.add(match);
|
|
21162
|
+
}
|
|
21163
|
+
if (!s.answerText) continue;
|
|
21164
|
+
for (const competitor of competitorDomains) {
|
|
21165
|
+
const matcher = competitorMatchers.get(competitor);
|
|
21166
|
+
if (matcher && matcherMatchesText(matcher, s.answerText)) competitorsMentioned.add(competitor);
|
|
21167
|
+
}
|
|
20895
21168
|
}
|
|
20896
21169
|
const cons = consistencyMap.get(queryId);
|
|
20897
21170
|
const consistency = {
|
|
@@ -20913,6 +21186,7 @@ async function analyticsRoutes(app) {
|
|
|
20913
21186
|
category,
|
|
20914
21187
|
providers: citedProviders,
|
|
20915
21188
|
competitorsCiting: [...competitorsCiting],
|
|
21189
|
+
competitorsMentioned: [...competitorsMentioned],
|
|
20916
21190
|
consistency
|
|
20917
21191
|
};
|
|
20918
21192
|
if (category === "cited") cited.push(citationEntry);
|
|
@@ -20921,7 +21195,7 @@ async function analyticsRoutes(app) {
|
|
|
20921
21195
|
let mentionCategory;
|
|
20922
21196
|
if (mentionedProviders.length > 0) {
|
|
20923
21197
|
mentionCategory = "cited";
|
|
20924
|
-
} else if (
|
|
21198
|
+
} else if (competitorsMentioned.size > 0) {
|
|
20925
21199
|
mentionCategory = "gap";
|
|
20926
21200
|
} else {
|
|
20927
21201
|
mentionCategory = "uncited";
|
|
@@ -20932,6 +21206,7 @@ async function analyticsRoutes(app) {
|
|
|
20932
21206
|
category: mentionCategory,
|
|
20933
21207
|
providers: mentionedProviders,
|
|
20934
21208
|
competitorsCiting: [...competitorsCiting],
|
|
21209
|
+
competitorsMentioned: [...competitorsMentioned],
|
|
20935
21210
|
consistency
|
|
20936
21211
|
};
|
|
20937
21212
|
if (mentionCategory === "cited") mentionedQueries.push(mentionEntry);
|
|
@@ -20941,7 +21216,7 @@ async function analyticsRoutes(app) {
|
|
|
20941
21216
|
gap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
|
|
20942
21217
|
cited.sort((a, b) => a.query.localeCompare(b.query));
|
|
20943
21218
|
uncited.sort((a, b) => a.query.localeCompare(b.query));
|
|
20944
|
-
mentionGap.sort((a, b) => b.
|
|
21219
|
+
mentionGap.sort((a, b) => b.competitorsMentioned.length - a.competitorsMentioned.length);
|
|
20945
21220
|
mentionedQueries.sort((a, b) => a.query.localeCompare(b.query));
|
|
20946
21221
|
notMentioned.sort((a, b) => a.query.localeCompare(b.query));
|
|
20947
21222
|
return reply.send({ cited, gap, uncited, mentionedQueries, mentionGap, notMentioned, runId: latestRun.id, window });
|
|
@@ -21058,7 +21333,7 @@ function computeProviderMetric(snapshots) {
|
|
|
21058
21333
|
mentionedCount
|
|
21059
21334
|
};
|
|
21060
21335
|
}
|
|
21061
|
-
function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, mentionShareCompetitors = [], referenceBasket) {
|
|
21336
|
+
function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, mentionShareCompetitors = [], referenceBasket, classificationAvailable = false) {
|
|
21062
21337
|
if (projectRuns.length === 0) return [];
|
|
21063
21338
|
const earliest = new Date(projectRuns[0].createdAt);
|
|
21064
21339
|
const latest = new Date(projectRuns[projectRuns.length - 1].createdAt);
|
|
@@ -21107,7 +21382,7 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
|
|
|
21107
21382
|
queryCount,
|
|
21108
21383
|
mentionRate: metric2.mentionRate,
|
|
21109
21384
|
mentionedCount: metric2.mentionedCount,
|
|
21110
|
-
mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors),
|
|
21385
|
+
mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors, classificationAvailable),
|
|
21111
21386
|
byProvider,
|
|
21112
21387
|
modelEvidenceByProvider,
|
|
21113
21388
|
basketRevision
|
|
@@ -21126,22 +21401,23 @@ function bucketStartDateFor(observedAt, earliest, bucketDays) {
|
|
|
21126
21401
|
const offset = Math.max(0, Math.floor((observationDay.getTime() - firstBucketStart.getTime()) / bucketMilliseconds));
|
|
21127
21402
|
return new Date(firstBucketStart.getTime() + offset * bucketMilliseconds).toISOString();
|
|
21128
21403
|
}
|
|
21129
|
-
function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors) {
|
|
21130
|
-
if (mentionShareCompetitors.length === 0) {
|
|
21131
|
-
return { rate: null, projectMentionSnapshots: 0, competitorMentionSnapshots: 0 };
|
|
21132
|
-
}
|
|
21404
|
+
function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors, classificationAvailable) {
|
|
21133
21405
|
const result = buildMentionShare(
|
|
21134
21406
|
snapshots.map((s) => ({
|
|
21135
21407
|
projectMentioned: s.resolvedMentioned,
|
|
21136
|
-
answerText: s.answerText
|
|
21408
|
+
answerText: s.answerText,
|
|
21409
|
+
queryClass: s.queryClass
|
|
21137
21410
|
})),
|
|
21138
|
-
{ competitors: mentionShareCompetitors }
|
|
21411
|
+
{ competitors: mentionShareCompetitors, classificationAvailable }
|
|
21139
21412
|
);
|
|
21140
21413
|
const projectMentionSnapshots = result.breakdown.projectMentionSnapshots;
|
|
21141
21414
|
const competitorMentionSnapshots = result.breakdown.competitorMentionSnapshots;
|
|
21142
21415
|
const denominator = projectMentionSnapshots + competitorMentionSnapshots;
|
|
21143
21416
|
return {
|
|
21144
|
-
|
|
21417
|
+
scope: result.scope,
|
|
21418
|
+
// A project-only denominator is recognition evidence, not competitive
|
|
21419
|
+
// share. Preserve the count but leave the rate undefined without a frame.
|
|
21420
|
+
rate: mentionShareCompetitors.length > 0 && denominator > 0 ? round4(projectMentionSnapshots / denominator) : null,
|
|
21145
21421
|
projectMentionSnapshots,
|
|
21146
21422
|
competitorMentionSnapshots
|
|
21147
21423
|
};
|
|
@@ -21725,20 +22001,27 @@ function aggregateCandidate(opts) {
|
|
|
21725
22001
|
const ourCitedRate = citedCount / totalSnaps;
|
|
21726
22002
|
const recentMissRate = 1 - ourCitedRate;
|
|
21727
22003
|
const competitorTally = /* @__PURE__ */ new Map();
|
|
22004
|
+
const competitorMentionTally = /* @__PURE__ */ new Map();
|
|
22005
|
+
const competitiveSignalResolver = compileCompetitiveSignalResolver([...opts.competitorSet]);
|
|
21728
22006
|
const competitorGroundingTally = /* @__PURE__ */ new Map();
|
|
21729
22007
|
const ourGroundingTally = /* @__PURE__ */ new Map();
|
|
21730
22008
|
const citedSurfaceTally = /* @__PURE__ */ new Map();
|
|
21731
22009
|
let ourCitedInLatestRun = false;
|
|
21732
22010
|
for (const snap of opts.snapshots) {
|
|
21733
22011
|
const isLatestRun = snap.runId === opts.latestRunId;
|
|
21734
|
-
const competitorOverlap = snap.competitorOverlap;
|
|
21735
|
-
for (const domain of competitorOverlap) {
|
|
21736
|
-
const normalized = hostOf(domain) ?? "";
|
|
21737
|
-
const competitor = findMatchingDomain(normalized, opts.competitorSet);
|
|
21738
|
-
if (!competitor) continue;
|
|
21739
|
-
competitorTally.set(competitor, (competitorTally.get(competitor) ?? 0) + 1);
|
|
21740
|
-
}
|
|
21741
22012
|
const grounding = extractGroundingSources(snap.rawResponse);
|
|
22013
|
+
const competitiveSignals = competitiveSignalResolver.resolve({
|
|
22014
|
+
citedDomains: snap.citedDomains,
|
|
22015
|
+
groundingSources: grounding,
|
|
22016
|
+
answerText: snap.answerText
|
|
22017
|
+
});
|
|
22018
|
+
const citedCompetitorsHere = /* @__PURE__ */ new Set();
|
|
22019
|
+
for (const competitor of competitiveSignals.citedCompetitorDomains) {
|
|
22020
|
+
citedCompetitorsHere.add(competitor);
|
|
22021
|
+
}
|
|
22022
|
+
for (const competitor of competitiveSignals.mentionedCompetitorDomains) {
|
|
22023
|
+
competitorMentionTally.set(competitor, (competitorMentionTally.get(competitor) ?? 0) + 1);
|
|
22024
|
+
}
|
|
21742
22025
|
for (const g of grounding) {
|
|
21743
22026
|
const domain = hostOf(g.uri) ?? "";
|
|
21744
22027
|
if (!domain) continue;
|
|
@@ -21748,9 +22031,14 @@ function aggregateCandidate(opts) {
|
|
|
21748
22031
|
continue;
|
|
21749
22032
|
}
|
|
21750
22033
|
citedSurfaceTally.set(domain, (citedSurfaceTally.get(domain) ?? 0) + 1);
|
|
21751
|
-
|
|
22034
|
+
const competitor = findMatchingDomain(domain, opts.competitorSet);
|
|
22035
|
+
if (!competitor) continue;
|
|
22036
|
+
citedCompetitorsHere.add(competitor);
|
|
21752
22037
|
recordGroundingHit(competitorGroundingTally, g, domain, snap.provider);
|
|
21753
22038
|
}
|
|
22039
|
+
for (const competitor of citedCompetitorsHere) {
|
|
22040
|
+
competitorTally.set(competitor, (competitorTally.get(competitor) ?? 0) + 1);
|
|
22041
|
+
}
|
|
21754
22042
|
}
|
|
21755
22043
|
return {
|
|
21756
22044
|
query: opts.query,
|
|
@@ -21761,8 +22049,10 @@ function aggregateCandidate(opts) {
|
|
|
21761
22049
|
gscCtr: opts.gsc?.ctr ?? 0,
|
|
21762
22050
|
ourCitedRate,
|
|
21763
22051
|
ourCitedInLatestRun,
|
|
21764
|
-
|
|
22052
|
+
competitorCitedDomains: Array.from(competitorTally.keys()),
|
|
22053
|
+
competitorMentionedDomains: Array.from(competitorMentionTally.keys()),
|
|
21765
22054
|
competitorCitationCount: Array.from(competitorTally.values()).reduce((a, b) => a + b, 0),
|
|
22055
|
+
competitorMentionCount: Array.from(competitorMentionTally.values()).reduce((a, b) => a + b, 0),
|
|
21766
22056
|
recentMissRate,
|
|
21767
22057
|
ourGroundingUrls: Array.from(ourGroundingTally.values()),
|
|
21768
22058
|
competitorGroundingUrls: Array.from(competitorGroundingTally.values()),
|
|
@@ -21800,8 +22090,10 @@ function emptyCandidate(query) {
|
|
|
21800
22090
|
gscCtr: 0,
|
|
21801
22091
|
ourCitedRate: 0,
|
|
21802
22092
|
ourCitedInLatestRun: false,
|
|
21803
|
-
|
|
22093
|
+
competitorCitedDomains: [],
|
|
22094
|
+
competitorMentionedDomains: [],
|
|
21804
22095
|
competitorCitationCount: 0,
|
|
22096
|
+
competitorMentionCount: 0,
|
|
21805
22097
|
recentMissRate: 0,
|
|
21806
22098
|
ourGroundingUrls: [],
|
|
21807
22099
|
competitorGroundingUrls: [],
|
|
@@ -22773,6 +23065,15 @@ table.report-table td .badge {
|
|
|
22773
23065
|
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
22774
23066
|
gap: 16px;
|
|
22775
23067
|
}
|
|
23068
|
+
.mention-branded-block {
|
|
23069
|
+
margin-top: 20px;
|
|
23070
|
+
}
|
|
23071
|
+
.chart-note {
|
|
23072
|
+
font-size: 12px;
|
|
23073
|
+
line-height: 1.6;
|
|
23074
|
+
color: #4b5563;
|
|
23075
|
+
margin: 0 0 12px;
|
|
23076
|
+
}
|
|
22776
23077
|
.legend {
|
|
22777
23078
|
display: flex;
|
|
22778
23079
|
flex-wrap: wrap;
|
|
@@ -23578,18 +23879,24 @@ function renderCompetitorBars(landscape, canonical) {
|
|
|
23578
23879
|
];
|
|
23579
23880
|
return renderLandscapeBars(data, "Citations per domain", "Citations per domain bar chart");
|
|
23580
23881
|
}
|
|
23581
|
-
function renderMentionBars(
|
|
23882
|
+
function renderMentionBars(section2, canonical, title) {
|
|
23582
23883
|
const data = [
|
|
23583
|
-
{ label: canonical, count:
|
|
23584
|
-
...
|
|
23884
|
+
{ label: canonical, count: section2.projectMentionCount, isProject: true },
|
|
23885
|
+
...section2.competitors.map((c) => ({ label: c.domain, count: c.mentionCount, isProject: false }))
|
|
23585
23886
|
];
|
|
23586
|
-
return renderLandscapeBars(data,
|
|
23887
|
+
return renderLandscapeBars(data, title, `${title} bar chart`);
|
|
23587
23888
|
}
|
|
23889
|
+
var MENTION_SCOPE_LABEL = {
|
|
23890
|
+
"non-brand": "non-brand queries",
|
|
23891
|
+
pooled: "pooled queries \xB7 classification unavailable"
|
|
23892
|
+
};
|
|
23588
23893
|
function renderCompetitorLandscape(report) {
|
|
23589
23894
|
const competitors2 = report.competitorLandscape.competitors;
|
|
23590
23895
|
const mentionLandscape = report.mentionLandscape;
|
|
23591
23896
|
const noCitationData = competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0;
|
|
23592
|
-
const
|
|
23897
|
+
const brandedMentions = mentionLandscape.branded;
|
|
23898
|
+
const hasBrandedMentions = (brandedMentions?.totalAnswerSnapshots ?? 0) > 0;
|
|
23899
|
+
const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0 && !hasBrandedMentions;
|
|
23593
23900
|
if (noCitationData && noMentionData) {
|
|
23594
23901
|
return section(
|
|
23595
23902
|
{ id: "competitor-landscape", eyebrow: "Section 4", title: "Competitor Landscape" },
|
|
@@ -23614,13 +23921,26 @@ function renderCompetitorLandscape(report) {
|
|
|
23614
23921
|
<td>${escapeHtml(c.citedQueries.slice(0, 5).join(", "))}${c.citedQueries.length > 5 ? "\u2026" : ""}${pagesDisclosure}</td>
|
|
23615
23922
|
</tr>`;
|
|
23616
23923
|
}).join("");
|
|
23924
|
+
const scopeLabel2 = MENTION_SCOPE_LABEL[mentionLandscape.scope] ?? MENTION_SCOPE_LABEL.pooled;
|
|
23925
|
+
const mentionScopeTooltip = mentionLandscape.scope === "non-brand" ? `Mentions on ${scopeLabel2}. Branded queries are counted separately \u2014 the client is named on nearly all of them and a competitor cannot be, so pooling the two would rank the client on its own brand recall.` : `Mentions on ${scopeLabel2}. The project has no usable brand identity for a branded/non-brand split, so all tracked queries remain pooled and this is not a competitive category read.`;
|
|
23617
23926
|
const table = competitors2.length > 0 ? `<table class="report-table">
|
|
23618
|
-
<thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric">Mentions</th><th class="numeric" title="Citation share \u2014 % of cited-source slots that went to this competitor across tracked queries. Distinct from Mention Share.">Citation share</th><th>Cited queries</th></tr></thead>
|
|
23927
|
+
<thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric" title="${escapeHtml(mentionScopeTooltip)}">Mentions (${escapeHtml(scopeLabel2)})</th><th class="numeric" title="Citation share \u2014 % of cited-source slots that went to this competitor across tracked queries. Distinct from Mention Share.">Citation share</th><th>Cited queries</th></tr></thead>
|
|
23619
23928
|
<tbody>${rows}</tbody>
|
|
23620
23929
|
</table>` : renderEmpty("No competitors configured.");
|
|
23621
23930
|
const citationBars = renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain);
|
|
23622
|
-
const mentionBars = renderMentionBars(
|
|
23931
|
+
const mentionBars = renderMentionBars(
|
|
23932
|
+
mentionLandscape,
|
|
23933
|
+
report.meta.project.canonicalDomain,
|
|
23934
|
+
`Mentions per domain \xB7 ${scopeLabel2}`
|
|
23935
|
+
);
|
|
23623
23936
|
const charts = citationBars && mentionBars ? `<div class="chart-grid">${citationBars}${mentionBars}</div>` : `${citationBars}${mentionBars}`;
|
|
23937
|
+
const mentionShareUnavailable = mentionLandscape.projectMentionCount === 0 && mentionLandscape.competitors.length > 0 && mentionLandscape.competitors.every((row) => row.sharePct === null);
|
|
23938
|
+
const mentionShareNote = mentionShareUnavailable ? `<p class="chart-note">Mention share unavailable for ${escapeHtml(scopeLabel2)}: no tracked brand was named, so the denominator is 0.</p>` : "";
|
|
23939
|
+
const brandedBars = hasBrandedMentions && brandedMentions ? renderMentionBars(brandedMentions, report.meta.project.canonicalDomain, "Mentions per domain \xB7 branded queries") : "";
|
|
23940
|
+
const brandedBlock = brandedBars ? `<div class="mention-branded-block">
|
|
23941
|
+
<p class="chart-note">Branded queries contain the client's own name. The client is named on nearly all of them and a competitor structurally cannot be, so these are kept out of the competitive figure above. Read them as brand recall: ${brandedMentions?.projectMentionCount ?? 0} of ${brandedMentions?.totalAnswerSnapshots ?? 0} branded answers named the client.</p>
|
|
23942
|
+
${brandedBars}
|
|
23943
|
+
</div>` : "";
|
|
23624
23944
|
return section(
|
|
23625
23945
|
{
|
|
23626
23946
|
id: "competitor-landscape",
|
|
@@ -23628,7 +23948,7 @@ function renderCompetitorLandscape(report) {
|
|
|
23628
23948
|
title: "Competitor Landscape",
|
|
23629
23949
|
intro: "Who AI engines cite and mention instead of the client."
|
|
23630
23950
|
},
|
|
23631
|
-
`${charts}${table}`
|
|
23951
|
+
`${mentionShareNote}${charts}${table}${brandedBlock}`
|
|
23632
23952
|
);
|
|
23633
23953
|
}
|
|
23634
23954
|
var SOURCE_CATEGORY_TONE = {
|
|
@@ -24752,19 +25072,22 @@ function buildGaSection(db, projectId, windowDays2) {
|
|
|
24752
25072
|
)
|
|
24753
25073
|
).limit(1).get() : void 0;
|
|
24754
25074
|
const fallbackSummary = windowSummary ? null : db.select().from(gaTrafficSummaries).where(eq28(gaTrafficSummaries.projectId, projectId)).orderBy(desc12(gaTrafficSummaries.syncedAt)).limit(1).get();
|
|
25075
|
+
const matchingFallbackSummary = fallbackSummary && inclusiveDayCount(fallbackSummary.periodStart, fallbackSummary.periodEnd) === windowDays2 ? fallbackSummary : null;
|
|
24755
25076
|
const snapshotMaxDate = db.select({ m: sql9`MAX(${gaTrafficSnapshots.date})` }).from(gaTrafficSnapshots).where(eq28(gaTrafficSnapshots.projectId, projectId)).get()?.m ?? "";
|
|
24756
|
-
if (!windowSummary && !
|
|
24757
|
-
const
|
|
25077
|
+
if (!windowSummary && !matchingFallbackSummary && !snapshotMaxDate) return null;
|
|
25078
|
+
const summaryPeriod = windowSummary ?? matchingFallbackSummary;
|
|
25079
|
+
const snapshotMaxInWindow = summaryPeriod?.periodEnd || snapshotMaxDate;
|
|
25080
|
+
const snapshotStartDate = summaryPeriod?.periodStart || (snapshotMaxDate ? windowStartDate(snapshotMaxDate, windowDays2) : "");
|
|
24758
25081
|
const snapshotRows = snapshotStartDate ? db.select().from(gaTrafficSnapshots).where(
|
|
24759
25082
|
and22(
|
|
24760
25083
|
eq28(gaTrafficSnapshots.projectId, projectId),
|
|
24761
25084
|
gte5(gaTrafficSnapshots.date, snapshotStartDate),
|
|
24762
|
-
lte3(gaTrafficSnapshots.date,
|
|
25085
|
+
lte3(gaTrafficSnapshots.date, snapshotMaxInWindow)
|
|
24763
25086
|
)
|
|
24764
25087
|
).all() : db.select().from(gaTrafficSnapshots).where(eq28(gaTrafficSnapshots.projectId, projectId)).all();
|
|
24765
|
-
const totalSessions = windowSummary?.totalSessions ??
|
|
24766
|
-
const totalUsers = windowSummary?.totalUsers ??
|
|
24767
|
-
const totalOrganicSessions = windowSummary?.totalOrganicSessions ??
|
|
25088
|
+
const totalSessions = windowSummary?.totalSessions ?? matchingFallbackSummary?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
|
|
25089
|
+
const totalUsers = windowSummary?.totalUsers ?? matchingFallbackSummary?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
|
|
25090
|
+
const totalOrganicSessions = windowSummary?.totalOrganicSessions ?? matchingFallbackSummary?.totalOrganicSessions ?? snapshotRows.reduce((s, r) => s + r.organicSessions, 0);
|
|
24768
25091
|
const pageAgg = /* @__PURE__ */ new Map();
|
|
24769
25092
|
let directSessions = windowSummary?.totalDirectSessions ?? 0;
|
|
24770
25093
|
for (const r of snapshotRows) {
|
|
@@ -24786,9 +25109,9 @@ function buildGaSection(db, projectId, windowDays2) {
|
|
|
24786
25109
|
eq28(gaAiReferrals.projectId, projectId),
|
|
24787
25110
|
eq28(gaAiReferrals.sourceDimension, "session")
|
|
24788
25111
|
];
|
|
24789
|
-
if (snapshotStartDate &&
|
|
25112
|
+
if (snapshotStartDate && snapshotMaxInWindow) {
|
|
24790
25113
|
aiConditions.push(gte5(gaAiReferrals.date, snapshotStartDate));
|
|
24791
|
-
aiConditions.push(lte3(gaAiReferrals.date,
|
|
25114
|
+
aiConditions.push(lte3(gaAiReferrals.date, snapshotMaxInWindow));
|
|
24792
25115
|
}
|
|
24793
25116
|
const aiSessionRows = db.select({
|
|
24794
25117
|
trafficClass: gaAiReferrals.trafficClass,
|
|
@@ -24828,8 +25151,8 @@ function buildGaSection(db, projectId, windowDays2) {
|
|
|
24828
25151
|
}
|
|
24829
25152
|
}
|
|
24830
25153
|
}
|
|
24831
|
-
const periodStart =
|
|
24832
|
-
const periodEnd =
|
|
25154
|
+
const periodStart = snapshotStartDate;
|
|
25155
|
+
const periodEnd = snapshotMaxInWindow;
|
|
24833
25156
|
return {
|
|
24834
25157
|
totalSessions,
|
|
24835
25158
|
totalUsers,
|
|
@@ -25931,11 +26254,7 @@ function buildProjectReport(db, projectName, periodDays) {
|
|
|
25931
26254
|
const competitorDomains = competitorRows.map((c) => c.domain);
|
|
25932
26255
|
const ownedDomains = project.ownedDomains;
|
|
25933
26256
|
const projectDomains = [project.canonicalDomain, ...ownedDomains];
|
|
25934
|
-
const
|
|
25935
|
-
const projectBrandNames = effectiveBrandNames({
|
|
25936
|
-
displayName: project.displayName,
|
|
25937
|
-
aliases: projectAliases
|
|
25938
|
-
});
|
|
26257
|
+
const projectBrandNames = effectiveBrandNames(project);
|
|
25939
26258
|
const citationScorecard = buildCitationScorecard(latestSnapshots, queryLookup);
|
|
25940
26259
|
const competitorLandscape = buildCompetitorLandscape(
|
|
25941
26260
|
latestSnapshots,
|
|
@@ -27227,10 +27546,8 @@ function computeCitationVisibility(input) {
|
|
|
27227
27546
|
for (const snap of latestByPair.values()) {
|
|
27228
27547
|
if (citationStateToCited(snap.citationState)) continue;
|
|
27229
27548
|
if (competitorDomains.length === 0) continue;
|
|
27230
|
-
const cited = snap.citedDomains;
|
|
27231
|
-
const overlap = snap.competitorOverlap;
|
|
27232
27549
|
const candidates = new Set(
|
|
27233
|
-
|
|
27550
|
+
snap.citedDomains.map((d) => hostOf(d) ?? "").filter((d) => d.length > 0)
|
|
27234
27551
|
);
|
|
27235
27552
|
const citingCompetitors = competitorDomains.filter(
|
|
27236
27553
|
(competitor) => [...candidates].some((candidate) => hostMatchesDomain(candidate, competitor))
|
|
@@ -27319,6 +27636,7 @@ function observedPairs(snapshots, attribution, queryIds) {
|
|
|
27319
27636
|
function period(numerator, denominator) {
|
|
27320
27637
|
const ci = wilsonInterval(numerator, denominator);
|
|
27321
27638
|
return {
|
|
27639
|
+
availability: denominator > 0 ? "available" : "no-observations",
|
|
27322
27640
|
point: denominator > 0 ? Math.round(numerator / denominator * 1e4) / 1e4 : null,
|
|
27323
27641
|
ciLow: ci ? ci.low : null,
|
|
27324
27642
|
ciHigh: ci ? ci.high : null,
|
|
@@ -27326,17 +27644,27 @@ function period(numerator, denominator) {
|
|
|
27326
27644
|
denominator
|
|
27327
27645
|
};
|
|
27328
27646
|
}
|
|
27647
|
+
function unavailableCompetitivePeriod(numerator) {
|
|
27648
|
+
return {
|
|
27649
|
+
availability: "no-competitive-frame",
|
|
27650
|
+
point: null,
|
|
27651
|
+
ciLow: null,
|
|
27652
|
+
ciHigh: null,
|
|
27653
|
+
numerator,
|
|
27654
|
+
denominator: 0
|
|
27655
|
+
};
|
|
27656
|
+
}
|
|
27329
27657
|
function ciOverlap(a, b) {
|
|
27330
27658
|
if (a.ciLow === null || a.ciHigh === null || b.ciLow === null || b.ciHigh === null) return true;
|
|
27331
27659
|
return a.ciLow <= b.ciHigh && b.ciLow <= a.ciHigh;
|
|
27332
27660
|
}
|
|
27333
|
-
function metric(key, label, driftRobust, from, to, continuityBlock) {
|
|
27661
|
+
function metric(key, label, queryClass, driftRobust, from, to, continuityBlock) {
|
|
27334
27662
|
const verdict = continuityBlock ?? (from.denominator === 0 || to.denominator === 0 ? "insufficient-data" : ciOverlap(from, to) ? "within-noise" : "moved");
|
|
27335
27663
|
const direction = from.point === null || to.point === null ? null : to.point > from.point ? "up" : to.point < from.point ? "down" : "flat";
|
|
27336
27664
|
const rateRatio = from.point === null || from.point === 0 || to.point === null ? null : Math.round(to.point / from.point * 100) / 100;
|
|
27337
|
-
return { key, label, driftRobust, from, to, rateRatio, direction, verdict };
|
|
27665
|
+
return { key, label, queryClass, driftRobust, from, to, rateRatio, direction, verdict };
|
|
27338
27666
|
}
|
|
27339
|
-
function countPeriod(snaps, competitors2) {
|
|
27667
|
+
function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable, projectBrandNames) {
|
|
27340
27668
|
let checked = 0;
|
|
27341
27669
|
let mentioned = 0;
|
|
27342
27670
|
let cited = 0;
|
|
@@ -27370,8 +27698,14 @@ function countPeriod(snaps, competitors2) {
|
|
|
27370
27698
|
}
|
|
27371
27699
|
}
|
|
27372
27700
|
const mentionShare = buildMentionShare(
|
|
27373
|
-
snaps.map((s) => ({
|
|
27374
|
-
|
|
27701
|
+
snaps.map((s) => ({
|
|
27702
|
+
// Share uses current identity; named-rate counts above deliberately keep
|
|
27703
|
+
// their historical persisted-boolean semantics.
|
|
27704
|
+
projectMentioned: s.answerText ? determineAnswerMentioned(s.answerText, [...projectBrandNames], []) : s.answerMentioned === true,
|
|
27705
|
+
answerText: s.answerText,
|
|
27706
|
+
queryClass: queryClassOf(s)
|
|
27707
|
+
})),
|
|
27708
|
+
{ competitors: competitors2, classificationAvailable }
|
|
27375
27709
|
);
|
|
27376
27710
|
return {
|
|
27377
27711
|
checked,
|
|
@@ -27397,6 +27731,10 @@ function isUnknownModelEvidence(evidence) {
|
|
|
27397
27731
|
}
|
|
27398
27732
|
function computeVisibilityCompare(input) {
|
|
27399
27733
|
const attribution = buildQueryAttribution(input.queries);
|
|
27734
|
+
const classifier = compileQueryClassifier(input.brandNames ?? []);
|
|
27735
|
+
const classificationAvailable = classifier !== null;
|
|
27736
|
+
const queryTextById = new Map(input.queries.map((q) => [q.id, q.query]));
|
|
27737
|
+
const queryClassOf = (snap) => classifier ? classifier.classify(queryTextById.get(snap.queryId) ?? snap.queryText) : null;
|
|
27400
27738
|
const fromObs = observed(input.from.snapshots, attribution);
|
|
27401
27739
|
const toObs = observed(input.to.snapshots, attribution);
|
|
27402
27740
|
const queriesObservedBoth = new Set([...fromObs.queryIds].filter((q) => toObs.queryIds.has(q)));
|
|
@@ -27406,8 +27744,8 @@ function computeVisibilityCompare(input) {
|
|
|
27406
27744
|
const candidateProviders = new Set([...pairsBoth.values()].map((pair) => pair.provider));
|
|
27407
27745
|
const fromCandidateSnaps = restrict(input.from.snapshots, attribution, pairsBoth);
|
|
27408
27746
|
const toCandidateSnaps = restrict(input.to.snapshots, attribution, pairsBoth);
|
|
27409
|
-
const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors);
|
|
27410
|
-
const toCandidateCounts = countPeriod(toCandidateSnaps, input.competitors);
|
|
27747
|
+
const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
|
|
27748
|
+
const toCandidateCounts = countPeriod(toCandidateSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
|
|
27411
27749
|
const continuityProviders = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
|
|
27412
27750
|
const fromEvidence = classifyModelEvidence(fromCandidateCounts.modelEvidence.get(provider) ?? []);
|
|
27413
27751
|
const toEvidence = classifyModelEvidence(toCandidateCounts.modelEvidence.get(provider) ?? []);
|
|
@@ -27426,8 +27764,8 @@ function computeVisibilityCompare(input) {
|
|
|
27426
27764
|
const excludedProviders = [.../* @__PURE__ */ new Set([...fromObs.providers, ...toObs.providers])].filter((p) => !providersBoth.has(p)).sort((a, b) => a.localeCompare(b));
|
|
27427
27765
|
const fromSnaps = restrict(input.from.snapshots, attribution, comparedPairs);
|
|
27428
27766
|
const toSnaps = restrict(input.to.snapshots, attribution, comparedPairs);
|
|
27429
|
-
const fromCounts = countPeriod(fromSnaps, input.competitors);
|
|
27430
|
-
const toCounts = countPeriod(toSnaps, input.competitors);
|
|
27767
|
+
const fromCounts = countPeriod(fromSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
|
|
27768
|
+
const toCounts = countPeriod(toSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
|
|
27431
27769
|
const shareCounts = (c) => ({
|
|
27432
27770
|
proj: c.mentionShare.breakdown.projectMentionSnapshots,
|
|
27433
27771
|
comp: c.mentionShare.breakdown.competitorMentionSnapshots
|
|
@@ -27438,9 +27776,10 @@ function computeVisibilityCompare(input) {
|
|
|
27438
27776
|
metric(
|
|
27439
27777
|
"mention-share-of-voice",
|
|
27440
27778
|
"Named share of voice",
|
|
27779
|
+
classificationAvailable ? "non-brand" : "pooled",
|
|
27441
27780
|
true,
|
|
27442
|
-
period(fromShare.proj, fromShare.proj + fromShare.comp),
|
|
27443
|
-
period(toShare.proj, toShare.proj + toShare.comp),
|
|
27781
|
+
input.competitors.length > 0 ? period(fromShare.proj, fromShare.proj + fromShare.comp) : unavailableCompetitivePeriod(fromShare.proj),
|
|
27782
|
+
input.competitors.length > 0 ? period(toShare.proj, toShare.proj + toShare.comp) : unavailableCompetitivePeriod(toShare.proj),
|
|
27444
27783
|
continuityBlock
|
|
27445
27784
|
),
|
|
27446
27785
|
// Share of voice is undefined without a competitive frame: with zero
|
|
@@ -27451,14 +27790,16 @@ function computeVisibilityCompare(input) {
|
|
|
27451
27790
|
metric(
|
|
27452
27791
|
"cited-share-of-voice",
|
|
27453
27792
|
"Cited share of voice",
|
|
27793
|
+
"all",
|
|
27454
27794
|
true,
|
|
27455
|
-
input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) :
|
|
27456
|
-
input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) :
|
|
27795
|
+
input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : unavailableCompetitivePeriod(fromCounts.projectCited),
|
|
27796
|
+
input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : unavailableCompetitivePeriod(toCounts.projectCited),
|
|
27457
27797
|
continuityBlock
|
|
27458
27798
|
),
|
|
27459
27799
|
metric(
|
|
27460
27800
|
"mention-rate",
|
|
27461
27801
|
"Named rate",
|
|
27802
|
+
"all",
|
|
27462
27803
|
false,
|
|
27463
27804
|
period(fromCounts.mentioned, fromCounts.checked),
|
|
27464
27805
|
period(toCounts.mentioned, toCounts.checked),
|
|
@@ -27467,6 +27808,7 @@ function computeVisibilityCompare(input) {
|
|
|
27467
27808
|
metric(
|
|
27468
27809
|
"cited-rate",
|
|
27469
27810
|
"Cited rate",
|
|
27811
|
+
"all",
|
|
27470
27812
|
false,
|
|
27471
27813
|
period(fromCounts.cited, fromCounts.total),
|
|
27472
27814
|
period(toCounts.cited, toCounts.total),
|
|
@@ -27624,7 +27966,8 @@ async function visibilityStatsRoutes(app) {
|
|
|
27624
27966
|
lastRuns: lastRunsRaw,
|
|
27625
27967
|
groupBy: groupByRaw,
|
|
27626
27968
|
month: monthRaw,
|
|
27627
|
-
shareOfVoice: shareOfVoiceRaw
|
|
27969
|
+
shareOfVoice: shareOfVoiceRaw,
|
|
27970
|
+
queryClass: queryClassRaw
|
|
27628
27971
|
} = request.query;
|
|
27629
27972
|
let groupBy = null;
|
|
27630
27973
|
if (groupByRaw !== void 0 && groupByRaw !== "") {
|
|
@@ -27636,6 +27979,9 @@ async function visibilityStatsRoutes(app) {
|
|
|
27636
27979
|
const hasLastRuns = lastRunsRaw !== void 0 && lastRunsRaw !== "";
|
|
27637
27980
|
const hasMonth = monthRaw !== void 0 && monthRaw !== "";
|
|
27638
27981
|
const wantShareOfVoice = shareOfVoiceRaw === "1" || shareOfVoiceRaw === "true";
|
|
27982
|
+
const requestedQueryClass = queryClassRaw === void 0 || queryClassRaw === "" ? "non-brand" : queryClassRaw === "branded" || queryClassRaw === "non-brand" ? queryClassRaw : (() => {
|
|
27983
|
+
throw validationError('"queryClass" must be "branded" or "non-brand"');
|
|
27984
|
+
})();
|
|
27639
27985
|
if (hasLastRuns && (hasSince || hasUntil)) {
|
|
27640
27986
|
throw validationError('"lastRuns" cannot be combined with "since"/"until" \u2014 use one or the other');
|
|
27641
27987
|
}
|
|
@@ -27695,10 +28041,6 @@ async function visibilityStatsRoutes(app) {
|
|
|
27695
28041
|
let shareOfVoice;
|
|
27696
28042
|
if (wantShareOfVoice) {
|
|
27697
28043
|
const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, project.id)).all();
|
|
27698
|
-
const mentionShareCompetitors = competitorRows.map((c) => ({
|
|
27699
|
-
domain: c.domain,
|
|
27700
|
-
brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
|
|
27701
|
-
}));
|
|
27702
28044
|
const sovSnapshots = runIds.length > 0 ? app.db.select({
|
|
27703
28045
|
queryId: querySnapshots.queryId,
|
|
27704
28046
|
queryText: querySnapshots.queryText,
|
|
@@ -27706,16 +28048,29 @@ async function visibilityStatsRoutes(app) {
|
|
|
27706
28048
|
answerText: querySnapshots.answerText
|
|
27707
28049
|
}).from(querySnapshots).where(inArray14(querySnapshots.runId, runIds)).all() : [];
|
|
27708
28050
|
const attributedSovSnapshots = sovSnapshots.filter((s) => resolveCurrentQuery(queryAttribution, s) !== void 0);
|
|
27709
|
-
const
|
|
27710
|
-
|
|
27711
|
-
|
|
27712
|
-
|
|
27713
|
-
|
|
28051
|
+
const inputs = buildMentionShareInputs({
|
|
28052
|
+
project,
|
|
28053
|
+
competitorDomains: competitorRows.map((c) => c.domain),
|
|
28054
|
+
snapshots: attributedSovSnapshots,
|
|
28055
|
+
// Attribution prefers the current query row by id; classification must
|
|
28056
|
+
// use that same text or a rename can put the stats row and its SoV row
|
|
28057
|
+
// in different query classes.
|
|
28058
|
+
queryTextById: new Map(projectQueries.map((q) => [q.id, q.query]))
|
|
28059
|
+
});
|
|
28060
|
+
const result = buildMentionShare(inputs.snapshots, {
|
|
28061
|
+
competitors: inputs.competitors,
|
|
28062
|
+
classificationAvailable: inputs.classified
|
|
28063
|
+
});
|
|
28064
|
+
const servedQueryClass = result.scope === "pooled" ? "pooled" : requestedQueryClass;
|
|
28065
|
+
const b = servedQueryClass === "branded" ? result.branded : result.breakdown;
|
|
27714
28066
|
const denom = b.projectMentionSnapshots + b.competitorMentionSnapshots;
|
|
27715
28067
|
shareOfVoice = {
|
|
27716
|
-
|
|
27717
|
-
//
|
|
27718
|
-
|
|
28068
|
+
queryClass: servedQueryClass,
|
|
28069
|
+
// `null` (not 0) when there is no competitive frame or nothing in the
|
|
28070
|
+
// requested class was named. Both are undefined 0/0 proportions, not a
|
|
28071
|
+
// competitive loss.
|
|
28072
|
+
percent: competitorRows.length === 0 || denom === 0 ? null : Math.round(b.projectMentionSnapshots / denom * 100),
|
|
28073
|
+
competitorCount: competitorRows.length,
|
|
27719
28074
|
projectMentions: b.projectMentionSnapshots,
|
|
27720
28075
|
competitorMentions: b.competitorMentionSnapshots,
|
|
27721
28076
|
snapshotsWithAnswerText: b.snapshotsWithAnswerText,
|
|
@@ -27765,10 +28120,7 @@ async function visibilityStatsRoutes(app) {
|
|
|
27765
28120
|
}
|
|
27766
28121
|
const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq32(queries.projectId, project.id)).all();
|
|
27767
28122
|
const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, project.id)).all();
|
|
27768
|
-
const competitorInputs = competitorRows.map((c) =>
|
|
27769
|
-
domain: c.domain,
|
|
27770
|
-
brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
|
|
27771
|
-
}));
|
|
28123
|
+
const competitorInputs = mentionShareCompetitorsFromDomains(competitorRows.map((c) => c.domain));
|
|
27772
28124
|
const loadMonth = (bounds) => {
|
|
27773
28125
|
const sinceMs = Date.parse(bounds.since);
|
|
27774
28126
|
const untilMs = Date.parse(bounds.until);
|
|
@@ -27794,6 +28146,7 @@ async function visibilityStatsRoutes(app) {
|
|
|
27794
28146
|
project: project.name,
|
|
27795
28147
|
queries: projectQueries,
|
|
27796
28148
|
competitors: competitorInputs,
|
|
28149
|
+
brandNames: effectiveBrandNames(project),
|
|
27797
28150
|
from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
|
|
27798
28151
|
to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
|
|
27799
28152
|
});
|
|
@@ -28107,25 +28460,35 @@ async function compositeRoutes(app) {
|
|
|
28107
28460
|
}
|
|
28108
28461
|
}
|
|
28109
28462
|
const configuredApiProviders = project.providers.filter((p) => !p.startsWith("cdp:"));
|
|
28110
|
-
const
|
|
28111
|
-
|
|
28112
|
-
|
|
28113
|
-
|
|
28114
|
-
|
|
28115
|
-
|
|
28463
|
+
const mentionShareInputs = buildMentionShareInputs({
|
|
28464
|
+
project,
|
|
28465
|
+
competitorDomains: competitorRows.map((c) => c.domain),
|
|
28466
|
+
snapshots: trackedLatest,
|
|
28467
|
+
queryTextById: queryLookup.byId
|
|
28468
|
+
});
|
|
28469
|
+
const competitiveSignalResolver = compileCompetitiveSignalResolver(
|
|
28470
|
+
competitorRows.map((c) => c.domain)
|
|
28471
|
+
);
|
|
28472
|
+
const gapSignalSnapshots = trackedLatest.map((snapshot) => ({
|
|
28473
|
+
...snapshot,
|
|
28474
|
+
...competitiveSignalResolver.resolve({
|
|
28475
|
+
citedDomains: snapshot.citedDomains,
|
|
28476
|
+
groundingSources: snapshot.groundingSources,
|
|
28477
|
+
answerText: snapshot.answerText
|
|
28478
|
+
})
|
|
28116
28479
|
}));
|
|
28117
28480
|
const scores = {
|
|
28118
28481
|
mention: buildMentionCoverage(trackedLatest, { configuredApiProviders }),
|
|
28119
28482
|
visibility: buildVisibilityScore(trackedLatest, { configuredApiProviders }),
|
|
28120
28483
|
mentionShare: buildMentionShare(
|
|
28121
|
-
|
|
28122
|
-
|
|
28123
|
-
|
|
28124
|
-
|
|
28125
|
-
|
|
28484
|
+
mentionShareInputs.snapshots,
|
|
28485
|
+
{
|
|
28486
|
+
competitors: mentionShareInputs.competitors,
|
|
28487
|
+
classificationAvailable: mentionShareInputs.classified
|
|
28488
|
+
}
|
|
28126
28489
|
),
|
|
28127
|
-
gapQueries: buildGapQueryScore(
|
|
28128
|
-
mentionGaps: buildMentionGapScore(
|
|
28490
|
+
gapQueries: buildGapQueryScore(gapSignalSnapshots),
|
|
28491
|
+
mentionGaps: buildMentionGapScore(gapSignalSnapshots),
|
|
28129
28492
|
indexCoverage: buildIndexCoverageScore(app, project.id),
|
|
28130
28493
|
competitorPressure: buildCompetitorPressureScore(
|
|
28131
28494
|
trackedLatest,
|
|
@@ -28307,6 +28670,7 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
|
|
|
28307
28670
|
citationState: querySnapshots.citationState,
|
|
28308
28671
|
answerMentioned: querySnapshots.answerMentioned,
|
|
28309
28672
|
answerText: querySnapshots.answerText,
|
|
28673
|
+
rawResponse: querySnapshots.rawResponse,
|
|
28310
28674
|
competitorOverlap: querySnapshots.competitorOverlap,
|
|
28311
28675
|
citedDomains: querySnapshots.citedDomains
|
|
28312
28676
|
}).from(querySnapshots).where(inArray15(querySnapshots.runId, [...runIds])).all();
|
|
@@ -28338,6 +28702,7 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
|
|
|
28338
28702
|
citationState: row.citationState,
|
|
28339
28703
|
answerMentioned: row.answerMentioned,
|
|
28340
28704
|
answerText: row.answerText,
|
|
28705
|
+
groundingSources: parseOverviewGroundingSources(row.rawResponse),
|
|
28341
28706
|
competitorOverlap: row.competitorOverlap,
|
|
28342
28707
|
citedDomains: row.citedDomains
|
|
28343
28708
|
});
|
|
@@ -28345,6 +28710,16 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
|
|
|
28345
28710
|
}
|
|
28346
28711
|
return result;
|
|
28347
28712
|
}
|
|
28713
|
+
function parseOverviewGroundingSources(rawResponse) {
|
|
28714
|
+
const parsed = parseJsonColumn(rawResponse, {});
|
|
28715
|
+
const sources = parsed.groundingSources;
|
|
28716
|
+
if (!Array.isArray(sources)) return [];
|
|
28717
|
+
return sources.flatMap((source) => {
|
|
28718
|
+
if (!source || typeof source !== "object") return [];
|
|
28719
|
+
const uri = source.uri;
|
|
28720
|
+
return typeof uri === "string" ? [{ uri }] : [];
|
|
28721
|
+
});
|
|
28722
|
+
}
|
|
28348
28723
|
function summarizeFromSnapshots(snapshots) {
|
|
28349
28724
|
const empty = {
|
|
28350
28725
|
queryCounts: {
|
|
@@ -29431,9 +29806,15 @@ var compareToQueryParameter = {
|
|
|
29431
29806
|
var shareOfVoiceQueryParameter = {
|
|
29432
29807
|
name: "shareOfVoice",
|
|
29433
29808
|
in: "query",
|
|
29434
|
-
description: 'Set to "1" to include
|
|
29809
|
+
description: 'Set to "1" to include share of voice (project vs tracked-competitor brand mentions in answer text) across the window. Scoped by `queryClass`, which defaults to non-brand.',
|
|
29435
29810
|
schema: { type: "string", enum: ["1"] }
|
|
29436
29811
|
};
|
|
29812
|
+
var shareOfVoiceQueryClassParameter = {
|
|
29813
|
+
name: "queryClass",
|
|
29814
|
+
in: "query",
|
|
29815
|
+
description: "Which query class `shareOfVoice` covers. Defaults to `non-brand`: a branded query names the project, so it is mentioned on nearly all of them and a competitor structurally cannot be, and one shared denominator would report brand recall as category placement. Never pooled across classes.",
|
|
29816
|
+
schema: { type: "string", enum: ["branded", "non-brand"], default: "non-brand" }
|
|
29817
|
+
};
|
|
29437
29818
|
var wordpressEnvQueryParameter = {
|
|
29438
29819
|
name: "env",
|
|
29439
29820
|
in: "query",
|
|
@@ -31176,7 +31557,7 @@ var routeCatalog = [
|
|
|
31176
31557
|
method: "get",
|
|
31177
31558
|
path: "/api/v1/projects/{name}/visibility-stats",
|
|
31178
31559
|
summary: "Get aggregated mention/citation stats per query",
|
|
31179
|
-
description: "Per-query mention (answer-text) and citation (source-list) counts with a sample size, pooled across many answer-visibility runs (probe-excluded). Tri-state aware: `checked` counts only snapshots where answerMentioned was recorded (null = not checked is excluded). Lets a consumer compute confidence-aware (e.g. Wilson) proportions without N+1 run fetches. With no since/until/lastRuns, EVERY completed/partial answer-visibility run is pooled \u2014 `window.runCount` reports how many; bound the window with lastRuns, since/until, or month=YYYY-MM for a recent sample. Set groupBy=provider for a per-provider breakdown whose counts sum to the pooled counts, or shareOfVoice=1 to add
|
|
31560
|
+
description: "Per-query mention (answer-text) and citation (source-list) counts with a sample size, pooled across many answer-visibility runs (probe-excluded). Tri-state aware: `checked` counts only snapshots where answerMentioned was recorded (null = not checked is excluded). Lets a consumer compute confidence-aware (e.g. Wilson) proportions without N+1 run fetches. With no since/until/lastRuns, EVERY completed/partial answer-visibility run is pooled \u2014 `window.runCount` reports how many; bound the window with lastRuns, since/until, or month=YYYY-MM for a recent sample. Set groupBy=provider for a per-provider breakdown whose counts sum to the pooled counts, or shareOfVoice=1 to add share of voice vs tracked competitors (non-brand queries by default; set queryClass=branded for brand recall).",
|
|
31180
31561
|
tags: ["analytics"],
|
|
31181
31562
|
parameters: [
|
|
31182
31563
|
nameParameter,
|
|
@@ -31185,7 +31566,8 @@ var routeCatalog = [
|
|
|
31185
31566
|
lastRunsQueryParameter,
|
|
31186
31567
|
groupByProviderQueryParameter,
|
|
31187
31568
|
monthQueryParameter,
|
|
31188
|
-
shareOfVoiceQueryParameter
|
|
31569
|
+
shareOfVoiceQueryParameter,
|
|
31570
|
+
shareOfVoiceQueryClassParameter
|
|
31189
31571
|
],
|
|
31190
31572
|
responses: {
|
|
31191
31573
|
200: jsonResponse("Aggregated visibility stats returned.", "VisibilityStatsDto"),
|
|
@@ -48195,11 +48577,9 @@ async function ga4Routes(app, opts) {
|
|
|
48195
48577
|
requireGa4Connection(opts, project.name, project.canonicalDomain);
|
|
48196
48578
|
const limit = Math.max(1, Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 500));
|
|
48197
48579
|
const range = resolveDateRange(request.query);
|
|
48198
|
-
const dateFiltered = range.startDate !== null || range.endDate !== null;
|
|
48199
|
-
const snapshotConditions = [eq45(gaTrafficSnapshots.projectId, project.id), ...dateRangeConditions(gaTrafficSnapshots.date, range)];
|
|
48200
|
-
const aiConditions = [eq45(gaAiReferrals.projectId, project.id), ...dateRangeConditions(gaAiReferrals.date, range)];
|
|
48201
|
-
const socialConditions = [eq45(gaSocialReferrals.projectId, project.id), ...dateRangeConditions(gaSocialReferrals.date, range)];
|
|
48202
48580
|
const windowSummaryRow = range.startDate && !range.explicitDates ? app.db.select({
|
|
48581
|
+
periodStart: gaTrafficWindowSummaries.periodStart,
|
|
48582
|
+
periodEnd: gaTrafficWindowSummaries.periodEnd,
|
|
48203
48583
|
totalSessions: gaTrafficWindowSummaries.totalSessions,
|
|
48204
48584
|
totalOrganicSessions: gaTrafficWindowSummaries.totalOrganicSessions,
|
|
48205
48585
|
totalDirectSessions: gaTrafficWindowSummaries.totalDirectSessions,
|
|
@@ -48210,7 +48590,29 @@ async function ga4Routes(app, opts) {
|
|
|
48210
48590
|
eq45(gaTrafficWindowSummaries.windowKey, range.window)
|
|
48211
48591
|
)
|
|
48212
48592
|
).get() : null;
|
|
48213
|
-
const
|
|
48593
|
+
const projectSummaryRow = app.db.select({
|
|
48594
|
+
periodStart: gaTrafficSummaries.periodStart,
|
|
48595
|
+
periodEnd: gaTrafficSummaries.periodEnd,
|
|
48596
|
+
totalSessions: gaTrafficSummaries.totalSessions,
|
|
48597
|
+
totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
|
|
48598
|
+
totalUsers: gaTrafficSummaries.totalUsers
|
|
48599
|
+
}).from(gaTrafficSummaries).where(eq45(gaTrafficSummaries.projectId, project.id)).get();
|
|
48600
|
+
const retainedBounds = [
|
|
48601
|
+
app.db.select({ min: sql17`MIN(${gaTrafficSnapshots.date})`, max: sql17`MAX(${gaTrafficSnapshots.date})` }).from(gaTrafficSnapshots).where(eq45(gaTrafficSnapshots.projectId, project.id)).get(),
|
|
48602
|
+
app.db.select({ min: sql17`MIN(${gaAiReferrals.date})`, max: sql17`MAX(${gaAiReferrals.date})` }).from(gaAiReferrals).where(eq45(gaAiReferrals.projectId, project.id)).get(),
|
|
48603
|
+
app.db.select({ min: sql17`MIN(${gaSocialReferrals.date})`, max: sql17`MAX(${gaSocialReferrals.date})` }).from(gaSocialReferrals).where(eq45(gaSocialReferrals.projectId, project.id)).get()
|
|
48604
|
+
];
|
|
48605
|
+
const retainedDates = retainedBounds.flatMap((bound) => [bound?.min, bound?.max]).filter((date) => Boolean(date));
|
|
48606
|
+
const projectSummaryCoversAll = Boolean(
|
|
48607
|
+
projectSummaryRow?.periodStart && projectSummaryRow.periodEnd && retainedDates.every((date) => date >= projectSummaryRow.periodStart && date <= projectSummaryRow.periodEnd)
|
|
48608
|
+
);
|
|
48609
|
+
const denominatorRow = windowSummaryRow ?? (range.window === "all" && projectSummaryCoversAll ? projectSummaryRow : null);
|
|
48610
|
+
const denominatorPeriod = denominatorRow?.periodStart && denominatorRow.periodEnd ? { startDate: denominatorRow.periodStart, endDate: denominatorRow.periodEnd } : null;
|
|
48611
|
+
const measuredRange = denominatorPeriod ? { ...range, ...denominatorPeriod } : range;
|
|
48612
|
+
const snapshotConditions = [eq45(gaTrafficSnapshots.projectId, project.id), ...dateRangeConditions(gaTrafficSnapshots.date, measuredRange)];
|
|
48613
|
+
const aiConditions = [eq45(gaAiReferrals.projectId, project.id), ...dateRangeConditions(gaAiReferrals.date, measuredRange)];
|
|
48614
|
+
const socialConditions = [eq45(gaSocialReferrals.projectId, project.id), ...dateRangeConditions(gaSocialReferrals.date, measuredRange)];
|
|
48615
|
+
const snapshotTotalsRow = !denominatorRow ? (() => {
|
|
48214
48616
|
const summed = app.db.select({
|
|
48215
48617
|
totalSessions: sql17`COALESCE(SUM(${gaTrafficSnapshots.sessions}), 0)`,
|
|
48216
48618
|
totalOrganicSessions: sql17`COALESCE(SUM(${gaTrafficSnapshots.organicSessions}), 0)`,
|
|
@@ -48219,25 +48621,16 @@ async function ga4Routes(app, opts) {
|
|
|
48219
48621
|
return {
|
|
48220
48622
|
totalSessions: summed?.totalSessions ?? 0,
|
|
48221
48623
|
totalOrganicSessions: summed?.totalOrganicSessions ?? 0,
|
|
48222
|
-
// Unavailable for an
|
|
48223
|
-
// fallback keeps its historical summed value
|
|
48224
|
-
//
|
|
48225
|
-
|
|
48226
|
-
totalUsers: range.explicitDates ? null : summed?.totalUsers ?? 0
|
|
48624
|
+
// Unavailable for an explicit or all-history range. The rolling-
|
|
48625
|
+
// window fallback keeps its historical summed value for projects
|
|
48626
|
+
// that have not yet backfilled window summaries.
|
|
48627
|
+
totalUsers: range.explicitDates || range.window === "all" ? null : summed?.totalUsers ?? 0
|
|
48227
48628
|
};
|
|
48228
48629
|
})() : null;
|
|
48229
|
-
const summaryRow =
|
|
48230
|
-
totalSessions: gaTrafficSummaries.totalSessions,
|
|
48231
|
-
totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
|
|
48232
|
-
totalUsers: gaTrafficSummaries.totalUsers
|
|
48233
|
-
}).from(gaTrafficSummaries).where(eq45(gaTrafficSummaries.projectId, project.id)).get();
|
|
48630
|
+
const summaryRow = denominatorRow ?? snapshotTotalsRow;
|
|
48234
48631
|
const directTotalRow = windowSummaryRow ? { totalDirectSessions: windowSummaryRow.totalDirectSessions } : app.db.select({
|
|
48235
48632
|
totalDirectSessions: sql17`COALESCE(SUM(${gaTrafficSnapshots.directSessions}), 0)`
|
|
48236
48633
|
}).from(gaTrafficSnapshots).where(and36(...snapshotConditions)).get();
|
|
48237
|
-
const summaryMeta = app.db.select({
|
|
48238
|
-
periodStart: gaTrafficSummaries.periodStart,
|
|
48239
|
-
periodEnd: gaTrafficSummaries.periodEnd
|
|
48240
|
-
}).from(gaTrafficSummaries).where(eq45(gaTrafficSummaries.projectId, project.id)).get();
|
|
48241
48634
|
const rows = app.db.select({
|
|
48242
48635
|
landingPage: sql17`COALESCE(${gaTrafficSnapshots.landingPageNormalized}, ${gaTrafficSnapshots.landingPage})`,
|
|
48243
48636
|
sessions: sql17`SUM(${gaTrafficSnapshots.sessions})`,
|
|
@@ -48300,6 +48693,9 @@ async function ga4Routes(app, opts) {
|
|
|
48300
48693
|
const totalDirectSessions = directTotalRow?.totalDirectSessions ?? 0;
|
|
48301
48694
|
const totalOrganicSessions = summaryRow?.totalOrganicSessions ?? 0;
|
|
48302
48695
|
const socialSessions = socialTotals?.sessions ?? 0;
|
|
48696
|
+
const windowStart = measuredRange.startDate;
|
|
48697
|
+
const windowEnd = measuredRange.endDate;
|
|
48698
|
+
const windowDays2 = windowStart && windowEnd ? inclusiveDayCount(windowStart, windowEnd) : null;
|
|
48303
48699
|
const channelBreakdown = buildChannelBreakdown({
|
|
48304
48700
|
totalSessions: total,
|
|
48305
48701
|
organicSessions: totalOrganicSessions,
|
|
@@ -48315,7 +48711,7 @@ async function ga4Routes(app, opts) {
|
|
|
48315
48711
|
// it. GA counts users as a COUNT DISTINCT at the grain requested, so the
|
|
48316
48712
|
// landing-page dimensioned sum is inflated, and a 0 would read as
|
|
48317
48713
|
// "nobody visited" rather than "not measurable for this range".
|
|
48318
|
-
totalUsers: summaryRow ? summaryRow.totalUsers :
|
|
48714
|
+
totalUsers: summaryRow ? summaryRow.totalUsers : null,
|
|
48319
48715
|
topPages: rows.map((r) => ({
|
|
48320
48716
|
landingPage: r.landingPage,
|
|
48321
48717
|
sessions: r.sessions ?? 0,
|
|
@@ -48382,25 +48778,19 @@ async function ga4Routes(app, opts) {
|
|
|
48382
48778
|
directSharePctDisplay: formatSharePct(totalDirectSessions, total),
|
|
48383
48779
|
socialSharePctDisplay: formatSharePct(socialSessions, total),
|
|
48384
48780
|
lastSyncedAt: latestSync?.syncedAt ?? null,
|
|
48385
|
-
//
|
|
48386
|
-
// period
|
|
48387
|
-
//
|
|
48388
|
-
//
|
|
48389
|
-
//
|
|
48390
|
-
|
|
48391
|
-
|
|
48392
|
-
|
|
48393
|
-
//
|
|
48394
|
-
//
|
|
48395
|
-
//
|
|
48396
|
-
periodStart:
|
|
48397
|
-
|
|
48398
|
-
const start = range.startDate ?? summaryMeta?.periodStart ?? null;
|
|
48399
|
-
const end = range.endDate ?? summaryMeta?.periodEnd ?? null;
|
|
48400
|
-
if (start && end && start > end) return summaryMeta?.periodStart ?? null;
|
|
48401
|
-
return start;
|
|
48402
|
-
})(),
|
|
48403
|
-
periodEnd: range.endDate ?? summaryMeta?.periodEnd ?? null
|
|
48781
|
+
// The window every figure above was measured over — never a different
|
|
48782
|
+
// table's period, and never a fallback that names dates the queries did
|
|
48783
|
+
// not use. A `null` bound is genuinely open (all retained history on that
|
|
48784
|
+
// side), which is why `windowDays` is null too rather than guessing a
|
|
48785
|
+
// span. Read a share against these dates or not at all.
|
|
48786
|
+
windowStart,
|
|
48787
|
+
windowEnd,
|
|
48788
|
+
windowDays: windowDays2,
|
|
48789
|
+
// Retained aliases of `windowStart` / `windowEnd`. They previously mixed
|
|
48790
|
+
// the requested range with the synced range, which is what let a 30-day
|
|
48791
|
+
// total sit under a 90-day channel count with a label that named neither.
|
|
48792
|
+
periodStart: windowStart,
|
|
48793
|
+
periodEnd: windowEnd
|
|
48404
48794
|
};
|
|
48405
48795
|
});
|
|
48406
48796
|
app.get("/projects/:name/ga/ai-referral-history", async (request, _reply) => {
|
|
@@ -65364,6 +65754,16 @@ function matchesBrandAlias(candidate, aliases) {
|
|
|
65364
65754
|
var RECURRENCE_LOOKBACK_RUNS = 5;
|
|
65365
65755
|
var HISTORY_WINDOW_RUNS = Math.max(PERSISTENT_GAP_THRESHOLD, 5);
|
|
65366
65756
|
var log = createLogger("IntelligenceService");
|
|
65757
|
+
function readStoredGroundingSources(rawResponse) {
|
|
65758
|
+
const parsed = parseJsonColumn(rawResponse, {});
|
|
65759
|
+
const sources = parsed.groundingSources;
|
|
65760
|
+
if (!Array.isArray(sources)) return [];
|
|
65761
|
+
return sources.flatMap((source) => {
|
|
65762
|
+
if (!source || typeof source !== "object") return [];
|
|
65763
|
+
const uri = source.uri;
|
|
65764
|
+
return typeof uri === "string" ? [{ uri }] : [];
|
|
65765
|
+
});
|
|
65766
|
+
}
|
|
65367
65767
|
function gbpInsightSlot(typeOrSlot) {
|
|
65368
65768
|
return typeOrSlot === "gbp-lodging-gap" || typeOrSlot === "gbp-listing-discrepancy" ? "lodging" : typeOrSlot;
|
|
65369
65769
|
}
|
|
@@ -65406,11 +65806,13 @@ var IntelligenceService = class {
|
|
|
65406
65806
|
log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
|
|
65407
65807
|
return null;
|
|
65408
65808
|
}
|
|
65809
|
+
const trackedCompetitors = this.loadTrackedCompetitors(projectId);
|
|
65409
65810
|
const currentRun = this.buildRunData(
|
|
65410
65811
|
runId,
|
|
65411
65812
|
projectId,
|
|
65412
65813
|
currentRunRecord.finishedAt ?? currentRunRecord.createdAt,
|
|
65413
|
-
currentRunRecord.location ?? null
|
|
65814
|
+
currentRunRecord.location ?? null,
|
|
65815
|
+
trackedCompetitors
|
|
65414
65816
|
);
|
|
65415
65817
|
if (currentRun.snapshots.length === 0) {
|
|
65416
65818
|
log.info("intelligence.skip", { runId, reason: "no snapshots" });
|
|
@@ -65425,10 +65827,16 @@ var IntelligenceService = class {
|
|
|
65425
65827
|
previousRunRecord.id,
|
|
65426
65828
|
projectId,
|
|
65427
65829
|
previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
|
|
65428
|
-
previousRunRecord.location ?? null
|
|
65830
|
+
previousRunRecord.location ?? null,
|
|
65831
|
+
trackedCompetitors
|
|
65429
65832
|
) : null;
|
|
65430
|
-
const
|
|
65431
|
-
|
|
65833
|
+
const history = sameLocationOrdered.slice(0, currentLocIdx + 1).map((r) => r.id === runId ? currentRun : this.buildRunData(
|
|
65834
|
+
r.id,
|
|
65835
|
+
projectId,
|
|
65836
|
+
r.finishedAt ?? r.createdAt,
|
|
65837
|
+
r.location ?? null,
|
|
65838
|
+
trackedCompetitors
|
|
65839
|
+
));
|
|
65432
65840
|
if (!previousRun) {
|
|
65433
65841
|
const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
|
|
65434
65842
|
log.info("intelligence.analyzed", {
|
|
@@ -65632,11 +66040,13 @@ var IntelligenceService = class {
|
|
|
65632
66040
|
* would have produced.
|
|
65633
66041
|
*/
|
|
65634
66042
|
analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords, opts) {
|
|
66043
|
+
const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
|
|
65635
66044
|
const currentRun = this.buildRunData(
|
|
65636
66045
|
runRecord.id,
|
|
65637
66046
|
runRecord.projectId,
|
|
65638
66047
|
runRecord.finishedAt ?? runRecord.createdAt,
|
|
65639
|
-
runRecord.location ?? null
|
|
66048
|
+
runRecord.location ?? null,
|
|
66049
|
+
trackedCompetitors
|
|
65640
66050
|
);
|
|
65641
66051
|
if (currentRun.snapshots.length === 0) {
|
|
65642
66052
|
return null;
|
|
@@ -65645,10 +66055,16 @@ var IntelligenceService = class {
|
|
|
65645
66055
|
previousRunRecord.id,
|
|
65646
66056
|
previousRunRecord.projectId,
|
|
65647
66057
|
previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
|
|
65648
|
-
previousRunRecord.location ?? null
|
|
66058
|
+
previousRunRecord.location ?? null,
|
|
66059
|
+
trackedCompetitors
|
|
65649
66060
|
) : null;
|
|
65650
|
-
const
|
|
65651
|
-
|
|
66061
|
+
const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(
|
|
66062
|
+
r.id,
|
|
66063
|
+
r.projectId,
|
|
66064
|
+
r.finishedAt ?? r.createdAt,
|
|
66065
|
+
r.location ?? null,
|
|
66066
|
+
trackedCompetitors
|
|
66067
|
+
));
|
|
65652
66068
|
if (!previousRun) {
|
|
65653
66069
|
const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
|
|
65654
66070
|
const emptyResult = this.emptyAnalysisResult(result2);
|
|
@@ -65918,12 +66334,13 @@ var IntelligenceService = class {
|
|
|
65918
66334
|
return { ...insight, severity };
|
|
65919
66335
|
});
|
|
65920
66336
|
}
|
|
65921
|
-
buildRunData(runId, projectId, completedAt, location = null) {
|
|
66337
|
+
buildRunData(runId, projectId, completedAt, location = null, trackedCompetitors = []) {
|
|
65922
66338
|
const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(eq60(projects.id, projectId)).get();
|
|
65923
66339
|
const projectDomains = projectDomainRow ? effectiveDomains({
|
|
65924
66340
|
canonicalDomain: projectDomainRow.canonicalDomain,
|
|
65925
66341
|
ownedDomains: projectDomainRow.ownedDomains
|
|
65926
66342
|
}) : [];
|
|
66343
|
+
const competitiveSignalResolver = compileCompetitiveSignalResolver(trackedCompetitors);
|
|
65927
66344
|
const rows = this.db.select({
|
|
65928
66345
|
query: queries.query,
|
|
65929
66346
|
// Denormalized query text persisted by v58 — the fallback when the
|
|
@@ -65934,7 +66351,7 @@ var IntelligenceService = class {
|
|
|
65934
66351
|
citationState: querySnapshots.citationState,
|
|
65935
66352
|
answerMentioned: querySnapshots.answerMentioned,
|
|
65936
66353
|
citedDomains: querySnapshots.citedDomains,
|
|
65937
|
-
|
|
66354
|
+
rawResponse: querySnapshots.rawResponse,
|
|
65938
66355
|
snapshotLocation: querySnapshots.location
|
|
65939
66356
|
}).from(querySnapshots).leftJoin(queries, eq60(querySnapshots.queryId, queries.id)).where(eq60(querySnapshots.runId, runId)).all();
|
|
65940
66357
|
const snapshots = [];
|
|
@@ -65946,7 +66363,10 @@ var IntelligenceService = class {
|
|
|
65946
66363
|
continue;
|
|
65947
66364
|
}
|
|
65948
66365
|
const domains = r.citedDomains;
|
|
65949
|
-
const
|
|
66366
|
+
const citedCompetitorDomains = competitiveSignalResolver.resolve({
|
|
66367
|
+
citedDomains: domains,
|
|
66368
|
+
groundingSources: readStoredGroundingSources(r.rawResponse)
|
|
66369
|
+
}).citedCompetitorDomains;
|
|
65950
66370
|
snapshots.push({
|
|
65951
66371
|
query: resolvedQuery,
|
|
65952
66372
|
provider: r.provider,
|
|
@@ -65964,7 +66384,7 @@ var IntelligenceService = class {
|
|
|
65964
66384
|
// we read it from the snapshot row so a stale runs.location can't
|
|
65965
66385
|
// mask snapshot truth.
|
|
65966
66386
|
location: r.snapshotLocation ?? location ?? null,
|
|
65967
|
-
|
|
66387
|
+
citedCompetitorDomains,
|
|
65968
66388
|
// citedDomains is the FULL set (tracked competitors + third-party
|
|
65969
66389
|
// sources). Cause analysis uses it to name the displacing source
|
|
65970
66390
|
// when no tracked competitor appears in the response.
|