@gobing-ai/knowledge-kit 0.1.7 → 0.1.9

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 (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.js +744 -1107
  3. package/dist/board/daily-voice/index.js +741 -107
  4. package/dist/board/itc/index.js +32 -6
  5. package/dist/index.js +1793 -103
  6. package/package.json +1 -1
  7. package/plugins/ingestions/horizon-ingest/dist/index.js +24 -1
  8. package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
  9. package/plugins/kk/config.example.yaml +8 -0
  10. package/plugins/kk/decisions/extract/ai-news.yaml +7 -1
  11. package/plugins/kk/plugin.json +1 -1
  12. package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
  13. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
  14. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
  15. package/plugins/kk/scripts/daily-stages.ts +664 -83
  16. package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
  17. package/plugins/kk/scripts/topic-boundary.json +18 -83
  18. package/plugins/kk/workflows/kk-collect.yaml +2 -0
  19. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +82 -8
  20. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
  21. package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
  22. package/plugins/publishings/podcast-pub/README.md +65 -0
  23. package/plugins/publishings/podcast-pub/dist/index.js +1709 -274
  24. package/plugins/publishings/podcast-pub/plugin.json +1 -1
  25. package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
  26. package/plugins/publishings/podcast-pub/src/index.ts +211 -34
  27. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
  28. package/plugins/publishings/podcast-pub/src/seo.ts +1081 -0
  29. package/plugins/publishings/podcast-pub/src/show-notes.ts +71 -6
  30. package/plugins/publishings/surfdash-pub/dist/index.js +66 -5
  31. package/plugins/publishings/surfdash-pub/src/index.ts +94 -10
  32. package/plugins/publishings/wechatmp-pub/dist/index.js +54 -30
  33. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +8 -3
  34. package/plugins/publishings/wechatmp-pub/src/enrich.ts +58 -38
  35. package/plugins/publishings/wechatmp-pub/src/flow.ts +18 -1
  36. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -1
@@ -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,6 +12257,59 @@ 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) {}
12259
12315
  // ../../packages/publish-harness/src/index.ts
@@ -12305,104 +12361,1443 @@ class FakeFileCliTransport {
12305
12361
  // ../../plugins/publishings/podcast-pub/src/index.ts
12306
12362
  init_dist();
12307
12363
 
12308
- // ../../plugins/publishings/podcast-pub/src/map.ts
12309
- import { extname } from "path";
12364
+ // ../../plugins/publishings/podcast-pub/src/backfill.ts
12365
+ init_dist();
12310
12366
 
12311
- // ../../plugins/publishings/podcast-pub/src/show-notes.ts
12312
- function escapeHtml(value) {
12313
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
12367
+ // ../../plugins/publishings/podcast-pub/src/microfeed-client.ts
12368
+ class MicrofeedRequestError extends Error {
12369
+ status;
12370
+ constructor(status, message) {
12371
+ super(message);
12372
+ this.name = "MicrofeedRequestError";
12373
+ this.status = status;
12374
+ }
12314
12375
  }
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);
12376
+ var MIN_TIMEOUT_MS = 1000;
12377
+ var DEFAULT_TIMEOUT_MS = 300000;
12378
+ function resolveTimeoutMs(raw) {
12379
+ const parsed = raw === undefined ? Number.NaN : Number(raw);
12380
+ if (!Number.isFinite(parsed) || parsed < MIN_TIMEOUT_MS) {
12381
+ return DEFAULT_TIMEOUT_MS;
12324
12382
  }
12325
- return (out + rest).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
12383
+ return parsed;
12326
12384
  }
12327
- function normalizeForMatch(text) {
12328
- return text.toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, "");
12385
+
12386
+ class MicrofeedClient {
12387
+ baseUrl;
12388
+ apiKey;
12389
+ timeoutMs;
12390
+ constructor(baseUrl, apiKey, timeoutMs) {
12391
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
12392
+ this.apiKey = apiKey;
12393
+ this.timeoutMs = timeoutMs;
12394
+ }
12395
+ itemUrl(itemId) {
12396
+ return `${this.baseUrl}/items/${itemId}`;
12397
+ }
12398
+ async request(path, init) {
12399
+ let response;
12400
+ try {
12401
+ response = await fetch(`${this.baseUrl}${path}`, {
12402
+ ...init,
12403
+ headers: {
12404
+ Authorization: `Bearer ${this.apiKey}`,
12405
+ Origin: this.baseUrl,
12406
+ ...init.headers
12407
+ },
12408
+ signal: AbortSignal.timeout(this.timeoutMs)
12409
+ });
12410
+ } catch (error) {
12411
+ const reason = error instanceof Error ? error.message : String(error);
12412
+ throw new Error(`microfeed transport error (${init.method ?? "GET"} ${path}): ${reason}`);
12413
+ }
12414
+ if (!response.ok) {
12415
+ const text = (await response.text()).slice(0, 1000);
12416
+ let detail = text.trim();
12417
+ try {
12418
+ const parsed = JSON.parse(text);
12419
+ if (typeof parsed.error === "string")
12420
+ detail = parsed.error;
12421
+ } catch {}
12422
+ throw new MicrofeedRequestError(response.status, `microfeed API request failed (HTTP ${response.status} ${response.statusText} ${path}): ${detail || "Unknown error"}`);
12423
+ }
12424
+ try {
12425
+ return await response.json();
12426
+ } catch {
12427
+ throw new Error(`microfeed API returned a non-JSON response from ${path}`);
12428
+ }
12429
+ }
12430
+ async createItem(input, idempotencyKey) {
12431
+ const data = await this.request("/api/v1/items", {
12432
+ method: "POST",
12433
+ headers: {
12434
+ "Content-Type": "application/json",
12435
+ ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}
12436
+ },
12437
+ body: JSON.stringify(input)
12438
+ });
12439
+ if (typeof data.id !== "string" || data.id.length === 0) {
12440
+ throw new Error("microfeed item create response is missing the item id");
12441
+ }
12442
+ return data.id;
12443
+ }
12444
+ async validateItem(input) {
12445
+ await this.request("/api/v1/items/validate", {
12446
+ method: "POST",
12447
+ headers: { "Content-Type": "application/json" },
12448
+ body: JSON.stringify(input)
12449
+ });
12450
+ }
12451
+ async getItem(itemId) {
12452
+ return await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
12453
+ method: "GET"
12454
+ });
12455
+ }
12456
+ async updateItem(itemId, input) {
12457
+ return await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
12458
+ method: "PUT",
12459
+ headers: { "Content-Type": "application/json" },
12460
+ body: JSON.stringify(input)
12461
+ });
12462
+ }
12463
+ async deleteItem(itemId) {
12464
+ await this.request(`/api/v1/items/${encodeURIComponent(itemId)}`, {
12465
+ method: "DELETE",
12466
+ headers: { "Content-Type": "application/json" }
12467
+ });
12468
+ }
12469
+ async listItems() {
12470
+ const first = await this.request("/api/v1/feed", { method: "GET" });
12471
+ const items = [...first.items ?? []];
12472
+ const seen = new Set(["/api/v1/feed"]);
12473
+ let next = typeof first.next_url === "string" ? first.next_url : "";
12474
+ for (let page = 1;page < 20 && next !== "" && !seen.has(next); page++) {
12475
+ seen.add(next);
12476
+ const path = next.startsWith(this.baseUrl) ? next.slice(this.baseUrl.length) || "/" : next;
12477
+ if (!path.startsWith("/"))
12478
+ break;
12479
+ const more = await this.request(path, { method: "GET" });
12480
+ items.push(...more.items ?? []);
12481
+ next = typeof more.next_url === "string" ? more.next_url : "";
12482
+ }
12483
+ return { ...first, items };
12484
+ }
12485
+ async prepareUpload(input) {
12486
+ const data = await this.request("/api/v1/media_files/presigned_urls", {
12487
+ method: "POST",
12488
+ headers: { "Content-Type": "application/json" },
12489
+ body: JSON.stringify(input)
12490
+ });
12491
+ if (typeof data.presigned_url !== "string" || typeof data.media_url !== "string") {
12492
+ throw new Error("microfeed upload-preparation response is missing presigned_url/media_url");
12493
+ }
12494
+ return { presignedUrl: data.presigned_url, mediaUrl: data.media_url };
12495
+ }
12496
+ async uploadBytes(presignedUrl, bytes, mimeType) {
12497
+ let response;
12498
+ try {
12499
+ response = await fetch(presignedUrl, {
12500
+ method: "PUT",
12501
+ headers: { "Content-Type": mimeType },
12502
+ body: bytes,
12503
+ signal: AbortSignal.timeout(this.timeoutMs)
12504
+ });
12505
+ } catch (error) {
12506
+ const reason = error instanceof Error ? error.message : String(error);
12507
+ throw new Error(`microfeed transport error (PUT audio bytes): ${reason}`);
12508
+ }
12509
+ if (!response.ok) {
12510
+ const text = (await response.text()).slice(0, 1000);
12511
+ throw new Error(`microfeed audio upload failed (HTTP ${response.status} ${response.statusText}): ${text.trim() || "Unknown error"}`);
12512
+ }
12513
+ }
12329
12514
  }
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")}`;
12515
+
12516
+ // ../../plugins/publishings/podcast-pub/src/seo.ts
12517
+ init_dist();
12518
+ import { readFileSync as readFileSync2 } from "fs";
12519
+ function codePointLength(value) {
12520
+ return Array.from(value).length;
12521
+ }
12522
+ var WARN_PREFIX = "WARN podcast-pub:";
12523
+ function warn(message, sink) {
12524
+ const line = message.startsWith(WARN_PREFIX) ? message : `${WARN_PREFIX} ${message}`;
12525
+ echoError(line);
12526
+ sink?.push(line);
12527
+ }
12528
+ var SEO_DEFAULTS = {
12529
+ seo: true,
12530
+ titleMode: "as-is",
12531
+ titlePattern: "{series} \xB7 {date}\uFF1A{keywords}",
12532
+ seriesName: "\u6BCF\u65E5AI\u4E71\u7096",
12533
+ showName: "\u54B8\u8BDD\u54B8\u8BF4",
12534
+ titleMax: 55,
12535
+ titleMaxKeywords: 2,
12536
+ seoTitleSuffix: "\uFF5C\u54B8\u8BDD\u54B8\u8BF4",
12537
+ seoTitleMax: 60,
12538
+ descriptionMin: 120,
12539
+ descriptionMax: 155,
12540
+ descriptionSource: "plan",
12541
+ descriptionPattern: "{show}\xB7{series} {date}\uFF08\u7EA6 {minutes} \u5206\u949F\uFF09\uFF1A{clauses}\u3002",
12542
+ summaryParagraph: true,
12543
+ timelineStyle: "after",
12544
+ autoEpisodeNumber: true,
12545
+ titleSanitize: "normalize",
12546
+ linkField: "none",
12547
+ slugMode: "pattern",
12548
+ slugPattern: "daily-ai-{date}",
12549
+ socialImage: false,
12550
+ socialImageAltPattern: "{series} {date} \u5C01\u9762\uFF1A{keywords}",
12551
+ roundDuration: true
12552
+ };
12553
+ var ENV_KEYS = {
12554
+ seo: "PODCAST_PUB_SEO",
12555
+ titleMode: "PODCAST_PUB_EPISODE_TITLE_MODE",
12556
+ titlePattern: "PODCAST_PUB_EPISODE_TITLE_PATTERN",
12557
+ seriesName: "PODCAST_PUB_SERIES_NAME",
12558
+ showName: "PODCAST_PUB_SHOW_NAME",
12559
+ titleMax: "PODCAST_PUB_TITLE_MAX",
12560
+ titleMaxKeywords: "PODCAST_PUB_TITLE_MAX_KEYWORDS",
12561
+ seoTitleSuffix: "PODCAST_PUB_SEO_TITLE_SUFFIX",
12562
+ seoTitleMax: "PODCAST_PUB_SEO_TITLE_MAX",
12563
+ descriptionMin: "PODCAST_PUB_SEO_DESCRIPTION_MIN",
12564
+ descriptionMax: "PODCAST_PUB_SEO_DESCRIPTION_MAX",
12565
+ descriptionSource: "PODCAST_PUB_SEO_DESCRIPTION_SOURCE",
12566
+ descriptionPattern: "PODCAST_PUB_SEO_DESCRIPTION_PATTERN",
12567
+ summaryParagraph: "PODCAST_PUB_SUMMARY_PARAGRAPH",
12568
+ timelineStyle: "PODCAST_PUB_TIMELINE_INLINE_STYLE",
12569
+ autoEpisodeNumber: "PODCAST_PUB_AUTO_EPISODE_NUMBER",
12570
+ titleSanitize: "PODCAST_PUB_TITLE_SANITIZE",
12571
+ linkField: "PODCAST_PUB_LINK_FIELD",
12572
+ slugMode: "PODCAST_PUB_SLUG",
12573
+ slugPattern: "PODCAST_PUB_SLUG_PATTERN",
12574
+ socialImage: "PODCAST_PUB_SOCIAL_IMAGE",
12575
+ socialImageAltPattern: "PODCAST_PUB_SOCIAL_IMAGE_ALT_PATTERN",
12576
+ roundDuration: "PODCAST_PUB_ROUND_DURATION"
12577
+ };
12578
+ function asRecord(value) {
12579
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
12580
+ return value;
12581
+ }
12582
+ return;
12336
12583
  }
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
- });
12584
+ function podcastOf(metadata) {
12585
+ return asRecord(metadata?.podcast) ?? {};
12586
+ }
12587
+ function pick2(metadata, podcastKey, envKey, env) {
12588
+ const podcast = podcastOf(metadata);
12589
+ if (Object.hasOwn(podcast, podcastKey)) {
12590
+ const value = podcast[podcastKey];
12591
+ if (value !== undefined && value !== "")
12592
+ return { value, key: `metadata.podcast.${podcastKey}` };
12593
+ }
12594
+ const fromEnv = env[envKey];
12595
+ if (fromEnv !== undefined && fromEnv !== "")
12596
+ return { value: fromEnv, key: envKey };
12597
+ return;
12598
+ }
12599
+ function invalid(key, value, expected) {
12600
+ return `invalid ${key} "${String(value)}": expected ${expected}`;
12601
+ }
12602
+ function parseBool(raw, fallback) {
12603
+ if (!raw)
12604
+ return fallback;
12605
+ const value = raw.value;
12606
+ if (value === true || value === "true" || value === "1")
12607
+ return true;
12608
+ if (value === false || value === "false" || value === "0")
12609
+ return false;
12610
+ return invalid(raw.key, value, "true or false");
12611
+ }
12612
+ function parseIntIn(raw, fallback, min) {
12613
+ if (!raw)
12614
+ return fallback;
12615
+ const value = typeof raw.value === "number" ? raw.value : Number(raw.value);
12616
+ if (!Number.isInteger(value) || value < min)
12617
+ return invalid(raw.key, raw.value, `an integer \u2265 ${min}`);
12618
+ return value;
12619
+ }
12620
+ function parseEnum(raw, allowed, fallback) {
12621
+ if (!raw)
12622
+ return fallback;
12623
+ const value = String(raw.value);
12624
+ if (allowed.includes(value))
12625
+ return value;
12626
+ return invalid(raw.key, raw.value, allowed.join(" or "));
12627
+ }
12628
+ function resolvePodcastSeoConfig(metadata, env = process.env) {
12629
+ const bool = (podcastKey, envKey, fallback) => parseBool(pick2(metadata, podcastKey, envKey, env), fallback);
12630
+ const int = (podcastKey, envKey, fallback, min) => parseIntIn(pick2(metadata, podcastKey, envKey, env), fallback, min);
12631
+ const text = (podcastKey, envKey, fallback) => {
12632
+ const raw = pick2(metadata, podcastKey, envKey, env);
12633
+ return raw ? String(raw.value) : fallback;
12348
12634
  };
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);
12635
+ const seo = bool("seo", ENV_KEYS.seo, SEO_DEFAULTS.seo);
12636
+ const titleMode = parseEnum(pick2(metadata, "titleMode", ENV_KEYS.titleMode, env), ["as-is", "headline"], SEO_DEFAULTS.titleMode);
12637
+ const titleMax = int("titleMax", ENV_KEYS.titleMax, SEO_DEFAULTS.titleMax, 1);
12638
+ const titleMaxKeywords = int("titleMaxKeywords", ENV_KEYS.titleMaxKeywords, SEO_DEFAULTS.titleMaxKeywords, 1);
12639
+ const seoTitleMax = int("seoTitleMax", ENV_KEYS.seoTitleMax, SEO_DEFAULTS.seoTitleMax, 1);
12640
+ const descriptionMin = int("descriptionMin", ENV_KEYS.descriptionMin, SEO_DEFAULTS.descriptionMin, 1);
12641
+ const descriptionMax = int("descriptionMax", ENV_KEYS.descriptionMax, SEO_DEFAULTS.descriptionMax, 1);
12642
+ const descriptionSource = parseEnum(pick2(metadata, "descriptionSource", ENV_KEYS.descriptionSource, env), ["plan", "surfing"], SEO_DEFAULTS.descriptionSource);
12643
+ const summaryParagraph = bool("summaryParagraph", ENV_KEYS.summaryParagraph, SEO_DEFAULTS.summaryParagraph);
12644
+ const timelineStyle = parseEnum(pick2(metadata, "timelineInlineStyle", ENV_KEYS.timelineStyle, env), ["after", "before", "omit", "true", "false"], "after");
12645
+ const autoEpisodeNumber = bool("autoEpisodeNumber", ENV_KEYS.autoEpisodeNumber, SEO_DEFAULTS.autoEpisodeNumber);
12646
+ const titleSanitize = parseEnum(pick2(metadata, "titleSanitize", ENV_KEYS.titleSanitize, env), ["normalize", "reject"], SEO_DEFAULTS.titleSanitize);
12647
+ const linkField = parseEnum(pick2(metadata, "linkField", ENV_KEYS.linkField, env), ["none", "url"], SEO_DEFAULTS.linkField);
12648
+ const slugMode = parseEnum(pick2(metadata, "slug", ENV_KEYS.slugMode, env), ["auto", "pattern"], SEO_DEFAULTS.slugMode);
12649
+ const socialImage = bool("socialImageEnabled", ENV_KEYS.socialImage, SEO_DEFAULTS.socialImage);
12650
+ const roundDuration = bool("roundDuration", ENV_KEYS.roundDuration, SEO_DEFAULTS.roundDuration);
12651
+ const parsed = [
12652
+ seo,
12653
+ titleMode,
12654
+ titleMax,
12655
+ titleMaxKeywords,
12656
+ seoTitleMax,
12657
+ descriptionMin,
12658
+ descriptionMax,
12659
+ descriptionSource,
12660
+ summaryParagraph,
12661
+ timelineStyle,
12662
+ autoEpisodeNumber,
12663
+ titleSanitize,
12664
+ linkField,
12665
+ slugMode,
12666
+ socialImage,
12667
+ roundDuration
12668
+ ];
12669
+ const failure = parsed.find((value) => typeof value === "string" && value.startsWith("invalid "));
12670
+ if (typeof failure === "string")
12671
+ return { error: failure };
12672
+ if (descriptionMin > descriptionMax) {
12673
+ return {
12674
+ error: `invalid ${ENV_KEYS.descriptionMin}: ${String(descriptionMin)} is greater than ${ENV_KEYS.descriptionMax} ${String(descriptionMax)}`
12675
+ };
12676
+ }
12677
+ const seoTitleSuffix = text("seoTitleSuffix", ENV_KEYS.seoTitleSuffix, SEO_DEFAULTS.seoTitleSuffix);
12678
+ if (codePointLength(seoTitleSuffix) >= seoTitleMax) {
12679
+ return {
12680
+ error: `invalid ${ENV_KEYS.seoTitleSuffix}: suffix is longer than ${ENV_KEYS.seoTitleMax} (${String(seoTitleMax)})`
12681
+ };
12682
+ }
12683
+ const styleRaw = timelineStyle;
12684
+ const timeline = styleRaw === "true" || styleRaw === "after" ? "after" : styleRaw === "false" || styleRaw === "omit" ? "omit" : "before";
12685
+ return {
12686
+ config: {
12687
+ seo,
12688
+ titleMode,
12689
+ titlePattern: text("titlePattern", ENV_KEYS.titlePattern, SEO_DEFAULTS.titlePattern),
12690
+ seriesName: text("seriesName", ENV_KEYS.seriesName, SEO_DEFAULTS.seriesName),
12691
+ showName: text("showName", ENV_KEYS.showName, SEO_DEFAULTS.showName),
12692
+ titleMax,
12693
+ titleMaxKeywords,
12694
+ seoTitleSuffix,
12695
+ seoTitleMax,
12696
+ descriptionMin,
12697
+ descriptionMax,
12698
+ descriptionSource,
12699
+ descriptionPattern: text("descriptionPattern", ENV_KEYS.descriptionPattern, SEO_DEFAULTS.descriptionPattern),
12700
+ summaryParagraph,
12701
+ timelineStyle: timeline,
12702
+ autoEpisodeNumber,
12703
+ titleSanitize,
12704
+ linkField,
12705
+ slugMode,
12706
+ slugPattern: text("slugPattern", ENV_KEYS.slugPattern, SEO_DEFAULTS.slugPattern),
12707
+ socialImage,
12708
+ socialImageAltPattern: text("socialImageAltPattern", ENV_KEYS.socialImageAltPattern, SEO_DEFAULTS.socialImageAltPattern),
12709
+ roundDuration
12368
12710
  }
12711
+ };
12712
+ }
12713
+ function hasControlChar(value) {
12714
+ for (const ch of value) {
12715
+ const cp = ch.codePointAt(0) ?? 0;
12716
+ if (cp <= 31 || cp >= 127 && cp <= 159 || cp === 8232 || cp === 8233)
12717
+ return true;
12369
12718
  }
12370
- if (starts.some((v) => !Number.isFinite(v)))
12371
- return fallback();
12372
- return starts;
12719
+ return false;
12373
12720
  }
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("<!--"))
12721
+ function forbiddenChars(value) {
12722
+ const found = [];
12723
+ if (value.includes('"'))
12724
+ found.push('"');
12725
+ if (value.includes("\\"))
12726
+ found.push("\\");
12727
+ if (value.includes("<"))
12728
+ found.push("<");
12729
+ if (value.includes(">"))
12730
+ found.push(">");
12731
+ if (hasControlChar(value))
12732
+ found.push("control");
12733
+ return found;
12734
+ }
12735
+ function sanitizeTitle(value, mode) {
12736
+ const forbidden = forbiddenChars(value);
12737
+ if (mode === "reject" && forbidden.length > 0) {
12738
+ return { ok: false, error: `title contains forbidden characters: ${forbidden.join(", ")}` };
12739
+ }
12740
+ const chars = Array.from(value);
12741
+ const quoteAt = [];
12742
+ chars.forEach((ch, index) => {
12743
+ if (ch === '"')
12744
+ quoteAt.push(index);
12745
+ });
12746
+ const odd = quoteAt.length % 2 === 1;
12747
+ for (let n = 0;n < quoteAt.length; n++) {
12748
+ const index = quoteAt[n] ?? 0;
12749
+ const last = n === quoteAt.length - 1;
12750
+ chars[index] = odd && last ? "\u201D" : n % 2 === 0 ? "\u201C" : "\u201D";
12751
+ }
12752
+ let out = "";
12753
+ for (const ch of chars) {
12754
+ const cp = ch.codePointAt(0) ?? 0;
12755
+ if (cp <= 31 || cp >= 127 && cp <= 159 || cp === 8232 || cp === 8233)
12756
+ continue;
12757
+ if (ch === "\\")
12758
+ out += "\uFF3C";
12759
+ else if (ch === "<")
12760
+ out += "\u3008";
12761
+ else if (ch === ">")
12762
+ out += "\u3009";
12763
+ else
12764
+ out += ch;
12765
+ }
12766
+ return { ok: true, value: out.trim() };
12767
+ }
12768
+ function spaceCjkLatin(text) {
12769
+ return text.replace(/(\p{Script=Han})([A-Za-z0-9])/gu, "$1 $2").replace(/([A-Za-z0-9])(\p{Script=Han})/gu, "$1 $2");
12770
+ }
12771
+ function normalizeEpisodeTitle(title, seriesName) {
12772
+ let out = title.replace(/\s+/g, " ").trim();
12773
+ const legacy = /^(?:\u6BCF\u65E5 AI \u8D44\u8BAF|\u6BCF\u65E5AI\u8D44\u8BAF|AI \u4E71\u7096|AI\u4E71\u7096)/;
12774
+ if (legacy.test(out))
12775
+ out = out.replace(legacy, seriesName);
12776
+ const trailing = /\s\u00B7\s*(\d{4}-\d{2}-\d{2})\s*$/;
12777
+ const trail = trailing.exec(out);
12778
+ if (trail?.[1] && out.slice(0, trail.index).includes(trail[1]))
12779
+ out = out.slice(0, trail.index).trim();
12780
+ if (seriesName.length > 0 && out.startsWith(seriesName)) {
12781
+ let rest = spaceCjkLatin(out.slice(seriesName.length));
12782
+ const seriesEndsCjk = /\p{Script=Han}$/u.test(seriesName);
12783
+ if (seriesEndsCjk && /^[A-Za-z0-9]/.test(rest))
12784
+ rest = ` ${rest}`;
12785
+ out = seriesName + rest;
12786
+ } else {
12787
+ out = spaceCjkLatin(out);
12788
+ }
12789
+ return out.replace(/\s+/g, " ").trim();
12790
+ }
12791
+ function isRealDate(year, month, day) {
12792
+ if (month < 1 || month > 12 || day < 1 || day > 31)
12387
12793
  return false;
12388
- return (rest.match(/<[^>]*>/g) ?? []).every((tag) => TIMELINE_TAG.test(tag)) && !/<(?![^>]*>)/.test(rest);
12794
+ const utc = new Date(Date.UTC(year, month - 1, day));
12795
+ return utc.getUTCFullYear() === year && utc.getUTCMonth() === month - 1 && utc.getUTCDate() === day;
12389
12796
  }
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-->`;
12797
+ function isoFromRunDate(raw) {
12798
+ const digits = raw.replaceAll(/\D/g, "");
12799
+ if (digits.length < 8)
12800
+ return;
12801
+ const year = Number(digits.slice(0, 4));
12802
+ const month = Number(digits.slice(4, 6));
12803
+ const day = Number(digits.slice(6, 8));
12804
+ if (!isRealDate(year, month, day))
12805
+ return;
12806
+ return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
12807
+ }
12808
+ function isoFromLosAngeles(ms) {
12809
+ if (!Number.isFinite(ms))
12810
+ return;
12811
+ const parts = new Intl.DateTimeFormat("en-US", {
12812
+ timeZone: "America/Los_Angeles",
12813
+ year: "numeric",
12814
+ month: "2-digit",
12815
+ day: "2-digit"
12816
+ }).formatToParts(new Date(ms));
12817
+ const year = parts.find((part) => part.type === "year")?.value;
12818
+ const month = parts.find((part) => part.type === "month")?.value;
12819
+ const day = parts.find((part) => part.type === "day")?.value;
12820
+ if (!year || !month || !day)
12821
+ return;
12822
+ return `${year}-${month}-${day}`;
12823
+ }
12824
+ function resolveEpisodeDate(metadata, publishedAtMs) {
12825
+ if (typeof metadata.runDate === "string") {
12826
+ const fromRun = isoFromRunDate(metadata.runDate);
12827
+ if (fromRun)
12828
+ return fromRun;
12829
+ }
12830
+ if (publishedAtMs !== undefined)
12831
+ return isoFromLosAngeles(publishedAtMs);
12832
+ const publishedAt = metadata.publishedAt;
12833
+ if (typeof publishedAt === "number")
12834
+ return isoFromLosAngeles(publishedAt);
12835
+ if (typeof publishedAt === "string" && publishedAt.length > 0) {
12836
+ const parsed = Date.parse(publishedAt);
12837
+ if (!Number.isNaN(parsed))
12838
+ return isoFromLosAngeles(parsed);
12839
+ }
12840
+ return;
12841
+ }
12842
+ function seasonAndEpisode(isoDate) {
12843
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
12844
+ if (!match)
12845
+ return;
12846
+ const year = Number(match[1]);
12847
+ const month = Number(match[2]);
12848
+ const day = Number(match[3]);
12849
+ if (!isRealDate(year, month, day))
12850
+ return;
12851
+ const episode = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(year, 0, 1)) / 86400000) + 1;
12852
+ return { season: year, episode };
12853
+ }
12854
+ function isPositiveInt(value) {
12855
+ return typeof value === "number" && Number.isInteger(value) && value >= 1;
12856
+ }
12857
+ function keywordsFromRefinedTitle(title) {
12858
+ const index = title.indexOf("\uFF1A");
12859
+ if (index < 0)
12860
+ return [];
12861
+ return title.slice(index + 1).split("\u3001").map((part) => part.trim()).filter((part) => part.length > 0);
12862
+ }
12863
+ function keywordFromDocTitle(title) {
12864
+ const cut = title.split(/\uFF0C|,|\uFF1A|\uFF1B|\u2014\u2014/)[0]?.trim() ?? "";
12865
+ return Array.from(cut).slice(0, 15).join("");
12866
+ }
12867
+ function clauseFromTitle(title) {
12868
+ const cut = (title.split(/\uFF0C|,/)[0] ?? title).replaceAll("\u300C", "").replaceAll("\u300D", "");
12869
+ return cut.replace(/\s+/g, " ").trim();
12870
+ }
12871
+ function firstSentence(text) {
12872
+ const trimmed = text.replace(/\s+/g, " ").trim();
12873
+ if (trimmed.length === 0)
12874
+ return "";
12875
+ const parts = trimmed.split(/(?<=[\u3002\uFF01\uFF1F.!?])/);
12876
+ return (parts[0] ?? trimmed).trim();
12877
+ }
12878
+ function planItemsFromMetadata(metadata) {
12879
+ const raw = podcastOf(metadata).planItems;
12880
+ if (!Array.isArray(raw))
12881
+ return [];
12882
+ const items = [];
12883
+ for (const entry of raw) {
12884
+ const record = asRecord(entry);
12885
+ if (!record)
12886
+ continue;
12887
+ const title = typeof record.title === "string" ? record.title.trim() : "";
12888
+ const brief = typeof record.brief === "string" ? record.brief.trim() : "";
12889
+ if (title.length === 0 && brief.length === 0)
12890
+ continue;
12891
+ items.push(brief.length > 0 ? { title, brief } : { title });
12892
+ }
12893
+ return items;
12894
+ }
12895
+ function stringList(value) {
12896
+ if (!Array.isArray(value))
12897
+ return;
12898
+ const items = value.filter((entry) => typeof entry === "string").map((entry) => entry.trim());
12899
+ return items.filter((entry) => entry.length > 0);
12900
+ }
12901
+ function cutAtBoundary(value, room) {
12902
+ if (room <= 0)
12903
+ return "";
12904
+ const chars = Array.from(value);
12905
+ if (chars.length <= room)
12906
+ return value;
12907
+ const sliced = chars.slice(0, room);
12908
+ let cut = sliced.length;
12909
+ for (let index = sliced.length - 1;index >= 0; index--) {
12910
+ if (/[\uFF0C\u3002\uFF01\uFF1F\u3001\uFF1B\uFF1A,.\s]/.test(sliced[index] ?? "")) {
12911
+ cut = index;
12912
+ break;
12913
+ }
12914
+ }
12915
+ const trimmed = sliced.slice(0, cut).join("").trim();
12916
+ return trimmed.length > 0 ? trimmed : sliced.join("").trim();
12917
+ }
12918
+ function headlineFromKeywords(config, date, keywords, limit) {
12919
+ const head = normalizeEpisodeTitle(config.titlePattern.replaceAll("{series}", config.seriesName).replaceAll("{date}", date).replaceAll("{keywords}", ""), config.seriesName);
12920
+ const chosen = [];
12921
+ for (const keyword of keywords) {
12922
+ const title = normalizeEpisodeTitle(`${head}${[...chosen, keyword].join("\u3001")}`, config.seriesName);
12923
+ if (codePointLength(title) <= limit)
12924
+ chosen.push(keyword);
12925
+ else
12926
+ break;
12927
+ }
12928
+ if (chosen.length === 0 && keywords[0]) {
12929
+ const room = limit - codePointLength(head);
12930
+ const cut = cutAtBoundary(keywords[0], room);
12931
+ if (cut.length > 0)
12932
+ chosen.push(cut);
12933
+ }
12934
+ return normalizeEpisodeTitle(chosen.length > 0 ? `${head}${chosen.join("\u3001")}` : head, config.seriesName);
12935
+ }
12936
+ function pickKeywords(metadata, config) {
12937
+ const podcast = podcastOf(metadata);
12938
+ if (typeof podcast.title === "string" && podcast.title.trim().length > 0) {
12939
+ return { explicitTitle: podcast.title.trim(), keywords: [] };
12940
+ }
12941
+ const explicit = stringList(podcast.titleKeywords);
12942
+ if (explicit && explicit.length > 0)
12943
+ return { keywords: explicit.slice(0, config.titleMaxKeywords) };
12944
+ const refined = typeof podcast.refinedTitle === "string" ? keywordsFromRefinedTitle(podcast.refinedTitle) : [];
12945
+ if (refined.length > 0)
12946
+ return { keywords: refined.slice(0, config.titleMaxKeywords) };
12947
+ const fromPlan = planItemsFromMetadata(metadata).map((item) => keywordFromDocTitle(item.title)).filter((keyword) => keyword.length > 0);
12948
+ if (fromPlan.length > 0)
12949
+ return { keywords: fromPlan.slice(0, config.titleMaxKeywords) };
12950
+ return { keywords: [] };
12951
+ }
12952
+ function buildItemTitle(asIs, metadata, config, date, warnings) {
12953
+ const picked = pickKeywords(metadata, config);
12954
+ if (picked.explicitTitle)
12955
+ return { title: picked.explicitTitle, keywords: [], explicit: true };
12956
+ if (config.titleMode !== "headline")
12957
+ return { title: asIs, keywords: picked.keywords, explicit: false };
12958
+ if (!date) {
12959
+ warnings.push(`${WARN_PREFIX} headline title skipped: episode date is unknown`);
12960
+ return { title: asIs, keywords: [], explicit: false };
12961
+ }
12962
+ if (picked.keywords.length === 0) {
12963
+ warnings.push(`${WARN_PREFIX} no keywords for the headline title; keeping the as-is title`);
12964
+ return { title: asIs, keywords: [], explicit: false };
12965
+ }
12966
+ return {
12967
+ title: headlineFromKeywords(config, date, picked.keywords, config.titleMax),
12968
+ keywords: picked.keywords,
12969
+ explicit: false
12970
+ };
12971
+ }
12972
+ function fitSeoTitle(episodeTitle, keywords, config, date, explicit) {
12973
+ const suffix = config.seoTitleSuffix;
12974
+ let stem = episodeTitle;
12975
+ let remaining = keywords;
12976
+ let seo = `${stem}${suffix}`;
12977
+ while (codePointLength(seo) > config.seoTitleMax && remaining.length > 0 && date && !explicit) {
12978
+ remaining = remaining.slice(0, -1);
12979
+ stem = headlineFromKeywords(config, date, remaining, config.titleMax);
12980
+ seo = `${stem}${suffix}`;
12981
+ }
12982
+ if (codePointLength(seo) > config.seoTitleMax) {
12983
+ const room = config.seoTitleMax - codePointLength(suffix);
12984
+ stem = cutAtBoundary(stem, room);
12985
+ seo = `${stem}${suffix}`;
12986
+ }
12987
+ return { stem, seo };
12988
+ }
12989
+ function cleanClause(value) {
12990
+ return value.replace(/<[^>]*>/g, "").replace(/[*#`[\]]/g, "").replace(/[\u3002\uFF01\uFF1F.!?]+$/u, "").replace(/\s+/g, " ").trim();
12991
+ }
12992
+ function descriptionHead(config, date, minutes) {
12993
+ const pattern = config.descriptionPattern.includes("{clauses}") ? config.descriptionPattern.split("{clauses}")[0] ?? config.descriptionPattern : config.descriptionPattern;
12994
+ let head = pattern.replaceAll("{show}", config.showName).replaceAll("{series}", config.seriesName);
12995
+ if (date)
12996
+ head = head.replaceAll("{date}", date);
12997
+ else
12998
+ head = head.replaceAll(" {date}", "").replaceAll("{date}", "");
12999
+ if (minutes !== undefined && minutes > 0)
13000
+ head = head.replaceAll("{minutes}", String(minutes));
13001
+ else
13002
+ head = head.replaceAll("\uFF08\u7EA6 {minutes} \u5206\u949F\uFF09", "").replaceAll("{minutes}", "");
13003
+ return head.replace(/\u3002\s*$/, "");
13004
+ }
13005
+ function durationMinutes(seconds) {
13006
+ if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0)
13007
+ return;
13008
+ return Math.round(seconds / 60);
13009
+ }
13010
+ function buildSeoDescription(input) {
13011
+ const head = descriptionHead(input.config, input.date, input.minutes);
13012
+ const render = (parts) => parts.length === 0 ? head : `${head}${parts.join("\uFF1B")}\u3002`;
13013
+ const picked = [];
13014
+ const pushWhile = (candidates) => {
13015
+ for (const candidate of candidates) {
13016
+ const clause = cleanClause(candidate);
13017
+ if (clause.length === 0 || picked.includes(clause))
13018
+ continue;
13019
+ const next = render([...picked, clause]);
13020
+ if (codePointLength(next) <= input.config.descriptionMax)
13021
+ picked.push(clause);
13022
+ }
13023
+ };
13024
+ pushWhile(input.clauses);
13025
+ if (codePointLength(render(picked)) < input.config.descriptionMin)
13026
+ pushWhile(input.extraSentences);
13027
+ const description = render(picked);
13028
+ return { description, short: codePointLength(description) < input.config.descriptionMin };
13029
+ }
13030
+ function descriptionInputs(metadata, config, date, minutes) {
13031
+ const podcast = podcastOf(metadata);
13032
+ const plan = planItemsFromMetadata(metadata);
13033
+ const summary = stringList(podcast.summaryClauses);
13034
+ const surfing = config.descriptionSource === "surfing" && typeof podcast.refinedDescription === "string" ? podcast.refinedDescription.trim() : "";
13035
+ const clauses = surfing ? [surfing] : summary && summary.length > 0 ? summary : plan.map((item) => clauseFromTitle(item.title)).filter((clause) => clause.length > 0);
13036
+ const extras = plan.map((item) => item.brief ? firstSentence(item.brief) : "").filter((sentence) => sentence.length > 0);
13037
+ return buildSeoDescription({ config, date, minutes, clauses, extraSentences: extras });
13038
+ }
13039
+ function isValidItemSlug(slug) {
13040
+ return slug.length >= 1 && slug.length <= 120 && !slug.endsWith("-") && /^[a-z0-9][a-z0-9-]*$/.test(slug);
13041
+ }
13042
+ function renderSlug(pattern, date) {
13043
+ if (!date)
13044
+ return { warning: "skipping slug: episode date is unknown" };
13045
+ const slug = pattern.replaceAll("{date}", date);
13046
+ if (!isValidItemSlug(slug)) {
13047
+ return {
13048
+ 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`
13049
+ };
13050
+ }
13051
+ return { slug };
13052
+ }
13053
+ function roundDurationSeconds(seconds, enabled) {
13054
+ if (seconds === undefined)
13055
+ return;
13056
+ return enabled ? Math.round(seconds) : seconds;
13057
+ }
13058
+ function imageMimeFromBytes(bytes) {
13059
+ if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
13060
+ return "image/png";
13061
+ }
13062
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 216)
13063
+ return "image/jpeg";
13064
+ return;
13065
+ }
13066
+ function readImageDimensions(bytes) {
13067
+ const mime = imageMimeFromBytes(bytes);
13068
+ if (mime === "image/png") {
13069
+ if (bytes.length < 24)
13070
+ return;
13071
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
13072
+ const width = view.getUint32(16);
13073
+ const height = view.getUint32(20);
13074
+ return { width, height, mime };
13075
+ }
13076
+ if (mime === "image/jpeg") {
13077
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
13078
+ let offset = 2;
13079
+ while (offset + 3 < bytes.length) {
13080
+ if (bytes[offset] !== 255)
13081
+ return;
13082
+ const marker = bytes[offset + 1] ?? 0;
13083
+ if (marker === 216 || marker === 217) {
13084
+ offset += 2;
13085
+ continue;
13086
+ }
13087
+ if (marker >= 208 && marker <= 215) {
13088
+ offset += 2;
13089
+ continue;
13090
+ }
13091
+ if (offset + 4 > bytes.length)
13092
+ return;
13093
+ const length = view.getUint16(offset + 2);
13094
+ if (length < 2)
13095
+ return;
13096
+ const isSof = marker === 192 || marker === 193 || marker === 194 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207;
13097
+ if (isSof) {
13098
+ if (offset + 9 > bytes.length)
13099
+ return;
13100
+ return { height: view.getUint16(offset + 5), width: view.getUint16(offset + 7), mime };
13101
+ }
13102
+ offset += 2 + length;
13103
+ }
13104
+ }
13105
+ return;
13106
+ }
13107
+ function readLocalBytes(path) {
13108
+ try {
13109
+ return new Uint8Array(readFileSync2(path));
13110
+ } catch {
13111
+ return;
13112
+ }
13113
+ }
13114
+ function altText(config, date, keywords) {
13115
+ const rendered = config.socialImageAltPattern.replaceAll("{series}", config.seriesName).replaceAll("{date}", date ?? "").replaceAll("{keywords}", keywords.join("\u3001"));
13116
+ const sanitized = sanitizeTitle(rendered, "normalize");
13117
+ const value = sanitized.ok ? sanitized.value : rendered;
13118
+ if (codePointLength(value) <= 200)
13119
+ return value;
13120
+ return cutAtBoundary(value, 200);
13121
+ }
13122
+ function mustSanitize(value, mode, label) {
13123
+ const sanitized = sanitizeTitle(value, mode);
13124
+ if (!sanitized.ok)
13125
+ return { ok: false, error: `${label}: ${sanitized.error}` };
13126
+ return sanitized;
13127
+ }
13128
+ function prepareEpisodeFields(episode, metadata, config) {
13129
+ const warnings = [];
13130
+ const date = resolveEpisodeDate(metadata, episode.datePublishedMs);
13131
+ const built = buildItemTitle(episode.title, metadata, config, date, warnings);
13132
+ const sanitizedTitle = mustSanitize(built.title, config.titleSanitize, "item title");
13133
+ if (!sanitizedTitle.ok)
13134
+ return sanitizedTitle;
13135
+ let title = built.explicit ? sanitizedTitle.value : config.titleMode === "headline" ? normalizeEpisodeTitle(sanitizedTitle.value, config.seriesName) : sanitizedTitle.value;
13136
+ const itunes = { ...episode.itunes ?? {} };
13137
+ if (itunes.season !== undefined && !isPositiveInt(itunes.season)) {
13138
+ return {
13139
+ ok: false,
13140
+ error: `invalid metadata.itunes.season: expected an integer \u2265 1, got ${String(itunes.season)}`
13141
+ };
13142
+ }
13143
+ if (itunes.episode !== undefined && !isPositiveInt(itunes.episode)) {
13144
+ return {
13145
+ ok: false,
13146
+ error: `invalid metadata.itunes.episode: expected an integer \u2265 1, got ${String(itunes.episode)}`
13147
+ };
13148
+ }
13149
+ if (config.autoEpisodeNumber) {
13150
+ const derived = date ? seasonAndEpisode(date) : undefined;
13151
+ if (derived) {
13152
+ if (itunes.season === undefined)
13153
+ itunes.season = derived.season;
13154
+ if (itunes.episode === undefined)
13155
+ itunes.episode = derived.episode;
13156
+ } else if (itunes.season === undefined || itunes.episode === undefined) {
13157
+ warnings.push(`${WARN_PREFIX} itunes season/episode left unset: episode date is unknown`);
13158
+ }
13159
+ }
13160
+ if (itunes.title) {
13161
+ const sanitizedItunes = mustSanitize(itunes.title, config.titleSanitize, "itunes:title");
13162
+ if (!sanitizedItunes.ok)
13163
+ return sanitizedItunes;
13164
+ itunes.title = sanitizedItunes.value;
13165
+ }
13166
+ const minutes = durationMinutes(episode.durationSeconds);
13167
+ let seo;
13168
+ if (config.seo) {
13169
+ const fitted = fitSeoTitle(title, built.keywords, config, date, built.explicit);
13170
+ if (!built.explicit && config.titleMode === "headline" && fitted.stem.length > 0)
13171
+ title = fitted.stem;
13172
+ const seoTitle = mustSanitize(fitted.seo, config.titleSanitize, "seo.title");
13173
+ if (!seoTitle.ok)
13174
+ return seoTitle;
13175
+ const described = descriptionInputs(metadata, config, date, minutes);
13176
+ if (described.short) {
13177
+ warnings.push(`${WARN_PREFIX} seo.description is ${codePointLength(described.description)} code points, under the ${config.descriptionMin} minimum; left unpadded`);
13178
+ }
13179
+ seo = { title: seoTitle.value, description: described.description };
13180
+ }
13181
+ let slug;
13182
+ if (config.slugMode === "pattern") {
13183
+ const rendered = renderSlug(config.slugPattern, date);
13184
+ if (rendered.error)
13185
+ return { ok: false, error: rendered.error };
13186
+ if (rendered.warning)
13187
+ warnings.push(`${WARN_PREFIX} ${rendered.warning}`);
13188
+ slug = rendered.slug;
13189
+ }
13190
+ if (config.linkField === "url" && episode.link) {
13191
+ 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`);
13192
+ }
13193
+ let socialAlt;
13194
+ let socialLocalPath;
13195
+ let socialMime;
13196
+ let socialRemoteUrl;
13197
+ if (config.socialImage) {
13198
+ const source = podcastOf(metadata).socialImage;
13199
+ const alt = altText(config, date, built.keywords);
13200
+ if (typeof source !== "string" || source.trim().length === 0) {
13201
+ warnings.push(`${WARN_PREFIX} PODCAST_PUB_SOCIAL_IMAGE is on but metadata.podcast.socialImage is empty; skipping alt text`);
13202
+ } else if (source.trim().startsWith("http")) {
13203
+ socialAlt = alt;
13204
+ socialRemoteUrl = source.trim();
13205
+ } else {
13206
+ const bytes = readLocalBytes(source.trim());
13207
+ const dims = bytes ? readImageDimensions(bytes) : undefined;
13208
+ if (dims?.width !== 1200 || dims?.height !== 630) {
13209
+ warnings.push(`${WARN_PREFIX} social image skipped: expected a 1200\xD7630 PNG or JPEG, got ${dims ? `${dims.width}\xD7${dims.height}` : "an unreadable file"}`);
13210
+ } else {
13211
+ socialAlt = alt;
13212
+ socialLocalPath = source.trim();
13213
+ socialMime = dims.mime;
13214
+ }
13215
+ }
13216
+ }
13217
+ for (const line of warnings)
13218
+ echoError(line);
13219
+ return {
13220
+ ok: true,
13221
+ fields: {
13222
+ title,
13223
+ itunes,
13224
+ durationSeconds: roundDurationSeconds(episode.durationSeconds, config.roundDuration),
13225
+ ...config.linkField === "url" && episode.link ? { link: episode.link } : {},
13226
+ ...seo ? { seo } : {},
13227
+ ...slug ? { slug } : {},
13228
+ warnings,
13229
+ ...socialAlt ? { socialAlt } : {},
13230
+ ...socialLocalPath ? { socialLocalPath, socialMime } : {},
13231
+ ...socialRemoteUrl ? { socialRemoteUrl } : {}
13232
+ }
13233
+ };
13234
+ }
13235
+ function showNotesSummaryParagraph(input) {
13236
+ const date = isoFromRunDate(input.runDate);
13237
+ const metadata = {
13238
+ ...date ? { runDate: date } : { runDate: input.runDate },
13239
+ podcast: {
13240
+ ...input.refinedDescription ? { refinedDescription: input.refinedDescription } : {},
13241
+ ...input.summaryClauses ? { summaryClauses: input.summaryClauses } : {},
13242
+ planItems: input.plan
13243
+ }
13244
+ };
13245
+ const described = descriptionInputs(metadata, input.config, date, durationMinutes(input.durationSeconds));
13246
+ const warnings = [];
13247
+ if (described.short) {
13248
+ warnings.push(`${WARN_PREFIX} seo.description is ${codePointLength(described.description)} code points, under the ${input.config.descriptionMin} minimum; left unpadded`);
13249
+ }
13250
+ const prefix = `${input.config.showName}\xB7`;
13251
+ const stripped = described.description.startsWith(prefix) ? described.description.slice(prefix.length) : described.description;
13252
+ const safeLabel = input.linkText.replaceAll("[", "").replaceAll("]", "").replaceAll("(", "").replaceAll(")", "");
13253
+ const markdown = `${stripped} \u5B8C\u6574\u56FE\u6587\u7248\uFF1A[${safeLabel}](${input.articleUrl})`;
13254
+ return { markdown, description: described.description, warnings };
13255
+ }
13256
+ function summaryParagraphHtml(description, showName, articleUrl, linkText) {
13257
+ const prefix = `${showName}\xB7`;
13258
+ const stripped = description.startsWith(prefix) ? description.slice(prefix.length) : description;
13259
+ const text = stripped.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
13260
+ if (!articleUrl)
13261
+ return `<p>${text}</p>`;
13262
+ const href = articleUrl.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "");
13263
+ const label = linkText.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
13264
+ return `<p>${text} \u5B8C\u6574\u56FE\u6587\u7248\uFF1A<a href="${href}" target="_blank" rel="noopener">${label}</a></p>`;
13265
+ }
13266
+ function stripMarkupTitle(raw) {
13267
+ return raw.replace(/<[^>]+>/g, " ").replace(/\u6765\u6E90/g, "").replace(/^\s*\d+\s*[.\u3001]\s*/, "").replace(/\s+/g, " ").trim();
13268
+ }
13269
+ function titlesFromContentHtml(html) {
13270
+ const timeline = [...html.matchAll(/class="kk-tl-title">([\s\S]*?)<\/div>/gi)].map((match) => stripMarkupTitle(match[1] ?? "")).filter((title) => title.length > 0);
13271
+ if (timeline.length > 0)
13272
+ return timeline;
13273
+ return [...html.matchAll(/<h2[^>]*>([\s\S]*?)<\/h2>/gi)].map((match) => stripMarkupTitle(match[1] ?? "")).filter((title) => title.length > 0);
13274
+ }
13275
+ function insertAfterTimeline(html, insertion) {
13276
+ const start = html.indexOf('<div class="kk-tl">');
13277
+ if (start < 0)
13278
+ return `${html.trim()}
13279
+ ${insertion}`;
13280
+ let depth = 0;
13281
+ const tags = /<\/?div\b[^>]*>/gi;
13282
+ tags.lastIndex = start;
13283
+ let match = tags.exec(html);
13284
+ while (match) {
13285
+ depth += match[0].startsWith("</") ? -1 : 1;
13286
+ if (depth === 0) {
13287
+ const at = match.index + match[0].length;
13288
+ const head = html.slice(0, at).replace(/\s*$/, "");
13289
+ const tail = html.slice(at).replace(/^\s*/, "");
13290
+ return tail.length > 0 ? `${head}
13291
+ ${insertion}
13292
+ ${tail}` : `${head}
13293
+ ${insertion}`;
13294
+ }
13295
+ match = tags.exec(html);
13296
+ }
13297
+ return `${html.trim()}
13298
+ ${insertion}`;
13299
+ }
13300
+ function rewriteEpisodeHtml(html, summaryHtml, moveStyle) {
13301
+ let body = html;
13302
+ if (moveStyle) {
13303
+ const styleMatch = body.match(/<style>[\s\S]*?<\/style>/i);
13304
+ if (styleMatch && styleMatch.index !== undefined) {
13305
+ const style = styleMatch[0];
13306
+ const without = `${body.slice(0, styleMatch.index)}${body.slice(styleMatch.index + style.length)}`;
13307
+ body = insertAfterTimeline(without, style);
13308
+ }
13309
+ }
13310
+ const trimmed = body.trim();
13311
+ if (summaryHtml.length > 0 && (trimmed === summaryHtml || trimmed.startsWith(`${summaryHtml}
13312
+ `) || trimmed.startsWith(`${summaryHtml}<`))) {
13313
+ return trimmed;
13314
+ }
13315
+ return `${summaryHtml}
13316
+ ${trimmed}`;
13317
+ }
13318
+ function dateFromFeedItem(item) {
13319
+ const title = typeof item.title === "string" ? item.title : "";
13320
+ const inTitle = /(\d{4}-\d{2}-\d{2})/.exec(title);
13321
+ if (inTitle?.[1] && isoFromRunDate(inTitle[1]))
13322
+ return inTitle[1];
13323
+ const msField = item.date_published_ms;
13324
+ if (typeof msField === "number")
13325
+ return isoFromLosAngeles(msField);
13326
+ const published = item.date_published;
13327
+ if (typeof published === "number")
13328
+ return isoFromLosAngeles(published);
13329
+ if (typeof published === "string") {
13330
+ const parsed = Date.parse(published);
13331
+ if (!Number.isNaN(parsed))
13332
+ return isoFromLosAngeles(parsed);
13333
+ }
13334
+ return;
13335
+ }
13336
+
13337
+ // ../../plugins/publishings/podcast-pub/src/backfill.ts
13338
+ var TARGET = "podcast-pub";
13339
+ function fail(error) {
13340
+ return ResultSchema.parse({ ok: false, target: TARGET, error });
13341
+ }
13342
+ function asRecord2(value) {
13343
+ if (value !== null && typeof value === "object" && !Array.isArray(value))
13344
+ return value;
13345
+ return;
13346
+ }
13347
+ function flag(value) {
13348
+ return value === true || value === "true" || value === "1";
13349
+ }
13350
+ function positiveInt(value) {
13351
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1)
13352
+ return value;
13353
+ if (typeof value === "string" && /^\d+$/.test(value)) {
13354
+ const parsed = Number(value);
13355
+ if (Number.isInteger(parsed) && parsed >= 1)
13356
+ return parsed;
13357
+ }
13358
+ return;
13359
+ }
13360
+ function parseOverrides(raw) {
13361
+ if (raw === undefined)
13362
+ return { overrides: new Map };
13363
+ if (!Array.isArray(raw))
13364
+ return {
13365
+ error: "invalid options.overrides: expected an array of {id, title?, titleKeywords?, summaryClauses?}"
13366
+ };
13367
+ const overrides = new Map;
13368
+ for (const entry of raw) {
13369
+ const record = asRecord2(entry);
13370
+ if (!record || typeof record.id !== "string" || record.id.trim().length === 0) {
13371
+ return { error: "invalid options.overrides: each entry needs a string id" };
13372
+ }
13373
+ const keywords = record.titleKeywords;
13374
+ if (keywords !== undefined && (!Array.isArray(keywords) || keywords.some((keyword) => typeof keyword !== "string"))) {
13375
+ return { error: `invalid options.overrides titleKeywords for "${record.id}": expected string[]` };
13376
+ }
13377
+ const clauses = record.summaryClauses;
13378
+ if (clauses !== undefined && (!Array.isArray(clauses) || clauses.some((clause) => typeof clause !== "string"))) {
13379
+ return { error: `invalid options.overrides summaryClauses for "${record.id}": expected string[]` };
13380
+ }
13381
+ if (record.title !== undefined && typeof record.title !== "string") {
13382
+ return { error: `invalid options.overrides title for "${record.id}": expected a string` };
13383
+ }
13384
+ overrides.set(record.id, {
13385
+ id: record.id,
13386
+ ...typeof record.title === "string" ? { title: record.title } : {},
13387
+ ...Array.isArray(keywords) ? { titleKeywords: keywords.filter((keyword) => typeof keyword === "string") } : {},
13388
+ ...Array.isArray(clauses) ? { summaryClauses: clauses.filter((clause) => typeof clause === "string") } : {}
13389
+ });
13390
+ }
13391
+ return { overrides };
13392
+ }
13393
+ function parseFlags(options) {
13394
+ const overrides = parseOverrides(options.overrides);
13395
+ if ("error" in overrides)
13396
+ return overrides;
13397
+ const since = options.since === undefined ? undefined : String(options.since);
13398
+ const until = options.until === undefined ? undefined : String(options.until);
13399
+ if (since !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(since)) {
13400
+ return { error: `invalid options.since "${since}": expected YYYY-MM-DD` };
13401
+ }
13402
+ if (until !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
13403
+ return { error: `invalid options.until "${until}": expected YYYY-MM-DD` };
13404
+ }
13405
+ let ids;
13406
+ if (options.ids !== undefined) {
13407
+ if (!Array.isArray(options.ids) || options.ids.some((id) => typeof id !== "string")) {
13408
+ return { error: "invalid options.ids: expected an array of item id strings" };
13409
+ }
13410
+ ids = new Set(options.ids);
13411
+ }
13412
+ return {
13413
+ flags: {
13414
+ apply: flag(options.apply),
13415
+ retitle: flag(options.retitle),
13416
+ rewriteBody: flag(options.rewriteBody),
13417
+ applySlug: flag(options.applySlug),
13418
+ ...since ? { since } : {},
13419
+ ...until ? { until } : {},
13420
+ ...ids ? { ids } : {},
13421
+ ...typeof options.out === "string" && options.out.length > 0 ? { out: options.out } : {},
13422
+ ...typeof options.plan === "string" && options.plan.length > 0 ? { planPath: options.plan } : {},
13423
+ overrides: overrides.overrides
13424
+ }
13425
+ };
13426
+ }
13427
+ function microfeedOf(item) {
13428
+ return asRecord2(item._microfeed) ?? {};
13429
+ }
13430
+ function durationOf(item) {
13431
+ const attachments = item.attachments;
13432
+ if (!Array.isArray(attachments))
13433
+ return;
13434
+ const first = asRecord2(attachments[0]);
13435
+ const seconds = first?.duration_in_seconds;
13436
+ return typeof seconds === "number" && Number.isFinite(seconds) ? seconds : undefined;
13437
+ }
13438
+ function articleUrlOf(html) {
13439
+ const marked = /\u5B8C\u6574\u56FE\u6587\u7248\uFF1A<a href="([^"]+)"/.exec(html);
13440
+ if (marked?.[1])
13441
+ return marked[1];
13442
+ const any = /href="(https?:\/\/[^"]+)"/.exec(html);
13443
+ return any?.[1];
13444
+ }
13445
+ function inWindow(date, flags) {
13446
+ if (!flags.since && !flags.until)
13447
+ return true;
13448
+ if (!date)
13449
+ return false;
13450
+ if (flags.since && date < flags.since)
13451
+ return false;
13452
+ if (flags.until && date > flags.until)
13453
+ return false;
13454
+ return true;
13455
+ }
13456
+ function planBackfillItem(item, config, flags, override) {
13457
+ const id = typeof item.id === "string" ? item.id : "";
13458
+ if (id.length === 0)
13459
+ return { error: "backfill-seo: a feed item is missing id" };
13460
+ const currentTitle = typeof item.title === "string" ? item.title : "";
13461
+ const micro = microfeedOf(item);
13462
+ const currentSeo = asRecord2(micro.seo);
13463
+ const currentSeoTitle = typeof currentSeo?.title === "string" ? currentSeo.title : undefined;
13464
+ const currentSeoDescription = typeof currentSeo?.description === "string" ? currentSeo.description : undefined;
13465
+ const currentSeason = positiveInt(micro["itunes:season"]);
13466
+ const currentEpisode = positiveInt(micro["itunes:episode"]);
13467
+ const currentSlug = typeof micro.slug === "string" ? micro.slug : undefined;
13468
+ const date = dateFromFeedItem(item);
13469
+ const html = typeof item.content_html === "string" ? item.content_html : "";
13470
+ const titles = titlesFromContentHtml(html);
13471
+ const clauses = override?.summaryClauses ?? titles.map((title) => clauseFromTitle(title)).filter((clause) => clause.length > 0);
13472
+ const keywords = override?.titleKeywords ?? titles.filter((title) => title.length > 0);
13473
+ const metadata = {
13474
+ ...date ? { runDate: date } : {},
13475
+ ...durationOf(item) !== undefined ? { durationSeconds: durationOf(item) } : {},
13476
+ podcast: {
13477
+ titleMode: "headline",
13478
+ ...override?.title ? { title: override.title } : {},
13479
+ ...keywords.length > 0 ? { titleKeywords: keywords.slice(0, config.titleMaxKeywords) } : {},
13480
+ ...clauses.length > 0 ? { summaryClauses: clauses } : {}
13481
+ }
13482
+ };
13483
+ const prepared = prepareEpisodeFields({
13484
+ title: currentTitle,
13485
+ durationSeconds: durationOf(item),
13486
+ itunes: {
13487
+ ...currentSeason !== undefined ? { season: currentSeason } : {},
13488
+ ...currentEpisode !== undefined ? { episode: currentEpisode } : {}
13489
+ }
13490
+ }, metadata, {
13491
+ ...config,
13492
+ seo: true,
13493
+ titleMode: "headline",
13494
+ slugMode: "auto",
13495
+ linkField: "none",
13496
+ socialImage: false,
13497
+ autoEpisodeNumber: true
13498
+ });
13499
+ if (!prepared.ok)
13500
+ return { error: `${id}: ${prepared.error}` };
13501
+ const violations = [];
13502
+ if (!date)
13503
+ violations.push("episode date is unknown");
13504
+ const patch = {};
13505
+ const nextMicro = {};
13506
+ if (flags.retitle && prepared.fields.title !== currentTitle)
13507
+ patch.title = prepared.fields.title;
13508
+ const seo = prepared.fields.seo;
13509
+ if (seo) {
13510
+ const seoPatch = {};
13511
+ if (seo.title && seo.title !== currentSeoTitle)
13512
+ seoPatch.title = seo.title;
13513
+ if (seo.description && seo.description !== currentSeoDescription)
13514
+ seoPatch.description = seo.description;
13515
+ if (seoPatch.title || seoPatch.description)
13516
+ nextMicro.seo = seoPatch;
13517
+ if (seo.description && codePointLength(seo.description) < config.descriptionMin) {
13518
+ violations.push(`seo.description is ${codePointLength(seo.description)} code points, under ${config.descriptionMin}`);
13519
+ }
13520
+ if (seo.description && /^\s*(\d|#|[-*])/.test(seo.description)) {
13521
+ violations.push("seo.description starts with a forbidden character");
13522
+ }
13523
+ }
13524
+ const numbered = prepared.fields.itunes;
13525
+ if (numbered?.season !== undefined && currentSeason === undefined)
13526
+ nextMicro["itunes:season"] = numbered.season;
13527
+ if (numbered?.episode !== undefined && currentEpisode === undefined)
13528
+ nextMicro["itunes:episode"] = numbered.episode;
13529
+ if (flags.applySlug && date) {
13530
+ const slug = renderSlug(config.slugPattern, date);
13531
+ if (slug.slug && slug.slug !== currentSlug && isValidItemSlug(slug.slug))
13532
+ nextMicro.slug = slug.slug;
13533
+ if (slug.error)
13534
+ violations.push(slug.error);
13535
+ }
13536
+ if (flags.rewriteBody && html.length > 0 && seo?.description) {
13537
+ const summary = summaryParagraphHtml(seo.description, config.showName, articleUrlOf(html), "\u672C\u671F\u5B8C\u6574\u6587\u7AE0");
13538
+ const rewritten = rewriteEpisodeHtml(html, summary, config.timelineStyle !== "omit");
13539
+ if (rewritten !== html.trim())
13540
+ patch.content_html = rewritten;
13541
+ }
13542
+ if (Object.keys(nextMicro).length > 0)
13543
+ patch._microfeed = nextMicro;
13544
+ const proposedTitle = typeof patch.title === "string" ? patch.title : currentTitle;
13545
+ const proposedSeo = asRecord2(nextMicro.seo);
13546
+ return {
13547
+ id,
13548
+ ...date ? { date } : {},
13549
+ patch,
13550
+ counts: {
13551
+ ...proposedTitle ? { title: codePointLength(proposedTitle) } : {},
13552
+ ...typeof proposedSeo?.title === "string" ? { seoTitle: codePointLength(proposedSeo.title) } : {},
13553
+ ...typeof proposedSeo?.description === "string" ? { seoDescription: codePointLength(proposedSeo.description) } : {}
13554
+ },
13555
+ violations,
13556
+ current: {
13557
+ ...currentTitle ? { title: currentTitle } : {},
13558
+ ...currentSeoTitle ? { seoTitle: currentSeoTitle } : {},
13559
+ ...currentSeoDescription ? { seoDescription: currentSeoDescription } : {},
13560
+ ...currentSeason !== undefined ? { season: currentSeason } : {},
13561
+ ...currentEpisode !== undefined ? { episode: currentEpisode } : {},
13562
+ ...currentSlug ? { slug: currentSlug } : {}
13563
+ }
13564
+ };
13565
+ }
13566
+ function describeEntry(entry) {
13567
+ const seo = asRecord2(asRecord2(entry.patch._microfeed)?.seo);
13568
+ const parts = [
13569
+ `${entry.id}:`,
13570
+ entry.date ? `date ${entry.date}` : "date unknown",
13571
+ `title ${JSON.stringify(entry.current.title ?? "")} \u2192 ${JSON.stringify(entry.patch.title ?? entry.current.title ?? "")} (${entry.counts.title ?? 0})`,
13572
+ `seo.title ${JSON.stringify(entry.current.seoTitle ?? "")} \u2192 ${JSON.stringify(seo?.title ?? entry.current.seoTitle ?? "")} (${entry.counts.seoTitle ?? codePointLength(String(entry.current.seoTitle ?? ""))})`,
13573
+ `seo.description (${entry.counts.seoDescription ?? codePointLength(String(entry.current.seoDescription ?? ""))})`
13574
+ ];
13575
+ if (entry.violations.length > 0)
13576
+ parts.push(`violations: ${entry.violations.join("; ")}`);
13577
+ return parts.join(" | ");
13578
+ }
13579
+ async function writePlan(path, entries, apply) {
13580
+ await ensureDirForFile(path);
13581
+ await writeJsonFile(path, { dryRun: !apply, items: entries });
13582
+ }
13583
+ async function loadPlan(path) {
13584
+ try {
13585
+ const parsed = await readJsonFile(path);
13586
+ const record = asRecord2(parsed);
13587
+ const items = record?.items;
13588
+ if (!Array.isArray(items))
13589
+ return { error: `invalid backfill plan ${path}: expected { items: [...] }` };
13590
+ const entries = [];
13591
+ for (const item of items) {
13592
+ const row = asRecord2(item);
13593
+ if (!row || typeof row.id !== "string" || !asRecord2(row.patch)) {
13594
+ return { error: `invalid backfill plan ${path}: each item needs id and patch` };
13595
+ }
13596
+ entries.push(row);
13597
+ }
13598
+ return { entries };
13599
+ } catch (error) {
13600
+ return { error: `invalid backfill plan ${path}: ${error instanceof Error ? error.message : String(error)}` };
13601
+ }
13602
+ }
13603
+ function withoutSlug(micro) {
13604
+ const rest = {};
13605
+ for (const [key, value] of Object.entries(micro)) {
13606
+ if (key !== "slug")
13607
+ rest[key] = value;
13608
+ }
13609
+ return rest;
13610
+ }
13611
+ async function putPatch(client, entry, warnings) {
13612
+ const micro = asRecord2(entry.patch._microfeed);
13613
+ const slug = typeof micro?.slug === "string" ? micro.slug : undefined;
13614
+ if (!slug || !micro) {
13615
+ await client.updateItem(entry.id, entry.patch);
13616
+ return;
13617
+ }
13618
+ try {
13619
+ await client.updateItem(entry.id, entry.patch);
13620
+ } catch (error) {
13621
+ if (!(error instanceof MicrofeedRequestError) || error.status !== 409)
13622
+ throw error;
13623
+ const retry = `${slug}-2`;
13624
+ try {
13625
+ await client.updateItem(entry.id, { ...entry.patch, _microfeed: { ...micro, slug: retry } });
13626
+ warn(`slug "${slug}" for ${entry.id} conflicted (HTTP 409); retried as ${retry}`, warnings);
13627
+ } catch (retryError) {
13628
+ if (!(retryError instanceof MicrofeedRequestError) || retryError.status !== 409)
13629
+ throw retryError;
13630
+ const rest = withoutSlug(micro);
13631
+ if (Object.keys(rest).length === 0) {
13632
+ const { _microfeed: _dropped, ...other } = entry.patch;
13633
+ if (Object.keys(other).length === 0) {
13634
+ warn(`slug "${slug}" for ${entry.id} conflicted twice (HTTP 409); skipped the slug and no other fields changed`, warnings);
13635
+ return;
13636
+ }
13637
+ await client.updateItem(entry.id, other);
13638
+ } else {
13639
+ await client.updateItem(entry.id, { ...entry.patch, _microfeed: rest });
13640
+ }
13641
+ warn(`slug "${slug}" for ${entry.id} conflicted twice (HTTP 409); applied the patch without a slug`, warnings);
13642
+ }
13643
+ }
13644
+ }
13645
+ async function runBackfill(client, options, config) {
13646
+ const parsed = parseFlags(options);
13647
+ if ("error" in parsed)
13648
+ return fail(parsed.error);
13649
+ const flags = parsed.flags;
13650
+ const warnings = [];
13651
+ let entries;
13652
+ if (flags.apply && flags.planPath) {
13653
+ const loaded = await loadPlan(flags.planPath);
13654
+ if ("error" in loaded)
13655
+ return fail(loaded.error);
13656
+ entries = loaded.entries.filter((entry) => !flags.ids || flags.ids.has(entry.id));
13657
+ } else {
13658
+ const feed = await client.listItems();
13659
+ entries = [];
13660
+ for (const item of feed.items ?? []) {
13661
+ const id = typeof item.id === "string" ? item.id : "";
13662
+ if (flags.ids && !flags.ids.has(id))
13663
+ continue;
13664
+ const date = dateFromFeedItem(item);
13665
+ if (!inWindow(date, flags))
13666
+ continue;
13667
+ const planned = planBackfillItem(item, config, flags, flags.overrides.get(id));
13668
+ if ("error" in planned)
13669
+ return fail(planned.error);
13670
+ entries.push(planned);
13671
+ }
13672
+ }
13673
+ for (const entry of entries)
13674
+ echo(describeEntry(entry));
13675
+ if (flags.out)
13676
+ await writePlan(flags.out, entries, flags.apply);
13677
+ if (!flags.apply) {
13678
+ return ResultSchema.parse({
13679
+ ok: true,
13680
+ target: TARGET,
13681
+ message: `backfill-seo dry-run for ${entries.length} item(s); no writes`,
13682
+ metadata: { dryRun: true, items: entries, warnings }
13683
+ });
13684
+ }
13685
+ let written = 0;
13686
+ for (const entry of entries) {
13687
+ if (Object.keys(entry.patch).length === 0)
13688
+ continue;
13689
+ await putPatch(client, entry, warnings);
13690
+ written += 1;
13691
+ }
13692
+ return ResultSchema.parse({
13693
+ ok: true,
13694
+ target: TARGET,
13695
+ message: `backfill-seo applied ${written} item(s)`,
13696
+ metadata: { dryRun: false, applied: written, items: entries, warnings }
13697
+ });
13698
+ }
13699
+
13700
+ // ../../plugins/publishings/podcast-pub/src/map.ts
13701
+ import { extname } from "path";
13702
+
13703
+ // ../../plugins/publishings/podcast-pub/src/show-notes.ts
13704
+ function escapeHtml(value) {
13705
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
13706
+ }
13707
+ function renderInline(escaped) {
13708
+ let out = "";
13709
+ let rest = escaped;
13710
+ for (;; ) {
13711
+ const match = rest.match(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/);
13712
+ if (!match || match.index === undefined)
13713
+ break;
13714
+ out += `${rest.slice(0, match.index)}<a href="${match[2]}" target="_blank" rel="noopener">${match[1]}</a>`;
13715
+ rest = rest.slice(match.index + match[0].length);
13716
+ }
13717
+ return (out + rest).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
13718
+ }
13719
+ function normalizeForMatch(text) {
13720
+ return text.toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, "");
13721
+ }
13722
+ function formatOffset(seconds) {
13723
+ if (!Number.isFinite(seconds) || seconds < 0)
13724
+ return "--:--";
13725
+ const m = Math.floor(seconds / 60);
13726
+ const s = Math.round(seconds % 60);
13727
+ return s === 60 ? `${m + 1}:00` : `${m}:${String(s).padStart(2, "0")}`;
13728
+ }
13729
+ function computeAudioOffsets(docs, segments) {
13730
+ const fallback = () => {
13731
+ const chars = docs.map((d) => Math.max((d.body ?? "").trim().length, 1));
13732
+ const total = chars.reduce((a, b) => a + b, 0);
13733
+ const sumDuration = (segments ?? []).reduce((a, s) => a + (s.duration ?? 0), 0);
13734
+ let acc = 0;
13735
+ return chars.map((c) => {
13736
+ const at = acc;
13737
+ acc += c / total * sumDuration;
13738
+ return at;
13739
+ });
13740
+ };
13741
+ if (!segments || segments.length < 2)
13742
+ return fallback();
13743
+ const starts = [];
13744
+ let elapsed = 0;
13745
+ const marks = segments.map((s) => {
13746
+ const at = elapsed;
13747
+ elapsed += s.duration ?? 0;
13748
+ return { at, text: normalizeForMatch(s.transcription ?? "") };
13749
+ });
13750
+ let cursor = 1;
13751
+ for (const d of docs) {
13752
+ const title = normalizeForMatch(d.title ?? "");
13753
+ const needle = title.slice(0, 12);
13754
+ const hit = needle.length >= 4 ? marks.findIndex((m, i) => i >= cursor && m.text.includes(needle)) : -1;
13755
+ if (hit >= 0) {
13756
+ starts.push(marks[hit]?.at ?? 0);
13757
+ cursor = hit + 1;
13758
+ } else {
13759
+ starts.push(Number.NaN);
13760
+ }
13761
+ }
13762
+ if (starts.some((v) => !Number.isFinite(v)))
13763
+ return fallback();
13764
+ return starts;
13765
+ }
13766
+ var isHttpUrl = (u) => /^https?:\/\//i.test(u.trim());
13767
+ var TIMELINE_STYLE = `<style>
13768
+ .kk-tl{margin:1em 0}
13769
+ .kk-tl-row{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid rgba(128,128,128,.25)}
13770
+ .kk-tl-t{flex:0 0 56px;font-variant-numeric:tabular-nums;font-weight:700;color:#0969da}
13771
+ .kk-tl-body{flex:1;min-width:0}
13772
+ .kk-tl-title{font-weight:600}
13773
+ .kk-tl-brief{margin:4px 0 0;color:#666;font-size:.95em}
13774
+ </style>`;
13775
+ var TIMELINE_TAG = /^<\/?(?:div|p|a)(?:\s+(?:class="kk-tl(?:-[a-z]+)?"|href="https?:\/\/[^"<>\s]*"|target="_blank"|rel="noopener"))*\s*>$/;
13776
+ function isGeneratedTimeline(block) {
13777
+ const rest = block.replace(TIMELINE_STYLE, "");
13778
+ if (rest.includes("<style") || rest.includes("<!--"))
13779
+ return false;
13780
+ return (rest.match(/<[^>]*>/g) ?? []).every((tag) => TIMELINE_TAG.test(tag)) && !/<(?![^>]*>)/.test(rest);
13781
+ }
13782
+ function renderTimelineHtml(docs, offsets, articleUrl, style) {
13783
+ const rows = docs.map((d, i) => {
13784
+ const title = escapeHtml(d.title ?? `\u6761\u76EE ${i + 1}`);
13785
+ const brief = escapeHtml(itemBrief(d));
13786
+ const at = formatOffset(offsets[i] ?? Number.NaN);
13787
+ const link = d.sourceUri && isHttpUrl(d.sourceUri) ? ` <a href="${escapeHtml(d.sourceUri)}" target="_blank" rel="noopener">\u6765\u6E90</a>` : "";
13788
+ return `
13789
+ <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>`;
13790
+ }).join("");
13791
+ 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>`;
13792
+ const styleBlock = style === "omit" ? "" : `${TIMELINE_STYLE}
13793
+ `;
13794
+ const before = style === "before" ? styleBlock : "";
13795
+ const after = style === "after" ? styleBlock : "";
13796
+ return `<!--kk:timeline-->
13797
+ ${before}<div class="kk-tl">${rows}
13798
+ </div>
13799
+ ${after}${articleTail}
13800
+ <!--/kk:timeline-->`;
12406
13801
  }
12407
13802
  function renderShowNotesHtml(markdown) {
12408
13803
  const blocks = [];
@@ -12476,7 +13871,7 @@ function itemBrief(d) {
12476
13871
  return meta.brief.trim();
12477
13872
  return (d.body ?? "").trim().split(/(?<=[\u3002\uFF01\uFF1F])\s*/).slice(0, 2).join("");
12478
13873
  }
12479
- function renderShowNotesMarkdown(docs, opts) {
13874
+ function renderShowNotesMarkdown(docs, opts, warnings) {
12480
13875
  const slugify = (t) => {
12481
13876
  const s = t.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-");
12482
13877
  return s.length > 0 ? s : "post";
@@ -12487,6 +13882,9 @@ function renderShowNotesMarkdown(docs, opts) {
12487
13882
  const postLocale = segments.find((s) => ["zh", "en", "ja"].includes(s)) ?? "zh";
12488
13883
  const localePrefix = postLocale === "en" ? "" : `/${postLocale}`;
12489
13884
  const articleUrl = postSlug.length > 0 ? `${baseUrl}${localePrefix}/posts/${postSlug}` : `${baseUrl}/zh/posts/${opts.runDate}-${slugify(opts.title)}`;
13885
+ if (opts.briefMode === true && postSlug.length === 0) {
13886
+ warn("brief mode requested but ARTICLE_POST_PATH is empty; using legacy layout", warnings);
13887
+ }
12490
13888
  const briefMode = opts.briefMode === true && postSlug.length > 0;
12491
13889
  const sourceLabel = (d) => {
12492
13890
  const m = d.metadata ?? {};
@@ -12500,8 +13898,42 @@ function renderShowNotesMarkdown(docs, opts) {
12500
13898
  };
12501
13899
  const offsets = computeAudioOffsets(docs, opts.audioSegments);
12502
13900
  const lines = [`# ${opts.title}`, ""];
13901
+ const summaryOn = opts.summaryParagraph !== false;
13902
+ if (summaryOn) {
13903
+ const segmentSeconds = opts.audioSegments?.reduce((sum, segment) => sum + (segment.duration ?? 0), 0);
13904
+ const durationSeconds = opts.durationSeconds ?? (segmentSeconds && segmentSeconds > 0 ? segmentSeconds : undefined);
13905
+ const plan = docs.map((doc) => {
13906
+ const title = doc.title?.trim() ?? "";
13907
+ const brief = itemBrief(doc);
13908
+ return brief.length > 0 ? { title, brief } : { title };
13909
+ });
13910
+ const config = {
13911
+ ...SEO_DEFAULTS,
13912
+ ...opts.seriesName ? { seriesName: opts.seriesName } : {},
13913
+ ...opts.showName ? { showName: opts.showName } : {},
13914
+ ...opts.descriptionMin !== undefined ? { descriptionMin: opts.descriptionMin } : {},
13915
+ ...opts.descriptionMax !== undefined ? { descriptionMax: opts.descriptionMax } : {},
13916
+ ...opts.descriptionSource ? { descriptionSource: opts.descriptionSource } : {},
13917
+ ...opts.descriptionPattern ? { descriptionPattern: opts.descriptionPattern } : {},
13918
+ summaryParagraph: true,
13919
+ timelineStyle: opts.timelineStyle ?? "after"
13920
+ };
13921
+ const summary = showNotesSummaryParagraph({
13922
+ config,
13923
+ runDate: opts.runDate,
13924
+ ...durationSeconds !== undefined ? { durationSeconds } : {},
13925
+ plan,
13926
+ ...opts.refinedDescription ? { refinedDescription: opts.refinedDescription } : {},
13927
+ ...opts.summaryClauses ? { summaryClauses: opts.summaryClauses } : {},
13928
+ articleUrl,
13929
+ linkText: opts.surfingTitle?.trim() || "\u672C\u671F\u5B8C\u6574\u6587\u7AE0"
13930
+ });
13931
+ for (const line of summary.warnings)
13932
+ warn(line.replace(/^WARN podcast-pub:\s*/, ""), warnings);
13933
+ lines.push(summary.markdown, "");
13934
+ }
12503
13935
  if (briefMode) {
12504
- lines.push(renderTimelineHtml(docs, offsets, articleUrl), "");
13936
+ lines.push(renderTimelineHtml(docs, offsets, articleUrl, opts.timelineStyle ?? "after"), "");
12505
13937
  } else {
12506
13938
  docs.forEach((d, i) => {
12507
13939
  const body = (d.body ?? "").trim();
@@ -12625,134 +14057,6 @@ function mapContentToEpisode(content) {
12625
14057
  };
12626
14058
  }
12627
14059
 
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
14060
  // ../../plugins/publishings/podcast-pub/src/index.ts
12757
14061
  function publicItemUrl(item, itemId, client) {
12758
14062
  const microfeed = item?._microfeed;
@@ -12762,8 +14066,8 @@ function publicItemUrl(item, itemId, client) {
12762
14066
  const feedUrl = typeof item?.url === "string" ? item.url : "";
12763
14067
  return feedUrl !== "" ? feedUrl : client.itemUrl(itemId);
12764
14068
  }
12765
- var TARGET = "podcast-pub";
12766
- var OPERATIONS = ["publish", "show-notes", "list", "get", "update", "delete", "validate"];
14069
+ var TARGET2 = "podcast-pub";
14070
+ var OPERATIONS = ["publish", "show-notes", "list", "get", "update", "delete", "validate", "backfill-seo"];
12767
14071
  function resolveClient() {
12768
14072
  const baseUrl = process.env.MICROFEED_BASE_URL;
12769
14073
  const apiKey = process.env.MICROFEED_API_KEY;
@@ -12781,8 +14085,8 @@ function resolveClient() {
12781
14085
  client: new MicrofeedClient(baseUrl.trim(), apiKey.trim(), resolveTimeoutMs(process.env.MICROFEED_TIMEOUT_MS))
12782
14086
  };
12783
14087
  }
12784
- function fail(error) {
12785
- return ResultSchema.parse({ ok: false, target: TARGET, error });
14088
+ function fail2(error) {
14089
+ return ResultSchema.parse({ ok: false, target: TARGET2, error });
12786
14090
  }
12787
14091
  var COVER_MIME = {
12788
14092
  ".png": "image/png",
@@ -12829,7 +14133,7 @@ function resolveShowNotesRenderOptions(options) {
12829
14133
  const audioContentPath = resolveOptionString(undefined, "PODCAST_PUB_AUDIO_CONTENT");
12830
14134
  if (audioContentPath !== undefined) {
12831
14135
  try {
12832
- const parsed = JSON.parse(readFileSync2(audioContentPath, "utf-8"));
14136
+ const parsed = JSON.parse(readFileSync3(audioContentPath, "utf-8"));
12833
14137
  const meta = parsed?.metadata;
12834
14138
  if (Array.isArray(meta?.segments)) {
12835
14139
  audioSegments = meta.segments.map((s) => ({
@@ -12839,6 +14143,10 @@ function resolveShowNotesRenderOptions(options) {
12839
14143
  }
12840
14144
  } catch {}
12841
14145
  }
14146
+ const seoConfig = resolvePodcastSeoConfig(undefined);
14147
+ if ("error" in seoConfig)
14148
+ return { error: seoConfig.error };
14149
+ const config = seoConfig.config;
12842
14150
  return {
12843
14151
  renderOptions: {
12844
14152
  title,
@@ -12847,7 +14155,15 @@ function resolveShowNotesRenderOptions(options) {
12847
14155
  ...briefMode ? { briefMode: true } : {},
12848
14156
  ...articlePostPath !== undefined ? { articlePostPath } : {},
12849
14157
  ...footerNote !== undefined ? { footerNote } : {},
12850
- ...audioSegments !== undefined ? { audioSegments } : {}
14158
+ ...audioSegments !== undefined ? { audioSegments } : {},
14159
+ summaryParagraph: config.summaryParagraph,
14160
+ timelineStyle: config.timelineStyle,
14161
+ seriesName: config.seriesName,
14162
+ showName: config.showName,
14163
+ descriptionMin: config.descriptionMin,
14164
+ descriptionMax: config.descriptionMax,
14165
+ descriptionSource: config.descriptionSource,
14166
+ descriptionPattern: config.descriptionPattern
12851
14167
  }
12852
14168
  };
12853
14169
  }
@@ -12860,7 +14176,8 @@ function renderShowNotesFromOptions(options) {
12860
14176
  const renderOptions = resolveShowNotesRenderOptions(options);
12861
14177
  if ("error" in renderOptions)
12862
14178
  return { error: renderOptions.error };
12863
- return { markdown: renderShowNotesMarkdown(docs.data, renderOptions.renderOptions) };
14179
+ const warnings = [];
14180
+ return { markdown: renderShowNotesMarkdown(docs.data, renderOptions.renderOptions, warnings), warnings };
12864
14181
  }
12865
14182
  function resolveAssemblyOptions(options) {
12866
14183
  const assembly = {};
@@ -12895,27 +14212,124 @@ function audioAttachment(audio, mediaUrl, sizeInBytes, durationSeconds) {
12895
14212
  ...durationSeconds !== undefined ? { duration_in_seconds: durationSeconds } : {}
12896
14213
  };
12897
14214
  }
12898
- function episodeExtras(episode) {
14215
+ function episodeExtras(episode, seo) {
12899
14216
  return {
12900
14217
  ...episode.imageUrl ? { image: episode.imageUrl } : {},
12901
14218
  ...episode.datePublishedMs !== undefined ? { date_published_ms: episode.datePublishedMs } : {},
12902
14219
  ...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
- }
14220
+ _microfeed: {
14221
+ "itunes:episodeType": episode.itunes?.episodeType ?? "full",
14222
+ ...episode.itunes?.title ? { "itunes:title": episode.itunes.title } : {},
14223
+ ...episode.itunes?.season !== undefined ? { "itunes:season": episode.itunes.season } : {},
14224
+ ...episode.itunes?.episode !== undefined ? { "itunes:episode": episode.itunes.episode } : {},
14225
+ ...episode.itunes?.explicit !== undefined ? { "itunes:explicit": episode.itunes.explicit } : {},
14226
+ ...episode.itunes?.block !== undefined ? { "itunes:block": episode.itunes.block } : {},
14227
+ ...seo ? { seo } : {}
12912
14228
  }
12913
14229
  };
12914
14230
  }
14231
+ function shapedEpisode(episode, fields) {
14232
+ return {
14233
+ ...episode,
14234
+ title: fields.title,
14235
+ ...fields.itunes ? { itunes: fields.itunes } : {},
14236
+ ...fields.durationSeconds !== undefined ? { durationSeconds: fields.durationSeconds } : { durationSeconds: undefined },
14237
+ link: fields.link
14238
+ };
14239
+ }
14240
+ async function createEpisodeItem(client, body, slug, warnings) {
14241
+ const withSlug = (itemSlug) => {
14242
+ const micro = body._microfeed ?? {};
14243
+ return { ...body, _microfeed: { ...micro, slug: itemSlug } };
14244
+ };
14245
+ if (!slug)
14246
+ return client.createItem(body, crypto.randomUUID());
14247
+ try {
14248
+ return await client.createItem(withSlug(slug), crypto.randomUUID());
14249
+ } catch (error) {
14250
+ if (!(error instanceof MicrofeedRequestError) || error.status !== 409)
14251
+ throw error;
14252
+ const retry = `${slug}-2`;
14253
+ try {
14254
+ return await client.createItem(withSlug(retry), crypto.randomUUID());
14255
+ } catch (retryError) {
14256
+ if (!(retryError instanceof MicrofeedRequestError) || retryError.status !== 409)
14257
+ throw retryError;
14258
+ warn(`slug "${slug}" conflicted (HTTP 409); publishing without a custom slug`, warnings);
14259
+ return client.createItem(body, crypto.randomUUID());
14260
+ }
14261
+ }
14262
+ }
14263
+ async function resolveSocialImage(client, fields, warnings) {
14264
+ if (!fields.socialAlt)
14265
+ return;
14266
+ if (fields.socialLocalPath && fields.socialMime) {
14267
+ const bytes = new Uint8Array(await readFile(fields.socialLocalPath));
14268
+ const prepared = await client.prepareUpload({
14269
+ category: "image",
14270
+ full_local_file_path: basename(fields.socialLocalPath),
14271
+ size: bytes.byteLength,
14272
+ type: fields.socialMime
14273
+ });
14274
+ await client.uploadBytes(prepared.presignedUrl, bytes, fields.socialMime);
14275
+ return {
14276
+ url: prepared.mediaUrl,
14277
+ width: 1200,
14278
+ height: 630,
14279
+ mime_type: fields.socialMime,
14280
+ alt: fields.socialAlt
14281
+ };
14282
+ }
14283
+ if (!fields.socialRemoteUrl)
14284
+ return;
14285
+ let bytes;
14286
+ try {
14287
+ const response = await fetch(fields.socialRemoteUrl);
14288
+ if (!response.ok)
14289
+ throw new Error(`HTTP ${response.status}`);
14290
+ bytes = new Uint8Array(await response.arrayBuffer());
14291
+ } catch (error) {
14292
+ warn(`social image skipped: could not read ${fields.socialRemoteUrl} (${error instanceof Error ? error.message : String(error)})`, warnings);
14293
+ return;
14294
+ }
14295
+ const dims = readImageDimensions(bytes);
14296
+ if (dims?.width !== 1200 || dims?.height !== 630) {
14297
+ warn(`social image skipped: expected a 1200\xD7630 PNG or JPEG, got ${dims ? `${dims.width}\xD7${dims.height}` : "an unreadable file"}`, warnings);
14298
+ return;
14299
+ }
14300
+ return {
14301
+ url: fields.socialRemoteUrl,
14302
+ width: 1200,
14303
+ height: 630,
14304
+ mime_type: dims.mime,
14305
+ alt: fields.socialAlt
14306
+ };
14307
+ }
14308
+ function mergeSocial(seo, social) {
14309
+ if (!social)
14310
+ return seo;
14311
+ return { ...seo ?? {}, social_image: social };
14312
+ }
14313
+ function mappedValidateInput(content) {
14314
+ const seoConfig = resolvePodcastSeoConfig(content.metadata);
14315
+ if ("error" in seoConfig)
14316
+ throw new Error(seoConfig.error);
14317
+ const episode = mapContentToEpisode(content);
14318
+ const policy = prepareEpisodeFields(episode, content.metadata ?? {}, seoConfig.config);
14319
+ if (!policy.ok)
14320
+ throw new Error(policy.error);
14321
+ const shaped = shapedEpisode(episode, policy.fields);
14322
+ return {
14323
+ title: shaped.title,
14324
+ content_html: shaped.contentHtml,
14325
+ status: shaped.status,
14326
+ ...episodeExtras(shaped, policy.fields.seo)
14327
+ };
14328
+ }
12915
14329
  async function publishEpisode(client, payload, options) {
12916
14330
  const assembly = resolveAssemblyOptions(options);
12917
14331
  if ("error" in assembly)
12918
- return fail(assembly.error);
14332
+ return fail2(assembly.error);
12919
14333
  const { audioFile, coverPath, durationSec } = assembly.assembly;
12920
14334
  const content = audioFile !== undefined || coverPath !== undefined || durationSec !== undefined ? {
12921
14335
  ...payload.content,
@@ -12930,17 +14344,31 @@ async function publishEpisode(client, payload, options) {
12930
14344
  try {
12931
14345
  episode = mapContentToEpisode(content);
12932
14346
  } catch (error) {
12933
- return fail(`Invalid Content: ${error instanceof Error ? error.message : String(error)}`);
14347
+ return fail2(`Invalid Content: ${error instanceof Error ? error.message : String(error)}`);
14348
+ }
14349
+ const seoConfig = resolvePodcastSeoConfig(content.metadata);
14350
+ if ("error" in seoConfig)
14351
+ return fail2(seoConfig.error);
14352
+ const policy = prepareEpisodeFields(episode, content.metadata ?? {}, seoConfig.config);
14353
+ if (!policy.ok)
14354
+ return fail2(policy.error);
14355
+ const fields = policy.fields;
14356
+ let social;
14357
+ try {
14358
+ social = await resolveSocialImage(client, fields, fields.warnings);
14359
+ } catch (error) {
14360
+ return fail2(error instanceof Error ? error.message : String(error));
12934
14361
  }
12935
- const idempotencyKey = crypto.randomUUID();
14362
+ const seo = mergeSocial(fields.seo, social);
14363
+ episode = shapedEpisode(episode, fields);
12936
14364
  if (episode.audio.kind === "url") {
12937
- const itemId = await client.createItem({
14365
+ const itemId = await createEpisodeItem(client, {
12938
14366
  title: episode.title,
12939
14367
  content_html: episode.contentHtml,
12940
14368
  status: episode.status,
12941
14369
  attachments: [audioAttachment(episode.audio, episode.audio.url, undefined, episode.durationSeconds)],
12942
- ...episodeExtras(episode)
12943
- }, idempotencyKey);
14370
+ ...episodeExtras(episode, seo)
14371
+ }, fields.slug, fields.warnings);
12944
14372
  let hostedItem;
12945
14373
  try {
12946
14374
  hostedItem = (await client.getItem(itemId)).items?.[0];
@@ -12949,17 +14377,18 @@ async function publishEpisode(client, payload, options) {
12949
14377
  }
12950
14378
  return ResultSchema.parse({
12951
14379
  ok: true,
12952
- target: TARGET,
14380
+ target: TARGET2,
12953
14381
  url: publicItemUrl(hostedItem, itemId, client),
12954
14382
  id: itemId,
12955
- message: "Published successfully to microfeed (hosted audio, single-call create)"
14383
+ message: "Published successfully to microfeed (hosted audio, single-call create)",
14384
+ ...fields.warnings.length > 0 ? { metadata: { warnings: fields.warnings } } : {}
12956
14385
  });
12957
14386
  }
12958
- const itemId = await client.createItem({
14387
+ const itemId = await createEpisodeItem(client, {
12959
14388
  title: episode.title,
12960
14389
  content_html: episode.contentHtml,
12961
14390
  status: "unpublished"
12962
- }, idempotencyKey);
14391
+ }, fields.slug, fields.warnings);
12963
14392
  const bytes = new Uint8Array(await readFile(episode.audio.path));
12964
14393
  const prepared = await client.prepareUpload({
12965
14394
  category: "audio",
@@ -12988,15 +14417,19 @@ async function publishEpisode(client, payload, options) {
12988
14417
  status: episode.status,
12989
14418
  attachments: [audioAttachment(episode.audio, prepared.mediaUrl, bytes.byteLength, episode.durationSeconds)],
12990
14419
  ...imageUrl ? { image: imageUrl } : {},
12991
- ...episodeExtras(episode)
14420
+ ...episodeExtras(episode, seo)
12992
14421
  });
12993
14422
  return ResultSchema.parse({
12994
14423
  ok: true,
12995
- target: TARGET,
14424
+ target: TARGET2,
12996
14425
  url: publicItemUrl(item, itemId, client),
12997
14426
  id: itemId,
12998
14427
  message: "Published successfully to microfeed (two-phase upload + publish)",
12999
- metadata: { itemId, imageUrl: imageUrl ?? null }
14428
+ metadata: {
14429
+ itemId,
14430
+ imageUrl: imageUrl ?? null,
14431
+ ...fields.warnings.length > 0 ? { warnings: fields.warnings } : {}
14432
+ }
13000
14433
  });
13001
14434
  }
13002
14435
 
@@ -13006,21 +14439,24 @@ class PodcastPubTransport {
13006
14439
  const options = payload.options !== undefined && typeof payload.options === "object" && payload.options !== null ? payload.options : {};
13007
14440
  const routed = resolveOperation(options);
13008
14441
  if ("error" in routed)
13009
- return fail(routed.error);
14442
+ return fail2(routed.error);
13010
14443
  if (routed.operation === "show-notes") {
13011
14444
  const rendered = renderShowNotesFromOptions(options);
13012
14445
  if ("error" in rendered)
13013
- return fail(rendered.error);
14446
+ return fail2(rendered.error);
13014
14447
  return ResultSchema.parse({
13015
14448
  ok: true,
13016
- target: TARGET,
14449
+ target: TARGET2,
13017
14450
  message: "Rendered show notes markdown (pure render; no microfeed API call)",
13018
- metadata: { markdown: rendered.markdown }
14451
+ metadata: {
14452
+ markdown: rendered.markdown,
14453
+ ...rendered.warnings.length > 0 ? { warnings: rendered.warnings } : {}
14454
+ }
13019
14455
  });
13020
14456
  }
13021
14457
  const resolved = resolveClient();
13022
14458
  if ("error" in resolved)
13023
- return fail(resolved.error);
14459
+ return fail2(resolved.error);
13024
14460
  const { client } = resolved;
13025
14461
  try {
13026
14462
  switch (routed.operation) {
@@ -13030,7 +14466,7 @@ class PodcastPubTransport {
13030
14466
  const feed = await client.listItems();
13031
14467
  return ResultSchema.parse({
13032
14468
  ok: true,
13033
- target: TARGET,
14469
+ target: TARGET2,
13034
14470
  url: typeof feed.feed_url === "string" ? feed.feed_url : undefined,
13035
14471
  message: `Listed ${feed.items?.length ?? 0} microfeed item(s)`,
13036
14472
  metadata: { items: feed.items ?? [] }
@@ -13039,14 +14475,14 @@ class PodcastPubTransport {
13039
14475
  case "get": {
13040
14476
  const itemId = resolveItemId(options);
13041
14477
  if ("error" in itemId)
13042
- return fail(itemId.error);
14478
+ return fail2(itemId.error);
13043
14479
  const feed = await client.getItem(itemId.itemId);
13044
14480
  const item = feed.items?.[0];
13045
14481
  if (!item)
13046
- return fail(`microfeed item "${itemId.itemId}" not found in the feed response`);
14482
+ return fail2(`microfeed item "${itemId.itemId}" not found in the feed response`);
13047
14483
  return ResultSchema.parse({
13048
14484
  ok: true,
13049
- target: TARGET,
14485
+ target: TARGET2,
13050
14486
  id: itemId.itemId,
13051
14487
  url: typeof item.url === "string" ? item.url : undefined,
13052
14488
  message: `Fetched microfeed item ${itemId.itemId}`,
@@ -13056,14 +14492,14 @@ class PodcastPubTransport {
13056
14492
  case "update": {
13057
14493
  const itemId = resolveItemId(options);
13058
14494
  if ("error" in itemId)
13059
- return fail(itemId.error);
14495
+ return fail2(itemId.error);
13060
14496
  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");
14497
+ return fail2("options.item (microfeed item input object) is required for the update operation");
13062
14498
  }
13063
14499
  const item = await client.updateItem(itemId.itemId, options.item);
13064
14500
  return ResultSchema.parse({
13065
14501
  ok: true,
13066
- target: TARGET,
14502
+ target: TARGET2,
13067
14503
  id: itemId.itemId,
13068
14504
  url: typeof item.url === "string" ? item.url : undefined,
13069
14505
  message: `Updated microfeed item ${itemId.itemId}`,
@@ -13073,34 +14509,33 @@ class PodcastPubTransport {
13073
14509
  case "delete": {
13074
14510
  const itemId = resolveItemId(options);
13075
14511
  if ("error" in itemId)
13076
- return fail(itemId.error);
14512
+ return fail2(itemId.error);
13077
14513
  await client.deleteItem(itemId.itemId);
13078
14514
  return ResultSchema.parse({
13079
14515
  ok: true,
13080
- target: TARGET,
14516
+ target: TARGET2,
13081
14517
  id: itemId.itemId,
13082
14518
  message: `Deleted microfeed item ${itemId.itemId}`
13083
14519
  });
13084
14520
  }
14521
+ case "backfill-seo": {
14522
+ const config = resolvePodcastSeoConfig(payload.content.metadata);
14523
+ if ("error" in config)
14524
+ return fail2(config.error);
14525
+ return await runBackfill(client, options, config.config);
14526
+ }
13085
14527
  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
- })();
14528
+ const input = options.item !== undefined && typeof options.item === "object" && options.item !== null ? options.item : mappedValidateInput(payload.content);
13094
14529
  await client.validateItem(input);
13095
14530
  return ResultSchema.parse({
13096
14531
  ok: true,
13097
- target: TARGET,
14532
+ target: TARGET2,
13098
14533
  message: "microfeed accepted the item input (POST /api/v1/items/validate)"
13099
14534
  });
13100
14535
  }
13101
14536
  }
13102
14537
  } catch (error) {
13103
- return fail(`${error instanceof Error ? error.message : String(error)}`);
14538
+ return fail2(`${error instanceof Error ? error.message : String(error)}`);
13104
14539
  }
13105
14540
  }
13106
14541
  }
@@ -13121,7 +14556,7 @@ async function renderShowNotesIO(docs, outputPath) {
13121
14556
  const rendered = renderShowNotesFromOptions({ docs });
13122
14557
  if ("error" in rendered) {
13123
14558
  const message = `show-notes render failed: ${rendered.error}`;
13124
- await writePublishResult(outputPath, fail(message));
14559
+ await writePublishResult(outputPath, fail2(message));
13125
14560
  throw new Error(message);
13126
14561
  }
13127
14562
  await createNodeFileSystem().writeFile(outputPath, rendered.markdown);
@@ -13143,7 +14578,7 @@ async function processPublishIO(inputPath, outputPath, transport = getPublishTra
13143
14578
  }
13144
14579
  } catch (error) {
13145
14580
  const message = `Invalid Content input: ${error instanceof Error ? error.message : String(error)}`;
13146
- await writePublishResult(outputPath, ResultSchema.parse({ ok: false, target: TARGET, error: message }));
14581
+ await writePublishResult(outputPath, ResultSchema.parse({ ok: false, target: TARGET2, error: message }));
13147
14582
  throw new Error(message);
13148
14583
  }
13149
14584
  const result = await transport.publish({ content, options });