@canonry/canonry 5.28.1 → 5.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/assets/{AuditHistoryPanel-CWd9UN6L.js → AuditHistoryPanel-B_H7nIp7.js} +1 -1
- package/assets/assets/{BacklinksPage-B2gPTzyE.js → BacklinksPage-rhms6z2S.js} +1 -1
- package/assets/assets/{HistoryPage-xtkG8SZN.js → HistoryPage-CWPETgrG.js} +1 -1
- package/assets/assets/MeasurementPropertyPage-D9-54Je9.js +1 -0
- package/assets/assets/ProjectPage-eNVzxsZW.js +8 -0
- package/assets/assets/{RunRow-DnSo7Nle.js → RunRow-DQPW7FIF.js} +1 -1
- package/assets/assets/{RunsPage-FAEuo4Ra.js → RunsPage-BOM_RkNi.js} +1 -1
- package/assets/assets/{SettingsPage-C4OZ1HB9.js → SettingsPage-BDnT6wtM.js} +1 -1
- package/assets/assets/SiteHealthSection-lcZA1XpM.js +4 -0
- package/assets/assets/{TrafficPage-D03S6xF6.js → TrafficPage-D4nGSfXx.js} +2 -2
- package/assets/assets/{TrafficSourceDetailPage-DBu6oFFK.js → TrafficSourceDetailPage-CvZcCbic.js} +1 -1
- package/assets/assets/{extract-error-message-BuH2CCaJ.js → extract-error-message-BJ3f0StM.js} +1 -1
- package/assets/assets/index-Cncchurw.js +86 -0
- package/assets/assets/{react-sigma_core.esm.min-CxwZsgxR.js → react-sigma_core.esm.min-c7qzyLLe.js} +1 -1
- package/assets/assets/{v2-overview-adapter-DTCKxzZT.js → v2-overview-adapter-Dsd6dWtE.js} +1 -1
- package/assets/index.html +1 -1
- package/dist/{chunk-DX2JUC5H.js → chunk-ASYPQYVV.js} +3 -3
- package/dist/{chunk-KVQHG3WB.js → chunk-FKL5EGOW.js} +6302 -5914
- package/dist/{chunk-EP4STEPI.js → chunk-N2SVFXOB.js} +164 -96
- package/dist/{chunk-ONTEFAA7.js → chunk-PTTVDWMN.js} +57 -3
- package/dist/{chunk-7RKCDOWO.js → chunk-RHZKOXDZ.js} +263 -190
- package/dist/{chunk-MPICT6V7.js → chunk-WL2SHCPU.js} +3 -3
- package/dist/{chunk-FNGITGDF.js → chunk-XWBQWAVE.js} +2 -2
- package/dist/cli.js +134 -123
- package/dist/{demo-server-HWWIMGD6.js → demo-server-VGUIDC5T.js} +24 -32
- package/dist/index.js +6 -6
- package/dist/{intelligence-service-6LDMVI66.js → intelligence-service-IQA4U3RA.js} +2 -2
- package/dist/mcp.js +4 -4
- package/package.json +12 -12
- package/assets/assets/MeasurementPropertyPage-DxIMU3_T.js +0 -1
- package/assets/assets/ProjectPage-CMAFZq_s.js +0 -8
- package/assets/assets/SiteHealthSection-BzMxlz8-.js +0 -4
- package/assets/assets/index-Ba-Zs45G.js +0 -86
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
GOOGLE_MARKETING_LIVE_READ_SCOPE,
|
|
62
62
|
GOOGLE_MARKETING_STORED_SNAPSHOT_PAGE_MAX,
|
|
63
63
|
GOOGLE_MARKETING_WRITE_SCOPE,
|
|
64
|
+
GaMoverChangeBases,
|
|
64
65
|
GoogleAdsCampaignStatuses,
|
|
65
66
|
GoogleAdsConnectionStates,
|
|
66
67
|
GoogleAdsGoalConfigurationLevels,
|
|
@@ -92,6 +93,7 @@ import {
|
|
|
92
93
|
MEASUREMENT_PORTFOLIO_TIE_NOTE,
|
|
93
94
|
MEASUREMENT_PROPERTY_CITED_DOMAINS_LIMIT,
|
|
94
95
|
MIN_DOMAIN_BRAND_KEY_LENGTH,
|
|
96
|
+
MIN_PCT_BASE,
|
|
95
97
|
MIN_TREND_POINTS,
|
|
96
98
|
MODEL_ATTRIBUTION_EVENT_LIMIT,
|
|
97
99
|
MeasurementEvidenceShapes,
|
|
@@ -110,6 +112,7 @@ import {
|
|
|
110
112
|
REPORT_VISIBILITY_COPY,
|
|
111
113
|
RESEARCH_RUN_SCOPE,
|
|
112
114
|
RUN_FILL_MAX_AGE_MS,
|
|
115
|
+
RatioUnits,
|
|
113
116
|
ReportSectionIds,
|
|
114
117
|
ResearchQueryStatuses,
|
|
115
118
|
ResearchRunStatuses,
|
|
@@ -211,6 +214,7 @@ import {
|
|
|
211
214
|
aggregateHarvestedQueries,
|
|
212
215
|
aiReferralClassCounts,
|
|
213
216
|
alreadyExists,
|
|
217
|
+
answerProseForMentions,
|
|
214
218
|
apiKeyDtoSchema,
|
|
215
219
|
apiKeyListDtoSchema,
|
|
216
220
|
applyHarvestSemanticNovelty,
|
|
@@ -238,6 +242,7 @@ import {
|
|
|
238
242
|
brandKeyFromText,
|
|
239
243
|
brandLabelFromDomain,
|
|
240
244
|
brandMetricsDtoSchema,
|
|
245
|
+
breakdownShares,
|
|
241
246
|
buildHarvestAnchorTerms,
|
|
242
247
|
buildMeasurementExecutionIdentity,
|
|
243
248
|
buildMeasurementRunManifestV1,
|
|
@@ -351,8 +356,8 @@ import {
|
|
|
351
356
|
formatIsoDate,
|
|
352
357
|
formatIsoDateInTimeZone,
|
|
353
358
|
formatNumber,
|
|
354
|
-
|
|
355
|
-
|
|
359
|
+
formatPercent,
|
|
360
|
+
formatSignedPercent,
|
|
356
361
|
formatWindowCountDelta,
|
|
357
362
|
ga4AiReferralDailyDtoSchema,
|
|
358
363
|
ga4AiReferralHistoryEntrySchema,
|
|
@@ -361,9 +366,12 @@ import {
|
|
|
361
366
|
ga4SocialReferralHistoryEntrySchema,
|
|
362
367
|
ga4StatusDtoSchema,
|
|
363
368
|
ga4SyncResponseDtoSchema,
|
|
369
|
+
gaAttributionTrendResponseSchema,
|
|
364
370
|
gaMeasurementAnalysisDtoSchema,
|
|
365
371
|
gaMeasurementAnalysisWindowSchema,
|
|
366
372
|
gaMeasurementHostScopeSchema,
|
|
373
|
+
gaSocialReferralTrendResponseSchema,
|
|
374
|
+
gaTrafficResponseSchema,
|
|
367
375
|
gateHarvestedSearchQueries,
|
|
368
376
|
gbpAccountListResponseSchema,
|
|
369
377
|
gbpAttributesListResponseSchema,
|
|
@@ -584,6 +592,7 @@ import {
|
|
|
584
592
|
parseRunError,
|
|
585
593
|
parseStoredMeasurementPlanAnyVersion,
|
|
586
594
|
parseWindow,
|
|
595
|
+
percentOf,
|
|
587
596
|
pickClusterRepresentative,
|
|
588
597
|
prepareBrandMatchText,
|
|
589
598
|
projectConfigSchema,
|
|
@@ -665,6 +674,7 @@ import {
|
|
|
665
674
|
reportProviderRateLabel,
|
|
666
675
|
reportQueryClassLabel,
|
|
667
676
|
reportRateDeltaCopy,
|
|
677
|
+
reportRateDeltaValue,
|
|
668
678
|
reportReferralRedirectNote,
|
|
669
679
|
reportServerActivityAgencyOperatorHeaders,
|
|
670
680
|
reportServerActivityAgencyTiles,
|
|
@@ -708,6 +718,7 @@ import {
|
|
|
708
718
|
resultsClearResponseSchema,
|
|
709
719
|
resultsExportDtoSchema,
|
|
710
720
|
retryAfterDelayMs,
|
|
721
|
+
roundRatio,
|
|
711
722
|
runCompletenessDtoSchema,
|
|
712
723
|
runDetailDtoSchema,
|
|
713
724
|
runDtoSchema,
|
|
@@ -734,6 +745,7 @@ import {
|
|
|
734
745
|
segmentCrawlerHits,
|
|
735
746
|
serializeRunError,
|
|
736
747
|
settingsDtoSchema,
|
|
748
|
+
shareOf,
|
|
737
749
|
shareOfVoiceReason,
|
|
738
750
|
shareOfVoiceSummary,
|
|
739
751
|
shiftIsoCalendarDate,
|
|
@@ -772,6 +784,7 @@ import {
|
|
|
772
784
|
sourceBreakdownDtoSchema,
|
|
773
785
|
sourceBreakdownQuerySchema,
|
|
774
786
|
splitList,
|
|
787
|
+
stripCitationChips,
|
|
775
788
|
sumInfraHits,
|
|
776
789
|
summarizeCheckResults,
|
|
777
790
|
summarizeRunUsage,
|
|
@@ -825,7 +838,7 @@ import {
|
|
|
825
838
|
wordpressSchemaDeployResultDtoSchema,
|
|
826
839
|
wordpressSchemaStatusResultDtoSchema,
|
|
827
840
|
wordpressStatusDtoSchema
|
|
828
|
-
} from "./chunk-
|
|
841
|
+
} from "./chunk-FKL5EGOW.js";
|
|
829
842
|
|
|
830
843
|
// src/intelligence-service.ts
|
|
831
844
|
import { eq as eq79, desc as desc34, asc as asc14, and as and66, ne as ne13, or as or18, inArray as inArray29, gte as gte21, lte as lte18, isNull as isNull14, sql as sql29, exists } from "drizzle-orm";
|
|
@@ -9059,8 +9072,7 @@ function buildDrivers(input) {
|
|
|
9059
9072
|
drivers.push(`${formatImpressions(input.gscImpressions)} GSC impressions`);
|
|
9060
9073
|
}
|
|
9061
9074
|
if (input.recentMissRate >= 0.5 && input.competitorCount > 0) {
|
|
9062
|
-
|
|
9063
|
-
drivers.push(`missed in ${pct}% of recent runs`);
|
|
9075
|
+
drivers.push(`missed in ${formatPercent(clamp01(input.recentMissRate))} of recent runs`);
|
|
9064
9076
|
}
|
|
9065
9077
|
if (input.action === "create" && input.position === null) {
|
|
9066
9078
|
drivers.push("no existing page");
|
|
@@ -9443,8 +9455,8 @@ function buildCitationScorecard(snapshots, queryLookup) {
|
|
|
9443
9455
|
}
|
|
9444
9456
|
const providerRates = providerList.map((provider) => {
|
|
9445
9457
|
const counts2 = providerCounts.get(provider) ?? { cited: 0, mentioned: 0, total: 0 };
|
|
9446
|
-
const citationRate =
|
|
9447
|
-
const mentionRate3 =
|
|
9458
|
+
const citationRate = percentOf(counts2.cited, counts2.total) ?? 0;
|
|
9459
|
+
const mentionRate3 = percentOf(counts2.mentioned, counts2.total) ?? 0;
|
|
9448
9460
|
return {
|
|
9449
9461
|
provider,
|
|
9450
9462
|
citedCount: counts2.cited,
|
|
@@ -9485,8 +9497,9 @@ function compileCompetitiveSignalResolver(competitorDomains) {
|
|
|
9485
9497
|
...evidence.citedDomains ?? [],
|
|
9486
9498
|
...(evidence.groundingSources ?? []).map((source) => source.uri)
|
|
9487
9499
|
];
|
|
9488
|
-
const
|
|
9489
|
-
const
|
|
9500
|
+
const prose = answerProseForMentions(evidence.answerText);
|
|
9501
|
+
const answerDomains2 = evidence.answerDomains ?? extractDomainsFromText(prose);
|
|
9502
|
+
const mentionedBrandKeys = matchedAliasKeys(domainBrandMatcher, prose);
|
|
9490
9503
|
const citedCompetitorDomains = [];
|
|
9491
9504
|
const mentionedCompetitorDomains = [];
|
|
9492
9505
|
for (const identity of identities) {
|
|
@@ -9549,7 +9562,7 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
|
|
|
9549
9562
|
else if (ratio >= 0.2) pressureLabel = "Moderate";
|
|
9550
9563
|
else pressureLabel = "Low";
|
|
9551
9564
|
}
|
|
9552
|
-
const sharePct =
|
|
9565
|
+
const sharePct = percentOf(data.count, totalCitedSlots) ?? 0;
|
|
9553
9566
|
const theirCitedPages = [...data.pages.entries()].map(([url, qs]) => ({ url, citedFor: [...qs].sort() })).sort((a, b) => b.citedFor.length - a.citedFor.length);
|
|
9554
9567
|
return {
|
|
9555
9568
|
domain,
|
|
@@ -9584,17 +9597,37 @@ function toBreakdown(tally, competitors2) {
|
|
|
9584
9597
|
const perCompetitor = competitors2.map((c) => ({
|
|
9585
9598
|
domain: c.domain,
|
|
9586
9599
|
mentionSnapshots: tally.competitorCounts.get(c.domain) ?? 0,
|
|
9587
|
-
shareOfCompetitiveTotal:
|
|
9600
|
+
shareOfCompetitiveTotal: percentOf(tally.competitorCounts.get(c.domain) ?? 0, competitorMentionSnapshots) ?? 0
|
|
9588
9601
|
})).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots || (a.domain < b.domain ? -1 : 1));
|
|
9589
9602
|
return {
|
|
9590
9603
|
projectMentionSnapshots: tally.projectMentionSnapshots,
|
|
9591
9604
|
competitorMentionSnapshots,
|
|
9605
|
+
combinedMentionSnapshots: denom,
|
|
9592
9606
|
perCompetitor,
|
|
9607
|
+
ranking: rankMentionShare(tally, competitors2, denom),
|
|
9593
9608
|
snapshotsWithAnswerText: tally.snapshotsWithAnswerText,
|
|
9594
9609
|
snapshotsTotal: tally.snapshotsTotal,
|
|
9595
|
-
score:
|
|
9610
|
+
score: percentOf(tally.projectMentionSnapshots, denom)
|
|
9596
9611
|
};
|
|
9597
9612
|
}
|
|
9613
|
+
function rankMentionShare(tally, competitors2, denom) {
|
|
9614
|
+
if (competitors2.length === 0 || denom === 0) return [];
|
|
9615
|
+
const project = {
|
|
9616
|
+
kind: "project",
|
|
9617
|
+
domain: null,
|
|
9618
|
+
mentionSnapshots: tally.projectMentionSnapshots,
|
|
9619
|
+
share: tally.projectMentionSnapshots / denom
|
|
9620
|
+
};
|
|
9621
|
+
const rivals = competitors2.map((c) => ({ domain: c.domain, mentionSnapshots: tally.competitorCounts.get(c.domain) ?? 0 })).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots || (a.domain < b.domain ? -1 : 1)).map((c) => ({
|
|
9622
|
+
kind: "competitor",
|
|
9623
|
+
domain: c.domain,
|
|
9624
|
+
mentionSnapshots: c.mentionSnapshots,
|
|
9625
|
+
share: c.mentionSnapshots / denom
|
|
9626
|
+
}));
|
|
9627
|
+
const firstNotAbove = rivals.findIndex((row) => row.mentionSnapshots <= project.mentionSnapshots);
|
|
9628
|
+
const at = firstNotAbove === -1 ? rivals.length : firstNotAbove;
|
|
9629
|
+
return [...rivals.slice(0, at), project, ...rivals.slice(at)];
|
|
9630
|
+
}
|
|
9598
9631
|
function buildMentionShare(snapshots, options) {
|
|
9599
9632
|
const competitorMatchers = new Map(
|
|
9600
9633
|
options.competitors.map((c) => [c.domain, compileBrandAliases(usableBrandAliases([...c.brandTokens]))])
|
|
@@ -9610,7 +9643,7 @@ function buildMentionShare(snapshots, options) {
|
|
|
9610
9643
|
tally.snapshotsWithAnswerText++;
|
|
9611
9644
|
if (snap.projectMentioned) tally.projectMentionSnapshots++;
|
|
9612
9645
|
if (options.competitors.length === 0) continue;
|
|
9613
|
-
const prepared = prepareBrandMatchText(text2);
|
|
9646
|
+
const prepared = prepareBrandMatchText(answerProseForMentions(text2));
|
|
9614
9647
|
for (const competitor of options.competitors) {
|
|
9615
9648
|
const matcher = competitorMatchers.get(competitor.domain);
|
|
9616
9649
|
if (matcher && matcherMatchesText(matcher, prepared)) {
|
|
@@ -9691,7 +9724,7 @@ function buildMentionShare(snapshots, options) {
|
|
|
9691
9724
|
value: "No mentions",
|
|
9692
9725
|
delta: scopeLabel(scope, "No brand mentions in this run"),
|
|
9693
9726
|
tone: "neutral",
|
|
9694
|
-
description: describe({ scope,
|
|
9727
|
+
description: describe({ scope, breakdown: headline, branded: brandedBreakdown }),
|
|
9695
9728
|
tooltip,
|
|
9696
9729
|
trend: [],
|
|
9697
9730
|
scope,
|
|
@@ -9701,10 +9734,10 @@ function buildMentionShare(snapshots, options) {
|
|
|
9701
9734
|
}
|
|
9702
9735
|
return {
|
|
9703
9736
|
label: "Mention Share",
|
|
9704
|
-
value:
|
|
9737
|
+
value: formatPercent(headline.projectMentionSnapshots / denom),
|
|
9705
9738
|
delta: scopeLabel(scope, `${headline.projectMentionSnapshots} of ${denom} brand mentions`),
|
|
9706
9739
|
tone: mentionShareTone(score),
|
|
9707
|
-
description: describe({ scope,
|
|
9740
|
+
description: describe({ scope, breakdown: headline, branded: brandedBreakdown }),
|
|
9708
9741
|
tooltip,
|
|
9709
9742
|
trend: [],
|
|
9710
9743
|
progress: score,
|
|
@@ -9722,7 +9755,7 @@ function mentionShareTone(score) {
|
|
|
9722
9755
|
return "negative";
|
|
9723
9756
|
}
|
|
9724
9757
|
function describe(parts) {
|
|
9725
|
-
const { scope,
|
|
9758
|
+
const { scope, breakdown: breakdown2, branded } = parts;
|
|
9726
9759
|
const { projectMentionSnapshots, competitorMentionSnapshots, perCompetitor } = breakdown2;
|
|
9727
9760
|
const where = scope === "non-brand" ? "on non-brand queries" : "across your tracked queries";
|
|
9728
9761
|
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.` : "";
|
|
@@ -9734,10 +9767,11 @@ function describe(parts) {
|
|
|
9734
9767
|
}
|
|
9735
9768
|
const top = perCompetitor[0];
|
|
9736
9769
|
const total = projectMentionSnapshots + competitorMentionSnapshots;
|
|
9770
|
+
const share = formatPercent(projectMentionSnapshots / total);
|
|
9737
9771
|
if (!top) {
|
|
9738
|
-
return `${
|
|
9772
|
+
return `${share} of brand mentions ${where} are you (${projectMentionSnapshots} of ${total}).${brandedNote}`;
|
|
9739
9773
|
}
|
|
9740
|
-
return `${
|
|
9774
|
+
return `${share} of brand mentions ${where} are you. Top competitor: ${top.domain} (${top.mentionSnapshots} mentions).${brandedNote}`;
|
|
9741
9775
|
}
|
|
9742
9776
|
|
|
9743
9777
|
// ../intelligence/src/share-of-voice-frame.ts
|
|
@@ -9754,7 +9788,7 @@ function buildShareOfVoiceFrame(input) {
|
|
|
9754
9788
|
basis,
|
|
9755
9789
|
reason,
|
|
9756
9790
|
availability: reason === null ? "measured" : "not-measured",
|
|
9757
|
-
score: reason === null ?
|
|
9791
|
+
score: reason === null ? percentOf(input.projectMentions, denominator) : null,
|
|
9758
9792
|
denominator,
|
|
9759
9793
|
competitorMentions,
|
|
9760
9794
|
domains
|
|
@@ -9875,7 +9909,7 @@ function buildCompetitorLandscapeHistory(options) {
|
|
|
9875
9909
|
for (const candidate of frozen.values()) putCandidate(eligibleCandidates, candidate);
|
|
9876
9910
|
combined = combineMatchers([...eligibleCandidates.values()].map((candidate) => candidate.matcher));
|
|
9877
9911
|
}
|
|
9878
|
-
const matchedKeys = matchedAliasKeys(combined, text2);
|
|
9912
|
+
const matchedKeys = matchedAliasKeys(combined, answerProseForMentions(text2));
|
|
9879
9913
|
if (matchedKeys.size > 0) {
|
|
9880
9914
|
for (const candidate of eligibleCandidates.values()) {
|
|
9881
9915
|
for (const key of candidate.matcher.keys) {
|
|
@@ -10058,7 +10092,7 @@ function finalizeRow(row, answeredResults, mentionCredits, competitive) {
|
|
|
10058
10092
|
surfaceClass: row.surfaceClass,
|
|
10059
10093
|
pinned: row.pinned,
|
|
10060
10094
|
mentionCount: row.mentionCount,
|
|
10061
|
-
shareOfVoice: competitive ?
|
|
10095
|
+
shareOfVoice: competitive ? percentOf(row.mentionCount, mentionCredits) : null,
|
|
10062
10096
|
citationCount: row.citationCount,
|
|
10063
10097
|
answeredResults,
|
|
10064
10098
|
firstSeenAt: row.firstSeenAt,
|
|
@@ -10066,9 +10100,6 @@ function finalizeRow(row, answeredResults, mentionCredits, competitive) {
|
|
|
10066
10100
|
sampleUrls: [...row.sampleUrls]
|
|
10067
10101
|
};
|
|
10068
10102
|
}
|
|
10069
|
-
function roundPercentage(value) {
|
|
10070
|
-
return Math.round(value * 10) / 10;
|
|
10071
|
-
}
|
|
10072
10103
|
function compareCompetitiveRows(a, b) {
|
|
10073
10104
|
return b.mentionCount - a.mentionCount || b.citationCount - a.citationCount || a.domain.localeCompare(b.domain);
|
|
10074
10105
|
}
|
|
@@ -10097,7 +10128,7 @@ function toSection(tally) {
|
|
|
10097
10128
|
else if (ratio >= 0.2) pressureLabel = "Moderate";
|
|
10098
10129
|
else pressureLabel = "Low";
|
|
10099
10130
|
}
|
|
10100
|
-
const sharePct =
|
|
10131
|
+
const sharePct = percentOf(data.count, totalMentionedSlots);
|
|
10101
10132
|
return {
|
|
10102
10133
|
domain,
|
|
10103
10134
|
mentionCount: data.count,
|
|
@@ -10186,7 +10217,7 @@ function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains, topDo
|
|
|
10186
10217
|
category,
|
|
10187
10218
|
label,
|
|
10188
10219
|
count: count4,
|
|
10189
|
-
sharePct:
|
|
10220
|
+
sharePct: percentOf(count4, totalCitations) ?? 0
|
|
10190
10221
|
})).sort((a, b) => b.count - a.count);
|
|
10191
10222
|
const topDomains = [...domainCounts.entries()].map(([domain, count4]) => ({
|
|
10192
10223
|
domain,
|
|
@@ -10378,13 +10409,13 @@ function buildVisibilityScore(snapshots, options) {
|
|
|
10378
10409
|
}
|
|
10379
10410
|
const totalCount = queryCited.size;
|
|
10380
10411
|
const citedCount = [...queryCited.values()].filter(Boolean).length;
|
|
10381
|
-
const score =
|
|
10412
|
+
const score = percentOf(citedCount, totalCount) ?? 0;
|
|
10382
10413
|
const runProviders = new Set(snapshots.map((s) => s.provider));
|
|
10383
10414
|
const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
|
|
10384
10415
|
const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
|
|
10385
10416
|
return {
|
|
10386
10417
|
label: "Citation Coverage",
|
|
10387
|
-
value:
|
|
10418
|
+
value: formatPercent(citedCount / totalCount),
|
|
10388
10419
|
delta: `${citedCount} of ${totalCount} queries cited`,
|
|
10389
10420
|
tone: isPartialProviderRun ? "caution" : scoreTone(score),
|
|
10390
10421
|
description: `${citedCount} of ${totalCount} tracked queries found your domain in at least one AI answer engine.`,
|
|
@@ -10416,13 +10447,13 @@ function buildMentionCoverage(snapshots, options) {
|
|
|
10416
10447
|
}
|
|
10417
10448
|
const totalCount = queryMentioned.size;
|
|
10418
10449
|
const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
|
|
10419
|
-
const score =
|
|
10450
|
+
const score = percentOf(mentionedCount, totalCount) ?? 0;
|
|
10420
10451
|
const runProviders = new Set(snapshots.map((s) => s.provider));
|
|
10421
10452
|
const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
|
|
10422
10453
|
const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
|
|
10423
10454
|
return {
|
|
10424
10455
|
label: "Mention Coverage",
|
|
10425
|
-
value:
|
|
10456
|
+
value: formatPercent(mentionedCount / totalCount),
|
|
10426
10457
|
delta: `${mentionedCount} of ${totalCount} queries mentioned`,
|
|
10427
10458
|
tone: isPartialProviderRun ? "caution" : scoreTone(score),
|
|
10428
10459
|
description: `${mentionedCount} of ${totalCount} tracked queries had your brand or domain in the AI answer text.`,
|
|
@@ -10468,7 +10499,7 @@ function buildGapQueryScore(snapshots) {
|
|
|
10468
10499
|
description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} currently cite competitors without citing your domain.` : "No competitive citation gaps detected in the latest visibility run.",
|
|
10469
10500
|
tooltip,
|
|
10470
10501
|
trend: [],
|
|
10471
|
-
progress:
|
|
10502
|
+
progress: percentOf(gapCount, totalCount) ?? 0
|
|
10472
10503
|
};
|
|
10473
10504
|
}
|
|
10474
10505
|
function buildMentionGapScore(snapshots) {
|
|
@@ -10505,7 +10536,7 @@ function buildMentionGapScore(snapshots) {
|
|
|
10505
10536
|
description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} mention competitors but never your brand.` : "No competitive mention gaps detected in the latest visibility run.",
|
|
10506
10537
|
tooltip,
|
|
10507
10538
|
trend: [],
|
|
10508
|
-
progress:
|
|
10539
|
+
progress: percentOf(gapCount, totalCount) ?? 0
|
|
10509
10540
|
};
|
|
10510
10541
|
}
|
|
10511
10542
|
|
|
@@ -10586,7 +10617,7 @@ function buildProviderScores(snapshots) {
|
|
|
10586
10617
|
).map(({ provider, model, cited, total }) => ({
|
|
10587
10618
|
provider,
|
|
10588
10619
|
model,
|
|
10589
|
-
score:
|
|
10620
|
+
score: percentOf(cited, total) ?? 0,
|
|
10590
10621
|
cited,
|
|
10591
10622
|
total
|
|
10592
10623
|
}));
|
|
@@ -10608,9 +10639,9 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
|
|
|
10608
10639
|
}
|
|
10609
10640
|
const totalCount = queryCited.size;
|
|
10610
10641
|
const citedCount = [...queryCited.values()].filter(Boolean).length;
|
|
10611
|
-
const citationRate =
|
|
10642
|
+
const citationRate = percentOf(citedCount, totalCount) ?? 0;
|
|
10612
10643
|
const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
|
|
10613
|
-
const mentionRate3 =
|
|
10644
|
+
const mentionRate3 = percentOf(mentionedCount, totalCount) ?? 0;
|
|
10614
10645
|
return {
|
|
10615
10646
|
runId: run.id,
|
|
10616
10647
|
createdAt: run.createdAt,
|
|
@@ -10642,7 +10673,8 @@ function buildProviderTrends(runs2, snapshotsByRunId, limit = 12) {
|
|
|
10642
10673
|
}
|
|
10643
10674
|
for (const key of keys) {
|
|
10644
10675
|
const queryMap = perKey.get(key);
|
|
10645
|
-
const
|
|
10676
|
+
const cited = queryMap ? [...queryMap.values()].filter(Boolean).length : 0;
|
|
10677
|
+
const rate2 = percentOf(cited, queryMap?.size ?? 0) ?? 0;
|
|
10646
10678
|
result.get(key).push({ rate: rate2, createdAt: run.createdAt });
|
|
10647
10679
|
}
|
|
10648
10680
|
}
|
|
@@ -10713,7 +10745,7 @@ function formatImpressions2(value) {
|
|
|
10713
10745
|
|
|
10714
10746
|
// ../intelligence/src/smoothed-delta.ts
|
|
10715
10747
|
var SMOOTHED_RUN_DELTA_MAX_WINDOW = 3;
|
|
10716
|
-
function smoothedRunDelta(points, valueFn, maxWindow = SMOOTHED_RUN_DELTA_MAX_WINDOW) {
|
|
10748
|
+
function smoothedRunDelta(points, valueFn, maxWindow = SMOOTHED_RUN_DELTA_MAX_WINDOW, ratioUnit) {
|
|
10717
10749
|
if (points.length < 2) return null;
|
|
10718
10750
|
const window = Math.min(maxWindow, Math.floor(points.length / 2));
|
|
10719
10751
|
const tail = points.slice(-window);
|
|
@@ -10721,8 +10753,9 @@ function smoothedRunDelta(points, valueFn, maxWindow = SMOOTHED_RUN_DELTA_MAX_WI
|
|
|
10721
10753
|
const sum = (arr) => arr.reduce((s, p) => s + valueFn(p), 0);
|
|
10722
10754
|
const currentAvg = sum(tail) / tail.length;
|
|
10723
10755
|
const priorAvg = sum(prior) / prior.length;
|
|
10724
|
-
const
|
|
10725
|
-
const
|
|
10756
|
+
const round = (value) => ratioUnit ? roundRatio(value, ratioUnit) : roundTo1Decimal(value);
|
|
10757
|
+
const current = round(currentAvg);
|
|
10758
|
+
const prior_ = round(priorAvg);
|
|
10726
10759
|
return {
|
|
10727
10760
|
current,
|
|
10728
10761
|
prior: prior_,
|
|
@@ -10819,7 +10852,7 @@ function analyzeGbp(signals) {
|
|
|
10819
10852
|
...base,
|
|
10820
10853
|
type: "gbp-metric-drop",
|
|
10821
10854
|
severity: abs >= GBP_METRIC_SEVERE_PCT ? "high" : "medium",
|
|
10822
|
-
title: `${loc.displayName}: ${metricLabel(worstMetric.metric)} down ${abs}
|
|
10855
|
+
title: `${loc.displayName}: ${metricLabel(worstMetric.metric)} down ${formatPercent(abs, RatioUnits.percent)} week-over-week`,
|
|
10823
10856
|
recommendation: {
|
|
10824
10857
|
action: "Investigate the local-visibility drop (profile changes, category edits, new competition)",
|
|
10825
10858
|
reason: `${metricLabel(worstMetric.metric)} fell from ${worstMetric.prior} to ${worstMetric.recent} (last 7d vs the prior 7d).`
|
|
@@ -10833,7 +10866,7 @@ function analyzeGbp(signals) {
|
|
|
10833
10866
|
...base,
|
|
10834
10867
|
type: "gbp-keyword-drop",
|
|
10835
10868
|
severity: worstKeyword.dropPct >= GBP_KEYWORD_SEVERE_PCT && !actionsHeldUp(loc) ? "high" : "medium",
|
|
10836
|
-
title: `${loc.displayName}: "${worstKeyword.keyword}" impressions down ${worstKeyword.dropPct}
|
|
10869
|
+
title: `${loc.displayName}: "${worstKeyword.keyword}" impressions down ${formatPercent(worstKeyword.dropPct, RatioUnits.percent)} month-over-month${window}`,
|
|
10837
10870
|
recommendation: {
|
|
10838
10871
|
action: "Check whether the property still ranks for this local search term and refresh the profile",
|
|
10839
10872
|
reason: `Search-keyword impressions for "${worstKeyword.keyword}" fell from ${worstKeyword.prior} to ${worstKeyword.recent} month-over-month.`
|
|
@@ -10867,7 +10900,7 @@ function pickWorstKeywordDrop(loc) {
|
|
|
10867
10900
|
if (point.recent == null || point.prior == null || point.prior < GBP_KEYWORD_MIN_BASELINE) continue;
|
|
10868
10901
|
const changeRatio = relativeChangeRatio(point.recent, point.prior);
|
|
10869
10902
|
if (changeRatio === null) continue;
|
|
10870
|
-
const dropPct =
|
|
10903
|
+
const dropPct = roundRatio(-changeRatio * 100, RatioUnits.percent);
|
|
10871
10904
|
if (dropPct < GBP_KEYWORD_DROP_PCT) continue;
|
|
10872
10905
|
if (!worst || dropPct > worst.dropPct) {
|
|
10873
10906
|
worst = { keyword: point.keyword, dropPct, recent: point.recent, prior: point.prior };
|
|
@@ -13288,8 +13321,8 @@ function aliasMatchesAt(textWords, aliasWords, start) {
|
|
|
13288
13321
|
}
|
|
13289
13322
|
return true;
|
|
13290
13323
|
}
|
|
13291
|
-
function containsAnyAlias(
|
|
13292
|
-
const textWords = words(
|
|
13324
|
+
function containsAnyAlias(prose, aliases) {
|
|
13325
|
+
const textWords = words(prose);
|
|
13293
13326
|
const candidates = aliases.map(words).filter((alias2) => alias2.length > 0);
|
|
13294
13327
|
for (let start = 0; start < textWords.length; start++) {
|
|
13295
13328
|
if (candidates.some((alias2) => aliasMatchesAt(textWords, alias2, start))) return true;
|
|
@@ -13431,6 +13464,7 @@ function prepareReport(input, options = {}, detailedEvidence = true) {
|
|
|
13431
13464
|
if (!source.complete) evidenceIncomplete.add(observation.id);
|
|
13432
13465
|
observationsBySlot.set(slot.id, {
|
|
13433
13466
|
input: observation,
|
|
13467
|
+
mentionText: answerProseForMentions(observation.answerText),
|
|
13434
13468
|
slot,
|
|
13435
13469
|
bridged: isBridged,
|
|
13436
13470
|
historical: source.historical,
|
|
@@ -13466,7 +13500,7 @@ function prepareReport(input, options = {}, detailedEvidence = true) {
|
|
|
13466
13500
|
const source = sourceAttribution(url);
|
|
13467
13501
|
return source.classification === "matched" ? source.matchedTargetIds : [];
|
|
13468
13502
|
}));
|
|
13469
|
-
const mentions = resolveMentionIdentity(observation.
|
|
13503
|
+
const mentions = resolveMentionIdentity(observation.mentionText, input.targets, aliasesByFirstWord, citedTargetIds, ambiguityPatterns);
|
|
13470
13504
|
observation.mentionedTargetIds = mentions.mentioned;
|
|
13471
13505
|
observation.unknownMentionTargetIds = mentions.unknown;
|
|
13472
13506
|
observation.citedTargetIds = citedTargetIds;
|
|
@@ -13645,7 +13679,7 @@ function buildSovForSlots(slots, competitors2, projectBrandNames, projectDomain,
|
|
|
13645
13679
|
return { domain: row.domain, own: row.own, presentIn: null, of: null, reason: "aliasless" };
|
|
13646
13680
|
}
|
|
13647
13681
|
const presentIn = selected.filter((slot) => {
|
|
13648
|
-
const answer = prepared.observationsBySlot.get(slot.id)?.
|
|
13682
|
+
const answer = prepared.observationsBySlot.get(slot.id)?.mentionText;
|
|
13649
13683
|
return answer !== null && answer !== void 0 && containsAnyAlias(answer, row.aliases);
|
|
13650
13684
|
}).length;
|
|
13651
13685
|
return { domain: row.domain, own: row.own, presentIn, of: selected.length };
|
|
@@ -13842,7 +13876,7 @@ function targetMentionRate(targets, slots, answered2, indexes) {
|
|
|
13842
13876
|
}
|
|
13843
13877
|
function presenceIn(slots, aliases, prepared) {
|
|
13844
13878
|
return slots.filter((slot) => {
|
|
13845
|
-
const answer = prepared.observationsBySlot.get(slot.id)?.
|
|
13879
|
+
const answer = prepared.observationsBySlot.get(slot.id)?.mentionText;
|
|
13846
13880
|
return answer !== null && answer !== void 0 && containsAnyAlias(answer, aliases);
|
|
13847
13881
|
}).length;
|
|
13848
13882
|
}
|
|
@@ -16206,7 +16240,7 @@ function buildMentionShareInputs(opts) {
|
|
|
16206
16240
|
snapshots: opts.snapshots.map((snap) => {
|
|
16207
16241
|
const queryText = (snap.queryId ? opts.queryTextById?.get(snap.queryId) : void 0) ?? snap.queryText ?? null;
|
|
16208
16242
|
const cachedAnswerDomains = !snap.answerText || !opts.answerDomainsByText ? void 0 : opts.answerDomainsByText.get(snap.answerText) ?? (() => {
|
|
16209
|
-
const domains = extractDomainsFromText(snap.answerText);
|
|
16243
|
+
const domains = extractDomainsFromText(answerProseForMentions(snap.answerText));
|
|
16210
16244
|
opts.answerDomainsByText.set(snap.answerText, domains);
|
|
16211
16245
|
return domains;
|
|
16212
16246
|
})();
|
|
@@ -25299,7 +25333,10 @@ function frozenCompetitorMatchers(competitors2) {
|
|
|
25299
25333
|
function exactCompetitorMatchers(plan) {
|
|
25300
25334
|
return frozenCompetitorMatchers(plan.groups.flatMap((group) => group.competitors));
|
|
25301
25335
|
}
|
|
25302
|
-
function
|
|
25336
|
+
function preparedAnswerProse(answerText) {
|
|
25337
|
+
return prepareBrandMatchText(answerProseForMentions(answerText));
|
|
25338
|
+
}
|
|
25339
|
+
function competitorSignals(snapshot, matchers, preparedAnswerText = preparedAnswerProse(snapshot.answerText)) {
|
|
25303
25340
|
const mentioned = preparedAnswerText === null ? [] : [...matchers].filter(([, matcher]) => matcherMatchesText(matcher, preparedAnswerText)).map(([domain]) => domain);
|
|
25304
25341
|
const citedDomains = new Set(snapshot.citedDomains.map((value) => {
|
|
25305
25342
|
try {
|
|
@@ -25517,7 +25554,7 @@ function frozenSimpleRun(run, definition, snapshots, includeEvidence = true) {
|
|
|
25517
25554
|
}
|
|
25518
25555
|
const slotId = `slot:simple:${query.queryId}:${provider}`;
|
|
25519
25556
|
if (!slotKeys.has(slotId)) throw new Error(`Frozen simple definition has no slot for stored snapshot ${snapshot.id}`);
|
|
25520
|
-
const preparedAnswerText =
|
|
25557
|
+
const preparedAnswerText = preparedAnswerProse(snapshot.answerText);
|
|
25521
25558
|
const mentioned = snapshot.answerText !== null ? matcherMatchesText(matcher, preparedAnswerText) : snapshot.answerMentioned === true;
|
|
25522
25559
|
const competitorSignalsForSnapshot = !includeEvidence || competitors2 === null ? { mentioned: [], cited: [] } : competitorSignals(snapshot, competitors2, preparedAnswerText);
|
|
25523
25560
|
observations.push({
|
|
@@ -28627,6 +28664,7 @@ async function analyticsRoutes(app) {
|
|
|
28627
28664
|
byProvider: {},
|
|
28628
28665
|
trend: "stable",
|
|
28629
28666
|
mentionTrend: "stable",
|
|
28667
|
+
windowChange: { citationRate: null, mentionRate: null, mentionShare: null },
|
|
28630
28668
|
queryChanges: [],
|
|
28631
28669
|
basketChanges: [],
|
|
28632
28670
|
executionIdentityChanges: [],
|
|
@@ -28839,6 +28877,7 @@ async function analyticsRoutes(app) {
|
|
|
28839
28877
|
}
|
|
28840
28878
|
const trend = computeTrend(buckets, "citationRate");
|
|
28841
28879
|
const mentionTrend = computeTrend(buckets, "mentionRate");
|
|
28880
|
+
const windowChange = computeWindowChange(buckets);
|
|
28842
28881
|
const queryChanges = computeQueryChanges(projectQueries, cutoff);
|
|
28843
28882
|
const basketChanges = basketRevisions.slice(1).map((rev, i) => {
|
|
28844
28883
|
const previous = new Set(basketRevisions[i].members);
|
|
@@ -28860,7 +28899,7 @@ async function analyticsRoutes(app) {
|
|
|
28860
28899
|
}
|
|
28861
28900
|
previousExecutionChecksum = identity.checksum;
|
|
28862
28901
|
}
|
|
28863
|
-
return reply.send({ window, mentionShareScope, buckets, overall, byProvider, trend, mentionTrend, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
|
|
28902
|
+
return reply.send({ window, mentionShareScope, buckets, overall, byProvider, trend, mentionTrend, windowChange, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
|
|
28864
28903
|
});
|
|
28865
28904
|
app.get("/projects/:name/analytics/gaps", async (request, reply) => {
|
|
28866
28905
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -28939,9 +28978,10 @@ async function analyticsRoutes(app) {
|
|
|
28939
28978
|
if (match) competitorsCiting.add(match);
|
|
28940
28979
|
}
|
|
28941
28980
|
if (!s.answerText) continue;
|
|
28981
|
+
const prose = prepareBrandMatchText(answerProseForMentions(s.answerText));
|
|
28942
28982
|
for (const competitor of competitorDomains) {
|
|
28943
28983
|
const matcher = competitorMatchers.get(competitor);
|
|
28944
|
-
if (matcher && matcherMatchesText(matcher,
|
|
28984
|
+
if (matcher && matcherMatchesText(matcher, prose)) competitorsMentioned.add(competitor);
|
|
28945
28985
|
}
|
|
28946
28986
|
}
|
|
28947
28987
|
const cons = consistencyMap.get(queryId);
|
|
@@ -29328,7 +29368,7 @@ function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors, cla
|
|
|
29328
29368
|
scope: result.scope,
|
|
29329
29369
|
// A project-only denominator is recognition evidence, not competitive
|
|
29330
29370
|
// share. Preserve the count but leave the rate undefined without a frame.
|
|
29331
|
-
rate: mentionShareCompetitors.length > 0 && denominator > 0 ?
|
|
29371
|
+
rate: mentionShareCompetitors.length > 0 && denominator > 0 ? roundRatio(projectMentionSnapshots / denominator, RatioUnits.fraction) : null,
|
|
29332
29372
|
projectMentionSnapshots,
|
|
29333
29373
|
competitorMentionSnapshots
|
|
29334
29374
|
};
|
|
@@ -29358,6 +29398,20 @@ function pooledRate(buckets, rateKey) {
|
|
|
29358
29398
|
}
|
|
29359
29399
|
return denominator > 0 ? numerator / denominator : 0;
|
|
29360
29400
|
}
|
|
29401
|
+
function windowRateChange(rates) {
|
|
29402
|
+
const first = rates[0];
|
|
29403
|
+
const latest = rates.at(-1);
|
|
29404
|
+
if (rates.length < 2 || first === void 0 || latest === void 0) return null;
|
|
29405
|
+
return { first, latest, delta: roundRatio(latest - first, RatioUnits.fraction) };
|
|
29406
|
+
}
|
|
29407
|
+
function computeWindowChange(buckets) {
|
|
29408
|
+
const measured = buckets.filter((b) => b.total > 0);
|
|
29409
|
+
return {
|
|
29410
|
+
citationRate: windowRateChange(measured.map((b) => b.citationRate)),
|
|
29411
|
+
mentionRate: windowRateChange(measured.map((b) => b.mentionRate)),
|
|
29412
|
+
mentionShare: windowRateChange(buckets.flatMap((b) => b.mentionShare.rate === null ? [] : [b.mentionShare.rate]))
|
|
29413
|
+
};
|
|
29414
|
+
}
|
|
29361
29415
|
function computeTrend(buckets, rateKey) {
|
|
29362
29416
|
const nonEmpty = buckets.filter((b) => b.total > 0);
|
|
29363
29417
|
if (nonEmpty.length < 2) return "stable";
|
|
@@ -29371,9 +29425,6 @@ function computeTrend(buckets, rateKey) {
|
|
|
29371
29425
|
if (diff < -0.05) return "declining";
|
|
29372
29426
|
return "stable";
|
|
29373
29427
|
}
|
|
29374
|
-
function round4(ratio) {
|
|
29375
|
-
return Math.round(ratio * 1e4) / 1e4;
|
|
29376
|
-
}
|
|
29377
29428
|
function bumpDomain(map, domain, category, label, surfaceClass) {
|
|
29378
29429
|
const existing = map.get(domain);
|
|
29379
29430
|
if (existing) existing.count += 1;
|
|
@@ -29388,8 +29439,8 @@ function buildRankedList(domains, limit, answerTotal, answersWithSources) {
|
|
|
29388
29439
|
const entries = shownEntries.map((d) => ({
|
|
29389
29440
|
domain: d.domain,
|
|
29390
29441
|
count: d.count,
|
|
29391
|
-
percentage: totalCitedSlots > 0 ?
|
|
29392
|
-
answerShare: answerTotal > 0 ?
|
|
29442
|
+
percentage: totalCitedSlots > 0 ? roundRatio(d.count / totalCitedSlots, RatioUnits.fraction) : 0,
|
|
29443
|
+
answerShare: answerTotal > 0 ? roundRatio(d.count / answerTotal, RatioUnits.fraction) : 0,
|
|
29393
29444
|
category: d.category,
|
|
29394
29445
|
label: d.label,
|
|
29395
29446
|
surfaceClass: d.surfaceClass
|
|
@@ -29406,7 +29457,7 @@ function buildRankedList(domains, limit, answerTotal, answersWithSources) {
|
|
|
29406
29457
|
surfaceClass,
|
|
29407
29458
|
label: surfaceClassLabel(surfaceClass),
|
|
29408
29459
|
count: v.count,
|
|
29409
|
-
percentage: totalCitedSlots > 0 ?
|
|
29460
|
+
percentage: totalCitedSlots > 0 ? roundRatio(v.count / totalCitedSlots, RatioUnits.fraction) : 0,
|
|
29410
29461
|
domainCount: v.domainCount
|
|
29411
29462
|
})).sort((a, b) => b.count - a.count || a.surfaceClass.localeCompare(b.surfaceClass));
|
|
29412
29463
|
return {
|
|
@@ -31610,7 +31661,7 @@ function renderExecutiveSummary(report) {
|
|
|
31610
31661
|
</div>
|
|
31611
31662
|
<div class="hero-proof">
|
|
31612
31663
|
<div class="mini-label">${copy.proofTiles.mentionCoverage}</div>
|
|
31613
|
-
<div class="mini-value">${s.mentionRate}
|
|
31664
|
+
<div class="mini-value">${formatPercent(s.mentionRate, RatioUnits.percent)}</div>
|
|
31614
31665
|
<div class="mini-copy">${escapeHtml2(headline.mentionedFragment)}</div>
|
|
31615
31666
|
</div>
|
|
31616
31667
|
<div class="hero-proof">
|
|
@@ -31623,12 +31674,12 @@ function renderExecutiveSummary(report) {
|
|
|
31623
31674
|
const metrics = [
|
|
31624
31675
|
{
|
|
31625
31676
|
label: copy.tiles.citationRate,
|
|
31626
|
-
value:
|
|
31677
|
+
value: formatPercent(s.citationRate, RatioUnits.percent),
|
|
31627
31678
|
delta: `<span class="tone-${headline.trendTone}">${headline.trendLabel}</span> \xB7 ${headline.citedFragment} \xB7 ${headline.providerCountLabel}`
|
|
31628
31679
|
},
|
|
31629
31680
|
{
|
|
31630
31681
|
label: copy.tiles.mentionRate,
|
|
31631
|
-
value:
|
|
31682
|
+
value: formatPercent(s.mentionRate, RatioUnits.percent),
|
|
31632
31683
|
delta: headline.mentionedFragment
|
|
31633
31684
|
},
|
|
31634
31685
|
{
|
|
@@ -31684,11 +31735,10 @@ function renderRateDeltaTile(label, delta, unit) {
|
|
|
31684
31735
|
if (!delta) {
|
|
31685
31736
|
return `<div class="metric"><div class="label">${escapeHtml2(label)}</div><div class="value">\u2014</div><div class="delta">${REPORT_SECTION_COPY["whats-changed"].rateTileEmpty}</div></div>`;
|
|
31686
31737
|
}
|
|
31687
|
-
const valueSuffix = unit === "%" ? "%" : "";
|
|
31688
31738
|
const deltaText = reportRateDeltaCopy(delta, unit);
|
|
31689
31739
|
return `<div class="metric">
|
|
31690
31740
|
<div class="label">${escapeHtml2(label)}</div>
|
|
31691
|
-
<div class="value ${deltaToneClass(delta.direction)}">${delta
|
|
31741
|
+
<div class="value ${deltaToneClass(delta.direction)}">${reportRateDeltaValue(delta, unit)} <span style="font-size:14px;font-weight:500;">${reportDeltaArrow(delta.direction)}</span></div>
|
|
31692
31742
|
<div class="delta">${deltaText}</div>
|
|
31693
31743
|
</div>`;
|
|
31694
31744
|
}
|
|
@@ -31711,8 +31761,8 @@ function renderProviderMovements(movements, audience) {
|
|
|
31711
31761
|
const rows = meaningful.map((m) => {
|
|
31712
31762
|
return `<tr>
|
|
31713
31763
|
<td>${escapeHtml2(isClient ? reportProviderDisplayName(m.provider) : m.provider)}</td>
|
|
31714
|
-
<td class="numeric">${m.prior}
|
|
31715
|
-
<td class="numeric">${m.current}
|
|
31764
|
+
<td class="numeric">${formatPercent(m.prior, RatioUnits.percent)}</td>
|
|
31765
|
+
<td class="numeric">${formatPercent(m.current, RatioUnits.percent)}</td>
|
|
31716
31766
|
<td class="numeric ${deltaToneClass(m.direction)}">${reportMovementChangeCopy(m)}</td>
|
|
31717
31767
|
</tr>`;
|
|
31718
31768
|
}).join("");
|
|
@@ -31905,7 +31955,7 @@ function renderCompetitorLandscape(report) {
|
|
|
31905
31955
|
<td><span class="badge tone-${tone}">${escapeHtml2(c.pressureLabel)}</span></td>
|
|
31906
31956
|
<td class="numeric">${c.citationCount} / ${c.totalCount}</td>
|
|
31907
31957
|
<td class="numeric">${mentionCount} / ${mentionTotal}</td>
|
|
31908
|
-
<td class="numeric">${c.sharePct}
|
|
31958
|
+
<td class="numeric">${formatPercent(c.sharePct, RatioUnits.percent)}</td>
|
|
31909
31959
|
<td>${escapeHtml2(reportTruncatedList(c.citedQueries, 5))}${pagesDisclosure}</td>
|
|
31910
31960
|
</tr>`;
|
|
31911
31961
|
}).join("");
|
|
@@ -32016,7 +32066,7 @@ function renderAiSourceOrigin(report) {
|
|
|
32016
32066
|
`${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
|
|
32017
32067
|
);
|
|
32018
32068
|
}
|
|
32019
|
-
function renderLineChart(points, color, title, height = 200) {
|
|
32069
|
+
function renderLineChart(points, color, title, height = 200, formatValue = formatNumber) {
|
|
32020
32070
|
if (points.length === 0) return "";
|
|
32021
32071
|
const width = 600;
|
|
32022
32072
|
const padX = 32;
|
|
@@ -32040,8 +32090,8 @@ function renderLineChart(points, color, title, height = 200) {
|
|
|
32040
32090
|
<h3>${escapeHtml2(title)}</h3>
|
|
32041
32091
|
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml2(reportLineChartLabel(title))}">
|
|
32042
32092
|
<line x1="${padX}" y1="${padY + usableH}" x2="${padX + usableW}" y2="${padY + usableH}" stroke="${COLORS.border}" stroke-width="1" />
|
|
32043
|
-
<text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${
|
|
32044
|
-
<text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end"
|
|
32093
|
+
<text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatValue(max2)}</text>
|
|
32094
|
+
<text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatValue(0)}</text>
|
|
32045
32095
|
<path d="${path8}" stroke="${color}" stroke-width="2" fill="none" />
|
|
32046
32096
|
${dots}
|
|
32047
32097
|
${xLabels}
|
|
@@ -32062,7 +32112,7 @@ function renderGsc(report) {
|
|
|
32062
32112
|
<td>${escapeHtml2(q.query)}</td>
|
|
32063
32113
|
<td class="numeric">${formatNumber(q.clicks)}</td>
|
|
32064
32114
|
<td class="numeric">${formatNumber(q.impressions)}</td>
|
|
32065
|
-
<td class="numeric">${
|
|
32115
|
+
<td class="numeric">${formatPercent(q.ctr)}</td>
|
|
32066
32116
|
<td class="numeric">${q.avgPosition.toFixed(1)}</td>
|
|
32067
32117
|
<td><span class="badge tone-neutral">${escapeHtml2(q.category)}</span></td>
|
|
32068
32118
|
</tr>`).join("");
|
|
@@ -32100,7 +32150,7 @@ function renderGsc(report) {
|
|
|
32100
32150
|
`<div class="metric-grid">
|
|
32101
32151
|
<div class="metric"><div class="label">${copy.tiles.clicks}</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
|
|
32102
32152
|
<div class="metric"><div class="label">${copy.tiles.impressions}</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
|
|
32103
|
-
<div class="metric"><div class="label">${copy.tiles.ctr}</div><div class="value">${
|
|
32153
|
+
<div class="metric"><div class="label">${copy.tiles.ctr}</div><div class="value">${formatPercent(gsc.ctr)}</div></div>
|
|
32104
32154
|
<div class="metric"><div class="label">${copy.tiles.position}</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
|
|
32105
32155
|
</div>
|
|
32106
32156
|
${trendChart}
|
|
@@ -32448,7 +32498,7 @@ function renderIndexingHealth(report) {
|
|
|
32448
32498
|
`<div class="metric-grid">
|
|
32449
32499
|
<div class="metric"><div class="label">${copy.tiles.indexed}</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
|
|
32450
32500
|
<div class="metric"><div class="label">${copy.tiles.total}</div><div class="value">${formatNumber(ih.total)}</div></div>
|
|
32451
|
-
<div class="metric"><div class="label">${copy.tiles.share}</div><div class="value">${ih.indexedPct}
|
|
32501
|
+
<div class="metric"><div class="label">${copy.tiles.share}</div><div class="value">${formatPercent(ih.indexedPct, RatioUnits.percent)}</div></div>
|
|
32452
32502
|
</div>
|
|
32453
32503
|
<div class="chart-card">
|
|
32454
32504
|
<h3>${copy.coverageHeading}</h3>
|
|
@@ -32476,12 +32526,13 @@ function renderCitationsTrend(report) {
|
|
|
32476
32526
|
trend.map((t) => ({ x: t.date, y: t.citationRate, label: formatDate(t.date) })),
|
|
32477
32527
|
COLORS.positive,
|
|
32478
32528
|
copy.chartTitle,
|
|
32479
|
-
220
|
|
32529
|
+
220,
|
|
32530
|
+
(value) => formatPercent(value, RatioUnits.percent)
|
|
32480
32531
|
);
|
|
32481
32532
|
const rows = trend.map((t) => `
|
|
32482
32533
|
<tr>
|
|
32483
32534
|
<td>${formatDate(t.date)}</td>
|
|
32484
|
-
<td class="numeric">${t.citationRate}
|
|
32535
|
+
<td class="numeric">${formatPercent(t.citationRate, RatioUnits.percent)} <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
|
|
32485
32536
|
<td>${escapeHtml2(reportTrendProviderRates(t.providerRates))}</td>
|
|
32486
32537
|
</tr>`).join("");
|
|
32487
32538
|
const [checkHeader, citedQueriesHeader, engineRatesHeader] = copy.breakdownHeaders;
|
|
@@ -32668,7 +32719,7 @@ function renderClientSummary(report) {
|
|
|
32668
32719
|
const sc = report.citationScorecard;
|
|
32669
32720
|
const copy = REPORT_SECTION_COPY["client-summary"];
|
|
32670
32721
|
const totalQ = s.totalQueryCount ?? 0;
|
|
32671
|
-
const heroNumber = totalQ > 0 ?
|
|
32722
|
+
const heroNumber = totalQ > 0 ? formatPercent(s.mentionRate, RatioUnits.percent) : "\u2014";
|
|
32672
32723
|
const heroSentence = reportClientHeroSentence(totalQ, s.mentionedQueryCount);
|
|
32673
32724
|
const trend = reportClientTrendCopy(report.whatsChanged.mentionRate);
|
|
32674
32725
|
const heroTrend = trend ? `<p class="client-hero-trend tone-${trend.tone}"><span style="margin-right:6px;">${trend.arrow}</span>${escapeHtml2(trend.text)}</p>` : "";
|
|
@@ -32682,12 +32733,12 @@ function renderClientSummary(report) {
|
|
|
32682
32733
|
const tiles = `<div class="client-metric-grid">
|
|
32683
32734
|
<div class="client-metric-tile">
|
|
32684
32735
|
<div class="label">${copy.tiles.mentioned}</div>
|
|
32685
|
-
<div class="value">${s.mentionRate}
|
|
32736
|
+
<div class="value">${formatPercent(s.mentionRate, RatioUnits.percent)}</div>
|
|
32686
32737
|
<div class="subtitle">${reportClientMentionedSubtitle(s.mentionedQueryCount, totalQ)}</div>
|
|
32687
32738
|
</div>
|
|
32688
32739
|
<div class="client-metric-tile">
|
|
32689
32740
|
<div class="label">${copy.tiles.cited}</div>
|
|
32690
|
-
<div class="value">${s.citationRate}
|
|
32741
|
+
<div class="value">${formatPercent(s.citationRate, RatioUnits.percent)}</div>
|
|
32691
32742
|
<div class="subtitle">${reportClientCitedSubtitle(s.citedQueryCount, totalQ)}</div>
|
|
32692
32743
|
</div>
|
|
32693
32744
|
<div class="client-metric-tile">
|
|
@@ -32719,7 +32770,7 @@ function renderClientSummary(report) {
|
|
|
32719
32770
|
return `<div class="client-bar-row">
|
|
32720
32771
|
<span class="bar-label">${escapeHtml2(reportProviderDisplayName(r.provider))}</span>
|
|
32721
32772
|
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
|
|
32722
|
-
<span class="bar-value">${r.mentionRate}
|
|
32773
|
+
<span class="bar-value">${formatPercent(r.mentionRate, RatioUnits.percent)} <span class="bar-value-sub">(${r.mentionedCount}/${r.totalCount})</span></span>
|
|
32723
32774
|
</div>`;
|
|
32724
32775
|
}).join("")}
|
|
32725
32776
|
</div>
|
|
@@ -32759,7 +32810,7 @@ function renderClientEvidenceSummary(report) {
|
|
|
32759
32810
|
cards.push(`<div class="client-card">
|
|
32760
32811
|
<h3>${copy.indexing.heading}</h3>
|
|
32761
32812
|
<p class="card-subtitle">${copy.indexing.subtitle}</p>
|
|
32762
|
-
<div class="client-progress-number tone-${tone}">${indexing.indexedPct}
|
|
32813
|
+
<div class="client-progress-number tone-${tone}">${formatPercent(indexing.indexedPct, RatioUnits.percent)}</div>
|
|
32763
32814
|
<div style="font-size:12px;color:${COLORS.textMuted};">${reportClientIndexedPages(indexing.indexed, indexing.total)}</div>
|
|
32764
32815
|
<div class="client-progress-bar"><div class="client-progress-fill tone-${tone}" style="width:${fillPct}%"></div></div>
|
|
32765
32816
|
<p style="margin:0;font-size:12px;color:${COLORS.textMuted};"><strong style="color:${COLORS.text};">${formatNumber(indexing.notIndexed)}</strong> ${reportClientNotIndexedTail(indexing.notIndexed)}</p>
|
|
@@ -33180,7 +33231,7 @@ function buildGscSection(db, projectId, projectBrandNames, canonicalDomain, trac
|
|
|
33180
33231
|
category,
|
|
33181
33232
|
clicks: agg.clicks,
|
|
33182
33233
|
impressions: agg.impressions,
|
|
33183
|
-
sharePct:
|
|
33234
|
+
sharePct: percentOf(agg.clicks, categoryTotalClicks) ?? 0
|
|
33184
33235
|
})).sort((a, b) => b.clicks - a.clicks);
|
|
33185
33236
|
const periodStart = trend[0]?.date ?? "";
|
|
33186
33237
|
const periodEnd = trend.at(-1)?.date ?? "";
|
|
@@ -33284,7 +33335,7 @@ function buildGaSection(db, projectId, windowDays2) {
|
|
|
33284
33335
|
channelBreakdown.push({
|
|
33285
33336
|
channel: b.channel,
|
|
33286
33337
|
sessions: b.sessions,
|
|
33287
|
-
sharePct:
|
|
33338
|
+
sharePct: percentOf(b.sessions, totalSessions) ?? 0
|
|
33288
33339
|
});
|
|
33289
33340
|
}
|
|
33290
33341
|
}
|
|
@@ -33327,7 +33378,7 @@ function buildSocialReferrals(db, projectId, windowDays2) {
|
|
|
33327
33378
|
const channels = [...channelAgg.entries()].map(([channelGroup, sessions]) => ({
|
|
33328
33379
|
channelGroup,
|
|
33329
33380
|
sessions,
|
|
33330
|
-
sharePct:
|
|
33381
|
+
sharePct: percentOf(sessions, total) ?? 0
|
|
33331
33382
|
})).sort((a, b) => b.sessions - a.sessions);
|
|
33332
33383
|
const topCampaigns = [...campaignAgg.values()].sort((a, b) => b.sessions - a.sessions).slice(0, TOP_CAMPAIGN_LIMIT);
|
|
33333
33384
|
return {
|
|
@@ -33409,7 +33460,7 @@ function buildAiReferrals(db, projectId, windowDays2) {
|
|
|
33409
33460
|
sessions: data.sessions,
|
|
33410
33461
|
paidSessions: data.paidSessions,
|
|
33411
33462
|
organicSessions: data.organicSessions,
|
|
33412
|
-
sharePct:
|
|
33463
|
+
sharePct: percentOf(data.sessions, total) ?? 0
|
|
33413
33464
|
})).sort((a, b) => b.sessions - a.sessions);
|
|
33414
33465
|
const trend = [...trendAgg.entries()].map(([date, sessions]) => ({ date, sessions })).sort((a, b) => a.date.localeCompare(b.date));
|
|
33415
33466
|
const topLandingPages = [...pageAgg.entries()].map(([page3, data]) => ({ page: page3, sessions: data.sessions })).sort((a, b) => b.sessions - a.sessions).slice(0, TOP_AI_REFERRAL_PAGES_LIMIT);
|
|
@@ -33740,7 +33791,7 @@ function buildIndexingHealth(db, projectId) {
|
|
|
33740
33791
|
notIndexed: gsc.notIndexed,
|
|
33741
33792
|
deindexed: 0,
|
|
33742
33793
|
unknown: 0,
|
|
33743
|
-
indexedPct:
|
|
33794
|
+
indexedPct: percentOf(gsc.indexed, total) ?? 0
|
|
33744
33795
|
};
|
|
33745
33796
|
}
|
|
33746
33797
|
const bing = db.select().from(bingCoverageSnapshots).where(eq37(bingCoverageSnapshots.projectId, projectId)).orderBy(desc17(bingCoverageSnapshots.date)).limit(1).get();
|
|
@@ -33753,7 +33804,7 @@ function buildIndexingHealth(db, projectId) {
|
|
|
33753
33804
|
notIndexed: bing.notIndexed,
|
|
33754
33805
|
deindexed: 0,
|
|
33755
33806
|
unknown: bing.unknown,
|
|
33756
|
-
indexedPct:
|
|
33807
|
+
indexedPct: percentOf(bing.indexed, total) ?? 0
|
|
33757
33808
|
};
|
|
33758
33809
|
}
|
|
33759
33810
|
return null;
|
|
@@ -33784,12 +33835,12 @@ function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
|
|
|
33784
33835
|
const totalQueries = new Set(snaps.map((snapshot) => snapshot.queryId)).size;
|
|
33785
33836
|
const citedQueryCount = citedQueryIds.size;
|
|
33786
33837
|
const mentionedQueryCount = mentionedQueryIds.size;
|
|
33787
|
-
const citationRate =
|
|
33788
|
-
const mentionRate3 =
|
|
33838
|
+
const citationRate = percentOf(citedQueryCount, totalQueries) ?? 0;
|
|
33839
|
+
const mentionRate3 = percentOf(mentionedQueryCount, totalQueries) ?? 0;
|
|
33789
33840
|
const providerRates = [...providerCounts.entries()].map(([provider, counts2]) => ({
|
|
33790
33841
|
provider,
|
|
33791
|
-
citationRate:
|
|
33792
|
-
mentionRate:
|
|
33842
|
+
citationRate: percentOf(counts2.cited, counts2.total) ?? 0,
|
|
33843
|
+
mentionRate: percentOf(counts2.mentioned, counts2.total) ?? 0
|
|
33793
33844
|
})).sort((a, b) => a.provider.localeCompare(b.provider));
|
|
33794
33845
|
points.push({
|
|
33795
33846
|
runId: run.id,
|
|
@@ -33911,7 +33962,7 @@ function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount,
|
|
|
33911
33962
|
const queryNoun = totalQueryCount === 1 ? "query" : "queries";
|
|
33912
33963
|
const ratioFragment = totalQueryCount > 0 ? ` (${citedQueryCount} of ${totalQueryCount} ${queryNoun} cited)` : "";
|
|
33913
33964
|
findings.push({
|
|
33914
|
-
title: `Citation rate at ${citationRate}
|
|
33965
|
+
title: `Citation rate at ${formatPercent(citationRate, RatioUnits.percent)}${ratioFragment}`,
|
|
33915
33966
|
detail,
|
|
33916
33967
|
tone
|
|
33917
33968
|
});
|
|
@@ -34044,7 +34095,7 @@ function buildReportActionPlan(input) {
|
|
|
34044
34095
|
if (input.indexingHealth && input.indexingHealth.total > 0 && input.indexingHealth.indexedPct < 70) {
|
|
34045
34096
|
const ih = input.indexingHealth;
|
|
34046
34097
|
const evidence = [
|
|
34047
|
-
`${ih.indexedPct}
|
|
34098
|
+
`${formatPercent(ih.indexedPct, RatioUnits.percent)} indexed (${ih.indexed}/${ih.total})`,
|
|
34048
34099
|
`${ih.notIndexed} not indexed${ih.deindexed > 0 ? `, ${ih.deindexed} deindexed` : ""}`
|
|
34049
34100
|
];
|
|
34050
34101
|
actions.push({
|
|
@@ -34119,7 +34170,7 @@ function buildReportActionPlan(input) {
|
|
|
34119
34170
|
"They are stronger evidence than a generic topic list because the model is already retrieving competing content."
|
|
34120
34171
|
],
|
|
34121
34172
|
evidence: [
|
|
34122
|
-
`"${topGap.query}" missed at ${
|
|
34173
|
+
`"${topGap.query}" missed at ${formatPercent(topGap.missRate)} with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
|
|
34123
34174
|
`Cited competitors: ${reportCompactList(topGap.competitorDomains)}`
|
|
34124
34175
|
],
|
|
34125
34176
|
successMetric: "The top content-gap query moves from missed to cited or mentioned after the recommended content work ships.",
|
|
@@ -34186,7 +34237,7 @@ function buildClientSummary(reportLike) {
|
|
|
34186
34237
|
const s = reportLike.executiveSummary;
|
|
34187
34238
|
const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
|
|
34188
34239
|
const headline = (s.totalQueryCount ?? 0) > 0 ? `${s.mentionedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} mention the brand in AI answers` : "No tracked queries have completed a check yet";
|
|
34189
|
-
const overview = (s.totalQueryCount ?? 0) > 0 ? `${reportLike.canonicalDomain} is mentioned on ${s.mentionRate}
|
|
34240
|
+
const overview = (s.totalQueryCount ?? 0) > 0 ? `${reportLike.canonicalDomain} is mentioned on ${formatPercent(s.mentionRate, RatioUnits.percent)} of tracked queries and cited on ${formatPercent(s.citationRate, RatioUnits.percent)} of tracked queries. ${mentionTrendSentence(reportLike.whatsChanged.mentionRate)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
|
|
34190
34241
|
const confidenceNotes = [];
|
|
34191
34242
|
if (s.totalQueryCount === 0) {
|
|
34192
34243
|
confidenceNotes.push("Confidence is low until the first tracked query check completes.");
|
|
@@ -34240,7 +34291,7 @@ function buildAgencyDiagnostics(input) {
|
|
|
34240
34291
|
const provider = input.indexingHealth.provider ?? "the connected provider";
|
|
34241
34292
|
diagnostics.push({
|
|
34242
34293
|
title: "Indexing health",
|
|
34243
|
-
detail: inspected ? `${input.indexingHealth.indexedPct}
|
|
34294
|
+
detail: inspected ? `${formatPercent(input.indexingHealth.indexedPct, RatioUnits.percent)} of inspected URLs are indexed in ${provider}.` : `No URLs have been inspected in ${provider} yet, so indexing coverage is not measured.`,
|
|
34244
34295
|
// Not measured is not a finding, so it must not carry a finding's tone.
|
|
34245
34296
|
severity: !inspected ? "neutral" : input.indexingHealth.indexedPct >= 90 ? "positive" : input.indexingHealth.indexedPct >= 70 ? "caution" : "negative",
|
|
34246
34297
|
evidence: inspected ? [
|
|
@@ -34292,7 +34343,7 @@ function buildWhatsChangedHeadline(citation, gscClicks, aiReferrals, enoughHisto
|
|
|
34292
34343
|
const arrow = citation.direction === "up" ? "\u2191" : citation.direction === "down" ? "\u2193" : "\u2192";
|
|
34293
34344
|
const verb = citation.direction === "up" ? "rose" : citation.direction === "down" ? "fell" : "held";
|
|
34294
34345
|
const smoothingHint = citation.window && citation.window >= 2 ? ` (avg of last ${citation.window} checks)` : "";
|
|
34295
|
-
parts.push(`Citation rate ${verb} ${citation.prior}
|
|
34346
|
+
parts.push(`Citation rate ${verb} ${formatPercent(citation.prior, RatioUnits.percent)} ${arrow} ${formatPercent(citation.current, RatioUnits.percent)}${smoothingHint}`);
|
|
34296
34347
|
}
|
|
34297
34348
|
if (aiReferrals && aiReferrals.direction !== "flat") {
|
|
34298
34349
|
const arrow = aiReferrals.direction === "up" ? "\u2191" : "\u2193";
|
|
@@ -34309,12 +34360,12 @@ function buildWhatsChanged(input) {
|
|
|
34309
34360
|
const latest = citationsTrend.at(-1);
|
|
34310
34361
|
const prior = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
|
|
34311
34362
|
const enoughHistory = !baseline && latest !== void 0 && prior !== void 0;
|
|
34312
|
-
const citationRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.citationRate);
|
|
34363
|
+
const citationRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.citationRate, SMOOTHED_RUN_DELTA_MAX_WINDOW, RatioUnits.percent);
|
|
34313
34364
|
const citationRate = enoughHistory && citationRateSmoothed ? {
|
|
34314
34365
|
...citationRateSmoothed,
|
|
34315
34366
|
direction: rateDirection(citationRateSmoothed.deltaAbs, RATE_REAL_MOVEMENT_THRESHOLD_PP)
|
|
34316
34367
|
} : null;
|
|
34317
|
-
const mentionRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.mentionRate);
|
|
34368
|
+
const mentionRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.mentionRate, SMOOTHED_RUN_DELTA_MAX_WINDOW, RatioUnits.percent);
|
|
34318
34369
|
const mentionRate3 = enoughHistory && mentionRateSmoothed ? {
|
|
34319
34370
|
...mentionRateSmoothed,
|
|
34320
34371
|
direction: rateDirection(mentionRateSmoothed.deltaAbs, RATE_REAL_MOVEMENT_THRESHOLD_PP)
|
|
@@ -34335,7 +34386,7 @@ function buildWhatsChanged(input) {
|
|
|
34335
34386
|
for (const cur of latest.providerRates) {
|
|
34336
34387
|
const priorRate = priorByProvider.get(cur.provider);
|
|
34337
34388
|
if (priorRate === void 0) continue;
|
|
34338
|
-
const deltaAbs = cur.citationRate - priorRate;
|
|
34389
|
+
const deltaAbs = roundRatio(cur.citationRate - priorRate, RatioUnits.percent);
|
|
34339
34390
|
providerMovements.push({
|
|
34340
34391
|
provider: cur.provider,
|
|
34341
34392
|
current: cur.citationRate,
|
|
@@ -34448,7 +34499,7 @@ function buildProjectReport(db, projectName, periodDays) {
|
|
|
34448
34499
|
pressureLabel: row.mentions === 0 ? "None" : row.mentions / share.snapshotsWithAnswerText >= 0.5 ? "High" : row.mentions / share.snapshotsWithAnswerText >= 0.2 ? "Moderate" : "Low",
|
|
34449
34500
|
mentionCount: counts2.get(row.domain) ?? 0,
|
|
34450
34501
|
totalCount: share.snapshotsWithAnswerText,
|
|
34451
|
-
sharePct: share.availability === "measured" ?
|
|
34502
|
+
sharePct: share.availability === "measured" ? percentOf(counts2.get(row.domain) ?? 0, share.projectMentions + share.competitorMentions) : null
|
|
34452
34503
|
}));
|
|
34453
34504
|
}
|
|
34454
34505
|
Object.assign(mentionLandscape, mentionLandscape.nonBrand);
|
|
@@ -34498,8 +34549,8 @@ function buildProjectReport(db, projectName, periodDays) {
|
|
|
34498
34549
|
}
|
|
34499
34550
|
const citedQueryCount = citedQueryIds.size;
|
|
34500
34551
|
const mentionedQueryCount = mentionedQueryIds.size;
|
|
34501
|
-
const citationRate =
|
|
34502
|
-
const mentionRate3 =
|
|
34552
|
+
const citationRate = percentOf(citedQueryCount, totalQueryCount) ?? 0;
|
|
34553
|
+
const mentionRate3 = percentOf(mentionedQueryCount, totalQueryCount) ?? 0;
|
|
34503
34554
|
const trendBaseline = isTrendBaseline(citationsTrend);
|
|
34504
34555
|
const latestPoint = citationsTrend.at(-1);
|
|
34505
34556
|
const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
|
|
@@ -35333,7 +35384,7 @@ function summarizeServer(crawlers, fetches, referrals) {
|
|
|
35333
35384
|
function comparisonDetail(latest, prior) {
|
|
35334
35385
|
if (prior === 0) return `${latest} in the latest cohort versus 0 in the prior cohort`;
|
|
35335
35386
|
const change = deltaPercent(latest, prior) ?? 0;
|
|
35336
|
-
return `${latest} in the latest cohort versus ${prior} prior (${change
|
|
35387
|
+
return `${latest} in the latest cohort versus ${prior} prior (${formatSignedPercent(change, RatioUnits.percent)})`;
|
|
35337
35388
|
}
|
|
35338
35389
|
function aggregateCoverage(row) {
|
|
35339
35390
|
return row?.startDate && row.endDate ? { startDate: row.startDate, endDate: row.endDate, observedDays: Number(row.observedDays) } : null;
|
|
@@ -36050,7 +36101,7 @@ function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable,
|
|
|
36050
36101
|
snaps.map((s) => ({
|
|
36051
36102
|
// Share uses current identity; named-rate counts above deliberately keep
|
|
36052
36103
|
// their historical persisted-boolean semantics.
|
|
36053
|
-
projectMentioned: s.answerText ? matcherMatchesText(projectMatcher, s.answerText) : s.answerMentioned === true,
|
|
36104
|
+
projectMentioned: s.answerText ? matcherMatchesText(projectMatcher, answerProseForMentions(s.answerText)) : s.answerMentioned === true,
|
|
36054
36105
|
answerText: s.answerText,
|
|
36055
36106
|
queryClass: queryClassOf(s)
|
|
36056
36107
|
})),
|
|
@@ -36250,9 +36301,6 @@ function computeVisibilityCompare(input) {
|
|
|
36250
36301
|
}
|
|
36251
36302
|
|
|
36252
36303
|
// ../api-routes/src/visibility-stats.ts
|
|
36253
|
-
function round42(value) {
|
|
36254
|
-
return Math.round(value * 1e4) / 1e4;
|
|
36255
|
-
}
|
|
36256
36304
|
function emptyAgg() {
|
|
36257
36305
|
return { total: 0, checked: 0, mentioned: 0, cited: 0, first: null, last: null };
|
|
36258
36306
|
}
|
|
@@ -36272,8 +36320,8 @@ function counts(agg) {
|
|
|
36272
36320
|
cited: agg.cited,
|
|
36273
36321
|
// mention proportion is over the CHECKED sample; citation proportion is
|
|
36274
36322
|
// over the full total (every snapshot is checked for citation).
|
|
36275
|
-
mentionRate: agg.checked > 0 ?
|
|
36276
|
-
citedRate: agg.total > 0 ?
|
|
36323
|
+
mentionRate: agg.checked > 0 ? roundRatio(agg.mentioned / agg.checked, RatioUnits.fraction) : null,
|
|
36324
|
+
citedRate: agg.total > 0 ? roundRatio(agg.cited / agg.total, RatioUnits.fraction) : null
|
|
36277
36325
|
};
|
|
36278
36326
|
}
|
|
36279
36327
|
function providerEntries(byProvider) {
|
|
@@ -36964,7 +37012,7 @@ async function compositeRoutes(app) {
|
|
|
36964
37012
|
const answerDomainsByText = /* @__PURE__ */ new Map();
|
|
36965
37013
|
for (const snapshot of trackedLatest) {
|
|
36966
37014
|
if (snapshot.answerText && !answerDomainsByText.has(snapshot.answerText)) {
|
|
36967
|
-
answerDomainsByText.set(snapshot.answerText, extractDomainsFromText(snapshot.answerText));
|
|
37015
|
+
answerDomainsByText.set(snapshot.answerText, extractDomainsFromText(answerProseForMentions(snapshot.answerText)));
|
|
36968
37016
|
}
|
|
36969
37017
|
}
|
|
36970
37018
|
const mentionShareInputs = buildMentionShareInputs({
|
|
@@ -37367,13 +37415,13 @@ function buildIndexCoverageScore(app, projectId) {
|
|
|
37367
37415
|
const deindexedLabel = deindexed === 1 ? "URL" : "URLs";
|
|
37368
37416
|
return {
|
|
37369
37417
|
label: "Index Coverage",
|
|
37370
|
-
value:
|
|
37418
|
+
value: formatPercent(chosen.indexed / total),
|
|
37371
37419
|
delta: `${chosen.provider} \xB7 ${chosen.indexed} of ${total} indexed`,
|
|
37372
37420
|
tone,
|
|
37373
37421
|
description: deindexed > 0 ? `${deindexed} deindexed ${deindexedLabel} detected in the latest Google Search Console inspection.` : `${chosen.notIndexed} ${notIndexedLabel} not indexed in ${chosen.provider === "Google" ? "Google Search Console" : "Bing Webmaster Tools"}.`,
|
|
37374
37422
|
tooltip,
|
|
37375
37423
|
trend: [],
|
|
37376
|
-
progress:
|
|
37424
|
+
progress: roundRatio(percentage, RatioUnits.percent)
|
|
37377
37425
|
};
|
|
37378
37426
|
}
|
|
37379
37427
|
function countGoogleDeindexedUrls(app, projectId) {
|
|
@@ -37726,6 +37774,9 @@ var SCHEMA_TABLE = {
|
|
|
37726
37774
|
GA4PropertiesDto: ga4PropertiesDtoSchema,
|
|
37727
37775
|
GA4StatusDto: ga4StatusDtoSchema,
|
|
37728
37776
|
GA4SyncResponseDto: ga4SyncResponseDtoSchema,
|
|
37777
|
+
GaAttributionTrendResponse: gaAttributionTrendResponseSchema,
|
|
37778
|
+
GaSocialReferralTrendResponse: gaSocialReferralTrendResponseSchema,
|
|
37779
|
+
GaTrafficResponse: gaTrafficResponseSchema,
|
|
37729
37780
|
GbpAccountListResponse: gbpAccountListResponseSchema,
|
|
37730
37781
|
GbpDailyMetricListResponse: gbpDailyMetricListResponseSchema,
|
|
37731
37782
|
GbpKeywordImpressionListResponse: gbpKeywordImpressionListResponseSchema,
|
|
@@ -37983,6 +38034,7 @@ function errorResponse(description) {
|
|
|
37983
38034
|
}
|
|
37984
38035
|
|
|
37985
38036
|
// ../api-routes/src/openapi.ts
|
|
38037
|
+
var GA_MOVER_DESCRIPTION = `A biggest mover is the source whose sessions changed most, in either direction, over the last 7 days against the 7 before. A source with no sessions in the prior 7 days has changeBasis new and a null changePct: a change from zero has no percentage. Below ${MIN_PCT_BASE} prior sessions changeBasis is small-base and changeSessions is the figure to state.`;
|
|
37986
38038
|
var stringSchema = { type: "string" };
|
|
37987
38039
|
var booleanSchema = { type: "boolean" };
|
|
37988
38040
|
var integerSchema = { type: "integer" };
|
|
@@ -43556,11 +43608,11 @@ var routeCatalog = [
|
|
|
43556
43608
|
method: "get",
|
|
43557
43609
|
path: "/api/v1/projects/{name}/ga/traffic",
|
|
43558
43610
|
summary: "Get GA4 landing page traffic, channel breakdown, and AI referral landing pages",
|
|
43611
|
+
description: "Every figure is measured over one window (windowStart to windowEnd). Each AI and social referral row carries share, its fraction of that table's sessions, so a table's shares add up to 1; each top page carries organicShare, its organic sessions over its own sessions.",
|
|
43559
43612
|
tags: ["ga4"],
|
|
43560
43613
|
parameters: [nameParameter, limitQueryParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
|
|
43561
43614
|
responses: {
|
|
43562
|
-
|
|
43563
|
-
200: rawJsonResponse("GA4 traffic data returned.", looseObjectSchema),
|
|
43615
|
+
200: jsonResponse("GA4 traffic data returned.", "GaTrafficResponse"),
|
|
43564
43616
|
400: errorResponse("GA4 is not connected."),
|
|
43565
43617
|
404: errorResponse("Project not found.")
|
|
43566
43618
|
}
|
|
@@ -43607,11 +43659,11 @@ var routeCatalog = [
|
|
|
43607
43659
|
method: "get",
|
|
43608
43660
|
path: "/api/v1/projects/{name}/ga/social-referral-trend",
|
|
43609
43661
|
summary: "Get social referral trend (7d/30d) with biggest mover",
|
|
43662
|
+
description: GA_MOVER_DESCRIPTION,
|
|
43610
43663
|
tags: ["ga4"],
|
|
43611
43664
|
parameters: [nameParameter],
|
|
43612
43665
|
responses: {
|
|
43613
|
-
|
|
43614
|
-
200: rawJsonResponse("Social referral trend returned.", looseObjectSchema),
|
|
43666
|
+
200: jsonResponse("Social referral trend returned.", "GaSocialReferralTrendResponse"),
|
|
43615
43667
|
400: errorResponse("GA4 is not connected."),
|
|
43616
43668
|
404: errorResponse("Project not found.")
|
|
43617
43669
|
}
|
|
@@ -43620,11 +43672,11 @@ var routeCatalog = [
|
|
|
43620
43672
|
method: "get",
|
|
43621
43673
|
path: "/api/v1/projects/{name}/ga/attribution-trend",
|
|
43622
43674
|
summary: "Get per-channel attribution trends (7d/30d) for organic, AI, and social",
|
|
43675
|
+
description: GA_MOVER_DESCRIPTION,
|
|
43623
43676
|
tags: ["ga4"],
|
|
43624
43677
|
parameters: [nameParameter],
|
|
43625
43678
|
responses: {
|
|
43626
|
-
|
|
43627
|
-
200: rawJsonResponse("Attribution trend returned.", looseObjectSchema),
|
|
43679
|
+
200: jsonResponse("Attribution trend returned.", "GaAttributionTrendResponse"),
|
|
43628
43680
|
400: errorResponse("GA4 is not connected."),
|
|
43629
43681
|
404: errorResponse("Project not found.")
|
|
43630
43682
|
}
|
|
@@ -46426,10 +46478,6 @@ function daysBetween(from, to) {
|
|
|
46426
46478
|
const [ty, tm, td] = to.split("-").map(Number);
|
|
46427
46479
|
return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 864e5);
|
|
46428
46480
|
}
|
|
46429
|
-
function roundPct(numerator, denominator) {
|
|
46430
|
-
if (denominator === 0) return 0;
|
|
46431
|
-
return Math.round(numerator / denominator * 100);
|
|
46432
|
-
}
|
|
46433
46481
|
function computeWindowDelta(rows, referenceDate) {
|
|
46434
46482
|
const recentLow = shiftDate(referenceDate, -7);
|
|
46435
46483
|
const priorLow = shiftDate(referenceDate, -14);
|
|
@@ -46494,7 +46542,7 @@ function buildTimeseries(rows, freshness, windowDays2 = 30) {
|
|
|
46494
46542
|
function computeKeywordCoverage(rows) {
|
|
46495
46543
|
const total = rows.length;
|
|
46496
46544
|
const thresholdedCount = rows.filter((r) => r.valueCount === null).length;
|
|
46497
|
-
return { total, thresholdedCount, thresholdedPct:
|
|
46545
|
+
return { total, thresholdedCount, thresholdedPct: percentOf(thresholdedCount, total) ?? 0 };
|
|
46498
46546
|
}
|
|
46499
46547
|
function summarizePlaceActions(rows) {
|
|
46500
46548
|
return {
|
|
@@ -49269,7 +49317,12 @@ async function googleRoutes(app, opts) {
|
|
|
49269
49317
|
indexed,
|
|
49270
49318
|
notIndexed,
|
|
49271
49319
|
deindexed: deindexedUrls.length,
|
|
49272
|
-
percentage:
|
|
49320
|
+
percentage: percentOf(indexed, total) ?? 0,
|
|
49321
|
+
// The coverage donut's two arcs and the CLI summary. Every latest
|
|
49322
|
+
// inspection lands in exactly one bucket, so the two shares sum to 1.
|
|
49323
|
+
// Unrounded, so the two arcs add up to 1.
|
|
49324
|
+
indexedShare: total > 0 ? indexed / total : null,
|
|
49325
|
+
notIndexedShare: total > 0 ? notIndexed / total : null
|
|
49273
49326
|
},
|
|
49274
49327
|
lastInspectedAt,
|
|
49275
49328
|
lastSyncedAt,
|
|
@@ -49887,7 +49940,7 @@ async function googleRoutes(app, opts) {
|
|
|
49887
49940
|
valueThreshold: r.valueThreshold ?? null
|
|
49888
49941
|
})),
|
|
49889
49942
|
total: rows.length,
|
|
49890
|
-
thresholdedPct:
|
|
49943
|
+
thresholdedPct: percentOf(thresholded, rows.length) ?? 0
|
|
49891
49944
|
};
|
|
49892
49945
|
});
|
|
49893
49946
|
app.get("/projects/:name/gbp/place-actions", async (request) => {
|
|
@@ -57212,7 +57265,7 @@ async function bingRoutes(app, opts) {
|
|
|
57212
57265
|
indexed,
|
|
57213
57266
|
notIndexed,
|
|
57214
57267
|
unknown,
|
|
57215
|
-
percentage:
|
|
57268
|
+
percentage: percentOf(indexed, total) ?? 0
|
|
57216
57269
|
},
|
|
57217
57270
|
lastInspectedAt,
|
|
57218
57271
|
indexed: indexedUrls.map(formatRow),
|
|
@@ -57658,6 +57711,47 @@ function buildSessionHistory(landingPageDays, dailyTotals) {
|
|
|
57658
57711
|
});
|
|
57659
57712
|
}
|
|
57660
57713
|
|
|
57714
|
+
// ../api-routes/src/ga-source-mover.ts
|
|
57715
|
+
function moverChangeBasis(prior) {
|
|
57716
|
+
if (prior <= 0) return GaMoverChangeBases.new;
|
|
57717
|
+
return prior < MIN_PCT_BASE ? GaMoverChangeBases["small-base"] : GaMoverChangeBases.percent;
|
|
57718
|
+
}
|
|
57719
|
+
function buildSourceMover(source, sessions7d, sessionsPrev7d) {
|
|
57720
|
+
return {
|
|
57721
|
+
source,
|
|
57722
|
+
sessions7d,
|
|
57723
|
+
sessionsPrev7d,
|
|
57724
|
+
changeSessions: sessions7d - sessionsPrev7d,
|
|
57725
|
+
// Null from a zero prior. It used to fall back to 100, so every source that
|
|
57726
|
+
// appeared this week read as "+100%" whatever it sent.
|
|
57727
|
+
changePct: deltaPercent(sessions7d, sessionsPrev7d),
|
|
57728
|
+
changeBasis: moverChangeBasis(sessionsPrev7d)
|
|
57729
|
+
};
|
|
57730
|
+
}
|
|
57731
|
+
function findBiggestMover(current, prior) {
|
|
57732
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
57733
|
+
const entry = (source) => {
|
|
57734
|
+
let sessions = bySource.get(source);
|
|
57735
|
+
if (!sessions) {
|
|
57736
|
+
sessions = { current: 0, prior: 0 };
|
|
57737
|
+
bySource.set(source, sessions);
|
|
57738
|
+
}
|
|
57739
|
+
return sessions;
|
|
57740
|
+
};
|
|
57741
|
+
for (const row of current) entry(row.source).current += row.sessions ?? 0;
|
|
57742
|
+
for (const row of prior) entry(row.source).prior += row.sessions ?? 0;
|
|
57743
|
+
let mover = null;
|
|
57744
|
+
const sources = [...bySource.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
57745
|
+
for (const source of sources) {
|
|
57746
|
+
const sessions = bySource.get(source);
|
|
57747
|
+
const size = Math.abs(sessions.current - sessions.prior);
|
|
57748
|
+
if (size > 0 && (mover === null || size > Math.abs(mover.changeSessions))) {
|
|
57749
|
+
mover = buildSourceMover(source, sessions.current, sessions.prior);
|
|
57750
|
+
}
|
|
57751
|
+
}
|
|
57752
|
+
return mover;
|
|
57753
|
+
}
|
|
57754
|
+
|
|
57661
57755
|
// ../api-routes/src/ga.ts
|
|
57662
57756
|
function gaLog(level, action, ctx) {
|
|
57663
57757
|
const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Routes", action, ...ctx };
|
|
@@ -57665,12 +57759,11 @@ function gaLog(level, action, ctx) {
|
|
|
57665
57759
|
stream.write(JSON.stringify(entry) + "\n");
|
|
57666
57760
|
}
|
|
57667
57761
|
function formatSharePct(numerator, total) {
|
|
57668
|
-
|
|
57669
|
-
|
|
57670
|
-
|
|
57671
|
-
const
|
|
57672
|
-
|
|
57673
|
-
return `${rounded}%`;
|
|
57762
|
+
return formatPercent(shareOf(numerator, total));
|
|
57763
|
+
}
|
|
57764
|
+
function withSessionShares(rows) {
|
|
57765
|
+
const shares = breakdownShares(rows.map((row) => row.sessions));
|
|
57766
|
+
return rows.map((row, index2) => ({ ...row, share: shares[index2] ?? 0 }));
|
|
57674
57767
|
}
|
|
57675
57768
|
function dateRangeConditions(column, range) {
|
|
57676
57769
|
const conditions = [];
|
|
@@ -57694,7 +57787,7 @@ function buildChannelBreakdown(input) {
|
|
|
57694
57787
|
const otherSessions = Math.max(0, input.totalSessions - coveredSessions);
|
|
57695
57788
|
const bucket = (sessions) => ({
|
|
57696
57789
|
sessions,
|
|
57697
|
-
sharePct:
|
|
57790
|
+
sharePct: percentOf(sessions, input.totalSessions) ?? 0,
|
|
57698
57791
|
sharePctDisplay: formatSharePct(sessions, input.totalSessions)
|
|
57699
57792
|
});
|
|
57700
57793
|
return {
|
|
@@ -57704,7 +57797,7 @@ function buildChannelBreakdown(input) {
|
|
|
57704
57797
|
ai: bucket(aiSessions),
|
|
57705
57798
|
other: {
|
|
57706
57799
|
sessions: otherSessions,
|
|
57707
|
-
sharePct:
|
|
57800
|
+
sharePct: percentOf(otherSessions, input.totalSessions) ?? 0,
|
|
57708
57801
|
sharePctDisplay: input.totalSessions <= 0 && coveredSessions > 0 ? "\u2014" : formatSharePct(otherSessions, input.totalSessions)
|
|
57709
57802
|
}
|
|
57710
57803
|
};
|
|
@@ -58536,7 +58629,8 @@ async function ga4Routes(app, opts) {
|
|
|
58536
58629
|
sessions: r.sessions ?? 0,
|
|
58537
58630
|
organicSessions: r.organicSessions ?? 0,
|
|
58538
58631
|
directSessions: r.directSessions ?? 0,
|
|
58539
|
-
users: r.users ?? 0
|
|
58632
|
+
users: r.users ?? 0,
|
|
58633
|
+
organicShare: shareOf(r.organicSessions ?? 0, r.sessions ?? 0)
|
|
58540
58634
|
})),
|
|
58541
58635
|
// No `users` on either referral row type, and no `ai*Users*` totals: GA
|
|
58542
58636
|
// reports users as a COUNT DISTINCT at the grain requested, so summing
|
|
@@ -58544,13 +58638,16 @@ async function ga4Routes(app, opts) {
|
|
|
58544
58638
|
// every date in the window) counted one visitor once per row. Withdrawn
|
|
58545
58639
|
// in 4.135.0; see `ga-ai-referral-aggregation.ts` for why no correct
|
|
58546
58640
|
// figure can be computed or fetched.
|
|
58547
|
-
|
|
58641
|
+
// Shares of the rows' own sum, not of `aiSessionsDeduped`: that total
|
|
58642
|
+
// keeps the winning lens per day, these rows one lens per source over the
|
|
58643
|
+
// window, so it can exceed what the rows add up to.
|
|
58644
|
+
aiReferrals: withSessionShares(aiReferrals.map((r) => ({
|
|
58548
58645
|
source: r.source,
|
|
58549
58646
|
medium: r.medium,
|
|
58550
58647
|
trafficClass: normalizeAiTrafficClass(r.trafficClass),
|
|
58551
58648
|
sourceDimension: r.sourceDimension,
|
|
58552
58649
|
sessions: r.sessions ?? 0
|
|
58553
|
-
})),
|
|
58650
|
+
}))),
|
|
58554
58651
|
aiReferralLandingPages: aiReferralLandingPages.map((r) => ({
|
|
58555
58652
|
source: r.source,
|
|
58556
58653
|
medium: r.medium,
|
|
@@ -58565,23 +58662,25 @@ async function ga4Routes(app, opts) {
|
|
|
58565
58662
|
aiSessionsBySession: aiSummary.bySession.sessions,
|
|
58566
58663
|
paidAiSessionsBySession: aiSummary.paidBySession.sessions,
|
|
58567
58664
|
organicAiSessionsBySession: aiSummary.organicBySession.sessions,
|
|
58568
|
-
|
|
58665
|
+
// These rows partition `socialSessions` (same window, one lens), so each
|
|
58666
|
+
// share is also the row's share of that total.
|
|
58667
|
+
socialReferrals: withSessionShares(socialReferrals.map((r) => ({
|
|
58569
58668
|
source: r.source,
|
|
58570
58669
|
medium: r.medium,
|
|
58571
58670
|
channelGroup: r.channelGroup,
|
|
58572
58671
|
sessions: r.sessions ?? 0
|
|
58573
|
-
})),
|
|
58672
|
+
}))),
|
|
58574
58673
|
socialSessions,
|
|
58575
58674
|
channelBreakdown,
|
|
58576
|
-
organicSharePct:
|
|
58577
|
-
aiSharePct:
|
|
58578
|
-
aiSharePctBySession:
|
|
58579
|
-
paidAiSharePct:
|
|
58580
|
-
paidAiSharePctBySession:
|
|
58581
|
-
organicAiSharePct:
|
|
58582
|
-
organicAiSharePctBySession:
|
|
58583
|
-
directSharePct:
|
|
58584
|
-
socialSharePct:
|
|
58675
|
+
organicSharePct: percentOf(totalOrganicSessions, total) ?? 0,
|
|
58676
|
+
aiSharePct: percentOf(aiSummary.deduped.sessions, total) ?? 0,
|
|
58677
|
+
aiSharePctBySession: percentOf(aiSummary.bySession.sessions, total) ?? 0,
|
|
58678
|
+
paidAiSharePct: percentOf(aiSummary.paidDeduped.sessions, total) ?? 0,
|
|
58679
|
+
paidAiSharePctBySession: percentOf(aiSummary.paidBySession.sessions, total) ?? 0,
|
|
58680
|
+
organicAiSharePct: percentOf(aiSummary.organicDeduped.sessions, total) ?? 0,
|
|
58681
|
+
organicAiSharePctBySession: percentOf(aiSummary.organicBySession.sessions, total) ?? 0,
|
|
58682
|
+
directSharePct: percentOf(totalDirectSessions, total) ?? 0,
|
|
58683
|
+
socialSharePct: percentOf(socialSessions, total) ?? 0,
|
|
58585
58684
|
otherSessions: channelBreakdown.other.sessions,
|
|
58586
58685
|
otherSharePct: channelBreakdown.other.sharePct,
|
|
58587
58686
|
otherSharePctDisplay: channelBreakdown.other.sharePctDisplay,
|
|
@@ -58703,31 +58802,16 @@ async function ga4Routes(app, opts) {
|
|
|
58703
58802
|
sql21`${gaSocialReferrals.date} >= ${daysAgo(14)}`,
|
|
58704
58803
|
sql21`${gaSocialReferrals.date} < ${daysAgo(7)}`
|
|
58705
58804
|
)).groupBy(gaSocialReferrals.source).all();
|
|
58706
|
-
const
|
|
58707
|
-
let biggestMover = null;
|
|
58708
|
-
let maxDelta = 0;
|
|
58709
|
-
for (const row of sourceCurrent) {
|
|
58710
|
-
const prev = prevMap.get(row.source) ?? 0;
|
|
58711
|
-
const delta = Math.abs(row.sessions - prev);
|
|
58712
|
-
if (delta > maxDelta) {
|
|
58713
|
-
maxDelta = delta;
|
|
58714
|
-
biggestMover = {
|
|
58715
|
-
source: row.source,
|
|
58716
|
-
sessions7d: row.sessions,
|
|
58717
|
-
sessionsPrev7d: prev,
|
|
58718
|
-
changePct: deltaPercent(row.sessions, prev) ?? (row.sessions > 0 ? 100 : 0)
|
|
58719
|
-
};
|
|
58720
|
-
}
|
|
58721
|
-
}
|
|
58722
|
-
return {
|
|
58805
|
+
const response = {
|
|
58723
58806
|
socialSessions7d: current7d?.sessions ?? 0,
|
|
58724
58807
|
socialSessionsPrev7d: prev7d?.sessions ?? 0,
|
|
58725
58808
|
trend7dPct: deltaPercent(current7d?.sessions ?? 0, prev7d?.sessions ?? 0),
|
|
58726
58809
|
socialSessions30d: current30d?.sessions ?? 0,
|
|
58727
58810
|
socialSessionsPrev30d: prev30d?.sessions ?? 0,
|
|
58728
58811
|
trend30dPct: deltaPercent(current30d?.sessions ?? 0, prev30d?.sessions ?? 0),
|
|
58729
|
-
biggestMover
|
|
58812
|
+
biggestMover: findBiggestMover(sourceCurrent, sourcePrev)
|
|
58730
58813
|
};
|
|
58814
|
+
return response;
|
|
58731
58815
|
});
|
|
58732
58816
|
app.get("/projects/:name/ga/attribution-trend", async (request, _reply) => {
|
|
58733
58817
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -58769,23 +58853,9 @@ async function ga4Routes(app, opts) {
|
|
|
58769
58853
|
sql21`${gaAiReferrals.date} < ${daysAgo(7)}`,
|
|
58770
58854
|
eq55(gaAiReferrals.sourceDimension, "session")
|
|
58771
58855
|
)).groupBy(gaAiReferrals.source).all();
|
|
58772
|
-
const findBiggestMover = (current, prev) => {
|
|
58773
|
-
const prevMap = new Map(prev.map((r) => [r.source, r.sessions]));
|
|
58774
|
-
let mover = null;
|
|
58775
|
-
let maxDelta = 0;
|
|
58776
|
-
for (const row of current) {
|
|
58777
|
-
const p = prevMap.get(row.source) ?? 0;
|
|
58778
|
-
const delta = Math.abs(row.sessions - p);
|
|
58779
|
-
if (delta > maxDelta) {
|
|
58780
|
-
maxDelta = delta;
|
|
58781
|
-
mover = { source: row.source, sessions7d: row.sessions, sessionsPrev7d: p, changePct: deltaPercent(row.sessions, p) ?? (row.sessions > 0 ? 100 : 0) };
|
|
58782
|
-
}
|
|
58783
|
-
}
|
|
58784
|
-
return mover;
|
|
58785
|
-
};
|
|
58786
58856
|
const socialSourceCurrent = app.db.select({ source: gaSocialReferrals.source, sessions: sql21`SUM(${gaSocialReferrals.sessions})` }).from(gaSocialReferrals).where(and47(eq55(gaSocialReferrals.projectId, project.id), sql21`${gaSocialReferrals.date} >= ${daysAgo(7)}`, sql21`${gaSocialReferrals.date} < ${todayStr}`)).groupBy(gaSocialReferrals.source).all();
|
|
58787
58857
|
const socialSourcePrev = app.db.select({ source: gaSocialReferrals.source, sessions: sql21`SUM(${gaSocialReferrals.sessions})` }).from(gaSocialReferrals).where(and47(eq55(gaSocialReferrals.projectId, project.id), sql21`${gaSocialReferrals.date} >= ${daysAgo(14)}`, sql21`${gaSocialReferrals.date} < ${daysAgo(7)}`)).groupBy(gaSocialReferrals.source).all();
|
|
58788
|
-
|
|
58858
|
+
const response = {
|
|
58789
58859
|
total: buildTrend(sumTotal),
|
|
58790
58860
|
organic: buildTrend(sumOrganic),
|
|
58791
58861
|
ai: buildTrend(sumAi),
|
|
@@ -58794,6 +58864,7 @@ async function ga4Routes(app, opts) {
|
|
|
58794
58864
|
aiBiggestMover: findBiggestMover(aiSourceCurrent, aiSourcePrev),
|
|
58795
58865
|
socialBiggestMover: findBiggestMover(socialSourceCurrent, socialSourcePrev)
|
|
58796
58866
|
};
|
|
58867
|
+
return response;
|
|
58797
58868
|
});
|
|
58798
58869
|
app.get("/projects/:name/ga/session-history", async (request, _reply) => {
|
|
58799
58870
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -82342,9 +82413,6 @@ function loadProject(ctx) {
|
|
|
82342
82413
|
if (!ctx.project) return null;
|
|
82343
82414
|
return ctx.db.select().from(projects).where(eq66(projects.id, ctx.project.id)).get() ?? null;
|
|
82344
82415
|
}
|
|
82345
|
-
function percent(value) {
|
|
82346
|
-
return Math.round(value * 100);
|
|
82347
|
-
}
|
|
82348
82416
|
var winnabilityCoverageCheck = {
|
|
82349
82417
|
id: "content.winnability.coverage",
|
|
82350
82418
|
category: CheckCategories.integrations,
|
|
@@ -82412,7 +82480,7 @@ var winnabilityCoverageCheck = {
|
|
|
82412
82480
|
return {
|
|
82413
82481
|
status: CheckStatuses.warn,
|
|
82414
82482
|
code: "content.winnability.low-coverage",
|
|
82415
|
-
summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${
|
|
82483
|
+
summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${formatPercent(coverage)}); the winnability gate may miss ceded surfaces in the unrecognized tail.`,
|
|
82416
82484
|
remediation: discoverRemediation,
|
|
82417
82485
|
details
|
|
82418
82486
|
};
|
|
@@ -82420,7 +82488,7 @@ var winnabilityCoverageCheck = {
|
|
|
82420
82488
|
return {
|
|
82421
82489
|
status: CheckStatuses.ok,
|
|
82422
82490
|
code: "content.winnability.covered",
|
|
82423
|
-
summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${
|
|
82491
|
+
summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${formatPercent(coverage)}); the winnability gate is active.`,
|
|
82424
82492
|
remediation: null,
|
|
82425
82493
|
details
|
|
82426
82494
|
};
|
|
@@ -86113,6 +86181,9 @@ function mapCrawlPage(row) {
|
|
|
86113
86181
|
healthState: deriveSiteHealthState(row)
|
|
86114
86182
|
};
|
|
86115
86183
|
}
|
|
86184
|
+
function withRecordedShare(factor) {
|
|
86185
|
+
return { ...factor, sharePct: typeof factor.sharePct === "number" ? factor.sharePct : null };
|
|
86186
|
+
}
|
|
86116
86187
|
function mapCrawlPageAuditEvidence(row) {
|
|
86117
86188
|
const fields = row.auditFields;
|
|
86118
86189
|
const rawFactors = Array.isArray(fields.factors) ? fields.factors : [];
|
|
@@ -86399,7 +86470,7 @@ async function technicalAeoRoutes(app, opts) {
|
|
|
86399
86470
|
trend,
|
|
86400
86471
|
previousScore: previous?.aggregateScore ?? null,
|
|
86401
86472
|
previousAuditedAt: previous?.auditedAt ?? null,
|
|
86402
|
-
factors: snap.factorAverages,
|
|
86473
|
+
factors: snap.factorAverages.map(withRecordedShare),
|
|
86403
86474
|
crossCuttingIssues: snap.crossCuttingIssues,
|
|
86404
86475
|
prioritizedFixes: snap.prioritizedFixes
|
|
86405
86476
|
};
|
|
@@ -86436,7 +86507,7 @@ async function technicalAeoRoutes(app, opts) {
|
|
|
86436
86507
|
overallScore: row.overallScore,
|
|
86437
86508
|
status: row.status === "error" ? "error" : "success",
|
|
86438
86509
|
error: row.error,
|
|
86439
|
-
factors: row.factors
|
|
86510
|
+
factors: row.factors.map(withRecordedShare)
|
|
86440
86511
|
}));
|
|
86441
86512
|
return { project: project.name, runId: latest.runId, auditedAt: latest.auditedAt, total, pages };
|
|
86442
86513
|
});
|
|
@@ -89288,10 +89359,11 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
|
|
|
89288
89359
|
"whyitstandsout",
|
|
89289
89360
|
"whywechoseit"
|
|
89290
89361
|
]);
|
|
89362
|
+
const scanText = stripCitationChips(answerText);
|
|
89291
89363
|
const seen2 = /* @__PURE__ */ new Map();
|
|
89292
89364
|
for (const pattern of candidatePatterns) {
|
|
89293
89365
|
let match;
|
|
89294
|
-
while ((match = pattern.exec(
|
|
89366
|
+
while ((match = pattern.exec(scanText)) !== null) {
|
|
89295
89367
|
const candidate = cleanCandidateName(match[1]);
|
|
89296
89368
|
const candidateKey2 = brandKeyFromText(candidate);
|
|
89297
89369
|
if (!candidateKey2) continue;
|
|
@@ -90164,6 +90236,7 @@ export {
|
|
|
90164
90236
|
deliverWebhook,
|
|
90165
90237
|
redactNotificationUrl,
|
|
90166
90238
|
renderReportHtml,
|
|
90239
|
+
buildOpenApiDocument,
|
|
90167
90240
|
toAlertView,
|
|
90168
90241
|
resolveDestination,
|
|
90169
90242
|
getPlaceDetails,
|