@nebulacomponents/citable 1.14.0 → 1.15.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/CHANGELOG.md +73 -0
- package/README.md +1 -1
- package/dist/universal/.agents/skills/citable/SKILL.md +11 -6
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/README.md +4 -1
- package/dist/universal/.agents/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.agents/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.agents/skills/citable/manifest.json +10 -9
- package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +11 -6
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/README.md +4 -1
- package/dist/universal/.claude/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.claude/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.claude/skills/citable/manifest.json +10 -9
- package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/SKILL.md +11 -6
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/README.md +4 -1
- package/dist/universal/.cursor/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.cursor/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +10 -9
- package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/SKILL.md +11 -6
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/README.md +4 -1
- package/dist/universal/.gemini/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.gemini/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +10 -9
- package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.github/skills/citable/SKILL.md +11 -6
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/README.md +4 -1
- package/dist/universal/.github/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.github/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.github/skills/citable/manifest.json +10 -9
- package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/SKILL.md +11 -6
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/README.md +4 -1
- package/dist/universal/.kiro/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.kiro/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +10 -9
- package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/SKILL.md +11 -6
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/README.md +4 -1
- package/dist/universal/.opencode/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.opencode/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +10 -9
- package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +11 -6
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +4 -1
- package/dist/universal/.pi/agent/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +10 -9
- package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/SKILL.md +11 -6
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/README.md +4 -1
- package/dist/universal/.qoder/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.qoder/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +10 -9
- package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/SKILL.md +11 -6
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/README.md +4 -1
- package/dist/universal/.rovodev/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.rovodev/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +10 -9
- package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/SKILL.md +11 -6
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/README.md +4 -1
- package/dist/universal/.trae/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.trae/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.trae/skills/citable/manifest.json +10 -9
- package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +11 -6
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +4 -1
- package/dist/universal/.trae-cn/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +10 -9
- package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/manifest.json +124 -112
- package/package.json +1 -1
- package/release/surfaces.json +2 -2
- package/schemas/observation.schema.json +1 -1
- package/schemas/schedule.schema.json +1 -1
- package/skill/SKILL.md +11 -6
- package/skill/commands/README.md +4 -1
- package/skill/commands/observe-and-act.md +12 -5
- package/skill/commands/reporting.md +57 -0
- package/src/cli/index.js +78 -11
- package/src/commands/connect.js +29 -0
- package/src/commands/context.js +15 -0
- package/src/commands/delivery.js +86 -2
- package/src/commands/monitor.js +35 -0
- package/src/commands/observe.js +3 -1
- package/src/commands/reportDashboard.js +21 -0
- package/src/commands/reportShareOfVoice.js +60 -0
- package/src/connectors/http.js +6 -3
- package/src/connectors/index.js +3 -1
- package/src/connectors/webflow.js +224 -0
- package/src/connectors/wordpress.js +226 -0
- package/src/detectors/geoReco.js +101 -0
- package/src/monitoring/alertDelivery.js +98 -0
- package/src/observations/common.js +10 -0
- package/src/observations/stance.js +261 -0
- package/src/reporting/dashboard.js +254 -0
- package/src/reporting/shareOfVoice.js +362 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJson } from '../shared/io.js';
|
|
4
|
+
|
|
5
|
+
const NO_GUARANTEE = 'This report calculates citation presence and share strictly from recorded observation runs and declared competitor domains. It does not estimate visibility, predict search rankings, or guarantee citation outcomes in live consumer experiences.';
|
|
6
|
+
|
|
7
|
+
export function extractDomain(urlStr) {
|
|
8
|
+
if (!urlStr) return null;
|
|
9
|
+
try {
|
|
10
|
+
const parsed = new URL(urlStr.includes('://') ? urlStr : `https://${urlStr}`);
|
|
11
|
+
return parsed.hostname.toLowerCase();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function matchesDomain(hostname, targetDomain) {
|
|
18
|
+
if (!hostname || !targetDomain) return false;
|
|
19
|
+
const h = hostname.toLowerCase();
|
|
20
|
+
const t = extractDomain(targetDomain) || targetDomain.toLowerCase();
|
|
21
|
+
return h === t || h.endsWith(`.${t}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadCitationRuns(root, { since, last } = {}) {
|
|
25
|
+
const runsDir = path.join(root, '.citable', 'runs');
|
|
26
|
+
const included = [];
|
|
27
|
+
const skipped = [];
|
|
28
|
+
if (!fs.existsSync(runsDir)) return { included, skipped };
|
|
29
|
+
|
|
30
|
+
let runIds = fs.readdirSync(runsDir).sort();
|
|
31
|
+
if (since) runIds = runIds.filter((id) => id >= since);
|
|
32
|
+
|
|
33
|
+
for (const runId of runIds) {
|
|
34
|
+
const dir = path.join(runsDir, runId);
|
|
35
|
+
const obsDir = path.join(dir, 'observations');
|
|
36
|
+
if (!fs.existsSync(obsDir)) continue;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const obsFiles = fs.readdirSync(obsDir).filter((f) => f.endsWith('.json')).sort();
|
|
40
|
+
const citations = [];
|
|
41
|
+
const reviews = [];
|
|
42
|
+
|
|
43
|
+
for (const f of obsFiles) {
|
|
44
|
+
const item = readJson(path.join(obsDir, f));
|
|
45
|
+
if (item.kind === 'citation') citations.push(item);
|
|
46
|
+
if (item.kind === 'citation_review') reviews.push(item);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (citations.length > 0 || reviews.length > 0) {
|
|
50
|
+
const manifest = fs.existsSync(path.join(dir, 'manifest.json'))
|
|
51
|
+
? readJson(path.join(dir, 'manifest.json'))
|
|
52
|
+
: null;
|
|
53
|
+
included.push({ run_id: runId, manifest, citations, reviews });
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
skipped.push({ run_id: runId, reason: err.message });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const windowed = Number.isInteger(last) && last > 0 ? included.slice(-last) : included;
|
|
61
|
+
const truncated = windowed.length < included.length && windowed.length > 0;
|
|
62
|
+
return {
|
|
63
|
+
included: windowed,
|
|
64
|
+
skipped: truncated ? skipped.filter((item) => item.run_id >= windowed[0].run_id) : skipped,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function calculateShareOfVoice(includedRuns, competitors = [], firstPartyDomains = new Set()) {
|
|
69
|
+
const promptsMap = new Map();
|
|
70
|
+
let totalObservations = 0;
|
|
71
|
+
let totalCitationsCount = 0;
|
|
72
|
+
|
|
73
|
+
for (const run of includedRuns) {
|
|
74
|
+
for (const item of run.citations) {
|
|
75
|
+
totalObservations++;
|
|
76
|
+
const promptId = item.data?.prompt_id || 'unspecified_prompt';
|
|
77
|
+
const promptText = item.data?.prompt_text || promptId;
|
|
78
|
+
const promptRecord = promptsMap.get(promptId) || {
|
|
79
|
+
prompt_id: promptId,
|
|
80
|
+
prompt_text: promptText,
|
|
81
|
+
total_runs: 0,
|
|
82
|
+
total_citations: 0,
|
|
83
|
+
first_party_presence_runs: 0,
|
|
84
|
+
first_party_citations: 0,
|
|
85
|
+
competitors: {},
|
|
86
|
+
other_citations: 0,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
promptRecord.total_runs++;
|
|
90
|
+
if (item.data?.property_cited) {
|
|
91
|
+
promptRecord.first_party_presence_runs++;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const reviews = item.data?.citations || [];
|
|
95
|
+
const competitorPresentInRun = new Set();
|
|
96
|
+
|
|
97
|
+
for (const review of reviews) {
|
|
98
|
+
promptRecord.total_citations++;
|
|
99
|
+
totalCitationsCount++;
|
|
100
|
+
const url = review.canonical_url || review.url || '';
|
|
101
|
+
const host = extractDomain(url);
|
|
102
|
+
|
|
103
|
+
const isFirstParty = review.first_party === true || (host && [...firstPartyDomains].some((d) => matchesDomain(host, d)));
|
|
104
|
+
if (isFirstParty) {
|
|
105
|
+
promptRecord.first_party_citations++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let matchedCompetitor = null;
|
|
110
|
+
for (const comp of competitors) {
|
|
111
|
+
const compDomains = comp.domains || [];
|
|
112
|
+
if (host && compDomains.some((d) => matchesDomain(host, d))) {
|
|
113
|
+
matchedCompetitor = comp;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (matchedCompetitor) {
|
|
119
|
+
const compId = matchedCompetitor.competitor_id;
|
|
120
|
+
promptRecord.competitors[compId] ||= { citations: 0, presence_runs: 0 };
|
|
121
|
+
promptRecord.competitors[compId].citations++;
|
|
122
|
+
competitorPresentInRun.add(compId);
|
|
123
|
+
} else {
|
|
124
|
+
promptRecord.other_citations++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const compId of competitorPresentInRun) {
|
|
129
|
+
promptRecord.competitors[compId].presence_runs++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
promptsMap.set(promptId, promptRecord);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Calculate aggregates
|
|
137
|
+
const aggregateCompetitors = {};
|
|
138
|
+
for (const comp of competitors) {
|
|
139
|
+
aggregateCompetitors[comp.competitor_id] = {
|
|
140
|
+
name: comp.name,
|
|
141
|
+
domains: comp.domains || [],
|
|
142
|
+
citations: 0,
|
|
143
|
+
presence_runs: 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let aggregateFirstPartyCitations = 0;
|
|
148
|
+
let aggregateFirstPartyPresenceRuns = 0;
|
|
149
|
+
let aggregateOtherCitations = 0;
|
|
150
|
+
|
|
151
|
+
for (const prompt of promptsMap.values()) {
|
|
152
|
+
aggregateFirstPartyCitations += prompt.first_party_citations;
|
|
153
|
+
aggregateFirstPartyPresenceRuns += prompt.first_party_presence_runs;
|
|
154
|
+
aggregateOtherCitations += prompt.other_citations;
|
|
155
|
+
|
|
156
|
+
for (const [compId, stats] of Object.entries(prompt.competitors)) {
|
|
157
|
+
if (aggregateCompetitors[compId]) {
|
|
158
|
+
aggregateCompetitors[compId].citations += stats.citations;
|
|
159
|
+
aggregateCompetitors[compId].presence_runs += stats.presence_runs;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
total_runs_evaluated: totalObservations,
|
|
166
|
+
total_citations_recorded: totalCitationsCount,
|
|
167
|
+
prompts: [...promptsMap.values()],
|
|
168
|
+
aggregate: {
|
|
169
|
+
first_party: {
|
|
170
|
+
citations: aggregateFirstPartyCitations,
|
|
171
|
+
presence_runs: aggregateFirstPartyPresenceRuns,
|
|
172
|
+
citation_share: totalCitationsCount > 0 ? aggregateFirstPartyCitations / totalCitationsCount : null,
|
|
173
|
+
presence_rate: totalObservations > 0 ? aggregateFirstPartyPresenceRuns / totalObservations : null,
|
|
174
|
+
},
|
|
175
|
+
competitors: Object.entries(aggregateCompetitors).map(([compId, data]) => ({
|
|
176
|
+
competitor_id: compId,
|
|
177
|
+
name: data.name,
|
|
178
|
+
domains: data.domains,
|
|
179
|
+
citations: data.citations,
|
|
180
|
+
presence_runs: data.presence_runs,
|
|
181
|
+
citation_share: totalCitationsCount > 0 ? data.citations / totalCitationsCount : null,
|
|
182
|
+
presence_rate: totalObservations > 0 ? data.presence_runs / totalObservations : null,
|
|
183
|
+
})),
|
|
184
|
+
other: {
|
|
185
|
+
citations: aggregateOtherCitations,
|
|
186
|
+
citation_share: totalCitationsCount > 0 ? aggregateOtherCitations / totalCitationsCount : null,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatPercent(val) {
|
|
193
|
+
return val == null ? 'not evidenced' : `${(val * 100).toFixed(1)}%`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function renderShareOfVoiceMarkdown(data, skipped = []) {
|
|
197
|
+
const lines = [];
|
|
198
|
+
lines.push('# Citable share-of-voice report');
|
|
199
|
+
lines.push('');
|
|
200
|
+
lines.push(`- Observation runs evaluated: ${data.total_runs_evaluated}`);
|
|
201
|
+
lines.push(`- Total citations recorded: ${data.total_citations_recorded}`);
|
|
202
|
+
lines.push(`- Prompts evaluated: ${data.prompts.length}`);
|
|
203
|
+
lines.push(`- Runs skipped: ${skipped.length}`);
|
|
204
|
+
lines.push('');
|
|
205
|
+
lines.push(`> ${NO_GUARANTEE}`);
|
|
206
|
+
lines.push('');
|
|
207
|
+
|
|
208
|
+
if (data.total_runs_evaluated === 0) {
|
|
209
|
+
lines.push('## Insufficient history');
|
|
210
|
+
lines.push('');
|
|
211
|
+
lines.push('No citation observations were found in `.citable/runs/`. Run `citable observe citations` to record provider citation evidence.');
|
|
212
|
+
lines.push('');
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
lines.push('## Aggregate citation share');
|
|
217
|
+
lines.push('');
|
|
218
|
+
lines.push('| Entity | Status | Citations | Citation share | Presence rate |');
|
|
219
|
+
lines.push('| --- | --- | --- | --- | --- |');
|
|
220
|
+
|
|
221
|
+
lines.push(`| **First-party** | Primary | ${data.aggregate.first_party.citations} | ${formatPercent(data.aggregate.first_party.citation_share)} | ${formatPercent(data.aggregate.first_party.presence_rate)} |`);
|
|
222
|
+
|
|
223
|
+
for (const comp of data.aggregate.competitors) {
|
|
224
|
+
lines.push(`| ${comp.name} (\`${comp.competitor_id}\`) | Registered competitor | ${comp.citations} | ${formatPercent(comp.citation_share)} | ${formatPercent(comp.presence_rate)} |`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
lines.push(`| Third-party sources (unregistered) | Uncontested | ${data.aggregate.other.citations} | ${formatPercent(data.aggregate.other.citation_share)} | — |`);
|
|
228
|
+
lines.push('');
|
|
229
|
+
|
|
230
|
+
lines.push('## Prompt breakdown');
|
|
231
|
+
lines.push('');
|
|
232
|
+
for (const prompt of data.prompts) {
|
|
233
|
+
lines.push(`### \`${prompt.prompt_id}\`: "${prompt.prompt_text}"`);
|
|
234
|
+
lines.push('');
|
|
235
|
+
lines.push(`- Total runs: ${prompt.total_runs}`);
|
|
236
|
+
lines.push(`- Total citations: ${prompt.total_citations}`);
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push('| Entity | Citations | Share | Prompt presence |');
|
|
239
|
+
lines.push('| --- | --- | --- | --- |');
|
|
240
|
+
const fpShare = prompt.total_citations > 0 ? prompt.first_party_citations / prompt.total_citations : null;
|
|
241
|
+
const fpPres = prompt.total_runs > 0 ? prompt.first_party_presence_runs / prompt.total_runs : null;
|
|
242
|
+
lines.push(`| First-party | ${prompt.first_party_citations} | ${formatPercent(fpShare)} | ${formatPercent(fpPres)} |`);
|
|
243
|
+
|
|
244
|
+
for (const comp of data.aggregate.competitors) {
|
|
245
|
+
const stats = prompt.competitors[comp.competitor_id] || { citations: 0, presence_runs: 0 };
|
|
246
|
+
const compShare = prompt.total_citations > 0 ? stats.citations / prompt.total_citations : null;
|
|
247
|
+
const compPres = prompt.total_runs > 0 ? stats.presence_runs / prompt.total_runs : null;
|
|
248
|
+
lines.push(`| ${comp.name} | ${stats.citations} | ${formatPercent(compShare)} | ${formatPercent(compPres)} |`);
|
|
249
|
+
}
|
|
250
|
+
const otherShare = prompt.total_citations > 0 ? prompt.other_citations / prompt.total_citations : null;
|
|
251
|
+
lines.push(`| Other | ${prompt.other_citations} | ${formatPercent(otherShare)} | — |`);
|
|
252
|
+
lines.push('');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (skipped.length) {
|
|
256
|
+
lines.push('## Skipped runs');
|
|
257
|
+
lines.push('');
|
|
258
|
+
for (const item of skipped) lines.push(`- \`${item.run_id}\`: ${item.reason}`);
|
|
259
|
+
lines.push('');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return lines.join('\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function escapeHtml(val) {
|
|
266
|
+
return String(val ?? '')
|
|
267
|
+
.replace(/&/g, '&')
|
|
268
|
+
.replace(/</g, '<')
|
|
269
|
+
.replace(/>/g, '>')
|
|
270
|
+
.replace(/"/g, '"')
|
|
271
|
+
.replace(/'/g, ''');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function renderShareOfVoiceHtml(data, skipped = []) {
|
|
275
|
+
const html = [];
|
|
276
|
+
html.push('<!doctype html>');
|
|
277
|
+
html.push('<html lang="en">');
|
|
278
|
+
html.push('<head>');
|
|
279
|
+
html.push('<meta charset="utf-8" />');
|
|
280
|
+
html.push('<meta name="viewport" content="width=device-width, initial-scale=1" />');
|
|
281
|
+
html.push('<title>Citable share-of-voice report</title>');
|
|
282
|
+
html.push('<style>');
|
|
283
|
+
html.push('body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; color: #1c1c1c; }');
|
|
284
|
+
html.push('table { border-collapse: collapse; width: 100%; font-size: 0.875rem; margin: 1rem 0 2rem; }');
|
|
285
|
+
html.push('th, td { border: 1px solid #d4d4d4; padding: 0.375rem 0.5rem; text-align: left; }');
|
|
286
|
+
html.push('th { background: #f3f3f3; }');
|
|
287
|
+
html.push('code { font-size: 0.8125rem; }');
|
|
288
|
+
html.push('blockquote { border-left: 4px solid #999; margin: 0 0 1.5rem; padding: 0.25rem 1rem; color: #333; }');
|
|
289
|
+
html.push('.bar-container { display: flex; height: 24px; border-radius: 4px; overflow: hidden; margin: 1rem 0; border: 1px solid #ccc; }');
|
|
290
|
+
html.push('.bar-fp { background: #2f6f4f; color: #fff; text-align: center; font-size: 0.75rem; line-height: 24px; }');
|
|
291
|
+
html.push('.bar-comp { background: #e09f3e; color: #fff; text-align: center; font-size: 0.75rem; line-height: 24px; }');
|
|
292
|
+
html.push('.bar-other { background: #999; color: #fff; text-align: center; font-size: 0.75rem; line-height: 24px; }');
|
|
293
|
+
html.push('.insufficient { background: #fbf1d8; border: 1px solid #e2c98a; padding: 0.75rem 1rem; }');
|
|
294
|
+
html.push('</style>');
|
|
295
|
+
html.push('</head>');
|
|
296
|
+
html.push('<body>');
|
|
297
|
+
html.push('<h1>Citable share-of-voice report</h1>');
|
|
298
|
+
html.push('<ul>');
|
|
299
|
+
html.push(`<li>Observation runs evaluated: ${data.total_runs_evaluated}</li>`);
|
|
300
|
+
html.push(`<li>Total citations recorded: ${data.total_citations_recorded}</li>`);
|
|
301
|
+
html.push(`<li>Prompts evaluated: ${data.prompts.length}</li>`);
|
|
302
|
+
html.push(`<li>Runs skipped: ${skipped.length}</li>`);
|
|
303
|
+
html.push('</ul>');
|
|
304
|
+
html.push(`<blockquote>${escapeHtml(NO_GUARANTEE)}</blockquote>`);
|
|
305
|
+
|
|
306
|
+
if (data.total_runs_evaluated === 0) {
|
|
307
|
+
html.push('<section class="insufficient"><h2>Insufficient history</h2><p>No citation observations found in <code>.citable/runs/</code>.</p></section>');
|
|
308
|
+
html.push('</body></html>');
|
|
309
|
+
return html.join('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
html.push('<h2>Aggregate citation distribution</h2>');
|
|
313
|
+
const fpPct = data.aggregate.first_party.citation_share ? (data.aggregate.first_party.citation_share * 100).toFixed(1) : 0;
|
|
314
|
+
const otherPct = data.aggregate.other.citation_share ? (data.aggregate.other.citation_share * 100).toFixed(1) : 0;
|
|
315
|
+
|
|
316
|
+
html.push('<div class="bar-container">');
|
|
317
|
+
if (fpPct > 0) html.push(`<div class="bar-fp" style="width: ${fpPct}%;">First-party (${fpPct}%)</div>`);
|
|
318
|
+
for (const comp of data.aggregate.competitors) {
|
|
319
|
+
const cPct = comp.citation_share ? (comp.citation_share * 100).toFixed(1) : 0;
|
|
320
|
+
if (cPct > 0) html.push(`<div class="bar-comp" style="width: ${cPct}%;">${escapeHtml(comp.name)} (${cPct}%)</div>`);
|
|
321
|
+
}
|
|
322
|
+
if (otherPct > 0) html.push(`<div class="bar-other" style="width: ${otherPct}%;">Other (${otherPct}%)</div>`);
|
|
323
|
+
html.push('</div>');
|
|
324
|
+
|
|
325
|
+
html.push('<table>');
|
|
326
|
+
html.push('<thead><tr><th>Entity</th><th>Type</th><th>Citations</th><th>Citation share</th><th>Presence rate</th></tr></thead>');
|
|
327
|
+
html.push('<tbody>');
|
|
328
|
+
html.push(`<tr><td><strong>First-party</strong></td><td>Primary</td><td>${data.aggregate.first_party.citations}</td><td>${formatPercent(data.aggregate.first_party.citation_share)}</td><td>${formatPercent(data.aggregate.first_party.presence_rate)}</td></tr>`);
|
|
329
|
+
for (const comp of data.aggregate.competitors) {
|
|
330
|
+
html.push(`<tr><td>${escapeHtml(comp.name)} (<code>${escapeHtml(comp.competitor_id)}</code>)</td><td>Competitor</td><td>${comp.citations}</td><td>${formatPercent(comp.citation_share)}</td><td>${formatPercent(comp.presence_rate)}</td></tr>`);
|
|
331
|
+
}
|
|
332
|
+
html.push(`<tr><td>Other third-party</td><td>Unregistered</td><td>${data.aggregate.other.citations}</td><td>${formatPercent(data.aggregate.other.citation_share)}</td><td>—</td></tr>`);
|
|
333
|
+
html.push('</tbody></table>');
|
|
334
|
+
|
|
335
|
+
html.push('<h2>Prompt breakdown</h2>');
|
|
336
|
+
for (const prompt of data.prompts) {
|
|
337
|
+
html.push(`<h3><code>${escapeHtml(prompt.prompt_id)}</code>: “${escapeHtml(prompt.prompt_text)}”</h3>`);
|
|
338
|
+
html.push(`<ul><li>Runs: ${prompt.total_runs}</li><li>Citations: ${prompt.total_citations}</li></ul>`);
|
|
339
|
+
html.push('<table><thead><tr><th>Entity</th><th>Citations</th><th>Share</th><th>Presence rate</th></tr></thead><tbody>');
|
|
340
|
+
const fpShare = prompt.total_citations > 0 ? prompt.first_party_citations / prompt.total_citations : null;
|
|
341
|
+
const fpPres = prompt.total_runs > 0 ? prompt.first_party_presence_runs / prompt.total_runs : null;
|
|
342
|
+
html.push(`<tr><td>First-party</td><td>${prompt.first_party_citations}</td><td>${formatPercent(fpShare)}</td><td>${formatPercent(fpPres)}</td></tr>`);
|
|
343
|
+
for (const comp of data.aggregate.competitors) {
|
|
344
|
+
const stats = prompt.competitors[comp.competitor_id] || { citations: 0, presence_runs: 0 };
|
|
345
|
+
const compShare = prompt.total_citations > 0 ? stats.citations / prompt.total_citations : null;
|
|
346
|
+
const compPres = prompt.total_runs > 0 ? stats.presence_runs / prompt.total_runs : null;
|
|
347
|
+
html.push(`<tr><td>${escapeHtml(comp.name)}</td><td>${stats.citations}</td><td>${formatPercent(compShare)}</td><td>${formatPercent(compPres)}</td></tr>`);
|
|
348
|
+
}
|
|
349
|
+
const otherShare = prompt.total_citations > 0 ? prompt.other_citations / prompt.total_citations : null;
|
|
350
|
+
html.push(`<tr><td>Other</td><td>${prompt.other_citations}</td><td>${formatPercent(otherShare)}</td><td>—</td></tr>`);
|
|
351
|
+
html.push('</tbody></table>');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (skipped.length) {
|
|
355
|
+
html.push('<h2>Skipped runs</h2><ul>');
|
|
356
|
+
for (const item of skipped) html.push(`<li><code>${escapeHtml(item.run_id)}</code>: ${escapeHtml(item.reason)}</li>`);
|
|
357
|
+
html.push('</ul>');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
html.push('</body></html>');
|
|
361
|
+
return html.join('\n');
|
|
362
|
+
}
|