agentlinkops 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +24 -0
  3. package/CHANGELOG.md +52 -0
  4. package/LICENSE +190 -0
  5. package/NOTICE +17 -0
  6. package/README.md +202 -0
  7. package/bin/agentlinkops.mjs +4 -0
  8. package/bin/linktrail.mjs +6 -0
  9. package/cli/adapters/csv.js +68 -0
  10. package/cli/adapters/sqlite-crm.js +79 -0
  11. package/cli/adapters/suppliers.js +180 -0
  12. package/cli/agent-setup.js +203 -0
  13. package/cli/agentlinkops.mjs +4 -0
  14. package/cli/args.js +18 -0
  15. package/cli/catalog.js +7483 -0
  16. package/cli/check.js +119 -0
  17. package/cli/citation.js +109 -0
  18. package/cli/client.js +60 -0
  19. package/cli/commands.js +183 -0
  20. package/cli/config.js +104 -0
  21. package/cli/connection.js +90 -0
  22. package/cli/context-mcp.js +193 -0
  23. package/cli/context.js +340 -0
  24. package/cli/delivery.js +67 -0
  25. package/cli/doctor.js +283 -0
  26. package/cli/env.js +66 -0
  27. package/cli/fleet.js +82 -0
  28. package/cli/gsc-browser.js +239 -0
  29. package/cli/import.js +108 -0
  30. package/cli/ledger.js +232 -0
  31. package/cli/linktrail.mjs +6 -0
  32. package/cli/local-result.js +161 -0
  33. package/cli/main.js +523 -0
  34. package/cli/mirror.js +126 -0
  35. package/cli/mix.js +123 -0
  36. package/cli/platforms.js +161 -0
  37. package/cli/receipt-performance.js +64 -0
  38. package/cli/receipts.js +122 -0
  39. package/cli/report.js +158 -0
  40. package/cli/setup-plan.js +211 -0
  41. package/cli/skill.js +39 -0
  42. package/cli/state.js +106 -0
  43. package/cli/status.js +51 -0
  44. package/cli/sync.js +179 -0
  45. package/package.json +59 -0
  46. package/references/cli.md +198 -0
  47. package/references/disavow.md +73 -0
  48. package/references/reports.md +53 -0
  49. package/scripts/agentlinkops.py +651 -0
  50. package/scripts/disavow.py +207 -0
  51. package/scripts/linktrail.py +10 -0
  52. package/scripts/migrations/001_crm.sql +45 -0
  53. package/scripts/migrations/002_cloud_events.sql +13 -0
  54. package/scripts/migrations/003_disavow.sql +20 -0
  55. package/scripts/migrations/004_placement_costs.sql +2 -0
  56. package/scripts/report.py +113 -0
  57. package/shared/agent-clients.js +49 -0
  58. package/shared/observation-event.js +43 -0
  59. package/shared/response-contract.js +59 -0
  60. package/shared/search-connection-contract.js +18 -0
  61. package/skills/agentlinkops-assets/SKILL.md +13 -0
  62. package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
  63. package/skills/agentlinkops-campaigns/SKILL.md +17 -0
  64. package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
  65. package/skills/agentlinkops-connect/SKILL.md +33 -0
  66. package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
  67. package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
  68. package/skills/agentlinkops-crm/SKILL.md +27 -0
  69. package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
  70. package/skills/content-defingerprinting/SKILL.md +29 -0
  71. package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
  82. package/skills/internal-linking-optimizer/SKILL.md +56 -0
  83. package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
  84. package/skills/pre-publish-review/SKILL.md +179 -0
  85. package/skills/source-cited-content-builder/SKILL.md +56 -0
  86. package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
  87. package/skills/source-cited-humanizer/SKILL.md +20 -0
  88. package/src/citations/adapter.js +141 -0
  89. package/src/citations/contract.js +171 -0
  90. package/src/citations/match.js +120 -0
  91. package/src/citations/runner.js +151 -0
  92. package/src/citations/stats.js +74 -0
  93. package/src/competitors/contract.js +157 -0
  94. package/src/competitors/domain-mix.js +199 -0
  95. package/src/context/ga4.js +652 -0
  96. package/src/context/gsc.js +925 -0
  97. package/src/context/site-profile.js +445 -0
  98. package/src/context/util.js +71 -0
  99. package/src/context-tool-registry.js +27 -0
  100. package/src/corpus/acquire.js +135 -0
  101. package/src/corpus/seeds.js +182 -0
  102. package/src/discovery/contract.js +237 -0
  103. package/src/discovery/import.js +156 -0
  104. package/src/tool-views.js +117 -0
  105. package/src/verifier/fetch.js +303 -0
  106. package/src/verifier/index.js +311 -0
  107. package/src/verifier/observation-kind.js +91 -0
  108. package/src/verifier/parser.js +241 -0
  109. package/src/verifier/readiness.js +90 -0
  110. package/src/verifier/render-evidence.js +57 -0
  111. package/src/verifier/robots.js +85 -0
  112. package/src/verifier/transition.js +129 -0
  113. package/src/verifier/url.js +56 -0
  114. package/templates/SITE.template.md +59 -0
  115. package/templates/campaigns.v1.json +65 -0
@@ -0,0 +1,157 @@
1
+ import { z } from 'zod';
2
+ import { DiscoveryQuery, DiscoveryRun, DiscoveryCandidate, candidateId, discoveryHash, queryHash, sourceQueryTerms } from '../discovery/contract.js';
3
+ import { matchesTarget, validatePublicUrl } from '../verifier/url.js';
4
+
5
+ // Pure internal boundary. No persistence, transport, provider calls or scheduling.
6
+ export const COMPETITOR_VERSION = 1;
7
+ export const COMPETITOR_LIMITS = Object.freeze({ competitors: 10, retrievalSkewMs: 86400000 });
8
+ const identifier = z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/);
9
+ const timestamp = z.iso.datetime({ precision: 3 });
10
+ // Scope validation only needs a well-formed query, so it borrows one source's ordering
11
+ // terms. Snapshot comparison uses each run's own source, never this template.
12
+ const policy = { backlinks_status_type: 'live', exclude_internal_backlinks: true, mode: 'as_is',
13
+ filters: null, ...sourceQueryTerms('dataforseo'), page_limit: 100, row_limit: 1000 };
14
+ const fail = code => { throw new CompetitorContractError(code); };
15
+ export class CompetitorContractError extends Error {
16
+ constructor(code) { super(code); this.code = code; }
17
+ }
18
+
19
+ export const TargetScope = z.strictObject({ target_kind: z.enum(['domain', 'exact_url']),
20
+ target: z.string().min(1).max(4096), include_subdomains: z.boolean(),
21
+ }).superRefine((scope, ctx) => {
22
+ if (!DiscoveryQuery.safeParse({ ...scope, ...policy }).success)
23
+ ctx.addIssue({ code: 'custom', message: 'Expected an explicit canonical discovery target scope.' });
24
+ });
25
+ const memberSchema = z.strictObject({ id: identifier, role: z.enum(['customer', 'competitor']), scope: TargetScope });
26
+ export const ApprovedCompetitorSet = z.strictObject({
27
+ v: z.literal(COMPETITOR_VERSION), id: identifier, revision: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
28
+ workspace_id: identifier, project_id: identifier, approved_by: identifier, approved_at: timestamp,
29
+ members: z.array(memberSchema).min(2).max(COMPETITOR_LIMITS.competitors + 1),
30
+ }).superRefine((set, ctx) => {
31
+ if (set.members.filter(m => m.role === 'customer').length !== 1 || new Set(set.members.map(m => m.id)).size !== set.members.length)
32
+ ctx.addIssue({ code: 'custom', message: 'Exactly one customer and distinct member IDs are required.' });
33
+ if (set.members.some(m => !TargetScope.safeParse(m.scope).success)) return;
34
+ for (let i = 0; i < set.members.length; i++) for (let j = i + 1; j < set.members.length; j++) {
35
+ if (scopesOverlap(set.members[i].scope, set.members[j].scope))
36
+ ctx.addIssue({ code: 'custom', message: 'Approved target scopes must not overlap.' });
37
+ }
38
+ });
39
+
40
+ function contains(scope, url) {
41
+ return matchesTarget(url, scope.target_kind === 'domain' ? `https://${scope.target}/` : scope.target,
42
+ scope.target_kind === 'exact_url' ? 'exact' : scope.include_subdomains ? 'domain' : 'subdomain');
43
+ }
44
+ function scopesOverlap(a, b) {
45
+ if (a.target_kind === 'exact_url') return contains(b, a.target);
46
+ if (b.target_kind === 'exact_url') return contains(a, b.target);
47
+ return contains(a, `https://${b.target}/`) || contains(b, `https://${a.target}/`);
48
+ }
49
+ const freeze = value => {
50
+ if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value); }
51
+ return value;
52
+ };
53
+ function orderedSet(input) {
54
+ const set = ApprovedCompetitorSet.parse(input);
55
+ set.members.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
56
+ return set;
57
+ }
58
+ export function approveCompetitorSet(input) {
59
+ // approved_by is supplied by the future authenticated service, never inferred
60
+ // from suggestions or from a provider's competitor ranking.
61
+ return freeze(orderedSet(input));
62
+ }
63
+ export async function competitorSetHash(input) { return discoveryHash(orderedSet(input)); }
64
+ export async function targetScopeId(input) {
65
+ return `ts_${await discoveryHash([COMPETITOR_VERSION, TargetScope.parse(input)])}`;
66
+ }
67
+ export function sourceGroupKey(sourceUrl, grouping = 'page') {
68
+ const checked = validatePublicUrl(sourceUrl);
69
+ if (!checked.valid || checked.url !== sourceUrl || sourceUrl.length > 4096) fail('INVALID_SOURCE_URL');
70
+ if (grouping === 'page') return checked.url;
71
+ if (grouping === 'source_host') return checked.hostname;
72
+ fail('UNSUPPORTED_SOURCE_GROUPING');
73
+ }
74
+
75
+ const receiptSchema = z.strictObject({ provider_task_id: identifier, provider_retrieved_at: timestamp });
76
+ const terminal = new Set(['succeeded', 'partial', 'failed', 'reconciliation_required']);
77
+
78
+ export async function freezeInventorySnapshot({ id, set: inputSet, memberId, run: inputRun, candidates: inputCandidates, receipts: inputReceipts, capturedAt }) {
79
+ identifier.parse(id); timestamp.parse(capturedAt);
80
+ const set = orderedSet(inputSet), run = DiscoveryRun.parse(inputRun);
81
+ const member = set.members.find(m => m.id === memberId);
82
+ if (!member) fail('COMPETITOR_MEMBER_NOT_FOUND');
83
+ if (run.workspace_id !== set.workspace_id || run.project_id !== set.project_id) fail('SNAPSHOT_SCOPE_MISMATCH');
84
+ if (!terminal.has(run.status) || !run.finished_at) fail('SNAPSHOT_RUN_NOT_TERMINAL');
85
+ if (run.query_hash !== await queryHash(run.query) || await targetScopeId(member.scope) !== await targetScopeId(scopeFromQuery(run.query)))
86
+ fail('SNAPSHOT_QUERY_MISMATCH');
87
+ if (run.created_at < set.approved_at || (run.started_at !== null && run.started_at < run.created_at) ||
88
+ run.finished_at < (run.started_at ?? run.created_at) || capturedAt < run.finished_at) fail('SNAPSHOT_TIME_MISMATCH');
89
+ const candidates = z.array(DiscoveryCandidate).max(run.query.row_limit).parse(inputCandidates);
90
+ const receipts = z.array(receiptSchema).max(1000).parse(inputReceipts);
91
+ if (candidates.length !== run.usage.accepted_candidates || new Set(candidates.map(c => c.id)).size !== candidates.length)
92
+ fail('SNAPSHOT_INVENTORY_INCOMPLETE');
93
+ if (receipts.length > run.usage.request_count || new Set(receipts.map(r => r.provider_task_id)).size !== receipts.length)
94
+ fail('SNAPSHOT_RECEIPT_MISMATCH');
95
+ const receiptById = new Map(receipts.map(r => [r.provider_task_id, r]));
96
+ for (const receipt of receipts) {
97
+ if (!run.started_at || receipt.provider_retrieved_at < run.started_at || receipt.provider_retrieved_at > run.finished_at)
98
+ fail('SNAPSHOT_TIME_MISMATCH');
99
+ }
100
+ for (const c of candidates) {
101
+ if (c.workspace_id !== run.workspace_id || c.project_id !== run.project_id || c.discovery_run_id !== run.id ||
102
+ c.provider !== run.provider || c.data_mode !== run.data_mode || !contains(member.scope, c.target_url) ||
103
+ c.id !== await candidateId(run.id, c.source_url, c.target_url)) fail('SNAPSHOT_CANDIDATE_MISMATCH');
104
+ if (receiptById.get(c.provider_task_id)?.provider_retrieved_at !== c.provider_retrieved_at) fail('SNAPSHOT_RECEIPT_MISMATCH');
105
+ }
106
+ if (run.coverage === 'complete_for_query' && (run.provider_total_count !== run.usage.returned_rows ||
107
+ receipts.length === 0 || receipts.length !== run.usage.request_count || run.coverage_reason !== null))
108
+ fail('SNAPSHOT_COMPLETENESS_MISMATCH');
109
+ candidates.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
110
+ receipts.sort((a, b) => a.provider_task_id < b.provider_task_id ? -1 : a.provider_task_id > b.provider_task_id ? 1 : 0);
111
+ const dates = receipts.map(r => r.provider_retrieved_at).sort();
112
+ const snapshot = { v: COMPETITOR_VERSION, id, workspace_id: set.workspace_id, project_id: set.project_id,
113
+ set_id: set.id, set_revision: set.revision, set_hash: await competitorSetHash(set),
114
+ member_id: member.id, member_role: member.role, target_scope_id: await targetScopeId(member.scope),
115
+ captured_at: capturedAt, provider_retrieved_from: dates[0] ?? null, provider_retrieved_to: dates.at(-1) ?? null,
116
+ run, receipts, candidates };
117
+ return freeze({ ...snapshot, content_hash: await discoveryHash(snapshot) });
118
+ }
119
+
120
+ const scopeFromQuery = q => ({ target_kind: q.target_kind, target: q.target, include_subdomains: q.include_subdomains });
121
+ const comparisonProfile = q => { const { target, ...profile } = DiscoveryQuery.parse(q); return profile; };
122
+
123
+ export async function validateInventorySnapshot(snapshot, set) {
124
+ if (!snapshot || typeof snapshot !== 'object') fail('INVALID_INVENTORY_SNAPSHOT');
125
+ const reconstructed = await freezeInventorySnapshot({ id: snapshot.id, set, memberId: snapshot.member_id,
126
+ run: snapshot.run, candidates: snapshot.candidates, receipts: snapshot.receipts, capturedAt: snapshot.captured_at });
127
+ // Compare all fields; a hash alone is not validation or authorization.
128
+ if (Object.keys(snapshot).length !== Object.keys(reconstructed).length ||
129
+ Object.keys(reconstructed).some(key => JSON.stringify(snapshot[key]) !== JSON.stringify(reconstructed[key])))
130
+ fail('SNAPSHOT_CONTENT_MISMATCH');
131
+ return reconstructed;
132
+ }
133
+
134
+ export async function compareInventoryCollection({ set, snapshots, maxRetrievalSkewMs = COMPETITOR_LIMITS.retrievalSkewMs }) {
135
+ if (!Number.isSafeInteger(maxRetrievalSkewMs) || maxRetrievalSkewMs < 0 || maxRetrievalSkewMs > COMPETITOR_LIMITS.retrievalSkewMs)
136
+ fail('INVALID_COMPARISON_WINDOW');
137
+ if (!Array.isArray(snapshots) || snapshots.length < 2 || snapshots.length > COMPETITOR_LIMITS.competitors + 1)
138
+ fail('INVALID_COMPARISON_INVENTORIES');
139
+ const validated = [];
140
+ for (const snapshot of snapshots) validated.push(await validateInventorySnapshot(snapshot, set));
141
+ if (new Set(validated.map(s => s.member_id)).size !== validated.length) fail('COMPARISON_REQUIRES_DISTINCT_MEMBERS');
142
+ const a = validated[0];
143
+ const reasons = [];
144
+ if (validated.some(b => a.run.provider !== b.run.provider)) reasons.push('provider_mismatch');
145
+ if (validated.some(b => a.run.data_mode !== b.run.data_mode)) reasons.push('data_mode_mismatch');
146
+ if (validated.some(b => JSON.stringify(comparisonProfile(a.run.query)) !== JSON.stringify(comparisonProfile(b.run.query)))) reasons.push('query_profile_mismatch');
147
+ if (reasons.length) return { snapshots: validated, comparison: freeze({ mode: 'incompatible', reasons, absence_claim: 'not_supported' }) };
148
+ if (validated.some(s => s.run.coverage !== 'complete_for_query')) reasons.push('incomplete_query_coverage');
149
+ const dates = validated.flatMap(s => [s.provider_retrieved_from, s.provider_retrieved_to]);
150
+ if (dates.some(d => d === null)) reasons.push('retrieval_time_unknown');
151
+ else if (Math.max(...dates.map(Date.parse)) - Math.min(...dates.map(Date.parse)) > maxRetrievalSkewMs) reasons.push('retrieval_window_exceeded');
152
+ return { snapshots: validated, comparison: freeze({ mode: reasons.length ? 'observed_overlap_only' : 'complete_query_datasets', reasons, absence_claim: 'not_supported' }) };
153
+ }
154
+
155
+ export async function compareInventorySnapshots({ set, left, right, maxRetrievalSkewMs }) {
156
+ return (await compareInventoryCollection({ set, snapshots: [left, right], maxRetrievalSkewMs })).comparison;
157
+ }
@@ -0,0 +1,199 @@
1
+ import { z } from 'zod';
2
+ import { validateInventorySnapshot, compareInventoryCollection, COMPETITOR_LIMITS } from './contract.js';
3
+ import { discoveryHash } from '../discovery/contract.js';
4
+
5
+ // The referring-domain mix report (DP-0003-T08).
6
+ //
7
+ // Compare imported referring-domain classes across customer lanes and selected inventories.
8
+ //
9
+ // The honesty rules are DP-0003-T06's, reused rather than reinvented:
10
+ // - a rank is `rank_within_dataset`, never `rank`, and the metadata says the ranking scope;
11
+ // - a thing we did not measure is unavailable or unlabelled, never zero or guessed;
12
+ // - one dataset is not a comparison, and a comparison is null rather than an empty object.
13
+ //
14
+ // The one rule this report adds: CLASS LABELS COME FROM IMPORTED ROWS. A referring domain
15
+ // no imported row ever labelled is counted `unlabelled`, and the share of a member's domains
16
+ // our labels reach is reported as label coverage. Inferring "generic" from a domain's shape
17
+ // would present a guess as an imported label.
18
+ export const DOMAIN_MIX_CLASSES = Object.freeze(['generic', 'niche']);
19
+
20
+ const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
21
+ const hostOf = url => { try { return new URL(url).hostname.replace(/^www\./u, ''); } catch { return null; } };
22
+
23
+ export const domainMixOptions = {
24
+ limit: z.number().int().min(1).max(200).optional(),
25
+ maxRetrievalSkewMs: z.number().int().min(0).max(COMPETITOR_LIMITS.retrievalSkewMs).optional(),
26
+ };
27
+
28
+ /** A lane's rows are whatever imported rows already carry: a URL, a class label, maybe a date. */
29
+ export const laneRow = z.object({
30
+ source_url: z.string().min(1),
31
+ class: z.string().min(1).nullable().optional(),
32
+ added: z.string().nullable().optional(),
33
+ });
34
+ export const laneInput = z.object({ lane: z.string().min(1), rows: z.array(laneRow).min(1) });
35
+
36
+ const coverageOf = snapshot => snapshot?.run?.coverage ?? snapshot?.coverage ?? 'unknown';
37
+
38
+ /**
39
+ * One side's referring-domain split. `labelFor` maps a host to its imported label or null;
40
+ * every domain it cannot label is counted `unlabelled`, and that count is a COVERAGE fact.
41
+ */
42
+ function memberSplit(domains, labelFor, labels, limit) {
43
+ // domains: Map<host, Set<source_url>> — distinct referring pages per host, T06's counting unit.
44
+ const classes = { unlabelled: 0 };
45
+ for (const label of labels) classes[label] = 0;
46
+ const hosts = [];
47
+ for (const [host, pages] of domains) {
48
+ const label = labelFor(host);
49
+ classes[label ?? 'unlabelled']++;
50
+ hosts.push({ host, class: label ?? 'unlabelled', referring_pages: pages.size });
51
+ }
52
+ const labelled = hosts.length - classes.unlabelled;
53
+ hosts.sort((a, b) => b.referring_pages - a.referring_pages || order(a.host, b.host));
54
+ return {
55
+ referring_domains: hosts.length,
56
+ classes,
57
+ labelled_domains: labelled,
58
+ label_coverage: hosts.length ? Number((labelled / hosts.length).toFixed(4)) : null,
59
+ // Ratios are over LABELLED domains only: an unlabelled domain has no class to be in, and a
60
+ // denominator that included it would read unlabelled as niche-by-default.
61
+ generic_share_of_labelled: labelled ? Number(((classes.generic ?? 0) / labelled).toFixed(4)) : null,
62
+ niche_share_of_labelled: labelled ? Number(((classes.niche ?? 0) / labelled).toFixed(4)) : null,
63
+ generic_niche_ratio: (classes.niche ?? 0) ? Number(((classes.generic ?? 0) / classes.niche).toFixed(4)) : null,
64
+ top_domains: hosts.slice(0, limit).map((row, index) => ({ ...row, rank_within_dataset: index + 1 })),
65
+ // The diff needs every domain, not the capped display list; compact on purpose.
66
+ domain_classes: Object.fromEntries(hosts.map(row => [row.host, row.class])),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * The report. `lanes` is one or more labelled lanes (an adopted registry, an imported
72
+ * supplier file); `snapshots` are frozen competitor inventories, and MAY be empty — the
73
+ * report then renders ours alone and states the competitor side is unavailable rather than
74
+ * inventing comparison numbers.
75
+ */
76
+ export async function buildDomainMixReport({ set = null, lanes, snapshots = [], ...input }) {
77
+ const options = z.strictObject(domainMixOptions).parse(input);
78
+ const limit = options.limit ?? 25;
79
+ const skew = options.maxRetrievalSkewMs ?? COMPETITOR_LIMITS.retrievalSkewMs;
80
+ const parsedLanes = lanes.map(lane => laneInput.parse(lane));
81
+ if (!parsedLanes.length) throw new Error('DOMAIN_MIX_REQUIRES_A_LANE');
82
+ // Ours alone is a legitimate request (the real lane today: no approved competitor set
83
+ // exists, so there is nothing to validate against). A set becomes required the moment a
84
+ // snapshot is selected, because a frozen inventory only means anything inside one.
85
+ if (snapshots.length && !set) throw new Error('DOMAIN_MIX_REQUIRES_A_SET_FOR_INVENTORIES');
86
+
87
+ // The label catalogue every side is classified against: host -> label, from our rows only.
88
+ const labelByHost = new Map();
89
+ const seenLabels = new Set();
90
+ for (const lane of parsedLanes) {
91
+ for (const row of lane.rows) {
92
+ const host = hostOf(row.source_url);
93
+ if (!host) continue;
94
+ if (row.class) { labelByHost.set(host, row.class); seenLabels.add(row.class); }
95
+ else if (!labelByHost.has(host)) labelByHost.set(host, null);
96
+ }
97
+ }
98
+ const labels = [...new Set([...DOMAIN_MIX_CLASSES, ...seenLabels])].filter(l => l !== 'unlabelled').sort();
99
+ const labelFor = host => labelByHost.get(host) ?? null;
100
+
101
+ const ours = parsedLanes.map(lane => {
102
+ const domains = new Map();
103
+ for (const row of lane.rows) {
104
+ const host = hostOf(row.source_url);
105
+ if (!host) continue;
106
+ if (!domains.has(host)) domains.set(host, new Set());
107
+ domains.get(host).add(row.source_url);
108
+ }
109
+ return { lane: lane.lane, rows_read: lane.rows.length, ...memberSplit(domains, labelFor, labels, limit) };
110
+ });
111
+
112
+ // Competitors: validated snapshots, each split against the same label catalogue. An empty
113
+ // selection is a legitimate request — ours alone — and is stated, not filled with anything.
114
+ const validated = [];
115
+ for (const snapshot of snapshots) validated.push(await validateInventorySnapshot(snapshot, set));
116
+ // A collection comparison is offered only when the datasets support one (T06's rule); with
117
+ // zero or one dataset it is null rather than an empty object, because empty reads as a result.
118
+ let comparison = null;
119
+ if (validated.length > 1) comparison = (await compareInventoryCollection({ set, snapshots: validated, maxRetrievalSkewMs: skew })).comparison;
120
+ const members = [];
121
+ for (const snapshot of validated) {
122
+ const domains = new Map();
123
+ for (const candidate of snapshot.candidates) {
124
+ const host = hostOf(candidate.source_url);
125
+ if (!host) continue;
126
+ if (!domains.has(host)) domains.set(host, new Set());
127
+ domains.get(host).add(candidate.source_url);
128
+ }
129
+ members.push({
130
+ member_id: snapshot.member_id, member_role: snapshot.member_role, inventory_id: snapshot.id,
131
+ coverage: coverageOf(snapshot), ...memberSplit(domains, labelFor, labels, limit),
132
+ });
133
+ }
134
+
135
+ const reportHash = await discoveryHash([1, 'competitor_referring_domain_mix',
136
+ parsedLanes.map(lane => lane.lane), validated.map(s => [s.id, s.content_hash]), limit, skew]);
137
+ return {
138
+ metadata: {
139
+ v: 1, kind: 'referring_domain_mix', set_id: set?.id ?? null, set_revision: set?.revision ?? null,
140
+ comparison, ranking_scope: 'selected_inventory_datasets', whole_web_coverage: false,
141
+ absence_claim: 'not_supported',
142
+ // Labels are the customer's own imported vocabulary, applied to domains their rows named.
143
+ label_source: 'imported_row_labels', labels, lanes: parsedLanes.map(lane => lane.lane),
144
+ selected_inventory_ids: validated.map(s => s.id),
145
+ coverage_by_inventory: Object.fromEntries(validated.map(s => [s.id, coverageOf(s)])),
146
+ unselected_member_ids: set ? set.members.filter(m => !validated.some(s => s.member_id === m.id)).map(m => m.id).sort() : [],
147
+ competitor_side_available: validated.length > 0,
148
+ // With no set there is no competitor scope at all, and the report says which side is empty.
149
+ competitor_side_state: validated.length ? 'selected_inventories' : (set ? 'no_inventories_selected' : 'no_approved_competitor_set'),
150
+ report_hash: reportHash, limit,
151
+ },
152
+ ours,
153
+ members,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * A change between two runs renders as a DIFF (the watch pattern), never a fresh page.
159
+ * Comparable means the same lanes and the same selected datasets: a diff across different
160
+ * datasets would present provider index churn as link acquisition, which is DP-0003's
161
+ * founding failure mode.
162
+ */
163
+ export function diffDomainMixReports(previous, current) {
164
+ if (!previous || !current) return { comparable: false, reason: 'a report is missing' };
165
+ const sameLanes = JSON.stringify(previous.metadata?.lanes) === JSON.stringify(current.metadata?.lanes);
166
+ const sameDatasets = JSON.stringify(previous.metadata?.selected_inventory_ids) === JSON.stringify(current.metadata?.selected_inventory_ids);
167
+ if (!sameLanes || !sameDatasets) {
168
+ return { comparable: false, reason: sameLanes ? 'selected datasets differ; re-baseline rather than diff across datasets' : 'lanes differ' };
169
+ }
170
+ const sides = [], changes = [];
171
+ const side = (before, after, who) => {
172
+ const beforeHosts = before?.domain_classes ?? {};
173
+ const afterHosts = after?.domain_classes ?? {};
174
+ for (const [host, klass] of Object.entries(afterHosts)) if (!(host in beforeHosts)) changes.push({ who, direction: 'added', host, class: klass });
175
+ for (const [host, klass] of Object.entries(beforeHosts)) if (!(host in afterHosts)) changes.push({ who, direction: 'removed', host, class: klass });
176
+ sides.push({
177
+ who,
178
+ referring_domains: { from: before?.referring_domains ?? null, to: after?.referring_domains ?? null },
179
+ generic_share_of_labelled: { from: before?.generic_share_of_labelled ?? null, to: after?.generic_share_of_labelled ?? null },
180
+ label_coverage: { from: before?.label_coverage ?? null, to: after?.label_coverage ?? null },
181
+ });
182
+ };
183
+ (previous.ours ?? []).forEach((before, index) => side(before, current.ours?.[index], `ours:${before.lane}`));
184
+ (previous.members ?? []).forEach((before, index) => side(before, current.members?.[index], before.member_id));
185
+ return { comparable: true, v: 1, generated_from: current.metadata.report_hash, sides, changes };
186
+ }
187
+
188
+ /** The watch-pattern rendering: `from -> to` per side, one line per changed domain. */
189
+ export function renderDomainMixDiff(diff, out = console.log) {
190
+ if (!diff.comparable) { out(`not comparable: ${diff.reason}`); return 0; }
191
+ for (const row of diff.sides) {
192
+ out(`${row.who} referring-domains ${row.referring_domains.from} -> ${row.referring_domains.to}`
193
+ + ` generic-share ${row.generic_share_of_labelled.from} -> ${row.generic_share_of_labelled.to}`
194
+ + ` label-coverage ${row.label_coverage.from} -> ${row.label_coverage.to}`);
195
+ }
196
+ for (const change of diff.changes) out(` ${change.direction === 'added' ? '+' : '-'} ${change.class} ${change.host} (${change.who})`);
197
+ if (!diff.changes.length) out(' no referring-domain changes');
198
+ return 0;
199
+ }