weftens 0.0.1 → 0.1.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.
Files changed (101) hide show
  1. package/README.md +178 -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 +234 -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/format.js +66 -0
  94. package/src/invoke.js +129 -0
  95. package/src/pio-client.js +142 -0
  96. package/src/provider.js +81 -0
  97. package/src/registry.js +160 -0
  98. package/src/richy-client.js +216 -0
  99. package/src/router.js +34 -0
  100. package/src/synthesize.js +111 -0
  101. package/src/weftens.js +61 -0
@@ -0,0 +1,56 @@
1
+ // Parser tests — run with `node --test`, zero install. Confirms the CSV tokenizer,
2
+ // Google Ads header detection, and currency/percent coercion do real work.
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 { parseCsv, parseReport, toNumber, toShare } from '../src/parse.mjs';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const stCsv = readFileSync(join(__dirname, 'fixtures', 'search-terms.csv'), 'utf8');
13
+ const cCsv = readFileSync(join(__dirname, 'fixtures', 'campaigns.csv'), 'utf8');
14
+
15
+ test('parseCsv handles quoted fields with embedded commas', () => {
16
+ const rows = parseCsv('a,"b,c",d\n1,2,3\n');
17
+ assert.deepEqual(rows[0], ['a', 'b,c', 'd']);
18
+ assert.deepEqual(rows[1], ['1', '2', '3']);
19
+ });
20
+
21
+ test('toNumber strips currency, commas, and quotes', () => {
22
+ assert.equal(toNumber('$1,234.50'), 1234.5);
23
+ assert.equal(toNumber('--'), null);
24
+ assert.equal(toNumber(''), null);
25
+ assert.equal(toNumber('42'), 42);
26
+ });
27
+
28
+ test('toShare normalizes percent, decimal, and Google floor/ceiling forms', () => {
29
+ assert.equal(toShare('35%'), 0.35);
30
+ assert.equal(toShare('0.35'), 0.35);
31
+ assert.equal(toShare('< 10%'), 0.1);
32
+ assert.equal(toShare('> 90%'), 0.9);
33
+ assert.equal(toShare('--'), null);
34
+ });
35
+
36
+ test('parseReport skips title/date/total rows and maps canonical fields', () => {
37
+ const st = parseReport(stCsv);
38
+ // 7 data rows, the "Total:" row dropped
39
+ assert.equal(st.length, 7);
40
+ const first = st[0];
41
+ assert.equal(first.searchTerm, 'buy blue widgets');
42
+ assert.equal(first.campaign, 'Generic');
43
+ assert.equal(first.clicks, 40);
44
+ assert.equal(first.cost, 120);
45
+ assert.equal(first.conversions, 6);
46
+ });
47
+
48
+ test('parseReport maps share + bidding columns on the campaign report', () => {
49
+ const c = parseReport(cCsv);
50
+ assert.equal(c.length, 3);
51
+ const generic = c.find((x) => x.campaign === 'Generic');
52
+ assert.equal(generic.searchIS, 0.45);
53
+ assert.equal(generic.lostISBudget, 0.35);
54
+ assert.equal(generic.biddingStrategy, 'Maximize conversions');
55
+ assert.equal(generic.sharedBudget, 'Shared-1');
56
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@agent-cores/seo-content",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Content & entity SEO strategy engine. Deterministic: parse a GSC/Semrush/Ahrefs export, compute striking-distance, low-CTR, and cannibalization opportunities with the math shown. No LLM in the core.",
7
+ "bin": {
8
+ "seo-content": "src/cli.mjs"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test",
12
+ "strategy": "node src/cli.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">=20"
16
+ }
17
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // Real CLI:
3
+ // seo-content <export.csv|export.json> [--pretty]
4
+ // Reads a GSC/Semrush/Ahrefs search-performance export, computes the keyword
5
+ // strategy, prints JSON to stdout and a one-line read to stderr.
6
+
7
+ import { readFileSync } from 'node:fs';
8
+ import { parseRecords } from './parse.mjs';
9
+ import { buildStrategy } from './strategy.mjs';
10
+
11
+ function parseArgs(argv) {
12
+ const args = { _: [] };
13
+ for (let i = 0; i < argv.length; i += 1) {
14
+ const a = argv[i];
15
+ if (a === '--pretty') args.pretty = true;
16
+ else if (a === '--target') args.target = Number(argv[++i]);
17
+ else args._.push(a);
18
+ }
19
+ return args;
20
+ }
21
+
22
+ function main() {
23
+ const args = parseArgs(process.argv.slice(2));
24
+ const file = args._[0];
25
+ if (!file) {
26
+ console.error('Usage: seo-content <export.csv|export.json> [--pretty] [--target <position>]');
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+
31
+ const text = readFileSync(file, 'utf8');
32
+ const records = parseRecords(text);
33
+ const options = {};
34
+ if (Number.isFinite(args.target)) options.strikingTarget = args.target;
35
+ const result = buildStrategy(records, options);
36
+
37
+ console.error(`seo-content: ${result.summary}`);
38
+ console.log(JSON.stringify(result, null, args.pretty ? 2 : 0));
39
+ }
40
+
41
+ main();
@@ -0,0 +1,141 @@
1
+ // Dependency-free parsing for search-performance exports.
2
+ // Handles GSC ("Queries" tab), Semrush "Organic Positions", and Ahrefs "Organic
3
+ // keywords" CSVs, plus a JSON array of the same records. The core downstream
4
+ // (strategy.mjs) never sees raw columns — only normalized numeric records.
5
+
6
+ // --- RFC-4180-ish CSV parser: quoted fields, embedded commas/newlines, "" escapes ---
7
+ export function parseCsv(text) {
8
+ const rows = [];
9
+ let row = [];
10
+ let field = '';
11
+ let inQuotes = false;
12
+ let sawAny = false;
13
+
14
+ for (let i = 0; i < text.length; i += 1) {
15
+ const c = text[i];
16
+ if (inQuotes) {
17
+ if (c === '"') {
18
+ if (text[i + 1] === '"') { field += '"'; i += 1; }
19
+ else inQuotes = false;
20
+ } else {
21
+ field += c;
22
+ }
23
+ } else if (c === '"') {
24
+ inQuotes = true;
25
+ sawAny = true;
26
+ } else if (c === ',') {
27
+ row.push(field); field = ''; sawAny = true;
28
+ } else if (c === '\r') {
29
+ // ignore CR; LF terminates
30
+ } else if (c === '\n') {
31
+ row.push(field); rows.push(row); row = []; field = ''; sawAny = false;
32
+ } else {
33
+ field += c; sawAny = true;
34
+ }
35
+ }
36
+ if (sawAny || field.length > 0 || row.length > 0) {
37
+ row.push(field);
38
+ rows.push(row);
39
+ }
40
+ // drop fully-blank lines
41
+ return rows.filter((r) => !(r.length === 1 && r[0].trim() === ''));
42
+ }
43
+
44
+ // header aliases -> canonical field. Compared lowercase, punctuation-stripped.
45
+ const HEADER_MAP = [
46
+ ['query', ['query', 'queries', 'keyword', 'keywords', 'search term', 'searchterm', 'top queries', 'term']],
47
+ ['url', ['url', 'page', 'landing page', 'address', 'top pages', 'urls', 'current url']],
48
+ ['clicks', ['clicks', 'url clicks', 'organic clicks']],
49
+ ['impressions', ['impressions', 'impr', 'impressions.']],
50
+ ['ctr', ['ctr', 'click through rate', 'clickthrough rate', 'url ctr']],
51
+ ['position', ['position', 'avg position', 'average position', 'pos', 'current position', 'rank']],
52
+ ['volume', ['volume', 'search volume', 'vol']],
53
+ ];
54
+
55
+ function normalizeHeader(h) {
56
+ const key = h.toLowerCase().replace(/[.%]/g, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
57
+ for (const [canon, aliases] of HEADER_MAP) {
58
+ if (aliases.includes(key)) return canon;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ // "1,234" -> 1234 ; "5.2%" -> 5.2 ; "" -> null
64
+ function parseNumber(raw) {
65
+ if (raw == null) return null;
66
+ const s = String(raw).replace(/[,%\s$]/g, '').trim();
67
+ if (s === '') return null;
68
+ const n = Number(s);
69
+ return Number.isFinite(n) ? n : null;
70
+ }
71
+
72
+ // CTR can arrive as "5.20%", "5.2", or "0.052". Return a fraction in [0,1].
73
+ function parseCtr(raw) {
74
+ if (raw == null) return null;
75
+ const hadPercent = String(raw).includes('%');
76
+ const n = parseNumber(raw);
77
+ if (n == null) return null;
78
+ if (hadPercent) return n / 100;
79
+ return n > 1 ? n / 100 : n; // bare "5.2" is 5.2%, "0.052" is already a fraction
80
+ }
81
+
82
+ function rowsToRecords(rows) {
83
+ if (rows.length === 0) return [];
84
+ const header = rows[0].map(normalizeHeader);
85
+ const records = [];
86
+ for (let r = 1; r < rows.length; r += 1) {
87
+ const cells = rows[r];
88
+ const rec = {};
89
+ for (let c = 0; c < header.length; c += 1) {
90
+ if (header[c]) rec[header[c]] = cells[c];
91
+ }
92
+ const norm = normalizeRecord(rec);
93
+ if (norm) records.push(norm);
94
+ }
95
+ return records;
96
+ }
97
+
98
+ function normalizeRecord(rec) {
99
+ const query = (rec.query ?? '').toString().trim();
100
+ if (!query) return null;
101
+ const clicks = parseNumber(rec.clicks);
102
+ const impressions = parseNumber(rec.impressions);
103
+ let ctr = parseCtr(rec.ctr);
104
+ const position = parseNumber(rec.position);
105
+ const volume = parseNumber(rec.volume);
106
+ // Derive CTR when both counts are present but CTR is missing.
107
+ if ((ctr == null || Number.isNaN(ctr)) && clicks != null && impressions != null && impressions > 0) {
108
+ ctr = clicks / impressions;
109
+ }
110
+ return {
111
+ query,
112
+ url: (rec.url ?? '').toString().trim() || null,
113
+ clicks: clicks ?? 0,
114
+ impressions: impressions ?? 0,
115
+ ctr: ctr ?? 0,
116
+ position: position ?? null,
117
+ volume: volume ?? null,
118
+ };
119
+ }
120
+
121
+ // Accepts a raw file body. JSON array/object -> records; otherwise CSV.
122
+ export function parseRecords(text) {
123
+ const trimmed = text.trimStart();
124
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
125
+ const data = JSON.parse(text);
126
+ const arr = Array.isArray(data) ? data : (Array.isArray(data.rows) ? data.rows : [data]);
127
+ return arr
128
+ .map((o) => {
129
+ const rec = {};
130
+ for (const [k, v] of Object.entries(o)) {
131
+ const canon = normalizeHeader(k);
132
+ if (canon) rec[canon] = v;
133
+ }
134
+ return normalizeRecord(rec);
135
+ })
136
+ .filter(Boolean);
137
+ }
138
+ return rowsToRecords(parseCsv(text));
139
+ }
140
+
141
+ export const _internal = { parseNumber, parseCtr, normalizeHeader };
@@ -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
+ });