@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
@@ -4866,10 +4866,13 @@ function writeLine(message, target) {
4866
4866
  target.write(`${message}
4867
4867
  `);
4868
4868
  }
4869
+ function echo(message, target = defaultStdoutTarget ?? processStream("stdout")) {
4870
+ writeLine(message, target);
4871
+ }
4869
4872
  function echoError(message, target = defaultStderrTarget ?? processStream("stderr")) {
4870
4873
  writeLine(message, target);
4871
4874
  }
4872
- var defaultStderrTarget;
4875
+ var defaultStdoutTarget, defaultStderrTarget;
4873
4876
 
4874
4877
  // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.32/node_modules/@gobing-ai/ts-utils/dist/index.js
4875
4878
  var init_dist = __esm(() => {
@@ -11981,7 +11984,7 @@ function findProjectRoot(startDir) {
11981
11984
  var init_file_system_node = () => {};
11982
11985
 
11983
11986
  // ../../plugins/publishings/podcast-pub/src/index.ts
11984
- import { readFileSync as readFileSync2 } from "fs";
11987
+ import { readFileSync as readFileSync3 } from "fs";
11985
11988
  import { readFile } from "fs/promises";
11986
11989
  import { basename, dirname as dirname2, extname as extname2 } from "path";
11987
11990
  import { parseArgs } from "util";
@@ -12254,8 +12257,162 @@ var PluginManifestSchema = PluginManifestSchemaBase.superRefine((manifest, ctx)
12254
12257
  init_file_system_node();
12255
12258
  init_config();
12256
12259
 
12260
+ // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.32+180a6e424da77181/node_modules/@gobing-ai/ts-runtime/dist/fs.js
12261
+ init_file_system_node();
12262
+
12263
+ // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.32+180a6e424da77181/node_modules/@gobing-ai/ts-runtime/dist/path.js
12264
+ function normalizeSeparators(path) {
12265
+ return path.replaceAll("\\", "/");
12266
+ }
12267
+ var SEP = globalThis.process?.platform === "win32" ? "\\" : "/";
12268
+ function splitRoot(path) {
12269
+ const normalized = normalizeSeparators(path);
12270
+ const drive = normalized.match(/^([A-Za-z]:)(?:\/|$)/);
12271
+ if (drive) {
12272
+ return { root: drive[1] ?? "", rest: normalized.slice((drive[1] ?? "").length).replace(/^\/+/, "") };
12273
+ }
12274
+ if (normalized.startsWith("/")) {
12275
+ return { root: "/", rest: normalized.replace(/^\/+/, "") };
12276
+ }
12277
+ return { root: "", rest: normalized };
12278
+ }
12279
+ function dirnamePath(path) {
12280
+ const input = normalizeSeparators(path);
12281
+ const { root } = splitRoot(input);
12282
+ if (root !== "" && input.replace(/\/+$/, "") === root)
12283
+ return root === "/" ? "/" : `${root}/`;
12284
+ if (/^\/+$/.test(input))
12285
+ return "/";
12286
+ const normalized = input.replace(/\/+$/, "");
12287
+ if (normalized === "" || normalized === "/")
12288
+ return normalized || ".";
12289
+ const index = normalized.lastIndexOf("/");
12290
+ if (index < 0)
12291
+ return ".";
12292
+ if (index === 0)
12293
+ return "/";
12294
+ return normalized.slice(0, index);
12295
+ }
12296
+
12297
+ // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.32+180a6e424da77181/node_modules/@gobing-ai/ts-runtime/dist/fs.js
12298
+ async function ensureDirForFile(path, fs = createNodeFileSystem()) {
12299
+ await fs.ensureDir(dirnamePath(path));
12300
+ }
12301
+ async function readJsonFile(path, fs = createNodeFileSystem()) {
12302
+ const content = await fs.readFile(path);
12303
+ try {
12304
+ return JSON.parse(content);
12305
+ } catch (error) {
12306
+ throw new Error(`Failed to parse JSON file "${path}": ${error.message}`);
12307
+ }
12308
+ }
12309
+ async function writeJsonFile(path, value, fs = createNodeFileSystem()) {
12310
+ await fs.writeFile(path, `${JSON.stringify(value, null, 2)}
12311
+ `);
12312
+ }
12257
12313
  // ../../packages/kk-core/src/render-md.ts
12258
12314
  if (false) {}
12315
+ // ../../packages/kk-core/src/sentences.ts
12316
+ var CJK_TERMINATORS = new Set(["\u3002", "\uFF01", "\uFF1F"]);
12317
+ var LATIN_TERMINATORS = new Set([".", "!", "?"]);
12318
+ var ATTACHED_CLOSERS = new Set(["\u201D", "\u2019", "\u300D", "\u300F", "\uFF09", ")", '"', "'"]);
12319
+ var OPEN_PAIRS = {
12320
+ "\u300C": "\u300D",
12321
+ "\u300E": "\u300F",
12322
+ "\u201C": "\u201D",
12323
+ "\uFF08": "\uFF09",
12324
+ "(": ")"
12325
+ };
12326
+ var CLOSE_PAIRS = {
12327
+ "\u300D": "\u300C",
12328
+ "\u300F": "\u300E",
12329
+ "\u201D": "\u201C",
12330
+ "\uFF09": "\uFF08",
12331
+ ")": "("
12332
+ };
12333
+ function splitSentences(text, options = {}) {
12334
+ if (!text || typeof text !== "string")
12335
+ return [];
12336
+ const results = [];
12337
+ let sentenceStart = 0;
12338
+ let stack = [];
12339
+ let i = 0;
12340
+ const isTerminator = (c) => CJK_TERMINATORS.has(c) || options.cjkOnly !== true && LATIN_TERMINATORS.has(c);
12341
+ while (i < text.length) {
12342
+ const ch = text[i];
12343
+ if (isTerminator(ch)) {
12344
+ let termEnd = i;
12345
+ let hasCjkTerm = false;
12346
+ let hasLatinTerm = false;
12347
+ while (termEnd < text.length) {
12348
+ const termChar = text[termEnd];
12349
+ if (!termChar || !isTerminator(termChar)) {
12350
+ break;
12351
+ }
12352
+ if (CJK_TERMINATORS.has(termChar))
12353
+ hasCjkTerm = true;
12354
+ if (LATIN_TERMINATORS.has(termChar))
12355
+ hasLatinTerm = true;
12356
+ termEnd++;
12357
+ }
12358
+ let closerEnd = termEnd;
12359
+ const tempStack = [...stack];
12360
+ while (closerEnd < text.length) {
12361
+ const closer = text[closerEnd];
12362
+ if (!closer || !ATTACHED_CLOSERS.has(closer)) {
12363
+ break;
12364
+ }
12365
+ const top = tempStack[tempStack.length - 1];
12366
+ if (top !== undefined && top === closer) {
12367
+ tempStack.pop();
12368
+ } else if (CLOSE_PAIRS[closer]) {
12369
+ const idx = tempStack.lastIndexOf(closer);
12370
+ if (idx >= 0)
12371
+ tempStack.splice(idx, 1);
12372
+ }
12373
+ closerEnd++;
12374
+ }
12375
+ let isBoundary = false;
12376
+ if (tempStack.length === 0) {
12377
+ if (hasCjkTerm) {
12378
+ isBoundary = true;
12379
+ } else if (hasLatinTerm) {
12380
+ const nextChar = text[closerEnd];
12381
+ if (closerEnd === text.length || nextChar !== undefined && /\s/.test(nextChar)) {
12382
+ isBoundary = true;
12383
+ }
12384
+ }
12385
+ }
12386
+ if (isBoundary) {
12387
+ const sentence = text.slice(sentenceStart, closerEnd).trim();
12388
+ if (sentence.length > 0) {
12389
+ results.push(sentence);
12390
+ }
12391
+ sentenceStart = closerEnd;
12392
+ stack = tempStack;
12393
+ i = closerEnd;
12394
+ continue;
12395
+ }
12396
+ }
12397
+ const openMatch = OPEN_PAIRS[ch];
12398
+ const closeMatch = CLOSE_PAIRS[ch];
12399
+ if (openMatch) {
12400
+ stack.push(openMatch);
12401
+ } else if (closeMatch) {
12402
+ const idx = stack.lastIndexOf(ch);
12403
+ if (idx >= 0)
12404
+ stack.splice(idx, 1);
12405
+ }
12406
+ i++;
12407
+ }
12408
+ if (sentenceStart < text.length) {
12409
+ const tail = text.slice(sentenceStart).trim();
12410
+ if (tail.length > 0) {
12411
+ results.push(tail);
12412
+ }
12413
+ }
12414
+ return results;
12415
+ }
12259
12416
  // ../../packages/publish-harness/src/index.ts
12260
12417
  class FakeTransportCore {
12261
12418
  callLogs = [];
@@ -12305,207 +12462,1579 @@ class FakeFileCliTransport {
12305
12462
  // ../../plugins/publishings/podcast-pub/src/index.ts
12306
12463
  init_dist();
12307
12464
 
12308
- // ../../plugins/publishings/podcast-pub/src/map.ts
12309
- import { extname } from "path";
12465
+ // ../../plugins/publishings/podcast-pub/src/backfill.ts
12466
+ init_dist();
12310
12467
 
12311
- // ../../plugins/publishings/podcast-pub/src/show-notes.ts
12312
- function escapeHtml(value) {
12313
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
12314
- }
12315
- function renderInline(escaped) {
12316
- let out = "";
12317
- let rest = escaped;
12318
- for (;; ) {
12319
- const match = rest.match(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/);
12320
- if (!match || match.index === undefined)
12321
- break;
12322
- out += `${rest.slice(0, match.index)}<a href="${match[2]}" target="_blank" rel="noopener">${match[1]}</a>`;
12323
- rest = rest.slice(match.index + match[0].length);
12468
+ // ../../plugins/publishings/podcast-pub/src/microfeed-client.ts
12469
+ class MicrofeedRequestError extends Error {
12470
+ status;
12471
+ constructor(status, message) {
12472
+ super(message);
12473
+ this.name = "MicrofeedRequestError";
12474
+ this.status = status;
12324
12475
  }
12325
- return (out + rest).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
12326
- }
12327
- function normalizeForMatch(text) {
12328
- return text.toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, "");
12329
- }
12330
- function formatOffset(seconds) {
12331
- if (!Number.isFinite(seconds) || seconds < 0)
12332
- return "--:--";
12333
- const m = Math.floor(seconds / 60);
12334
- const s = Math.round(seconds % 60);
12335
- return s === 60 ? `${m + 1}:00` : `${m}:${String(s).padStart(2, "0")}`;
12336
12476
  }
12337
- function computeAudioOffsets(docs, segments) {
12338
- const fallback = () => {
12339
- const chars = docs.map((d) => Math.max((d.body ?? "").trim().length, 1));
12340
- const total = chars.reduce((a, b) => a + b, 0);
12341
- const sumDuration = (segments ?? []).reduce((a, s) => a + (s.duration ?? 0), 0);
12342
- let acc = 0;
12343
- return chars.map((c) => {
12344
- const at = acc;
12345
- acc += c / total * sumDuration;
12346
- return at;
12347
- });
12348
- };
12349
- if (!segments || segments.length < 2)
12350
- return fallback();
12351
- const starts = [];
12352
- let elapsed = 0;
12353
- const marks = segments.map((s) => {
12354
- const at = elapsed;
12355
- elapsed += s.duration ?? 0;
12356
- return { at, text: normalizeForMatch(s.transcription ?? "") };
12357
- });
12358
- let cursor = 1;
12359
- for (const d of docs) {
12360
- const title = normalizeForMatch(d.title ?? "");
12361
- const needle = title.slice(0, 12);
12362
- const hit = needle.length >= 4 ? marks.findIndex((m, i) => i >= cursor && m.text.includes(needle)) : -1;
12363
- if (hit >= 0) {
12364
- starts.push(marks[hit]?.at ?? 0);
12365
- cursor = hit + 1;
12366
- } else {
12367
- starts.push(Number.NaN);
12368
- }
12477
+ var MIN_TIMEOUT_MS = 1000;
12478
+ var DEFAULT_TIMEOUT_MS = 300000;
12479
+ function resolveTimeoutMs(raw) {
12480
+ const parsed = raw === undefined ? Number.NaN : Number(raw);
12481
+ if (!Number.isFinite(parsed) || parsed < MIN_TIMEOUT_MS) {
12482
+ return DEFAULT_TIMEOUT_MS;
12369
12483
  }
12370
- if (starts.some((v) => !Number.isFinite(v)))
12371
- return fallback();
12372
- return starts;
12373
- }
12374
- var isHttpUrl = (u) => /^https?:\/\//i.test(u.trim());
12375
- var TIMELINE_STYLE = `<style>
12376
- .kk-tl{margin:1em 0}
12377
- .kk-tl-row{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid rgba(128,128,128,.25)}
12378
- .kk-tl-t{flex:0 0 56px;font-variant-numeric:tabular-nums;font-weight:700;color:#0969da}
12379
- .kk-tl-body{flex:1;min-width:0}
12380
- .kk-tl-title{font-weight:600}
12381
- .kk-tl-brief{margin:4px 0 0;color:#666;font-size:.95em}
12382
- </style>`;
12383
- var TIMELINE_TAG = /^<\/?(?:div|p|a)(?:\s+(?:class="kk-tl(?:-[a-z]+)?"|href="https?:\/\/[^"<>\s]*"|target="_blank"|rel="noopener"))*\s*>$/;
12384
- function isGeneratedTimeline(block) {
12385
- const rest = block.replace(TIMELINE_STYLE, "");
12386
- if (rest.includes("<style") || rest.includes("<!--"))
12387
- return false;
12388
- return (rest.match(/<[^>]*>/g) ?? []).every((tag) => TIMELINE_TAG.test(tag)) && !/<(?![^>]*>)/.test(rest);
12389
- }
12390
- function renderTimelineHtml(docs, offsets, articleUrl) {
12391
- const rows = docs.map((d, i) => {
12392
- const title = escapeHtml(d.title ?? `\u6761\u76EE ${i + 1}`);
12393
- const brief = escapeHtml(itemBrief(d));
12394
- const at = formatOffset(offsets[i] ?? Number.NaN);
12395
- const link = d.sourceUri && isHttpUrl(d.sourceUri) ? ` <a href="${escapeHtml(d.sourceUri)}" target="_blank" rel="noopener">\u6765\u6E90</a>` : "";
12396
- return `
12397
- <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>`;
12398
- }).join("");
12399
- 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>`;
12400
- return `<!--kk:timeline-->
12401
- ${TIMELINE_STYLE}
12402
- <div class="kk-tl">${rows}
12403
- </div>
12404
- ${articleTail}
12405
- <!--/kk:timeline-->`;
12484
+ return parsed;
12406
12485
  }
12407
- function renderShowNotesHtml(markdown) {
12408
- const blocks = [];
12409
- const timeline = [];
12410
- let inTimeline = false;
12411
- let paragraph = [];
12412
- const flush = () => {
12413
- if (paragraph.length > 0) {
12414
- blocks.push(`<p>${renderInline(escapeHtml(paragraph.join(" ")))}</p>`);
12415
- paragraph = [];
12416
- }
12417
- };
12418
- const escapeBufferedTimeline = () => {
12419
- for (const l of timeline) {
12420
- const t = l.trim();
12421
- if (t.length > 0)
12422
- paragraph.push(t);
12423
- }
12424
- timeline.length = 0;
12425
- flush();
12426
- };
12427
- for (const rawLine of markdown.split(`
12428
- `)) {
12429
- const line = rawLine.trim();
12430
- if (line === "<!--kk:timeline-->") {
12431
- flush();
12432
- inTimeline = true;
12433
- continue;
12434
- }
12435
- if (inTimeline) {
12436
- if (line === "<!--/kk:timeline-->") {
12437
- inTimeline = false;
12438
- if (isGeneratedTimeline(timeline.join(`
12439
- `))) {
12440
- blocks.push(timeline.join(`
12441
- `));
12442
- timeline.length = 0;
12443
- } else {
12444
- escapeBufferedTimeline();
12445
- }
12446
- continue;
12447
- }
12448
- timeline.push(rawLine);
12449
- continue;
12450
- }
12451
- if (line.length === 0) {
12452
- flush();
12453
- continue;
12486
+
12487
+ class MicrofeedClient {
12488
+ baseUrl;
12489
+ apiKey;
12490
+ timeoutMs;
12491
+ constructor(baseUrl, apiKey, timeoutMs) {
12492
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
12493
+ this.apiKey = apiKey;
12494
+ this.timeoutMs = timeoutMs;
12495
+ }
12496
+ itemUrl(itemId) {
12497
+ return `${this.baseUrl}/items/${itemId}`;
12498
+ }
12499
+ async request(path, init) {
12500
+ let response;
12501
+ try {
12502
+ response = await fetch(`${this.baseUrl}${path}`, {
12503
+ ...init,
12504
+ headers: {
12505
+ Authorization: `Bearer ${this.apiKey}`,
12506
+ Origin: this.baseUrl,
12507
+ ...init.headers
12508
+ },
12509
+ signal: AbortSignal.timeout(this.timeoutMs)
12510
+ });
12511
+ } catch (error) {
12512
+ const reason = error instanceof Error ? error.message : String(error);
12513
+ throw new Error(`microfeed transport error (${init.method ?? "GET"} ${path}): ${reason}`);
12454
12514
  }
12455
- const heading = line.match(/^(#{1,3})\s+(.*)$/);
12456
- if (heading) {
12457
- flush();
12458
- const tag = heading[1]?.length === 1 ? "h1" : heading[1]?.length === 2 ? "h2" : "h3";
12459
- if (tag === "h1" && blocks.length === 0 && paragraph.length === 0) {
12460
- continue;
12461
- }
12462
- blocks.push(`<${tag}>${renderInline(escapeHtml(heading[2] ?? ""))}</${tag}>`);
12463
- continue;
12515
+ if (!response.ok) {
12516
+ const text = (await response.text()).slice(0, 1000);
12517
+ let detail = text.trim();
12518
+ try {
12519
+ const parsed = JSON.parse(text);
12520
+ if (typeof parsed.error === "string")
12521
+ detail = parsed.error;
12522
+ } catch {}
12523
+ throw new MicrofeedRequestError(response.status, `microfeed API request failed (HTTP ${response.status} ${response.statusText} ${path}): ${detail || "Unknown error"}`);
12464
12524
  }
12465
- paragraph.push(line);
12466
- }
12467
- flush();
12468
- if (timeline.length > 0)
12469
- escapeBufferedTimeline();
12470
- return blocks.join(`
12471
- `);
12472
- }
12473
- function itemBrief(d) {
12474
- const meta = d.metadata ?? {};
12475
- if (typeof meta.brief === "string" && meta.brief.trim().length > 0)
12476
- return meta.brief.trim();
12477
- return (d.body ?? "").trim().split(/(?<=[\u3002\uFF01\uFF1F])\s*/).slice(0, 2).join("");
12478
- }
12479
- function renderShowNotesMarkdown(docs, opts) {
12480
- const slugify = (t) => {
12481
- const s = t.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-");
12482
- return s.length > 0 ? s : "post";
12483
- };
12484
- const baseUrl = opts.articleBaseUrl ?? "https://surfing.salty.vip";
12485
- const segments = opts.articlePostPath?.split(/[\\/]/).filter((s) => s.length > 0) ?? [];
12486
- const postSlug = segments.length >= 2 ? segments[segments.length - 2] ?? "" : "";
12487
- const postLocale = segments.find((s) => ["zh", "en", "ja"].includes(s)) ?? "zh";
12488
- const localePrefix = postLocale === "en" ? "" : `/${postLocale}`;
12489
- const articleUrl = postSlug.length > 0 ? `${baseUrl}${localePrefix}/posts/${postSlug}` : `${baseUrl}/zh/posts/${opts.runDate}-${slugify(opts.title)}`;
12490
- const briefMode = opts.briefMode === true && postSlug.length > 0;
12491
- const sourceLabel = (d) => {
12492
- const m = d.metadata ?? {};
12493
- if (typeof m.sourceName === "string" && m.sourceName.trim())
12494
- return m.sourceName.trim();
12495
12525
  try {
12496
- return new URL(d.sourceUri ?? "").hostname.replace(/^www\./, "");
12526
+ return await response.json();
12497
12527
  } catch {
12498
- return "\u6765\u6E90";
12528
+ throw new Error(`microfeed API returned a non-JSON response from ${path}`);
12499
12529
  }
12500
- };
12501
- const offsets = computeAudioOffsets(docs, opts.audioSegments);
12502
- const lines = [`# ${opts.title}`, ""];
12503
- if (briefMode) {
12504
- lines.push(renderTimelineHtml(docs, offsets, articleUrl), "");
12530
+ }
12531
+ async createItem(input, idempotencyKey) {
12532
+ const data = await this.request("/api/v1/items", {
12533
+ method: "POST",
12534
+ headers: {
12535
+ "Content-Type": "application/json",
12536
+ ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}
12537
+ },
12538
+ body: JSON.stringify(input)
12539
+ });
12540
+ if (typeof data.id !== "string" || data.id.length === 0) {
12541
+ throw new Error("microfeed item create response is missing the item id");
12542
+ }
12543
+ return data.id;
12544
+ }
12545
+ async validateItem(input) {
12546
+ await this.request("/api/v1/items/validate", {
12547
+ method: "POST",
12548
+ headers: { "Content-Type": "application/json" },
12549
+ body: JSON.stringify(input)
12550
+ });
12551
+ }
12552
+ async getItem(itemId) {
12553
+ return await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
12554
+ method: "GET"
12555
+ });
12556
+ }
12557
+ async updateItem(itemId, input) {
12558
+ return await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
12559
+ method: "PUT",
12560
+ headers: { "Content-Type": "application/json" },
12561
+ body: JSON.stringify(input)
12562
+ });
12563
+ }
12564
+ async deleteItem(itemId) {
12565
+ await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
12566
+ method: "DELETE",
12567
+ headers: { "Content-Type": "application/json" }
12568
+ });
12569
+ }
12570
+ async listItems() {
12571
+ const first = await this.request("/api/v1/feed", { method: "GET" });
12572
+ const items = [...first.items ?? []];
12573
+ const seen = new Set(["/api/v1/feed"]);
12574
+ let next = typeof first.next_url === "string" ? first.next_url : "";
12575
+ for (let page = 1;page < 20 && next !== "" && !seen.has(next); page++) {
12576
+ seen.add(next);
12577
+ const path = next.startsWith(this.baseUrl) ? next.slice(this.baseUrl.length) || "/" : next;
12578
+ if (!path.startsWith("/"))
12579
+ break;
12580
+ const more = await this.request(path, { method: "GET" });
12581
+ items.push(...more.items ?? []);
12582
+ next = typeof more.next_url === "string" ? more.next_url : "";
12583
+ }
12584
+ return { ...first, items };
12585
+ }
12586
+ async prepareUpload(input) {
12587
+ const data = await this.request("/api/v1/media_files/presigned_urls", {
12588
+ method: "POST",
12589
+ headers: { "Content-Type": "application/json" },
12590
+ body: JSON.stringify(input)
12591
+ });
12592
+ if (typeof data.presigned_url !== "string" || typeof data.media_url !== "string") {
12593
+ throw new Error("microfeed upload-preparation response is missing presigned_url/media_url");
12594
+ }
12595
+ return { presignedUrl: data.presigned_url, mediaUrl: data.media_url };
12596
+ }
12597
+ async uploadBytes(presignedUrl, bytes, mimeType) {
12598
+ let response;
12599
+ try {
12600
+ response = await fetch(presignedUrl, {
12601
+ method: "PUT",
12602
+ headers: { "Content-Type": mimeType },
12603
+ body: bytes,
12604
+ signal: AbortSignal.timeout(this.timeoutMs)
12605
+ });
12606
+ } catch (error) {
12607
+ const reason = error instanceof Error ? error.message : String(error);
12608
+ throw new Error(`microfeed transport error (PUT audio bytes): ${reason}`);
12609
+ }
12610
+ if (!response.ok) {
12611
+ const text = (await response.text()).slice(0, 1000);
12612
+ throw new Error(`microfeed audio upload failed (HTTP ${response.status} ${response.statusText}): ${text.trim() || "Unknown error"}`);
12613
+ }
12614
+ }
12615
+ }
12616
+
12617
+ // ../../plugins/publishings/podcast-pub/src/seo.ts
12618
+ import { readFileSync as readFileSync2 } from "fs";
12619
+ init_dist();
12620
+ function codePointLength(value) {
12621
+ return Array.from(value).length;
12622
+ }
12623
+ var WARN_PREFIX = "WARN podcast-pub:";
12624
+ function warn(message, sink) {
12625
+ const line = message.startsWith(WARN_PREFIX) ? message : `${WARN_PREFIX} ${message}`;
12626
+ echoError(line);
12627
+ sink?.push(line);
12628
+ }
12629
+ var SEO_DEFAULTS = {
12630
+ seo: true,
12631
+ titleMode: "as-is",
12632
+ titlePattern: "{series} \xB7 {date}\uFF1A{keywords}",
12633
+ seriesName: "\u6BCF\u65E5AI\u4E71\u7096",
12634
+ showName: "\u54B8\u8BDD\u54B8\u8BF4",
12635
+ titleMax: 55,
12636
+ titleMaxKeywords: 2,
12637
+ seoTitleSuffix: "\uFF5C\u54B8\u8BDD\u54B8\u8BF4",
12638
+ seoTitleMax: 60,
12639
+ descriptionMin: 120,
12640
+ descriptionMax: 155,
12641
+ descriptionSource: "plan",
12642
+ descriptionPattern: "{show}\xB7{series} {date}\uFF08\u7EA6 {minutes} \u5206\u949F\uFF09\uFF1A{clauses}\u3002",
12643
+ summaryParagraph: true,
12644
+ timelineStyle: "after",
12645
+ autoEpisodeNumber: true,
12646
+ titleSanitize: "normalize",
12647
+ linkField: "none",
12648
+ slugMode: "pattern",
12649
+ slugPattern: "daily-ai-{date}",
12650
+ socialImage: false,
12651
+ socialImageAltPattern: "{series} {date} \u5C01\u9762\uFF1A{keywords}",
12652
+ roundDuration: true
12653
+ };
12654
+ var ENV_KEYS = {
12655
+ seo: "PODCAST_PUB_SEO",
12656
+ titleMode: "PODCAST_PUB_EPISODE_TITLE_MODE",
12657
+ titlePattern: "PODCAST_PUB_EPISODE_TITLE_PATTERN",
12658
+ seriesName: "PODCAST_PUB_SERIES_NAME",
12659
+ showName: "PODCAST_PUB_SHOW_NAME",
12660
+ titleMax: "PODCAST_PUB_TITLE_MAX",
12661
+ titleMaxKeywords: "PODCAST_PUB_TITLE_MAX_KEYWORDS",
12662
+ seoTitleSuffix: "PODCAST_PUB_SEO_TITLE_SUFFIX",
12663
+ seoTitleMax: "PODCAST_PUB_SEO_TITLE_MAX",
12664
+ descriptionMin: "PODCAST_PUB_SEO_DESCRIPTION_MIN",
12665
+ descriptionMax: "PODCAST_PUB_SEO_DESCRIPTION_MAX",
12666
+ descriptionSource: "PODCAST_PUB_SEO_DESCRIPTION_SOURCE",
12667
+ descriptionPattern: "PODCAST_PUB_SEO_DESCRIPTION_PATTERN",
12668
+ summaryParagraph: "PODCAST_PUB_SUMMARY_PARAGRAPH",
12669
+ timelineStyle: "PODCAST_PUB_TIMELINE_INLINE_STYLE",
12670
+ autoEpisodeNumber: "PODCAST_PUB_AUTO_EPISODE_NUMBER",
12671
+ titleSanitize: "PODCAST_PUB_TITLE_SANITIZE",
12672
+ linkField: "PODCAST_PUB_LINK_FIELD",
12673
+ slugMode: "PODCAST_PUB_SLUG",
12674
+ slugPattern: "PODCAST_PUB_SLUG_PATTERN",
12675
+ socialImage: "PODCAST_PUB_SOCIAL_IMAGE",
12676
+ socialImageAltPattern: "PODCAST_PUB_SOCIAL_IMAGE_ALT_PATTERN",
12677
+ roundDuration: "PODCAST_PUB_ROUND_DURATION"
12678
+ };
12679
+ function asRecord(value) {
12680
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
12681
+ return value;
12682
+ }
12683
+ return;
12684
+ }
12685
+ function podcastOf(metadata) {
12686
+ return asRecord(metadata?.podcast) ?? {};
12687
+ }
12688
+ function pick2(metadata, podcastKey, envKey, env) {
12689
+ const podcast = podcastOf(metadata);
12690
+ if (Object.hasOwn(podcast, podcastKey)) {
12691
+ const value = podcast[podcastKey];
12692
+ if (value !== undefined && value !== "")
12693
+ return { value, key: `metadata.podcast.${podcastKey}` };
12694
+ }
12695
+ const fromEnv = env[envKey];
12696
+ if (fromEnv !== undefined && fromEnv !== "")
12697
+ return { value: fromEnv, key: envKey };
12698
+ return;
12699
+ }
12700
+ function invalid(key, value, expected) {
12701
+ return `invalid ${key} "${String(value)}": expected ${expected}`;
12702
+ }
12703
+ function parseBool(raw, fallback) {
12704
+ if (!raw)
12705
+ return fallback;
12706
+ const value = raw.value;
12707
+ if (value === true || value === "true" || value === "1")
12708
+ return true;
12709
+ if (value === false || value === "false" || value === "0")
12710
+ return false;
12711
+ return invalid(raw.key, value, "true or false");
12712
+ }
12713
+ function parseIntIn(raw, fallback, min) {
12714
+ if (!raw)
12715
+ return fallback;
12716
+ const value = typeof raw.value === "number" ? raw.value : Number(raw.value);
12717
+ if (!Number.isInteger(value) || value < min)
12718
+ return invalid(raw.key, raw.value, `an integer \u2265 ${min}`);
12719
+ return value;
12720
+ }
12721
+ function parseEnum(raw, allowed, fallback) {
12722
+ if (!raw)
12723
+ return fallback;
12724
+ const value = String(raw.value);
12725
+ if (allowed.includes(value))
12726
+ return value;
12727
+ return invalid(raw.key, raw.value, allowed.join(" or "));
12728
+ }
12729
+ function resolvePodcastSeoConfig(metadata, env = process.env) {
12730
+ const bool = (podcastKey, envKey, fallback) => parseBool(pick2(metadata, podcastKey, envKey, env), fallback);
12731
+ const int = (podcastKey, envKey, fallback, min) => parseIntIn(pick2(metadata, podcastKey, envKey, env), fallback, min);
12732
+ const text = (podcastKey, envKey, fallback) => {
12733
+ const raw = pick2(metadata, podcastKey, envKey, env);
12734
+ return raw ? String(raw.value) : fallback;
12735
+ };
12736
+ const seo = bool("seo", ENV_KEYS.seo, SEO_DEFAULTS.seo);
12737
+ const titleMode = parseEnum(pick2(metadata, "titleMode", ENV_KEYS.titleMode, env), ["as-is", "headline"], SEO_DEFAULTS.titleMode);
12738
+ const titleMax = int("titleMax", ENV_KEYS.titleMax, SEO_DEFAULTS.titleMax, 1);
12739
+ const titleMaxKeywords = int("titleMaxKeywords", ENV_KEYS.titleMaxKeywords, SEO_DEFAULTS.titleMaxKeywords, 1);
12740
+ const seoTitleMax = int("seoTitleMax", ENV_KEYS.seoTitleMax, SEO_DEFAULTS.seoTitleMax, 1);
12741
+ const descriptionMin = int("descriptionMin", ENV_KEYS.descriptionMin, SEO_DEFAULTS.descriptionMin, 1);
12742
+ const descriptionMax = int("descriptionMax", ENV_KEYS.descriptionMax, SEO_DEFAULTS.descriptionMax, 1);
12743
+ const descriptionSource = parseEnum(pick2(metadata, "descriptionSource", ENV_KEYS.descriptionSource, env), ["plan", "surfing"], SEO_DEFAULTS.descriptionSource);
12744
+ const summaryParagraph = bool("summaryParagraph", ENV_KEYS.summaryParagraph, SEO_DEFAULTS.summaryParagraph);
12745
+ const timelineStyle = parseEnum(pick2(metadata, "timelineInlineStyle", ENV_KEYS.timelineStyle, env), ["after", "before", "omit", "true", "false"], "after");
12746
+ const autoEpisodeNumber = bool("autoEpisodeNumber", ENV_KEYS.autoEpisodeNumber, SEO_DEFAULTS.autoEpisodeNumber);
12747
+ const titleSanitize = parseEnum(pick2(metadata, "titleSanitize", ENV_KEYS.titleSanitize, env), ["normalize", "reject"], SEO_DEFAULTS.titleSanitize);
12748
+ const linkField = parseEnum(pick2(metadata, "linkField", ENV_KEYS.linkField, env), ["none", "url"], SEO_DEFAULTS.linkField);
12749
+ const slugMode = parseEnum(pick2(metadata, "slug", ENV_KEYS.slugMode, env), ["auto", "pattern"], SEO_DEFAULTS.slugMode);
12750
+ const socialImage = bool("socialImageEnabled", ENV_KEYS.socialImage, SEO_DEFAULTS.socialImage);
12751
+ const roundDuration = bool("roundDuration", ENV_KEYS.roundDuration, SEO_DEFAULTS.roundDuration);
12752
+ const parsed = [
12753
+ seo,
12754
+ titleMode,
12755
+ titleMax,
12756
+ titleMaxKeywords,
12757
+ seoTitleMax,
12758
+ descriptionMin,
12759
+ descriptionMax,
12760
+ descriptionSource,
12761
+ summaryParagraph,
12762
+ timelineStyle,
12763
+ autoEpisodeNumber,
12764
+ titleSanitize,
12765
+ linkField,
12766
+ slugMode,
12767
+ socialImage,
12768
+ roundDuration
12769
+ ];
12770
+ const failure = parsed.find((value) => typeof value === "string" && value.startsWith("invalid "));
12771
+ if (typeof failure === "string")
12772
+ return { error: failure };
12773
+ if (descriptionMin > descriptionMax) {
12774
+ return {
12775
+ error: `invalid ${ENV_KEYS.descriptionMin}: ${String(descriptionMin)} is greater than ${ENV_KEYS.descriptionMax} ${String(descriptionMax)}`
12776
+ };
12777
+ }
12778
+ const seoTitleSuffix = text("seoTitleSuffix", ENV_KEYS.seoTitleSuffix, SEO_DEFAULTS.seoTitleSuffix);
12779
+ if (codePointLength(seoTitleSuffix) >= seoTitleMax) {
12780
+ return {
12781
+ error: `invalid ${ENV_KEYS.seoTitleSuffix}: suffix is longer than ${ENV_KEYS.seoTitleMax} (${String(seoTitleMax)})`
12782
+ };
12783
+ }
12784
+ const styleRaw = timelineStyle;
12785
+ const timeline = styleRaw === "true" || styleRaw === "after" ? "after" : styleRaw === "false" || styleRaw === "omit" ? "omit" : "before";
12786
+ return {
12787
+ config: {
12788
+ seo,
12789
+ titleMode,
12790
+ titlePattern: text("titlePattern", ENV_KEYS.titlePattern, SEO_DEFAULTS.titlePattern),
12791
+ seriesName: text("seriesName", ENV_KEYS.seriesName, SEO_DEFAULTS.seriesName),
12792
+ showName: text("showName", ENV_KEYS.showName, SEO_DEFAULTS.showName),
12793
+ titleMax,
12794
+ titleMaxKeywords,
12795
+ seoTitleSuffix,
12796
+ seoTitleMax,
12797
+ descriptionMin,
12798
+ descriptionMax,
12799
+ descriptionSource,
12800
+ descriptionPattern: text("descriptionPattern", ENV_KEYS.descriptionPattern, SEO_DEFAULTS.descriptionPattern),
12801
+ summaryParagraph,
12802
+ timelineStyle: timeline,
12803
+ autoEpisodeNumber,
12804
+ titleSanitize,
12805
+ linkField,
12806
+ slugMode,
12807
+ slugPattern: text("slugPattern", ENV_KEYS.slugPattern, SEO_DEFAULTS.slugPattern),
12808
+ socialImage,
12809
+ socialImageAltPattern: text("socialImageAltPattern", ENV_KEYS.socialImageAltPattern, SEO_DEFAULTS.socialImageAltPattern),
12810
+ roundDuration
12811
+ }
12812
+ };
12813
+ }
12814
+ function hasControlChar(value) {
12815
+ for (const ch of value) {
12816
+ const cp = ch.codePointAt(0) ?? 0;
12817
+ if (cp <= 31 || cp >= 127 && cp <= 159 || cp === 8232 || cp === 8233)
12818
+ return true;
12819
+ }
12820
+ return false;
12821
+ }
12822
+ function forbiddenChars(value) {
12823
+ const found = [];
12824
+ if (value.includes('"'))
12825
+ found.push('"');
12826
+ if (value.includes("\\"))
12827
+ found.push("\\");
12828
+ if (value.includes("<"))
12829
+ found.push("<");
12830
+ if (value.includes(">"))
12831
+ found.push(">");
12832
+ if (hasControlChar(value))
12833
+ found.push("control");
12834
+ return found;
12835
+ }
12836
+ function sanitizeTitle(value, mode) {
12837
+ const forbidden = forbiddenChars(value);
12838
+ if (mode === "reject" && forbidden.length > 0) {
12839
+ return { ok: false, error: `title contains forbidden characters: ${forbidden.join(", ")}` };
12840
+ }
12841
+ const chars = Array.from(value);
12842
+ const quoteAt = [];
12843
+ chars.forEach((ch, index) => {
12844
+ if (ch === '"')
12845
+ quoteAt.push(index);
12846
+ });
12847
+ const odd = quoteAt.length % 2 === 1;
12848
+ for (let n = 0;n < quoteAt.length; n++) {
12849
+ const index = quoteAt[n] ?? 0;
12850
+ const last = n === quoteAt.length - 1;
12851
+ chars[index] = odd && last ? "\u201D" : n % 2 === 0 ? "\u201C" : "\u201D";
12852
+ }
12853
+ let out = "";
12854
+ for (const ch of chars) {
12855
+ const cp = ch.codePointAt(0) ?? 0;
12856
+ if (cp <= 31 || cp >= 127 && cp <= 159 || cp === 8232 || cp === 8233)
12857
+ continue;
12858
+ if (ch === "\\")
12859
+ out += "\uFF3C";
12860
+ else if (ch === "<")
12861
+ out += "\u3008";
12862
+ else if (ch === ">")
12863
+ out += "\u3009";
12864
+ else
12865
+ out += ch;
12866
+ }
12867
+ return { ok: true, value: out.trim() };
12868
+ }
12869
+ function spaceCjkLatin(text) {
12870
+ return text.replace(/(\p{Script=Han})([A-Za-z0-9])/gu, "$1 $2").replace(/([A-Za-z0-9])(\p{Script=Han})/gu, "$1 $2");
12871
+ }
12872
+ function normalizeEpisodeTitle(title, seriesName) {
12873
+ let out = title.replace(/\s+/g, " ").trim();
12874
+ const legacy = /^(?:\u6BCF\u65E5 AI \u8D44\u8BAF|\u6BCF\u65E5AI\u8D44\u8BAF|AI \u4E71\u7096|AI\u4E71\u7096)/;
12875
+ if (legacy.test(out))
12876
+ out = out.replace(legacy, seriesName);
12877
+ const trailing = /\s\u00B7\s*(\d{4}-\d{2}-\d{2})\s*$/;
12878
+ const trail = trailing.exec(out);
12879
+ if (trail?.[1] && out.slice(0, trail.index).includes(trail[1]))
12880
+ out = out.slice(0, trail.index).trim();
12881
+ if (seriesName.length > 0 && out.startsWith(seriesName)) {
12882
+ let rest = spaceCjkLatin(out.slice(seriesName.length));
12883
+ const seriesEndsCjk = /\p{Script=Han}$/u.test(seriesName);
12884
+ if (seriesEndsCjk && /^[A-Za-z0-9]/.test(rest))
12885
+ rest = ` ${rest}`;
12886
+ out = seriesName + rest;
12887
+ } else {
12888
+ out = spaceCjkLatin(out);
12889
+ }
12890
+ return out.replace(/\s+/g, " ").trim();
12891
+ }
12892
+ function isRealDate(year, month, day) {
12893
+ if (month < 1 || month > 12 || day < 1 || day > 31)
12894
+ return false;
12895
+ const utc = new Date(Date.UTC(year, month - 1, day));
12896
+ return utc.getUTCFullYear() === year && utc.getUTCMonth() === month - 1 && utc.getUTCDate() === day;
12897
+ }
12898
+ function isoFromRunDate(raw) {
12899
+ const digits = raw.replaceAll(/\D/g, "");
12900
+ if (digits.length < 8)
12901
+ return;
12902
+ const year = Number(digits.slice(0, 4));
12903
+ const month = Number(digits.slice(4, 6));
12904
+ const day = Number(digits.slice(6, 8));
12905
+ if (!isRealDate(year, month, day))
12906
+ return;
12907
+ return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
12908
+ }
12909
+ function isoFromLosAngeles(ms) {
12910
+ if (!Number.isFinite(ms))
12911
+ return;
12912
+ const parts = new Intl.DateTimeFormat("en-US", {
12913
+ timeZone: "America/Los_Angeles",
12914
+ year: "numeric",
12915
+ month: "2-digit",
12916
+ day: "2-digit"
12917
+ }).formatToParts(new Date(ms));
12918
+ const year = parts.find((part) => part.type === "year")?.value;
12919
+ const month = parts.find((part) => part.type === "month")?.value;
12920
+ const day = parts.find((part) => part.type === "day")?.value;
12921
+ if (!year || !month || !day)
12922
+ return;
12923
+ return `${year}-${month}-${day}`;
12924
+ }
12925
+ function resolveEpisodeDate(metadata, publishedAtMs) {
12926
+ if (typeof metadata.runDate === "string") {
12927
+ const fromRun = isoFromRunDate(metadata.runDate);
12928
+ if (fromRun)
12929
+ return fromRun;
12930
+ }
12931
+ if (publishedAtMs !== undefined)
12932
+ return isoFromLosAngeles(publishedAtMs);
12933
+ const publishedAt = metadata.publishedAt;
12934
+ if (typeof publishedAt === "number")
12935
+ return isoFromLosAngeles(publishedAt);
12936
+ if (typeof publishedAt === "string" && publishedAt.length > 0) {
12937
+ const parsed = Date.parse(publishedAt);
12938
+ if (!Number.isNaN(parsed))
12939
+ return isoFromLosAngeles(parsed);
12940
+ }
12941
+ return;
12942
+ }
12943
+ function seasonAndEpisode(isoDate) {
12944
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
12945
+ if (!match)
12946
+ return;
12947
+ const year = Number(match[1]);
12948
+ const month = Number(match[2]);
12949
+ const day = Number(match[3]);
12950
+ if (!isRealDate(year, month, day))
12951
+ return;
12952
+ const episode = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(year, 0, 1)) / 86400000) + 1;
12953
+ return { season: year, episode };
12954
+ }
12955
+ function isPositiveInt(value) {
12956
+ return typeof value === "number" && Number.isInteger(value) && value >= 1;
12957
+ }
12958
+ function keywordsFromRefinedTitle(title) {
12959
+ const index = title.indexOf("\uFF1A");
12960
+ if (index < 0)
12961
+ return [];
12962
+ return title.slice(index + 1).split("\u3001").map((part) => part.trim()).filter((part) => part.length > 0);
12963
+ }
12964
+ function keywordFromDocTitle(title) {
12965
+ const cut = title.split(/\uFF0C|,|\uFF1A|\uFF1B|\u2014\u2014/)[0]?.trim() ?? "";
12966
+ return Array.from(cut).slice(0, 15).join("");
12967
+ }
12968
+ function clauseFromTitle(title) {
12969
+ const cut = (title.split(/\uFF0C|,/)[0] ?? title).replaceAll("\u300C", "").replaceAll("\u300D", "");
12970
+ return cut.replace(/\s+/g, " ").trim();
12971
+ }
12972
+ function firstSentence(text) {
12973
+ return splitSentences(text.replace(/\s+/g, " ").trim())[0] ?? "";
12974
+ }
12975
+ function planItemsFromMetadata(metadata) {
12976
+ const raw = podcastOf(metadata).planItems;
12977
+ if (!Array.isArray(raw))
12978
+ return [];
12979
+ const items = [];
12980
+ for (const entry of raw) {
12981
+ const record = asRecord(entry);
12982
+ if (!record)
12983
+ continue;
12984
+ const title = typeof record.title === "string" ? record.title.trim() : "";
12985
+ const brief = typeof record.brief === "string" ? record.brief.trim() : "";
12986
+ if (title.length === 0 && brief.length === 0)
12987
+ continue;
12988
+ items.push(brief.length > 0 ? { title, brief } : { title });
12989
+ }
12990
+ return items;
12991
+ }
12992
+ function stringList(value) {
12993
+ if (!Array.isArray(value))
12994
+ return;
12995
+ const items = value.filter((entry) => typeof entry === "string").map((entry) => entry.trim());
12996
+ return items.filter((entry) => entry.length > 0);
12997
+ }
12998
+ function cutAtBoundary(value, room) {
12999
+ if (room <= 0)
13000
+ return "";
13001
+ const chars = Array.from(value);
13002
+ if (chars.length <= room)
13003
+ return value;
13004
+ const sliced = chars.slice(0, room);
13005
+ let cut = sliced.length;
13006
+ for (let index = sliced.length - 1;index >= 0; index--) {
13007
+ if (/[\uFF0C\u3002\uFF01\uFF1F\u3001\uFF1B\uFF1A,.\s]/.test(sliced[index] ?? "")) {
13008
+ cut = index;
13009
+ break;
13010
+ }
13011
+ }
13012
+ const trimmed = sliced.slice(0, cut).join("").trim();
13013
+ return trimmed.length > 0 ? trimmed : sliced.join("").trim();
13014
+ }
13015
+ function headlineFromKeywords(config, date, keywords, limit) {
13016
+ const head = normalizeEpisodeTitle(config.titlePattern.replaceAll("{series}", config.seriesName).replaceAll("{date}", date).replaceAll("{keywords}", ""), config.seriesName);
13017
+ const chosen = [];
13018
+ for (const keyword of keywords) {
13019
+ const title = normalizeEpisodeTitle(`${head}${[...chosen, keyword].join("\u3001")}`, config.seriesName);
13020
+ if (codePointLength(title) <= limit)
13021
+ chosen.push(keyword);
13022
+ else
13023
+ break;
13024
+ }
13025
+ if (chosen.length === 0 && keywords[0]) {
13026
+ const room = limit - codePointLength(head);
13027
+ const cut = cutAtBoundary(keywords[0], room);
13028
+ if (cut.length > 0)
13029
+ chosen.push(cut);
13030
+ }
13031
+ return normalizeEpisodeTitle(chosen.length > 0 ? `${head}${chosen.join("\u3001")}` : head, config.seriesName);
13032
+ }
13033
+ function pickKeywords(metadata, config) {
13034
+ const podcast = podcastOf(metadata);
13035
+ if (typeof podcast.title === "string" && podcast.title.trim().length > 0) {
13036
+ return { explicitTitle: podcast.title.trim(), keywords: [] };
13037
+ }
13038
+ const explicit = stringList(podcast.titleKeywords);
13039
+ if (explicit && explicit.length > 0)
13040
+ return { keywords: explicit.slice(0, config.titleMaxKeywords) };
13041
+ const refined = typeof podcast.refinedTitle === "string" ? keywordsFromRefinedTitle(podcast.refinedTitle) : [];
13042
+ if (refined.length > 0)
13043
+ return { keywords: refined.slice(0, config.titleMaxKeywords) };
13044
+ const fromPlan = planItemsFromMetadata(metadata).map((item) => keywordFromDocTitle(item.title)).filter((keyword) => keyword.length > 0);
13045
+ if (fromPlan.length > 0)
13046
+ return { keywords: fromPlan.slice(0, config.titleMaxKeywords) };
13047
+ return { keywords: [] };
13048
+ }
13049
+ function buildItemTitle(asIs, metadata, config, date, warnings) {
13050
+ const picked = pickKeywords(metadata, config);
13051
+ if (picked.explicitTitle)
13052
+ return { title: picked.explicitTitle, keywords: [], explicit: true };
13053
+ if (config.titleMode !== "headline")
13054
+ return { title: asIs, keywords: picked.keywords, explicit: false };
13055
+ if (!date) {
13056
+ warnings.push(`${WARN_PREFIX} headline title skipped: episode date is unknown`);
13057
+ return { title: asIs, keywords: [], explicit: false };
13058
+ }
13059
+ if (picked.keywords.length === 0) {
13060
+ warnings.push(`${WARN_PREFIX} no keywords for the headline title; keeping the as-is title`);
13061
+ return { title: asIs, keywords: [], explicit: false };
13062
+ }
13063
+ return {
13064
+ title: headlineFromKeywords(config, date, picked.keywords, config.titleMax),
13065
+ keywords: picked.keywords,
13066
+ explicit: false
13067
+ };
13068
+ }
13069
+ function fitSeoTitle(episodeTitle, keywords, config, date, explicit) {
13070
+ const suffix = config.seoTitleSuffix;
13071
+ let stem = episodeTitle;
13072
+ let remaining = keywords;
13073
+ let seo = `${stem}${suffix}`;
13074
+ while (codePointLength(seo) > config.seoTitleMax && remaining.length > 0 && date && !explicit) {
13075
+ remaining = remaining.slice(0, -1);
13076
+ stem = headlineFromKeywords(config, date, remaining, config.titleMax);
13077
+ seo = `${stem}${suffix}`;
13078
+ }
13079
+ if (codePointLength(seo) > config.seoTitleMax) {
13080
+ const room = config.seoTitleMax - codePointLength(suffix);
13081
+ stem = cutAtBoundary(stem, room);
13082
+ seo = `${stem}${suffix}`;
13083
+ }
13084
+ return { stem, seo };
13085
+ }
13086
+ function cleanClause(value) {
13087
+ return value.replace(/<[^>]*>/g, "").replace(/[*#`[\]]/g, "").replace(/[\u3002\uFF01\uFF1F.!?]+$/u, "").replace(/\s+/g, " ").trim();
13088
+ }
13089
+ function descriptionHead(config, date, minutes) {
13090
+ const pattern = config.descriptionPattern.includes("{clauses}") ? config.descriptionPattern.split("{clauses}")[0] ?? config.descriptionPattern : config.descriptionPattern;
13091
+ let head = pattern.replaceAll("{show}", config.showName).replaceAll("{series}", config.seriesName);
13092
+ if (date)
13093
+ head = head.replaceAll("{date}", date);
13094
+ else
13095
+ head = head.replaceAll(" {date}", "").replaceAll("{date}", "");
13096
+ if (minutes !== undefined && minutes > 0)
13097
+ head = head.replaceAll("{minutes}", String(minutes));
13098
+ else
13099
+ head = head.replaceAll("\uFF08\u7EA6 {minutes} \u5206\u949F\uFF09", "").replaceAll("{minutes}", "");
13100
+ return head.replace(/\u3002\s*$/, "");
13101
+ }
13102
+ function durationMinutes(seconds) {
13103
+ if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0)
13104
+ return;
13105
+ return Math.round(seconds / 60);
13106
+ }
13107
+ function buildSeoDescription(input) {
13108
+ const head = descriptionHead(input.config, input.date, input.minutes);
13109
+ const render = (parts) => parts.length === 0 ? head : `${head}${parts.join("\uFF1B")}\u3002`;
13110
+ const picked = [];
13111
+ const pushWhile = (candidates) => {
13112
+ for (const candidate of candidates) {
13113
+ const clause = cleanClause(candidate);
13114
+ if (clause.length === 0 || picked.includes(clause))
13115
+ continue;
13116
+ const next = render([...picked, clause]);
13117
+ if (codePointLength(next) <= input.config.descriptionMax)
13118
+ picked.push(clause);
13119
+ }
13120
+ };
13121
+ pushWhile(input.clauses);
13122
+ if (codePointLength(render(picked)) < input.config.descriptionMin)
13123
+ pushWhile(input.extraSentences);
13124
+ const description = render(picked);
13125
+ return { description, short: codePointLength(description) < input.config.descriptionMin };
13126
+ }
13127
+ function descriptionInputs(metadata, config, date, minutes) {
13128
+ const podcast = podcastOf(metadata);
13129
+ const plan = planItemsFromMetadata(metadata);
13130
+ const summary = stringList(podcast.summaryClauses);
13131
+ const surfing = config.descriptionSource === "surfing" && typeof podcast.refinedDescription === "string" ? podcast.refinedDescription.trim() : "";
13132
+ const clauses = surfing ? [surfing] : summary && summary.length > 0 ? summary : plan.map((item) => clauseFromTitle(item.title)).filter((clause) => clause.length > 0);
13133
+ const extras = plan.map((item) => item.brief ? firstSentence(item.brief) : "").filter((sentence) => sentence.length > 0);
13134
+ return buildSeoDescription({ config, date, minutes, clauses, extraSentences: extras });
13135
+ }
13136
+ function isValidItemSlug(slug) {
13137
+ return slug.length >= 1 && slug.length <= 120 && !slug.endsWith("-") && /^[a-z0-9][a-z0-9-]*$/.test(slug);
13138
+ }
13139
+ function renderSlug(pattern, date) {
13140
+ if (!date)
13141
+ return { warning: "skipping slug: episode date is unknown" };
13142
+ const slug = pattern.replaceAll("{date}", date);
13143
+ if (!isValidItemSlug(slug)) {
13144
+ return {
13145
+ error: `invalid PODCAST_PUB_SLUG_PATTERN output "${slug}": expected lowercase letters, digits and hyphens (^[a-z0-9][a-z0-9-]*$), at most 120 characters, and no trailing hyphen`
13146
+ };
13147
+ }
13148
+ return { slug };
13149
+ }
13150
+ function roundDurationSeconds(seconds, enabled) {
13151
+ if (seconds === undefined)
13152
+ return;
13153
+ return enabled ? Math.round(seconds) : seconds;
13154
+ }
13155
+ function imageMimeFromBytes(bytes) {
13156
+ if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
13157
+ return "image/png";
13158
+ }
13159
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 216)
13160
+ return "image/jpeg";
13161
+ return;
13162
+ }
13163
+ function readImageDimensions(bytes) {
13164
+ const mime = imageMimeFromBytes(bytes);
13165
+ if (mime === "image/png") {
13166
+ if (bytes.length < 24)
13167
+ return;
13168
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
13169
+ const width = view.getUint32(16);
13170
+ const height = view.getUint32(20);
13171
+ return { width, height, mime };
13172
+ }
13173
+ if (mime === "image/jpeg") {
13174
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
13175
+ let offset = 2;
13176
+ while (offset + 3 < bytes.length) {
13177
+ if (bytes[offset] !== 255)
13178
+ return;
13179
+ const marker = bytes[offset + 1] ?? 0;
13180
+ if (marker === 216 || marker === 217) {
13181
+ offset += 2;
13182
+ continue;
13183
+ }
13184
+ if (marker >= 208 && marker <= 215) {
13185
+ offset += 2;
13186
+ continue;
13187
+ }
13188
+ if (offset + 4 > bytes.length)
13189
+ return;
13190
+ const length = view.getUint16(offset + 2);
13191
+ if (length < 2)
13192
+ return;
13193
+ const isSof = marker === 192 || marker === 193 || marker === 194 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207;
13194
+ if (isSof) {
13195
+ if (offset + 9 > bytes.length)
13196
+ return;
13197
+ return { height: view.getUint16(offset + 5), width: view.getUint16(offset + 7), mime };
13198
+ }
13199
+ offset += 2 + length;
13200
+ }
13201
+ }
13202
+ return;
13203
+ }
13204
+ function readLocalBytes(path) {
13205
+ try {
13206
+ return new Uint8Array(readFileSync2(path));
13207
+ } catch {
13208
+ return;
13209
+ }
13210
+ }
13211
+ function altText(config, date, keywords) {
13212
+ const rendered = config.socialImageAltPattern.replaceAll("{series}", config.seriesName).replaceAll("{date}", date ?? "").replaceAll("{keywords}", keywords.join("\u3001"));
13213
+ const sanitized = sanitizeTitle(rendered, "normalize");
13214
+ const value = sanitized.ok ? sanitized.value : rendered;
13215
+ if (codePointLength(value) <= 200)
13216
+ return value;
13217
+ return cutAtBoundary(value, 200);
13218
+ }
13219
+ function mustSanitize(value, mode, label) {
13220
+ const sanitized = sanitizeTitle(value, mode);
13221
+ if (!sanitized.ok)
13222
+ return { ok: false, error: `${label}: ${sanitized.error}` };
13223
+ return sanitized;
13224
+ }
13225
+ function prepareEpisodeFields(episode, metadata, config) {
13226
+ const warnings = [];
13227
+ const date = resolveEpisodeDate(metadata, episode.datePublishedMs);
13228
+ const built = buildItemTitle(episode.title, metadata, config, date, warnings);
13229
+ const sanitizedTitle = mustSanitize(built.title, config.titleSanitize, "item title");
13230
+ if (!sanitizedTitle.ok)
13231
+ return sanitizedTitle;
13232
+ let title = built.explicit ? sanitizedTitle.value : config.titleMode === "headline" ? normalizeEpisodeTitle(sanitizedTitle.value, config.seriesName) : sanitizedTitle.value;
13233
+ const itunes = { ...episode.itunes ?? {} };
13234
+ if (itunes.season !== undefined && !isPositiveInt(itunes.season)) {
13235
+ return {
13236
+ ok: false,
13237
+ error: `invalid metadata.itunes.season: expected an integer \u2265 1, got ${String(itunes.season)}`
13238
+ };
13239
+ }
13240
+ if (itunes.episode !== undefined && !isPositiveInt(itunes.episode)) {
13241
+ return {
13242
+ ok: false,
13243
+ error: `invalid metadata.itunes.episode: expected an integer \u2265 1, got ${String(itunes.episode)}`
13244
+ };
13245
+ }
13246
+ if (config.autoEpisodeNumber) {
13247
+ const derived = date ? seasonAndEpisode(date) : undefined;
13248
+ if (derived) {
13249
+ if (itunes.season === undefined)
13250
+ itunes.season = derived.season;
13251
+ if (itunes.episode === undefined)
13252
+ itunes.episode = derived.episode;
13253
+ } else if (itunes.season === undefined || itunes.episode === undefined) {
13254
+ warnings.push(`${WARN_PREFIX} itunes season/episode left unset: episode date is unknown`);
13255
+ }
13256
+ }
13257
+ if (itunes.title) {
13258
+ const sanitizedItunes = mustSanitize(itunes.title, config.titleSanitize, "itunes:title");
13259
+ if (!sanitizedItunes.ok)
13260
+ return sanitizedItunes;
13261
+ itunes.title = sanitizedItunes.value;
13262
+ }
13263
+ const minutes = durationMinutes(episode.durationSeconds);
13264
+ let seo;
13265
+ if (config.seo) {
13266
+ const fitted = fitSeoTitle(title, built.keywords, config, date, built.explicit);
13267
+ if (!built.explicit && config.titleMode === "headline" && fitted.stem.length > 0)
13268
+ title = fitted.stem;
13269
+ const seoTitle = mustSanitize(fitted.seo, config.titleSanitize, "seo.title");
13270
+ if (!seoTitle.ok)
13271
+ return seoTitle;
13272
+ const described = descriptionInputs(metadata, config, date, minutes);
13273
+ if (described.short) {
13274
+ warnings.push(`${WARN_PREFIX} seo.description is ${codePointLength(described.description)} code points, under the ${config.descriptionMin} minimum; left unpadded`);
13275
+ }
13276
+ seo = { title: seoTitle.value, description: described.description };
13277
+ }
13278
+ let slug;
13279
+ if (config.slugMode === "pattern") {
13280
+ const rendered = renderSlug(config.slugPattern, date);
13281
+ if (rendered.error)
13282
+ return { ok: false, error: rendered.error };
13283
+ if (rendered.warning)
13284
+ warnings.push(`${WARN_PREFIX} ${rendered.warning}`);
13285
+ slug = rendered.slug;
13286
+ }
13287
+ if (config.linkField === "url" && episode.link) {
13288
+ warnings.push(`${WARN_PREFIX} PODCAST_PUB_LINK_FIELD=url sends metadata.articleUrl as the item url; microfeed uses that URL as the canonical and drops the episode from the podcast sitemap`);
13289
+ }
13290
+ let socialAlt;
13291
+ let socialLocalPath;
13292
+ let socialMime;
13293
+ let socialRemoteUrl;
13294
+ if (config.socialImage) {
13295
+ const source = podcastOf(metadata).socialImage;
13296
+ const alt = altText(config, date, built.keywords);
13297
+ if (typeof source !== "string" || source.trim().length === 0) {
13298
+ warnings.push(`${WARN_PREFIX} PODCAST_PUB_SOCIAL_IMAGE is on but metadata.podcast.socialImage is empty; skipping alt text`);
13299
+ } else if (source.trim().startsWith("http")) {
13300
+ socialAlt = alt;
13301
+ socialRemoteUrl = source.trim();
13302
+ } else {
13303
+ const bytes = readLocalBytes(source.trim());
13304
+ const dims = bytes ? readImageDimensions(bytes) : undefined;
13305
+ if (dims?.width !== 1200 || dims?.height !== 630) {
13306
+ warnings.push(`${WARN_PREFIX} social image skipped: expected a 1200\xD7630 PNG or JPEG, got ${dims ? `${dims.width}\xD7${dims.height}` : "an unreadable file"}`);
13307
+ } else {
13308
+ socialAlt = alt;
13309
+ socialLocalPath = source.trim();
13310
+ socialMime = dims.mime;
13311
+ }
13312
+ }
13313
+ }
13314
+ for (const line of warnings)
13315
+ echoError(line);
13316
+ return {
13317
+ ok: true,
13318
+ fields: {
13319
+ title,
13320
+ itunes,
13321
+ durationSeconds: roundDurationSeconds(episode.durationSeconds, config.roundDuration),
13322
+ ...config.linkField === "url" && episode.link ? { link: episode.link } : {},
13323
+ ...seo ? { seo } : {},
13324
+ ...slug ? { slug } : {},
13325
+ warnings,
13326
+ ...socialAlt ? { socialAlt } : {},
13327
+ ...socialLocalPath ? { socialLocalPath, socialMime } : {},
13328
+ ...socialRemoteUrl ? { socialRemoteUrl } : {}
13329
+ }
13330
+ };
13331
+ }
13332
+ function showNotesSummaryParagraph(input) {
13333
+ const date = isoFromRunDate(input.runDate);
13334
+ const metadata = {
13335
+ ...date ? { runDate: date } : { runDate: input.runDate },
13336
+ podcast: {
13337
+ ...input.refinedDescription ? { refinedDescription: input.refinedDescription } : {},
13338
+ ...input.summaryClauses ? { summaryClauses: input.summaryClauses } : {},
13339
+ planItems: input.plan
13340
+ }
13341
+ };
13342
+ const described = descriptionInputs(metadata, input.config, date, durationMinutes(input.durationSeconds));
13343
+ const warnings = [];
13344
+ if (described.short) {
13345
+ warnings.push(`${WARN_PREFIX} seo.description is ${codePointLength(described.description)} code points, under the ${input.config.descriptionMin} minimum; left unpadded`);
13346
+ }
13347
+ const prefix = `${input.config.showName}\xB7`;
13348
+ const stripped = described.description.startsWith(prefix) ? described.description.slice(prefix.length) : described.description;
13349
+ const safeLabel = input.linkText.replaceAll("[", "").replaceAll("]", "").replaceAll("(", "").replaceAll(")", "");
13350
+ const markdown = `${stripped} \u5B8C\u6574\u56FE\u6587\u7248\uFF1A[${safeLabel}](${input.articleUrl})`;
13351
+ return { markdown, description: described.description, warnings };
13352
+ }
13353
+ function summaryParagraphHtml(description, showName, articleUrl, linkText) {
13354
+ const prefix = `${showName}\xB7`;
13355
+ const stripped = description.startsWith(prefix) ? description.slice(prefix.length) : description;
13356
+ const text = stripped.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
13357
+ if (!articleUrl)
13358
+ return `<p>${text}</p>`;
13359
+ const href = articleUrl.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "");
13360
+ const label = linkText.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
13361
+ return `<p>${text} \u5B8C\u6574\u56FE\u6587\u7248\uFF1A<a href="${href}" target="_blank" rel="noopener">${label}</a></p>`;
13362
+ }
13363
+ function stripMarkupTitle(raw) {
13364
+ return raw.replace(/<[^>]+>/g, " ").replace(/\u6765\u6E90/g, "").replace(/^\s*\d+\s*[.\u3001]\s*/, "").replace(/\s+/g, " ").trim();
13365
+ }
13366
+ function titlesFromContentHtml(html) {
13367
+ const timeline = [...html.matchAll(/class="kk-tl-title">([\s\S]*?)<\/div>/gi)].map((match) => stripMarkupTitle(match[1] ?? "")).filter((title) => title.length > 0);
13368
+ if (timeline.length > 0)
13369
+ return timeline;
13370
+ return [...html.matchAll(/<h2[^>]*>([\s\S]*?)<\/h2>/gi)].map((match) => stripMarkupTitle(match[1] ?? "")).filter((title) => title.length > 0);
13371
+ }
13372
+ function insertAfterTimeline(html, insertion) {
13373
+ const start = html.indexOf('<div class="kk-tl">');
13374
+ if (start < 0)
13375
+ return `${html.trim()}
13376
+ ${insertion}`;
13377
+ let depth = 0;
13378
+ const tags = /<\/?div\b[^>]*>/gi;
13379
+ tags.lastIndex = start;
13380
+ let match = tags.exec(html);
13381
+ while (match) {
13382
+ depth += match[0].startsWith("</") ? -1 : 1;
13383
+ if (depth === 0) {
13384
+ const at = match.index + match[0].length;
13385
+ const head = html.slice(0, at).replace(/\s*$/, "");
13386
+ const tail = html.slice(at).replace(/^\s*/, "");
13387
+ return tail.length > 0 ? `${head}
13388
+ ${insertion}
13389
+ ${tail}` : `${head}
13390
+ ${insertion}`;
13391
+ }
13392
+ match = tags.exec(html);
13393
+ }
13394
+ return `${html.trim()}
13395
+ ${insertion}`;
13396
+ }
13397
+ function rewriteEpisodeHtml(html, summaryHtml, moveStyle) {
13398
+ let body = html;
13399
+ if (moveStyle) {
13400
+ const styleMatch = body.match(/<style>[\s\S]*?<\/style>/i);
13401
+ if (styleMatch && styleMatch.index !== undefined) {
13402
+ const style = styleMatch[0];
13403
+ const without = `${body.slice(0, styleMatch.index)}${body.slice(styleMatch.index + style.length)}`;
13404
+ body = insertAfterTimeline(without, style);
13405
+ }
13406
+ }
13407
+ const trimmed = body.trim();
13408
+ if (summaryHtml.length > 0 && (trimmed === summaryHtml || trimmed.startsWith(`${summaryHtml}
13409
+ `) || trimmed.startsWith(`${summaryHtml}<`))) {
13410
+ return trimmed;
13411
+ }
13412
+ return `${summaryHtml}
13413
+ ${trimmed}`;
13414
+ }
13415
+ function dateFromFeedItem(item) {
13416
+ const title = typeof item.title === "string" ? item.title : "";
13417
+ const inTitle = /(\d{4}-\d{2}-\d{2})/.exec(title);
13418
+ if (inTitle?.[1] && isoFromRunDate(inTitle[1]))
13419
+ return inTitle[1];
13420
+ const msField = item.date_published_ms;
13421
+ if (typeof msField === "number")
13422
+ return isoFromLosAngeles(msField);
13423
+ const published = item.date_published;
13424
+ if (typeof published === "number")
13425
+ return isoFromLosAngeles(published);
13426
+ if (typeof published === "string") {
13427
+ const parsed = Date.parse(published);
13428
+ if (!Number.isNaN(parsed))
13429
+ return isoFromLosAngeles(parsed);
13430
+ }
13431
+ return;
13432
+ }
13433
+
13434
+ // ../../plugins/publishings/podcast-pub/src/backfill.ts
13435
+ var TARGET = "podcast-pub";
13436
+ function fail(error) {
13437
+ return ResultSchema.parse({ ok: false, target: TARGET, error });
13438
+ }
13439
+ function asRecord2(value) {
13440
+ if (value !== null && typeof value === "object" && !Array.isArray(value))
13441
+ return value;
13442
+ return;
13443
+ }
13444
+ function flag(value) {
13445
+ return value === true || value === "true" || value === "1";
13446
+ }
13447
+ function positiveInt(value) {
13448
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1)
13449
+ return value;
13450
+ if (typeof value === "string" && /^\d+$/.test(value)) {
13451
+ const parsed = Number(value);
13452
+ if (Number.isInteger(parsed) && parsed >= 1)
13453
+ return parsed;
13454
+ }
13455
+ return;
13456
+ }
13457
+ function parseOverrides(raw) {
13458
+ if (raw === undefined)
13459
+ return { overrides: new Map };
13460
+ if (!Array.isArray(raw))
13461
+ return {
13462
+ error: "invalid options.overrides: expected an array of {id, title?, titleKeywords?, summaryClauses?}"
13463
+ };
13464
+ const overrides = new Map;
13465
+ for (const entry of raw) {
13466
+ const record = asRecord2(entry);
13467
+ if (!record || typeof record.id !== "string" || record.id.trim().length === 0) {
13468
+ return { error: "invalid options.overrides: each entry needs a string id" };
13469
+ }
13470
+ const keywords = record.titleKeywords;
13471
+ if (keywords !== undefined && (!Array.isArray(keywords) || keywords.some((keyword) => typeof keyword !== "string"))) {
13472
+ return { error: `invalid options.overrides titleKeywords for "${record.id}": expected string[]` };
13473
+ }
13474
+ const clauses = record.summaryClauses;
13475
+ if (clauses !== undefined && (!Array.isArray(clauses) || clauses.some((clause) => typeof clause !== "string"))) {
13476
+ return { error: `invalid options.overrides summaryClauses for "${record.id}": expected string[]` };
13477
+ }
13478
+ if (record.title !== undefined && typeof record.title !== "string") {
13479
+ return { error: `invalid options.overrides title for "${record.id}": expected a string` };
13480
+ }
13481
+ overrides.set(record.id, {
13482
+ id: record.id,
13483
+ ...typeof record.title === "string" ? { title: record.title } : {},
13484
+ ...Array.isArray(keywords) ? { titleKeywords: keywords.filter((keyword) => typeof keyword === "string") } : {},
13485
+ ...Array.isArray(clauses) ? { summaryClauses: clauses.filter((clause) => typeof clause === "string") } : {}
13486
+ });
13487
+ }
13488
+ return { overrides };
13489
+ }
13490
+ function parseFlags(options) {
13491
+ const overrides = parseOverrides(options.overrides);
13492
+ if ("error" in overrides)
13493
+ return overrides;
13494
+ const since = options.since === undefined ? undefined : String(options.since);
13495
+ const until = options.until === undefined ? undefined : String(options.until);
13496
+ if (since !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(since)) {
13497
+ return { error: `invalid options.since "${since}": expected YYYY-MM-DD` };
13498
+ }
13499
+ if (until !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
13500
+ return { error: `invalid options.until "${until}": expected YYYY-MM-DD` };
13501
+ }
13502
+ let ids;
13503
+ if (options.ids !== undefined) {
13504
+ if (!Array.isArray(options.ids) || options.ids.some((id) => typeof id !== "string")) {
13505
+ return { error: "invalid options.ids: expected an array of item id strings" };
13506
+ }
13507
+ ids = new Set(options.ids);
13508
+ }
13509
+ return {
13510
+ flags: {
13511
+ apply: flag(options.apply),
13512
+ retitle: flag(options.retitle),
13513
+ rewriteBody: flag(options.rewriteBody),
13514
+ applySlug: flag(options.applySlug),
13515
+ ...since ? { since } : {},
13516
+ ...until ? { until } : {},
13517
+ ...ids ? { ids } : {},
13518
+ ...typeof options.out === "string" && options.out.length > 0 ? { out: options.out } : {},
13519
+ ...typeof options.plan === "string" && options.plan.length > 0 ? { planPath: options.plan } : {},
13520
+ overrides: overrides.overrides
13521
+ }
13522
+ };
13523
+ }
13524
+ function microfeedOf(item) {
13525
+ return asRecord2(item._microfeed) ?? {};
13526
+ }
13527
+ function durationOf(item) {
13528
+ const attachments = item.attachments;
13529
+ if (!Array.isArray(attachments))
13530
+ return;
13531
+ const first = asRecord2(attachments[0]);
13532
+ const seconds = first?.duration_in_seconds;
13533
+ return typeof seconds === "number" && Number.isFinite(seconds) ? seconds : undefined;
13534
+ }
13535
+ function articleUrlOf(html) {
13536
+ const marked = /\u5B8C\u6574\u56FE\u6587\u7248\uFF1A<a href="([^"]+)"/.exec(html);
13537
+ if (marked?.[1])
13538
+ return marked[1];
13539
+ const any = /href="(https?:\/\/[^"]+)"/.exec(html);
13540
+ return any?.[1];
13541
+ }
13542
+ function inWindow(date, flags) {
13543
+ if (!flags.since && !flags.until)
13544
+ return true;
13545
+ if (!date)
13546
+ return false;
13547
+ if (flags.since && date < flags.since)
13548
+ return false;
13549
+ if (flags.until && date > flags.until)
13550
+ return false;
13551
+ return true;
13552
+ }
13553
+ function planBackfillItem(item, config, flags, override) {
13554
+ const id = typeof item.id === "string" ? item.id : "";
13555
+ if (id.length === 0)
13556
+ return { error: "backfill-seo: a feed item is missing id" };
13557
+ const currentTitle = typeof item.title === "string" ? item.title : "";
13558
+ const micro = microfeedOf(item);
13559
+ const currentSeo = asRecord2(micro.seo);
13560
+ const currentSeoTitle = typeof currentSeo?.title === "string" ? currentSeo.title : undefined;
13561
+ const currentSeoDescription = typeof currentSeo?.description === "string" ? currentSeo.description : undefined;
13562
+ const currentSeason = positiveInt(micro["itunes:season"]);
13563
+ const currentEpisode = positiveInt(micro["itunes:episode"]);
13564
+ const currentSlug = typeof micro.slug === "string" ? micro.slug : undefined;
13565
+ const date = dateFromFeedItem(item);
13566
+ const html = typeof item.content_html === "string" ? item.content_html : "";
13567
+ const titles = titlesFromContentHtml(html);
13568
+ const clauses = override?.summaryClauses ?? titles.map((title) => clauseFromTitle(title)).filter((clause) => clause.length > 0);
13569
+ const keywords = override?.titleKeywords ?? titles.filter((title) => title.length > 0);
13570
+ const metadata = {
13571
+ ...date ? { runDate: date } : {},
13572
+ ...durationOf(item) !== undefined ? { durationSeconds: durationOf(item) } : {},
13573
+ podcast: {
13574
+ titleMode: "headline",
13575
+ ...override?.title ? { title: override.title } : {},
13576
+ ...keywords.length > 0 ? { titleKeywords: keywords.slice(0, config.titleMaxKeywords) } : {},
13577
+ ...clauses.length > 0 ? { summaryClauses: clauses } : {}
13578
+ }
13579
+ };
13580
+ const prepared = prepareEpisodeFields({
13581
+ title: currentTitle,
13582
+ durationSeconds: durationOf(item),
13583
+ itunes: {
13584
+ ...currentSeason !== undefined ? { season: currentSeason } : {},
13585
+ ...currentEpisode !== undefined ? { episode: currentEpisode } : {}
13586
+ }
13587
+ }, metadata, {
13588
+ ...config,
13589
+ seo: true,
13590
+ titleMode: "headline",
13591
+ slugMode: "auto",
13592
+ linkField: "none",
13593
+ socialImage: false,
13594
+ autoEpisodeNumber: true
13595
+ });
13596
+ if (!prepared.ok)
13597
+ return { error: `${id}: ${prepared.error}` };
13598
+ const violations = [];
13599
+ if (!date)
13600
+ violations.push("episode date is unknown");
13601
+ const patch = {};
13602
+ const nextMicro = {};
13603
+ if (flags.retitle && prepared.fields.title !== currentTitle)
13604
+ patch.title = prepared.fields.title;
13605
+ const seo = prepared.fields.seo;
13606
+ if (seo) {
13607
+ const seoPatch = {};
13608
+ if (seo.title && seo.title !== currentSeoTitle)
13609
+ seoPatch.title = seo.title;
13610
+ if (seo.description && seo.description !== currentSeoDescription)
13611
+ seoPatch.description = seo.description;
13612
+ if (seoPatch.title || seoPatch.description)
13613
+ nextMicro.seo = seoPatch;
13614
+ if (seo.description && codePointLength(seo.description) < config.descriptionMin) {
13615
+ violations.push(`seo.description is ${codePointLength(seo.description)} code points, under ${config.descriptionMin}`);
13616
+ }
13617
+ if (seo.description && /^\s*(\d|#|[-*])/.test(seo.description)) {
13618
+ violations.push("seo.description starts with a forbidden character");
13619
+ }
13620
+ }
13621
+ const numbered = prepared.fields.itunes;
13622
+ if (numbered?.season !== undefined && currentSeason === undefined)
13623
+ nextMicro["itunes:season"] = numbered.season;
13624
+ if (numbered?.episode !== undefined && currentEpisode === undefined)
13625
+ nextMicro["itunes:episode"] = numbered.episode;
13626
+ if (flags.applySlug && date) {
13627
+ const slug = renderSlug(config.slugPattern, date);
13628
+ if (slug.slug && slug.slug !== currentSlug && isValidItemSlug(slug.slug))
13629
+ nextMicro.slug = slug.slug;
13630
+ if (slug.error)
13631
+ violations.push(slug.error);
13632
+ }
13633
+ if (flags.rewriteBody && html.length > 0 && seo?.description) {
13634
+ const summary = summaryParagraphHtml(seo.description, config.showName, articleUrlOf(html), "\u672C\u671F\u5B8C\u6574\u6587\u7AE0");
13635
+ const rewritten = rewriteEpisodeHtml(html, summary, config.timelineStyle !== "omit");
13636
+ if (rewritten !== html.trim())
13637
+ patch.content_html = rewritten;
13638
+ }
13639
+ if (Object.keys(nextMicro).length > 0)
13640
+ patch._microfeed = nextMicro;
13641
+ const proposedTitle = typeof patch.title === "string" ? patch.title : currentTitle;
13642
+ const proposedSeo = asRecord2(nextMicro.seo);
13643
+ return {
13644
+ id,
13645
+ ...date ? { date } : {},
13646
+ patch,
13647
+ counts: {
13648
+ ...proposedTitle ? { title: codePointLength(proposedTitle) } : {},
13649
+ ...typeof proposedSeo?.title === "string" ? { seoTitle: codePointLength(proposedSeo.title) } : {},
13650
+ ...typeof proposedSeo?.description === "string" ? { seoDescription: codePointLength(proposedSeo.description) } : {}
13651
+ },
13652
+ violations,
13653
+ current: {
13654
+ ...currentTitle ? { title: currentTitle } : {},
13655
+ ...currentSeoTitle ? { seoTitle: currentSeoTitle } : {},
13656
+ ...currentSeoDescription ? { seoDescription: currentSeoDescription } : {},
13657
+ ...currentSeason !== undefined ? { season: currentSeason } : {},
13658
+ ...currentEpisode !== undefined ? { episode: currentEpisode } : {},
13659
+ ...currentSlug ? { slug: currentSlug } : {}
13660
+ }
13661
+ };
13662
+ }
13663
+ function describeEntry(entry) {
13664
+ const seo = asRecord2(asRecord2(entry.patch._microfeed)?.seo);
13665
+ const parts = [
13666
+ `${entry.id}:`,
13667
+ entry.date ? `date ${entry.date}` : "date unknown",
13668
+ `title ${JSON.stringify(entry.current.title ?? "")} \u2192 ${JSON.stringify(entry.patch.title ?? entry.current.title ?? "")} (${entry.counts.title ?? 0})`,
13669
+ `seo.title ${JSON.stringify(entry.current.seoTitle ?? "")} \u2192 ${JSON.stringify(seo?.title ?? entry.current.seoTitle ?? "")} (${entry.counts.seoTitle ?? codePointLength(String(entry.current.seoTitle ?? ""))})`,
13670
+ `seo.description (${entry.counts.seoDescription ?? codePointLength(String(entry.current.seoDescription ?? ""))})`
13671
+ ];
13672
+ if (entry.violations.length > 0)
13673
+ parts.push(`violations: ${entry.violations.join("; ")}`);
13674
+ return parts.join(" | ");
13675
+ }
13676
+ async function writePlan(path, entries, apply) {
13677
+ await ensureDirForFile(path);
13678
+ await writeJsonFile(path, { dryRun: !apply, items: entries });
13679
+ }
13680
+ async function loadPlan(path) {
13681
+ try {
13682
+ const parsed = await readJsonFile(path);
13683
+ const record = asRecord2(parsed);
13684
+ const items = record?.items;
13685
+ if (!Array.isArray(items))
13686
+ return { error: `invalid backfill plan ${path}: expected { items: [...] }` };
13687
+ const entries = [];
13688
+ for (const item of items) {
13689
+ const row = asRecord2(item);
13690
+ if (!row || typeof row.id !== "string" || !asRecord2(row.patch)) {
13691
+ return { error: `invalid backfill plan ${path}: each item needs id and patch` };
13692
+ }
13693
+ entries.push(row);
13694
+ }
13695
+ return { entries };
13696
+ } catch (error) {
13697
+ return { error: `invalid backfill plan ${path}: ${error instanceof Error ? error.message : String(error)}` };
13698
+ }
13699
+ }
13700
+ function withoutSlug(micro) {
13701
+ const rest = {};
13702
+ for (const [key, value] of Object.entries(micro)) {
13703
+ if (key !== "slug")
13704
+ rest[key] = value;
13705
+ }
13706
+ return rest;
13707
+ }
13708
+ async function putPatch(client, entry, warnings) {
13709
+ const micro = asRecord2(entry.patch._microfeed);
13710
+ const slug = typeof micro?.slug === "string" ? micro.slug : undefined;
13711
+ if (!slug || !micro) {
13712
+ await client.updateItem(entry.id, entry.patch);
13713
+ return;
13714
+ }
13715
+ try {
13716
+ await client.updateItem(entry.id, entry.patch);
13717
+ } catch (error) {
13718
+ if (!(error instanceof MicrofeedRequestError) || error.status !== 409)
13719
+ throw error;
13720
+ const retry = `${slug}-2`;
13721
+ try {
13722
+ await client.updateItem(entry.id, { ...entry.patch, _microfeed: { ...micro, slug: retry } });
13723
+ warn(`slug "${slug}" for ${entry.id} conflicted (HTTP 409); retried as ${retry}`, warnings);
13724
+ } catch (retryError) {
13725
+ if (!(retryError instanceof MicrofeedRequestError) || retryError.status !== 409)
13726
+ throw retryError;
13727
+ const rest = withoutSlug(micro);
13728
+ if (Object.keys(rest).length === 0) {
13729
+ const { _microfeed: _dropped, ...other } = entry.patch;
13730
+ if (Object.keys(other).length === 0) {
13731
+ warn(`slug "${slug}" for ${entry.id} conflicted twice (HTTP 409); skipped the slug and no other fields changed`, warnings);
13732
+ return;
13733
+ }
13734
+ await client.updateItem(entry.id, other);
13735
+ } else {
13736
+ await client.updateItem(entry.id, { ...entry.patch, _microfeed: rest });
13737
+ }
13738
+ warn(`slug "${slug}" for ${entry.id} conflicted twice (HTTP 409); applied the patch without a slug`, warnings);
13739
+ }
13740
+ }
13741
+ }
13742
+ async function runBackfill(client, options, config) {
13743
+ const parsed = parseFlags(options);
13744
+ if ("error" in parsed)
13745
+ return fail(parsed.error);
13746
+ const flags = parsed.flags;
13747
+ const warnings = [];
13748
+ let entries;
13749
+ if (flags.apply && flags.planPath) {
13750
+ const loaded = await loadPlan(flags.planPath);
13751
+ if ("error" in loaded)
13752
+ return fail(loaded.error);
13753
+ entries = loaded.entries.filter((entry) => !flags.ids || flags.ids.has(entry.id));
13754
+ } else {
13755
+ const feed = await client.listItems();
13756
+ entries = [];
13757
+ for (const item of feed.items ?? []) {
13758
+ const id = typeof item.id === "string" ? item.id : "";
13759
+ if (flags.ids && !flags.ids.has(id))
13760
+ continue;
13761
+ const date = dateFromFeedItem(item);
13762
+ if (!inWindow(date, flags))
13763
+ continue;
13764
+ const planned = planBackfillItem(item, config, flags, flags.overrides.get(id));
13765
+ if ("error" in planned)
13766
+ return fail(planned.error);
13767
+ entries.push(planned);
13768
+ }
13769
+ }
13770
+ for (const entry of entries)
13771
+ echo(describeEntry(entry));
13772
+ if (flags.out)
13773
+ await writePlan(flags.out, entries, flags.apply);
13774
+ if (!flags.apply) {
13775
+ return ResultSchema.parse({
13776
+ ok: true,
13777
+ target: TARGET,
13778
+ message: `backfill-seo dry-run for ${entries.length} item(s); no writes`,
13779
+ metadata: { dryRun: true, items: entries, warnings }
13780
+ });
13781
+ }
13782
+ let written = 0;
13783
+ for (const entry of entries) {
13784
+ if (Object.keys(entry.patch).length === 0)
13785
+ continue;
13786
+ await putPatch(client, entry, warnings);
13787
+ written += 1;
13788
+ }
13789
+ return ResultSchema.parse({
13790
+ ok: true,
13791
+ target: TARGET,
13792
+ message: `backfill-seo applied ${written} item(s)`,
13793
+ metadata: { dryRun: false, applied: written, items: entries, warnings }
13794
+ });
13795
+ }
13796
+
13797
+ // ../../plugins/publishings/podcast-pub/src/map.ts
13798
+ import { extname } from "path";
13799
+
13800
+ // ../../plugins/publishings/podcast-pub/src/show-notes.ts
13801
+ function escapeHtml(value) {
13802
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
13803
+ }
13804
+ function renderInline(escaped) {
13805
+ let out = "";
13806
+ let rest = escaped;
13807
+ for (;; ) {
13808
+ const match = rest.match(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/);
13809
+ if (!match || match.index === undefined)
13810
+ break;
13811
+ out += `${rest.slice(0, match.index)}<a href="${match[2]}" target="_blank" rel="noopener">${match[1]}</a>`;
13812
+ rest = rest.slice(match.index + match[0].length);
13813
+ }
13814
+ return (out + rest).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
13815
+ }
13816
+ function normalizeForMatch(text) {
13817
+ return text.toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, "");
13818
+ }
13819
+ function formatOffset(seconds) {
13820
+ if (!Number.isFinite(seconds) || seconds < 0)
13821
+ return "--:--";
13822
+ const m = Math.floor(seconds / 60);
13823
+ const s = Math.round(seconds % 60);
13824
+ return s === 60 ? `${m + 1}:00` : `${m}:${String(s).padStart(2, "0")}`;
13825
+ }
13826
+ function computeAudioOffsets(docs, segments) {
13827
+ const fallback = () => {
13828
+ const chars = docs.map((d) => Math.max((d.body ?? "").trim().length, 1));
13829
+ const total = chars.reduce((a, b) => a + b, 0);
13830
+ const sumDuration = (segments ?? []).reduce((a, s) => a + (s.duration ?? 0), 0);
13831
+ let acc = 0;
13832
+ return chars.map((c) => {
13833
+ const at = acc;
13834
+ acc += c / total * sumDuration;
13835
+ return at;
13836
+ });
13837
+ };
13838
+ if (!segments || segments.length < 2)
13839
+ return fallback();
13840
+ const starts = [];
13841
+ let elapsed = 0;
13842
+ const marks = segments.map((s) => {
13843
+ const at = elapsed;
13844
+ elapsed += s.duration ?? 0;
13845
+ return { at, text: normalizeForMatch(s.transcription ?? "") };
13846
+ });
13847
+ let cursor = 1;
13848
+ for (const d of docs) {
13849
+ const title = normalizeForMatch(d.title ?? "");
13850
+ const needle = title.slice(0, 12);
13851
+ const hit = needle.length >= 4 ? marks.findIndex((m, i) => i >= cursor && m.text.includes(needle)) : -1;
13852
+ if (hit >= 0) {
13853
+ starts.push(marks[hit]?.at ?? 0);
13854
+ cursor = hit + 1;
13855
+ } else {
13856
+ starts.push(Number.NaN);
13857
+ }
13858
+ }
13859
+ if (starts.some((v) => !Number.isFinite(v)))
13860
+ return fallback();
13861
+ return starts;
13862
+ }
13863
+ var isHttpUrl = (u) => /^https?:\/\//i.test(u.trim());
13864
+ var TIMELINE_STYLE = `<style>
13865
+ .kk-tl{margin:1em 0}
13866
+ .kk-tl-row{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid rgba(128,128,128,.25)}
13867
+ .kk-tl-t{flex:0 0 56px;font-variant-numeric:tabular-nums;font-weight:700;color:#0969da}
13868
+ .kk-tl-body{flex:1;min-width:0}
13869
+ .kk-tl-title{font-weight:600}
13870
+ .kk-tl-brief{margin:4px 0 0;color:#666;font-size:.95em}
13871
+ </style>`;
13872
+ var TIMELINE_TAG = /^<\/?(?:div|p|a)(?:\s+(?:class="kk-tl(?:-[a-z]+)?"|href="https?:\/\/[^"<>\s]*"|target="_blank"|rel="noopener"))*\s*>$/;
13873
+ function isGeneratedTimeline(block) {
13874
+ const rest = block.replace(TIMELINE_STYLE, "");
13875
+ if (rest.includes("<style") || rest.includes("<!--"))
13876
+ return false;
13877
+ return (rest.match(/<[^>]*>/g) ?? []).every((tag) => TIMELINE_TAG.test(tag)) && !/<(?![^>]*>)/.test(rest);
13878
+ }
13879
+ function renderTimelineHtml(docs, offsets, articleUrl, style) {
13880
+ const rows = docs.map((d, i) => {
13881
+ const title = escapeHtml(d.title ?? `\u6761\u76EE ${i + 1}`);
13882
+ const brief = escapeHtml(itemBrief(d));
13883
+ const at = formatOffset(offsets[i] ?? Number.NaN);
13884
+ const link = d.sourceUri && isHttpUrl(d.sourceUri) ? ` <a href="${escapeHtml(d.sourceUri)}" target="_blank" rel="noopener">\u6765\u6E90</a>` : "";
13885
+ return `
13886
+ <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>`;
13887
+ }).join("");
13888
+ 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>`;
13889
+ const styleBlock = style === "omit" ? "" : `${TIMELINE_STYLE}
13890
+ `;
13891
+ const before = style === "before" ? styleBlock : "";
13892
+ const after = style === "after" ? styleBlock : "";
13893
+ return `<!--kk:timeline-->
13894
+ ${before}<div class="kk-tl">${rows}
13895
+ </div>
13896
+ ${after}${articleTail}
13897
+ <!--/kk:timeline-->`;
13898
+ }
13899
+ function renderShowNotesHtml(markdown) {
13900
+ const blocks = [];
13901
+ const timeline = [];
13902
+ let inTimeline = false;
13903
+ let paragraph = [];
13904
+ const flush = () => {
13905
+ if (paragraph.length > 0) {
13906
+ blocks.push(`<p>${renderInline(escapeHtml(paragraph.join(" ")))}</p>`);
13907
+ paragraph = [];
13908
+ }
13909
+ };
13910
+ const escapeBufferedTimeline = () => {
13911
+ for (const l of timeline) {
13912
+ const t = l.trim();
13913
+ if (t.length > 0)
13914
+ paragraph.push(t);
13915
+ }
13916
+ timeline.length = 0;
13917
+ flush();
13918
+ };
13919
+ for (const rawLine of markdown.split(`
13920
+ `)) {
13921
+ const line = rawLine.trim();
13922
+ if (line === "<!--kk:timeline-->") {
13923
+ flush();
13924
+ inTimeline = true;
13925
+ continue;
13926
+ }
13927
+ if (inTimeline) {
13928
+ if (line === "<!--/kk:timeline-->") {
13929
+ inTimeline = false;
13930
+ if (isGeneratedTimeline(timeline.join(`
13931
+ `))) {
13932
+ blocks.push(timeline.join(`
13933
+ `));
13934
+ timeline.length = 0;
13935
+ } else {
13936
+ escapeBufferedTimeline();
13937
+ }
13938
+ continue;
13939
+ }
13940
+ timeline.push(rawLine);
13941
+ continue;
13942
+ }
13943
+ if (line.length === 0) {
13944
+ flush();
13945
+ continue;
13946
+ }
13947
+ const heading = line.match(/^(#{1,3})\s+(.*)$/);
13948
+ if (heading) {
13949
+ flush();
13950
+ const tag = heading[1]?.length === 1 ? "h1" : heading[1]?.length === 2 ? "h2" : "h3";
13951
+ if (tag === "h1" && blocks.length === 0 && paragraph.length === 0) {
13952
+ continue;
13953
+ }
13954
+ blocks.push(`<${tag}>${renderInline(escapeHtml(heading[2] ?? ""))}</${tag}>`);
13955
+ continue;
13956
+ }
13957
+ paragraph.push(line);
13958
+ }
13959
+ flush();
13960
+ if (timeline.length > 0)
13961
+ escapeBufferedTimeline();
13962
+ return blocks.join(`
13963
+ `);
13964
+ }
13965
+ function itemBrief(d) {
13966
+ const meta = d.metadata ?? {};
13967
+ if (typeof meta.brief === "string" && meta.brief.trim().length > 0)
13968
+ return meta.brief.trim();
13969
+ return splitSentences((d.body ?? "").trim(), { cjkOnly: true }).slice(0, 2).join("");
13970
+ }
13971
+ function renderShowNotesMarkdown(docs, opts, warnings) {
13972
+ const slugify = (t) => {
13973
+ const s = t.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-");
13974
+ return s.length > 0 ? s : "post";
13975
+ };
13976
+ const baseUrl = opts.articleBaseUrl ?? "https://surfing.salty.vip";
13977
+ const segments = opts.articlePostPath?.split(/[\\/]/).filter((s) => s.length > 0) ?? [];
13978
+ const postSlug = segments.length >= 2 ? segments[segments.length - 2] ?? "" : "";
13979
+ const postLocale = segments.find((s) => ["zh", "en", "ja"].includes(s)) ?? "zh";
13980
+ const localePrefix = postLocale === "en" ? "" : `/${postLocale}`;
13981
+ const articleUrl = postSlug.length > 0 ? `${baseUrl}${localePrefix}/posts/${postSlug}` : `${baseUrl}/zh/posts/${opts.runDate}-${slugify(opts.title)}`;
13982
+ if (opts.briefMode === true && postSlug.length === 0) {
13983
+ warn("brief mode requested but ARTICLE_POST_PATH is empty; using legacy layout", warnings);
13984
+ }
13985
+ const briefMode = opts.briefMode === true && postSlug.length > 0;
13986
+ const sourceLabel = (d) => {
13987
+ const m = d.metadata ?? {};
13988
+ if (typeof m.sourceName === "string" && m.sourceName.trim())
13989
+ return m.sourceName.trim();
13990
+ try {
13991
+ return new URL(d.sourceUri ?? "").hostname.replace(/^www\./, "");
13992
+ } catch {
13993
+ return "\u6765\u6E90";
13994
+ }
13995
+ };
13996
+ const offsets = computeAudioOffsets(docs, opts.audioSegments);
13997
+ const lines = [`# ${opts.title}`, ""];
13998
+ const summaryOn = opts.summaryParagraph !== false;
13999
+ if (summaryOn) {
14000
+ const segmentSeconds = opts.audioSegments?.reduce((sum, segment) => sum + (segment.duration ?? 0), 0);
14001
+ const durationSeconds = opts.durationSeconds ?? (segmentSeconds && segmentSeconds > 0 ? segmentSeconds : undefined);
14002
+ const plan = docs.map((doc) => {
14003
+ const title = doc.title?.trim() ?? "";
14004
+ const brief = itemBrief(doc);
14005
+ return brief.length > 0 ? { title, brief } : { title };
14006
+ });
14007
+ const config = {
14008
+ ...SEO_DEFAULTS,
14009
+ ...opts.seriesName ? { seriesName: opts.seriesName } : {},
14010
+ ...opts.showName ? { showName: opts.showName } : {},
14011
+ ...opts.descriptionMin !== undefined ? { descriptionMin: opts.descriptionMin } : {},
14012
+ ...opts.descriptionMax !== undefined ? { descriptionMax: opts.descriptionMax } : {},
14013
+ ...opts.descriptionSource ? { descriptionSource: opts.descriptionSource } : {},
14014
+ ...opts.descriptionPattern ? { descriptionPattern: opts.descriptionPattern } : {},
14015
+ summaryParagraph: true,
14016
+ timelineStyle: opts.timelineStyle ?? "after"
14017
+ };
14018
+ const summary = showNotesSummaryParagraph({
14019
+ config,
14020
+ runDate: opts.runDate,
14021
+ ...durationSeconds !== undefined ? { durationSeconds } : {},
14022
+ plan,
14023
+ ...opts.refinedDescription ? { refinedDescription: opts.refinedDescription } : {},
14024
+ ...opts.summaryClauses ? { summaryClauses: opts.summaryClauses } : {},
14025
+ articleUrl,
14026
+ linkText: opts.surfingTitle?.trim() || "\u672C\u671F\u5B8C\u6574\u6587\u7AE0"
14027
+ });
14028
+ for (const line of summary.warnings)
14029
+ warn(line.replace(/^WARN podcast-pub:\s*/, ""), warnings);
14030
+ lines.push(summary.markdown, "");
14031
+ }
14032
+ if (briefMode) {
14033
+ lines.push(renderTimelineHtml(docs, offsets, articleUrl, opts.timelineStyle ?? "after"), "");
12505
14034
  } else {
12506
14035
  docs.forEach((d, i) => {
12507
14036
  const body = (d.body ?? "").trim();
12508
- const sentences = body.split(/(?<=[\u3002\uFF01\uFF1F])\s*/).slice(0, 2).join("");
14037
+ const sentences = splitSentences(body, { cjkOnly: true }).slice(0, 2).join("");
12509
14038
  lines.push(`## ${i + 1}. ${d.title ?? `\u6761\u76EE ${i + 1}`}`, "", sentences, "", `[\u6765\u6E90\uFF1A${sourceLabel(d)}](${d.sourceUri ?? ""})`, "");
12510
14039
  });
12511
14040
  lines.push(`\u8BE6\u60C5\u89C1\u672C\u671F\u5B8C\u6574\u6587\u7AE0\uFF1A[ ${opts.title} ](${articleUrl})`, "");
@@ -12625,134 +14154,6 @@ function mapContentToEpisode(content) {
12625
14154
  };
12626
14155
  }
12627
14156
 
12628
- // ../../plugins/publishings/podcast-pub/src/microfeed-client.ts
12629
- var MIN_TIMEOUT_MS = 1000;
12630
- var DEFAULT_TIMEOUT_MS = 300000;
12631
- function resolveTimeoutMs(raw) {
12632
- const parsed = raw === undefined ? Number.NaN : Number(raw);
12633
- if (!Number.isFinite(parsed) || parsed < MIN_TIMEOUT_MS) {
12634
- return DEFAULT_TIMEOUT_MS;
12635
- }
12636
- return parsed;
12637
- }
12638
-
12639
- class MicrofeedClient {
12640
- baseUrl;
12641
- apiKey;
12642
- timeoutMs;
12643
- constructor(baseUrl, apiKey, timeoutMs) {
12644
- this.baseUrl = baseUrl.replace(/\/+$/, "");
12645
- this.apiKey = apiKey;
12646
- this.timeoutMs = timeoutMs;
12647
- }
12648
- itemUrl(itemId) {
12649
- return `${this.baseUrl}/items/${itemId}`;
12650
- }
12651
- async request(path, init) {
12652
- let response;
12653
- try {
12654
- response = await fetch(`${this.baseUrl}${path}`, {
12655
- ...init,
12656
- headers: {
12657
- Authorization: `Bearer ${this.apiKey}`,
12658
- Origin: this.baseUrl,
12659
- ...init.headers
12660
- },
12661
- signal: AbortSignal.timeout(this.timeoutMs)
12662
- });
12663
- } catch (error) {
12664
- const reason = error instanceof Error ? error.message : String(error);
12665
- throw new Error(`microfeed transport error (${init.method ?? "GET"} ${path}): ${reason}`);
12666
- }
12667
- if (!response.ok) {
12668
- const text = (await response.text()).slice(0, 1000);
12669
- let detail = text.trim();
12670
- try {
12671
- const parsed = JSON.parse(text);
12672
- if (typeof parsed.error === "string")
12673
- detail = parsed.error;
12674
- } catch {}
12675
- throw new Error(`microfeed API request failed (HTTP ${response.status} ${response.statusText} ${path}): ${detail || "Unknown error"}`);
12676
- }
12677
- try {
12678
- return await response.json();
12679
- } catch {
12680
- throw new Error(`microfeed API returned a non-JSON response from ${path}`);
12681
- }
12682
- }
12683
- async createItem(input, idempotencyKey) {
12684
- const data = await this.request("/api/v1/items", {
12685
- method: "POST",
12686
- headers: {
12687
- "Content-Type": "application/json",
12688
- ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}
12689
- },
12690
- body: JSON.stringify(input)
12691
- });
12692
- if (typeof data.id !== "string" || data.id.length === 0) {
12693
- throw new Error("microfeed item create response is missing the item id");
12694
- }
12695
- return data.id;
12696
- }
12697
- async validateItem(input) {
12698
- await this.request("/api/v1/items/validate", {
12699
- method: "POST",
12700
- headers: { "Content-Type": "application/json" },
12701
- body: JSON.stringify(input)
12702
- });
12703
- }
12704
- async getItem(itemId) {
12705
- return await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
12706
- method: "GET"
12707
- });
12708
- }
12709
- async updateItem(itemId, input) {
12710
- return await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
12711
- method: "PUT",
12712
- headers: { "Content-Type": "application/json" },
12713
- body: JSON.stringify(input)
12714
- });
12715
- }
12716
- async deleteItem(itemId) {
12717
- await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
12718
- method: "DELETE",
12719
- headers: { "Content-Type": "application/json" }
12720
- });
12721
- }
12722
- async listItems() {
12723
- return await this.request("/api/v1/feed", { method: "GET" });
12724
- }
12725
- async prepareUpload(input) {
12726
- const data = await this.request("/api/v1/media_files/presigned_urls", {
12727
- method: "POST",
12728
- headers: { "Content-Type": "application/json" },
12729
- body: JSON.stringify(input)
12730
- });
12731
- if (typeof data.presigned_url !== "string" || typeof data.media_url !== "string") {
12732
- throw new Error("microfeed upload-preparation response is missing presigned_url/media_url");
12733
- }
12734
- return { presignedUrl: data.presigned_url, mediaUrl: data.media_url };
12735
- }
12736
- async uploadBytes(presignedUrl, bytes, mimeType) {
12737
- let response;
12738
- try {
12739
- response = await fetch(presignedUrl, {
12740
- method: "PUT",
12741
- headers: { "Content-Type": mimeType },
12742
- body: bytes,
12743
- signal: AbortSignal.timeout(this.timeoutMs)
12744
- });
12745
- } catch (error) {
12746
- const reason = error instanceof Error ? error.message : String(error);
12747
- throw new Error(`microfeed transport error (PUT audio bytes): ${reason}`);
12748
- }
12749
- if (!response.ok) {
12750
- const text = (await response.text()).slice(0, 1000);
12751
- throw new Error(`microfeed audio upload failed (HTTP ${response.status} ${response.statusText}): ${text.trim() || "Unknown error"}`);
12752
- }
12753
- }
12754
- }
12755
-
12756
14157
  // ../../plugins/publishings/podcast-pub/src/index.ts
12757
14158
  function publicItemUrl(item, itemId, client) {
12758
14159
  const microfeed = item?._microfeed;
@@ -12762,8 +14163,8 @@ function publicItemUrl(item, itemId, client) {
12762
14163
  const feedUrl = typeof item?.url === "string" ? item.url : "";
12763
14164
  return feedUrl !== "" ? feedUrl : client.itemUrl(itemId);
12764
14165
  }
12765
- var TARGET = "podcast-pub";
12766
- var OPERATIONS = ["publish", "show-notes", "list", "get", "update", "delete", "validate"];
14166
+ var TARGET2 = "podcast-pub";
14167
+ var OPERATIONS = ["publish", "show-notes", "list", "get", "update", "delete", "validate", "backfill-seo"];
12767
14168
  function resolveClient() {
12768
14169
  const baseUrl = process.env.MICROFEED_BASE_URL;
12769
14170
  const apiKey = process.env.MICROFEED_API_KEY;
@@ -12781,8 +14182,8 @@ function resolveClient() {
12781
14182
  client: new MicrofeedClient(baseUrl.trim(), apiKey.trim(), resolveTimeoutMs(process.env.MICROFEED_TIMEOUT_MS))
12782
14183
  };
12783
14184
  }
12784
- function fail(error) {
12785
- return ResultSchema.parse({ ok: false, target: TARGET, error });
14185
+ function fail2(error) {
14186
+ return ResultSchema.parse({ ok: false, target: TARGET2, error });
12786
14187
  }
12787
14188
  var COVER_MIME = {
12788
14189
  ".png": "image/png",
@@ -12829,7 +14230,7 @@ function resolveShowNotesRenderOptions(options) {
12829
14230
  const audioContentPath = resolveOptionString(undefined, "PODCAST_PUB_AUDIO_CONTENT");
12830
14231
  if (audioContentPath !== undefined) {
12831
14232
  try {
12832
- const parsed = JSON.parse(readFileSync2(audioContentPath, "utf-8"));
14233
+ const parsed = JSON.parse(readFileSync3(audioContentPath, "utf-8"));
12833
14234
  const meta = parsed?.metadata;
12834
14235
  if (Array.isArray(meta?.segments)) {
12835
14236
  audioSegments = meta.segments.map((s) => ({
@@ -12839,6 +14240,10 @@ function resolveShowNotesRenderOptions(options) {
12839
14240
  }
12840
14241
  } catch {}
12841
14242
  }
14243
+ const seoConfig = resolvePodcastSeoConfig(undefined);
14244
+ if ("error" in seoConfig)
14245
+ return { error: seoConfig.error };
14246
+ const config = seoConfig.config;
12842
14247
  return {
12843
14248
  renderOptions: {
12844
14249
  title,
@@ -12847,7 +14252,15 @@ function resolveShowNotesRenderOptions(options) {
12847
14252
  ...briefMode ? { briefMode: true } : {},
12848
14253
  ...articlePostPath !== undefined ? { articlePostPath } : {},
12849
14254
  ...footerNote !== undefined ? { footerNote } : {},
12850
- ...audioSegments !== undefined ? { audioSegments } : {}
14255
+ ...audioSegments !== undefined ? { audioSegments } : {},
14256
+ summaryParagraph: config.summaryParagraph,
14257
+ timelineStyle: config.timelineStyle,
14258
+ seriesName: config.seriesName,
14259
+ showName: config.showName,
14260
+ descriptionMin: config.descriptionMin,
14261
+ descriptionMax: config.descriptionMax,
14262
+ descriptionSource: config.descriptionSource,
14263
+ descriptionPattern: config.descriptionPattern
12851
14264
  }
12852
14265
  };
12853
14266
  }
@@ -12860,7 +14273,8 @@ function renderShowNotesFromOptions(options) {
12860
14273
  const renderOptions = resolveShowNotesRenderOptions(options);
12861
14274
  if ("error" in renderOptions)
12862
14275
  return { error: renderOptions.error };
12863
- return { markdown: renderShowNotesMarkdown(docs.data, renderOptions.renderOptions) };
14276
+ const warnings = [];
14277
+ return { markdown: renderShowNotesMarkdown(docs.data, renderOptions.renderOptions, warnings), warnings };
12864
14278
  }
12865
14279
  function resolveAssemblyOptions(options) {
12866
14280
  const assembly = {};
@@ -12895,27 +14309,124 @@ function audioAttachment(audio, mediaUrl, sizeInBytes, durationSeconds) {
12895
14309
  ...durationSeconds !== undefined ? { duration_in_seconds: durationSeconds } : {}
12896
14310
  };
12897
14311
  }
12898
- function episodeExtras(episode) {
14312
+ function episodeExtras(episode, seo) {
12899
14313
  return {
12900
14314
  ...episode.imageUrl ? { image: episode.imageUrl } : {},
12901
14315
  ...episode.datePublishedMs !== undefined ? { date_published_ms: episode.datePublishedMs } : {},
12902
14316
  ...episode.link ? { url: episode.link } : {},
12903
- ...{
12904
- _microfeed: {
12905
- "itunes:episodeType": episode.itunes?.episodeType ?? "full",
12906
- ...episode.itunes?.title ? { "itunes:title": episode.itunes.title } : {},
12907
- ...episode.itunes?.season !== undefined ? { "itunes:season": episode.itunes.season } : {},
12908
- ...episode.itunes?.episode !== undefined ? { "itunes:episode": episode.itunes.episode } : {},
12909
- ...episode.itunes?.explicit !== undefined ? { "itunes:explicit": episode.itunes.explicit } : {},
12910
- ...episode.itunes?.block !== undefined ? { "itunes:block": episode.itunes.block } : {}
12911
- }
14317
+ _microfeed: {
14318
+ "itunes:episodeType": episode.itunes?.episodeType ?? "full",
14319
+ ...episode.itunes?.title ? { "itunes:title": episode.itunes.title } : {},
14320
+ ...episode.itunes?.season !== undefined ? { "itunes:season": episode.itunes.season } : {},
14321
+ ...episode.itunes?.episode !== undefined ? { "itunes:episode": episode.itunes.episode } : {},
14322
+ ...episode.itunes?.explicit !== undefined ? { "itunes:explicit": episode.itunes.explicit } : {},
14323
+ ...episode.itunes?.block !== undefined ? { "itunes:block": episode.itunes.block } : {},
14324
+ ...seo ? { seo } : {}
14325
+ }
14326
+ };
14327
+ }
14328
+ function shapedEpisode(episode, fields) {
14329
+ return {
14330
+ ...episode,
14331
+ title: fields.title,
14332
+ ...fields.itunes ? { itunes: fields.itunes } : {},
14333
+ ...fields.durationSeconds !== undefined ? { durationSeconds: fields.durationSeconds } : { durationSeconds: undefined },
14334
+ link: fields.link
14335
+ };
14336
+ }
14337
+ async function createEpisodeItem(client, body, slug, warnings) {
14338
+ const withSlug = (itemSlug) => {
14339
+ const micro = body._microfeed ?? {};
14340
+ return { ...body, _microfeed: { ...micro, slug: itemSlug } };
14341
+ };
14342
+ if (!slug)
14343
+ return client.createItem(body, crypto.randomUUID());
14344
+ try {
14345
+ return await client.createItem(withSlug(slug), crypto.randomUUID());
14346
+ } catch (error) {
14347
+ if (!(error instanceof MicrofeedRequestError) || error.status !== 409)
14348
+ throw error;
14349
+ const retry = `${slug}-2`;
14350
+ try {
14351
+ return await client.createItem(withSlug(retry), crypto.randomUUID());
14352
+ } catch (retryError) {
14353
+ if (!(retryError instanceof MicrofeedRequestError) || retryError.status !== 409)
14354
+ throw retryError;
14355
+ warn(`slug "${slug}" conflicted (HTTP 409); publishing without a custom slug`, warnings);
14356
+ return client.createItem(body, crypto.randomUUID());
12912
14357
  }
14358
+ }
14359
+ }
14360
+ async function resolveSocialImage(client, fields, warnings) {
14361
+ if (!fields.socialAlt)
14362
+ return;
14363
+ if (fields.socialLocalPath && fields.socialMime) {
14364
+ const bytes = new Uint8Array(await readFile(fields.socialLocalPath));
14365
+ const prepared = await client.prepareUpload({
14366
+ category: "image",
14367
+ full_local_file_path: basename(fields.socialLocalPath),
14368
+ size: bytes.byteLength,
14369
+ type: fields.socialMime
14370
+ });
14371
+ await client.uploadBytes(prepared.presignedUrl, bytes, fields.socialMime);
14372
+ return {
14373
+ url: prepared.mediaUrl,
14374
+ width: 1200,
14375
+ height: 630,
14376
+ mime_type: fields.socialMime,
14377
+ alt: fields.socialAlt
14378
+ };
14379
+ }
14380
+ if (!fields.socialRemoteUrl)
14381
+ return;
14382
+ let bytes;
14383
+ try {
14384
+ const response = await fetch(fields.socialRemoteUrl);
14385
+ if (!response.ok)
14386
+ throw new Error(`HTTP ${response.status}`);
14387
+ bytes = new Uint8Array(await response.arrayBuffer());
14388
+ } catch (error) {
14389
+ warn(`social image skipped: could not read ${fields.socialRemoteUrl} (${error instanceof Error ? error.message : String(error)})`, warnings);
14390
+ return;
14391
+ }
14392
+ const dims = readImageDimensions(bytes);
14393
+ if (dims?.width !== 1200 || dims?.height !== 630) {
14394
+ warn(`social image skipped: expected a 1200\xD7630 PNG or JPEG, got ${dims ? `${dims.width}\xD7${dims.height}` : "an unreadable file"}`, warnings);
14395
+ return;
14396
+ }
14397
+ return {
14398
+ url: fields.socialRemoteUrl,
14399
+ width: 1200,
14400
+ height: 630,
14401
+ mime_type: dims.mime,
14402
+ alt: fields.socialAlt
14403
+ };
14404
+ }
14405
+ function mergeSocial(seo, social) {
14406
+ if (!social)
14407
+ return seo;
14408
+ return { ...seo ?? {}, social_image: social };
14409
+ }
14410
+ function mappedValidateInput(content) {
14411
+ const seoConfig = resolvePodcastSeoConfig(content.metadata);
14412
+ if ("error" in seoConfig)
14413
+ throw new Error(seoConfig.error);
14414
+ const episode = mapContentToEpisode(content);
14415
+ const policy = prepareEpisodeFields(episode, content.metadata ?? {}, seoConfig.config);
14416
+ if (!policy.ok)
14417
+ throw new Error(policy.error);
14418
+ const shaped = shapedEpisode(episode, policy.fields);
14419
+ return {
14420
+ title: shaped.title,
14421
+ content_html: shaped.contentHtml,
14422
+ status: shaped.status,
14423
+ ...episodeExtras(shaped, policy.fields.seo)
12913
14424
  };
12914
14425
  }
12915
14426
  async function publishEpisode(client, payload, options) {
12916
14427
  const assembly = resolveAssemblyOptions(options);
12917
14428
  if ("error" in assembly)
12918
- return fail(assembly.error);
14429
+ return fail2(assembly.error);
12919
14430
  const { audioFile, coverPath, durationSec } = assembly.assembly;
12920
14431
  const content = audioFile !== undefined || coverPath !== undefined || durationSec !== undefined ? {
12921
14432
  ...payload.content,
@@ -12930,17 +14441,31 @@ async function publishEpisode(client, payload, options) {
12930
14441
  try {
12931
14442
  episode = mapContentToEpisode(content);
12932
14443
  } catch (error) {
12933
- return fail(`Invalid Content: ${error instanceof Error ? error.message : String(error)}`);
14444
+ return fail2(`Invalid Content: ${error instanceof Error ? error.message : String(error)}`);
14445
+ }
14446
+ const seoConfig = resolvePodcastSeoConfig(content.metadata);
14447
+ if ("error" in seoConfig)
14448
+ return fail2(seoConfig.error);
14449
+ const policy = prepareEpisodeFields(episode, content.metadata ?? {}, seoConfig.config);
14450
+ if (!policy.ok)
14451
+ return fail2(policy.error);
14452
+ const fields = policy.fields;
14453
+ let social;
14454
+ try {
14455
+ social = await resolveSocialImage(client, fields, fields.warnings);
14456
+ } catch (error) {
14457
+ return fail2(error instanceof Error ? error.message : String(error));
12934
14458
  }
12935
- const idempotencyKey = crypto.randomUUID();
14459
+ const seo = mergeSocial(fields.seo, social);
14460
+ episode = shapedEpisode(episode, fields);
12936
14461
  if (episode.audio.kind === "url") {
12937
- const itemId = await client.createItem({
14462
+ const itemId = await createEpisodeItem(client, {
12938
14463
  title: episode.title,
12939
14464
  content_html: episode.contentHtml,
12940
14465
  status: episode.status,
12941
14466
  attachments: [audioAttachment(episode.audio, episode.audio.url, undefined, episode.durationSeconds)],
12942
- ...episodeExtras(episode)
12943
- }, idempotencyKey);
14467
+ ...episodeExtras(episode, seo)
14468
+ }, fields.slug, fields.warnings);
12944
14469
  let hostedItem;
12945
14470
  try {
12946
14471
  hostedItem = (await client.getItem(itemId)).items?.[0];
@@ -12949,17 +14474,18 @@ async function publishEpisode(client, payload, options) {
12949
14474
  }
12950
14475
  return ResultSchema.parse({
12951
14476
  ok: true,
12952
- target: TARGET,
14477
+ target: TARGET2,
12953
14478
  url: publicItemUrl(hostedItem, itemId, client),
12954
14479
  id: itemId,
12955
- message: "Published successfully to microfeed (hosted audio, single-call create)"
14480
+ message: "Published successfully to microfeed (hosted audio, single-call create)",
14481
+ ...fields.warnings.length > 0 ? { metadata: { warnings: fields.warnings } } : {}
12956
14482
  });
12957
14483
  }
12958
- const itemId = await client.createItem({
14484
+ const itemId = await createEpisodeItem(client, {
12959
14485
  title: episode.title,
12960
14486
  content_html: episode.contentHtml,
12961
14487
  status: "unpublished"
12962
- }, idempotencyKey);
14488
+ }, fields.slug, fields.warnings);
12963
14489
  const bytes = new Uint8Array(await readFile(episode.audio.path));
12964
14490
  const prepared = await client.prepareUpload({
12965
14491
  category: "audio",
@@ -12988,15 +14514,19 @@ async function publishEpisode(client, payload, options) {
12988
14514
  status: episode.status,
12989
14515
  attachments: [audioAttachment(episode.audio, prepared.mediaUrl, bytes.byteLength, episode.durationSeconds)],
12990
14516
  ...imageUrl ? { image: imageUrl } : {},
12991
- ...episodeExtras(episode)
14517
+ ...episodeExtras(episode, seo)
12992
14518
  });
12993
14519
  return ResultSchema.parse({
12994
14520
  ok: true,
12995
- target: TARGET,
14521
+ target: TARGET2,
12996
14522
  url: publicItemUrl(item, itemId, client),
12997
14523
  id: itemId,
12998
14524
  message: "Published successfully to microfeed (two-phase upload + publish)",
12999
- metadata: { itemId, imageUrl: imageUrl ?? null }
14525
+ metadata: {
14526
+ itemId,
14527
+ imageUrl: imageUrl ?? null,
14528
+ ...fields.warnings.length > 0 ? { warnings: fields.warnings } : {}
14529
+ }
13000
14530
  });
13001
14531
  }
13002
14532
 
@@ -13006,21 +14536,24 @@ class PodcastPubTransport {
13006
14536
  const options = payload.options !== undefined && typeof payload.options === "object" && payload.options !== null ? payload.options : {};
13007
14537
  const routed = resolveOperation(options);
13008
14538
  if ("error" in routed)
13009
- return fail(routed.error);
14539
+ return fail2(routed.error);
13010
14540
  if (routed.operation === "show-notes") {
13011
14541
  const rendered = renderShowNotesFromOptions(options);
13012
14542
  if ("error" in rendered)
13013
- return fail(rendered.error);
14543
+ return fail2(rendered.error);
13014
14544
  return ResultSchema.parse({
13015
14545
  ok: true,
13016
- target: TARGET,
14546
+ target: TARGET2,
13017
14547
  message: "Rendered show notes markdown (pure render; no microfeed API call)",
13018
- metadata: { markdown: rendered.markdown }
14548
+ metadata: {
14549
+ markdown: rendered.markdown,
14550
+ ...rendered.warnings.length > 0 ? { warnings: rendered.warnings } : {}
14551
+ }
13019
14552
  });
13020
14553
  }
13021
14554
  const resolved = resolveClient();
13022
14555
  if ("error" in resolved)
13023
- return fail(resolved.error);
14556
+ return fail2(resolved.error);
13024
14557
  const { client } = resolved;
13025
14558
  try {
13026
14559
  switch (routed.operation) {
@@ -13030,7 +14563,7 @@ class PodcastPubTransport {
13030
14563
  const feed = await client.listItems();
13031
14564
  return ResultSchema.parse({
13032
14565
  ok: true,
13033
- target: TARGET,
14566
+ target: TARGET2,
13034
14567
  url: typeof feed.feed_url === "string" ? feed.feed_url : undefined,
13035
14568
  message: `Listed ${feed.items?.length ?? 0} microfeed item(s)`,
13036
14569
  metadata: { items: feed.items ?? [] }
@@ -13039,14 +14572,14 @@ class PodcastPubTransport {
13039
14572
  case "get": {
13040
14573
  const itemId = resolveItemId(options);
13041
14574
  if ("error" in itemId)
13042
- return fail(itemId.error);
14575
+ return fail2(itemId.error);
13043
14576
  const feed = await client.getItem(itemId.itemId);
13044
14577
  const item = feed.items?.[0];
13045
14578
  if (!item)
13046
- return fail(`microfeed item "${itemId.itemId}" not found in the feed response`);
14579
+ return fail2(`microfeed item "${itemId.itemId}" not found in the feed response`);
13047
14580
  return ResultSchema.parse({
13048
14581
  ok: true,
13049
- target: TARGET,
14582
+ target: TARGET2,
13050
14583
  id: itemId.itemId,
13051
14584
  url: typeof item.url === "string" ? item.url : undefined,
13052
14585
  message: `Fetched microfeed item ${itemId.itemId}`,
@@ -13056,14 +14589,14 @@ class PodcastPubTransport {
13056
14589
  case "update": {
13057
14590
  const itemId = resolveItemId(options);
13058
14591
  if ("error" in itemId)
13059
- return fail(itemId.error);
14592
+ return fail2(itemId.error);
13060
14593
  if (options.item === undefined || typeof options.item !== "object" || options.item === null) {
13061
- return fail("options.item (microfeed item input object) is required for the update operation");
14594
+ return fail2("options.item (microfeed item input object) is required for the update operation");
13062
14595
  }
13063
14596
  const item = await client.updateItem(itemId.itemId, options.item);
13064
14597
  return ResultSchema.parse({
13065
14598
  ok: true,
13066
- target: TARGET,
14599
+ target: TARGET2,
13067
14600
  id: itemId.itemId,
13068
14601
  url: typeof item.url === "string" ? item.url : undefined,
13069
14602
  message: `Updated microfeed item ${itemId.itemId}`,
@@ -13073,34 +14606,33 @@ class PodcastPubTransport {
13073
14606
  case "delete": {
13074
14607
  const itemId = resolveItemId(options);
13075
14608
  if ("error" in itemId)
13076
- return fail(itemId.error);
14609
+ return fail2(itemId.error);
13077
14610
  await client.deleteItem(itemId.itemId);
13078
14611
  return ResultSchema.parse({
13079
14612
  ok: true,
13080
- target: TARGET,
14613
+ target: TARGET2,
13081
14614
  id: itemId.itemId,
13082
14615
  message: `Deleted microfeed item ${itemId.itemId}`
13083
14616
  });
13084
14617
  }
14618
+ case "backfill-seo": {
14619
+ const config = resolvePodcastSeoConfig(payload.content.metadata);
14620
+ if ("error" in config)
14621
+ return fail2(config.error);
14622
+ return await runBackfill(client, options, config.config);
14623
+ }
13085
14624
  case "validate": {
13086
- const input = options.item !== undefined && typeof options.item === "object" && options.item !== null ? options.item : (() => {
13087
- const episode = mapContentToEpisode(payload.content);
13088
- return {
13089
- title: episode.title,
13090
- content_html: episode.contentHtml,
13091
- status: episode.status
13092
- };
13093
- })();
14625
+ const input = options.item !== undefined && typeof options.item === "object" && options.item !== null ? options.item : mappedValidateInput(payload.content);
13094
14626
  await client.validateItem(input);
13095
14627
  return ResultSchema.parse({
13096
14628
  ok: true,
13097
- target: TARGET,
14629
+ target: TARGET2,
13098
14630
  message: "microfeed accepted the item input (POST /api/v1/items/validate)"
13099
14631
  });
13100
14632
  }
13101
14633
  }
13102
14634
  } catch (error) {
13103
- return fail(`${error instanceof Error ? error.message : String(error)}`);
14635
+ return fail2(`${error instanceof Error ? error.message : String(error)}`);
13104
14636
  }
13105
14637
  }
13106
14638
  }
@@ -13121,7 +14653,7 @@ async function renderShowNotesIO(docs, outputPath) {
13121
14653
  const rendered = renderShowNotesFromOptions({ docs });
13122
14654
  if ("error" in rendered) {
13123
14655
  const message = `show-notes render failed: ${rendered.error}`;
13124
- await writePublishResult(outputPath, fail(message));
14656
+ await writePublishResult(outputPath, fail2(message));
13125
14657
  throw new Error(message);
13126
14658
  }
13127
14659
  await createNodeFileSystem().writeFile(outputPath, rendered.markdown);
@@ -13143,7 +14675,7 @@ async function processPublishIO(inputPath, outputPath, transport = getPublishTra
13143
14675
  }
13144
14676
  } catch (error) {
13145
14677
  const message = `Invalid Content input: ${error instanceof Error ? error.message : String(error)}`;
13146
- await writePublishResult(outputPath, ResultSchema.parse({ ok: false, target: TARGET, error: message }));
14678
+ await writePublishResult(outputPath, ResultSchema.parse({ ok: false, target: TARGET2, error: message }));
13147
14679
  throw new Error(message);
13148
14680
  }
13149
14681
  const result = await transport.publish({ content, options });