@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": "x-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 X: post via official tweets API; article via browser/agent"
7
+ "description": "post via official tweets API; article via script browser transport (ts-browser-automation)"
7
8
  }
@@ -1,5 +1,5 @@
1
1
  import { createHmac, randomBytes } from 'node:crypto';
2
- import { dirname } from 'node:path';
2
+ import { dirname, extname, isAbsolute } from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
5
5
  import {
@@ -8,10 +8,29 @@ import {
8
8
  FakeFileCliTransport,
9
9
  type PublishTransport,
10
10
  type PublishTransportPayload,
11
- resolveBrowserProfileDir,
11
+ trySelectors,
12
12
  } from '@gobing-ai/publish-harness';
13
+ import {
14
+ type ChannelBrowserConfig,
15
+ checkChannelSession,
16
+ expectOutcome,
17
+ kkBrowserLauncher,
18
+ markdownToHtml,
19
+ pasteHtml,
20
+ resolveChannelProfileDir,
21
+ resolveSelector,
22
+ uploadFile,
23
+ } from '@gobing-ai/publish-harness/browser';
24
+ import {
25
+ type BrowserLauncher,
26
+ BrowserProfileMissingError,
27
+ type IsAuthenticatedPredicate,
28
+ LoginTimeoutError,
29
+ withProfilePage,
30
+ } from '@gobing-ai/ts-browser-automation';
13
31
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
14
32
  import { echoError } from '@gobing-ai/ts-utils';
33
+ import type { Page } from 'playwright';
15
34
 
16
35
  /** Per-request timeout (ms) for the X API call — Bun's `fetch` has no default deadline (R5). */
17
36
  const REQUEST_TIMEOUT_MS = 120_000;
@@ -63,9 +82,23 @@ export function oauth1Header(opts: {
63
82
  return `OAuth ${header}`;
64
83
  }
65
84
 
66
- function resolveOperation(options?: Record<string, unknown>, content?: Content): 'post' | 'article' {
85
+ /**
86
+ * Declared modes (ADR-029 dispatch): `post` (API v2 transport) and `article` (browser composer).
87
+ * An unknown operation is refused by name before any transport — and therefore before any
88
+ * browser — is resolved (task 0205 R2).
89
+ */
90
+ export const X_OPERATIONS = ['post', 'article'] as const;
91
+ export type XOperation = (typeof X_OPERATIONS)[number];
92
+
93
+ export function resolveOperation(
94
+ options?: Record<string, unknown>,
95
+ content?: Content,
96
+ ): { operation: XOperation; error?: undefined } | { operation?: undefined; error: string } {
67
97
  const raw = options?.operation ?? content?.metadata?.operation ?? 'post';
68
- return raw === 'article' ? 'article' : 'post';
98
+ if (typeof raw !== 'string' || !(X_OPERATIONS as readonly string[]).includes(raw)) {
99
+ return { error: `unsupported operation '${String(raw)}' for x: expected one of ${X_OPERATIONS.join(', ')}` };
100
+ }
101
+ return { operation: raw as XOperation };
69
102
  }
70
103
 
71
104
  export class XPostApiTransport implements PublishTransport {
@@ -127,43 +160,297 @@ export class XPostApiTransport implements PublishTransport {
127
160
  }
128
161
  }
129
162
 
163
+ /** Channel slug for the article browser session: resolves the `X_PROFILE_DIR` tier (unchanged). */
164
+ export const ARTICLE_CHANNEL = 'x';
165
+ /** X's Articles composer; the preflight and the editor visit both open it. */
166
+ export const X_ARTICLES_URL = 'https://x.com/compose/articles';
167
+ /** The draft signal: the editor URL X renders once the article exists — the only draft evidence. */
168
+ export const ARTICLE_EDIT_RE = /\/compose\/articles\/edit\/(\d+)/;
169
+ /** Login/redirect URLs that mean "not authenticated" — the composer never renders behind them. */
170
+ export const LOGIN_URL_RE = /(\/i\/flow\/login|\/login)/;
171
+
172
+ /**
173
+ * Selectors seeded from the wt script (`x-article-playwright.ts:143-187`); each list is tried in
174
+ * order. Measured live 2026-10-07 (0194): `write` candidate 1 and body candidate 1 hit; the title
175
+ * is a two-step interaction — the `twitter-article-title` div is a collapsed display that mounts
176
+ * the real `textarea[placeholder="Add a title"]` on click (handled in `runArticleFlow`); the
177
+ * paste into the Draft.js container and the `/compose/articles/edit/<id>` signal were both
178
+ * verified. `addPhotos` (`button[aria-label*="Add photos" i]`, "Add photos or video") is present
179
+ * but is NOT clicked in the flow: opening the picker detaches the fileInput's handler and swallows
180
+ * the set file (see the cover leg in `runArticleFlow`). The seed's `tweetButton`/`Tweet` candidates are dropped: they belong to the tweet
181
+ * composer, and clicking one would post a tweet.
182
+ */
183
+ export const ARTICLE_SELECTORS = {
184
+ write: ['[data-testid="empty_state_button_text"]', 'button:has-text("Write")', '[aria-label*="Write" i]'],
185
+ title: ['textarea[placeholder="Add a title"]', '[data-testid="twitter-article-title"]'],
186
+ body: ['.DraftEditor-editorContainer [contenteditable="true"]', 'div[role="textbox"]'],
187
+ addPhotos: ['[data-testid="addPhotoButton"]', 'button[aria-label*="Add photos" i]'],
188
+ fileInput: ['[data-testid="fileInput"]', 'input[type="file"][accept*="image"]'],
189
+ publish: ['[data-testid="publishButton"]', 'button:has-text("Publish")'],
190
+ } as const;
191
+
192
+ const STEP_TIMEOUT_MS = 30_000;
193
+ const LOGIN_TIMEOUT_MS = 180_000;
194
+ /** Operator recovery command every session failure names — never a cookie, token, page HTML or stack. */
195
+ const LOGIN_INSTRUCTION = 'bun plugins/publishings/x-pub/src/profile-check.ts login';
196
+ /** A live article lands on a URL carrying the article id; the composer URL is `/edit/`. */
197
+ const LIVE_URL_ID_RE = /\/articles?\/(\d+)/;
198
+ /** Cover extensions accepted from `options.coverPath` / `metadata.coverImage` (R2). */
199
+ const COVER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
200
+
201
+ /**
202
+ * X-owned readiness predicate: no login redirect and the article composer reachable (the write
203
+ * control or the title field attached). Shared by the publish preflight and the profile CLI (R1, R5).
204
+ */
205
+ export const isArticleAuthenticated: IsAuthenticatedPredicate = async (page) => {
206
+ if (LOGIN_URL_RE.test(page.url())) return false;
207
+ if ((await trySelectors(page, ARTICLE_SELECTORS.write, { visible: false })).found) return true;
208
+ return (await trySelectors(page, ARTICLE_SELECTORS.title, { visible: false })).found;
209
+ };
210
+
211
+ /** Site-owned config for the shared session check and operator CLI (0183). */
212
+ export const ARTICLE_CONFIG: ChannelBrowserConfig = {
213
+ channel: ARTICLE_CHANNEL,
214
+ homeUrl: X_ARTICLES_URL,
215
+ isAuthenticated: isArticleAuthenticated,
216
+ loginTimeoutMs: LOGIN_TIMEOUT_MS,
217
+ // X login-walls headless article loads (measured 0216: the headless pass refuses even on a live
218
+ // profile), so the default headless-first + headed-fallback spends two deadlines per check and
219
+ // blows kk's 30s probe-kill (spawn SIGTERM → error instead of refused). One headed pass, like
220
+ // infoq's WAF, keeps the probe inside the budget.
221
+ checkHeadedOnly: true,
222
+ };
223
+
224
+ function errorMessage(error: unknown): string {
225
+ return error instanceof Error ? error.message : String(error);
226
+ }
227
+
228
+ /** Session failures whose one-liner must name the recovery command (R5). */
229
+ function needsLogin(error: unknown): boolean {
230
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
231
+ }
232
+
233
+ /**
234
+ * Resolves and validates the optional cover (R2): `options.coverPath` wins over
235
+ * `metadata.coverImage`, and a given cover must be an absolute path to an existing
236
+ * `.png|.jpg|.jpeg|.webp` file. Returns the failure message otherwise, so a bad cover is refused
237
+ * **before** any browser opens.
238
+ */
239
+ async function resolveCover(
240
+ options: Record<string, unknown> | undefined,
241
+ content: Content,
242
+ ): Promise<{ path: string } | { error: string } | undefined> {
243
+ const raw = options?.coverPath ?? content.metadata?.coverImage;
244
+ if (raw === undefined) return undefined;
245
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
246
+ return { error: `cover path must be a non-empty local file path, got '${String(raw)}'` };
247
+ }
248
+ if (!isAbsolute(raw)) {
249
+ return { error: `cover path must be an absolute path, got '${raw}'` };
250
+ }
251
+ if (!COVER_EXTS.has(extname(raw).toLowerCase())) {
252
+ return { error: `cover path must be a .png, .jpg, .jpeg or .webp file, got '${raw}'` };
253
+ }
254
+ if (!(await createNodeFileSystem().exists(raw))) {
255
+ return { error: `cover file does not exist: '${raw}'` };
256
+ }
257
+ return { path: raw };
258
+ }
259
+
130
260
  export class XArticleBrowserTransport implements PublishTransport {
131
261
  public readonly kind = 'browser';
132
262
  public readonly driver: BrowserDriver;
133
- private readonly loginTimeoutMs: number;
134
- private readonly sessionReady: boolean;
263
+ private readonly launcher: BrowserLauncher;
264
+ private readonly stepTimeoutMs: number;
135
265
 
136
- constructor(driver: BrowserDriver = 'agent', opts?: { loginTimeoutMs?: number; sessionReady?: boolean }) {
266
+ constructor(driver: BrowserDriver = 'script', deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {}) {
137
267
  this.driver = driver;
138
- this.loginTimeoutMs = opts?.loginTimeoutMs ?? 120_000;
139
- this.sessionReady = opts?.sessionReady ?? false;
268
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
269
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
140
270
  }
141
271
 
142
272
  public async publish(payload: PublishTransportPayload): Promise<Result> {
143
- const profileDir = resolveBrowserProfileDir(
144
- 'x',
145
- typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : process.env.X_PROFILE_DIR,
146
- );
147
- if (!this.sessionReady && this.loginTimeoutMs <= 0) {
148
- return fail(`login timeout: no authenticated X article session in ${profileDir}`);
273
+ if (this.driver !== 'script') {
274
+ return fail(`unsupported driver '${this.driver}' for x article`);
275
+ }
276
+ const requested = payload.content.metadata?.publishStatus;
277
+ if (requested !== undefined && requested !== 'draft' && requested !== 'public') {
278
+ return fail(`unknown publishStatus '${String(requested)}' (expected absent, 'draft' or 'public')`);
279
+ }
280
+ // Pre-validation, before any browser opens: a given cover must be a usable local image (R2),
281
+ // so a bad path is a fast refusal rather than a half-built draft.
282
+ const cover = await resolveCover(payload.options, payload.content);
283
+ if (cover !== undefined && 'error' in cover) return fail(cover.error);
284
+
285
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
286
+ const profileDir = resolveChannelProfileDir(ARTICLE_CHANNEL, override);
287
+
288
+ // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed
289
+ // visit, so the read-only headless check runs first and a refusal stays a fast refusal.
290
+ const session = await checkChannelSession(ARTICLE_CONFIG, {
291
+ profileDir,
292
+ launcher: this.launcher,
293
+ headedFallback: false,
294
+ timeoutMs: this.stepTimeoutMs,
295
+ });
296
+ if (session.status !== 'verified') {
297
+ return fail(`${TARGET} session preflight refused: ${session.detail} — run: ${LOGIN_INSTRUCTION}`);
298
+ }
299
+
300
+ const publishStatus = requested === 'public' ? 'public' : 'draft';
301
+ try {
302
+ return await withProfilePage(
303
+ {
304
+ profileDir,
305
+ url: X_ARTICLES_URL,
306
+ isAuthenticated: isArticleAuthenticated,
307
+ // Headed by default; the headless preflight above is the unattended pass,
308
+ // `options.headless === true` the opt-in.
309
+ headless: payload.options?.headless === true,
310
+ launcher: this.launcher,
311
+ // The session is already preflight-verified, so the post-goto confirmation is
312
+ // a bounded step like every other one, not the 180s interactive login wait.
313
+ timeoutMs: this.stepTimeoutMs,
314
+ },
315
+ async ({ page }) => this.runArticleFlow(page, payload.content, publishStatus, cover?.path, profileDir),
316
+ );
317
+ } catch (error) {
318
+ const hint = needsLogin(error) ? ` — run: ${LOGIN_INSTRUCTION}` : '';
319
+ return fail(`${errorMessage(error)}${hint}`);
320
+ }
321
+ }
322
+
323
+ /**
324
+ * The site-owned composer flow: write → title → Draft.js HTML paste → optional cover → the
325
+ * draft signal, then — for a live run only — the publish control and the live signal. Success
326
+ * only from an `expectOutcome` probe (E91-D6): a wait without an observed signal is never a
327
+ * success, and a draft run never clicks the publish control (E91-D5).
328
+ */
329
+ private async runArticleFlow(
330
+ page: Page,
331
+ content: Content,
332
+ publishStatus: 'draft' | 'public',
333
+ coverPath: string | undefined,
334
+ profileDir: string,
335
+ ): Promise<Result> {
336
+ const metadata = { operation: 'article', driver: this.driver, publishStatus, profileDir };
337
+
338
+ const writeSelector = await resolveSelector(page, ARTICLE_SELECTORS.write, {
339
+ timeoutMs: this.stepTimeoutMs,
340
+ label: 'write control',
341
+ });
342
+ await page.locator(writeSelector).first().click();
343
+
344
+ // X collapses the title into a display div (`[data-testid="twitter-article-title"]`, text
345
+ // `(Needs title)`); the real `textarea[placeholder="Add a title"]` is only mounted after
346
+ // that div is clicked (measured live 2026-10-07, 0194). So: fill the textarea when it is
347
+ // already attached, otherwise click the display div and fill the textarea it mounts.
348
+ const titleInput = ARTICLE_SELECTORS.title[0];
349
+ if ((await page.locator(titleInput).first().count()) === 0) {
350
+ await page.locator(ARTICLE_SELECTORS.title[1]).first().click({ timeout: this.stepTimeoutMs });
149
351
  }
150
- if (!this.sessionReady) {
151
- return fail(`login timeout: no authenticated X article session in ${profileDir}`);
352
+ const titleSelector = await resolveSelector(page, [titleInput], {
353
+ timeoutMs: this.stepTimeoutMs,
354
+ label: 'title input',
355
+ });
356
+ await page
357
+ .locator(titleSelector)
358
+ .first()
359
+ .fill(content.title ?? 'Untitled');
360
+
361
+ const bodySelector = await resolveSelector(page, ARTICLE_SELECTORS.body, {
362
+ timeoutMs: this.stepTimeoutMs,
363
+ label: 'body editor',
364
+ });
365
+ // X's article editor is Draft.js rich text: the body goes in as rendered HTML through the
366
+ // shared paste, never as plain markdown text (E91-D4).
367
+ await pasteHtml(page, bodySelector, markdownToHtml(content.body), { timeoutMs: this.stepTimeoutMs });
368
+
369
+ if (coverPath !== undefined) {
370
+ // The base `fileInput` handles a set file directly: X itself opens an "Edit media" crop
371
+ // dialog, and the cover attaches only when that dialog is confirmed with Apply.
372
+ // Clicking the add-photos picker first detaches that handler — a file set while the
373
+ // picker is open uploads nothing (measured live 2026-10-07, 0194). So: set the file,
374
+ // click Apply, and require the cover image to render (E91-D6: observed outcome only).
375
+ const fileInputSelector = await resolveSelector(page, ARTICLE_SELECTORS.fileInput, {
376
+ timeoutMs: this.stepTimeoutMs,
377
+ label: 'cover file input',
378
+ });
379
+ await uploadFile(page, fileInputSelector, coverPath, { timeoutMs: this.stepTimeoutMs });
380
+ // The crop dialog renders two identical "Edit media" layers; click the visible Apply.
381
+ await page
382
+ .locator('[role="dialog"] button:has-text("Apply") >> visible=true')
383
+ .first()
384
+ .click({ timeout: this.stepTimeoutMs });
385
+ const attached = await expectOutcome(
386
+ async () =>
387
+ (await page.evaluate(coverAttachedScript())) === true ? 'cover image rendered in composer' : null,
388
+ { timeoutMs: this.stepTimeoutMs, label: 'cover attachment' },
389
+ );
390
+ if (!attached.ok) return fail(attached.error);
391
+ }
392
+
393
+ const draft = await expectOutcome(() => probeDraft(page), {
394
+ timeoutMs: this.stepTimeoutMs,
395
+ label: 'x article draft',
396
+ });
397
+ if (!draft.ok) return fail(draft.error);
398
+
399
+ // The probe only ever returns a `/compose/articles/edit/<id>` URL, so this always resolves.
400
+ const id = ARTICLE_EDIT_RE.exec(draft.evidence)?.[1];
401
+ if (publishStatus === 'draft') {
402
+ return ResultSchema.parse({
403
+ ok: true,
404
+ target: TARGET,
405
+ ...(id === undefined ? {} : { id }),
406
+ url: draft.evidence,
407
+ message: `${TARGET} article draft saved (unpublished) in profile ${profileDir}`,
408
+ metadata,
409
+ });
152
410
  }
153
- const title = payload.content.title ?? 'Untitled';
411
+
412
+ const publishSelector = await resolveSelector(page, ARTICLE_SELECTORS.publish, {
413
+ timeoutMs: this.stepTimeoutMs,
414
+ label: 'publish control',
415
+ });
416
+ await page.locator(publishSelector).first().click();
417
+ const live = await expectOutcome(() => probeLive(page), {
418
+ timeoutMs: this.stepTimeoutMs,
419
+ label: `x article publish '${publishSelector}'`,
420
+ });
421
+ if (!live.ok) return fail(live.error);
422
+
154
423
  return ResultSchema.parse({
155
424
  ok: true,
156
425
  target: TARGET,
157
- id: `article-draft-${title.length}`,
158
- message: 'X article draft prepared (unpublished)',
159
- metadata: { operation: 'article', driver: this.driver, publishStatus: 'draft', profileDir },
426
+ ...(id === undefined ? {} : { id }),
427
+ url: live.evidence,
428
+ message: `${TARGET} article published live in profile ${profileDir}`,
429
+ metadata,
160
430
  });
161
431
  }
162
432
  }
163
433
 
434
+ /** Draft evidence is URL-only by design: X autosaves the composer onto `/edit/<id>`. */
435
+ async function probeDraft(page: Page): Promise<string | null> {
436
+ const url = page.url();
437
+ return ARTICLE_EDIT_RE.test(url) ? url : null;
438
+ }
439
+
440
+ /** DOM probe script: true once a non-avatar twimg image — the just-attached cover — is rendered. */
441
+ function coverAttachedScript(): string {
442
+ return `[...document.querySelectorAll('img[src*="twimg"]')].some((img) => !/profile_images/.test(img.src ?? ''))`;
443
+ }
444
+
445
+ /** Live evidence is URL-only too: a published article URL that left the `/edit/` composer. */
446
+ async function probeLive(page: Page): Promise<string | null> {
447
+ const url = page.url();
448
+ return !ARTICLE_EDIT_RE.test(url) && LIVE_URL_ID_RE.test(url) ? url : null;
449
+ }
450
+
164
451
  export function getPublishTransport(operation: 'post' | 'article' = 'post'): PublishTransport {
165
452
  if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') {
166
- return operation === 'article' ? new FakeBrowserTransport('agent') : new FakeFileCliTransport();
453
+ return operation === 'article' ? new FakeBrowserTransport('script') : new FakeFileCliTransport();
167
454
  }
168
455
  return operation === 'article' ? new XArticleBrowserTransport() : new XPostApiTransport();
169
456
  }
@@ -193,7 +480,12 @@ export async function processPublishIO(
193
480
  await writePublishResult(outputPath, fail(message));
194
481
  throw new Error(message);
195
482
  }
196
- const operation = resolveOperation(options, content);
483
+ const mode = resolveOperation(options, content);
484
+ if (mode.error !== undefined) {
485
+ await writePublishResult(outputPath, fail(mode.error));
486
+ throw new Error(mode.error);
487
+ }
488
+ const operation = mode.operation;
197
489
  const resolved = transport ?? getPublishTransport(operation);
198
490
  const result = ResultSchema.parse(await resolved.publish({ content, options }));
199
491
  await writePublishResult(outputPath, result);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * X article profile-session capability (R5, AC3).
3
+ *
4
+ * `login` opens the Articles composer in a headed browser and resolves only once X's readiness
5
+ * predicate passes; `check` reopens the profile headlessly and reports `verified` or `refused`.
6
+ * Arg parsing, profile precedence (`--profile` > `X_PROFILE_DIR` > `KK_BROWSER_PROFILE_DIR` >
7
+ * `~/.config/kk/browser-profiles/x`) and the exit codes (0 verified, 1 refused, 2 usage error) all
8
+ * live in the shared `runProfileCli` (0183), so the publish preflight and this CLI apply the same
9
+ * gate.
10
+ *
11
+ * Operator dogfood (plan step 6, handed to 0194): run `profile-check.ts login`, then `check`, on
12
+ * the same profile, and re-measure the selectors and the edit-URL pattern.
13
+ */
14
+ import { runProfileCli } from '@gobing-ai/publish-harness/browser';
15
+ import { ARTICLE_CONFIG } from './index';
16
+
17
+ if (import.meta.main) {
18
+ process.exit(await runProfileCli(ARTICLE_CONFIG, Bun.argv.slice(2)));
19
+ }