@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": "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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
40
|
-
private readonly
|
|
214
|
+
private readonly launcher: BrowserLauncher;
|
|
215
|
+
private readonly stepTimeoutMs: number;
|
|
216
|
+
private readonly checkTimeoutMs: number;
|
|
41
217
|
|
|
42
|
-
constructor(
|
|
218
|
+
constructor(
|
|
219
|
+
driver: BrowserDriver = 'script',
|
|
220
|
+
deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number; checkTimeoutMs?: number } = {},
|
|
221
|
+
) {
|
|
43
222
|
this.driver = driver;
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 = '
|
|
392
|
+
export function getPublishTransport(driver: BrowserDriver = 'script'): PublishTransport {
|
|
64
393
|
if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') {
|
|
65
|
-
return new FakeBrowserTransport(
|
|
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
|
+
}
|