@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +32320 -15021
- package/package.json +4 -4
- package/plugins/generations/content-gen/dist/index.js +10 -9
- package/plugins/generations/core-facts-gen/dist/index.js +13 -12
- package/plugins/generations/daily-article-gen/dist/index.js +14 -13
- package/plugins/generations/daily-article-gen/src/index.ts +1 -1
- package/plugins/generations/dailynews-gen/dist/index.js +10 -9
- package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
- package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
- package/plugins/generations/gemini-tts/README.md +11 -0
- package/plugins/generations/gemini-tts/dist/index.js +10 -9
- package/plugins/generations/image-gen/dist/index.js +154 -151
- package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
- package/plugins/generations/news-report-gen/dist/index.js +13 -12
- package/plugins/generations/voice-gen/dist/index.js +10 -9
- package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
- package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
- package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
- package/plugins/ingestions/web-search/dist/index.js +11 -10
- package/plugins/kk/README.md +38 -0
- package/plugins/kk/decisions/extract/ai-news.yaml +21 -0
- package/plugins/kk/decisions/kk-items.yaml +113 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/schedules/cron.tmpl +15 -0
- package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
- package/plugins/kk/scripts/daily-stages.ts +1533 -0
- package/plugins/kk/scripts/kk-workflow-stages.ts +268 -1228
- package/plugins/kk/skills/content-judge/SKILL.md +12 -2
- package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
- package/plugins/kk/skills/content-safety/SKILL.md +60 -18
- package/plugins/kk/skills/web-automation/SKILL.md +100 -0
- package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
- package/plugins/kk/workflows/kk-collect.yaml +258 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +74 -11
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +655 -0
- package/plugins/kk/workflows/kk-daily-safety.yaml +170 -0
- package/plugins/publishings/emdash-pub/dist/index.js +10 -9
- package/plugins/publishings/infoq-pub/dist/index.js +8695 -211
- package/plugins/publishings/infoq-pub/package.json +3 -1
- package/plugins/publishings/infoq-pub/plugin.json +2 -1
- package/plugins/publishings/infoq-pub/src/index.ts +358 -29
- package/plugins/publishings/infoq-pub/src/profile-check.ts +21 -0
- package/plugins/publishings/juejin-pub/dist/index.js +8746 -211
- package/plugins/publishings/juejin-pub/package.json +3 -1
- package/plugins/publishings/juejin-pub/plugin.json +2 -1
- package/plugins/publishings/juejin-pub/src/index.ts +345 -29
- package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/medium-pub/dist/index.js +10 -9
- package/plugins/publishings/podcast-pub/dist/index.js +10 -9
- package/plugins/publishings/qiita-pub/dist/index.js +10 -9
- package/plugins/publishings/substack-pub/dist/index.js +8687 -211
- package/plugins/publishings/substack-pub/package.json +3 -1
- package/plugins/publishings/substack-pub/plugin.json +2 -1
- package/plugins/publishings/substack-pub/src/index.ts +302 -29
- package/plugins/publishings/substack-pub/src/profile-check.ts +76 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
- package/plugins/publishings/wechatmp-pub/dist/index.js +1315 -352
- package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
- package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
- package/plugins/publishings/wechatmp-pub/src/index.ts +223 -520
- package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
- package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -38
- package/plugins/publishings/x-pub/dist/index.js +8707 -223
- package/plugins/publishings/x-pub/package.json +3 -1
- package/plugins/publishings/x-pub/plugin.json +2 -1
- package/plugins/publishings/x-pub/src/index.ts +315 -23
- package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/xhs-pub/dist/index.js +897 -354
- package/plugins/publishings/xhs-pub/plugin.json +2 -1
- package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
- package/plugins/publishings/xhs-pub/src/index.ts +108 -596
- package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
- package/plugins/publishings/xhs-pub/src/page.ts +254 -0
- package/plugins/publishings/xhs-pub/src/profile-check.ts +29 -17
- 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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
134
|
-
private readonly
|
|
263
|
+
private readonly launcher: BrowserLauncher;
|
|
264
|
+
private readonly stepTimeoutMs: number;
|
|
135
265
|
|
|
136
|
-
constructor(driver: BrowserDriver = '
|
|
266
|
+
constructor(driver: BrowserDriver = 'script', deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {}) {
|
|
137
267
|
this.driver = driver;
|
|
138
|
-
this.
|
|
139
|
-
this.
|
|
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
|
-
|
|
144
|
-
'x
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
return fail(`
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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:
|
|
158
|
-
|
|
159
|
-
|
|
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('
|
|
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
|
|
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
|
+
}
|