@gobing-ai/knowledge-kit 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +4 -0
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +4 -0
  5. package/plugins/generations/core-facts-gen/dist/index.js +4 -0
  6. package/plugins/generations/daily-article-gen/dist/index.js +4 -0
  7. package/plugins/generations/dailynews-gen/dist/index.js +4 -0
  8. package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
  9. package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
  10. package/plugins/generations/gemini-tts/dist/index.js +4 -0
  11. package/plugins/generations/image-gen/dist/index.js +4 -0
  12. package/plugins/generations/news-report-gen/dist/index.js +4 -0
  13. package/plugins/generations/voice-gen/dist/index.js +4 -0
  14. package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
  15. package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
  16. package/plugins/ingestions/horizon-ingest/dist/index.js +4 -0
  17. package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
  18. package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
  19. package/plugins/ingestions/web-search/dist/index.js +4 -0
  20. package/plugins/kk/plugin.json +1 -1
  21. package/plugins/kk/scripts/daily-stages.ts +86 -1
  22. package/plugins/kk/scripts/sentences.ts +134 -0
  23. package/plugins/kk/scripts/series-names.ts +124 -0
  24. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +37 -3
  25. package/plugins/kk/workflows/kk-daily-safety.yaml +50 -5
  26. package/plugins/publishings/bilibili-pub/dist/index.js +4 -0
  27. package/plugins/publishings/cnblogs-pub/dist/index.js +4 -0
  28. package/plugins/publishings/csdn-pub/dist/index.js +4 -0
  29. package/plugins/publishings/csdn-pub/dist/profile-check.js +4 -0
  30. package/plugins/publishings/emdash-pub/dist/index.js +4 -0
  31. package/plugins/publishings/infoq-pub/dist/index.js +4 -0
  32. package/plugins/publishings/infoq-pub/dist/profile-check.js +4 -0
  33. package/plugins/publishings/jike-pub/dist/index.js +4 -0
  34. package/plugins/publishings/jike-pub/dist/profile-check.js +4 -0
  35. package/plugins/publishings/juejin-pub/dist/index.js +4 -0
  36. package/plugins/publishings/juejin-pub/dist/profile-check.js +4 -0
  37. package/plugins/publishings/medium-pub/dist/index.js +4 -0
  38. package/plugins/publishings/podcast-pub/dist/index.js +105 -8
  39. package/plugins/publishings/podcast-pub/src/seo.ts +2 -4
  40. package/plugins/publishings/podcast-pub/src/show-notes.ts +3 -8
  41. package/plugins/publishings/qiita-pub/dist/index.js +4 -0
  42. package/plugins/publishings/shipinhao-pub/dist/index.js +4 -0
  43. package/plugins/publishings/sspai-pub/dist/index.js +4 -0
  44. package/plugins/publishings/substack-pub/dist/index.js +4 -0
  45. package/plugins/publishings/substack-pub/dist/profile-check.js +4 -0
  46. package/plugins/publishings/surfdash-pub/dist/index.js +247 -27
  47. package/plugins/publishings/surfdash-pub/src/digest-check.ts +218 -0
  48. package/plugins/publishings/surfdash-pub/src/index.ts +76 -29
  49. package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
  50. package/plugins/publishings/threads-pub/dist/index.js +4 -0
  51. package/plugins/publishings/wechatmp-pub/dist/index.js +4 -0
  52. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +4 -0
  53. package/plugins/publishings/weibo-pub/dist/index.js +4 -0
  54. package/plugins/publishings/weibo-pub/dist/profile-check.js +4 -0
  55. package/plugins/publishings/x-pub/dist/index.js +4 -0
  56. package/plugins/publishings/x-pub/dist/profile-check.js +4 -0
  57. package/plugins/publishings/xhs-pub/dist/index.js +4 -0
  58. package/plugins/publishings/xhs-pub/dist/profile-check.js +4 -0
  59. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +4 -0
  60. package/plugins/publishings/zenn-pub/dist/index.js +4 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Post-refine digest contract check (2026-10-24).
3
+ *
4
+ * The surfdash refine step (scripts/lib/seo-digest.ts) owns the daily-digest title, description
5
+ * and key_points (ADR-008). `requireRefined` only proves refine *ran*; this module re-verifies the
6
+ * values against the site's published contract (exact `<series> · <date>:h1、h2` title shape,
7
+ * `title | Surfing` ≤ 60 code points, description length window + terminal punctuation, no
8
+ * ellipsis, `label — sentence` key_points) so a regressed derivation fails the publish loud
9
+ * instead of shipping broken SEO metadata.
10
+ *
11
+ * Pure, line-based frontmatter parsing — no YAML dependency (same stance as
12
+ * {@link normalizeLocaleFrontmatter} and the site's own check-digest script). Handles `"..."`,
13
+ * `'...'`, plain and `>-`-folded scalars, flow arrays and ` - "..."` block lists.
14
+ */
15
+ import { SERIES_NAMES } from './patch';
16
+
17
+ /** Locales the digest series publishes in (zh primary; en/ja translated siblings). */
18
+ export type DigestLocale = keyof typeof SERIES_NAMES;
19
+
20
+ /** The tag that marks a post as one of the daily digest series (site DIGEST_TAG). */
21
+ export const DIGEST_TAG = 'sub-category:ai-daily';
22
+
23
+ /** Podcast-intro openings a digest description must not start with (case-insensitive). */
24
+ const PODCAST_INTROS = ['这里是由Robin', '咸话咸说', 'This is Robin', 'Robin がお届け'] as const;
25
+
26
+ /** Rendered `<title>` budget: title + " | Surfing" must fit (site SEO_TITLE_LIMIT). */
27
+ const SEO_TITLE_LIMIT = 60;
28
+
29
+ /** One parsed frontmatter entry: a scalar string or a block/flow list of strings. */
30
+ type FrontmatterValue = string | string[];
31
+
32
+ /** Opening `key:` line: bare key, no leading whitespace (frontmatter is never indented). */
33
+ const KEY_LINE = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/;
34
+
35
+ const escapeRe = (token: string): string => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+
37
+ /** Unquotes a YAML scalar: `"…"` via JSON (mapContent writes JSON.stringify), `'…'` with '' unescape. */
38
+ function unquote(value: string): string {
39
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
40
+ try {
41
+ return JSON.parse(value) as string;
42
+ } catch {
43
+ return value.slice(1, -1);
44
+ }
45
+ }
46
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
47
+ return value.slice(1, -1).replaceAll("''", "'");
48
+ }
49
+ return value;
50
+ }
51
+
52
+ /**
53
+ * Line-based frontmatter parse: scalars (plain/quoted/folded) and string lists (block `- item`
54
+ * or flow `[a, b]`). Unknown shapes degrade to the raw text; a missing frontmatter yields {}.
55
+ */
56
+ export function parseFrontmatterScalars(markdown: string): Map<string, FrontmatterValue> {
57
+ const out = new Map<string, FrontmatterValue>();
58
+ const lines = markdown.split(/\r?\n/);
59
+ if (lines[0]?.trim() !== '---') return out;
60
+ const end = lines.findIndex((line, i) => i > 0 && line.trim() === '---');
61
+ if (end < 0) return out;
62
+ for (let i = 1; i < end; i++) {
63
+ const line = lines[i];
64
+ if (!line) continue;
65
+ const match = line.match(KEY_LINE);
66
+ if (!match?.[1] || match[2] === undefined) continue;
67
+ const key = match[1];
68
+ const value = match[2].trim();
69
+ if (value === '>-' || value === '>' || value === '>+' || value === '|-' || value === '|') {
70
+ // Folded scalar: indented continuation lines, joined with spaces (block scalars too —
71
+ // descriptions are prose, a stray newline matters less than losing the text).
72
+ const parts: string[] = [];
73
+ let j = i + 1;
74
+ while (j < end) {
75
+ const continuation = lines[j];
76
+ if (!continuation || !/^[ \t]+\S/.test(continuation)) break;
77
+ parts.push(continuation.trim());
78
+ j++;
79
+ }
80
+ out.set(key, parts.join(' '));
81
+ i = j - 1;
82
+ continue;
83
+ }
84
+ if (value === '') {
85
+ // Block list: `key:` followed by ` - item` lines; an empty key stays ''.
86
+ const items: string[] = [];
87
+ let j = i + 1;
88
+ while (j < end) {
89
+ const itemLine = lines[j];
90
+ if (!itemLine || !/^\s*-\s+/.test(itemLine)) break;
91
+ items.push(unquote(itemLine.replace(/^\s*-\s+/, '').trim()));
92
+ j++;
93
+ }
94
+ if (items.length > 0) {
95
+ out.set(key, items);
96
+ i = j - 1;
97
+ } else {
98
+ out.set(key, '');
99
+ }
100
+ continue;
101
+ }
102
+ if (value.startsWith('[') && value.endsWith(']')) {
103
+ out.set(
104
+ key,
105
+ value
106
+ .slice(1, -1)
107
+ .split(',')
108
+ .map((item) => unquote(item.trim()))
109
+ .filter((item) => item !== ''),
110
+ );
111
+ continue;
112
+ }
113
+ out.set(key, unquote(value));
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** The scalar at `key`: a list joins with ' ' (never a shape the digest contract accepts). */
119
+ function scalar(frontmatter: Map<string, FrontmatterValue>, key: string): string | undefined {
120
+ const value = frontmatter.get(key);
121
+ if (value === undefined) return undefined;
122
+ return Array.isArray(value) ? value.join(' ') : value;
123
+ }
124
+
125
+ /** Terminal punctuation per locale: terminator, optionally followed by closing quotes/brackets. */
126
+ function endsAsSentence(text: string, locale: DigestLocale): boolean {
127
+ return locale === 'en' ? /[.!?]["')”’]*$/u.test(text.trimEnd()) : /[。!?][」』”)]*$/u.test(text.trimEnd());
128
+ }
129
+
130
+ /**
131
+ * Validates a refined post's frontmatter against the digest contract. Returns the problems
132
+ * (empty = ok); non-digest posts (no `sub-category:ai-daily` tag) return [] unchecked.
133
+ */
134
+ export function checkDigestFrontmatter(markdown: string, locale: DigestLocale): string[] {
135
+ const frontmatter = parseFrontmatterScalars(markdown);
136
+ const tags = frontmatter.get('tags');
137
+ const tagList = Array.isArray(tags) ? tags : typeof tags === 'string' && tags !== '' ? [tags] : [];
138
+ if (!tagList.some((tag) => tag.includes(DIGEST_TAG))) return [];
139
+
140
+ const problems: string[] = [];
141
+ const series = SERIES_NAMES[locale];
142
+ const titleSep = locale === 'en' ? ': ' : ':';
143
+ const listSep = locale === 'en' ? ', ' : '、';
144
+
145
+ const title = scalar(frontmatter, 'title');
146
+ if (title === undefined || title === '') {
147
+ problems.push('title: missing');
148
+ } else {
149
+ const match = title.match(
150
+ new RegExp(`^${escapeRe(series)} · (\\d{4}-\\d{2}-\\d{2})${escapeRe(titleSep)}(.+)$`),
151
+ );
152
+ if (!match) {
153
+ problems.push(
154
+ `title: expected "<series> · YYYY-MM-DD${titleSep}<h1>${listSep}<h2>" for ${series}, got "${title}"`,
155
+ );
156
+ } else {
157
+ const rest = match[2] ?? '';
158
+ const heads = locale === 'en' ? rest.split(', ') : rest.split('、');
159
+ const seps = locale === 'en' ? (rest.match(/, /g) ?? []).length : (rest.match(/、/g) ?? []).length;
160
+ if (seps !== 1 || heads.some((head) => head.trim() === '')) {
161
+ problems.push(`title: expected exactly two non-empty headlines joined "${listSep}", got "${rest}"`);
162
+ }
163
+ }
164
+ const seoTitle = `${title} | Surfing`;
165
+ const seoLength = Array.from(seoTitle).length;
166
+ if (seoLength > SEO_TITLE_LIMIT) {
167
+ problems.push(`title: "${seoTitle}" is ${seoLength} code points (max ${SEO_TITLE_LIMIT})`);
168
+ }
169
+ if (title.includes('…') || title.includes('...')) problems.push('title: contains "…" or "..."');
170
+ }
171
+
172
+ const description = scalar(frontmatter, 'description');
173
+ const [min, max] = locale === 'en' ? [120, 155] : [70, 100];
174
+ if (description === undefined || description === '') {
175
+ problems.push('description: missing');
176
+ } else {
177
+ const length = Array.from(description).length;
178
+ if (length < min || length > max) {
179
+ problems.push(`description: ${length} code points (expected ${min}–${max} for ${locale})`);
180
+ }
181
+ if (!endsAsSentence(description, locale)) {
182
+ problems.push('description: does not end with sentence punctuation');
183
+ }
184
+ const lower = description.toLowerCase();
185
+ const intro = PODCAST_INTROS.find((candidate) => lower.startsWith(candidate.toLowerCase()));
186
+ if (intro) problems.push(`description: starts with the podcast intro ("${intro}")`);
187
+ if (description.includes('…') || description.includes('...')) {
188
+ problems.push('description: contains "…" or "..."');
189
+ }
190
+ }
191
+
192
+ const points = frontmatter.get('key_points');
193
+ if (!Array.isArray(points) || points.length === 0) {
194
+ problems.push('key_points: missing');
195
+ } else {
196
+ const labelMax = locale === 'en' ? 28 : 14;
197
+ points.forEach((point, index) => {
198
+ const sep = point.indexOf(' — ');
199
+ if (sep < 0) {
200
+ problems.push(`key_points[${index}]: missing " — " separator`);
201
+ return;
202
+ }
203
+ const label = point.slice(0, sep);
204
+ const text = point.slice(sep + 3);
205
+ const labelLength = Array.from(label).length;
206
+ if (labelLength === 0 || labelLength > labelMax) {
207
+ problems.push(`key_points[${index}]: label "${label}" is ${labelLength} code points (max ${labelMax})`);
208
+ }
209
+ if (!endsAsSentence(text, locale)) {
210
+ problems.push(`key_points[${index}]: text does not end with sentence punctuation`);
211
+ }
212
+ if (point.includes('…') || point.includes('...')) {
213
+ problems.push(`key_points[${index}]: contains "…" or "..."`);
214
+ }
215
+ });
216
+ }
217
+ return problems;
218
+ }
@@ -6,8 +6,12 @@ import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-
6
6
  import type { PublishTransport, PublishTransportPayload } from '@gobing-ai/publish-harness';
7
7
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
8
8
  import { echoError } from '@gobing-ai/ts-utils';
9
+ // The plugin's public digest-contract surface (2026-10-24).
10
+ import { checkDigestFrontmatter, DIGEST_TAG, type DigestLocale } from './digest-check';
9
11
  import { localeIndexPath, PATCH_LOCALES, type PatchPostOutcome, patchPostFiles } from './patch';
10
12
 
13
+ export { checkDigestFrontmatter, DIGEST_TAG, type DigestLocale } from './digest-check';
14
+
11
15
  /** Result target name for this plugin. */
12
16
  const TARGET = 'surfdash';
13
17
 
@@ -76,28 +80,14 @@ export function normalizeTag(raw: string): string {
76
80
  }
77
81
 
78
82
  /**
79
- * Maps Content object into Surfing frontmatter and body per the publish spec.
83
+ * The content's tag sources (metadata.tags, keywords, subCategory) as normalized frontmatter tags.
84
+ *
85
+ * `slugify` drops non-ASCII, so a Chinese tag (`硬件发布`) collapsed to '' and shipped as an empty
86
+ * YAML tag — the site renders it as a blank chip (seen on 20261011-ai-2026-10-11). Case variants
87
+ * also collapsed to the same slug twice (`AI` + `ai`), so the set dedupes. `normalizeTag` preserves
88
+ * `sub-category:<slug>` colon prefixes so sub-category series tags survive into frontmatter.
80
89
  */
81
- export function mapContentToSurfingMarkdown(content: Content, options: MapContentOptions = {}): string {
82
- let title = content.title;
83
- if (!title) {
84
- const match = content.body.match(/^#\s+(.+)$/m);
85
- title = match?.[1] ? match[1].trim() : 'Untitled Post';
86
- }
87
-
88
- let description: string | undefined;
89
- if (typeof content.metadata?.description === 'string') {
90
- description = content.metadata.description;
91
- } else {
92
- const paragraphs = content.body
93
- .split(/\n\s*\n/)
94
- .map((p) => p.replace(/^#+\s+.*$/, '').trim())
95
- .filter((p) => p.length > 0);
96
- if (paragraphs.length > 0) {
97
- description = paragraphs[0];
98
- }
99
- }
100
-
90
+ export function contentTags(content: Content): string[] {
101
91
  const rawTags: unknown[] = Array.isArray(content.metadata?.tags)
102
92
  ? [...content.metadata.tags]
103
93
  : Array.isArray(content.metadata?.keywords)
@@ -107,17 +97,33 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
107
97
  const subRaw = content.metadata.subCategory.trim();
108
98
  rawTags.push(/^sub-category\s*:/i.test(subRaw) ? subRaw : `sub-category:${subRaw}`);
109
99
  }
110
- // `slugify` drops non-ASCII, so a Chinese tag (`硬件发布`) collapsed to '' and shipped as an empty
111
- // YAML tag — the site renders it as a blank chip (seen on 20261011-ai-2026-10-11). Case variants
112
- // also collapsed to the same slug twice (`AI` + `ai`), so the set dedupes. `normalizeTag` preserves
113
- // `sub-category:<slug>` colon prefixes so sub-category series tags survive into frontmatter.
114
- const tags = [
100
+ return [
115
101
  ...new Set(
116
102
  rawTags
117
103
  .filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
118
104
  .map((t: string) => normalizeTag(t)),
119
105
  ),
120
106
  ].filter((tag) => tag.length > 0);
107
+ }
108
+
109
+ /**
110
+ * Maps Content object into Surfing frontmatter and body per the publish spec.
111
+ */
112
+ export function mapContentToSurfingMarkdown(content: Content, options: MapContentOptions = {}): string {
113
+ let title = content.title;
114
+ if (!title) {
115
+ const match = content.body.match(/^#\s+(.+)$/m);
116
+ title = match?.[1] ? match[1].trim() : 'Untitled Post';
117
+ }
118
+
119
+ // ADR-008: the site's refine step owns the digest/SEO description; the generator's
120
+ // first-paragraph fallback shipped the raw lede as SEO metadata before refine existed.
121
+ const description =
122
+ typeof content.metadata?.description === 'string' && content.metadata.description.length > 0
123
+ ? content.metadata.description
124
+ : '';
125
+
126
+ const tags = contentTags(content);
121
127
 
122
128
  const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
123
129
  const draft = typeof content.metadata?.published === 'boolean' ? !content.metadata.published : false;
@@ -133,7 +139,7 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
133
139
  // supplies identity.
134
140
  const frontmatterObj: Record<string, unknown> = {
135
141
  title,
136
- description: description ?? '',
142
+ description,
137
143
  tags,
138
144
  publishDate: isoDate,
139
145
  draft,
@@ -144,6 +150,11 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
144
150
  return `${yamlLines.join('\n')}\n\n${content.body.trim()}\n`;
145
151
  }
146
152
 
153
+ /** True when the content's tag sources (contentTags) mark it as a daily digest post. */
154
+ function isDigestContent(content: Content): boolean {
155
+ return contentTags(content).some((tag) => tag.includes(DIGEST_TAG));
156
+ }
157
+
147
158
  /** `Result` failure helper: ok false + error (podcast-pub pattern). */
148
159
  function fail(error: string): Result {
149
160
  return ResultSchema.parse({ ok: false, target: TARGET, error });
@@ -250,6 +261,7 @@ async function spawnCli(cmd: string[], cwd: string): Promise<CliSpawnResult> {
250
261
  cwd,
251
262
  stdout: 'pipe',
252
263
  stderr: 'pipe',
264
+ env: process.env,
253
265
  });
254
266
  const [exitCode, stdout, stderr] = await Promise.all([
255
267
  proc.exited,
@@ -315,6 +327,21 @@ export function requireRefined(filePath: string): string | undefined {
315
327
  return `surfdash refine step did not run for ${filePath} (no \`refined: true\`): the publishing pipeline is broken — the post would ship with generator-supplied SEO metadata`;
316
328
  }
317
329
 
330
+ /**
331
+ * Post-refine digest contract gate (2026-10-24): `requireRefined` proves refine ran; this proves
332
+ * the refined values still satisfy the digest contract. Returns the fail-loud error text, or
333
+ * undefined when ok / downgraded. `SURFDASH_DIGEST_CHECK=warn` echoes each problem and continues.
334
+ */
335
+ export function digestCheckGate(filePath: string, locale: DigestLocale): string | undefined {
336
+ const problems = checkDigestFrontmatter(readFileSync(filePath, 'utf-8'), locale);
337
+ if (problems.length === 0) return undefined;
338
+ if (process.env.SURFDASH_DIGEST_CHECK === 'warn') {
339
+ for (const problem of problems) echoError(`digest check (warn): ${problem}`);
340
+ return undefined;
341
+ }
342
+ return `digest frontmatter check failed: ${problems.join('; ')}`;
343
+ }
344
+
318
345
  /** Outcome of {@link pushPostGit} — the union keeps fail-loud errors typed at the call site. */
319
346
  export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: string };
320
347
 
@@ -563,6 +590,13 @@ async function syncPost(options: SurfdashPubOptions): Promise<Result> {
563
590
  `publish-orchestrator sync failed (exit ${spawned.exitCode}): ${withEmdashTokenHint(cliErrorText(spawned))}`,
564
591
  );
565
592
  }
593
+ // Digest contract (2026-10-24): verify the synced post's frontmatter for its own locale;
594
+ // paths outside content/posts/articles/<zh|en|ja>/ carry no digest series to check.
595
+ const localeInPath = postPath.postPath.match(/[\\/]content[\\/]posts[\\/]articles[\\/](zh|en|ja)[\\/]/);
596
+ if (localeInPath) {
597
+ const digestProblem = digestCheckGate(postPath.postPath, localeInPath[1] as DigestLocale);
598
+ if (digestProblem) return fail(digestProblem);
599
+ }
566
600
  return ResultSchema.parse({
567
601
  ok: true,
568
602
  target: TARGET,
@@ -700,16 +734,26 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
700
734
  return fail(`post path escapes ${articlesRoot}`);
701
735
  }
702
736
  const coverPath = payload.content.metadata?.coverImage;
703
- if (typeof coverPath === 'string' && existsSync(coverPath) && !COVER_EXTS.has(extname(coverPath).toLowerCase())) {
737
+ // R4 + 2026-10-24: a coverImage that does not exist must fail before anything is written —
738
+ // the old silent skip published a coverless post while the run believed it had one.
739
+ if (typeof coverPath === 'string' && !existsSync(coverPath)) {
740
+ return fail(`metadata.coverImage not found: ${coverPath}`);
741
+ }
742
+ if (typeof coverPath === 'string' && !COVER_EXTS.has(extname(coverPath).toLowerCase())) {
704
743
  return fail(`metadata.coverImage must be one of ${[...COVER_EXTS].join(', ')}`);
705
744
  }
745
+ // Daily digest posts are the series' public face: a missing cover ships a blank OG card.
746
+ // The check uses the same tag sources as mapContentToSurfingMarkdown (contentTags).
747
+ if (isDigestContent(payload.content) && typeof coverPath !== 'string') {
748
+ return fail('digest post has no cover image (metadata.coverImage)');
749
+ }
706
750
 
707
751
  await createNodeFileSystem().ensureDir(postDir);
708
752
 
709
753
  // Cover image: metadata.coverImage (absolute local path) is copied into the post's
710
754
  // assets dir and referenced relatively — the surfdash PostSchema accepts ./assets/<file>.
711
755
  let imageFrontmatter: string | undefined;
712
- if (typeof coverPath === 'string' && existsSync(coverPath)) {
756
+ if (typeof coverPath === 'string') {
713
757
  const assetsDir = join(postDir, 'assets');
714
758
  await createNodeFileSystem().ensureDir(assetsDir);
715
759
  const coverName = `cover${extname(coverPath).toLowerCase()}`;
@@ -735,6 +779,9 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
735
779
  // Contract check (ADR-008): the site, not this plugin, owns SEO metadata.
736
780
  const notRefined = requireRefined(filePath);
737
781
  if (notRefined) return fail(notRefined);
782
+ // Digest contract (2026-10-24): the refined values themselves must hold, before any push.
783
+ const digestProblem = digestCheckGate(filePath, locale as DigestLocale);
784
+ if (digestProblem) return fail(digestProblem);
738
785
  const refined = readRefinedSnapshot(filePath);
739
786
 
740
787
  // Deploy seam: commit + push the post so deploy.yml builds it, then wait for
@@ -60,13 +60,23 @@ export function localeIndexPath(postPath: string, locale: PatchLocale): string |
60
60
 
61
61
  /**
62
62
  * The podcast cross-link line per locale (task 0189 R4: the column's episode-title pattern, not the
63
- * retired news-bulletin name).
63
+ * retired news-bulletin name). The series names are fixed per-locale product identity (2026-10-23:
64
+ * the translate agent had been inventing them) and must agree with the pipeline's single table in
65
+ * plugins/kk/scripts/series-names.ts — the two packages cannot import each other, so tests assert
66
+ * the agreement. digest-check.ts imports this table as the title-contract series names.
64
67
  */
68
+ export const SERIES_NAMES: Record<PatchLocale, string> = {
69
+ zh: '每日AI乱炖',
70
+ en: 'Daily AI Stew',
71
+ ja: 'デイリーAIシチュー',
72
+ };
73
+
65
74
  export function crossLinkFor(locale: PatchLocale, dateIso: string, showNotesUrl: string): string {
75
+ const series = SERIES_NAMES[locale];
66
76
  const links: Record<PatchLocale, string> = {
67
- zh: `🎧 本期已同步制作为播客节目,[点击收听《每日AI乱炖 · ${dateIso}》](${showNotesUrl})。`,
68
- en: `🎧 This episode is also available as a podcast: [listen to 每日AI乱炖 · ${dateIso}](${showNotesUrl}).`,
69
- ja: `🎧 本エピソードはポッドキャストでも配信中:[每日AI乱炖 · ${dateIso} を聴く](${showNotesUrl})。`,
77
+ zh: `🎧 本期已同步制作为播客节目,[点击收听《${series} · ${dateIso}》](${showNotesUrl})。`,
78
+ en: `🎧 This episode is also available as a podcast: [listen to ${series} · ${dateIso}](${showNotesUrl}).`,
79
+ ja: `🎧 本エピソードはポッドキャストでも配信中:[${series} · ${dateIso} を聴く](${showNotesUrl})。`,
70
80
  };
71
81
  return links[locale];
72
82
  }
@@ -118,11 +128,23 @@ export async function patchPostFiles(request: PatchPostRequest): Promise<PatchPo
118
128
  const end = frontmatterEnd(md);
119
129
  const fm = end < 0 ? md : md.slice(0, end);
120
130
  if (request.coverUrl !== '') {
131
+ // A relative or http:// cover would ship an unservable OG card — the deployed worker
132
+ // only serves absolute https URLs (prod feedback 2026-09-29).
133
+ if (!/^https:\/\//.test(request.coverUrl)) {
134
+ throw new Error(`coverUrl must be an absolute https:// URL, got: ${request.coverUrl}`);
135
+ }
121
136
  md = upsertFrontmatterKey(md, 'image', request.coverUrl);
122
137
  md = upsertFrontmatterKey(md, 'og_image', request.coverUrl);
123
138
  images[locale] = request.coverUrl;
124
- } else if (!/^image:/m.test(fm) && (await fs.exists(request.localCover))) {
139
+ } else if (!/^image:/m.test(fm)) {
125
140
  // Fallback for a run without a podcast cover: ship the rendered cover with the post.
141
+ // A locale with no cover anywhere must fail naming the locale, not silently skip —
142
+ // the CMS card would carry no servable image at all.
143
+ if (!(await fs.exists(request.localCover))) {
144
+ throw new Error(
145
+ `${locale} post has no cover: coverUrl is empty, the frontmatter has no image key, and localCover does not exist (${request.localCover})`,
146
+ );
147
+ }
126
148
  const asset = join(dir, 'assets', 'cover.png');
127
149
  await fs.ensureDir(dirname(asset));
128
150
  await fs.copy(request.localCover, asset);
@@ -131,8 +153,23 @@ export async function patchPostFiles(request: PatchPostRequest): Promise<PatchPo
131
153
  md = upsertFrontmatterKey(md, 'og_image', './assets/cover.png');
132
154
  images[locale] = './assets/cover.png';
133
155
  }
134
- if (request.showNotesUrl !== '' && !md.includes(request.showNotesUrl)) {
135
- md = `${md.replace(/\s*$/, '')}\n\n${crossLinkFor(locale, request.dateIso, request.showNotesUrl)}\n`;
156
+ if (request.showNotesUrl !== '') {
157
+ // 2026-10-23: a previous run may have left a 🎧 cross-link line carrying a retired
158
+ // series name — refresh that line in place instead of keeping it forever (the URL it
159
+ // carries made a fresh append a no-op). A non-cross-link line mentioning the URL is
160
+ // left alone; the fresh link is only appended when no line carries the URL at all.
161
+ const link = crossLinkFor(locale, request.dateIso, request.showNotesUrl);
162
+ const lines = md.split('\n');
163
+ const carriesUrl = lines.some((line) => line.includes(request.showNotesUrl));
164
+ if (!carriesUrl) {
165
+ md = `${md.replace(/\s*$/, '')}\n\n${link}\n`;
166
+ } else {
167
+ md = lines
168
+ .map((line) =>
169
+ line.includes(request.showNotesUrl) && line.trimStart().startsWith('🎧') ? link : line,
170
+ )
171
+ .join('\n');
172
+ }
136
173
  }
137
174
  if (md !== before) {
138
175
  await fs.writeFile(indexMd, md);
@@ -12254,6 +12254,10 @@ init_config();
12254
12254
 
12255
12255
  // ../../packages/kk-core/src/render-md.ts
12256
12256
  if (false) {}
12257
+ // ../../packages/kk-core/src/sentences.ts
12258
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
12259
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
12260
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
12257
12261
  // ../../packages/publish-harness/src/index.ts
12258
12262
  class FakeTransportCore {
12259
12263
  callLogs = [];
@@ -20162,6 +20162,10 @@ init_runtime_node_bun();
20162
20162
 
20163
20163
  // ../../packages/kk-core/src/render-md.ts
20164
20164
  if (false) {}
20165
+ // ../../packages/kk-core/src/sentences.ts
20166
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
20167
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
20168
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
20165
20169
  // ../../packages/publish-harness/src/index.ts
20166
20170
  import { homedir } from "os";
20167
20171
  import { join, resolve } from "path";
@@ -20682,6 +20682,10 @@ var PluginManifestSchema = PluginManifestSchemaBase.superRefine((manifest, ctx)
20682
20682
  });
20683
20683
  // ../../packages/kk-core/src/render-md.ts
20684
20684
  if (false) {}
20685
+ // ../../packages/kk-core/src/sentences.ts
20686
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
20687
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
20688
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
20685
20689
  // ../../plugins/publishings/wechatmp-pub/src/cgi.ts
20686
20690
  var MP_ORIGIN = "https://mp.weixin.qq.com";
20687
20691
  var FILETRANSFER_URL = `${MP_ORIGIN}/cgi-bin/filetransfer`;
@@ -20163,6 +20163,10 @@ init_runtime_node_bun();
20163
20163
 
20164
20164
  // ../../packages/kk-core/src/render-md.ts
20165
20165
  if (false) {}
20166
+ // ../../packages/kk-core/src/sentences.ts
20167
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
20168
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
20169
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
20166
20170
  // ../../packages/publish-harness/src/index.ts
20167
20171
  import { homedir } from "os";
20168
20172
  import { join, resolve } from "path";
@@ -20969,6 +20969,10 @@ var PluginManifestSchema = PluginManifestSchemaBase.superRefine((manifest, ctx)
20969
20969
  });
20970
20970
  // ../../packages/kk-core/src/render-md.ts
20971
20971
  if (false) {}
20972
+ // ../../packages/kk-core/src/sentences.ts
20973
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
20974
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
20975
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
20972
20976
  // ../../plugins/publishings/weibo-pub/src/index.ts
20973
20977
  var TARGET = "weibo";
20974
20978
  var WEIBO_HOME_URL = "https://weibo.com";
@@ -20164,6 +20164,10 @@ init_runtime_node_bun();
20164
20164
 
20165
20165
  // ../../packages/kk-core/src/render-md.ts
20166
20166
  if (false) {}
20167
+ // ../../packages/kk-core/src/sentences.ts
20168
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
20169
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
20170
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
20167
20171
  // ../../packages/publish-harness/src/index.ts
20168
20172
  import { homedir } from "os";
20169
20173
  import { join, resolve } from "path";
@@ -20969,6 +20969,10 @@ var PluginManifestSchema = PluginManifestSchemaBase.superRefine((manifest, ctx)
20969
20969
  });
20970
20970
  // ../../packages/kk-core/src/render-md.ts
20971
20971
  if (false) {}
20972
+ // ../../packages/kk-core/src/sentences.ts
20973
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
20974
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
20975
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
20972
20976
  // ../../plugins/publishings/x-pub/src/index.ts
20973
20977
  var ARTICLE_CHANNEL = "x";
20974
20978
  var X_ARTICLES_URL = "https://x.com/compose/articles";
@@ -20163,6 +20163,10 @@ init_runtime_node_bun();
20163
20163
 
20164
20164
  // ../../packages/kk-core/src/render-md.ts
20165
20165
  if (false) {}
20166
+ // ../../packages/kk-core/src/sentences.ts
20167
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
20168
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
20169
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
20166
20170
  // ../../packages/publish-harness/src/index.ts
20167
20171
  import { homedir } from "os";
20168
20172
  import { join, resolve } from "path";
@@ -20943,6 +20943,10 @@ var PluginManifestSchema = PluginManifestSchemaBase.superRefine((manifest, ctx)
20943
20943
  });
20944
20944
  // ../../packages/kk-core/src/render-md.ts
20945
20945
  if (false) {}
20946
+ // ../../packages/kk-core/src/sentences.ts
20947
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
20948
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
20949
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
20946
20950
  // ../../plugins/publishings/xhs-pub/src/modes.ts
20947
20951
  var PUBLISH_URL = "https://creator.xiaohongshu.com/publish/publish";
20948
20952
 
@@ -20163,6 +20163,10 @@ init_runtime_node_bun();
20163
20163
 
20164
20164
  // ../../packages/kk-core/src/render-md.ts
20165
20165
  if (false) {}
20166
+ // ../../packages/kk-core/src/sentences.ts
20167
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
20168
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
20169
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
20166
20170
  // ../../packages/publish-harness/src/index.ts
20167
20171
  import { homedir } from "os";
20168
20172
  import { join, resolve } from "path";
@@ -12255,6 +12255,10 @@ init_config();
12255
12255
 
12256
12256
  // ../../packages/kk-core/src/render-md.ts
12257
12257
  if (false) {}
12258
+ // ../../packages/kk-core/src/sentences.ts
12259
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
12260
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
12261
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
12258
12262
  // ../../packages/publish-harness/src/index.ts
12259
12263
  class FakeTransportCore {
12260
12264
  callLogs = [];