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,158 @@
1
+ // Dependency-free CSV parser + Google Ads column mapper.
2
+ // Real Google Ads UI exports carry a title row, a date-range row, sometimes a blank
3
+ // line, then the real header, the data rows, and a trailing "Total: ..." row. We find
4
+ // the header by looking for a row that contains known column tokens, map arbitrary
5
+ // header spellings to canonical field names, and coerce currency/percent/number cells.
6
+
7
+ // --- CSV tokenizer (state machine; handles quotes, embedded commas, CRLF) ---
8
+
9
+ export function parseCsv(text) {
10
+ const rows = [];
11
+ let row = [];
12
+ let field = '';
13
+ let inQuotes = false;
14
+ const s = String(text).replace(/^/, ''); // strip BOM
15
+
16
+ for (let i = 0; i < s.length; i += 1) {
17
+ const c = s[i];
18
+ if (inQuotes) {
19
+ if (c === '"') {
20
+ if (s[i + 1] === '"') { field += '"'; i += 1; }
21
+ else inQuotes = false;
22
+ } else {
23
+ field += c;
24
+ }
25
+ } else if (c === '"') {
26
+ inQuotes = true;
27
+ } else if (c === ',') {
28
+ row.push(field); field = '';
29
+ } else if (c === '\n') {
30
+ row.push(field); field = '';
31
+ rows.push(row); row = [];
32
+ } else if (c === '\r') {
33
+ // handled by \n; ignore lone CR
34
+ } else {
35
+ field += c;
36
+ }
37
+ }
38
+ // flush last field/row if any content
39
+ if (field.length > 0 || row.length > 0) {
40
+ row.push(field);
41
+ rows.push(row);
42
+ }
43
+ return rows;
44
+ }
45
+
46
+ // --- header normalization + alias map ---
47
+
48
+ function normHeader(h) {
49
+ return String(h).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
50
+ }
51
+
52
+ // canonical field -> the normalized header spellings that map to it
53
+ const ALIASES = {
54
+ searchTerm: ['search term', 'search terms', 'query'],
55
+ campaign: ['campaign', 'campaign name'],
56
+ adGroup: ['ad group', 'ad group name'],
57
+ matchType: ['match type', 'search term match type'],
58
+ impressions: ['impr', 'impressions', 'impr '],
59
+ clicks: ['clicks'],
60
+ cost: ['cost', 'spend'],
61
+ conversions: ['conversions', 'conv', 'conversions all', 'all conv'],
62
+ convValue: ['conv value', 'conversion value', 'all conv value', 'total conv value', 'conv value all'],
63
+ searchIS: ['search impr share', 'search impression share', 'impr share', 'search is'],
64
+ lostISBudget: [
65
+ 'search lost is budget', 'search lost impr share budget', 'budget lost is',
66
+ 'search lost impression share budget', 'lost is budget',
67
+ ],
68
+ lostISRank: ['search lost is rank', 'search lost impr share rank', 'lost is rank'],
69
+ biddingStrategy: ['bid strategy type', 'bidding strategy', 'bid strategy'],
70
+ budget: ['budget', 'daily budget', 'budget amount'],
71
+ sharedBudget: ['budget name', 'shared budget', 'budget name shared'],
72
+ };
73
+
74
+ // reverse lookup: normalized header -> canonical
75
+ const HEADER_TO_FIELD = (() => {
76
+ const m = new Map();
77
+ for (const [field, spellings] of Object.entries(ALIASES)) {
78
+ for (const sp of spellings) m.set(sp, field);
79
+ }
80
+ return m;
81
+ })();
82
+
83
+ const SHARE_FIELDS = new Set(['searchIS', 'lostISBudget', 'lostISRank']);
84
+ const NUMERIC_FIELDS = new Set(['impressions', 'clicks', 'cost', 'conversions', 'convValue', 'budget']);
85
+
86
+ // --- cell coercion ---
87
+
88
+ export function toNumber(raw) {
89
+ if (raw == null) return null;
90
+ let v = String(raw).trim();
91
+ if (v === '' || v === '--' || v === '—' || v.toLowerCase() === 'n/a') return null;
92
+ v = v.replace(/[$£€,%\s"]/g, '');
93
+ if (v === '' || v === '<' || v === '>') return null;
94
+ v = v.replace(/^[<>]/, ''); // "< 10" style already stripped of %
95
+ const n = Number.parseFloat(v);
96
+ return Number.isFinite(n) ? n : null;
97
+ }
98
+
99
+ // Impression-share style cell -> fraction in [0,1]. Handles "35%", "0.35", "35",
100
+ // "< 10%" (Google's floor -> 0.10), "> 90%" (ceiling -> 0.90), "--".
101
+ export function toShare(raw) {
102
+ if (raw == null) return null;
103
+ const v = String(raw).trim();
104
+ if (v === '' || v === '--' || v === '—') return null;
105
+ const hadPercent = v.includes('%');
106
+ const n = toNumber(v);
107
+ if (n == null) return null;
108
+ let frac = hadPercent ? n / 100 : (n > 1.5 ? n / 100 : n);
109
+ if (frac < 0) frac = 0;
110
+ if (frac > 1) frac = 1;
111
+ return frac;
112
+ }
113
+
114
+ function coerce(field, raw) {
115
+ if (SHARE_FIELDS.has(field)) return toShare(raw);
116
+ if (NUMERIC_FIELDS.has(field)) return toNumber(raw);
117
+ const s = raw == null ? '' : String(raw).trim();
118
+ return s;
119
+ }
120
+
121
+ // Find the header row: the first row that maps at least `minKnown` cells to canonical fields.
122
+ function findHeader(rows, minKnown = 2) {
123
+ for (let i = 0; i < rows.length; i += 1) {
124
+ const mapped = rows[i].map((h) => HEADER_TO_FIELD.get(normHeader(h))).filter(Boolean);
125
+ if (new Set(mapped).size >= minKnown) return i;
126
+ }
127
+ return -1;
128
+ }
129
+
130
+ // Parse a Google Ads CSV export into canonical row objects.
131
+ export function parseReport(text) {
132
+ const rows = parseCsv(text);
133
+ const headerIdx = findHeader(rows);
134
+ if (headerIdx === -1) {
135
+ throw new Error('No recognizable Google Ads header row found (need e.g. "Campaign", "Cost", "Clicks").');
136
+ }
137
+ const header = rows[headerIdx];
138
+ const colField = header.map((h) => HEADER_TO_FIELD.get(normHeader(h)) || null);
139
+
140
+ const out = [];
141
+ for (let i = headerIdx + 1; i < rows.length; i += 1) {
142
+ const cells = rows[i];
143
+ // skip blank rows and Google's trailing "Total: ..." summary rows
144
+ const first = (cells[0] || '').trim();
145
+ if (cells.every((c) => (c || '').trim() === '')) continue;
146
+ if (/^total/i.test(first)) continue;
147
+
148
+ const rec = {};
149
+ for (let c = 0; c < colField.length; c += 1) {
150
+ const field = colField[c];
151
+ if (!field) continue;
152
+ rec[field] = coerce(field, cells[c]);
153
+ }
154
+ // require at least one identifying string so we don't emit garbage rows
155
+ if (rec.searchTerm || rec.campaign) out.push(rec);
156
+ }
157
+ return out;
158
+ }
@@ -0,0 +1,120 @@
1
+ // Engine tests — run with `node --test`, zero install. Asserts the analyzer detects
2
+ // the SPECIFIC issues seeded into the fixtures and that the output is deterministic.
3
+ // If these pass, the core is doing real arithmetic on real reports, 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 { analyze } from '../src/analyze.mjs';
11
+ import { parseReport } from '../src/parse.mjs';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const load = (f) => parseReport(readFileSync(join(__dirname, 'fixtures', f), 'utf8'));
15
+
16
+ function run() {
17
+ return analyze({
18
+ searchTerms: load('search-terms.csv'),
19
+ campaigns: load('campaigns.csv'),
20
+ });
21
+ }
22
+
23
+ const bySub = (r, sub) => r.changes.filter((c) => c.subtype === sub);
24
+ const findTerm = (r, term) => r.changes.find((c) => c.evidence && c.evidence.searchTerm === term);
25
+
26
+ test('flags zero-conversion queries with enough clicks as negatives', () => {
27
+ const r = run();
28
+ const negs = bySub(r, 'irrelevant');
29
+ const terms = negs.map((n) => n.evidence.searchTerm).sort();
30
+ assert.deepEqual(terms, ['free blue widgets', 'what is a widget', 'widget repair career jobs']);
31
+ });
32
+
33
+ test('does NOT flag a zero-conv query below the click threshold', () => {
34
+ const r = run();
35
+ // "blue widget reviews" has only 5 clicks — must not appear
36
+ assert.equal(findTerm(r, 'blue widget reviews'), undefined);
37
+ });
38
+
39
+ test('recovered spend on a negative equals the query cost', () => {
40
+ const r = run();
41
+ const free = findTerm(r, 'free blue widgets');
42
+ assert.equal(free.type, 'negative');
43
+ assert.equal(free.dollars, 90);
44
+ assert.equal(free.evidence.cost, 90);
45
+ });
46
+
47
+ test('flags a converting-but-inefficient query (CPA >> account CPA)', () => {
48
+ const r = run();
49
+ const eff = bySub(r, 'inefficient');
50
+ assert.equal(eff.length, 1);
51
+ assert.equal(eff[0].evidence.searchTerm, 'premium widget wholesale bulk');
52
+ // account CPA = 910/11 = 82.73; overspend = 500 - 1*82.73 = 417.27
53
+ assert.ok(Math.abs(eff[0].dollars - 417.27) < 0.5, `overspend ~417.27, got ${eff[0].dollars}`);
54
+ });
55
+
56
+ test('flags a strangled converting campaign as a budget move with real opportunity math', () => {
57
+ const r = run();
58
+ const budget = r.changes.filter((c) => c.type === 'budget');
59
+ const generic = budget.find((c) => c.evidence.campaign === 'Generic');
60
+ assert.ok(generic, 'Generic budget move exists');
61
+ assert.equal(generic.subtype, 'strangled'); // 35% lost >= 30%
62
+ // scale = 0.35/0.45 = 0.7778; addClicks = 778; addConv = 778*0.04 = 31.1; value = *100 = 3111
63
+ assert.equal(generic.evidence.addClicks, 778);
64
+ assert.ok(Math.abs(generic.dollars - 3111.2) < 5, `~3111 value, got ${generic.dollars}`);
65
+ });
66
+
67
+ test('flags Smart Bidding starved of conversions as a STRUCTURE fix', () => {
68
+ const r = run();
69
+ const struct = bySub(r, 'smart-bidding-starved');
70
+ assert.equal(struct.length, 1);
71
+ assert.equal(struct[0].type, 'structure');
72
+ assert.equal(struct[0].evidence.campaign, 'Competitor');
73
+ assert.equal(struct[0].evidence.conversions, 6); // below the 30 floor
74
+ });
75
+
76
+ test('flags a shared budget pool starving converting campaigns as STRUCTURE', () => {
77
+ const r = run();
78
+ const pool = bySub(r, 'shared-budget-starve');
79
+ assert.equal(pool.length, 1);
80
+ assert.equal(pool[0].evidence.pool, 'Shared-1');
81
+ assert.deepEqual(pool[0].evidence.members.sort(), ['Competitor', 'Generic']);
82
+ });
83
+
84
+ test('structure outranks the budget move on the same campaign', () => {
85
+ const r = run();
86
+ const bidding = r.changes.find((c) => c.subtype === 'smart-bidding-starved');
87
+ const compBudget = r.changes.find((c) => c.type === 'budget' && c.evidence.campaign === 'Competitor');
88
+ assert.ok(bidding.rank < compBudget.rank, `structure (rank ${bidding.rank}) must precede budget (rank ${compBudget.rank})`);
89
+ });
90
+
91
+ test('the change sheet is ranked by dollars of impact', () => {
92
+ const r = run();
93
+ const d = r.changes.map((c) => c.rankValue);
94
+ for (let i = 1; i < d.length; i += 1) {
95
+ assert.ok(d[i - 1] >= d[i], `rankValue must be non-increasing: ${d[i - 1]} < ${d[i]} at ${i}`);
96
+ }
97
+ // top finding is the aggregate shared-budget root cause
98
+ assert.equal(r.changes[0].subtype, 'shared-budget-starve');
99
+ });
100
+
101
+ test('counts and totals reflect the seeded fixture exactly', () => {
102
+ const r = run();
103
+ assert.equal(r.counts.negatives, 4); // 3 irrelevant + 1 inefficient
104
+ assert.equal(r.counts.budget, 2);
105
+ assert.equal(r.counts.structure, 2);
106
+ assert.equal(r.account.totalConversions, 11);
107
+ assert.equal(r.proposeOnly, true);
108
+ });
109
+
110
+ test('output is deterministic — same input yields byte-identical JSON', () => {
111
+ const a = JSON.stringify(run());
112
+ const b = JSON.stringify(run());
113
+ assert.equal(a, b);
114
+ });
115
+
116
+ test('empty input produces a clean, non-crashing result', () => {
117
+ const r = analyze({ searchTerms: [], campaigns: [] });
118
+ assert.equal(r.changes.length, 0);
119
+ assert.match(r.summary, /No negative, budget, or structure changes/);
120
+ });
@@ -0,0 +1,7 @@
1
+ Campaign performance report
2
+ Jan 1, 2026 - Jan 31, 2026
3
+ Campaign,Impr.,Clicks,Cost,Conversions,Conv. value,Search impr. share,Search lost IS (budget),Bid strategy type,Budget name
4
+ Brand,10000,800,400.00,60,6000.00,82%,3%,Target CPA,Brand-Budget
5
+ Generic,20000,1000,2000.00,40,4000.00,45%,35%,Maximize conversions,Shared-1
6
+ Competitor,5000,200,600.00,6,600.00,30%,40%,Target CPA,Shared-1
7
+ Total: Campaigns,35000,2000,3000.00,106,10600.00,,,,
@@ -0,0 +1,9 @@
1
+ {
2
+ "searchTerms": [
3
+ { "searchTerm": "buy blue widgets", "campaign": "Generic", "clicks": 40, "cost": 120, "conversions": 6, "convValue": 600 },
4
+ { "searchTerm": "free blue widgets", "campaign": "Generic", "clicks": 60, "cost": 90, "conversions": 0, "convValue": 0 }
5
+ ],
6
+ "campaigns": [
7
+ { "campaign": "Generic", "impressions": 20000, "clicks": 1000, "cost": 2000, "conversions": 40, "convValue": 4000, "searchIS": 0.45, "lostISBudget": 0.35, "biddingStrategy": "Maximize conversions" }
8
+ ]
9
+ }
@@ -0,0 +1,11 @@
1
+ Search terms report
2
+ Jan 1, 2026 - Jan 31, 2026
3
+ Search term,Campaign,Ad group,Match type,Impr.,Clicks,Cost,Conversions,Conv. value
4
+ buy blue widgets,Generic,Widgets - Exact,Exact,500,40,120.00,6,600.00
5
+ blue widget store near me,Generic,Widgets - Phrase,Phrase,420,30,95.00,4,400.00
6
+ free blue widgets,Generic,Widgets - Broad,Broad,800,60,90.00,0,0.00
7
+ widget repair career jobs,Generic,Widgets - Broad,Broad,300,25,55.00,0,0.00
8
+ blue widget reviews,Generic,Widgets - Phrase,Phrase,200,5,8.00,0,0.00
9
+ premium widget wholesale bulk,Brand,Brand - Exact,Exact,400,30,500.00,1,100.00
10
+ what is a widget,Generic,Widgets - Broad,Broad,600,18,42.00,0,0.00
11
+ Total: Search terms,,,,3220,208,910.00,11,1200.00
@@ -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 };