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,177 @@
|
|
|
1
|
+
// Offline tests — run with `node --test`, zero install. Asserts the engine makes
|
|
2
|
+
// the specific keep/update/retire decisions the seeded fixture demands, flags the
|
|
3
|
+
// seeded metadata gaps, and is deterministic. If these pass, the core is doing real
|
|
4
|
+
// arithmetic on real data — not echoing a prompt.
|
|
5
|
+
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { inventoryFromText, buildInventory, DEFAULT_THRESHOLDS } from '../src/inventory.mjs';
|
|
12
|
+
import { parseCsv, csvToRecords } from '../src/parse.mjs';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const csvText = readFileSync(join(__dirname, 'fixtures', 'cms-export.csv'), 'utf8');
|
|
16
|
+
const jsonText = readFileSync(join(__dirname, 'fixtures', 'cms-export.json'), 'utf8');
|
|
17
|
+
|
|
18
|
+
// Pin asOf so hand-computed staleness matches the seeded expectations.
|
|
19
|
+
const ASOF = '2026-07-01';
|
|
20
|
+
const run = () => inventoryFromText(csvText, 'csv', { asOf: ASOF });
|
|
21
|
+
const byUrl = (r, u) => r.items.find((it) => it.url === u);
|
|
22
|
+
|
|
23
|
+
test('CSV parser handles quoted fields with embedded commas', () => {
|
|
24
|
+
const rows = parseCsv('a,b\n"x, y",z\n');
|
|
25
|
+
assert.deepEqual(rows, [['a', 'b'], ['x, y', 'z']]);
|
|
26
|
+
// the seeded homepage row has a comma inside a quoted meta description
|
|
27
|
+
const recs = csvToRecords(csvText);
|
|
28
|
+
const home = recs.find((r) => r.url === 'https://acme.example/');
|
|
29
|
+
assert.equal(home.author, 'Team', 'quoted comma must not shift the author column');
|
|
30
|
+
assert.match(home['meta description'], /Acme, updated recently/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('retires stale + low-traffic content with a redirect requirement', () => {
|
|
34
|
+
const r = run();
|
|
35
|
+
const it = byUrl(r, 'https://acme.example/old-news-2019');
|
|
36
|
+
assert.equal(it.decision, 'retire');
|
|
37
|
+
assert.match(it.reasons.join(' '), /stale.*low traffic|low traffic.*stale/);
|
|
38
|
+
assert.equal(it.redirectTarget, null, 'redirect target is an unresolved decision, not omitted');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('retires thin + low-traffic content as ROT even when recent', () => {
|
|
42
|
+
const r = run();
|
|
43
|
+
const it = byUrl(r, 'https://acme.example/tags/misc');
|
|
44
|
+
assert.equal(it.decision, 'retire');
|
|
45
|
+
assert.match(it.reasons.join(' '), /thin content/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('updates high-traffic pages that have gone stale (never retires them)', () => {
|
|
49
|
+
const r = run();
|
|
50
|
+
const it = byUrl(r, 'https://acme.example/pricing-guide');
|
|
51
|
+
assert.equal(it.decision, 'update');
|
|
52
|
+
assert.match(it.reasons.join(' '), /high traffic/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('keeps high-traffic fresh content', () => {
|
|
56
|
+
const r = run();
|
|
57
|
+
const it = byUrl(r, 'https://acme.example/');
|
|
58
|
+
assert.equal(it.decision, 'keep');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('updates a low-but-aging article that is not yet ROT', () => {
|
|
62
|
+
const r = run();
|
|
63
|
+
const it = byUrl(r, 'https://acme.example/blog/mid-article');
|
|
64
|
+
assert.equal(it.decision, 'update');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('updates decayed moderate-traffic reference docs', () => {
|
|
68
|
+
const r = run();
|
|
69
|
+
const it = byUrl(r, 'https://acme.example/docs/reference');
|
|
70
|
+
assert.equal(it.decision, 'update');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('routes rows with insufficient data to review, never a guessed decision', () => {
|
|
74
|
+
const r = run();
|
|
75
|
+
const noTraffic = byUrl(r, 'https://acme.example/reports/quarterly');
|
|
76
|
+
assert.equal(noTraffic.decision, 'review');
|
|
77
|
+
assert.match(noTraffic.reasons.join(' '), /pageviews/);
|
|
78
|
+
|
|
79
|
+
const noDate = byUrl(r, 'https://acme.example/orphan');
|
|
80
|
+
assert.equal(noDate.decision, 'review');
|
|
81
|
+
assert.equal(noDate.confidence, 'low');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('decision counts match the seeded fixture exactly', () => {
|
|
85
|
+
const r = run();
|
|
86
|
+
assert.deepEqual(r.counts, { total: 12, keep: 5, update: 3, retire: 2, review: 2 });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('flags the seeded metadata gaps (missing desc, short/long title, long desc)', () => {
|
|
90
|
+
const r = run();
|
|
91
|
+
const gapFor = (url, field) =>
|
|
92
|
+
r.metadataGaps.find((g) => g.url === url && g.field === field);
|
|
93
|
+
|
|
94
|
+
// tags/misc has an empty meta description
|
|
95
|
+
assert.ok(gapFor('https://acme.example/tags/misc', 'metaDescription'));
|
|
96
|
+
// jane-doe bio: title too short AND meta description missing (tier-1 page)
|
|
97
|
+
const jane = byUrl(r, 'https://acme.example/team/jane-doe');
|
|
98
|
+
assert.ok(jane.metadataGaps.some((g) => g.field === 'title' && /too short/.test(g.issue)));
|
|
99
|
+
assert.ok(jane.metadataGaps.some((g) => g.field === 'metaDescription' && g.issue === 'missing'));
|
|
100
|
+
// pricing-guide meta description exceeds 160 chars
|
|
101
|
+
const pricing = byUrl(r, 'https://acme.example/pricing-guide');
|
|
102
|
+
assert.ok(pricing.metadataGaps.some((g) => g.field === 'metaDescription' && /too long/.test(g.issue)));
|
|
103
|
+
// reference doc title exceeds 65 chars
|
|
104
|
+
const ref = byUrl(r, 'https://acme.example/docs/reference');
|
|
105
|
+
assert.ok(ref.metadataGaps.some((g) => g.field === 'title' && /too long/.test(g.issue)));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a missing date is a blocking (fail) gap', () => {
|
|
109
|
+
const r = run();
|
|
110
|
+
const orphan = byUrl(r, 'https://acme.example/orphan');
|
|
111
|
+
assert.ok(orphan.metadataGaps.some((g) => g.field === 'date' && g.severity === 'fail'));
|
|
112
|
+
assert.ok(r.migrationReadiness.blockingGaps >= 1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('detects duplicate titles across URLs (taxonomy collision)', () => {
|
|
116
|
+
const r = run();
|
|
117
|
+
const dup = r.duplicates.titles.find((d) => d.title === 'complete guide');
|
|
118
|
+
assert.ok(dup, 'the two "Complete Guide" pages are flagged');
|
|
119
|
+
assert.equal(dup.count, 2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('surfaces source fields with no canonical destination', () => {
|
|
123
|
+
const r = run();
|
|
124
|
+
assert.ok(r.unmappedFields.includes('author'), '"author" has no mapped destination');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('migration readiness: redirect ledger, blockers, and a bounded score', () => {
|
|
128
|
+
const r = run();
|
|
129
|
+
const m = r.migrationReadiness;
|
|
130
|
+
assert.equal(m.redirectsRequired, 2);
|
|
131
|
+
assert.equal(m.redirectLedger.length, 2);
|
|
132
|
+
assert.ok(m.redirectLedger.every((e) => e.to === null && e.resolved === false && e.status === 301));
|
|
133
|
+
assert.ok(m.blockers.some((b) => /redirect target/.test(b)));
|
|
134
|
+
assert.ok(m.readinessScore >= 0 && m.readinessScore <= 100);
|
|
135
|
+
assert.equal(typeof m.fieldCoverage.pageviews.pct, 'number');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('same input -> byte-identical output (deterministic, no wall clock)', () => {
|
|
139
|
+
const a = run();
|
|
140
|
+
const b = run();
|
|
141
|
+
assert.equal(JSON.stringify(a), JSON.stringify(b));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('default asOf is the latest date in the export, not Date.now()', () => {
|
|
145
|
+
const r = inventoryFromText(csvText, 'csv'); // no asOf passed
|
|
146
|
+
assert.equal(r.asOf, '2026-06-20', 'latest last-updated in the CSV drives asOf');
|
|
147
|
+
// and it is still deterministic across runs
|
|
148
|
+
const r2 = inventoryFromText(csvText, 'csv');
|
|
149
|
+
assert.equal(JSON.stringify(r), JSON.stringify(r2));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('JSON export path works and applies the same decision logic', () => {
|
|
153
|
+
const r = inventoryFromText(jsonText, 'json');
|
|
154
|
+
assert.equal(byUrl(r, 'https://acme.example/legacy-faq').decision, 'retire');
|
|
155
|
+
assert.equal(byUrl(r, 'https://acme.example/flagship').decision, 'update');
|
|
156
|
+
assert.equal(byUrl(r, 'https://acme.example/whats-new').decision, 'keep');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('empty export yields a well-formed, non-crashing result', () => {
|
|
160
|
+
const r = buildInventory([]);
|
|
161
|
+
assert.equal(r.counts.total, 0);
|
|
162
|
+
assert.equal(r.migrationReadiness.readinessScore, 0);
|
|
163
|
+
assert.ok(r.migrationReadiness.blockers.length > 0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('thresholds are overridable and change decisions predictably', () => {
|
|
167
|
+
// Baseline: /guide is mid-traffic (300 views) and fresh (122 days) -> keep.
|
|
168
|
+
const base = run();
|
|
169
|
+
assert.equal(base.items.find((it) => it.url === 'https://acme.example/guide').decision, 'keep');
|
|
170
|
+
// Tighten veryStaleDays below 122: the same page now reads as decayed -> update.
|
|
171
|
+
const strict = inventoryFromText(csvText, 'csv', {
|
|
172
|
+
asOf: ASOF,
|
|
173
|
+
thresholds: { ...DEFAULT_THRESHOLDS, veryStaleDays: 100 },
|
|
174
|
+
});
|
|
175
|
+
const guide = strict.items.find((it) => it.url === 'https://acme.example/guide');
|
|
176
|
+
assert.equal(guide.decision, 'update');
|
|
177
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-cores/meta-ads",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Meta (Facebook/Instagram) ads auditor. Deterministic: parse an Ads Manager export, compute a ranked PROPOSE-ONLY change sheet (pause wasted spend, creative fatigue, low ROAS, cost-per-result inefficiency). No LLM in the core.",
|
|
7
|
+
"bin": {
|
|
8
|
+
"meta-ads": "src/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Deterministic Meta (Facebook/Instagram) ads analysis. No LLM. PROPOSE-ONLY.
|
|
2
|
+
//
|
|
3
|
+
// Input: parsed rows (canonical fields from parse.mjs), one per breakdown entity — an
|
|
4
|
+
// ad, an ad set, or a campaign. Output: a ranked change sheet; every row carries the
|
|
5
|
+
// evidence numbers that triggered it. The core never calls a model and never touches an
|
|
6
|
+
// account — a human applies the proposed changes.
|
|
7
|
+
//
|
|
8
|
+
// Four finding families, mirroring the Meta buyer's job:
|
|
9
|
+
// pause — spend with zero results (burning budget, nothing back)
|
|
10
|
+
// low-roas — purchases tracked but ROAS < 1 (losing money on every dollar)
|
|
11
|
+
// inefficient — cost per result far above the account average (expensive but not losing)
|
|
12
|
+
// fatigue — high frequency (the same people seeing it too often — refresh creative)
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_THRESHOLDS = {
|
|
15
|
+
pauseMinSpend: 20, // spend where "0 results" is signal, not noise
|
|
16
|
+
lowRoasMax: 1, // ROAS < 1 loses money
|
|
17
|
+
lowRoasMinSpend: 30,
|
|
18
|
+
effCprMultiple: 3, // cost/result >= 3x the account average
|
|
19
|
+
effMinSpend: 30,
|
|
20
|
+
fatigueFrequency: 3.5, // avg impressions per person before fatigue bites
|
|
21
|
+
fatigueMinSpend: 20,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function sum(arr, f) {
|
|
25
|
+
let t = 0;
|
|
26
|
+
for (const x of arr) { const v = f(x); if (Number.isFinite(v)) t += v; }
|
|
27
|
+
return t;
|
|
28
|
+
}
|
|
29
|
+
function round(n, dp = 2) {
|
|
30
|
+
if (!Number.isFinite(n)) return n;
|
|
31
|
+
const m = 10 ** dp;
|
|
32
|
+
return Math.round(n * m) / m;
|
|
33
|
+
}
|
|
34
|
+
// Name an entity by its most granular identifier, and say which level that is.
|
|
35
|
+
function label(r) {
|
|
36
|
+
if (r.ad) return { name: r.ad, level: 'ad' };
|
|
37
|
+
if (r.adset) return { name: r.adset, level: 'ad set' };
|
|
38
|
+
return { name: r.campaign || '(unnamed)', level: 'campaign' };
|
|
39
|
+
}
|
|
40
|
+
function where(r) {
|
|
41
|
+
return [r.campaign, r.adset, r.ad].filter(Boolean).join(' / ') || 'account';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function analyze(input, options = {}) {
|
|
45
|
+
const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds || {}) };
|
|
46
|
+
const rows = Array.isArray(input) ? input : (Array.isArray(input?.rows) ? input.rows : []);
|
|
47
|
+
|
|
48
|
+
const totalSpend = sum(rows, (r) => r.spend);
|
|
49
|
+
const totalResults = sum(rows, (r) => r.results);
|
|
50
|
+
const totalValue = sum(rows, (r) => r.purchaseValue ?? (Number.isFinite(r.roas) && Number.isFinite(r.spend) ? r.roas * r.spend : 0));
|
|
51
|
+
const accountCPR = totalResults > 0 ? totalSpend / totalResults : null;
|
|
52
|
+
const accountROAS = totalSpend > 0 && totalValue > 0 ? totalValue / totalSpend : null;
|
|
53
|
+
|
|
54
|
+
const changes = [];
|
|
55
|
+
|
|
56
|
+
for (const r of rows) {
|
|
57
|
+
const spend = r.spend || 0;
|
|
58
|
+
const results = r.results || 0;
|
|
59
|
+
const roas = Number.isFinite(r.roas) ? r.roas : null;
|
|
60
|
+
const cpr = Number.isFinite(r.costPerResult) ? r.costPerResult : (results > 0 ? spend / results : null);
|
|
61
|
+
const { name, level } = label(r);
|
|
62
|
+
|
|
63
|
+
// 1. pause — spend, zero results
|
|
64
|
+
if (results === 0 && spend >= thresholds.pauseMinSpend) {
|
|
65
|
+
changes.push({
|
|
66
|
+
type: 'pause',
|
|
67
|
+
change: `Pause ${level} "${name}" — spend with zero results`,
|
|
68
|
+
where: where(r),
|
|
69
|
+
why: `$${round(spend)} spent, 0 results${r.linkClicks ? `, ${r.linkClicks} link clicks that never converted` : ''}`,
|
|
70
|
+
expectedEffect: `Recover ~$${round(spend)}/period of wasted spend`,
|
|
71
|
+
risk: 'If this is a new ad still in learning, give it a few days of results before killing it.',
|
|
72
|
+
dollars: round(spend),
|
|
73
|
+
evidence: { entity: name, level, spend: round(spend), results: 0, roas },
|
|
74
|
+
});
|
|
75
|
+
continue; // one primary call per entity
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. low ROAS — losing money on tracked purchases
|
|
79
|
+
if (roas != null && roas < thresholds.lowRoasMax && spend >= thresholds.lowRoasMinSpend) {
|
|
80
|
+
const value = round(spend * roas);
|
|
81
|
+
const loss = round(spend - value);
|
|
82
|
+
changes.push({
|
|
83
|
+
type: 'low-roas',
|
|
84
|
+
change: `Pause or rework ${level} "${name}" — ROAS ${round(roas, 2)} is below break-even`,
|
|
85
|
+
where: where(r),
|
|
86
|
+
why: `$${round(spend)} spent returned ~$${value} (ROAS ${round(roas, 2)}) — losing ~$${loss}`,
|
|
87
|
+
expectedEffect: `Stop ~$${loss}/period of net loss; reallocate to profitable ad sets`,
|
|
88
|
+
risk: 'ROAS can lag with a long purchase window; confirm the attribution window before pausing a recent ad.',
|
|
89
|
+
dollars: loss,
|
|
90
|
+
evidence: { entity: name, level, spend: round(spend), roas: round(roas, 2), value },
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. inefficient — cost per result far above account average (and not already low-roas)
|
|
96
|
+
if (accountCPR != null && cpr != null && results > 0 && spend >= thresholds.effMinSpend
|
|
97
|
+
&& cpr >= thresholds.effCprMultiple * accountCPR) {
|
|
98
|
+
const overspend = round(spend - results * accountCPR);
|
|
99
|
+
changes.push({
|
|
100
|
+
type: 'inefficient',
|
|
101
|
+
change: `Reduce or restructure ${level} "${name}" — cost per result far above account average`,
|
|
102
|
+
where: where(r),
|
|
103
|
+
why: `$${round(cpr)}/result vs $${round(accountCPR)} account average (${round(cpr / accountCPR, 1)}x) on $${round(spend)} spend`,
|
|
104
|
+
expectedEffect: `Recover ~$${overspend}/period of over-average cost`,
|
|
105
|
+
risk: 'A small results count makes cost-per-result noisy; check it holds over more conversions before cutting.',
|
|
106
|
+
dollars: overspend,
|
|
107
|
+
evidence: { entity: name, level, spend: round(spend), costPerResult: round(cpr), accountCPR: round(accountCPR), results },
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 4. fatigue — high frequency (creative refresh). Not a "continue" gate: a healthy ad
|
|
113
|
+
// can still be fatiguing, but pause/low-roas/inefficient are more urgent when present.
|
|
114
|
+
if (Number.isFinite(r.frequency) && r.frequency >= thresholds.fatigueFrequency && spend >= thresholds.fatigueMinSpend) {
|
|
115
|
+
changes.push({
|
|
116
|
+
type: 'fatigue',
|
|
117
|
+
change: `Refresh creative on ${level} "${name}" — audience is seeing it too often`,
|
|
118
|
+
where: where(r),
|
|
119
|
+
why: `frequency ${round(r.frequency, 1)} (each person saw it ~${Math.round(r.frequency)}x) on $${round(spend)} spend`,
|
|
120
|
+
expectedEffect: 'New creative usually recovers CTR/CPM as fatigue resets; expand or refresh the audience too',
|
|
121
|
+
risk: 'High frequency on a small retargeting pool can be fine; judge against that audience size.',
|
|
122
|
+
dollars: round(spend * 0.25), // a quarter of spend treated as at-risk to fatigue, for ranking
|
|
123
|
+
evidence: { entity: name, level, frequency: round(r.frequency, 1), spend: round(spend) },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// rank by dollars; tiebreak by type order then a stable key
|
|
129
|
+
const typeRank = { pause: 0, 'low-roas': 1, inefficient: 2, fatigue: 3 };
|
|
130
|
+
const keyed = changes.map((c, i) => ({ ...c, _i: i }));
|
|
131
|
+
keyed.sort((a, b) => {
|
|
132
|
+
if ((b.dollars || 0) !== (a.dollars || 0)) return (b.dollars || 0) - (a.dollars || 0);
|
|
133
|
+
if (typeRank[a.type] !== typeRank[b.type]) return typeRank[a.type] - typeRank[b.type];
|
|
134
|
+
return a._i - b._i;
|
|
135
|
+
});
|
|
136
|
+
const ranked = keyed.map(({ _i, ...c }, idx) => ({ rank: idx + 1, ...c }));
|
|
137
|
+
|
|
138
|
+
const recoverable = round(sum(ranked.filter((c) => c.type === 'pause' || c.type === 'low-roas' || c.type === 'inefficient'), (c) => c.dollars), 2);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
proposeOnly: true,
|
|
142
|
+
account: {
|
|
143
|
+
rows: rows.length,
|
|
144
|
+
totalSpend: round(totalSpend),
|
|
145
|
+
totalResults: round(totalResults, 1),
|
|
146
|
+
accountCostPerResult: accountCPR == null ? null : round(accountCPR),
|
|
147
|
+
accountROAS: accountROAS == null ? null : round(accountROAS, 2),
|
|
148
|
+
},
|
|
149
|
+
counts: {
|
|
150
|
+
total: ranked.length,
|
|
151
|
+
pause: ranked.filter((c) => c.type === 'pause').length,
|
|
152
|
+
lowRoas: ranked.filter((c) => c.type === 'low-roas').length,
|
|
153
|
+
inefficient: ranked.filter((c) => c.type === 'inefficient').length,
|
|
154
|
+
fatigue: ranked.filter((c) => c.type === 'fatigue').length,
|
|
155
|
+
},
|
|
156
|
+
wastedSpendRecoverable: recoverable,
|
|
157
|
+
summary: buildSummary(ranked, recoverable),
|
|
158
|
+
changes: ranked,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildSummary(ranked, recoverable) {
|
|
163
|
+
if (ranked.length === 0) return 'No pause, ROAS, cost, or fatigue changes found in the exported report.';
|
|
164
|
+
return `${ranked.length} proposed change(s); ~$${recoverable} recoverable from wasted/losing/over-cost spend. `
|
|
165
|
+
+ `Start with: ${ranked[0].change}.`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Real CLI. Reads a Meta Ads Manager export (CSV or JSON) from a file path, prints the
|
|
3
|
+
// PROPOSE-ONLY change sheet as JSON on stdout and a one-line summary on stderr. No
|
|
4
|
+
// account access.
|
|
5
|
+
// meta-ads <export.csv> [--pretty]
|
|
6
|
+
// meta-ads <export.json> (a JSON array of rows, or { "rows": [...] })
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { analyze } from './analyze.mjs';
|
|
10
|
+
import { parseReport } from './parse.mjs';
|
|
11
|
+
|
|
12
|
+
function main() {
|
|
13
|
+
const argv = process.argv.slice(2);
|
|
14
|
+
const pretty = argv.includes('--pretty');
|
|
15
|
+
const file = argv.find((a) => !a.startsWith('--'));
|
|
16
|
+
if (!file) {
|
|
17
|
+
console.error('Usage: meta-ads <export.csv|export.json> [--pretty]');
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const text = readFileSync(file, 'utf8');
|
|
22
|
+
let rows;
|
|
23
|
+
if (file.endsWith('.json')) {
|
|
24
|
+
const parsed = JSON.parse(text);
|
|
25
|
+
rows = Array.isArray(parsed) ? parsed : (parsed.rows || []);
|
|
26
|
+
} else {
|
|
27
|
+
rows = parseReport(text);
|
|
28
|
+
}
|
|
29
|
+
const result = analyze(rows);
|
|
30
|
+
console.error(`meta-ads: ${result.summary}`);
|
|
31
|
+
console.log(JSON.stringify(result, null, pretty ? 2 : 0));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
main();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
@@ -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
|
+
}
|