@gobing-ai/knowledge-kit 0.1.8 → 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 (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.js +725 -1101
  3. package/dist/board/daily-voice/index.js +722 -101
  4. package/dist/board/itc/index.js +13 -0
  5. package/dist/index.js +1796 -102
  6. package/package.json +1 -1
  7. package/plugins/generations/content-gen/dist/index.js +4 -0
  8. package/plugins/generations/core-facts-gen/dist/index.js +4 -0
  9. package/plugins/generations/daily-article-gen/dist/index.js +4 -0
  10. package/plugins/generations/dailynews-gen/dist/index.js +4 -0
  11. package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
  12. package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
  13. package/plugins/generations/gemini-tts/dist/index.js +4 -0
  14. package/plugins/generations/image-gen/dist/index.js +4 -0
  15. package/plugins/generations/news-report-gen/dist/index.js +4 -0
  16. package/plugins/generations/voice-gen/dist/index.js +4 -0
  17. package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
  18. package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
  19. package/plugins/ingestions/horizon-ingest/dist/index.js +28 -1
  20. package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
  21. package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
  22. package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
  23. package/plugins/ingestions/web-search/dist/index.js +4 -0
  24. package/plugins/kk/config.example.yaml +8 -0
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
  27. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
  28. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
  29. package/plugins/kk/scripts/daily-stages.ts +665 -78
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
  31. package/plugins/kk/scripts/sentences.ts +134 -0
  32. package/plugins/kk/scripts/series-names.ts +124 -0
  33. package/plugins/kk/scripts/topic-boundary.json +18 -83
  34. package/plugins/kk/workflows/kk-collect.yaml +2 -0
  35. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +112 -9
  36. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
  37. package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +50 -5
  39. package/plugins/publishings/bilibili-pub/dist/index.js +4 -0
  40. package/plugins/publishings/cnblogs-pub/dist/index.js +4 -0
  41. package/plugins/publishings/csdn-pub/dist/index.js +4 -0
  42. package/plugins/publishings/csdn-pub/dist/profile-check.js +4 -0
  43. package/plugins/publishings/emdash-pub/dist/index.js +4 -0
  44. package/plugins/publishings/infoq-pub/dist/index.js +4 -0
  45. package/plugins/publishings/infoq-pub/dist/profile-check.js +4 -0
  46. package/plugins/publishings/jike-pub/dist/index.js +4 -0
  47. package/plugins/publishings/jike-pub/dist/profile-check.js +4 -0
  48. package/plugins/publishings/juejin-pub/dist/index.js +4 -0
  49. package/plugins/publishings/juejin-pub/dist/profile-check.js +4 -0
  50. package/plugins/publishings/medium-pub/dist/index.js +4 -0
  51. package/plugins/publishings/podcast-pub/README.md +65 -0
  52. package/plugins/publishings/podcast-pub/dist/index.js +1906 -374
  53. package/plugins/publishings/podcast-pub/plugin.json +1 -1
  54. package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
  55. package/plugins/publishings/podcast-pub/src/index.ts +211 -34
  56. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
  57. package/plugins/publishings/podcast-pub/src/seo.ts +1079 -0
  58. package/plugins/publishings/podcast-pub/src/show-notes.ts +74 -14
  59. package/plugins/publishings/qiita-pub/dist/index.js +4 -0
  60. package/plugins/publishings/shipinhao-pub/dist/index.js +4 -0
  61. package/plugins/publishings/sspai-pub/dist/index.js +4 -0
  62. package/plugins/publishings/substack-pub/dist/index.js +4 -0
  63. package/plugins/publishings/substack-pub/dist/profile-check.js +4 -0
  64. package/plugins/publishings/surfdash-pub/dist/index.js +313 -32
  65. package/plugins/publishings/surfdash-pub/src/digest-check.ts +218 -0
  66. package/plugins/publishings/surfdash-pub/src/index.ts +170 -39
  67. package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
  68. package/plugins/publishings/threads-pub/dist/index.js +4 -0
  69. package/plugins/publishings/wechatmp-pub/dist/index.js +4 -0
  70. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +4 -0
  71. package/plugins/publishings/weibo-pub/dist/index.js +4 -0
  72. package/plugins/publishings/weibo-pub/dist/profile-check.js +4 -0
  73. package/plugins/publishings/x-pub/dist/index.js +4 -0
  74. package/plugins/publishings/x-pub/dist/profile-check.js +4 -0
  75. package/plugins/publishings/xhs-pub/dist/index.js +4 -0
  76. package/plugins/publishings/xhs-pub/dist/profile-check.js +4 -0
  77. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +4 -0
  78. package/plugins/publishings/zenn-pub/dist/index.js +4 -0
@@ -6,8 +6,23 @@ import { type Content, ContentSchema, DocListSchema, type Result, ResultSchema }
6
6
  import { FakeFileCliTransport, type PublishTransport, type 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
+ import { runBackfill } from './backfill';
9
10
  import { type EpisodeMapping, mapContentToEpisode } from './map';
10
- import { MicrofeedClient, type MicrofeedItem, type MicrofeedItemInput, resolveTimeoutMs } from './microfeed-client';
11
+ import {
12
+ MicrofeedClient,
13
+ type MicrofeedItem,
14
+ type MicrofeedItemInput,
15
+ MicrofeedRequestError,
16
+ resolveTimeoutMs,
17
+ } from './microfeed-client';
18
+ import {
19
+ type PreparedEpisodeFields,
20
+ prepareEpisodeFields,
21
+ readImageDimensions,
22
+ resolvePodcastSeoConfig,
23
+ type SeoBlock,
24
+ warn,
25
+ } from './seo';
11
26
  import { renderShowNotesMarkdown, type ShowNotesRenderOptions } from './show-notes';
12
27
 
13
28
  /** Result target name for this plugin. */
@@ -27,7 +42,7 @@ export function publicItemUrl(item: MicrofeedItem | undefined, itemId: string, c
27
42
  const TARGET = 'podcast-pub';
28
43
 
29
44
  /** Management operations routed via `options.operation` (design D5). */
30
- const OPERATIONS = ['publish', 'show-notes', 'list', 'get', 'update', 'delete', 'validate'] as const;
45
+ const OPERATIONS = ['publish', 'show-notes', 'list', 'get', 'update', 'delete', 'validate', 'backfill-seo'] as const;
31
46
  type Operation = (typeof OPERATIONS)[number];
32
47
 
33
48
  /** Target-specific options carried on the invoke payload. */
@@ -50,6 +65,17 @@ interface PodcastPubOptions {
50
65
  audioFile?: unknown;
51
66
  coverPath?: unknown;
52
67
  durationSec?: unknown;
68
+ /** `backfill-seo` (R10). Dry-run unless `apply` is true. */
69
+ since?: unknown;
70
+ until?: unknown;
71
+ ids?: unknown;
72
+ out?: unknown;
73
+ plan?: unknown;
74
+ apply?: unknown;
75
+ retitle?: unknown;
76
+ rewriteBody?: unknown;
77
+ applySlug?: unknown;
78
+ overrides?: unknown;
53
79
  }
54
80
 
55
81
  /** Re-reads env each call so tests can set credentials without constructor plumbing (design D4). */
@@ -158,6 +184,9 @@ function resolveShowNotesRenderOptions(
158
184
  // Timeline offsets degrade to proportional — never block show-notes on audio metadata.
159
185
  }
160
186
  }
187
+ const seoConfig = resolvePodcastSeoConfig(undefined);
188
+ if ('error' in seoConfig) return { error: seoConfig.error };
189
+ const config = seoConfig.config;
161
190
  return {
162
191
  renderOptions: {
163
192
  title,
@@ -167,12 +196,22 @@ function resolveShowNotesRenderOptions(
167
196
  ...(articlePostPath !== undefined ? { articlePostPath } : {}),
168
197
  ...(footerNote !== undefined ? { footerNote } : {}),
169
198
  ...(audioSegments !== undefined ? { audioSegments } : {}),
199
+ summaryParagraph: config.summaryParagraph,
200
+ timelineStyle: config.timelineStyle,
201
+ seriesName: config.seriesName,
202
+ showName: config.showName,
203
+ descriptionMin: config.descriptionMin,
204
+ descriptionMax: config.descriptionMax,
205
+ descriptionSource: config.descriptionSource,
206
+ descriptionPattern: config.descriptionPattern,
170
207
  },
171
208
  };
172
209
  }
173
210
 
174
211
  /** Validates the plan Doc[] and renders show-notes markdown (pure render; no microfeed call). */
175
- function renderShowNotesFromOptions(options: ShowNotesOpOptions): { markdown: string } | { error: string } {
212
+ function renderShowNotesFromOptions(
213
+ options: ShowNotesOpOptions,
214
+ ): { markdown: string; warnings: string[] } | { error: string } {
176
215
  const docs = DocListSchema.safeParse(options.docs);
177
216
  if (!docs.success) {
178
217
  const reason = docs.error.issues[0]?.message ?? 'invalid document list';
@@ -180,7 +219,8 @@ function renderShowNotesFromOptions(options: ShowNotesOpOptions): { markdown: st
180
219
  }
181
220
  const renderOptions = resolveShowNotesRenderOptions(options);
182
221
  if ('error' in renderOptions) return { error: renderOptions.error };
183
- return { markdown: renderShowNotesMarkdown(docs.data, renderOptions.renderOptions) };
222
+ const warnings: string[] = [];
223
+ return { markdown: renderShowNotesMarkdown(docs.data, renderOptions.renderOptions, warnings), warnings };
184
224
  }
185
225
 
186
226
  /** Episode-assembly options (R2): present keys make the plugin merge article + audio + cover itself. */
@@ -236,25 +276,141 @@ function audioAttachment(
236
276
  };
237
277
  }
238
278
 
239
- /** Spreads optional episode fields (image, date, link, itunes) into a microfeed item payload. */
240
- function episodeExtras(episode: EpisodeMapping): MicrofeedItemInput {
279
+ /** Spreads optional episode fields (image, date, link, itunes, seo) into a microfeed item payload. */
280
+ function episodeExtras(episode: EpisodeMapping, seo?: SeoBlock): MicrofeedItemInput {
241
281
  return {
242
282
  ...(episode.imageUrl ? { image: episode.imageUrl } : {}),
243
283
  ...(episode.datePublishedMs !== undefined ? { date_published_ms: episode.datePublishedMs } : {}),
244
284
  ...(episode.link ? { url: episode.link } : {}),
245
- ...{
246
- _microfeed: {
247
- 'itunes:episodeType': episode.itunes?.episodeType ?? 'full',
248
- ...(episode.itunes?.title ? { 'itunes:title': episode.itunes.title } : {}),
249
- ...(episode.itunes?.season !== undefined ? { 'itunes:season': episode.itunes.season } : {}),
250
- ...(episode.itunes?.episode !== undefined ? { 'itunes:episode': episode.itunes.episode } : {}),
251
- ...(episode.itunes?.explicit !== undefined ? { 'itunes:explicit': episode.itunes.explicit } : {}),
252
- ...(episode.itunes?.block !== undefined ? { 'itunes:block': episode.itunes.block } : {}),
253
- },
285
+ _microfeed: {
286
+ 'itunes:episodeType': episode.itunes?.episodeType ?? 'full',
287
+ ...(episode.itunes?.title ? { 'itunes:title': episode.itunes.title } : {}),
288
+ ...(episode.itunes?.season !== undefined ? { 'itunes:season': episode.itunes.season } : {}),
289
+ ...(episode.itunes?.episode !== undefined ? { 'itunes:episode': episode.itunes.episode } : {}),
290
+ ...(episode.itunes?.explicit !== undefined ? { 'itunes:explicit': episode.itunes.explicit } : {}),
291
+ ...(episode.itunes?.block !== undefined ? { 'itunes:block': episode.itunes.block } : {}),
292
+ ...(seo ? { seo } : {}),
254
293
  },
255
294
  };
256
295
  }
257
296
 
297
+ function shapedEpisode(episode: EpisodeMapping, fields: PreparedEpisodeFields): EpisodeMapping {
298
+ return {
299
+ ...episode,
300
+ title: fields.title,
301
+ ...(fields.itunes ? { itunes: fields.itunes } : {}),
302
+ ...(fields.durationSeconds !== undefined
303
+ ? { durationSeconds: fields.durationSeconds }
304
+ : { durationSeconds: undefined }),
305
+ link: fields.link,
306
+ };
307
+ }
308
+
309
+ /** First createItem carries the slug. A 409 retries once as `{slug}-2`, then publishes with no slug. */
310
+ async function createEpisodeItem(
311
+ client: MicrofeedClient,
312
+ body: MicrofeedItemInput,
313
+ slug: string | undefined,
314
+ warnings: string[],
315
+ ): Promise<string> {
316
+ const withSlug = (itemSlug: string): MicrofeedItemInput => {
317
+ const micro = (body._microfeed as Record<string, unknown> | undefined) ?? {};
318
+ return { ...body, _microfeed: { ...micro, slug: itemSlug } };
319
+ };
320
+ if (!slug) return client.createItem(body, crypto.randomUUID());
321
+ try {
322
+ return await client.createItem(withSlug(slug), crypto.randomUUID());
323
+ } catch (error: unknown) {
324
+ if (!(error instanceof MicrofeedRequestError) || error.status !== 409) throw error;
325
+ const retry = `${slug}-2`;
326
+ try {
327
+ return await client.createItem(withSlug(retry), crypto.randomUUID());
328
+ } catch (retryError: unknown) {
329
+ if (!(retryError instanceof MicrofeedRequestError) || retryError.status !== 409) throw retryError;
330
+ warn(`slug "${slug}" conflicted (HTTP 409); publishing without a custom slug`, warnings);
331
+ return client.createItem(body, crypto.randomUUID());
332
+ }
333
+ }
334
+ }
335
+
336
+ /** Uploads a local 1200×630 image, or checks a remote one. A bad size skips the image and the publish continues. */
337
+ async function resolveSocialImage(
338
+ client: MicrofeedClient,
339
+ fields: PreparedEpisodeFields,
340
+ warnings: string[],
341
+ ): Promise<NonNullable<SeoBlock['social_image']> | undefined> {
342
+ if (!fields.socialAlt) return undefined;
343
+ if (fields.socialLocalPath && fields.socialMime) {
344
+ const bytes = new Uint8Array(await readFile(fields.socialLocalPath));
345
+ const prepared = await client.prepareUpload({
346
+ category: 'image',
347
+ full_local_file_path: basename(fields.socialLocalPath),
348
+ size: bytes.byteLength,
349
+ type: fields.socialMime,
350
+ });
351
+ await client.uploadBytes(prepared.presignedUrl, bytes, fields.socialMime);
352
+ return {
353
+ url: prepared.mediaUrl,
354
+ width: 1200,
355
+ height: 630,
356
+ mime_type: fields.socialMime,
357
+ alt: fields.socialAlt,
358
+ };
359
+ }
360
+ if (!fields.socialRemoteUrl) return undefined;
361
+ let bytes: Uint8Array;
362
+ try {
363
+ const response = await fetch(fields.socialRemoteUrl);
364
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
365
+ bytes = new Uint8Array(await response.arrayBuffer());
366
+ } catch (error: unknown) {
367
+ warn(
368
+ `social image skipped: could not read ${fields.socialRemoteUrl} (${error instanceof Error ? error.message : String(error)})`,
369
+ warnings,
370
+ );
371
+ return undefined;
372
+ }
373
+ const dims = readImageDimensions(bytes);
374
+ if (dims?.width !== 1200 || dims?.height !== 630) {
375
+ warn(
376
+ `social image skipped: expected a 1200×630 PNG or JPEG, got ${dims ? `${dims.width}×${dims.height}` : 'an unreadable file'}`,
377
+ warnings,
378
+ );
379
+ return undefined;
380
+ }
381
+ return {
382
+ url: fields.socialRemoteUrl,
383
+ width: 1200,
384
+ height: 630,
385
+ mime_type: dims.mime,
386
+ alt: fields.socialAlt,
387
+ };
388
+ }
389
+
390
+ function mergeSocial(
391
+ seo: SeoBlock | undefined,
392
+ social: NonNullable<SeoBlock['social_image']> | undefined,
393
+ ): SeoBlock | undefined {
394
+ if (!social) return seo;
395
+ return { ...(seo ?? {}), social_image: social };
396
+ }
397
+
398
+ /** Item body for `validate` when the caller did not pass `options.item`. Throws on a bad episode. */
399
+ function mappedValidateInput(content: Content): MicrofeedItemInput {
400
+ const seoConfig = resolvePodcastSeoConfig(content.metadata);
401
+ if ('error' in seoConfig) throw new Error(seoConfig.error);
402
+ const episode = mapContentToEpisode(content);
403
+ const policy = prepareEpisodeFields(episode, content.metadata ?? {}, seoConfig.config);
404
+ if (!policy.ok) throw new Error(policy.error);
405
+ const shaped = shapedEpisode(episode, policy.fields);
406
+ return {
407
+ title: shaped.title,
408
+ content_html: shaped.contentHtml,
409
+ status: shaped.status,
410
+ ...episodeExtras(shaped, policy.fields.seo),
411
+ };
412
+ }
413
+
258
414
  /**
259
415
  * Two-phase publish (design D2, the only legal order): create a draft item →
260
416
  * presign with `item_id` → PUT bytes → PUT item with attachment + target status.
@@ -294,20 +450,32 @@ async function publishEpisode(
294
450
  return fail(`Invalid Content: ${error instanceof Error ? error.message : String(error)}`);
295
451
  }
296
452
 
297
- // Caller-supplied uuid per publish attempt (design D3; microfeed makes create
298
- // idempotent for 24h, so a retry after a lost response cannot duplicate the item).
299
- const idempotencyKey = crypto.randomUUID();
453
+ const seoConfig = resolvePodcastSeoConfig(content.metadata);
454
+ if ('error' in seoConfig) return fail(seoConfig.error);
455
+ const policy = prepareEpisodeFields(episode, content.metadata ?? {}, seoConfig.config);
456
+ if (!policy.ok) return fail(policy.error);
457
+ const fields = policy.fields;
458
+ let social: NonNullable<SeoBlock['social_image']> | undefined;
459
+ try {
460
+ social = await resolveSocialImage(client, fields, fields.warnings);
461
+ } catch (error: unknown) {
462
+ return fail(error instanceof Error ? error.message : String(error));
463
+ }
464
+ const seo = mergeSocial(fields.seo, social);
465
+ episode = shapedEpisode(episode, fields);
300
466
 
301
467
  if (episode.audio.kind === 'url') {
302
- const itemId = await client.createItem(
468
+ const itemId = await createEpisodeItem(
469
+ client,
303
470
  {
304
471
  title: episode.title,
305
472
  content_html: episode.contentHtml,
306
473
  status: episode.status,
307
474
  attachments: [audioAttachment(episode.audio, episode.audio.url, undefined, episode.durationSeconds)],
308
- ...episodeExtras(episode),
475
+ ...episodeExtras(episode, seo),
309
476
  },
310
- idempotencyKey,
477
+ fields.slug,
478
+ fields.warnings,
311
479
  );
312
480
  // Best-effort canonical-URL enrichment: the episode is already published, so a failed
313
481
  // follow-up GET (or an unparsable response) falls back to publicItemUrl's own chain.
@@ -323,17 +491,21 @@ async function publishEpisode(
323
491
  url: publicItemUrl(hostedItem, itemId, client),
324
492
  id: itemId,
325
493
  message: 'Published successfully to microfeed (hosted audio, single-call create)',
494
+ ...(fields.warnings.length > 0 ? { metadata: { warnings: fields.warnings } } : {}),
326
495
  });
327
496
  }
328
497
 
329
498
  // Phase 1: draft item without attachment (audio presign requires item_id).
330
- const itemId = await client.createItem(
499
+ // The slug is sent only here — a later update would move an existing URL.
500
+ const itemId = await createEpisodeItem(
501
+ client,
331
502
  {
332
503
  title: episode.title,
333
504
  content_html: episode.contentHtml,
334
505
  status: 'unpublished',
335
506
  },
336
- idempotencyKey,
507
+ fields.slug,
508
+ fields.warnings,
337
509
  );
338
510
 
339
511
  // Phase 2-3: presign against the draft, then PUT the raw bytes.
@@ -371,7 +543,7 @@ async function publishEpisode(
371
543
  status: episode.status,
372
544
  attachments: [audioAttachment(episode.audio, prepared.mediaUrl, bytes.byteLength, episode.durationSeconds)],
373
545
  ...(imageUrl ? { image: imageUrl } : {}),
374
- ...episodeExtras(episode),
546
+ ...episodeExtras(episode, seo),
375
547
  });
376
548
 
377
549
  return ResultSchema.parse({
@@ -382,7 +554,11 @@ async function publishEpisode(
382
554
  message: 'Published successfully to microfeed (two-phase upload + publish)',
383
555
  // 20260917 review #6: downstream surfdash patch reuses the podcast-hosted cover URL
384
556
  // (no re-upload); null when the episode carried no local cover.
385
- metadata: { itemId, imageUrl: imageUrl ?? null },
557
+ metadata: {
558
+ itemId,
559
+ imageUrl: imageUrl ?? null,
560
+ ...(fields.warnings.length > 0 ? { warnings: fields.warnings } : {}),
561
+ },
386
562
  });
387
563
  }
388
564
 
@@ -411,7 +587,10 @@ export class PodcastPubTransport implements PublishTransport {
411
587
  ok: true,
412
588
  target: TARGET,
413
589
  message: 'Rendered show notes markdown (pure render; no microfeed API call)',
414
- metadata: { markdown: rendered.markdown },
590
+ metadata: {
591
+ markdown: rendered.markdown,
592
+ ...(rendered.warnings.length > 0 ? { warnings: rendered.warnings } : {}),
593
+ },
415
594
  });
416
595
  }
417
596
 
@@ -475,20 +654,18 @@ export class PodcastPubTransport implements PublishTransport {
475
654
  message: `Deleted microfeed item ${itemId.itemId}`,
476
655
  });
477
656
  }
657
+ case 'backfill-seo': {
658
+ const config = resolvePodcastSeoConfig(payload.content.metadata);
659
+ if ('error' in config) return fail(config.error);
660
+ return await runBackfill(client, options, config.config);
661
+ }
478
662
  case 'validate': {
479
663
  // Validate the caller-supplied item input, or the Content-mapped
480
664
  // draft when no explicit input is given (server-side schema check).
481
665
  const input =
482
666
  options.item !== undefined && typeof options.item === 'object' && options.item !== null
483
667
  ? (options.item as MicrofeedItemInput)
484
- : (() => {
485
- const episode = mapContentToEpisode(payload.content);
486
- return {
487
- title: episode.title,
488
- content_html: episode.contentHtml,
489
- status: episode.status,
490
- } satisfies MicrofeedItemInput;
491
- })();
668
+ : mappedValidateInput(payload.content);
492
669
  await client.validateItem(input);
493
670
  return ResultSchema.parse({
494
671
  ok: true,
@@ -5,6 +5,17 @@
5
5
  * diagnostic — never silent (R4).
6
6
  */
7
7
 
8
+ /** HTTP failure from `/api/v1/`. `status` lets callers retry a slug 409 without parsing the message. */
9
+ export class MicrofeedRequestError extends Error {
10
+ readonly status: number;
11
+
12
+ constructor(status: number, message: string) {
13
+ super(message);
14
+ this.name = 'MicrofeedRequestError';
15
+ this.status = status;
16
+ }
17
+ }
18
+
8
19
  /** microfeed API responses are `apiItemInputSchema`-shaped loose objects. */
9
20
  export type MicrofeedItemInput = Record<string, unknown>;
10
21
  /** microfeed item output (`apiItemOutputSchema` shape). */
@@ -76,7 +87,8 @@ export class MicrofeedClient {
76
87
  } catch {
77
88
  // keep raw text as the detail
78
89
  }
79
- throw new Error(
90
+ throw new MicrofeedRequestError(
91
+ response.status,
80
92
  `microfeed API request failed (HTTP ${response.status} ${response.statusText} ${path}): ${detail || 'Unknown error'}`,
81
93
  );
82
94
  }
@@ -144,9 +156,26 @@ export class MicrofeedClient {
144
156
  });
145
157
  }
146
158
 
147
- /** `GET /api/v1/feed` — the full JSON Feed document. */
159
+ /**
160
+ * `GET /api/v1/feed` — the JSON Feed document. A `next_url` on the same origin is followed
161
+ * (at most 20 pages). microfeed 1.0.13 returns the whole feed in one document; the follow
162
+ * is only for a deployment that pages.
163
+ */
148
164
  public async listItems(): Promise<MicrofeedFeed> {
149
- return (await this.request('/api/v1/feed', { method: 'GET' })) as MicrofeedFeed;
165
+ const first = (await this.request('/api/v1/feed', { method: 'GET' })) as MicrofeedFeed;
166
+ const items = [...(first.items ?? [])];
167
+ const seen = new Set<string>(['/api/v1/feed']);
168
+ let next = typeof first.next_url === 'string' ? first.next_url : '';
169
+ // The first document is page 1. Follow at most 19 more so the whole walk stays within 20 pages.
170
+ for (let page = 1; page < 20 && next !== '' && !seen.has(next); page++) {
171
+ seen.add(next);
172
+ const path = next.startsWith(this.baseUrl) ? next.slice(this.baseUrl.length) || '/' : next;
173
+ if (!path.startsWith('/')) break;
174
+ const more = (await this.request(path, { method: 'GET' })) as MicrofeedFeed;
175
+ items.push(...(more.items ?? []));
176
+ next = typeof more.next_url === 'string' ? more.next_url : '';
177
+ }
178
+ return { ...first, items };
150
179
  }
151
180
 
152
181
  /**