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.
- package/README.md +178 -12
- package/agent-cores/amazon-ppc/package.json +17 -0
- package/agent-cores/amazon-ppc/src/cli.mjs +66 -0
- package/agent-cores/amazon-ppc/src/optimize.mjs +272 -0
- package/agent-cores/amazon-ppc/src/parse.mjs +153 -0
- package/agent-cores/amazon-ppc/test/fixtures/search-term-report.csv +8 -0
- package/agent-cores/amazon-ppc/test/fixtures/search-term-report.json +5 -0
- package/agent-cores/amazon-ppc/test/optimize.test.mjs +161 -0
- package/agent-cores/content-ops/package.json +17 -0
- package/agent-cores/content-ops/src/cli.mjs +51 -0
- package/agent-cores/content-ops/src/inventory.mjs +343 -0
- package/agent-cores/content-ops/src/parse.mjs +121 -0
- package/agent-cores/content-ops/test/fixtures/cms-export.csv +13 -0
- package/agent-cores/content-ops/test/fixtures/cms-export.json +31 -0
- package/agent-cores/content-ops/test/inventory.test.mjs +177 -0
- package/agent-cores/meta-ads/package.json +16 -0
- package/agent-cores/meta-ads/src/analyze.mjs +166 -0
- package/agent-cores/meta-ads/src/cli.mjs +39 -0
- package/agent-cores/meta-ads/src/parse.mjs +123 -0
- package/agent-cores/meta-ads/test/analyze.test.js +72 -0
- package/agent-cores/meta-ads/test/fixtures/meta-ads.csv +6 -0
- package/agent-cores/paid-search/package.json +17 -0
- package/agent-cores/paid-search/src/analyze.mjs +303 -0
- package/agent-cores/paid-search/src/cli.mjs +57 -0
- package/agent-cores/paid-search/src/parse.mjs +158 -0
- package/agent-cores/paid-search/test/analyze.test.mjs +120 -0
- package/agent-cores/paid-search/test/fixtures/campaigns.csv +7 -0
- package/agent-cores/paid-search/test/fixtures/sample.json +9 -0
- package/agent-cores/paid-search/test/fixtures/search-terms.csv +11 -0
- package/agent-cores/paid-search/test/parse.test.mjs +56 -0
- package/agent-cores/seo-content/package.json +17 -0
- package/agent-cores/seo-content/src/cli.mjs +41 -0
- package/agent-cores/seo-content/src/parse.mjs +141 -0
- package/agent-cores/seo-content/src/strategy.mjs +266 -0
- package/agent-cores/seo-content/test/fixtures/gsc-sample.csv +11 -0
- package/agent-cores/seo-content/test/parse.test.mjs +59 -0
- package/agent-cores/seo-content/test/strategy.test.mjs +108 -0
- package/agent-cores/seo-technical/package.json +17 -0
- package/agent-cores/seo-technical/src/audit.mjs +233 -0
- package/agent-cores/seo-technical/src/cli.mjs +48 -0
- package/agent-cores/seo-technical/src/fetch.mjs +51 -0
- package/agent-cores/seo-technical/test/audit.test.mjs +73 -0
- package/agent-cores/seo-technical/test/fixtures/sample.html +22 -0
- package/agent-cores/supply-verify/package.json +17 -0
- package/agent-cores/supply-verify/src/cli.mjs +44 -0
- package/agent-cores/supply-verify/src/verify.mjs +303 -0
- package/agent-cores/supply-verify/test/fixtures/placements.csv +8 -0
- package/agent-cores/supply-verify/test/fixtures/placements.json +4 -0
- package/agent-cores/supply-verify/test/verify.test.mjs +148 -0
- package/agents/agent2060.md +99 -0
- package/agents/amazon-ppc.md +42 -0
- package/agents/claims-content.md +35 -0
- package/agents/content-ops.md +46 -0
- package/agents/content-strategy.md +13 -0
- package/agents/disclosure-qc.md +13 -0
- package/agents/feed-optimizer.md +13 -0
- package/agents/growth-analyst.md +24 -0
- package/agents/hilbert.md +55 -0
- package/agents/ideator.md +13 -0
- package/agents/lippmann.md +68 -0
- package/agents/paid-search.md +35 -0
- package/agents/pio.md +34 -0
- package/agents/researcher.md +13 -0
- package/agents/richy.md +72 -0
- package/agents/script-editor.md +13 -0
- package/agents/seo-content.md +57 -0
- package/agents/seo-technical.md +53 -0
- package/agents/showrunner.md +13 -0
- package/agents/social-voice.md +38 -0
- package/agents/supply-verify.md +38 -0
- package/agents/video-editor.md +13 -0
- package/agents/writer.md +45 -0
- package/bin/weftens.js +234 -0
- package/content-org/.preflight/telemetry.jsonl +6 -0
- package/content-org/briefs/BRIEFS.md +47 -0
- package/content-org/config.mjs +78 -0
- package/content-org/cost.mjs +36 -0
- package/content-org/lead.js +64 -0
- package/content-org/metrics.mjs +166 -0
- package/content-org/orchestrator.mjs +246 -0
- package/content-org/package.json +14 -0
- package/content-org/producer.mjs +59 -0
- package/content-org/publish.mjs +34 -0
- package/content-org/review.mjs +32 -0
- package/content-org/studio/README.md +27 -0
- package/content-org/studio/assemble.sh +48 -0
- package/content-org/test.mjs +110 -0
- package/index.js +32 -0
- package/package.json +49 -10
- package/providers.json.example +9 -0
- package/src/agent-router.js +124 -0
- package/src/agent.js +123 -0
- package/src/format.js +66 -0
- package/src/invoke.js +129 -0
- package/src/pio-client.js +142 -0
- package/src/provider.js +81 -0
- package/src/registry.js +160 -0
- package/src/richy-client.js +216 -0
- package/src/router.js +34 -0
- package/src/synthesize.js +111 -0
- package/src/weftens.js +61 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Dependency-free parser for an Amazon Ads search-term report.
|
|
2
|
+
// Accepts CSV (comma or tab) or JSON. Normalizes the messy real-world column
|
|
3
|
+
// names Amazon exports ("7 Day Total Sales", "Customer Search Term", "$1.23",
|
|
4
|
+
// "25.00%") into clean numeric rows. No LLM, no deps.
|
|
5
|
+
|
|
6
|
+
// --- CSV tokenizer: RFC-4180-ish, handles quoted fields with embedded commas/quotes ---
|
|
7
|
+
export function parseDelimited(text) {
|
|
8
|
+
const rows = [];
|
|
9
|
+
let field = '';
|
|
10
|
+
let record = [];
|
|
11
|
+
let inQuotes = false;
|
|
12
|
+
let delimiter = null;
|
|
13
|
+
|
|
14
|
+
// Sniff delimiter from the header line (tab beats comma if present).
|
|
15
|
+
const firstLine = text.slice(0, text.indexOf('\n') === -1 ? text.length : text.indexOf('\n'));
|
|
16
|
+
delimiter = firstLine.includes('\t') && !firstLine.includes(',') ? '\t' : ',';
|
|
17
|
+
|
|
18
|
+
const pushField = () => {
|
|
19
|
+
record.push(field);
|
|
20
|
+
field = '';
|
|
21
|
+
};
|
|
22
|
+
const pushRecord = () => {
|
|
23
|
+
// ignore a trailing empty record (blank last line)
|
|
24
|
+
if (record.length > 1 || (record.length === 1 && record[0] !== '')) rows.push(record);
|
|
25
|
+
record = [];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
29
|
+
const c = text[i];
|
|
30
|
+
if (inQuotes) {
|
|
31
|
+
if (c === '"') {
|
|
32
|
+
if (text[i + 1] === '"') { field += '"'; i += 1; } // escaped quote
|
|
33
|
+
else inQuotes = false;
|
|
34
|
+
} else field += c;
|
|
35
|
+
} else if (c === '"') {
|
|
36
|
+
inQuotes = true;
|
|
37
|
+
} else if (c === delimiter) {
|
|
38
|
+
pushField();
|
|
39
|
+
} else if (c === '\r') {
|
|
40
|
+
// swallow; \n handles the record break
|
|
41
|
+
} else if (c === '\n') {
|
|
42
|
+
pushField();
|
|
43
|
+
pushRecord();
|
|
44
|
+
} else {
|
|
45
|
+
field += c;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// flush trailing field/record if file did not end with newline
|
|
49
|
+
if (field !== '' || record.length > 0) {
|
|
50
|
+
pushField();
|
|
51
|
+
pushRecord();
|
|
52
|
+
}
|
|
53
|
+
return rows;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Map a raw header cell to a canonical key by stripping to alphanumerics.
|
|
57
|
+
const HEADER_ALIASES = [
|
|
58
|
+
['searchterm', 'searchTerm'],
|
|
59
|
+
['customersearchterm', 'searchTerm'],
|
|
60
|
+
['campaignname', 'campaign'],
|
|
61
|
+
['campaign', 'campaign'],
|
|
62
|
+
['adgroupname', 'adGroup'],
|
|
63
|
+
['adgroup', 'adGroup'],
|
|
64
|
+
['matchtype', 'matchType'],
|
|
65
|
+
['targeting', 'targeting'],
|
|
66
|
+
['impressions', 'impressions'],
|
|
67
|
+
['clicks', 'clicks'],
|
|
68
|
+
['spend', 'spend'],
|
|
69
|
+
['cost', 'spend'],
|
|
70
|
+
['costperclickcpc', 'cpc'],
|
|
71
|
+
['costperclick', 'cpc'],
|
|
72
|
+
['cpc', 'cpc'],
|
|
73
|
+
['7daytotalsales', 'sales'],
|
|
74
|
+
['7daytotalsales', 'sales'],
|
|
75
|
+
['totalsales', 'sales'],
|
|
76
|
+
['sales', 'sales'],
|
|
77
|
+
['7daytotalorders', 'orders'],
|
|
78
|
+
['7daytotalorders', 'orders'],
|
|
79
|
+
['totalorders', 'orders'],
|
|
80
|
+
['orders', 'orders'],
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
function canonicalKey(rawHeader) {
|
|
84
|
+
const norm = String(rawHeader).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
85
|
+
// longest alias match first so "7daytotalsales" wins over "sales"
|
|
86
|
+
let best = null;
|
|
87
|
+
for (const [alias, key] of HEADER_ALIASES) {
|
|
88
|
+
if (norm === alias) return key; // exact wins immediately
|
|
89
|
+
if (norm.includes(alias) && (!best || alias.length > best.aliasLen)) {
|
|
90
|
+
best = { key, aliasLen: alias.length };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return best ? best.key : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Strip currency/percent/thousands formatting to a number. "%" values are
|
|
97
|
+
// returned as a fraction (25% -> 0.25). Returns 0 for blank/undefined.
|
|
98
|
+
function toNumber(raw, { percent = false } = {}) {
|
|
99
|
+
if (raw === undefined || raw === null) return 0;
|
|
100
|
+
let s = String(raw).trim();
|
|
101
|
+
if (s === '' || s === '-' || s.toLowerCase() === 'n/a') return 0;
|
|
102
|
+
const isPct = percent || s.includes('%');
|
|
103
|
+
s = s.replace(/[$£€,%\s]/g, '');
|
|
104
|
+
const n = Number(s);
|
|
105
|
+
if (Number.isNaN(n)) return 0;
|
|
106
|
+
return isPct ? n / 100 : n;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Turn a JSON array or parsed CSV into normalized numeric rows.
|
|
110
|
+
export function normalizeRows(input) {
|
|
111
|
+
let records;
|
|
112
|
+
if (typeof input === 'string') {
|
|
113
|
+
const trimmed = input.trimStart();
|
|
114
|
+
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
|
115
|
+
const json = JSON.parse(trimmed);
|
|
116
|
+
records = Array.isArray(json) ? json : json.rows || json.searchTerms || [];
|
|
117
|
+
return records.map((r) => normalizeObject(r)).filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
const cells = parseDelimited(input);
|
|
120
|
+
if (cells.length < 2) return [];
|
|
121
|
+
const header = cells[0].map(canonicalKey);
|
|
122
|
+
return cells.slice(1).map((cols) => {
|
|
123
|
+
const obj = {};
|
|
124
|
+
header.forEach((k, i) => { if (k) obj[k] = cols[i]; });
|
|
125
|
+
return normalizeObject(obj);
|
|
126
|
+
}).filter(Boolean);
|
|
127
|
+
}
|
|
128
|
+
// already an array of objects
|
|
129
|
+
return input.map((r) => normalizeObject(r)).filter(Boolean);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeObject(r) {
|
|
133
|
+
// allow objects whose keys are already-raw Amazon headers
|
|
134
|
+
const lookup = {};
|
|
135
|
+
for (const [k, v] of Object.entries(r)) {
|
|
136
|
+
const ck = canonicalKey(k);
|
|
137
|
+
if (ck && lookup[ck] === undefined) lookup[ck] = v;
|
|
138
|
+
}
|
|
139
|
+
const searchTerm = String(lookup.searchTerm ?? '').trim();
|
|
140
|
+
if (!searchTerm) return null;
|
|
141
|
+
return {
|
|
142
|
+
searchTerm,
|
|
143
|
+
campaign: String(lookup.campaign ?? '').trim() || '(unknown campaign)',
|
|
144
|
+
adGroup: String(lookup.adGroup ?? '').trim() || '',
|
|
145
|
+
matchType: String(lookup.matchType ?? lookup.targeting ?? '').trim(),
|
|
146
|
+
impressions: Math.round(toNumber(lookup.impressions)),
|
|
147
|
+
clicks: Math.round(toNumber(lookup.clicks)),
|
|
148
|
+
spend: toNumber(lookup.spend),
|
|
149
|
+
cpc: toNumber(lookup.cpc),
|
|
150
|
+
sales: toNumber(lookup.sales),
|
|
151
|
+
orders: Math.round(toNumber(lookup.orders)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Campaign Name,Ad Group Name,Match Type,Customer Search Term,Impressions,Clicks,Cost Per Click (CPC),Spend,7 Day Total Sales,7 Day Total Orders (#)
|
|
2
|
+
Auto - Garlic Press,Auto Group,auto,garlic press,5000,40,$0.50,$20.00,$0.00,0
|
|
3
|
+
Auto - Garlic Press,Auto Group,auto,stainless garlic press,3200,30,$0.50,$15.00,$120.00,8
|
|
4
|
+
Broad - Garlic,Broad Group,broad,garlic mincer tool,4100,25,$0.88,$22.00,$40.00,2
|
|
5
|
+
Exact - Pro Press,Exact Group,exact,professional garlic press,6000,50,$0.20,$10.00,$200.00,15
|
|
6
|
+
Broad - Garlic,Broad Group,broad,cheap kitchen gadget,2200,12,$0.54,$6.50,$0.00,0
|
|
7
|
+
Broad - Garlic,Broad Group,broad,garlic,20000,3,$0.50,$1.50,$0.00,0
|
|
8
|
+
Auto - Garlic Press,Auto Group,auto,rocker garlic press,1800,18,$0.50,$9.00,$90.00,5
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "Customer Search Term": "garlic press", "Campaign Name": "Auto - Garlic Press", "Match Type": "auto", "Impressions": 5000, "Clicks": 40, "Spend": 20.0, "7 Day Total Sales": 0, "7 Day Total Orders (#)": 0 },
|
|
3
|
+
{ "Customer Search Term": "stainless garlic press", "Campaign Name": "Auto - Garlic Press", "Match Type": "auto", "Impressions": 3200, "Clicks": 30, "Spend": 15.0, "7 Day Total Sales": 120.0, "7 Day Total Orders (#)": 8 },
|
|
4
|
+
{ "Customer Search Term": "professional garlic press", "Campaign Name": "Exact - Pro Press", "Match Type": "exact", "Impressions": 6000, "Clicks": 50, "Spend": 10.0, "7 Day Total Sales": 200.0, "7 Day Total Orders (#)": 15 }
|
|
5
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Offline tests — run with `node --test`, zero install. Asserts the optimizer
|
|
2
|
+
// computes real, specific proposals from a known search-term report with seeded
|
|
3
|
+
// issues. If these pass, the core is doing deterministic arithmetic, not echoing.
|
|
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 { optimize } from '../src/optimize.mjs';
|
|
11
|
+
import { normalizeRows, parseDelimited } from '../src/parse.mjs';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const csv = readFileSync(join(__dirname, 'fixtures', 'search-term-report.csv'), 'utf8');
|
|
15
|
+
const jsonText = readFileSync(join(__dirname, 'fixtures', 'search-term-report.json'), 'utf8');
|
|
16
|
+
|
|
17
|
+
const CFG = { targetAcos: 25, margin: 35 };
|
|
18
|
+
const run = () => optimize(csv, CFG);
|
|
19
|
+
const findNeg = (r, term) => r.negatives.find((n) => n.term === term);
|
|
20
|
+
const findPromo = (r, term) => r.promotions.find((p) => p.term === term);
|
|
21
|
+
const findRebid = (r, term) => r.rebids.find((b) => b.term === term);
|
|
22
|
+
|
|
23
|
+
test('CSV parser strips $/%/commas and normalizes messy Amazon headers', () => {
|
|
24
|
+
const rows = normalizeRows(csv);
|
|
25
|
+
assert.equal(rows.length, 7);
|
|
26
|
+
const r = rows.find((x) => x.searchTerm === 'garlic mincer tool');
|
|
27
|
+
assert.equal(r.spend, 22.0);
|
|
28
|
+
assert.equal(r.sales, 40.0);
|
|
29
|
+
assert.equal(r.orders, 2);
|
|
30
|
+
assert.equal(r.clicks, 25);
|
|
31
|
+
assert.equal(r.cpc, 0.88);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('CSV tokenizer handles a quoted field containing a comma', () => {
|
|
35
|
+
const rows = parseDelimited('a,b\n"hello, world",2\n');
|
|
36
|
+
assert.deepEqual(rows[1], ['hello, world', '2']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('negates zero-order waste terms and estimates spend stopped', () => {
|
|
40
|
+
const r = run();
|
|
41
|
+
assert.equal(r.negatives.length, 2);
|
|
42
|
+
const gp = findNeg(r, 'garlic press');
|
|
43
|
+
assert.ok(gp, 'garlic press negated');
|
|
44
|
+
assert.equal(gp.spendStopped, 20.0);
|
|
45
|
+
assert.match(gp.reason, /40 clicks, 0 orders/);
|
|
46
|
+
assert.ok(findNeg(r, 'cheap kitchen gadget'), '12-click zero-order term negated');
|
|
47
|
+
assert.equal(r.summary.estSpendStopped, 26.5);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('does NOT negate a low-signal term (3 clicks, $1.50, 0 orders) — it holds', () => {
|
|
51
|
+
const r = run();
|
|
52
|
+
assert.equal(findNeg(r, 'garlic'), undefined);
|
|
53
|
+
const held = r.held.find((h) => h.term === 'garlic');
|
|
54
|
+
assert.ok(held && held.action === 'hold', 'low-signal term held, not negated');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('promotes converting non-exact winners with the margin math attached', () => {
|
|
58
|
+
const r = run();
|
|
59
|
+
assert.equal(r.promotions.length, 2);
|
|
60
|
+
const p = findPromo(r, 'stainless garlic press');
|
|
61
|
+
assert.ok(p, 'winner promoted');
|
|
62
|
+
assert.equal(p.newMatchType, 'exact');
|
|
63
|
+
assert.equal(p.acos, 12.5);
|
|
64
|
+
// rev/click $4.00 * 25% target = $1.00 ideal, capped to 1.5x CPC($0.50) = $0.75
|
|
65
|
+
assert.equal(p.startingBid, 0.75);
|
|
66
|
+
assert.equal(p.capped, true);
|
|
67
|
+
assert.equal(p.negateInSource, true);
|
|
68
|
+
assert.match(p.marginMath, /\$4 rev\/click x 25% target ACOS = \$1 ideal bid/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('promotions are ranked by sales (biggest winner first)', () => {
|
|
72
|
+
const r = run();
|
|
73
|
+
assert.equal(r.promotions[0].term, 'stainless garlic press'); // $120
|
|
74
|
+
assert.equal(r.promotions[1].term, 'rocker garlic press'); // $90
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('does NOT promote a high-ACOS term (55% > 25% target) — it re-bids down', () => {
|
|
78
|
+
const r = run();
|
|
79
|
+
assert.equal(findPromo(r, 'garlic mincer tool'), undefined);
|
|
80
|
+
const b = findRebid(r, 'garlic mincer tool');
|
|
81
|
+
assert.ok(b, 'high-ACOS converting term re-bid instead of promoted');
|
|
82
|
+
assert.equal(b.direction, 'down');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('re-bids DOWN an overpaying term, capped at -50%, flagged as losing vs break-even', () => {
|
|
86
|
+
const r = run();
|
|
87
|
+
const b = findRebid(r, 'garlic mincer tool');
|
|
88
|
+
assert.equal(b.oldBid, 0.88);
|
|
89
|
+
// ideal $0.40 but -50% cut floor is $0.44 -> capped
|
|
90
|
+
assert.equal(b.newBid, 0.44);
|
|
91
|
+
assert.equal(b.capped, true);
|
|
92
|
+
assert.match(b.why, /losing: ACOS above 35% break-even/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('re-bids UP an underpriced converter, capped at +100%', () => {
|
|
96
|
+
const r = run();
|
|
97
|
+
const b = findRebid(r, 'professional garlic press');
|
|
98
|
+
assert.ok(b && b.direction === 'up');
|
|
99
|
+
assert.equal(b.oldBid, 0.2);
|
|
100
|
+
// ideal $1.00 but +100% ceiling is $0.40 -> capped
|
|
101
|
+
assert.equal(b.newBid, 0.4);
|
|
102
|
+
assert.equal(b.capped, true);
|
|
103
|
+
assert.equal(b.changePct, 100);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('re-bids ranked by magnitude of change', () => {
|
|
107
|
+
const r = run();
|
|
108
|
+
assert.equal(r.rebids[0].term, 'professional garlic press'); // +100%
|
|
109
|
+
assert.equal(r.rebids[1].term, 'garlic mincer tool'); // -50%
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('computes correct account totals and blended ACOS', () => {
|
|
113
|
+
const r = run();
|
|
114
|
+
assert.equal(r.totals.terms, 7);
|
|
115
|
+
assert.equal(r.totals.clicks, 178);
|
|
116
|
+
assert.equal(r.totals.spend, 84.0);
|
|
117
|
+
assert.equal(r.totals.sales, 450.0);
|
|
118
|
+
assert.equal(r.totals.orders, 30);
|
|
119
|
+
assert.equal(r.totals.acos, 18.67); // 84/450
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('inventory gate suppresses scaling on a low-inventory campaign', () => {
|
|
123
|
+
const r = optimize(csv, { ...CFG, lowInventoryCampaigns: ['Auto - Garlic Press'] });
|
|
124
|
+
// stainless + rocker garlic press were promotions from that auto campaign
|
|
125
|
+
assert.equal(findPromo(r, 'stainless garlic press'), undefined);
|
|
126
|
+
assert.equal(findPromo(r, 'rocker garlic press'), undefined);
|
|
127
|
+
const held = r.held.find((h) => h.term === 'stainless garlic press');
|
|
128
|
+
assert.ok(held && held.action === 'promote-suppressed', 'promotion suppressed by inventory gate');
|
|
129
|
+
assert.match(held.reason, /low inventory/);
|
|
130
|
+
// negatives from the same campaign still fire (a stock-out does not un-waste spend)
|
|
131
|
+
assert.ok(findNeg(r, 'garlic press'), 'negatives unaffected by inventory gate');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('margin-only config sets target below break-even (0.75 x margin)', () => {
|
|
135
|
+
const r = optimize(csv, { margin: 40 }); // no explicit targetAcos
|
|
136
|
+
assert.equal(r.config.margin, 40);
|
|
137
|
+
assert.equal(r.config.targetAcos, 30); // 40 * 0.75
|
|
138
|
+
assert.equal(r.config.breakEvenAcos, 40);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('deterministic: same input -> byte-identical output', () => {
|
|
142
|
+
const a = JSON.stringify(optimize(csv, CFG));
|
|
143
|
+
const b = JSON.stringify(optimize(csv, CFG));
|
|
144
|
+
assert.equal(a, b);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('JSON report input parses through the same path as CSV', () => {
|
|
148
|
+
const r = optimize(jsonText, CFG);
|
|
149
|
+
assert.equal(r.totals.terms, 3);
|
|
150
|
+
assert.ok(findNeg(r, 'garlic press'));
|
|
151
|
+
assert.ok(findPromo(r, 'stainless garlic press'));
|
|
152
|
+
assert.ok(findRebid(r, 'professional garlic press'));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('every proposal is propose-only and carries its numbers', () => {
|
|
156
|
+
const r = run();
|
|
157
|
+
for (const n of r.negatives) assert.ok(typeof n.spendStopped === 'number' && n.reason);
|
|
158
|
+
for (const p of r.promotions) assert.ok(typeof p.startingBid === 'number' && p.marginMath);
|
|
159
|
+
for (const b of r.rebids) assert.ok(b.oldBid >= 0 && b.newBid >= 0 && b.why);
|
|
160
|
+
assert.match(r.summary.headline, /PROPOSE-ONLY/);
|
|
161
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-cores/content-ops",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Content-operations engine. Deterministic: parse a CMS export, compute keep/update/retire decisions with evidence, metadata gaps, and migration-readiness QA. No LLM in the core.",
|
|
7
|
+
"bin": {
|
|
8
|
+
"content-ops": "src/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test",
|
|
12
|
+
"inventory": "node src/cli.mjs"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Real CLI.
|
|
3
|
+
// content-ops <file.csv|file.json> [--asof YYYY-MM-DD] [--pretty]
|
|
4
|
+
// Reads a CMS export from disk, computes the inventory, prints JSON to stdout
|
|
5
|
+
// (machine-readable) and a one-line summary to stderr.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { inventoryFromText } from './inventory.mjs';
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const args = { _: [] };
|
|
12
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
13
|
+
const a = argv[i];
|
|
14
|
+
if (a === '--asof') args.asof = argv[++i];
|
|
15
|
+
else if (a === '--pretty') args.pretty = true;
|
|
16
|
+
else if (a === '--csv') args.hint = 'csv';
|
|
17
|
+
else if (a === '--json') args.hint = 'json';
|
|
18
|
+
else args._.push(a);
|
|
19
|
+
}
|
|
20
|
+
return args;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function main() {
|
|
24
|
+
const args = parseArgs(process.argv.slice(2));
|
|
25
|
+
const file = args._[0];
|
|
26
|
+
if (!file) {
|
|
27
|
+
console.error('Usage: content-ops <file.csv|file.json> [--asof YYYY-MM-DD] [--pretty]');
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hint = args.hint ?? (file.toLowerCase().endsWith('.json') ? 'json' : file.toLowerCase().endsWith('.csv') ? 'csv' : null);
|
|
33
|
+
const text = readFileSync(file, 'utf8');
|
|
34
|
+
const result = inventoryFromText(text, hint, { asOf: args.asof });
|
|
35
|
+
|
|
36
|
+
const c = result.counts;
|
|
37
|
+
const m = result.migrationReadiness;
|
|
38
|
+
console.error(
|
|
39
|
+
`content-ops: ${c.total} URLs (asOf ${result.asOf}) — ` +
|
|
40
|
+
`keep ${c.keep} / update ${c.update} / retire ${c.retire} / review ${c.review} — ` +
|
|
41
|
+
`${m.redirectsRequired} redirect(s) needed, readiness ${m.readinessScore}/100`,
|
|
42
|
+
);
|
|
43
|
+
console.log(JSON.stringify(result, null, args.pretty ? 2 : 0));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
main();
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
}
|