@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
@@ -12,6 +12,14 @@
12
12
  */
13
13
 
14
14
  import type { Doc } from '@gobing-ai/kk-core';
15
+ import {
16
+ type PlanItem,
17
+ type PodcastSeoConfig,
18
+ SEO_DEFAULTS,
19
+ showNotesSummaryParagraph,
20
+ type TimelineStyle,
21
+ warn,
22
+ } from './seo';
15
23
 
16
24
  /** Escapes text content and attribute values (`&`, `<`, `>`, `"`). */
17
25
  function escapeHtml(value: string): string {
@@ -133,7 +141,7 @@ function isGeneratedTimeline(block: string): boolean {
133
141
  * input. `ponytail`: no JS — timestamp deep-linking depends on the theme's player
134
142
  * API; add when microfeed exposes one.
135
143
  */
136
- function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string): string {
144
+ function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string, style: TimelineStyle): string {
137
145
  const rows = docs
138
146
  .map((d, i) => {
139
147
  const title = escapeHtml(d.title ?? `条目 ${i + 1}`);
@@ -152,11 +160,15 @@ function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string):
152
160
  const articleTail = isHttpUrl(articleUrl)
153
161
  ? `<p>完整图文版:<a href="${escapeHtml(articleUrl)}" target="_blank" rel="noopener">${escapeHtml('本期完整文章')}</a></p>`
154
162
  : `<p>${escapeHtml('本期完整文章')}</p>`;
163
+ const styleBlock = style === 'omit' ? '' : `${TIMELINE_STYLE}\n`;
164
+ // `after` (default) keeps the style inside the markers but after the list, so content_text
165
+ // does not open on CSS. `before` is the pre-D9 position, for a byte-identical legacy render.
166
+ const before = style === 'before' ? styleBlock : '';
167
+ const after = style === 'after' ? styleBlock : '';
155
168
  return `<!--kk:timeline-->
156
- ${TIMELINE_STYLE}
157
- <div class="kk-tl">${rows}
169
+ ${before}<div class="kk-tl">${rows}
158
170
  </div>
159
- ${articleTail}
171
+ ${after}${articleTail}
160
172
  <!--/kk:timeline-->`;
161
173
  }
162
174
 
@@ -277,6 +289,22 @@ export interface ShowNotesRenderOptions {
277
289
  * matched monotonically against segment transcriptions; otherwise `--:--`.
278
290
  */
279
291
  audioSegments?: Array<{ duration?: number; transcription?: string }>;
292
+ /** R4. Omit to keep the default (summary on). `false` reproduces the pre-D9 body. */
293
+ summaryParagraph?: boolean;
294
+ /** R4. `after` moves `<style>` below the timeline; `before` is the legacy position; `omit` drops it. */
295
+ timelineStyle?: TimelineStyle;
296
+ seriesName?: string;
297
+ showName?: string;
298
+ descriptionMin?: number;
299
+ descriptionMax?: number;
300
+ descriptionSource?: PodcastSeoConfig['descriptionSource'];
301
+ descriptionPattern?: string;
302
+ /** Playback seconds for the `(约 N 分钟)` clause. Segment durations are the fallback. */
303
+ durationSeconds?: number;
304
+ /** Link text for the summary cross-link. Falls back to 本期完整文章. */
305
+ surfingTitle?: string;
306
+ refinedDescription?: string;
307
+ summaryClauses?: string[];
280
308
  }
281
309
 
282
310
  /** Per-item 1–2-sentence briefing: `metadata.brief` first, body sentences as fallback. */
@@ -298,7 +326,7 @@ function itemBrief(d: Doc): string {
298
326
  * a `[来源:<label>](<sourceUri>)` link per item, and the full-article footer.
299
327
  * Downstream HTML rendering stays with {@link renderShowNotesHtml}.
300
328
  */
301
- export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOptions): string {
329
+ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOptions, warnings?: string[]): string {
302
330
  const slugify = (t: string): string => {
303
331
  const s = t
304
332
  .toLowerCase()
@@ -323,6 +351,9 @@ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOption
323
351
  postSlug.length > 0
324
352
  ? `${baseUrl}${localePrefix}/posts/${postSlug}`
325
353
  : `${baseUrl}/zh/posts/${opts.runDate}-${slugify(opts.title)}`;
354
+ if (opts.briefMode === true && postSlug.length === 0) {
355
+ warn('brief mode requested but ARTICLE_POST_PATH is empty; using legacy layout', warnings);
356
+ }
326
357
  const briefMode = opts.briefMode === true && postSlug.length > 0;
327
358
  const sourceLabel = (d: Doc): string => {
328
359
  const m = d.metadata ?? {};
@@ -335,10 +366,44 @@ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOption
335
366
  };
336
367
  const offsets = computeAudioOffsets(docs, opts.audioSegments);
337
368
  const lines: string[] = [`# ${opts.title}`, ''];
369
+ const summaryOn = opts.summaryParagraph !== false;
370
+ if (summaryOn) {
371
+ const segmentSeconds = opts.audioSegments?.reduce((sum, segment) => sum + (segment.duration ?? 0), 0);
372
+ const durationSeconds =
373
+ opts.durationSeconds ?? (segmentSeconds && segmentSeconds > 0 ? segmentSeconds : undefined);
374
+ const plan: PlanItem[] = docs.map((doc) => {
375
+ const title = doc.title?.trim() ?? '';
376
+ const brief = itemBrief(doc);
377
+ return brief.length > 0 ? { title, brief } : { title };
378
+ });
379
+ const config: PodcastSeoConfig = {
380
+ ...SEO_DEFAULTS,
381
+ ...(opts.seriesName ? { seriesName: opts.seriesName } : {}),
382
+ ...(opts.showName ? { showName: opts.showName } : {}),
383
+ ...(opts.descriptionMin !== undefined ? { descriptionMin: opts.descriptionMin } : {}),
384
+ ...(opts.descriptionMax !== undefined ? { descriptionMax: opts.descriptionMax } : {}),
385
+ ...(opts.descriptionSource ? { descriptionSource: opts.descriptionSource } : {}),
386
+ ...(opts.descriptionPattern ? { descriptionPattern: opts.descriptionPattern } : {}),
387
+ summaryParagraph: true,
388
+ timelineStyle: opts.timelineStyle ?? 'after',
389
+ };
390
+ const summary = showNotesSummaryParagraph({
391
+ config,
392
+ runDate: opts.runDate,
393
+ ...(durationSeconds !== undefined ? { durationSeconds } : {}),
394
+ plan,
395
+ ...(opts.refinedDescription ? { refinedDescription: opts.refinedDescription } : {}),
396
+ ...(opts.summaryClauses ? { summaryClauses: opts.summaryClauses } : {}),
397
+ articleUrl,
398
+ linkText: opts.surfingTitle?.trim() || '本期完整文章',
399
+ });
400
+ for (const line of summary.warnings) warn(line.replace(/^WARN podcast-pub:\s*/, ''), warnings);
401
+ lines.push(summary.markdown, '');
402
+ }
338
403
  if (briefMode) {
339
404
  // Timeline layout (2026-09-16 feedback): real audio offsets left, title + briefing
340
405
  // right, article link last — the whole brief body is one pre-built HTML block.
341
- lines.push(renderTimelineHtml(docs, offsets, articleUrl), '');
406
+ lines.push(renderTimelineHtml(docs, offsets, articleUrl, opts.timelineStyle ?? 'after'), '');
342
407
  } else {
343
408
  docs.forEach((d, i) => {
344
409
  const body = (d.body ?? '').trim();
@@ -12473,15 +12473,12 @@ function mapContentToSurfingMarkdown(content, options = {}) {
12473
12473
  ...new Set(rawTags.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => normalizeTag(t)))
12474
12474
  ].filter((tag) => tag.length > 0);
12475
12475
  const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
12476
- const firstRefUrl = content.references?.[0]?.url;
12477
- const canonical = typeof firstRefUrl === "string" ? firstRefUrl : typeof content.metadata?.canonicalUrl === "string" ? content.metadata.canonicalUrl : undefined;
12478
12476
  const draft = typeof content.metadata?.published === "boolean" ? !content.metadata.published : false;
12479
12477
  const frontmatterObj = {
12480
12478
  title,
12481
12479
  description: description ?? "",
12482
12480
  tags,
12483
12481
  publishDate: isoDate,
12484
- ...canonical ? { canonical } : {},
12485
12482
  draft,
12486
12483
  ...options.image ? { image: options.image, og_image: options.image } : {}
12487
12484
  };
@@ -12568,6 +12565,35 @@ function cliErrorText(spawned) {
12568
12565
  function withEmdashTokenHint(errText) {
12569
12566
  return /INVALID_TOKEN|API error 401/.test(errText) ? `${errText} \u2014 hint: regenerate the EmDash token (surfing.salty.vip admin console) and update EMDASH_API_TOKEN in ~/.config/kk/config.yaml` : errText;
12570
12567
  }
12568
+ function readRefinedSnapshot(filePath) {
12569
+ if (!existsSync2(filePath))
12570
+ return;
12571
+ const raw = readFileSync2(filePath, "utf-8");
12572
+ const block = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
12573
+ const fm = block?.[1] ?? "";
12574
+ if (!fm || !/^refined:\s*true$/m.test(fm))
12575
+ return;
12576
+ const read = (key) => {
12577
+ const m = fm.match(new RegExp(`^${key}:\\s*(?:>-?
12578
+ ((?:[ \\t]+.*
12579
+ ?)+)|(.*))$`, "m"));
12580
+ if (!m)
12581
+ return;
12582
+ const value = (m[1] ?? m[2] ?? "").replace(/\n[ \t]+/g, " ").trim();
12583
+ return value.replace(/^["']|["']$/g, "") || undefined;
12584
+ };
12585
+ return {
12586
+ title: read("title"),
12587
+ seoTitle: read("seo_title"),
12588
+ description: read("description"),
12589
+ canonical: read("canonical")
12590
+ };
12591
+ }
12592
+ function requireRefined(filePath) {
12593
+ if (readRefinedSnapshot(filePath))
12594
+ return;
12595
+ return `surfdash refine step did not run for ${filePath} (no \`refined: true\`): the publishing pipeline is broken \u2014 the post would ship with generator-supplied SEO metadata`;
12596
+ }
12571
12597
  async function pushPostGit(surfdashRoot, relPostDir, message) {
12572
12598
  return commitPostPaths(surfdashRoot, [relPostDir], message);
12573
12599
  }
@@ -12584,12 +12610,40 @@ async function assertMainBranch(surfdashRoot) {
12584
12610
  }
12585
12611
  return { ok: true };
12586
12612
  }
12613
+ async function fastForwardToOrigin(surfdashRoot) {
12614
+ const fetched = await spawnCli(["git", "fetch", "--quiet", "origin", "main"], surfdashRoot);
12615
+ if (fetched.exitCode !== 0)
12616
+ return { ok: true };
12617
+ const counts = await spawnCli(["git", "rev-list", "--left-right", "--count", "origin/main...HEAD"], surfdashRoot);
12618
+ if (counts.exitCode !== 0)
12619
+ return { ok: true };
12620
+ const [behindRaw = "0", aheadRaw = "0"] = counts.stdout.trim().split(/\s+/);
12621
+ const behind = Number(behindRaw);
12622
+ const ahead = Number(aheadRaw);
12623
+ if (!Number.isFinite(behind) || !Number.isFinite(ahead) || behind === 0)
12624
+ return { ok: true };
12625
+ if (ahead > 0) {
12626
+ return {
12627
+ ok: false,
12628
+ error: `surfdash checkout diverged from origin/main (${ahead} local, ${behind} remote commit(s)): reconcile it by hand before publishing`
12629
+ };
12630
+ }
12631
+ const merged = await spawnCli(["git", "merge", "--ff-only", "--quiet", "origin/main"], surfdashRoot);
12632
+ if (merged.exitCode !== 0) {
12633
+ return { ok: false, error: `surfdash fast-forward to origin/main failed: ${cliErrorText(merged)}` };
12634
+ }
12635
+ echoError(`surfdash: fast-forwarded ${behind} commit(s) to origin/main before writing`);
12636
+ return { ok: true };
12637
+ }
12587
12638
  async function commitPostPaths(surfdashRoot, relPaths, message, push = true) {
12588
12639
  if (relPaths.length === 0)
12589
12640
  return { ok: true, hash: "unchanged" };
12590
12641
  const branch = await assertMainBranch(surfdashRoot);
12591
12642
  if (!branch.ok)
12592
12643
  return branch;
12644
+ const base = await fastForwardToOrigin(surfdashRoot);
12645
+ if (!base.ok)
12646
+ return base;
12593
12647
  const add = await spawnCli(["git", "add", "--", ...relPaths], surfdashRoot);
12594
12648
  if (add.exitCode !== 0)
12595
12649
  return { ok: false, error: `surfdash git add failed: ${cliErrorText(add)}` };
@@ -12843,6 +12897,10 @@ async function publishPost(payload) {
12843
12897
  if (spawned.exitCode !== 0) {
12844
12898
  return fail(`postsurfing CLI failed (exit ${spawned.exitCode}): ${withEmdashTokenHint(cliErrorText(spawned))}`);
12845
12899
  }
12900
+ const notRefined = requireRefined(filePath);
12901
+ if (notRefined)
12902
+ return fail(notRefined);
12903
+ const refined = readRefinedSnapshot(filePath);
12846
12904
  const baseUrl = (process.env.SURFDASH_URL ?? "https://surfing.salty.vip").replace(/\/$/, "");
12847
12905
  const articleUrl = `${baseUrl}${locale === "en" ? "" : `/${locale}`}/posts/${slug}`;
12848
12906
  if (process.env.SURFDASH_AUTO_PUSH === "0") {
@@ -12851,7 +12909,7 @@ async function publishPost(payload) {
12851
12909
  target: TARGET,
12852
12910
  url: articleUrl,
12853
12911
  message: "Published via postsurfing CLI (auto-push disabled)",
12854
- metadata: { postPath: filePath }
12912
+ metadata: { postPath: filePath, refinedTitle: refined?.title, refinedCanonical: refined?.canonical }
12855
12913
  });
12856
12914
  }
12857
12915
  const relPostDir = join2("content", "posts", "articles", locale, slug);
@@ -12864,7 +12922,7 @@ async function publishPost(payload) {
12864
12922
  target: TARGET,
12865
12923
  url: articleUrl,
12866
12924
  message: live ? "Published, committed, pushed, and live on the site" : "Published and pushed; URL not live within the verify window \u2014 check the surfdash deploy workflow",
12867
- metadata: { postPath: filePath, gitCommit: pushed.hash, live }
12925
+ metadata: { postPath: filePath, gitCommit: pushed.hash, live, refinedTitle: refined?.title }
12868
12926
  });
12869
12927
  } catch (err) {
12870
12928
  return fail(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
@@ -12944,6 +13002,7 @@ export {
12944
13002
  PostsurfingFileCliTransport,
12945
13003
  awaitArticleLive,
12946
13004
  commitPostPaths,
13005
+ fastForwardToOrigin,
12947
13006
  getPublishTransport,
12948
13007
  main2 as main,
12949
13008
  mapContentToSurfingMarkdown,
@@ -12951,6 +13010,8 @@ export {
12951
13010
  normalizeTag,
12952
13011
  processPublishIO,
12953
13012
  pushPostGit,
13013
+ readRefinedSnapshot,
13014
+ requireRefined,
12954
13015
  slugify2 as slugify,
12955
13016
  withEmdashTokenHint
12956
13017
  };
@@ -120,23 +120,22 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
120
120
  ].filter((tag) => tag.length > 0);
121
121
 
122
122
  const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
123
- const firstRefUrl = content.references?.[0]?.url;
124
- const canonical =
125
- typeof firstRefUrl === 'string'
126
- ? firstRefUrl
127
- : typeof content.metadata?.canonicalUrl === 'string'
128
- ? content.metadata.canonicalUrl
129
- : undefined;
130
123
  const draft = typeof content.metadata?.published === 'boolean' ? !content.metadata.published : false;
131
124
 
132
125
  // Strict surfdash PostSchema (surfdash/src/content/schema.ts): publishDate (ISO date) is
133
126
  // required; unknown keys (`date`, `canonical_url`, `published`) are rejected.
127
+ //
128
+ // `canonical` is deliberately NOT written here. The canonical URL is the site's own route
129
+ // for this slug, which only the site can compute (locale prefix + collection segment); the
130
+ // previous `references[0].url` fallback pointed the canonical at an unrelated *external*
131
+ // source and would have dropped the post out of the sitemap if surfdash's refine step had
132
+ // not overwritten it. See docs/00_ADR.md ADR-008: the generator supplies content, the site
133
+ // supplies identity.
134
134
  const frontmatterObj: Record<string, unknown> = {
135
135
  title,
136
136
  description: description ?? '',
137
137
  tags,
138
138
  publishDate: isoDate,
139
- ...(canonical ? { canonical } : {}),
140
139
  draft,
141
140
  ...(options.image ? { image: options.image, og_image: options.image } : {}),
142
141
  };
@@ -273,6 +272,49 @@ export function withEmdashTokenHint(errText: string): string {
273
272
  : errText;
274
273
  }
275
274
 
275
+ /**
276
+ * Post-conditions the site guarantees after its publishing pipeline ran.
277
+ *
278
+ * The publisher does not derive SEO metadata any more (ADR-008): surfdash's refine step owns
279
+ * `refined`, `seo_title`, `canonical` and the digest title/description. Verifying the marker
280
+ * here turns a silently-regressed site pipeline into a loud publisher failure instead of a post
281
+ * that ships with a date-only title — which is exactly how the 2026-10-21 regression went
282
+ * unnoticed.
283
+ */
284
+ export interface RefinedSnapshot {
285
+ title?: string;
286
+ seoTitle?: string;
287
+ description?: string;
288
+ canonical?: string;
289
+ }
290
+
291
+ /** Reads the frontmatter scalar fields the site owns; undefined when refine never ran. */
292
+ export function readRefinedSnapshot(filePath: string): RefinedSnapshot | undefined {
293
+ if (!existsSync(filePath)) return undefined;
294
+ const raw = readFileSync(filePath, 'utf-8');
295
+ const block = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
296
+ const fm = block?.[1] ?? '';
297
+ if (!fm || !/^refined:\s*true$/m.test(fm)) return undefined;
298
+ const read = (key: string): string | undefined => {
299
+ const m = fm.match(new RegExp(`^${key}:\\s*(?:>-?\n((?:[ \\t]+.*\n?)+)|(.*))$`, 'm'));
300
+ if (!m) return undefined;
301
+ const value = (m[1] ?? m[2] ?? '').replace(/\n[ \t]+/g, ' ').trim();
302
+ return value.replace(/^["']|["']$/g, '') || undefined;
303
+ };
304
+ return {
305
+ title: read('title'),
306
+ seoTitle: read('seo_title'),
307
+ description: read('description'),
308
+ canonical: read('canonical'),
309
+ };
310
+ }
311
+
312
+ /** Error text when the site pipeline did not mark the post refined, else undefined. */
313
+ export function requireRefined(filePath: string): string | undefined {
314
+ if (readRefinedSnapshot(filePath)) return undefined;
315
+ return `surfdash refine step did not run for ${filePath} (no \`refined: true\`): the publishing pipeline is broken — the post would ship with generator-supplied SEO metadata`;
316
+ }
317
+
276
318
  /** Outcome of {@link pushPostGit} — the union keeps fail-loud errors typed at the call site. */
277
319
  export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: string };
278
320
 
@@ -300,6 +342,41 @@ async function assertMainBranch(surfdashRoot: string): Promise<{ ok: true } | {
300
342
  return { ok: true };
301
343
  }
302
344
 
345
+ /**
346
+ * Fast-forward the checkout onto `origin/main` before anything is written (2026-10-22).
347
+ *
348
+ * Three consecutive publish days failed with `! [rejected] main -> main (fetch first)`: this
349
+ * checkout sat behind a remote another machine had pushed to, so the publish commit landed on a
350
+ * stale base and the push was rejected — after the article had already been written and committed,
351
+ * and after the podcast episode had already published, so the run closed `partial`/`failed` while a
352
+ * real episode was live. A fast-forward is lossless (HEAD moves only when local carries no commit of
353
+ * its own), which is exactly the case here; a DIVERGED checkout is the operator's call and fails
354
+ * loud with both counts. An unreachable remote is not fatal: the fetch error is swallowed so the
355
+ * push below reports the real reason.
356
+ */
357
+ export async function fastForwardToOrigin(surfdashRoot: string): Promise<{ ok: true } | { ok: false; error: string }> {
358
+ const fetched = await spawnCli(['git', 'fetch', '--quiet', 'origin', 'main'], surfdashRoot);
359
+ if (fetched.exitCode !== 0) return { ok: true };
360
+ const counts = await spawnCli(['git', 'rev-list', '--left-right', '--count', 'origin/main...HEAD'], surfdashRoot);
361
+ if (counts.exitCode !== 0) return { ok: true };
362
+ const [behindRaw = '0', aheadRaw = '0'] = counts.stdout.trim().split(/\s+/);
363
+ const behind = Number(behindRaw);
364
+ const ahead = Number(aheadRaw);
365
+ if (!Number.isFinite(behind) || !Number.isFinite(ahead) || behind === 0) return { ok: true };
366
+ if (ahead > 0) {
367
+ return {
368
+ ok: false,
369
+ error: `surfdash checkout diverged from origin/main (${ahead} local, ${behind} remote commit(s)): reconcile it by hand before publishing`,
370
+ };
371
+ }
372
+ const merged = await spawnCli(['git', 'merge', '--ff-only', '--quiet', 'origin/main'], surfdashRoot);
373
+ if (merged.exitCode !== 0) {
374
+ return { ok: false, error: `surfdash fast-forward to origin/main failed: ${cliErrorText(merged)}` };
375
+ }
376
+ echoError(`surfdash: fast-forwarded ${behind} commit(s) to origin/main before writing`);
377
+ return { ok: true };
378
+ }
379
+
303
380
  /**
304
381
  * Commits several paths of the surfdash checkout and pushes them to `main` — the plural form of
305
382
  * {@link pushPostGit} the post-patch needs (it writes the posts and any copied cover). Same
@@ -315,6 +392,8 @@ export async function commitPostPaths(
315
392
  if (relPaths.length === 0) return { ok: true, hash: 'unchanged' };
316
393
  const branch = await assertMainBranch(surfdashRoot);
317
394
  if (!branch.ok) return branch;
395
+ const base = await fastForwardToOrigin(surfdashRoot);
396
+ if (!base.ok) return base;
318
397
  const add = await spawnCli(['git', 'add', '--', ...relPaths], surfdashRoot);
319
398
  if (add.exitCode !== 0) return { ok: false, error: `surfdash git add failed: ${cliErrorText(add)}` };
320
399
  const staged = await spawnCli(['git', 'diff', '--cached', '--quiet', '--', ...relPaths], surfdashRoot);
@@ -653,6 +732,11 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
653
732
  );
654
733
  }
655
734
 
735
+ // Contract check (ADR-008): the site, not this plugin, owns SEO metadata.
736
+ const notRefined = requireRefined(filePath);
737
+ if (notRefined) return fail(notRefined);
738
+ const refined = readRefinedSnapshot(filePath);
739
+
656
740
  // Deploy seam: commit + push the post so deploy.yml builds it, then wait for
657
741
  // the live URL — downstream show-notes advertise a real page, not a 404.
658
742
  // Tests (and offline runs) opt out via SURFDASH_AUTO_PUSH=0.
@@ -669,7 +753,7 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
669
753
  message: 'Published via postsurfing CLI (auto-push disabled)',
670
754
  // Absolute post path lets downstream steps (locale translation + CMS sync)
671
755
  // derive the surfdash checkout root without extra configuration.
672
- metadata: { postPath: filePath },
756
+ metadata: { postPath: filePath, refinedTitle: refined?.title, refinedCanonical: refined?.canonical },
673
757
  });
674
758
  }
675
759
  const relPostDir = join('content', 'posts', 'articles', locale, slug);
@@ -684,7 +768,7 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
684
768
  message: live
685
769
  ? 'Published, committed, pushed, and live on the site'
686
770
  : 'Published and pushed; URL not live within the verify window — check the surfdash deploy workflow',
687
- metadata: { postPath: filePath, gitCommit: pushed.hash, live },
771
+ metadata: { postPath: filePath, gitCommit: pushed.hash, live, refinedTitle: refined?.title },
688
772
  });
689
773
  } catch (err: unknown) {
690
774
  return fail(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
@@ -22166,12 +22166,7 @@ async function waitForVisible(page, selector, timeoutMs, opts = {}) {
22166
22166
  // ../../plugins/publishings/wechatmp-pub/src/enrich.ts
22167
22167
  var DIALOG = ".weui-desktop-dialog:visible";
22168
22168
  var CONFIRM = `${DIALOG} button:has-text("\u786E\u8BA4"), ${DIALOG} button:has-text("\u786E\u5B9A")`;
22169
- var ROW_ORIGINAL = [
22170
- ".setting-group__switch.js_original_apply.js_edit_ori",
22171
- ".setting-group__switch.js_edit_ori",
22172
- ".js_edit_ori",
22173
- "text=\u539F\u521B"
22174
- ];
22169
+ var ROW_ORIGINAL = ["#js_original .setting-group__content-info", "#js_original .setting-group__title"];
22175
22170
  var ROW_REWARD = [
22176
22171
  ".setting-group__switch.js_reward_open",
22177
22172
  ".js_reward_open",
@@ -22180,18 +22175,28 @@ var ROW_REWARD = [
22180
22175
  "text=\u8D5E\u8D4F"
22181
22176
  ];
22182
22177
  var ROW_COLLECTION = [".allow_click_opr.js_article_tags_label", "text=\u5408\u96C6"];
22183
- var ROW_URL = [".allow_click_opr.js_url_checkbox", "text=\u539F\u6587\u94FE\u63A5"];
22184
- var ROW_CLAIM = ["#js_claim_source_area .frm_checkbox_label", "text=\u521B\u4F5C\u6765\u6E90"];
22178
+ var ROW_URL = ["#js_article_url_area .js_article_url_allow_click"];
22179
+ var ROW_CLAIM = ["#js_claim_source_area .allow_click_opr"];
22185
22180
  var DIALOG_CONFIRM = CONFIRM;
22181
+ var ORIGINAL_CONFIRM = `${DIALOG} button:text-is("\u786E\u5B9A")`;
22182
+ var CLAIM_CONFIRM = `${DIALOG} button:text-is("\u786E\u8BA4")`;
22186
22183
  var TAG_INPUT = 'input[placeholder*="\u5408\u96C6"]';
22187
22184
  var REWARD_ACCOUNT = [
22188
22185
  `${DIALOG} input[placeholder*="\u8D5E\u8D4F"]`,
22189
22186
  `${DIALOG} input[placeholder*="\u8D26\u6237"]`,
22190
22187
  `${DIALOG} input.frm_input`
22191
22188
  ];
22192
- var URL_INPUT = [`${DIALOG} input[type="url"]`, `${DIALOG} input[placeholder*="\u94FE\u63A5"]`, `${DIALOG} input.frm_input`];
22189
+ var URL_POPOVER = ".popover_inner:has(.popover-article-setting__content):visible";
22190
+ var URL_INPUT = [`${URL_POPOVER} input.js_url[name="source_url"]`];
22191
+ var URL_CONFIRM = `${URL_POPOVER} a.jsPopoverBt:text-is("\u786E\u5B9A")`;
22192
+ var URL_CANCEL = `${URL_POPOVER} a.jsPopoverBt:text-is("\u53D6\u6D88")`;
22193
+ var URL_COMMITTED = `(() => {
22194
+ const area = document.querySelector('#js_article_url_area');
22195
+ const checkbox = area?.querySelector('input.js_url_checkbox');
22196
+ return checkbox?.checked === true ? area.querySelector('.article_url_setting')?.textContent ?? null : null;
22197
+ })()`;
22193
22198
  var ORIGINAL_DECLARATION = `${DIALOG} label:has(.js_original_type_radio):has-text("\u6587\u5B57\u539F\u521B")`;
22194
- var ORIGINAL_AGREEMENT = `${DIALOG} .original_agreement`;
22199
+ var ORIGINAL_AGREEMENT = `${DIALOG} label.original_agreement:has(input[type="checkbox"]:not(:checked))`;
22195
22200
  var COLLECTION_SETTLE_MS = 1200;
22196
22201
  var STEP_RETRY_SETTLE_MS = 1000;
22197
22202
  var SWEEP_SETTLE_MS = 400;
@@ -22203,7 +22208,8 @@ function norm(value) {
22203
22208
  }
22204
22209
  var COMMITTED = {
22205
22210
  original: `(() => {
22206
- const el = document.querySelector('#js_original, .js_ori_setting_checkbox');
22211
+ const el = [...document.querySelectorAll('#js_original input.weui-desktop-switch__input')]
22212
+ .find(input => input.parentElement?.getClientRects().length > 0);
22207
22213
  return el ? el.checked === true : null;
22208
22214
  })()`,
22209
22215
  reward: `(() => {
@@ -22234,7 +22240,7 @@ async function enrichArticle(page, meta) {
22234
22240
  const str = (key) => typeof meta[key] === "string" ? meta[key] : "";
22235
22241
  const on = (key) => meta[key] === true;
22236
22242
  const sweep = async () => {
22237
- const cancel = await trySelectors(page, ['button:has-text("\u53D6\u6D88")']);
22243
+ const cancel = await trySelectors(page, ['button:has-text("\u53D6\u6D88")', URL_CANCEL]);
22238
22244
  if (cancel.found) {
22239
22245
  try {
22240
22246
  await page.locator(cancel.selector ?? "").first().click({ timeout: 2000 });
@@ -22305,7 +22311,7 @@ async function enrichArticle(page, meta) {
22305
22311
  if (agreement.found) {
22306
22312
  await clickSelector(page, ORIGINAL_AGREEMENT, 3000);
22307
22313
  }
22308
- await clickSelector(page, DIALOG_CONFIRM, 8000);
22314
+ await clickSelector(page, ORIGINAL_CONFIRM, 8000);
22309
22315
  });
22310
22316
  if (outcome !== "capability" && await readChecked(page, COMMITTED.original) !== true && !await residual(name)) {
22311
22317
  warnings.push(`${name}: \u539F\u521B\u58F0\u660E\u672A\u751F\u6548\uFF0C\u8BF7\u624B\u52A8\u8BBE\u7F6E`);
@@ -22358,23 +22364,32 @@ async function enrichArticle(page, meta) {
22358
22364
  const originalUrl = str("originalUrl");
22359
22365
  if (originalUrl !== "") {
22360
22366
  const name = "\u539F\u6587\u94FE\u63A5";
22361
- const urlRow = await tryFindSelector(page, ROW_URL, 2000);
22367
+ const readUrl = async () => {
22368
+ try {
22369
+ return norm(await page.evaluate(URL_COMMITTED));
22370
+ } catch {
22371
+ return "";
22372
+ }
22373
+ };
22374
+ const urlRow = await readUrl() === norm(originalUrl) ? "committed" : await tryFindSelector(page, ROW_URL, 2000);
22362
22375
  if (urlRow === undefined) {
22363
22376
  warnings.push(`${name}: \u884C\u4E0D\u5B58\u5728\uFF08\u5F53\u524D\u516C\u4F17\u53F7\u4E0D\u652F\u6301\uFF09\uFF0C\u8BF7\u624B\u52A8\u5904\u7406\u6216\u79FB\u9664 originalUrl`);
22364
- } else {
22377
+ } else if (urlRow !== "committed") {
22365
22378
  await step(name, async () => {
22379
+ if (await readUrl() === norm(originalUrl))
22380
+ return;
22366
22381
  await openRow(ROW_URL, "\u539F\u6587\u94FE\u63A5");
22367
22382
  const field = await trySelectors(page, URL_INPUT);
22368
22383
  if (!field.found)
22369
- throw new Error("no URL field in the \u539F\u6587\u94FE\u63A5 dialog");
22384
+ throw new Error("no URL field in the \u539F\u6587\u94FE\u63A5 popover");
22370
22385
  await fillSelector(page, field.selector ?? URL_INPUT.join(", "), originalUrl);
22371
- await clickSelector(page, DIALOG_CONFIRM, 8000);
22386
+ await clickSelector(page, URL_CONFIRM, 8000);
22387
+ await page.locator(URL_POPOVER).first().waitFor?.({ state: "hidden", timeout: 8000 });
22372
22388
  });
22373
- await residual(name);
22374
- const locator = page.locator(URL_INPUT[0] ?? "").first();
22375
- const committed = typeof locator.inputValue === "function" ? await locator.inputValue().catch(() => null) : null;
22376
- if (norm(committed) !== norm(originalUrl))
22389
+ if (await readUrl() !== norm(originalUrl))
22377
22390
  warnings.push(`${name}: \u5DF2\u63D0\u4EA4\u4F46\u672A\u8BFB\u53D6\u5230\u5DF2\u4FDD\u5B58\u7684\u94FE\u63A5\uFF0C\u8BF7\u6838\u5BF9`);
22391
+ if ((await trySelectors(page, [URL_POPOVER])).found)
22392
+ await sweep();
22378
22393
  }
22379
22394
  }
22380
22395
  const claimSource = str("claimSource");
@@ -22390,17 +22405,22 @@ async function enrichArticle(page, meta) {
22390
22405
  return null;
22391
22406
  }
22392
22407
  };
22408
+ let unavailable = false;
22393
22409
  if (await claimMatched() !== true) {
22394
- await step(name, async () => {
22410
+ const outcome = await step(name, async () => {
22395
22411
  await openRow(ROW_CLAIM, "\u521B\u4F5C\u6765\u6E90");
22396
- const dialog = await waitForVisible(page, DIALOG, 3000, { required: false });
22397
- if (dialog !== undefined) {
22398
- await clickSelector(page, `${DIALOG} :text("${claimSource}")`, 8000);
22399
- await clickSelector(page, DIALOG_CONFIRM, 8000);
22412
+ await waitForVisible(page, DIALOG, 3000);
22413
+ const stance = `${DIALOG} label:has(input[type="radio"]):has(:text-is(${JSON.stringify(claimSource)}))`;
22414
+ if (await tryFindSelector(page, [stance], 1500) === undefined) {
22415
+ await sweep();
22416
+ throw new CapabilityError(`\u5E73\u53F0\u672A\u63D0\u4F9B\u58F0\u660E\u7C7B\u578B\u300C${claimSource}\u300D\uFF0C\u5DF2\u8DF3\u8FC7`);
22400
22417
  }
22418
+ await clickSelector(page, stance, 8000);
22419
+ await clickSelector(page, CLAIM_CONFIRM, 8000);
22401
22420
  });
22421
+ unavailable = outcome === "capability";
22402
22422
  }
22403
- if (await claimMatched() !== true && !await residual(name)) {
22423
+ if (!unavailable && await claimMatched() !== true && !await residual(name)) {
22404
22424
  warnings.push(`${name}: \u672A\u751F\u6548\uFF0C\u8BF7\u624B\u52A8\u8BBE\u7F6E`);
22405
22425
  }
22406
22426
  }
@@ -22507,13 +22527,17 @@ var AUDIO_UPLOAD_INPUT = [
22507
22527
  '.weui-desktop-dialog input[type="file"][accept*="audio"]',
22508
22528
  '.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
22509
22529
  '.audio_dialog input[type="file"][accept*="audio"]',
22510
- 'input[type="file"][accept*=".mp3"]'
22530
+ 'input[type="file"][accept*=".mp3"]',
22531
+ '.weui-desktop-dialog input[type="file"]:not([accept*="image"])',
22532
+ '.audio_dialog input[type="file"]:not([accept*="image"])'
22511
22533
  ];
22512
22534
  var UPLOAD_COVER = ["text=\u4E0A\u4F20\u5C01\u9762", 'button:has-text("\u4E0A\u4F20\u5C01\u9762")'];
22513
22535
  var AUDIO_COVER_INPUT = [
22514
22536
  '.weui-desktop-dialog input[type="file"][accept*="image/"]',
22515
22537
  '.audio_dialog input[type="file"][accept*="image/"]',
22516
- 'input[type="file"][accept*=".png"]'
22538
+ 'input[type="file"][accept*=".png"]',
22539
+ '.weui-desktop-dialog input[type="file"][accept*="image"]',
22540
+ '.audio_dialog input[type="file"][accept*="image"]'
22517
22541
  ];
22518
22542
  var AUDIO_TITLE_INPUT = [
22519
22543
  '.weui-desktop-dialog input[placeholder*="\u6807\u9898"]',
@@ -22702,7 +22726,7 @@ async function applyLayout(page, deps) {
22702
22726
  }
22703
22727
  const option = await tryFindSelector(page, LAYOUT_TARGET, 5000);
22704
22728
  if (option === undefined) {
22705
- return "\u4E00\u952E\u6392\u7248: clicked, but no \u5339\u914D\u76EE\u6807\u683C\u5F0F option appeared \u2014 the body may be unformatted; check the draft";
22729
+ return "\u4E00\u952E\u6392\u7248: the control exists but this build renders no \u5339\u914D\u76EE\u6807\u683C\u5F0F option (measured 2026-10-21 with a body in place) \u2014 layout skipped, apply \u4E00\u952E\u6392\u7248 by hand before publishing";
22706
22730
  }
22707
22731
  try {
22708
22732
  await clickSelector(page, option, 5000);
@@ -20729,14 +20729,19 @@ var MAX_AGGREGATE_BYTES = 20 * 1024 * 1024;
20729
20729
  // ../../plugins/publishings/wechatmp-pub/src/enrich.ts
20730
20730
  var DIALOG = ".weui-desktop-dialog:visible";
20731
20731
  var CONFIRM = `${DIALOG} button:has-text("\u786E\u8BA4"), ${DIALOG} button:has-text("\u786E\u5B9A")`;
20732
+ var ORIGINAL_CONFIRM = `${DIALOG} button:text-is("\u786E\u5B9A")`;
20733
+ var CLAIM_CONFIRM = `${DIALOG} button:text-is("\u786E\u8BA4")`;
20732
20734
  var REWARD_ACCOUNT = [
20733
20735
  `${DIALOG} input[placeholder*="\u8D5E\u8D4F"]`,
20734
20736
  `${DIALOG} input[placeholder*="\u8D26\u6237"]`,
20735
20737
  `${DIALOG} input.frm_input`
20736
20738
  ];
20737
- var URL_INPUT = [`${DIALOG} input[type="url"]`, `${DIALOG} input[placeholder*="\u94FE\u63A5"]`, `${DIALOG} input.frm_input`];
20739
+ var URL_POPOVER = ".popover_inner:has(.popover-article-setting__content):visible";
20740
+ var URL_INPUT = [`${URL_POPOVER} input.js_url[name="source_url"]`];
20741
+ var URL_CONFIRM = `${URL_POPOVER} a.jsPopoverBt:text-is("\u786E\u5B9A")`;
20742
+ var URL_CANCEL = `${URL_POPOVER} a.jsPopoverBt:text-is("\u53D6\u6D88")`;
20738
20743
  var ORIGINAL_DECLARATION = `${DIALOG} label:has(.js_original_type_radio):has-text("\u6587\u5B57\u539F\u521B")`;
20739
- var ORIGINAL_AGREEMENT = `${DIALOG} .original_agreement`;
20744
+ var ORIGINAL_AGREEMENT = `${DIALOG} label.original_agreement:has(input[type="checkbox"]:not(:checked))`;
20740
20745
 
20741
20746
  // ../../plugins/publishings/wechatmp-pub/src/index.ts
20742
20747
  var CHANNEL = TARGET;
@@ -20828,7 +20833,7 @@ if (false) {}
20828
20833
  // ../../plugins/publishings/wechatmp-pub/src/profile-check.ts
20829
20834
  var USAGE = "usage: wechatmp profile-check [login|check|open] [--profile <dir>] [--url <url>] [--timeout <ms>] [--headed]";
20830
20835
  var LOGIN_TIMEOUT_MS = 180000;
20831
- var PROFILE_OVERHEAD_MS = 8000;
20836
+ var PROFILE_OVERHEAD_MS = 25000;
20832
20837
  var MIN_READINESS_MS = 1000;
20833
20838
  async function main2(argv = process.argv.slice(2)) {
20834
20839
  let command;