@gobing-ai/knowledge-kit 0.0.33 → 0.0.34
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 +14 -20
- package/plugins/generations/daily-article-gen/src/index.ts +7 -11
- package/plugins/generations/dailynews-gen/dist/index.js +22 -21
- package/plugins/generations/dailynews-gen/src/script-builder.ts +25 -26
- package/plugins/generations/episode-plan-gen/dist/index.js +12 -12
- package/plugins/generations/gemini-tts/dist/index.js +9 -9
- 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/voice-gen/dist/index.js +9 -9
- 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 +539 -49
- package/plugins/kk/scripts/daily-voice-style.zh.md +15 -10
- package/plugins/kk/scripts/kk-workflow-stages.ts +6 -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/workflows/kk-collect.yaml +7 -9
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +163 -44
- package/plugins/kk/workflows/kk-daily-safety.yaml +8 -11
- package/plugins/publishings/emdash-pub/dist/index.js +9 -9
- package/plugins/publishings/infoq-pub/dist/index.js +22 -22
- package/plugins/publishings/juejin-pub/dist/index.js +22 -22
- 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/surfdash-pub/dist/index.js +9 -9
- package/plugins/publishings/wechatmp-pub/dist/index.js +22 -22
- package/plugins/publishings/x-pub/dist/index.js +22 -22
- package/plugins/publishings/xhs-pub/dist/index.js +76 -27
- 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
|
@@ -104,6 +104,7 @@ import {
|
|
|
104
104
|
writeJqText,
|
|
105
105
|
writeJsonText,
|
|
106
106
|
writeRaw,
|
|
107
|
+
XHS_BODY_CAP,
|
|
107
108
|
XHS_FORMAT,
|
|
108
109
|
XHS_MAX_CARDS,
|
|
109
110
|
xhsPlan,
|
|
@@ -197,12 +198,31 @@ export function dailyArticle(args: string[]): void {
|
|
|
197
198
|
timed(workDir, 'article', () => {
|
|
198
199
|
let planUsable = true;
|
|
199
200
|
const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
|
|
201
|
+
// The plan file is canonically the plan `Doc[]` — `dailyPlan` writes the generator's
|
|
202
|
+
// `metadata.docs` — and every downstream stage reads it as one. `plan-translate`'s executor
|
|
203
|
+
// has been observed writing the generator's Content ENVELOPE back instead: valid JSON of the
|
|
204
|
+
// wrong shape, which the article generator then refuses with `expected array, received
|
|
205
|
+
// object`. Unwrap that envelope rather than discard the translation; anything else unusable
|
|
206
|
+
// still falls back to the untranslated candidates.
|
|
207
|
+
let planShape: unknown;
|
|
200
208
|
try {
|
|
201
|
-
JSON.parse(raw);
|
|
209
|
+
planShape = JSON.parse(raw);
|
|
202
210
|
} catch (error) {
|
|
203
211
|
console.error(
|
|
204
212
|
`article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): ${(error as Error).message}`,
|
|
205
213
|
);
|
|
214
|
+
}
|
|
215
|
+
if (planShape !== undefined && !Array.isArray(planShape)) {
|
|
216
|
+
const enveloped = metadataOf(planShape as Record<string, unknown>).docs;
|
|
217
|
+
if (Array.isArray(enveloped) && enveloped.length > 0) {
|
|
218
|
+
console.error(
|
|
219
|
+
`article: plan.json carries the Content envelope, not the Doc[] — unwrapping metadata.docs (${enveloped.length} doc(s))`,
|
|
220
|
+
);
|
|
221
|
+
sted('write', planIn, () => writeJsonText(planIn, enveloped));
|
|
222
|
+
planShape = enveloped;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!Array.isArray(planShape)) {
|
|
206
226
|
const docs = metadataOf(readJson(candidates)).docs;
|
|
207
227
|
if (!Array.isArray(docs) || docs.length === 0) {
|
|
208
228
|
console.error('article: candidates.json metadata.docs missing');
|
|
@@ -386,8 +406,51 @@ export function dailyEpisodeDuration(args: string[]): void {
|
|
|
386
406
|
console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
|
|
387
407
|
}
|
|
388
408
|
|
|
409
|
+
/** Ceiling for the pre-spend estimate when the caller passes no `episode_max_minutes`. */
|
|
410
|
+
const DEFAULT_EPISODE_MAX_MINUTES = 20;
|
|
411
|
+
|
|
412
|
+
/** `kk executor run` argv shared by the quota probe, the voice1 one-shot, and each section. */
|
|
413
|
+
function voiceExecutorArgs(generator: string, input: string, output: string, pluginsPath: string): string[] {
|
|
414
|
+
return ['executor', 'run', generator, '--in', input, '--out', output, ...pluginsArgs(pluginsPath)];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* One tiny gemini-tts render before any episode speech. A non-zero exit (quota, key, network)
|
|
419
|
+
* means this run should use voicebox. Probe files are removed either way.
|
|
420
|
+
* Returns false when the caller should fall back.
|
|
421
|
+
*/
|
|
422
|
+
function geminiQuotaProbeOk(workDir: string, pluginsPath: string, transcodeMp3: string, probeDocs: unknown): boolean {
|
|
423
|
+
const probeDir = join(workDir, '30-audio');
|
|
424
|
+
const probeIn = join(probeDir, '.gemini-probe-in.json');
|
|
425
|
+
const probeOut = join(probeDir, '.gemini-probe-out.json');
|
|
426
|
+
writeJsonText(probeIn, probeDocs);
|
|
427
|
+
const probe = spawnSync('kk', voiceExecutorArgs('gemini-tts', probeIn, probeOut, pluginsPath), {
|
|
428
|
+
encoding: 'utf8',
|
|
429
|
+
timeout: 120_000,
|
|
430
|
+
env: { ...process.env, VOICE_GEN_MP3: transcodeMp3 },
|
|
431
|
+
});
|
|
432
|
+
for (const stale of [probeIn, probeOut, probeOut.replace(/\.json$/, '.wav'), probeOut.replace(/\.json$/, '.mp3')]) {
|
|
433
|
+
rmSync(stale, { force: true });
|
|
434
|
+
}
|
|
435
|
+
if (probe.status !== 0) {
|
|
436
|
+
const reason = (probe.stderr ?? '').trim().split('\n').slice(-1)[0] || `exit ${probe.status}`;
|
|
437
|
+
console.log(`generate: gemini quota probe failed (${reason}) — falling back to voicebox for this run`);
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
console.log('generate: gemini quota probe ok');
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
389
444
|
export function dailyGenerate(args: string[]): void {
|
|
390
|
-
const [
|
|
445
|
+
const [
|
|
446
|
+
workDir = '',
|
|
447
|
+
runDate = '',
|
|
448
|
+
pluginsPath = '',
|
|
449
|
+
transcodeMp3 = '',
|
|
450
|
+
vdriver = '',
|
|
451
|
+
sectionConcurrency = '',
|
|
452
|
+
episodeMaxMinutes = '',
|
|
453
|
+
] = args;
|
|
391
454
|
// 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
|
|
392
455
|
// passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
|
|
393
456
|
const VOICE_DRIVERS: Record<string, string> = {
|
|
@@ -402,54 +465,351 @@ export function dailyGenerate(args: string[]): void {
|
|
|
402
465
|
`generate: unknown vdriver '${vdriver}' (expected one of: ${Object.keys(VOICE_DRIVERS).join(', ')})`,
|
|
403
466
|
);
|
|
404
467
|
}
|
|
468
|
+
// Strict `Number`, not `parseFloat`: a typo'd var ('2O') must refuse, not cap the episode at 2 min.
|
|
469
|
+
const maxMinutes = episodeMaxMinutes === '' ? DEFAULT_EPISODE_MAX_MINUTES : Number(episodeMaxMinutes);
|
|
470
|
+
if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) {
|
|
471
|
+
failWith(1, `generate: invalid episode_max_minutes: ${episodeMaxMinutes}`);
|
|
472
|
+
}
|
|
405
473
|
const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
|
|
406
474
|
const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
475
|
+
|
|
476
|
+
if (!sectionConcurrency) {
|
|
477
|
+
timed(workDir, 'generate', () => {
|
|
478
|
+
if (isFile(content) && metadataOf(readJson(content)).qc !== undefined) {
|
|
479
|
+
const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
|
|
480
|
+
if (qc?.passed === true) {
|
|
481
|
+
console.log(`audio cached: ${content} (QC passed)`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// Gemini quota probe (20261006): the 06:00 cron hit gemini-3.8-flash-tts 429 with a
|
|
486
|
+
// multi-hour retry-after after the shared key's window drained overnight. One tiny
|
|
487
|
+
// render (~1 request) before the real episode; ANY probe failure (quota, key, network)
|
|
488
|
+
// flips this run to the local voicebox driver instead of dying mid-episode — voicebox
|
|
489
|
+
// needs no key. Operator decision 20261006: keep the gemini default + auto-fallback.
|
|
490
|
+
if (vdriver === 'gemini') {
|
|
491
|
+
const docsList = readJson(docs) as Array<Record<string, unknown>>;
|
|
492
|
+
if (
|
|
493
|
+
docsList.length > 0 &&
|
|
494
|
+
!geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [{ ...docsList[0], body: 'Hello.' }])
|
|
495
|
+
) {
|
|
496
|
+
voiceGenerator = VOICE_DRIVERS.voicebox;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
kk(voiceExecutorArgs(voiceGenerator, docs, content, pluginsPath), {
|
|
500
|
+
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (sectionConcurrency !== '1') {
|
|
507
|
+
failWith(1, `generate: section_concurrency '${sectionConcurrency}' not supported (must be 1)`);
|
|
508
|
+
}
|
|
509
|
+
|
|
407
510
|
timed(workDir, 'generate', () => {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
511
|
+
const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
512
|
+
if (!existsSync(scriptPath)) {
|
|
513
|
+
failWith(1, `generate: voicescript not found: ${scriptPath}`);
|
|
514
|
+
}
|
|
515
|
+
const scriptRaw = readFileSync(scriptPath, 'utf8');
|
|
516
|
+
const script = Bun.YAML.parse(scriptRaw) as Record<string, unknown>;
|
|
517
|
+
const segments = (script.segments ?? []) as Array<Record<string, unknown>>;
|
|
518
|
+
if (!Array.isArray(segments) || segments.length < 2) {
|
|
519
|
+
failWith(1, 'generate: invalid voicescript (expected at least opening and closing segments)');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Grouping: first is opening, last is closing, middle segments split after gap_ms: 1200
|
|
523
|
+
const middleSegments = segments.slice(1, -1);
|
|
524
|
+
const itemSections: Array<Array<Record<string, unknown>>> = [];
|
|
525
|
+
let currentItem: Array<Record<string, unknown>> = [];
|
|
526
|
+
for (const seg of middleSegments) {
|
|
527
|
+
currentItem.push(seg);
|
|
528
|
+
if (seg.gap_ms === 1200) {
|
|
529
|
+
itemSections.push(currentItem);
|
|
530
|
+
currentItem = [];
|
|
413
531
|
}
|
|
414
532
|
}
|
|
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`,
|
|
533
|
+
if (currentItem.length > 0) {
|
|
534
|
+
itemSections.push(currentItem);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const planPath = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
|
|
538
|
+
if (existsSync(planPath)) {
|
|
539
|
+
try {
|
|
540
|
+
const plan = readJson(planPath) as { items?: unknown[] };
|
|
541
|
+
if (Array.isArray(plan.items) && plan.items.length !== itemSections.length) {
|
|
542
|
+
failWith(
|
|
543
|
+
1,
|
|
544
|
+
`generate: plan item count (${plan.items.length}) does not match script item sections (${itemSections.length})`,
|
|
443
545
|
);
|
|
444
|
-
voiceGenerator = VOICE_DRIVERS.voicebox;
|
|
445
|
-
} else {
|
|
446
|
-
console.log('generate: gemini quota probe ok');
|
|
447
546
|
}
|
|
547
|
+
} catch (e: unknown) {
|
|
548
|
+
if (e instanceof StageFailure) throw e;
|
|
448
549
|
}
|
|
449
550
|
}
|
|
450
|
-
|
|
451
|
-
|
|
551
|
+
|
|
552
|
+
interface SectionDef {
|
|
553
|
+
index: number;
|
|
554
|
+
role: string;
|
|
555
|
+
stem: string;
|
|
556
|
+
name: string;
|
|
557
|
+
segments: Array<Record<string, unknown>>;
|
|
558
|
+
characters: number;
|
|
559
|
+
gap_ms: number;
|
|
560
|
+
text: string;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const sectionDefs: SectionDef[] = [];
|
|
564
|
+
// 00-opening
|
|
565
|
+
const openSegs = [segments[0]];
|
|
566
|
+
const openText = openSegs.map((s) => String(s.text ?? '')).join('');
|
|
567
|
+
sectionDefs.push({
|
|
568
|
+
index: 0,
|
|
569
|
+
role: 'opening',
|
|
570
|
+
stem: '00-opening',
|
|
571
|
+
name: '00-opening',
|
|
572
|
+
segments: openSegs,
|
|
573
|
+
characters: [...openText].length,
|
|
574
|
+
gap_ms:
|
|
575
|
+
typeof openSegs[openSegs.length - 1].gap_ms === 'number'
|
|
576
|
+
? (openSegs[openSegs.length - 1].gap_ms as number)
|
|
577
|
+
: 1200,
|
|
578
|
+
text: openText,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// 01-item ...
|
|
582
|
+
itemSections.forEach((segs, idx) => {
|
|
583
|
+
const itemIdx = idx + 1;
|
|
584
|
+
const pad = String(itemIdx).padStart(2, '0');
|
|
585
|
+
const stem = `${pad}-item`;
|
|
586
|
+
const itemText = segs.map((s) => String(s.text ?? '')).join('');
|
|
587
|
+
const lastSeg = segs[segs.length - 1];
|
|
588
|
+
sectionDefs.push({
|
|
589
|
+
index: itemIdx,
|
|
590
|
+
role: 'item',
|
|
591
|
+
stem,
|
|
592
|
+
name: stem,
|
|
593
|
+
segments: segs,
|
|
594
|
+
characters: [...itemText].length,
|
|
595
|
+
gap_ms: typeof lastSeg?.gap_ms === 'number' ? (lastSeg.gap_ms as number) : 1200,
|
|
596
|
+
text: itemText,
|
|
597
|
+
});
|
|
452
598
|
});
|
|
599
|
+
|
|
600
|
+
// closing
|
|
601
|
+
const closeIdx = sectionDefs.length;
|
|
602
|
+
const closePad = String(closeIdx).padStart(2, '0');
|
|
603
|
+
const closeSegs = [segments[segments.length - 1]];
|
|
604
|
+
const closeText = closeSegs.map((s) => String(s.text ?? '')).join('');
|
|
605
|
+
sectionDefs.push({
|
|
606
|
+
index: closeIdx,
|
|
607
|
+
role: 'closing',
|
|
608
|
+
stem: `${closePad}-closing`,
|
|
609
|
+
name: `${closePad}-closing`,
|
|
610
|
+
segments: closeSegs,
|
|
611
|
+
characters: [...closeText].length,
|
|
612
|
+
gap_ms:
|
|
613
|
+
typeof closeSegs[closeSegs.length - 1].gap_ms === 'number'
|
|
614
|
+
? (closeSegs[closeSegs.length - 1].gap_ms as number)
|
|
615
|
+
: 300,
|
|
616
|
+
text: closeText,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Character-rate estimate: section characters / driver rate + 8 s sting > the workflow's
|
|
620
|
+
// ceiling (`episode_max_minutes`, arg 8; default 20 for a bare five/six-argument call).
|
|
621
|
+
// The rates are MEASURED from shipped renders, not the style contract's prose. gemini-tts:
|
|
622
|
+
// 20261004 5466 chars / 1026 s = 5.33, 20261007 5970 / 1096 = 5.45, and the 0237 live dry run
|
|
623
|
+
// 4645 / 789.9 s of section speech = 5.88 — a 5.3–5.9 range, so 5.4 deliberately errs low (a
|
|
624
|
+
// slightly early refusal is cheaper than a rendered episode the duration gate then rejects).
|
|
625
|
+
// voice-gen: 20261006 6376 / 1135 = 5.62. A 20-minute ceiling is therefore ~6400 characters.
|
|
626
|
+
// The style sidecar's 3.3 / 6.3 figures are stale and mutually inconsistent with its own
|
|
627
|
+
// 4300–5800 字 budget (which is 13–29 min at 3.3); using them refused a script that really
|
|
628
|
+
// renders in 17 min. Recalibrate from a fresh pair of `metadata.duration` readings when a
|
|
629
|
+
// driver changes model or speed.
|
|
630
|
+
const charRate = voiceGenerator === 'gemini-tts' ? 5.4 : 5.6;
|
|
631
|
+
const totalChars = sectionDefs.reduce((acc, s) => acc + s.characters, 0);
|
|
632
|
+
const estimatedSeconds = 8 + totalChars / charRate;
|
|
633
|
+
if (estimatedSeconds > maxMinutes * 60) {
|
|
634
|
+
const longest3 = [...sectionDefs].sort((a, b) => b.characters - a.characters).slice(0, 3);
|
|
635
|
+
failWith(
|
|
636
|
+
1,
|
|
637
|
+
`generate: character-rate estimate ${Math.round(estimatedSeconds / 60)} min exceeds ${maxMinutes} min limit (longest sections: ${longest3.map((s) => s.name).join(', ')})`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const voiceProfile =
|
|
642
|
+
(typeof script.default_profile_id === 'string' && script.default_profile_id) ||
|
|
643
|
+
(typeof script.default_profile === 'string' && script.default_profile) ||
|
|
644
|
+
process.env.VOICEBOX_DEFAULT_PROFILE ||
|
|
645
|
+
'robin-news';
|
|
646
|
+
|
|
647
|
+
const secDir = join(workDir, '30-audio', 'sections', runDate);
|
|
648
|
+
mkdirSync(secDir, { recursive: true });
|
|
649
|
+
|
|
650
|
+
const joinedWavPath = join(workDir, '30-audio', `${runDate}_11_generate_content.wav`);
|
|
651
|
+
const joinedContentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
652
|
+
|
|
653
|
+
interface SectionPlan {
|
|
654
|
+
def: SectionDef;
|
|
655
|
+
hash: string;
|
|
656
|
+
wavPath: string;
|
|
657
|
+
jsonPath: string;
|
|
658
|
+
needsSynthesis: boolean;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const computePlans = (driver: string): SectionPlan[] => {
|
|
662
|
+
return sectionDefs.map((def) => {
|
|
663
|
+
const hash = createHash('sha256').update(`${def.text}\0${voiceProfile}\0${driver}`).digest('hex');
|
|
664
|
+
const wavPath = join(secDir, `${def.stem}.wav`);
|
|
665
|
+
const jsonPath = join(secDir, `${def.stem}.json`);
|
|
666
|
+
let cached = false;
|
|
667
|
+
if (existsSync(wavPath) && existsSync(jsonPath)) {
|
|
668
|
+
try {
|
|
669
|
+
const sidecar = readJson(jsonPath) as Record<string, unknown>;
|
|
670
|
+
const sidecarMeta = metadataOf(sidecar) as Record<string, unknown>;
|
|
671
|
+
if (sidecar.hash === hash || sidecarMeta.hash === hash) {
|
|
672
|
+
cached = true;
|
|
673
|
+
}
|
|
674
|
+
} catch {}
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
def,
|
|
678
|
+
hash,
|
|
679
|
+
wavPath,
|
|
680
|
+
jsonPath,
|
|
681
|
+
needsSynthesis: !cached,
|
|
682
|
+
};
|
|
683
|
+
});
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
let plans = computePlans(voiceGenerator);
|
|
687
|
+
const anySynthesisNeeded = plans.some((p) => p.needsSynthesis);
|
|
688
|
+
|
|
689
|
+
if (!anySynthesisNeeded && existsSync(joinedWavPath) && existsSync(joinedContentPath)) {
|
|
690
|
+
console.log('generate: all sections cached, joined speech reused');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Same quota probe as the one-shot path, before any section request. A fallback changes
|
|
695
|
+
// the cache key, so plans are recomputed and no Gemini file is played as voicebox speech.
|
|
696
|
+
if (
|
|
697
|
+
anySynthesisNeeded &&
|
|
698
|
+
vdriver === 'gemini' &&
|
|
699
|
+
!geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [
|
|
700
|
+
{
|
|
701
|
+
id: 'probe',
|
|
702
|
+
title: 'Probe',
|
|
703
|
+
body: Bun.YAML.stringify({
|
|
704
|
+
title: 'Probe',
|
|
705
|
+
segments: [{ text: 'Hello.' }],
|
|
706
|
+
}),
|
|
707
|
+
sourceUri: 'file://probe',
|
|
708
|
+
mediaType: 'application/yaml',
|
|
709
|
+
metadata: { voiceProfile },
|
|
710
|
+
},
|
|
711
|
+
])
|
|
712
|
+
) {
|
|
713
|
+
voiceGenerator = VOICE_DRIVERS.voicebox;
|
|
714
|
+
plans = computePlans(voiceGenerator);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Synthesize sections sequentially (concurrency 1)
|
|
718
|
+
for (const plan of plans) {
|
|
719
|
+
if (!plan.needsSynthesis) {
|
|
720
|
+
console.log(`generate: section cached: ${plan.def.stem}`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
console.log(`generate: synthesizing section: ${plan.def.stem}`);
|
|
724
|
+
const secScript = { ...script, segments: plan.def.segments };
|
|
725
|
+
const secDocIn = join(secDir, `.${plan.def.stem}_in.json`);
|
|
726
|
+
const doc = {
|
|
727
|
+
id: createHash('sha256').update(`section-${plan.def.stem}`).digest('hex').slice(0, 16),
|
|
728
|
+
title: `Daily AI News VoiceScript - ${plan.def.stem}`,
|
|
729
|
+
sourceUri: `file://${resolve(scriptPath)}#${plan.def.stem}`,
|
|
730
|
+
body: Bun.YAML.stringify(secScript),
|
|
731
|
+
mediaType: 'application/yaml',
|
|
732
|
+
metadata: { voiceProfile },
|
|
733
|
+
};
|
|
734
|
+
writeJsonText(secDocIn, [doc]);
|
|
735
|
+
try {
|
|
736
|
+
const status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
|
|
737
|
+
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
738
|
+
soft: true,
|
|
739
|
+
});
|
|
740
|
+
if (status !== 0) {
|
|
741
|
+
failWith(status, `generate: section synthesis failed for ${plan.def.stem}`);
|
|
742
|
+
}
|
|
743
|
+
} finally {
|
|
744
|
+
rmSync(secDocIn, { force: true });
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const sidecar = readJson(plan.jsonPath) as Record<string, unknown>;
|
|
748
|
+
sidecar.hash = plan.hash;
|
|
749
|
+
if (sidecar.metadata && typeof sidecar.metadata === 'object') {
|
|
750
|
+
(sidecar.metadata as Record<string, unknown>).hash = plan.hash;
|
|
751
|
+
}
|
|
752
|
+
writeJsonText(plan.jsonPath, sidecar);
|
|
753
|
+
} catch {}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Join sections with ffmpeg
|
|
757
|
+
const probeHelper = (file: string, entries: string): string =>
|
|
758
|
+
spawnSync('ffprobe', [
|
|
759
|
+
'-v',
|
|
760
|
+
'error',
|
|
761
|
+
'-select_streams',
|
|
762
|
+
'a:0',
|
|
763
|
+
'-show_entries',
|
|
764
|
+
entries,
|
|
765
|
+
'-of',
|
|
766
|
+
'csv=p=0',
|
|
767
|
+
file,
|
|
768
|
+
])
|
|
769
|
+
.stdout?.toString()
|
|
770
|
+
.trim() ?? '';
|
|
771
|
+
|
|
772
|
+
const sampleRate = probeHelper(plans[0].wavPath, 'stream=sample_rate') || '24000';
|
|
773
|
+
const ffmpegArgs = ['-y', '-hide_banner', '-loglevel', 'error'];
|
|
774
|
+
const filterParts: string[] = [];
|
|
775
|
+
const concatInputs: string[] = [];
|
|
776
|
+
|
|
777
|
+
plans.forEach((plan, i) => {
|
|
778
|
+
ffmpegArgs.push('-i', plan.wavPath);
|
|
779
|
+
filterParts.push(`[${i}:a]aresample=${sampleRate},aformat=sample_fmts=s16:channel_layouts=mono[a${i}]`);
|
|
780
|
+
concatInputs.push(`[a${i}]`);
|
|
781
|
+
if (i < plans.length - 1) {
|
|
782
|
+
const gapMs = plan.def.gap_ms;
|
|
783
|
+
const gapS = gapMs > 0 ? (gapMs / 1000).toFixed(4) : '0';
|
|
784
|
+
filterParts.push(`anullsrc=r=${sampleRate}:cl=mono,atrim=duration=${gapS}[sil${i}]`);
|
|
785
|
+
concatInputs.push(`[sil${i}]`);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
const filterComplex = `${filterParts.join(';')};${concatInputs.join('')}concat=n=${concatInputs.length}:v=0:a=1[out]`;
|
|
790
|
+
ffmpegArgs.push('-filter_complex', filterComplex, '-map', '[out]', '-c:a', 'pcm_s16le', joinedWavPath);
|
|
791
|
+
|
|
792
|
+
const conv = spawnSync('ffmpeg', ffmpegArgs);
|
|
793
|
+
if (conv.status !== 0 || !existsSync(joinedWavPath)) {
|
|
794
|
+
failWith(1, `generate: ffmpeg join failed: ${conv.stderr?.toString().trim()}`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const joinedDuration = Number.parseFloat(probeHelper(joinedWavPath, 'format=duration'));
|
|
798
|
+
|
|
799
|
+
let contentObj: Record<string, unknown> = {};
|
|
800
|
+
if (existsSync(joinedContentPath)) {
|
|
801
|
+
try {
|
|
802
|
+
contentObj = readJson(joinedContentPath) as Record<string, unknown>;
|
|
803
|
+
} catch {}
|
|
804
|
+
}
|
|
805
|
+
const meta = (contentObj.metadata ?? {}) as Record<string, unknown>;
|
|
806
|
+
delete meta.introMerged;
|
|
807
|
+
meta.audioPath = joinedWavPath;
|
|
808
|
+
meta.duration = joinedDuration;
|
|
809
|
+
contentObj.audioPath = joinedWavPath;
|
|
810
|
+
contentObj.mediaType = 'audio/wav';
|
|
811
|
+
contentObj.metadata = meta;
|
|
812
|
+
writeJsonText(joinedContentPath, contentObj);
|
|
453
813
|
});
|
|
454
814
|
}
|
|
455
815
|
|
|
@@ -791,7 +1151,7 @@ export function dailyPublishWechat(args: string[]): void {
|
|
|
791
1151
|
original: true,
|
|
792
1152
|
aiGenerated: true,
|
|
793
1153
|
author: '冰原奔狼',
|
|
794
|
-
collection: collectionArg || 'AI
|
|
1154
|
+
collection: collectionArg || '每日AI乱炖',
|
|
795
1155
|
...(isFile(cover) ? { coverPath: cover } : {}),
|
|
796
1156
|
// Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
|
|
797
1157
|
// the 素材库 under the article title; the plugin reuses an existing same-title material.
|
|
@@ -842,10 +1202,15 @@ export function dailyPublishWechat(args: string[]): void {
|
|
|
842
1202
|
* its own). Non-blocking by operator decision: a missing/expired XHS session or a changed console
|
|
843
1203
|
* logs a loud failure and leaves the run alive, and the outcome never enters `publish_status`
|
|
844
1204
|
* (surfdash + podcast remain the episode's publication contract).
|
|
1205
|
+
*
|
|
1206
|
+
* 0236: the optional trailing `<podcast>` arg (`'true'` from voice2 only) attaches the episode's
|
|
1207
|
+
* 发播客 note to the same visit as `metadata.followUps` — one console visit, two notes, one cap entry
|
|
1208
|
+
* (ADR-025 amendment 2026-10-09).
|
|
845
1209
|
*/
|
|
846
1210
|
export function dailyPublishXhs(args: string[]): void {
|
|
847
|
-
const [workDir = '', runDate = '', pluginsPath = '', live = 'false'] = args;
|
|
1211
|
+
const [workDir = '', runDate = '', pluginsPath = '', live = 'false', podcast = ''] = args;
|
|
848
1212
|
const content = join(workDir, '25-xhs', 'content.json');
|
|
1213
|
+
const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
849
1214
|
const input = join(workDir, '31-publish', `${runDate}_19_publish-xhs_content.json`);
|
|
850
1215
|
const result = join(workDir, '31-publish', `${runDate}_19_publish-xhs_result.json`);
|
|
851
1216
|
const want = live === 'true' ? 'public' : 'draft';
|
|
@@ -854,6 +1219,17 @@ export function dailyPublishXhs(args: string[]): void {
|
|
|
854
1219
|
// xhs_live=true re-run (that would report a live post that never happened).
|
|
855
1220
|
if (resultPublishStatus(result) === want) {
|
|
856
1221
|
console.log(`publish-xhs cached: ${want} already recorded in ${result}`);
|
|
1222
|
+
// 0236: the cache keys on the visit's primary note, so a 发播客 follow-up that failed in
|
|
1223
|
+
// that visit is named here instead of hidden. It is not retried: a re-visit would post
|
|
1224
|
+
// a second 图文 draft (the console has no idempotency key).
|
|
1225
|
+
const followUps = metadataOf(readJson(result)).followUps;
|
|
1226
|
+
for (const entry of Array.isArray(followUps) ? followUps : []) {
|
|
1227
|
+
const step = (entry ?? {}) as Record<string, unknown>;
|
|
1228
|
+
if (step.ok === true) continue;
|
|
1229
|
+
console.log(
|
|
1230
|
+
`publish-xhs cached: follow-up ${String(metadataOf(step).operation ?? '?')} did not complete: ${String(step.error ?? 'no error recorded')}`,
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
857
1233
|
return;
|
|
858
1234
|
}
|
|
859
1235
|
if (!isFile(content)) {
|
|
@@ -861,8 +1237,16 @@ export function dailyPublishXhs(args: string[]): void {
|
|
|
861
1237
|
return;
|
|
862
1238
|
}
|
|
863
1239
|
const packaged = requireJson(content, 'publish-xhs');
|
|
1240
|
+
const followUp = podcast === 'true' ? xhsPodcastNote(prep, want) : undefined;
|
|
864
1241
|
sted('write', input, () =>
|
|
865
|
-
writeJsonText(input, {
|
|
1242
|
+
writeJsonText(input, {
|
|
1243
|
+
...packaged,
|
|
1244
|
+
metadata: {
|
|
1245
|
+
...metadataOf(packaged),
|
|
1246
|
+
publishStatus: want,
|
|
1247
|
+
...(followUp === undefined ? {} : { followUps: [followUp] }),
|
|
1248
|
+
},
|
|
1249
|
+
}),
|
|
866
1250
|
);
|
|
867
1251
|
// `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
|
|
868
1252
|
// and prints the failing step (login gate, upload, body, submit control), which is what the
|
|
@@ -878,6 +1262,34 @@ export function dailyPublishXhs(args: string[]): void {
|
|
|
878
1262
|
});
|
|
879
1263
|
}
|
|
880
1264
|
|
|
1265
|
+
/**
|
|
1266
|
+
* 0236 R1: the 发播客 Content for the same episode — the run's publish-prep Content (the final mp3
|
|
1267
|
+
* with the intro, `23-cover`'s PNG, title and body) plus `metadata.operation` and the run's own
|
|
1268
|
+
* `publishStatus`. No artifact is produced here: both files already exist.
|
|
1269
|
+
*
|
|
1270
|
+
* `metadata.audio` repeats the prep's `metadata.audioFile` under the key the xhs channel's 发播客 mode
|
|
1271
|
+
* reads (`xhs-pub/src/modes.ts`); `metadata.coverImage` is already the shared spelling. A run with no
|
|
1272
|
+
* publish-prep output names the missing episode drafts and carries the 图文 note alone — the 发播客
|
|
1273
|
+
* note is never invented.
|
|
1274
|
+
*/
|
|
1275
|
+
function xhsPodcastNote(prepPath: string, publishStatus: 'draft' | 'public'): Record<string, unknown> | undefined {
|
|
1276
|
+
const prep = readJson(prepPath);
|
|
1277
|
+
if (prep === null) {
|
|
1278
|
+
console.log(`publish-xhs: no publish-prep Content at ${prepPath} — the 发播客 note is skipped`);
|
|
1279
|
+
return undefined;
|
|
1280
|
+
}
|
|
1281
|
+
const metadata = metadataOf(prep);
|
|
1282
|
+
return {
|
|
1283
|
+
...prep,
|
|
1284
|
+
metadata: {
|
|
1285
|
+
...metadata,
|
|
1286
|
+
operation: '发播客',
|
|
1287
|
+
audio: metadata.audioFile,
|
|
1288
|
+
publishStatus,
|
|
1289
|
+
},
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
|
|
881
1293
|
/**
|
|
882
1294
|
* WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
|
|
883
1295
|
* draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
|
|
@@ -1290,9 +1702,8 @@ export function dailyShowNotes(args: string[]): void {
|
|
|
1290
1702
|
PODCAST_PUB_RUN_DATE: runDate,
|
|
1291
1703
|
PODCAST_PUB_BRIEF_MODE: 'true',
|
|
1292
1704
|
PODCAST_PUB_ARTICLE_POST_PATH: postPath,
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
PODCAST_PUB_FOOTER_NOTE: '本期文字由 AI 辅助整理,音频为 AI 合成配音。',
|
|
1705
|
+
// 2026-10-08: the column settings carry the AI-production note. Do not append the
|
|
1706
|
+
// stock closer to show notes (or the article, or the spoken sign-off).
|
|
1296
1707
|
...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
|
|
1297
1708
|
},
|
|
1298
1709
|
});
|
|
@@ -1310,7 +1721,10 @@ export function dailyShowNotes(args: string[]): void {
|
|
|
1310
1721
|
*/
|
|
1311
1722
|
export function dailyTranslateLocalize(args: string[]): void {
|
|
1312
1723
|
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
1313
|
-
|
|
1724
|
+
// Same seam as show-notes (task 0137): `_17_publish_result.json` is written later by
|
|
1725
|
+
// publish-classify, which depends on translate-sync. Reading it here always saw no zh
|
|
1726
|
+
// path, so en/ja were never scaffolded (2026-10-08 dogfood).
|
|
1727
|
+
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
1314
1728
|
const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
|
|
1315
1729
|
timed(workDir, 'translate-localize', () => {
|
|
1316
1730
|
const postPath = firstPostPath(readJson(result), 'surfdash-pub');
|
|
@@ -1408,12 +1822,63 @@ export function dailyWrapDocs(args: string[]): void {
|
|
|
1408
1822
|
});
|
|
1409
1823
|
}
|
|
1410
1824
|
|
|
1825
|
+
/** 0235 R4: `note_scope`'s one opt-in value. Anything else — absent included — is today's path. */
|
|
1826
|
+
const XHS_NOTE_SCOPE_EPISODE = 'episode';
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* 0235 R2: `①` — the circled digits ①…⑳ are contiguous from here, and the episode note attaches
|
|
1830
|
+
* `XHS_MAX_CARDS` cards plus at most one cover, so no bare number can ever be needed.
|
|
1831
|
+
*/
|
|
1832
|
+
const CIRCLED_DIGIT_ONE = 0x2460;
|
|
1833
|
+
|
|
1834
|
+
/** The attach-order marker of line `position` (0-based). */
|
|
1835
|
+
const markerFor = (position: number): string => String.fromCodePoint(CIRCLED_DIGIT_ONE + position);
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* 0235 R2: split the agent's note body into its lead paragraph and its trailing hashtag run. The
|
|
1839
|
+
* prompt asks for 3-5 hashtags at the end, so the run is read off the end of the body; a body
|
|
1840
|
+
* without hashtags is entirely lead.
|
|
1841
|
+
*/
|
|
1842
|
+
function splitNoteBody(body: string): { lead: string; hashtags: string } {
|
|
1843
|
+
const trimmed = body.trim();
|
|
1844
|
+
const match = /(?:^|\s)(#\S+(?:\s+#\S+)*)\s*$/.exec(trimmed);
|
|
1845
|
+
if (match === null) return { lead: trimmed, hashtags: '' };
|
|
1846
|
+
return { lead: trimmed.slice(0, match.index).trimEnd(), hashtags: match[1] ?? '' };
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* 0235 R2: the episode note body — the agent's lead paragraph, then one numbered line per attached
|
|
1851
|
+
* image in attach order, then the plan's hashtags. `cap` is the console's own note limit
|
|
1852
|
+
* (`XHS_NOTE_BODY_MAX`): only the lead is trimmed to fit, so an image never loses its line. A body
|
|
1853
|
+
* that overflows even without a lead fails loud rather than shipping a note the editor would cut.
|
|
1854
|
+
*/
|
|
1855
|
+
function episodeNoteBody(body: string, lines: string[], cap: number): string {
|
|
1856
|
+
const { lead, hashtags } = splitNoteBody(body);
|
|
1857
|
+
const tail = [lines.join('\n'), hashtags].filter((part) => part !== '').join('\n\n');
|
|
1858
|
+
// `\n\n` joins the lead to the tail; a lead trimmed away drops the separator with it.
|
|
1859
|
+
const budget = cap - tail.length - (lead === '' ? 0 : 2);
|
|
1860
|
+
const head = budget >= lead.length ? lead : lead.slice(0, Math.max(budget, 0)).trimEnd();
|
|
1861
|
+
const note = head === '' ? tail : `${head}\n\n${tail}`;
|
|
1862
|
+
if (note.length > cap) {
|
|
1863
|
+
failWith(
|
|
1864
|
+
1,
|
|
1865
|
+
`xhs render: episode note body is ${note.length} chars for ${lines.length} image lines — the cap is ${cap} and a line is never dropped`,
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
return note;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1411
1871
|
/**
|
|
1412
1872
|
* E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
|
|
1413
1873
|
* ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
|
|
1414
1874
|
* are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
|
|
1415
1875
|
* is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
|
|
1416
1876
|
* a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
|
|
1877
|
+
*
|
|
1878
|
+
* `note_scope` (`episode`, 0235) opts into the episode note for voice2: the packaged set then also
|
|
1879
|
+
* attaches the episode cover last, and the body is rebuilt as the lead paragraph plus one numbered
|
|
1880
|
+
* line per attached image. Absent or any other value: the lead-item series and the agent's body are
|
|
1881
|
+
* packaged verbatim (voice1's contract).
|
|
1417
1882
|
*/
|
|
1418
1883
|
export function dailyXhsRender(args: string[]): void {
|
|
1419
1884
|
const [
|
|
@@ -1425,9 +1890,12 @@ export function dailyXhsRender(args: string[]): void {
|
|
|
1425
1890
|
provider = '',
|
|
1426
1891
|
model = '',
|
|
1427
1892
|
fallback = '',
|
|
1893
|
+
noteScope = '',
|
|
1428
1894
|
] = args;
|
|
1429
1895
|
const count = Number.parseInt(countArg, 10);
|
|
1896
|
+
const episode = noteScope === XHS_NOTE_SCOPE_EPISODE;
|
|
1430
1897
|
const planPath = join(workDir, '25-xhs', `${runDate}_18_xhs_plan.json`);
|
|
1898
|
+
const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
1431
1899
|
const cardsDir = join(workDir, '25-xhs', 'cards');
|
|
1432
1900
|
const manifestPath = join(workDir, '25-xhs', 'cards.json');
|
|
1433
1901
|
const contentPath = join(workDir, '25-xhs', 'content.json');
|
|
@@ -1439,6 +1907,13 @@ export function dailyXhsRender(args: string[]): void {
|
|
|
1439
1907
|
sted('mkdir', cardsDir, () => mkdirSync(cardsDir, { recursive: true }));
|
|
1440
1908
|
const images: Record<string, unknown>[] = [];
|
|
1441
1909
|
const cards: Record<string, unknown>[] = [];
|
|
1910
|
+
// 0235 R2: the numbered lines are a pure function of the plan and the cover, in attach order
|
|
1911
|
+
// (cards in plan order, the cover last), so the note — and its cap — is settled before the
|
|
1912
|
+
// first card is billed: an over-cap tail fails here with nothing rendered.
|
|
1913
|
+
const withCover = episode && isFile(coverPath);
|
|
1914
|
+
const lines = plan.cards.map((card, i) => `${markerFor(i)} ${card.copy}:${card.points[0] ?? ''}`);
|
|
1915
|
+
if (withCover) lines.push(`${markerFor(lines.length)} 每日AI乱炖 ${runDate} 本期封面`);
|
|
1916
|
+
const body = episode ? episodeNoteBody(plan.body, lines, XHS_BODY_CAP) : plan.body;
|
|
1442
1917
|
for (const card of plan.cards) {
|
|
1443
1918
|
const out = join(cardsDir, `card-${card.index}.json`);
|
|
1444
1919
|
const promptPath = join(cardsDir, `card-${card.index}.prompt.txt`);
|
|
@@ -1514,19 +1989,34 @@ export function dailyXhsRender(args: string[]): void {
|
|
|
1514
1989
|
promptPath,
|
|
1515
1990
|
imagePath,
|
|
1516
1991
|
illustrationPath,
|
|
1992
|
+
...(card.itemId === undefined ? {} : { itemId: card.itemId }),
|
|
1517
1993
|
status: 'rendered',
|
|
1518
1994
|
});
|
|
1519
1995
|
}
|
|
1996
|
+
// 0235 R1: the episode cover closes the set — it is ultrawide, and image 1 (card 01) is what
|
|
1997
|
+
// sets the note's grid ratio. It is `daily-cover`'s own artifact (no new image kind), and
|
|
1998
|
+
// the set is ≤ XHS_MAX_CARDS + 1 = 11, inside `xhs-pub`'s XHS_IMAGE_MAX (18).
|
|
1999
|
+
if (withCover) {
|
|
2000
|
+
images.push({ path: resolve(coverPath) });
|
|
2001
|
+
} else if (episode) {
|
|
2002
|
+
console.log(
|
|
2003
|
+
`xhs render: no episode cover at ${coverPath} — the note carries the ${cards.length} cards only`,
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
1520
2006
|
sted('write', manifestPath, () => writeJsonText(manifestPath, { source: planPath, cards }));
|
|
1521
2007
|
sted('write', contentPath, () =>
|
|
1522
2008
|
writeJsonText(contentPath, {
|
|
1523
2009
|
title: plan.title,
|
|
1524
|
-
body
|
|
2010
|
+
body,
|
|
1525
2011
|
format: 'text',
|
|
1526
2012
|
metadata: { images, publishStatus: 'draft' },
|
|
1527
2013
|
}),
|
|
1528
2014
|
);
|
|
1529
|
-
console.log(
|
|
2015
|
+
console.log(
|
|
2016
|
+
episode
|
|
2017
|
+
? `xhs render: ${images.length} images packaged in image order (${contentPath})`
|
|
2018
|
+
: `xhs render: ${cards.length} cards packaged in card order (${contentPath})`,
|
|
2019
|
+
);
|
|
1530
2020
|
});
|
|
1531
2021
|
}
|
|
1532
2022
|
|