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,303 @@
|
|
|
1
|
+
// Deterministic Google Ads paid-search analysis. No LLM. PROPOSE-ONLY.
|
|
2
|
+
//
|
|
3
|
+
// Input: parsed search-terms rows and campaign-performance rows (canonical fields
|
|
4
|
+
// from parse.mjs). Output: a ranked change sheet. Every row carries the evidence
|
|
5
|
+
// numbers that triggered it. The core never calls a model and never touches an
|
|
6
|
+
// account — a human applies the proposed changes.
|
|
7
|
+
//
|
|
8
|
+
// Three finding families, mirroring the job:
|
|
9
|
+
// negative — search terms burning spend with no/poor return (add as negatives)
|
|
10
|
+
// budget — converting campaigns throttled by budget (raise / reallocate)
|
|
11
|
+
// structure — root-cause fixes (starved Smart Bidding, shared-budget starvation)
|
|
12
|
+
// that outrank the budget moves they explain.
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_THRESHOLDS = {
|
|
15
|
+
// Negatives (irrelevant / zero-conversion queries).
|
|
16
|
+
negMinClicks: 15, // enough traffic that "0 conversions" is signal, not noise
|
|
17
|
+
negMinCost: 1, // ignore trivial spend
|
|
18
|
+
// Negatives (inefficient: converting but far above account CPA).
|
|
19
|
+
effCpaMultiple: 3, // term CPA >= 3x account CPA
|
|
20
|
+
effMinCost: 20,
|
|
21
|
+
// Budget moves.
|
|
22
|
+
lostIsBudgetMin: 0.1, // >=10% eligible impressions lost to budget
|
|
23
|
+
lostIsBudgetStrangled: 0.3, // >=30% => campaign is being strangled
|
|
24
|
+
// Structure: Smart Bidding conversion floor (Google rule of thumb ~15-30/30d).
|
|
25
|
+
biddingMinConv: 30,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const SMART_BIDDING = /target\s*cpa|troas|target\s*roas|maximize\s*conv|maximise\s*conv/i;
|
|
29
|
+
|
|
30
|
+
function sum(arr, f) {
|
|
31
|
+
let t = 0;
|
|
32
|
+
for (const x of arr) { const v = f(x); if (Number.isFinite(v)) t += v; }
|
|
33
|
+
return t;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function round(n, dp = 2) {
|
|
37
|
+
if (!Number.isFinite(n)) return n;
|
|
38
|
+
const m = 10 ** dp;
|
|
39
|
+
return Math.round(n * m) / m;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Impression-share opportunity math for a budget-throttled campaign.
|
|
43
|
+
// eligible = impressions / searchIS ; impressions lost to budget = eligible * lostISBudget.
|
|
44
|
+
// Additional clicks/conv/cost/value scale off the campaign's own CTR / CVR / CPC / value.
|
|
45
|
+
// This is an UPPER bound: CTR and CVR usually soften as volume grows.
|
|
46
|
+
function budgetOpportunity(c, thresholds) {
|
|
47
|
+
const clicks = c.clicks || 0;
|
|
48
|
+
const cost = c.cost || 0;
|
|
49
|
+
const conv = c.conversions || 0;
|
|
50
|
+
const value = c.convValue || 0;
|
|
51
|
+
const is = c.searchIS;
|
|
52
|
+
const lost = c.lostISBudget || 0;
|
|
53
|
+
|
|
54
|
+
// scaleFactor = how much more share we could capture relative to what we capture now.
|
|
55
|
+
// Prefer the direct impression math when searchIS is known; else scale by share ratio.
|
|
56
|
+
let scaleFactor;
|
|
57
|
+
if (Number.isFinite(is) && is > 0) {
|
|
58
|
+
scaleFactor = lost / is;
|
|
59
|
+
} else {
|
|
60
|
+
scaleFactor = lost / Math.max(1 - lost, 0.01);
|
|
61
|
+
}
|
|
62
|
+
const addClicks = clicks * scaleFactor;
|
|
63
|
+
const cvr = clicks > 0 ? conv / clicks : 0;
|
|
64
|
+
const cpc = clicks > 0 ? cost / clicks : 0;
|
|
65
|
+
const valuePerConv = conv > 0 ? value / conv : 0;
|
|
66
|
+
const addConv = addClicks * cvr;
|
|
67
|
+
const addCost = addClicks * cpc;
|
|
68
|
+
const addValue = addConv * valuePerConv;
|
|
69
|
+
return {
|
|
70
|
+
scaleFactor: round(scaleFactor, 4),
|
|
71
|
+
addClicks: Math.round(addClicks),
|
|
72
|
+
addConv: round(addConv, 1),
|
|
73
|
+
addCost: round(addCost, 2),
|
|
74
|
+
addValue: round(addValue, 2),
|
|
75
|
+
valuePerConv: round(valuePerConv, 2),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function analyze(input = {}, options = {}) {
|
|
80
|
+
const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds || {}) };
|
|
81
|
+
const searchTerms = Array.isArray(input.searchTerms) ? input.searchTerms : [];
|
|
82
|
+
const campaigns = Array.isArray(input.campaigns) ? input.campaigns : [];
|
|
83
|
+
|
|
84
|
+
// --- account-level aggregates from the search-terms report ---
|
|
85
|
+
const totalClicks = sum(searchTerms, (t) => t.clicks);
|
|
86
|
+
const totalCost = sum(searchTerms, (t) => t.cost);
|
|
87
|
+
const totalConv = sum(searchTerms, (t) => t.conversions);
|
|
88
|
+
const totalConvValue = sum(searchTerms, (t) => t.convValue);
|
|
89
|
+
const accountCVR = totalClicks > 0 ? totalConv / totalClicks : 0;
|
|
90
|
+
const accountCPA = totalConv > 0 ? totalCost / totalConv : null;
|
|
91
|
+
const accountValuePerConv = totalConv > 0 ? totalConvValue / totalConv : null;
|
|
92
|
+
|
|
93
|
+
const changes = [];
|
|
94
|
+
|
|
95
|
+
// --- 1. Negatives: irrelevant / zero-conversion queries ---
|
|
96
|
+
for (const t of searchTerms) {
|
|
97
|
+
const clicks = t.clicks || 0;
|
|
98
|
+
const cost = t.cost || 0;
|
|
99
|
+
const conv = t.conversions || 0;
|
|
100
|
+
if (conv === 0 && clicks >= thresholds.negMinClicks && cost >= thresholds.negMinCost) {
|
|
101
|
+
const expectedConv = round(clicks * accountCVR, 1);
|
|
102
|
+
changes.push({
|
|
103
|
+
type: 'negative',
|
|
104
|
+
subtype: 'irrelevant',
|
|
105
|
+
change: `Add "${t.searchTerm}" as a negative keyword`,
|
|
106
|
+
where: t.campaign ? `campaign: ${t.campaign}${t.adGroup ? ` / ${t.adGroup}` : ''}` : 'account level',
|
|
107
|
+
why: `${clicks} clicks, $${round(cost)} spent, 0 conversions`
|
|
108
|
+
+ (accountCVR > 0 ? ` (expected ~${expectedConv} at the ${round(accountCVR * 100, 1)}% account conversion rate)` : ''),
|
|
109
|
+
expectedEffect: `Recover ~$${round(cost)}/period of wasted spend`,
|
|
110
|
+
risk: 'If the query is actually relevant, you forfeit those impressions — confirm intent before excluding.',
|
|
111
|
+
dollars: round(cost),
|
|
112
|
+
evidence: {
|
|
113
|
+
searchTerm: t.searchTerm, campaign: t.campaign ?? null,
|
|
114
|
+
clicks, cost: round(cost), conversions: conv, expectedConv,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
} else if (
|
|
118
|
+
conv > 0 && accountCPA != null && cost >= thresholds.effMinCost
|
|
119
|
+
&& cost / conv >= thresholds.effCpaMultiple * accountCPA
|
|
120
|
+
) {
|
|
121
|
+
// inefficient: converting, but paying far above the account's cost per conversion
|
|
122
|
+
const termCpa = cost / conv;
|
|
123
|
+
const overspend = cost - conv * accountCPA;
|
|
124
|
+
changes.push({
|
|
125
|
+
type: 'negative',
|
|
126
|
+
subtype: 'inefficient',
|
|
127
|
+
change: `Bid down or restrict "${t.searchTerm}" (or add as negative if off-intent)`,
|
|
128
|
+
where: t.campaign ? `campaign: ${t.campaign}${t.adGroup ? ` / ${t.adGroup}` : ''}` : 'account level',
|
|
129
|
+
why: `CPA $${round(termCpa)} vs account $${round(accountCPA)} (${round(termCpa / accountCPA, 1)}x); `
|
|
130
|
+
+ `$${round(cost)} for ${round(conv, 1)} conv`,
|
|
131
|
+
expectedEffect: `Recover ~$${round(overspend)}/period of over-target spend`,
|
|
132
|
+
risk: 'Converting term — restricting too hard can cut real volume; prefer a bid cut first.',
|
|
133
|
+
dollars: round(overspend),
|
|
134
|
+
evidence: {
|
|
135
|
+
searchTerm: t.searchTerm, campaign: t.campaign ?? null,
|
|
136
|
+
cost: round(cost), conversions: round(conv, 1),
|
|
137
|
+
termCpa: round(termCpa), accountCPA: round(accountCPA),
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- 2 & 3. Campaign-level: budget moves and structure fixes ---
|
|
144
|
+
const byCampaign = new Map();
|
|
145
|
+
for (const c of campaigns) if (c.campaign) byCampaign.set(c.campaign, c);
|
|
146
|
+
|
|
147
|
+
// group campaigns by shared budget pool for structure analysis
|
|
148
|
+
const pools = new Map();
|
|
149
|
+
for (const c of campaigns) {
|
|
150
|
+
if (!c.sharedBudget) continue;
|
|
151
|
+
if (!pools.has(c.sharedBudget)) pools.set(c.sharedBudget, []);
|
|
152
|
+
pools.get(c.sharedBudget).push(c);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// maps campaign -> the largest related budget-opportunity dollars (for structure ranking)
|
|
156
|
+
const budgetDollarsByCampaign = new Map();
|
|
157
|
+
|
|
158
|
+
for (const c of campaigns) {
|
|
159
|
+
const conv = c.conversions || 0;
|
|
160
|
+
const lost = c.lostISBudget || 0;
|
|
161
|
+
if (lost >= thresholds.lostIsBudgetMin && conv > 0) {
|
|
162
|
+
const opp = budgetOpportunity(c, thresholds);
|
|
163
|
+
const strangled = lost >= thresholds.lostIsBudgetStrangled;
|
|
164
|
+
const dollars = opp.addValue > 0 ? opp.addValue
|
|
165
|
+
: (accountCPA != null ? round(opp.addConv * accountCPA, 2) : opp.addConv);
|
|
166
|
+
budgetDollarsByCampaign.set(
|
|
167
|
+
c.campaign,
|
|
168
|
+
Math.max(budgetDollarsByCampaign.get(c.campaign) || 0, dollars),
|
|
169
|
+
);
|
|
170
|
+
changes.push({
|
|
171
|
+
type: 'budget',
|
|
172
|
+
subtype: strangled ? 'strangled' : 'throttled',
|
|
173
|
+
change: strangled
|
|
174
|
+
? `Raise budget (or reallocate to) "${c.campaign}" — it is being strangled`
|
|
175
|
+
: `Raise budget for "${c.campaign}"`,
|
|
176
|
+
where: `campaign: ${c.campaign}`,
|
|
177
|
+
why: `${round(lost * 100, 1)}% of eligible impressions lost to budget, `
|
|
178
|
+
+ `${round(conv, 1)} conversions at ${Number.isFinite(c.searchIS) ? round(c.searchIS * 100, 1) + '% impr. share' : 'unknown share'}`,
|
|
179
|
+
expectedEffect: `~+${opp.addClicks} clicks, +${opp.addConv} conv`
|
|
180
|
+
+ (opp.addValue > 0 ? `, +$${opp.addValue} value` : '')
|
|
181
|
+
+ ` at ~$${opp.addCost} more spend (upper bound)`,
|
|
182
|
+
risk: 'Scaling assumes current CTR/CVR hold as volume grows; they usually soften. Treat as a ceiling.',
|
|
183
|
+
dollars,
|
|
184
|
+
evidence: {
|
|
185
|
+
campaign: c.campaign, lostISBudget: round(lost, 4),
|
|
186
|
+
searchIS: Number.isFinite(c.searchIS) ? round(c.searchIS, 4) : null,
|
|
187
|
+
conversions: round(conv, 1), ...opp,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Structure: Smart Bidding starved of conversion signal
|
|
194
|
+
for (const c of campaigns) {
|
|
195
|
+
const strat = c.biddingStrategy || '';
|
|
196
|
+
const conv = c.conversions || 0;
|
|
197
|
+
if (SMART_BIDDING.test(strat) && conv < thresholds.biddingMinConv) {
|
|
198
|
+
const atRisk = round(c.cost || 0);
|
|
199
|
+
const related = budgetDollarsByCampaign.get(c.campaign) || 0;
|
|
200
|
+
changes.push({
|
|
201
|
+
type: 'structure',
|
|
202
|
+
subtype: 'smart-bidding-starved',
|
|
203
|
+
change: `Fix bidding on "${c.campaign}" before touching budget — "${strat}" is starved of conversion signal`,
|
|
204
|
+
where: `campaign: ${c.campaign}`,
|
|
205
|
+
why: `${round(conv, 1)} conversions in the period is below the ~${thresholds.biddingMinConv}/30d Smart Bidding needs; `
|
|
206
|
+
+ `$${atRisk} of spend is governed by a mis-calibrated bid strategy`,
|
|
207
|
+
expectedEffect: 'Consolidate conversion volume or switch to Maximize Clicks / manual until signal is sufficient; pacing issues here usually resolve once bidding has data',
|
|
208
|
+
risk: 'Bid-strategy changes trigger a new learning window; expect 1-2 weeks of unstable performance.',
|
|
209
|
+
dollars: atRisk,
|
|
210
|
+
// rank at/above any budget move on the same campaign (structure outranks budget)
|
|
211
|
+
rankValue: Math.max(atRisk, related + 0.01),
|
|
212
|
+
evidence: {
|
|
213
|
+
campaign: c.campaign, biddingStrategy: strat,
|
|
214
|
+
conversions: round(conv, 1), cost: atRisk, floor: thresholds.biddingMinConv,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Structure: shared budget pool starving converting campaigns
|
|
221
|
+
for (const [poolName, members] of pools) {
|
|
222
|
+
if (members.length < 2) continue;
|
|
223
|
+
const throttled = members.filter((c) => (c.lostISBudget || 0) >= thresholds.lostIsBudgetMin && (c.conversions || 0) > 0);
|
|
224
|
+
if (throttled.length === 0) continue;
|
|
225
|
+
let poolDollars = 0;
|
|
226
|
+
const detail = [];
|
|
227
|
+
for (const c of throttled) {
|
|
228
|
+
const opp = budgetOpportunity(c, thresholds);
|
|
229
|
+
const d = opp.addValue > 0 ? opp.addValue
|
|
230
|
+
: (accountCPA != null ? round(opp.addConv * accountCPA, 2) : opp.addConv);
|
|
231
|
+
poolDollars += d;
|
|
232
|
+
detail.push({ campaign: c.campaign, lostISBudget: round(c.lostISBudget || 0, 4), addConv: opp.addConv, addValue: opp.addValue });
|
|
233
|
+
}
|
|
234
|
+
poolDollars = round(poolDollars, 2);
|
|
235
|
+
changes.push({
|
|
236
|
+
type: 'structure',
|
|
237
|
+
subtype: 'shared-budget-starve',
|
|
238
|
+
change: `Split or resize shared budget "${poolName}" — one pool is starving ${throttled.length} converting campaign(s)`,
|
|
239
|
+
where: `shared budget: ${poolName} (${members.map((m) => m.campaign).join(', ')})`,
|
|
240
|
+
why: `${throttled.length} converting campaign(s) losing impression share to a single shared pool: `
|
|
241
|
+
+ throttled.map((c) => `${c.campaign} ${round((c.lostISBudget || 0) * 100, 1)}%`).join(', '),
|
|
242
|
+
expectedEffect: `Give throttled campaigns their own budgets; unlocks ~$${poolDollars} of value (aggregate upper bound)`,
|
|
243
|
+
risk: 'Splitting a pool removes automatic cross-campaign smoothing; monitor the others do not now underspend.',
|
|
244
|
+
dollars: poolDollars,
|
|
245
|
+
rankValue: poolDollars, // aggregate root cause, already large
|
|
246
|
+
evidence: { pool: poolName, members: members.map((m) => m.campaign), throttled: detail },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- rank the change sheet ---
|
|
251
|
+
// Primary: dollars of impact (rankValue lets structure sit at/above related budget moves).
|
|
252
|
+
// Tiebreak: type (structure < negative < budget), then a stable string key.
|
|
253
|
+
const typeRank = { structure: 0, negative: 1, budget: 2 };
|
|
254
|
+
const keyed = changes.map((c, i) => ({ ...c, rankValue: Number.isFinite(c.rankValue) ? c.rankValue : (c.dollars || 0), _i: i }));
|
|
255
|
+
keyed.sort((a, b) => {
|
|
256
|
+
if (b.rankValue !== a.rankValue) return b.rankValue - a.rankValue;
|
|
257
|
+
if (typeRank[a.type] !== typeRank[b.type]) return typeRank[a.type] - typeRank[b.type];
|
|
258
|
+
const ka = `${a.type}:${a.subtype}:${a.change}`;
|
|
259
|
+
const kb = `${b.type}:${b.subtype}:${b.change}`;
|
|
260
|
+
return ka < kb ? -1 : ka > kb ? 1 : a._i - b._i;
|
|
261
|
+
});
|
|
262
|
+
const ranked = keyed.map(({ _i, ...c }, idx) => ({ rank: idx + 1, ...c }));
|
|
263
|
+
|
|
264
|
+
const recoverable = round(
|
|
265
|
+
sum(ranked.filter((c) => c.type === 'negative'), (c) => c.dollars),
|
|
266
|
+
2,
|
|
267
|
+
);
|
|
268
|
+
const opportunity = round(
|
|
269
|
+
sum(ranked.filter((c) => c.type === 'budget'), (c) => c.dollars),
|
|
270
|
+
2,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
proposeOnly: true,
|
|
275
|
+
account: {
|
|
276
|
+
searchTermRows: searchTerms.length,
|
|
277
|
+
campaignRows: campaigns.length,
|
|
278
|
+
totalClicks,
|
|
279
|
+
totalCost: round(totalCost),
|
|
280
|
+
totalConversions: round(totalConv, 1),
|
|
281
|
+
accountCVR: round(accountCVR, 4),
|
|
282
|
+
accountCPA: accountCPA == null ? null : round(accountCPA),
|
|
283
|
+
accountValuePerConv: accountValuePerConv == null ? null : round(accountValuePerConv),
|
|
284
|
+
},
|
|
285
|
+
counts: {
|
|
286
|
+
total: ranked.length,
|
|
287
|
+
negatives: ranked.filter((c) => c.type === 'negative').length,
|
|
288
|
+
budget: ranked.filter((c) => c.type === 'budget').length,
|
|
289
|
+
structure: ranked.filter((c) => c.type === 'structure').length,
|
|
290
|
+
},
|
|
291
|
+
wastedSpendRecoverable: recoverable,
|
|
292
|
+
budgetOpportunityValue: opportunity,
|
|
293
|
+
summary: buildSummary(ranked, recoverable, opportunity),
|
|
294
|
+
changes: ranked,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function buildSummary(ranked, recoverable, opportunity) {
|
|
299
|
+
if (ranked.length === 0) return 'No negative, budget, or structure changes found in the exported reports.';
|
|
300
|
+
const top = ranked[0];
|
|
301
|
+
return `${ranked.length} proposed change(s); ~$${recoverable} recoverable from negatives, `
|
|
302
|
+
+ `~$${opportunity} budget opportunity. Start with: ${top.change}.`;
|
|
303
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Real CLI. PROPOSE-ONLY — reads exported Google Ads reports, prints a ranked
|
|
3
|
+
// change sheet as JSON on stdout and a one-line summary on stderr. No account access.
|
|
4
|
+
//
|
|
5
|
+
// Modes:
|
|
6
|
+
// paid-search --search-terms <csv> [--campaigns <csv>] [--pretty]
|
|
7
|
+
// paid-search --campaigns <csv> [--search-terms <csv>]
|
|
8
|
+
// paid-search <input.json> JSON: { "searchTerms": [...], "campaigns": [...] }
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { analyze } from './analyze.mjs';
|
|
12
|
+
import { parseReport } from './parse.mjs';
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const args = { _: [] };
|
|
16
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
17
|
+
const a = argv[i];
|
|
18
|
+
if (a === '--search-terms' || a === '-s') args.searchTerms = argv[++i];
|
|
19
|
+
else if (a === '--campaigns' || a === '-c') args.campaigns = argv[++i];
|
|
20
|
+
else if (a === '--pretty') args.pretty = true;
|
|
21
|
+
else args._.push(a);
|
|
22
|
+
}
|
|
23
|
+
return args;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function main() {
|
|
27
|
+
const args = parseArgs(process.argv.slice(2));
|
|
28
|
+
|
|
29
|
+
if (!args.searchTerms && !args.campaigns && args._.length === 0) {
|
|
30
|
+
console.error('Usage: paid-search --search-terms <csv> [--campaigns <csv>] [--pretty]');
|
|
31
|
+
console.error(' paid-search <input.json> (keys: searchTerms, campaigns)');
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let input = { searchTerms: [], campaigns: [] };
|
|
37
|
+
|
|
38
|
+
if (args._.length > 0 && !args.searchTerms && !args.campaigns) {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(args._[0], 'utf8'));
|
|
40
|
+
input.searchTerms = parsed.searchTerms || [];
|
|
41
|
+
input.campaigns = parsed.campaigns || [];
|
|
42
|
+
} else {
|
|
43
|
+
if (args.searchTerms) input.searchTerms = parseReport(readFileSync(args.searchTerms, 'utf8'));
|
|
44
|
+
if (args.campaigns) input.campaigns = parseReport(readFileSync(args.campaigns, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = analyze(input);
|
|
48
|
+
console.error(`paid-search: ${result.summary}`);
|
|
49
|
+
console.log(JSON.stringify(result, null, args.pretty ? 2 : 0));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
main();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Dependency-free CSV parser + Google Ads column mapper.
|
|
2
|
+
// Real Google Ads UI exports carry a title row, a date-range row, sometimes a blank
|
|
3
|
+
// line, then the real header, the data rows, and a trailing "Total: ..." row. We find
|
|
4
|
+
// the header by looking for a row that contains known column tokens, map arbitrary
|
|
5
|
+
// header spellings to canonical field names, and coerce currency/percent/number cells.
|
|
6
|
+
|
|
7
|
+
// --- CSV tokenizer (state machine; handles quotes, embedded commas, CRLF) ---
|
|
8
|
+
|
|
9
|
+
export function parseCsv(text) {
|
|
10
|
+
const rows = [];
|
|
11
|
+
let row = [];
|
|
12
|
+
let field = '';
|
|
13
|
+
let inQuotes = false;
|
|
14
|
+
const s = String(text).replace(/^/, ''); // strip BOM
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
17
|
+
const c = s[i];
|
|
18
|
+
if (inQuotes) {
|
|
19
|
+
if (c === '"') {
|
|
20
|
+
if (s[i + 1] === '"') { field += '"'; i += 1; }
|
|
21
|
+
else inQuotes = false;
|
|
22
|
+
} else {
|
|
23
|
+
field += c;
|
|
24
|
+
}
|
|
25
|
+
} else if (c === '"') {
|
|
26
|
+
inQuotes = true;
|
|
27
|
+
} else if (c === ',') {
|
|
28
|
+
row.push(field); field = '';
|
|
29
|
+
} else if (c === '\n') {
|
|
30
|
+
row.push(field); field = '';
|
|
31
|
+
rows.push(row); row = [];
|
|
32
|
+
} else if (c === '\r') {
|
|
33
|
+
// handled by \n; ignore lone CR
|
|
34
|
+
} else {
|
|
35
|
+
field += c;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// flush last field/row if any content
|
|
39
|
+
if (field.length > 0 || row.length > 0) {
|
|
40
|
+
row.push(field);
|
|
41
|
+
rows.push(row);
|
|
42
|
+
}
|
|
43
|
+
return rows;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- header normalization + alias map ---
|
|
47
|
+
|
|
48
|
+
function normHeader(h) {
|
|
49
|
+
return String(h).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// canonical field -> the normalized header spellings that map to it
|
|
53
|
+
const ALIASES = {
|
|
54
|
+
searchTerm: ['search term', 'search terms', 'query'],
|
|
55
|
+
campaign: ['campaign', 'campaign name'],
|
|
56
|
+
adGroup: ['ad group', 'ad group name'],
|
|
57
|
+
matchType: ['match type', 'search term match type'],
|
|
58
|
+
impressions: ['impr', 'impressions', 'impr '],
|
|
59
|
+
clicks: ['clicks'],
|
|
60
|
+
cost: ['cost', 'spend'],
|
|
61
|
+
conversions: ['conversions', 'conv', 'conversions all', 'all conv'],
|
|
62
|
+
convValue: ['conv value', 'conversion value', 'all conv value', 'total conv value', 'conv value all'],
|
|
63
|
+
searchIS: ['search impr share', 'search impression share', 'impr share', 'search is'],
|
|
64
|
+
lostISBudget: [
|
|
65
|
+
'search lost is budget', 'search lost impr share budget', 'budget lost is',
|
|
66
|
+
'search lost impression share budget', 'lost is budget',
|
|
67
|
+
],
|
|
68
|
+
lostISRank: ['search lost is rank', 'search lost impr share rank', 'lost is rank'],
|
|
69
|
+
biddingStrategy: ['bid strategy type', 'bidding strategy', 'bid strategy'],
|
|
70
|
+
budget: ['budget', 'daily budget', 'budget amount'],
|
|
71
|
+
sharedBudget: ['budget name', 'shared budget', 'budget name shared'],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// reverse lookup: normalized header -> canonical
|
|
75
|
+
const HEADER_TO_FIELD = (() => {
|
|
76
|
+
const m = new Map();
|
|
77
|
+
for (const [field, spellings] of Object.entries(ALIASES)) {
|
|
78
|
+
for (const sp of spellings) m.set(sp, field);
|
|
79
|
+
}
|
|
80
|
+
return m;
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
const SHARE_FIELDS = new Set(['searchIS', 'lostISBudget', 'lostISRank']);
|
|
84
|
+
const NUMERIC_FIELDS = new Set(['impressions', 'clicks', 'cost', 'conversions', 'convValue', 'budget']);
|
|
85
|
+
|
|
86
|
+
// --- cell coercion ---
|
|
87
|
+
|
|
88
|
+
export function toNumber(raw) {
|
|
89
|
+
if (raw == null) return null;
|
|
90
|
+
let v = String(raw).trim();
|
|
91
|
+
if (v === '' || v === '--' || v === '—' || v.toLowerCase() === 'n/a') return null;
|
|
92
|
+
v = v.replace(/[$£€,%\s"]/g, '');
|
|
93
|
+
if (v === '' || v === '<' || v === '>') return null;
|
|
94
|
+
v = v.replace(/^[<>]/, ''); // "< 10" style already stripped of %
|
|
95
|
+
const n = Number.parseFloat(v);
|
|
96
|
+
return Number.isFinite(n) ? n : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Impression-share style cell -> fraction in [0,1]. Handles "35%", "0.35", "35",
|
|
100
|
+
// "< 10%" (Google's floor -> 0.10), "> 90%" (ceiling -> 0.90), "--".
|
|
101
|
+
export function toShare(raw) {
|
|
102
|
+
if (raw == null) return null;
|
|
103
|
+
const v = String(raw).trim();
|
|
104
|
+
if (v === '' || v === '--' || v === '—') return null;
|
|
105
|
+
const hadPercent = v.includes('%');
|
|
106
|
+
const n = toNumber(v);
|
|
107
|
+
if (n == null) return null;
|
|
108
|
+
let frac = hadPercent ? n / 100 : (n > 1.5 ? n / 100 : n);
|
|
109
|
+
if (frac < 0) frac = 0;
|
|
110
|
+
if (frac > 1) frac = 1;
|
|
111
|
+
return frac;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function coerce(field, raw) {
|
|
115
|
+
if (SHARE_FIELDS.has(field)) return toShare(raw);
|
|
116
|
+
if (NUMERIC_FIELDS.has(field)) return toNumber(raw);
|
|
117
|
+
const s = raw == null ? '' : String(raw).trim();
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Find the header row: the first row that maps at least `minKnown` cells to canonical fields.
|
|
122
|
+
function findHeader(rows, minKnown = 2) {
|
|
123
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
124
|
+
const mapped = rows[i].map((h) => HEADER_TO_FIELD.get(normHeader(h))).filter(Boolean);
|
|
125
|
+
if (new Set(mapped).size >= minKnown) return i;
|
|
126
|
+
}
|
|
127
|
+
return -1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Parse a Google Ads CSV export into canonical row objects.
|
|
131
|
+
export function parseReport(text) {
|
|
132
|
+
const rows = parseCsv(text);
|
|
133
|
+
const headerIdx = findHeader(rows);
|
|
134
|
+
if (headerIdx === -1) {
|
|
135
|
+
throw new Error('No recognizable Google Ads header row found (need e.g. "Campaign", "Cost", "Clicks").');
|
|
136
|
+
}
|
|
137
|
+
const header = rows[headerIdx];
|
|
138
|
+
const colField = header.map((h) => HEADER_TO_FIELD.get(normHeader(h)) || null);
|
|
139
|
+
|
|
140
|
+
const out = [];
|
|
141
|
+
for (let i = headerIdx + 1; i < rows.length; i += 1) {
|
|
142
|
+
const cells = rows[i];
|
|
143
|
+
// skip blank rows and Google's trailing "Total: ..." summary rows
|
|
144
|
+
const first = (cells[0] || '').trim();
|
|
145
|
+
if (cells.every((c) => (c || '').trim() === '')) continue;
|
|
146
|
+
if (/^total/i.test(first)) continue;
|
|
147
|
+
|
|
148
|
+
const rec = {};
|
|
149
|
+
for (let c = 0; c < colField.length; c += 1) {
|
|
150
|
+
const field = colField[c];
|
|
151
|
+
if (!field) continue;
|
|
152
|
+
rec[field] = coerce(field, cells[c]);
|
|
153
|
+
}
|
|
154
|
+
// require at least one identifying string so we don't emit garbage rows
|
|
155
|
+
if (rec.searchTerm || rec.campaign) out.push(rec);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Engine tests — run with `node --test`, zero install. Asserts the analyzer detects
|
|
2
|
+
// the SPECIFIC issues seeded into the fixtures and that the output is deterministic.
|
|
3
|
+
// If these pass, the core is doing real arithmetic on real reports, not echoing a prompt.
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { analyze } from '../src/analyze.mjs';
|
|
11
|
+
import { parseReport } from '../src/parse.mjs';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const load = (f) => parseReport(readFileSync(join(__dirname, 'fixtures', f), 'utf8'));
|
|
15
|
+
|
|
16
|
+
function run() {
|
|
17
|
+
return analyze({
|
|
18
|
+
searchTerms: load('search-terms.csv'),
|
|
19
|
+
campaigns: load('campaigns.csv'),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const bySub = (r, sub) => r.changes.filter((c) => c.subtype === sub);
|
|
24
|
+
const findTerm = (r, term) => r.changes.find((c) => c.evidence && c.evidence.searchTerm === term);
|
|
25
|
+
|
|
26
|
+
test('flags zero-conversion queries with enough clicks as negatives', () => {
|
|
27
|
+
const r = run();
|
|
28
|
+
const negs = bySub(r, 'irrelevant');
|
|
29
|
+
const terms = negs.map((n) => n.evidence.searchTerm).sort();
|
|
30
|
+
assert.deepEqual(terms, ['free blue widgets', 'what is a widget', 'widget repair career jobs']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('does NOT flag a zero-conv query below the click threshold', () => {
|
|
34
|
+
const r = run();
|
|
35
|
+
// "blue widget reviews" has only 5 clicks — must not appear
|
|
36
|
+
assert.equal(findTerm(r, 'blue widget reviews'), undefined);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('recovered spend on a negative equals the query cost', () => {
|
|
40
|
+
const r = run();
|
|
41
|
+
const free = findTerm(r, 'free blue widgets');
|
|
42
|
+
assert.equal(free.type, 'negative');
|
|
43
|
+
assert.equal(free.dollars, 90);
|
|
44
|
+
assert.equal(free.evidence.cost, 90);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('flags a converting-but-inefficient query (CPA >> account CPA)', () => {
|
|
48
|
+
const r = run();
|
|
49
|
+
const eff = bySub(r, 'inefficient');
|
|
50
|
+
assert.equal(eff.length, 1);
|
|
51
|
+
assert.equal(eff[0].evidence.searchTerm, 'premium widget wholesale bulk');
|
|
52
|
+
// account CPA = 910/11 = 82.73; overspend = 500 - 1*82.73 = 417.27
|
|
53
|
+
assert.ok(Math.abs(eff[0].dollars - 417.27) < 0.5, `overspend ~417.27, got ${eff[0].dollars}`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('flags a strangled converting campaign as a budget move with real opportunity math', () => {
|
|
57
|
+
const r = run();
|
|
58
|
+
const budget = r.changes.filter((c) => c.type === 'budget');
|
|
59
|
+
const generic = budget.find((c) => c.evidence.campaign === 'Generic');
|
|
60
|
+
assert.ok(generic, 'Generic budget move exists');
|
|
61
|
+
assert.equal(generic.subtype, 'strangled'); // 35% lost >= 30%
|
|
62
|
+
// scale = 0.35/0.45 = 0.7778; addClicks = 778; addConv = 778*0.04 = 31.1; value = *100 = 3111
|
|
63
|
+
assert.equal(generic.evidence.addClicks, 778);
|
|
64
|
+
assert.ok(Math.abs(generic.dollars - 3111.2) < 5, `~3111 value, got ${generic.dollars}`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('flags Smart Bidding starved of conversions as a STRUCTURE fix', () => {
|
|
68
|
+
const r = run();
|
|
69
|
+
const struct = bySub(r, 'smart-bidding-starved');
|
|
70
|
+
assert.equal(struct.length, 1);
|
|
71
|
+
assert.equal(struct[0].type, 'structure');
|
|
72
|
+
assert.equal(struct[0].evidence.campaign, 'Competitor');
|
|
73
|
+
assert.equal(struct[0].evidence.conversions, 6); // below the 30 floor
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('flags a shared budget pool starving converting campaigns as STRUCTURE', () => {
|
|
77
|
+
const r = run();
|
|
78
|
+
const pool = bySub(r, 'shared-budget-starve');
|
|
79
|
+
assert.equal(pool.length, 1);
|
|
80
|
+
assert.equal(pool[0].evidence.pool, 'Shared-1');
|
|
81
|
+
assert.deepEqual(pool[0].evidence.members.sort(), ['Competitor', 'Generic']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('structure outranks the budget move on the same campaign', () => {
|
|
85
|
+
const r = run();
|
|
86
|
+
const bidding = r.changes.find((c) => c.subtype === 'smart-bidding-starved');
|
|
87
|
+
const compBudget = r.changes.find((c) => c.type === 'budget' && c.evidence.campaign === 'Competitor');
|
|
88
|
+
assert.ok(bidding.rank < compBudget.rank, `structure (rank ${bidding.rank}) must precede budget (rank ${compBudget.rank})`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the change sheet is ranked by dollars of impact', () => {
|
|
92
|
+
const r = run();
|
|
93
|
+
const d = r.changes.map((c) => c.rankValue);
|
|
94
|
+
for (let i = 1; i < d.length; i += 1) {
|
|
95
|
+
assert.ok(d[i - 1] >= d[i], `rankValue must be non-increasing: ${d[i - 1]} < ${d[i]} at ${i}`);
|
|
96
|
+
}
|
|
97
|
+
// top finding is the aggregate shared-budget root cause
|
|
98
|
+
assert.equal(r.changes[0].subtype, 'shared-budget-starve');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('counts and totals reflect the seeded fixture exactly', () => {
|
|
102
|
+
const r = run();
|
|
103
|
+
assert.equal(r.counts.negatives, 4); // 3 irrelevant + 1 inefficient
|
|
104
|
+
assert.equal(r.counts.budget, 2);
|
|
105
|
+
assert.equal(r.counts.structure, 2);
|
|
106
|
+
assert.equal(r.account.totalConversions, 11);
|
|
107
|
+
assert.equal(r.proposeOnly, true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('output is deterministic — same input yields byte-identical JSON', () => {
|
|
111
|
+
const a = JSON.stringify(run());
|
|
112
|
+
const b = JSON.stringify(run());
|
|
113
|
+
assert.equal(a, b);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('empty input produces a clean, non-crashing result', () => {
|
|
117
|
+
const r = analyze({ searchTerms: [], campaigns: [] });
|
|
118
|
+
assert.equal(r.changes.length, 0);
|
|
119
|
+
assert.match(r.summary, /No negative, budget, or structure changes/);
|
|
120
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Campaign performance report
|
|
2
|
+
Jan 1, 2026 - Jan 31, 2026
|
|
3
|
+
Campaign,Impr.,Clicks,Cost,Conversions,Conv. value,Search impr. share,Search lost IS (budget),Bid strategy type,Budget name
|
|
4
|
+
Brand,10000,800,400.00,60,6000.00,82%,3%,Target CPA,Brand-Budget
|
|
5
|
+
Generic,20000,1000,2000.00,40,4000.00,45%,35%,Maximize conversions,Shared-1
|
|
6
|
+
Competitor,5000,200,600.00,6,600.00,30%,40%,Target CPA,Shared-1
|
|
7
|
+
Total: Campaigns,35000,2000,3000.00,106,10600.00,,,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"searchTerms": [
|
|
3
|
+
{ "searchTerm": "buy blue widgets", "campaign": "Generic", "clicks": 40, "cost": 120, "conversions": 6, "convValue": 600 },
|
|
4
|
+
{ "searchTerm": "free blue widgets", "campaign": "Generic", "clicks": 60, "cost": 90, "conversions": 0, "convValue": 0 }
|
|
5
|
+
],
|
|
6
|
+
"campaigns": [
|
|
7
|
+
{ "campaign": "Generic", "impressions": 20000, "clicks": 1000, "cost": 2000, "conversions": 40, "convValue": 4000, "searchIS": 0.45, "lostISBudget": 0.35, "biddingStrategy": "Maximize conversions" }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Search terms report
|
|
2
|
+
Jan 1, 2026 - Jan 31, 2026
|
|
3
|
+
Search term,Campaign,Ad group,Match type,Impr.,Clicks,Cost,Conversions,Conv. value
|
|
4
|
+
buy blue widgets,Generic,Widgets - Exact,Exact,500,40,120.00,6,600.00
|
|
5
|
+
blue widget store near me,Generic,Widgets - Phrase,Phrase,420,30,95.00,4,400.00
|
|
6
|
+
free blue widgets,Generic,Widgets - Broad,Broad,800,60,90.00,0,0.00
|
|
7
|
+
widget repair career jobs,Generic,Widgets - Broad,Broad,300,25,55.00,0,0.00
|
|
8
|
+
blue widget reviews,Generic,Widgets - Phrase,Phrase,200,5,8.00,0,0.00
|
|
9
|
+
premium widget wholesale bulk,Brand,Brand - Exact,Exact,400,30,500.00,1,100.00
|
|
10
|
+
what is a widget,Generic,Widgets - Broad,Broad,600,18,42.00,0,0.00
|
|
11
|
+
Total: Search terms,,,,3220,208,910.00,11,1200.00
|