@gobing-ai/knowledge-kit 0.0.31 → 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 (79) 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 +268 -1228
  30. package/plugins/kk/skills/content-judge/SKILL.md +12 -2
  31. package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
  32. package/plugins/kk/skills/content-safety/SKILL.md +60 -18
  33. package/plugins/kk/skills/web-automation/SKILL.md +100 -0
  34. package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
  35. package/plugins/kk/workflows/kk-collect.yaml +258 -0
  36. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +74 -11
  37. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +655 -0
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +170 -0
  39. package/plugins/publishings/emdash-pub/dist/index.js +10 -9
  40. package/plugins/publishings/infoq-pub/dist/index.js +8695 -211
  41. package/plugins/publishings/infoq-pub/package.json +3 -1
  42. package/plugins/publishings/infoq-pub/plugin.json +2 -1
  43. package/plugins/publishings/infoq-pub/src/index.ts +358 -29
  44. package/plugins/publishings/infoq-pub/src/profile-check.ts +21 -0
  45. package/plugins/publishings/juejin-pub/dist/index.js +8746 -211
  46. package/plugins/publishings/juejin-pub/package.json +3 -1
  47. package/plugins/publishings/juejin-pub/plugin.json +2 -1
  48. package/plugins/publishings/juejin-pub/src/index.ts +345 -29
  49. package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
  50. package/plugins/publishings/medium-pub/dist/index.js +10 -9
  51. package/plugins/publishings/podcast-pub/dist/index.js +10 -9
  52. package/plugins/publishings/qiita-pub/dist/index.js +10 -9
  53. package/plugins/publishings/substack-pub/dist/index.js +8687 -211
  54. package/plugins/publishings/substack-pub/package.json +3 -1
  55. package/plugins/publishings/substack-pub/plugin.json +2 -1
  56. package/plugins/publishings/substack-pub/src/index.ts +302 -29
  57. package/plugins/publishings/substack-pub/src/profile-check.ts +76 -0
  58. package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +1315 -352
  60. package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
  61. package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
  62. package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
  63. package/plugins/publishings/wechatmp-pub/src/index.ts +223 -520
  64. package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
  65. package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
  66. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -38
  67. package/plugins/publishings/x-pub/dist/index.js +8707 -223
  68. package/plugins/publishings/x-pub/package.json +3 -1
  69. package/plugins/publishings/x-pub/plugin.json +2 -1
  70. package/plugins/publishings/x-pub/src/index.ts +315 -23
  71. package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
  72. package/plugins/publishings/xhs-pub/dist/index.js +897 -354
  73. package/plugins/publishings/xhs-pub/plugin.json +2 -1
  74. package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
  75. package/plugins/publishings/xhs-pub/src/index.ts +108 -596
  76. package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
  77. package/plugins/publishings/xhs-pub/src/page.ts +254 -0
  78. package/plugins/publishings/xhs-pub/src/profile-check.ts +29 -17
  79. package/plugins/publishings/zenn-pub/dist/index.js +10 -9
@@ -7,9 +7,11 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/ts-browser-automation": "catalog:",
10
11
  "@gobing-ai/publish-harness": "workspace:*",
11
12
  "@gobing-ai/ts-runtime": "catalog:",
12
- "@gobing-ai/ts-utils": "catalog:"
13
+ "@gobing-ai/ts-utils": "catalog:",
14
+ "playwright": "catalog:"
13
15
  },
14
16
  "devDependencies": {
15
17
  "@types/bun": "1.3.14"
@@ -2,6 +2,7 @@
2
2
  "name": "infoq-pub",
3
3
  "kind": "publish",
4
4
  "entry": "./dist/index.js",
5
+ "check": "./src/profile-check.ts",
5
6
  "version": "1.0.0",
6
- "description": "Publish plugin for InfoQ CN (xie.infoq.cn) via browser/agent transport"
7
+ "description": "Publish plugin for InfoQ CN (xie.infoq.cn): article drafts through the InfoQ editor via script browser transport (ts-browser-automation)"
7
8
  }
@@ -6,63 +6,392 @@ import {
6
6
  FakeBrowserTransport,
7
7
  type PublishTransport,
8
8
  type PublishTransportPayload,
9
- resolveBrowserProfileDir,
9
+ trySelectors,
10
10
  } from '@gobing-ai/publish-harness';
11
+ import {
12
+ type ChannelBrowserConfig,
13
+ checkChannelSession,
14
+ DEFAULT_SIGNAL_CONTAINERS,
15
+ expectOutcome,
16
+ kkBrowserLauncher,
17
+ markdownToHtml,
18
+ pasteHtml,
19
+ readScopedSignal,
20
+ resolveChannelProfileDir,
21
+ waitUntil,
22
+ } from '@gobing-ai/publish-harness/browser';
23
+ import {
24
+ type BrowserLauncher,
25
+ BrowserProfileMissingError,
26
+ type IsAuthenticatedPredicate,
27
+ LoginTimeoutError,
28
+ withProfilePage,
29
+ } from '@gobing-ai/ts-browser-automation';
11
30
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
12
31
  import { echoError } from '@gobing-ai/ts-utils';
32
+ import type { Page } from 'playwright';
13
33
 
14
- const CHANNEL = 'infoq';
34
+ /** Channel slug: names the Result target and resolves the `INFOQ_PROFILE_DIR` tier. */
35
+ export const CHANNEL = 'infoq';
15
36
  const TARGET = 'infoq';
37
+ /**
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.
42
+ */
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)/;
53
+
54
+ /**
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.
61
+ */
62
+ export const SELECTORS = {
63
+ writeEntry: ['div.write-btn'],
64
+ noticeDismiss: ['text=知道了'],
65
+ title: ['input[placeholder="请输入标题"]', 'input[placeholder*="标题"]'],
66
+ body: ['.ProseMirror[contenteditable="true"]', '[contenteditable="true"]'],
67
+ live: ['div.submit-btn'],
68
+ } as const;
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
+ */
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;
84
+ const LOGIN_TIMEOUT_MS = 180_000;
85
+ /** Operator recovery command every session failure names — never a cookie, page HTML or stack. */
86
+ const LOGIN_INSTRUCTION = 'bun plugins/publishings/infoq-pub/src/profile-check.ts login';
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]'];
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
+ })()`;
116
+
117
+ /**
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).
122
+ */
123
+ export const isAuthenticated: IsAuthenticatedPredicate = async (page) =>
124
+ !LOGIN_URL_RE.test(page.url()) &&
125
+ (await trySelectors(page, SELECTORS.writeEntry, { visible: false })).found &&
126
+ !(await page.evaluate(LOGGED_OUT_MARKER_SCRIPT));
127
+
128
+ /** Site-owned config for the shared session check and operator CLI (0183). */
129
+ export const CONFIG: ChannelBrowserConfig = {
130
+ channel: CHANNEL,
131
+ homeUrl: HOME_URL,
132
+ isAuthenticated,
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,
137
+ };
16
138
 
17
139
  function fail(error: string): Result {
18
140
  return ResultSchema.parse({ ok: false, target: TARGET, error });
19
141
  }
20
142
 
21
- function draftId(content: Content): string {
22
- const title = content.title ?? 'untitled';
23
- return `${CHANNEL}-draft-${title.length}`;
143
+ function errorMessage(error: unknown): string {
144
+ return error instanceof Error ? error.message : String(error);
24
145
  }
25
146
 
26
- function draftResult(content: Content, driver: BrowserDriver, profileDir: string): Result {
27
- return ResultSchema.parse({
28
- ok: true,
29
- target: TARGET,
30
- id: draftId(content),
31
- message: `${TARGET} draft prepared (unpublished)`,
32
- metadata: { driver, publishStatus: 'draft', profileDir },
33
- });
147
+ /** Session failures whose one-liner must name the recovery command (R5). */
148
+ function needsLogin(error: unknown): boolean {
149
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
150
+ }
151
+
152
+ /**
153
+ * First candidate selector with an attached match, polled to the deadline. Attachment — not
154
+ * visibility — is the readiness signal here: InfoQ keeps the editor's inputs attached behind a
155
+ * collapsing layout, and the shared `trySelectors` filters on visibility by default.
156
+ */
157
+ async function findSelector(
158
+ page: Page,
159
+ selectors: readonly string[],
160
+ what: string,
161
+ timeoutMs: number,
162
+ ): Promise<string> {
163
+ const found: { selector: string | null } = { selector: null };
164
+ const attached = await waitUntil(async () => {
165
+ const result = await trySelectors(page, selectors, { visible: false });
166
+ found.selector = result.selector;
167
+ return result.found && result.selector !== null;
168
+ }, timeoutMs);
169
+ const selector = found.selector;
170
+ if (!attached || selector === null) {
171
+ throw new Error(`${what}: ${selectors.join(' | ')} not attached within ${timeoutMs}ms`);
172
+ }
173
+ return selector;
174
+ }
175
+
176
+ /**
177
+ * Text of the first visible toast among `texts`, or null. `:text(…)` matches on a substring, so
178
+ * the element's own text is returned rather than the candidate literal.
179
+ */
180
+ /**
181
+ * The draft/live outcome, read only from the page's notification containers (residual 0195): an
182
+ * unscoped `:text(...)` match also hits the article body this flow pasted a few steps earlier, so
183
+ * a body containing `保存成功` reported a draft that was never saved.
184
+ */
185
+ async function readToast(page: Page, texts: readonly string[]): Promise<string | null> {
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 };
34
209
  }
35
210
 
36
211
  export class InfoqBrowserTransport implements PublishTransport {
37
212
  public readonly kind = 'browser';
38
213
  public readonly driver: BrowserDriver;
39
- private readonly loginTimeoutMs: number;
40
- private readonly sessionReady: boolean;
214
+ private readonly launcher: BrowserLauncher;
215
+ private readonly stepTimeoutMs: number;
216
+ private readonly checkTimeoutMs: number;
41
217
 
42
- constructor(driver: BrowserDriver = 'agent', opts?: { loginTimeoutMs?: number; sessionReady?: boolean }) {
218
+ constructor(
219
+ driver: BrowserDriver = 'script',
220
+ deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number; checkTimeoutMs?: number } = {},
221
+ ) {
43
222
  this.driver = driver;
44
- this.loginTimeoutMs = opts?.loginTimeoutMs ?? 120_000;
45
- this.sessionReady = opts?.sessionReady ?? false;
223
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
224
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
225
+ this.checkTimeoutMs = deps.checkTimeoutMs ?? CHECK_TIMEOUT_MS;
46
226
  }
47
227
 
48
228
  public async publish(payload: PublishTransportPayload): Promise<Result> {
49
- const override =
50
- typeof payload.options?.profileDir === 'string'
51
- ? payload.options.profileDir
52
- : (process.env.INFOQ_PROFILE_DIR ?? process.env.KK_BROWSER_PROFILE_DIR);
53
- const profileDir = resolveBrowserProfileDir(CHANNEL, override);
54
- if (!this.sessionReady) {
55
- return fail(
56
- `login timeout after ${this.loginTimeoutMs}ms: no authenticated ${CHANNEL} session in ${profileDir}`,
229
+ if (this.driver !== 'script') {
230
+ return fail(`unsupported driver '${this.driver}' for ${TARGET}`);
231
+ }
232
+ const mode = resolveOperation(payload.options, payload.content);
233
+ if (mode.error !== undefined) return fail(mode.error);
234
+ const requested = payload.content.metadata?.publishStatus;
235
+ if (requested !== undefined && requested !== 'draft' && requested !== 'public') {
236
+ return fail(`unknown publishStatus '${String(requested)}' (expected absent, 'draft' or 'public')`);
237
+ }
238
+ const publishStatus = requested === 'public' ? 'public' : 'draft';
239
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
240
+ const profileDir = resolveChannelProfileDir(CHANNEL, override);
241
+
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.
244
+ const session = await checkChannelSession(CONFIG, {
245
+ profileDir,
246
+ launcher: this.launcher,
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,
253
+ });
254
+ if (session.status !== 'verified') {
255
+ return fail(`${CHANNEL} session preflight refused: ${session.detail} — run: ${LOGIN_INSTRUCTION}`);
256
+ }
257
+
258
+ try {
259
+ return await withProfilePage(
260
+ {
261
+ profileDir,
262
+ url: HOME_URL,
263
+ isAuthenticated,
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.
267
+ headless: payload.options?.headless === true,
268
+ launcher: this.launcher,
269
+ // The session is already preflight-verified, so the post-goto confirmation is
270
+ // a bounded step like every other one, not the 180s interactive login wait.
271
+ timeoutMs: this.stepTimeoutMs,
272
+ },
273
+ async ({ page }) => this.runEditorFlow(page, payload.content, publishStatus, profileDir),
57
274
  );
275
+ } catch (error) {
276
+ const hint = needsLogin(error) ? ` — run: ${LOGIN_INSTRUCTION}` : '';
277
+ return fail(`${errorMessage(error)}${hint}`);
58
278
  }
59
- return draftResult(payload.content, this.driver, profileDir);
60
279
  }
280
+
281
+ /**
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).
288
+ */
289
+ private async runEditorFlow(
290
+ page: Page,
291
+ content: Content,
292
+ publishStatus: 'draft' | 'public',
293
+ profileDir: string,
294
+ ): Promise<Result> {
295
+ const metadata = { driver: this.driver, publishStatus, profileDir };
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
+
309
+ const titleSelector = await findSelector(page, SELECTORS.title, 'title input', this.stepTimeoutMs);
310
+ await page
311
+ .locator(titleSelector)
312
+ .first()
313
+ .fill(content.title ?? '');
314
+
315
+ // No summary step: the measured console has no summary field (see SELECTORS).
316
+
317
+ const bodySelector = await findSelector(page, SELECTORS.body, 'body editor', this.stepTimeoutMs);
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(() => {});
329
+
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), {
333
+ timeoutMs: this.stepTimeoutMs,
334
+ label: 'infoq draft autosave',
335
+ });
336
+ if (!draft.ok) return fail(draft.error);
337
+
338
+ if (publishStatus === 'draft') {
339
+ return ResultSchema.parse({
340
+ ok: true,
341
+ target: TARGET,
342
+ ...(id === undefined ? {} : { id }),
343
+ url: page.url(),
344
+ message: `${TARGET} draft saved (unpublished) in profile ${profileDir}`,
345
+ metadata,
346
+ });
347
+ }
348
+
349
+ const liveSelector = await findSelector(page, SELECTORS.live, 'submit control', this.stepTimeoutMs);
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.
352
+ const urlBeforeSubmit = page.url();
353
+ await page.locator(liveSelector).first().click();
354
+ const live = await expectOutcome(() => probeLive(page, urlBeforeSubmit), {
355
+ timeoutMs: this.stepTimeoutMs,
356
+ label: `infoq submit '${liveSelector}'`,
357
+ });
358
+ if (!live.ok) return fail(live.error);
359
+
360
+ return ResultSchema.parse({
361
+ ok: true,
362
+ target: TARGET,
363
+ ...(id === undefined ? {} : { id }),
364
+ url: page.url(),
365
+ message: `${TARGET} submitted for InfoQ editorial review`,
366
+ metadata,
367
+ });
368
+ }
369
+ }
370
+
371
+ /**
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.
374
+ */
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
+ }
382
+ }
383
+
384
+ /** Live evidence: a visible submit toast, or a URL that moved off the URL the draft run left. */
385
+ async function probeLive(page: Page, urlBeforeSave: string): Promise<string | null> {
386
+ const toast = await readToast(page, LIVE_TOASTS);
387
+ if (toast !== null) return toast;
388
+ const url = page.url();
389
+ return url === urlBeforeSave ? null : url;
61
390
  }
62
391
 
63
- export function getPublishTransport(driver: BrowserDriver = 'agent'): PublishTransport {
392
+ export function getPublishTransport(driver: BrowserDriver = 'script'): PublishTransport {
64
393
  if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') {
65
- return new FakeBrowserTransport(driver);
394
+ return new FakeBrowserTransport('script');
66
395
  }
67
396
  return new InfoqBrowserTransport(driver);
68
397
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * InfoQ profile-session capability (R5, AC3).
3
+ *
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.
8
+ * Arg parsing, profile precedence (`--profile` > `INFOQ_PROFILE_DIR` > `KK_BROWSER_PROFILE_DIR` >
9
+ * `~/.config/kk/browser-profiles/infoq`) and the exit codes (0 verified, 1 refused, 2 usage
10
+ * error) all live in the shared `runProfileCli` (0183), so the publish preflight and this CLI
11
+ * apply the same gate.
12
+ *
13
+ * Manual dogfood (plan step 6): `bun plugins/publishings/infoq-pub/src/profile-check.ts login`
14
+ * then `… check` on the same profile.
15
+ */
16
+ import { runProfileCli } from '@gobing-ai/publish-harness/browser';
17
+ import { CONFIG } from './index';
18
+
19
+ if (import.meta.main) {
20
+ process.exit(await runProfileCli(CONFIG, Bun.argv.slice(2)));
21
+ }