@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/board/collect/index.js +725 -1101
- package/dist/board/daily-voice/index.js +722 -101
- package/dist/board/itc/index.js +13 -0
- package/dist/index.js +1796 -102
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +4 -0
- package/plugins/generations/core-facts-gen/dist/index.js +4 -0
- package/plugins/generations/daily-article-gen/dist/index.js +4 -0
- package/plugins/generations/dailynews-gen/dist/index.js +4 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
- package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
- package/plugins/generations/gemini-tts/dist/index.js +4 -0
- package/plugins/generations/image-gen/dist/index.js +4 -0
- package/plugins/generations/news-report-gen/dist/index.js +4 -0
- package/plugins/generations/voice-gen/dist/index.js +4 -0
- package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
- package/plugins/ingestions/horizon-ingest/dist/index.js +28 -1
- package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
- package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
- package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
- package/plugins/ingestions/web-search/dist/index.js +4 -0
- package/plugins/kk/config.example.yaml +8 -0
- 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 +665 -78
- package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
- package/plugins/kk/scripts/sentences.ts +134 -0
- package/plugins/kk/scripts/series-names.ts +124 -0
- 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 +112 -9
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
- package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
- package/plugins/kk/workflows/kk-daily-safety.yaml +50 -5
- package/plugins/publishings/bilibili-pub/dist/index.js +4 -0
- package/plugins/publishings/cnblogs-pub/dist/index.js +4 -0
- package/plugins/publishings/csdn-pub/dist/index.js +4 -0
- package/plugins/publishings/csdn-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/emdash-pub/dist/index.js +4 -0
- package/plugins/publishings/infoq-pub/dist/index.js +4 -0
- package/plugins/publishings/infoq-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/jike-pub/dist/index.js +4 -0
- package/plugins/publishings/jike-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/juejin-pub/dist/index.js +4 -0
- package/plugins/publishings/juejin-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/medium-pub/dist/index.js +4 -0
- package/plugins/publishings/podcast-pub/README.md +65 -0
- package/plugins/publishings/podcast-pub/dist/index.js +1906 -374
- 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 +1079 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +74 -14
- package/plugins/publishings/qiita-pub/dist/index.js +4 -0
- package/plugins/publishings/shipinhao-pub/dist/index.js +4 -0
- package/plugins/publishings/sspai-pub/dist/index.js +4 -0
- package/plugins/publishings/substack-pub/dist/index.js +4 -0
- package/plugins/publishings/substack-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +313 -32
- package/plugins/publishings/surfdash-pub/src/digest-check.ts +218 -0
- package/plugins/publishings/surfdash-pub/src/index.ts +170 -39
- package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
- package/plugins/publishings/threads-pub/dist/index.js +4 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +4 -0
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/weibo-pub/dist/index.js +4 -0
- package/plugins/publishings/weibo-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/x-pub/dist/index.js +4 -0
- package/plugins/publishings/x-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/xhs-pub/dist/index.js +4 -0
- package/plugins/publishings/xhs-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +4 -0
- 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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
1634
|
-
|
|
1635
|
-
|
|
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(
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
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(
|
|
1735
|
-
|
|
1736
|
-
|
|
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
|
-
|
|
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
|
);
|