@gobing-ai/knowledge-kit 0.1.6 → 0.1.8
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/board/collect/index.js +20 -7
- package/dist/board/daily-voice/index.js +20 -7
- package/dist/board/itc/index.js +20 -7
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/plugins/kk/decisions/extract/ai-news.yaml +7 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-stages.ts +89 -6
- package/plugins/kk/scripts/kk-workflow-stages.ts +1 -1
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +3 -1
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +8 -3
- package/plugins/publishings/wechatmp-pub/dist/index.js +54 -30
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +8 -3
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +58 -38
- package/plugins/publishings/wechatmp-pub/src/flow.ts +18 -1
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -1
|
@@ -73,6 +73,19 @@ function isLoopbackHttpOrigin(origin) {
|
|
|
73
73
|
return false;
|
|
74
74
|
return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
|
|
75
75
|
}
|
|
76
|
+
function isHttpOrigin(origin) {
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(origin);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
84
|
+
return false;
|
|
85
|
+
if (url.username !== "" || url.password !== "")
|
|
86
|
+
return false;
|
|
87
|
+
return url.pathname === "/" && url.search === "" && url.hash === "";
|
|
88
|
+
}
|
|
76
89
|
function origin(base) {
|
|
77
90
|
return base.replace(/\/+$/, "");
|
|
78
91
|
}
|
|
@@ -96,16 +109,16 @@ function parseSpurDbRows(pageData) {
|
|
|
96
109
|
});
|
|
97
110
|
}
|
|
98
111
|
function createBoardClient(options) {
|
|
99
|
-
if (!isLoopbackHttpOrigin(options.readerOrigin)) {
|
|
100
|
-
throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
|
|
101
|
-
}
|
|
102
|
-
if (!isLoopbackHttpOrigin(options.boardOrigin)) {
|
|
103
|
-
throw new Error(`board origin must be loopback http, got '${options.boardOrigin}'`);
|
|
104
|
-
}
|
|
105
112
|
const reader = origin(options.readerOrigin);
|
|
106
113
|
const board = origin(options.boardOrigin);
|
|
107
|
-
const doFetch = options.fetchImpl ?? fetch;
|
|
108
114
|
const isNativeSpur = reader === board;
|
|
115
|
+
if (!isHttpOrigin(options.boardOrigin)) {
|
|
116
|
+
throw new Error(`board origin must be an http origin, got '${options.boardOrigin}'`);
|
|
117
|
+
}
|
|
118
|
+
if (!isNativeSpur && !isLoopbackHttpOrigin(options.readerOrigin)) {
|
|
119
|
+
throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
|
|
120
|
+
}
|
|
121
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
109
122
|
async function getJson(url) {
|
|
110
123
|
const response = await doFetch(url, { headers: { accept: "application/json" } });
|
|
111
124
|
if (!response.ok) {
|
|
@@ -67,6 +67,19 @@ function isLoopbackHttpOrigin(origin) {
|
|
|
67
67
|
return false;
|
|
68
68
|
return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
|
|
69
69
|
}
|
|
70
|
+
function isHttpOrigin(origin) {
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(origin);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
78
|
+
return false;
|
|
79
|
+
if (url.username !== "" || url.password !== "")
|
|
80
|
+
return false;
|
|
81
|
+
return url.pathname === "/" && url.search === "" && url.hash === "";
|
|
82
|
+
}
|
|
70
83
|
function origin(base) {
|
|
71
84
|
return base.replace(/\/+$/, "");
|
|
72
85
|
}
|
|
@@ -90,16 +103,16 @@ function parseSpurDbRows(pageData) {
|
|
|
90
103
|
});
|
|
91
104
|
}
|
|
92
105
|
function createBoardClient(options) {
|
|
93
|
-
if (!isLoopbackHttpOrigin(options.readerOrigin)) {
|
|
94
|
-
throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
|
|
95
|
-
}
|
|
96
|
-
if (!isLoopbackHttpOrigin(options.boardOrigin)) {
|
|
97
|
-
throw new Error(`board origin must be loopback http, got '${options.boardOrigin}'`);
|
|
98
|
-
}
|
|
99
106
|
const reader = origin(options.readerOrigin);
|
|
100
107
|
const board = origin(options.boardOrigin);
|
|
101
|
-
const doFetch = options.fetchImpl ?? fetch;
|
|
102
108
|
const isNativeSpur = reader === board;
|
|
109
|
+
if (!isHttpOrigin(options.boardOrigin)) {
|
|
110
|
+
throw new Error(`board origin must be an http origin, got '${options.boardOrigin}'`);
|
|
111
|
+
}
|
|
112
|
+
if (!isNativeSpur && !isLoopbackHttpOrigin(options.readerOrigin)) {
|
|
113
|
+
throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
|
|
114
|
+
}
|
|
115
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
103
116
|
async function getJson(url) {
|
|
104
117
|
const response = await doFetch(url, { headers: { accept: "application/json" } });
|
|
105
118
|
if (!response.ok) {
|
package/dist/board/itc/index.js
CHANGED
|
@@ -67,6 +67,19 @@ function isLoopbackHttpOrigin(origin) {
|
|
|
67
67
|
return false;
|
|
68
68
|
return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
|
|
69
69
|
}
|
|
70
|
+
function isHttpOrigin(origin) {
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(origin);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
78
|
+
return false;
|
|
79
|
+
if (url.username !== "" || url.password !== "")
|
|
80
|
+
return false;
|
|
81
|
+
return url.pathname === "/" && url.search === "" && url.hash === "";
|
|
82
|
+
}
|
|
70
83
|
function origin(base) {
|
|
71
84
|
return base.replace(/\/+$/, "");
|
|
72
85
|
}
|
|
@@ -90,16 +103,16 @@ function parseSpurDbRows(pageData) {
|
|
|
90
103
|
});
|
|
91
104
|
}
|
|
92
105
|
function createBoardClient(options) {
|
|
93
|
-
if (!isLoopbackHttpOrigin(options.readerOrigin)) {
|
|
94
|
-
throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
|
|
95
|
-
}
|
|
96
|
-
if (!isLoopbackHttpOrigin(options.boardOrigin)) {
|
|
97
|
-
throw new Error(`board origin must be loopback http, got '${options.boardOrigin}'`);
|
|
98
|
-
}
|
|
99
106
|
const reader = origin(options.readerOrigin);
|
|
100
107
|
const board = origin(options.boardOrigin);
|
|
101
|
-
const doFetch = options.fetchImpl ?? fetch;
|
|
102
108
|
const isNativeSpur = reader === board;
|
|
109
|
+
if (!isHttpOrigin(options.boardOrigin)) {
|
|
110
|
+
throw new Error(`board origin must be an http origin, got '${options.boardOrigin}'`);
|
|
111
|
+
}
|
|
112
|
+
if (!isNativeSpur && !isLoopbackHttpOrigin(options.readerOrigin)) {
|
|
113
|
+
throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
|
|
114
|
+
}
|
|
115
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
103
116
|
async function getJson(url) {
|
|
104
117
|
const response = await doFetch(url, { headers: { accept: "application/json" } });
|
|
105
118
|
if (!response.ok) {
|
package/dist/index.js
CHANGED
|
@@ -49085,7 +49085,7 @@ function checkOne(record, options) {
|
|
|
49085
49085
|
if (!existsSync6(record.check)) {
|
|
49086
49086
|
return { name: record.name, kind: record.kind, status: "error", detail: "probe entry missing" };
|
|
49087
49087
|
}
|
|
49088
|
-
const timeoutMs = options.timeout !== undefined ? Number(options.timeout) :
|
|
49088
|
+
const timeoutMs = options.timeout !== undefined ? Number(options.timeout) : 60000;
|
|
49089
49089
|
const result = spawnPluginProbe({
|
|
49090
49090
|
entryPath: record.check,
|
|
49091
49091
|
cwd: record.dir,
|
package/package.json
CHANGED
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
# `kk store extract --limit` overrides it.
|
|
13
13
|
topic: ai-news
|
|
14
14
|
where:
|
|
15
|
-
|
|
15
|
+
# 2026-10-21: the allowlist is the whole AI taxonomy, in lockstep with the workflow's
|
|
16
|
+
# qc_categories (`ai-news,ai-technical,ai-research,ai-industry`) and episode-plan-gen's
|
|
17
|
+
# DEFAULT_FILTER_CONFIG. The two-category gate here front-ran that widening one layer upstream:
|
|
18
|
+
# a live pool labelled 20 ai-industry + 7 ai-research + 7 ai-technical + 3 ai-news came out of
|
|
19
|
+
# extract as 6 items, so `daily-qc-content`'s floor refusal closed the day as no-content before
|
|
20
|
+
# the widened QC allowlist could admit a technical or research story.
|
|
21
|
+
kk-item-category: { in: [ai-news, ai-industry, ai-technical, ai-research] }
|
|
16
22
|
# quality 2, not 3: real ingest bodies (last30days/aihot summaries) are stub-length, so the
|
|
17
23
|
# heuristic labeler tops out at 1-2 and a gte-3 gate empties every real pool (task 0212 dogfood
|
|
18
24
|
# finding). Raise this back when a full-text ingest source lands.
|
package/plugins/kk/plugin.json
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
coverBrief,
|
|
75
75
|
coverNormalizeBody,
|
|
76
76
|
dedupPlanByTitle,
|
|
77
|
+
expandHome,
|
|
77
78
|
fail,
|
|
78
79
|
failWith,
|
|
79
80
|
firstArtifact,
|
|
@@ -937,11 +938,14 @@ export function dailyGenerate(args: string[]): void {
|
|
|
937
938
|
if (existsSync(planPath)) {
|
|
938
939
|
try {
|
|
939
940
|
const plan = readJson(planPath) as { items?: unknown[] };
|
|
940
|
-
if (Array.isArray(plan.items)
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
941
|
+
if (Array.isArray(plan.items)) {
|
|
942
|
+
const expected = expectedItemSections(workDir, runDate, plan.items);
|
|
943
|
+
if (expected !== itemSections.length) {
|
|
944
|
+
failWith(
|
|
945
|
+
1,
|
|
946
|
+
`generate: plan item count (${plan.items.length}, ${plan.items.length - expected} recorded as excluded) does not match script item sections (${itemSections.length})`,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
945
949
|
}
|
|
946
950
|
} catch (e: unknown) {
|
|
947
951
|
if (e instanceof StageFailure) throw e;
|
|
@@ -1317,7 +1321,10 @@ export function dailyIntro(args: string[]): void {
|
|
|
1317
1321
|
if (rawFile === undefined) {
|
|
1318
1322
|
failWith(1, 'intro-music: voice content carries no mp3Path/audioPath');
|
|
1319
1323
|
}
|
|
1320
|
-
|
|
1324
|
+
// The sting is an operator asset under the config root (ADR-042), not a works artifact:
|
|
1325
|
+
// workflow vars carry it as `~/.config/kk/voices/…`, and a shell does not expand `~` inside
|
|
1326
|
+
// the quoted "${vars.intro_music}" argument, so expansion happens here.
|
|
1327
|
+
const introPath = resolve(expandHome(introSrc));
|
|
1321
1328
|
if (!existsSync(introPath)) failWith(1, `intro-music: intro file not found: ${introPath}`);
|
|
1322
1329
|
const mainPath = resolve(rawFile);
|
|
1323
1330
|
if (!existsSync(mainPath)) failWith(1, `intro-music: generated audio not found: ${mainPath}`);
|
|
@@ -2087,6 +2094,16 @@ export function dailySafetyPrep(args: string[]): void {
|
|
|
2087
2094
|
const [workDir = '', runDate = '', platforms = ''] = args;
|
|
2088
2095
|
timed(workDir, 'safety-review', () => {
|
|
2089
2096
|
const digest = safetyDigest(workDir, runDate);
|
|
2097
|
+
// Structural snapshot of the VoiceScript BEFORE any remediation edit. `daily-safety-revise`
|
|
2098
|
+
// restores it when the remediation pass changes the item-section structure without a recorded
|
|
2099
|
+
// exclusion: the agent is allowed to drop a 播报形态 line, not an item the article kept,
|
|
2100
|
+
// because `daily-wrap-docs` derives the spoken item sections from `gap_ms: 1200` and fails the
|
|
2101
|
+
// run when they no longer match the episode's included items (2026-10-21 04:30 run: 3
|
|
2102
|
+
// segments removed → 5 sections vs a 6-item plan → `generate` refused at 05:03 and the whole
|
|
2103
|
+
// day published nothing).
|
|
2104
|
+
const [, scriptPath] = safetyDraftPaths(workDir, runDate);
|
|
2105
|
+
const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
|
|
2106
|
+
if (isFile(scriptPath)) sted('write', snapshot, () => writeRaw(snapshot, readFileSync(scriptPath, 'utf-8')));
|
|
2090
2107
|
for (const name of [
|
|
2091
2108
|
'safety-review_report.json',
|
|
2092
2109
|
'safety-judge_verdict.json',
|
|
@@ -2146,6 +2163,7 @@ export function dailySafetyRevise(args: string[]): void {
|
|
|
2146
2163
|
}
|
|
2147
2164
|
}
|
|
2148
2165
|
assertScriptContent(scriptPath);
|
|
2166
|
+
restoreStructureIfBroken(workDir, runDate, scriptPath);
|
|
2149
2167
|
const path = join(workDir, SAFETY_REVISE_COUNT);
|
|
2150
2168
|
const next = readSafetyReviseCount(workDir) + 1;
|
|
2151
2169
|
sted('write', path, () => writeFileSync(path, `${next}\n`));
|
|
@@ -2153,6 +2171,71 @@ export function dailySafetyRevise(args: string[]): void {
|
|
|
2153
2171
|
});
|
|
2154
2172
|
}
|
|
2155
2173
|
|
|
2174
|
+
/**
|
|
2175
|
+
* The spoken item-section count the episode is contracted to carry: the script plan's items minus the
|
|
2176
|
+
* ones the article boundary recorded in `metadata.excludedIds`.
|
|
2177
|
+
*
|
|
2178
|
+
* The article drops boundary docs at its own stage, the safety gate aligns the VoiceScript with the
|
|
2179
|
+
* article it reviews, and `daily-show-notes` already filters the plan copy by the same ids — but
|
|
2180
|
+
* `daily-generate` compared the sections against the raw plan length, so the 2026-10-21 run refused a
|
|
2181
|
+
* 5-section script against a 6-item plan (the 6th was the article-excluded 通用汽车 buyback) after two
|
|
2182
|
+
* hours of safety review and rendered nothing. Every consumer must count the same set.
|
|
2183
|
+
*/
|
|
2184
|
+
export function expectedItemSections(workDir: string, runDate: string, planItems: unknown[]): number {
|
|
2185
|
+
const excluded = metadataOf(
|
|
2186
|
+
readJson(join(workDir, '22-article', `${runDate}_06_article_content.json`)),
|
|
2187
|
+
).excludedIds;
|
|
2188
|
+
if (!Array.isArray(excluded)) return planItems.length;
|
|
2189
|
+
const dropped = new Set(excluded.filter((id): id is string => typeof id === 'string' && id !== ''));
|
|
2190
|
+
if (dropped.size === 0) return planItems.length;
|
|
2191
|
+
return planItems.filter(
|
|
2192
|
+
(item) => !(item !== null && typeof item === 'object' && dropped.has(String((item as { id?: unknown }).id))),
|
|
2193
|
+
).length;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* Repair a remediation pass that changed the VoiceScript's item-section structure.
|
|
2198
|
+
*
|
|
2199
|
+
* `daily-wrap-docs` (and `daily-generate`) derive the spoken item sections from `gap_ms: 1200` and
|
|
2200
|
+
* refuse the run when they disagree with the episode's included items. The safety agent is allowed
|
|
2201
|
+
* to reshape lines, and to drop an item the article boundary already excluded (that drop is the
|
|
2202
|
+
* contract, and `expectedItemSections` counts it out), but an unrecorded drop breaks the one-to-one
|
|
2203
|
+
* audio/notes/plan relation — and on 2026-10-21 one killed the 04:30 run at `generate` after two
|
|
2204
|
+
* hours of safety review. The pre-revise bytes are snapshotted by `daily-safety-prep`, so an
|
|
2205
|
+
* unaccounted structural change is undone here, inside the budget loop, and named for the operator.
|
|
2206
|
+
* The budget is still spent, so the loop still converges (the re-review sees the unchanged script and
|
|
2207
|
+
* the gate classifies the residue) instead of the day failing hours later at the TTS seam.
|
|
2208
|
+
*/
|
|
2209
|
+
function restoreStructureIfBroken(workDir: string, runDate: string, scriptPath: string): void {
|
|
2210
|
+
const planPath = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
|
|
2211
|
+
const plan = readJson(planPath) as { items?: unknown[] } | null;
|
|
2212
|
+
if (plan === null || !Array.isArray(plan.items)) return;
|
|
2213
|
+
const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
|
|
2214
|
+
if (!isFile(snapshot)) return;
|
|
2215
|
+
const sections = (path: string): number => {
|
|
2216
|
+
const parsed = Bun.YAML.parse(readFileSync(path, 'utf-8')) as { segments?: Array<Record<string, unknown>> };
|
|
2217
|
+
const segments = Array.isArray(parsed.segments) ? parsed.segments : [];
|
|
2218
|
+
let count = 0;
|
|
2219
|
+
let pending = false;
|
|
2220
|
+
for (const segment of segments.slice(1, -1)) {
|
|
2221
|
+
pending = true;
|
|
2222
|
+
if (segment.gap_ms === 1200) {
|
|
2223
|
+
count += 1;
|
|
2224
|
+
pending = false;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
return count + (pending ? 1 : 0);
|
|
2228
|
+
};
|
|
2229
|
+
const expected = expectedItemSections(workDir, runDate, plan.items);
|
|
2230
|
+
const actual = sections(scriptPath);
|
|
2231
|
+
if (actual === expected) return;
|
|
2232
|
+
const original = sections(snapshot);
|
|
2233
|
+
sted('write', scriptPath, () => writeRaw(scriptPath, readFileSync(snapshot, 'utf-8')));
|
|
2234
|
+
console.error(
|
|
2235
|
+
`safety-revise: the edit changed the VoiceScript item sections (${expected} included plan items, ${original} before, ${actual} after) — restored the pre-revision structure from ${basename(snapshot)}; audio/notes stay one-to-one with the plan`,
|
|
2236
|
+
);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2156
2239
|
/** Block YAML for the fields a fitted VoiceScript actually carries. Strings use JSON quoting. */
|
|
2157
2240
|
function voicescriptToBlockYaml(script: Record<string, unknown>): string {
|
|
2158
2241
|
const lines: string[] = [];
|
|
@@ -751,7 +751,7 @@ function formatRunDate(): string {
|
|
|
751
751
|
}
|
|
752
752
|
|
|
753
753
|
/** Expand the `~/` and `$HOME/` prefixes the config examples use. */
|
|
754
|
-
function expandHome(p: string): string {
|
|
754
|
+
export function expandHome(p: string): string {
|
|
755
755
|
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
756
756
|
if (p.startsWith('$HOME/')) return join(homedir(), p.slice('$HOME/'.length));
|
|
757
757
|
return p;
|
|
@@ -30,7 +30,9 @@ vars:
|
|
|
30
30
|
# env block; smoke render 6.8s). Flip back with vdriver="voicebox".
|
|
31
31
|
vdriver: "gemini"
|
|
32
32
|
# 20260917 review #7: 8s intro sting prepended to every daily output; empty = skip.
|
|
33
|
-
|
|
33
|
+
# The sting is an operator asset: it lives under the config root (ADR-042), never in the works
|
|
34
|
+
# tree. `~` is expanded by the daily-intro stage, so it stays machine-portable.
|
|
35
|
+
intro_music: "~/.config/kk/voices/AI每日资讯_8秒播客片头.mp3"
|
|
34
36
|
# 20260917 review #4/#6: surfdash checkout the patch stage commits to.
|
|
35
37
|
surfdash_root: "/Users/robin/xprojects/surfdash"
|
|
36
38
|
title: ""
|
|
@@ -35,7 +35,7 @@ vars:
|
|
|
35
35
|
# exists; flipping this one var to "gemini" is the escape hatch (measured dry: 20-25 ms / -34..-37 dB)
|
|
36
36
|
# at the price of a 1h47m-2h21m remote render.
|
|
37
37
|
vdriver: "voxcpm"
|
|
38
|
-
intro_music: "
|
|
38
|
+
intro_music: "~/.config/kk/voices/AI每日资讯_8秒播客片头.mp3"
|
|
39
39
|
surfdash_root: "/Users/robin/xprojects/surfdash"
|
|
40
40
|
title: ""
|
|
41
41
|
skip_review: "false"
|
|
@@ -48,7 +48,12 @@ vars:
|
|
|
48
48
|
cover_fallback: ""
|
|
49
49
|
# 0219 R3: image-gen vendor fallback for the XHS render; "" restores strict fail-fast.
|
|
50
50
|
xhs_fallback: "true"
|
|
51
|
-
|
|
51
|
+
# 2026-10-21 operator decision: OFF for now (was on since 2026-09-29). The plugin reported today's
|
|
52
|
+
# draft as saved (`草稿箱` 12 -> 13 plus a scoped `保存成功` toast) while the console showed the
|
|
53
|
+
# operator no draft, so the channel's success evidence is not trustworthy until that is resolved.
|
|
54
|
+
# Flip back to "true" to resume the card plan/render/draft series; `xhs_live` stays a separate
|
|
55
|
+
# launch-time switch and nothing else in the run depends on this target.
|
|
56
|
+
xhs_enabled: "false"
|
|
52
57
|
xhs_cards: "4"
|
|
53
58
|
xhs_style: "xhs-knowledge"
|
|
54
59
|
xhs_provider: "minimax"
|
|
@@ -185,7 +190,7 @@ nodes:
|
|
|
185
190
|
options:
|
|
186
191
|
onError: continue
|
|
187
192
|
command: >-
|
|
188
|
-
kk plugin check wechatmp-pub xhs-pub
|
|
193
|
+
kk plugin check wechatmp-pub xhs-pub --timeout 60000
|
|
189
194
|
|| echo "session-preflight: a publish channel's saved session was refused — fix it before this run's publish step with: bun plugins/publishings/<channel>-pub/src/profile-check.ts login (wechatmp or xhs)"
|
|
190
195
|
|
|
191
196
|
- id: extract
|
|
@@ -22166,12 +22166,7 @@ async function waitForVisible(page, selector, timeoutMs, opts = {}) {
|
|
|
22166
22166
|
// ../../plugins/publishings/wechatmp-pub/src/enrich.ts
|
|
22167
22167
|
var DIALOG = ".weui-desktop-dialog:visible";
|
|
22168
22168
|
var CONFIRM = `${DIALOG} button:has-text("\u786E\u8BA4"), ${DIALOG} button:has-text("\u786E\u5B9A")`;
|
|
22169
|
-
var ROW_ORIGINAL = [
|
|
22170
|
-
".setting-group__switch.js_original_apply.js_edit_ori",
|
|
22171
|
-
".setting-group__switch.js_edit_ori",
|
|
22172
|
-
".js_edit_ori",
|
|
22173
|
-
"text=\u539F\u521B"
|
|
22174
|
-
];
|
|
22169
|
+
var ROW_ORIGINAL = ["#js_original .setting-group__content-info", "#js_original .setting-group__title"];
|
|
22175
22170
|
var ROW_REWARD = [
|
|
22176
22171
|
".setting-group__switch.js_reward_open",
|
|
22177
22172
|
".js_reward_open",
|
|
@@ -22180,18 +22175,28 @@ var ROW_REWARD = [
|
|
|
22180
22175
|
"text=\u8D5E\u8D4F"
|
|
22181
22176
|
];
|
|
22182
22177
|
var ROW_COLLECTION = [".allow_click_opr.js_article_tags_label", "text=\u5408\u96C6"];
|
|
22183
|
-
var ROW_URL = [".
|
|
22184
|
-
var ROW_CLAIM = ["#js_claim_source_area .
|
|
22178
|
+
var ROW_URL = ["#js_article_url_area .js_article_url_allow_click"];
|
|
22179
|
+
var ROW_CLAIM = ["#js_claim_source_area .allow_click_opr"];
|
|
22185
22180
|
var DIALOG_CONFIRM = CONFIRM;
|
|
22181
|
+
var ORIGINAL_CONFIRM = `${DIALOG} button:text-is("\u786E\u5B9A")`;
|
|
22182
|
+
var CLAIM_CONFIRM = `${DIALOG} button:text-is("\u786E\u8BA4")`;
|
|
22186
22183
|
var TAG_INPUT = 'input[placeholder*="\u5408\u96C6"]';
|
|
22187
22184
|
var REWARD_ACCOUNT = [
|
|
22188
22185
|
`${DIALOG} input[placeholder*="\u8D5E\u8D4F"]`,
|
|
22189
22186
|
`${DIALOG} input[placeholder*="\u8D26\u6237"]`,
|
|
22190
22187
|
`${DIALOG} input.frm_input`
|
|
22191
22188
|
];
|
|
22192
|
-
var
|
|
22189
|
+
var URL_POPOVER = ".popover_inner:has(.popover-article-setting__content):visible";
|
|
22190
|
+
var URL_INPUT = [`${URL_POPOVER} input.js_url[name="source_url"]`];
|
|
22191
|
+
var URL_CONFIRM = `${URL_POPOVER} a.jsPopoverBt:text-is("\u786E\u5B9A")`;
|
|
22192
|
+
var URL_CANCEL = `${URL_POPOVER} a.jsPopoverBt:text-is("\u53D6\u6D88")`;
|
|
22193
|
+
var URL_COMMITTED = `(() => {
|
|
22194
|
+
const area = document.querySelector('#js_article_url_area');
|
|
22195
|
+
const checkbox = area?.querySelector('input.js_url_checkbox');
|
|
22196
|
+
return checkbox?.checked === true ? area.querySelector('.article_url_setting')?.textContent ?? null : null;
|
|
22197
|
+
})()`;
|
|
22193
22198
|
var ORIGINAL_DECLARATION = `${DIALOG} label:has(.js_original_type_radio):has-text("\u6587\u5B57\u539F\u521B")`;
|
|
22194
|
-
var ORIGINAL_AGREEMENT = `${DIALOG} .original_agreement`;
|
|
22199
|
+
var ORIGINAL_AGREEMENT = `${DIALOG} label.original_agreement:has(input[type="checkbox"]:not(:checked))`;
|
|
22195
22200
|
var COLLECTION_SETTLE_MS = 1200;
|
|
22196
22201
|
var STEP_RETRY_SETTLE_MS = 1000;
|
|
22197
22202
|
var SWEEP_SETTLE_MS = 400;
|
|
@@ -22203,7 +22208,8 @@ function norm(value) {
|
|
|
22203
22208
|
}
|
|
22204
22209
|
var COMMITTED = {
|
|
22205
22210
|
original: `(() => {
|
|
22206
|
-
const el = document.
|
|
22211
|
+
const el = [...document.querySelectorAll('#js_original input.weui-desktop-switch__input')]
|
|
22212
|
+
.find(input => input.parentElement?.getClientRects().length > 0);
|
|
22207
22213
|
return el ? el.checked === true : null;
|
|
22208
22214
|
})()`,
|
|
22209
22215
|
reward: `(() => {
|
|
@@ -22234,7 +22240,7 @@ async function enrichArticle(page, meta) {
|
|
|
22234
22240
|
const str = (key) => typeof meta[key] === "string" ? meta[key] : "";
|
|
22235
22241
|
const on = (key) => meta[key] === true;
|
|
22236
22242
|
const sweep = async () => {
|
|
22237
|
-
const cancel = await trySelectors(page, ['button:has-text("\u53D6\u6D88")']);
|
|
22243
|
+
const cancel = await trySelectors(page, ['button:has-text("\u53D6\u6D88")', URL_CANCEL]);
|
|
22238
22244
|
if (cancel.found) {
|
|
22239
22245
|
try {
|
|
22240
22246
|
await page.locator(cancel.selector ?? "").first().click({ timeout: 2000 });
|
|
@@ -22305,7 +22311,7 @@ async function enrichArticle(page, meta) {
|
|
|
22305
22311
|
if (agreement.found) {
|
|
22306
22312
|
await clickSelector(page, ORIGINAL_AGREEMENT, 3000);
|
|
22307
22313
|
}
|
|
22308
|
-
await clickSelector(page,
|
|
22314
|
+
await clickSelector(page, ORIGINAL_CONFIRM, 8000);
|
|
22309
22315
|
});
|
|
22310
22316
|
if (outcome !== "capability" && await readChecked(page, COMMITTED.original) !== true && !await residual(name)) {
|
|
22311
22317
|
warnings.push(`${name}: \u539F\u521B\u58F0\u660E\u672A\u751F\u6548\uFF0C\u8BF7\u624B\u52A8\u8BBE\u7F6E`);
|
|
@@ -22358,23 +22364,32 @@ async function enrichArticle(page, meta) {
|
|
|
22358
22364
|
const originalUrl = str("originalUrl");
|
|
22359
22365
|
if (originalUrl !== "") {
|
|
22360
22366
|
const name = "\u539F\u6587\u94FE\u63A5";
|
|
22361
|
-
const
|
|
22367
|
+
const readUrl = async () => {
|
|
22368
|
+
try {
|
|
22369
|
+
return norm(await page.evaluate(URL_COMMITTED));
|
|
22370
|
+
} catch {
|
|
22371
|
+
return "";
|
|
22372
|
+
}
|
|
22373
|
+
};
|
|
22374
|
+
const urlRow = await readUrl() === norm(originalUrl) ? "committed" : await tryFindSelector(page, ROW_URL, 2000);
|
|
22362
22375
|
if (urlRow === undefined) {
|
|
22363
22376
|
warnings.push(`${name}: \u884C\u4E0D\u5B58\u5728\uFF08\u5F53\u524D\u516C\u4F17\u53F7\u4E0D\u652F\u6301\uFF09\uFF0C\u8BF7\u624B\u52A8\u5904\u7406\u6216\u79FB\u9664 originalUrl`);
|
|
22364
|
-
} else {
|
|
22377
|
+
} else if (urlRow !== "committed") {
|
|
22365
22378
|
await step(name, async () => {
|
|
22379
|
+
if (await readUrl() === norm(originalUrl))
|
|
22380
|
+
return;
|
|
22366
22381
|
await openRow(ROW_URL, "\u539F\u6587\u94FE\u63A5");
|
|
22367
22382
|
const field = await trySelectors(page, URL_INPUT);
|
|
22368
22383
|
if (!field.found)
|
|
22369
|
-
throw new Error("no URL field in the \u539F\u6587\u94FE\u63A5
|
|
22384
|
+
throw new Error("no URL field in the \u539F\u6587\u94FE\u63A5 popover");
|
|
22370
22385
|
await fillSelector(page, field.selector ?? URL_INPUT.join(", "), originalUrl);
|
|
22371
|
-
await clickSelector(page,
|
|
22386
|
+
await clickSelector(page, URL_CONFIRM, 8000);
|
|
22387
|
+
await page.locator(URL_POPOVER).first().waitFor?.({ state: "hidden", timeout: 8000 });
|
|
22372
22388
|
});
|
|
22373
|
-
await
|
|
22374
|
-
const locator = page.locator(URL_INPUT[0] ?? "").first();
|
|
22375
|
-
const committed = typeof locator.inputValue === "function" ? await locator.inputValue().catch(() => null) : null;
|
|
22376
|
-
if (norm(committed) !== norm(originalUrl))
|
|
22389
|
+
if (await readUrl() !== norm(originalUrl))
|
|
22377
22390
|
warnings.push(`${name}: \u5DF2\u63D0\u4EA4\u4F46\u672A\u8BFB\u53D6\u5230\u5DF2\u4FDD\u5B58\u7684\u94FE\u63A5\uFF0C\u8BF7\u6838\u5BF9`);
|
|
22391
|
+
if ((await trySelectors(page, [URL_POPOVER])).found)
|
|
22392
|
+
await sweep();
|
|
22378
22393
|
}
|
|
22379
22394
|
}
|
|
22380
22395
|
const claimSource = str("claimSource");
|
|
@@ -22390,17 +22405,22 @@ async function enrichArticle(page, meta) {
|
|
|
22390
22405
|
return null;
|
|
22391
22406
|
}
|
|
22392
22407
|
};
|
|
22408
|
+
let unavailable = false;
|
|
22393
22409
|
if (await claimMatched() !== true) {
|
|
22394
|
-
await step(name, async () => {
|
|
22410
|
+
const outcome = await step(name, async () => {
|
|
22395
22411
|
await openRow(ROW_CLAIM, "\u521B\u4F5C\u6765\u6E90");
|
|
22396
|
-
|
|
22397
|
-
|
|
22398
|
-
|
|
22399
|
-
await
|
|
22412
|
+
await waitForVisible(page, DIALOG, 3000);
|
|
22413
|
+
const stance = `${DIALOG} label:has(input[type="radio"]):has(:text-is(${JSON.stringify(claimSource)}))`;
|
|
22414
|
+
if (await tryFindSelector(page, [stance], 1500) === undefined) {
|
|
22415
|
+
await sweep();
|
|
22416
|
+
throw new CapabilityError(`\u5E73\u53F0\u672A\u63D0\u4F9B\u58F0\u660E\u7C7B\u578B\u300C${claimSource}\u300D\uFF0C\u5DF2\u8DF3\u8FC7`);
|
|
22400
22417
|
}
|
|
22418
|
+
await clickSelector(page, stance, 8000);
|
|
22419
|
+
await clickSelector(page, CLAIM_CONFIRM, 8000);
|
|
22401
22420
|
});
|
|
22421
|
+
unavailable = outcome === "capability";
|
|
22402
22422
|
}
|
|
22403
|
-
if (await claimMatched() !== true && !await residual(name)) {
|
|
22423
|
+
if (!unavailable && await claimMatched() !== true && !await residual(name)) {
|
|
22404
22424
|
warnings.push(`${name}: \u672A\u751F\u6548\uFF0C\u8BF7\u624B\u52A8\u8BBE\u7F6E`);
|
|
22405
22425
|
}
|
|
22406
22426
|
}
|
|
@@ -22507,13 +22527,17 @@ var AUDIO_UPLOAD_INPUT = [
|
|
|
22507
22527
|
'.weui-desktop-dialog input[type="file"][accept*="audio"]',
|
|
22508
22528
|
'.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
|
|
22509
22529
|
'.audio_dialog input[type="file"][accept*="audio"]',
|
|
22510
|
-
'input[type="file"][accept*=".mp3"]'
|
|
22530
|
+
'input[type="file"][accept*=".mp3"]',
|
|
22531
|
+
'.weui-desktop-dialog input[type="file"]:not([accept*="image"])',
|
|
22532
|
+
'.audio_dialog input[type="file"]:not([accept*="image"])'
|
|
22511
22533
|
];
|
|
22512
22534
|
var UPLOAD_COVER = ["text=\u4E0A\u4F20\u5C01\u9762", 'button:has-text("\u4E0A\u4F20\u5C01\u9762")'];
|
|
22513
22535
|
var AUDIO_COVER_INPUT = [
|
|
22514
22536
|
'.weui-desktop-dialog input[type="file"][accept*="image/"]',
|
|
22515
22537
|
'.audio_dialog input[type="file"][accept*="image/"]',
|
|
22516
|
-
'input[type="file"][accept*=".png"]'
|
|
22538
|
+
'input[type="file"][accept*=".png"]',
|
|
22539
|
+
'.weui-desktop-dialog input[type="file"][accept*="image"]',
|
|
22540
|
+
'.audio_dialog input[type="file"][accept*="image"]'
|
|
22517
22541
|
];
|
|
22518
22542
|
var AUDIO_TITLE_INPUT = [
|
|
22519
22543
|
'.weui-desktop-dialog input[placeholder*="\u6807\u9898"]',
|
|
@@ -22702,7 +22726,7 @@ async function applyLayout(page, deps) {
|
|
|
22702
22726
|
}
|
|
22703
22727
|
const option = await tryFindSelector(page, LAYOUT_TARGET, 5000);
|
|
22704
22728
|
if (option === undefined) {
|
|
22705
|
-
return "\u4E00\u952E\u6392\u7248:
|
|
22729
|
+
return "\u4E00\u952E\u6392\u7248: the control exists but this build renders no \u5339\u914D\u76EE\u6807\u683C\u5F0F option (measured 2026-10-21 with a body in place) \u2014 layout skipped, apply \u4E00\u952E\u6392\u7248 by hand before publishing";
|
|
22706
22730
|
}
|
|
22707
22731
|
try {
|
|
22708
22732
|
await clickSelector(page, option, 5000);
|
|
@@ -20729,14 +20729,19 @@ var MAX_AGGREGATE_BYTES = 20 * 1024 * 1024;
|
|
|
20729
20729
|
// ../../plugins/publishings/wechatmp-pub/src/enrich.ts
|
|
20730
20730
|
var DIALOG = ".weui-desktop-dialog:visible";
|
|
20731
20731
|
var CONFIRM = `${DIALOG} button:has-text("\u786E\u8BA4"), ${DIALOG} button:has-text("\u786E\u5B9A")`;
|
|
20732
|
+
var ORIGINAL_CONFIRM = `${DIALOG} button:text-is("\u786E\u5B9A")`;
|
|
20733
|
+
var CLAIM_CONFIRM = `${DIALOG} button:text-is("\u786E\u8BA4")`;
|
|
20732
20734
|
var REWARD_ACCOUNT = [
|
|
20733
20735
|
`${DIALOG} input[placeholder*="\u8D5E\u8D4F"]`,
|
|
20734
20736
|
`${DIALOG} input[placeholder*="\u8D26\u6237"]`,
|
|
20735
20737
|
`${DIALOG} input.frm_input`
|
|
20736
20738
|
];
|
|
20737
|
-
var
|
|
20739
|
+
var URL_POPOVER = ".popover_inner:has(.popover-article-setting__content):visible";
|
|
20740
|
+
var URL_INPUT = [`${URL_POPOVER} input.js_url[name="source_url"]`];
|
|
20741
|
+
var URL_CONFIRM = `${URL_POPOVER} a.jsPopoverBt:text-is("\u786E\u5B9A")`;
|
|
20742
|
+
var URL_CANCEL = `${URL_POPOVER} a.jsPopoverBt:text-is("\u53D6\u6D88")`;
|
|
20738
20743
|
var ORIGINAL_DECLARATION = `${DIALOG} label:has(.js_original_type_radio):has-text("\u6587\u5B57\u539F\u521B")`;
|
|
20739
|
-
var ORIGINAL_AGREEMENT = `${DIALOG} .original_agreement`;
|
|
20744
|
+
var ORIGINAL_AGREEMENT = `${DIALOG} label.original_agreement:has(input[type="checkbox"]:not(:checked))`;
|
|
20740
20745
|
|
|
20741
20746
|
// ../../plugins/publishings/wechatmp-pub/src/index.ts
|
|
20742
20747
|
var CHANNEL = TARGET;
|
|
@@ -20828,7 +20833,7 @@ if (false) {}
|
|
|
20828
20833
|
// ../../plugins/publishings/wechatmp-pub/src/profile-check.ts
|
|
20829
20834
|
var USAGE = "usage: wechatmp profile-check [login|check|open] [--profile <dir>] [--url <url>] [--timeout <ms>] [--headed]";
|
|
20830
20835
|
var LOGIN_TIMEOUT_MS = 180000;
|
|
20831
|
-
var PROFILE_OVERHEAD_MS =
|
|
20836
|
+
var PROFILE_OVERHEAD_MS = 25000;
|
|
20832
20837
|
var MIN_READINESS_MS = 1000;
|
|
20833
20838
|
async function main2(argv = process.argv.slice(2)) {
|
|
20834
20839
|
let command;
|
|
@@ -21,16 +21,10 @@ import { clickSelector, fillSelector, modalState, tryFindSelector, type WechatPa
|
|
|
21
21
|
const DIALOG = '.weui-desktop-dialog:visible';
|
|
22
22
|
const CONFIRM = `${DIALOG} button:has-text("确认"), ${DIALOG} button:has-text("确定")`;
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* dispatch the same handler; the first selector that resolves wins.
|
|
24
|
+
* Dialog entries measured 2026-10-21. Confirm labels belong to each dialog: 原创 uses 确定,
|
|
25
|
+
* 创作来源 uses 确认. A generic text match can hit an inert row heading instead of its opener.
|
|
27
26
|
*/
|
|
28
|
-
const ROW_ORIGINAL = [
|
|
29
|
-
'.setting-group__switch.js_original_apply.js_edit_ori',
|
|
30
|
-
'.setting-group__switch.js_edit_ori',
|
|
31
|
-
'.js_edit_ori',
|
|
32
|
-
'text=原创',
|
|
33
|
-
];
|
|
27
|
+
const ROW_ORIGINAL = ['#js_original .setting-group__content-info', '#js_original .setting-group__title'];
|
|
34
28
|
// Measured 2026-10-13: the 赞-tipped rows are `.js_reward_open` / `.js_reward_allow_click` — the
|
|
35
29
|
// old `.setting-group__switch.js_can_reward` no longer resolves. The 原创 dialog's own 赞赏
|
|
36
30
|
// switch (`js_reward_switch`) must never be clicked by this row list (R3, drift task 0272).
|
|
@@ -42,9 +36,11 @@ const ROW_REWARD = [
|
|
|
42
36
|
'text=赞赏',
|
|
43
37
|
];
|
|
44
38
|
const ROW_COLLECTION = ['.allow_click_opr.js_article_tags_label', 'text=合集'];
|
|
45
|
-
const ROW_URL = ['.
|
|
46
|
-
const ROW_CLAIM = ['#js_claim_source_area .
|
|
39
|
+
const ROW_URL = ['#js_article_url_area .js_article_url_allow_click'];
|
|
40
|
+
const ROW_CLAIM = ['#js_claim_source_area .allow_click_opr'];
|
|
47
41
|
const DIALOG_CONFIRM = CONFIRM;
|
|
42
|
+
const ORIGINAL_CONFIRM = `${DIALOG} button:text-is("确定")`;
|
|
43
|
+
const CLAIM_CONFIRM = `${DIALOG} button:text-is("确认")`;
|
|
48
44
|
const TAG_INPUT = 'input[placeholder*="合集"]';
|
|
49
45
|
/** The 赞赏 dialog's account field, filled only when the platform leaves it empty. */
|
|
50
46
|
const REWARD_ACCOUNT = [
|
|
@@ -53,15 +49,23 @@ const REWARD_ACCOUNT = [
|
|
|
53
49
|
`${DIALOG} input.frm_input`,
|
|
54
50
|
];
|
|
55
51
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
52
|
+
* 原文链接 is a popover outside the dialog tree (measured 2026-10-21, task 0318).
|
|
53
|
+
* Its field disappears on commit; committed readback belongs to the settings row.
|
|
58
54
|
*/
|
|
59
|
-
const
|
|
55
|
+
const URL_POPOVER = '.popover_inner:has(.popover-article-setting__content):visible';
|
|
56
|
+
const URL_INPUT = [`${URL_POPOVER} input.js_url[name="source_url"]`];
|
|
57
|
+
const URL_CONFIRM = `${URL_POPOVER} a.jsPopoverBt:text-is("确定")`;
|
|
58
|
+
const URL_CANCEL = `${URL_POPOVER} a.jsPopoverBt:text-is("取消")`;
|
|
59
|
+
const URL_COMMITTED = `(() => {
|
|
60
|
+
const area = document.querySelector('#js_article_url_area');
|
|
61
|
+
const checkbox = area?.querySelector('input.js_url_checkbox');
|
|
62
|
+
return checkbox?.checked === true ? area.querySelector('.article_url_setting')?.textContent ?? null : null;
|
|
63
|
+
})()`;
|
|
60
64
|
/** The 原创 dialog's declaration radio (measured 2026-10-13): the platform requires an explicit
|
|
61
65
|
* declaration type + the agreement check before 确定. `无需声明` is the undo choice, never a target. */
|
|
62
66
|
const ORIGINAL_DECLARATION = `${DIALOG} label:has(.js_original_type_radio):has-text("文字原创")`;
|
|
63
|
-
/**
|
|
64
|
-
const ORIGINAL_AGREEMENT = `${DIALOG} .original_agreement`;
|
|
67
|
+
/** Drive the hidden checkbox through its label, preserving a pre-checked agreement (probe 6). */
|
|
68
|
+
const ORIGINAL_AGREEMENT = `${DIALOG} label.original_agreement:has(input[type="checkbox"]:not(:checked))`;
|
|
65
69
|
/** The 合集 picker's options load after the dialog opens (the pre-ADR-029 settle, kept). */
|
|
66
70
|
const COLLECTION_SETTLE_MS = 1_200;
|
|
67
71
|
/** The settings panel re-renders after a dialog confirm and can briefly detach rows. */
|
|
@@ -86,9 +90,11 @@ function norm(value: unknown): string {
|
|
|
86
90
|
* Playwright-only syntax (`:visible`, `:has-text`) never enters `querySelector` — it throws there.
|
|
87
91
|
*/
|
|
88
92
|
const COMMITTED = {
|
|
89
|
-
/**
|
|
93
|
+
/** Both undeclared and declared templates keep hidden inputs. Read the switch in the visible
|
|
94
|
+
* row branch, never the hidden undeclared template or the dialog's matching checkbox. */
|
|
90
95
|
original: `(() => {
|
|
91
|
-
const el = document.
|
|
96
|
+
const el = [...document.querySelectorAll('#js_original input.weui-desktop-switch__input')]
|
|
97
|
+
.find(input => input.parentElement?.getClientRects().length > 0);
|
|
92
98
|
return el ? el.checked === true : null;
|
|
93
99
|
})()`,
|
|
94
100
|
/** 赞赏 enabled = the reward settings checkbox checked (area-scoped, else the visible cell's). */
|
|
@@ -133,7 +139,7 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
|
|
|
133
139
|
const on = (key: string) => meta[key] === true;
|
|
134
140
|
// Sweep any leaked dialog so the next step (especially 保存为草稿) is never overlay-blocked.
|
|
135
141
|
const sweep = async (): Promise<'closed' | 'unknown'> => {
|
|
136
|
-
const cancel = await trySelectors(page, ['button:has-text("取消")']);
|
|
142
|
+
const cancel = await trySelectors(page, ['button:has-text("取消")', URL_CANCEL]);
|
|
137
143
|
if (cancel.found) {
|
|
138
144
|
try {
|
|
139
145
|
await page
|
|
@@ -222,7 +228,7 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
|
|
|
222
228
|
if ((await readChecked(page, COMMITTED.original)) !== true) {
|
|
223
229
|
const outcome = await step(name, async () => {
|
|
224
230
|
await openRow(ROW_ORIGINAL, '原创声明');
|
|
225
|
-
// Measured 2026-10-
|
|
231
|
+
// Measured 2026-10-21: keep the 文字原创 radio, click the agreement's label when it renders,
|
|
226
232
|
// then 确定. The plain-text probe the old rail used could hit the dialog heading.
|
|
227
233
|
await clickSelector(page, ORIGINAL_DECLARATION, 5_000);
|
|
228
234
|
// trySelectors absorbs its own probe failures (returns found:false) — no .catch here.
|
|
@@ -230,7 +236,7 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
|
|
|
230
236
|
if (agreement.found) {
|
|
231
237
|
await clickSelector(page, ORIGINAL_AGREEMENT, 3_000);
|
|
232
238
|
}
|
|
233
|
-
await clickSelector(page,
|
|
239
|
+
await clickSelector(page, ORIGINAL_CONFIRM, 8_000);
|
|
234
240
|
});
|
|
235
241
|
if (
|
|
236
242
|
outcome !== 'capability' &&
|
|
@@ -305,27 +311,36 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
|
|
|
305
311
|
const originalUrl = str('originalUrl');
|
|
306
312
|
if (originalUrl !== '') {
|
|
307
313
|
const name = '原文链接';
|
|
314
|
+
const readUrl = async () => {
|
|
315
|
+
try {
|
|
316
|
+
return norm(await page.evaluate(URL_COMMITTED));
|
|
317
|
+
} catch {
|
|
318
|
+
return '';
|
|
319
|
+
}
|
|
320
|
+
};
|
|
308
321
|
// Measured 2026-10-13: some accounts no longer render the 原文链接 row at all. That is an
|
|
309
322
|
// unavailable capability — report it once, never fall back to the composer's unrelated
|
|
310
323
|
// `链接` row (`btm-card-link__switch`).
|
|
311
|
-
const urlRow =
|
|
324
|
+
const urlRow =
|
|
325
|
+
(await readUrl()) === norm(originalUrl) ? 'committed' : await tryFindSelector(page, ROW_URL, 2_000);
|
|
312
326
|
if (urlRow === undefined) {
|
|
313
327
|
warnings.push(`${name}: 行不存在(当前公众号不支持),请手动处理或移除 originalUrl`);
|
|
314
|
-
} else {
|
|
328
|
+
} else if (urlRow !== 'committed') {
|
|
315
329
|
await step(name, async () => {
|
|
330
|
+
if ((await readUrl()) === norm(originalUrl)) return;
|
|
316
331
|
await openRow(ROW_URL, '原文链接');
|
|
317
332
|
const field = await trySelectors(page, URL_INPUT);
|
|
318
|
-
if (!field.found) throw new Error('no URL field in the 原文链接
|
|
333
|
+
if (!field.found) throw new Error('no URL field in the 原文链接 popover');
|
|
319
334
|
await fillSelector(page, field.selector ?? URL_INPUT.join(', '), originalUrl);
|
|
320
|
-
await clickSelector(page,
|
|
335
|
+
await clickSelector(page, URL_CONFIRM, 8_000);
|
|
336
|
+
// URL validation closes the popover asynchronously. Sweeping before it closes
|
|
337
|
+
// cancels that pending commit and turns a valid URL into a false mismatch.
|
|
338
|
+
await page.locator(URL_POPOVER).first().waitFor?.({ state: 'hidden', timeout: 8_000 });
|
|
321
339
|
});
|
|
322
|
-
await
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
const committed =
|
|
327
|
-
typeof locator.inputValue === 'function' ? await locator.inputValue().catch(() => null) : null;
|
|
328
|
-
if (norm(committed) !== norm(originalUrl)) warnings.push(`${name}: 已提交但未读取到已保存的链接,请核对`);
|
|
340
|
+
if ((await readUrl()) !== norm(originalUrl)) warnings.push(`${name}: 已提交但未读取到已保存的链接,请核对`);
|
|
341
|
+
// Popovers are outside modalState's dialog inventory; release one even on a moved field
|
|
342
|
+
// or refused confirmation, before later settings and the draft save.
|
|
343
|
+
if ((await trySelectors(page, [URL_POPOVER])).found) await sweep();
|
|
329
344
|
}
|
|
330
345
|
}
|
|
331
346
|
|
|
@@ -343,17 +358,22 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
|
|
|
343
358
|
return null;
|
|
344
359
|
}
|
|
345
360
|
};
|
|
361
|
+
let unavailable = false;
|
|
346
362
|
if ((await claimMatched()) !== true) {
|
|
347
|
-
await step(name, async () => {
|
|
363
|
+
const outcome = await step(name, async () => {
|
|
348
364
|
await openRow(ROW_CLAIM, '创作来源');
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
await
|
|
365
|
+
await waitForVisible(page, DIALOG, 3_000);
|
|
366
|
+
const stance = `${DIALOG} label:has(input[type="radio"]):has(:text-is(${JSON.stringify(claimSource)}))`;
|
|
367
|
+
if ((await tryFindSelector(page, [stance], 1_500)) === undefined) {
|
|
368
|
+
await sweep();
|
|
369
|
+
throw new CapabilityError(`平台未提供声明类型「${claimSource}」,已跳过`);
|
|
353
370
|
}
|
|
371
|
+
await clickSelector(page, stance, 8_000);
|
|
372
|
+
await clickSelector(page, CLAIM_CONFIRM, 8_000);
|
|
354
373
|
});
|
|
374
|
+
unavailable = outcome === 'capability';
|
|
355
375
|
}
|
|
356
|
-
if ((await claimMatched()) !== true && !(await residual(name))) {
|
|
376
|
+
if (!unavailable && (await claimMatched()) !== true && !(await residual(name))) {
|
|
357
377
|
warnings.push(`${name}: 未生效,请手动设置`);
|
|
358
378
|
}
|
|
359
379
|
}
|
|
@@ -210,12 +210,25 @@ const AUDIO_UPLOAD_INPUT = [
|
|
|
210
210
|
'.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
|
|
211
211
|
'.audio_dialog input[type="file"][accept*="audio"]',
|
|
212
212
|
'input[type="file"][accept*=".mp3"]',
|
|
213
|
+
// 2026-10-21 measured: this build's upload form exposes a plain `input[type=file]` with NO accept
|
|
214
|
+
// attribute, so every accept-qualified candidate above missed and the run reported `音频: no audio
|
|
215
|
+
// file input appeared` — which costs the draft its audio player, the audio material's cover, and
|
|
216
|
+
// the library cover the 封面 step reuses. Dialog-scoped on purpose: the editor's own image input
|
|
217
|
+
// lives outside `.weui-desktop-dialog`, so it can never be the winner here.
|
|
218
|
+
'.weui-desktop-dialog input[type="file"]:not([accept*="image"])',
|
|
219
|
+
'.audio_dialog input[type="file"]:not([accept*="image"])',
|
|
213
220
|
];
|
|
214
221
|
const UPLOAD_COVER = ['text=上传封面', 'button:has-text("上传封面")'];
|
|
215
222
|
const AUDIO_COVER_INPUT = [
|
|
216
223
|
'.weui-desktop-dialog input[type="file"][accept*="image/"]',
|
|
217
224
|
'.audio_dialog input[type="file"][accept*="image/"]',
|
|
218
225
|
'input[type="file"][accept*=".png"]',
|
|
226
|
+
// 2026-10-21 measured: the upload form's cover input carries `image/bmp, image/png, image/jpeg,
|
|
227
|
+
// image/jpg, image/gif, image/webp` — space-separated MIME names WITHOUT a slash-terminated
|
|
228
|
+
// `image/` prefix match (`accept*="image/"` misses `image/bmp`), so the cover upload silently
|
|
229
|
+
// skipped on every run and the draft shipped with no cover.
|
|
230
|
+
'.weui-desktop-dialog input[type="file"][accept*="image"]',
|
|
231
|
+
'.audio_dialog input[type="file"][accept*="image"]',
|
|
219
232
|
];
|
|
220
233
|
const AUDIO_TITLE_INPUT = [
|
|
221
234
|
'.weui-desktop-dialog input[placeholder*="标题"]',
|
|
@@ -585,7 +598,11 @@ async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string
|
|
|
585
598
|
}
|
|
586
599
|
const option = await tryFindSelector(page, LAYOUT_TARGET, 5_000);
|
|
587
600
|
if (option === undefined) {
|
|
588
|
-
|
|
601
|
+
// Measured 2026-10-21 with a body in the editor: `div#ai_layout_container > div.ailayout-btn_wrp`
|
|
602
|
+
// is present and clickable, and no option set ever renders (only a hidden `ailayout-edu_tips`
|
|
603
|
+
// onboarding block). Nothing programmable is left to click, so name the finding instead of
|
|
604
|
+
// guessing at the body's formatting.
|
|
605
|
+
return '一键排版: the control exists but this build renders no 匹配目标格式 option (measured 2026-10-21 with a body in place) — layout skipped, apply 一键排版 by hand before publishing';
|
|
589
606
|
}
|
|
590
607
|
try {
|
|
591
608
|
await clickSelector(page, option, 5_000);
|
|
@@ -25,8 +25,14 @@ const LOGIN_TIMEOUT_MS = 180_000;
|
|
|
25
25
|
* at that instant — so the poll must not spend all of it: a dead session then dies mid-report and
|
|
26
26
|
* surfaces as `probe exit 1 (malformed probe output)` instead of `refused` (observed 2026-10-16, where
|
|
27
27
|
* the 30s poll plus overhead overran `kk plugin check`'s 30s default).
|
|
28
|
+
*
|
|
29
|
+
* 25s, not 8s (2026-10-21): a dead session spends the WHOLE poll before it can report, so the reserve
|
|
30
|
+
* has to cover a cold `KK_BROWSER_CHANNEL=chrome` start, not a warm one — at 8s the first
|
|
31
|
+
* `kk plugin check wechatmp-pub xhs-pub --timeout 60000` of the day SIGKILLed the probe at 60s and
|
|
32
|
+
* reported `malformed probe output` while the immediately following run answered the real
|
|
33
|
+
* `refused — Authentication was not confirmed` in the same 60s budget.
|
|
28
34
|
*/
|
|
29
|
-
const PROFILE_OVERHEAD_MS =
|
|
35
|
+
const PROFILE_OVERHEAD_MS = 25_000;
|
|
30
36
|
/** Floor for the readiness poll, so a tiny `--timeout` still probes the console instead of refusing blind. */
|
|
31
37
|
const MIN_READINESS_MS = 1_000;
|
|
32
38
|
|