@gobing-ai/knowledge-kit 0.0.19 → 0.0.22

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +161 -142
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +20 -3
  5. package/plugins/generations/core-facts-gen/dist/index.js +20 -3
  6. package/plugins/generations/daily-article-gen/dist/index.js +20 -3
  7. package/plugins/generations/dailynews-gen/dist/index.js +29 -4
  8. package/plugins/generations/dailynews-gen/src/script-builder.ts +19 -1
  9. package/plugins/generations/episode-plan-gen/dist/index.js +20 -3
  10. package/plugins/generations/image-gen/dist/index.js +147 -69
  11. package/plugins/generations/image-gen/src/presets.ts +2 -2
  12. package/plugins/generations/image-gen/src/providers/agnes.ts +6 -2
  13. package/plugins/generations/image-gen/src/providers/azure.ts +6 -2
  14. package/plugins/generations/image-gen/src/providers/dashscope.ts +10 -3
  15. package/plugins/generations/image-gen/src/providers/google.ts +70 -29
  16. package/plugins/generations/image-gen/src/providers/huggingface.ts +3 -0
  17. package/plugins/generations/image-gen/src/providers/jimeng.ts +9 -2
  18. package/plugins/generations/image-gen/src/providers/minimax.ts +10 -2
  19. package/plugins/generations/image-gen/src/providers/openai.ts +9 -3
  20. package/plugins/generations/image-gen/src/providers/openrouter.ts +16 -4
  21. package/plugins/generations/image-gen/src/providers/replicate.ts +13 -3
  22. package/plugins/generations/image-gen/src/providers/seedream.ts +4 -1
  23. package/plugins/generations/image-gen/src/providers/timeouts.ts +14 -0
  24. package/plugins/generations/image-gen/src/providers/types.ts +10 -0
  25. package/plugins/generations/image-gen/src/providers/zai.ts +4 -1
  26. package/plugins/generations/news-report-gen/dist/index.js +20 -3
  27. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +13 -5
  28. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +23 -15
  29. package/plugins/generations/voice-gen/dist/index.js +36 -10
  30. package/plugins/generations/voice-gen/src/voicebox-client.ts +18 -4
  31. package/plugins/generations/voice-gen/src/voicebox-server.ts +2 -1
  32. package/plugins/ingestions/aihot-ingest/dist/index.js +26 -6
  33. package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
  34. package/plugins/ingestions/aihot-ingest/src/client.ts +5 -0
  35. package/plugins/ingestions/aihot-ingest/src/rss.ts +12 -2
  36. package/plugins/ingestions/horizon-ingest/dist/index.js +20 -3
  37. package/plugins/ingestions/last30days-ingest/dist/index.js +20 -3
  38. package/plugins/ingestions/web-search/dist/index.js +22 -3
  39. package/plugins/ingestions/web-search/src/index.ts +4 -0
  40. package/plugins/kk/commands/workflow-run.md +20 -6
  41. package/plugins/kk/plugin.json +1 -1
  42. package/plugins/kk/scripts/itc-stages.ts +563 -0
  43. package/plugins/kk/scripts/kk-workflow-stages.ts +125 -18
  44. package/plugins/kk/scripts/render-md.ts +7 -1
  45. package/plugins/kk/skills/article-adapt/SKILL.md +85 -0
  46. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +26 -16
  47. package/plugins/kk/workflows/kk-itc.yaml +461 -12
  48. package/plugins/publishings/emdash-pub/dist/index.js +76 -19
  49. package/plugins/publishings/emdash-pub/src/index.ts +4 -0
  50. package/plugins/publishings/infoq-pub/dist/index.js +74 -21
  51. package/plugins/publishings/juejin-pub/dist/index.js +74 -21
  52. package/plugins/publishings/medium-pub/dist/index.js +77 -19
  53. package/plugins/publishings/medium-pub/src/index.ts +5 -0
  54. package/plugins/publishings/podcast-pub/dist/index.js +130 -34
  55. package/plugins/publishings/podcast-pub/src/index.ts +20 -5
  56. package/plugins/publishings/podcast-pub/src/map.ts +11 -0
  57. package/plugins/publishings/podcast-pub/src/show-notes.ts +66 -13
  58. package/plugins/publishings/qiita-pub/dist/index.js +75 -18
  59. package/plugins/publishings/qiita-pub/src/index.ts +4 -0
  60. package/plugins/publishings/substack-pub/dist/index.js +74 -21
  61. package/plugins/publishings/surfdash-pub/dist/index.js +99 -62
  62. package/plugins/publishings/surfdash-pub/src/index.ts +60 -19
  63. package/plugins/publishings/wechatmp-pub/dist/index.js +79 -22
  64. package/plugins/publishings/wechatmp-pub/src/index.ts +12 -3
  65. package/plugins/publishings/x-pub/dist/index.js +56 -37
  66. package/plugins/publishings/x-pub/src/index.ts +4 -0
  67. package/plugins/publishings/xhs-pub/dist/index.js +74 -21
  68. package/plugins/publishings/zenn-pub/dist/index.js +103 -25
  69. package/plugins/publishings/zenn-pub/src/index.ts +43 -9
@@ -21746,7 +21746,7 @@ var init_file_system_node = () => {};
21746
21746
  // ../../plugins/publishings/podcast-pub/src/index.ts
21747
21747
  import { readFileSync as readFileSync2 } from "fs";
21748
21748
  import { readFile } from "fs/promises";
21749
- import { dirname as dirname2 } from "path";
21749
+ import { basename, dirname as dirname2, extname as extname2 } from "path";
21750
21750
  import { parseArgs } from "util";
21751
21751
 
21752
21752
  // ../../packages/kk-core/src/kinds.ts
@@ -21783,6 +21783,22 @@ var ResultSchema = exports_external.object({
21783
21783
  error: exports_external.string().optional(),
21784
21784
  metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21785
21785
  });
21786
+ var TargetOutcomeSchema = exports_external.object({
21787
+ target: exports_external.string(),
21788
+ ok: exports_external.boolean(),
21789
+ status: exports_external.enum(["published", "failed", "skipped"]),
21790
+ url: exports_external.string().optional(),
21791
+ id: exports_external.string().optional(),
21792
+ error: exports_external.string().optional(),
21793
+ durationMs: exports_external.number().optional(),
21794
+ metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21795
+ });
21796
+ var AggregateResultSchema = exports_external.object({
21797
+ ok: exports_external.boolean(),
21798
+ summary: exports_external.object({ total: exports_external.number(), succeeded: exports_external.number(), failed: exports_external.number(), skipped: exports_external.number() }),
21799
+ targets: exports_external.array(TargetOutcomeSchema),
21800
+ metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21801
+ });
21786
21802
  var DocListSchema = exports_external.array(DocSchema);
21787
21803
  var omniVoiceGenContentSchema = ContentSchema.extend({
21788
21804
  format: exports_external.literal("audio"),
@@ -21811,14 +21827,15 @@ var PluginKindSchema = exports_external.enum(PLUGIN_KINDS);
21811
21827
  // ../../packages/kk-core/src/manifest.ts
21812
21828
  init_zod();
21813
21829
  var OperationIOSchema = exports_external.enum(["doc-list", "markdown"]);
21814
- var PluginManifestSchema = exports_external.object({
21830
+ var PluginOperationSchema = exports_external.object({ input: OperationIOSchema, output: OperationIOSchema });
21831
+ var PluginManifestSchema = exports_external.looseObject({
21815
21832
  name: exports_external.string().min(1),
21816
21833
  kind: PluginKindSchema,
21817
21834
  entry: exports_external.string().min(1),
21818
21835
  version: exports_external.string().optional(),
21819
21836
  description: exports_external.string().optional(),
21820
- operations: exports_external.record(exports_external.string(), exports_external.object({ input: OperationIOSchema, output: OperationIOSchema })).optional()
21821
- }).passthrough();
21837
+ operations: exports_external.record(exports_external.string(), PluginOperationSchema).optional()
21838
+ });
21822
21839
  // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.4.39/node_modules/@gobing-ai/ts-runtime/dist/index.js
21823
21840
  init_file_system_node();
21824
21841
  init_config();
@@ -21844,41 +21861,82 @@ function echoError(message, target = defaultStderrTarget ?? processStream("stder
21844
21861
  // ../../packages/kk-core/src/render-md.ts
21845
21862
  if (false) {}
21846
21863
  // ../../packages/publish-harness/src/index.ts
21847
- class FakeFileCliTransport {
21848
- kind = "file-cli";
21849
- callLogs;
21864
+ class FakeTransportCore {
21865
+ callLogs = [];
21850
21866
  nextResult;
21851
- constructor() {
21852
- this.callLogs = [];
21853
- }
21854
21867
  setNextResult(result) {
21855
21868
  this.nextResult = result;
21856
21869
  }
21857
- async publish(payload) {
21870
+ async publish(payload, defaultResult) {
21858
21871
  this.callLogs.push({
21859
21872
  timestamp: new Date().toISOString(),
21860
21873
  payload
21861
21874
  });
21862
- if (this.nextResult) {
21863
- return this.nextResult;
21864
- }
21865
- return {
21875
+ return this.nextResult ?? defaultResult;
21876
+ }
21877
+ getCallLogs() {
21878
+ return this.callLogs;
21879
+ }
21880
+ reset() {
21881
+ this.callLogs = [];
21882
+ this.nextResult = undefined;
21883
+ }
21884
+ }
21885
+
21886
+ class FakeFileCliTransport {
21887
+ kind = "file-cli";
21888
+ core = new FakeTransportCore;
21889
+ setNextResult(result) {
21890
+ this.core.setNextResult(result);
21891
+ }
21892
+ async publish(payload) {
21893
+ return this.core.publish(payload, {
21866
21894
  ok: true,
21867
21895
  target: "fake-file-cli",
21868
21896
  url: "https://example.com/published-post",
21869
21897
  id: "fake-id-123",
21870
21898
  message: "Published offline via FakeFileCliTransport"
21871
- };
21899
+ });
21872
21900
  }
21873
21901
  getCallLogs() {
21874
- return this.callLogs;
21902
+ return this.core.getCallLogs();
21875
21903
  }
21876
21904
  reset() {
21877
- this.callLogs = [];
21878
- this.nextResult = undefined;
21905
+ this.core.reset();
21879
21906
  }
21880
21907
  }
21881
21908
 
21909
+ class FakeBrowserTransport {
21910
+ kind = "browser";
21911
+ driver;
21912
+ core = new FakeTransportCore;
21913
+ constructor(driver = "agent") {
21914
+ this.driver = driver;
21915
+ }
21916
+ setNextResult(result) {
21917
+ this.core.setNextResult(result);
21918
+ }
21919
+ async publish(payload) {
21920
+ return this.core.publish(payload, {
21921
+ ok: true,
21922
+ target: "fake-browser",
21923
+ url: "https://example.com/browser-draft",
21924
+ id: "fake-browser-id-123",
21925
+ message: "Published offline via FakeBrowserTransport",
21926
+ metadata: { driver: this.driver }
21927
+ });
21928
+ }
21929
+ getCallLogs() {
21930
+ return this.core.getCallLogs();
21931
+ }
21932
+ reset() {
21933
+ this.core.reset();
21934
+ }
21935
+ }
21936
+
21937
+ // ../../plugins/publishings/podcast-pub/src/map.ts
21938
+ import { extname } from "path";
21939
+
21882
21940
  // ../../plugins/publishings/podcast-pub/src/show-notes.ts
21883
21941
  function escapeHtml(value) {
21884
21942
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
@@ -21942,27 +22000,37 @@ function computeAudioOffsets(docs, segments) {
21942
22000
  return fallback();
21943
22001
  return starts;
21944
22002
  }
22003
+ var isHttpUrl = (u) => /^https?:\/\//i.test(u.trim());
22004
+ var TIMELINE_STYLE = `<style>
22005
+ .kk-tl{margin:1em 0}
22006
+ .kk-tl-row{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid rgba(128,128,128,.25)}
22007
+ .kk-tl-t{flex:0 0 56px;font-variant-numeric:tabular-nums;font-weight:700;color:#0969da}
22008
+ .kk-tl-body{flex:1;min-width:0}
22009
+ .kk-tl-title{font-weight:600}
22010
+ .kk-tl-brief{margin:4px 0 0;color:#666;font-size:.95em}
22011
+ </style>`;
22012
+ var TIMELINE_TAG = /^<\/?(?:div|p|a)(?:\s+(?:class="kk-tl(?:-[a-z]+)?"|href="https?:\/\/[^"<>\s]*"|target="_blank"|rel="noopener"))*\s*>$/;
22013
+ function isGeneratedTimeline(block) {
22014
+ const rest = block.replace(TIMELINE_STYLE, "");
22015
+ if (rest.includes("<style") || rest.includes("<!--"))
22016
+ return false;
22017
+ return (rest.match(/<[^>]*>/g) ?? []).every((tag) => TIMELINE_TAG.test(tag)) && !/<(?![^>]*>)/.test(rest);
22018
+ }
21945
22019
  function renderTimelineHtml(docs, offsets, articleUrl) {
21946
22020
  const rows = docs.map((d, i) => {
21947
22021
  const title = escapeHtml(d.title ?? `\u6761\u76EE ${i + 1}`);
21948
22022
  const brief = escapeHtml(itemBrief(d));
21949
22023
  const at = formatOffset(offsets[i] ?? Number.NaN);
21950
- const link = d.sourceUri ? ` <a href="${escapeHtml(d.sourceUri)}" target="_blank" rel="noopener">\u6765\u6E90</a>` : "";
22024
+ const link = d.sourceUri && isHttpUrl(d.sourceUri) ? ` <a href="${escapeHtml(d.sourceUri)}" target="_blank" rel="noopener">\u6765\u6E90</a>` : "";
21951
22025
  return `
21952
22026
  <div class="kk-tl-row"><div class="kk-tl-t">${at}</div><div class="kk-tl-body"><div class="kk-tl-title">${i + 1}. ${title}${link}</div><p class="kk-tl-brief">${brief}</p></div></div>`;
21953
22027
  }).join("");
22028
+ const articleTail = isHttpUrl(articleUrl) ? `<p>\u5B8C\u6574\u56FE\u6587\u7248\uFF1A<a href="${escapeHtml(articleUrl)}" target="_blank" rel="noopener">${escapeHtml("\u672C\u671F\u5B8C\u6574\u6587\u7AE0")}</a></p>` : `<p>${escapeHtml("\u672C\u671F\u5B8C\u6574\u6587\u7AE0")}</p>`;
21954
22029
  return `<!--kk:timeline-->
21955
- <style>
21956
- .kk-tl{margin:1em 0}
21957
- .kk-tl-row{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid rgba(128,128,128,.25)}
21958
- .kk-tl-t{flex:0 0 56px;font-variant-numeric:tabular-nums;font-weight:700;color:#0969da}
21959
- .kk-tl-body{flex:1;min-width:0}
21960
- .kk-tl-title{font-weight:600}
21961
- .kk-tl-brief{margin:4px 0 0;color:#666;font-size:.95em}
21962
- </style>
22030
+ ${TIMELINE_STYLE}
21963
22031
  <div class="kk-tl">${rows}
21964
22032
  </div>
21965
- <p>\u5B8C\u6574\u56FE\u6587\u7248\uFF1A<a href="${escapeHtml(articleUrl)}" target="_blank" rel="noopener">${escapeHtml("\u672C\u671F\u5B8C\u6574\u6587\u7AE0")}</a></p>
22033
+ ${articleTail}
21966
22034
  <!--/kk:timeline-->`;
21967
22035
  }
21968
22036
  function renderShowNotesHtml(markdown) {
@@ -21976,6 +22044,15 @@ function renderShowNotesHtml(markdown) {
21976
22044
  paragraph = [];
21977
22045
  }
21978
22046
  };
22047
+ const escapeBufferedTimeline = () => {
22048
+ for (const l of timeline) {
22049
+ const t = l.trim();
22050
+ if (t.length > 0)
22051
+ paragraph.push(t);
22052
+ }
22053
+ timeline.length = 0;
22054
+ flush();
22055
+ };
21979
22056
  for (const rawLine of markdown.split(`
21980
22057
  `)) {
21981
22058
  const line = rawLine.trim();
@@ -21987,6 +22064,14 @@ function renderShowNotesHtml(markdown) {
21987
22064
  if (inTimeline) {
21988
22065
  if (line === "<!--/kk:timeline-->") {
21989
22066
  inTimeline = false;
22067
+ if (isGeneratedTimeline(timeline.join(`
22068
+ `))) {
22069
+ blocks.push(timeline.join(`
22070
+ `));
22071
+ timeline.length = 0;
22072
+ } else {
22073
+ escapeBufferedTimeline();
22074
+ }
21990
22075
  continue;
21991
22076
  }
21992
22077
  timeline.push(rawLine);
@@ -22010,8 +22095,7 @@ function renderShowNotesHtml(markdown) {
22010
22095
  }
22011
22096
  flush();
22012
22097
  if (timeline.length > 0)
22013
- blocks.push(timeline.join(`
22014
- `));
22098
+ escapeBufferedTimeline();
22015
22099
  return blocks.join(`
22016
22100
  `);
22017
22101
  }
@@ -22059,6 +22143,7 @@ function renderShowNotesMarkdown(docs, opts) {
22059
22143
  }
22060
22144
 
22061
22145
  // ../../plugins/publishings/podcast-pub/src/map.ts
22146
+ var COVER_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
22062
22147
  var AUDIO_MIME_BY_EXTENSION = {
22063
22148
  ".mp3": "audio/mpeg",
22064
22149
  ".wav": "audio/wav",
@@ -22108,6 +22193,9 @@ function mapContentToEpisode(content) {
22108
22193
  const link = articleUrl && articleUrl.length > 0 ? articleUrl : undefined;
22109
22194
  const coverImage = typeof metadata.coverImage === "string" ? metadata.coverImage.trim() : undefined;
22110
22195
  const imageUrl = coverImage?.startsWith("http") ? coverImage : undefined;
22196
+ if (coverImage && coverImage.length > 0 && !imageUrl && !COVER_EXTS.has(extname(coverImage).toLowerCase())) {
22197
+ throw new Error(`invalid metadata.coverImage: expected an http(s) URL or a local image (${[...COVER_EXTS].join(", ")}), got '${coverImage}'`);
22198
+ }
22111
22199
  const imagePath = !imageUrl && coverImage && coverImage.length > 0 ? coverImage : undefined;
22112
22200
  let datePublishedMs;
22113
22201
  const publishedAt = metadata.publishedAt;
@@ -22314,6 +22402,13 @@ function resolveClient() {
22314
22402
  function fail(error51) {
22315
22403
  return ResultSchema.parse({ ok: false, target: TARGET, error: error51 });
22316
22404
  }
22405
+ var COVER_MIME = {
22406
+ ".png": "image/png",
22407
+ ".jpg": "image/jpeg",
22408
+ ".jpeg": "image/jpeg",
22409
+ ".webp": "image/webp",
22410
+ ".gif": "image/gif"
22411
+ };
22317
22412
  function resolveOperation(options) {
22318
22413
  const raw = options.operation ?? "publish";
22319
22414
  if (typeof raw !== "string" || !OPERATIONS.includes(raw)) {
@@ -22492,14 +22587,15 @@ async function publishEpisode(client, payload, options) {
22492
22587
  await client.uploadBytes(prepared.presignedUrl, bytes, episode.audio.mimeType);
22493
22588
  let imageUrl = episode.imageUrl;
22494
22589
  if (episode.imagePath && !imageUrl) {
22590
+ const imageMime = COVER_MIME[extname2(episode.imagePath).toLowerCase()] ?? "image/png";
22495
22591
  const imageBytes = new Uint8Array(await readFile(episode.imagePath));
22496
22592
  const imagePrepared = await client.prepareUpload({
22497
22593
  category: "image",
22498
- full_local_file_path: episode.imagePath,
22594
+ full_local_file_path: basename(episode.imagePath),
22499
22595
  size: imageBytes.byteLength,
22500
- type: "image/png"
22596
+ type: imageMime
22501
22597
  });
22502
- await client.uploadBytes(imagePrepared.presignedUrl, imageBytes, "image/png");
22598
+ await client.uploadBytes(imagePrepared.presignedUrl, imageBytes, imageMime);
22503
22599
  imageUrl = imagePrepared.mediaUrl;
22504
22600
  }
22505
22601
  const item = await client.updateItem(itemId, {
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { dirname } from 'node:path';
3
+ import { basename, dirname, extname } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { type Content, ContentSchema, DocListSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
6
6
  import { FakeFileCliTransport, type PublishTransport, type PublishTransportPayload } from '@gobing-ai/publish-harness';
@@ -75,6 +75,18 @@ function fail(error: string): Result {
75
75
  return ResultSchema.parse({ ok: false, target: TARGET, error });
76
76
  }
77
77
 
78
+ /**
79
+ * Local-cover MIME by extension. `map.ts` has already rejected anything outside this set (R4), so
80
+ * the fallback is unreachable in practice; it keeps the field non-empty for the type system.
81
+ */
82
+ const COVER_MIME: Record<string, string> = {
83
+ '.png': 'image/png',
84
+ '.jpg': 'image/jpeg',
85
+ '.jpeg': 'image/jpeg',
86
+ '.webp': 'image/webp',
87
+ '.gif': 'image/gif',
88
+ };
89
+
78
90
  /** Parses and routes `options.operation`; unknown or misplaced values fail loud. */
79
91
  function resolveOperation(options: PodcastPubOptions): { operation: Operation } | { error: string } {
80
92
  const raw = options.operation ?? 'publish';
@@ -333,17 +345,20 @@ async function publishEpisode(
333
345
  });
334
346
  await client.uploadBytes(prepared.presignedUrl, bytes, episode.audio.mimeType);
335
347
 
336
- // Phase 2b: upload a local cover via the presigned image flow (no item_id needed).
348
+ // Phase 2b: upload a local cover via the presigned image flow (no item_id needed). Only the
349
+ // basename is sent as `full_local_file_path`, and the MIME follows the real extension rather
350
+ // than assuming PNG (R4).
337
351
  let imageUrl = episode.imageUrl;
338
352
  if (episode.imagePath && !imageUrl) {
353
+ const imageMime = COVER_MIME[extname(episode.imagePath).toLowerCase()] ?? 'image/png';
339
354
  const imageBytes = new Uint8Array(await readFile(episode.imagePath));
340
355
  const imagePrepared = await client.prepareUpload({
341
356
  category: 'image',
342
- full_local_file_path: episode.imagePath,
357
+ full_local_file_path: basename(episode.imagePath),
343
358
  size: imageBytes.byteLength,
344
- type: 'image/png',
359
+ type: imageMime,
345
360
  });
346
- await client.uploadBytes(imagePrepared.presignedUrl, imageBytes, 'image/png');
361
+ await client.uploadBytes(imagePrepared.presignedUrl, imageBytes, imageMime);
347
362
  imageUrl = imagePrepared.mediaUrl;
348
363
  }
349
364
 
@@ -1,6 +1,10 @@
1
+ import { extname } from 'node:path';
1
2
  import type { Content } from '@gobing-ai/kk-core';
2
3
  import { renderShowNotesHtml } from './show-notes';
3
4
 
5
+ /** Local-cover extensions accepted from `metadata.coverImage` (R4); anything else is rejected. */
6
+ const COVER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
7
+
4
8
  /** Resolved episode fields derived from a Content payload (design D6). */
5
9
  export interface EpisodeMapping {
6
10
  /** Episode title: `content.title`, else the first `#` heading, else validation error. */
@@ -107,6 +111,13 @@ export function mapContentToEpisode(content: Content): EpisodeMapping {
107
111
  // microfeed keeps remote image URLs verbatim (`image` field); a local cover file
108
112
  // goes through the presigned image upload flow (no item_id required) instead.
109
113
  const imageUrl = coverImage?.startsWith('http') ? coverImage : undefined;
114
+ // `coverImage` is agent-steerable via article metadata, so a local path must be a known image
115
+ // extension — otherwise an arbitrary file (/etc/passwd) would be read and uploaded (R4).
116
+ if (coverImage && coverImage.length > 0 && !imageUrl && !COVER_EXTS.has(extname(coverImage).toLowerCase())) {
117
+ throw new Error(
118
+ `invalid metadata.coverImage: expected an http(s) URL or a local image (${[...COVER_EXTS].join(', ')}), got '${coverImage}'`,
119
+ );
120
+ }
110
121
  const imagePath = !imageUrl && coverImage && coverImage.length > 0 ? coverImage : undefined;
111
122
 
112
123
  let datePublishedMs: number | undefined;
@@ -95,6 +95,37 @@ export function computeAudioOffsets(
95
95
  return starts;
96
96
  }
97
97
 
98
+ const isHttpUrl = (u: string): boolean => /^https?:\/\//i.test(u.trim());
99
+
100
+ /** The exact `<style>` block {@link renderTimelineHtml} emits; reused by the marker validator. */
101
+ const TIMELINE_STYLE = `<style>
102
+ .kk-tl{margin:1em 0}
103
+ .kk-tl-row{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid rgba(128,128,128,.25)}
104
+ .kk-tl-t{flex:0 0 56px;font-variant-numeric:tabular-nums;font-weight:700;color:#0969da}
105
+ .kk-tl-body{flex:1;min-width:0}
106
+ .kk-tl-title{font-weight:600}
107
+ .kk-tl-brief{margin:4px 0 0;color:#666;font-size:.95em}
108
+ </style>`;
109
+
110
+ /**
111
+ * Tags {@link renderTimelineHtml} can emit: `div`/`p`/`a` carrying only the exact class, href,
112
+ * target, and rel attributes it produces. Anything else inside a marker block is attacker-supplied.
113
+ */
114
+ const TIMELINE_TAG =
115
+ /^<\/?(?:div|p|a)(?:\s+(?:class="kk-tl(?:-[a-z]+)?"|href="https?:\/\/[^"<>\s]*"|target="_blank"|rel="noopener"))*\s*>$/;
116
+
117
+ /**
118
+ * A marker block is trusted only if it is markup {@link renderTimelineHtml} could have produced.
119
+ * The markers are a legitimate cross-process channel (see {@link renderShowNotesHtml}), so the
120
+ * block is validated rather than stripped: a comment or `<style` inside it is an injection attempt,
121
+ * and any leftover stray `<` means the block is not well-formed generator output.
122
+ */
123
+ function isGeneratedTimeline(block: string): boolean {
124
+ const rest = block.replace(TIMELINE_STYLE, '');
125
+ if (rest.includes('<style') || rest.includes('<!--')) return false;
126
+ return (rest.match(/<[^>]*>/g) ?? []).every((tag) => TIMELINE_TAG.test(tag)) && !/<(?![^>]*>)/.test(rest);
127
+ }
128
+
98
129
  /**
99
130
  * Brief-mode timeline block: two-column HTML (offset left, title + briefing right),
100
131
  * marked `<!--kk:timeline-->` so {@link renderShowNotesHtml} passes it through
@@ -108,25 +139,24 @@ function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string):
108
139
  const title = escapeHtml(d.title ?? `条目 ${i + 1}`);
109
140
  const brief = escapeHtml(itemBrief(d));
110
141
  const at = formatOffset(offsets[i] ?? Number.NaN);
111
- const link = d.sourceUri
112
- ? ` <a href="${escapeHtml(d.sourceUri)}" target="_blank" rel="noopener">来源</a>`
113
- : '';
142
+ // A `sourceUri` that is not http(s) (e.g. `javascript:`) must not become a live href;
143
+ // escaping alone does not neutralise a scheme, so the link is dropped instead (R2).
144
+ const link =
145
+ d.sourceUri && isHttpUrl(d.sourceUri)
146
+ ? ` <a href="${escapeHtml(d.sourceUri)}" target="_blank" rel="noopener">来源</a>`
147
+ : '';
114
148
  return `
115
149
  <div class="kk-tl-row"><div class="kk-tl-t">${at}</div><div class="kk-tl-body"><div class="kk-tl-title">${i + 1}. ${title}${link}</div><p class="kk-tl-brief">${brief}</p></div></div>`;
116
150
  })
117
151
  .join('');
152
+ const articleTail = isHttpUrl(articleUrl)
153
+ ? `<p>完整图文版:<a href="${escapeHtml(articleUrl)}" target="_blank" rel="noopener">${escapeHtml('本期完整文章')}</a></p>`
154
+ : `<p>${escapeHtml('本期完整文章')}</p>`;
118
155
  return `<!--kk:timeline-->
119
- <style>
120
- .kk-tl{margin:1em 0}
121
- .kk-tl-row{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid rgba(128,128,128,.25)}
122
- .kk-tl-t{flex:0 0 56px;font-variant-numeric:tabular-nums;font-weight:700;color:#0969da}
123
- .kk-tl-body{flex:1;min-width:0}
124
- .kk-tl-title{font-weight:600}
125
- .kk-tl-brief{margin:4px 0 0;color:#666;font-size:.95em}
126
- </style>
156
+ ${TIMELINE_STYLE}
127
157
  <div class="kk-tl">${rows}
128
158
  </div>
129
- <p>完整图文版:<a href="${escapeHtml(articleUrl)}" target="_blank" rel="noopener">${escapeHtml('本期完整文章')}</a></p>
159
+ ${articleTail}
130
160
  <!--/kk:timeline-->`;
131
161
  }
132
162
 
@@ -150,6 +180,17 @@ export function renderShowNotesHtml(markdown: string): string {
150
180
  }
151
181
  };
152
182
 
183
+ // Escape every buffered timeline line through the normal paragraph path. Used when the block
184
+ // fails {@link isGeneratedTimeline} and for a marker that is never closed.
185
+ const escapeBufferedTimeline = () => {
186
+ for (const l of timeline) {
187
+ const t = l.trim();
188
+ if (t.length > 0) paragraph.push(t);
189
+ }
190
+ timeline.length = 0;
191
+ flush();
192
+ };
193
+
153
194
  for (const rawLine of markdown.split('\n')) {
154
195
  const line = rawLine.trim();
155
196
  if (line === '<!--kk:timeline-->') {
@@ -160,6 +201,17 @@ export function renderShowNotesHtml(markdown: string): string {
160
201
  if (inTimeline) {
161
202
  if (line === '<!--/kk:timeline-->') {
162
203
  inTimeline = false;
204
+ // The marker is a legitimate cross-process channel: renderShowNotesMarkdown writes
205
+ // the timeline into markdown, and that markdown later arrives as metadata.showNotes.
206
+ // Stripping the markers would break the feature, so the block is validated against
207
+ // the generator's own grammar instead — anything renderTimelineHtml could not have
208
+ // produced is escaped line by line rather than passed through (R2).
209
+ if (isGeneratedTimeline(timeline.join('\n'))) {
210
+ blocks.push(timeline.join('\n'));
211
+ timeline.length = 0;
212
+ } else {
213
+ escapeBufferedTimeline();
214
+ }
163
215
  continue;
164
216
  }
165
217
  timeline.push(rawLine);
@@ -182,7 +234,8 @@ export function renderShowNotesHtml(markdown: string): string {
182
234
  paragraph.push(line);
183
235
  }
184
236
  flush();
185
- if (timeline.length > 0) blocks.push(timeline.join('\n'));
237
+ // An unclosed marker never reaches the closing branch above — escape whatever it buffered.
238
+ if (timeline.length > 0) escapeBufferedTimeline();
186
239
  return blocks.join('\n');
187
240
  }
188
241
 
@@ -21781,6 +21781,22 @@ var ResultSchema = exports_external.object({
21781
21781
  error: exports_external.string().optional(),
21782
21782
  metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21783
21783
  });
21784
+ var TargetOutcomeSchema = exports_external.object({
21785
+ target: exports_external.string(),
21786
+ ok: exports_external.boolean(),
21787
+ status: exports_external.enum(["published", "failed", "skipped"]),
21788
+ url: exports_external.string().optional(),
21789
+ id: exports_external.string().optional(),
21790
+ error: exports_external.string().optional(),
21791
+ durationMs: exports_external.number().optional(),
21792
+ metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21793
+ });
21794
+ var AggregateResultSchema = exports_external.object({
21795
+ ok: exports_external.boolean(),
21796
+ summary: exports_external.object({ total: exports_external.number(), succeeded: exports_external.number(), failed: exports_external.number(), skipped: exports_external.number() }),
21797
+ targets: exports_external.array(TargetOutcomeSchema),
21798
+ metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21799
+ });
21784
21800
  var DocListSchema = exports_external.array(DocSchema);
21785
21801
  var omniVoiceGenContentSchema = ContentSchema.extend({
21786
21802
  format: exports_external.literal("audio"),
@@ -21809,14 +21825,15 @@ var PluginKindSchema = exports_external.enum(PLUGIN_KINDS);
21809
21825
  // ../../packages/kk-core/src/manifest.ts
21810
21826
  init_zod();
21811
21827
  var OperationIOSchema = exports_external.enum(["doc-list", "markdown"]);
21812
- var PluginManifestSchema = exports_external.object({
21828
+ var PluginOperationSchema = exports_external.object({ input: OperationIOSchema, output: OperationIOSchema });
21829
+ var PluginManifestSchema = exports_external.looseObject({
21813
21830
  name: exports_external.string().min(1),
21814
21831
  kind: PluginKindSchema,
21815
21832
  entry: exports_external.string().min(1),
21816
21833
  version: exports_external.string().optional(),
21817
21834
  description: exports_external.string().optional(),
21818
- operations: exports_external.record(exports_external.string(), exports_external.object({ input: OperationIOSchema, output: OperationIOSchema })).optional()
21819
- }).passthrough();
21835
+ operations: exports_external.record(exports_external.string(), PluginOperationSchema).optional()
21836
+ });
21820
21837
  // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.4.39/node_modules/@gobing-ai/ts-runtime/dist/index.js
21821
21838
  init_file_system_node();
21822
21839
  init_config();
@@ -21842,43 +21859,82 @@ function echoError(message, target = defaultStderrTarget ?? processStream("stder
21842
21859
  // ../../packages/kk-core/src/render-md.ts
21843
21860
  if (false) {}
21844
21861
  // ../../packages/publish-harness/src/index.ts
21845
- class FakeFileCliTransport {
21846
- kind = "file-cli";
21847
- callLogs;
21862
+ class FakeTransportCore {
21863
+ callLogs = [];
21848
21864
  nextResult;
21849
- constructor() {
21850
- this.callLogs = [];
21851
- }
21852
21865
  setNextResult(result) {
21853
21866
  this.nextResult = result;
21854
21867
  }
21855
- async publish(payload) {
21868
+ async publish(payload, defaultResult) {
21856
21869
  this.callLogs.push({
21857
21870
  timestamp: new Date().toISOString(),
21858
21871
  payload
21859
21872
  });
21860
- if (this.nextResult) {
21861
- return this.nextResult;
21862
- }
21863
- return {
21873
+ return this.nextResult ?? defaultResult;
21874
+ }
21875
+ getCallLogs() {
21876
+ return this.callLogs;
21877
+ }
21878
+ reset() {
21879
+ this.callLogs = [];
21880
+ this.nextResult = undefined;
21881
+ }
21882
+ }
21883
+
21884
+ class FakeFileCliTransport {
21885
+ kind = "file-cli";
21886
+ core = new FakeTransportCore;
21887
+ setNextResult(result) {
21888
+ this.core.setNextResult(result);
21889
+ }
21890
+ async publish(payload) {
21891
+ return this.core.publish(payload, {
21864
21892
  ok: true,
21865
21893
  target: "fake-file-cli",
21866
21894
  url: "https://example.com/published-post",
21867
21895
  id: "fake-id-123",
21868
21896
  message: "Published offline via FakeFileCliTransport"
21869
- };
21897
+ });
21870
21898
  }
21871
21899
  getCallLogs() {
21872
- return this.callLogs;
21900
+ return this.core.getCallLogs();
21873
21901
  }
21874
21902
  reset() {
21875
- this.callLogs = [];
21876
- this.nextResult = undefined;
21903
+ this.core.reset();
21904
+ }
21905
+ }
21906
+
21907
+ class FakeBrowserTransport {
21908
+ kind = "browser";
21909
+ driver;
21910
+ core = new FakeTransportCore;
21911
+ constructor(driver = "agent") {
21912
+ this.driver = driver;
21913
+ }
21914
+ setNextResult(result) {
21915
+ this.core.setNextResult(result);
21916
+ }
21917
+ async publish(payload) {
21918
+ return this.core.publish(payload, {
21919
+ ok: true,
21920
+ target: "fake-browser",
21921
+ url: "https://example.com/browser-draft",
21922
+ id: "fake-browser-id-123",
21923
+ message: "Published offline via FakeBrowserTransport",
21924
+ metadata: { driver: this.driver }
21925
+ });
21926
+ }
21927
+ getCallLogs() {
21928
+ return this.core.getCallLogs();
21929
+ }
21930
+ reset() {
21931
+ this.core.reset();
21877
21932
  }
21878
21933
  }
21879
21934
 
21880
21935
  // ../../plugins/publishings/qiita-pub/src/index.ts
21881
21936
  var QIITA_ITEMS_URL = "https://qiita.com/api/v2/items";
21937
+ var REQUEST_TIMEOUT_MS = 120000;
21882
21938
  function slugify2(str) {
21883
21939
  return str.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-");
21884
21940
  }
@@ -21944,6 +22000,7 @@ class QiitaApiTransport {
21944
22000
  Authorization: `Bearer ${token}`,
21945
22001
  "Content-Type": "application/json"
21946
22002
  },
22003
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
21947
22004
  body: JSON.stringify({
21948
22005
  title: fields.title,
21949
22006
  body: payload.content.body,
@@ -8,6 +8,9 @@ import { echoError } from '@gobing-ai/ts-utils';
8
8
  /** Qiita API v2 article-create endpoint (audited from wt-publish-to-qiita). */
9
9
  const QIITA_ITEMS_URL = 'https://qiita.com/api/v2/items';
10
10
 
11
+ /** Per-request timeout (ms) for the Qiita call — Bun's `fetch` has no default deadline (R5). */
12
+ const REQUEST_TIMEOUT_MS = 120_000;
13
+
11
14
  /**
12
15
  * Converts a string into a lowercase hyphenated tag (slug) per the publish spec tags convention.
13
16
  * Qiita tag names allow only alphanumeric, hyphen, and underscore, so the slugified form is
@@ -131,6 +134,7 @@ export class QiitaApiTransport implements PublishTransport {
131
134
  Authorization: `Bearer ${token}`,
132
135
  'Content-Type': 'application/json',
133
136
  },
137
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
134
138
  body: JSON.stringify({
135
139
  title: fields.title,
136
140
  body: payload.content.body,