@gobing-ai/knowledge-kit 0.1.8 → 0.1.10

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 (78) 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 +1796 -102
  6. package/package.json +1 -1
  7. package/plugins/generations/content-gen/dist/index.js +4 -0
  8. package/plugins/generations/core-facts-gen/dist/index.js +4 -0
  9. package/plugins/generations/daily-article-gen/dist/index.js +4 -0
  10. package/plugins/generations/dailynews-gen/dist/index.js +4 -0
  11. package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
  12. package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
  13. package/plugins/generations/gemini-tts/dist/index.js +4 -0
  14. package/plugins/generations/image-gen/dist/index.js +4 -0
  15. package/plugins/generations/news-report-gen/dist/index.js +4 -0
  16. package/plugins/generations/voice-gen/dist/index.js +4 -0
  17. package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
  18. package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
  19. package/plugins/ingestions/horizon-ingest/dist/index.js +28 -1
  20. package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
  21. package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
  22. package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
  23. package/plugins/ingestions/web-search/dist/index.js +4 -0
  24. package/plugins/kk/config.example.yaml +8 -0
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
  27. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
  28. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
  29. package/plugins/kk/scripts/daily-stages.ts +665 -78
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
  31. package/plugins/kk/scripts/sentences.ts +134 -0
  32. package/plugins/kk/scripts/series-names.ts +124 -0
  33. package/plugins/kk/scripts/topic-boundary.json +18 -83
  34. package/plugins/kk/workflows/kk-collect.yaml +2 -0
  35. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +112 -9
  36. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
  37. package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +50 -5
  39. package/plugins/publishings/bilibili-pub/dist/index.js +4 -0
  40. package/plugins/publishings/cnblogs-pub/dist/index.js +4 -0
  41. package/plugins/publishings/csdn-pub/dist/index.js +4 -0
  42. package/plugins/publishings/csdn-pub/dist/profile-check.js +4 -0
  43. package/plugins/publishings/emdash-pub/dist/index.js +4 -0
  44. package/plugins/publishings/infoq-pub/dist/index.js +4 -0
  45. package/plugins/publishings/infoq-pub/dist/profile-check.js +4 -0
  46. package/plugins/publishings/jike-pub/dist/index.js +4 -0
  47. package/plugins/publishings/jike-pub/dist/profile-check.js +4 -0
  48. package/plugins/publishings/juejin-pub/dist/index.js +4 -0
  49. package/plugins/publishings/juejin-pub/dist/profile-check.js +4 -0
  50. package/plugins/publishings/medium-pub/dist/index.js +4 -0
  51. package/plugins/publishings/podcast-pub/README.md +65 -0
  52. package/plugins/publishings/podcast-pub/dist/index.js +1906 -374
  53. package/plugins/publishings/podcast-pub/plugin.json +1 -1
  54. package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
  55. package/plugins/publishings/podcast-pub/src/index.ts +211 -34
  56. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
  57. package/plugins/publishings/podcast-pub/src/seo.ts +1079 -0
  58. package/plugins/publishings/podcast-pub/src/show-notes.ts +74 -14
  59. package/plugins/publishings/qiita-pub/dist/index.js +4 -0
  60. package/plugins/publishings/shipinhao-pub/dist/index.js +4 -0
  61. package/plugins/publishings/sspai-pub/dist/index.js +4 -0
  62. package/plugins/publishings/substack-pub/dist/index.js +4 -0
  63. package/plugins/publishings/substack-pub/dist/profile-check.js +4 -0
  64. package/plugins/publishings/surfdash-pub/dist/index.js +313 -32
  65. package/plugins/publishings/surfdash-pub/src/digest-check.ts +218 -0
  66. package/plugins/publishings/surfdash-pub/src/index.ts +170 -39
  67. package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
  68. package/plugins/publishings/threads-pub/dist/index.js +4 -0
  69. package/plugins/publishings/wechatmp-pub/dist/index.js +4 -0
  70. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +4 -0
  71. package/plugins/publishings/weibo-pub/dist/index.js +4 -0
  72. package/plugins/publishings/weibo-pub/dist/profile-check.js +4 -0
  73. package/plugins/publishings/x-pub/dist/index.js +4 -0
  74. package/plugins/publishings/x-pub/dist/profile-check.js +4 -0
  75. package/plugins/publishings/xhs-pub/dist/index.js +4 -0
  76. package/plugins/publishings/xhs-pub/dist/profile-check.js +4 -0
  77. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +4 -0
  78. package/plugins/publishings/zenn-pub/dist/index.js +4 -0
@@ -114,6 +114,8 @@ import {
114
114
  type XhsCard,
115
115
  xhsPlan,
116
116
  } from './kk-workflow-stages.ts';
117
+ import { splitSentences } from './sentences.ts';
118
+ import { normalizeSeriesNames } from './series-names.ts';
117
119
 
118
120
  export function dailyQcContent(args: string[]): void {
119
121
  const [
@@ -162,7 +164,6 @@ export function dailyQcContent(args: string[]): void {
162
164
  },
163
165
  },
164
166
  );
165
- applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
166
167
  });
167
168
  // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
168
169
  // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
@@ -197,15 +198,21 @@ export function dailyQcContent(args: string[]): void {
197
198
  // `unknown-id`, the whole plan is rejected, and generate then sees a template with fewer sections.
198
199
  const currentDocs = excluded.length > 0 ? kept : boundaryDocs;
199
200
  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');
201
+ if (dropped.length > 0) {
202
+ const dedupedContent = requireJson(candidates, 'qc-dedup');
203
+ const dedupedMeta = dedupedContent.metadata;
204
+ if (dedupedMeta === null || typeof dedupedMeta !== 'object') {
205
+ failWith(1, 'qc-dedup: candidates content carries no metadata object');
206
+ }
207
+ (dedupedMeta as Record<string, unknown>).docs = deduped;
208
+ sted('write', candidates, () => writeJsonText(candidates, dedupedContent));
209
+ console.error(`qc: dropped ${dropped.length} near-duplicate candidate(s): ${dropped.join(', ')}`);
205
210
  }
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(', ')}`);
211
+ // Last, not inside the timed block above: the estimate must see the pool every shrink pass has
212
+ // already produced. 2026-10-22 cleared the floor on a 4-item pool (21.0 min estimated) that the
213
+ // topic boundary then cut to 3 (14.4 min), so the run paid for a full synthesis the measured
214
+ // gate refused. Covered here it also re-runs on a cached-rerun, which the timed block skipped.
215
+ applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
209
216
  }
210
217
 
211
218
  /**
@@ -287,7 +294,7 @@ function applyFloorGate(
287
294
  }
288
295
 
289
296
  /** 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;
297
+ const LADDER_MAX_MS = 168 * 3_600_000;
291
298
 
292
299
  const WINDOW_UNIT_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
293
300
 
@@ -307,13 +314,17 @@ function formatWindowMs(ms: number): string {
307
314
 
308
315
  /**
309
316
  * 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`
317
+ * 0266 D1 blanket rejection; extended ×7/168h 2026-10-23 when upstream supply collapsed and the
318
+ * 72h ceiling again closed a day as no-content while a week of older unselected items sat in the
319
+ * store). `kk store extract` reads one first_seen window and `applyFloorGate`
311
320
  * then refuses a pool that cannot reach `episode_min_minutes`. Together they ate whole days: an
312
321
  * episode consumes 6-12 stories while a day of collection adds ~3-13 qualifying ones, so the 04:30
313
322
  * runs of 2026-10-18 and 2026-10-19 saw 2- and 3-item pools and closed as no-content while older
314
323
  * 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.
324
+ * widens (×2, ×3, ×7, capped at 168h) ONLY when that pool's estimate misses the floor — a rich day is
325
+ * extracted exactly once, and a thin one is filled from the backlog instead of dropped. The store's
326
+ * QC gates still read the aged items' decayed urgency, so a widened pool does not force stale stories
327
+ * into an episode by itself.
317
328
  *
318
329
  * The store's `selections` ledger still excludes every story a previous run took, so a widened
319
330
  * window can only add items no episode has used. The estimate here runs on the raw pool (before QC
@@ -329,7 +340,7 @@ export function dailyExtract(args: string[]): void {
329
340
  const floor = Number(minMinutes);
330
341
  const gated = minMinutes !== '' && Number.isFinite(floor) && floor > 0;
331
342
  const windows: number[] = [base];
332
- for (const factor of gated ? [2, 3] : []) {
343
+ for (const factor of gated ? [2, 3, 7] : []) {
333
344
  const ms = base * factor;
334
345
  if (ms > LADDER_MAX_MS) break;
335
346
  windows.push(ms);
@@ -574,6 +585,52 @@ export function dailyVoiceAudit(args: string[]): void {
574
585
  }
575
586
  }
576
587
 
588
+ /**
589
+ * Empty the QC candidates file so every downstream `pool-count --min 1` guard skips the content,
590
+ * audio, publish and XHS subtrees — the one mechanism both refusal paths use to close a run as
591
+ * no-content without aborting it (0266).
592
+ */
593
+ function emptyCandidates(workDir: string, runDate: string): void {
594
+ const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
595
+ const content = readJson(candidates);
596
+ if (content === null) return;
597
+ sted('write', candidates, () =>
598
+ writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
599
+ );
600
+ }
601
+
602
+ /**
603
+ * The pre-spend refusal of `daily-generate`'s script gate: same artifact and the same candidate
604
+ * emptying as the measured gate, plus `stage: "script"` so the run report can name which gate
605
+ * closed the day. Nothing was synthesized, so there is no measured duration to record.
606
+ */
607
+ function refuseScriptBelowFloor(
608
+ workDir: string,
609
+ runDate: string,
610
+ gate: { chars: number; charRate: number; seconds: number; minMinutes: number },
611
+ ): void {
612
+ const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
613
+ const candidateDocs = metadataOf(readJson(candidates)).docs;
614
+ const keptCount = Array.isArray(candidateDocs) ? candidateDocs.length : 0;
615
+ const refusal = join(workDir, '30-audio', `${runDate}_11_generate_content_duration-refusal.json`);
616
+ const shown = (gate.seconds / 60).toFixed(1);
617
+ sted('write', refusal, () =>
618
+ writeJqText(refusal, {
619
+ stage: 'script',
620
+ estimatedMinutes: Math.round((gate.seconds / 60) * 10) / 10,
621
+ minMinutes: gate.minMinutes,
622
+ keptCount,
623
+ scriptChars: gate.chars,
624
+ charRate: gate.charRate,
625
+ reason: `the authored script estimates ${shown} min of speech, below the ${gate.minMinutes} min floor — refusing before the TTS spend`,
626
+ }),
627
+ );
628
+ emptyCandidates(workDir, runDate);
629
+ console.error(
630
+ `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})`,
631
+ );
632
+ }
633
+
577
634
  export function dailyEpisodeDuration(args: string[]): void {
578
635
  const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = ''] = args;
579
636
  if (!workDir) fail('missing work_dir');
@@ -609,11 +666,7 @@ export function dailyEpisodeDuration(args: string[]): void {
609
666
  // A kill between the two writes below leaves the refusal beside non-empty candidates, which would
610
667
  // let the pool-gated publish fan package a sub-floor episode — so finish the emptying here.
611
668
  if (isFile(refusal)) {
612
- if (content !== null && docs.length > 0) {
613
- sted('write', candidates, () =>
614
- writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
615
- );
616
- }
669
+ if (docs.length > 0) emptyCandidates(workDir, runDate);
617
670
  console.log(`episode-duration: duration refusal already recorded (${refusal}) — nothing to do`);
618
671
  return;
619
672
  }
@@ -629,11 +682,7 @@ export function dailyEpisodeDuration(args: string[]): void {
629
682
  reason: `the episode ran ${shown} min of speech, below the ${minMinutes} min floor (pre-spend estimate ${estimated.minutes} min) — refusing after synthesis`,
630
683
  }),
631
684
  );
632
- if (content !== null) {
633
- sted('write', candidates, () =>
634
- writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
635
- );
636
- }
685
+ emptyCandidates(workDir, runDate);
637
686
  console.error(
638
687
  `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
688
  );
@@ -645,6 +694,17 @@ export function dailyEpisodeDuration(args: string[]): void {
645
694
  `episode-duration: episode runs ${shown} min, above the ${minMinutes}–${maxMinutes} min requirement. Trim items or per-item treatment.`,
646
695
  );
647
696
  }
697
+ // A passing gate leaves this artifact behind only: the 2026-10-20 run published an 8.4-min
698
+ // episode with nothing on disk to say whether the gate ran, passed, or was stale (2026-10-22).
699
+ const verdict = join(workDir, '30-audio', `${runDate}_11_generate_content_duration.json`);
700
+ sted('write', verdict, () =>
701
+ writeJqText(verdict, {
702
+ measuredMinutes: Math.round(minutes * 10) / 10,
703
+ minMinutes,
704
+ maxMinutes,
705
+ ok: true,
706
+ }),
707
+ );
648
708
  console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
649
709
  }
650
710
 
@@ -844,6 +904,7 @@ export function dailyGenerate(args: string[]): void {
844
904
  vdriver = '',
845
905
  sectionConcurrency = '',
846
906
  episodeMaxMinutes = '',
907
+ episodeMinMinutes = '',
847
908
  ] = args;
848
909
  // 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
849
910
  // passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
@@ -868,6 +929,37 @@ export function dailyGenerate(args: string[]): void {
868
929
  const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
869
930
  const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
870
931
 
932
+ // Pre-spend script floor gate (2026-10-22). QC's candidate-text estimate does not predict the
933
+ // spoken length — 20261020 planned 3968 chars and rendered 8.4 min, 20261021 planned 4548 and
934
+ // rendered 17.9 — but the authored VoiceScript IS the spoken text, and 14 measured episodes
935
+ // deliver it at 5.24-5.94 chars/s. Reusing `spokenCharRate` (the slowest measured band) makes the
936
+ // estimate over-predict slightly, which is the safe direction here: a thin day is refused before
937
+ // the 1-2 h synthesis, and one that would have cleared the floor is never refused.
938
+ const minMinutes = episodeMinMinutes === '' ? 0 : Number(episodeMinMinutes);
939
+ if (!Number.isFinite(minMinutes) || minMinutes < 0) {
940
+ failWith(1, `generate: invalid episode_min_minutes: ${episodeMinMinutes}`);
941
+ }
942
+ if (minMinutes > 0 && !wholeDocCache(content, voiceGenerator, transcodeMp3)) {
943
+ const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
944
+ if (isFile(scriptPath)) {
945
+ const script = Bun.YAML.parse(readFileSync(scriptPath, 'utf8')) as {
946
+ segments?: Array<{ text?: unknown }>;
947
+ };
948
+ const segments = Array.isArray(script.segments) ? script.segments : [];
949
+ const chars = segments.reduce(
950
+ (sum, segment) => sum + (typeof segment.text === 'string' ? segment.text.length : 0),
951
+ 0,
952
+ );
953
+ const charRate = spokenCharRate(vdriver);
954
+ const seconds = 8 + chars / charRate;
955
+ if (seconds < minMinutes * 60) {
956
+ refuseScriptBelowFloor(workDir, runDate, { chars, charRate, seconds, minMinutes });
957
+ return;
958
+ }
959
+ console.log(`script floor ok: ${chars} script chars ≈ ${(seconds / 60).toFixed(1)} min`);
960
+ }
961
+ }
962
+
871
963
  if (!sectionConcurrency) {
872
964
  timed(workDir, 'generate', () => {
873
965
  if (wholeDocCache(content, voiceGenerator, transcodeMp3)) {
@@ -1438,9 +1530,455 @@ export function dailyPlan(args: string[]): void {
1438
1530
  });
1439
1531
  }
1440
1532
 
1533
+ /**
1534
+ * The two public URLs every downstream channel links to (task 0327 R1).
1535
+ *
1536
+ * voice2 passes the real urls its tier-0 stages just produced; the variant stage passes
1537
+ * `{{article_url}}` / `{{podcast_url}}` so a generate-side bundle can be frozen before anything is
1538
+ * published, and `publish-matrix-send` resolves them from the tier-0 receipts at send time.
1539
+ */
1540
+ export interface PublishLinks {
1541
+ articleUrl: string;
1542
+ podcastUrl: string;
1543
+ }
1544
+
1545
+ /** Placeholder links: the variant contract publish-matrix-send resolves. */
1546
+ export const PLACEHOLDER_LINKS: PublishLinks = {
1547
+ articleUrl: '{{article_url}}',
1548
+ podcastUrl: '{{podcast_url}}',
1549
+ };
1550
+
1551
+ /** The wechatmp-only closing line (operator request 2026-09-29). */
1552
+ const WECHAT_OUTRO = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
1553
+
1554
+ /** The brand strings this daily show pins on every 公众号 draft. */
1555
+ const WECHAT_BRAND = {
1556
+ author: '冰原奔狼',
1557
+ claimSource: '个人观点,仅供参考',
1558
+ coverSource: 'library',
1559
+ } as const;
1560
+
1561
+ /**
1562
+ * The 公众号 article payload (task 0327 R1): the article Content plus MP draft enrichment.
1563
+ *
1564
+ * Every field is consumed by `wechatmp-pub`'s flow; the only link it needs is the 原文链接, which is
1565
+ * the surfdash post — hence `links.articleUrl` rather than a path read.
1566
+ */
1567
+ export function buildWechatArticleContent(
1568
+ article: Record<string, unknown>,
1569
+ options: {
1570
+ links: PublishLinks;
1571
+ publishStatus: 'draft' | 'public';
1572
+ collection: string;
1573
+ coverPath?: string;
1574
+ audioPath?: string;
1575
+ audioTitle?: string;
1576
+ /**
1577
+ * The 咸话咸说 closer is an article-only sign-post (operator request 2026-09-29: "appended
1578
+ * to the wechat input body only, never the master article"). An audio-first 公众号 播客 post
1579
+ * carries the episode body, so it passes `false`.
1580
+ */
1581
+ appendOutro?: boolean;
1582
+ },
1583
+ ): Record<string, unknown> {
1584
+ const wechatmp: Record<string, unknown> = {
1585
+ original: true,
1586
+ // 赞赏 is enabled per episode; the plugin fills the 赞赏账户 only when the platform leaves it
1587
+ // empty (it defaults from 作者, operator walk 2026-10-10).
1588
+ reward: true,
1589
+ author: WECHAT_BRAND.author,
1590
+ collection: options.collection,
1591
+ // 创作来源 is the column's own stance, not an AI-declaration label (2026-10-10).
1592
+ claimSource: WECHAT_BRAND.claimSource,
1593
+ // 封面 comes from 图片库: the episode cover is uploaded there as the audio material's cover.
1594
+ coverSource: WECHAT_BRAND.coverSource,
1595
+ ...(options.links.articleUrl !== '' ? { originalUrl: options.links.articleUrl } : {}),
1596
+ ...(options.coverPath === undefined ? {} : { coverPath: options.coverPath }),
1597
+ ...(options.audioPath === undefined
1598
+ ? {}
1599
+ : {
1600
+ audioPath: options.audioPath,
1601
+ audioTitle: options.audioTitle ?? '',
1602
+ audioCoverPath: options.coverPath ?? '',
1603
+ }),
1604
+ };
1605
+ const body = typeof article.body === 'string' ? article.body : '';
1606
+ return {
1607
+ ...article,
1608
+ body: options.appendOutro === false ? body : `${body}${WECHAT_OUTRO}`,
1609
+ metadata: { ...metadataOf(article), publishStatus: options.publishStatus, wechatmp },
1610
+ };
1611
+ }
1612
+
1613
+ /**
1614
+ * The 小红书 发播客 payload (task 0327 R1, extracted from `xhsPodcastNote`).
1615
+ *
1616
+ * The run's publish-prep Content carries the final mp3, the cover and the body; this only restates
1617
+ * `metadata.audio` under the key the channel's 发播客 mode reads.
1618
+ */
1619
+ export function buildXhsEpisodeContent(
1620
+ prep: Record<string, unknown>,
1621
+ options: { publishStatus: 'draft' | 'public'; links?: PublishLinks },
1622
+ ): Record<string, unknown> {
1623
+ const metadata = metadataOf(prep);
1624
+ return {
1625
+ ...prep,
1626
+ metadata: {
1627
+ ...metadata,
1628
+ operation: '发播客',
1629
+ audio: metadata.audioFile,
1630
+ publishStatus: options.publishStatus,
1631
+ },
1632
+ };
1633
+ }
1634
+
1635
+ /** The 小红书 写长文 payload (task 0327 R1): the article Content with its publish status. */
1636
+ export function buildXhsArticleContent(
1637
+ article: Record<string, unknown>,
1638
+ options: { publishStatus: 'draft' | 'public' },
1639
+ ): Record<string, unknown> {
1640
+ return {
1641
+ ...article,
1642
+ metadata: { ...metadataOf(article), publishStatus: options.publishStatus },
1643
+ };
1644
+ }
1645
+
1646
+ /** The 小红书 上传图文 payload (task 0327 R1): the rendered card series plus its status. */
1647
+ export function buildXhsCardsContent(
1648
+ cards: Record<string, unknown>,
1649
+ options: { publishStatus: 'draft' | 'public'; followUps?: unknown[] },
1650
+ ): Record<string, unknown> {
1651
+ return {
1652
+ ...cards,
1653
+ metadata: {
1654
+ ...metadataOf(cards),
1655
+ publishStatus: options.publishStatus,
1656
+ ...(options.followUps === undefined ? {} : { followUps: options.followUps }),
1657
+ },
1658
+ };
1659
+ }
1660
+
1661
+ /** The podcast payload (task 0327 R1): publish-prep Content plus the episode's show notes. */
1662
+ export function buildPodcastContent(
1663
+ prep: Record<string, unknown>,
1664
+ options: { showNotes: string; podcast?: Record<string, unknown> },
1665
+ ): Record<string, unknown> {
1666
+ return {
1667
+ ...prep,
1668
+ metadata: {
1669
+ ...metadataOf(prep),
1670
+ showNotes: options.showNotes,
1671
+ // Podcast-specific SEO metadata (refined title/description, per-item plan) rides under
1672
+ // one key so a channel payload cannot leak it into another channel's fields.
1673
+ ...(options.podcast === undefined ? {} : { podcast: options.podcast }),
1674
+ },
1675
+ };
1676
+ }
1677
+
1678
+ /**
1679
+ * The surfdash payload (task 0327 R1): the article Content, unchanged.
1680
+ *
1681
+ * surfdash is the article's own publisher, so it consumes what `22-article` already composed — the
1682
+ * builder exists so the variant set is uniform, not because it reshapes anything.
1683
+ */
1684
+ export function buildSurfdashContent(article: Record<string, unknown>, _links: PublishLinks): Record<string, unknown> {
1685
+ return { ...article };
1686
+ }
1687
+
1688
+ /**
1689
+ * One channel's short copy as a Content (task 0327 R3): the body is the authored post text and the
1690
+ * episode cover rides along as `metadata.coverImage`.
1691
+ */
1692
+ /**
1693
+ * A channel's length budget: `max` in that channel's own counting unit.
1694
+ *
1695
+ * `weighted` is X's rule (CJK counts 2, a Latin letter 1) — the design's "280 weighted chars". Counting
1696
+ * code points instead let a 280-character Chinese post through at ~560 of X's units, which is exactly
1697
+ * the over-length draft the backstop exists to catch.
1698
+ */
1699
+ interface SocialLimit {
1700
+ max: number;
1701
+ weighted?: boolean;
1702
+ }
1703
+
1704
+ /** Weight of one code point in X's unit system: CJK ideographs and fullwidth forms count 2. */
1705
+ function weightedCharWeight(char: string): number {
1706
+ const code = char.codePointAt(0) ?? 0;
1707
+ const wide =
1708
+ (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
1709
+ (code >= 0x2e80 && code <= 0xa4cf) || // CJK radicals … Yi
1710
+ (code >= 0xac00 && code <= 0xd7a3) || // Hangul syllables
1711
+ (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
1712
+ (code >= 0xfe30 && code <= 0xfe6f) || // CJK compatibility forms
1713
+ (code >= 0xff00 && code <= 0xff60) || // fullwidth forms
1714
+ (code >= 0xffe0 && code <= 0xffe6);
1715
+ return wide ? 2 : 1;
1716
+ }
1717
+
1718
+ /** Weighted length of `text` (code-point based, so an emoji counts once before its weight). */
1719
+ function weightedLength(text: string): number {
1720
+ let total = 0;
1721
+ for (const char of text) total += weightedCharWeight(char);
1722
+ return total;
1723
+ }
1724
+
1725
+ /**
1726
+ * One channel's short copy as a Content (task 0327 R3): the body is the authored post text and the
1727
+ * episode cover rides along as `metadata.coverImage`.
1728
+ *
1729
+ * The cap is a backstop, not the author: the prompt owns the limit, so an over-length draft is
1730
+ * truncated rather than rejected, and `truncatedFrom` records what was cut. Truncation respects the
1731
+ * channel's own unit — see {@link SocialLimit}.
1732
+ */
1733
+ export function buildSocialContent(
1734
+ copy: string,
1735
+ options: { platform: string; coverPath?: string; limit: SocialLimit },
1736
+ ): Record<string, unknown> {
1737
+ const trimmed = copy.trim();
1738
+ const measure = (text: string): number => (options.limit.weighted === true ? weightedLength(text) : text.length);
1739
+ let body = trimmed;
1740
+ if (measure(trimmed) > options.limit.max) {
1741
+ // Walk code points, adding while the budget holds: a slice by code units could split a
1742
+ // surrogate pair or bust the weighted budget on CJK text.
1743
+ let kept = '';
1744
+ let used = 0;
1745
+ for (const char of trimmed) {
1746
+ const weight = options.limit.weighted === true ? weightedCharWeight(char) : 1;
1747
+ if (used + weight > options.limit.max) break;
1748
+ kept += char;
1749
+ used += weight;
1750
+ }
1751
+ body = kept;
1752
+ }
1753
+ return {
1754
+ title: `${options.platform} · 每日AI乱炖`,
1755
+ format: 'text',
1756
+ body,
1757
+ metadata: {
1758
+ platform: options.platform,
1759
+ ...(body === trimmed
1760
+ ? {}
1761
+ : {
1762
+ truncatedFrom: measure(trimmed),
1763
+ truncatedUnit: options.limit.weighted === true ? 'weighted' : 'code_units',
1764
+ }),
1765
+ ...(options.coverPath === undefined ? {} : { coverImage: options.coverPath }),
1766
+ },
1767
+ };
1768
+ }
1769
+
1770
+ /**
1771
+ * Length budgets the social authoring prompt targets, enforced here as a backstop (task 0327 R3).
1772
+ * X counts CJK at 2 (`weighted`); weibo and threads count characters.
1773
+ */
1774
+ const SOCIAL_LIMITS: Record<string, SocialLimit> = {
1775
+ x: { max: 280, weighted: true },
1776
+ weibo: { max: 2000 },
1777
+ threads: { max: 500 },
1778
+ };
1779
+
1780
+ /** Variant key → file name: `social:x` becomes `social_x.content.json`. */
1781
+ function variantFileName(key: string): string {
1782
+ return `${key.replaceAll(':', '_')}.content.json`;
1783
+ }
1784
+
1785
+ /**
1786
+ * `daily-bundle-variants <workDir> <runDate> <pluginsPath>` — the generate-side payload builder
1787
+ * (task 0327 R2/R3).
1788
+ *
1789
+ * Everything the publish side used to assemble inline is produced here instead, once, as bundle
1790
+ * variants under `30-variants/`. Links are the `{{article_url}}` / `{{podcast_url}}` placeholders,
1791
+ * because at generate time nothing is published yet; `publish-matrix-send` resolves them from the
1792
+ * tier-0 receipts. A variant whose source artifact is absent (channel disabled, cards never
1793
+ * rendered, social copy not authored) is skipped with a log line, never invented.
1794
+ */
1795
+ export function dailyBundleVariants(args: string[]): void {
1796
+ const [workDir = '', runDate = '', _pluginsPath = ''] = args;
1797
+ timed(workDir, 'bundle-variants', () => {
1798
+ const outDir = join(workDir, '30-variants');
1799
+ sted('mkdir', outDir, () => mkdirSync(outDir, { recursive: true }));
1800
+ const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1801
+ const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1802
+ const notesPath = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
1803
+ const cardsPath = join(workDir, '25-xhs', 'content.json');
1804
+ const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1805
+ const socialDir = join(workDir, '26-social');
1806
+ const cover = isFile(coverPath) ? coverPath : undefined;
1807
+ const links = PLACEHOLDER_LINKS;
1808
+ const written: string[] = [];
1809
+ const skipped: string[] = [];
1810
+
1811
+ const article = readJson(articlePath);
1812
+ const prep = readJson(prepPath);
1813
+ const write = (key: string, content: Record<string, unknown>): void => {
1814
+ const file = join(outDir, variantFileName(key));
1815
+ sted('write', file, () => writeJsonText(file, content));
1816
+ written.push(key);
1817
+ };
1818
+
1819
+ // Generic variants first: a channel-specific variant wins over the shared one, so the
1820
+ // shared copy must exist for the channels the preset does not specialise.
1821
+ if (article === null) skipped.push('article (no 22-article content)');
1822
+ else {
1823
+ write('article', buildSurfdashContent(article, links));
1824
+ write('article@surfdash-pub', buildSurfdashContent(article, links));
1825
+ write('article@xhs-pub', buildXhsArticleContent(article, { publishStatus: 'draft' }));
1826
+ write(
1827
+ 'article@wechatmp-pub',
1828
+ buildWechatArticleContent(article, {
1829
+ links,
1830
+ publishStatus: 'draft',
1831
+ collection: '每日AI资讯',
1832
+ ...(cover === undefined ? {} : { coverPath: cover }),
1833
+ }),
1834
+ );
1835
+ }
1836
+
1837
+ if (prep === null) skipped.push('episode (no publish-prep content)');
1838
+ else {
1839
+ const showNotes = isFile(notesPath) ? readFileSync(notesPath, 'utf-8') : '';
1840
+ write('episode', buildPodcastContent(prep, { showNotes }));
1841
+ write('episode@podcast-pub', buildPodcastContent(prep, { showNotes }));
1842
+ write('episode@xhs-pub', buildXhsEpisodeContent(prep, { publishStatus: 'draft', links }));
1843
+ // 公众号 播客 shares the episode with podcast-pub: same audio, same cover, no article body.
1844
+ write(
1845
+ 'episode@wechatmp-pub',
1846
+ buildWechatArticleContent(prep, {
1847
+ links,
1848
+ publishStatus: 'draft',
1849
+ collection: '每日AI资讯',
1850
+ // Audio-first post: it carries the episode body, so no article closer.
1851
+ appendOutro: false,
1852
+ ...(cover === undefined ? {} : { coverPath: cover }),
1853
+ ...(typeof metadataOf(prep).audioFile === 'string'
1854
+ ? { audioPath: String(metadataOf(prep).audioFile) }
1855
+ : {}),
1856
+ }),
1857
+ );
1858
+ }
1859
+
1860
+ const cards = readJson(cardsPath);
1861
+ if (cards === null) skipped.push('cards (no rendered card series)');
1862
+ else write('cards@xhs-pub', buildXhsCardsContent(cards, { publishStatus: 'draft' }));
1863
+
1864
+ for (const platform of Object.keys(SOCIAL_LIMITS)) {
1865
+ const copyPath = join(socialDir, `${platform}.md`);
1866
+ if (!isFile(copyPath)) {
1867
+ skipped.push(`social:${platform} (no 26-social/${platform}.md)`);
1868
+ continue;
1869
+ }
1870
+ const limit = SOCIAL_LIMITS[platform] ?? { max: 500 };
1871
+ write(
1872
+ `social:${platform}`,
1873
+ buildSocialContent(readFileSync(copyPath, 'utf-8'), {
1874
+ platform,
1875
+ limit,
1876
+ ...(cover === undefined ? {} : { coverPath: cover }),
1877
+ }),
1878
+ );
1879
+ }
1880
+
1881
+ console.log(`bundle-variants: ${written.length} variant(s) in ${outDir}: ${written.join(', ')}`);
1882
+ if (skipped.length > 0) console.log(`bundle-variants: skipped ${skipped.join('; ')}`);
1883
+ });
1884
+ }
1885
+
1441
1886
  /**
1442
1887
  * Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
1443
1888
  */
1889
+ /** Surfing frontmatter scalar. Inline on purpose: this file cannot import `@gobing-ai/*`. */
1890
+ function readFrontmatterScalar(filePath: string, key: string): string | undefined {
1891
+ if (!isFile(filePath)) return undefined;
1892
+ let raw: string;
1893
+ try {
1894
+ raw = readFileSync(filePath, 'utf-8');
1895
+ } catch {
1896
+ return undefined;
1897
+ }
1898
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
1899
+ const matter = block?.[1];
1900
+ if (!matter) return undefined;
1901
+ const match = new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(matter);
1902
+ if (!match?.[1]) return undefined;
1903
+ const value = match[1].trim().replace(/^["']|["']$/g, '');
1904
+ return value.length > 0 ? value : undefined;
1905
+ }
1906
+
1907
+ /** The `key_points` item texts of a Surfing post's frontmatter; undefined when absent/unreadable. */
1908
+ export function readKeyPointItems(filePath: string): string[] | undefined {
1909
+ if (!isFile(filePath)) return undefined;
1910
+ let raw: string;
1911
+ try {
1912
+ raw = readFileSync(filePath, 'utf-8');
1913
+ } catch {
1914
+ return undefined;
1915
+ }
1916
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1];
1917
+ if (!block) return undefined;
1918
+ // Slice from `key_points` to the first top-level key (or block end), then take the item lines
1919
+ // (`- `), so folded (`>-`) and quoted single-line items both read once.
1920
+ const head = /^key_points:\n/m.exec(block);
1921
+ if (!head) return undefined;
1922
+ const body = block.slice(head.index + head[0].length);
1923
+ const endMatch = /^\S/m.exec(body);
1924
+ const list = endMatch ? body.slice(0, endMatch.index) : body;
1925
+ // A quoted scalar's quotes are YAML syntax, not text — strip them so label lengths are real.
1926
+ return list
1927
+ .split('\n')
1928
+ .filter((line) => /^[ \t]+- /.test(line))
1929
+ .map((line) => line.replace(/^[ \t]+- /, '').trim())
1930
+ .map((item) => item.replace(/^(["'])(.*)\1$/, '$2'));
1931
+ }
1932
+
1933
+ /** Count `key_points` list items in a Surfing post's frontmatter; undefined when absent/unreadable. */
1934
+ export function countKeyPoints(filePath: string): number | undefined {
1935
+ return readKeyPointItems(filePath)?.length;
1936
+ }
1937
+
1938
+ function firstRefinedTitle(parsed: Record<string, unknown> | null): string {
1939
+ const targets = parsed?.targets;
1940
+ if (!Array.isArray(targets)) return '';
1941
+ for (const entry of targets) {
1942
+ if (entry === null || typeof entry !== 'object') continue;
1943
+ const record = entry as Record<string, unknown>;
1944
+ if (record.target !== 'surfdash-pub' || record.ok !== true) continue;
1945
+ const title = metadataOf(record).refinedTitle;
1946
+ if (typeof title === 'string' && title.trim() !== '') return title.trim();
1947
+ }
1948
+ return '';
1949
+ }
1950
+
1951
+ /** Plan titles and briefs in audio order. The show-notes filter wins when it dropped boundary items. */
1952
+ function planItemsForPodcast(workDir: string, runDate: string): Array<{ title: string; brief?: string }> {
1953
+ const filtered = join(workDir, '21-plan', `${runDate}_03_plan_plan-shownotes.json`);
1954
+ const full = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
1955
+ const path = isFile(filtered) ? filtered : full;
1956
+ if (!isFile(path)) return [];
1957
+ let parsed: unknown;
1958
+ try {
1959
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
1960
+ } catch {
1961
+ return [];
1962
+ }
1963
+ if (!Array.isArray(parsed)) return [];
1964
+ const items: Array<{ title: string; brief?: string }> = [];
1965
+ for (const entry of parsed) {
1966
+ if (entry === null || typeof entry !== 'object') continue;
1967
+ const record = entry as Record<string, unknown>;
1968
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
1969
+ if (title === '') continue;
1970
+ const meta = record.metadata;
1971
+ const metaBrief =
1972
+ meta !== null && typeof meta === 'object' && typeof (meta as Record<string, unknown>).brief === 'string'
1973
+ ? String((meta as Record<string, unknown>).brief).trim()
1974
+ : '';
1975
+ const body = typeof record.body === 'string' ? record.body.trim() : '';
1976
+ const brief = metaBrief || (splitSentences(body, { cjkOnly: true })[0] ?? '').trim();
1977
+ items.push(brief.length > 0 ? { title, brief } : { title });
1978
+ }
1979
+ return items;
1980
+ }
1981
+
1444
1982
  export function dailyPublishPodcast(args: string[]): void {
1445
1983
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
1446
1984
  const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
@@ -1454,7 +1992,23 @@ export function dailyPublishPodcast(args: string[]): void {
1454
1992
  try {
1455
1993
  const content = requireJson(prep, 'publish-podcast');
1456
1994
  const showNotes = readFileSync(notes, 'utf-8');
1457
- const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
1995
+ const surfdash = readJson(join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`));
1996
+ const postPath = firstPostPath(surfdash, 'surfdash-pub');
1997
+ const refinedTitle = firstRefinedTitle(surfdash);
1998
+ const refinedDescription = postPath !== '' ? readFrontmatterScalar(postPath, 'description') : undefined;
1999
+ const planItems = planItemsForPodcast(workDir, runDate);
2000
+ const prior = metadataOf(content).podcast;
2001
+ const podcast: Record<string, unknown> =
2002
+ prior !== null && typeof prior === 'object' && !Array.isArray(prior)
2003
+ ? { ...(prior as Record<string, unknown>) }
2004
+ : {};
2005
+ if (refinedTitle !== '') podcast.refinedTitle = refinedTitle;
2006
+ if (refinedDescription) podcast.refinedDescription = refinedDescription;
2007
+ if (planItems.length > 0) podcast.planItems = planItems;
2008
+ const merged = buildPodcastContent(content, {
2009
+ showNotes,
2010
+ ...(Object.keys(podcast).length > 0 ? { podcast } : {}),
2011
+ });
1458
2012
  sted('write', prep, () => writeJsonText(prep, merged));
1459
2013
  kk(
1460
2014
  [
@@ -1623,47 +2177,26 @@ export function dailyPublishWechat(args: string[]): void {
1623
2177
  return;
1624
2178
  }
1625
2179
  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.
2180
+ // MP draft enrichment (operator preferences 2026-09-29, completed 2026-10-10) lives in the
2181
+ // builder so a generate-side bundle can produce the same payload without publishing.
1629
2182
  const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1630
2183
  const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
1631
2184
  const surfdashResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1632
2185
  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 等平台搜索【咸话咸说】。';
2186
+ const hasCover = isFile(cover);
2187
+ const hasAudio = isFile(audio);
2188
+ const firstLine = typeof packaged.title === 'string' ? packaged.title : '';
1661
2189
  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
- }),
2190
+ writeJsonText(
2191
+ input,
2192
+ buildWechatArticleContent(packaged, {
2193
+ links: { articleUrl: originalUrl, podcastUrl: '' },
2194
+ publishStatus: want,
2195
+ collection: collectionArg || '每日AI资讯',
2196
+ ...(hasCover ? { coverPath: cover } : {}),
2197
+ ...(hasAudio ? { audioPath: audio, audioTitle: firstLine } : {}),
2198
+ }),
2199
+ ),
1667
2200
  );
1668
2201
  // `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
1669
2202
  // and prints the failing step (session preflight, editor paste, save control), which is what
@@ -1731,14 +2264,13 @@ export function dailyPublishXhs(args: string[]): void {
1731
2264
  const packaged = requireJson(content, 'publish-xhs');
1732
2265
  const followUp = podcast === 'true' ? xhsPodcastNote(prep, want) : undefined;
1733
2266
  sted('write', input, () =>
1734
- writeJsonText(input, {
1735
- ...packaged,
1736
- metadata: {
1737
- ...metadataOf(packaged),
2267
+ writeJsonText(
2268
+ input,
2269
+ buildXhsCardsContent(packaged, {
1738
2270
  publishStatus: want,
1739
2271
  ...(followUp === undefined ? {} : { followUps: [followUp] }),
1740
- },
1741
- }),
2272
+ }),
2273
+ ),
1742
2274
  );
1743
2275
  // `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
1744
2276
  // and prints the failing step (login gate, upload, body, submit control), which is what the
@@ -1770,16 +2302,7 @@ function xhsPodcastNote(prepPath: string, publishStatus: 'draft' | 'public'): Re
1770
2302
  console.log(`publish-xhs: no publish-prep Content at ${prepPath} — the 发播客 note is skipped`);
1771
2303
  return undefined;
1772
2304
  }
1773
- const metadata = metadataOf(prep);
1774
- return {
1775
- ...prep,
1776
- metadata: {
1777
- ...metadata,
1778
- operation: '发播客',
1779
- audio: metadata.audioFile,
1780
- publishStatus,
1781
- },
1782
- };
2305
+ return buildXhsEpisodeContent(prep, { publishStatus });
1783
2306
  }
1784
2307
 
1785
2308
  /**
@@ -2460,6 +2983,9 @@ export function dailyShowNotes(args: string[]): void {
2460
2983
  // Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
2461
2984
  // grammar lives in daily-article-gen's excludedIdsFromNote.
2462
2985
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
2986
+ if (postPath === '') {
2987
+ console.error('WARN podcast-pub: brief mode requested but ARTICLE_POST_PATH is empty; using legacy layout');
2988
+ }
2463
2989
  const articleContent = readJson(article);
2464
2990
  const articleBody = articleContent?.body;
2465
2991
  const excluded = new Set<string>();
@@ -2606,11 +3132,72 @@ export function dailyTranslateSync(args: string[]): void {
2606
3132
  timed(workDir, 'translate-sync', () => {
2607
3133
  // Reads the marker file itself — the collapsed `translate` state has no
2608
3134
  // cross-state var to hand the path through.
2609
- const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8') : '';
3135
+ const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8').trim() : '';
2610
3136
  if (zhPostPath === '') {
2611
3137
  console.log('no zh post path — skip translate-sync');
2612
3138
  return;
2613
3139
  }
3140
+ // The 4th argument is the MARKER file, not the post (2026-10-22). Passing the post made the
3141
+ // plugin read the whole article as `postPath` and echo it back inside its error, which cost a
3142
+ // confusing round trip on a manual recovery run.
3143
+ if (zhPathFile.endsWith('.md') || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
3144
+ failWith(
3145
+ 1,
3146
+ `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}'`}`,
3147
+ );
3148
+ }
3149
+ // The 2026-10-23 translate run published spliced en/ja description/key_points; the locale
3150
+ // channel has no judge, so gate the structural minimum here before the CMS sync:
3151
+ // a summary must exist and the key_points count must match the zh post (splices merge items).
3152
+ // The same run also invented retired series names (毎日AIブレンド, "Daily AI Medley",
3153
+ // zh-name listen links inside en/ja): normalizeSeriesNames rewrites the known bad set per
3154
+ // locale deterministically (URLs untouched) and any leftover it cannot make safe fails the
3155
+ // sync. The zh post rides the same sync, so it gets the same series-name pass.
3156
+ const zhPoints = countKeyPoints(zhPostPath);
3157
+ for (const locale of ['zh', 'en', 'ja'] as const) {
3158
+ const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
3159
+ if (!isFile(sibling)) continue;
3160
+ const problems: string[] = [];
3161
+ const raw = readFileSync(sibling, 'utf-8');
3162
+ const { markdown, remaining } = normalizeSeriesNames(raw, locale);
3163
+ if (markdown !== raw) sted('write', sibling, () => writeRaw(sibling, markdown));
3164
+ if (remaining.length > 0) {
3165
+ problems.push(`retired series names left after rewrite: ${remaining.join(' / ')}`);
3166
+ }
3167
+ if (locale !== 'zh') {
3168
+ if (readFrontmatterScalar(sibling, 'description') === undefined) {
3169
+ problems.push('description missing/empty');
3170
+ }
3171
+ const points = countKeyPoints(sibling);
3172
+ if (zhPoints !== undefined && points !== undefined && points !== zhPoints) {
3173
+ problems.push(`key_points count ${points} != zh ${zhPoints} (spliced or merged items)`);
3174
+ }
3175
+ // Label discipline (same 2026-10-23 audit): a key_points item is `Label — sentence`
3176
+ // and the label is a short phrase — ≤14 chars for ja, ≤28 for en — never a
3177
+ // sentence and never an ellipsis-truncated fragment.
3178
+ const items = readKeyPointItems(sibling);
3179
+ if (items !== undefined) {
3180
+ const cap = locale === 'en' ? 28 : 14;
3181
+ const overLimit = items.filter((item) => {
3182
+ const sep = item.indexOf(' — ');
3183
+ if (sep < 0) return false;
3184
+ const label = item.slice(0, sep);
3185
+ return label.includes('…') || Array.from(label).length > cap;
3186
+ });
3187
+ if (overLimit.length > 0) {
3188
+ problems.push(
3189
+ `key_points label over ${cap} chars or …-truncated: ${overLimit.length} of ${items.length} items`,
3190
+ );
3191
+ }
3192
+ }
3193
+ }
3194
+ if (problems.length > 0) {
3195
+ failWith(
3196
+ 1,
3197
+ `translate-sync: ${locale} draft failed the pre-sync structural check (${problems.join('; ')}) — repair ${sibling} per the translate stage contract before syncing`,
3198
+ );
3199
+ }
3200
+ }
2614
3201
  sted('write', sync, () =>
2615
3202
  writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
2616
3203
  );