@gobing-ai/knowledge-kit 0.0.33 → 0.0.35
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/index.js +25191 -24167
- package/package.json +4 -4
- package/plugins/generations/content-gen/dist/index.js +9 -9
- package/plugins/generations/core-facts-gen/dist/index.js +12 -12
- package/plugins/generations/daily-article-gen/dist/index.js +31 -23
- package/plugins/generations/daily-article-gen/src/index.ts +39 -14
- package/plugins/generations/dailynews-gen/dist/index.js +23 -21
- package/plugins/generations/dailynews-gen/src/script-builder.ts +30 -26
- package/plugins/generations/episode-plan-gen/dist/index.js +12 -12
- package/plugins/generations/gemini-tts/dist/index.js +13 -12
- package/plugins/generations/image-gen/dist/index.js +26 -11
- package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +16 -14
- package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -2
- package/plugins/generations/image-gen/src/compose/card.ts +17 -2
- package/plugins/generations/news-report-gen/dist/index.js +12 -12
- package/plugins/generations/omni-voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/dist/index.js +13 -12
- package/plugins/generations/voice-gen/src/index.ts +9 -3
- package/plugins/ingestions/aihot-ingest/dist/index.js +12 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +12 -12
- package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
- package/plugins/ingestions/last30days-ingest/dist/index.js +12 -12
- package/plugins/ingestions/web-search/dist/index.js +10 -10
- package/plugins/kk/decisions/extract/ai-news.yaml +6 -0
- package/plugins/kk/decisions/kk-items.yaml +69 -3
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-stages.ts +834 -87
- package/plugins/kk/scripts/daily-voice-style.zh.md +15 -10
- package/plugins/kk/scripts/kk-workflow-stages.ts +46 -3
- package/plugins/kk/skills/content-safety/SKILL.md +1 -1
- package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +2 -2
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
- package/plugins/kk/workflows/kk-collect.yaml +7 -9
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +32 -7
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +169 -47
- package/plugins/kk/workflows/kk-daily-safety.yaml +9 -12
- package/plugins/publishings/emdash-pub/dist/index.js +9 -9
- package/plugins/publishings/infoq-pub/dist/index.js +22 -22
- package/plugins/publishings/infoq-pub/dist/profile-check.js +30336 -0
- package/plugins/publishings/infoq-pub/plugin.json +1 -1
- package/plugins/publishings/juejin-pub/dist/index.js +22 -22
- package/plugins/publishings/juejin-pub/dist/profile-check.js +30322 -0
- package/plugins/publishings/juejin-pub/plugin.json +1 -1
- package/plugins/publishings/medium-pub/dist/index.js +9 -9
- package/plugins/publishings/podcast-pub/dist/index.js +9 -9
- package/plugins/publishings/qiita-pub/dist/index.js +9 -9
- package/plugins/publishings/substack-pub/dist/index.js +22 -22
- package/plugins/publishings/substack-pub/dist/profile-check.js +30392 -0
- package/plugins/publishings/substack-pub/plugin.json +1 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +9 -9
- package/plugins/publishings/wechatmp-pub/dist/index.js +329 -154
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +30271 -0
- package/plugins/publishings/wechatmp-pub/plugin.json +1 -1
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +106 -145
- package/plugins/publishings/wechatmp-pub/src/flow.ts +283 -42
- package/plugins/publishings/wechatmp-pub/src/page.ts +20 -0
- package/plugins/publishings/x-pub/dist/index.js +22 -22
- package/plugins/publishings/x-pub/dist/profile-check.js +30328 -0
- package/plugins/publishings/x-pub/plugin.json +1 -1
- package/plugins/publishings/xhs-pub/dist/index.js +76 -27
- package/plugins/publishings/xhs-pub/dist/profile-check.js +30538 -0
- package/plugins/publishings/xhs-pub/plugin.json +1 -1
- package/plugins/publishings/xhs-pub/src/flow.ts +9 -3
- package/plugins/publishings/xhs-pub/src/index.ts +111 -5
- package/plugins/publishings/zenn-pub/dist/index.js +9 -9
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
|
@@ -77,8 +77,10 @@ import {
|
|
|
77
77
|
failWith,
|
|
78
78
|
firstArtifact,
|
|
79
79
|
firstPostPath,
|
|
80
|
+
firstTargetUrl,
|
|
80
81
|
isFile,
|
|
81
82
|
kk,
|
|
83
|
+
kkCaptured,
|
|
82
84
|
metadataOf,
|
|
83
85
|
NESTED_SPUR_UNSET,
|
|
84
86
|
PII_FINDING_CATEGORY,
|
|
@@ -104,8 +106,10 @@ import {
|
|
|
104
106
|
writeJqText,
|
|
105
107
|
writeJsonText,
|
|
106
108
|
writeRaw,
|
|
109
|
+
XHS_BODY_CAP,
|
|
107
110
|
XHS_FORMAT,
|
|
108
111
|
XHS_MAX_CARDS,
|
|
112
|
+
type XhsCard,
|
|
109
113
|
xhsPlan,
|
|
110
114
|
} from './kk-workflow-stages.ts';
|
|
111
115
|
|
|
@@ -162,25 +166,41 @@ export function dailyQcContent(args: string[]): void {
|
|
|
162
166
|
const boundaryDocs = metadataOf(readJson(candidates)).docs;
|
|
163
167
|
if (!Array.isArray(boundaryDocs)) return;
|
|
164
168
|
const { kept, excluded } = applyTopicBoundary(boundaryDocs, runDate, readTopicBoundary());
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
169
|
+
// A no-op boundary must not rewrite the file: the idempotent-rerun test compares bytes.
|
|
170
|
+
if (excluded.length > 0) {
|
|
171
|
+
sted('write', exclusions, () =>
|
|
172
|
+
writeJsonText(exclusions, {
|
|
173
|
+
version: 1,
|
|
174
|
+
runDate,
|
|
175
|
+
excluded,
|
|
176
|
+
kept: kept.map((doc) => (doc as { id: string }).id),
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
const content = requireJson(candidates, 'topic-boundary');
|
|
180
|
+
const meta = content.metadata;
|
|
181
|
+
if (meta === null || typeof meta !== 'object') {
|
|
182
|
+
failWith(1, 'topic-boundary: candidates content carries no metadata object');
|
|
183
|
+
}
|
|
184
|
+
(meta as Record<string, unknown>).docs = kept;
|
|
185
|
+
sted('write', candidates, () => writeJsonText(candidates, content));
|
|
186
|
+
console.log(
|
|
187
|
+
`topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
// Title dedup before plan-translate / script-write. dailyArticle repeats this on plan.json, but
|
|
191
|
+
// that runs in parallel with script-write: an id dropped after the script plan is authored is
|
|
192
|
+
// `unknown-id`, the whole plan is rejected, and generate then sees a template with fewer sections.
|
|
193
|
+
const currentDocs = excluded.length > 0 ? kept : boundaryDocs;
|
|
194
|
+
const { kept: deduped, dropped } = dedupPlanByTitle(currentDocs as Array<{ id?: unknown; title?: unknown }>);
|
|
195
|
+
if (dropped.length === 0) return;
|
|
196
|
+
const dedupedContent = requireJson(candidates, 'qc-dedup');
|
|
197
|
+
const dedupedMeta = dedupedContent.metadata;
|
|
198
|
+
if (dedupedMeta === null || typeof dedupedMeta !== 'object') {
|
|
199
|
+
failWith(1, 'qc-dedup: candidates content carries no metadata object');
|
|
178
200
|
}
|
|
179
|
-
(
|
|
180
|
-
sted('write', candidates, () => writeJsonText(candidates,
|
|
181
|
-
console.
|
|
182
|
-
`topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
|
|
183
|
-
);
|
|
201
|
+
(dedupedMeta as Record<string, unknown>).docs = deduped;
|
|
202
|
+
sted('write', candidates, () => writeJsonText(candidates, dedupedContent));
|
|
203
|
+
console.error(`qc: dropped ${dropped.length} near-duplicate candidate(s): ${dropped.join(', ')}`);
|
|
184
204
|
}
|
|
185
205
|
|
|
186
206
|
/**
|
|
@@ -197,12 +217,31 @@ export function dailyArticle(args: string[]): void {
|
|
|
197
217
|
timed(workDir, 'article', () => {
|
|
198
218
|
let planUsable = true;
|
|
199
219
|
const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
|
|
220
|
+
// The plan file is canonically the plan `Doc[]` — `dailyPlan` writes the generator's
|
|
221
|
+
// `metadata.docs` — and every downstream stage reads it as one. `plan-translate`'s executor
|
|
222
|
+
// has been observed writing the generator's Content ENVELOPE back instead: valid JSON of the
|
|
223
|
+
// wrong shape, which the article generator then refuses with `expected array, received
|
|
224
|
+
// object`. Unwrap that envelope rather than discard the translation; anything else unusable
|
|
225
|
+
// still falls back to the untranslated candidates.
|
|
226
|
+
let planShape: unknown;
|
|
200
227
|
try {
|
|
201
|
-
JSON.parse(raw);
|
|
228
|
+
planShape = JSON.parse(raw);
|
|
202
229
|
} catch (error) {
|
|
203
230
|
console.error(
|
|
204
231
|
`article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): ${(error as Error).message}`,
|
|
205
232
|
);
|
|
233
|
+
}
|
|
234
|
+
if (planShape !== undefined && !Array.isArray(planShape)) {
|
|
235
|
+
const enveloped = metadataOf(planShape as Record<string, unknown>).docs;
|
|
236
|
+
if (Array.isArray(enveloped) && enveloped.length > 0) {
|
|
237
|
+
console.error(
|
|
238
|
+
`article: plan.json carries the Content envelope, not the Doc[] — unwrapping metadata.docs (${enveloped.length} doc(s))`,
|
|
239
|
+
);
|
|
240
|
+
sted('write', planIn, () => writeJsonText(planIn, enveloped));
|
|
241
|
+
planShape = enveloped;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!Array.isArray(planShape)) {
|
|
206
245
|
const docs = metadataOf(readJson(candidates)).docs;
|
|
207
246
|
if (!Array.isArray(docs) || docs.length === 0) {
|
|
208
247
|
console.error('article: candidates.json metadata.docs missing');
|
|
@@ -386,8 +425,51 @@ export function dailyEpisodeDuration(args: string[]): void {
|
|
|
386
425
|
console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
|
|
387
426
|
}
|
|
388
427
|
|
|
428
|
+
/** Ceiling for the pre-spend estimate when the caller passes no `episode_max_minutes`. */
|
|
429
|
+
const DEFAULT_EPISODE_MAX_MINUTES = 20;
|
|
430
|
+
|
|
431
|
+
/** `kk executor run` argv shared by the quota probe, the voice1 one-shot, and each section. */
|
|
432
|
+
function voiceExecutorArgs(generator: string, input: string, output: string, pluginsPath: string): string[] {
|
|
433
|
+
return ['executor', 'run', generator, '--in', input, '--out', output, ...pluginsArgs(pluginsPath)];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* One tiny gemini-tts render before any episode speech. A non-zero exit (quota, key, network)
|
|
438
|
+
* means this run should use voicebox. Probe files are removed either way.
|
|
439
|
+
* Returns false when the caller should fall back.
|
|
440
|
+
*/
|
|
441
|
+
function geminiQuotaProbeOk(workDir: string, pluginsPath: string, transcodeMp3: string, probeDocs: unknown): boolean {
|
|
442
|
+
const probeDir = join(workDir, '30-audio');
|
|
443
|
+
const probeIn = join(probeDir, '.gemini-probe-in.json');
|
|
444
|
+
const probeOut = join(probeDir, '.gemini-probe-out.json');
|
|
445
|
+
writeJsonText(probeIn, probeDocs);
|
|
446
|
+
const probe = spawnSync('kk', voiceExecutorArgs('gemini-tts', probeIn, probeOut, pluginsPath), {
|
|
447
|
+
encoding: 'utf8',
|
|
448
|
+
timeout: 120_000,
|
|
449
|
+
env: { ...process.env, VOICE_GEN_MP3: transcodeMp3 },
|
|
450
|
+
});
|
|
451
|
+
for (const stale of [probeIn, probeOut, probeOut.replace(/\.json$/, '.wav'), probeOut.replace(/\.json$/, '.mp3')]) {
|
|
452
|
+
rmSync(stale, { force: true });
|
|
453
|
+
}
|
|
454
|
+
if (probe.status !== 0) {
|
|
455
|
+
const reason = (probe.stderr ?? '').trim().split('\n').slice(-1)[0] || `exit ${probe.status}`;
|
|
456
|
+
console.log(`generate: gemini quota probe failed (${reason}) — falling back to voicebox for this run`);
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
console.log('generate: gemini quota probe ok');
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
|
|
389
463
|
export function dailyGenerate(args: string[]): void {
|
|
390
|
-
const [
|
|
464
|
+
const [
|
|
465
|
+
workDir = '',
|
|
466
|
+
runDate = '',
|
|
467
|
+
pluginsPath = '',
|
|
468
|
+
transcodeMp3 = '',
|
|
469
|
+
vdriver = '',
|
|
470
|
+
sectionConcurrency = '',
|
|
471
|
+
episodeMaxMinutes = '',
|
|
472
|
+
] = args;
|
|
391
473
|
// 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
|
|
392
474
|
// passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
|
|
393
475
|
const VOICE_DRIVERS: Record<string, string> = {
|
|
@@ -402,54 +484,351 @@ export function dailyGenerate(args: string[]): void {
|
|
|
402
484
|
`generate: unknown vdriver '${vdriver}' (expected one of: ${Object.keys(VOICE_DRIVERS).join(', ')})`,
|
|
403
485
|
);
|
|
404
486
|
}
|
|
487
|
+
// Strict `Number`, not `parseFloat`: a typo'd var ('2O') must refuse, not cap the episode at 2 min.
|
|
488
|
+
const maxMinutes = episodeMaxMinutes === '' ? DEFAULT_EPISODE_MAX_MINUTES : Number(episodeMaxMinutes);
|
|
489
|
+
if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) {
|
|
490
|
+
failWith(1, `generate: invalid episode_max_minutes: ${episodeMaxMinutes}`);
|
|
491
|
+
}
|
|
405
492
|
const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
|
|
406
493
|
const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
494
|
+
|
|
495
|
+
if (!sectionConcurrency) {
|
|
496
|
+
timed(workDir, 'generate', () => {
|
|
497
|
+
if (isFile(content) && metadataOf(readJson(content)).qc !== undefined) {
|
|
498
|
+
const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
|
|
499
|
+
if (qc?.passed === true) {
|
|
500
|
+
console.log(`audio cached: ${content} (QC passed)`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Gemini quota probe (20261006): the 06:00 cron hit gemini-3.8-flash-tts 429 with a
|
|
505
|
+
// multi-hour retry-after after the shared key's window drained overnight. One tiny
|
|
506
|
+
// render (~1 request) before the real episode; ANY probe failure (quota, key, network)
|
|
507
|
+
// flips this run to the local voicebox driver instead of dying mid-episode — voicebox
|
|
508
|
+
// needs no key. Operator decision 20261006: keep the gemini default + auto-fallback.
|
|
509
|
+
if (vdriver === 'gemini') {
|
|
510
|
+
const docsList = readJson(docs) as Array<Record<string, unknown>>;
|
|
511
|
+
if (
|
|
512
|
+
docsList.length > 0 &&
|
|
513
|
+
!geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [{ ...docsList[0], body: 'Hello.' }])
|
|
514
|
+
) {
|
|
515
|
+
voiceGenerator = VOICE_DRIVERS.voicebox;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
kk(voiceExecutorArgs(voiceGenerator, docs, content, pluginsPath), {
|
|
519
|
+
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (sectionConcurrency !== '1') {
|
|
526
|
+
failWith(1, `generate: section_concurrency '${sectionConcurrency}' not supported (must be 1)`);
|
|
527
|
+
}
|
|
528
|
+
|
|
407
529
|
timed(workDir, 'generate', () => {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
530
|
+
const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
531
|
+
if (!existsSync(scriptPath)) {
|
|
532
|
+
failWith(1, `generate: voicescript not found: ${scriptPath}`);
|
|
533
|
+
}
|
|
534
|
+
const scriptRaw = readFileSync(scriptPath, 'utf8');
|
|
535
|
+
const script = Bun.YAML.parse(scriptRaw) as Record<string, unknown>;
|
|
536
|
+
const segments = (script.segments ?? []) as Array<Record<string, unknown>>;
|
|
537
|
+
if (!Array.isArray(segments) || segments.length < 2) {
|
|
538
|
+
failWith(1, 'generate: invalid voicescript (expected at least opening and closing segments)');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Grouping: first is opening, last is closing, middle segments split after gap_ms: 1200
|
|
542
|
+
const middleSegments = segments.slice(1, -1);
|
|
543
|
+
const itemSections: Array<Array<Record<string, unknown>>> = [];
|
|
544
|
+
let currentItem: Array<Record<string, unknown>> = [];
|
|
545
|
+
for (const seg of middleSegments) {
|
|
546
|
+
currentItem.push(seg);
|
|
547
|
+
if (seg.gap_ms === 1200) {
|
|
548
|
+
itemSections.push(currentItem);
|
|
549
|
+
currentItem = [];
|
|
413
550
|
}
|
|
414
551
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const probe = spawnSync(
|
|
428
|
-
'kk',
|
|
429
|
-
['executor', 'run', 'gemini-tts', '--in', probeIn, '--out', probeOut, ...pluginsArgs(pluginsPath)],
|
|
430
|
-
{ encoding: 'utf8', timeout: 120_000, env: { ...process.env, VOICE_GEN_MP3: transcodeMp3 } },
|
|
431
|
-
);
|
|
432
|
-
for (const stale of [
|
|
433
|
-
probeIn,
|
|
434
|
-
probeOut,
|
|
435
|
-
probeOut.replace(/\.json$/, '.wav'),
|
|
436
|
-
probeOut.replace(/\.json$/, '.mp3'),
|
|
437
|
-
])
|
|
438
|
-
rmSync(stale, { force: true });
|
|
439
|
-
if (probe.status !== 0) {
|
|
440
|
-
const reason = (probe.stderr ?? '').trim().split('\n').slice(-1)[0] || `exit ${probe.status}`;
|
|
441
|
-
console.log(
|
|
442
|
-
`generate: gemini quota probe failed (${reason}) — falling back to voicebox for this run`,
|
|
552
|
+
if (currentItem.length > 0) {
|
|
553
|
+
itemSections.push(currentItem);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const planPath = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
|
|
557
|
+
if (existsSync(planPath)) {
|
|
558
|
+
try {
|
|
559
|
+
const plan = readJson(planPath) as { items?: unknown[] };
|
|
560
|
+
if (Array.isArray(plan.items) && plan.items.length !== itemSections.length) {
|
|
561
|
+
failWith(
|
|
562
|
+
1,
|
|
563
|
+
`generate: plan item count (${plan.items.length}) does not match script item sections (${itemSections.length})`,
|
|
443
564
|
);
|
|
444
|
-
voiceGenerator = VOICE_DRIVERS.voicebox;
|
|
445
|
-
} else {
|
|
446
|
-
console.log('generate: gemini quota probe ok');
|
|
447
565
|
}
|
|
566
|
+
} catch (e: unknown) {
|
|
567
|
+
if (e instanceof StageFailure) throw e;
|
|
448
568
|
}
|
|
449
569
|
}
|
|
450
|
-
|
|
451
|
-
|
|
570
|
+
|
|
571
|
+
interface SectionDef {
|
|
572
|
+
index: number;
|
|
573
|
+
role: string;
|
|
574
|
+
stem: string;
|
|
575
|
+
name: string;
|
|
576
|
+
segments: Array<Record<string, unknown>>;
|
|
577
|
+
characters: number;
|
|
578
|
+
gap_ms: number;
|
|
579
|
+
text: string;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const sectionDefs: SectionDef[] = [];
|
|
583
|
+
// 00-opening
|
|
584
|
+
const openSegs = [segments[0]];
|
|
585
|
+
const openText = openSegs.map((s) => String(s.text ?? '')).join('');
|
|
586
|
+
sectionDefs.push({
|
|
587
|
+
index: 0,
|
|
588
|
+
role: 'opening',
|
|
589
|
+
stem: '00-opening',
|
|
590
|
+
name: '00-opening',
|
|
591
|
+
segments: openSegs,
|
|
592
|
+
characters: [...openText].length,
|
|
593
|
+
gap_ms:
|
|
594
|
+
typeof openSegs[openSegs.length - 1].gap_ms === 'number'
|
|
595
|
+
? (openSegs[openSegs.length - 1].gap_ms as number)
|
|
596
|
+
: 1200,
|
|
597
|
+
text: openText,
|
|
452
598
|
});
|
|
599
|
+
|
|
600
|
+
// 01-item ...
|
|
601
|
+
itemSections.forEach((segs, idx) => {
|
|
602
|
+
const itemIdx = idx + 1;
|
|
603
|
+
const pad = String(itemIdx).padStart(2, '0');
|
|
604
|
+
const stem = `${pad}-item`;
|
|
605
|
+
const itemText = segs.map((s) => String(s.text ?? '')).join('');
|
|
606
|
+
const lastSeg = segs[segs.length - 1];
|
|
607
|
+
sectionDefs.push({
|
|
608
|
+
index: itemIdx,
|
|
609
|
+
role: 'item',
|
|
610
|
+
stem,
|
|
611
|
+
name: stem,
|
|
612
|
+
segments: segs,
|
|
613
|
+
characters: [...itemText].length,
|
|
614
|
+
gap_ms: typeof lastSeg?.gap_ms === 'number' ? (lastSeg.gap_ms as number) : 1200,
|
|
615
|
+
text: itemText,
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// closing
|
|
620
|
+
const closeIdx = sectionDefs.length;
|
|
621
|
+
const closePad = String(closeIdx).padStart(2, '0');
|
|
622
|
+
const closeSegs = [segments[segments.length - 1]];
|
|
623
|
+
const closeText = closeSegs.map((s) => String(s.text ?? '')).join('');
|
|
624
|
+
sectionDefs.push({
|
|
625
|
+
index: closeIdx,
|
|
626
|
+
role: 'closing',
|
|
627
|
+
stem: `${closePad}-closing`,
|
|
628
|
+
name: `${closePad}-closing`,
|
|
629
|
+
segments: closeSegs,
|
|
630
|
+
characters: [...closeText].length,
|
|
631
|
+
gap_ms:
|
|
632
|
+
typeof closeSegs[closeSegs.length - 1].gap_ms === 'number'
|
|
633
|
+
? (closeSegs[closeSegs.length - 1].gap_ms as number)
|
|
634
|
+
: 300,
|
|
635
|
+
text: closeText,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Character-rate estimate: section characters / driver rate + 8 s sting > the workflow's
|
|
639
|
+
// ceiling (`episode_max_minutes`, arg 8; default 20 for a bare five/six-argument call).
|
|
640
|
+
// The rates are MEASURED from shipped renders, not the style contract's prose. gemini-tts:
|
|
641
|
+
// 20261004 5466 chars / 1026 s = 5.33, 20261007 5970 / 1096 = 5.45, and the 0237 live dry run
|
|
642
|
+
// 4645 / 789.9 s of section speech = 5.88 — a 5.3–5.9 range, so 5.4 deliberately errs low (a
|
|
643
|
+
// slightly early refusal is cheaper than a rendered episode the duration gate then rejects).
|
|
644
|
+
// voice-gen: 20261006 6376 / 1135 = 5.62. A 20-minute ceiling is therefore ~6400 characters.
|
|
645
|
+
// The style sidecar's 3.3 / 6.3 figures are stale and mutually inconsistent with its own
|
|
646
|
+
// 4300–5800 字 budget (which is 13–29 min at 3.3); using them refused a script that really
|
|
647
|
+
// renders in 17 min. Recalibrate from a fresh pair of `metadata.duration` readings when a
|
|
648
|
+
// driver changes model or speed.
|
|
649
|
+
const charRate = voiceGenerator === 'gemini-tts' ? 5.4 : 5.6;
|
|
650
|
+
const totalChars = sectionDefs.reduce((acc, s) => acc + s.characters, 0);
|
|
651
|
+
const estimatedSeconds = 8 + totalChars / charRate;
|
|
652
|
+
if (estimatedSeconds > maxMinutes * 60) {
|
|
653
|
+
const longest3 = [...sectionDefs].sort((a, b) => b.characters - a.characters).slice(0, 3);
|
|
654
|
+
failWith(
|
|
655
|
+
1,
|
|
656
|
+
`generate: character-rate estimate ${Math.round(estimatedSeconds / 60)} min exceeds ${maxMinutes} min limit (longest sections: ${longest3.map((s) => s.name).join(', ')})`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const voiceProfile =
|
|
661
|
+
(typeof script.default_profile_id === 'string' && script.default_profile_id) ||
|
|
662
|
+
(typeof script.default_profile === 'string' && script.default_profile) ||
|
|
663
|
+
process.env.VOICEBOX_DEFAULT_PROFILE ||
|
|
664
|
+
'robin-news';
|
|
665
|
+
|
|
666
|
+
const secDir = join(workDir, '30-audio', 'sections', runDate);
|
|
667
|
+
mkdirSync(secDir, { recursive: true });
|
|
668
|
+
|
|
669
|
+
const joinedWavPath = join(workDir, '30-audio', `${runDate}_11_generate_content.wav`);
|
|
670
|
+
const joinedContentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
671
|
+
|
|
672
|
+
interface SectionPlan {
|
|
673
|
+
def: SectionDef;
|
|
674
|
+
hash: string;
|
|
675
|
+
wavPath: string;
|
|
676
|
+
jsonPath: string;
|
|
677
|
+
needsSynthesis: boolean;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const computePlans = (driver: string): SectionPlan[] => {
|
|
681
|
+
return sectionDefs.map((def) => {
|
|
682
|
+
const hash = createHash('sha256').update(`${def.text}\0${voiceProfile}\0${driver}`).digest('hex');
|
|
683
|
+
const wavPath = join(secDir, `${def.stem}.wav`);
|
|
684
|
+
const jsonPath = join(secDir, `${def.stem}.json`);
|
|
685
|
+
let cached = false;
|
|
686
|
+
if (existsSync(wavPath) && existsSync(jsonPath)) {
|
|
687
|
+
try {
|
|
688
|
+
const sidecar = readJson(jsonPath) as Record<string, unknown>;
|
|
689
|
+
const sidecarMeta = metadataOf(sidecar) as Record<string, unknown>;
|
|
690
|
+
if (sidecar.hash === hash || sidecarMeta.hash === hash) {
|
|
691
|
+
cached = true;
|
|
692
|
+
}
|
|
693
|
+
} catch {}
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
def,
|
|
697
|
+
hash,
|
|
698
|
+
wavPath,
|
|
699
|
+
jsonPath,
|
|
700
|
+
needsSynthesis: !cached,
|
|
701
|
+
};
|
|
702
|
+
});
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
let plans = computePlans(voiceGenerator);
|
|
706
|
+
const anySynthesisNeeded = plans.some((p) => p.needsSynthesis);
|
|
707
|
+
|
|
708
|
+
if (!anySynthesisNeeded && existsSync(joinedWavPath) && existsSync(joinedContentPath)) {
|
|
709
|
+
console.log('generate: all sections cached, joined speech reused');
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Same quota probe as the one-shot path, before any section request. A fallback changes
|
|
714
|
+
// the cache key, so plans are recomputed and no Gemini file is played as voicebox speech.
|
|
715
|
+
if (
|
|
716
|
+
anySynthesisNeeded &&
|
|
717
|
+
vdriver === 'gemini' &&
|
|
718
|
+
!geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [
|
|
719
|
+
{
|
|
720
|
+
id: 'probe',
|
|
721
|
+
title: 'Probe',
|
|
722
|
+
body: Bun.YAML.stringify({
|
|
723
|
+
title: 'Probe',
|
|
724
|
+
segments: [{ text: 'Hello.' }],
|
|
725
|
+
}),
|
|
726
|
+
sourceUri: 'file://probe',
|
|
727
|
+
mediaType: 'application/yaml',
|
|
728
|
+
metadata: { voiceProfile },
|
|
729
|
+
},
|
|
730
|
+
])
|
|
731
|
+
) {
|
|
732
|
+
voiceGenerator = VOICE_DRIVERS.voicebox;
|
|
733
|
+
plans = computePlans(voiceGenerator);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Synthesize sections sequentially (concurrency 1)
|
|
737
|
+
for (const plan of plans) {
|
|
738
|
+
if (!plan.needsSynthesis) {
|
|
739
|
+
console.log(`generate: section cached: ${plan.def.stem}`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
console.log(`generate: synthesizing section: ${plan.def.stem}`);
|
|
743
|
+
const secScript = { ...script, segments: plan.def.segments };
|
|
744
|
+
const secDocIn = join(secDir, `.${plan.def.stem}_in.json`);
|
|
745
|
+
const doc = {
|
|
746
|
+
id: createHash('sha256').update(`section-${plan.def.stem}`).digest('hex').slice(0, 16),
|
|
747
|
+
title: `Daily AI News VoiceScript - ${plan.def.stem}`,
|
|
748
|
+
sourceUri: `file://${resolve(scriptPath)}#${plan.def.stem}`,
|
|
749
|
+
body: Bun.YAML.stringify(secScript),
|
|
750
|
+
mediaType: 'application/yaml',
|
|
751
|
+
metadata: { voiceProfile },
|
|
752
|
+
};
|
|
753
|
+
writeJsonText(secDocIn, [doc]);
|
|
754
|
+
try {
|
|
755
|
+
const status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
|
|
756
|
+
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
757
|
+
soft: true,
|
|
758
|
+
});
|
|
759
|
+
if (status !== 0) {
|
|
760
|
+
failWith(status, `generate: section synthesis failed for ${plan.def.stem}`);
|
|
761
|
+
}
|
|
762
|
+
} finally {
|
|
763
|
+
rmSync(secDocIn, { force: true });
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const sidecar = readJson(plan.jsonPath) as Record<string, unknown>;
|
|
767
|
+
sidecar.hash = plan.hash;
|
|
768
|
+
if (sidecar.metadata && typeof sidecar.metadata === 'object') {
|
|
769
|
+
(sidecar.metadata as Record<string, unknown>).hash = plan.hash;
|
|
770
|
+
}
|
|
771
|
+
writeJsonText(plan.jsonPath, sidecar);
|
|
772
|
+
} catch {}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Join sections with ffmpeg
|
|
776
|
+
const probeHelper = (file: string, entries: string): string =>
|
|
777
|
+
spawnSync('ffprobe', [
|
|
778
|
+
'-v',
|
|
779
|
+
'error',
|
|
780
|
+
'-select_streams',
|
|
781
|
+
'a:0',
|
|
782
|
+
'-show_entries',
|
|
783
|
+
entries,
|
|
784
|
+
'-of',
|
|
785
|
+
'csv=p=0',
|
|
786
|
+
file,
|
|
787
|
+
])
|
|
788
|
+
.stdout?.toString()
|
|
789
|
+
.trim() ?? '';
|
|
790
|
+
|
|
791
|
+
const sampleRate = probeHelper(plans[0].wavPath, 'stream=sample_rate') || '24000';
|
|
792
|
+
const ffmpegArgs = ['-y', '-hide_banner', '-loglevel', 'error'];
|
|
793
|
+
const filterParts: string[] = [];
|
|
794
|
+
const concatInputs: string[] = [];
|
|
795
|
+
|
|
796
|
+
plans.forEach((plan, i) => {
|
|
797
|
+
ffmpegArgs.push('-i', plan.wavPath);
|
|
798
|
+
filterParts.push(`[${i}:a]aresample=${sampleRate},aformat=sample_fmts=s16:channel_layouts=mono[a${i}]`);
|
|
799
|
+
concatInputs.push(`[a${i}]`);
|
|
800
|
+
if (i < plans.length - 1) {
|
|
801
|
+
const gapMs = plan.def.gap_ms;
|
|
802
|
+
const gapS = gapMs > 0 ? (gapMs / 1000).toFixed(4) : '0';
|
|
803
|
+
filterParts.push(`anullsrc=r=${sampleRate}:cl=mono,atrim=duration=${gapS}[sil${i}]`);
|
|
804
|
+
concatInputs.push(`[sil${i}]`);
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const filterComplex = `${filterParts.join(';')};${concatInputs.join('')}concat=n=${concatInputs.length}:v=0:a=1[out]`;
|
|
809
|
+
ffmpegArgs.push('-filter_complex', filterComplex, '-map', '[out]', '-c:a', 'pcm_s16le', joinedWavPath);
|
|
810
|
+
|
|
811
|
+
const conv = spawnSync('ffmpeg', ffmpegArgs);
|
|
812
|
+
if (conv.status !== 0 || !existsSync(joinedWavPath)) {
|
|
813
|
+
failWith(1, `generate: ffmpeg join failed: ${conv.stderr?.toString().trim()}`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const joinedDuration = Number.parseFloat(probeHelper(joinedWavPath, 'format=duration'));
|
|
817
|
+
|
|
818
|
+
let contentObj: Record<string, unknown> = {};
|
|
819
|
+
if (existsSync(joinedContentPath)) {
|
|
820
|
+
try {
|
|
821
|
+
contentObj = readJson(joinedContentPath) as Record<string, unknown>;
|
|
822
|
+
} catch {}
|
|
823
|
+
}
|
|
824
|
+
const meta = (contentObj.metadata ?? {}) as Record<string, unknown>;
|
|
825
|
+
delete meta.introMerged;
|
|
826
|
+
meta.audioPath = joinedWavPath;
|
|
827
|
+
meta.duration = joinedDuration;
|
|
828
|
+
contentObj.audioPath = joinedWavPath;
|
|
829
|
+
contentObj.mediaType = 'audio/wav';
|
|
830
|
+
contentObj.metadata = meta;
|
|
831
|
+
writeJsonText(joinedContentPath, contentObj);
|
|
453
832
|
});
|
|
454
833
|
}
|
|
455
834
|
|
|
@@ -573,16 +952,21 @@ export function dailyIntro(args: string[]): void {
|
|
|
573
952
|
}
|
|
574
953
|
|
|
575
954
|
export function dailyPlan(args: string[]): void {
|
|
576
|
-
const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = ''] = args;
|
|
955
|
+
const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = '', deferPlanDocs = ''] = args;
|
|
577
956
|
const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
|
|
578
957
|
const planDocs = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
|
|
579
958
|
// voice2's extract node writes 10-collect/pool.json; voice1's fan-in still writes the blended
|
|
580
959
|
// file. Prefer the pool when it exists so both DAGs share this stage (0212 dogfood finding).
|
|
581
960
|
const pool = join(workDir, '10-collect', 'pool.json');
|
|
582
961
|
const blended = isFile(pool) ? pool : join(workDir, '10-ingest', `${runDate}_02_collect_blended.json`);
|
|
962
|
+
// voice2 passes plan_translate_enabled. When it is true, plan-translate owns plan.json: its
|
|
963
|
+
// `! test -s` guard is also the first-run gate, so writing the English docs here skips the
|
|
964
|
+
// translation. Voice1 omits the arg and still materializes the file (its translate node has no
|
|
965
|
+
// skip guard and overwrites it).
|
|
966
|
+
const deferDocs = deferPlanDocs === 'true';
|
|
583
967
|
timed(workDir, 'plan', () => {
|
|
584
|
-
if (isFile(planDocs)) {
|
|
585
|
-
console.log(`plan cached: ${planDocs}`);
|
|
968
|
+
if (deferDocs ? isFile(planContent) : isFile(planDocs)) {
|
|
969
|
+
console.log(`plan cached: ${deferDocs ? planContent : planDocs}`);
|
|
586
970
|
return;
|
|
587
971
|
}
|
|
588
972
|
kk(
|
|
@@ -593,6 +977,7 @@ export function dailyPlan(args: string[]): void {
|
|
|
593
977
|
);
|
|
594
978
|
const docs = metadataOf(readJson(planContent)).docs;
|
|
595
979
|
if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
|
|
980
|
+
if (deferDocs) return;
|
|
596
981
|
sted('write', planDocs, () => writeJsonText(planDocs, docs));
|
|
597
982
|
if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
|
|
598
983
|
});
|
|
@@ -783,18 +1168,30 @@ export function dailyPublishWechat(args: string[]): void {
|
|
|
783
1168
|
return;
|
|
784
1169
|
}
|
|
785
1170
|
const packaged = requireJson(article, 'publish-wechat');
|
|
786
|
-
// MP draft enrichment (operator
|
|
787
|
-
// (the surfdash post),
|
|
1171
|
+
// MP draft enrichment (operator preferences 2026-09-29, completed 2026-10-10): 原创, 赞赏,
|
|
1172
|
+
// 作者, 合集, 原文链接 (the surfdash post), 创作来源, cover, audio material. The brand strings
|
|
1173
|
+
// are this daily show's constants; every field here is consumed by `wechatmp-pub`'s flow.
|
|
788
1174
|
const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
789
1175
|
const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
|
|
1176
|
+
const surfdashResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
1177
|
+
const originalUrl = isFile(surfdashResult) ? firstTargetUrl(readJson(surfdashResult), 'surfdash-pub') : '';
|
|
790
1178
|
const wechatmp: Record<string, unknown> = {
|
|
791
1179
|
original: true,
|
|
792
|
-
|
|
1180
|
+
// 赞赏 is enabled per episode; the plugin fills the 赞赏账户 only when the platform
|
|
1181
|
+
// leaves it empty (it defaults from 作者, operator walk 2026-10-10).
|
|
1182
|
+
reward: true,
|
|
793
1183
|
author: '冰原奔狼',
|
|
794
|
-
collection: collectionArg || 'AI
|
|
1184
|
+
collection: collectionArg || '每日AI资讯',
|
|
1185
|
+
// 创作来源 is the column's own stance, not an AI-declaration label — the operator owns
|
|
1186
|
+
// the wording (2026-10-10).
|
|
1187
|
+
claimSource: '个人观点,仅供参考',
|
|
1188
|
+
// 封面 comes from 图片库: the episode cover is uploaded there as the audio material's
|
|
1189
|
+
// cover, so the library's newest entry is the intended image.
|
|
1190
|
+
coverSource: 'library',
|
|
1191
|
+
...(originalUrl !== '' ? { originalUrl } : {}),
|
|
795
1192
|
...(isFile(cover) ? { coverPath: cover } : {}),
|
|
796
|
-
// Audio block at the article top (operator request 2026-09-29
|
|
797
|
-
//
|
|
1193
|
+
// Audio block at the article top (operator request 2026-09-29; the plugin UPLOADS the
|
|
1194
|
+
// material when the 素材库 has no same-title entry, 2026-10-10).
|
|
798
1195
|
...(isFile(audio)
|
|
799
1196
|
? {
|
|
800
1197
|
audioPath: audio,
|
|
@@ -842,10 +1239,15 @@ export function dailyPublishWechat(args: string[]): void {
|
|
|
842
1239
|
* its own). Non-blocking by operator decision: a missing/expired XHS session or a changed console
|
|
843
1240
|
* logs a loud failure and leaves the run alive, and the outcome never enters `publish_status`
|
|
844
1241
|
* (surfdash + podcast remain the episode's publication contract).
|
|
1242
|
+
*
|
|
1243
|
+
* 0236: the optional trailing `<podcast>` arg (`'true'` from voice2 only) attaches the episode's
|
|
1244
|
+
* 发播客 note to the same visit as `metadata.followUps` — one console visit, two notes, one cap entry
|
|
1245
|
+
* (ADR-025 amendment 2026-10-09).
|
|
845
1246
|
*/
|
|
846
1247
|
export function dailyPublishXhs(args: string[]): void {
|
|
847
|
-
const [workDir = '', runDate = '', pluginsPath = '', live = 'false'] = args;
|
|
1248
|
+
const [workDir = '', runDate = '', pluginsPath = '', live = 'false', podcast = ''] = args;
|
|
848
1249
|
const content = join(workDir, '25-xhs', 'content.json');
|
|
1250
|
+
const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
849
1251
|
const input = join(workDir, '31-publish', `${runDate}_19_publish-xhs_content.json`);
|
|
850
1252
|
const result = join(workDir, '31-publish', `${runDate}_19_publish-xhs_result.json`);
|
|
851
1253
|
const want = live === 'true' ? 'public' : 'draft';
|
|
@@ -854,6 +1256,17 @@ export function dailyPublishXhs(args: string[]): void {
|
|
|
854
1256
|
// xhs_live=true re-run (that would report a live post that never happened).
|
|
855
1257
|
if (resultPublishStatus(result) === want) {
|
|
856
1258
|
console.log(`publish-xhs cached: ${want} already recorded in ${result}`);
|
|
1259
|
+
// 0236: the cache keys on the visit's primary note, so a 发播客 follow-up that failed in
|
|
1260
|
+
// that visit is named here instead of hidden. It is not retried: a re-visit would post
|
|
1261
|
+
// a second 图文 draft (the console has no idempotency key).
|
|
1262
|
+
const followUps = metadataOf(readJson(result)).followUps;
|
|
1263
|
+
for (const entry of Array.isArray(followUps) ? followUps : []) {
|
|
1264
|
+
const step = (entry ?? {}) as Record<string, unknown>;
|
|
1265
|
+
if (step.ok === true) continue;
|
|
1266
|
+
console.log(
|
|
1267
|
+
`publish-xhs cached: follow-up ${String(metadataOf(step).operation ?? '?')} did not complete: ${String(step.error ?? 'no error recorded')}`,
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
857
1270
|
return;
|
|
858
1271
|
}
|
|
859
1272
|
if (!isFile(content)) {
|
|
@@ -861,8 +1274,16 @@ export function dailyPublishXhs(args: string[]): void {
|
|
|
861
1274
|
return;
|
|
862
1275
|
}
|
|
863
1276
|
const packaged = requireJson(content, 'publish-xhs');
|
|
1277
|
+
const followUp = podcast === 'true' ? xhsPodcastNote(prep, want) : undefined;
|
|
864
1278
|
sted('write', input, () =>
|
|
865
|
-
writeJsonText(input, {
|
|
1279
|
+
writeJsonText(input, {
|
|
1280
|
+
...packaged,
|
|
1281
|
+
metadata: {
|
|
1282
|
+
...metadataOf(packaged),
|
|
1283
|
+
publishStatus: want,
|
|
1284
|
+
...(followUp === undefined ? {} : { followUps: [followUp] }),
|
|
1285
|
+
},
|
|
1286
|
+
}),
|
|
866
1287
|
);
|
|
867
1288
|
// `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
|
|
868
1289
|
// and prints the failing step (login gate, upload, body, submit control), which is what the
|
|
@@ -878,6 +1299,34 @@ export function dailyPublishXhs(args: string[]): void {
|
|
|
878
1299
|
});
|
|
879
1300
|
}
|
|
880
1301
|
|
|
1302
|
+
/**
|
|
1303
|
+
* 0236 R1: the 发播客 Content for the same episode — the run's publish-prep Content (the final mp3
|
|
1304
|
+
* with the intro, `23-cover`'s PNG, title and body) plus `metadata.operation` and the run's own
|
|
1305
|
+
* `publishStatus`. No artifact is produced here: both files already exist.
|
|
1306
|
+
*
|
|
1307
|
+
* `metadata.audio` repeats the prep's `metadata.audioFile` under the key the xhs channel's 发播客 mode
|
|
1308
|
+
* reads (`xhs-pub/src/modes.ts`); `metadata.coverImage` is already the shared spelling. A run with no
|
|
1309
|
+
* publish-prep output names the missing episode drafts and carries the 图文 note alone — the 发播客
|
|
1310
|
+
* note is never invented.
|
|
1311
|
+
*/
|
|
1312
|
+
function xhsPodcastNote(prepPath: string, publishStatus: 'draft' | 'public'): Record<string, unknown> | undefined {
|
|
1313
|
+
const prep = readJson(prepPath);
|
|
1314
|
+
if (prep === null) {
|
|
1315
|
+
console.log(`publish-xhs: no publish-prep Content at ${prepPath} — the 发播客 note is skipped`);
|
|
1316
|
+
return undefined;
|
|
1317
|
+
}
|
|
1318
|
+
const metadata = metadataOf(prep);
|
|
1319
|
+
return {
|
|
1320
|
+
...prep,
|
|
1321
|
+
metadata: {
|
|
1322
|
+
...metadata,
|
|
1323
|
+
operation: '发播客',
|
|
1324
|
+
audio: metadata.audioFile,
|
|
1325
|
+
publishStatus,
|
|
1326
|
+
},
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
881
1330
|
/**
|
|
882
1331
|
* WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
|
|
883
1332
|
* draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
|
|
@@ -1167,8 +1616,141 @@ export function dailySafetyRevise(args: string[]): void {
|
|
|
1167
1616
|
});
|
|
1168
1617
|
}
|
|
1169
1618
|
|
|
1619
|
+
/** Block YAML for the fields a fitted VoiceScript actually carries. Strings use JSON quoting. */
|
|
1620
|
+
function voicescriptToBlockYaml(script: Record<string, unknown>): string {
|
|
1621
|
+
const lines: string[] = [];
|
|
1622
|
+
for (const key of [
|
|
1623
|
+
'title',
|
|
1624
|
+
'language',
|
|
1625
|
+
'default_profile',
|
|
1626
|
+
'default_profile_id',
|
|
1627
|
+
'default_engine',
|
|
1628
|
+
'default_emotion',
|
|
1629
|
+
'default_effect_preset',
|
|
1630
|
+
]) {
|
|
1631
|
+
const value = script[key];
|
|
1632
|
+
if (typeof value === 'string' && value !== '') lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
1633
|
+
}
|
|
1634
|
+
const segments = script.segments;
|
|
1635
|
+
if (!Array.isArray(segments)) return lines.join('\n');
|
|
1636
|
+
lines.push('segments:');
|
|
1637
|
+
for (const entry of segments) {
|
|
1638
|
+
const segment = entry as Record<string, unknown>;
|
|
1639
|
+
lines.push(` - text: ${JSON.stringify(segment.text ?? '')}`);
|
|
1640
|
+
for (const key of ['speaker', 'profile', 'profile_id', 'emotion', 'instruct', 'effect_preset']) {
|
|
1641
|
+
if (typeof segment[key] === 'string') lines.push(` ${key}: ${JSON.stringify(segment[key])}`);
|
|
1642
|
+
}
|
|
1643
|
+
for (const key of ['gap_ms', 'speed', 'seed', 'personality']) {
|
|
1644
|
+
if (typeof segment[key] === 'number') lines.push(` ${key}: ${segment[key]}`);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
return lines.join('\n');
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/** Measured driver rates from dailyGenerate. Unknown drivers use the voicebox rate. */
|
|
1651
|
+
function spokenCharRate(vdriver: string): number {
|
|
1652
|
+
return vdriver === 'gemini' ? 5.4 : 5.6;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* Shorten one fact/insight at 。!? so the cut removes at least `removeAtLeast` characters when a
|
|
1657
|
+
* boundary allows it, otherwise drop the last sentence. Returns null when the text is one sentence.
|
|
1658
|
+
*/
|
|
1659
|
+
function shrinkFactText(text: string, removeAtLeast: number): string | null {
|
|
1660
|
+
const ends: number[] = [];
|
|
1661
|
+
for (let i = 0; i < text.length; i++) {
|
|
1662
|
+
const ch = text[i];
|
|
1663
|
+
if (ch === '。' || ch === '!' || ch === '?') ends.push(i + 1);
|
|
1664
|
+
}
|
|
1665
|
+
if (ends.length === 0) return null;
|
|
1666
|
+
const limit = text.length - Math.max(removeAtLeast, 1);
|
|
1667
|
+
let chosen: number | null = null;
|
|
1668
|
+
for (const end of ends) {
|
|
1669
|
+
if (end <= limit && end < text.length && end > 0) chosen = end;
|
|
1670
|
+
}
|
|
1671
|
+
if (chosen !== null) return text.slice(0, chosen);
|
|
1672
|
+
const shorter = ends.filter((end) => end < text.length && end > 0);
|
|
1673
|
+
const tail = shorter[shorter.length - 1];
|
|
1674
|
+
if (tail === undefined) return null;
|
|
1675
|
+
return text.slice(0, tail);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* The pre-spend gate in dailyGenerate refuses before any TTS when the spoken character estimate
|
|
1680
|
+
* exceeds `episode_max_minutes`. Optional args (voice2 only) fit the fresh VoiceScript down to that
|
|
1681
|
+
* budget by shortening the longest fact segments, then insights. Absent args leave the file untouched.
|
|
1682
|
+
*/
|
|
1683
|
+
function fitVoicescriptToBudget(yamlOut: string, contentOut: string, episodeMaxMinutes: string, vdriver: string): void {
|
|
1684
|
+
if (episodeMaxMinutes === '') return;
|
|
1685
|
+
const maxMinutes = Number(episodeMaxMinutes);
|
|
1686
|
+
if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) return;
|
|
1687
|
+
// Two minutes under the hard duration gate. The character rate is a past measurement; fitting
|
|
1688
|
+
// flush to the ceiling renders an episode the gate then rejects.
|
|
1689
|
+
const maxChars = Math.floor((maxMinutes * 60 - 8 - 120) * spokenCharRate(vdriver));
|
|
1690
|
+
const original = readFileSync(yamlOut, 'utf8');
|
|
1691
|
+
const script = Bun.YAML.parse(original) as { segments?: Array<Record<string, unknown>> };
|
|
1692
|
+
const segments = script.segments;
|
|
1693
|
+
if (!Array.isArray(segments) || segments.length < 3) return;
|
|
1694
|
+
const totalOf = (): number => segments.reduce((sum, seg) => sum + [...String(seg.text ?? '')].length, 0);
|
|
1695
|
+
const before = totalOf();
|
|
1696
|
+
if (before <= maxChars) return;
|
|
1697
|
+
const unsplittable = new Set<number>();
|
|
1698
|
+
for (const factsOnly of [true, false]) {
|
|
1699
|
+
while (totalOf() > maxChars) {
|
|
1700
|
+
let best = -1;
|
|
1701
|
+
let bestLen = 0;
|
|
1702
|
+
for (let i = 1; i < segments.length - 1; i++) {
|
|
1703
|
+
if (unsplittable.has(i)) continue;
|
|
1704
|
+
if (factsOnly && segments[i]?.speed === 1.12) continue;
|
|
1705
|
+
const len = [...String(segments[i]?.text ?? '')].length;
|
|
1706
|
+
if (len > bestLen) {
|
|
1707
|
+
best = i;
|
|
1708
|
+
bestLen = len;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (best < 0) break;
|
|
1712
|
+
const text = String(segments[best]?.text ?? '');
|
|
1713
|
+
const next = shrinkFactText(text, totalOf() - maxChars);
|
|
1714
|
+
if (next === null || next.length >= text.length) {
|
|
1715
|
+
unsplittable.add(best);
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
const segment = segments[best];
|
|
1719
|
+
if (segment) segment.text = next;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
const after = totalOf();
|
|
1723
|
+
if (after === before) return;
|
|
1724
|
+
// Block YAML, same shape as dailynews-gen's formatter. Bun.YAML.stringify emits a `{...}` flow
|
|
1725
|
+
// document, and the VoiceScript checker JSON-parses anything that starts with `{`.
|
|
1726
|
+
const fitted = `${voicescriptToBlockYaml(script)}\n`;
|
|
1727
|
+
sted('write', yamlOut, () => writeRaw(yamlOut, fitted));
|
|
1728
|
+
if (isFile(contentOut)) {
|
|
1729
|
+
const content = readJson(contentOut);
|
|
1730
|
+
if (content && typeof content.body === 'string') {
|
|
1731
|
+
content.body = fitted;
|
|
1732
|
+
sted('write', contentOut, () => writeJsonText(contentOut, content));
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
console.log(`script: fitted voicescript ${before} -> ${after} chars (budget ${maxChars})`);
|
|
1736
|
+
if (after > maxChars) {
|
|
1737
|
+
console.error(
|
|
1738
|
+
`script: voicescript still ${after - maxChars} chars over the ${maxChars} budget after sentence cuts`,
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1170
1743
|
export function dailyScript(args: string[]): void {
|
|
1171
|
-
const [
|
|
1744
|
+
const [
|
|
1745
|
+
workDir = '',
|
|
1746
|
+
runDate = '',
|
|
1747
|
+
pluginsPath = '',
|
|
1748
|
+
language = '',
|
|
1749
|
+
title = '',
|
|
1750
|
+
voiceProfile = '',
|
|
1751
|
+
episodeMaxMinutes = '',
|
|
1752
|
+
vdriver = '',
|
|
1753
|
+
] = args;
|
|
1172
1754
|
const yamlOut = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
1173
1755
|
const contentOut = join(workDir, '24-script', `${runDate}_08_script_content.json`);
|
|
1174
1756
|
const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
|
|
@@ -1195,6 +1777,7 @@ export function dailyScript(args: string[]): void {
|
|
|
1195
1777
|
const body = readJson(contentOut)?.body;
|
|
1196
1778
|
if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
|
|
1197
1779
|
sted('write', yamlOut, () => writeRaw(yamlOut, body));
|
|
1780
|
+
fitVoicescriptToBudget(yamlOut, contentOut, episodeMaxMinutes, vdriver);
|
|
1198
1781
|
// R3 (task 0139): fresh writes are validated here; the script cache path is covered by
|
|
1199
1782
|
// wrap-docs, which runs on every path that reaches audio.
|
|
1200
1783
|
try {
|
|
@@ -1249,8 +1832,30 @@ export function dailyShowNotes(args: string[]): void {
|
|
|
1249
1832
|
// Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
|
|
1250
1833
|
// grammar lives in daily-article-gen's excludedIdsFromNote.
|
|
1251
1834
|
const postPath = firstPostPath(readJson(result), 'surfdash-pub');
|
|
1252
|
-
const
|
|
1835
|
+
const articleContent = readJson(article);
|
|
1836
|
+
const articleBody = articleContent?.body;
|
|
1253
1837
|
const excluded = new Set<string>();
|
|
1838
|
+
// Article Content carries the dropped ids as `metadata.excludedIds` (2026-10-10): the boundary
|
|
1839
|
+
// note is stripped from the published body, so re-parsing the body alone would silently stop
|
|
1840
|
+
// filtering and leak excluded docs into the show notes. The note parse below stays as the
|
|
1841
|
+
// fallback for Content produced before the field existed.
|
|
1842
|
+
const metaExcluded = metadataOf(articleContent)?.excludedIds;
|
|
1843
|
+
if (Array.isArray(metaExcluded)) {
|
|
1844
|
+
for (const id of metaExcluded) {
|
|
1845
|
+
if (typeof id === 'string' && id !== '') excluded.add(id);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
// Script-plan exclusions are the prompt-layer topic boundary (foreign public affairs the
|
|
1849
|
+
// keyword floor deliberately leaves in the plan). Show notes must match what was spoken.
|
|
1850
|
+
const scriptPlan = readJson(join(workDir, '24-script', `${runDate}_08_script-write_plan.json`));
|
|
1851
|
+
const scriptExcluded = scriptPlan?.excluded;
|
|
1852
|
+
if (Array.isArray(scriptExcluded)) {
|
|
1853
|
+
for (const entry of scriptExcluded) {
|
|
1854
|
+
if (entry !== null && typeof entry === 'object' && typeof (entry as { id?: unknown }).id === 'string') {
|
|
1855
|
+
excluded.add((entry as { id: string }).id);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1254
1859
|
if (typeof articleBody === 'string') {
|
|
1255
1860
|
for (const note of articleBody.matchAll(/未写入[::](.*)/g)) {
|
|
1256
1861
|
for (const id of note[1].matchAll(/(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16,64}/g)) {
|
|
@@ -1290,9 +1895,8 @@ export function dailyShowNotes(args: string[]): void {
|
|
|
1290
1895
|
PODCAST_PUB_RUN_DATE: runDate,
|
|
1291
1896
|
PODCAST_PUB_BRIEF_MODE: 'true',
|
|
1292
1897
|
PODCAST_PUB_ARTICLE_POST_PATH: postPath,
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
PODCAST_PUB_FOOTER_NOTE: '本期文字由 AI 辅助整理,音频为 AI 合成配音。',
|
|
1898
|
+
// 2026-10-08: the column settings carry the AI-production note. Do not append the
|
|
1899
|
+
// stock closer to show notes (or the article, or the spoken sign-off).
|
|
1296
1900
|
...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
|
|
1297
1901
|
},
|
|
1298
1902
|
});
|
|
@@ -1310,7 +1914,10 @@ export function dailyShowNotes(args: string[]): void {
|
|
|
1310
1914
|
*/
|
|
1311
1915
|
export function dailyTranslateLocalize(args: string[]): void {
|
|
1312
1916
|
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
1313
|
-
|
|
1917
|
+
// Same seam as show-notes (task 0137): `_17_publish_result.json` is written later by
|
|
1918
|
+
// publish-classify, which depends on translate-sync. Reading it here always saw no zh
|
|
1919
|
+
// path, so en/ja were never scaffolded (2026-10-08 dogfood).
|
|
1920
|
+
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
1314
1921
|
const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
|
|
1315
1922
|
timed(workDir, 'translate-localize', () => {
|
|
1316
1923
|
const postPath = firstPostPath(readJson(result), 'surfdash-pub');
|
|
@@ -1408,12 +2015,99 @@ export function dailyWrapDocs(args: string[]): void {
|
|
|
1408
2015
|
});
|
|
1409
2016
|
}
|
|
1410
2017
|
|
|
2018
|
+
/** 0235 R4: `note_scope`'s one opt-in value. Anything else — absent included — is today's path. */
|
|
2019
|
+
const XHS_NOTE_SCOPE_EPISODE = 'episode';
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* 0235 R2: `①` — the circled digits ①…⑳ are contiguous from here, and the episode note attaches
|
|
2023
|
+
* `XHS_MAX_CARDS` cards plus at most one cover, so no bare number can ever be needed.
|
|
2024
|
+
*/
|
|
2025
|
+
const CIRCLED_DIGIT_ONE = 0x2460;
|
|
2026
|
+
|
|
2027
|
+
/** The attach-order marker of line `position` (0-based). */
|
|
2028
|
+
const markerFor = (position: number): string => String.fromCodePoint(CIRCLED_DIGIT_ONE + position);
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* 0235 R2: split the agent's note body into its lead paragraph and its trailing hashtag run. The
|
|
2032
|
+
* prompt asks for 3-5 hashtags at the end, so the run is read off the end of the body; a body
|
|
2033
|
+
* without hashtags is entirely lead.
|
|
2034
|
+
*/
|
|
2035
|
+
function splitNoteBody(body: string): { lead: string; hashtags: string } {
|
|
2036
|
+
const trimmed = body.trim();
|
|
2037
|
+
const match = /(?:^|\s)(#\S+(?:\s+#\S+)*)\s*$/.exec(trimmed);
|
|
2038
|
+
if (match === null) return { lead: trimmed, hashtags: '' };
|
|
2039
|
+
return { lead: trimmed.slice(0, match.index).trimEnd(), hashtags: match[1] ?? '' };
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/**
|
|
2043
|
+
* 0235 R2: the episode note body — the agent's lead paragraph, then one numbered line per attached
|
|
2044
|
+
* image in attach order, then the plan's hashtags. `cap` is the console's own note limit
|
|
2045
|
+
* (`XHS_NOTE_BODY_MAX`): only the lead is trimmed to fit, so an image never loses its line. A body
|
|
2046
|
+
* that overflows even without a lead fails loud rather than shipping a note the editor would cut.
|
|
2047
|
+
*/
|
|
2048
|
+
function episodeNoteBody(body: string, lines: string[], cap: number): string {
|
|
2049
|
+
const { lead, hashtags } = splitNoteBody(body);
|
|
2050
|
+
const tail = [lines.join('\n'), hashtags].filter((part) => part !== '').join('\n\n');
|
|
2051
|
+
// `\n\n` joins the lead to the tail; a lead trimmed away drops the separator with it.
|
|
2052
|
+
const budget = cap - tail.length - (lead === '' ? 0 : 2);
|
|
2053
|
+
const head = budget >= lead.length ? lead : lead.slice(0, Math.max(budget, 0)).trimEnd();
|
|
2054
|
+
const note = head === '' ? tail : `${head}\n\n${tail}`;
|
|
2055
|
+
if (note.length > cap) {
|
|
2056
|
+
failWith(
|
|
2057
|
+
1,
|
|
2058
|
+
`xhs render: episode note body is ${note.length} chars for ${lines.length} image lines — the cap is ${cap} and a line is never dropped`,
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
return note;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* 0251 R1: parse the composer's safe-area overflow sentence
|
|
2066
|
+
* (`text overflows the safe area for field(s): <fields> (safe area: …)`, chromium.ts) into the
|
|
2067
|
+
* named `points[n]` indexes — or null when the failure is anything else: no marker, a non-points
|
|
2068
|
+
* field (e.g. `headline`), or an index outside `card.points`. Null means no retry.
|
|
2069
|
+
*/
|
|
2070
|
+
export function xhsOverflowPoints(stderr: string, card: XhsCard): number[] | null {
|
|
2071
|
+
const marker = 'text overflows the safe area for field(s):';
|
|
2072
|
+
const at = stderr.indexOf(marker);
|
|
2073
|
+
if (at < 0) return null;
|
|
2074
|
+
const list = stderr.slice(at + marker.length).split('(')[0] ?? '';
|
|
2075
|
+
const fields = list.split(',').map((name) => /^points\[(\d+)\]$/.exec(name.trim())?.[1]);
|
|
2076
|
+
if (fields.length === 0 || fields.some((field) => field === undefined)) return null;
|
|
2077
|
+
const indexes = fields.map((field) => Number(field));
|
|
2078
|
+
if (indexes.some((n) => n >= card.points.length)) return null;
|
|
2079
|
+
return indexes;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
/** The sentence-ending marks the shorten rule cuts at. */
|
|
2083
|
+
const POINT_SENTENCE_MARKS = new Set(['。', '!', '?']);
|
|
2084
|
+
|
|
2085
|
+
/**
|
|
2086
|
+
* 0251 R1: shorten one overflowing card point — drop its last sentence (cut at the last
|
|
2087
|
+
* 。/!/? that is not the final character, inclusive); a one-sentence point longer than 36
|
|
2088
|
+
* code points keeps its first 36 plus `…`. Returns the input unchanged when neither rule
|
|
2089
|
+
* shortens it, which the caller reads as "nothing got shorter — do not retry".
|
|
2090
|
+
*/
|
|
2091
|
+
export function shortenOverflowedPoint(point: string): string {
|
|
2092
|
+
const chars = [...point];
|
|
2093
|
+
for (let i = chars.length - 2; i >= 0; i--) {
|
|
2094
|
+
if (POINT_SENTENCE_MARKS.has(chars[i] ?? '')) return chars.slice(0, i + 1).join('');
|
|
2095
|
+
}
|
|
2096
|
+
if (chars.length > 36) return `${chars.slice(0, 36).join('')}…`;
|
|
2097
|
+
return point;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
1411
2100
|
/**
|
|
1412
2101
|
* E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
|
|
1413
2102
|
* ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
|
|
1414
2103
|
* are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
|
|
1415
2104
|
* is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
|
|
1416
2105
|
* a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
|
|
2106
|
+
*
|
|
2107
|
+
* `note_scope` (`episode`, 0235) opts into the episode note for voice2: the packaged set then also
|
|
2108
|
+
* attaches the episode cover last, and the body is rebuilt as the lead paragraph plus one numbered
|
|
2109
|
+
* line per attached image. Absent or any other value: the lead-item series and the agent's body are
|
|
2110
|
+
* packaged verbatim (voice1's contract).
|
|
1417
2111
|
*/
|
|
1418
2112
|
export function dailyXhsRender(args: string[]): void {
|
|
1419
2113
|
const [
|
|
@@ -1425,9 +2119,12 @@ export function dailyXhsRender(args: string[]): void {
|
|
|
1425
2119
|
provider = '',
|
|
1426
2120
|
model = '',
|
|
1427
2121
|
fallback = '',
|
|
2122
|
+
noteScope = '',
|
|
1428
2123
|
] = args;
|
|
1429
2124
|
const count = Number.parseInt(countArg, 10);
|
|
2125
|
+
const episode = noteScope === XHS_NOTE_SCOPE_EPISODE;
|
|
1430
2126
|
const planPath = join(workDir, '25-xhs', `${runDate}_18_xhs_plan.json`);
|
|
2127
|
+
const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
1431
2128
|
const cardsDir = join(workDir, '25-xhs', 'cards');
|
|
1432
2129
|
const manifestPath = join(workDir, '25-xhs', 'cards.json');
|
|
1433
2130
|
const contentPath = join(workDir, '25-xhs', 'content.json');
|
|
@@ -1439,6 +2136,13 @@ export function dailyXhsRender(args: string[]): void {
|
|
|
1439
2136
|
sted('mkdir', cardsDir, () => mkdirSync(cardsDir, { recursive: true }));
|
|
1440
2137
|
const images: Record<string, unknown>[] = [];
|
|
1441
2138
|
const cards: Record<string, unknown>[] = [];
|
|
2139
|
+
// 0235 R2: the numbered lines are a pure function of the plan and the cover, in attach order
|
|
2140
|
+
// (cards in plan order, the cover last), so the note — and its cap — is settled before the
|
|
2141
|
+
// first card is billed: an over-cap tail fails here with nothing rendered.
|
|
2142
|
+
const withCover = episode && isFile(coverPath);
|
|
2143
|
+
const lines = plan.cards.map((card, i) => `${markerFor(i)} ${card.copy}:${card.points[0] ?? ''}`);
|
|
2144
|
+
if (withCover) lines.push(`${markerFor(lines.length)} 每日AI乱炖 ${runDate} 本期封面`);
|
|
2145
|
+
const body = episode ? episodeNoteBody(plan.body, lines, XHS_BODY_CAP) : plan.body;
|
|
1442
2146
|
for (const card of plan.cards) {
|
|
1443
2147
|
const out = join(cardsDir, `card-${card.index}.json`);
|
|
1444
2148
|
const promptPath = join(cardsDir, `card-${card.index}.prompt.txt`);
|
|
@@ -1471,17 +2175,45 @@ export function dailyXhsRender(args: string[]): void {
|
|
|
1471
2175
|
// Sequential on purpose: one provider call per card (illustration only —
|
|
1472
2176
|
// card copy is typeset by image-gen's composer, never prompted), and the
|
|
1473
2177
|
// first failure stops the spend — later cards stay unrendered for a resume
|
|
1474
|
-
// run to pick up.
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
2178
|
+
// run to pick up. 0251: the one captured call — the overflow retry branches
|
|
2179
|
+
// on the composer's stderr, which kk()'s inherit stdio cannot return.
|
|
2180
|
+
const imageGenArgs = [
|
|
2181
|
+
'executor',
|
|
2182
|
+
'run',
|
|
2183
|
+
'image-gen',
|
|
2184
|
+
'--in',
|
|
2185
|
+
envPath,
|
|
2186
|
+
'--out',
|
|
2187
|
+
out,
|
|
2188
|
+
...pluginsArgs(pluginsPath),
|
|
2189
|
+
];
|
|
1479
2190
|
// 0219 R2: name the failed card so a hand re-run resumes without re-billing.
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
2191
|
+
const resumeLine = (status: number): string =>
|
|
2192
|
+
`xhs render: card ${card.index} failed (image-gen exit ${status}); cards before it are kept — re-run daily-xhs-render to resume from card ${card.index}`;
|
|
2193
|
+
let result = kkCaptured(imageGenArgs, { soft: true });
|
|
2194
|
+
if (result.status !== 0) {
|
|
2195
|
+
// 0251 R1: retry ONCE, only on a points[n] safe-area overflow where at least one
|
|
2196
|
+
// named point got shorter. The envelope is rewritten in the same shape and
|
|
2197
|
+
// image-gen is called once more; a second failure takes the resume line.
|
|
2198
|
+
let shortened = false;
|
|
2199
|
+
for (const n of xhsOverflowPoints(result.stderr, card) ?? []) {
|
|
2200
|
+
const point = card.points[n] ?? '';
|
|
2201
|
+
const next = shortenOverflowedPoint(point);
|
|
2202
|
+
if (next !== point) {
|
|
2203
|
+
card.points[n] = next;
|
|
2204
|
+
shortened = true;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
if (shortened) {
|
|
2208
|
+
(imageGen.vars as Record<string, unknown>).points = card.points.join('\n');
|
|
2209
|
+
sted('write', envPath, () =>
|
|
2210
|
+
writeJsonText(envPath, [
|
|
2211
|
+
{ id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
|
|
2212
|
+
]),
|
|
2213
|
+
);
|
|
2214
|
+
result = kkCaptured(imageGenArgs, { soft: true });
|
|
2215
|
+
}
|
|
2216
|
+
if (result.status !== 0) failWith(result.status, resumeLine(result.status));
|
|
1485
2217
|
}
|
|
1486
2218
|
artifact = firstArtifact(requireJson(out, 'xhs render'));
|
|
1487
2219
|
}
|
|
@@ -1514,19 +2246,34 @@ export function dailyXhsRender(args: string[]): void {
|
|
|
1514
2246
|
promptPath,
|
|
1515
2247
|
imagePath,
|
|
1516
2248
|
illustrationPath,
|
|
2249
|
+
...(card.itemId === undefined ? {} : { itemId: card.itemId }),
|
|
1517
2250
|
status: 'rendered',
|
|
1518
2251
|
});
|
|
1519
2252
|
}
|
|
2253
|
+
// 0235 R1: the episode cover closes the set — it is ultrawide, and image 1 (card 01) is what
|
|
2254
|
+
// sets the note's grid ratio. It is `daily-cover`'s own artifact (no new image kind), and
|
|
2255
|
+
// the set is ≤ XHS_MAX_CARDS + 1 = 11, inside `xhs-pub`'s XHS_IMAGE_MAX (18).
|
|
2256
|
+
if (withCover) {
|
|
2257
|
+
images.push({ path: resolve(coverPath) });
|
|
2258
|
+
} else if (episode) {
|
|
2259
|
+
console.log(
|
|
2260
|
+
`xhs render: no episode cover at ${coverPath} — the note carries the ${cards.length} cards only`,
|
|
2261
|
+
);
|
|
2262
|
+
}
|
|
1520
2263
|
sted('write', manifestPath, () => writeJsonText(manifestPath, { source: planPath, cards }));
|
|
1521
2264
|
sted('write', contentPath, () =>
|
|
1522
2265
|
writeJsonText(contentPath, {
|
|
1523
2266
|
title: plan.title,
|
|
1524
|
-
body
|
|
2267
|
+
body,
|
|
1525
2268
|
format: 'text',
|
|
1526
2269
|
metadata: { images, publishStatus: 'draft' },
|
|
1527
2270
|
}),
|
|
1528
2271
|
);
|
|
1529
|
-
console.log(
|
|
2272
|
+
console.log(
|
|
2273
|
+
episode
|
|
2274
|
+
? `xhs render: ${images.length} images packaged in image order (${contentPath})`
|
|
2275
|
+
: `xhs render: ${cards.length} cards packaged in card order (${contentPath})`,
|
|
2276
|
+
);
|
|
1530
2277
|
});
|
|
1531
2278
|
}
|
|
1532
2279
|
|