@clize/clize 0.28.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +33 -14
- package/dist/cli.js.map +1 -1
- package/dist/core/seo.js +286 -45
- package/dist/core/seo.js.map +1 -1
- package/dist/remote.js +2 -1
- package/dist/remote.js.map +1 -1
- package/package.json +1 -1
- package/skills/clize-seo/SKILL.md +251 -53
package/dist/core/seo.js
CHANGED
|
@@ -7,8 +7,9 @@ import { readTraffic } from "./seo-traffic.js";
|
|
|
7
7
|
/** 对客加价:成本 × (1 + 4) = ×5。刻意不复用 platform.billing.markupPct(0.2)——
|
|
8
8
|
* 那是给域名 / 媒体等大额供给定的,套在美分级数据上会让定价失真(一次 serp 收 8 厘)。 */
|
|
9
9
|
export const SEO_MARKUP_PCT = 4;
|
|
10
|
-
/**
|
|
11
|
-
*
|
|
10
|
+
/** 租户月度上限的**出厂值**($5)。它是防意外累计的兜底,不是产品的定价上界 ——
|
|
11
|
+
* 用户随时可用任一条 seo 命令的 `--cap <usd>` 自助上调(`tenants.seo_budget_monthly_usd`)。
|
|
12
|
+
* 单域配额(seo_domains.budget_monthly_usd)沿用同一个出厂值,两堵墙各管各的。 */
|
|
12
13
|
export const SEO_BUDGET_DEFAULT_USD = 5;
|
|
13
14
|
/** 缓存 TTL:词/竞品指标月更口径 30 天;结果页时效短,7 天。 */
|
|
14
15
|
const TTL_DAYS = { keywords: 30, competitors: 30, serp: 7 };
|
|
@@ -40,7 +41,9 @@ export function chargedForSeo(upstreamCostUsd) {
|
|
|
40
41
|
return Number((upstreamCostUsd * (1 + SEO_MARKUP_PCT)).toFixed(4));
|
|
41
42
|
}
|
|
42
43
|
export function keywordBand(sv, kd) {
|
|
43
|
-
if (sv === null
|
|
44
|
+
if (sv === null)
|
|
45
|
+
return "no_data";
|
|
46
|
+
if (sv === 0)
|
|
44
47
|
return "no_volume";
|
|
45
48
|
if (kd === null)
|
|
46
49
|
return "stretch"; // 有量但上游没给 KD:落中档,不假装知道
|
|
@@ -50,8 +53,14 @@ export function keywordBand(sv, kd) {
|
|
|
50
53
|
return "stretch";
|
|
51
54
|
return "wall";
|
|
52
55
|
}
|
|
53
|
-
/** 行动优先级排序:能打的在前(低 KD 优先,同档大量优先)
|
|
54
|
-
const BAND_ORDER = {
|
|
56
|
+
/** 行动优先级排序:能打的在前(低 KD 优先,同档大量优先),无量的沉底,一无所知的最底。 */
|
|
57
|
+
const BAND_ORDER = {
|
|
58
|
+
attackable: 0,
|
|
59
|
+
stretch: 1,
|
|
60
|
+
wall: 2,
|
|
61
|
+
no_volume: 3,
|
|
62
|
+
no_data: 4,
|
|
63
|
+
};
|
|
55
64
|
// ---------------- 计费与预算 ----------------
|
|
56
65
|
/** 本月该租户(可选:限定 domain)的 seo 支出,口径 = 用户实付额。 */
|
|
57
66
|
export async function seoSpentThisMonth(db, tenantId, domain) {
|
|
@@ -65,26 +74,104 @@ export async function seoSpentThisMonth(db, tenantId, domain) {
|
|
|
65
74
|
.first();
|
|
66
75
|
return row?.s ?? 0;
|
|
67
76
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// ── 预算体系(0.29.0 重做;三处旧行为都被 2026-08-31 的 kunavo dogfood 打脸)
|
|
78
|
+
//
|
|
79
|
+
// ① **墙可以自助推**。旧的租户墙是代码常量 + 报错指路 "contact support" —— 一条死路,
|
|
80
|
+
// 而这是个自助产品。改成 `tenants.seo_budget_monthly_usd`,任何一条 seo 命令带
|
|
81
|
+
// `--cap <usd>` 即改(**带这个 flag 本身就是知情同意**,不再多一道 confirm)。
|
|
82
|
+
// 刻意**不**新开 `seo budget` 命令:它在被砍清单里(产品文档 §2.2),而"改一个上限"
|
|
83
|
+
// 不值得一条顶级命令 —— 挂在花钱的命令上,恰好也是撞墙时手边就有的那条路。
|
|
84
|
+
// ② **两堵墙都真的生效,配高的那个不架空另一个**。租户墙(所有 seo 花费)与单域配额
|
|
85
|
+
// (seo_domains.budget_monthly_usd,只管该域的 check)是两回事。旧实现里
|
|
86
|
+
// `--budget 15` 配在租户 $5 之下,读起来像 $15 可花,实际 $5 就被拦 —— 撒谎的配置。
|
|
87
|
+
// 现在两个数都返回给调用方,单域配额超过租户墙时明说「你会先撞租户墙」。
|
|
88
|
+
// ③ **闸前置到"这次要花的钱"上**。旧闸是 `spent < cap` 即放行:$4.99 时放进一次
|
|
89
|
+
// $1.20 的调用 → 实测 $6.17/$5.00,超额 23% 才拦。现在按 op 估一个上限,
|
|
90
|
+
// `spent + 估算 > cap` 就拒 —— **估算只用于闸,计费仍按响应里的真实 cost**。
|
|
91
|
+
/** 一次调用的**上游成本估算上限**(美元)。数字来自 2026-08-30 sandbox 实测
|
|
92
|
+
* (design/seo-geo-impl.md §9:keyword_overview 10 词 $0.01296 · ai_keyword_data 10 词
|
|
93
|
+
* $0.0110 · ranked_keywords limit=20 $0.0144 · bulk_traffic 3 域 $0.01236 ·
|
|
94
|
+
* serp depth=20 $0.0040 / depth=100 $0.020),各项取略高于实测的线性模型。
|
|
95
|
+
* **只用于前置闸**:估高一点会早拦一次,估低会让墙漏一点点 —— 前者可自助推墙,后者是旧 bug。 */
|
|
96
|
+
export function estimateSeoCost(op, n = {}) {
|
|
97
|
+
const kw = n.keywords ?? 0;
|
|
98
|
+
switch (op) {
|
|
99
|
+
case "keywords":
|
|
100
|
+
return 0.011 + 0.0002 * kw;
|
|
101
|
+
case "keywords+ai":
|
|
102
|
+
return 0.011 + 0.0002 * kw + (0.01 + 0.0001 * kw);
|
|
103
|
+
case "competitors": {
|
|
104
|
+
const d = n.domains ?? 1;
|
|
105
|
+
return 0.011 + 0.0005 * d + d * (0.011 + 0.0002 * (n.limit ?? RANKED_KEYWORDS_LIMIT));
|
|
106
|
+
}
|
|
107
|
+
case "serp":
|
|
108
|
+
return 0.0002 * (n.depth ?? SERP_DEPTH);
|
|
79
109
|
}
|
|
80
|
-
|
|
81
|
-
|
|
110
|
+
}
|
|
111
|
+
/** 读租户墙:tenants 列有值用它,没有(或没这行)用默认 $5。 */
|
|
112
|
+
export async function tenantSeoCap(db, tenantId) {
|
|
113
|
+
const row = await db
|
|
114
|
+
.prepare("SELECT seo_budget_monthly_usd AS cap FROM tenants WHERE id=?")
|
|
115
|
+
.bind(tenantId)
|
|
116
|
+
.first();
|
|
117
|
+
return row?.cap == null ? SEO_BUDGET_DEFAULT_USD : Number(row.cap);
|
|
118
|
+
}
|
|
119
|
+
/** 自助上调/下调租户墙。带 `--cap` 走到这里 —— 动作本身即知情同意,不再多一道闸。 */
|
|
120
|
+
export async function setTenantSeoCap(db, tenantId, usd) {
|
|
121
|
+
if (!Number.isFinite(usd) || usd < 0)
|
|
122
|
+
throw new ClizeError(`--cap must be a non-negative number of dollars, got "${usd}"`, 400);
|
|
123
|
+
const cap = Number(usd.toFixed(2));
|
|
124
|
+
// 租户行由注册流程建;这里只更新,不 upsert(没有行 = 没有这个租户,不该被一个 flag 造出来)。
|
|
125
|
+
// 改不到就报错,**绝不静默无视** —— 一个说了不算的 --cap 正是这次要修掉的那类撒谎配置。
|
|
126
|
+
const r = (await db
|
|
127
|
+
.prepare("UPDATE tenants SET seo_budget_monthly_usd=?, updated_at=? WHERE id=?")
|
|
128
|
+
.bind(cap, new Date().toISOString(), tenantId)
|
|
129
|
+
.run());
|
|
130
|
+
if (r?.meta?.changes === 0)
|
|
131
|
+
throw new ClizeError(`cannot set seo cap: no such tenant ${tenantId}`, 404);
|
|
132
|
+
return cap;
|
|
133
|
+
}
|
|
134
|
+
export async function seoBudgetState(db, tenantId) {
|
|
135
|
+
const capUsd = await tenantSeoCap(db, tenantId);
|
|
136
|
+
const spentThisMonth = Number((await seoSpentThisMonth(db, tenantId)).toFixed(4));
|
|
137
|
+
return {
|
|
138
|
+
capUsd,
|
|
139
|
+
spentThisMonth,
|
|
140
|
+
remainingUsd: Number(Math.max(0, capUsd - spentThisMonth).toFixed(4)),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** 撞墙时给的自助路径 —— 具体到可以直接跑的一行,不是 "contact support"。 */
|
|
144
|
+
function raiseHint(cap, needMore) {
|
|
145
|
+
const suggest = Math.max(Math.ceil(cap * 2), Math.ceil(cap + needMore + 1));
|
|
146
|
+
return `raise it yourself and re-run: add \`--cap ${suggest}\` to any \`clize seo\` command (passing --cap is the consent; it sets this tenant's monthly seo cap)`;
|
|
147
|
+
}
|
|
148
|
+
/** 预算闸(**前置**):这次要花的钱放不进剩余额度就拒,不等超支了才拦。
|
|
149
|
+
*
|
|
150
|
+
* 两层依次过:先租户墙(所有花费),再单域配额(有 domain 的路径)。
|
|
151
|
+
* `estimateUsd` = 本次上游成本估算(未加价);闸按对客价算 —— 用户看到的墙是对客金额。 */
|
|
152
|
+
export async function assertSeoBudget(ctx, db, opts = {}) {
|
|
153
|
+
const need = chargedForSeo(opts.estimateUsd ?? 0);
|
|
154
|
+
const cap = await tenantSeoCap(db, ctx.tenantId);
|
|
155
|
+
const spent = await seoSpentThisMonth(db, ctx.tenantId);
|
|
156
|
+
// 两个条件都要:已经吃满就拒(哪怕这次估算是 0),以及"这次要花的放不进剩余额度"就拒。
|
|
157
|
+
if (spent >= cap || spent + need > cap)
|
|
158
|
+
throw new ClizeError(`monthly seo cap reached: $${spent.toFixed(2)} spent of $${cap.toFixed(2)}` +
|
|
159
|
+
(need > 0 ? `, and this call needs about $${need.toFixed(2)}` : "") +
|
|
160
|
+
`. ${raiseHint(cap, need)}`, 429);
|
|
161
|
+
if (!opts.domain)
|
|
162
|
+
return;
|
|
163
|
+
const row = await db
|
|
164
|
+
.prepare("SELECT budget_monthly_usd FROM seo_domains WHERE domain=? AND tenant_id=?")
|
|
165
|
+
.bind(opts.domain, ctx.tenantId)
|
|
166
|
+
.first();
|
|
167
|
+
if (row?.budget_monthly_usd == null)
|
|
82
168
|
return;
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
169
|
+
const dCap = Number(row.budget_monthly_usd);
|
|
170
|
+
const dSpent = await seoSpentThisMonth(db, ctx.tenantId, opts.domain);
|
|
171
|
+
if (dSpent >= dCap || dSpent + need > dCap)
|
|
172
|
+
throw new ClizeError(`monthly seo budget reached for ${opts.domain}: $${dSpent.toFixed(2)} of $${dCap.toFixed(2)}` +
|
|
173
|
+
(need > 0 ? `, and this call needs about $${need.toFixed(2)}` : "") +
|
|
174
|
+
`. raise it with \`clize seo check --domain ${opts.domain} --budget ${Math.ceil(dCap * 2)}\``, 429);
|
|
88
175
|
}
|
|
89
176
|
/** 扣费 + 入账(缓存命中不扣、不入账,ledger 里就不会出现一条 $0 噪音行)。 */
|
|
90
177
|
async function chargeSeo(ctx, op, upstreamCostUsd, detail = {}) {
|
|
@@ -138,12 +225,19 @@ export async function keywords(ctx, db, opts) {
|
|
|
138
225
|
if (seeds.length > KEYWORDS_PER_CALL)
|
|
139
226
|
throw new ClizeError(`too many keywords (${seeds.length}); max ${KEYWORDS_PER_CALL} per call`, 400);
|
|
140
227
|
const { tag, codes } = resolveLocale(opts.locale);
|
|
228
|
+
if (opts.capUsd !== undefined)
|
|
229
|
+
await setTenantSeoCap(db, ctx.tenantId, opts.capUsd);
|
|
141
230
|
const op = opts.ai ? "keywords+ai" : "keywords";
|
|
142
231
|
const key = await cacheKey(op, seeds.slice().sort().join("|"), tag);
|
|
143
232
|
const cached = await readCache(db, ctx.tenantId, key, TTL_DAYS.keywords);
|
|
144
233
|
if (cached)
|
|
145
|
-
return {
|
|
146
|
-
|
|
234
|
+
return {
|
|
235
|
+
locale: tag,
|
|
236
|
+
keywords: cached,
|
|
237
|
+
charged: { usd: 0, cacheHit: true },
|
|
238
|
+
budget: await seoBudgetState(db, ctx.tenantId),
|
|
239
|
+
};
|
|
240
|
+
await assertSeoBudget(ctx, db, { estimateUsd: estimateSeoCost(op, { keywords: seeds.length }) });
|
|
147
241
|
const api = client(ctx);
|
|
148
242
|
const base = await api.keywordOverview(seeds, codes);
|
|
149
243
|
let cost = base.costUsd;
|
|
@@ -169,9 +263,10 @@ export async function keywords(ctx, db, opts) {
|
|
|
169
263
|
(b.sv ?? 0) - (a.sv ?? 0));
|
|
170
264
|
await writeCache(db, ctx.tenantId, key, rows, cost);
|
|
171
265
|
const charged = await chargeSeo(ctx, op, cost, { keywords: seeds.length, locale: tag });
|
|
172
|
-
return { locale: tag, keywords: rows, charged };
|
|
266
|
+
return { locale: tag, keywords: rows, charged, budget: await seoBudgetState(db, ctx.tenantId) };
|
|
173
267
|
}
|
|
174
|
-
/** 竞品反查:一次问多个域的流量画像 + 每个域靠什么词活。
|
|
268
|
+
/** 竞品反查:一次问多个域的流量画像 + 每个域靠什么词活。
|
|
269
|
+
* **这是命令面里唯一的词源命令**(反查出来的 topKeywords 就是候选种子);判读见 skill §6。 */
|
|
175
270
|
export async function competitors(ctx, db, opts) {
|
|
176
271
|
const domains = [...new Set(opts.domains.map(normalizeHost).filter(Boolean))];
|
|
177
272
|
if (!domains.length)
|
|
@@ -180,11 +275,20 @@ export async function competitors(ctx, db, opts) {
|
|
|
180
275
|
throw new ClizeError(`too many domains (${domains.length}); max 10`, 400);
|
|
181
276
|
const { tag, codes } = resolveLocale(opts.locale);
|
|
182
277
|
const limit = Math.min(Math.max(opts.limit ?? RANKED_KEYWORDS_LIMIT, 1), 100);
|
|
278
|
+
if (opts.capUsd !== undefined)
|
|
279
|
+
await setTenantSeoCap(db, ctx.tenantId, opts.capUsd);
|
|
183
280
|
const key = await cacheKey(`competitors:${limit}`, domains.slice().sort().join("|"), tag);
|
|
184
281
|
const cached = await readCache(db, ctx.tenantId, key, TTL_DAYS.competitors);
|
|
185
282
|
if (cached)
|
|
186
|
-
return {
|
|
187
|
-
|
|
283
|
+
return {
|
|
284
|
+
locale: tag,
|
|
285
|
+
competitors: cached,
|
|
286
|
+
charged: { usd: 0, cacheHit: true },
|
|
287
|
+
budget: await seoBudgetState(db, ctx.tenantId),
|
|
288
|
+
};
|
|
289
|
+
await assertSeoBudget(ctx, db, {
|
|
290
|
+
estimateUsd: estimateSeoCost("competitors", { domains: domains.length, limit }),
|
|
291
|
+
});
|
|
188
292
|
const api = client(ctx);
|
|
189
293
|
const traffic = await api.bulkTraffic(domains, codes);
|
|
190
294
|
let cost = traffic.costUsd;
|
|
@@ -211,7 +315,7 @@ export async function competitors(ctx, db, opts) {
|
|
|
211
315
|
rows.sort((a, b) => (b.etv ?? 0) - (a.etv ?? 0));
|
|
212
316
|
await writeCache(db, ctx.tenantId, key, rows, cost);
|
|
213
317
|
const charged = await chargeSeo(ctx, "competitors", cost, { domains: domains.length, locale: tag });
|
|
214
|
-
return { locale: tag, competitors: rows, charged };
|
|
318
|
+
return { locale: tag, competitors: rows, charged, budget: await seoBudgetState(db, ctx.tenantId) };
|
|
215
319
|
}
|
|
216
320
|
/** 结果页真相:自然结果 + 占位判定 + 寄生目标。 */
|
|
217
321
|
export async function serp(ctx, db, opts) {
|
|
@@ -220,11 +324,17 @@ export async function serp(ctx, db, opts) {
|
|
|
220
324
|
throw new ClizeError("no keyword given", 400);
|
|
221
325
|
const { tag, codes } = resolveLocale(opts.locale);
|
|
222
326
|
const depth = Math.min(Math.max(opts.depth ?? SERP_DEPTH, 10), 100);
|
|
327
|
+
if (opts.capUsd !== undefined)
|
|
328
|
+
await setTenantSeoCap(db, ctx.tenantId, opts.capUsd);
|
|
223
329
|
const key = await cacheKey(`serp:${depth}`, keyword, tag);
|
|
224
330
|
const cached = await readCache(db, ctx.tenantId, key, TTL_DAYS.serp);
|
|
225
331
|
if (cached)
|
|
226
|
-
return {
|
|
227
|
-
|
|
332
|
+
return {
|
|
333
|
+
...cached,
|
|
334
|
+
charged: { usd: 0, cacheHit: true },
|
|
335
|
+
budget: await seoBudgetState(db, ctx.tenantId),
|
|
336
|
+
};
|
|
337
|
+
await assertSeoBudget(ctx, db, { estimateUsd: estimateSeoCost("serp", { depth }) });
|
|
228
338
|
const { result: page, costUsd } = await client(ctx).serpLiveAdvanced(keyword, codes, depth);
|
|
229
339
|
const classified = classifySerp(keyword, page.items);
|
|
230
340
|
const payload = {
|
|
@@ -246,7 +356,27 @@ export async function serp(ctx, db, opts) {
|
|
|
246
356
|
};
|
|
247
357
|
await writeCache(db, ctx.tenantId, key, payload, costUsd);
|
|
248
358
|
const charged = await chargeSeo(ctx, "serp", costUsd, { keyword, locale: tag });
|
|
249
|
-
return { ...payload, charged };
|
|
359
|
+
return { ...payload, charged, budget: await seoBudgetState(db, ctx.tenantId) };
|
|
360
|
+
}
|
|
361
|
+
/** `pre_emergence` 要求的最低曝光:低于此量与噪声不可分(同 GSC 新词回填的阈值口径)。 */
|
|
362
|
+
const SIGNAL_MIN_IMPRESSIONS = 10;
|
|
363
|
+
/** `authority_limited` 要求的曝光量级 —— 少量曝光 + 零点击只是没人搜,不是授权度问题。 */
|
|
364
|
+
const AUTHORITY_LIMITED_MIN_IMPRESSIONS = 50;
|
|
365
|
+
/** `authority_limited` 的位次下界(GSC 均位次)。50+ 基本落在第 5 页之后,点击天然趋零。 */
|
|
366
|
+
const AUTHORITY_LIMITED_MIN_POSITION = 50;
|
|
367
|
+
/** `demand_no_surface` 要求的最低搜索量:量表报个位数的词本来就可能整月零曝光。 */
|
|
368
|
+
const DEMAND_NO_SURFACE_MIN_SV = 50;
|
|
369
|
+
/** 形态判定:纯函数、纯阈值,给定同样三个数字永远给同样的答案(agent 可自行复核)。 */
|
|
370
|
+
export function signalForm(a) {
|
|
371
|
+
if (a.position === null)
|
|
372
|
+
return a.sv !== null && a.sv >= DEMAND_NO_SURFACE_MIN_SV ? "demand_no_surface" : null;
|
|
373
|
+
if (a.sv === 0 && a.impressions >= SIGNAL_MIN_IMPRESSIONS)
|
|
374
|
+
return "pre_emergence";
|
|
375
|
+
if (a.clicks === 0 &&
|
|
376
|
+
a.impressions >= AUTHORITY_LIMITED_MIN_IMPRESSIONS &&
|
|
377
|
+
a.position >= AUTHORITY_LIMITED_MIN_POSITION)
|
|
378
|
+
return "authority_limited";
|
|
379
|
+
return null;
|
|
250
380
|
}
|
|
251
381
|
/** GSC 窗口重拉的天数:Google 会事后修正近日数据,固定重拉最近 N 天并替换。 */
|
|
252
382
|
const GSC_WINDOW_DAYS = 30;
|
|
@@ -305,11 +435,84 @@ async function upsertDomain(ctx, db, domain, cfg) {
|
|
|
305
435
|
}
|
|
306
436
|
async function readKeywords(db, domain) {
|
|
307
437
|
const { results } = await db
|
|
308
|
-
.prepare("SELECT keyword, sv, kd, source FROM seo_keywords WHERE domain=? ORDER BY id")
|
|
438
|
+
.prepare("SELECT keyword, locale, sv, kd, ai_sv, cpc, source, last_refreshed_at FROM seo_keywords WHERE domain=? ORDER BY id")
|
|
309
439
|
.bind(domain)
|
|
310
440
|
.all();
|
|
311
441
|
return results;
|
|
312
442
|
}
|
|
443
|
+
/** 词表指标多久算过期(与 keywords 命令的缓存 TTL 同口径:SV/KD 是月更的量)。 */
|
|
444
|
+
const METRICS_TTL_DAYS = TTL_DAYS.keywords;
|
|
445
|
+
/** 一轮 check 最多给多少个词补指标。上限存在只为拦住"回填炸了词表"这种意外
|
|
446
|
+
* ——正常一轮是几十个词、几美分;真要更大的词表就分几轮,或先跑 `seo keywords`。 */
|
|
447
|
+
const METRICS_PER_CHECK = 200;
|
|
448
|
+
/** `sv/kd/ai_sv/cpc/last_refreshed_at` 五列此前是死列(没有任何写入点),于是
|
|
449
|
+
* `byBand` 对任何词表都恒为 `{no_volume: N}` —— 恒真且误导(2026-08-31 线上首轮 check 暴露)。
|
|
450
|
+
* 出路选了产品文档 §12.2 的①:**check 顺带把缺指标的词批量取一次并写回**。
|
|
451
|
+
*
|
|
452
|
+
* 这让 check 从"恒免费"变成"有时花几美分",换来的是 byBand 有真实分布、
|
|
453
|
+
* 以及交叉层(§signals)有 sv 这一源可比 —— 没有它,check 只有 GSC 一个源,
|
|
454
|
+
* "三源并排"少一条腿。计费口径**沿用既有规则,不新造**:
|
|
455
|
+
* · 词表自己就是缓存 —— `last_refreshed_at` 在 30 天内的词不重取、不重复计费;
|
|
456
|
+
* · 未命中的按 `keywords` 端点如实入账(op="keywords",detail 带 domain)。
|
|
457
|
+
* 预算不够时**不中断 check**:跳过补指标、记 note —— GSC/流量两段是免费的,
|
|
458
|
+
* 不该被一次几美分的可选动作拖垮。 */
|
|
459
|
+
async function refreshKeywordMetrics(ctx, db, domain, rows, notes, now) {
|
|
460
|
+
const cutoff = now - METRICS_TTL_DAYS * DAY_MS;
|
|
461
|
+
const stale = rows.filter((r) => {
|
|
462
|
+
const t = r.last_refreshed_at ? Date.parse(r.last_refreshed_at) : NaN;
|
|
463
|
+
return !Number.isFinite(t) || t < cutoff;
|
|
464
|
+
});
|
|
465
|
+
if (!stale.length)
|
|
466
|
+
return { usd: 0, cacheHit: true };
|
|
467
|
+
if (stale.length > METRICS_PER_CHECK)
|
|
468
|
+
notes.push(`keywords: ${stale.length} words need metrics — pricing the first ${METRICS_PER_CHECK} this round, the rest on the next check`);
|
|
469
|
+
const todo = stale.slice(0, METRICS_PER_CHECK);
|
|
470
|
+
// 按 locale 分组:上游按 (location, language) 问量,混着问会给错市场的数字。
|
|
471
|
+
const byLocale = new Map();
|
|
472
|
+
for (const r of todo) {
|
|
473
|
+
const tag = r.locale || DEFAULT_LOCALE;
|
|
474
|
+
byLocale.set(tag, [...(byLocale.get(tag) ?? []), r.keyword]);
|
|
475
|
+
}
|
|
476
|
+
let cost = 0;
|
|
477
|
+
let priced = 0;
|
|
478
|
+
const stamp = new Date(now).toISOString();
|
|
479
|
+
for (const [tag, words] of byLocale) {
|
|
480
|
+
let codes;
|
|
481
|
+
try {
|
|
482
|
+
codes = resolveLocale(tag).codes;
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
notes.push(`keywords: skipped ${words.length} word(s) in unsupported locale "${tag}"`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
for (let i = 0; i < words.length; i += KEYWORDS_PER_CALL) {
|
|
489
|
+
const batch = words.slice(i, i + KEYWORDS_PER_CALL);
|
|
490
|
+
try {
|
|
491
|
+
await assertSeoBudget(ctx, db, {
|
|
492
|
+
domain,
|
|
493
|
+
estimateUsd: estimateSeoCost("keywords", { keywords: batch.length }),
|
|
494
|
+
});
|
|
495
|
+
const { result, costUsd } = await client(ctx).keywordOverview(batch, codes);
|
|
496
|
+
cost += costUsd;
|
|
497
|
+
await writeAll(db, result.map((m) => db
|
|
498
|
+
.prepare("UPDATE seo_keywords SET sv=?, kd=?, cpc=?, last_refreshed_at=? WHERE domain=? AND keyword=? AND locale=?")
|
|
499
|
+
.bind(m.sv, m.kd, m.cpc, stamp, domain, m.keyword, tag)));
|
|
500
|
+
priced += batch.length;
|
|
501
|
+
}
|
|
502
|
+
catch (e) {
|
|
503
|
+
// 预算不够 / 上游抽风 / 没配凭证 —— 都只让这一段降级,不毁掉整轮免费测量。
|
|
504
|
+
notes.push(`keywords: metrics not refreshed (${e instanceof Error ? e.message : String(e)})`);
|
|
505
|
+
break; // 同一 locale 的后续批次没必要再撞同一堵墙
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// cost=0 但走到这里 = 试过而没成(预算/上游/凭证),不是"缓存命中"——别把失败报成免费命中。
|
|
510
|
+
if (!cost)
|
|
511
|
+
return { usd: 0, cacheHit: false };
|
|
512
|
+
const charged = await chargeSeo(ctx, "keywords", cost, { domain, keywords: priced, via: "check" });
|
|
513
|
+
notes.push(`keywords: priced ${priced} word(s) this round ($${charged.usd.toFixed(4)}); metrics are reused for ${METRICS_TTL_DAYS} days, so a re-run costs nothing`);
|
|
514
|
+
return charged;
|
|
515
|
+
}
|
|
313
516
|
/** 词表:--keywords 是**全量替换**(保留已有行的指标),不带则读已存的。 */
|
|
314
517
|
async function syncKeywords(db, domain, keywords, now) {
|
|
315
518
|
if (keywords) {
|
|
@@ -336,7 +539,10 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
|
|
|
336
539
|
const domain = normalizeHost(domain0.replace(/^https?:\/\//, "").split("/")[0] ?? "");
|
|
337
540
|
if (!domain)
|
|
338
541
|
throw new ClizeError("no domain: pass --domain or run inside a clize project", 400);
|
|
339
|
-
//
|
|
542
|
+
// 预算闸不拦整轮 check:测量两段是免费的,不该被一次几美分的可选动作拖垮。
|
|
543
|
+
// 闸在 refreshKeywordMetrics 里前置到那一次上游调用上(超了就跳过补指标 + 记 note)。
|
|
544
|
+
if (cfg.capUsd !== undefined)
|
|
545
|
+
await setTenantSeoCap(db, ctx.tenantId, cfg.capUsd);
|
|
340
546
|
const ranAt = new Date(now).toISOString();
|
|
341
547
|
const win = measureWindow(now);
|
|
342
548
|
const conf = await upsertDomain(ctx, db, domain, cfg);
|
|
@@ -364,8 +570,10 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
|
|
|
364
570
|
.run();
|
|
365
571
|
// ── GSC(唯一的搜索面)。拿不到不中断整轮 —— 给 null + hint。
|
|
366
572
|
const gsc = await gscSection(ctx, db, domain, conf, kwRows, notes, win, now);
|
|
367
|
-
// ──
|
|
368
|
-
//
|
|
573
|
+
// ── 补指标:回填可能刚往词表里加了词,所以先重读一次(否则 total 会漏掉本轮的新发现),
|
|
574
|
+
// 再把缺指标 / 指标过期的词批量取一次写回。**这是 check 唯一可能花钱的一步**,
|
|
575
|
+
// 30 天内重跑不重复取、不重复计费;预算不够就跳过并记 note,不毁掉免费的两段。
|
|
576
|
+
const charged = await refreshKeywordMetrics(ctx, db, domain, await readKeywords(db, domain), notes, now);
|
|
369
577
|
const kwAll = await readKeywords(db, domain);
|
|
370
578
|
const tracked = kwAll.filter((k) => k.source !== "gsc_backfill");
|
|
371
579
|
let rank = null;
|
|
@@ -375,12 +583,13 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
|
|
|
375
583
|
notes.push("no keyword list yet — pass --keywords a,b,c to start tracking positions");
|
|
376
584
|
else
|
|
377
585
|
rank = await rankSection(db, domain, tracked, win);
|
|
378
|
-
// byBand
|
|
379
|
-
//
|
|
586
|
+
// byBand 只统计**问过量**的词(`last_refreshed_at` 非空)。没问过 ≠ 没有量:
|
|
587
|
+
// 把没问过的算进 no_volume 会让分桶恒真且误导(2026-08-31 线上首轮 check 暴露)。
|
|
588
|
+
// 问过而上游没给行的词落 `no_data` —— 那是如实的"查无此词",与 `no_volume`(量确实是 0)分开。
|
|
380
589
|
const byBand = {};
|
|
381
590
|
let unpriced = 0;
|
|
382
591
|
for (const k of kwAll) {
|
|
383
|
-
if (k.sv === null) {
|
|
592
|
+
if (!k.last_refreshed_at && k.sv === null) {
|
|
384
593
|
unpriced++;
|
|
385
594
|
continue;
|
|
386
595
|
}
|
|
@@ -388,21 +597,26 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
|
|
|
388
597
|
byBand[b] = (byBand[b] ?? 0) + 1;
|
|
389
598
|
}
|
|
390
599
|
if (unpriced)
|
|
391
|
-
notes.push(`keywords: no metrics on file for ${unpriced} of ${kwAll.length} — byBand counts only priced keywords (the
|
|
392
|
-
const
|
|
600
|
+
notes.push(`keywords: no metrics on file for ${unpriced} of ${kwAll.length} — byBand counts only priced keywords (see the note above for why they were skipped)`);
|
|
601
|
+
const cap = await tenantSeoCap(db, ctx.tenantId);
|
|
602
|
+
const spent = Number((await seoSpentThisMonth(db, ctx.tenantId)).toFixed(4));
|
|
603
|
+
const remaining = Number(Math.max(0, cap - spent).toFixed(4));
|
|
604
|
+
// 单域配额配得比租户墙高 = 会先撞租户墙。旧版只报单域数字,读起来像那个数可花 —— 撒谎的配置。
|
|
605
|
+
if (conf.budget_monthly_usd > cap)
|
|
606
|
+
notes.push(`budget: this domain's cap is $${conf.budget_monthly_usd.toFixed(2)}, but the tenant-wide monthly seo cap is $${cap.toFixed(2)} ($${remaining.toFixed(2)} left) — you will hit the tenant cap first. Raise it with \`--cap <usd>\` on any \`clize seo\` command.`);
|
|
393
607
|
return {
|
|
394
608
|
domain,
|
|
395
609
|
brand: conf.brand,
|
|
396
610
|
competitors,
|
|
397
611
|
ranAt,
|
|
398
|
-
budget: { monthlyUsd: conf.budget_monthly_usd, spentThisMonth:
|
|
612
|
+
budget: { monthlyUsd: conf.budget_monthly_usd, spentThisMonth: spent, capUsd: cap, remainingUsd: remaining },
|
|
399
613
|
keywords: { total: kwAll.length, byBand, tracked: tracked.length },
|
|
400
614
|
rank,
|
|
401
615
|
traffic,
|
|
402
616
|
gsc,
|
|
403
617
|
notes,
|
|
404
|
-
//
|
|
405
|
-
charged
|
|
618
|
+
// 测量本身是免费的(GSC service account + 自家 CF RUM);花的只可能是补词指标那一步。
|
|
619
|
+
charged,
|
|
406
620
|
};
|
|
407
621
|
}
|
|
408
622
|
/** 按 query 聚合一段窗口的 GSC 明细。位次 = **曝光加权均值**(GSC 自己的 average position 口径)。 */
|
|
@@ -433,14 +647,20 @@ async function rankSection(db, domain, tracked, win) {
|
|
|
433
647
|
const series = await weeklySeries(db, domain, wanted, win.to);
|
|
434
648
|
const latest = [];
|
|
435
649
|
const unseen = [];
|
|
650
|
+
const signals = [];
|
|
436
651
|
for (const k of tracked) {
|
|
437
652
|
const a = cur.get(k.keyword);
|
|
438
653
|
if (!a) {
|
|
439
654
|
// GSC 本窗口没见过这个词。**只列名,零成本** —— 对它做深抓只会一遍遍确认「仍未见」。
|
|
440
655
|
unseen.push(k.keyword);
|
|
656
|
+
// 但"量表说有需求 × Google 一次曝光都没给"本身是个形态,不该只当空行。
|
|
657
|
+
const form = signalForm({ sv: k.sv, position: null, impressions: 0, clicks: 0 });
|
|
658
|
+
if (form)
|
|
659
|
+
signals.push({ keyword: k.keyword, form, sv: k.sv, kd: k.kd, position: null, impressions: 0, clicks: 0 });
|
|
441
660
|
continue;
|
|
442
661
|
}
|
|
443
662
|
const p = prev.get(k.keyword);
|
|
663
|
+
const form = signalForm({ sv: k.sv, position: a.position, impressions: a.impressions, clicks: a.clicks });
|
|
444
664
|
latest.push({
|
|
445
665
|
keyword: k.keyword,
|
|
446
666
|
position: a.position,
|
|
@@ -449,12 +669,32 @@ async function rankSection(db, domain, tracked, win) {
|
|
|
449
669
|
// 位次变小 = 前进,所以 delta = 旧 - 新(正数是好消息)。上一窗口没数据 → null。
|
|
450
670
|
delta: p ? Number((p.position - a.position).toFixed(1)) : null,
|
|
451
671
|
series: series.get(k.keyword) ?? [],
|
|
672
|
+
sv: k.sv,
|
|
673
|
+
kd: k.kd,
|
|
674
|
+
form,
|
|
452
675
|
});
|
|
676
|
+
if (form)
|
|
677
|
+
signals.push({
|
|
678
|
+
keyword: k.keyword,
|
|
679
|
+
form,
|
|
680
|
+
sv: k.sv,
|
|
681
|
+
kd: k.kd,
|
|
682
|
+
position: a.position,
|
|
683
|
+
impressions: a.impressions,
|
|
684
|
+
clicks: a.clicks,
|
|
685
|
+
});
|
|
453
686
|
}
|
|
454
687
|
latest.sort((x, y) => x.position - y.position);
|
|
455
688
|
const movers = latest
|
|
456
689
|
.filter((r) => r.delta != null && Math.abs(r.delta) >= RANK_MOVER_MIN)
|
|
457
690
|
.sort((x, y) => Math.abs(y.delta ?? 0) - Math.abs(x.delta ?? 0));
|
|
691
|
+
// 形态排序 = 值得先看的在前:能立刻动手的 pre_emergence,再是要换打法的 authority_limited。
|
|
692
|
+
const FORM_ORDER = {
|
|
693
|
+
pre_emergence: 0,
|
|
694
|
+
authority_limited: 1,
|
|
695
|
+
demand_no_surface: 2,
|
|
696
|
+
};
|
|
697
|
+
signals.sort((x, y) => FORM_ORDER[x.form] - FORM_ORDER[y.form] || y.impressions - x.impressions || (y.sv ?? 0) - (x.sv ?? 0));
|
|
458
698
|
return {
|
|
459
699
|
source: "gsc",
|
|
460
700
|
window: { from: win.from, to: win.to },
|
|
@@ -462,6 +702,7 @@ async function rankSection(db, domain, tracked, win) {
|
|
|
462
702
|
latest,
|
|
463
703
|
movers,
|
|
464
704
|
unseen,
|
|
705
|
+
signals,
|
|
465
706
|
};
|
|
466
707
|
}
|
|
467
708
|
/** 周口径时序:从窗口末端往回按 7 天分桶(不用自然周,免得口径受星期几影响)。 */
|