@canonry/canonry 4.172.1 → 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-BO_TSFPx.js → AuditHistoryPanel-DnqySUw3.js} +1 -1
- package/assets/assets/{BacklinksPage-CNrzKbiR.js → BacklinksPage-CbcHWD-G.js} +1 -1
- package/assets/assets/{HistoryPage-CvzLzfj3.js → HistoryPage-BYFSyxuB.js} +1 -1
- package/assets/assets/{MeasurementPropertyPage-CUdAqft3.js → MeasurementPropertyPage-BBnXQPIv.js} +1 -1
- package/assets/assets/ProjectPage-Cq5bHpmk.js +9 -0
- package/assets/assets/{RunRow-BLZsQTiY.js → RunRow-lfyHktTf.js} +1 -1
- package/assets/assets/{RunsPage-CTuTDUcn.js → RunsPage-ngnP0Y_D.js} +1 -1
- package/assets/assets/{SettingsPage-BY-nM5uj.js → SettingsPage-DEg74T20.js} +1 -1
- package/assets/assets/{SiteHealthSection-br9-ha8G.js → SiteHealthSection-C6ycXwbt.js} +3 -3
- package/assets/assets/{TrafficPage-BgX1QlXy.js → TrafficPage-hrJ1IDqA.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-D5bsBc6A.js → TrafficSourceDetailPage-fu7nPl3E.js} +1 -1
- package/assets/assets/{extract-error-message-BywQFP69.js → extract-error-message-dcdO9njP.js} +1 -1
- package/assets/assets/index-D1mXm81Z.css +1 -0
- package/assets/assets/{index-BpHZirJJ.js → index-DpQ_-QC_.js} +27 -27
- package/assets/assets/{react-sigma_core.esm.min-Do4rkEBs.js → react-sigma_core.esm.min-DZGxGBSe.js} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-X63CLHYM.js → chunk-EGGQSFQL.js} +4748 -4679
- package/dist/{chunk-3XN226N6.js → chunk-PXTSQ67Z.js} +651 -243
- package/dist/{chunk-IAX4WRAR.js → chunk-TI3SZAVI.js} +4 -4
- package/dist/{chunk-7L4453BU.js → chunk-U5JTZM66.js} +2 -2
- package/dist/{chunk-5MRE2TXR.js → chunk-YXDCVM5U.js} +8 -5
- package/dist/cli.js +36 -14
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-FSHZXSGK.js → intelligence-service-H7756PBD.js} +2 -2
- package/dist/mcp.js +3 -3
- package/package.json +13 -13
- package/assets/assets/ProjectPage-xbRWsne7.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,
|
|
@@ -372,6 +374,7 @@ import {
|
|
|
372
374
|
locationContextSchema,
|
|
373
375
|
loginRequestSchema,
|
|
374
376
|
mapWithConcurrency,
|
|
377
|
+
matchedAliasKeys,
|
|
375
378
|
matcherMatchesText,
|
|
376
379
|
measurementChangesQuerySchema,
|
|
377
380
|
measurementChangesResponseSchema,
|
|
@@ -625,7 +628,7 @@ import {
|
|
|
625
628
|
wordpressSchemaDeployResultDtoSchema,
|
|
626
629
|
wordpressSchemaStatusResultDtoSchema,
|
|
627
630
|
wordpressStatusDtoSchema
|
|
628
|
-
} from "./chunk-
|
|
631
|
+
} from "./chunk-EGGQSFQL.js";
|
|
629
632
|
|
|
630
633
|
// src/intelligence-service.ts
|
|
631
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";
|
|
@@ -1020,6 +1023,21 @@ var querySnapshots = sqliteTable("query_snapshots", {
|
|
|
1020
1023
|
// observation predates the field, NOT that retrieval did not happen.
|
|
1021
1024
|
retrievalStatus: text("retrieval_status").$type(),
|
|
1022
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
|
+
*/
|
|
1023
1041
|
competitorOverlap: text("competitor_overlap", { mode: "json" }).$type().notNull().default([]),
|
|
1024
1042
|
recommendedCompetitors: text("recommended_competitors", { mode: "json" }).$type().notNull().default([]),
|
|
1025
1043
|
location: text("location"),
|
|
@@ -7248,9 +7266,9 @@ function analyzeCause(regression, currentSnapshots) {
|
|
|
7248
7266
|
const matchingSnaps = currentSnapshots.filter(
|
|
7249
7267
|
(s) => s.query === regression.query && s.provider === regression.provider && !s.cited
|
|
7250
7268
|
);
|
|
7251
|
-
const withCompetitor = matchingSnaps.find((s) => s.
|
|
7269
|
+
const withCompetitor = matchingSnaps.find((s) => s.citedCompetitorDomains?.length);
|
|
7252
7270
|
if (withCompetitor) {
|
|
7253
|
-
const competitor = withCompetitor.
|
|
7271
|
+
const competitor = withCompetitor.citedCompetitorDomains[0];
|
|
7254
7272
|
return {
|
|
7255
7273
|
cause: "competitor_gain",
|
|
7256
7274
|
competitorDomain: competitor,
|
|
@@ -7488,8 +7506,8 @@ function detectPersistentGaps(runs2, threshold = PERSISTENT_GAP_THRESHOLD) {
|
|
|
7488
7506
|
function buildCompetitorQueryMap(run, tracked) {
|
|
7489
7507
|
const result = /* @__PURE__ */ new Map();
|
|
7490
7508
|
for (const snap of run.snapshots) {
|
|
7491
|
-
if (!snap.query || !snap.
|
|
7492
|
-
for (const domain of snap.
|
|
7509
|
+
if (!snap.query || !snap.citedCompetitorDomains || snap.citedCompetitorDomains.length === 0) continue;
|
|
7510
|
+
for (const domain of snap.citedCompetitorDomains) {
|
|
7493
7511
|
if (!tracked.has(domain)) continue;
|
|
7494
7512
|
const existing = result.get(domain) ?? /* @__PURE__ */ new Set();
|
|
7495
7513
|
existing.add(snap.query);
|
|
@@ -7754,6 +7772,7 @@ function calculateActionConfidence(input) {
|
|
|
7754
7772
|
function buildContentTargetRows(input) {
|
|
7755
7773
|
const rows = [];
|
|
7756
7774
|
for (const cq of input.candidateQueries) {
|
|
7775
|
+
const competitiveDomains = competitivePresenceDomains(cq);
|
|
7757
7776
|
const ourPage = resolveOurPage(cq, input.inventory);
|
|
7758
7777
|
const ourPageInGroundingSources = cq.ourCitedInLatestRun;
|
|
7759
7778
|
const ourPageHasSchema = ourPage ? input.wpSchemaAudit.get(ourPage.url) ?? null : null;
|
|
@@ -7764,7 +7783,7 @@ function buildContentTargetRows(input) {
|
|
|
7764
7783
|
});
|
|
7765
7784
|
if (!action) continue;
|
|
7766
7785
|
const hasGsc = cq.gscImpressions > 0;
|
|
7767
|
-
const hasCompetitor =
|
|
7786
|
+
const hasCompetitor = competitiveDomains.length > 0;
|
|
7768
7787
|
if (!hasGsc && !hasCompetitor && !cq.ourCitedInLatestRun) continue;
|
|
7769
7788
|
const aiReferralFactor = computeAiReferralFactor(
|
|
7770
7789
|
input.totalAiReferralSessions,
|
|
@@ -7773,7 +7792,7 @@ function buildContentTargetRows(input) {
|
|
|
7773
7792
|
const scoring = scoreContentTarget({
|
|
7774
7793
|
gscImpressions: cq.gscImpressions,
|
|
7775
7794
|
aiReferralFactor,
|
|
7776
|
-
competitorCount:
|
|
7795
|
+
competitorCount: competitiveDomains.length,
|
|
7777
7796
|
recentMissRate: cq.recentMissRate,
|
|
7778
7797
|
citationCount: cq.competitorCitationCount,
|
|
7779
7798
|
ourCitedRate: cq.ourCitedRate,
|
|
@@ -7784,7 +7803,7 @@ function buildContentTargetRows(input) {
|
|
|
7784
7803
|
hasGsc: cq.gscPage !== null,
|
|
7785
7804
|
gscImpressions: cq.gscImpressions,
|
|
7786
7805
|
runsOfHistory: cq.runsOfHistory,
|
|
7787
|
-
hasCompetitorEvidence:
|
|
7806
|
+
hasCompetitorEvidence: competitiveDomains.length > 0,
|
|
7788
7807
|
hasInventoryMatch: ourPage?.source === "inventory"
|
|
7789
7808
|
});
|
|
7790
7809
|
const targetRef = computeTargetRef({
|
|
@@ -7855,12 +7874,12 @@ function buildContentSourceRows(input) {
|
|
|
7855
7874
|
function buildContentGapRows(input) {
|
|
7856
7875
|
const gaps = [];
|
|
7857
7876
|
for (const cq of input.candidateQueries) {
|
|
7858
|
-
if (cq.
|
|
7877
|
+
if (cq.competitorCitedDomains.length === 0) continue;
|
|
7859
7878
|
if (cq.ourCitedRate >= 1) continue;
|
|
7860
7879
|
gaps.push({
|
|
7861
7880
|
query: cq.query,
|
|
7862
|
-
competitorDomains: cq.
|
|
7863
|
-
competitorCount: cq.
|
|
7881
|
+
competitorDomains: cq.competitorCitedDomains,
|
|
7882
|
+
competitorCount: cq.competitorCitedDomains.length,
|
|
7864
7883
|
missRate: clamp012(cq.recentMissRate),
|
|
7865
7884
|
lastSeenInRunId: input.latestRunId
|
|
7866
7885
|
});
|
|
@@ -7870,6 +7889,12 @@ function buildContentGapRows(input) {
|
|
|
7870
7889
|
return b.competitorCount - a.competitorCount;
|
|
7871
7890
|
});
|
|
7872
7891
|
}
|
|
7892
|
+
function competitivePresenceDomains(cq) {
|
|
7893
|
+
return [.../* @__PURE__ */ new Set([
|
|
7894
|
+
...cq.competitorCitedDomains,
|
|
7895
|
+
...cq.competitorMentionedDomains
|
|
7896
|
+
])];
|
|
7897
|
+
}
|
|
7873
7898
|
function resolveOurPage(cq, inventory) {
|
|
7874
7899
|
if (cq.gscPage && cq.gscPosition !== null) {
|
|
7875
7900
|
return { url: cq.gscPage, position: cq.gscPosition, source: "gsc" };
|
|
@@ -8107,6 +8132,46 @@ function citedDomainBelongsToProject(citedDomain, projectDomains) {
|
|
|
8107
8132
|
return hostMatchesAnyDomain(citedDomain, projectDomains);
|
|
8108
8133
|
}
|
|
8109
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
|
+
|
|
8110
8175
|
// ../intelligence/src/competitor-landscape.ts
|
|
8111
8176
|
function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, queryLookup) {
|
|
8112
8177
|
let projectCitationCount = 0;
|
|
@@ -8114,14 +8179,22 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
|
|
|
8114
8179
|
for (const c of competitorDomains) {
|
|
8115
8180
|
competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
|
|
8116
8181
|
}
|
|
8182
|
+
const competitorResolver = compileCompetitiveSignalResolver(competitorDomains);
|
|
8183
|
+
const projectResolver = compileCompetitiveSignalResolver(projectDomains);
|
|
8117
8184
|
for (const snap of snapshots) {
|
|
8118
8185
|
const q = queryLookup.byId.get(snap.queryId);
|
|
8119
|
-
const
|
|
8120
|
-
|
|
8186
|
+
const evidence = {
|
|
8187
|
+
citedDomains: snap.citedDomains,
|
|
8188
|
+
groundingSources: snap.groundingSources
|
|
8189
|
+
};
|
|
8190
|
+
if (projectResolver.resolve(evidence).citedCompetitorDomains.length > 0) {
|
|
8121
8191
|
projectCitationCount++;
|
|
8122
8192
|
}
|
|
8193
|
+
const citedCompetitors = new Set(
|
|
8194
|
+
competitorResolver.resolve(evidence).citedCompetitorDomains
|
|
8195
|
+
);
|
|
8123
8196
|
for (const competitor of competitorDomains) {
|
|
8124
|
-
if (
|
|
8197
|
+
if (citedCompetitors.has(competitor)) {
|
|
8125
8198
|
const entry = competitorMap.get(competitor);
|
|
8126
8199
|
entry.count++;
|
|
8127
8200
|
if (q) entry.queries.add(q);
|
|
@@ -8163,55 +8236,84 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
|
|
|
8163
8236
|
}
|
|
8164
8237
|
|
|
8165
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
|
+
}
|
|
8166
8273
|
function buildMentionLandscape(snapshots, competitorDomains, projectBrandNames, projectDomains, queryLookup) {
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
const
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
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
|
+
);
|
|
8173
8284
|
for (const snap of snapshots) {
|
|
8174
8285
|
const text2 = snap.answerText;
|
|
8175
8286
|
if (!text2) continue;
|
|
8176
|
-
totalAnswerSnapshots++;
|
|
8177
8287
|
const q = queryLookup.byId.get(snap.queryId);
|
|
8178
|
-
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(
|
|
8179
8292
|
text2,
|
|
8180
8293
|
[...projectBrandNames],
|
|
8181
8294
|
[...projectDomains]
|
|
8182
8295
|
);
|
|
8183
|
-
if (projectMentioned) projectMentionCount++;
|
|
8296
|
+
if (projectMentioned) tally.projectMentionCount++;
|
|
8184
8297
|
for (const competitor of competitorDomains) {
|
|
8185
|
-
const
|
|
8186
|
-
const mentioned = determineAnswerMentioned(text2,
|
|
8298
|
+
const aliases = competitorAliases.get(competitor) ?? [];
|
|
8299
|
+
const mentioned = determineAnswerMentioned(text2, aliases, [competitor]);
|
|
8187
8300
|
if (mentioned) {
|
|
8188
|
-
const entry = competitorMap.get(competitor);
|
|
8301
|
+
const entry = tally.competitorMap.get(competitor);
|
|
8189
8302
|
entry.count++;
|
|
8190
8303
|
if (q) entry.queries.add(q);
|
|
8191
8304
|
}
|
|
8192
8305
|
}
|
|
8193
8306
|
}
|
|
8194
|
-
const
|
|
8195
|
-
const
|
|
8196
|
-
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
8203
|
-
|
|
8204
|
-
return {
|
|
8205
|
-
domain,
|
|
8206
|
-
mentionCount: data.count,
|
|
8207
|
-
totalCount: totalAnswerSnapshots,
|
|
8208
|
-
pressureLabel,
|
|
8209
|
-
mentionedQueries: [...data.queries].sort(),
|
|
8210
|
-
sharePct
|
|
8211
|
-
};
|
|
8212
|
-
});
|
|
8213
|
-
competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
|
|
8214
|
-
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
|
+
};
|
|
8215
8317
|
}
|
|
8216
8318
|
|
|
8217
8319
|
// ../intelligence/src/ai-source-origin.ts
|
|
@@ -8504,14 +8606,14 @@ function buildGapQueryScore(snapshots) {
|
|
|
8504
8606
|
const byQuery = /* @__PURE__ */ new Map();
|
|
8505
8607
|
for (const snap of snapshots) {
|
|
8506
8608
|
const key = snap.queryId;
|
|
8507
|
-
const current = byQuery.get(key) ?? { cited: false,
|
|
8609
|
+
const current = byQuery.get(key) ?? { cited: false, citedCompetitors: /* @__PURE__ */ new Set() };
|
|
8508
8610
|
if (snap.citationState === CitationStates.cited) current.cited = true;
|
|
8509
|
-
for (const domain of snap.
|
|
8611
|
+
for (const domain of snap.citedCompetitorDomains) current.citedCompetitors.add(domain);
|
|
8510
8612
|
byQuery.set(key, current);
|
|
8511
8613
|
}
|
|
8512
8614
|
const totalCount = byQuery.size;
|
|
8513
8615
|
const gapCount = [...byQuery.values()].filter(
|
|
8514
|
-
(entry) => !entry.cited && entry.
|
|
8616
|
+
(entry) => !entry.cited && entry.citedCompetitors.size > 0
|
|
8515
8617
|
).length;
|
|
8516
8618
|
const gapQueryLabel = gapCount === 1 ? "query" : "queries";
|
|
8517
8619
|
return {
|
|
@@ -8541,14 +8643,14 @@ function buildMentionGapScore(snapshots) {
|
|
|
8541
8643
|
const byQuery = /* @__PURE__ */ new Map();
|
|
8542
8644
|
for (const snap of snapshots) {
|
|
8543
8645
|
const key = snap.queryId;
|
|
8544
|
-
const current = byQuery.get(key) ?? { mentioned: false,
|
|
8646
|
+
const current = byQuery.get(key) ?? { mentioned: false, mentionedCompetitors: /* @__PURE__ */ new Set() };
|
|
8545
8647
|
if (snap.answerMentioned === true) current.mentioned = true;
|
|
8546
|
-
for (const domain of snap.
|
|
8648
|
+
for (const domain of snap.mentionedCompetitorDomains) current.mentionedCompetitors.add(domain);
|
|
8547
8649
|
byQuery.set(key, current);
|
|
8548
8650
|
}
|
|
8549
8651
|
const totalCount = byQuery.size;
|
|
8550
8652
|
const gapCount = [...byQuery.values()].filter(
|
|
8551
|
-
(entry) => !entry.mentioned && entry.
|
|
8653
|
+
(entry) => !entry.mentioned && entry.mentionedCompetitors.size > 0
|
|
8552
8654
|
).length;
|
|
8553
8655
|
const gapQueryLabel = gapCount === 1 ? "query" : "queries";
|
|
8554
8656
|
return {
|
|
@@ -8580,7 +8682,7 @@ function buildCompetitorPressureScore(snapshots, competitorDomains, totalTracked
|
|
|
8580
8682
|
}
|
|
8581
8683
|
let overlapCount = 0;
|
|
8582
8684
|
for (const snap of snapshots) {
|
|
8583
|
-
if (snap.
|
|
8685
|
+
if (snap.citedDomains.some((domain) => hostMatchesAnyDomain(domain, competitorDomains))) {
|
|
8584
8686
|
overlapCount++;
|
|
8585
8687
|
}
|
|
8586
8688
|
}
|
|
@@ -8605,7 +8707,7 @@ function buildOverviewCompetitors(snapshots, competitors2, queryLookup) {
|
|
|
8605
8707
|
return competitors2.map((competitor, index2) => {
|
|
8606
8708
|
const citedQuerySet = /* @__PURE__ */ new Set();
|
|
8607
8709
|
for (const snap of snapshots) {
|
|
8608
|
-
if (snap.
|
|
8710
|
+
if (snap.citedDomains.some((domain) => hostMatchesDomain(domain, competitor.domain))) {
|
|
8609
8711
|
if (snap.queryId) citedQuerySet.add(snap.queryId);
|
|
8610
8712
|
}
|
|
8611
8713
|
}
|
|
@@ -8716,15 +8818,62 @@ function collectProviderKeys(perRun) {
|
|
|
8716
8818
|
}
|
|
8717
8819
|
|
|
8718
8820
|
// ../intelligence/src/mention-share.ts
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
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 {
|
|
8722
8827
|
projectMentionSnapshots: 0,
|
|
8723
|
-
competitorMentionSnapshots: 0,
|
|
8724
|
-
perCompetitor: [],
|
|
8725
8828
|
snapshotsWithAnswerText: 0,
|
|
8726
|
-
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
|
|
8727
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.";
|
|
8728
8877
|
if (snapshots.length === 0) {
|
|
8729
8878
|
return {
|
|
8730
8879
|
label: "Mention Share",
|
|
@@ -8734,10 +8883,14 @@ function buildMentionShare(snapshots, options) {
|
|
|
8734
8883
|
description: "No mention share data yet. Trigger a run to start tracking.",
|
|
8735
8884
|
tooltip,
|
|
8736
8885
|
trend: [],
|
|
8737
|
-
|
|
8886
|
+
scope,
|
|
8887
|
+
breakdown: headline,
|
|
8888
|
+
branded: brandedBreakdown
|
|
8738
8889
|
};
|
|
8739
8890
|
}
|
|
8740
8891
|
if (options.competitors.length === 0) {
|
|
8892
|
+
headline = { ...headline, score: null };
|
|
8893
|
+
brandedBreakdown = { ...brandedBreakdown, score: null };
|
|
8741
8894
|
return {
|
|
8742
8895
|
label: "Mention Share",
|
|
8743
8896
|
value: "Add competitors",
|
|
@@ -8746,83 +8899,95 @@ function buildMentionShare(snapshots, options) {
|
|
|
8746
8899
|
description: "Mention Share is a head-to-head competitive metric \u2014 add tracked competitors to compare brand mention rates.",
|
|
8747
8900
|
tooltip,
|
|
8748
8901
|
trend: [],
|
|
8749
|
-
|
|
8902
|
+
scope,
|
|
8903
|
+
breakdown: headline,
|
|
8904
|
+
branded: brandedBreakdown
|
|
8750
8905
|
};
|
|
8751
8906
|
}
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
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
|
+
};
|
|
8762
8922
|
}
|
|
8763
|
-
|
|
8764
|
-
const
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
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
|
+
};
|
|
8774
8951
|
}
|
|
8775
|
-
const competitorMentionSnapshots = [...competitorCounts.values()].reduce((a, b) => a + b, 0);
|
|
8776
|
-
const denom = projectMentionSnapshots + competitorMentionSnapshots;
|
|
8777
|
-
const score = denom > 0 ? Math.round(projectMentionSnapshots / denom * 100) : 0;
|
|
8778
|
-
const perCompetitor = options.competitors.map((c) => ({
|
|
8779
|
-
domain: c.domain,
|
|
8780
|
-
mentionSnapshots: competitorCounts.get(c.domain) ?? 0,
|
|
8781
|
-
shareOfCompetitiveTotal: competitorMentionSnapshots > 0 ? Math.round((competitorCounts.get(c.domain) ?? 0) / competitorMentionSnapshots * 1e3) / 10 : 0
|
|
8782
|
-
})).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots);
|
|
8783
|
-
const breakdown = {
|
|
8784
|
-
projectMentionSnapshots,
|
|
8785
|
-
competitorMentionSnapshots,
|
|
8786
|
-
perCompetitor,
|
|
8787
|
-
snapshotsWithAnswerText,
|
|
8788
|
-
snapshotsTotal: snapshots.length
|
|
8789
|
-
};
|
|
8790
|
-
const description = describe({
|
|
8791
|
-
score,
|
|
8792
|
-
projectMentionSnapshots,
|
|
8793
|
-
competitorMentionSnapshots,
|
|
8794
|
-
perCompetitor
|
|
8795
|
-
});
|
|
8796
8952
|
return {
|
|
8797
8953
|
label: "Mention Share",
|
|
8798
|
-
value:
|
|
8799
|
-
delta:
|
|
8800
|
-
tone:
|
|
8801
|
-
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 }),
|
|
8802
8958
|
tooltip,
|
|
8803
8959
|
trend: [],
|
|
8804
|
-
progress:
|
|
8805
|
-
|
|
8960
|
+
progress: score,
|
|
8961
|
+
scope,
|
|
8962
|
+
breakdown: headline,
|
|
8963
|
+
branded: brandedBreakdown
|
|
8806
8964
|
};
|
|
8807
8965
|
}
|
|
8966
|
+
function scopeLabel(scope, body) {
|
|
8967
|
+
return scope === "non-brand" ? `${body} \xB7 non-brand queries` : `${body} \xB7 pooled queries \xB7 classification unavailable`;
|
|
8968
|
+
}
|
|
8808
8969
|
function mentionShareTone(score) {
|
|
8809
8970
|
if (score >= 50) return "positive";
|
|
8810
8971
|
if (score >= 25) return "caution";
|
|
8811
8972
|
return "negative";
|
|
8812
8973
|
}
|
|
8813
8974
|
function describe(parts) {
|
|
8814
|
-
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.` : "";
|
|
8815
8979
|
if (projectMentionSnapshots === 0 && competitorMentionSnapshots === 0) {
|
|
8816
|
-
return
|
|
8980
|
+
return `No brand mentions detected for you or your tracked competitors ${where}.${brandedNote}`;
|
|
8817
8981
|
}
|
|
8818
8982
|
if (competitorMentionSnapshots === 0) {
|
|
8819
|
-
return `${projectMentionSnapshots} brand mentions of you
|
|
8983
|
+
return `${projectMentionSnapshots} brand mentions of you and zero competitor mentions ${where} \u2014 you own the conversation.${brandedNote}`;
|
|
8820
8984
|
}
|
|
8821
8985
|
const top = perCompetitor[0];
|
|
8986
|
+
const total = projectMentionSnapshots + competitorMentionSnapshots;
|
|
8822
8987
|
if (!top) {
|
|
8823
|
-
return `${score}% of brand mentions are you (${projectMentionSnapshots} of ${
|
|
8988
|
+
return `${score}% of brand mentions ${where} are you (${projectMentionSnapshots} of ${total}).${brandedNote}`;
|
|
8824
8989
|
}
|
|
8825
|
-
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}`;
|
|
8826
8991
|
}
|
|
8827
8992
|
|
|
8828
8993
|
// ../intelligence/src/suggested-queries.ts
|
|
@@ -13284,6 +13449,8 @@ function loadRunDetail(app, run) {
|
|
|
13284
13449
|
ownedDomains: projects.ownedDomains,
|
|
13285
13450
|
aliases: projects.aliases
|
|
13286
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);
|
|
13287
13454
|
const snapshots = app.db.select({
|
|
13288
13455
|
id: querySnapshots.id,
|
|
13289
13456
|
runId: querySnapshots.runId,
|
|
@@ -13317,6 +13484,14 @@ function loadRunDetail(app, run) {
|
|
|
13317
13484
|
...formatRun(run),
|
|
13318
13485
|
snapshots: snapshots.map((s) => {
|
|
13319
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
|
+
});
|
|
13320
13495
|
const answerMentioned = project ? resolveSnapshotAnswerMentioned(s, project) : s.answerMentioned ?? false;
|
|
13321
13496
|
return {
|
|
13322
13497
|
id: s.id,
|
|
@@ -13337,6 +13512,8 @@ function loadRunDetail(app, run) {
|
|
|
13337
13512
|
sourceCount: s.sourceCount,
|
|
13338
13513
|
resolvedCount: s.resolvedCount,
|
|
13339
13514
|
captureVersion: s.captureVersion,
|
|
13515
|
+
...competitiveSignals,
|
|
13516
|
+
// Legacy mixed signal retained for backwards compatibility.
|
|
13340
13517
|
competitorOverlap: s.competitorOverlap,
|
|
13341
13518
|
recommendedCompetitors: s.recommendedCompetitors,
|
|
13342
13519
|
matchedTerms: project ? resolveSnapshotMatchedTerms(s, project) : [],
|
|
@@ -20165,6 +20342,8 @@ async function historyRoutes(app) {
|
|
|
20165
20342
|
if (projectRuns.length === 0) {
|
|
20166
20343
|
return reply.send({ snapshots: [], total: 0 });
|
|
20167
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);
|
|
20168
20347
|
const allSnapshots = app.db.select({
|
|
20169
20348
|
id: querySnapshots.id,
|
|
20170
20349
|
runId: querySnapshots.runId,
|
|
@@ -20184,6 +20363,7 @@ async function historyRoutes(app) {
|
|
|
20184
20363
|
competitorOverlap: querySnapshots.competitorOverlap,
|
|
20185
20364
|
recommendedCompetitors: querySnapshots.recommendedCompetitors,
|
|
20186
20365
|
location: querySnapshots.location,
|
|
20366
|
+
rawResponse: querySnapshots.rawResponse,
|
|
20187
20367
|
createdAt: querySnapshots.createdAt
|
|
20188
20368
|
}).from(querySnapshots).leftJoin(queries, eq22(querySnapshots.queryId, queries.id)).where(inArray8(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc7(querySnapshots.createdAt)).all();
|
|
20189
20369
|
const locationFilter = request.query.location;
|
|
@@ -20191,29 +20371,43 @@ async function historyRoutes(app) {
|
|
|
20191
20371
|
const total = filtered.length;
|
|
20192
20372
|
const paged = filtered.slice(offset, offset + limit);
|
|
20193
20373
|
return reply.send({
|
|
20194
|
-
snapshots: paged.map((s) =>
|
|
20195
|
-
|
|
20196
|
-
|
|
20197
|
-
|
|
20198
|
-
|
|
20199
|
-
|
|
20200
|
-
|
|
20201
|
-
|
|
20202
|
-
|
|
20203
|
-
|
|
20204
|
-
|
|
20205
|
-
|
|
20206
|
-
|
|
20207
|
-
|
|
20208
|
-
|
|
20209
|
-
|
|
20210
|
-
|
|
20211
|
-
|
|
20212
|
-
|
|
20213
|
-
|
|
20214
|
-
|
|
20215
|
-
|
|
20216
|
-
|
|
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
|
+
}),
|
|
20217
20411
|
total
|
|
20218
20412
|
});
|
|
20219
20413
|
});
|
|
@@ -20452,6 +20646,61 @@ function parseOptionalPositiveInt(value, max2) {
|
|
|
20452
20646
|
// ../api-routes/src/analytics.ts
|
|
20453
20647
|
import { and as and17, desc as desc8, eq as eq23, gte as gte4, inArray as inArray9, lt as lt4 } from "drizzle-orm";
|
|
20454
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
|
+
|
|
20455
20704
|
// ../api-routes/src/model-evidence.ts
|
|
20456
20705
|
function classifyModelEvidence(values) {
|
|
20457
20706
|
const models = /* @__PURE__ */ new Set();
|
|
@@ -20580,6 +20829,8 @@ function measuredWhole(db, run) {
|
|
|
20580
20829
|
async function analyticsRoutes(app) {
|
|
20581
20830
|
app.get("/projects/:name/analytics/metrics", async (request, reply) => {
|
|
20582
20831
|
const project = resolveProject(app.db, request.params.name);
|
|
20832
|
+
const classifyQuery = projectQueryClassifier(project);
|
|
20833
|
+
const mentionShareScope = classifyQuery ? "non-brand" : "pooled";
|
|
20583
20834
|
const window = parseWindow(request.query.window);
|
|
20584
20835
|
const cutoff = windowCutoff(window);
|
|
20585
20836
|
const projectRuns = app.db.select().from(runs).where(and17(
|
|
@@ -20592,6 +20843,7 @@ async function analyticsRoutes(app) {
|
|
|
20592
20843
|
if (projectRuns.length === 0) {
|
|
20593
20844
|
return reply.send({
|
|
20594
20845
|
window,
|
|
20846
|
+
mentionShareScope,
|
|
20595
20847
|
buckets: [],
|
|
20596
20848
|
overall: { citationRate: 0, cited: 0, total: 0, mentionRate: 0, mentionedCount: 0 },
|
|
20597
20849
|
byProvider: {},
|
|
@@ -20638,16 +20890,16 @@ async function analyticsRoutes(app) {
|
|
|
20638
20890
|
...s,
|
|
20639
20891
|
runCreatedAt: runCreatedAt.get(s.runId),
|
|
20640
20892
|
resolvedMentioned: resolveSnapshotAnswerMentioned(s, project),
|
|
20893
|
+
queryClass: classifyQuery ? classifyQuery((s.queryId ? queryTextById.get(s.queryId) : void 0) ?? s.queryText ?? null) : null,
|
|
20641
20894
|
// Falls back to the id if no text resolves, so two distinct queries can
|
|
20642
20895
|
// never collapse into one member on an empty key. (An orphan only gets
|
|
20643
20896
|
// this far when its text matched the basket, so its key is never empty.)
|
|
20644
20897
|
basketKey: normalizeQueryText(s.queryText ?? (s.queryId ? queryTextById.get(s.queryId) ?? "" : "")) || s.queryId || "",
|
|
20645
20898
|
runBasketRevision: runBasketRevision.get(s.runId) ?? null
|
|
20646
20899
|
}));
|
|
20647
|
-
const mentionShareCompetitors =
|
|
20648
|
-
domain: c.domain
|
|
20649
|
-
|
|
20650
|
-
}));
|
|
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
|
+
);
|
|
20651
20903
|
const overall = computeProviderMetric(allSnapshots);
|
|
20652
20904
|
const byProvider = {};
|
|
20653
20905
|
const providers = new Set(allSnapshots.map((s) => s.provider));
|
|
@@ -20658,7 +20910,15 @@ async function analyticsRoutes(app) {
|
|
|
20658
20910
|
const latest = new Date(projectRuns[projectRuns.length - 1].createdAt);
|
|
20659
20911
|
const spanDays = Math.max(1, Math.ceil((latest.getTime() - earliest.getTime()) / 864e5));
|
|
20660
20912
|
const bucketSize = bucketSizeForSpan(spanDays);
|
|
20661
|
-
const buckets = computeBuckets(
|
|
20913
|
+
const buckets = computeBuckets(
|
|
20914
|
+
allSnapshots,
|
|
20915
|
+
projectRuns,
|
|
20916
|
+
bucketSize,
|
|
20917
|
+
queryCreatedAt,
|
|
20918
|
+
mentionShareCompetitors,
|
|
20919
|
+
referenceBasket,
|
|
20920
|
+
classifyQuery !== null
|
|
20921
|
+
);
|
|
20662
20922
|
const anchors = {};
|
|
20663
20923
|
const anchorObservedAt = {};
|
|
20664
20924
|
const anchorUnavailable = /* @__PURE__ */ new Set();
|
|
@@ -20822,7 +21082,7 @@ async function analyticsRoutes(app) {
|
|
|
20822
21082
|
}
|
|
20823
21083
|
previousExecutionChecksum = identity.checksum;
|
|
20824
21084
|
}
|
|
20825
|
-
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 });
|
|
20826
21086
|
});
|
|
20827
21087
|
app.get("/projects/:name/analytics/gaps", async (request, reply) => {
|
|
20828
21088
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -20837,6 +21097,10 @@ async function analyticsRoutes(app) {
|
|
|
20837
21097
|
}
|
|
20838
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);
|
|
20839
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
|
+
);
|
|
20840
21104
|
const runIdToCreatedAt = new Map(windowRuns.map((r) => [r.id, r.createdAt]));
|
|
20841
21105
|
const consistencyMap = /* @__PURE__ */ new Map();
|
|
20842
21106
|
if (windowRunIds.length > 0) {
|
|
@@ -20866,7 +21130,7 @@ async function analyticsRoutes(app) {
|
|
|
20866
21130
|
citationState: querySnapshots.citationState,
|
|
20867
21131
|
answerMentioned: querySnapshots.answerMentioned,
|
|
20868
21132
|
answerText: querySnapshots.answerText,
|
|
20869
|
-
|
|
21133
|
+
citedDomains: querySnapshots.citedDomains
|
|
20870
21134
|
}).from(querySnapshots).leftJoin(queries, eq23(querySnapshots.queryId, queries.id)).where(inArray9(querySnapshots.runId, latestGroupRunIds)).all());
|
|
20871
21135
|
const snapshots = rawSnapshots.map((s) => ({
|
|
20872
21136
|
...s,
|
|
@@ -20890,9 +21154,17 @@ async function analyticsRoutes(app) {
|
|
|
20890
21154
|
const citedProviders = qSnapshots.filter((s) => s.citationState === CitationStates.cited).map((s) => s.provider);
|
|
20891
21155
|
const mentionedProviders = qSnapshots.filter((s) => s.resolvedMentioned).map((s) => s.provider);
|
|
20892
21156
|
const competitorsCiting = /* @__PURE__ */ new Set();
|
|
21157
|
+
const competitorsMentioned = /* @__PURE__ */ new Set();
|
|
20893
21158
|
for (const s of qSnapshots) {
|
|
20894
|
-
const
|
|
20895
|
-
|
|
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
|
+
}
|
|
20896
21168
|
}
|
|
20897
21169
|
const cons = consistencyMap.get(queryId);
|
|
20898
21170
|
const consistency = {
|
|
@@ -20914,6 +21186,7 @@ async function analyticsRoutes(app) {
|
|
|
20914
21186
|
category,
|
|
20915
21187
|
providers: citedProviders,
|
|
20916
21188
|
competitorsCiting: [...competitorsCiting],
|
|
21189
|
+
competitorsMentioned: [...competitorsMentioned],
|
|
20917
21190
|
consistency
|
|
20918
21191
|
};
|
|
20919
21192
|
if (category === "cited") cited.push(citationEntry);
|
|
@@ -20922,7 +21195,7 @@ async function analyticsRoutes(app) {
|
|
|
20922
21195
|
let mentionCategory;
|
|
20923
21196
|
if (mentionedProviders.length > 0) {
|
|
20924
21197
|
mentionCategory = "cited";
|
|
20925
|
-
} else if (
|
|
21198
|
+
} else if (competitorsMentioned.size > 0) {
|
|
20926
21199
|
mentionCategory = "gap";
|
|
20927
21200
|
} else {
|
|
20928
21201
|
mentionCategory = "uncited";
|
|
@@ -20933,6 +21206,7 @@ async function analyticsRoutes(app) {
|
|
|
20933
21206
|
category: mentionCategory,
|
|
20934
21207
|
providers: mentionedProviders,
|
|
20935
21208
|
competitorsCiting: [...competitorsCiting],
|
|
21209
|
+
competitorsMentioned: [...competitorsMentioned],
|
|
20936
21210
|
consistency
|
|
20937
21211
|
};
|
|
20938
21212
|
if (mentionCategory === "cited") mentionedQueries.push(mentionEntry);
|
|
@@ -20942,7 +21216,7 @@ async function analyticsRoutes(app) {
|
|
|
20942
21216
|
gap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
|
|
20943
21217
|
cited.sort((a, b) => a.query.localeCompare(b.query));
|
|
20944
21218
|
uncited.sort((a, b) => a.query.localeCompare(b.query));
|
|
20945
|
-
mentionGap.sort((a, b) => b.
|
|
21219
|
+
mentionGap.sort((a, b) => b.competitorsMentioned.length - a.competitorsMentioned.length);
|
|
20946
21220
|
mentionedQueries.sort((a, b) => a.query.localeCompare(b.query));
|
|
20947
21221
|
notMentioned.sort((a, b) => a.query.localeCompare(b.query));
|
|
20948
21222
|
return reply.send({ cited, gap, uncited, mentionedQueries, mentionGap, notMentioned, runId: latestRun.id, window });
|
|
@@ -21059,7 +21333,7 @@ function computeProviderMetric(snapshots) {
|
|
|
21059
21333
|
mentionedCount
|
|
21060
21334
|
};
|
|
21061
21335
|
}
|
|
21062
|
-
function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, mentionShareCompetitors = [], referenceBasket) {
|
|
21336
|
+
function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, mentionShareCompetitors = [], referenceBasket, classificationAvailable = false) {
|
|
21063
21337
|
if (projectRuns.length === 0) return [];
|
|
21064
21338
|
const earliest = new Date(projectRuns[0].createdAt);
|
|
21065
21339
|
const latest = new Date(projectRuns[projectRuns.length - 1].createdAt);
|
|
@@ -21108,7 +21382,7 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
|
|
|
21108
21382
|
queryCount,
|
|
21109
21383
|
mentionRate: metric2.mentionRate,
|
|
21110
21384
|
mentionedCount: metric2.mentionedCount,
|
|
21111
|
-
mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors),
|
|
21385
|
+
mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors, classificationAvailable),
|
|
21112
21386
|
byProvider,
|
|
21113
21387
|
modelEvidenceByProvider,
|
|
21114
21388
|
basketRevision
|
|
@@ -21127,22 +21401,23 @@ function bucketStartDateFor(observedAt, earliest, bucketDays) {
|
|
|
21127
21401
|
const offset = Math.max(0, Math.floor((observationDay.getTime() - firstBucketStart.getTime()) / bucketMilliseconds));
|
|
21128
21402
|
return new Date(firstBucketStart.getTime() + offset * bucketMilliseconds).toISOString();
|
|
21129
21403
|
}
|
|
21130
|
-
function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors) {
|
|
21131
|
-
if (mentionShareCompetitors.length === 0) {
|
|
21132
|
-
return { rate: null, projectMentionSnapshots: 0, competitorMentionSnapshots: 0 };
|
|
21133
|
-
}
|
|
21404
|
+
function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors, classificationAvailable) {
|
|
21134
21405
|
const result = buildMentionShare(
|
|
21135
21406
|
snapshots.map((s) => ({
|
|
21136
21407
|
projectMentioned: s.resolvedMentioned,
|
|
21137
|
-
answerText: s.answerText
|
|
21408
|
+
answerText: s.answerText,
|
|
21409
|
+
queryClass: s.queryClass
|
|
21138
21410
|
})),
|
|
21139
|
-
{ competitors: mentionShareCompetitors }
|
|
21411
|
+
{ competitors: mentionShareCompetitors, classificationAvailable }
|
|
21140
21412
|
);
|
|
21141
21413
|
const projectMentionSnapshots = result.breakdown.projectMentionSnapshots;
|
|
21142
21414
|
const competitorMentionSnapshots = result.breakdown.competitorMentionSnapshots;
|
|
21143
21415
|
const denominator = projectMentionSnapshots + competitorMentionSnapshots;
|
|
21144
21416
|
return {
|
|
21145
|
-
|
|
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,
|
|
21146
21421
|
projectMentionSnapshots,
|
|
21147
21422
|
competitorMentionSnapshots
|
|
21148
21423
|
};
|
|
@@ -21726,20 +22001,27 @@ function aggregateCandidate(opts) {
|
|
|
21726
22001
|
const ourCitedRate = citedCount / totalSnaps;
|
|
21727
22002
|
const recentMissRate = 1 - ourCitedRate;
|
|
21728
22003
|
const competitorTally = /* @__PURE__ */ new Map();
|
|
22004
|
+
const competitorMentionTally = /* @__PURE__ */ new Map();
|
|
22005
|
+
const competitiveSignalResolver = compileCompetitiveSignalResolver([...opts.competitorSet]);
|
|
21729
22006
|
const competitorGroundingTally = /* @__PURE__ */ new Map();
|
|
21730
22007
|
const ourGroundingTally = /* @__PURE__ */ new Map();
|
|
21731
22008
|
const citedSurfaceTally = /* @__PURE__ */ new Map();
|
|
21732
22009
|
let ourCitedInLatestRun = false;
|
|
21733
22010
|
for (const snap of opts.snapshots) {
|
|
21734
22011
|
const isLatestRun = snap.runId === opts.latestRunId;
|
|
21735
|
-
const competitorOverlap = snap.competitorOverlap;
|
|
21736
|
-
for (const domain of competitorOverlap) {
|
|
21737
|
-
const normalized = hostOf(domain) ?? "";
|
|
21738
|
-
const competitor = findMatchingDomain(normalized, opts.competitorSet);
|
|
21739
|
-
if (!competitor) continue;
|
|
21740
|
-
competitorTally.set(competitor, (competitorTally.get(competitor) ?? 0) + 1);
|
|
21741
|
-
}
|
|
21742
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
|
+
}
|
|
21743
22025
|
for (const g of grounding) {
|
|
21744
22026
|
const domain = hostOf(g.uri) ?? "";
|
|
21745
22027
|
if (!domain) continue;
|
|
@@ -21749,9 +22031,14 @@ function aggregateCandidate(opts) {
|
|
|
21749
22031
|
continue;
|
|
21750
22032
|
}
|
|
21751
22033
|
citedSurfaceTally.set(domain, (citedSurfaceTally.get(domain) ?? 0) + 1);
|
|
21752
|
-
|
|
22034
|
+
const competitor = findMatchingDomain(domain, opts.competitorSet);
|
|
22035
|
+
if (!competitor) continue;
|
|
22036
|
+
citedCompetitorsHere.add(competitor);
|
|
21753
22037
|
recordGroundingHit(competitorGroundingTally, g, domain, snap.provider);
|
|
21754
22038
|
}
|
|
22039
|
+
for (const competitor of citedCompetitorsHere) {
|
|
22040
|
+
competitorTally.set(competitor, (competitorTally.get(competitor) ?? 0) + 1);
|
|
22041
|
+
}
|
|
21755
22042
|
}
|
|
21756
22043
|
return {
|
|
21757
22044
|
query: opts.query,
|
|
@@ -21762,8 +22049,10 @@ function aggregateCandidate(opts) {
|
|
|
21762
22049
|
gscCtr: opts.gsc?.ctr ?? 0,
|
|
21763
22050
|
ourCitedRate,
|
|
21764
22051
|
ourCitedInLatestRun,
|
|
21765
|
-
|
|
22052
|
+
competitorCitedDomains: Array.from(competitorTally.keys()),
|
|
22053
|
+
competitorMentionedDomains: Array.from(competitorMentionTally.keys()),
|
|
21766
22054
|
competitorCitationCount: Array.from(competitorTally.values()).reduce((a, b) => a + b, 0),
|
|
22055
|
+
competitorMentionCount: Array.from(competitorMentionTally.values()).reduce((a, b) => a + b, 0),
|
|
21767
22056
|
recentMissRate,
|
|
21768
22057
|
ourGroundingUrls: Array.from(ourGroundingTally.values()),
|
|
21769
22058
|
competitorGroundingUrls: Array.from(competitorGroundingTally.values()),
|
|
@@ -21801,8 +22090,10 @@ function emptyCandidate(query) {
|
|
|
21801
22090
|
gscCtr: 0,
|
|
21802
22091
|
ourCitedRate: 0,
|
|
21803
22092
|
ourCitedInLatestRun: false,
|
|
21804
|
-
|
|
22093
|
+
competitorCitedDomains: [],
|
|
22094
|
+
competitorMentionedDomains: [],
|
|
21805
22095
|
competitorCitationCount: 0,
|
|
22096
|
+
competitorMentionCount: 0,
|
|
21806
22097
|
recentMissRate: 0,
|
|
21807
22098
|
ourGroundingUrls: [],
|
|
21808
22099
|
competitorGroundingUrls: [],
|
|
@@ -22774,6 +23065,15 @@ table.report-table td .badge {
|
|
|
22774
23065
|
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
22775
23066
|
gap: 16px;
|
|
22776
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
|
+
}
|
|
22777
23077
|
.legend {
|
|
22778
23078
|
display: flex;
|
|
22779
23079
|
flex-wrap: wrap;
|
|
@@ -23579,18 +23879,24 @@ function renderCompetitorBars(landscape, canonical) {
|
|
|
23579
23879
|
];
|
|
23580
23880
|
return renderLandscapeBars(data, "Citations per domain", "Citations per domain bar chart");
|
|
23581
23881
|
}
|
|
23582
|
-
function renderMentionBars(
|
|
23882
|
+
function renderMentionBars(section2, canonical, title) {
|
|
23583
23883
|
const data = [
|
|
23584
|
-
{ label: canonical, count:
|
|
23585
|
-
...
|
|
23884
|
+
{ label: canonical, count: section2.projectMentionCount, isProject: true },
|
|
23885
|
+
...section2.competitors.map((c) => ({ label: c.domain, count: c.mentionCount, isProject: false }))
|
|
23586
23886
|
];
|
|
23587
|
-
return renderLandscapeBars(data,
|
|
23887
|
+
return renderLandscapeBars(data, title, `${title} bar chart`);
|
|
23588
23888
|
}
|
|
23889
|
+
var MENTION_SCOPE_LABEL = {
|
|
23890
|
+
"non-brand": "non-brand queries",
|
|
23891
|
+
pooled: "pooled queries \xB7 classification unavailable"
|
|
23892
|
+
};
|
|
23589
23893
|
function renderCompetitorLandscape(report) {
|
|
23590
23894
|
const competitors2 = report.competitorLandscape.competitors;
|
|
23591
23895
|
const mentionLandscape = report.mentionLandscape;
|
|
23592
23896
|
const noCitationData = competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0;
|
|
23593
|
-
const
|
|
23897
|
+
const brandedMentions = mentionLandscape.branded;
|
|
23898
|
+
const hasBrandedMentions = (brandedMentions?.totalAnswerSnapshots ?? 0) > 0;
|
|
23899
|
+
const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0 && !hasBrandedMentions;
|
|
23594
23900
|
if (noCitationData && noMentionData) {
|
|
23595
23901
|
return section(
|
|
23596
23902
|
{ id: "competitor-landscape", eyebrow: "Section 4", title: "Competitor Landscape" },
|
|
@@ -23615,13 +23921,26 @@ function renderCompetitorLandscape(report) {
|
|
|
23615
23921
|
<td>${escapeHtml(c.citedQueries.slice(0, 5).join(", "))}${c.citedQueries.length > 5 ? "\u2026" : ""}${pagesDisclosure}</td>
|
|
23616
23922
|
</tr>`;
|
|
23617
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.`;
|
|
23618
23926
|
const table = competitors2.length > 0 ? `<table class="report-table">
|
|
23619
|
-
<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>
|
|
23620
23928
|
<tbody>${rows}</tbody>
|
|
23621
23929
|
</table>` : renderEmpty("No competitors configured.");
|
|
23622
23930
|
const citationBars = renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain);
|
|
23623
|
-
const mentionBars = renderMentionBars(
|
|
23931
|
+
const mentionBars = renderMentionBars(
|
|
23932
|
+
mentionLandscape,
|
|
23933
|
+
report.meta.project.canonicalDomain,
|
|
23934
|
+
`Mentions per domain \xB7 ${scopeLabel2}`
|
|
23935
|
+
);
|
|
23624
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>` : "";
|
|
23625
23944
|
return section(
|
|
23626
23945
|
{
|
|
23627
23946
|
id: "competitor-landscape",
|
|
@@ -23629,7 +23948,7 @@ function renderCompetitorLandscape(report) {
|
|
|
23629
23948
|
title: "Competitor Landscape",
|
|
23630
23949
|
intro: "Who AI engines cite and mention instead of the client."
|
|
23631
23950
|
},
|
|
23632
|
-
`${charts}${table}`
|
|
23951
|
+
`${mentionShareNote}${charts}${table}${brandedBlock}`
|
|
23633
23952
|
);
|
|
23634
23953
|
}
|
|
23635
23954
|
var SOURCE_CATEGORY_TONE = {
|
|
@@ -25935,11 +26254,7 @@ function buildProjectReport(db, projectName, periodDays) {
|
|
|
25935
26254
|
const competitorDomains = competitorRows.map((c) => c.domain);
|
|
25936
26255
|
const ownedDomains = project.ownedDomains;
|
|
25937
26256
|
const projectDomains = [project.canonicalDomain, ...ownedDomains];
|
|
25938
|
-
const
|
|
25939
|
-
const projectBrandNames = effectiveBrandNames({
|
|
25940
|
-
displayName: project.displayName,
|
|
25941
|
-
aliases: projectAliases
|
|
25942
|
-
});
|
|
26257
|
+
const projectBrandNames = effectiveBrandNames(project);
|
|
25943
26258
|
const citationScorecard = buildCitationScorecard(latestSnapshots, queryLookup);
|
|
25944
26259
|
const competitorLandscape = buildCompetitorLandscape(
|
|
25945
26260
|
latestSnapshots,
|
|
@@ -27231,10 +27546,8 @@ function computeCitationVisibility(input) {
|
|
|
27231
27546
|
for (const snap of latestByPair.values()) {
|
|
27232
27547
|
if (citationStateToCited(snap.citationState)) continue;
|
|
27233
27548
|
if (competitorDomains.length === 0) continue;
|
|
27234
|
-
const cited = snap.citedDomains;
|
|
27235
|
-
const overlap = snap.competitorOverlap;
|
|
27236
27549
|
const candidates = new Set(
|
|
27237
|
-
|
|
27550
|
+
snap.citedDomains.map((d) => hostOf(d) ?? "").filter((d) => d.length > 0)
|
|
27238
27551
|
);
|
|
27239
27552
|
const citingCompetitors = competitorDomains.filter(
|
|
27240
27553
|
(competitor) => [...candidates].some((candidate) => hostMatchesDomain(candidate, competitor))
|
|
@@ -27323,6 +27636,7 @@ function observedPairs(snapshots, attribution, queryIds) {
|
|
|
27323
27636
|
function period(numerator, denominator) {
|
|
27324
27637
|
const ci = wilsonInterval(numerator, denominator);
|
|
27325
27638
|
return {
|
|
27639
|
+
availability: denominator > 0 ? "available" : "no-observations",
|
|
27326
27640
|
point: denominator > 0 ? Math.round(numerator / denominator * 1e4) / 1e4 : null,
|
|
27327
27641
|
ciLow: ci ? ci.low : null,
|
|
27328
27642
|
ciHigh: ci ? ci.high : null,
|
|
@@ -27330,17 +27644,27 @@ function period(numerator, denominator) {
|
|
|
27330
27644
|
denominator
|
|
27331
27645
|
};
|
|
27332
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
|
+
}
|
|
27333
27657
|
function ciOverlap(a, b) {
|
|
27334
27658
|
if (a.ciLow === null || a.ciHigh === null || b.ciLow === null || b.ciHigh === null) return true;
|
|
27335
27659
|
return a.ciLow <= b.ciHigh && b.ciLow <= a.ciHigh;
|
|
27336
27660
|
}
|
|
27337
|
-
function metric(key, label, driftRobust, from, to, continuityBlock) {
|
|
27661
|
+
function metric(key, label, queryClass, driftRobust, from, to, continuityBlock) {
|
|
27338
27662
|
const verdict = continuityBlock ?? (from.denominator === 0 || to.denominator === 0 ? "insufficient-data" : ciOverlap(from, to) ? "within-noise" : "moved");
|
|
27339
27663
|
const direction = from.point === null || to.point === null ? null : to.point > from.point ? "up" : to.point < from.point ? "down" : "flat";
|
|
27340
27664
|
const rateRatio = from.point === null || from.point === 0 || to.point === null ? null : Math.round(to.point / from.point * 100) / 100;
|
|
27341
|
-
return { key, label, driftRobust, from, to, rateRatio, direction, verdict };
|
|
27665
|
+
return { key, label, queryClass, driftRobust, from, to, rateRatio, direction, verdict };
|
|
27342
27666
|
}
|
|
27343
|
-
function countPeriod(snaps, competitors2) {
|
|
27667
|
+
function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable, projectBrandNames) {
|
|
27344
27668
|
let checked = 0;
|
|
27345
27669
|
let mentioned = 0;
|
|
27346
27670
|
let cited = 0;
|
|
@@ -27374,8 +27698,14 @@ function countPeriod(snaps, competitors2) {
|
|
|
27374
27698
|
}
|
|
27375
27699
|
}
|
|
27376
27700
|
const mentionShare = buildMentionShare(
|
|
27377
|
-
snaps.map((s) => ({
|
|
27378
|
-
|
|
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 }
|
|
27379
27709
|
);
|
|
27380
27710
|
return {
|
|
27381
27711
|
checked,
|
|
@@ -27401,6 +27731,10 @@ function isUnknownModelEvidence(evidence) {
|
|
|
27401
27731
|
}
|
|
27402
27732
|
function computeVisibilityCompare(input) {
|
|
27403
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;
|
|
27404
27738
|
const fromObs = observed(input.from.snapshots, attribution);
|
|
27405
27739
|
const toObs = observed(input.to.snapshots, attribution);
|
|
27406
27740
|
const queriesObservedBoth = new Set([...fromObs.queryIds].filter((q) => toObs.queryIds.has(q)));
|
|
@@ -27410,8 +27744,8 @@ function computeVisibilityCompare(input) {
|
|
|
27410
27744
|
const candidateProviders = new Set([...pairsBoth.values()].map((pair) => pair.provider));
|
|
27411
27745
|
const fromCandidateSnaps = restrict(input.from.snapshots, attribution, pairsBoth);
|
|
27412
27746
|
const toCandidateSnaps = restrict(input.to.snapshots, attribution, pairsBoth);
|
|
27413
|
-
const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors);
|
|
27414
|
-
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 ?? []);
|
|
27415
27749
|
const continuityProviders = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
|
|
27416
27750
|
const fromEvidence = classifyModelEvidence(fromCandidateCounts.modelEvidence.get(provider) ?? []);
|
|
27417
27751
|
const toEvidence = classifyModelEvidence(toCandidateCounts.modelEvidence.get(provider) ?? []);
|
|
@@ -27430,8 +27764,8 @@ function computeVisibilityCompare(input) {
|
|
|
27430
27764
|
const excludedProviders = [.../* @__PURE__ */ new Set([...fromObs.providers, ...toObs.providers])].filter((p) => !providersBoth.has(p)).sort((a, b) => a.localeCompare(b));
|
|
27431
27765
|
const fromSnaps = restrict(input.from.snapshots, attribution, comparedPairs);
|
|
27432
27766
|
const toSnaps = restrict(input.to.snapshots, attribution, comparedPairs);
|
|
27433
|
-
const fromCounts = countPeriod(fromSnaps, input.competitors);
|
|
27434
|
-
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 ?? []);
|
|
27435
27769
|
const shareCounts = (c) => ({
|
|
27436
27770
|
proj: c.mentionShare.breakdown.projectMentionSnapshots,
|
|
27437
27771
|
comp: c.mentionShare.breakdown.competitorMentionSnapshots
|
|
@@ -27442,9 +27776,10 @@ function computeVisibilityCompare(input) {
|
|
|
27442
27776
|
metric(
|
|
27443
27777
|
"mention-share-of-voice",
|
|
27444
27778
|
"Named share of voice",
|
|
27779
|
+
classificationAvailable ? "non-brand" : "pooled",
|
|
27445
27780
|
true,
|
|
27446
|
-
period(fromShare.proj, fromShare.proj + fromShare.comp),
|
|
27447
|
-
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),
|
|
27448
27783
|
continuityBlock
|
|
27449
27784
|
),
|
|
27450
27785
|
// Share of voice is undefined without a competitive frame: with zero
|
|
@@ -27455,14 +27790,16 @@ function computeVisibilityCompare(input) {
|
|
|
27455
27790
|
metric(
|
|
27456
27791
|
"cited-share-of-voice",
|
|
27457
27792
|
"Cited share of voice",
|
|
27793
|
+
"all",
|
|
27458
27794
|
true,
|
|
27459
|
-
input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) :
|
|
27460
|
-
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),
|
|
27461
27797
|
continuityBlock
|
|
27462
27798
|
),
|
|
27463
27799
|
metric(
|
|
27464
27800
|
"mention-rate",
|
|
27465
27801
|
"Named rate",
|
|
27802
|
+
"all",
|
|
27466
27803
|
false,
|
|
27467
27804
|
period(fromCounts.mentioned, fromCounts.checked),
|
|
27468
27805
|
period(toCounts.mentioned, toCounts.checked),
|
|
@@ -27471,6 +27808,7 @@ function computeVisibilityCompare(input) {
|
|
|
27471
27808
|
metric(
|
|
27472
27809
|
"cited-rate",
|
|
27473
27810
|
"Cited rate",
|
|
27811
|
+
"all",
|
|
27474
27812
|
false,
|
|
27475
27813
|
period(fromCounts.cited, fromCounts.total),
|
|
27476
27814
|
period(toCounts.cited, toCounts.total),
|
|
@@ -27628,7 +27966,8 @@ async function visibilityStatsRoutes(app) {
|
|
|
27628
27966
|
lastRuns: lastRunsRaw,
|
|
27629
27967
|
groupBy: groupByRaw,
|
|
27630
27968
|
month: monthRaw,
|
|
27631
|
-
shareOfVoice: shareOfVoiceRaw
|
|
27969
|
+
shareOfVoice: shareOfVoiceRaw,
|
|
27970
|
+
queryClass: queryClassRaw
|
|
27632
27971
|
} = request.query;
|
|
27633
27972
|
let groupBy = null;
|
|
27634
27973
|
if (groupByRaw !== void 0 && groupByRaw !== "") {
|
|
@@ -27640,6 +27979,9 @@ async function visibilityStatsRoutes(app) {
|
|
|
27640
27979
|
const hasLastRuns = lastRunsRaw !== void 0 && lastRunsRaw !== "";
|
|
27641
27980
|
const hasMonth = monthRaw !== void 0 && monthRaw !== "";
|
|
27642
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
|
+
})();
|
|
27643
27985
|
if (hasLastRuns && (hasSince || hasUntil)) {
|
|
27644
27986
|
throw validationError('"lastRuns" cannot be combined with "since"/"until" \u2014 use one or the other');
|
|
27645
27987
|
}
|
|
@@ -27699,10 +28041,6 @@ async function visibilityStatsRoutes(app) {
|
|
|
27699
28041
|
let shareOfVoice;
|
|
27700
28042
|
if (wantShareOfVoice) {
|
|
27701
28043
|
const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, project.id)).all();
|
|
27702
|
-
const mentionShareCompetitors = competitorRows.map((c) => ({
|
|
27703
|
-
domain: c.domain,
|
|
27704
|
-
brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
|
|
27705
|
-
}));
|
|
27706
28044
|
const sovSnapshots = runIds.length > 0 ? app.db.select({
|
|
27707
28045
|
queryId: querySnapshots.queryId,
|
|
27708
28046
|
queryText: querySnapshots.queryText,
|
|
@@ -27710,16 +28048,29 @@ async function visibilityStatsRoutes(app) {
|
|
|
27710
28048
|
answerText: querySnapshots.answerText
|
|
27711
28049
|
}).from(querySnapshots).where(inArray14(querySnapshots.runId, runIds)).all() : [];
|
|
27712
28050
|
const attributedSovSnapshots = sovSnapshots.filter((s) => resolveCurrentQuery(queryAttribution, s) !== void 0);
|
|
27713
|
-
const
|
|
27714
|
-
|
|
27715
|
-
|
|
27716
|
-
|
|
27717
|
-
|
|
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;
|
|
27718
28066
|
const denom = b.projectMentionSnapshots + b.competitorMentionSnapshots;
|
|
27719
28067
|
shareOfVoice = {
|
|
27720
|
-
|
|
27721
|
-
//
|
|
27722
|
-
|
|
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,
|
|
27723
28074
|
projectMentions: b.projectMentionSnapshots,
|
|
27724
28075
|
competitorMentions: b.competitorMentionSnapshots,
|
|
27725
28076
|
snapshotsWithAnswerText: b.snapshotsWithAnswerText,
|
|
@@ -27769,10 +28120,7 @@ async function visibilityStatsRoutes(app) {
|
|
|
27769
28120
|
}
|
|
27770
28121
|
const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq32(queries.projectId, project.id)).all();
|
|
27771
28122
|
const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, project.id)).all();
|
|
27772
|
-
const competitorInputs = competitorRows.map((c) =>
|
|
27773
|
-
domain: c.domain,
|
|
27774
|
-
brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
|
|
27775
|
-
}));
|
|
28123
|
+
const competitorInputs = mentionShareCompetitorsFromDomains(competitorRows.map((c) => c.domain));
|
|
27776
28124
|
const loadMonth = (bounds) => {
|
|
27777
28125
|
const sinceMs = Date.parse(bounds.since);
|
|
27778
28126
|
const untilMs = Date.parse(bounds.until);
|
|
@@ -27798,6 +28146,7 @@ async function visibilityStatsRoutes(app) {
|
|
|
27798
28146
|
project: project.name,
|
|
27799
28147
|
queries: projectQueries,
|
|
27800
28148
|
competitors: competitorInputs,
|
|
28149
|
+
brandNames: effectiveBrandNames(project),
|
|
27801
28150
|
from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
|
|
27802
28151
|
to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
|
|
27803
28152
|
});
|
|
@@ -28111,25 +28460,35 @@ async function compositeRoutes(app) {
|
|
|
28111
28460
|
}
|
|
28112
28461
|
}
|
|
28113
28462
|
const configuredApiProviders = project.providers.filter((p) => !p.startsWith("cdp:"));
|
|
28114
|
-
const
|
|
28115
|
-
|
|
28116
|
-
|
|
28117
|
-
|
|
28118
|
-
|
|
28119
|
-
|
|
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
|
+
})
|
|
28120
28479
|
}));
|
|
28121
28480
|
const scores = {
|
|
28122
28481
|
mention: buildMentionCoverage(trackedLatest, { configuredApiProviders }),
|
|
28123
28482
|
visibility: buildVisibilityScore(trackedLatest, { configuredApiProviders }),
|
|
28124
28483
|
mentionShare: buildMentionShare(
|
|
28125
|
-
|
|
28126
|
-
|
|
28127
|
-
|
|
28128
|
-
|
|
28129
|
-
|
|
28484
|
+
mentionShareInputs.snapshots,
|
|
28485
|
+
{
|
|
28486
|
+
competitors: mentionShareInputs.competitors,
|
|
28487
|
+
classificationAvailable: mentionShareInputs.classified
|
|
28488
|
+
}
|
|
28130
28489
|
),
|
|
28131
|
-
gapQueries: buildGapQueryScore(
|
|
28132
|
-
mentionGaps: buildMentionGapScore(
|
|
28490
|
+
gapQueries: buildGapQueryScore(gapSignalSnapshots),
|
|
28491
|
+
mentionGaps: buildMentionGapScore(gapSignalSnapshots),
|
|
28133
28492
|
indexCoverage: buildIndexCoverageScore(app, project.id),
|
|
28134
28493
|
competitorPressure: buildCompetitorPressureScore(
|
|
28135
28494
|
trackedLatest,
|
|
@@ -28311,6 +28670,7 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
|
|
|
28311
28670
|
citationState: querySnapshots.citationState,
|
|
28312
28671
|
answerMentioned: querySnapshots.answerMentioned,
|
|
28313
28672
|
answerText: querySnapshots.answerText,
|
|
28673
|
+
rawResponse: querySnapshots.rawResponse,
|
|
28314
28674
|
competitorOverlap: querySnapshots.competitorOverlap,
|
|
28315
28675
|
citedDomains: querySnapshots.citedDomains
|
|
28316
28676
|
}).from(querySnapshots).where(inArray15(querySnapshots.runId, [...runIds])).all();
|
|
@@ -28342,6 +28702,7 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
|
|
|
28342
28702
|
citationState: row.citationState,
|
|
28343
28703
|
answerMentioned: row.answerMentioned,
|
|
28344
28704
|
answerText: row.answerText,
|
|
28705
|
+
groundingSources: parseOverviewGroundingSources(row.rawResponse),
|
|
28345
28706
|
competitorOverlap: row.competitorOverlap,
|
|
28346
28707
|
citedDomains: row.citedDomains
|
|
28347
28708
|
});
|
|
@@ -28349,6 +28710,16 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
|
|
|
28349
28710
|
}
|
|
28350
28711
|
return result;
|
|
28351
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
|
+
}
|
|
28352
28723
|
function summarizeFromSnapshots(snapshots) {
|
|
28353
28724
|
const empty = {
|
|
28354
28725
|
queryCounts: {
|
|
@@ -29435,9 +29806,15 @@ var compareToQueryParameter = {
|
|
|
29435
29806
|
var shareOfVoiceQueryParameter = {
|
|
29436
29807
|
name: "shareOfVoice",
|
|
29437
29808
|
in: "query",
|
|
29438
|
-
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.',
|
|
29439
29810
|
schema: { type: "string", enum: ["1"] }
|
|
29440
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
|
+
};
|
|
29441
29818
|
var wordpressEnvQueryParameter = {
|
|
29442
29819
|
name: "env",
|
|
29443
29820
|
in: "query",
|
|
@@ -31180,7 +31557,7 @@ var routeCatalog = [
|
|
|
31180
31557
|
method: "get",
|
|
31181
31558
|
path: "/api/v1/projects/{name}/visibility-stats",
|
|
31182
31559
|
summary: "Get aggregated mention/citation stats per query",
|
|
31183
|
-
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).",
|
|
31184
31561
|
tags: ["analytics"],
|
|
31185
31562
|
parameters: [
|
|
31186
31563
|
nameParameter,
|
|
@@ -31189,7 +31566,8 @@ var routeCatalog = [
|
|
|
31189
31566
|
lastRunsQueryParameter,
|
|
31190
31567
|
groupByProviderQueryParameter,
|
|
31191
31568
|
monthQueryParameter,
|
|
31192
|
-
shareOfVoiceQueryParameter
|
|
31569
|
+
shareOfVoiceQueryParameter,
|
|
31570
|
+
shareOfVoiceQueryClassParameter
|
|
31193
31571
|
],
|
|
31194
31572
|
responses: {
|
|
31195
31573
|
200: jsonResponse("Aggregated visibility stats returned.", "VisibilityStatsDto"),
|
|
@@ -65376,6 +65754,16 @@ function matchesBrandAlias(candidate, aliases) {
|
|
|
65376
65754
|
var RECURRENCE_LOOKBACK_RUNS = 5;
|
|
65377
65755
|
var HISTORY_WINDOW_RUNS = Math.max(PERSISTENT_GAP_THRESHOLD, 5);
|
|
65378
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
|
+
}
|
|
65379
65767
|
function gbpInsightSlot(typeOrSlot) {
|
|
65380
65768
|
return typeOrSlot === "gbp-lodging-gap" || typeOrSlot === "gbp-listing-discrepancy" ? "lodging" : typeOrSlot;
|
|
65381
65769
|
}
|
|
@@ -65418,11 +65806,13 @@ var IntelligenceService = class {
|
|
|
65418
65806
|
log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
|
|
65419
65807
|
return null;
|
|
65420
65808
|
}
|
|
65809
|
+
const trackedCompetitors = this.loadTrackedCompetitors(projectId);
|
|
65421
65810
|
const currentRun = this.buildRunData(
|
|
65422
65811
|
runId,
|
|
65423
65812
|
projectId,
|
|
65424
65813
|
currentRunRecord.finishedAt ?? currentRunRecord.createdAt,
|
|
65425
|
-
currentRunRecord.location ?? null
|
|
65814
|
+
currentRunRecord.location ?? null,
|
|
65815
|
+
trackedCompetitors
|
|
65426
65816
|
);
|
|
65427
65817
|
if (currentRun.snapshots.length === 0) {
|
|
65428
65818
|
log.info("intelligence.skip", { runId, reason: "no snapshots" });
|
|
@@ -65437,10 +65827,16 @@ var IntelligenceService = class {
|
|
|
65437
65827
|
previousRunRecord.id,
|
|
65438
65828
|
projectId,
|
|
65439
65829
|
previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
|
|
65440
|
-
previousRunRecord.location ?? null
|
|
65830
|
+
previousRunRecord.location ?? null,
|
|
65831
|
+
trackedCompetitors
|
|
65441
65832
|
) : null;
|
|
65442
|
-
const
|
|
65443
|
-
|
|
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
|
+
));
|
|
65444
65840
|
if (!previousRun) {
|
|
65445
65841
|
const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
|
|
65446
65842
|
log.info("intelligence.analyzed", {
|
|
@@ -65644,11 +66040,13 @@ var IntelligenceService = class {
|
|
|
65644
66040
|
* would have produced.
|
|
65645
66041
|
*/
|
|
65646
66042
|
analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords, opts) {
|
|
66043
|
+
const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
|
|
65647
66044
|
const currentRun = this.buildRunData(
|
|
65648
66045
|
runRecord.id,
|
|
65649
66046
|
runRecord.projectId,
|
|
65650
66047
|
runRecord.finishedAt ?? runRecord.createdAt,
|
|
65651
|
-
runRecord.location ?? null
|
|
66048
|
+
runRecord.location ?? null,
|
|
66049
|
+
trackedCompetitors
|
|
65652
66050
|
);
|
|
65653
66051
|
if (currentRun.snapshots.length === 0) {
|
|
65654
66052
|
return null;
|
|
@@ -65657,10 +66055,16 @@ var IntelligenceService = class {
|
|
|
65657
66055
|
previousRunRecord.id,
|
|
65658
66056
|
previousRunRecord.projectId,
|
|
65659
66057
|
previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
|
|
65660
|
-
previousRunRecord.location ?? null
|
|
66058
|
+
previousRunRecord.location ?? null,
|
|
66059
|
+
trackedCompetitors
|
|
65661
66060
|
) : null;
|
|
65662
|
-
const
|
|
65663
|
-
|
|
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
|
+
));
|
|
65664
66068
|
if (!previousRun) {
|
|
65665
66069
|
const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
|
|
65666
66070
|
const emptyResult = this.emptyAnalysisResult(result2);
|
|
@@ -65930,12 +66334,13 @@ var IntelligenceService = class {
|
|
|
65930
66334
|
return { ...insight, severity };
|
|
65931
66335
|
});
|
|
65932
66336
|
}
|
|
65933
|
-
buildRunData(runId, projectId, completedAt, location = null) {
|
|
66337
|
+
buildRunData(runId, projectId, completedAt, location = null, trackedCompetitors = []) {
|
|
65934
66338
|
const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(eq60(projects.id, projectId)).get();
|
|
65935
66339
|
const projectDomains = projectDomainRow ? effectiveDomains({
|
|
65936
66340
|
canonicalDomain: projectDomainRow.canonicalDomain,
|
|
65937
66341
|
ownedDomains: projectDomainRow.ownedDomains
|
|
65938
66342
|
}) : [];
|
|
66343
|
+
const competitiveSignalResolver = compileCompetitiveSignalResolver(trackedCompetitors);
|
|
65939
66344
|
const rows = this.db.select({
|
|
65940
66345
|
query: queries.query,
|
|
65941
66346
|
// Denormalized query text persisted by v58 — the fallback when the
|
|
@@ -65946,7 +66351,7 @@ var IntelligenceService = class {
|
|
|
65946
66351
|
citationState: querySnapshots.citationState,
|
|
65947
66352
|
answerMentioned: querySnapshots.answerMentioned,
|
|
65948
66353
|
citedDomains: querySnapshots.citedDomains,
|
|
65949
|
-
|
|
66354
|
+
rawResponse: querySnapshots.rawResponse,
|
|
65950
66355
|
snapshotLocation: querySnapshots.location
|
|
65951
66356
|
}).from(querySnapshots).leftJoin(queries, eq60(querySnapshots.queryId, queries.id)).where(eq60(querySnapshots.runId, runId)).all();
|
|
65952
66357
|
const snapshots = [];
|
|
@@ -65958,7 +66363,10 @@ var IntelligenceService = class {
|
|
|
65958
66363
|
continue;
|
|
65959
66364
|
}
|
|
65960
66365
|
const domains = r.citedDomains;
|
|
65961
|
-
const
|
|
66366
|
+
const citedCompetitorDomains = competitiveSignalResolver.resolve({
|
|
66367
|
+
citedDomains: domains,
|
|
66368
|
+
groundingSources: readStoredGroundingSources(r.rawResponse)
|
|
66369
|
+
}).citedCompetitorDomains;
|
|
65962
66370
|
snapshots.push({
|
|
65963
66371
|
query: resolvedQuery,
|
|
65964
66372
|
provider: r.provider,
|
|
@@ -65976,7 +66384,7 @@ var IntelligenceService = class {
|
|
|
65976
66384
|
// we read it from the snapshot row so a stale runs.location can't
|
|
65977
66385
|
// mask snapshot truth.
|
|
65978
66386
|
location: r.snapshotLocation ?? location ?? null,
|
|
65979
|
-
|
|
66387
|
+
citedCompetitorDomains,
|
|
65980
66388
|
// citedDomains is the FULL set (tracked competitors + third-party
|
|
65981
66389
|
// sources). Cause analysis uses it to name the displacing source
|
|
65982
66390
|
// when no tracked competitor appears in the response.
|