@clize/clize 0.28.1 → 0.29.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/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
- /** 无 domain 归属的查询(keywords/competitors/serp)按租户走的月度兜底。
11
- * domain 的路径(PR2 check)用 seo_domains.budget_monthly_usd,同一个默认值。 */
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 || sv === 0)
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 = { attackable: 0, stretch: 1, wall: 2, no_volume: 3 };
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
- /** 预算闸:超了给 429 + 解法(免逐次 confirm 的对价是花钱透明 + 有上限兜底)。 */
69
- export async function assertSeoBudget(ctx, db, opts = {}) {
70
- const domain = opts.domain;
71
- let cap = SEO_BUDGET_DEFAULT_USD;
72
- if (domain) {
73
- const row = await db
74
- .prepare("SELECT budget_monthly_usd FROM seo_domains WHERE domain=? AND tenant_id=?")
75
- .bind(domain, ctx.tenantId)
76
- .first();
77
- if (row?.budget_monthly_usd != null)
78
- cap = Number(row.budget_monthly_usd);
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
- const spent = await seoSpentThisMonth(db, ctx.tenantId, domain);
81
- if (spent < cap)
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)
82
162
  return;
83
- const scope = domain ? `for ${domain}` : "this month";
84
- const raise = domain
85
- ? `clize seo check --domain ${domain} --budget ${Math.ceil(cap * 2)}`
86
- : "top up the cap by contacting support (per-domain budgets are set by `clize seo check --budget`)";
87
- throw new ClizeError(`monthly seo budget reached ${scope}: $${spent.toFixed(2)} of $${cap.toFixed(2)}. raise: ${raise}`, 429);
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)
168
+ return;
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 { locale: tag, keywords: cached, charged: { usd: 0, cacheHit: true } };
146
- await assertSeoBudget(ctx, db);
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 { locale: tag, competitors: cached, charged: { usd: 0, cacheHit: true } };
187
- await assertSeoBudget(ctx, db);
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 { ...cached, charged: { usd: 0, cacheHit: true } };
227
- await assertSeoBudget(ctx, db);
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,32 @@ 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
+ /** `signals` 里最多留几条 `demand_no_surface`。**这条形态在冷启动站上是常态不是意外**
370
+ * ——0.29.0 发布日对 clize.ai 实测:18 条 signals 里 17 条是它,一份"每行都说同一句话"
371
+ * 的清单不是压缩,是把词表又抄了一遍。另两个形态是真稀有,不设上限。
372
+ * 被截掉的词不会消失:它们本来就全在 `rank.unseen` 里,notes 说明还有多少条。 */
373
+ const DEMAND_NO_SURFACE_MAX = 5;
374
+ /** 形态判定:纯函数、纯阈值,给定同样三个数字永远给同样的答案(agent 可自行复核)。 */
375
+ export function signalForm(a) {
376
+ if (a.position === null)
377
+ return a.sv !== null && a.sv >= DEMAND_NO_SURFACE_MIN_SV ? "demand_no_surface" : null;
378
+ if (a.sv === 0 && a.impressions >= SIGNAL_MIN_IMPRESSIONS)
379
+ return "pre_emergence";
380
+ if (a.clicks === 0 &&
381
+ a.impressions >= AUTHORITY_LIMITED_MIN_IMPRESSIONS &&
382
+ a.position >= AUTHORITY_LIMITED_MIN_POSITION)
383
+ return "authority_limited";
384
+ return null;
250
385
  }
251
386
  /** GSC 窗口重拉的天数:Google 会事后修正近日数据,固定重拉最近 N 天并替换。 */
252
387
  const GSC_WINDOW_DAYS = 30;
@@ -305,11 +440,84 @@ async function upsertDomain(ctx, db, domain, cfg) {
305
440
  }
306
441
  async function readKeywords(db, domain) {
307
442
  const { results } = await db
308
- .prepare("SELECT keyword, sv, kd, source FROM seo_keywords WHERE domain=? ORDER BY id")
443
+ .prepare("SELECT keyword, locale, sv, kd, ai_sv, cpc, source, last_refreshed_at FROM seo_keywords WHERE domain=? ORDER BY id")
309
444
  .bind(domain)
310
445
  .all();
311
446
  return results;
312
447
  }
448
+ /** 词表指标多久算过期(与 keywords 命令的缓存 TTL 同口径:SV/KD 是月更的量)。 */
449
+ const METRICS_TTL_DAYS = TTL_DAYS.keywords;
450
+ /** 一轮 check 最多给多少个词补指标。上限存在只为拦住"回填炸了词表"这种意外
451
+ * ——正常一轮是几十个词、几美分;真要更大的词表就分几轮,或先跑 `seo keywords`。 */
452
+ const METRICS_PER_CHECK = 200;
453
+ /** `sv/kd/ai_sv/cpc/last_refreshed_at` 五列此前是死列(没有任何写入点),于是
454
+ * `byBand` 对任何词表都恒为 `{no_volume: N}` —— 恒真且误导(2026-08-31 线上首轮 check 暴露)。
455
+ * 出路选了产品文档 §12.2 的①:**check 顺带把缺指标的词批量取一次并写回**。
456
+ *
457
+ * 这让 check 从"恒免费"变成"有时花几美分",换来的是 byBand 有真实分布、
458
+ * 以及交叉层(§signals)有 sv 这一源可比 —— 没有它,check 只有 GSC 一个源,
459
+ * "三源并排"少一条腿。计费口径**沿用既有规则,不新造**:
460
+ * · 词表自己就是缓存 —— `last_refreshed_at` 在 30 天内的词不重取、不重复计费;
461
+ * · 未命中的按 `keywords` 端点如实入账(op="keywords",detail 带 domain)。
462
+ * 预算不够时**不中断 check**:跳过补指标、记 note —— GSC/流量两段是免费的,
463
+ * 不该被一次几美分的可选动作拖垮。 */
464
+ async function refreshKeywordMetrics(ctx, db, domain, rows, notes, now) {
465
+ const cutoff = now - METRICS_TTL_DAYS * DAY_MS;
466
+ const stale = rows.filter((r) => {
467
+ const t = r.last_refreshed_at ? Date.parse(r.last_refreshed_at) : NaN;
468
+ return !Number.isFinite(t) || t < cutoff;
469
+ });
470
+ if (!stale.length)
471
+ return { usd: 0, cacheHit: true };
472
+ if (stale.length > METRICS_PER_CHECK)
473
+ notes.push(`keywords: ${stale.length} words need metrics — pricing the first ${METRICS_PER_CHECK} this round, the rest on the next check`);
474
+ const todo = stale.slice(0, METRICS_PER_CHECK);
475
+ // 按 locale 分组:上游按 (location, language) 问量,混着问会给错市场的数字。
476
+ const byLocale = new Map();
477
+ for (const r of todo) {
478
+ const tag = r.locale || DEFAULT_LOCALE;
479
+ byLocale.set(tag, [...(byLocale.get(tag) ?? []), r.keyword]);
480
+ }
481
+ let cost = 0;
482
+ let priced = 0;
483
+ const stamp = new Date(now).toISOString();
484
+ for (const [tag, words] of byLocale) {
485
+ let codes;
486
+ try {
487
+ codes = resolveLocale(tag).codes;
488
+ }
489
+ catch {
490
+ notes.push(`keywords: skipped ${words.length} word(s) in unsupported locale "${tag}"`);
491
+ continue;
492
+ }
493
+ for (let i = 0; i < words.length; i += KEYWORDS_PER_CALL) {
494
+ const batch = words.slice(i, i + KEYWORDS_PER_CALL);
495
+ try {
496
+ await assertSeoBudget(ctx, db, {
497
+ domain,
498
+ estimateUsd: estimateSeoCost("keywords", { keywords: batch.length }),
499
+ });
500
+ const { result, costUsd } = await client(ctx).keywordOverview(batch, codes);
501
+ cost += costUsd;
502
+ await writeAll(db, result.map((m) => db
503
+ .prepare("UPDATE seo_keywords SET sv=?, kd=?, cpc=?, last_refreshed_at=? WHERE domain=? AND keyword=? AND locale=?")
504
+ .bind(m.sv, m.kd, m.cpc, stamp, domain, m.keyword, tag)));
505
+ priced += batch.length;
506
+ }
507
+ catch (e) {
508
+ // 预算不够 / 上游抽风 / 没配凭证 —— 都只让这一段降级,不毁掉整轮免费测量。
509
+ notes.push(`keywords: metrics not refreshed (${e instanceof Error ? e.message : String(e)})`);
510
+ break; // 同一 locale 的后续批次没必要再撞同一堵墙
511
+ }
512
+ }
513
+ }
514
+ // cost=0 但走到这里 = 试过而没成(预算/上游/凭证),不是"缓存命中"——别把失败报成免费命中。
515
+ if (!cost)
516
+ return { usd: 0, cacheHit: false };
517
+ const charged = await chargeSeo(ctx, "keywords", cost, { domain, keywords: priced, via: "check" });
518
+ 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`);
519
+ return charged;
520
+ }
313
521
  /** 词表:--keywords 是**全量替换**(保留已有行的指标),不带则读已存的。 */
314
522
  async function syncKeywords(db, domain, keywords, now) {
315
523
  if (keywords) {
@@ -336,7 +544,10 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
336
544
  const domain = normalizeHost(domain0.replace(/^https?:\/\//, "").split("/")[0] ?? "");
337
545
  if (!domain)
338
546
  throw new ClizeError("no domain: pass --domain or run inside a clize project", 400);
339
- // 预算闸刻意不在这里:check 不打上游数据源,没有可花的钱。花钱的是查询三条。
547
+ // 预算闸不拦整轮 check:测量两段是免费的,不该被一次几美分的可选动作拖垮。
548
+ // 闸在 refreshKeywordMetrics 里前置到那一次上游调用上(超了就跳过补指标 + 记 note)。
549
+ if (cfg.capUsd !== undefined)
550
+ await setTenantSeoCap(db, ctx.tenantId, cfg.capUsd);
340
551
  const ranAt = new Date(now).toISOString();
341
552
  const win = measureWindow(now);
342
553
  const conf = await upsertDomain(ctx, db, domain, cfg);
@@ -364,8 +575,10 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
364
575
  .run();
365
576
  // ── GSC(唯一的搜索面)。拿不到不中断整轮 —— 给 null + hint。
366
577
  const gsc = await gscSection(ctx, db, domain, conf, kwRows, notes, win, now);
367
- // ── 位次:词表 join 刚落库的 GSC 明细。零上游调用、零成本。
368
- // 回填可能刚往词表里加了词,所以重新读一次 —— 否则 total 会漏掉本轮的新发现。
578
+ // ── 补指标:回填可能刚往词表里加了词,所以先重读一次(否则 total 会漏掉本轮的新发现),
579
+ // 再把缺指标 / 指标过期的词批量取一次写回。**这是 check 唯一可能花钱的一步**,
580
+ // 30 天内重跑不重复取、不重复计费;预算不够就跳过并记 note,不毁掉免费的两段。
581
+ const charged = await refreshKeywordMetrics(ctx, db, domain, await readKeywords(db, domain), notes, now);
369
582
  const kwAll = await readKeywords(db, domain);
370
583
  const tracked = kwAll.filter((k) => k.source !== "gsc_backfill");
371
584
  let rank = null;
@@ -374,13 +587,14 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
374
587
  else if (!tracked.length)
375
588
  notes.push("no keyword list yet — pass --keywords a,b,c to start tracking positions");
376
589
  else
377
- rank = await rankSection(db, domain, tracked, win);
378
- // byBand 只统计**取过指标**的词:sv 为空是「没问过量」,不是「没有量」——
379
- // 把它算进 no_volume 会让分桶恒真且误导(2026-08-31 线上首轮 check 暴露)。
590
+ rank = await rankSection(db, domain, tracked, win, notes);
591
+ // byBand 只统计**问过量**的词(`last_refreshed_at` 非空)。没问过 ≠ 没有量:
592
+ // 把没问过的算进 no_volume 会让分桶恒真且误导(2026-08-31 线上首轮 check 暴露)。
593
+ // 问过而上游没给行的词落 `no_data` —— 那是如实的"查无此词",与 `no_volume`(量确实是 0)分开。
380
594
  const byBand = {};
381
595
  let unpriced = 0;
382
596
  for (const k of kwAll) {
383
- if (k.sv === null) {
597
+ if (!k.last_refreshed_at && k.sv === null) {
384
598
  unpriced++;
385
599
  continue;
386
600
  }
@@ -388,21 +602,26 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
388
602
  byBand[b] = (byBand[b] ?? 0) + 1;
389
603
  }
390
604
  if (unpriced)
391
- notes.push(`keywords: no metrics on file for ${unpriced} of ${kwAll.length} — byBand counts only priced keywords (the saved list stores words, not metrics)`);
392
- const spent = await seoSpentThisMonth(db, ctx.tenantId);
605
+ 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)`);
606
+ const cap = await tenantSeoCap(db, ctx.tenantId);
607
+ const spent = Number((await seoSpentThisMonth(db, ctx.tenantId)).toFixed(4));
608
+ const remaining = Number(Math.max(0, cap - spent).toFixed(4));
609
+ // 单域配额配得比租户墙高 = 会先撞租户墙。旧版只报单域数字,读起来像那个数可花 —— 撒谎的配置。
610
+ if (conf.budget_monthly_usd > cap)
611
+ 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
612
  return {
394
613
  domain,
395
614
  brand: conf.brand,
396
615
  competitors,
397
616
  ranAt,
398
- budget: { monthlyUsd: conf.budget_monthly_usd, spentThisMonth: Number(spent.toFixed(4)) },
617
+ budget: { monthlyUsd: conf.budget_monthly_usd, spentThisMonth: spent, capUsd: cap, remainingUsd: remaining },
399
618
  keywords: { total: kwAll.length, byBand, tracked: tracked.length },
400
619
  rank,
401
620
  traffic,
402
621
  gsc,
403
622
  notes,
404
- // check 免费:两个数据源都是自有的(GSC service account + 自家 CF RUM)
405
- charged: { usd: 0, cacheHit: false },
623
+ // 测量本身是免费的(GSC service account + 自家 CF RUM);花的只可能是补词指标那一步。
624
+ charged,
406
625
  };
407
626
  }
408
627
  /** 按 query 聚合一段窗口的 GSC 明细。位次 = **曝光加权均值**(GSC 自己的 average position 口径)。 */
@@ -426,21 +645,27 @@ async function aggregateGsc(db, domain, from, to) {
426
645
  return out;
427
646
  }
428
647
  /** 位次段:词表 join GSC 明细。纯 D1 读,零上游调用。 */
429
- async function rankSection(db, domain, tracked, win) {
648
+ async function rankSection(db, domain, tracked, win, notes) {
430
649
  const cur = await aggregateGsc(db, domain, win.from, win.to);
431
650
  const prev = await aggregateGsc(db, domain, win.prevFrom, win.prevTo);
432
651
  const wanted = new Set(tracked.map((k) => k.keyword));
433
652
  const series = await weeklySeries(db, domain, wanted, win.to);
434
653
  const latest = [];
435
654
  const unseen = [];
655
+ const signals = [];
436
656
  for (const k of tracked) {
437
657
  const a = cur.get(k.keyword);
438
658
  if (!a) {
439
659
  // GSC 本窗口没见过这个词。**只列名,零成本** —— 对它做深抓只会一遍遍确认「仍未见」。
440
660
  unseen.push(k.keyword);
661
+ // 但"量表说有需求 × Google 一次曝光都没给"本身是个形态,不该只当空行。
662
+ const form = signalForm({ sv: k.sv, position: null, impressions: 0, clicks: 0 });
663
+ if (form)
664
+ signals.push({ keyword: k.keyword, form, sv: k.sv, kd: k.kd, position: null, impressions: 0, clicks: 0 });
441
665
  continue;
442
666
  }
443
667
  const p = prev.get(k.keyword);
668
+ const form = signalForm({ sv: k.sv, position: a.position, impressions: a.impressions, clicks: a.clicks });
444
669
  latest.push({
445
670
  keyword: k.keyword,
446
671
  position: a.position,
@@ -449,12 +674,41 @@ async function rankSection(db, domain, tracked, win) {
449
674
  // 位次变小 = 前进,所以 delta = 旧 - 新(正数是好消息)。上一窗口没数据 → null。
450
675
  delta: p ? Number((p.position - a.position).toFixed(1)) : null,
451
676
  series: series.get(k.keyword) ?? [],
677
+ sv: k.sv,
678
+ kd: k.kd,
679
+ form,
452
680
  });
681
+ if (form)
682
+ signals.push({
683
+ keyword: k.keyword,
684
+ form,
685
+ sv: k.sv,
686
+ kd: k.kd,
687
+ position: a.position,
688
+ impressions: a.impressions,
689
+ clicks: a.clicks,
690
+ });
453
691
  }
454
692
  latest.sort((x, y) => x.position - y.position);
455
693
  const movers = latest
456
694
  .filter((r) => r.delta != null && Math.abs(r.delta) >= RANK_MOVER_MIN)
457
695
  .sort((x, y) => Math.abs(y.delta ?? 0) - Math.abs(x.delta ?? 0));
696
+ // 形态排序 = 值得先看的在前:能立刻动手的 pre_emergence,再是要换打法的 authority_limited。
697
+ const FORM_ORDER = {
698
+ pre_emergence: 0,
699
+ authority_limited: 1,
700
+ demand_no_surface: 2,
701
+ };
702
+ signals.sort((x, y) => FORM_ORDER[x.form] - FORM_ORDER[y.form] || y.impressions - x.impressions || (y.sv ?? 0) - (x.sv ?? 0));
703
+ // demand_no_surface 在冷启动站上是常态,留全量会把两条真稀有的形态淹掉。截断要说出来。
704
+ const noSurface = signals.filter((x) => x.form === "demand_no_surface");
705
+ if (noSurface.length > DEMAND_NO_SURFACE_MAX) {
706
+ const drop = new Set(noSurface.slice(DEMAND_NO_SURFACE_MAX).map((x) => x.keyword));
707
+ for (let i = signals.length - 1; i >= 0; i--)
708
+ if (drop.has(signals[i].keyword))
709
+ signals.splice(i, 1);
710
+ notes.push(`signals: ${noSurface.length} keywords have demand but no impressions this window — listing the ${DEMAND_NO_SURFACE_MAX} with the most volume; the rest are in rank.unseen`);
711
+ }
458
712
  return {
459
713
  source: "gsc",
460
714
  window: { from: win.from, to: win.to },
@@ -462,6 +716,7 @@ async function rankSection(db, domain, tracked, win) {
462
716
  latest,
463
717
  movers,
464
718
  unseen,
719
+ signals,
465
720
  };
466
721
  }
467
722
  /** 周口径时序:从窗口末端往回按 7 天分桶(不用自然周,免得口径受星期几影响)。 */