@gobing-ai/knowledge-kit 0.1.7 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/board/collect/index.js +744 -1107
- package/dist/board/daily-voice/index.js +741 -107
- package/dist/board/itc/index.js +32 -6
- package/dist/index.js +1793 -103
- package/package.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +24 -1
- package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
- package/plugins/kk/config.example.yaml +8 -0
- package/plugins/kk/decisions/extract/ai-news.yaml +7 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
- package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
- package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
- package/plugins/kk/scripts/daily-stages.ts +664 -83
- package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
- package/plugins/kk/scripts/topic-boundary.json +18 -83
- package/plugins/kk/workflows/kk-collect.yaml +2 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +82 -8
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
- package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
- package/plugins/publishings/podcast-pub/README.md +65 -0
- package/plugins/publishings/podcast-pub/dist/index.js +1709 -274
- package/plugins/publishings/podcast-pub/plugin.json +1 -1
- package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
- package/plugins/publishings/podcast-pub/src/index.ts +211 -34
- package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
- package/plugins/publishings/podcast-pub/src/seo.ts +1081 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +71 -6
- package/plugins/publishings/surfdash-pub/dist/index.js +66 -5
- package/plugins/publishings/surfdash-pub/src/index.ts +94 -10
- package/plugins/publishings/wechatmp-pub/dist/index.js +54 -30
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +8 -3
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +58 -38
- package/plugins/publishings/wechatmp-pub/src/flow.ts +18 -1
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -1
|
@@ -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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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)) {
|
|
@@ -938,11 +1028,14 @@ export function dailyGenerate(args: string[]): void {
|
|
|
938
1028
|
if (existsSync(planPath)) {
|
|
939
1029
|
try {
|
|
940
1030
|
const plan = readJson(planPath) as { items?: unknown[] };
|
|
941
|
-
if (Array.isArray(plan.items)
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1031
|
+
if (Array.isArray(plan.items)) {
|
|
1032
|
+
const expected = expectedItemSections(workDir, runDate, plan.items);
|
|
1033
|
+
if (expected !== itemSections.length) {
|
|
1034
|
+
failWith(
|
|
1035
|
+
1,
|
|
1036
|
+
`generate: plan item count (${plan.items.length}, ${plan.items.length - expected} recorded as excluded) does not match script item sections (${itemSections.length})`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
946
1039
|
}
|
|
947
1040
|
} catch (e: unknown) {
|
|
948
1041
|
if (e instanceof StageFailure) throw e;
|
|
@@ -1435,9 +1528,424 @@ export function dailyPlan(args: string[]): void {
|
|
|
1435
1528
|
});
|
|
1436
1529
|
}
|
|
1437
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
|
+
|
|
1438
1884
|
/**
|
|
1439
1885
|
* Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
|
|
1440
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
|
+
|
|
1441
1949
|
export function dailyPublishPodcast(args: string[]): void {
|
|
1442
1950
|
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
1443
1951
|
const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
@@ -1451,7 +1959,23 @@ export function dailyPublishPodcast(args: string[]): void {
|
|
|
1451
1959
|
try {
|
|
1452
1960
|
const content = requireJson(prep, 'publish-podcast');
|
|
1453
1961
|
const showNotes = readFileSync(notes, 'utf-8');
|
|
1454
|
-
const
|
|
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
|
+
});
|
|
1455
1979
|
sted('write', prep, () => writeJsonText(prep, merged));
|
|
1456
1980
|
kk(
|
|
1457
1981
|
[
|
|
@@ -1620,47 +2144,26 @@ export function dailyPublishWechat(args: string[]): void {
|
|
|
1620
2144
|
return;
|
|
1621
2145
|
}
|
|
1622
2146
|
const packaged = requireJson(article, 'publish-wechat');
|
|
1623
|
-
// MP draft enrichment (operator preferences 2026-09-29, completed 2026-10-10)
|
|
1624
|
-
//
|
|
1625
|
-
// 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.
|
|
1626
2149
|
const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
1627
2150
|
const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
|
|
1628
2151
|
const surfdashResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
1629
2152
|
const originalUrl = isFile(surfdashResult) ? firstTargetUrl(readJson(surfdashResult), 'surfdash-pub') : '';
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
// leaves it empty (it defaults from 作者, operator walk 2026-10-10).
|
|
1634
|
-
reward: true,
|
|
1635
|
-
author: '冰原奔狼',
|
|
1636
|
-
collection: collectionArg || '每日AI资讯',
|
|
1637
|
-
// 创作来源 is the column's own stance, not an AI-declaration label — the operator owns
|
|
1638
|
-
// the wording (2026-10-10).
|
|
1639
|
-
claimSource: '个人观点,仅供参考',
|
|
1640
|
-
// 封面 comes from 图片库: the episode cover is uploaded there as the audio material's
|
|
1641
|
-
// cover, so the library's newest entry is the intended image.
|
|
1642
|
-
coverSource: 'library',
|
|
1643
|
-
...(originalUrl !== '' ? { originalUrl } : {}),
|
|
1644
|
-
...(isFile(cover) ? { coverPath: cover } : {}),
|
|
1645
|
-
// Audio block at the article top (operator request 2026-09-29; the plugin UPLOADS the
|
|
1646
|
-
// material when the 素材库 has no same-title entry, 2026-10-10).
|
|
1647
|
-
...(isFile(audio)
|
|
1648
|
-
? {
|
|
1649
|
-
audioPath: audio,
|
|
1650
|
-
audioTitle: typeof packaged.title === 'string' ? packaged.title : '',
|
|
1651
|
-
audioCoverPath: isFile(cover) ? cover : '',
|
|
1652
|
-
}
|
|
1653
|
-
: {}),
|
|
1654
|
-
};
|
|
1655
|
-
// WeChat-only closing line (operator request 2026-09-29): point readers/listeners back to
|
|
1656
|
-
// the show's platforms. Appended to the wechat input body only, never the master article.
|
|
1657
|
-
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 : '';
|
|
1658
2156
|
sted('write', input, () =>
|
|
1659
|
-
writeJsonText(
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
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
|
+
),
|
|
1664
2167
|
);
|
|
1665
2168
|
// `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
|
|
1666
2169
|
// and prints the failing step (session preflight, editor paste, save control), which is what
|
|
@@ -1728,14 +2231,13 @@ export function dailyPublishXhs(args: string[]): void {
|
|
|
1728
2231
|
const packaged = requireJson(content, 'publish-xhs');
|
|
1729
2232
|
const followUp = podcast === 'true' ? xhsPodcastNote(prep, want) : undefined;
|
|
1730
2233
|
sted('write', input, () =>
|
|
1731
|
-
writeJsonText(
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
...metadataOf(packaged),
|
|
2234
|
+
writeJsonText(
|
|
2235
|
+
input,
|
|
2236
|
+
buildXhsCardsContent(packaged, {
|
|
1735
2237
|
publishStatus: want,
|
|
1736
2238
|
...(followUp === undefined ? {} : { followUps: [followUp] }),
|
|
1737
|
-
},
|
|
1738
|
-
|
|
2239
|
+
}),
|
|
2240
|
+
),
|
|
1739
2241
|
);
|
|
1740
2242
|
// `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
|
|
1741
2243
|
// and prints the failing step (login gate, upload, body, submit control), which is what the
|
|
@@ -1767,16 +2269,7 @@ function xhsPodcastNote(prepPath: string, publishStatus: 'draft' | 'public'): Re
|
|
|
1767
2269
|
console.log(`publish-xhs: no publish-prep Content at ${prepPath} — the 发播客 note is skipped`);
|
|
1768
2270
|
return undefined;
|
|
1769
2271
|
}
|
|
1770
|
-
|
|
1771
|
-
return {
|
|
1772
|
-
...prep,
|
|
1773
|
-
metadata: {
|
|
1774
|
-
...metadata,
|
|
1775
|
-
operation: '发播客',
|
|
1776
|
-
audio: metadata.audioFile,
|
|
1777
|
-
publishStatus,
|
|
1778
|
-
},
|
|
1779
|
-
};
|
|
2272
|
+
return buildXhsEpisodeContent(prep, { publishStatus });
|
|
1780
2273
|
}
|
|
1781
2274
|
|
|
1782
2275
|
/**
|
|
@@ -2091,6 +2584,16 @@ export function dailySafetyPrep(args: string[]): void {
|
|
|
2091
2584
|
const [workDir = '', runDate = '', platforms = ''] = args;
|
|
2092
2585
|
timed(workDir, 'safety-review', () => {
|
|
2093
2586
|
const digest = safetyDigest(workDir, runDate);
|
|
2587
|
+
// Structural snapshot of the VoiceScript BEFORE any remediation edit. `daily-safety-revise`
|
|
2588
|
+
// restores it when the remediation pass changes the item-section structure without a recorded
|
|
2589
|
+
// exclusion: the agent is allowed to drop a 播报形态 line, not an item the article kept,
|
|
2590
|
+
// because `daily-wrap-docs` derives the spoken item sections from `gap_ms: 1200` and fails the
|
|
2591
|
+
// run when they no longer match the episode's included items (2026-10-21 04:30 run: 3
|
|
2592
|
+
// segments removed → 5 sections vs a 6-item plan → `generate` refused at 05:03 and the whole
|
|
2593
|
+
// day published nothing).
|
|
2594
|
+
const [, scriptPath] = safetyDraftPaths(workDir, runDate);
|
|
2595
|
+
const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
|
|
2596
|
+
if (isFile(scriptPath)) sted('write', snapshot, () => writeRaw(snapshot, readFileSync(scriptPath, 'utf-8')));
|
|
2094
2597
|
for (const name of [
|
|
2095
2598
|
'safety-review_report.json',
|
|
2096
2599
|
'safety-judge_verdict.json',
|
|
@@ -2150,6 +2653,7 @@ export function dailySafetyRevise(args: string[]): void {
|
|
|
2150
2653
|
}
|
|
2151
2654
|
}
|
|
2152
2655
|
assertScriptContent(scriptPath);
|
|
2656
|
+
restoreStructureIfBroken(workDir, runDate, scriptPath);
|
|
2153
2657
|
const path = join(workDir, SAFETY_REVISE_COUNT);
|
|
2154
2658
|
const next = readSafetyReviseCount(workDir) + 1;
|
|
2155
2659
|
sted('write', path, () => writeFileSync(path, `${next}\n`));
|
|
@@ -2157,6 +2661,71 @@ export function dailySafetyRevise(args: string[]): void {
|
|
|
2157
2661
|
});
|
|
2158
2662
|
}
|
|
2159
2663
|
|
|
2664
|
+
/**
|
|
2665
|
+
* The spoken item-section count the episode is contracted to carry: the script plan's items minus the
|
|
2666
|
+
* ones the article boundary recorded in `metadata.excludedIds`.
|
|
2667
|
+
*
|
|
2668
|
+
* The article drops boundary docs at its own stage, the safety gate aligns the VoiceScript with the
|
|
2669
|
+
* article it reviews, and `daily-show-notes` already filters the plan copy by the same ids — but
|
|
2670
|
+
* `daily-generate` compared the sections against the raw plan length, so the 2026-10-21 run refused a
|
|
2671
|
+
* 5-section script against a 6-item plan (the 6th was the article-excluded 通用汽车 buyback) after two
|
|
2672
|
+
* hours of safety review and rendered nothing. Every consumer must count the same set.
|
|
2673
|
+
*/
|
|
2674
|
+
export function expectedItemSections(workDir: string, runDate: string, planItems: unknown[]): number {
|
|
2675
|
+
const excluded = metadataOf(
|
|
2676
|
+
readJson(join(workDir, '22-article', `${runDate}_06_article_content.json`)),
|
|
2677
|
+
).excludedIds;
|
|
2678
|
+
if (!Array.isArray(excluded)) return planItems.length;
|
|
2679
|
+
const dropped = new Set(excluded.filter((id): id is string => typeof id === 'string' && id !== ''));
|
|
2680
|
+
if (dropped.size === 0) return planItems.length;
|
|
2681
|
+
return planItems.filter(
|
|
2682
|
+
(item) => !(item !== null && typeof item === 'object' && dropped.has(String((item as { id?: unknown }).id))),
|
|
2683
|
+
).length;
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
/**
|
|
2687
|
+
* Repair a remediation pass that changed the VoiceScript's item-section structure.
|
|
2688
|
+
*
|
|
2689
|
+
* `daily-wrap-docs` (and `daily-generate`) derive the spoken item sections from `gap_ms: 1200` and
|
|
2690
|
+
* refuse the run when they disagree with the episode's included items. The safety agent is allowed
|
|
2691
|
+
* to reshape lines, and to drop an item the article boundary already excluded (that drop is the
|
|
2692
|
+
* contract, and `expectedItemSections` counts it out), but an unrecorded drop breaks the one-to-one
|
|
2693
|
+
* audio/notes/plan relation — and on 2026-10-21 one killed the 04:30 run at `generate` after two
|
|
2694
|
+
* hours of safety review. The pre-revise bytes are snapshotted by `daily-safety-prep`, so an
|
|
2695
|
+
* unaccounted structural change is undone here, inside the budget loop, and named for the operator.
|
|
2696
|
+
* The budget is still spent, so the loop still converges (the re-review sees the unchanged script and
|
|
2697
|
+
* the gate classifies the residue) instead of the day failing hours later at the TTS seam.
|
|
2698
|
+
*/
|
|
2699
|
+
function restoreStructureIfBroken(workDir: string, runDate: string, scriptPath: string): void {
|
|
2700
|
+
const planPath = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
|
|
2701
|
+
const plan = readJson(planPath) as { items?: unknown[] } | null;
|
|
2702
|
+
if (plan === null || !Array.isArray(plan.items)) return;
|
|
2703
|
+
const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
|
|
2704
|
+
if (!isFile(snapshot)) return;
|
|
2705
|
+
const sections = (path: string): number => {
|
|
2706
|
+
const parsed = Bun.YAML.parse(readFileSync(path, 'utf-8')) as { segments?: Array<Record<string, unknown>> };
|
|
2707
|
+
const segments = Array.isArray(parsed.segments) ? parsed.segments : [];
|
|
2708
|
+
let count = 0;
|
|
2709
|
+
let pending = false;
|
|
2710
|
+
for (const segment of segments.slice(1, -1)) {
|
|
2711
|
+
pending = true;
|
|
2712
|
+
if (segment.gap_ms === 1200) {
|
|
2713
|
+
count += 1;
|
|
2714
|
+
pending = false;
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
return count + (pending ? 1 : 0);
|
|
2718
|
+
};
|
|
2719
|
+
const expected = expectedItemSections(workDir, runDate, plan.items);
|
|
2720
|
+
const actual = sections(scriptPath);
|
|
2721
|
+
if (actual === expected) return;
|
|
2722
|
+
const original = sections(snapshot);
|
|
2723
|
+
sted('write', scriptPath, () => writeRaw(scriptPath, readFileSync(snapshot, 'utf-8')));
|
|
2724
|
+
console.error(
|
|
2725
|
+
`safety-revise: the edit changed the VoiceScript item sections (${expected} included plan items, ${original} before, ${actual} after) — restored the pre-revision structure from ${basename(snapshot)}; audio/notes stay one-to-one with the plan`,
|
|
2726
|
+
);
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2160
2729
|
/** Block YAML for the fields a fitted VoiceScript actually carries. Strings use JSON quoting. */
|
|
2161
2730
|
function voicescriptToBlockYaml(script: Record<string, unknown>): string {
|
|
2162
2731
|
const lines: string[] = [];
|
|
@@ -2381,6 +2950,9 @@ export function dailyShowNotes(args: string[]): void {
|
|
|
2381
2950
|
// Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
|
|
2382
2951
|
// grammar lives in daily-article-gen's excludedIdsFromNote.
|
|
2383
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
|
+
}
|
|
2384
2956
|
const articleContent = readJson(article);
|
|
2385
2957
|
const articleBody = articleContent?.body;
|
|
2386
2958
|
const excluded = new Set<string>();
|
|
@@ -2527,11 +3099,20 @@ export function dailyTranslateSync(args: string[]): void {
|
|
|
2527
3099
|
timed(workDir, 'translate-sync', () => {
|
|
2528
3100
|
// Reads the marker file itself — the collapsed `translate` state has no
|
|
2529
3101
|
// cross-state var to hand the path through.
|
|
2530
|
-
const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8') : '';
|
|
3102
|
+
const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8').trim() : '';
|
|
2531
3103
|
if (zhPostPath === '') {
|
|
2532
3104
|
console.log('no zh post path — skip translate-sync');
|
|
2533
3105
|
return;
|
|
2534
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
|
+
}
|
|
2535
3116
|
sted('write', sync, () =>
|
|
2536
3117
|
writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
|
|
2537
3118
|
);
|