@clize/clize 0.28.0 → 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/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)
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 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);
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,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
- // 预算闸刻意不在这里:check 不打上游数据源,没有可花的钱。花钱的是查询三条。
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
- // ── 位次:词表 join 刚落库的 GSC 明细。零上游调用、零成本。
368
- // 回填可能刚往词表里加了词,所以重新读一次 —— 否则 total 会漏掉本轮的新发现。
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 只统计**取过指标**的词:sv 为空是「没问过量」,不是「没有量」——
379
- // 把它算进 no_volume 会让分桶恒真且误导(2026-08-31 线上首轮 check 暴露)。
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 saved list stores words, not metrics)`);
392
- const spent = await seoSpentThisMonth(db, ctx.tenantId);
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: Number(spent.toFixed(4)) },
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
- // check 免费:两个数据源都是自有的(GSC service account + 自家 CF RUM)
405
- charged: { usd: 0, cacheHit: false },
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 天分桶(不用自然周,免得口径受星期几影响)。 */
@@ -497,6 +738,20 @@ async function weeklySeries(db, domain, wanted, to) {
497
738
  ]));
498
739
  return out;
499
740
  }
741
+ /** 一批语句一次写完。D1 binding 的 batch 一次往返跑一组(隐式事务),按 100 条切块
742
+ * (太大的批量会撞请求体上限);没有 batch 的实现回落逐条 —— 语义一样,只是慢。 */
743
+ const D1_BATCH = 100;
744
+ async function writeAll(db, stmts) {
745
+ if (!stmts.length)
746
+ return;
747
+ if (!db.batch) {
748
+ for (const st of stmts)
749
+ await st.run();
750
+ return;
751
+ }
752
+ for (let i = 0; i < stmts.length; i += D1_BATCH)
753
+ await db.batch(stmts.slice(i, i + D1_BATCH));
754
+ }
500
755
  /** GSC 段:窗口重拉替换 + 新词回填。任何失败都降级成 note,不中断 check。 */
501
756
  async function gscSection(ctx, db, domain, conf, kwRows, notes, win, now) {
502
757
  if (conf.gsc_property === GSC_OFF) {
@@ -541,13 +796,14 @@ async function gscSection(ctx, db, domain, conf, kwRows, notes, win, now) {
541
796
  .prepare("DELETE FROM seo_gsc_daily WHERE domain=? AND date>=? AND date<=?")
542
797
  .bind(domain, win.from, win.to)
543
798
  .run();
544
- for (const r of rows)
545
- await db
546
- .prepare("INSERT INTO seo_gsc_daily (domain, date, query, page, clicks, impressions, position) VALUES (?,?,?,?,?,?,?)" +
547
- " ON CONFLICT(domain, date, query, page) DO UPDATE SET clicks=excluded.clicks," +
548
- " impressions=excluded.impressions, position=excluded.position")
549
- .bind(domain, r.date, r.query, r.page, r.clicks, r.impressions, r.position)
550
- .run();
799
+ // **必须批量写**:一个窗口是几百到几千行明细,逐条 await 的话每条都是一次
800
+ // 远端 D1 往返 —— 2026-08-31 线上实测 583 行 = 2 分 40 秒,而同一轮 check 的
801
+ // 其余部分只要 4.8 秒。批量后是每 100 条一次往返。
802
+ await writeAll(db, rows.map((r) => db
803
+ .prepare("INSERT INTO seo_gsc_daily (domain, date, query, page, clicks, impressions, position) VALUES (?,?,?,?,?,?,?)" +
804
+ " ON CONFLICT(domain, date, query, page) DO UPDATE SET clicks=excluded.clicks," +
805
+ " impressions=excluded.impressions, position=excluded.position")
806
+ .bind(domain, r.date, r.query, r.page, r.clicks, r.impressions, r.position)));
551
807
  // 按 query 聚合(明细已落库,返回只给摘要——为 context 优化)。
552
808
  const agg = new Map();
553
809
  for (const r of rows) {