@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +4 -0
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +4 -0
- package/plugins/generations/core-facts-gen/dist/index.js +4 -0
- package/plugins/generations/daily-article-gen/dist/index.js +4 -0
- package/plugins/generations/dailynews-gen/dist/index.js +4 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
- package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
- package/plugins/generations/gemini-tts/dist/index.js +4 -0
- package/plugins/generations/image-gen/dist/index.js +4 -0
- package/plugins/generations/news-report-gen/dist/index.js +4 -0
- package/plugins/generations/voice-gen/dist/index.js +4 -0
- package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
- package/plugins/ingestions/horizon-ingest/dist/index.js +4 -0
- package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
- package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
- package/plugins/ingestions/web-search/dist/index.js +4 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-stages.ts +86 -1
- package/plugins/kk/scripts/sentences.ts +134 -0
- package/plugins/kk/scripts/series-names.ts +124 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +37 -3
- package/plugins/kk/workflows/kk-daily-safety.yaml +50 -5
- package/plugins/publishings/bilibili-pub/dist/index.js +4 -0
- package/plugins/publishings/cnblogs-pub/dist/index.js +4 -0
- package/plugins/publishings/csdn-pub/dist/index.js +4 -0
- package/plugins/publishings/csdn-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/emdash-pub/dist/index.js +4 -0
- package/plugins/publishings/infoq-pub/dist/index.js +4 -0
- package/plugins/publishings/infoq-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/jike-pub/dist/index.js +4 -0
- package/plugins/publishings/jike-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/juejin-pub/dist/index.js +4 -0
- package/plugins/publishings/juejin-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/medium-pub/dist/index.js +4 -0
- package/plugins/publishings/podcast-pub/dist/index.js +105 -8
- package/plugins/publishings/podcast-pub/src/seo.ts +2 -4
- package/plugins/publishings/podcast-pub/src/show-notes.ts +3 -8
- package/plugins/publishings/qiita-pub/dist/index.js +4 -0
- package/plugins/publishings/shipinhao-pub/dist/index.js +4 -0
- package/plugins/publishings/sspai-pub/dist/index.js +4 -0
- package/plugins/publishings/substack-pub/dist/index.js +4 -0
- package/plugins/publishings/substack-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +247 -27
- package/plugins/publishings/surfdash-pub/src/digest-check.ts +218 -0
- package/plugins/publishings/surfdash-pub/src/index.ts +76 -29
- package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
- package/plugins/publishings/threads-pub/dist/index.js +4 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +4 -0
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/weibo-pub/dist/index.js +4 -0
- package/plugins/publishings/weibo-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/x-pub/dist/index.js +4 -0
- package/plugins/publishings/x-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/xhs-pub/dist/index.js +4 -0
- package/plugins/publishings/xhs-pub/dist/profile-check.js +4 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +4 -0
- package/plugins/publishings/zenn-pub/dist/index.js +4 -0
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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'
|
|
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: `🎧 本期已同步制作为播客节目,[
|
|
68
|
-
en: `🎧 This episode is also available as a podcast: [listen to
|
|
69
|
-
ja: `🎧 本エピソードはポッドキャストでも配信中:[
|
|
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)
|
|
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 !== ''
|
|
135
|
-
|
|
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 = [];
|