@gobing-ai/knowledge-kit 0.1.8 → 0.1.9

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.
Files changed (30) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.js +725 -1101
  3. package/dist/board/daily-voice/index.js +722 -101
  4. package/dist/board/itc/index.js +13 -0
  5. package/dist/index.js +1792 -102
  6. package/package.json +1 -1
  7. package/plugins/ingestions/horizon-ingest/dist/index.js +24 -1
  8. package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
  9. package/plugins/kk/config.example.yaml +8 -0
  10. package/plugins/kk/plugin.json +1 -1
  11. package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
  12. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
  13. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
  14. package/plugins/kk/scripts/daily-stages.ts +580 -78
  15. package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
  16. package/plugins/kk/scripts/topic-boundary.json +18 -83
  17. package/plugins/kk/workflows/kk-collect.yaml +2 -0
  18. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +75 -6
  19. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
  20. package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
  21. package/plugins/publishings/podcast-pub/README.md +65 -0
  22. package/plugins/publishings/podcast-pub/dist/index.js +1709 -274
  23. package/plugins/publishings/podcast-pub/plugin.json +1 -1
  24. package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
  25. package/plugins/publishings/podcast-pub/src/index.ts +211 -34
  26. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
  27. package/plugins/publishings/podcast-pub/src/seo.ts +1081 -0
  28. package/plugins/publishings/podcast-pub/src/show-notes.ts +71 -6
  29. package/plugins/publishings/surfdash-pub/dist/index.js +66 -5
  30. package/plugins/publishings/surfdash-pub/src/index.ts +94 -10
@@ -162,7 +162,6 @@ export function dailyQcContent(args: string[]): void {
162
162
  },
163
163
  },
164
164
  );
165
- applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
166
165
  });
167
166
  // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
168
167
  // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
@@ -197,15 +196,21 @@ export function dailyQcContent(args: string[]): void {
197
196
  // `unknown-id`, the whole plan is rejected, and generate then sees a template with fewer sections.
198
197
  const currentDocs = excluded.length > 0 ? kept : boundaryDocs;
199
198
  const { kept: deduped, dropped } = dedupPlanByTitle(currentDocs as Array<{ id?: unknown; title?: unknown }>);
200
- if (dropped.length === 0) return;
201
- const dedupedContent = requireJson(candidates, 'qc-dedup');
202
- const dedupedMeta = dedupedContent.metadata;
203
- if (dedupedMeta === null || typeof dedupedMeta !== 'object') {
204
- failWith(1, 'qc-dedup: candidates content carries no metadata object');
199
+ if (dropped.length > 0) {
200
+ const dedupedContent = requireJson(candidates, 'qc-dedup');
201
+ const dedupedMeta = dedupedContent.metadata;
202
+ if (dedupedMeta === null || typeof dedupedMeta !== 'object') {
203
+ failWith(1, 'qc-dedup: candidates content carries no metadata object');
204
+ }
205
+ (dedupedMeta as Record<string, unknown>).docs = deduped;
206
+ sted('write', candidates, () => writeJsonText(candidates, dedupedContent));
207
+ console.error(`qc: dropped ${dropped.length} near-duplicate candidate(s): ${dropped.join(', ')}`);
205
208
  }
206
- (dedupedMeta as Record<string, unknown>).docs = deduped;
207
- sted('write', candidates, () => writeJsonText(candidates, dedupedContent));
208
- console.error(`qc: dropped ${dropped.length} near-duplicate candidate(s): ${dropped.join(', ')}`);
209
+ // Last, not inside the timed block above: the estimate must see the pool every shrink pass has
210
+ // already produced. 2026-10-22 cleared the floor on a 4-item pool (21.0 min estimated) that the
211
+ // topic boundary then cut to 3 (14.4 min), so the run paid for a full synthesis the measured
212
+ // gate refused. Covered here it also re-runs on a cached-rerun, which the timed block skipped.
213
+ applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
209
214
  }
210
215
 
211
216
  /**
@@ -287,7 +292,7 @@ function applyFloorGate(
287
292
  }
288
293
 
289
294
  /** Widest window the extract ladder may reach; a "daily" show must not import a week-old story. */
290
- const LADDER_MAX_MS = 72 * 3_600_000;
295
+ const LADDER_MAX_MS = 168 * 3_600_000;
291
296
 
292
297
  const WINDOW_UNIT_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
293
298
 
@@ -307,13 +312,17 @@ function formatWindowMs(ms: number): string {
307
312
 
308
313
  /**
309
314
  * Pool extraction with a conditional window ladder (2026-10-19 operator decision, superseding the
310
- * 0266 D1 blanket rejection). `kk store extract` reads one first_seen window and `applyFloorGate`
315
+ * 0266 D1 blanket rejection; extended ×7/168h 2026-10-23 when upstream supply collapsed and the
316
+ * 72h ceiling again closed a day as no-content while a week of older unselected items sat in the
317
+ * store). `kk store extract` reads one first_seen window and `applyFloorGate`
311
318
  * then refuses a pool that cannot reach `episode_min_minutes`. Together they ate whole days: an
312
319
  * episode consumes 6-12 stories while a day of collection adds ~3-13 qualifying ones, so the 04:30
313
320
  * runs of 2026-10-18 and 2026-10-19 saw 2- and 3-item pools and closed as no-content while older
314
321
  * unselected items sat in the store. The ladder keeps the configured window as the first choice and
315
- * widens (×2, ×3, capped at 72h) ONLY when that pool's estimate misses the floor — a rich day is
316
- * extracted exactly once, and a thin one is filled from the backlog instead of dropped.
322
+ * widens (×2, ×3, ×7, capped at 168h) ONLY when that pool's estimate misses the floor — a rich day is
323
+ * extracted exactly once, and a thin one is filled from the backlog instead of dropped. The store's
324
+ * QC gates still read the aged items' decayed urgency, so a widened pool does not force stale stories
325
+ * into an episode by itself.
317
326
  *
318
327
  * The store's `selections` ledger still excludes every story a previous run took, so a widened
319
328
  * window can only add items no episode has used. The estimate here runs on the raw pool (before QC
@@ -329,7 +338,7 @@ export function dailyExtract(args: string[]): void {
329
338
  const floor = Number(minMinutes);
330
339
  const gated = minMinutes !== '' && Number.isFinite(floor) && floor > 0;
331
340
  const windows: number[] = [base];
332
- for (const factor of gated ? [2, 3] : []) {
341
+ for (const factor of gated ? [2, 3, 7] : []) {
333
342
  const ms = base * factor;
334
343
  if (ms > LADDER_MAX_MS) break;
335
344
  windows.push(ms);
@@ -574,6 +583,52 @@ export function dailyVoiceAudit(args: string[]): void {
574
583
  }
575
584
  }
576
585
 
586
+ /**
587
+ * Empty the QC candidates file so every downstream `pool-count --min 1` guard skips the content,
588
+ * audio, publish and XHS subtrees — the one mechanism both refusal paths use to close a run as
589
+ * no-content without aborting it (0266).
590
+ */
591
+ function emptyCandidates(workDir: string, runDate: string): void {
592
+ const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
593
+ const content = readJson(candidates);
594
+ if (content === null) return;
595
+ sted('write', candidates, () =>
596
+ writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
597
+ );
598
+ }
599
+
600
+ /**
601
+ * The pre-spend refusal of `daily-generate`'s script gate: same artifact and the same candidate
602
+ * emptying as the measured gate, plus `stage: "script"` so the run report can name which gate
603
+ * closed the day. Nothing was synthesized, so there is no measured duration to record.
604
+ */
605
+ function refuseScriptBelowFloor(
606
+ workDir: string,
607
+ runDate: string,
608
+ gate: { chars: number; charRate: number; seconds: number; minMinutes: number },
609
+ ): void {
610
+ const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
611
+ const candidateDocs = metadataOf(readJson(candidates)).docs;
612
+ const keptCount = Array.isArray(candidateDocs) ? candidateDocs.length : 0;
613
+ const refusal = join(workDir, '30-audio', `${runDate}_11_generate_content_duration-refusal.json`);
614
+ const shown = (gate.seconds / 60).toFixed(1);
615
+ sted('write', refusal, () =>
616
+ writeJqText(refusal, {
617
+ stage: 'script',
618
+ estimatedMinutes: Math.round((gate.seconds / 60) * 10) / 10,
619
+ minMinutes: gate.minMinutes,
620
+ keptCount,
621
+ scriptChars: gate.chars,
622
+ charRate: gate.charRate,
623
+ reason: `the authored script estimates ${shown} min of speech, below the ${gate.minMinutes} min floor — refusing before the TTS spend`,
624
+ }),
625
+ );
626
+ emptyCandidates(workDir, runDate);
627
+ console.error(
628
+ `generate: script estimates ${shown} min (${gate.chars} chars at ${gate.charRate}/s), below the ${gate.minMinutes} min floor — candidates emptied, the run closes as no-content before any TTS spend (see ${refusal})`,
629
+ );
630
+ }
631
+
577
632
  export function dailyEpisodeDuration(args: string[]): void {
578
633
  const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = ''] = args;
579
634
  if (!workDir) fail('missing work_dir');
@@ -609,11 +664,7 @@ export function dailyEpisodeDuration(args: string[]): void {
609
664
  // A kill between the two writes below leaves the refusal beside non-empty candidates, which would
610
665
  // let the pool-gated publish fan package a sub-floor episode — so finish the emptying here.
611
666
  if (isFile(refusal)) {
612
- if (content !== null && docs.length > 0) {
613
- sted('write', candidates, () =>
614
- writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
615
- );
616
- }
667
+ if (docs.length > 0) emptyCandidates(workDir, runDate);
617
668
  console.log(`episode-duration: duration refusal already recorded (${refusal}) — nothing to do`);
618
669
  return;
619
670
  }
@@ -629,11 +680,7 @@ export function dailyEpisodeDuration(args: string[]): void {
629
680
  reason: `the episode ran ${shown} min of speech, below the ${minMinutes} min floor (pre-spend estimate ${estimated.minutes} min) — refusing after synthesis`,
630
681
  }),
631
682
  );
632
- if (content !== null) {
633
- sted('write', candidates, () =>
634
- writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
635
- );
636
- }
683
+ emptyCandidates(workDir, runDate);
637
684
  console.error(
638
685
  `episode-duration: episode ran ${shown} min (estimate ${estimated.minutes} min), below the ${minMinutes}–${maxMinutes} min requirement — candidates emptied, the run closes as no-content (see ${refusal})`,
639
686
  );
@@ -645,6 +692,17 @@ export function dailyEpisodeDuration(args: string[]): void {
645
692
  `episode-duration: episode runs ${shown} min, above the ${minMinutes}–${maxMinutes} min requirement. Trim items or per-item treatment.`,
646
693
  );
647
694
  }
695
+ // A passing gate leaves this artifact behind only: the 2026-10-20 run published an 8.4-min
696
+ // episode with nothing on disk to say whether the gate ran, passed, or was stale (2026-10-22).
697
+ const verdict = join(workDir, '30-audio', `${runDate}_11_generate_content_duration.json`);
698
+ sted('write', verdict, () =>
699
+ writeJqText(verdict, {
700
+ measuredMinutes: Math.round(minutes * 10) / 10,
701
+ minMinutes,
702
+ maxMinutes,
703
+ ok: true,
704
+ }),
705
+ );
648
706
  console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
649
707
  }
650
708
 
@@ -844,6 +902,7 @@ export function dailyGenerate(args: string[]): void {
844
902
  vdriver = '',
845
903
  sectionConcurrency = '',
846
904
  episodeMaxMinutes = '',
905
+ episodeMinMinutes = '',
847
906
  ] = args;
848
907
  // 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
849
908
  // passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
@@ -868,6 +927,37 @@ export function dailyGenerate(args: string[]): void {
868
927
  const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
869
928
  const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
870
929
 
930
+ // Pre-spend script floor gate (2026-10-22). QC's candidate-text estimate does not predict the
931
+ // spoken length — 20261020 planned 3968 chars and rendered 8.4 min, 20261021 planned 4548 and
932
+ // rendered 17.9 — but the authored VoiceScript IS the spoken text, and 14 measured episodes
933
+ // deliver it at 5.24-5.94 chars/s. Reusing `spokenCharRate` (the slowest measured band) makes the
934
+ // estimate over-predict slightly, which is the safe direction here: a thin day is refused before
935
+ // the 1-2 h synthesis, and one that would have cleared the floor is never refused.
936
+ const minMinutes = episodeMinMinutes === '' ? 0 : Number(episodeMinMinutes);
937
+ if (!Number.isFinite(minMinutes) || minMinutes < 0) {
938
+ failWith(1, `generate: invalid episode_min_minutes: ${episodeMinMinutes}`);
939
+ }
940
+ if (minMinutes > 0 && !wholeDocCache(content, voiceGenerator, transcodeMp3)) {
941
+ const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
942
+ if (isFile(scriptPath)) {
943
+ const script = Bun.YAML.parse(readFileSync(scriptPath, 'utf8')) as {
944
+ segments?: Array<{ text?: unknown }>;
945
+ };
946
+ const segments = Array.isArray(script.segments) ? script.segments : [];
947
+ const chars = segments.reduce(
948
+ (sum, segment) => sum + (typeof segment.text === 'string' ? segment.text.length : 0),
949
+ 0,
950
+ );
951
+ const charRate = spokenCharRate(vdriver);
952
+ const seconds = 8 + chars / charRate;
953
+ if (seconds < minMinutes * 60) {
954
+ refuseScriptBelowFloor(workDir, runDate, { chars, charRate, seconds, minMinutes });
955
+ return;
956
+ }
957
+ console.log(`script floor ok: ${chars} script chars ≈ ${(seconds / 60).toFixed(1)} min`);
958
+ }
959
+ }
960
+
871
961
  if (!sectionConcurrency) {
872
962
  timed(workDir, 'generate', () => {
873
963
  if (wholeDocCache(content, voiceGenerator, transcodeMp3)) {
@@ -1438,9 +1528,424 @@ export function dailyPlan(args: string[]): void {
1438
1528
  });
1439
1529
  }
1440
1530
 
1531
+ /**
1532
+ * The two public URLs every downstream channel links to (task 0327 R1).
1533
+ *
1534
+ * voice2 passes the real urls its tier-0 stages just produced; the variant stage passes
1535
+ * `{{article_url}}` / `{{podcast_url}}` so a generate-side bundle can be frozen before anything is
1536
+ * published, and `publish-matrix-send` resolves them from the tier-0 receipts at send time.
1537
+ */
1538
+ export interface PublishLinks {
1539
+ articleUrl: string;
1540
+ podcastUrl: string;
1541
+ }
1542
+
1543
+ /** Placeholder links: the variant contract publish-matrix-send resolves. */
1544
+ export const PLACEHOLDER_LINKS: PublishLinks = {
1545
+ articleUrl: '{{article_url}}',
1546
+ podcastUrl: '{{podcast_url}}',
1547
+ };
1548
+
1549
+ /** The wechatmp-only closing line (operator request 2026-09-29). */
1550
+ const WECHAT_OUTRO = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
1551
+
1552
+ /** The brand strings this daily show pins on every 公众号 draft. */
1553
+ const WECHAT_BRAND = {
1554
+ author: '冰原奔狼',
1555
+ claimSource: '个人观点,仅供参考',
1556
+ coverSource: 'library',
1557
+ } as const;
1558
+
1559
+ /**
1560
+ * The 公众号 article payload (task 0327 R1): the article Content plus MP draft enrichment.
1561
+ *
1562
+ * Every field is consumed by `wechatmp-pub`'s flow; the only link it needs is the 原文链接, which is
1563
+ * the surfdash post — hence `links.articleUrl` rather than a path read.
1564
+ */
1565
+ export function buildWechatArticleContent(
1566
+ article: Record<string, unknown>,
1567
+ options: {
1568
+ links: PublishLinks;
1569
+ publishStatus: 'draft' | 'public';
1570
+ collection: string;
1571
+ coverPath?: string;
1572
+ audioPath?: string;
1573
+ audioTitle?: string;
1574
+ /**
1575
+ * The 咸话咸说 closer is an article-only sign-post (operator request 2026-09-29: "appended
1576
+ * to the wechat input body only, never the master article"). An audio-first 公众号 播客 post
1577
+ * carries the episode body, so it passes `false`.
1578
+ */
1579
+ appendOutro?: boolean;
1580
+ },
1581
+ ): Record<string, unknown> {
1582
+ const wechatmp: Record<string, unknown> = {
1583
+ original: true,
1584
+ // 赞赏 is enabled per episode; the plugin fills the 赞赏账户 only when the platform leaves it
1585
+ // empty (it defaults from 作者, operator walk 2026-10-10).
1586
+ reward: true,
1587
+ author: WECHAT_BRAND.author,
1588
+ collection: options.collection,
1589
+ // 创作来源 is the column's own stance, not an AI-declaration label (2026-10-10).
1590
+ claimSource: WECHAT_BRAND.claimSource,
1591
+ // 封面 comes from 图片库: the episode cover is uploaded there as the audio material's cover.
1592
+ coverSource: WECHAT_BRAND.coverSource,
1593
+ ...(options.links.articleUrl !== '' ? { originalUrl: options.links.articleUrl } : {}),
1594
+ ...(options.coverPath === undefined ? {} : { coverPath: options.coverPath }),
1595
+ ...(options.audioPath === undefined
1596
+ ? {}
1597
+ : {
1598
+ audioPath: options.audioPath,
1599
+ audioTitle: options.audioTitle ?? '',
1600
+ audioCoverPath: options.coverPath ?? '',
1601
+ }),
1602
+ };
1603
+ const body = typeof article.body === 'string' ? article.body : '';
1604
+ return {
1605
+ ...article,
1606
+ body: options.appendOutro === false ? body : `${body}${WECHAT_OUTRO}`,
1607
+ metadata: { ...metadataOf(article), publishStatus: options.publishStatus, wechatmp },
1608
+ };
1609
+ }
1610
+
1611
+ /**
1612
+ * The 小红书 发播客 payload (task 0327 R1, extracted from `xhsPodcastNote`).
1613
+ *
1614
+ * The run's publish-prep Content carries the final mp3, the cover and the body; this only restates
1615
+ * `metadata.audio` under the key the channel's 发播客 mode reads.
1616
+ */
1617
+ export function buildXhsEpisodeContent(
1618
+ prep: Record<string, unknown>,
1619
+ options: { publishStatus: 'draft' | 'public'; links?: PublishLinks },
1620
+ ): Record<string, unknown> {
1621
+ const metadata = metadataOf(prep);
1622
+ return {
1623
+ ...prep,
1624
+ metadata: {
1625
+ ...metadata,
1626
+ operation: '发播客',
1627
+ audio: metadata.audioFile,
1628
+ publishStatus: options.publishStatus,
1629
+ },
1630
+ };
1631
+ }
1632
+
1633
+ /** The 小红书 写长文 payload (task 0327 R1): the article Content with its publish status. */
1634
+ export function buildXhsArticleContent(
1635
+ article: Record<string, unknown>,
1636
+ options: { publishStatus: 'draft' | 'public' },
1637
+ ): Record<string, unknown> {
1638
+ return {
1639
+ ...article,
1640
+ metadata: { ...metadataOf(article), publishStatus: options.publishStatus },
1641
+ };
1642
+ }
1643
+
1644
+ /** The 小红书 上传图文 payload (task 0327 R1): the rendered card series plus its status. */
1645
+ export function buildXhsCardsContent(
1646
+ cards: Record<string, unknown>,
1647
+ options: { publishStatus: 'draft' | 'public'; followUps?: unknown[] },
1648
+ ): Record<string, unknown> {
1649
+ return {
1650
+ ...cards,
1651
+ metadata: {
1652
+ ...metadataOf(cards),
1653
+ publishStatus: options.publishStatus,
1654
+ ...(options.followUps === undefined ? {} : { followUps: options.followUps }),
1655
+ },
1656
+ };
1657
+ }
1658
+
1659
+ /** The podcast payload (task 0327 R1): publish-prep Content plus the episode's show notes. */
1660
+ export function buildPodcastContent(
1661
+ prep: Record<string, unknown>,
1662
+ options: { showNotes: string; podcast?: Record<string, unknown> },
1663
+ ): Record<string, unknown> {
1664
+ return {
1665
+ ...prep,
1666
+ metadata: {
1667
+ ...metadataOf(prep),
1668
+ showNotes: options.showNotes,
1669
+ // Podcast-specific SEO metadata (refined title/description, per-item plan) rides under
1670
+ // one key so a channel payload cannot leak it into another channel's fields.
1671
+ ...(options.podcast === undefined ? {} : { podcast: options.podcast }),
1672
+ },
1673
+ };
1674
+ }
1675
+
1676
+ /**
1677
+ * The surfdash payload (task 0327 R1): the article Content, unchanged.
1678
+ *
1679
+ * surfdash is the article's own publisher, so it consumes what `22-article` already composed — the
1680
+ * builder exists so the variant set is uniform, not because it reshapes anything.
1681
+ */
1682
+ export function buildSurfdashContent(article: Record<string, unknown>, _links: PublishLinks): Record<string, unknown> {
1683
+ return { ...article };
1684
+ }
1685
+
1686
+ /**
1687
+ * One channel's short copy as a Content (task 0327 R3): the body is the authored post text and the
1688
+ * episode cover rides along as `metadata.coverImage`.
1689
+ */
1690
+ /**
1691
+ * A channel's length budget: `max` in that channel's own counting unit.
1692
+ *
1693
+ * `weighted` is X's rule (CJK counts 2, a Latin letter 1) — the design's "280 weighted chars". Counting
1694
+ * code points instead let a 280-character Chinese post through at ~560 of X's units, which is exactly
1695
+ * the over-length draft the backstop exists to catch.
1696
+ */
1697
+ interface SocialLimit {
1698
+ max: number;
1699
+ weighted?: boolean;
1700
+ }
1701
+
1702
+ /** Weight of one code point in X's unit system: CJK ideographs and fullwidth forms count 2. */
1703
+ function weightedCharWeight(char: string): number {
1704
+ const code = char.codePointAt(0) ?? 0;
1705
+ const wide =
1706
+ (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
1707
+ (code >= 0x2e80 && code <= 0xa4cf) || // CJK radicals … Yi
1708
+ (code >= 0xac00 && code <= 0xd7a3) || // Hangul syllables
1709
+ (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
1710
+ (code >= 0xfe30 && code <= 0xfe6f) || // CJK compatibility forms
1711
+ (code >= 0xff00 && code <= 0xff60) || // fullwidth forms
1712
+ (code >= 0xffe0 && code <= 0xffe6);
1713
+ return wide ? 2 : 1;
1714
+ }
1715
+
1716
+ /** Weighted length of `text` (code-point based, so an emoji counts once before its weight). */
1717
+ function weightedLength(text: string): number {
1718
+ let total = 0;
1719
+ for (const char of text) total += weightedCharWeight(char);
1720
+ return total;
1721
+ }
1722
+
1723
+ /**
1724
+ * One channel's short copy as a Content (task 0327 R3): the body is the authored post text and the
1725
+ * episode cover rides along as `metadata.coverImage`.
1726
+ *
1727
+ * The cap is a backstop, not the author: the prompt owns the limit, so an over-length draft is
1728
+ * truncated rather than rejected, and `truncatedFrom` records what was cut. Truncation respects the
1729
+ * channel's own unit — see {@link SocialLimit}.
1730
+ */
1731
+ export function buildSocialContent(
1732
+ copy: string,
1733
+ options: { platform: string; coverPath?: string; limit: SocialLimit },
1734
+ ): Record<string, unknown> {
1735
+ const trimmed = copy.trim();
1736
+ const measure = (text: string): number => (options.limit.weighted === true ? weightedLength(text) : text.length);
1737
+ let body = trimmed;
1738
+ if (measure(trimmed) > options.limit.max) {
1739
+ // Walk code points, adding while the budget holds: a slice by code units could split a
1740
+ // surrogate pair or bust the weighted budget on CJK text.
1741
+ let kept = '';
1742
+ let used = 0;
1743
+ for (const char of trimmed) {
1744
+ const weight = options.limit.weighted === true ? weightedCharWeight(char) : 1;
1745
+ if (used + weight > options.limit.max) break;
1746
+ kept += char;
1747
+ used += weight;
1748
+ }
1749
+ body = kept;
1750
+ }
1751
+ return {
1752
+ title: `${options.platform} · 每日AI乱炖`,
1753
+ format: 'text',
1754
+ body,
1755
+ metadata: {
1756
+ platform: options.platform,
1757
+ ...(body === trimmed
1758
+ ? {}
1759
+ : {
1760
+ truncatedFrom: measure(trimmed),
1761
+ truncatedUnit: options.limit.weighted === true ? 'weighted' : 'code_units',
1762
+ }),
1763
+ ...(options.coverPath === undefined ? {} : { coverImage: options.coverPath }),
1764
+ },
1765
+ };
1766
+ }
1767
+
1768
+ /**
1769
+ * Length budgets the social authoring prompt targets, enforced here as a backstop (task 0327 R3).
1770
+ * X counts CJK at 2 (`weighted`); weibo and threads count characters.
1771
+ */
1772
+ const SOCIAL_LIMITS: Record<string, SocialLimit> = {
1773
+ x: { max: 280, weighted: true },
1774
+ weibo: { max: 2000 },
1775
+ threads: { max: 500 },
1776
+ };
1777
+
1778
+ /** Variant key → file name: `social:x` becomes `social_x.content.json`. */
1779
+ function variantFileName(key: string): string {
1780
+ return `${key.replaceAll(':', '_')}.content.json`;
1781
+ }
1782
+
1783
+ /**
1784
+ * `daily-bundle-variants <workDir> <runDate> <pluginsPath>` — the generate-side payload builder
1785
+ * (task 0327 R2/R3).
1786
+ *
1787
+ * Everything the publish side used to assemble inline is produced here instead, once, as bundle
1788
+ * variants under `30-variants/`. Links are the `{{article_url}}` / `{{podcast_url}}` placeholders,
1789
+ * because at generate time nothing is published yet; `publish-matrix-send` resolves them from the
1790
+ * tier-0 receipts. A variant whose source artifact is absent (channel disabled, cards never
1791
+ * rendered, social copy not authored) is skipped with a log line, never invented.
1792
+ */
1793
+ export function dailyBundleVariants(args: string[]): void {
1794
+ const [workDir = '', runDate = '', _pluginsPath = ''] = args;
1795
+ timed(workDir, 'bundle-variants', () => {
1796
+ const outDir = join(workDir, '30-variants');
1797
+ sted('mkdir', outDir, () => mkdirSync(outDir, { recursive: true }));
1798
+ const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1799
+ const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1800
+ const notesPath = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
1801
+ const cardsPath = join(workDir, '25-xhs', 'content.json');
1802
+ const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1803
+ const socialDir = join(workDir, '26-social');
1804
+ const cover = isFile(coverPath) ? coverPath : undefined;
1805
+ const links = PLACEHOLDER_LINKS;
1806
+ const written: string[] = [];
1807
+ const skipped: string[] = [];
1808
+
1809
+ const article = readJson(articlePath);
1810
+ const prep = readJson(prepPath);
1811
+ const write = (key: string, content: Record<string, unknown>): void => {
1812
+ const file = join(outDir, variantFileName(key));
1813
+ sted('write', file, () => writeJsonText(file, content));
1814
+ written.push(key);
1815
+ };
1816
+
1817
+ // Generic variants first: a channel-specific variant wins over the shared one, so the
1818
+ // shared copy must exist for the channels the preset does not specialise.
1819
+ if (article === null) skipped.push('article (no 22-article content)');
1820
+ else {
1821
+ write('article', buildSurfdashContent(article, links));
1822
+ write('article@surfdash-pub', buildSurfdashContent(article, links));
1823
+ write('article@xhs-pub', buildXhsArticleContent(article, { publishStatus: 'draft' }));
1824
+ write(
1825
+ 'article@wechatmp-pub',
1826
+ buildWechatArticleContent(article, {
1827
+ links,
1828
+ publishStatus: 'draft',
1829
+ collection: '每日AI资讯',
1830
+ ...(cover === undefined ? {} : { coverPath: cover }),
1831
+ }),
1832
+ );
1833
+ }
1834
+
1835
+ if (prep === null) skipped.push('episode (no publish-prep content)');
1836
+ else {
1837
+ const showNotes = isFile(notesPath) ? readFileSync(notesPath, 'utf-8') : '';
1838
+ write('episode', buildPodcastContent(prep, { showNotes }));
1839
+ write('episode@podcast-pub', buildPodcastContent(prep, { showNotes }));
1840
+ write('episode@xhs-pub', buildXhsEpisodeContent(prep, { publishStatus: 'draft', links }));
1841
+ // 公众号 播客 shares the episode with podcast-pub: same audio, same cover, no article body.
1842
+ write(
1843
+ 'episode@wechatmp-pub',
1844
+ buildWechatArticleContent(prep, {
1845
+ links,
1846
+ publishStatus: 'draft',
1847
+ collection: '每日AI资讯',
1848
+ // Audio-first post: it carries the episode body, so no article closer.
1849
+ appendOutro: false,
1850
+ ...(cover === undefined ? {} : { coverPath: cover }),
1851
+ ...(typeof metadataOf(prep).audioFile === 'string'
1852
+ ? { audioPath: String(metadataOf(prep).audioFile) }
1853
+ : {}),
1854
+ }),
1855
+ );
1856
+ }
1857
+
1858
+ const cards = readJson(cardsPath);
1859
+ if (cards === null) skipped.push('cards (no rendered card series)');
1860
+ else write('cards@xhs-pub', buildXhsCardsContent(cards, { publishStatus: 'draft' }));
1861
+
1862
+ for (const platform of Object.keys(SOCIAL_LIMITS)) {
1863
+ const copyPath = join(socialDir, `${platform}.md`);
1864
+ if (!isFile(copyPath)) {
1865
+ skipped.push(`social:${platform} (no 26-social/${platform}.md)`);
1866
+ continue;
1867
+ }
1868
+ const limit = SOCIAL_LIMITS[platform] ?? { max: 500 };
1869
+ write(
1870
+ `social:${platform}`,
1871
+ buildSocialContent(readFileSync(copyPath, 'utf-8'), {
1872
+ platform,
1873
+ limit,
1874
+ ...(cover === undefined ? {} : { coverPath: cover }),
1875
+ }),
1876
+ );
1877
+ }
1878
+
1879
+ console.log(`bundle-variants: ${written.length} variant(s) in ${outDir}: ${written.join(', ')}`);
1880
+ if (skipped.length > 0) console.log(`bundle-variants: skipped ${skipped.join('; ')}`);
1881
+ });
1882
+ }
1883
+
1441
1884
  /**
1442
1885
  * Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
1443
1886
  */
1887
+ /** Surfing frontmatter scalar. Inline on purpose: this file cannot import `@gobing-ai/*`. */
1888
+ function readFrontmatterScalar(filePath: string, key: string): string | undefined {
1889
+ if (!isFile(filePath)) return undefined;
1890
+ let raw: string;
1891
+ try {
1892
+ raw = readFileSync(filePath, 'utf-8');
1893
+ } catch {
1894
+ return undefined;
1895
+ }
1896
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
1897
+ const matter = block?.[1];
1898
+ if (!matter) return undefined;
1899
+ const match = new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(matter);
1900
+ if (!match?.[1]) return undefined;
1901
+ const value = match[1].trim().replace(/^["']|["']$/g, '');
1902
+ return value.length > 0 ? value : undefined;
1903
+ }
1904
+
1905
+ function firstRefinedTitle(parsed: Record<string, unknown> | null): string {
1906
+ const targets = parsed?.targets;
1907
+ if (!Array.isArray(targets)) return '';
1908
+ for (const entry of targets) {
1909
+ if (entry === null || typeof entry !== 'object') continue;
1910
+ const record = entry as Record<string, unknown>;
1911
+ if (record.target !== 'surfdash-pub' || record.ok !== true) continue;
1912
+ const title = metadataOf(record).refinedTitle;
1913
+ if (typeof title === 'string' && title.trim() !== '') return title.trim();
1914
+ }
1915
+ return '';
1916
+ }
1917
+
1918
+ /** Plan titles and briefs in audio order. The show-notes filter wins when it dropped boundary items. */
1919
+ function planItemsForPodcast(workDir: string, runDate: string): Array<{ title: string; brief?: string }> {
1920
+ const filtered = join(workDir, '21-plan', `${runDate}_03_plan_plan-shownotes.json`);
1921
+ const full = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
1922
+ const path = isFile(filtered) ? filtered : full;
1923
+ if (!isFile(path)) return [];
1924
+ let parsed: unknown;
1925
+ try {
1926
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
1927
+ } catch {
1928
+ return [];
1929
+ }
1930
+ if (!Array.isArray(parsed)) return [];
1931
+ const items: Array<{ title: string; brief?: string }> = [];
1932
+ for (const entry of parsed) {
1933
+ if (entry === null || typeof entry !== 'object') continue;
1934
+ const record = entry as Record<string, unknown>;
1935
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
1936
+ if (title === '') continue;
1937
+ const meta = record.metadata;
1938
+ const metaBrief =
1939
+ meta !== null && typeof meta === 'object' && typeof (meta as Record<string, unknown>).brief === 'string'
1940
+ ? String((meta as Record<string, unknown>).brief).trim()
1941
+ : '';
1942
+ const body = typeof record.body === 'string' ? record.body.trim() : '';
1943
+ const brief = metaBrief || (body.split(/(?<=[。!?])\s*/)[0] ?? '').trim();
1944
+ items.push(brief.length > 0 ? { title, brief } : { title });
1945
+ }
1946
+ return items;
1947
+ }
1948
+
1444
1949
  export function dailyPublishPodcast(args: string[]): void {
1445
1950
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
1446
1951
  const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
@@ -1454,7 +1959,23 @@ export function dailyPublishPodcast(args: string[]): void {
1454
1959
  try {
1455
1960
  const content = requireJson(prep, 'publish-podcast');
1456
1961
  const showNotes = readFileSync(notes, 'utf-8');
1457
- const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
1962
+ const surfdash = readJson(join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`));
1963
+ const postPath = firstPostPath(surfdash, 'surfdash-pub');
1964
+ const refinedTitle = firstRefinedTitle(surfdash);
1965
+ const refinedDescription = postPath !== '' ? readFrontmatterScalar(postPath, 'description') : undefined;
1966
+ const planItems = planItemsForPodcast(workDir, runDate);
1967
+ const prior = metadataOf(content).podcast;
1968
+ const podcast: Record<string, unknown> =
1969
+ prior !== null && typeof prior === 'object' && !Array.isArray(prior)
1970
+ ? { ...(prior as Record<string, unknown>) }
1971
+ : {};
1972
+ if (refinedTitle !== '') podcast.refinedTitle = refinedTitle;
1973
+ if (refinedDescription) podcast.refinedDescription = refinedDescription;
1974
+ if (planItems.length > 0) podcast.planItems = planItems;
1975
+ const merged = buildPodcastContent(content, {
1976
+ showNotes,
1977
+ ...(Object.keys(podcast).length > 0 ? { podcast } : {}),
1978
+ });
1458
1979
  sted('write', prep, () => writeJsonText(prep, merged));
1459
1980
  kk(
1460
1981
  [
@@ -1623,47 +2144,26 @@ export function dailyPublishWechat(args: string[]): void {
1623
2144
  return;
1624
2145
  }
1625
2146
  const packaged = requireJson(article, 'publish-wechat');
1626
- // MP draft enrichment (operator preferences 2026-09-29, completed 2026-10-10): 原创, 赞赏,
1627
- // 作者, 合集, 原文链接 (the surfdash post), 创作来源, cover, audio material. The brand strings
1628
- // are this daily show's constants; every field here is consumed by `wechatmp-pub`'s flow.
2147
+ // MP draft enrichment (operator preferences 2026-09-29, completed 2026-10-10) lives in the
2148
+ // builder so a generate-side bundle can produce the same payload without publishing.
1629
2149
  const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1630
2150
  const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
1631
2151
  const surfdashResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1632
2152
  const originalUrl = isFile(surfdashResult) ? firstTargetUrl(readJson(surfdashResult), 'surfdash-pub') : '';
1633
- const wechatmp: Record<string, unknown> = {
1634
- original: true,
1635
- // 赞赏 is enabled per episode; the plugin fills the 赞赏账户 only when the platform
1636
- // leaves it empty (it defaults from 作者, operator walk 2026-10-10).
1637
- reward: true,
1638
- author: '冰原奔狼',
1639
- collection: collectionArg || '每日AI资讯',
1640
- // 创作来源 is the column's own stance, not an AI-declaration label — the operator owns
1641
- // the wording (2026-10-10).
1642
- claimSource: '个人观点,仅供参考',
1643
- // 封面 comes from 图片库: the episode cover is uploaded there as the audio material's
1644
- // cover, so the library's newest entry is the intended image.
1645
- coverSource: 'library',
1646
- ...(originalUrl !== '' ? { originalUrl } : {}),
1647
- ...(isFile(cover) ? { coverPath: cover } : {}),
1648
- // Audio block at the article top (operator request 2026-09-29; the plugin UPLOADS the
1649
- // material when the 素材库 has no same-title entry, 2026-10-10).
1650
- ...(isFile(audio)
1651
- ? {
1652
- audioPath: audio,
1653
- audioTitle: typeof packaged.title === 'string' ? packaged.title : '',
1654
- audioCoverPath: isFile(cover) ? cover : '',
1655
- }
1656
- : {}),
1657
- };
1658
- // WeChat-only closing line (operator request 2026-09-29): point readers/listeners back to
1659
- // the show's platforms. Appended to the wechat input body only, never the master article.
1660
- const outro = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
2153
+ const hasCover = isFile(cover);
2154
+ const hasAudio = isFile(audio);
2155
+ const firstLine = typeof packaged.title === 'string' ? packaged.title : '';
1661
2156
  sted('write', input, () =>
1662
- writeJsonText(input, {
1663
- ...packaged,
1664
- body: `${typeof packaged.body === 'string' ? packaged.body : ''}${outro}`,
1665
- metadata: { ...metadataOf(packaged), publishStatus: want, wechatmp },
1666
- }),
2157
+ writeJsonText(
2158
+ input,
2159
+ buildWechatArticleContent(packaged, {
2160
+ links: { articleUrl: originalUrl, podcastUrl: '' },
2161
+ publishStatus: want,
2162
+ collection: collectionArg || '每日AI资讯',
2163
+ ...(hasCover ? { coverPath: cover } : {}),
2164
+ ...(hasAudio ? { audioPath: audio, audioTitle: firstLine } : {}),
2165
+ }),
2166
+ ),
1667
2167
  );
1668
2168
  // `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
1669
2169
  // and prints the failing step (session preflight, editor paste, save control), which is what
@@ -1731,14 +2231,13 @@ export function dailyPublishXhs(args: string[]): void {
1731
2231
  const packaged = requireJson(content, 'publish-xhs');
1732
2232
  const followUp = podcast === 'true' ? xhsPodcastNote(prep, want) : undefined;
1733
2233
  sted('write', input, () =>
1734
- writeJsonText(input, {
1735
- ...packaged,
1736
- metadata: {
1737
- ...metadataOf(packaged),
2234
+ writeJsonText(
2235
+ input,
2236
+ buildXhsCardsContent(packaged, {
1738
2237
  publishStatus: want,
1739
2238
  ...(followUp === undefined ? {} : { followUps: [followUp] }),
1740
- },
1741
- }),
2239
+ }),
2240
+ ),
1742
2241
  );
1743
2242
  // `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
1744
2243
  // and prints the failing step (login gate, upload, body, submit control), which is what the
@@ -1770,16 +2269,7 @@ function xhsPodcastNote(prepPath: string, publishStatus: 'draft' | 'public'): Re
1770
2269
  console.log(`publish-xhs: no publish-prep Content at ${prepPath} — the 发播客 note is skipped`);
1771
2270
  return undefined;
1772
2271
  }
1773
- const metadata = metadataOf(prep);
1774
- return {
1775
- ...prep,
1776
- metadata: {
1777
- ...metadata,
1778
- operation: '发播客',
1779
- audio: metadata.audioFile,
1780
- publishStatus,
1781
- },
1782
- };
2272
+ return buildXhsEpisodeContent(prep, { publishStatus });
1783
2273
  }
1784
2274
 
1785
2275
  /**
@@ -2460,6 +2950,9 @@ export function dailyShowNotes(args: string[]): void {
2460
2950
  // Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
2461
2951
  // grammar lives in daily-article-gen's excludedIdsFromNote.
2462
2952
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
2953
+ if (postPath === '') {
2954
+ console.error('WARN podcast-pub: brief mode requested but ARTICLE_POST_PATH is empty; using legacy layout');
2955
+ }
2463
2956
  const articleContent = readJson(article);
2464
2957
  const articleBody = articleContent?.body;
2465
2958
  const excluded = new Set<string>();
@@ -2606,11 +3099,20 @@ export function dailyTranslateSync(args: string[]): void {
2606
3099
  timed(workDir, 'translate-sync', () => {
2607
3100
  // Reads the marker file itself — the collapsed `translate` state has no
2608
3101
  // cross-state var to hand the path through.
2609
- const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8') : '';
3102
+ const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8').trim() : '';
2610
3103
  if (zhPostPath === '') {
2611
3104
  console.log('no zh post path — skip translate-sync');
2612
3105
  return;
2613
3106
  }
3107
+ // The 4th argument is the MARKER file, not the post (2026-10-22). Passing the post made the
3108
+ // plugin read the whole article as `postPath` and echo it back inside its error, which cost a
3109
+ // confusing round trip on a manual recovery run.
3110
+ if (zhPathFile.endsWith('.md') || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
3111
+ failWith(
3112
+ 1,
3113
+ `translate-sync: 4th argument must be the zh marker file (${runDate}_20_translate-localize_zh-post-path.txt), not the post itself — '${zhPathFile}' holds ${zhPostPath.includes('\n') ? 'a multi-line document' : `'${zhPostPath}'`}`,
3114
+ );
3115
+ }
2614
3116
  sted('write', sync, () =>
2615
3117
  writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
2616
3118
  );