weftens 0.0.1 → 0.1.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 (100) hide show
  1. package/README.md +138 -12
  2. package/agent-cores/amazon-ppc/package.json +17 -0
  3. package/agent-cores/amazon-ppc/src/cli.mjs +66 -0
  4. package/agent-cores/amazon-ppc/src/optimize.mjs +272 -0
  5. package/agent-cores/amazon-ppc/src/parse.mjs +153 -0
  6. package/agent-cores/amazon-ppc/test/fixtures/search-term-report.csv +8 -0
  7. package/agent-cores/amazon-ppc/test/fixtures/search-term-report.json +5 -0
  8. package/agent-cores/amazon-ppc/test/optimize.test.mjs +161 -0
  9. package/agent-cores/content-ops/package.json +17 -0
  10. package/agent-cores/content-ops/src/cli.mjs +51 -0
  11. package/agent-cores/content-ops/src/inventory.mjs +343 -0
  12. package/agent-cores/content-ops/src/parse.mjs +121 -0
  13. package/agent-cores/content-ops/test/fixtures/cms-export.csv +13 -0
  14. package/agent-cores/content-ops/test/fixtures/cms-export.json +31 -0
  15. package/agent-cores/content-ops/test/inventory.test.mjs +177 -0
  16. package/agent-cores/meta-ads/package.json +16 -0
  17. package/agent-cores/meta-ads/src/analyze.mjs +166 -0
  18. package/agent-cores/meta-ads/src/cli.mjs +39 -0
  19. package/agent-cores/meta-ads/src/parse.mjs +123 -0
  20. package/agent-cores/meta-ads/test/analyze.test.js +72 -0
  21. package/agent-cores/meta-ads/test/fixtures/meta-ads.csv +6 -0
  22. package/agent-cores/paid-search/package.json +17 -0
  23. package/agent-cores/paid-search/src/analyze.mjs +303 -0
  24. package/agent-cores/paid-search/src/cli.mjs +57 -0
  25. package/agent-cores/paid-search/src/parse.mjs +158 -0
  26. package/agent-cores/paid-search/test/analyze.test.mjs +120 -0
  27. package/agent-cores/paid-search/test/fixtures/campaigns.csv +7 -0
  28. package/agent-cores/paid-search/test/fixtures/sample.json +9 -0
  29. package/agent-cores/paid-search/test/fixtures/search-terms.csv +11 -0
  30. package/agent-cores/paid-search/test/parse.test.mjs +56 -0
  31. package/agent-cores/seo-content/package.json +17 -0
  32. package/agent-cores/seo-content/src/cli.mjs +41 -0
  33. package/agent-cores/seo-content/src/parse.mjs +141 -0
  34. package/agent-cores/seo-content/src/strategy.mjs +266 -0
  35. package/agent-cores/seo-content/test/fixtures/gsc-sample.csv +11 -0
  36. package/agent-cores/seo-content/test/parse.test.mjs +59 -0
  37. package/agent-cores/seo-content/test/strategy.test.mjs +108 -0
  38. package/agent-cores/seo-technical/package.json +17 -0
  39. package/agent-cores/seo-technical/src/audit.mjs +233 -0
  40. package/agent-cores/seo-technical/src/cli.mjs +48 -0
  41. package/agent-cores/seo-technical/src/fetch.mjs +51 -0
  42. package/agent-cores/seo-technical/test/audit.test.mjs +73 -0
  43. package/agent-cores/seo-technical/test/fixtures/sample.html +22 -0
  44. package/agent-cores/supply-verify/package.json +17 -0
  45. package/agent-cores/supply-verify/src/cli.mjs +44 -0
  46. package/agent-cores/supply-verify/src/verify.mjs +303 -0
  47. package/agent-cores/supply-verify/test/fixtures/placements.csv +8 -0
  48. package/agent-cores/supply-verify/test/fixtures/placements.json +4 -0
  49. package/agent-cores/supply-verify/test/verify.test.mjs +148 -0
  50. package/agents/agent2060.md +99 -0
  51. package/agents/amazon-ppc.md +42 -0
  52. package/agents/claims-content.md +35 -0
  53. package/agents/content-ops.md +46 -0
  54. package/agents/content-strategy.md +13 -0
  55. package/agents/disclosure-qc.md +13 -0
  56. package/agents/feed-optimizer.md +13 -0
  57. package/agents/growth-analyst.md +24 -0
  58. package/agents/hilbert.md +55 -0
  59. package/agents/ideator.md +13 -0
  60. package/agents/lippmann.md +68 -0
  61. package/agents/paid-search.md +35 -0
  62. package/agents/pio.md +34 -0
  63. package/agents/researcher.md +13 -0
  64. package/agents/richy.md +72 -0
  65. package/agents/script-editor.md +13 -0
  66. package/agents/seo-content.md +57 -0
  67. package/agents/seo-technical.md +53 -0
  68. package/agents/showrunner.md +13 -0
  69. package/agents/social-voice.md +38 -0
  70. package/agents/supply-verify.md +38 -0
  71. package/agents/video-editor.md +13 -0
  72. package/agents/writer.md +45 -0
  73. package/bin/weftens.js +226 -0
  74. package/content-org/.preflight/telemetry.jsonl +6 -0
  75. package/content-org/briefs/BRIEFS.md +47 -0
  76. package/content-org/config.mjs +78 -0
  77. package/content-org/cost.mjs +36 -0
  78. package/content-org/lead.js +64 -0
  79. package/content-org/metrics.mjs +166 -0
  80. package/content-org/orchestrator.mjs +246 -0
  81. package/content-org/package.json +14 -0
  82. package/content-org/producer.mjs +59 -0
  83. package/content-org/publish.mjs +34 -0
  84. package/content-org/review.mjs +32 -0
  85. package/content-org/studio/README.md +27 -0
  86. package/content-org/studio/assemble.sh +48 -0
  87. package/content-org/test.mjs +110 -0
  88. package/index.js +32 -0
  89. package/package.json +49 -10
  90. package/providers.json.example +9 -0
  91. package/src/agent-router.js +124 -0
  92. package/src/agent.js +123 -0
  93. package/src/invoke.js +129 -0
  94. package/src/pio-client.js +142 -0
  95. package/src/provider.js +81 -0
  96. package/src/registry.js +160 -0
  97. package/src/richy-client.js +216 -0
  98. package/src/router.js +34 -0
  99. package/src/synthesize.js +111 -0
  100. package/src/weftens.js +61 -0
@@ -0,0 +1,266 @@
1
+ // Deterministic content & entity SEO strategy engine. No LLM.
2
+ //
3
+ // Input: normalized search-performance records (query, url, clicks, impressions,
4
+ // ctr, position, volume). Output: a keyword strategy — striking-distance keywords,
5
+ // high-impression/low-CTR opportunities, cannibalization, topic clusters, and a
6
+ // ranked opportunity list with the arithmetic shown on every row.
7
+ //
8
+ // Every projection is a modeled ESTIMATE (labeled). Cannibalization is observed
9
+ // directly in the data (labeled verified-in-data). Nothing here writes prose.
10
+
11
+ // Organic click-through rate by SERP position. Conservative industry-style curve;
12
+ // used to model the click ceiling a page is leaving on the table. Estimates only.
13
+ const CTR_CURVE = {
14
+ 1: 0.283, 2: 0.157, 3: 0.110, 4: 0.080, 5: 0.061,
15
+ 6: 0.048, 7: 0.040, 8: 0.033, 9: 0.028, 10: 0.025,
16
+ 11: 0.015, 12: 0.013, 13: 0.012, 14: 0.011, 15: 0.010,
17
+ 16: 0.009, 17: 0.008, 18: 0.008, 19: 0.007, 20: 0.007,
18
+ };
19
+
20
+ export function expectedCtr(position) {
21
+ if (position == null || !Number.isFinite(position)) return 0;
22
+ if (position < 1) return CTR_CURVE[1];
23
+ if (position > 20) return 0.005;
24
+ const lo = Math.floor(position);
25
+ const hi = Math.ceil(position);
26
+ if (lo === hi) return CTR_CURVE[lo];
27
+ const frac = position - lo;
28
+ return CTR_CURVE[lo] + (CTR_CURVE[hi] - CTR_CURVE[lo]) * frac; // linear interpolation
29
+ }
30
+
31
+ const STOPWORDS = new Set([
32
+ 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'and', 'or', 'with',
33
+ 'my', 'your', 'is', 'are', 'how', 'what', 'best', 'near', 'me', 'do', 'i',
34
+ 'can', 'you', 'vs', 'get', 'use',
35
+ ]);
36
+
37
+ // Deterministic intent staging from lexical signals. Precedence: act > compare > learn.
38
+ const INTENT_RULES = [
39
+ ['act', /\b(buy|price|pricing|cost|costs|cheap|cheapest|deal|deals|coupon|discount|order|shop|hire|book|booking|near me|for sale|quote|demo|sign ?up|trial|subscribe|download|free)\b/],
40
+ ['compare', /\b(best|top|vs|versus|review|reviews|comparison|compare|alternative|alternatives|which|better)\b/],
41
+ ['learn', /\b(how|what|why|when|who|where|guide|tutorial|ideas|examples|tips|meaning|definition|learn|explained)\b/],
42
+ ];
43
+
44
+ export function classifyIntent(query) {
45
+ const q = ` ${query.toLowerCase()} `;
46
+ for (const [stage, re] of INTENT_RULES) {
47
+ if (re.test(q)) return stage;
48
+ }
49
+ return 'unknown';
50
+ }
51
+
52
+ const round = (n, d = 0) => {
53
+ const f = 10 ** d;
54
+ return Math.round(n * f) / f;
55
+ };
56
+ const pct = (fraction) => `${round(fraction * 100, 1)}%`;
57
+
58
+ export function buildStrategy(records, options = {}) {
59
+ const cfg = {
60
+ strikingMin: 11,
61
+ strikingMax: 20,
62
+ strikingTarget: 5, // realistic mid-page-one landing spot to model against
63
+ lowCtrMaxPosition: 10,
64
+ lowCtrMinImpressions: 100,
65
+ lowCtrRatio: 0.7, // flag when actual CTR is below 70% of the position's expected CTR
66
+ cannibalMinImpressions: 1,
67
+ ...options,
68
+ };
69
+
70
+ const rows = records
71
+ .filter((r) => r.query)
72
+ .map((r) => ({ ...r, intent: classifyIntent(r.query) }));
73
+
74
+ const strikingDistance = [];
75
+ const lowCtrOpportunities = [];
76
+ const opportunities = [];
77
+
78
+ for (const r of rows) {
79
+ const pos = r.position;
80
+ if (pos == null) continue;
81
+
82
+ // --- Striking distance: page-two keywords one push from page one ---
83
+ if (pos >= cfg.strikingMin && pos <= cfg.strikingMax) {
84
+ const projectedCtr = expectedCtr(cfg.strikingTarget);
85
+ const projectedClicks = r.impressions * projectedCtr;
86
+ const gain = Math.max(0, projectedClicks - r.clicks);
87
+ const rec = {
88
+ query: r.query,
89
+ url: r.url,
90
+ intent: r.intent,
91
+ position: pos,
92
+ impressions: r.impressions,
93
+ clicks: r.clicks,
94
+ ctr: r.ctr,
95
+ lever: 'improve-rank',
96
+ target: `position ${cfg.strikingTarget}`,
97
+ projectedCtr: round(projectedCtr, 4),
98
+ projectedClicks: round(projectedClicks, 1),
99
+ opportunityClicks: round(gain, 1),
100
+ confidence: 'estimated',
101
+ math: `pos ${round(pos, 1)} (page 2) → target pos ${cfg.strikingTarget}: `
102
+ + `${r.impressions} impr × ${pct(projectedCtr)} = ${round(projectedClicks, 1)} est. clicks `
103
+ + `− ${r.clicks} now = +${round(gain, 1)}`,
104
+ };
105
+ strikingDistance.push(rec);
106
+ opportunities.push(rec);
107
+ continue;
108
+ }
109
+
110
+ // --- High-impression / low-CTR: ranks on page one but underclicks its slot ---
111
+ if (pos <= cfg.lowCtrMaxPosition && r.impressions >= cfg.lowCtrMinImpressions) {
112
+ const expCtr = expectedCtr(pos);
113
+ if (expCtr > 0 && r.ctr < expCtr * cfg.lowCtrRatio) {
114
+ const projectedClicks = r.impressions * expCtr;
115
+ const gain = Math.max(0, projectedClicks - r.clicks);
116
+ const rec = {
117
+ query: r.query,
118
+ url: r.url,
119
+ intent: r.intent,
120
+ position: pos,
121
+ impressions: r.impressions,
122
+ clicks: r.clicks,
123
+ ctr: r.ctr,
124
+ lever: 'improve-ctr',
125
+ target: 'title/meta rewrite',
126
+ projectedCtr: round(expCtr, 4),
127
+ projectedClicks: round(projectedClicks, 1),
128
+ opportunityClicks: round(gain, 1),
129
+ confidence: 'estimated',
130
+ math: `pos ${round(pos, 1)}: actual CTR ${pct(r.ctr)} vs expected ${pct(expCtr)}; `
131
+ + `${r.impressions} impr × ${pct(expCtr)} = ${round(projectedClicks, 1)} est. clicks `
132
+ + `− ${r.clicks} now = +${round(gain, 1)}`,
133
+ };
134
+ lowCtrOpportunities.push(rec);
135
+ opportunities.push(rec);
136
+ }
137
+ }
138
+ }
139
+
140
+ const cannibalization = detectCannibalization(rows, cfg);
141
+ const clusters = clusterTopics(rows);
142
+
143
+ // Ranked opportunity list: highest modeled click gain first, deterministic tie-break.
144
+ const cmp = (a, b) => (b.opportunityClicks - a.opportunityClicks)
145
+ || a.query.localeCompare(b.query)
146
+ || (a.url || '').localeCompare(b.url || '');
147
+ opportunities.sort(cmp);
148
+ strikingDistance.sort(cmp);
149
+ lowCtrOpportunities.sort(cmp);
150
+
151
+ const totalOpportunityClicks = round(
152
+ opportunities.reduce((s, o) => s + o.opportunityClicks, 0), 1,
153
+ );
154
+
155
+ return {
156
+ input: {
157
+ rows: rows.length,
158
+ hasUrls: rows.some((r) => r.url),
159
+ withPosition: rows.filter((r) => r.position != null).length,
160
+ },
161
+ summary: buildSummary({
162
+ rows, strikingDistance, lowCtrOpportunities, cannibalization,
163
+ opportunities, totalOpportunityClicks,
164
+ }),
165
+ totalOpportunityClicks,
166
+ strikingDistance,
167
+ lowCtrOpportunities,
168
+ cannibalization,
169
+ clusters,
170
+ opportunities,
171
+ };
172
+ }
173
+
174
+ function detectCannibalization(rows, cfg) {
175
+ const byQuery = new Map();
176
+ for (const r of rows) {
177
+ if (!r.url) continue;
178
+ if (r.impressions < cfg.cannibalMinImpressions) continue;
179
+ if (!byQuery.has(r.query)) byQuery.set(r.query, new Map());
180
+ const urls = byQuery.get(r.query);
181
+ // aggregate in case the same query+url appears on multiple rows
182
+ const prev = urls.get(r.url) || { url: r.url, clicks: 0, impressions: 0, position: r.position };
183
+ prev.clicks += r.clicks;
184
+ prev.impressions += r.impressions;
185
+ // keep the best (lowest) position seen for that url
186
+ if (r.position != null && (prev.position == null || r.position < prev.position)) prev.position = r.position;
187
+ urls.set(r.url, prev);
188
+ }
189
+
190
+ const out = [];
191
+ for (const [query, urlMap] of byQuery) {
192
+ if (urlMap.size < 2) continue;
193
+ const urls = [...urlMap.values()].sort(
194
+ (a, b) => (b.impressions - a.impressions) || a.url.localeCompare(b.url),
195
+ );
196
+ const totalImpr = urls.reduce((s, u) => s + u.impressions, 0);
197
+ const primary = urls[0];
198
+ const competing = urls.slice(1);
199
+ const splitImpr = totalImpr - primary.impressions;
200
+ const splitShare = totalImpr > 0 ? splitImpr / totalImpr : 0;
201
+ out.push({
202
+ query,
203
+ urlCount: urls.length,
204
+ urls: urls.map((u) => ({
205
+ url: u.url,
206
+ clicks: u.clicks,
207
+ impressions: u.impressions,
208
+ position: u.position,
209
+ })),
210
+ primaryUrl: primary.url,
211
+ competingUrls: competing.map((u) => u.url),
212
+ splitShare: round(splitShare, 3),
213
+ severity: splitShare >= 0.2 ? 'warn' : 'info',
214
+ confidence: 'verified-in-data',
215
+ note: `${urls.length} URLs rank for "${query}"; ${pct(splitShare)} of impressions land off the primary URL (${primary.url}).`,
216
+ });
217
+ }
218
+ out.sort((a, b) => (b.splitShare - a.splitShare) || a.query.localeCompare(b.query));
219
+ return out;
220
+ }
221
+
222
+ // Heuristic (not semantic) topic clustering: group queries by their highest-signal
223
+ // shared token. Deterministic; used to sketch pillar/cluster structure.
224
+ function clusterTopics(rows) {
225
+ const freq = new Map();
226
+ const tokenize = (q) => q.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t && t.length > 2 && !STOPWORDS.has(t));
227
+ for (const r of rows) {
228
+ for (const t of new Set(tokenize(r.query))) freq.set(t, (freq.get(t) || 0) + 1);
229
+ }
230
+ const clusters = new Map();
231
+ for (const r of rows) {
232
+ const tokens = tokenize(r.query);
233
+ let label = tokens[0] || r.query.toLowerCase();
234
+ let bestFreq = -1;
235
+ for (const t of tokens) {
236
+ const f = freq.get(t) || 0;
237
+ if (f > bestFreq || (f === bestFreq && t < label)) { bestFreq = f; label = t; }
238
+ }
239
+ if (!clusters.has(label)) clusters.set(label, { topic: label, queries: [], impressions: 0, clicks: 0 });
240
+ const c = clusters.get(label);
241
+ c.queries.push(r.query);
242
+ c.impressions += r.impressions;
243
+ c.clicks += r.clicks;
244
+ }
245
+ return [...clusters.values()]
246
+ .map((c) => ({ ...c, size: c.queries.length, queries: [...c.queries].sort() }))
247
+ .sort((a, b) => (b.impressions - a.impressions) || (b.size - a.size) || a.topic.localeCompare(b.topic));
248
+ }
249
+
250
+ // Factual, templated read — filled from the numbers, not generated prose.
251
+ function buildSummary(s) {
252
+ const parts = [];
253
+ parts.push(`${s.rows.length} queries analyzed; ${s.opportunities.length} carry modeled upside worth ~${s.totalOpportunityClicks} est. clicks/mo.`);
254
+ if (s.strikingDistance.length) {
255
+ const top = s.strikingDistance[0];
256
+ parts.push(`${s.strikingDistance.length} striking-distance keyword(s) (page 2) — biggest: "${top.query}" (+${top.opportunityClicks} est. clicks if moved to top of page 1).`);
257
+ }
258
+ if (s.lowCtrOpportunities.length) {
259
+ const top = s.lowCtrOpportunities[0];
260
+ parts.push(`${s.lowCtrOpportunities.length} page-one keyword(s) underclicking their slot — biggest: "${top.query}" (title/meta lever, +${top.opportunityClicks} est. clicks).`);
261
+ }
262
+ if (s.cannibalization.length) {
263
+ parts.push(`${s.cannibalization.length} query(ies) split across multiple URLs (cannibalization) — consolidate to one canonical page.`);
264
+ }
265
+ return parts.join(' ');
266
+ }
@@ -0,0 +1,11 @@
1
+ Query,Landing Page,Clicks,Impressions,CTR,Position
2
+ crm for real estate agents,https://acme.example/crm-real-estate,20,"2,000",1.00%,14.0
3
+ best crm for solar installers,https://acme.example/crm-solar,8,900,0.89%,17.4
4
+ free crm,https://acme.example/free-crm,50,"5,000",1.00%,4.0
5
+ crm pricing,https://acme.example/pricing,40,800,5.00%,6.0
6
+ crm pricing,https://acme.example/plans,5,300,1.67%,9.0
7
+ best crm software,https://acme.example/best-crm,110,"1,000",11.00%,3.0
8
+ how to use a crm,https://acme.example/crm-guide,300,"3,000",10.00%,2.0
9
+ buy crm software online,https://acme.example/checkout,2,60,3.33%,8.0
10
+ enterprise crm platform,https://acme.example/enterprise,1,1200,0.08%,42.0
11
+ crm integrations,https://acme.example/integrations,12,1500,0.80%,7.0
@@ -0,0 +1,59 @@
1
+ // Parser tests — run with `node --test`. Confirms the CSV/JSON normalizer turns
2
+ // messy real-export columns into clean numeric records.
3
+
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { dirname, join } from 'node:path';
9
+ import { parseRecords, parseCsv, _internal } from '../src/parse.mjs';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const csv = readFileSync(join(__dirname, 'fixtures', 'gsc-sample.csv'), 'utf8');
13
+
14
+ test('CSV parser respects quoted fields with embedded commas', () => {
15
+ const rows = parseCsv('a,b\n"1,234",x\n');
16
+ assert.deepEqual(rows, [['a', 'b'], ['1,234', 'x']]);
17
+ });
18
+
19
+ test('thousands separators and percent CTR parse to real numbers', () => {
20
+ const recs = parseRecords(csv);
21
+ const rre = recs.find((r) => r.query === 'crm for real estate agents');
22
+ assert.equal(rre.impressions, 2000, '"2,000" -> 2000');
23
+ assert.equal(rre.clicks, 20);
24
+ assert.equal(rre.ctr, 0.01, '1.00% -> 0.01');
25
+ assert.equal(rre.position, 14);
26
+ });
27
+
28
+ test('bare CTR ">1" is treated as a percentage, "<=1" as a fraction', () => {
29
+ const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} ~= ${b}`);
30
+ near(_internal.parseCtr('5.2'), 0.052);
31
+ near(_internal.parseCtr('0.052'), 0.052);
32
+ near(_internal.parseCtr('5.2%'), 0.052);
33
+ });
34
+
35
+ test('header aliases normalize across GSC/Semrush/Ahrefs naming', () => {
36
+ assert.equal(_internal.normalizeHeader('Keyword'), 'query');
37
+ assert.equal(_internal.normalizeHeader('Avg. Position'), 'position');
38
+ assert.equal(_internal.normalizeHeader('URL Clicks'), 'clicks');
39
+ assert.equal(_internal.normalizeHeader('Search Volume'), 'volume');
40
+ });
41
+
42
+ test('CTR is derived from clicks/impressions when the column is absent', () => {
43
+ const recs = parseRecords('Keyword,URL,Clicks,Impressions,Position\nx,https://a,25,1000,6\n');
44
+ assert.equal(recs[0].ctr, 0.025);
45
+ });
46
+
47
+ test('JSON input yields the same normalized shape as CSV', () => {
48
+ const recs = parseRecords(JSON.stringify([
49
+ { query: 'x', url: 'https://a', clicks: 10, impressions: 500, ctr: '2%', position: 8 },
50
+ ]));
51
+ assert.equal(recs.length, 1);
52
+ assert.deepEqual(recs[0], {
53
+ query: 'x', url: 'https://a', clicks: 10, impressions: 500, ctr: 0.02, position: 8, volume: null,
54
+ });
55
+ });
56
+
57
+ test('all ten fixture rows parse (none dropped)', () => {
58
+ assert.equal(parseRecords(csv).length, 10);
59
+ });
@@ -0,0 +1,108 @@
1
+ // Strategy-engine tests — run with `node --test`. Every assertion pins a SPECIFIC
2
+ // seeded issue in the fixture. If these pass, the engine is doing real arithmetic
3
+ // and threshold logic, not echoing a prompt.
4
+
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname, join } from 'node:path';
10
+ import { parseRecords } from '../src/parse.mjs';
11
+ import { buildStrategy, expectedCtr, classifyIntent } from '../src/strategy.mjs';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const csv = readFileSync(join(__dirname, 'fixtures', 'gsc-sample.csv'), 'utf8');
15
+ const records = parseRecords(csv);
16
+ const result = buildStrategy(records);
17
+
18
+ const q = (list, query) => list.find((x) => x.query === query);
19
+
20
+ test('striking-distance detects the two page-two keywords (pos 11-20)', () => {
21
+ const queries = result.strikingDistance.map((r) => r.query).sort();
22
+ assert.deepEqual(queries, ['best crm for solar installers', 'crm for real estate agents']);
23
+ for (const r of result.strikingDistance) {
24
+ assert.ok(r.position >= 11 && r.position <= 20);
25
+ assert.equal(r.lever, 'improve-rank');
26
+ }
27
+ });
28
+
29
+ test('striking-distance click math is real: 2000 impr x CTR@pos5 - 20 now', () => {
30
+ const rec = q(result.strikingDistance, 'crm for real estate agents');
31
+ // expectedCtr(5) = 0.061 -> 2000*0.061 = 122 ; gain = 122 - 20 = 102
32
+ assert.equal(rec.projectedCtr, 0.061);
33
+ assert.equal(rec.projectedClicks, 122);
34
+ assert.equal(rec.opportunityClicks, 102);
35
+ assert.match(rec.math, /2000 impr/);
36
+ });
37
+
38
+ test('high-impression/low-CTR flags "free crm" with the title/meta lever', () => {
39
+ const rec = q(result.lowCtrOpportunities, 'free crm');
40
+ assert.ok(rec, 'free crm surfaced as a low-CTR opportunity');
41
+ assert.equal(rec.lever, 'improve-ctr');
42
+ // pos4 expected CTR 0.08 -> 5000*0.08 = 400 ; gain = 400 - 50 = 350
43
+ assert.equal(rec.projectedClicks, 400);
44
+ assert.equal(rec.opportunityClicks, 350);
45
+ });
46
+
47
+ test('a healthy page-one keyword is NOT flagged as low-CTR', () => {
48
+ // "best crm software" pos3, 11% CTR is above 70% of expected(3)=0.11
49
+ assert.equal(q(result.lowCtrOpportunities, 'best crm software'), undefined);
50
+ });
51
+
52
+ test('cannibalization: "crm pricing" splits across two URLs', () => {
53
+ const rec = q(result.cannibalization, 'crm pricing');
54
+ assert.ok(rec, 'cannibalization detected');
55
+ assert.equal(rec.urlCount, 2);
56
+ assert.equal(rec.primaryUrl, 'https://acme.example/pricing');
57
+ assert.deepEqual(rec.competingUrls, ['https://acme.example/plans']);
58
+ // 300 of 1100 impressions land off the primary URL -> ~0.273, above the 0.2 warn line
59
+ assert.equal(rec.splitShare, 0.273);
60
+ assert.equal(rec.severity, 'warn');
61
+ assert.equal(rec.confidence, 'verified-in-data');
62
+ });
63
+
64
+ test('a single-URL query is not reported as cannibalization', () => {
65
+ assert.equal(q(result.cannibalization, 'free crm'), undefined);
66
+ });
67
+
68
+ test('opportunity list is ranked by modeled click gain, descending', () => {
69
+ const gains = result.opportunities.map((o) => o.opportunityClicks);
70
+ for (let i = 1; i < gains.length; i += 1) {
71
+ assert.ok(gains[i - 1] >= gains[i], 'sorted descending by opportunityClicks');
72
+ }
73
+ assert.ok(result.totalOpportunityClicks > 0);
74
+ });
75
+
76
+ test('intent classification is lexical and deterministic', () => {
77
+ assert.equal(classifyIntent('buy crm software online'), 'act');
78
+ assert.equal(classifyIntent('best crm software'), 'compare');
79
+ assert.equal(classifyIntent('how to use a crm'), 'learn');
80
+ assert.equal(classifyIntent('enterprise crm platform'), 'unknown');
81
+ });
82
+
83
+ test('expectedCtr curve is monotonic down and interpolates fractional positions', () => {
84
+ assert.ok(expectedCtr(1) > expectedCtr(5));
85
+ assert.ok(expectedCtr(5) > expectedCtr(10));
86
+ assert.ok(expectedCtr(10) > expectedCtr(20));
87
+ const mid = expectedCtr(1.5);
88
+ assert.ok(mid < expectedCtr(1) && mid > expectedCtr(2), 'pos 1.5 lies between pos 1 and 2');
89
+ });
90
+
91
+ test('topic clustering groups the crm queries under a shared token', () => {
92
+ const crm = result.clusters.find((c) => c.topic === 'crm');
93
+ assert.ok(crm, 'a "crm" cluster exists');
94
+ assert.ok(crm.size >= 5);
95
+ assert.ok(crm.impressions > 0);
96
+ });
97
+
98
+ test('same input -> byte-identical output (deterministic)', () => {
99
+ const a = JSON.stringify(buildStrategy(parseRecords(csv)));
100
+ const b = JSON.stringify(buildStrategy(parseRecords(csv)));
101
+ assert.equal(a, b);
102
+ });
103
+
104
+ test('the templated read reports real counts, no fabricated prose', () => {
105
+ assert.match(result.summary, /10 queries analyzed/);
106
+ assert.match(result.summary, /striking-distance/);
107
+ assert.match(result.summary, /cannibalization/);
108
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@agent-cores/seo-technical",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Technical-SEO auditor. Deterministic: fetch a URL, compute crawl/index/render findings. No LLM in the core.",
7
+ "bin": {
8
+ "seo-technical": "src/cli.mjs"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test",
12
+ "audit": "node src/cli.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">=20"
16
+ }
17
+ }