@clize/clize 0.35.5 → 0.36.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
@@ -1,10 +1,12 @@
1
1
  import { ClizeError } from "../lib/errors.js";
2
2
  import { sha256hex } from "../lib/crypto.js";
3
3
  import { DataForSeo, KEYWORDS_PER_CALL, normalizeHost, } from "../providers/seo/dataforseo.js";
4
- import { classifySerp } from "./seo-classify.js";
4
+ import { classifySerp, clusterCells, ITEM_TYPES } from "./seo-classify.js";
5
5
  import { Gsc, toGscProperty } from "../providers/seo/gsc.js";
6
6
  import { readTraffic } from "./seo-traffic.js";
7
- import { INSPECT_MAX, IMPRESSIONS_MOVE_MIN, isImpressionsMove, probeStatus, readSitemap, selectPages, } from "./seo-pages.js";
7
+ import { INSPECT_MAX, IMPRESSIONS_MOVE_MIN, fetchPageHeads, isImpressionsMove, normalizePageUrl, probeStatus, readSitemap, selectPages, } from "./seo-pages.js";
8
+ import { deriveCells, derivePageFamilies, familyKeyOf, localeOfUrl, normalizeLocaleTag, parsePageHead, } from "./seo-cells.js";
9
+ import { isIndexablePage, pageUrlPath } from "./site-manifest.js";
8
10
  /** 对客加价:成本 × (1 + 4) = ×5。刻意不复用 platform.billing.markupPct(0.2)——
9
11
  * 那是给域名 / 媒体等大额供给定的,套在美分级数据上会让定价失真(一次 serp 收 8 厘)。 */
10
12
  export const SEO_MARKUP_PCT = 4;
@@ -383,7 +385,7 @@ export async function keywords(ctx, db, opts) {
383
385
  keywords: cached.value,
384
386
  charged,
385
387
  cap,
386
- notes: withCapNote(thisCallNote(charged, shape, chargedForSeo(cached.costUsd)), cap),
388
+ notes: withCapNote(thisCallNote(charged, shape, chargedForSeo(cached.costUsd)), cap, ...keywordsHandoffNotes(cached.value, tag)),
387
389
  };
388
390
  }
389
391
  await assertSeoCap(ctx, db, { estimateUsd: estimateSeoCost(op, { keywords: seeds.length }) });
@@ -418,9 +420,21 @@ export async function keywords(ctx, db, opts) {
418
420
  keywords: rows,
419
421
  charged,
420
422
  cap,
421
- notes: withCapNote(thisCallNote(charged, shape), cap),
423
+ notes: withCapNote(thisCallNote(charged, shape), cap, ...keywordsHandoffNotes(rows, tag)),
422
424
  };
423
425
  }
426
+ /** 定价回执的交接行(design/seo-cell-funnel.md §3.2 F)。漏斗死在这一步:量表对非英语工具词六成给
427
+ * `no_data`,agent 把空值当没需求扔掉(2026-09-02 119 格 82 格未买一次 SERP;09-03 上午 zh-TW 22 词 1 页)。
428
+ * 规则「no_data 不是没需求」在 skill 里写了三次失效三次 —— 放在回执上,命令现成、路径分好,agent 不做决定。 */
429
+ function keywordsHandoffNotes(rows, tag) {
430
+ const empty = rows.filter((r) => r.band === "no_data" || r.band === "no_volume");
431
+ if (!empty.length)
432
+ return [];
433
+ const loc = tag === DEFAULT_LOCALE ? "" : `@${tag}`;
434
+ return [
435
+ `${empty.length} of ${rows.length} keyword(s) have no volume row — missing data, not missing demand; volume only orders cells, it never drops one. Siblings of a verified family: enroll and build — \`clize seo check --add-keywords "<word>${loc}#<family>"\`; a new family: \`clize seo serp "${empty[0]?.keyword ?? "<word>"}${loc}"\` first ($0.02)`,
436
+ ];
437
+ }
424
438
  /** 竞品反查:一次问多个域的流量画像 + 每个域靠什么词活。
425
439
  * **这是命令面里唯一的词源命令**(反查出来的 topKeywords 就是候选种子);判读见 skill §6。 */
426
440
  export async function competitors(ctx, db, opts) {
@@ -493,7 +507,9 @@ const SERP_RELAY_NOTE = "when the survivor list is final, set the baseline: `cli
493
507
  * (四道闸之一:没跑过 serp 不许建页),所以这里是指向页型规格包的黄金位置。**只发给可
494
508
  * 进入的 SERP**(listicle_window / open)——对 official_wall / definition_wall 提示建页
495
509
  * 就是误导;条件取自已算出的 verdict,同 signals 一样是按事实选择流程指引,不是建议。 */
496
- const SERP_BUILD_NOTE = "building a page for this keyword? page-type skeletons + on-page spec: `clize build site start`; after deploy, add the keyword to `clize seo check --keywords` (`<keyword>@<locale>` for a non-English cell); a locale sibling is gated by its own SERP `clize seo serp <keyword> --locale <l>`, not by the English page";
510
+ const SERP_BUILD_NOTE = "building a page for this keyword? page-type skeletons + on-page spec: `clize build site start`; after deploy, enroll the cell with `clize seo check --add-keywords \"<keyword>@<locale>#<family>\"` once a family earns or holds an enterable verdict, its locale siblings need no SERP of their own: enroll them the same way and build";
511
+ /** tool_window 专属(v2):占位者是工具,角度是它们缺什么 —— 读前三名的页,不是标题。 */
512
+ const SERP_TOOL_TAIL = "; the gap is what the tools on this page do not do — read the top 3 pages, then write the angle";
497
513
  /** listicle_window 专属追加(0.30.1):放置→外部是四条缝里唯一没有回执配合的一段,两轮
498
514
  * 实测(subagent 二测 vs tabledi 真实使用)执行方差全集中在放置环 —— 失守的规矩只活在
499
515
  * prose 里,接力链此前终止于"入表",agent 写交付物时手边没有这一行。 */
@@ -501,10 +517,32 @@ const SERP_PITCH_TAIL = "; then pitch the parasite targets above — inclusion b
501
517
  function serpTailNotes(verdict) {
502
518
  if (verdict === "listicle_window")
503
519
  return [SERP_BUILD_NOTE + SERP_PITCH_TAIL, SERP_RELAY_NOTE];
520
+ if (verdict === "tool_window")
521
+ return [SERP_BUILD_NOTE + SERP_TOOL_TAIL, SERP_RELAY_NOTE];
504
522
  if (verdict === "open")
505
523
  return [SERP_BUILD_NOTE, SERP_RELAY_NOTE];
506
524
  return [SERP_RELAY_NOTE];
507
525
  }
526
+ const emptyComposition = () => {
527
+ const c = {};
528
+ for (const t of ITEM_TYPES)
529
+ c[t] = 0;
530
+ return c;
531
+ };
532
+ /** serp 跑完把 verdict 回写到这个租户词表里同名同市场的条目(design/seo-cell-funnel.md §3.2 E):
533
+ * 族的验证态从此是事实不是记忆。serp 本身无 domain,所以按租户名下所有域写;写不到就是没这条,不是错。 */
534
+ async function rememberVerdict(db, tenantId, keyword, tag, verdict) {
535
+ try {
536
+ await db
537
+ .prepare("UPDATE seo_keywords SET serp_verdict=?, serp_at=? WHERE keyword=? AND locale=?" +
538
+ " AND domain IN (SELECT domain FROM seo_domains WHERE tenant_id=?)")
539
+ .bind(verdict, new Date().toISOString(), keyword.toLowerCase(), tag, tenantId)
540
+ .run();
541
+ }
542
+ catch {
543
+ /* 回写是增强层,不拦查询 */
544
+ }
545
+ }
508
546
  /** 结果页真相:自然结果 + 占位判定 + 寄生目标。 */
509
547
  export async function serp(ctx, db, opts) {
510
548
  const keyword = opts.keyword.trim();
@@ -520,8 +558,10 @@ export async function serp(ctx, db, opts) {
520
558
  if (cached) {
521
559
  const charged = { usd: 0, cacheHit: true };
522
560
  const cap = await seoCapState(db, ctx.tenantId);
561
+ await rememberVerdict(db, ctx.tenantId, keyword, tag, cached.value.verdict);
523
562
  return {
524
563
  ...cached.value,
564
+ composition: cached.value.composition ?? emptyComposition(), // 7 天缓存里可能还有 v2 之前的条目
525
565
  charged,
526
566
  cap,
527
567
  notes: withCapNote(thisCallNote(charged, shape, chargedForSeo(cached.costUsd)), cap, ...serpTailNotes(cached.value.verdict)),
@@ -537,6 +577,7 @@ export async function serp(ctx, db, opts) {
537
577
  aiOverview: page.aiOverview,
538
578
  itemTypes: page.itemTypes,
539
579
  counts: classified.counts,
580
+ composition: classified.composition,
540
581
  items: classified.items.map((i) => ({
541
582
  pos: i.pos,
542
583
  host: i.host,
@@ -550,6 +591,7 @@ export async function serp(ctx, db, opts) {
550
591
  await writeCache(db, ctx.tenantId, key, payload, costUsd);
551
592
  const charged = await chargeSeo(ctx, "serp", costUsd, { keyword, locale: tag });
552
593
  const cap = await seoCapState(db, ctx.tenantId);
594
+ await rememberVerdict(db, ctx.tenantId, keyword, tag, payload.verdict);
553
595
  return {
554
596
  ...payload,
555
597
  charged,
@@ -557,6 +599,119 @@ export async function serp(ctx, db, opts) {
557
599
  notes: withCapNote(thisCallNote(charged, shape), cap, ...serpTailNotes(payload.verdict)),
558
600
  };
559
601
  }
602
+ /** 一批最多多少格(整站矩阵 100–150 格要一次装下;再大就分两次)。 */
603
+ const SERP_BATCH_MAX = 200;
604
+ /** 上游并发(每格一次 live 抓取,实测 ~2.7s;Workers 出站连接上限 6)。 */
605
+ const SERP_BATCH_CONCURRENCY = 6;
606
+ /** 一批格子的结果页真相:每格一次 serp(缓存前置、各自入账),再在批内算簇、内联定价排序、汇总回执。
607
+ * 它是 B 档的闸与简报原料、A+ 档的放置目标来源 —— 不是兄弟格的前置(兄弟格直接建,§7.2)。 */
608
+ export async function serpBatch(ctx, db, opts) {
609
+ const seen = new Set();
610
+ const entries = [];
611
+ for (const raw of opts.entries) {
612
+ const e = parseKeywordEntry(raw);
613
+ if (!e.keyword)
614
+ continue;
615
+ const k = `${e.keyword}\u0000${e.locale}`;
616
+ if (seen.has(k))
617
+ continue;
618
+ seen.add(k);
619
+ entries.push(e);
620
+ }
621
+ if (!entries.length)
622
+ throw new ClizeError("no keyword given", 400);
623
+ if (entries.length > SERP_BATCH_MAX)
624
+ throw new ClizeError(`too many cells (${entries.length}); max ${SERP_BATCH_MAX} per call — split the list`, 400);
625
+ if (opts.capUsd !== undefined)
626
+ await setTenantSeoCap(db, ctx.tenantId, opts.capUsd);
627
+ // 每格一次 serp:各自查缓存、各自过闸、各自入账(账本逐笔仍是真值);并发受出站连接上限约束。
628
+ const results = new Array(entries.length);
629
+ const queue = entries.map((e, i) => i);
630
+ const worker = async () => {
631
+ for (let i = queue.shift(); i !== undefined; i = queue.shift()) {
632
+ const e = entries[i];
633
+ try {
634
+ results[i] = await serp(ctx, db, { keyword: e.keyword, locale: e.locale, depth: opts.depth });
635
+ }
636
+ catch (err) {
637
+ results[i] = err instanceof Error ? err : new Error(String(err));
638
+ }
639
+ }
640
+ };
641
+ await Promise.all(Array.from({ length: Math.min(SERP_BATCH_CONCURRENCY, entries.length) }, worker));
642
+ const notes = [];
643
+ let usd = 0;
644
+ let cached = 0;
645
+ const ok = [];
646
+ for (let i = 0; i < entries.length; i++) {
647
+ const r = results[i];
648
+ const e = entries[i];
649
+ if (r instanceof Error || r === undefined) {
650
+ notes.push(`"${e.keyword}"@${e.locale}: ${r instanceof Error ? r.message : "no result"}`);
651
+ continue;
652
+ }
653
+ usd += r.charged.usd;
654
+ if (r.charged.cacheHit)
655
+ cached++;
656
+ ok.push({ e, r });
657
+ }
658
+ // 内联定价:只排序,从不淘汰。按市场分组走 keywords(30 天缓存;预算不够就跳过并说明)。
659
+ const metrics = new Map();
660
+ let priced = 0;
661
+ if (opts.price !== false && ok.length) {
662
+ const byLocale = new Map();
663
+ for (const { e } of ok)
664
+ byLocale.set(e.locale, [...(byLocale.get(e.locale) ?? []), e.keyword]);
665
+ for (const [locale, seeds] of byLocale) {
666
+ try {
667
+ const kr = await keywords(ctx, db, { seeds, locale });
668
+ usd += kr.charged.usd;
669
+ for (const row of kr.keywords) {
670
+ metrics.set(`${row.keyword.toLowerCase()}\u0000${locale}`, { sv: row.sv, kd: row.kd, band: row.band });
671
+ priced++;
672
+ }
673
+ }
674
+ catch (err) {
675
+ notes.push(`pricing skipped for ${locale} (${err instanceof Error ? err.message : String(err)}) — cells are unordered, not dropped`);
676
+ }
677
+ }
678
+ }
679
+ const { clusterOf, clusters } = clusterCells(ok.map(({ e, r }) => ({ key: `${e.keyword}@${e.locale}`, locale: e.locale, urls: r.items.slice(0, 10).map((i) => i.url) })));
680
+ const cells = ok.map(({ e, r }) => {
681
+ const m = metrics.get(`${e.keyword}\u0000${e.locale}`);
682
+ return {
683
+ keyword: e.keyword,
684
+ locale: e.locale,
685
+ verdict: r.verdict,
686
+ composition: r.composition,
687
+ aiOverview: r.aiOverview,
688
+ top: r.items.slice(0, 10),
689
+ parasiteTargets: r.parasiteTargets,
690
+ cluster: clusterOf.get(`${e.keyword}@${e.locale}`) ?? null,
691
+ sv: m?.sv ?? null,
692
+ kd: m?.kd ?? null,
693
+ band: m?.band ?? null,
694
+ cached: r.charged.cacheHit,
695
+ };
696
+ });
697
+ const byVerdict = {};
698
+ for (const c of cells)
699
+ byVerdict[c.verdict] = (byVerdict[c.verdict] ?? 0) + 1;
700
+ const enterable = cells.filter((c) => c.verdict === "tool_window" || c.verdict === "listicle_window" || c.verdict === "open").length;
701
+ const walls = cells.length - enterable;
702
+ const cap = await seoCapState(db, ctx.tenantId);
703
+ const money = `this call: $${usd.toFixed(4)} (serp batch, ${cells.length} cell${cells.length === 1 ? "" : "s"}, ${cached} cache hit${cached === 1 ? "" : "s"}${opts.price === false ? ", not priced" : `, ${priced} priced`})`;
704
+ const tally = `${cells.length} cells: ${enterable} enterable (${byVerdict.tool_window ?? 0} tool_window · ${byVerdict.listicle_window ?? 0} listicle_window · ${byVerdict.open ?? 0} open) · ${walls} wall${walls === 1 ? "" : "s"} · ${clusters.length} cluster${clusters.length === 1 ? "" : "s"} of ≥2 (same page in the occupants' eyes: shared ≥5 of the top 10)`;
705
+ const handoff = "enterable cells: brief + build (§7.1), then `clize seo check --add-keywords \"<word>@<locale>#<family>\"`; once a family is verified its siblings need no SERP; walls: skip rows with the reason";
706
+ return {
707
+ cells,
708
+ clusters,
709
+ summary: { cells: cells.length, enterable, walls, byVerdict, clusters: clusters.length, priced, cached },
710
+ charged: { usd: Number(usd.toFixed(4)), cacheHit: cells.length > 0 && cached === cells.length && priced === 0 },
711
+ cap,
712
+ notes: withCapNote(money, cap, tally, handoff, SERP_RELAY_NOTE, ...notes),
713
+ };
714
+ }
560
715
  /** `pre_emergence` 要求的最低曝光:低于此量与噪声不可分(同 GSC 新词回填的阈值口径)。 */
561
716
  const SIGNAL_MIN_IMPRESSIONS = 10;
562
717
  /** `pre_emergence` 的第二条件:窗口曝光 ≥ 月量 × 此倍数 = 量表明显没跟上需求。
@@ -623,7 +778,7 @@ function measureWindow(now) {
623
778
  /** 配置 upsert:带参则更新,不带则沿用已存的(首次给出后记住)。 */
624
779
  async function upsertDomain(ctx, db, domain, cfg) {
625
780
  const existing = await db
626
- .prepare("SELECT domain, brand, competitors, gsc_property FROM seo_domains WHERE domain=? AND tenant_id=?")
781
+ .prepare("SELECT domain, brand, competitors, gsc_property, locales FROM seo_domains WHERE domain=? AND tenant_id=?")
627
782
  .bind(domain, ctx.tenantId)
628
783
  .first();
629
784
  const row = {
@@ -635,20 +790,22 @@ async function upsertDomain(ctx, db, domain, cfg) {
635
790
  // "none" 存成哨兵而不是清空:清空等于「还没探测过」,下轮又会去问一遍 —— 那句
636
791
  // 「pass --gsc none to stop asking」就成了空头支票。
637
792
  gsc_property: cfg.gsc === GSC_OFF ? GSC_OFF : (cfg.gsc ?? existing?.gsc_property ?? null),
793
+ locales: cfg.locales ? JSON.stringify(cfg.locales.map((l) => resolveLocaleLoose(l))) : (existing?.locales ?? null),
638
794
  };
639
795
  // budget_monthly_usd 不再写也不再读(0.29.2 砍掉单域配额):列留在表里(有 DEFAULT,
640
796
  // INSERT 省略它即可),但它不再影响任何行为 —— 唯一的墙是租户级 `--cap`。
641
797
  await db
642
- .prepare("INSERT INTO seo_domains (domain, tenant_id, brand, competitors, gsc_property, created_at) VALUES (?,?,?,?,?,?)" +
798
+ .prepare("INSERT INTO seo_domains (domain, tenant_id, brand, competitors, gsc_property, locales, created_at) VALUES (?,?,?,?,?,?,?)" +
643
799
  " ON CONFLICT(domain) DO UPDATE SET brand=excluded.brand, competitors=excluded.competitors," +
644
- " gsc_property=excluded.gsc_property")
645
- .bind(domain, ctx.tenantId, row.brand, row.competitors, row.gsc_property, new Date().toISOString())
800
+ " gsc_property=excluded.gsc_property, locales=excluded.locales")
801
+ .bind(domain, ctx.tenantId, row.brand, row.competitors, row.gsc_property, row.locales, new Date().toISOString())
646
802
  .run();
647
803
  return row;
648
804
  }
649
805
  async function readKeywords(db, domain) {
650
806
  const { results } = await db
651
- .prepare("SELECT keyword, locale, sv, kd, ai_sv, cpc, source, saved_at, last_refreshed_at FROM seo_keywords WHERE domain=? ORDER BY id")
807
+ .prepare("SELECT keyword, locale, sv, kd, ai_sv, cpc, source, saved_at, last_refreshed_at, family, page_url, serp_verdict, serp_at" +
808
+ " FROM seo_keywords WHERE domain=? ORDER BY id")
652
809
  .bind(domain)
653
810
  .all();
654
811
  return results;
@@ -725,31 +882,45 @@ async function refreshKeywordMetrics(ctx, db, domain, rows, notes, now) {
725
882
  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`);
726
883
  return charged;
727
884
  }
728
- /** 词表条目(0.35.0):`word` `word@ja-JP` —— `@` 后是定价市场,缺省 en-US,白名单外 400。
729
- * 此前非英语词一律按 en-US 定价、全部 no_data;语言页按 skill §7「上线即入表」的契约就成了没有
730
- * 记分牌的孤儿页(tabledi 全球台账 §6 把非英语量价整个留在文件里,进不了 check)。 */
885
+ /** 词表条目(0.35.0 `word@ja-JP`;v2 `#family` / `=url`,design/seo-cell-funnel.md §3.2 B):
886
+ * `csv 分割@ja-JP#split-csv`、`listen vergleichen online@de-DE=/de/tools/listen-vergleichen/`。
887
+ * 族与页二选一,给页的由清单反查族。`#` / `=` 只在条目末尾按这个形状识别(`c# tutorial` 里的 # 不算)。
888
+ * 此前非英语词一律按 en-US 定价、全部 no_data;语言页按 skill §7「上线即入表」的契约就成了没有记分牌的孤儿页。 */
731
889
  export function parseKeywordEntry(raw) {
732
- const s = raw.trim();
733
- const at = s.lastIndexOf("@");
734
- if (at > 0 && at < s.length - 1) {
735
- const { tag } = resolveLocale(s.slice(at + 1).trim());
736
- return { keyword: s.slice(0, at).trim().toLowerCase(), locale: tag };
737
- }
738
- return { keyword: s.toLowerCase(), locale: DEFAULT_LOCALE };
890
+ const m = /^(.+?)(?:@([A-Za-z]{2}(?:-[A-Za-z]{2})?))?(?:=((?:\/|https?:\/\/)[^#\s]*))?(?:#([A-Za-z0-9._-]+))?$/.exec(raw.trim());
891
+ const kw = (m?.[1] ?? raw).trim().toLowerCase();
892
+ const locale = m?.[2] ? resolveLocale(m[2]).tag : DEFAULT_LOCALE;
893
+ const pageUrl = m?.[3] ? m[3].trim() : null;
894
+ const family = m?.[4] ? m[4].toLowerCase() : null;
895
+ return { keyword: kw, locale, family, pageUrl };
739
896
  }
740
897
  /** 输出侧的条目写法:与输入同形,en-US 不带后缀。 */
741
898
  function keywordLabel(k) {
742
899
  return !k.locale || k.locale === DEFAULT_LOCALE ? k.keyword : `${k.keyword}@${k.locale}`;
743
900
  }
744
- /** 词表:--keywords 是**全量替换**(保留已有行的指标),不带则读已存的。同一词可按不同市场各占一行。 */
745
- async function syncKeywords(db, domain, keywords, now) {
746
- if (keywords) {
747
- const wanted = new Map();
748
- for (const raw of keywords) {
901
+ /** 词表:--keywords 是**全量替换**(保留已有行的指标),--add-keywords 是追加;都不带则读已存的。
902
+ * 同一词可按不同市场各占一行。族 / 承载页只在条目给了时覆盖(COALESCE),不给不动。 */
903
+ async function syncKeywords(db, domain, keywords, addKeywords, now) {
904
+ const parse = (raws) => {
905
+ const out = new Map();
906
+ for (const raw of raws) {
749
907
  const e = parseKeywordEntry(raw);
750
908
  if (e.keyword)
751
- wanted.set(`${e.keyword}\u0000${e.locale}`, e);
909
+ out.set(`${e.keyword}\u0000${e.locale}`, e);
752
910
  }
911
+ return out;
912
+ };
913
+ const upsert = async (entries) => {
914
+ for (const e of entries)
915
+ await db
916
+ .prepare("INSERT INTO seo_keywords (domain, keyword, locale, source, saved_at, family, page_url) VALUES (?,?,?,?,?,?,?)" +
917
+ " ON CONFLICT(domain, keyword, locale) DO UPDATE SET source='check'," +
918
+ " family=COALESCE(excluded.family, seo_keywords.family), page_url=COALESCE(excluded.page_url, seo_keywords.page_url)")
919
+ .bind(domain, e.keyword, e.locale, "check", now, e.family, e.pageUrl)
920
+ .run();
921
+ };
922
+ if (keywords) {
923
+ const wanted = parse(keywords);
753
924
  // 全量替换语义:不在新表里的(词, 市场)删掉(含之前 gsc_backfill 进来的)。
754
925
  const { results: existing } = await db
755
926
  .prepare("SELECT keyword, locale FROM seo_keywords WHERE domain=?")
@@ -761,15 +932,24 @@ async function syncKeywords(db, domain, keywords, now) {
761
932
  .prepare("DELETE FROM seo_keywords WHERE domain=? AND keyword=? AND locale=?")
762
933
  .bind(domain, e.keyword, e.locale)
763
934
  .run();
764
- for (const e of wanted.values())
765
- await db
766
- .prepare("INSERT INTO seo_keywords (domain, keyword, locale, source, saved_at) VALUES (?,?,?,?,?)" +
767
- " ON CONFLICT(domain, keyword, locale) DO UPDATE SET source='check'")
768
- .bind(domain, e.keyword, e.locale, "check", now)
769
- .run();
935
+ await upsert(wanted.values());
770
936
  }
937
+ if (addKeywords)
938
+ await upsert(parse(addKeywords).values());
771
939
  return readKeywords(db, domain);
772
940
  }
941
+ /** `--locales` 的条目:白名单内按白名单写法,白名单外保留规范形(it / nl):这些语区照样能算格子,只是不能定价。 */
942
+ function resolveLocaleLoose(raw) {
943
+ try {
944
+ return resolveLocale(raw).tag;
945
+ }
946
+ catch {
947
+ const m = /^([a-z]{2})(?:[-_]([a-z]{2}))?$/i.exec(raw.trim());
948
+ if (!m)
949
+ throw new ClizeError(`unknown locale "${raw}"`, 400);
950
+ return m[2] ? `${m[1].toLowerCase()}-${m[2].toUpperCase()}` : m[1].toLowerCase();
951
+ }
952
+ }
773
953
  /** 测一轮。cron 若将来复活,定期跑的就是这个函数(同一条代码路径)。 */
774
954
  export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
775
955
  const domain = normalizeHost(domain0.replace(/^https?:\/\//, "").split("/")[0] ?? "");
@@ -783,7 +963,7 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
783
963
  const win = measureWindow(now);
784
964
  const conf = await upsertDomain(ctx, db, domain, cfg);
785
965
  const competitors = conf.competitors ? JSON.parse(conf.competitors) : [];
786
- const kwRows = await syncKeywords(db, domain, cfg.keywords, ranAt);
966
+ const kwRows = await syncKeywords(db, domain, cfg.keywords, cfg.addKeywords, ranAt);
787
967
  const notes = [];
788
968
  // ── 流量分源(自有基建,不花数据钱)。AI 来源单列;全零也返回,零就是信息。
789
969
  let traffic = null;
@@ -804,8 +984,21 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
804
984
  " referrers=excluded.referrers, ai_sources=excluded.ai_sources")
805
985
  .bind(domain, traffic.from.slice(0, 10), traffic.to.slice(0, 10), traffic.pv, JSON.stringify(Object.fromEntries(traffic.topReferrers.map((r) => [r.host, r.pv]))), JSON.stringify(traffic.ai.byEngine))
806
986
  .run();
987
+ // ── 页面清单(资产,产品自记;design/seo-cell-funnel.md v2 §3.2 A):sitemap → seo_pages,
988
+ // 新页 / lastmod 变了的页抓一次页首(hreflang / lang / noindex / title),按 hreflang 互链分族。
989
+ // 自己的站,零上游费用;任何失败只降级成 note。托管站的清单由 deploy 直接写,这里只补 sitemap 没有的。
990
+ const sitemap = await readSitemap(domain);
991
+ if (!sitemap.urls.length)
992
+ notes.push("pages: no sitemap found (/sitemap.xml or a Sitemap: line in robots.txt) — pages Google was never shown cannot be listed; publish one to get index status for pages without impressions");
993
+ const firstSeen = await rememberPages(db, domain, sitemap.urls, now);
994
+ try {
995
+ await refreshPageHeads(db, domain, notes, now);
996
+ }
997
+ catch (e) {
998
+ notes.push(`pages: inventory not refreshed this round (${e instanceof Error ? e.message : String(e)})`);
999
+ }
807
1000
  // ── GSC(唯一的搜索面)。拿不到不中断整轮 —— 给 null + hint。
808
- const gsc = await gscSection(ctx, db, domain, conf, kwRows, notes, win, now);
1001
+ const gsc = await gscSection(ctx, db, domain, conf, kwRows, notes, win, now, sitemap, firstSeen);
809
1002
  // ── 补指标:回填可能刚往词表里加了词,所以先重读一次(否则 total 会漏掉本轮的新发现),
810
1003
  // 再把缺指标 / 指标过期的词批量取一次写回。**这是 check 唯一可能花钱的一步**,
811
1004
  // 30 天内重跑不重复取、不重复计费;预算不够就跳过并记 note,不毁掉免费的两段。
@@ -836,6 +1029,14 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
836
1029
  }
837
1030
  if (unpriced)
838
1031
  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)`);
1032
+ // ── 格子面:页面清单 × 词表意图。全部免费源;失败只降级。
1033
+ let cells = null;
1034
+ try {
1035
+ cells = await cellsSection(db, domain, kwAll, conf, win, rank, notes);
1036
+ }
1037
+ catch (e) {
1038
+ notes.push(`cells: unavailable this round (${e instanceof Error ? e.message : String(e)})`);
1039
+ }
839
1040
  // ── 可用性分层如实回显(seo-geo-product.md §2.4):check 的三源(词表指标/流量/GSC)
840
1041
  // 不是对所有站都成立 —— 托管站有流量没 GSC,外部站加了 SA 才有 GSC,裸外部站只剩
841
1042
  // 词表一源。对一源站假装三源测量是口径事故;缺哪源、去哪解锁,一行说清。
@@ -863,6 +1064,7 @@ export async function checkOne(ctx, db, domain0, cfg = {}, now = Date.now()) {
863
1064
  rank,
864
1065
  traffic,
865
1066
  gsc,
1067
+ cells,
866
1068
  notes,
867
1069
  // 测量本身是免费的(GSC service account + 自家 CF RUM);花的只可能是补词指标那一步。
868
1070
  charged,
@@ -940,13 +1142,9 @@ async function aggregateGscByPage(db, domain, from, to) {
940
1142
  /** 页面面(0.34.0):每页两窗口曝光 × 实况状态 × 收录态。GSC 段成功后才有(数据都在明细表里)。
941
1143
  * 探测与 inspection 的任何失败只降级成 note,不中断 check;收录态按预算分配:
942
1144
  * 塌方的 movers 先(「页丢了还是掉了」)→ sitemap 里零曝光的新页(「Google 收了没」)→ 头部页。 */
943
- async function pagesSection(db, domain, gscClient, property, win, notes, now) {
1145
+ async function pagesSection(db, domain, gscClient, property, win, notes, now, sitemap, firstSeen) {
944
1146
  const cur = await aggregateGscByPage(db, domain, win.from, win.to);
945
1147
  const prev = await aggregateGscByPage(db, domain, win.prevFrom, win.prevTo);
946
- const sitemap = await readSitemap(domain);
947
- if (!sitemap.urls.length)
948
- notes.push("pages: no sitemap found (/sitemap.xml or a Sitemap: line in robots.txt) — pages Google was never shown cannot be listed; publish one to get index status for pages without impressions");
949
- const firstSeen = await rememberSitemap(db, domain, sitemap.urls, now);
950
1148
  const face = selectPages(cur, prev, sitemap.urls, firstSeen);
951
1149
  if (face.unseenTotal > face.unseen.length)
952
1150
  notes.push(`pages: unseen lists ${face.unseen.length} of ${face.unseenTotal} zero-impression sitemap URLs (newest first seen first)`);
@@ -989,36 +1187,217 @@ async function pagesSection(db, domain, gscClient, property, win, notes, now) {
989
1187
  notes.push(`pages: index status unavailable — ${stop}`);
990
1188
  else if (candidates)
991
1189
  notes.push(`pages: index status checked for ${done} of ${order.length} candidate page(s) — collapsed movers first, then the most recently first-seen sitemap URLs without impressions, then top pages (${INSPECT_MAX} per check)`);
1190
+ // 实况状态与收录态回写清单(页面是资产:退役规则要看「已收录 + 零曝光」,不能只活在一轮的返回里)。
1191
+ const stamp = new Date(now).toISOString();
1192
+ await writeAll(db, [...face.latest, ...face.movers, ...face.unseen]
1193
+ .filter((r) => r.status !== null || r.index)
1194
+ .map((r) => db
1195
+ .prepare("UPDATE seo_pages SET status=COALESCE(?, status), index_state=COALESCE(?, index_state)," +
1196
+ " index_verdict=COALESCE(?, index_verdict), index_at=COALESCE(?, index_at) WHERE domain=? AND url=?")
1197
+ .bind(r.status, r.index?.state ?? null, r.index?.verdict ?? null, r.index ? stamp : null, domain, r.page)));
992
1198
  return { ...face, sitemap: { source: sitemap.source, urls: sitemap.urls.length } };
993
1199
  }
994
- /** sitemap 首见表(0.35.0):每次 check 把 sitemap 里的 URL upsert 一遍,首见日期只写一次。
1200
+ /** 页面清单(v2,原 0.35.0 sitemap 首见表):每次 check 把 sitemap 里的 URL upsert 进 seo_pages,首见日期只写一次。
995
1201
  * 「这批页是刚上线的还是上一批的」lastmod 分不出来 —— 一次全站改版(2026-09-02 主站 124 条全同日)
996
1202
  * 就把新页挤出 unseen 的检查配额;首见是机器自己记得住的事实,与站的生成器无关。首轮入库时
997
- * 用 lastmod 兜底(没有就当天),之后只更新 last_seen。 */
998
- async function rememberSitemap(db, domain, urls, now) {
1203
+ * 用 lastmod 兜底(没有就当天),之后只更新 last_seen / lastmod。托管站 deploy 写进来的行(source=deploy)不动 source。 */
1204
+ async function rememberPages(db, domain, urls, now) {
999
1205
  const out = new Map();
1000
- if (!urls.length)
1001
- return out;
1002
1206
  const today = new Date(now).toISOString().slice(0, 10);
1003
- // 只在这个站第一次入库时拿 lastmod 兜底(一次性回填);之后没见过的 URL 就是新页,首见 = 今天 ——
1004
- // 否则一张带旧 lastmod 的新页会被当成老页,排序又退回 lastmod。
1005
- const { results: any } = await db
1006
- .prepare("SELECT 1 AS one FROM seo_sitemap_urls WHERE domain=? LIMIT 1")
1007
- .bind(domain)
1008
- .all();
1009
- const bootstrap = !any.length;
1010
- await writeAll(db, urls.map((u) => db
1011
- .prepare("INSERT INTO seo_sitemap_urls (domain, url, first_seen_at, last_seen_at) VALUES (?,?,?,?)" +
1012
- " ON CONFLICT(domain, url) DO UPDATE SET last_seen_at=excluded.last_seen_at")
1013
- .bind(domain, u.loc, bootstrap && u.lastmod && u.lastmod < today ? u.lastmod : today, today)));
1207
+ if (urls.length) {
1208
+ const { results: any } = await db
1209
+ .prepare("SELECT 1 AS one FROM seo_pages WHERE domain=? LIMIT 1")
1210
+ .bind(domain)
1211
+ .all();
1212
+ const bootstrap = !any.length;
1213
+ await writeAll(db, urls.map((u) => db
1214
+ .prepare("INSERT INTO seo_pages (domain, url, first_seen_at, last_seen_at, source, lastmod) VALUES (?,?,?,?,'sitemap',?)" +
1215
+ " ON CONFLICT(domain, url) DO UPDATE SET last_seen_at=excluded.last_seen_at, lastmod=excluded.lastmod")
1216
+ .bind(domain, u.loc, bootstrap && u.lastmod && u.lastmod < today ? u.lastmod : today, today, u.lastmod)));
1217
+ }
1014
1218
  const { results } = await db
1015
- .prepare("SELECT url, first_seen_at FROM seo_sitemap_urls WHERE domain=?")
1219
+ .prepare("SELECT url, first_seen_at FROM seo_pages WHERE domain=?")
1016
1220
  .bind(domain)
1017
1221
  .all();
1018
1222
  for (const r of results)
1019
1223
  out.set(r.url, r.first_seen_at);
1020
1224
  return out;
1021
1225
  }
1226
+ /** 一轮 check 最多抓多少页的页首(自己的站,不花数据钱,只花时间;新页与 lastmod 变了的页优先)。 */
1227
+ const HEADS_PER_CHECK = 300;
1228
+ async function readPages(db, domain) {
1229
+ const { results } = await db
1230
+ .prepare("SELECT url, locale, family, first_seen_at, lastmod, head_at, alternates, noindex, index_state, index_verdict, source" +
1231
+ " FROM seo_pages WHERE domain=? ORDER BY first_seen_at DESC, url")
1232
+ .bind(domain)
1233
+ .all();
1234
+ return results;
1235
+ }
1236
+ const parseAlternates = (json) => {
1237
+ if (!json)
1238
+ return {};
1239
+ try {
1240
+ const v = JSON.parse(json);
1241
+ return v && typeof v === "object" ? v : {};
1242
+ }
1243
+ catch {
1244
+ return {};
1245
+ }
1246
+ };
1247
+ /** 页首抓取 + 分族(design/seo-cell-funnel.md §3.2 A):没抓过 / lastmod 变了的页抓一次页首,然后对全站
1248
+ * 按 hreflang 互链重算族与语区。**agent 不声明任何页面字段** —— 语言、族、标题、noindex 全从站上读。
1249
+ * 托管站(source=deploy)的行由 deploy 写清单,这里不抓。 */
1250
+ async function refreshPageHeads(db, domain, notes, now) {
1251
+ const rows = await readPages(db, domain);
1252
+ if (!rows.length)
1253
+ return;
1254
+ const stale = rows.filter((r) => r.source !== "deploy" && (!r.head_at || (r.lastmod !== null && r.lastmod > r.head_at.slice(0, 10))));
1255
+ const todo = stale.slice(0, HEADS_PER_CHECK);
1256
+ const langThisRound = new Map();
1257
+ if (todo.length) {
1258
+ const heads = await fetchPageHeads(todo.map((r) => r.url));
1259
+ const stamp = new Date(now).toISOString();
1260
+ const stmts = [];
1261
+ for (const r of todo) {
1262
+ const h = heads.get(r.url);
1263
+ if (!h || h.status === null)
1264
+ continue; // 探不到:下轮再试,不记 head_at
1265
+ const head = h.html ? parsePageHead(h.html, r.url) : null;
1266
+ if (head) {
1267
+ r.alternates = JSON.stringify(head.alternates);
1268
+ r.noindex = head.noindex ? 1 : 0;
1269
+ langThisRound.set(r.url, head.lang);
1270
+ }
1271
+ stmts.push(db
1272
+ .prepare("UPDATE seo_pages SET status=?, head_at=?, alternates=COALESCE(?, alternates), title=COALESCE(?, title), noindex=?" +
1273
+ " WHERE domain=? AND url=?")
1274
+ .bind(h.status, stamp, head ? JSON.stringify(head.alternates) : null, head?.title ?? null, head?.noindex ? 1 : 0, domain, r.url));
1275
+ }
1276
+ await writeAll(db, stmts);
1277
+ if (stale.length > todo.length)
1278
+ notes.push(`pages: read the head of ${todo.length} of ${stale.length} new or changed page(s) this round (${HEADS_PER_CHECK} per check)`);
1279
+ }
1280
+ // 分族:全站一起算(簇是全局关系;缓存的 alternates 与本轮新抓的一起进并查集)。
1281
+ const fam = derivePageFamilies(rows.map((r) => ({ url: r.url, lang: langThisRound.get(r.url) ?? null, alternates: parseAlternates(r.alternates) })), DEFAULT_LOCALE);
1282
+ const updates = [];
1283
+ for (const r of rows) {
1284
+ const f = fam.get(normalizePageUrl(r.url));
1285
+ if (!f)
1286
+ continue;
1287
+ // 语区:本轮抓过或还没有的页按派生值;已有值且本轮没抓的页不动(别让一次抓失败把已知语区抹掉)。
1288
+ const locale = langThisRound.has(r.url) || !r.locale ? f.locale : r.locale;
1289
+ if (f.family !== r.family || locale !== r.locale)
1290
+ updates.push(db.prepare("UPDATE seo_pages SET family=?, locale=? WHERE domain=? AND url=?").bind(f.family, locale, domain, r.url));
1291
+ }
1292
+ await writeAll(db, updates);
1293
+ }
1294
+ /** 同词落多页的展开(rank.latest.pages > 1 的那些 query):本窗口有曝光的页,按曝光降序,最多 5 页。 */
1295
+ async function pagesOfQueries(db, domain, queries, from, to) {
1296
+ const out = new Map();
1297
+ if (!queries.length)
1298
+ return out;
1299
+ const wanted = new Set(queries);
1300
+ const { results } = await db
1301
+ .prepare("SELECT query, page, SUM(impressions) AS impressions FROM seo_gsc_daily" +
1302
+ " WHERE domain=? AND date>=? AND date<=? GROUP BY query, page")
1303
+ .bind(domain, from, to)
1304
+ .all();
1305
+ const acc = new Map();
1306
+ for (const r of results) {
1307
+ if (!wanted.has(r.query) || !r.page || !r.impressions)
1308
+ continue;
1309
+ acc.set(r.query, [...(acc.get(r.query) ?? []), { page: r.page, impressions: r.impressions }]);
1310
+ }
1311
+ for (const [q, pages] of acc)
1312
+ out.set(q, pages
1313
+ .sort((a, b) => b.impressions - a.impressions || (a.page < b.page ? -1 : 1))
1314
+ .slice(0, 5)
1315
+ .map((p) => p.page));
1316
+ return out;
1317
+ }
1318
+ /** 格子面:页面清单 × 词表意图。绑定(族 + 语区 → 页)在这里做并写回词表;派生全在 seo-cells.ts 的纯函数里。 */
1319
+ async function cellsSection(db, domain, kwAll, conf, win, rank, notes) {
1320
+ const pageRows = await readPages(db, domain);
1321
+ const cur = await aggregateGscByPage(db, domain, win.from, win.to);
1322
+ const prev = await aggregateGscByPage(db, domain, win.prevFrom, win.prevTo);
1323
+ const norm = (m) => {
1324
+ const out = new Map();
1325
+ for (const [p, a] of m)
1326
+ out.set(normalizePageUrl(p), (out.get(normalizePageUrl(p)) ?? 0) + a.impressions);
1327
+ return out;
1328
+ };
1329
+ const curN = norm(cur);
1330
+ const prevN = norm(prev);
1331
+ const pages = pageRows.map((r) => ({
1332
+ url: r.url,
1333
+ locale: r.locale,
1334
+ family: r.family,
1335
+ firstSeen: r.first_seen_at,
1336
+ noindex: !!r.noindex,
1337
+ indexState: r.index_state,
1338
+ indexVerdict: r.index_verdict,
1339
+ impressions: curN.get(normalizePageUrl(r.url)) ?? 0,
1340
+ prevImpressions: prev.size ? (prevN.get(normalizePageUrl(r.url)) ?? 0) : null,
1341
+ alternates: parseAlternates(r.alternates),
1342
+ }));
1343
+ // 绑定:族 + 语区 → 清单里的页;给了页没给族的 → 从页反查族。写回词表(下一轮就是事实)。
1344
+ const byUrl = new Map();
1345
+ for (const r of pageRows)
1346
+ byUrl.set(normalizePageUrl(r.url), r);
1347
+ const byFamLoc = new Map();
1348
+ for (const r of pageRows)
1349
+ if (r.family && r.locale && !byFamLoc.has(`${r.family}\u0000${r.locale}`))
1350
+ byFamLoc.set(`${r.family}\u0000${r.locale}`, r.url);
1351
+ const updates = [];
1352
+ const keywords = [];
1353
+ // 条目里的 `=/path/` 是站内相对路径:按域补全成绝对 URL 再比对 / 存回
1354
+ const absolute = (u) => (u.startsWith("/") ? `https://${domain}${u}` : u);
1355
+ for (const k of kwAll) {
1356
+ if (k.source === "gsc_backfill")
1357
+ continue;
1358
+ let family = k.family;
1359
+ let pageUrl = k.page_url ? absolute(k.page_url) : null;
1360
+ if (!family && pageUrl)
1361
+ family = byUrl.get(normalizePageUrl(pageUrl))?.family ?? familyKeyOf(pageUrl);
1362
+ if (family && (!pageUrl || !byUrl.has(normalizePageUrl(pageUrl)))) {
1363
+ const bound = byFamLoc.get(`${family}\u0000${k.locale}`);
1364
+ if (bound)
1365
+ pageUrl = bound;
1366
+ }
1367
+ if (family !== k.family || pageUrl !== k.page_url)
1368
+ updates.push(db
1369
+ .prepare("UPDATE seo_keywords SET family=?, page_url=? WHERE domain=? AND keyword=? AND locale=?")
1370
+ .bind(family, pageUrl, domain, k.keyword, k.locale));
1371
+ keywords.push({
1372
+ keyword: k.keyword,
1373
+ locale: k.locale,
1374
+ family,
1375
+ pageUrl,
1376
+ serpVerdict: k.serp_verdict,
1377
+ sv: k.sv,
1378
+ band: keywordBand(k.sv, k.kd),
1379
+ });
1380
+ }
1381
+ await writeAll(db, updates);
1382
+ // 同词落多页:rank 已经算出 pages > 1 的词,这里只展开页清单。
1383
+ const split = (rank?.latest ?? []).filter((r) => r.pages > 1);
1384
+ const pagesOf = await pagesOfQueries(db, domain, split.map((r) => r.keyword), win.from, win.to);
1385
+ const cannibal = split
1386
+ .map((r) => ({ query: r.keyword, locale: r.locale, pages: pagesOf.get(r.keyword) ?? [] }))
1387
+ .filter((c) => c.pages.length > 1);
1388
+ let locales;
1389
+ if (conf.locales)
1390
+ try {
1391
+ locales = JSON.parse(conf.locales);
1392
+ }
1393
+ catch {
1394
+ locales = undefined;
1395
+ }
1396
+ const face = deriveCells(pages, keywords, { locales, windowTo: win.to, cannibal, priceable: new Set(Object.keys(LOCALES)) });
1397
+ const d = face.defects.noAlternates.length + face.defects.oneWay.length + face.defects.sameLocaleInCluster.length;
1398
+ notes.push(`cells: ${face.byStatus.missing} missing · ${face.byStatus.queued} queued (${face.byStatus.family_unverified} families unverified → serp their first keyword) · ${face.byStatus.built} built · ${face.byStatus.retire} to retire · ${face.cannibal.length} cannibal · ${d} defects`);
1399
+ return face;
1400
+ }
1022
1401
  /** 位次段:词表 join GSC 明细。纯 D1 读,零上游调用。 */
1023
1402
  async function rankSection(db, domain, tracked, win, notes) {
1024
1403
  const cur = await aggregateGsc(db, domain, win.from, win.to);
@@ -1194,7 +1573,7 @@ async function writeAll(db, stmts) {
1194
1573
  await db.batch(stmts.slice(i, i + D1_BATCH));
1195
1574
  }
1196
1575
  /** GSC 段:窗口重拉替换 + 新词回填。任何失败都降级成 note,不中断 check。 */
1197
- async function gscSection(ctx, db, domain, conf, kwRows, notes, win, now) {
1576
+ async function gscSection(ctx, db, domain, conf, kwRows, notes, win, now, sitemap, firstSeen) {
1198
1577
  if (conf.gsc_property === GSC_OFF) {
1199
1578
  notes.push("gsc: switched off for this domain (--gsc none) — pass --gsc <property> to turn it back on");
1200
1579
  return null;
@@ -1308,7 +1687,7 @@ async function gscSection(ctx, db, domain, conf, kwRows, notes, win, now) {
1308
1687
  // 自己失败只降级(pages: null + note),不毁掉上面已经算好的 GSC 段。
1309
1688
  let pages = null;
1310
1689
  try {
1311
- pages = await pagesSection(db, domain, gscClient, property, win, notes, now);
1690
+ pages = await pagesSection(db, domain, gscClient, property, win, notes, now, sitemap, firstSeen);
1312
1691
  }
1313
1692
  catch (e) {
1314
1693
  notes.push(`pages: unavailable this round (${e instanceof Error ? e.message : String(e)})`);
@@ -1323,4 +1702,47 @@ async function gscSection(ctx, db, domain, conf, kwRows, notes, win, now) {
1323
1702
  pages,
1324
1703
  };
1325
1704
  }
1705
+ /** 托管站(design/seo-cell-funnel.md §3.2 A):deploy plan 时把清单里的页直接写进 seo_pages(source=deploy),
1706
+ * 族 / 语区 = deploy GEO-ready 按目录派生的 hreflang 族 —— 页面是资产、产品自记,托管站连 sitemap 都不用读。
1707
+ * best-effort:任何失败都不该拦 deploy(调用方吞掉);首见日期用清单里的 mtime 兜底(同 sitemap 首见表的做法)。 */
1708
+ export async function recordDeployedPages(db, host, m, now = Date.now()) {
1709
+ const domain = normalizeHost(host);
1710
+ const today = new Date(now).toISOString().slice(0, 10);
1711
+ const abs = (p) => `https://${domain}${p.startsWith("/") ? p : "/" + p}`;
1712
+ // 反查:URL 形路径 → 族键 + 语区 + 全族 alternates
1713
+ const byPath = new Map();
1714
+ for (const [stem, fam] of Object.entries(m.families)) {
1715
+ const family = familyKeyOf(abs(stem));
1716
+ const alternates = {};
1717
+ for (const [tag, p] of Object.entries(fam))
1718
+ alternates[tag] = abs(p);
1719
+ for (const [tag, p] of Object.entries(fam)) {
1720
+ if (tag.toLowerCase() === "x-default")
1721
+ continue;
1722
+ const prev = byPath.get(p);
1723
+ byPath.set(p, { family, locale: normalizeLocaleTag(tag) ?? prev?.locale ?? null, alternates });
1724
+ }
1725
+ }
1726
+ const stmts = [];
1727
+ let n = 0;
1728
+ for (const [path, f] of Object.entries(m.files)) {
1729
+ if (!isIndexablePage(path))
1730
+ continue;
1731
+ const up = pageUrlPath(path);
1732
+ const url = abs(up);
1733
+ const fam = byPath.get(up);
1734
+ const family = fam?.family ?? familyKeyOf(url);
1735
+ const locale = fam?.locale ?? localeOfUrl(url) ?? DEFAULT_LOCALE;
1736
+ const firstSeen = f.mtime && f.mtime.slice(0, 10) < today ? f.mtime.slice(0, 10) : today;
1737
+ stmts.push(db
1738
+ .prepare("INSERT INTO seo_pages (domain, url, first_seen_at, last_seen_at, source, locale, family, alternates, head_at)" +
1739
+ " VALUES (?,?,?,?,'deploy',?,?,?,?)" +
1740
+ " ON CONFLICT(domain, url) DO UPDATE SET last_seen_at=excluded.last_seen_at, source='deploy'," +
1741
+ " locale=excluded.locale, family=excluded.family, alternates=excluded.alternates, head_at=excluded.head_at")
1742
+ .bind(domain, url, firstSeen, today, locale, family, JSON.stringify(fam?.alternates ?? {}), new Date(now).toISOString()));
1743
+ n++;
1744
+ }
1745
+ await writeAll(db, stmts);
1746
+ return n;
1747
+ }
1326
1748
  //# sourceMappingURL=seo.js.map