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,123 @@
1
+ // Dependency-free CSV parser + Meta Ads Manager column mapper. A Meta export is a clean
2
+ // header row followed by one row per breakdown level (campaign / ad set / ad). We find
3
+ // the header by known column tokens, map arbitrary spellings to canonical field names,
4
+ // and coerce currency / percent / number cells.
5
+
6
+ // --- CSV tokenizer (state machine; handles quotes, embedded commas, CRLF) ---
7
+ export function parseCsv(text) {
8
+ const rows = [];
9
+ let row = [];
10
+ let field = '';
11
+ let inQuotes = false;
12
+ const s = String(text).replace(/^/, ''); // strip BOM
13
+
14
+ for (let i = 0; i < s.length; i += 1) {
15
+ const c = s[i];
16
+ if (inQuotes) {
17
+ if (c === '"') {
18
+ if (s[i + 1] === '"') { field += '"'; i += 1; }
19
+ else inQuotes = false;
20
+ } else field += c;
21
+ } else if (c === '"') inQuotes = true;
22
+ else if (c === ',') { row.push(field); field = ''; }
23
+ else if (c === '\n') { row.push(field); field = ''; rows.push(row); row = []; }
24
+ else if (c === '\r') { /* handled by \n */ }
25
+ else field += c;
26
+ }
27
+ if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
28
+ return rows;
29
+ }
30
+
31
+ function normHeader(h) {
32
+ return String(h).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
33
+ }
34
+
35
+ // canonical field -> normalized header spellings that map to it
36
+ const ALIASES = {
37
+ campaign: ['campaign name', 'campaign'],
38
+ adset: ['ad set name', 'ad set', 'adset name', 'adset'],
39
+ ad: ['ad name', 'ad'],
40
+ spend: ['amount spent usd', 'amount spent', 'spend'],
41
+ impressions: ['impressions', 'impr'],
42
+ reach: ['reach'],
43
+ frequency: ['frequency'],
44
+ results: ['results', 'result'],
45
+ resultType: ['result indicator', 'result type', 'result name'],
46
+ costPerResult: ['cost per result', 'cost per results'],
47
+ linkClicks: ['link clicks', 'clicks all', 'clicks'],
48
+ ctr: ['ctr link click through rate', 'ctr all', 'ctr'],
49
+ purchases: ['purchases', 'website purchases'],
50
+ purchaseValue: ['purchases conversion value', 'website purchases conversion value', 'purchase conversion value'],
51
+ roas: ['purchase roas return on ad spend', 'website purchase roas', 'purchase roas', 'roas'],
52
+ };
53
+
54
+ const HEADER_TO_FIELD = (() => {
55
+ const m = new Map();
56
+ for (const [field, spellings] of Object.entries(ALIASES)) for (const sp of spellings) m.set(sp, field);
57
+ return m;
58
+ })();
59
+
60
+ const PERCENT_FIELDS = new Set(['ctr']);
61
+ const NUMERIC_FIELDS = new Set([
62
+ 'spend', 'impressions', 'reach', 'frequency', 'results', 'costPerResult',
63
+ 'linkClicks', 'purchases', 'purchaseValue', 'roas',
64
+ ]);
65
+
66
+ export function toNumber(raw) {
67
+ if (raw == null) return null;
68
+ let v = String(raw).trim();
69
+ if (v === '' || v === '--' || v === '—' || v.toLowerCase() === 'n/a') return null;
70
+ v = v.replace(/[$£€,%\s"]/g, '');
71
+ if (v === '') return null;
72
+ const n = Number.parseFloat(v);
73
+ return Number.isFinite(n) ? n : null;
74
+ }
75
+
76
+ // Percent cell -> fraction. "0.30%" -> 0.003, "1.00%" -> 0.01, bare "0.3" -> 0.003.
77
+ export function toPercent(raw) {
78
+ if (raw == null) return null;
79
+ const v = String(raw).trim();
80
+ if (v === '' || v === '--' || v === '—') return null;
81
+ const hadPercent = v.includes('%');
82
+ const n = toNumber(v);
83
+ if (n == null) return null;
84
+ return hadPercent ? n / 100 : (n > 1 ? n / 100 : n);
85
+ }
86
+
87
+ function coerce(field, raw) {
88
+ if (PERCENT_FIELDS.has(field)) return toPercent(raw);
89
+ if (NUMERIC_FIELDS.has(field)) return toNumber(raw);
90
+ return raw == null ? '' : String(raw).trim();
91
+ }
92
+
93
+ function findHeader(rows, minKnown = 3) {
94
+ for (let i = 0; i < rows.length; i += 1) {
95
+ const mapped = rows[i].map((h) => HEADER_TO_FIELD.get(normHeader(h))).filter(Boolean);
96
+ if (new Set(mapped).size >= minKnown) return i;
97
+ }
98
+ return -1;
99
+ }
100
+
101
+ // Parse a Meta Ads Manager CSV export into canonical row objects.
102
+ export function parseReport(text) {
103
+ const rows = parseCsv(text);
104
+ const headerIdx = findHeader(rows);
105
+ if (headerIdx === -1) {
106
+ throw new Error('No recognizable Meta Ads header row found (need e.g. "Ad set name", "Amount spent", "Results").');
107
+ }
108
+ const colField = rows[headerIdx].map((h) => HEADER_TO_FIELD.get(normHeader(h)) || null);
109
+
110
+ const out = [];
111
+ for (let i = headerIdx + 1; i < rows.length; i += 1) {
112
+ const cells = rows[i];
113
+ if (cells.every((c) => (c || '').trim() === '')) continue;
114
+ const rec = {};
115
+ for (let c = 0; c < colField.length; c += 1) {
116
+ const field = colField[c];
117
+ if (field) rec[field] = coerce(field, cells[c]);
118
+ }
119
+ // require at least one identifying name so we don't emit garbage rows
120
+ if (rec.ad || rec.adset || rec.campaign) out.push(rec);
121
+ }
122
+ return out;
123
+ }
@@ -0,0 +1,72 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+ import { parseReport } from '../src/parse.mjs';
7
+ import { analyze, DEFAULT_THRESHOLDS } from '../src/analyze.mjs';
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ const fixture = () => parseReport(readFileSync(join(HERE, 'fixtures', 'meta-ads.csv'), 'utf8'));
11
+ const find = (result, entity) => result.changes.find((c) => c.evidence.entity === entity);
12
+
13
+ test('parse maps Meta columns and coerces numbers/percent', () => {
14
+ const rows = fixture();
15
+ assert.equal(rows.length, 5);
16
+ const a = rows.find((r) => r.ad === 'Video A');
17
+ assert.equal(a.spend, 85);
18
+ assert.equal(a.results, 0);
19
+ assert.equal(a.frequency, 1.25);
20
+ assert.equal(rows.find((r) => r.ad === 'Video E').ctr, 0.01); // "1.00%" -> 0.01
21
+ });
22
+
23
+ test('the whole sheet is propose-only', () => {
24
+ assert.equal(analyze(fixture()).proposeOnly, true);
25
+ });
26
+
27
+ test('pause: spend with zero results is flagged', () => {
28
+ const r = find(analyze(fixture()), 'Video A');
29
+ assert.equal(r.type, 'pause');
30
+ assert.equal(r.dollars, 85);
31
+ });
32
+
33
+ test('low-roas: ROAS below break-even is flagged as a loss', () => {
34
+ const r = find(analyze(fixture()), 'Story C'); // roas 0.6, spend 70
35
+ assert.equal(r.type, 'low-roas');
36
+ assert.equal(r.dollars, 28); // 70 - 70*0.6
37
+ });
38
+
39
+ test('inefficient: cost/result far above account average is flagged (not double-counted as low-roas)', () => {
40
+ const r = find(analyze(fixture()), 'Image D'); // cpr 80 vs ~26 account, roas 1.3 (profitable)
41
+ assert.equal(r.type, 'inefficient');
42
+ assert.ok(r.evidence.costPerResult >= 3 * r.evidence.accountCPR);
43
+ });
44
+
45
+ test('fatigue: high frequency triggers a creative-refresh proposal', () => {
46
+ const r = find(analyze(fixture()), 'Carousel B'); // frequency 4.8
47
+ assert.equal(r.type, 'fatigue');
48
+ });
49
+
50
+ test('a healthy ad produces no finding', () => {
51
+ assert.equal(find(analyze(fixture()), 'Video E'), undefined);
52
+ });
53
+
54
+ test('changes are ranked by dollars, descending', () => {
55
+ const { changes } = analyze(fixture());
56
+ for (let i = 1; i < changes.length; i += 1) {
57
+ assert.ok((changes[i - 1].dollars || 0) >= (changes[i].dollars || 0));
58
+ assert.equal(changes[i].rank, i + 1);
59
+ }
60
+ });
61
+
62
+ test('empty input yields a clean, empty propose-only sheet', () => {
63
+ const result = analyze([]);
64
+ assert.equal(result.changes.length, 0);
65
+ assert.match(result.summary, /No pause/);
66
+ });
67
+
68
+ test('thresholds are overridable', () => {
69
+ assert.equal(typeof DEFAULT_THRESHOLDS.fatigueFrequency, 'number');
70
+ const strict = analyze(fixture(), { thresholds: { fatigueFrequency: 10 } });
71
+ assert.equal(strict.changes.find((c) => c.type === 'fatigue'), undefined);
72
+ });
@@ -0,0 +1,6 @@
1
+ Campaign name,Ad set name,Ad name,Amount spent (USD),Impressions,Reach,Frequency,Results,Result indicator,Cost per result,Link clicks,CTR (link click-through rate),Purchases,Purchase ROAS (return on ad spend)
2
+ Prospecting,Broad Interests,Video A,85.00,40000,32000,1.25,0,actions:purchase,,120,0.30%,0,
3
+ Prospecting,Lookalike 1%,Carousel B,120.00,90000,18750,4.80,6,actions:purchase,20.00,300,0.33%,6,2.10
4
+ Retargeting,Cart Abandoners 7d,Story C,70.00,15000,12000,1.25,1,actions:purchase,70.00,45,0.30%,1,0.60
5
+ Prospecting,Interest - Cooking,Image D,80.00,20000,17500,1.14,1,actions:purchase,80.00,60,0.30%,1,1.30
6
+ Retargeting,Site Visitors 30d,Video E,90.00,20000,16000,1.25,9,actions:purchase,10.00,200,1.00%,9,3.50
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@agent-cores/paid-search",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Google Ads paid-search auditor. Deterministic: parse exported search-terms + campaign reports, compute a ranked change sheet (negatives, budget moves, structure fixes). PROPOSE-ONLY. No LLM in the core.",
7
+ "bin": {
8
+ "paid-search": "src/cli.mjs"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test",
12
+ "audit": "node src/cli.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">=20"
16
+ }
17
+ }
@@ -0,0 +1,303 @@
1
+ // Deterministic Google Ads paid-search analysis. No LLM. PROPOSE-ONLY.
2
+ //
3
+ // Input: parsed search-terms rows and campaign-performance rows (canonical fields
4
+ // from parse.mjs). Output: a ranked change sheet. Every row carries the evidence
5
+ // numbers that triggered it. The core never calls a model and never touches an
6
+ // account — a human applies the proposed changes.
7
+ //
8
+ // Three finding families, mirroring the job:
9
+ // negative — search terms burning spend with no/poor return (add as negatives)
10
+ // budget — converting campaigns throttled by budget (raise / reallocate)
11
+ // structure — root-cause fixes (starved Smart Bidding, shared-budget starvation)
12
+ // that outrank the budget moves they explain.
13
+
14
+ export const DEFAULT_THRESHOLDS = {
15
+ // Negatives (irrelevant / zero-conversion queries).
16
+ negMinClicks: 15, // enough traffic that "0 conversions" is signal, not noise
17
+ negMinCost: 1, // ignore trivial spend
18
+ // Negatives (inefficient: converting but far above account CPA).
19
+ effCpaMultiple: 3, // term CPA >= 3x account CPA
20
+ effMinCost: 20,
21
+ // Budget moves.
22
+ lostIsBudgetMin: 0.1, // >=10% eligible impressions lost to budget
23
+ lostIsBudgetStrangled: 0.3, // >=30% => campaign is being strangled
24
+ // Structure: Smart Bidding conversion floor (Google rule of thumb ~15-30/30d).
25
+ biddingMinConv: 30,
26
+ };
27
+
28
+ const SMART_BIDDING = /target\s*cpa|troas|target\s*roas|maximize\s*conv|maximise\s*conv/i;
29
+
30
+ function sum(arr, f) {
31
+ let t = 0;
32
+ for (const x of arr) { const v = f(x); if (Number.isFinite(v)) t += v; }
33
+ return t;
34
+ }
35
+
36
+ function round(n, dp = 2) {
37
+ if (!Number.isFinite(n)) return n;
38
+ const m = 10 ** dp;
39
+ return Math.round(n * m) / m;
40
+ }
41
+
42
+ // Impression-share opportunity math for a budget-throttled campaign.
43
+ // eligible = impressions / searchIS ; impressions lost to budget = eligible * lostISBudget.
44
+ // Additional clicks/conv/cost/value scale off the campaign's own CTR / CVR / CPC / value.
45
+ // This is an UPPER bound: CTR and CVR usually soften as volume grows.
46
+ function budgetOpportunity(c, thresholds) {
47
+ const clicks = c.clicks || 0;
48
+ const cost = c.cost || 0;
49
+ const conv = c.conversions || 0;
50
+ const value = c.convValue || 0;
51
+ const is = c.searchIS;
52
+ const lost = c.lostISBudget || 0;
53
+
54
+ // scaleFactor = how much more share we could capture relative to what we capture now.
55
+ // Prefer the direct impression math when searchIS is known; else scale by share ratio.
56
+ let scaleFactor;
57
+ if (Number.isFinite(is) && is > 0) {
58
+ scaleFactor = lost / is;
59
+ } else {
60
+ scaleFactor = lost / Math.max(1 - lost, 0.01);
61
+ }
62
+ const addClicks = clicks * scaleFactor;
63
+ const cvr = clicks > 0 ? conv / clicks : 0;
64
+ const cpc = clicks > 0 ? cost / clicks : 0;
65
+ const valuePerConv = conv > 0 ? value / conv : 0;
66
+ const addConv = addClicks * cvr;
67
+ const addCost = addClicks * cpc;
68
+ const addValue = addConv * valuePerConv;
69
+ return {
70
+ scaleFactor: round(scaleFactor, 4),
71
+ addClicks: Math.round(addClicks),
72
+ addConv: round(addConv, 1),
73
+ addCost: round(addCost, 2),
74
+ addValue: round(addValue, 2),
75
+ valuePerConv: round(valuePerConv, 2),
76
+ };
77
+ }
78
+
79
+ export function analyze(input = {}, options = {}) {
80
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds || {}) };
81
+ const searchTerms = Array.isArray(input.searchTerms) ? input.searchTerms : [];
82
+ const campaigns = Array.isArray(input.campaigns) ? input.campaigns : [];
83
+
84
+ // --- account-level aggregates from the search-terms report ---
85
+ const totalClicks = sum(searchTerms, (t) => t.clicks);
86
+ const totalCost = sum(searchTerms, (t) => t.cost);
87
+ const totalConv = sum(searchTerms, (t) => t.conversions);
88
+ const totalConvValue = sum(searchTerms, (t) => t.convValue);
89
+ const accountCVR = totalClicks > 0 ? totalConv / totalClicks : 0;
90
+ const accountCPA = totalConv > 0 ? totalCost / totalConv : null;
91
+ const accountValuePerConv = totalConv > 0 ? totalConvValue / totalConv : null;
92
+
93
+ const changes = [];
94
+
95
+ // --- 1. Negatives: irrelevant / zero-conversion queries ---
96
+ for (const t of searchTerms) {
97
+ const clicks = t.clicks || 0;
98
+ const cost = t.cost || 0;
99
+ const conv = t.conversions || 0;
100
+ if (conv === 0 && clicks >= thresholds.negMinClicks && cost >= thresholds.negMinCost) {
101
+ const expectedConv = round(clicks * accountCVR, 1);
102
+ changes.push({
103
+ type: 'negative',
104
+ subtype: 'irrelevant',
105
+ change: `Add "${t.searchTerm}" as a negative keyword`,
106
+ where: t.campaign ? `campaign: ${t.campaign}${t.adGroup ? ` / ${t.adGroup}` : ''}` : 'account level',
107
+ why: `${clicks} clicks, $${round(cost)} spent, 0 conversions`
108
+ + (accountCVR > 0 ? ` (expected ~${expectedConv} at the ${round(accountCVR * 100, 1)}% account conversion rate)` : ''),
109
+ expectedEffect: `Recover ~$${round(cost)}/period of wasted spend`,
110
+ risk: 'If the query is actually relevant, you forfeit those impressions — confirm intent before excluding.',
111
+ dollars: round(cost),
112
+ evidence: {
113
+ searchTerm: t.searchTerm, campaign: t.campaign ?? null,
114
+ clicks, cost: round(cost), conversions: conv, expectedConv,
115
+ },
116
+ });
117
+ } else if (
118
+ conv > 0 && accountCPA != null && cost >= thresholds.effMinCost
119
+ && cost / conv >= thresholds.effCpaMultiple * accountCPA
120
+ ) {
121
+ // inefficient: converting, but paying far above the account's cost per conversion
122
+ const termCpa = cost / conv;
123
+ const overspend = cost - conv * accountCPA;
124
+ changes.push({
125
+ type: 'negative',
126
+ subtype: 'inefficient',
127
+ change: `Bid down or restrict "${t.searchTerm}" (or add as negative if off-intent)`,
128
+ where: t.campaign ? `campaign: ${t.campaign}${t.adGroup ? ` / ${t.adGroup}` : ''}` : 'account level',
129
+ why: `CPA $${round(termCpa)} vs account $${round(accountCPA)} (${round(termCpa / accountCPA, 1)}x); `
130
+ + `$${round(cost)} for ${round(conv, 1)} conv`,
131
+ expectedEffect: `Recover ~$${round(overspend)}/period of over-target spend`,
132
+ risk: 'Converting term — restricting too hard can cut real volume; prefer a bid cut first.',
133
+ dollars: round(overspend),
134
+ evidence: {
135
+ searchTerm: t.searchTerm, campaign: t.campaign ?? null,
136
+ cost: round(cost), conversions: round(conv, 1),
137
+ termCpa: round(termCpa), accountCPA: round(accountCPA),
138
+ },
139
+ });
140
+ }
141
+ }
142
+
143
+ // --- 2 & 3. Campaign-level: budget moves and structure fixes ---
144
+ const byCampaign = new Map();
145
+ for (const c of campaigns) if (c.campaign) byCampaign.set(c.campaign, c);
146
+
147
+ // group campaigns by shared budget pool for structure analysis
148
+ const pools = new Map();
149
+ for (const c of campaigns) {
150
+ if (!c.sharedBudget) continue;
151
+ if (!pools.has(c.sharedBudget)) pools.set(c.sharedBudget, []);
152
+ pools.get(c.sharedBudget).push(c);
153
+ }
154
+
155
+ // maps campaign -> the largest related budget-opportunity dollars (for structure ranking)
156
+ const budgetDollarsByCampaign = new Map();
157
+
158
+ for (const c of campaigns) {
159
+ const conv = c.conversions || 0;
160
+ const lost = c.lostISBudget || 0;
161
+ if (lost >= thresholds.lostIsBudgetMin && conv > 0) {
162
+ const opp = budgetOpportunity(c, thresholds);
163
+ const strangled = lost >= thresholds.lostIsBudgetStrangled;
164
+ const dollars = opp.addValue > 0 ? opp.addValue
165
+ : (accountCPA != null ? round(opp.addConv * accountCPA, 2) : opp.addConv);
166
+ budgetDollarsByCampaign.set(
167
+ c.campaign,
168
+ Math.max(budgetDollarsByCampaign.get(c.campaign) || 0, dollars),
169
+ );
170
+ changes.push({
171
+ type: 'budget',
172
+ subtype: strangled ? 'strangled' : 'throttled',
173
+ change: strangled
174
+ ? `Raise budget (or reallocate to) "${c.campaign}" — it is being strangled`
175
+ : `Raise budget for "${c.campaign}"`,
176
+ where: `campaign: ${c.campaign}`,
177
+ why: `${round(lost * 100, 1)}% of eligible impressions lost to budget, `
178
+ + `${round(conv, 1)} conversions at ${Number.isFinite(c.searchIS) ? round(c.searchIS * 100, 1) + '% impr. share' : 'unknown share'}`,
179
+ expectedEffect: `~+${opp.addClicks} clicks, +${opp.addConv} conv`
180
+ + (opp.addValue > 0 ? `, +$${opp.addValue} value` : '')
181
+ + ` at ~$${opp.addCost} more spend (upper bound)`,
182
+ risk: 'Scaling assumes current CTR/CVR hold as volume grows; they usually soften. Treat as a ceiling.',
183
+ dollars,
184
+ evidence: {
185
+ campaign: c.campaign, lostISBudget: round(lost, 4),
186
+ searchIS: Number.isFinite(c.searchIS) ? round(c.searchIS, 4) : null,
187
+ conversions: round(conv, 1), ...opp,
188
+ },
189
+ });
190
+ }
191
+ }
192
+
193
+ // Structure: Smart Bidding starved of conversion signal
194
+ for (const c of campaigns) {
195
+ const strat = c.biddingStrategy || '';
196
+ const conv = c.conversions || 0;
197
+ if (SMART_BIDDING.test(strat) && conv < thresholds.biddingMinConv) {
198
+ const atRisk = round(c.cost || 0);
199
+ const related = budgetDollarsByCampaign.get(c.campaign) || 0;
200
+ changes.push({
201
+ type: 'structure',
202
+ subtype: 'smart-bidding-starved',
203
+ change: `Fix bidding on "${c.campaign}" before touching budget — "${strat}" is starved of conversion signal`,
204
+ where: `campaign: ${c.campaign}`,
205
+ why: `${round(conv, 1)} conversions in the period is below the ~${thresholds.biddingMinConv}/30d Smart Bidding needs; `
206
+ + `$${atRisk} of spend is governed by a mis-calibrated bid strategy`,
207
+ expectedEffect: 'Consolidate conversion volume or switch to Maximize Clicks / manual until signal is sufficient; pacing issues here usually resolve once bidding has data',
208
+ risk: 'Bid-strategy changes trigger a new learning window; expect 1-2 weeks of unstable performance.',
209
+ dollars: atRisk,
210
+ // rank at/above any budget move on the same campaign (structure outranks budget)
211
+ rankValue: Math.max(atRisk, related + 0.01),
212
+ evidence: {
213
+ campaign: c.campaign, biddingStrategy: strat,
214
+ conversions: round(conv, 1), cost: atRisk, floor: thresholds.biddingMinConv,
215
+ },
216
+ });
217
+ }
218
+ }
219
+
220
+ // Structure: shared budget pool starving converting campaigns
221
+ for (const [poolName, members] of pools) {
222
+ if (members.length < 2) continue;
223
+ const throttled = members.filter((c) => (c.lostISBudget || 0) >= thresholds.lostIsBudgetMin && (c.conversions || 0) > 0);
224
+ if (throttled.length === 0) continue;
225
+ let poolDollars = 0;
226
+ const detail = [];
227
+ for (const c of throttled) {
228
+ const opp = budgetOpportunity(c, thresholds);
229
+ const d = opp.addValue > 0 ? opp.addValue
230
+ : (accountCPA != null ? round(opp.addConv * accountCPA, 2) : opp.addConv);
231
+ poolDollars += d;
232
+ detail.push({ campaign: c.campaign, lostISBudget: round(c.lostISBudget || 0, 4), addConv: opp.addConv, addValue: opp.addValue });
233
+ }
234
+ poolDollars = round(poolDollars, 2);
235
+ changes.push({
236
+ type: 'structure',
237
+ subtype: 'shared-budget-starve',
238
+ change: `Split or resize shared budget "${poolName}" — one pool is starving ${throttled.length} converting campaign(s)`,
239
+ where: `shared budget: ${poolName} (${members.map((m) => m.campaign).join(', ')})`,
240
+ why: `${throttled.length} converting campaign(s) losing impression share to a single shared pool: `
241
+ + throttled.map((c) => `${c.campaign} ${round((c.lostISBudget || 0) * 100, 1)}%`).join(', '),
242
+ expectedEffect: `Give throttled campaigns their own budgets; unlocks ~$${poolDollars} of value (aggregate upper bound)`,
243
+ risk: 'Splitting a pool removes automatic cross-campaign smoothing; monitor the others do not now underspend.',
244
+ dollars: poolDollars,
245
+ rankValue: poolDollars, // aggregate root cause, already large
246
+ evidence: { pool: poolName, members: members.map((m) => m.campaign), throttled: detail },
247
+ });
248
+ }
249
+
250
+ // --- rank the change sheet ---
251
+ // Primary: dollars of impact (rankValue lets structure sit at/above related budget moves).
252
+ // Tiebreak: type (structure < negative < budget), then a stable string key.
253
+ const typeRank = { structure: 0, negative: 1, budget: 2 };
254
+ const keyed = changes.map((c, i) => ({ ...c, rankValue: Number.isFinite(c.rankValue) ? c.rankValue : (c.dollars || 0), _i: i }));
255
+ keyed.sort((a, b) => {
256
+ if (b.rankValue !== a.rankValue) return b.rankValue - a.rankValue;
257
+ if (typeRank[a.type] !== typeRank[b.type]) return typeRank[a.type] - typeRank[b.type];
258
+ const ka = `${a.type}:${a.subtype}:${a.change}`;
259
+ const kb = `${b.type}:${b.subtype}:${b.change}`;
260
+ return ka < kb ? -1 : ka > kb ? 1 : a._i - b._i;
261
+ });
262
+ const ranked = keyed.map(({ _i, ...c }, idx) => ({ rank: idx + 1, ...c }));
263
+
264
+ const recoverable = round(
265
+ sum(ranked.filter((c) => c.type === 'negative'), (c) => c.dollars),
266
+ 2,
267
+ );
268
+ const opportunity = round(
269
+ sum(ranked.filter((c) => c.type === 'budget'), (c) => c.dollars),
270
+ 2,
271
+ );
272
+
273
+ return {
274
+ proposeOnly: true,
275
+ account: {
276
+ searchTermRows: searchTerms.length,
277
+ campaignRows: campaigns.length,
278
+ totalClicks,
279
+ totalCost: round(totalCost),
280
+ totalConversions: round(totalConv, 1),
281
+ accountCVR: round(accountCVR, 4),
282
+ accountCPA: accountCPA == null ? null : round(accountCPA),
283
+ accountValuePerConv: accountValuePerConv == null ? null : round(accountValuePerConv),
284
+ },
285
+ counts: {
286
+ total: ranked.length,
287
+ negatives: ranked.filter((c) => c.type === 'negative').length,
288
+ budget: ranked.filter((c) => c.type === 'budget').length,
289
+ structure: ranked.filter((c) => c.type === 'structure').length,
290
+ },
291
+ wastedSpendRecoverable: recoverable,
292
+ budgetOpportunityValue: opportunity,
293
+ summary: buildSummary(ranked, recoverable, opportunity),
294
+ changes: ranked,
295
+ };
296
+ }
297
+
298
+ function buildSummary(ranked, recoverable, opportunity) {
299
+ if (ranked.length === 0) return 'No negative, budget, or structure changes found in the exported reports.';
300
+ const top = ranked[0];
301
+ return `${ranked.length} proposed change(s); ~$${recoverable} recoverable from negatives, `
302
+ + `~$${opportunity} budget opportunity. Start with: ${top.change}.`;
303
+ }
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // Real CLI. PROPOSE-ONLY — reads exported Google Ads reports, prints a ranked
3
+ // change sheet as JSON on stdout and a one-line summary on stderr. No account access.
4
+ //
5
+ // Modes:
6
+ // paid-search --search-terms <csv> [--campaigns <csv>] [--pretty]
7
+ // paid-search --campaigns <csv> [--search-terms <csv>]
8
+ // paid-search <input.json> JSON: { "searchTerms": [...], "campaigns": [...] }
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { analyze } from './analyze.mjs';
12
+ import { parseReport } from './parse.mjs';
13
+
14
+ function parseArgs(argv) {
15
+ const args = { _: [] };
16
+ for (let i = 0; i < argv.length; i += 1) {
17
+ const a = argv[i];
18
+ if (a === '--search-terms' || a === '-s') args.searchTerms = argv[++i];
19
+ else if (a === '--campaigns' || a === '-c') args.campaigns = argv[++i];
20
+ else if (a === '--pretty') args.pretty = true;
21
+ else args._.push(a);
22
+ }
23
+ return args;
24
+ }
25
+
26
+ function main() {
27
+ const args = parseArgs(process.argv.slice(2));
28
+
29
+ if (!args.searchTerms && !args.campaigns && args._.length === 0) {
30
+ console.error('Usage: paid-search --search-terms <csv> [--campaigns <csv>] [--pretty]');
31
+ console.error(' paid-search <input.json> (keys: searchTerms, campaigns)');
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+
36
+ let input = { searchTerms: [], campaigns: [] };
37
+
38
+ if (args._.length > 0 && !args.searchTerms && !args.campaigns) {
39
+ const parsed = JSON.parse(readFileSync(args._[0], 'utf8'));
40
+ input.searchTerms = parsed.searchTerms || [];
41
+ input.campaigns = parsed.campaigns || [];
42
+ } else {
43
+ if (args.searchTerms) input.searchTerms = parseReport(readFileSync(args.searchTerms, 'utf8'));
44
+ if (args.campaigns) input.campaigns = parseReport(readFileSync(args.campaigns, 'utf8'));
45
+ }
46
+
47
+ const result = analyze(input);
48
+ console.error(`paid-search: ${result.summary}`);
49
+ console.log(JSON.stringify(result, null, args.pretty ? 2 : 0));
50
+ }
51
+
52
+ try {
53
+ main();
54
+ } catch (err) {
55
+ console.error(err instanceof Error ? err.message : String(err));
56
+ process.exitCode = 1;
57
+ }