@gobing-ai/knowledge-kit 0.0.30 → 0.0.32

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 (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +10 -10
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +9 -9
  5. package/plugins/generations/core-facts-gen/dist/index.js +12 -12
  6. package/plugins/generations/daily-article-gen/dist/index.js +12 -12
  7. package/plugins/generations/dailynews-gen/dist/index.js +10 -10
  8. package/plugins/generations/dailynews-gen/src/script-builder.ts +4 -2
  9. package/plugins/generations/episode-plan-gen/dist/index.js +23 -13
  10. package/plugins/generations/episode-plan-gen/src/index.ts +23 -2
  11. package/plugins/generations/gemini-tts/dist/index.js +9 -9
  12. package/plugins/generations/image-gen/dist/index.js +9 -9
  13. package/plugins/generations/news-report-gen/dist/index.js +12 -12
  14. package/plugins/generations/voice-gen/dist/index.js +9 -9
  15. package/plugins/ingestions/aihot-ingest/dist/index.js +12 -12
  16. package/plugins/ingestions/horizon-ingest/dist/index.js +12 -12
  17. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  18. package/plugins/ingestions/last30days-ingest/dist/index.js +12 -12
  19. package/plugins/ingestions/web-search/dist/index.js +10 -10
  20. package/plugins/kk/plugin.json +1 -1
  21. package/plugins/kk/scripts/kk-workflow-stages.ts +173 -15
  22. package/plugins/kk/skills/content-judge/SKILL.md +16 -4
  23. package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
  24. package/plugins/kk/skills/content-judge/references/workflow-integration.md +2 -1
  25. package/plugins/kk/skills/content-safety/SKILL.md +24 -13
  26. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +65 -8
  27. package/plugins/publishings/emdash-pub/dist/index.js +9 -9
  28. package/plugins/publishings/infoq-pub/dist/index.js +8602 -211
  29. package/plugins/publishings/infoq-pub/package.json +3 -1
  30. package/plugins/publishings/infoq-pub/plugin.json +1 -1
  31. package/plugins/publishings/infoq-pub/src/index.ts +246 -29
  32. package/plugins/publishings/infoq-pub/src/profile-check.ts +19 -0
  33. package/plugins/publishings/juejin-pub/dist/index.js +8665 -211
  34. package/plugins/publishings/juejin-pub/package.json +3 -1
  35. package/plugins/publishings/juejin-pub/plugin.json +1 -1
  36. package/plugins/publishings/juejin-pub/src/index.ts +305 -29
  37. package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
  38. package/plugins/publishings/medium-pub/dist/index.js +9 -9
  39. package/plugins/publishings/podcast-pub/dist/index.js +9 -9
  40. package/plugins/publishings/qiita-pub/dist/index.js +9 -9
  41. package/plugins/publishings/substack-pub/dist/index.js +8610 -211
  42. package/plugins/publishings/substack-pub/package.json +3 -1
  43. package/plugins/publishings/substack-pub/plugin.json +1 -1
  44. package/plugins/publishings/substack-pub/src/index.ts +271 -29
  45. package/plugins/publishings/substack-pub/src/profile-check.ts +74 -0
  46. package/plugins/publishings/surfdash-pub/dist/index.js +9 -9
  47. package/plugins/publishings/wechatmp-pub/dist/index.js +1227 -348
  48. package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
  49. package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
  50. package/plugins/publishings/wechatmp-pub/src/index.ts +186 -516
  51. package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
  52. package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
  53. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -5
  54. package/plugins/publishings/x-pub/dist/index.js +8626 -221
  55. package/plugins/publishings/x-pub/package.json +3 -1
  56. package/plugins/publishings/x-pub/plugin.json +1 -1
  57. package/plugins/publishings/x-pub/src/index.ts +258 -20
  58. package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
  59. package/plugins/publishings/xhs-pub/dist/index.js +843 -346
  60. package/plugins/publishings/xhs-pub/plugin.json +1 -1
  61. package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
  62. package/plugins/publishings/xhs-pub/src/index.ts +101 -593
  63. package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
  64. package/plugins/publishings/xhs-pub/src/page.ts +267 -0
  65. package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -3
  66. package/plugins/publishings/zenn-pub/dist/index.js +9 -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"
@@ -3,5 +3,5 @@
3
3
  "kind": "publish",
4
4
  "entry": "./dist/index.js",
5
5
  "version": "1.0.0",
6
- "description": "Publish plugin for X: post via official tweets API; article via browser/agent"
6
+ "description": "post via official tweets API; article via script browser transport (ts-browser-automation)"
7
7
  }
@@ -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,28 @@ 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
+ markdownToHtml,
18
+ pasteHtml,
19
+ resolveChannelProfileDir,
20
+ resolveSelector,
21
+ uploadFile,
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';
13
30
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
14
31
  import { echoError } from '@gobing-ai/ts-utils';
32
+ import type { Page } from 'playwright';
15
33
 
16
34
  /** Per-request timeout (ms) for the X API call — Bun's `fetch` has no default deadline (R5). */
17
35
  const REQUEST_TIMEOUT_MS = 120_000;
@@ -127,43 +145,263 @@ export class XPostApiTransport implements PublishTransport {
127
145
  }
128
146
  }
129
147
 
148
+ /** Channel slug for the article browser session: resolves the `X_PROFILE_DIR` tier (unchanged). */
149
+ export const ARTICLE_CHANNEL = 'x';
150
+ /** X's Articles composer; the preflight and the editor visit both open it. */
151
+ export const X_ARTICLES_URL = 'https://x.com/compose/articles';
152
+ /** The draft signal: the editor URL X renders once the article exists — the only draft evidence. */
153
+ export const ARTICLE_EDIT_RE = /\/compose\/articles\/edit\/(\d+)/;
154
+ /** Login/redirect URLs that mean "not authenticated" — the composer never renders behind them. */
155
+ export const LOGIN_URL_RE = /(\/i\/flow\/login|\/login)/;
156
+
157
+ /**
158
+ * Selectors seeded from the wt script (`x-article-playwright.ts:143-187`); each list is tried in
159
+ * order and the operator dogfood (0194) re-measures them. The seed's `tweetButton`/`Tweet`
160
+ * candidates are dropped: they belong to the tweet composer, and clicking one would post a tweet.
161
+ */
162
+ export const ARTICLE_SELECTORS = {
163
+ write: ['[data-testid="empty_state_button_text"]', 'button:has-text("Write")', '[aria-label*="Write" i]'],
164
+ title: ['textarea[placeholder="Add a title"]', '[data-testid="twitter-article-title"]'],
165
+ body: ['.DraftEditor-editorContainer [contenteditable="true"]', 'div[role="textbox"]'],
166
+ addPhotos: ['[data-testid="addPhotoButton"]', 'button[aria-label*="Add photos" i]'],
167
+ fileInput: ['[data-testid="fileInput"]', 'input[type="file"][accept*="image"]'],
168
+ publish: ['[data-testid="publishButton"]', 'button:has-text("Publish")'],
169
+ } as const;
170
+
171
+ const STEP_TIMEOUT_MS = 30_000;
172
+ const LOGIN_TIMEOUT_MS = 180_000;
173
+ /** Operator recovery command every session failure names — never a cookie, token, page HTML or stack. */
174
+ const LOGIN_INSTRUCTION = 'bun plugins/publishings/x-pub/src/profile-check.ts login';
175
+ /** A live article lands on a URL carrying the article id; the composer URL is `/edit/`. */
176
+ const LIVE_URL_ID_RE = /\/articles?\/(\d+)/;
177
+ /** Cover extensions accepted from `options.coverPath` / `metadata.coverImage` (R2). */
178
+ const COVER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
179
+
180
+ /**
181
+ * X-owned readiness predicate: no login redirect and the article composer reachable (the write
182
+ * control or the title field attached). Shared by the publish preflight and the profile CLI (R1, R5).
183
+ */
184
+ export const isArticleAuthenticated: IsAuthenticatedPredicate = async (page) => {
185
+ if (LOGIN_URL_RE.test(page.url())) return false;
186
+ if ((await trySelectors(page, ARTICLE_SELECTORS.write, { visible: false })).found) return true;
187
+ return (await trySelectors(page, ARTICLE_SELECTORS.title, { visible: false })).found;
188
+ };
189
+
190
+ /** Site-owned config for the shared session check and operator CLI (0183). */
191
+ export const ARTICLE_CONFIG: ChannelBrowserConfig = {
192
+ channel: ARTICLE_CHANNEL,
193
+ homeUrl: X_ARTICLES_URL,
194
+ isAuthenticated: isArticleAuthenticated,
195
+ loginTimeoutMs: LOGIN_TIMEOUT_MS,
196
+ };
197
+
198
+ function errorMessage(error: unknown): string {
199
+ return error instanceof Error ? error.message : String(error);
200
+ }
201
+
202
+ /** Session failures whose one-liner must name the recovery command (R5). */
203
+ function needsLogin(error: unknown): boolean {
204
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
205
+ }
206
+
207
+ /**
208
+ * Resolves and validates the optional cover (R2): `options.coverPath` wins over
209
+ * `metadata.coverImage`, and a given cover must be an absolute path to an existing
210
+ * `.png|.jpg|.jpeg|.webp` file. Returns the failure message otherwise, so a bad cover is refused
211
+ * **before** any browser opens.
212
+ */
213
+ async function resolveCover(
214
+ options: Record<string, unknown> | undefined,
215
+ content: Content,
216
+ ): Promise<{ path: string } | { error: string } | undefined> {
217
+ const raw = options?.coverPath ?? content.metadata?.coverImage;
218
+ if (raw === undefined) return undefined;
219
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
220
+ return { error: `cover path must be a non-empty local file path, got '${String(raw)}'` };
221
+ }
222
+ if (!isAbsolute(raw)) {
223
+ return { error: `cover path must be an absolute path, got '${raw}'` };
224
+ }
225
+ if (!COVER_EXTS.has(extname(raw).toLowerCase())) {
226
+ return { error: `cover path must be a .png, .jpg, .jpeg or .webp file, got '${raw}'` };
227
+ }
228
+ if (!(await createNodeFileSystem().exists(raw))) {
229
+ return { error: `cover file does not exist: '${raw}'` };
230
+ }
231
+ return { path: raw };
232
+ }
233
+
130
234
  export class XArticleBrowserTransport implements PublishTransport {
131
235
  public readonly kind = 'browser';
132
236
  public readonly driver: BrowserDriver;
133
- private readonly loginTimeoutMs: number;
134
- private readonly sessionReady: boolean;
237
+ private readonly launcher?: BrowserLauncher;
238
+ private readonly stepTimeoutMs: number;
135
239
 
136
- constructor(driver: BrowserDriver = 'agent', opts?: { loginTimeoutMs?: number; sessionReady?: boolean }) {
240
+ constructor(driver: BrowserDriver = 'script', deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {}) {
137
241
  this.driver = driver;
138
- this.loginTimeoutMs = opts?.loginTimeoutMs ?? 120_000;
139
- this.sessionReady = opts?.sessionReady ?? false;
242
+ this.launcher = deps.launcher;
243
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
140
244
  }
141
245
 
142
246
  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}`);
247
+ if (this.driver !== 'script') {
248
+ return fail(`unsupported driver '${this.driver}' for x article`);
249
+ }
250
+ const requested = payload.content.metadata?.publishStatus;
251
+ if (requested !== undefined && requested !== 'draft' && requested !== 'public') {
252
+ return fail(`unknown publishStatus '${String(requested)}' (expected absent, 'draft' or 'public')`);
253
+ }
254
+ // Pre-validation, before any browser opens: a given cover must be a usable local image (R2),
255
+ // so a bad path is a fast refusal rather than a half-built draft.
256
+ const cover = await resolveCover(payload.options, payload.content);
257
+ if (cover !== undefined && 'error' in cover) return fail(cover.error);
258
+
259
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
260
+ const profileDir = resolveChannelProfileDir(ARTICLE_CHANNEL, override);
261
+
262
+ // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed
263
+ // visit, so the read-only headless check runs first and a refusal stays a fast refusal.
264
+ const session = await checkChannelSession(ARTICLE_CONFIG, {
265
+ profileDir,
266
+ launcher: this.launcher,
267
+ headedFallback: false,
268
+ timeoutMs: this.stepTimeoutMs,
269
+ });
270
+ if (session.status !== 'verified') {
271
+ return fail(`${TARGET} session preflight refused: ${session.detail} — run: ${LOGIN_INSTRUCTION}`);
272
+ }
273
+
274
+ const publishStatus = requested === 'public' ? 'public' : 'draft';
275
+ try {
276
+ return await withProfilePage(
277
+ {
278
+ profileDir,
279
+ url: X_ARTICLES_URL,
280
+ isAuthenticated: isArticleAuthenticated,
281
+ // Headed by default; the headless preflight above is the unattended pass,
282
+ // `options.headless === true` the opt-in.
283
+ headless: payload.options?.headless === true,
284
+ launcher: this.launcher,
285
+ // The session is already preflight-verified, so the post-goto confirmation is
286
+ // a bounded step like every other one, not the 180s interactive login wait.
287
+ timeoutMs: this.stepTimeoutMs,
288
+ },
289
+ async ({ page }) => this.runArticleFlow(page, payload.content, publishStatus, cover?.path, profileDir),
290
+ );
291
+ } catch (error) {
292
+ const hint = needsLogin(error) ? ` — run: ${LOGIN_INSTRUCTION}` : '';
293
+ return fail(`${errorMessage(error)}${hint}`);
149
294
  }
150
- if (!this.sessionReady) {
151
- return fail(`login timeout: no authenticated X article session in ${profileDir}`);
295
+ }
296
+
297
+ /**
298
+ * The site-owned composer flow: write → title → Draft.js HTML paste → optional cover → the
299
+ * draft signal, then — for a live run only — the publish control and the live signal. Success
300
+ * only from an `expectOutcome` probe (E91-D6): a wait without an observed signal is never a
301
+ * success, and a draft run never clicks the publish control (E91-D5).
302
+ */
303
+ private async runArticleFlow(
304
+ page: Page,
305
+ content: Content,
306
+ publishStatus: 'draft' | 'public',
307
+ coverPath: string | undefined,
308
+ profileDir: string,
309
+ ): Promise<Result> {
310
+ const metadata = { operation: 'article', driver: this.driver, publishStatus, profileDir };
311
+
312
+ const writeSelector = await resolveSelector(page, ARTICLE_SELECTORS.write, {
313
+ timeoutMs: this.stepTimeoutMs,
314
+ label: 'write control',
315
+ });
316
+ await page.locator(writeSelector).first().click();
317
+
318
+ const titleSelector = await resolveSelector(page, ARTICLE_SELECTORS.title, {
319
+ timeoutMs: this.stepTimeoutMs,
320
+ label: 'title input',
321
+ });
322
+ await page
323
+ .locator(titleSelector)
324
+ .first()
325
+ .fill(content.title ?? 'Untitled');
326
+
327
+ const bodySelector = await resolveSelector(page, ARTICLE_SELECTORS.body, {
328
+ timeoutMs: this.stepTimeoutMs,
329
+ label: 'body editor',
330
+ });
331
+ // X's article editor is Draft.js rich text: the body goes in as rendered HTML through the
332
+ // shared paste, never as plain markdown text (E91-D4).
333
+ await pasteHtml(page, bodySelector, markdownToHtml(content.body), { timeoutMs: this.stepTimeoutMs });
334
+
335
+ if (coverPath !== undefined) {
336
+ // The photo picker is opened by a control when the composer renders it; the file input
337
+ // itself is routinely hidden behind it, so it is found by attachment (R2).
338
+ const addPhotos = await trySelectors(page, ARTICLE_SELECTORS.addPhotos, { visible: false });
339
+ if (addPhotos.found && addPhotos.selector !== null) {
340
+ await page.locator(addPhotos.selector).first().click();
341
+ }
342
+ const fileInputSelector = await resolveSelector(page, ARTICLE_SELECTORS.fileInput, {
343
+ timeoutMs: this.stepTimeoutMs,
344
+ label: 'cover file input',
345
+ });
346
+ await uploadFile(page, fileInputSelector, coverPath, { timeoutMs: this.stepTimeoutMs });
152
347
  }
153
- const title = payload.content.title ?? 'Untitled';
348
+
349
+ const draft = await expectOutcome(() => probeDraft(page), {
350
+ timeoutMs: this.stepTimeoutMs,
351
+ label: 'x article draft',
352
+ });
353
+ if (!draft.ok) return fail(draft.error);
354
+
355
+ // The probe only ever returns a `/compose/articles/edit/<id>` URL, so this always resolves.
356
+ const id = ARTICLE_EDIT_RE.exec(draft.evidence)?.[1];
357
+ if (publishStatus === 'draft') {
358
+ return ResultSchema.parse({
359
+ ok: true,
360
+ target: TARGET,
361
+ ...(id === undefined ? {} : { id }),
362
+ url: draft.evidence,
363
+ message: `${TARGET} article draft saved (unpublished) in profile ${profileDir}`,
364
+ metadata,
365
+ });
366
+ }
367
+
368
+ const publishSelector = await resolveSelector(page, ARTICLE_SELECTORS.publish, {
369
+ timeoutMs: this.stepTimeoutMs,
370
+ label: 'publish control',
371
+ });
372
+ await page.locator(publishSelector).first().click();
373
+ const live = await expectOutcome(() => probeLive(page), {
374
+ timeoutMs: this.stepTimeoutMs,
375
+ label: `x article publish '${publishSelector}'`,
376
+ });
377
+ if (!live.ok) return fail(live.error);
378
+
154
379
  return ResultSchema.parse({
155
380
  ok: true,
156
381
  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 },
382
+ ...(id === undefined ? {} : { id }),
383
+ url: live.evidence,
384
+ message: `${TARGET} article published live in profile ${profileDir}`,
385
+ metadata,
160
386
  });
161
387
  }
162
388
  }
163
389
 
390
+ /** Draft evidence is URL-only by design: X autosaves the composer onto `/edit/<id>`. */
391
+ async function probeDraft(page: Page): Promise<string | null> {
392
+ const url = page.url();
393
+ return ARTICLE_EDIT_RE.test(url) ? url : null;
394
+ }
395
+
396
+ /** Live evidence is URL-only too: a published article URL that left the `/edit/` composer. */
397
+ async function probeLive(page: Page): Promise<string | null> {
398
+ const url = page.url();
399
+ return !ARTICLE_EDIT_RE.test(url) && LIVE_URL_ID_RE.test(url) ? url : null;
400
+ }
401
+
164
402
  export function getPublishTransport(operation: 'post' | 'article' = 'post'): PublishTransport {
165
403
  if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') {
166
- return operation === 'article' ? new FakeBrowserTransport('agent') : new FakeFileCliTransport();
404
+ return operation === 'article' ? new FakeBrowserTransport('script') : new FakeFileCliTransport();
167
405
  }
168
406
  return operation === 'article' ? new XArticleBrowserTransport() : new XPostApiTransport();
169
407
  }
@@ -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
+ }