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
package/README.md
CHANGED
|
@@ -1,12 +1,178 @@
|
|
|
1
|
-
# Weftens
|
|
2
|
-
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
# Weftens
|
|
2
|
+
|
|
3
|
+
**Where did the ad money go, and what should change?** Give Weftens an exported report from
|
|
4
|
+
Google Ads, Meta, Amazon, or an SEO crawl, and it gives you back a ranked change sheet — each
|
|
5
|
+
recommendation with where it applies, why it's proposed, what it's worth, and what could go
|
|
6
|
+
wrong if you're wrong.
|
|
7
|
+
|
|
8
|
+
4 proposed changes | $604.27 of spend flagged
|
|
9
|
+
account: spend $910.00, 208 clicks, 11 conversions, CPA $82.73
|
|
10
|
+
|
|
11
|
+
1. Bid down or restrict "premium widget wholesale bulk" $417.27
|
|
12
|
+
where campaign: Brand / Brand - Exact
|
|
13
|
+
why CPA $500 vs account $82.73 (6x); $500 for 1 conv
|
|
14
|
+
risk Converting term — restricting too hard can cut real volume; prefer a bid cut first.
|
|
15
|
+
|
|
16
|
+
2. Add "free blue widgets" as a negative keyword $90.00
|
|
17
|
+
where campaign: Generic / Widgets - Broad
|
|
18
|
+
why 60 clicks, $90 spent, 0 conversions (expected ~3.2 at the 5.3% account rate)
|
|
19
|
+
risk If the query is actually relevant, you forfeit those impressions.
|
|
20
|
+
|
|
21
|
+
Proposals only — nothing was sent to your ad account. Apply what you agree with.
|
|
22
|
+
|
|
23
|
+
That is the actual output of the sample export included in this repo, produced by:
|
|
24
|
+
|
|
25
|
+
npm i weftens
|
|
26
|
+
npx weftens send paid-search audit --input agent-cores/paid-search/test/fixtures/search-terms.csv
|
|
27
|
+
|
|
28
|
+
**Same file in, same change sheet out, every time.** The ad and SEO auditors are arithmetic, not
|
|
29
|
+
a model — nothing is generated, so nothing can be invented, and two runs can be diffed to prove
|
|
30
|
+
nothing moved. They read a file you already have and **never sign in to your ad account**. Note
|
|
31
|
+
the second line of the first recommendation: that keyword *converted*. "Flagged" means the numbers
|
|
32
|
+
crossed a threshold, not that the money is guaranteed back — the risk line is there because the
|
|
33
|
+
call is still yours.
|
|
34
|
+
|
|
35
|
+
**Not technical?** You don't have to run this. Send the repo link and your exported report to
|
|
36
|
+
whoever manages your ads, or paste this to an AI assistant that can use your computer
|
|
37
|
+
(Claude, ChatGPT/Codex, Gemini): *"install weftens and run the paid-search audit on my export."*
|
|
38
|
+
|
|
39
|
+
## What's finished, and what isn't
|
|
40
|
+
|
|
41
|
+
- **Finished and deterministic — seven auditors:** paid search (Google Ads), paid social (Meta),
|
|
42
|
+
Amazon PPC, technical SEO, content SEO, supply-path verification, content operations. No model,
|
|
43
|
+
no key, propose-only.
|
|
44
|
+
- **Model-backed seats** run on an AI CLI *you* already have and are logged into — your
|
|
45
|
+
subscription, no API key, nobody is billed through Weftens. They are only as good as the model
|
|
46
|
+
you point them at. `claude` and `codex` are verified; `gemini` and `grok` are configured but have
|
|
47
|
+
never been run.
|
|
48
|
+
- **Two seats are not in this repo.** `richy` (representation) and `pio` (prospect research) are
|
|
49
|
+
clients for separate services that aren't open source. Without those services configured they
|
|
50
|
+
return sample data that says so in its own output.
|
|
51
|
+
- **The content pipeline is a placeholder.** Ten seats exist; it emits stub output and does not yet
|
|
52
|
+
take a free-text brief.
|
|
53
|
+
|
|
54
|
+
Free, MIT licensed, no account, no billing.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
git clone git@github.com:Weftens/weftens.git
|
|
60
|
+
cd weftens
|
|
61
|
+
npm install
|
|
62
|
+
npm test
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That's the whole install. Everything lives in this one repo — the deterministic auditors
|
|
66
|
+
(`agent-cores/`), the agent seat definitions (`agents/`), and the content pipeline (`content-org/`).
|
|
67
|
+
A fresh clone runs on its own; there is no second checkout and no external directory. (Verified by
|
|
68
|
+
cloning to an unrelated path and running the suite there — the earlier version of this claim was
|
|
69
|
+
false: the end-to-end test resolved `agent-cores/` from the repo's *parent*, so it only passed on the
|
|
70
|
+
author's machine.)
|
|
71
|
+
|
|
72
|
+
Node 22+. `npm test` runs both suites: `test:unit` (vitest, the router/registry/CLI layer) and
|
|
73
|
+
`test:cores` (`node --test`, the deterministic auditors — they are zero-install by design so a single
|
|
74
|
+
core can be handed to someone who has nothing but Node).
|
|
75
|
+
|
|
76
|
+
For the model-backed agents, have a model CLI installed and logged in (e.g. `claude`) — that's your
|
|
77
|
+
own subscription, no key. The deterministic ad/SEO auditors need no model at all.
|
|
78
|
+
|
|
79
|
+
## Use it as an SDK
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { weftens, route, invoke, REGISTRY } from "weftens";
|
|
83
|
+
|
|
84
|
+
// one call — route a request and run the chosen agent:
|
|
85
|
+
const { decision, outcome } = await weftens("audit our facebook ads", { inputFile: "meta.csv" });
|
|
86
|
+
console.log(decision.agent.name, outcome.result);
|
|
87
|
+
|
|
88
|
+
// or drive the pieces yourself:
|
|
89
|
+
const d = route({ text: "who are the funders for this grant" });
|
|
90
|
+
const o = await invoke(d.agent, { text: "..." });
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Run
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
node bin/weftens.js list # the agent directory + which have a deterministic core
|
|
97
|
+
node bin/weftens.js --route "<request>" # dry run: which agent it routes to, and the words it matched
|
|
98
|
+
node bin/weftens.js send "<request>" # route, then run
|
|
99
|
+
node bin/weftens.js send <agent> "<request>" # address one agent directly
|
|
100
|
+
node bin/weftens.js send <agent> --input report.csv # feed a file to a calculator agent
|
|
101
|
+
node bin/weftens.js send <agent> "<...>" --provider gemini # pick which model runs it
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
# paid media — PROPOSE-ONLY, never touches an account:
|
|
108
|
+
node bin/weftens.js send paid-search x --input google-ads-export.csv
|
|
109
|
+
node bin/weftens.js send meta-ads x --input meta-ads-export.csv
|
|
110
|
+
node bin/weftens.js send amazon-ppc x --input amazon-search-terms.csv
|
|
111
|
+
|
|
112
|
+
# a judgment call, run on your own model:
|
|
113
|
+
node bin/weftens.js send hilbert "will this tagline survive being repeated: ..."
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## What's inside
|
|
117
|
+
|
|
118
|
+
- **Deterministic auditors** (no model; run on an exported report; propose changes a human applies):
|
|
119
|
+
`paid-search` (Google Ads), `meta-ads` (Facebook/Instagram), `amazon-ppc`, `seo-technical`,
|
|
120
|
+
`seo-content`, `supply-verify`, `content-ops`.
|
|
121
|
+
- **Model-backed seats** (run on your AI CLI): the evaluation and marketing agents — `hilbert`,
|
|
122
|
+
`lippmann`, `agent2060`, `growth-marketing`, `social-voice`, `claims-content`, `security-writer`.
|
|
123
|
+
- **Content team**: the `content` lead runs the 10-seat media pipeline as one node.
|
|
124
|
+
- **Richy + PIO**: representation and operations agents with their own orchestrator/synthesizer —
|
|
125
|
+
`node bin/weftens.js route request.json`, or `weftens ask` / `weftens chat` for a conversation.
|
|
126
|
+
|
|
127
|
+
Routing is deterministic and explainable: a request matches an agent by its declared keywords, and the
|
|
128
|
+
CLI prints which words matched, so you always see why a request went where it did.
|
|
129
|
+
|
|
130
|
+
## Configuration
|
|
131
|
+
|
|
132
|
+
- `WEFTENS_PROVIDER` — which model CLI to use (default `claude`).
|
|
133
|
+
- `providers.json` (or `WEFTENS_PROVIDERS_FILE`) — map a provider name to its command, e.g.
|
|
134
|
+
`{ "providers": { "gemini": { "cmd": "gemini", "args": ["-p"] } } }`.
|
|
135
|
+
- `WEFTENS_WORKSPACE` — where `agent-cores` / `content-org` live (default: this repo's parent dir).
|
|
136
|
+
- `WEFTENS_AGENTS_DIR` — where the agent seat files live. Defaults to the copies bundled in this repo
|
|
137
|
+
(`agents/`), so a fresh clone runs self-contained; override to point at your own set.
|
|
138
|
+
|
|
139
|
+
## Honest status
|
|
140
|
+
|
|
141
|
+
- **Verified:** deterministic routing, all seven auditor cores end-to-end, both specialist legs
|
|
142
|
+
(Richy/PIO) through the door, and BYOM model-produce via `claude`. **171 tests pass — 67 unit/CLI
|
|
143
|
+
(vitest) + 104 core (`node --test`)**, confirmed in a fresh clone at an unrelated path, not just in
|
|
144
|
+
the author's workspace.
|
|
145
|
+
- **Richy and PIO are interfaces here, not implementations — read this before you install.**
|
|
146
|
+
Two of the seats (`richy`, representation intelligence; `pio`, prospect research and grants) are
|
|
147
|
+
clients for **separate services that are not part of this repo and are not open source**. What ships
|
|
148
|
+
here is the seat definition, the wire client, the orchestrator that joins their answers, and
|
|
149
|
+
fixtures. Point `WEFTENS_RICHY_URL`/`WEFTENS_RICHY_KEY` and the `WEFTENS_PIO_*` group at running
|
|
150
|
+
services and they work. Without those, they return **sample data that labels itself as sample data**
|
|
151
|
+
(`"sample": true`, and the headline says so) — so you can see the shape of the output, and you can
|
|
152
|
+
never mistake it for a finding about a real business. Everything else in this repo — all seven
|
|
153
|
+
deterministic auditors, the router, the CLI, the SDK — runs entirely on what you just cloned.
|
|
154
|
+
`weftens send richy --org "Acme Co" "audit our listings"` shows you exactly this.
|
|
155
|
+
- **The router refuses rather than guesses.** Generic words alone ("site", "listing", "people") no
|
|
156
|
+
longer dispatch — a confident wrong route in front of a customer costs more than a reprompt.
|
|
157
|
+
- **Licensed MIT.** Free to use, including commercially.
|
|
158
|
+
- **Published:** `npm i weftens` installs the real thing (`weftens@0.1.0`, MIT). Verified from the
|
|
159
|
+
registry by a clean third-party install — `npx weftens list` prints the registry, and a
|
|
160
|
+
deterministic core runs a real audit from inside `node_modules`. (`0.0.1` was an empty name-holder;
|
|
161
|
+
ignore it.)
|
|
162
|
+
- **Two providers verified, not one:** `claude` and `codex` both produce end-to-end through the
|
|
163
|
+
user's own subscription (codex confirmed 2026-07-19). `gemini` and `grok` remain config-only — the
|
|
164
|
+
shape is right, but neither CLI is installed here, so neither has been run. A missing or broken
|
|
165
|
+
provider degrades to a plain note; it never fabricates an answer.
|
|
166
|
+
- **Dry-run only:** the content pipeline produces placeholder output; real content generation is gated
|
|
167
|
+
on open decisions.
|
|
168
|
+
- **Propose-only:** every ad/SEO core proposes changes and writes nothing to a live account.
|
|
169
|
+
|
|
170
|
+
## Notes
|
|
171
|
+
|
|
172
|
+
**Weftens is the company AND this front door — one thing, not two.** Richy and PIO are two of the
|
|
173
|
+
agent seats behind it, not standalone products under a brand. Architecture and agent-wiring detail:
|
|
174
|
+
[`ARCHITECTURE.md`](ARCHITECTURE.md).
|
|
175
|
+
|
|
176
|
+
Invariants that must not break: each agent stays independently runnable; PIO's wire-shape
|
|
177
|
+
(`grantengine_session` cookie, `x-grantengine-*` headers) is frozen; the privacy spine — sanitized
|
|
178
|
+
exports, human review gates, receipts — is shared across every seat.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-cores/amazon-ppc",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Amazon PPC search-term optimizer. Deterministic: parse a search-term report + margin/target-ACOS, compute harvest→negate→promote→re-bid proposals. PROPOSE-ONLY. No LLM in the core.",
|
|
7
|
+
"bin": {
|
|
8
|
+
"amazon-ppc": "src/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test",
|
|
12
|
+
"ppc": "node src/cli.mjs"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Real CLI. Reads an Amazon search-term report (CSV/TSV/JSON) from a file path,
|
|
3
|
+
// computes the PROPOSE-ONLY change sheet, prints JSON on stdout and a one-line
|
|
4
|
+
// summary on stderr.
|
|
5
|
+
//
|
|
6
|
+
// amazon-ppc <report.csv> --target-acos 25 --margin 35
|
|
7
|
+
// amazon-ppc <report.json> --low-inventory "Auto - Garlic" --pretty
|
|
8
|
+
//
|
|
9
|
+
// Flags:
|
|
10
|
+
// --target-acos <n> target ACOS (percent or fraction)
|
|
11
|
+
// --margin <n> product contribution margin (percent or fraction)
|
|
12
|
+
// --neg-clicks <n> zero-order click threshold to negate (default 10)
|
|
13
|
+
// --neg-spend <n> zero-order spend threshold to negate (default 5)
|
|
14
|
+
// --low-inventory <c> campaign to suppress scaling on (repeatable)
|
|
15
|
+
// --pretty pretty-print JSON
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { optimize } from './optimize.mjs';
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const args = { _: [], lowInventory: [] };
|
|
22
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
23
|
+
const a = argv[i];
|
|
24
|
+
if (a === '--target-acos') args.targetAcos = Number(argv[++i]);
|
|
25
|
+
else if (a === '--margin') args.margin = Number(argv[++i]);
|
|
26
|
+
else if (a === '--neg-clicks') args.negClickThreshold = Number(argv[++i]);
|
|
27
|
+
else if (a === '--neg-spend') args.negSpendThreshold = Number(argv[++i]);
|
|
28
|
+
else if (a === '--low-inventory') args.lowInventory.push(argv[++i]);
|
|
29
|
+
else if (a === '--pretty') args.pretty = true;
|
|
30
|
+
else args._.push(a);
|
|
31
|
+
}
|
|
32
|
+
return args;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function main() {
|
|
36
|
+
const args = parseArgs(process.argv.slice(2));
|
|
37
|
+
const file = args._[0];
|
|
38
|
+
if (!file) {
|
|
39
|
+
console.error('Usage: amazon-ppc <report.csv|.json> [--target-acos 25] [--margin 35] [--low-inventory "Campaign"] [--pretty]');
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const text = readFileSync(file, 'utf8');
|
|
45
|
+
const cfg = {};
|
|
46
|
+
if (args.targetAcos !== undefined) cfg.targetAcos = args.targetAcos;
|
|
47
|
+
if (args.margin !== undefined) cfg.margin = args.margin;
|
|
48
|
+
if (args.negClickThreshold !== undefined) cfg.negClickThreshold = args.negClickThreshold;
|
|
49
|
+
if (args.negSpendThreshold !== undefined) cfg.negSpendThreshold = args.negSpendThreshold;
|
|
50
|
+
if (args.lowInventory.length) cfg.lowInventoryCampaigns = args.lowInventory;
|
|
51
|
+
|
|
52
|
+
const result = optimize(text, cfg);
|
|
53
|
+
|
|
54
|
+
console.error(
|
|
55
|
+
`amazon-ppc: ${result.totals.terms} terms, $${result.totals.spend} spend, ` +
|
|
56
|
+
`ACOS ${result.totals.acos ?? 'n/a'}% (target ${result.config.targetAcos}%) — ${result.summary.headline}`
|
|
57
|
+
);
|
|
58
|
+
console.log(JSON.stringify(result, null, args.pretty ? 2 : 0));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
main();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Deterministic Amazon PPC optimizer. No LLM. Given normalized search-term rows
|
|
2
|
+
// and an economics config (target ACOS derived from product margin), compute the
|
|
3
|
+
// harvest -> negate -> promote -> re-bid change sheet. PROPOSE-ONLY: every row is
|
|
4
|
+
// a proposal a human applies in the account, with the numbers attached.
|
|
5
|
+
//
|
|
6
|
+
// The whole thing is arithmetic and thresholds. Same input -> same output.
|
|
7
|
+
|
|
8
|
+
import { normalizeRows } from './parse.mjs';
|
|
9
|
+
|
|
10
|
+
const round2 = (n) => Math.round((n + Number.EPSILON) * 100) / 100;
|
|
11
|
+
const pct = (n) => round2(n * 100); // fraction -> percentage number
|
|
12
|
+
|
|
13
|
+
// A value like 25 (percent) or 0.25 (fraction) -> fraction.
|
|
14
|
+
function asFraction(v) {
|
|
15
|
+
if (v === undefined || v === null) return null;
|
|
16
|
+
const n = Number(v);
|
|
17
|
+
if (Number.isNaN(n)) return null;
|
|
18
|
+
return n > 1 ? n / 100 : n;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULTS = {
|
|
22
|
+
targetAcos: 0.25, // fraction; where a term's ACOS should land for profit
|
|
23
|
+
margin: null, // product contribution margin (fraction). Break-even ACOS = margin.
|
|
24
|
+
negClickThreshold: 10, // clicks with zero orders => repeat waste => negate
|
|
25
|
+
negSpendThreshold: 5, // OR spend (with zero orders) at/above this => negate
|
|
26
|
+
minPromoteOrders: 2, // orders needed to call a term a signal, not luck
|
|
27
|
+
minPromoteClicks: 10, // clicks needed before promotion is credible
|
|
28
|
+
minRebidClicks: 8, // clicks needed before we trust a re-bid
|
|
29
|
+
rebidDeadband: 0.1, // ignore bid changes smaller than +/-10%
|
|
30
|
+
maxRaise: 1.0, // cap a single-cycle bid increase to +100%
|
|
31
|
+
maxCut: 0.5, // cap a single-cycle bid decrease to -50%
|
|
32
|
+
promoteBidCap: 1.5, // starting exact bid capped to 1.5x current effective CPC
|
|
33
|
+
bidFloor: 0.02, // Amazon minimum bid
|
|
34
|
+
lowInventoryCampaigns: [], // campaigns near stock-out: suppress scaling proposals
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function resolveConfig(cfg = {}) {
|
|
38
|
+
const out = { ...DEFAULTS, ...cfg };
|
|
39
|
+
out.margin = asFraction(cfg.margin ?? out.margin);
|
|
40
|
+
// If only margin is given, target sits below break-even for actual profit.
|
|
41
|
+
if (cfg.targetAcos === undefined && out.margin != null) {
|
|
42
|
+
out.targetAcos = round2(out.margin * 0.75);
|
|
43
|
+
} else {
|
|
44
|
+
out.targetAcos = asFraction(out.targetAcos) ?? DEFAULTS.targetAcos;
|
|
45
|
+
}
|
|
46
|
+
out.breakEvenAcos = out.margin; // may be null if margin unknown
|
|
47
|
+
const lc = new Set((out.lowInventoryCampaigns || []).map((s) => String(s).toLowerCase()));
|
|
48
|
+
out.isLowInventory = (campaign) => lc.has(String(campaign).toLowerCase());
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isExactMatch(row) {
|
|
53
|
+
return /exact/i.test(row.matchType) || /exact/i.test(row.campaign);
|
|
54
|
+
}
|
|
55
|
+
function isAuto(row) {
|
|
56
|
+
return /auto/i.test(row.matchType) || /auto/i.test(row.campaign) || row.matchType === '' || row.matchType === '-';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function termAcos(row) {
|
|
60
|
+
return row.sales > 0 ? row.spend / row.sales : Infinity;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Effective per-click bid we observed. Prefer reported CPC, else spend/clicks.
|
|
64
|
+
function effectiveCpc(row) {
|
|
65
|
+
if (row.cpc > 0) return row.cpc;
|
|
66
|
+
return row.clicks > 0 ? row.spend / row.clicks : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The bid at which, at this term's observed revenue-per-click, ACOS == target.
|
|
70
|
+
// rev/click * targetAcos. This is the economic anchor for every bid decision.
|
|
71
|
+
function idealBid(row, targetAcos) {
|
|
72
|
+
const rpc = row.clicks > 0 ? row.sales / row.clicks : 0;
|
|
73
|
+
return rpc * targetAcos;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function optimize(input, cfg = {}) {
|
|
77
|
+
const config = resolveConfig(cfg);
|
|
78
|
+
const rows = Array.isArray(input) && typeof input[0] === 'object' && input[0]?.searchTerm !== undefined
|
|
79
|
+
? input
|
|
80
|
+
: normalizeRows(input);
|
|
81
|
+
|
|
82
|
+
const negatives = [];
|
|
83
|
+
const promotions = [];
|
|
84
|
+
const rebids = [];
|
|
85
|
+
const held = [];
|
|
86
|
+
|
|
87
|
+
const totals = { terms: 0, impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 };
|
|
88
|
+
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
totals.terms += 1;
|
|
91
|
+
totals.impressions += row.impressions;
|
|
92
|
+
totals.clicks += row.clicks;
|
|
93
|
+
totals.spend += round2(row.spend);
|
|
94
|
+
totals.sales += round2(row.sales);
|
|
95
|
+
totals.orders += row.orders;
|
|
96
|
+
|
|
97
|
+
const acos = termAcos(row);
|
|
98
|
+
const cpc = effectiveCpc(row);
|
|
99
|
+
const ideal = idealBid(row, config.targetAcos);
|
|
100
|
+
const lowInv = config.isLowInventory(row.campaign);
|
|
101
|
+
|
|
102
|
+
// 1. NEGATE — repeat waste: clicks/spend accrued, zero orders.
|
|
103
|
+
if (row.orders === 0 && (row.clicks >= config.negClickThreshold || row.spend >= config.negSpendThreshold)) {
|
|
104
|
+
negatives.push({
|
|
105
|
+
term: row.searchTerm,
|
|
106
|
+
campaign: row.campaign,
|
|
107
|
+
matchType: row.matchType || (isAuto(row) ? 'auto' : ''),
|
|
108
|
+
clicks: row.clicks,
|
|
109
|
+
spend: round2(row.spend),
|
|
110
|
+
orders: 0,
|
|
111
|
+
spendStopped: round2(row.spend),
|
|
112
|
+
reason: row.clicks >= config.negClickThreshold
|
|
113
|
+
? `${row.clicks} clicks, 0 orders (>= ${config.negClickThreshold}-click waste bar)`
|
|
114
|
+
: `$${round2(row.spend)} spent, 0 orders (>= $${config.negSpendThreshold} waste bar)`,
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2. PROMOTE — a converting non-exact winner isolated into its own exact campaign.
|
|
120
|
+
const meetsPromoteBar =
|
|
121
|
+
row.orders >= config.minPromoteOrders &&
|
|
122
|
+
row.clicks >= config.minPromoteClicks &&
|
|
123
|
+
acos <= config.targetAcos &&
|
|
124
|
+
!isExactMatch(row);
|
|
125
|
+
|
|
126
|
+
if (meetsPromoteBar) {
|
|
127
|
+
if (lowInv) {
|
|
128
|
+
held.push({
|
|
129
|
+
term: row.searchTerm,
|
|
130
|
+
campaign: row.campaign,
|
|
131
|
+
action: 'promote-suppressed',
|
|
132
|
+
reason: `winner (ACOS ${pct(acos)}%) but campaign flagged low inventory — do not scale into a stock-out`,
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const rpc = row.sales / row.clicks;
|
|
137
|
+
const cap = cpc > 0 ? cpc * config.promoteBidCap : ideal;
|
|
138
|
+
const capped = ideal > cap;
|
|
139
|
+
const startingBid = Math.max(config.bidFloor, round2(capped ? cap : ideal));
|
|
140
|
+
promotions.push({
|
|
141
|
+
term: row.searchTerm,
|
|
142
|
+
sourceCampaign: row.campaign,
|
|
143
|
+
sourceMatchType: row.matchType || (isAuto(row) ? 'auto' : ''),
|
|
144
|
+
clicks: row.clicks,
|
|
145
|
+
orders: row.orders,
|
|
146
|
+
sales: round2(row.sales),
|
|
147
|
+
acos: pct(acos),
|
|
148
|
+
newCampaign: `Exact - ${row.searchTerm}`,
|
|
149
|
+
newMatchType: 'exact',
|
|
150
|
+
startingBid,
|
|
151
|
+
capped,
|
|
152
|
+
negateInSource: isAuto(row),
|
|
153
|
+
marginMath:
|
|
154
|
+
`${row.orders} orders / ${row.clicks} clicks, $${round2(row.sales)} sales -> ` +
|
|
155
|
+
`$${round2(rpc)} rev/click x ${pct(config.targetAcos)}% target ACOS = $${round2(ideal)} ideal bid` +
|
|
156
|
+
(capped ? `, capped to ${config.promoteBidCap}x CPC ($${round2(cpc)}) = $${startingBid}` : ` = $${startingBid}`),
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3. RE-BID — converting term whose current CPC is off its economic ideal bid.
|
|
162
|
+
if (row.orders >= 1 && row.clicks >= config.minRebidClicks && cpc > 0 && ideal > 0) {
|
|
163
|
+
const rawRatio = ideal / cpc; // >1 underpaying (raise), <1 overpaying (cut)
|
|
164
|
+
const delta = Math.abs(rawRatio - 1);
|
|
165
|
+
if (delta >= config.rebidDeadband) {
|
|
166
|
+
const up = ideal > cpc;
|
|
167
|
+
if (up && lowInv) {
|
|
168
|
+
held.push({
|
|
169
|
+
term: row.searchTerm,
|
|
170
|
+
campaign: row.campaign,
|
|
171
|
+
action: 'raise-suppressed',
|
|
172
|
+
reason: `bid-up justified (ACOS ${pct(acos)}%) but campaign flagged low inventory — hold`,
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
let target = ideal;
|
|
177
|
+
let capped = false;
|
|
178
|
+
if (up) {
|
|
179
|
+
const ceiling = cpc * (1 + config.maxRaise);
|
|
180
|
+
if (target > ceiling) { target = ceiling; capped = true; }
|
|
181
|
+
} else {
|
|
182
|
+
const floorByCut = cpc * (1 - config.maxCut);
|
|
183
|
+
if (target < floorByCut) { target = floorByCut; capped = true; }
|
|
184
|
+
}
|
|
185
|
+
const newBid = Math.max(config.bidFloor, round2(target));
|
|
186
|
+
const oldBid = round2(cpc);
|
|
187
|
+
const overBreakEven = config.breakEvenAcos != null && acos > config.breakEvenAcos;
|
|
188
|
+
rebids.push({
|
|
189
|
+
term: row.searchTerm,
|
|
190
|
+
campaign: row.campaign,
|
|
191
|
+
matchType: row.matchType || (isExactMatch(row) ? 'exact' : ''),
|
|
192
|
+
clicks: row.clicks,
|
|
193
|
+
orders: row.orders,
|
|
194
|
+
acos: pct(acos),
|
|
195
|
+
currentCpc: oldBid,
|
|
196
|
+
oldBid,
|
|
197
|
+
newBid,
|
|
198
|
+
direction: up ? 'up' : 'down',
|
|
199
|
+
changePct: pct((newBid - oldBid) / oldBid),
|
|
200
|
+
capped,
|
|
201
|
+
why:
|
|
202
|
+
`ACOS ${pct(acos)}% vs ${pct(config.targetAcos)}% target; ideal bid $${round2(ideal)} ` +
|
|
203
|
+
`${up ? 'above' : 'below'} current $${oldBid} -> ${up ? 'raise' : 'cut'} to $${newBid}` +
|
|
204
|
+
(capped ? ` (capped at ${up ? '+' : '-'}${pct(up ? config.maxRaise : config.maxCut)}%)` : '') +
|
|
205
|
+
(overBreakEven ? ` [losing: ACOS above ${pct(config.breakEvenAcos)}% break-even]` : ''),
|
|
206
|
+
});
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Otherwise: hold. Not enough signal, or already on target.
|
|
212
|
+
held.push({
|
|
213
|
+
term: row.searchTerm,
|
|
214
|
+
campaign: row.campaign,
|
|
215
|
+
action: 'hold',
|
|
216
|
+
reason: row.orders === 0
|
|
217
|
+
? `${row.clicks} clicks, 0 orders — below the ${config.negClickThreshold}-click / $${config.negSpendThreshold} negate bar (watch)`
|
|
218
|
+
: `on target or insufficient click signal (${row.clicks} clicks)`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Deterministic ordering: biggest lever first, term as stable tie-breaker.
|
|
223
|
+
negatives.sort((a, b) => b.spendStopped - a.spendStopped || a.term.localeCompare(b.term));
|
|
224
|
+
promotions.sort((a, b) => b.sales - a.sales || a.term.localeCompare(b.term));
|
|
225
|
+
rebids.sort((a, b) => Math.abs(b.changePct) - Math.abs(a.changePct) || a.term.localeCompare(b.term));
|
|
226
|
+
held.sort((a, b) => a.term.localeCompare(b.term));
|
|
227
|
+
|
|
228
|
+
const accountAcos = totals.sales > 0 ? totals.spend / totals.sales : Infinity;
|
|
229
|
+
const estSpendStopped = round2(negatives.reduce((s, n) => s + n.spendStopped, 0));
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
config: {
|
|
233
|
+
targetAcos: pct(config.targetAcos),
|
|
234
|
+
margin: config.margin != null ? pct(config.margin) : null,
|
|
235
|
+
breakEvenAcos: config.breakEvenAcos != null ? pct(config.breakEvenAcos) : null,
|
|
236
|
+
negClickThreshold: config.negClickThreshold,
|
|
237
|
+
negSpendThreshold: config.negSpendThreshold,
|
|
238
|
+
minPromoteOrders: config.minPromoteOrders,
|
|
239
|
+
minPromoteClicks: config.minPromoteClicks,
|
|
240
|
+
lowInventoryCampaigns: config.lowInventoryCampaigns,
|
|
241
|
+
},
|
|
242
|
+
totals: {
|
|
243
|
+
...totals,
|
|
244
|
+
spend: round2(totals.spend),
|
|
245
|
+
sales: round2(totals.sales),
|
|
246
|
+
acos: totals.sales > 0 ? pct(accountAcos) : null,
|
|
247
|
+
},
|
|
248
|
+
summary: {
|
|
249
|
+
negatives: negatives.length,
|
|
250
|
+
estSpendStopped,
|
|
251
|
+
promotions: promotions.length,
|
|
252
|
+
rebids: rebids.length,
|
|
253
|
+
rebidsUp: rebids.filter((r) => r.direction === 'up').length,
|
|
254
|
+
rebidsDown: rebids.filter((r) => r.direction === 'down').length,
|
|
255
|
+
held: held.length,
|
|
256
|
+
headline: buildHeadline({ negatives, promotions, rebids, estSpendStopped }),
|
|
257
|
+
},
|
|
258
|
+
negatives,
|
|
259
|
+
promotions,
|
|
260
|
+
rebids,
|
|
261
|
+
held,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildHeadline({ negatives, promotions, rebids, estSpendStopped }) {
|
|
266
|
+
const parts = [];
|
|
267
|
+
if (negatives.length) parts.push(`${negatives.length} negatives (~$${estSpendStopped} waste stopped)`);
|
|
268
|
+
if (promotions.length) parts.push(`${promotions.length} winners to promote`);
|
|
269
|
+
if (rebids.length) parts.push(`${rebids.length} re-bids`);
|
|
270
|
+
if (parts.length === 0) return 'No proposed changes — every term is on target or below signal thresholds.';
|
|
271
|
+
return `PROPOSE-ONLY change sheet: ${parts.join(', ')}.`;
|
|
272
|
+
}
|