@gobing-ai/knowledge-kit 0.0.32 → 0.0.33

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 (65) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +32320 -15021
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +10 -9
  5. package/plugins/generations/core-facts-gen/dist/index.js +13 -12
  6. package/plugins/generations/daily-article-gen/dist/index.js +14 -13
  7. package/plugins/generations/daily-article-gen/src/index.ts +1 -1
  8. package/plugins/generations/dailynews-gen/dist/index.js +10 -9
  9. package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
  10. package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
  11. package/plugins/generations/gemini-tts/README.md +11 -0
  12. package/plugins/generations/gemini-tts/dist/index.js +10 -9
  13. package/plugins/generations/image-gen/dist/index.js +154 -151
  14. package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
  15. package/plugins/generations/news-report-gen/dist/index.js +13 -12
  16. package/plugins/generations/voice-gen/dist/index.js +10 -9
  17. package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
  18. package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
  19. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  20. package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
  21. package/plugins/ingestions/web-search/dist/index.js +11 -10
  22. package/plugins/kk/README.md +38 -0
  23. package/plugins/kk/decisions/extract/ai-news.yaml +21 -0
  24. package/plugins/kk/decisions/kk-items.yaml +113 -0
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/schedules/cron.tmpl +15 -0
  27. package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
  28. package/plugins/kk/scripts/daily-stages.ts +1533 -0
  29. package/plugins/kk/scripts/kk-workflow-stages.ts +247 -1365
  30. package/plugins/kk/skills/content-safety/SKILL.md +38 -7
  31. package/plugins/kk/skills/web-automation/SKILL.md +100 -0
  32. package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
  33. package/plugins/kk/workflows/kk-collect.yaml +258 -0
  34. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +9 -3
  35. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +655 -0
  36. package/plugins/kk/workflows/kk-daily-safety.yaml +170 -0
  37. package/plugins/publishings/emdash-pub/dist/index.js +10 -9
  38. package/plugins/publishings/infoq-pub/dist/index.js +152 -59
  39. package/plugins/publishings/infoq-pub/plugin.json +1 -0
  40. package/plugins/publishings/infoq-pub/src/index.ts +167 -55
  41. package/plugins/publishings/infoq-pub/src/profile-check.ts +4 -2
  42. package/plugins/publishings/juejin-pub/dist/index.js +111 -30
  43. package/plugins/publishings/juejin-pub/plugin.json +1 -0
  44. package/plugins/publishings/juejin-pub/src/index.ts +49 -9
  45. package/plugins/publishings/medium-pub/dist/index.js +10 -9
  46. package/plugins/publishings/podcast-pub/dist/index.js +10 -9
  47. package/plugins/publishings/qiita-pub/dist/index.js +10 -9
  48. package/plugins/publishings/substack-pub/dist/index.js +105 -28
  49. package/plugins/publishings/substack-pub/plugin.json +1 -0
  50. package/plugins/publishings/substack-pub/src/index.ts +39 -8
  51. package/plugins/publishings/substack-pub/src/profile-check.ts +2 -0
  52. package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
  53. package/plugins/publishings/wechatmp-pub/dist/index.js +115 -31
  54. package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
  55. package/plugins/publishings/wechatmp-pub/src/index.ts +42 -9
  56. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -40
  57. package/plugins/publishings/x-pub/dist/index.js +114 -35
  58. package/plugins/publishings/x-pub/plugin.json +1 -0
  59. package/plugins/publishings/x-pub/src/index.ts +68 -14
  60. package/plugins/publishings/xhs-pub/dist/index.js +100 -54
  61. package/plugins/publishings/xhs-pub/plugin.json +1 -0
  62. package/plugins/publishings/xhs-pub/src/index.ts +8 -4
  63. package/plugins/publishings/xhs-pub/src/page.ts +4 -17
  64. package/plugins/publishings/xhs-pub/src/profile-check.ts +28 -15
  65. package/plugins/publishings/zenn-pub/dist/index.js +10 -9
@@ -11,7 +11,9 @@ import {
11
11
  import {
12
12
  type ChannelBrowserConfig,
13
13
  checkChannelSession,
14
+ DEFAULT_SIGNAL_CONTAINERS,
14
15
  expectOutcome,
16
+ kkBrowserLauncher,
15
17
  markdownToHtml,
16
18
  pasteHtml,
17
19
  readScopedSignal,
@@ -33,48 +35,105 @@ import type { Page } from 'playwright';
33
35
  export const CHANNEL = 'infoq';
34
36
  const TARGET = 'infoq';
35
37
  /**
36
- * InfoQ's write page. The wt seed tries `/write`, `/write/article/new` and `/article/create`
37
- * (`infoq-playwright.ts:545-547`); the operator dogfood re-measures this one.
38
+ * The live entry to the InfoQ editor (measured 2026-10-06, tasks 0201 + 0191): the home page's
39
+ * 写点什么 write button SPA-routes to `/draft/<id>`, where the draft already exists server-side.
40
+ * The former `https://xie.infoq.cn/write` constant is dead — it renders an empty shell even
41
+ * authenticated, with no editor and no login redirect.
38
42
  */
39
- export const EDITOR_URL = 'https://xie.infoq.cn/write';
40
- /** Login/SSO URLs that mean "not authenticated" — the editor never renders behind them. */
41
- export const LOGIN_URL_RE = /(account\.infoq\.cn|\/login)/;
43
+ export const HOME_URL = 'https://xie.infoq.cn/';
44
+ /** The editor URL the entry flow lands on; the captured group is the server-side draft id. */
45
+ export const DRAFT_URL_RE = /\/draft\/(\d+)/;
46
+ /**
47
+ * Login/SSO URLs that mean "not authenticated" — the editor never renders behind them. The SSO
48
+ * domain moved from account.infoq.cn to account.geekbang.org (measured, task 0201). The home page
49
+ * renders logged-out content without redirecting, so this regex is only the fast arm of the
50
+ * readiness predicate below.
51
+ */
52
+ export const LOGIN_URL_RE = /(account\.infoq\.cn|account\.geekbang\.org|\/login)/;
42
53
 
43
54
  /**
44
- * Selectors measured against the InfoQ editor (wt seed `infoq-playwright.ts:26-62`); each list is
45
- * tried in order and the operator dogfood re-measures them. `live` is `提交`, InfoQ's submit for
46
- * editorial review — the site has no self-publish control.
55
+ * Selectors measured against the live authenticated console (2026-10-06, tasks 0201 + 0191);
56
+ * each list is tried in order. Measured absent and therefore removed: the summary field (the
57
+ * console has no 摘要 input, so `metadata.summary` is not representable on InfoQ) and the draft
58
+ * button (the editor autosaves — the save signal is the 已保存 indicator, not a click). `live`
59
+ * is the 发布 control, a `div`: InfoQ submit goes to editorial review, the site has no
60
+ * self-publish control.
47
61
  */
48
62
  export const SELECTORS = {
49
- title: ['input[placeholder*="标题"]', 'textarea[placeholder*="标题"]'],
50
- summary: ['input[placeholder*="摘要"]', 'textarea[placeholder*="摘要"]', 'input[name="summary"]'],
63
+ writeEntry: ['div.write-btn'],
64
+ noticeDismiss: ['text=知道了'],
65
+ title: ['input[placeholder="请输入标题"]', 'input[placeholder*="标题"]'],
51
66
  body: ['.ProseMirror[contenteditable="true"]', '[contenteditable="true"]'],
52
- draft: ['button:has-text("保存草稿")'],
53
- live: ['button:has-text("提交")'],
67
+ live: ['div.submit-btn'],
54
68
  } as const;
55
69
 
70
+ /**
71
+ * Per-step wait. Lower bound: the measured autosave indicator appears within ~6 s of content
72
+ * entry (docs/design/infoq-ui-discovery.md:41, 0201 walk), so the window must cover the slow end
73
+ * of that latency plus hydration; 30 s is the 5× margin. Unit tests override this for fakes that
74
+ * resolve instantly.
75
+ */
56
76
  const STEP_TIMEOUT_MS = 30_000;
77
+
78
+ /**
79
+ * Read-only session check budget, independent of {@link STEP_TIMEOUT_MS}: headed hydration of
80
+ * the logged-in home has a slow tail (2.5-35 s measured on the operator profile, 0191 dogfood),
81
+ * so one 30 s step window is not enough for the check.
82
+ */
83
+ const CHECK_TIMEOUT_MS = 60_000;
57
84
  const LOGIN_TIMEOUT_MS = 180_000;
58
85
  /** Operator recovery command every session failure names — never a cookie, page HTML or stack. */
59
86
  const LOGIN_INSTRUCTION = 'bun plugins/publishings/infoq-pub/src/profile-check.ts login';
60
- /** A saved draft lands on a URL carrying its article id; a query-string move is not a signal. */
61
- const DRAFT_URL_ID_RE = /\/(\d{4,}|[0-9a-f]{16,})(?:\/|$|\?)/;
62
- const DRAFT_TOASTS = ['保存成功', '已保存'];
87
+ /**
88
+ * The draft-saved signal (measured, task 0201; re-walked 0191): the editor autosaves and shows
89
+ * the 已保存 indicator — read only from the page's notification containers, never the article
90
+ * body (residual 0195).
91
+ */
92
+ const DRAFT_SAVED_TEXTS = ['已保存'];
93
+
94
+ /**
95
+ * Signal containers for this channel: the shared toast/notice defaults plus the editor header's
96
+ * persistent save indicator (`div.save-status`, measured 0191 re-dogfood — InfoQ now renders
97
+ * `已保存 <timestamp>` there instead of a toast). Class-scoped, so the 0195 body false-positive
98
+ * risk does not apply.
99
+ */
100
+ const SIGNAL_CONTAINERS: readonly string[] = [...DEFAULT_SIGNAL_CONTAINERS, '[class*="save-status" i]'];
63
101
  const LIVE_TOASTS = ['提交成功', '审核'];
102
+ /**
103
+ * Page-side scan behind {@link isAuthenticated}: is a control labelled exactly 登录 visible?
104
+ * Exact leaf text, because feed articles can carry the substring 登录 without the session being
105
+ * logged out.
106
+ */
107
+ const LOGGED_OUT_MARKER_SCRIPT = `(() => {
108
+ for (const node of document.querySelectorAll('body *')) {
109
+ if (node.children.length > 0) continue;
110
+ if ((node.textContent ?? '').trim() !== '登录') continue;
111
+ if (typeof node.checkVisibility === 'function' ? !node.checkVisibility() : node.getClientRects().length === 0) continue;
112
+ return true;
113
+ }
114
+ return false;
115
+ })()`;
64
116
 
65
117
  /**
66
- * InfoQ-owned readiness predicate: the editor is reachable (no SSO redirect) and the title field
67
- * is attached. Shared by the publish preflight and the profile-session CLI (R1, R5).
118
+ * InfoQ-owned readiness predicate: the home page has hydrated to the write entry and no 登录
119
+ * control is visible (the logged-out marker). Shared by the publish preflight and the
120
+ * profile-session CLI (R1, R5). Hydration gates the marker read: until the home chrome renders,
121
+ * the 登录 control has not attached yet and a logged-out page false-reads logged-in (0201).
68
122
  */
69
123
  export const isAuthenticated: IsAuthenticatedPredicate = async (page) =>
70
- !LOGIN_URL_RE.test(page.url()) && (await trySelectors(page, SELECTORS.title, { visible: false })).found;
124
+ !LOGIN_URL_RE.test(page.url()) &&
125
+ (await trySelectors(page, SELECTORS.writeEntry, { visible: false })).found &&
126
+ !(await page.evaluate(LOGGED_OUT_MARKER_SCRIPT));
71
127
 
72
128
  /** Site-owned config for the shared session check and operator CLI (0183). */
73
129
  export const CONFIG: ChannelBrowserConfig = {
74
130
  channel: CHANNEL,
75
- homeUrl: EDITOR_URL,
131
+ homeUrl: HOME_URL,
76
132
  isAuthenticated,
77
133
  loginTimeoutMs: LOGIN_TIMEOUT_MS,
134
+ // The WAF 451s every headless load, so the profile-check CLI must not waste its first
135
+ // pass headlessly either (measured 0201, re-confirmed 0191).
136
+ checkHeadedOnly: true,
78
137
  };
79
138
 
80
139
  function fail(error: string): Result {
@@ -124,25 +183,54 @@ async function findSelector(
124
183
  * a body containing `保存成功` reported a draft that was never saved.
125
184
  */
126
185
  async function readToast(page: Page, texts: readonly string[]): Promise<string | null> {
127
- return readScopedSignal(page, texts);
186
+ return readScopedSignal(page, texts, SIGNAL_CONTAINERS);
187
+ }
188
+
189
+ /**
190
+ * Declared modes (ADR-029 dispatch; task 0205 R2): InfoQ's only console flow is the draft path; 提交 (editorial review) is live-only and not a mode. An unknown operation is refused by
191
+ * name before a profile is resolved or a browser opens.
192
+ */
193
+ export const INFOQ_OPERATIONS = ['draft'] as const;
194
+ export type InfoqOperation = (typeof INFOQ_OPERATIONS)[number];
195
+
196
+ /** Exit-early resolution of the requested operation; a default Content without one resolves to `draft`. */
197
+ export function resolveOperation(
198
+ options?: Record<string, unknown>,
199
+ content?: Content,
200
+ ): { operation: InfoqOperation; error?: undefined } | { operation?: undefined; error: string } {
201
+ const raw = options?.operation ?? content?.metadata?.operation;
202
+ if (raw === undefined) return { operation: 'draft' };
203
+ if (typeof raw !== 'string' || !(INFOQ_OPERATIONS as readonly string[]).includes(raw)) {
204
+ return {
205
+ error: `unsupported operation '${String(raw)}' for infoq: expected one of ${INFOQ_OPERATIONS.join(', ')}`,
206
+ };
207
+ }
208
+ return { operation: raw as InfoqOperation };
128
209
  }
129
210
 
130
211
  export class InfoqBrowserTransport implements PublishTransport {
131
212
  public readonly kind = 'browser';
132
213
  public readonly driver: BrowserDriver;
133
- private readonly launcher?: BrowserLauncher;
214
+ private readonly launcher: BrowserLauncher;
134
215
  private readonly stepTimeoutMs: number;
216
+ private readonly checkTimeoutMs: number;
135
217
 
136
- constructor(driver: BrowserDriver = 'script', deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {}) {
218
+ constructor(
219
+ driver: BrowserDriver = 'script',
220
+ deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number; checkTimeoutMs?: number } = {},
221
+ ) {
137
222
  this.driver = driver;
138
- this.launcher = deps.launcher;
223
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
139
224
  this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
225
+ this.checkTimeoutMs = deps.checkTimeoutMs ?? CHECK_TIMEOUT_MS;
140
226
  }
141
227
 
142
228
  public async publish(payload: PublishTransportPayload): Promise<Result> {
143
229
  if (this.driver !== 'script') {
144
230
  return fail(`unsupported driver '${this.driver}' for ${TARGET}`);
145
231
  }
232
+ const mode = resolveOperation(payload.options, payload.content);
233
+ if (mode.error !== undefined) return fail(mode.error);
146
234
  const requested = payload.content.metadata?.publishStatus;
147
235
  if (requested !== undefined && requested !== 'draft' && requested !== 'public') {
148
236
  return fail(`unknown publishStatus '${String(requested)}' (expected absent, 'draft' or 'public')`);
@@ -151,13 +239,17 @@ export class InfoqBrowserTransport implements PublishTransport {
151
239
  const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
152
240
  const profileDir = resolveChannelProfileDir(CHANNEL, override);
153
241
 
154
- // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed
155
- // visit, so the read-only headless check runs first and a refusal stays a fast refusal.
242
+ // ADR-025 preflight: an unauthenticated or absent profile must never reach the editor
243
+ // flow, so the read-only session check runs first and a refusal stays a fast refusal.
156
244
  const session = await checkChannelSession(CONFIG, {
157
245
  profileDir,
158
246
  launcher: this.launcher,
159
- headedFallback: false,
160
- timeoutMs: this.stepTimeoutMs,
247
+ // InfoQ's WAF serves HTTP 451 to every headless page load (measured 0201, re-confirmed
248
+ // 0191), so the check runs headed only — a headless pass is a guaranteed 30 s burn
249
+ // that shortens the headed budget below the measured hydration tail. A
250
+ // missing/expired profile still refuses before any flow opens (ADR-025 shape).
251
+ headedOnly: true,
252
+ timeoutMs: this.checkTimeoutMs,
161
253
  });
162
254
  if (session.status !== 'verified') {
163
255
  return fail(`${CHANNEL} session preflight refused: ${session.detail} — run: ${LOGIN_INSTRUCTION}`);
@@ -167,10 +259,11 @@ export class InfoqBrowserTransport implements PublishTransport {
167
259
  return await withProfilePage(
168
260
  {
169
261
  profileDir,
170
- url: EDITOR_URL,
262
+ url: HOME_URL,
171
263
  isAuthenticated,
172
- // Headed by default (sites bot-wall headless loads); the headless preflight
173
- // above is the unattended pass, `options.headless === true` the opt-in.
264
+ // Headed by default — InfoQ's WAF 451s every headless load (measured 0201,
265
+ // re-confirmed 0191; see the preflight comment); `options.headless === true`
266
+ // is an explicit opt-in that will fail loudly against that wall.
174
267
  headless: payload.options?.headless === true,
175
268
  launcher: this.launcher,
176
269
  // The session is already preflight-verified, so the post-goto confirmation is
@@ -186,9 +279,12 @@ export class InfoqBrowserTransport implements PublishTransport {
186
279
  }
187
280
 
188
281
  /**
189
- * The site-owned editor flow: title → optional summary → ProseMirror paste → draft control,
190
- * plus the submit control only for `publishStatus === 'public'`. Every outcome comes from an
191
- * `expectOutcome` probe, so a click without an observed signal is never a success (E91-D6).
282
+ * The site-owned editor flow (measured 0201 + 0191): home 写点什么 → the server-side draft
283
+ * `/draft/<id>` → title → ProseMirror paste → autosave indicator. There is no draft click:
284
+ * the entry itself created the draft, and the save is proven by the observed 已保存 signal.
285
+ * The submit control is touched only for `publishStatus === 'public'`. Every outcome comes
286
+ * from an `expectOutcome` probe, so a step without an observed signal is never a success
287
+ * (E91-D6).
192
288
  */
193
289
  private async runEditorFlow(
194
290
  page: Page,
@@ -198,33 +294,46 @@ export class InfoqBrowserTransport implements PublishTransport {
198
294
  ): Promise<Result> {
199
295
  const metadata = { driver: this.driver, publishStatus, profileDir };
200
296
 
297
+ const entrySelector = await findSelector(page, SELECTORS.writeEntry, 'write entry', this.stepTimeoutMs);
298
+ await page.locator(entrySelector).first().click();
299
+ const entered = await waitUntil(() => Promise.resolve(DRAFT_URL_RE.test(page.url())), this.stepTimeoutMs);
300
+ if (!entered) {
301
+ throw new Error(
302
+ `infoq editor entry: still on '${page.url()}' — no /draft/<id> within ${this.stepTimeoutMs}ms`,
303
+ );
304
+ }
305
+ // The draft id exists before any content is typed: the entry itself created the draft.
306
+ const id = DRAFT_URL_RE.exec(page.url())?.[1];
307
+ await dismissNoticeModal(page, this.stepTimeoutMs);
308
+
201
309
  const titleSelector = await findSelector(page, SELECTORS.title, 'title input', this.stepTimeoutMs);
202
310
  await page
203
311
  .locator(titleSelector)
204
312
  .first()
205
313
  .fill(content.title ?? '');
206
314
 
207
- const summary = content.metadata?.summary;
208
- if (typeof summary === 'string' && summary.length > 0) {
209
- const summarySelector = await findSelector(page, SELECTORS.summary, 'summary input', this.stepTimeoutMs);
210
- await page.locator(summarySelector).first().fill(summary);
211
- }
315
+ // No summary step: the measured console has no summary field (see SELECTORS).
212
316
 
213
317
  const bodySelector = await findSelector(page, SELECTORS.body, 'body editor', this.stepTimeoutMs);
214
318
  await pasteHtml(page, bodySelector, markdownToHtml(content.body), { timeoutMs: this.stepTimeoutMs });
319
+ // pasteHtml sets innerHTML without an input event, so ProseMirror's autosave may not see
320
+ // the change; dispatch one so the 已保存 indicator actually fires (measured 0191 probe:
321
+ // the indicator appeared only after a real input event reached the editor).
322
+ // String script: this plugin's tsconfig has no DOM lib, so the callback must not be TS.
323
+ await page
324
+ .evaluate(
325
+ '(selector) => { const editor = document.querySelector(selector); editor && editor.dispatchEvent(new InputEvent("input", { bubbles: true })); }',
326
+ bodySelector,
327
+ )
328
+ .catch(() => {});
215
329
 
216
- const urlBeforeSave = page.url();
217
- const draftSelector = await findSelector(page, SELECTORS.draft, 'draft control', this.stepTimeoutMs);
218
- await page.locator(draftSelector).first().click();
219
-
220
- const draft = await expectOutcome(() => probeDraft(page, urlBeforeSave), {
330
+ // Signal = the autosave 已保存 indicator, measured within ~6 s of paste (0201 walk);
331
+ // the 30 s default window is the 5× margin (see STEP_TIMEOUT_MS).
332
+ const draft = await expectOutcome(() => readToast(page, DRAFT_SAVED_TEXTS), {
221
333
  timeoutMs: this.stepTimeoutMs,
222
- label: `infoq draft save '${draftSelector}'`,
334
+ label: 'infoq draft autosave',
223
335
  });
224
336
  if (!draft.ok) return fail(draft.error);
225
- // The probe only returns a URL that moved and carried an id, so a capture exists exactly
226
- // for that branch; a toast text carries none.
227
- const id = DRAFT_URL_ID_RE.exec(draft.evidence)?.[1];
228
337
 
229
338
  if (publishStatus === 'draft') {
230
339
  return ResultSchema.parse({
@@ -238,8 +347,8 @@ export class InfoqBrowserTransport implements PublishTransport {
238
347
  }
239
348
 
240
349
  const liveSelector = await findSelector(page, SELECTORS.live, 'submit control', this.stepTimeoutMs);
241
- // Captured at the click, not at the draft: the draft run already moved the URL, and a
242
- // pre-draft baseline would read that move as the submit's own outcome.
350
+ // Captured at the click, not at the entry: the entry already moved the URL to
351
+ // /draft/<id>, and an entry-time baseline would read that move as the submit's outcome.
243
352
  const urlBeforeSubmit = page.url();
244
353
  await page.locator(liveSelector).first().click();
245
354
  const live = await expectOutcome(() => probeLive(page, urlBeforeSubmit), {
@@ -260,13 +369,16 @@ export class InfoqBrowserTransport implements PublishTransport {
260
369
  }
261
370
 
262
371
  /**
263
- * Draft evidence (E91-D6): the editor URL moved onto an article id, or a visible save toast.
264
- * A URL that merely gained a query parameter is not evidence of a saved draft.
372
+ * The feature-notice modal overlays the editor and swallows click/fill events (measured 0201);
373
+ * it is not always shown, so dismissal is a guarded no-op when absent.
265
374
  */
266
- async function probeDraft(page: Page, urlBeforeSave: string): Promise<string | null> {
267
- const url = page.url();
268
- if (url !== urlBeforeSave && DRAFT_URL_ID_RE.test(url)) return url;
269
- return readToast(page, DRAFT_TOASTS);
375
+ async function dismissNoticeModal(page: Page, timeoutMs: number): Promise<void> {
376
+ const modal = page.locator(SELECTORS.noticeDismiss[0]).first();
377
+ try {
378
+ if ((await modal.count()) > 0) await modal.click({ timeout: Math.min(timeoutMs, 5_000) });
379
+ } catch {
380
+ // A modal that vanished between the count and the click needs no dismissal.
381
+ }
270
382
  }
271
383
 
272
384
  /** Live evidence: a visible submit toast, or a URL that moved off the URL the draft run left. */
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * InfoQ profile-session capability (R5, AC3).
3
3
  *
4
- * `login` opens the editor in a headed browser and resolves only once InfoQ's readiness
5
- * predicate passes; `check` reopens the profile headlessly and reports `verified` or `refused`.
4
+ * `login` opens the InfoQ home in a headed browser and resolves only once the readiness
5
+ * predicate passes (hydrated write entry, no visible 登录 control); `check` reopens the profile
6
+ * headlessly and reports `verified` or `refused` — on InfoQ a headless pass always 451s at the
7
+ * WAF (measured 0201 + 0191), so a refusal is retried once headed.
6
8
  * Arg parsing, profile precedence (`--profile` > `INFOQ_PROFILE_DIR` > `KK_BROWSER_PROFILE_DIR` >
7
9
  * `~/.config/kk/browser-profiles/infoq`) and the exit codes (0 verified, 1 refused, 2 usage
8
10
  * error) all live in the shared `runProfileCli` (0183), so the publish preflight and this CLI