@gobing-ai/knowledge-kit 0.0.11 → 0.0.13
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/dist/index.js +451 -152
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +22167 -0
- package/plugins/generations/content-gen/plugin.json +1 -1
- package/plugins/generations/content-gen/src/storm.ts +99 -50
- package/plugins/generations/core-facts-gen/dist/index.js +22048 -0
- package/plugins/generations/core-facts-gen/package.json +17 -0
- package/plugins/generations/core-facts-gen/plugin.json +7 -0
- package/plugins/generations/core-facts-gen/src/index.ts +116 -0
- package/plugins/generations/core-facts-gen/tsconfig.json +4 -0
- package/plugins/generations/daily-article-gen/dist/index.js +22022 -0
- package/plugins/generations/daily-article-gen/package.json +17 -0
- package/plugins/generations/daily-article-gen/plugin.json +7 -0
- package/plugins/generations/daily-article-gen/src/index.ts +91 -0
- package/plugins/generations/daily-article-gen/tsconfig.json +4 -0
- package/plugins/generations/dailynews-gen/dist/index.js +22324 -0
- package/plugins/generations/dailynews-gen/plugin.json +1 -1
- package/plugins/generations/dailynews-gen/src/index.ts +11 -0
- package/plugins/generations/dailynews-gen/src/script-builder.ts +1 -1
- package/plugins/generations/episode-plan-gen/dist/index.js +22479 -0
- package/plugins/generations/episode-plan-gen/package.json +17 -0
- package/plugins/generations/episode-plan-gen/plugin.json +7 -0
- package/plugins/generations/episode-plan-gen/src/index.ts +726 -0
- package/plugins/generations/episode-plan-gen/tsconfig.json +4 -0
- package/plugins/generations/image-gen/config.example.yaml +75 -0
- package/plugins/generations/image-gen/dist/index.js +22619 -0
- package/plugins/generations/image-gen/package.json +17 -0
- package/plugins/generations/image-gen/plugin.json +7 -0
- package/plugins/generations/image-gen/presets/formats/cover.yaml +57 -0
- package/plugins/generations/image-gen/presets/formats/free.yaml +46 -0
- package/plugins/generations/image-gen/presets/formats/illustration.yaml +48 -0
- package/plugins/generations/image-gen/presets/styles/clean-webapp-ui.yaml +28 -0
- package/plugins/generations/image-gen/presets/styles/cute.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/editorial.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/fresh.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/minimalist.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/photorealistic.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/sketch.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/technical-diagram.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/vibrant.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/warm.yaml +3 -0
- package/plugins/generations/image-gen/src/bytes.ts +19 -0
- package/plugins/generations/image-gen/src/index.ts +319 -0
- package/plugins/generations/image-gen/src/job.ts +143 -0
- package/plugins/generations/image-gen/src/paths.ts +31 -0
- package/plugins/generations/image-gen/src/presets.ts +344 -0
- package/plugins/generations/image-gen/src/providers/seedream.ts +128 -0
- package/plugins/generations/image-gen/src/providers/types.ts +285 -0
- package/plugins/generations/image-gen/tsconfig.json +8 -0
- package/plugins/generations/voice-gen/dist/index.js +23027 -0
- package/plugins/generations/voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/src/index.ts +102 -11
- package/plugins/generations/voice-gen/src/qc.ts +154 -9
- package/plugins/ingestions/aihot-ingest/dist/index.js +22358 -0
- package/plugins/ingestions/aihot-ingest/plugin.json +2 -2
- package/plugins/ingestions/aihot-ingest/src/index.ts +72 -13
- package/plugins/ingestions/aihot-ingest/src/mapper.ts +1 -0
- package/plugins/ingestions/aihot-ingest/src/rss.ts +151 -0
- package/plugins/ingestions/horizon-ingest/dist/index.js +22105 -0
- package/plugins/ingestions/horizon-ingest/package.json +17 -0
- package/plugins/ingestions/horizon-ingest/plugin.json +7 -0
- package/plugins/ingestions/horizon-ingest/src/index.ts +205 -0
- package/plugins/ingestions/horizon-ingest/tsconfig.json +4 -0
- package/plugins/ingestions/karakeep-local/dist/index.js +24204 -0
- package/plugins/ingestions/karakeep-local/plugin.json +1 -1
- package/plugins/ingestions/last30days-ingest/dist/index.js +22050 -0
- package/plugins/ingestions/last30days-ingest/package.json +17 -0
- package/plugins/ingestions/last30days-ingest/plugin.json +7 -0
- package/plugins/ingestions/last30days-ingest/src/index.ts +148 -0
- package/plugins/ingestions/last30days-ingest/tsconfig.json +4 -0
- package/plugins/ingestions/web-search/dist/index.js +24379 -0
- package/plugins/ingestions/web-search/plugin.json +1 -1
- package/plugins/kk/commands/image-generate.md +15 -0
- package/plugins/kk/config.example.yaml +80 -0
- package/plugins/kk/skills/image-authoring/SKILL.md +250 -0
- package/plugins/kk/skills/image-authoring/references/illustration-positions.md +87 -0
- package/plugins/kk/skills/image-authoring/references/migrating-from-wt.md +31 -0
- package/plugins/kk/skills/image-authoring/references/providers.md +52 -0
- package/plugins/kk/skills/image-authoring/references/style-extraction.md +134 -0
- package/plugins/kk/skills/taste-unslop/SKILL.md +12 -6
- package/plugins/kk/skills/taste-unslop/references/pattern-guide.md +128 -48
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +432 -19
- package/plugins/publishings/emdash-pub/dist/index.js +22243 -0
- package/plugins/publishings/emdash-pub/plugin.json +1 -1
- package/plugins/publishings/podcast-pub/dist/index.js +22596 -0
- package/plugins/publishings/podcast-pub/package.json +17 -0
- package/plugins/publishings/podcast-pub/plugin.json +13 -0
- package/plugins/publishings/podcast-pub/src/index.ts +538 -0
- package/plugins/publishings/podcast-pub/src/map.ts +165 -0
- package/plugins/publishings/podcast-pub/src/microfeed-client.ts +196 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +132 -0
- package/plugins/publishings/podcast-pub/tsconfig.json +4 -0
- package/plugins/publishings/qiita-pub/dist/index.js +22081 -0
- package/plugins/publishings/qiita-pub/plugin.json +1 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +22233 -0
- package/plugins/publishings/surfdash-pub/plugin.json +1 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +328 -62
- package/plugins/publishings/zenn-pub/dist/index.js +22122 -0
- package/plugins/publishings/zenn-pub/plugin.json +1 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Content } from '@gobing-ai/kk-core';
|
|
2
|
+
import { renderShowNotesHtml } from './show-notes';
|
|
3
|
+
|
|
4
|
+
/** Resolved episode fields derived from a Content payload (design D6). */
|
|
5
|
+
export interface EpisodeMapping {
|
|
6
|
+
/** Episode title: `content.title`, else the first `#` heading, else validation error. */
|
|
7
|
+
title: string;
|
|
8
|
+
/** Episode show notes as sanitized HTML: `metadata.showNotes` (markdown → HTML) or `content.body` rendered. */
|
|
9
|
+
contentHtml: string;
|
|
10
|
+
/** microfeed item status; `metadata.status` when given, `published` by default. */
|
|
11
|
+
status: string | number;
|
|
12
|
+
/** Audio reference: local file (upload flow) or already-hosted URL (single create call). */
|
|
13
|
+
audio: { kind: 'file'; path: string; mimeType: string } | { kind: 'url'; url: string; mimeType: string };
|
|
14
|
+
/** Optional playback duration in seconds (`metadata.durationSeconds`). */
|
|
15
|
+
durationSeconds?: number;
|
|
16
|
+
/** Optional episode cover: absolute or remote URL (`metadata.coverImage` → microfeed `image`). */
|
|
17
|
+
imageUrl?: string;
|
|
18
|
+
/** Optional local episode cover file (`metadata.coverImage` local path → presigned image upload). */
|
|
19
|
+
imagePath?: string;
|
|
20
|
+
/** Optional publish date as epoch ms (`metadata.publishedAt` ISO string or epoch). */
|
|
21
|
+
datePublishedMs?: number;
|
|
22
|
+
/** Optional public link for the episode (`metadata.articleUrl` → microfeed `url`). */
|
|
23
|
+
link?: string;
|
|
24
|
+
/** Podcast-directory fields (`metadata.itunes`) passed through to `_microfeed`. */
|
|
25
|
+
itunes?: {
|
|
26
|
+
title?: string;
|
|
27
|
+
episodeType?: 'full' | 'trailer' | 'bonus';
|
|
28
|
+
season?: number;
|
|
29
|
+
episode?: number;
|
|
30
|
+
explicit?: boolean;
|
|
31
|
+
block?: boolean;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Extension → audio MIME defaults; `metadata.mimeType` overrides. */
|
|
36
|
+
const AUDIO_MIME_BY_EXTENSION: Record<string, string> = {
|
|
37
|
+
'.mp3': 'audio/mpeg',
|
|
38
|
+
'.wav': 'audio/wav',
|
|
39
|
+
'.m4a': 'audio/mp4',
|
|
40
|
+
'.aac': 'audio/aac',
|
|
41
|
+
'.ogg': 'audio/ogg',
|
|
42
|
+
'.oga': 'audio/ogg',
|
|
43
|
+
'.flac': 'audio/flac',
|
|
44
|
+
'.opus': 'audio/opus',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolves the audio MIME type from `metadata.mimeType` or the file extension
|
|
49
|
+
* (`.wav` → `audio/wav`); unknown extensions fall back to `application/octet-stream`.
|
|
50
|
+
* The plugin never probes or transcodes audio (design anti-pattern) — metadata
|
|
51
|
+
* carries what the caller knows.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveAudioMimeType(reference: string, mimeType?: unknown): string {
|
|
54
|
+
if (typeof mimeType === 'string' && mimeType.trim().length > 0) {
|
|
55
|
+
return mimeType.trim();
|
|
56
|
+
}
|
|
57
|
+
const dot = reference.lastIndexOf('.');
|
|
58
|
+
const extension = dot >= 0 ? reference.slice(dot).toLowerCase() : '';
|
|
59
|
+
return AUDIO_MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Maps Content + episode metadata into microfeed item fields (design D6).
|
|
64
|
+
* Validates only the plugin's own requirements — title resolvable and exactly one
|
|
65
|
+
* audio reference — before any network call (R5); microfeed's `validate` endpoint
|
|
66
|
+
* and server 400s surface everything else (R4 error path).
|
|
67
|
+
* Throws with a diagnostic when the Content is not publishable.
|
|
68
|
+
*/
|
|
69
|
+
export function mapContentToEpisode(content: Content): EpisodeMapping {
|
|
70
|
+
const metadata = content.metadata ?? {};
|
|
71
|
+
|
|
72
|
+
let title = content.title?.trim();
|
|
73
|
+
if (!title) {
|
|
74
|
+
const match = content.body.match(/^#\s+(.+)$/m);
|
|
75
|
+
title = match?.[1]?.trim();
|
|
76
|
+
}
|
|
77
|
+
if (!title) {
|
|
78
|
+
throw new Error('episode title is required: set content.title or start the body with a "# " heading');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const audioFile = typeof metadata.audioFile === 'string' ? metadata.audioFile.trim() : undefined;
|
|
82
|
+
const audioUrl = typeof metadata.audioUrl === 'string' ? metadata.audioUrl.trim() : undefined;
|
|
83
|
+
if (audioFile && audioUrl) {
|
|
84
|
+
throw new Error('ambiguous audio reference: set either metadata.audioUrl or metadata.audioFile, not both');
|
|
85
|
+
}
|
|
86
|
+
if (!audioFile && !audioUrl) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'audio reference is required: set metadata.audioUrl (hosted) or metadata.audioFile (local path)',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const duration = metadata.durationSeconds;
|
|
93
|
+
if (duration !== undefined && (typeof duration !== 'number' || !Number.isFinite(duration) || duration < 0)) {
|
|
94
|
+
throw new Error(`invalid metadata.durationSeconds: expected a nonnegative number, got ${String(duration)}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const status = metadata.status ?? 'published';
|
|
98
|
+
if (typeof status !== 'string' && typeof status !== 'number') {
|
|
99
|
+
throw new Error(`invalid metadata.status: expected a status name or number, got ${typeof status}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const showNotes = typeof metadata.showNotes === 'string' ? metadata.showNotes.trim() : undefined;
|
|
103
|
+
|
|
104
|
+
const articleUrl = typeof metadata.articleUrl === 'string' ? metadata.articleUrl.trim() : undefined;
|
|
105
|
+
const link = articleUrl && articleUrl.length > 0 ? articleUrl : undefined;
|
|
106
|
+
const coverImage = typeof metadata.coverImage === 'string' ? metadata.coverImage.trim() : undefined;
|
|
107
|
+
// microfeed keeps remote image URLs verbatim (`image` field); a local cover file
|
|
108
|
+
// goes through the presigned image upload flow (no item_id required) instead.
|
|
109
|
+
const imageUrl = coverImage?.startsWith('http') ? coverImage : undefined;
|
|
110
|
+
const imagePath = !imageUrl && coverImage && coverImage.length > 0 ? coverImage : undefined;
|
|
111
|
+
|
|
112
|
+
let datePublishedMs: number | undefined;
|
|
113
|
+
const publishedAt = metadata.publishedAt;
|
|
114
|
+
if (typeof publishedAt === 'string' && publishedAt.length > 0) {
|
|
115
|
+
const parsed = Date.parse(publishedAt);
|
|
116
|
+
if (Number.isNaN(parsed)) {
|
|
117
|
+
throw new Error(`invalid metadata.publishedAt: not an ISO date string: ${publishedAt}`);
|
|
118
|
+
}
|
|
119
|
+
datePublishedMs = parsed;
|
|
120
|
+
} else if (typeof publishedAt === 'number' && Number.isFinite(publishedAt)) {
|
|
121
|
+
datePublishedMs = publishedAt;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let itunes: EpisodeMapping['itunes'] | undefined;
|
|
125
|
+
const rawItunes = metadata.itunes;
|
|
126
|
+
if (rawItunes !== undefined && typeof rawItunes === 'object' && !Array.isArray(rawItunes)) {
|
|
127
|
+
const rec = rawItunes as Record<string, unknown>;
|
|
128
|
+
const episodeType = rec.episodeType;
|
|
129
|
+
if (episodeType !== undefined && !['full', 'trailer', 'bonus'].includes(String(episodeType))) {
|
|
130
|
+
throw new Error(`invalid metadata.itunes.episodeType: ${String(episodeType)}`);
|
|
131
|
+
}
|
|
132
|
+
itunes = {
|
|
133
|
+
...(typeof rec.title === 'string' ? { title: rec.title } : {}),
|
|
134
|
+
...(episodeType !== undefined ? { episodeType: episodeType as 'full' | 'trailer' | 'bonus' } : {}),
|
|
135
|
+
...(typeof rec.season === 'number' ? { season: rec.season } : {}),
|
|
136
|
+
...(typeof rec.episode === 'number' ? { episode: rec.episode } : {}),
|
|
137
|
+
...(typeof rec.explicit === 'boolean' ? { explicit: rec.explicit } : {}),
|
|
138
|
+
...(typeof rec.block === 'boolean' ? { block: rec.block } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
title,
|
|
144
|
+
contentHtml:
|
|
145
|
+
showNotes && showNotes.length > 0
|
|
146
|
+
? renderShowNotesHtml(showNotes)
|
|
147
|
+
: content.format === 'html'
|
|
148
|
+
? content.body
|
|
149
|
+
: renderShowNotesHtml(content.body),
|
|
150
|
+
status,
|
|
151
|
+
audio: audioFile
|
|
152
|
+
? { kind: 'file', path: audioFile, mimeType: resolveAudioMimeType(audioFile, metadata.mimeType) }
|
|
153
|
+
: {
|
|
154
|
+
kind: 'url',
|
|
155
|
+
url: audioUrl as string,
|
|
156
|
+
mimeType: resolveAudioMimeType(audioUrl as string, metadata.mimeType),
|
|
157
|
+
},
|
|
158
|
+
durationSeconds: duration as number | undefined,
|
|
159
|
+
...(imageUrl ? { imageUrl } : {}),
|
|
160
|
+
...(imagePath ? { imagePath } : {}),
|
|
161
|
+
...(datePublishedMs !== undefined ? { datePublishedMs } : {}),
|
|
162
|
+
...(link ? { link } : {}),
|
|
163
|
+
...(itunes !== undefined ? { itunes } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-authenticated microfeed `/api/v1/` REST client (design D1).
|
|
3
|
+
* Endpoints pinned to `vendors/microfeed/src/server/api/handlers.ts` +
|
|
4
|
+
* `src/shared/ApiSchemas.ts`; every 4xx/5xx or transport fault throws a
|
|
5
|
+
* diagnostic — never silent (R4).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** microfeed API responses are `apiItemInputSchema`-shaped loose objects. */
|
|
9
|
+
export type MicrofeedItemInput = Record<string, unknown>;
|
|
10
|
+
/** microfeed item output (`apiItemOutputSchema` shape). */
|
|
11
|
+
export type MicrofeedItem = Record<string, unknown>;
|
|
12
|
+
/** JSON Feed document (`apiFeedSchema` shape) returned by the feed and item-get endpoints. */
|
|
13
|
+
export interface MicrofeedFeed {
|
|
14
|
+
items?: MicrofeedItem[];
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Presigned upload target returned by `POST /api/v1/media_files/presigned_urls`. */
|
|
19
|
+
export interface PreparedUpload {
|
|
20
|
+
presignedUrl: string;
|
|
21
|
+
mediaUrl: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Timeout floor so a zero/negative `MICROFEED_TIMEOUT_MS` cannot hang forever. */
|
|
25
|
+
const MIN_TIMEOUT_MS = 1_000;
|
|
26
|
+
/** Default timeout for large audio uploads (design D4). */
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
28
|
+
|
|
29
|
+
export function resolveTimeoutMs(raw: string | undefined): number {
|
|
30
|
+
const parsed = raw === undefined ? Number.NaN : Number(raw);
|
|
31
|
+
if (!Number.isFinite(parsed) || parsed < MIN_TIMEOUT_MS) {
|
|
32
|
+
return DEFAULT_TIMEOUT_MS;
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class MicrofeedClient {
|
|
38
|
+
private readonly baseUrl: string;
|
|
39
|
+
private readonly apiKey: string;
|
|
40
|
+
private readonly timeoutMs: number;
|
|
41
|
+
|
|
42
|
+
constructor(baseUrl: string, apiKey: string, timeoutMs: number) {
|
|
43
|
+
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
44
|
+
this.apiKey = apiKey;
|
|
45
|
+
this.timeoutMs = timeoutMs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Canonical public URL fallback for an item page (`{base}/items/{id}`). */
|
|
49
|
+
public itemUrl(itemId: string): string {
|
|
50
|
+
return `${this.baseUrl}/items/${itemId}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async request(path: string, init: RequestInit & { body?: string | Uint8Array }): Promise<unknown> {
|
|
54
|
+
let response: Response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetch(`${this.baseUrl}${path}`, {
|
|
57
|
+
...init,
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
60
|
+
Origin: this.baseUrl, // Astro CSRF origin check rejects cross-site form-like methods (DELETE/PUT/POST)
|
|
61
|
+
...init.headers,
|
|
62
|
+
},
|
|
63
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
64
|
+
});
|
|
65
|
+
} catch (error: unknown) {
|
|
66
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
67
|
+
throw new Error(`microfeed transport error (${init.method ?? 'GET'} ${path}): ${reason}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const text = (await response.text()).slice(0, 1000);
|
|
72
|
+
let detail = text.trim();
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(text) as { error?: unknown };
|
|
75
|
+
if (typeof parsed.error === 'string') detail = parsed.error;
|
|
76
|
+
} catch {
|
|
77
|
+
// keep raw text as the detail
|
|
78
|
+
}
|
|
79
|
+
throw new Error(
|
|
80
|
+
`microfeed API request failed (HTTP ${response.status} ${response.statusText} ${path}): ${detail || 'Unknown error'}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
return await response.json();
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(`microfeed API returned a non-JSON response from ${path}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `POST /api/v1/items` → `{id}`; 201 with optional 24h idempotent create (design D3). */
|
|
92
|
+
public async createItem(input: MicrofeedItemInput, idempotencyKey?: string): Promise<string> {
|
|
93
|
+
const data = (await this.request('/api/v1/items', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: {
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify(input),
|
|
100
|
+
})) as { id?: unknown };
|
|
101
|
+
if (typeof data.id !== 'string' || data.id.length === 0) {
|
|
102
|
+
throw new Error('microfeed item create response is missing the item id');
|
|
103
|
+
}
|
|
104
|
+
return data.id;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `POST /api/v1/items/validate` → `{valid: true}`; 400s surface as thrown diagnostics. */
|
|
108
|
+
public async validateItem(input: MicrofeedItemInput): Promise<void> {
|
|
109
|
+
await this.request('/api/v1/items/validate', {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify(input),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `GET /api/v1/items/{itemId}` — returns the JSON Feed document limited to
|
|
118
|
+
* that item (the endpoint serves the feed shape, items[0] is the item).
|
|
119
|
+
*/
|
|
120
|
+
public async getItem(itemId: string): Promise<MicrofeedFeed> {
|
|
121
|
+
return (await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
|
|
122
|
+
method: 'GET',
|
|
123
|
+
})) as MicrofeedFeed;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `PUT /api/v1/items/{itemId}` → the updated public item (includes `url` when live). */
|
|
127
|
+
public async updateItem(itemId: string, input: MicrofeedItemInput): Promise<MicrofeedItem> {
|
|
128
|
+
return (await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
|
|
129
|
+
method: 'PUT',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify(input),
|
|
132
|
+
})) as MicrofeedItem;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `DELETE /api/v1/items/{itemId}` → `{}` (404s surface as thrown diagnostics).
|
|
137
|
+
* Sends a JSON Content-Type: Astro 7 checkOrigin 403s non-safe methods without
|
|
138
|
+
* one when no Origin header is present (task 0087).
|
|
139
|
+
*/
|
|
140
|
+
public async deleteItem(itemId: string): Promise<void> {
|
|
141
|
+
await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
|
|
142
|
+
method: 'DELETE',
|
|
143
|
+
headers: { 'Content-Type': 'application/json' },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `GET /api/v1/feed` — the full JSON Feed document. */
|
|
148
|
+
public async listItems(): Promise<MicrofeedFeed> {
|
|
149
|
+
return (await this.request('/api/v1/feed', { method: 'GET' })) as MicrofeedFeed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `POST /api/v1/media_files/presigned_urls` — audio uploads require `item_id`
|
|
154
|
+
* (handlers.ts `prepareApiMediaUpload`), so the draft item must exist first (design D2);
|
|
155
|
+
* image uploads (`category: 'image'`) presign without an item.
|
|
156
|
+
*/
|
|
157
|
+
public async prepareUpload(input: {
|
|
158
|
+
category: 'audio' | 'image';
|
|
159
|
+
full_local_file_path: string;
|
|
160
|
+
item_id?: string;
|
|
161
|
+
size?: number;
|
|
162
|
+
type?: string;
|
|
163
|
+
}): Promise<PreparedUpload> {
|
|
164
|
+
const data = (await this.request('/api/v1/media_files/presigned_urls', {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
167
|
+
body: JSON.stringify(input),
|
|
168
|
+
})) as { presigned_url?: unknown; media_url?: unknown };
|
|
169
|
+
if (typeof data.presigned_url !== 'string' || typeof data.media_url !== 'string') {
|
|
170
|
+
throw new Error('microfeed upload-preparation response is missing presigned_url/media_url');
|
|
171
|
+
}
|
|
172
|
+
return { presignedUrl: data.presigned_url, mediaUrl: data.media_url };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `PUT <presigned_url>` — raw audio bytes, same-origin. */
|
|
176
|
+
public async uploadBytes(presignedUrl: string, bytes: Uint8Array, mimeType: string): Promise<void> {
|
|
177
|
+
let response: Response;
|
|
178
|
+
try {
|
|
179
|
+
response = await fetch(presignedUrl, {
|
|
180
|
+
method: 'PUT',
|
|
181
|
+
headers: { 'Content-Type': mimeType },
|
|
182
|
+
body: bytes,
|
|
183
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
184
|
+
});
|
|
185
|
+
} catch (error: unknown) {
|
|
186
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
187
|
+
throw new Error(`microfeed transport error (PUT audio bytes): ${reason}`);
|
|
188
|
+
}
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
const text = (await response.text()).slice(0, 1000);
|
|
191
|
+
throw new Error(
|
|
192
|
+
`microfeed audio upload failed (HTTP ${response.status} ${response.statusText}): ${text.trim() || 'Unknown error'}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown show-notes → sanitized HTML for microfeed `content_html`
|
|
3
|
+
* (design D6.1; vendored-source contract: microfeed stores and re-emits
|
|
4
|
+
* `content_html` verbatim — JSON Feed, RSS `content:encoded`, and the theme
|
|
5
|
+
* template `{{{content_html}}}` unescaped-insert — and derives `content_text`
|
|
6
|
+
* by stripping tags, so the field must be HTML, never markdown).
|
|
7
|
+
*
|
|
8
|
+
* The plugin's own show-notes dialect is closed (generated by the workflow
|
|
9
|
+
* show-notes step): `#/##/###` headings, `[text](https://…)` links, `**bold**`,
|
|
10
|
+
* paragraphs. Everything else is escaped text — sanitize-by-construction: the
|
|
11
|
+
* input is escaped first, and only the tags this module emits reach the API.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Doc } from '@gobing-ai/kk-core';
|
|
15
|
+
|
|
16
|
+
/** Escapes text content and attribute values (`&`, `<`, `>`, `"`). */
|
|
17
|
+
function escapeHtml(value: string): string {
|
|
18
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderInline(escaped: string): string {
|
|
22
|
+
let out = '';
|
|
23
|
+
let rest = escaped;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const match = rest.match(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/);
|
|
26
|
+
if (!match || match.index === undefined) break;
|
|
27
|
+
out += `${rest.slice(0, match.index)}<a href="${match[2]}" target="_blank" rel="noopener">${match[1]}</a>`;
|
|
28
|
+
rest = rest.slice(match.index + match[0].length);
|
|
29
|
+
}
|
|
30
|
+
return (out + rest).replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Renders show-notes markdown to sanitized HTML.
|
|
35
|
+
* The leading `# ` heading is dropped: the episode `title` is rendered
|
|
36
|
+
* separately by the page theme, so keeping it would duplicate the H1.
|
|
37
|
+
* Input that already looks like HTML (`format: 'html'`) must be passed
|
|
38
|
+
* through untouched by the caller, not here.
|
|
39
|
+
*/
|
|
40
|
+
export function renderShowNotesHtml(markdown: string): string {
|
|
41
|
+
const blocks: string[] = [];
|
|
42
|
+
let paragraph: string[] = [];
|
|
43
|
+
|
|
44
|
+
const flush = () => {
|
|
45
|
+
if (paragraph.length > 0) {
|
|
46
|
+
blocks.push(`<p>${renderInline(escapeHtml(paragraph.join(' ')))}</p>`);
|
|
47
|
+
paragraph = [];
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
for (const rawLine of markdown.split('\n')) {
|
|
52
|
+
const line = rawLine.trim();
|
|
53
|
+
if (line.length === 0) {
|
|
54
|
+
flush();
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const heading = line.match(/^(#{1,3})\s+(.*)$/);
|
|
58
|
+
if (heading) {
|
|
59
|
+
flush();
|
|
60
|
+
const tag = heading[1]?.length === 1 ? 'h1' : heading[1]?.length === 2 ? 'h2' : 'h3';
|
|
61
|
+
if (tag === 'h1' && blocks.length === 0 && paragraph.length === 0) {
|
|
62
|
+
continue; // drop the duplicate title heading
|
|
63
|
+
}
|
|
64
|
+
blocks.push(`<${tag}>${renderInline(escapeHtml(heading[2] ?? ''))}</${tag}>`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
paragraph.push(line);
|
|
68
|
+
}
|
|
69
|
+
flush();
|
|
70
|
+
return blocks.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Options for {@link renderShowNotesMarkdown} — the workflow show-notes step's
|
|
75
|
+
* inputs (`title` = the effective episode title, `vars.title || article.title`
|
|
76
|
+
* at the call site; `runDate` = the `YYYY-MM-DD` slug prefix).
|
|
77
|
+
*/
|
|
78
|
+
export interface ShowNotesRenderOptions {
|
|
79
|
+
/** Episode heading, article-link text, and article-URL slug source. */
|
|
80
|
+
title: string;
|
|
81
|
+
/** Run date prefixing the article URL slug. */
|
|
82
|
+
runDate: string;
|
|
83
|
+
/** Article base URL; defaults to the production site (surfing.salty.vip). */
|
|
84
|
+
articleBaseUrl?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Renders the episode show-notes markdown from a plan Doc[] — plugin-owned port
|
|
89
|
+
* of the workflow's inline show-notes bun -e (design L1 R6; the workflow keeps
|
|
90
|
+
* only the orchestration call). Output dialect: `# title` heading, per-item
|
|
91
|
+
* `## N. <title>` sections capped at the first two 。!?-terminated sentences,
|
|
92
|
+
* a `[来源:<label>](<sourceUri>)` link per item, and the full-article footer.
|
|
93
|
+
* Downstream HTML rendering stays with {@link renderShowNotesHtml}.
|
|
94
|
+
*/
|
|
95
|
+
export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOptions): string {
|
|
96
|
+
const slugify = (t: string): string => {
|
|
97
|
+
const s = t
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.replace(/[^\w\s-]/g, '')
|
|
100
|
+
.trim()
|
|
101
|
+
.replace(/[\s_]+/g, '-');
|
|
102
|
+
return s.length > 0 ? s : 'post';
|
|
103
|
+
};
|
|
104
|
+
const articleUrl = `${opts.articleBaseUrl ?? 'https://surfing.salty.vip'}/zh/posts/${opts.runDate}-${slugify(opts.title)}`;
|
|
105
|
+
const sourceLabel = (d: Doc): string => {
|
|
106
|
+
const m = d.metadata ?? {};
|
|
107
|
+
if (typeof m.sourceName === 'string' && m.sourceName.trim()) return m.sourceName.trim();
|
|
108
|
+
try {
|
|
109
|
+
return new URL(d.sourceUri ?? '').hostname.replace(/^www\./, '');
|
|
110
|
+
} catch {
|
|
111
|
+
return '来源';
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const lines: string[] = [`# ${opts.title}`, ''];
|
|
115
|
+
docs.forEach((d, i) => {
|
|
116
|
+
const body = (d.body ?? '').trim();
|
|
117
|
+
const sentences = body
|
|
118
|
+
.split(/(?<=[。!?])\s*/)
|
|
119
|
+
.slice(0, 2)
|
|
120
|
+
.join('');
|
|
121
|
+
lines.push(
|
|
122
|
+
`## ${i + 1}. ${d.title ?? `条目 ${i + 1}`}`,
|
|
123
|
+
'',
|
|
124
|
+
sentences,
|
|
125
|
+
'',
|
|
126
|
+
`[来源:${sourceLabel(d)}](${d.sourceUri ?? ''})`,
|
|
127
|
+
'',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
lines.push(`详情见本期完整文章:[ ${opts.title} ](${articleUrl})`, '');
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|