@gobing-ai/knowledge-kit 0.1.11 → 0.1.13
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 +2372 -1722
- package/package.json +1 -1
- package/plugins/generations/image-gen/README.md +108 -0
- package/plugins/generations/image-gen/assets/sample/cover__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/dist/index.js +102 -58
- package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +5 -21
- package/plugins/generations/image-gen/scripts/gen-samples.ts +208 -0
- package/plugins/generations/image-gen/src/compose/chromium.ts +2 -3
- package/plugins/generations/image-gen/src/presets.ts +6 -3
- package/plugins/generations/image-gen/src/providers/errors.ts +16 -1
- package/plugins/generations/image-gen/src/providers/minimax.ts +46 -17
- package/plugins/ingestions/web-search/src/index.ts +1 -1
- package/plugins/kk/README.md +18 -0
- package/plugins/kk/commands/image-extract.md +1 -0
- package/plugins/kk/commands/image-generate.md +1 -0
- package/plugins/kk/commands/tell-me.md +1 -0
- package/plugins/kk/commands/workflow-run.md +7 -1
- package/plugins/kk/decisions/extract/it-topics.yaml +24 -0
- package/plugins/kk/decisions/kk-itc.yaml +6 -4
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/daily-ai-voice-migration.json +146 -0
- package/plugins/kk/publish-presets/daily-ai-voice.json +15 -7
- package/plugins/kk/schedules/kk-collect.grok-bot.md.tmpl +69 -0
- package/plugins/kk/schedules/kk-daily-ai-voice3.cron.tmpl +18 -0
- package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +15 -5
- package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +9 -4
- package/plugins/kk/scripts/daily-scheduled-run.ts +91 -41
- package/plugins/kk/scripts/daily-stages.ts +394 -53
- package/plugins/kk/scripts/kk-workflow-stages.ts +190 -0
- package/plugins/kk/scripts/safety-artifact-check.ts +70 -28
- package/plugins/kk/scripts/voice3-publish-window.ts +202 -0
- package/plugins/kk/skills/article-adapt/SKILL.md +1 -0
- package/plugins/kk/skills/audio-authoring/SKILL.md +1 -0
- package/plugins/kk/skills/content-judge/SKILL.md +1 -0
- package/plugins/kk/skills/content-safety/SKILL.md +1 -0
- package/plugins/kk/skills/content-safety/references/platforms/podcast-salty.md +41 -0
- package/plugins/kk/skills/content-safety/references/platforms/surfdash.md +36 -0
- package/plugins/kk/skills/content-safety/references/platforms/xhs.md +39 -0
- package/plugins/kk/skills/explain-things/SKILL.md +1 -0
- package/plugins/kk/skills/image-authoring/SKILL.md +1 -0
- package/plugins/kk/skills/itc-generating/SKILL.md +1 -0
- package/plugins/kk/skills/publish/SKILL.md +1 -0
- package/plugins/kk/skills/smart-seo/SKILL.md +1 -0
- package/plugins/kk/skills/storm-research/SKILL.md +1 -0
- package/plugins/kk/skills/taste-unslop/SKILL.md +1 -0
- package/plugins/kk/skills/topic/SKILL.md +1 -0
- package/plugins/kk/skills/web-automation/SKILL.md +1 -0
- package/plugins/kk/skills/xhs-images/SKILL.md +1 -0
- package/plugins/kk/workflows/kk-collect.yaml +3 -3
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +21 -19
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +163 -63
- package/plugins/kk/workflows/kk-daily-publish3.yaml +82 -26
- package/plugins/kk/workflows/kk-daily-safety.yaml +39 -31
- package/plugins/kk/workflows/kk-itc-author.yaml +75 -0
- package/plugins/kk/workflows/kk-itc-publish-tail.yaml +25 -3
- package/plugins/kk/workflows/kk-itc.yaml +36 -4
- package/plugins/publishings/linkedin-pub/dist/index.js +21333 -0
- package/plugins/publishings/linkedin-pub/dist/profile-check.js +21005 -0
- package/plugins/publishings/linkedin-pub/package.json +19 -0
- package/plugins/publishings/linkedin-pub/plugin.json +12 -0
- package/plugins/publishings/linkedin-pub/src/index.ts +705 -0
- package/plugins/publishings/linkedin-pub/src/profile-check.ts +17 -0
- package/plugins/publishings/linkedin-pub/tsconfig.json +4 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +53 -24
- package/plugins/publishings/surfdash-pub/src/index.ts +72 -25
- package/plugins/publishings/threads-pub/dist/index.js +7 -0
- package/plugins/publishings/threads-pub/src/index.ts +12 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +176 -32
- package/plugins/publishings/wechatmp-pub/src/flow.ts +298 -52
- package/plugins/publishings/weibo-pub/dist/index.js +100 -7
- package/plugins/publishings/weibo-pub/src/index.ts +111 -11
- package/plugins/publishings/x-pub/dist/index.js +7 -0
- package/plugins/publishings/x-pub/src/index.ts +11 -0
- package/plugins/publishings/xhs-pub/dist/index.js +164 -12
- package/plugins/publishings/xhs-pub/dist/profile-check.js +11 -1
- package/plugins/publishings/xhs-pub/src/flow.ts +194 -23
- package/plugins/publishings/xhs-pub/src/page.ts +26 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* linkedin-pub — LinkedIn feed-post publisher (task 0328).
|
|
3
|
+
*
|
|
4
|
+
* Browser controls follow the jike-pub live-only shape: one headless session preflight, one headed
|
|
5
|
+
* flow, and success only from an observed site signal. Every selector and signal regex below is
|
|
6
|
+
* **provisional** — never yet measured against a live logged-in LinkedIn session — so the
|
|
7
|
+
* operator's live gate (0328 plan step 5: login, then one manual post) must re-measure them before
|
|
8
|
+
* any live flip; each stage fails loud by name when a selector has drifted. Live-only: LinkedIn's
|
|
9
|
+
* drafts are not addressable, so an absent or `draft` `publishStatus` refuses before any browser
|
|
10
|
+
* opens (0328 R1).
|
|
11
|
+
*/
|
|
12
|
+
import { dirname, extname } from 'node:path';
|
|
13
|
+
import { parseArgs } from 'node:util';
|
|
14
|
+
import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
|
|
15
|
+
import {
|
|
16
|
+
type BrowserDriver,
|
|
17
|
+
FakeBrowserTransport,
|
|
18
|
+
type PublishTransport,
|
|
19
|
+
type PublishTransportPayload,
|
|
20
|
+
trySelectors,
|
|
21
|
+
} from '@gobing-ai/publish-harness';
|
|
22
|
+
import {
|
|
23
|
+
type ChannelBrowserConfig,
|
|
24
|
+
checkChannelSession,
|
|
25
|
+
expectOutcome,
|
|
26
|
+
kkBrowserLauncher,
|
|
27
|
+
resolveChannelProfileDir,
|
|
28
|
+
resolveSelector,
|
|
29
|
+
uploadFile,
|
|
30
|
+
} from '@gobing-ai/publish-harness/browser';
|
|
31
|
+
import {
|
|
32
|
+
type BrowserLauncher,
|
|
33
|
+
BrowserProfileMissingError,
|
|
34
|
+
type IsAuthenticatedPredicate,
|
|
35
|
+
LoginTimeoutError,
|
|
36
|
+
withProfilePage,
|
|
37
|
+
} from '@gobing-ai/ts-browser-automation';
|
|
38
|
+
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
39
|
+
import { echoError } from '@gobing-ai/ts-utils';
|
|
40
|
+
import type { Page } from 'playwright';
|
|
41
|
+
|
|
42
|
+
/** Site-owned target slug; also the profile channel key (`browser-profiles/linkedin`, ADR-042). */
|
|
43
|
+
const TARGET = 'linkedin';
|
|
44
|
+
/** Home entry: the feed, where the share box and the fresh posts live. */
|
|
45
|
+
export const LINKEDIN_HOME_URL = 'https://www.linkedin.com/feed/';
|
|
46
|
+
/**
|
|
47
|
+
* Login/redirect markers. **Provisional**: LinkedIn shows an `/authwall` interstitial for logged-out
|
|
48
|
+
* feed visits and `/checkpoint` for challenge flows; no redirect URL is source-grounded yet. The
|
|
49
|
+
* composer probe below carries the real gate.
|
|
50
|
+
*/
|
|
51
|
+
export const LINKEDIN_LOGIN_URL_RE = /(\/authwall|\/login|\/checkpoint)/;
|
|
52
|
+
|
|
53
|
+
/** Declared operations (ADR-029 dispatch): the feed-post flow only. */
|
|
54
|
+
export const LINKEDIN_OPERATIONS = ['post'] as const;
|
|
55
|
+
export type LinkedinOperation = (typeof LINKEDIN_OPERATIONS)[number];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* LinkedIn's own hard cap on one feed post: 3000 characters. Counted in code points; overlong text
|
|
59
|
+
* is refused, never truncated (the social-write stage's backstop is the only truncation, 0327 R3).
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_TEXT_CHARS = 3000;
|
|
62
|
+
/** One image per post (0328 R1 "text plus one image"); excess is refused, not sliced. */
|
|
63
|
+
const MAX_IMAGES = 1;
|
|
64
|
+
/**
|
|
65
|
+
* Image extensions this channel accepts. **Provisional**: the peer-parity set (jike-pub), not a
|
|
66
|
+
* measured LinkedIn limit; the live gate must confirm.
|
|
67
|
+
*/
|
|
68
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Post-composer selectors. **All provisional** — LinkedIn's share box, the modal editor, the file
|
|
72
|
+
* input and the Post control are named from the publicly known DOM, never measured live:
|
|
73
|
+
* - share box trigger on the feed (the "Start a post" button);
|
|
74
|
+
* - the modal's rich-text editor;
|
|
75
|
+
* - the composer-scoped file input (visible only once the modal is open);
|
|
76
|
+
* - the Post submit control inside the same dialog.
|
|
77
|
+
* A drifted selector is a loud typed refusal at its stage, never a silent skip or a text-only post.
|
|
78
|
+
*/
|
|
79
|
+
export const LINKEDIN_POST_SELECTORS = {
|
|
80
|
+
shareTrigger: ['button.share-box-feed-entry__trigger', 'button[aria-label*="Start a post" i]'],
|
|
81
|
+
composer: ['div.editor-content[contenteditable="true"]', 'div[role="textbox"][contenteditable="true"]'],
|
|
82
|
+
fileInput: ['[role="dialog"] input[type="file"]'],
|
|
83
|
+
send: ['[role="dialog"] button.share-actions__primary-action', '[role="dialog"] button:has-text("Post")'],
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
/** Failure toast words. **Provisional** (LinkedIn's artdeco toast wording). */
|
|
87
|
+
const FAILURE_TOAST_RE = /post failed|couldn't post|something went wrong/i;
|
|
88
|
+
/** Success toast words. **Provisional** ("Post successful"). */
|
|
89
|
+
const SUCCESS_TOAST_RE = /post successful/i;
|
|
90
|
+
/** Toast/notice containers — artdeco toasts. **Provisional**. */
|
|
91
|
+
const TOAST_CONTAINERS = '[role="alert"], [role="status"], [class*="toast" i]';
|
|
92
|
+
/** Fresh-post href shapes — the activity permalink LinkedIn inserts into the feed. **Provisional**. */
|
|
93
|
+
const POST_LINK_SELECTOR = 'a[href*="/feed/update/"], a[href*="/posts/"]';
|
|
94
|
+
/** Image upload preview wait: source-pinned 5s @ 250ms like the jike peer, not the step timeout. */
|
|
95
|
+
const PREVIEW_TIMEOUT_MS = 5_000;
|
|
96
|
+
const PREVIEW_POLL_MS = 250;
|
|
97
|
+
|
|
98
|
+
const STEP_TIMEOUT_MS = 30_000;
|
|
99
|
+
const LOGIN_TIMEOUT_MS = 180_000;
|
|
100
|
+
/** Operator recovery command every session failure names — never a cookie, DOM dump or stack. */
|
|
101
|
+
export const LINKEDIN_LOGIN_INSTRUCTION = 'bun plugins/publishings/linkedin-pub/src/profile-check.ts login';
|
|
102
|
+
/** Body excerpt length compared on readback and on the fresh-post-link signal. */
|
|
103
|
+
const EXCERPT_CHARS = 30;
|
|
104
|
+
|
|
105
|
+
function fail(error: string): Result {
|
|
106
|
+
return ResultSchema.parse({ ok: false, target: TARGET, error: boundedDetail(error) });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A browser-stage failure: `ok: false` with `stage` (and optional evidence) in the Result metadata. */
|
|
110
|
+
function stageFail(error: string, stage: string, metadata: Record<string, unknown>, evidence?: string): Result {
|
|
111
|
+
return ResultSchema.parse({
|
|
112
|
+
ok: false,
|
|
113
|
+
target: TARGET,
|
|
114
|
+
error: boundedDetail(error),
|
|
115
|
+
metadata: { ...metadata, stage, ...(evidence === undefined ? {} : { evidence: boundedDetail(evidence) }) },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One short line, bounded: no DOM dump, cookie, token or stack ever reaches the Result. */
|
|
120
|
+
function boundedDetail(detail: string): string {
|
|
121
|
+
return detail.replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A bounded, single-line rendering of an operator-supplied value for a refusal message. */
|
|
125
|
+
function bounded(value: unknown): string {
|
|
126
|
+
if (Array.isArray(value)) return `${value.length} entr${value.length === 1 ? 'y' : 'ies'}`;
|
|
127
|
+
return String(value).replace(/\s+/g, ' ').slice(0, 100);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function errorMessage(error: unknown): string {
|
|
131
|
+
return error instanceof Error ? error.message : String(error);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whitespace-insensitive comparison: the contenteditable editor normalizes runs of spaces/newlines. */
|
|
135
|
+
function normalizeText(value: string): string {
|
|
136
|
+
return value.replace(/\s+/g, '');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function excerpt(text: string): string {
|
|
140
|
+
return normalizeText(text).slice(0, EXCERPT_CHARS);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolves the requested operation and refuses unknown names before any transport — and therefore
|
|
145
|
+
* before any browser — is resolved (0328 R1: `options.operation ?? content.metadata.operation`).
|
|
146
|
+
* @param options - Per-call publish options (`operation`).
|
|
147
|
+
* @param content - Content whose `metadata.operation` is the fallback source.
|
|
148
|
+
* @returns The resolved operation, or the refusal message.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveOperation(
|
|
151
|
+
options?: Record<string, unknown>,
|
|
152
|
+
content?: Content,
|
|
153
|
+
): { operation: LinkedinOperation; error?: undefined } | { operation?: undefined; error: string } {
|
|
154
|
+
const raw = options?.operation ?? content?.metadata?.operation ?? 'post';
|
|
155
|
+
if (typeof raw !== 'string' || !(LINKEDIN_OPERATIONS as readonly string[]).includes(raw)) {
|
|
156
|
+
return {
|
|
157
|
+
error: `unsupported operation '${bounded(raw)}' for linkedin: expected one of ${LINKEDIN_OPERATIONS.join(', ')}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { operation: raw as LinkedinOperation };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolves the driver from `options.driver ?? 'script'`. This channel ships one driver and no
|
|
165
|
+
* `agent` path, so anything else — including a wrong type — is refused by name before a transport
|
|
166
|
+
* is built.
|
|
167
|
+
*/
|
|
168
|
+
function resolveDriver(options?: Record<string, unknown>): BrowserDriver | { error: string } {
|
|
169
|
+
const raw = options?.driver ?? 'script';
|
|
170
|
+
if (raw !== 'script') {
|
|
171
|
+
return { error: `unsupported driver '${bounded(raw)}' for linkedin: expected one of script` };
|
|
172
|
+
}
|
|
173
|
+
return 'script';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* LinkedIn-owned readiness predicate: no login redirect and the share box reachable. Shared by the
|
|
178
|
+
* publish preflight and the operator profile CLI, so both apply one gate (0328 R2). The
|
|
179
|
+
* login-redirect regex is provisional (see `LINKEDIN_LOGIN_URL_RE`); the share box carries the real
|
|
180
|
+
* gate — a drifted selector fails the probe loud.
|
|
181
|
+
*/
|
|
182
|
+
export const isLinkedinAuthenticated: IsAuthenticatedPredicate = async (page) => {
|
|
183
|
+
if (LINKEDIN_LOGIN_URL_RE.test(page.url())) return false;
|
|
184
|
+
return (await trySelectors(page, LINKEDIN_POST_SELECTORS.shareTrigger, { visible: false })).found;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Site-owned config for the shared session check and operator CLI (ADR-032). The publish preflight
|
|
189
|
+
* never forwards a headed fallback: it is the operator CLI's setting, and a refusal must never
|
|
190
|
+
* escalate to a headed window on its own.
|
|
191
|
+
*/
|
|
192
|
+
export const LINKEDIN_CONFIG: ChannelBrowserConfig = {
|
|
193
|
+
channel: TARGET,
|
|
194
|
+
homeUrl: LINKEDIN_HOME_URL,
|
|
195
|
+
isAuthenticated: isLinkedinAuthenticated,
|
|
196
|
+
loginTimeoutMs: LOGIN_TIMEOUT_MS,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Resolves the optional single image (`options.images ?? metadata.images`) without uploading:
|
|
201
|
+
* a payload of at most one existing local image file, so a bad path is a fast pre-browser refusal
|
|
202
|
+
* (0328 R1 "text plus one image").
|
|
203
|
+
*/
|
|
204
|
+
async function resolveImages(
|
|
205
|
+
options?: Record<string, unknown>,
|
|
206
|
+
content?: Content,
|
|
207
|
+
): Promise<{ paths: string[] } | { error: string }> {
|
|
208
|
+
const raw = options?.images ?? content?.metadata?.images;
|
|
209
|
+
if (raw === undefined) return { paths: [] };
|
|
210
|
+
if (!Array.isArray(raw)) {
|
|
211
|
+
return { error: `metadata.images must be an array of local image paths, got '${bounded(raw)}'` };
|
|
212
|
+
}
|
|
213
|
+
if (raw.length > MAX_IMAGES) {
|
|
214
|
+
return {
|
|
215
|
+
error: `metadata.images requests ${raw.length} images, over the LinkedIn cap of ${MAX_IMAGES} image per post; refusing rather than truncating`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const paths: string[] = [];
|
|
219
|
+
for (const entry of raw) {
|
|
220
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
221
|
+
return { error: `metadata.images entries must be non-empty local image paths, got '${bounded(entry)}'` };
|
|
222
|
+
}
|
|
223
|
+
if (!IMAGE_EXTS.has(extname(entry).toLowerCase())) {
|
|
224
|
+
return { error: `metadata.images entry must be one of ${[...IMAGE_EXTS].join(', ')}, got '${entry}'` };
|
|
225
|
+
}
|
|
226
|
+
if (!(await createNodeFileSystem().exists(entry))) {
|
|
227
|
+
return { error: `metadata.images file does not exist: '${entry}'` };
|
|
228
|
+
}
|
|
229
|
+
paths.push(entry);
|
|
230
|
+
}
|
|
231
|
+
return { paths };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* LinkedIn browser transport on the shared rails (ADR-027): a headless session preflight, one
|
|
236
|
+
* headed flow, and success only from an observed signal. Live-only (0328 R1): `publishStatus` must
|
|
237
|
+
* be `public` — LinkedIn's drafts are not addressable, so absent and `draft` refuse before the
|
|
238
|
+
* browser.
|
|
239
|
+
*/
|
|
240
|
+
export class LinkedinBrowserTransport implements PublishTransport {
|
|
241
|
+
public readonly kind = 'browser';
|
|
242
|
+
public readonly driver: BrowserDriver;
|
|
243
|
+
private readonly operation: LinkedinOperation;
|
|
244
|
+
private readonly launcher: BrowserLauncher;
|
|
245
|
+
private readonly stepTimeoutMs: number;
|
|
246
|
+
constructor(
|
|
247
|
+
operation: LinkedinOperation = 'post',
|
|
248
|
+
driver: BrowserDriver = 'script',
|
|
249
|
+
deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {},
|
|
250
|
+
) {
|
|
251
|
+
this.operation = operation;
|
|
252
|
+
this.driver = driver;
|
|
253
|
+
this.launcher = deps.launcher ?? kkBrowserLauncher;
|
|
254
|
+
this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Publishes one Content as a live feed post.
|
|
259
|
+
* @param payload - Target content plus per-call options.
|
|
260
|
+
* @returns The publish `Result`, `ok: true` only from an observed site signal.
|
|
261
|
+
*/
|
|
262
|
+
public async publish(payload: PublishTransportPayload): Promise<Result> {
|
|
263
|
+
// Refusal order (all pre-browser): driver → publishStatus → body text → images → session
|
|
264
|
+
// preflight. (The operation is resolved and refused in `resolveOperation` before any
|
|
265
|
+
// transport exists.)
|
|
266
|
+
if (this.driver !== 'script') {
|
|
267
|
+
return fail(`unsupported driver '${bounded(this.driver)}' for linkedin: expected one of script`);
|
|
268
|
+
}
|
|
269
|
+
const requested = payload.content.metadata?.publishStatus;
|
|
270
|
+
if (requested === undefined || requested === 'draft') {
|
|
271
|
+
return fail(
|
|
272
|
+
`linkedin has no draft mode: publishStatus ${requested === undefined ? 'is absent' : "'draft'"} refuses before the browser — LinkedIn drafts are not addressable, set publishStatus: public to publish live`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (requested !== 'public') {
|
|
276
|
+
return fail(`unknown publishStatus '${bounded(requested)}' for linkedin: only 'public' publishes`);
|
|
277
|
+
}
|
|
278
|
+
const text = payload.content.body.trim();
|
|
279
|
+
if (text.length === 0) {
|
|
280
|
+
return fail('linkedin post requires non-empty Content.body text (the composer has no title field)');
|
|
281
|
+
}
|
|
282
|
+
const length = [...text].length;
|
|
283
|
+
if (length > MAX_TEXT_CHARS) {
|
|
284
|
+
return fail(
|
|
285
|
+
`linkedin post text is ${length} code points, over the ${MAX_TEXT_CHARS}-character limit: linkedin-pub never truncates`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const images = await resolveImages(payload.options, payload.content);
|
|
289
|
+
if ('error' in images) return fail(images.error);
|
|
290
|
+
|
|
291
|
+
const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
|
|
292
|
+
const profileDir = resolveChannelProfileDir(TARGET, override);
|
|
293
|
+
|
|
294
|
+
// ADR-025 preflight: an unauthenticated or absent profile must never produce a headed visit,
|
|
295
|
+
// so the read-only headless check runs first and a refusal stays a fast refusal.
|
|
296
|
+
const session = await checkChannelSession(LINKEDIN_CONFIG, {
|
|
297
|
+
profileDir,
|
|
298
|
+
launcher: this.launcher,
|
|
299
|
+
headedFallback: false,
|
|
300
|
+
timeoutMs: this.stepTimeoutMs,
|
|
301
|
+
});
|
|
302
|
+
if (session.status !== 'verified') {
|
|
303
|
+
// The recovery command leads the message: `fail()` bounds the line, and the operator's
|
|
304
|
+
// next action must never be the part that gets truncated.
|
|
305
|
+
return fail(
|
|
306
|
+
`${TARGET} session preflight refused — run: ${LINKEDIN_LOGIN_INSTRUCTION} — detail: ${session.detail}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
return await withProfilePage(
|
|
312
|
+
{
|
|
313
|
+
profileDir,
|
|
314
|
+
url: LINKEDIN_HOME_URL,
|
|
315
|
+
isAuthenticated: isLinkedinAuthenticated,
|
|
316
|
+
// Headed by default; the headless preflight above is the unattended pass and
|
|
317
|
+
// `options.headless === true` the opt-in.
|
|
318
|
+
headless: payload.options?.headless === true,
|
|
319
|
+
launcher: this.launcher,
|
|
320
|
+
// The session is already preflight-verified, so the post-goto confirmation is a
|
|
321
|
+
// bounded step like every other one, not the 180s interactive login wait.
|
|
322
|
+
timeoutMs: this.stepTimeoutMs,
|
|
323
|
+
},
|
|
324
|
+
async ({ page }) => this.runPostFlow(page, text, images.paths, profileDir),
|
|
325
|
+
);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
// Same ordering rule as the preflight refusal: the recovery command is never the tail
|
|
328
|
+
// that a bounded message drops.
|
|
329
|
+
const detail = errorMessage(error);
|
|
330
|
+
return fail(
|
|
331
|
+
needsLogin(error)
|
|
332
|
+
? `${TARGET} session failed — run: ${LINKEDIN_LOGIN_INSTRUCTION} — detail: ${detail}`
|
|
333
|
+
: detail,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The feed-post flow: share box → modal editor fill → readback → image upload → baselines →
|
|
340
|
+
* Post → confirmation. Success only from a fresh strong signal: a fresh post link whose card
|
|
341
|
+
* read back the body, or a fresh success toast. Weak signals (editor cleared, modal closed)
|
|
342
|
+
* alone never report success, and every stage failure is typed so a drifted selector is loud.
|
|
343
|
+
*/
|
|
344
|
+
private async runPostFlow(
|
|
345
|
+
page: Page,
|
|
346
|
+
text: string,
|
|
347
|
+
images: readonly string[],
|
|
348
|
+
profileDir: string,
|
|
349
|
+
): Promise<Result> {
|
|
350
|
+
const metadata = { operation: this.operation, driver: this.driver, publishStatus: 'public', profileDir };
|
|
351
|
+
|
|
352
|
+
// Share box. A missing trigger is the typed stage failure — the page never reached the
|
|
353
|
+
// composer (logged-out feed or selector drift).
|
|
354
|
+
let triggerSelector: string;
|
|
355
|
+
try {
|
|
356
|
+
triggerSelector = await resolveSelector(page, LINKEDIN_POST_SELECTORS.shareTrigger, {
|
|
357
|
+
timeoutMs: this.stepTimeoutMs,
|
|
358
|
+
label: 'linkedin share box',
|
|
359
|
+
});
|
|
360
|
+
} catch {
|
|
361
|
+
return stageFail(
|
|
362
|
+
`linkedin share box not found on ${LINKEDIN_HOME_URL} (session may be logged out, or the selector drifted) — run: ${LINKEDIN_LOGIN_INSTRUCTION}`,
|
|
363
|
+
'share-trigger',
|
|
364
|
+
metadata,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
await page.locator(triggerSelector).first().click();
|
|
368
|
+
|
|
369
|
+
// Modal editor. A missing editor after the click is the same loud typed refusal family.
|
|
370
|
+
let composerSelector: string;
|
|
371
|
+
try {
|
|
372
|
+
composerSelector = await resolveSelector(page, LINKEDIN_POST_SELECTORS.composer, {
|
|
373
|
+
timeoutMs: this.stepTimeoutMs,
|
|
374
|
+
label: 'linkedin composer',
|
|
375
|
+
});
|
|
376
|
+
} catch {
|
|
377
|
+
return stageFail(
|
|
378
|
+
`linkedin composer did not open after the share box (selector drift) — run: ${LINKEDIN_LOGIN_INSTRUCTION}`,
|
|
379
|
+
'composer',
|
|
380
|
+
metadata,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
await page.locator(composerSelector).first().fill(text);
|
|
384
|
+
|
|
385
|
+
// Readback before anything else: a field the editor dropped must never be submitted.
|
|
386
|
+
const readback = await this.requireReadback(page, composerSelector, text);
|
|
387
|
+
if (!readback.ok) return stageFail(readback.error, 'text-readback', metadata);
|
|
388
|
+
|
|
389
|
+
// Image upload: confirmed landed; an unconfirmed upload refuses before submit — never a
|
|
390
|
+
// silent text-only post while an image was requested.
|
|
391
|
+
const [image] = images;
|
|
392
|
+
if (image !== undefined) {
|
|
393
|
+
const uploaded = await this.uploadImage(page, image);
|
|
394
|
+
if (!uploaded.ok) return stageFail(uploaded.error, 'image-upload', metadata);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Baselines before submit: only fresh toast and fresh post-link signals can confirm.
|
|
398
|
+
const toastsBefore = new Set(await this.readToasts(page));
|
|
399
|
+
const beforeLinks = await this.readPostLinks(page);
|
|
400
|
+
|
|
401
|
+
// Send: the Post control inside the share dialog.
|
|
402
|
+
const sendSelector = await resolveSelector(page, LINKEDIN_POST_SELECTORS.send, {
|
|
403
|
+
timeoutMs: this.stepTimeoutMs,
|
|
404
|
+
label: 'linkedin Post button',
|
|
405
|
+
});
|
|
406
|
+
const send = page.locator(sendSelector).first();
|
|
407
|
+
const enabled = await expectOutcome(async () => ((await send.isEnabled()) ? 'enabled' : null), {
|
|
408
|
+
timeoutMs: this.stepTimeoutMs,
|
|
409
|
+
label: 'linkedin Post button enabled',
|
|
410
|
+
});
|
|
411
|
+
if (!enabled.ok)
|
|
412
|
+
return stageFail(
|
|
413
|
+
`linkedin Post button never became clickable — ${enabled.error}`,
|
|
414
|
+
'send-enabled',
|
|
415
|
+
metadata,
|
|
416
|
+
);
|
|
417
|
+
await send.click();
|
|
418
|
+
|
|
419
|
+
// Confirmation: a failure toast fails fast; otherwise the fresh post link is preferred so
|
|
420
|
+
// the Result can carry the created post's url/id, with the fresh success toast as the
|
|
421
|
+
// remaining strong receipt.
|
|
422
|
+
const confirmed = await expectOutcome(() => this.probeConfirmation(page, text, beforeLinks, toastsBefore), {
|
|
423
|
+
timeoutMs: this.stepTimeoutMs,
|
|
424
|
+
label: 'linkedin publish confirmation',
|
|
425
|
+
});
|
|
426
|
+
if (!confirmed.ok) {
|
|
427
|
+
return stageFail(`linkedin publish not confirmed — ${confirmed.error}`, 'confirmation', metadata);
|
|
428
|
+
}
|
|
429
|
+
if (confirmed.evidence.startsWith('failure-toast: ')) {
|
|
430
|
+
return stageFail(
|
|
431
|
+
`linkedin send failed — site reported: ${confirmed.evidence.slice('failure-toast: '.length)}`,
|
|
432
|
+
'failure-toast',
|
|
433
|
+
metadata,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
const url = confirmed.evidence.startsWith('post-url: ')
|
|
437
|
+
? confirmed.evidence.slice('post-url: '.length)
|
|
438
|
+
: undefined;
|
|
439
|
+
return ResultSchema.parse({
|
|
440
|
+
ok: true,
|
|
441
|
+
target: TARGET,
|
|
442
|
+
...(url === undefined ? {} : { id: postIdFrom(url), url }),
|
|
443
|
+
message: `linkedin post published live in profile ${profileDir}${url === undefined ? ' (success-toast receipt; no post URL read back)' : ''}`,
|
|
444
|
+
metadata,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Polls until the editor shows the submitted text; a field the editor dropped is a refusal. */
|
|
449
|
+
private async requireReadback(
|
|
450
|
+
page: Page,
|
|
451
|
+
selector: string,
|
|
452
|
+
expected: string,
|
|
453
|
+
): Promise<{ ok: true; evidence: string } | { ok: false; error: string }> {
|
|
454
|
+
const wanted = excerpt(expected);
|
|
455
|
+
return expectOutcome(
|
|
456
|
+
async () => {
|
|
457
|
+
const current = normalizeText(String(await page.evaluate(editorTextScript(selector))));
|
|
458
|
+
if (wanted.length === 0 || !current.includes(wanted)) return null;
|
|
459
|
+
return current.slice(0, 60);
|
|
460
|
+
},
|
|
461
|
+
{ timeoutMs: this.stepTimeoutMs, label: 'linkedin composer text readback' },
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Uploads the one requested image through the composer-scoped file input and confirms it
|
|
467
|
+
* landed. The input is provisional (see `LINKEDIN_POST_SELECTORS`), so an absent input is a
|
|
468
|
+
* typed refusal, never a text-only submit; landing is the dialog's media-node count rising by
|
|
469
|
+
* one after the upload.
|
|
470
|
+
*/
|
|
471
|
+
private async uploadImage(
|
|
472
|
+
page: Page,
|
|
473
|
+
image: string,
|
|
474
|
+
): Promise<{ ok: true; evidence: string } | { ok: false; error: string }> {
|
|
475
|
+
try {
|
|
476
|
+
const inputSelector = await resolveSelector(page, LINKEDIN_POST_SELECTORS.fileInput, {
|
|
477
|
+
timeoutMs: this.stepTimeoutMs,
|
|
478
|
+
label: 'linkedin file input',
|
|
479
|
+
});
|
|
480
|
+
const before = Number(await page.evaluate(mediaEvidenceScript()));
|
|
481
|
+
await uploadFile(page, inputSelector, image, { timeoutMs: this.stepTimeoutMs });
|
|
482
|
+
const confirmed = await expectOutcome(
|
|
483
|
+
async () => {
|
|
484
|
+
const count = Number(await page.evaluate(mediaEvidenceScript()));
|
|
485
|
+
return count >= before + 1 ? `media nodes ${before} → ${count}` : null;
|
|
486
|
+
},
|
|
487
|
+
{ timeoutMs: PREVIEW_TIMEOUT_MS, pollMs: PREVIEW_POLL_MS, label: 'linkedin image preview evidence' },
|
|
488
|
+
);
|
|
489
|
+
if (!confirmed.ok) {
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
error: `linkedin composer preview never reached ${before + 1} media nodes (baseline ${before}) for the uploaded image`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
return { ok: true, evidence: confirmed.evidence };
|
|
496
|
+
} catch (error) {
|
|
497
|
+
return { ok: false, error: `linkedin image upload failed — ${errorMessage(error)}` };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Visible toast texts (provisional containers), used as the pre-send baseline. */
|
|
502
|
+
private async readToasts(page: Page): Promise<readonly string[]> {
|
|
503
|
+
return parseStringArray(await page.evaluate(toastTextsScript()));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** A toast carrying `pattern` that was not already on screen before the send. */
|
|
507
|
+
private async findFreshToast(page: Page, pattern: RegExp, before: ReadonlySet<string>): Promise<string | null> {
|
|
508
|
+
return (await this.readToasts(page)).find((text) => !before.has(text) && pattern.test(text)) ?? null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Post-link hrefs currently on the page — the submit-time baseline. */
|
|
512
|
+
private async readPostLinks(page: Page): Promise<readonly string[]> {
|
|
513
|
+
return parseStringArray(await page.evaluate(postLinksScript()));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* One confirmation probe per poll: failure toast fails fast; otherwise a fresh post link whose
|
|
518
|
+
* card contains the body excerpt, or a fresh success toast.
|
|
519
|
+
*/
|
|
520
|
+
private async probeConfirmation(
|
|
521
|
+
page: Page,
|
|
522
|
+
text: string,
|
|
523
|
+
beforeLinks: readonly string[],
|
|
524
|
+
toastsBefore: ReadonlySet<string>,
|
|
525
|
+
): Promise<string | null> {
|
|
526
|
+
const failure = await this.findFreshToast(page, FAILURE_TOAST_RE, toastsBefore);
|
|
527
|
+
if (failure !== null) return `failure-toast: ${failure}`;
|
|
528
|
+
const link = String(await page.evaluate(freshPostLinkScript(beforeLinks, excerpt(text)))).trim();
|
|
529
|
+
if (link.length > 0) return `post-url: ${link}`;
|
|
530
|
+
const success = await this.findFreshToast(page, SUCCESS_TOAST_RE, toastsBefore);
|
|
531
|
+
return success === null ? null : `success-toast: ${success}`;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Session failures whose one-liner must name the recovery command. */
|
|
536
|
+
function needsLogin(error: unknown): boolean {
|
|
537
|
+
return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Parses a JSON string array from a page probe, tolerating anything else as "no snapshot". */
|
|
541
|
+
function parseStringArray(raw: unknown): readonly string[] {
|
|
542
|
+
try {
|
|
543
|
+
const parsed: unknown = JSON.parse(String(raw));
|
|
544
|
+
return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
|
|
545
|
+
} catch {
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Post id from an observed LinkedIn post URL (activity id; extraction is provisional). */
|
|
551
|
+
function postIdFrom(url: string): string | undefined {
|
|
552
|
+
return /activity[:_-](\d{6,})/i.exec(url)?.[1];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Reads the composer's text back (`textContent` — the grounded control is a contenteditable div). */
|
|
556
|
+
function editorTextScript(selector: string): string {
|
|
557
|
+
return `(() => { // kk-linkedin-editor-text
|
|
558
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
559
|
+
return el === null ? '' : String(el.textContent ?? '').trim();
|
|
560
|
+
})()`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Media-node count inside the share dialog — the landed-preview predicate; the dialog scope is
|
|
565
|
+
* provisional (the composer renders in a `[role="dialog"]` layer).
|
|
566
|
+
*/
|
|
567
|
+
function mediaEvidenceScript(): string {
|
|
568
|
+
return `(() => { // kk-linkedin-media-evidence
|
|
569
|
+
const scope = document.querySelector('[role="dialog"]') || document;
|
|
570
|
+
return scope.querySelectorAll('img[src], video[src], video source[src], [style*="background-image"]').length;
|
|
571
|
+
})()`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Visible toast texts, used as the pre-send baseline and as the fresh-signal read (provisional). */
|
|
575
|
+
function toastTextsScript(): string {
|
|
576
|
+
return `(() => { // kk-linkedin-toasts
|
|
577
|
+
const nodes = document.querySelectorAll('${TOAST_CONTAINERS}');
|
|
578
|
+
const texts = [];
|
|
579
|
+
for (const node of nodes) {
|
|
580
|
+
const text = (node.textContent || '').trim();
|
|
581
|
+
if (text.length > 0) texts.push(text);
|
|
582
|
+
}
|
|
583
|
+
return JSON.stringify(texts);
|
|
584
|
+
})()`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** All post-link hrefs on the page, for the submit-time baseline. */
|
|
588
|
+
function postLinksScript(): string {
|
|
589
|
+
return `(() => { // kk-linkedin-post-links
|
|
590
|
+
return JSON.stringify(Array.from(document.querySelectorAll('${POST_LINK_SELECTOR}')).map((a) => a.href).filter(Boolean));
|
|
591
|
+
})()`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The first post link that was not on the page before the send and whose containing card read back
|
|
596
|
+
* the body excerpt. `''` means "no signal yet".
|
|
597
|
+
*/
|
|
598
|
+
function freshPostLinkScript(before: readonly string[], wanted: string): string {
|
|
599
|
+
return `(() => { // kk-linkedin-fresh-post-link
|
|
600
|
+
const seen = new Set(${JSON.stringify(before)});
|
|
601
|
+
const wanted = ${JSON.stringify(wanted)};
|
|
602
|
+
if (wanted === '') return '';
|
|
603
|
+
for (const anchor of document.querySelectorAll('${POST_LINK_SELECTOR}')) {
|
|
604
|
+
if (seen.has(anchor.href)) continue;
|
|
605
|
+
const card = anchor.closest('article, section, li, div');
|
|
606
|
+
const cardText = (card === null ? '' : card.textContent || '').replace(/\\s+/g, '');
|
|
607
|
+
if (cardText.includes(wanted)) return anchor.href;
|
|
608
|
+
}
|
|
609
|
+
return '';
|
|
610
|
+
})()`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Resolves the channel transport: the publish-harness fake under
|
|
615
|
+
* `KNOWLEDGE_KIT_PUBLISH_TRANSPORT=fake` (offline, no profile, no browser), otherwise the real
|
|
616
|
+
* LinkedIn browser transport.
|
|
617
|
+
* @param operation - Resolved operation (`post`).
|
|
618
|
+
* @param driver - Resolved driver; only `script` is declared.
|
|
619
|
+
* @returns The publish transport for this channel.
|
|
620
|
+
*/
|
|
621
|
+
export function getPublishTransport(
|
|
622
|
+
operation: LinkedinOperation = 'post',
|
|
623
|
+
driver: BrowserDriver = 'script',
|
|
624
|
+
): PublishTransport {
|
|
625
|
+
if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') return new FakeBrowserTransport('script');
|
|
626
|
+
return new LinkedinBrowserTransport(operation, driver);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Writes the Result to `--out`, creating its directory when the caller named a nested path. */
|
|
630
|
+
async function writePublishResult(outputPath: string, result: Result): Promise<void> {
|
|
631
|
+
const outDir = dirname(outputPath);
|
|
632
|
+
if (outDir && outDir !== '.') await createNodeFileSystem().ensureDir(outDir);
|
|
633
|
+
await createNodeFileSystem().writeFile(outputPath, JSON.stringify(result, null, 2));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The plugin's `--in/--out` contract: validate the Content, resolve **and refuse** the operation and
|
|
638
|
+
* the driver before any transport exists, then publish through the injected or configured transport.
|
|
639
|
+
* @param inputPath - Path to the Content JSON.
|
|
640
|
+
* @param outputPath - Path the Result JSON is written to, on success and on refusal.
|
|
641
|
+
* @param transport - Optional transport injection for tests and offline runs.
|
|
642
|
+
*/
|
|
643
|
+
export async function processPublishIO(
|
|
644
|
+
inputPath: string,
|
|
645
|
+
outputPath: string,
|
|
646
|
+
transport?: PublishTransport,
|
|
647
|
+
): Promise<void> {
|
|
648
|
+
await createNodeFileSystem().deleteFile(outputPath);
|
|
649
|
+
let content: Content;
|
|
650
|
+
let options: Record<string, unknown> | undefined;
|
|
651
|
+
try {
|
|
652
|
+
const raw = JSON.parse(await createNodeFileSystem().readFile(inputPath)) as Record<string, unknown>;
|
|
653
|
+
content = ContentSchema.parse(raw);
|
|
654
|
+
if (raw.options !== undefined && typeof raw.options === 'object' && raw.options !== null) {
|
|
655
|
+
options = raw.options as Record<string, unknown>;
|
|
656
|
+
}
|
|
657
|
+
} catch (error: unknown) {
|
|
658
|
+
const message = boundedDetail(`Invalid Content input: ${errorMessage(error)}`);
|
|
659
|
+
await writePublishResult(outputPath, fail(message));
|
|
660
|
+
throw new Error(message);
|
|
661
|
+
}
|
|
662
|
+
const mode = resolveOperation(options, content);
|
|
663
|
+
if (mode.error !== undefined) {
|
|
664
|
+
await writePublishResult(outputPath, fail(mode.error));
|
|
665
|
+
throw new Error(mode.error);
|
|
666
|
+
}
|
|
667
|
+
const driverMode = resolveDriver(options);
|
|
668
|
+
if (typeof driverMode !== 'string') {
|
|
669
|
+
await writePublishResult(outputPath, fail(driverMode.error));
|
|
670
|
+
throw new Error(driverMode.error);
|
|
671
|
+
}
|
|
672
|
+
const resolved = transport ?? getPublishTransport(mode.operation, driverMode);
|
|
673
|
+
const result = ResultSchema.parse(await resolved.publish({ content, options }));
|
|
674
|
+
await writePublishResult(outputPath, result);
|
|
675
|
+
if (!result.ok) throw new Error(result.error ?? 'Publish failed');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* CLI entry point: `--in <content.json> --out <result.json>`.
|
|
680
|
+
* @returns Process exit code (0 published, 1 refused or invalid arguments).
|
|
681
|
+
*/
|
|
682
|
+
export async function main(): Promise<number> {
|
|
683
|
+
let values: { in?: string; out?: string };
|
|
684
|
+
try {
|
|
685
|
+
({ values } = parseArgs({ options: { in: { type: 'string' }, out: { type: 'string' } } }));
|
|
686
|
+
} catch (error: unknown) {
|
|
687
|
+
echoError(`Invalid arguments: ${error instanceof Error ? error.message : String(error)}`);
|
|
688
|
+
return 1;
|
|
689
|
+
}
|
|
690
|
+
if (!values.in || !values.out) {
|
|
691
|
+
echoError('Missing required arguments: --in and --out');
|
|
692
|
+
return 1;
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
await processPublishIO(values.in, values.out);
|
|
696
|
+
return 0;
|
|
697
|
+
} catch (error: unknown) {
|
|
698
|
+
echoError(`linkedin-pub failed: ${boundedDetail(errorMessage(error))}`);
|
|
699
|
+
return 1;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (import.meta.main) {
|
|
704
|
+
process.exit(await main());
|
|
705
|
+
}
|