@qcplay/cli 1.0.19 → 1.0.21
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/bin/qcplay.js +238 -3
- package/lib/platform-publish.js +611 -48
- package/package.json +1 -1
- package/templates/skills/qcplay-publish-article/SKILL.md +12 -0
package/bin/qcplay.js
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
availableProjects,
|
|
41
41
|
bilibiliPublishingPlan,
|
|
42
|
+
editPreparedPlatformPage,
|
|
42
43
|
entriesForProject,
|
|
43
44
|
loadPlatformEntries,
|
|
44
45
|
openPublishPage,
|
|
@@ -49,6 +50,9 @@ import {
|
|
|
49
50
|
runPlatformPublishSequence,
|
|
50
51
|
selectRequestedEntries,
|
|
51
52
|
tapTapPublishingPlan,
|
|
53
|
+
deleteDiscordMessages,
|
|
54
|
+
findDiscordMessageRecord,
|
|
55
|
+
removeDiscordMessageRecord,
|
|
52
56
|
weiboPublishingPlan,
|
|
53
57
|
writePlatformPublishUrls
|
|
54
58
|
} from "../lib/platform-publish.js";
|
|
@@ -184,6 +188,7 @@ const ARTICLE_STATE_FILE = path.join(QCPLAY_DIR, "article-state.json");
|
|
|
184
188
|
const CONTENT_RULES_FILE = DEFAULT_CONTENT_RULES_FILE;
|
|
185
189
|
const ARTICLE_PREVIEW_BASE_URL = "https://snail.qingcigame.com/official/news-details.html";
|
|
186
190
|
const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
|
|
191
|
+
const LARK_SKILL_NAMES = ["lark-shared", "lark-sheets", "lark-doc", "lark-wiki"];
|
|
187
192
|
const WEST_ARTICLE_LANGUAGES = new Set(["portuguese", "german", "spanish", "italian", "french", "america_en"]);
|
|
188
193
|
const ARTICLE_PREVIEW_URLS = {
|
|
189
194
|
"33": "https://tideng.qingcigame.com/official/news.html",
|
|
@@ -247,6 +252,8 @@ Usage:
|
|
|
247
252
|
qcplay-cli wechat article --game-id <id> [--game-id <id> ...] (--month <YYYY-MM> | --begin-date <YYYY-MM-DD> --end-date <YYYY-MM-DD>) [--output <file>] [--format markdown|json] [--local] [--backend <url>]
|
|
248
253
|
qcplay-cli publish [file] [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--project <项目>] [--region <区域>] [--platform <平台>]
|
|
249
254
|
qcplay-cli publish --project <项目> --list-platforms
|
|
255
|
+
qcplay-cli edit-page --project <项目> --region <区域> --platform <平台> --target <title|body> --find <原文> --replace <新文>
|
|
256
|
+
qcplay-cli discord delete <文章文件> --project <项目> --region <区域>
|
|
250
257
|
qcplay-cli rules [init|validate|list] [--rules-file <json>]
|
|
251
258
|
qcplay-cli www-article-list.store <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
|
|
252
259
|
qcplay-cli www-article-list.update [id] [--local] [--backend <url>] [--dry-run]
|
|
@@ -268,6 +275,14 @@ function printUpdateHelp() {
|
|
|
268
275
|
console.log(" qcplay-cli update");
|
|
269
276
|
}
|
|
270
277
|
|
|
278
|
+
function printEditPageHelp() {
|
|
279
|
+
console.log(`Usage:
|
|
280
|
+
qcplay-cli edit-page --project <项目> --region <区域> --platform <平台> --target <title|body> --find <原文> --replace <新文>
|
|
281
|
+
|
|
282
|
+
Edits one exact text occurrence in a platform editor page that is already prepared and still open.
|
|
283
|
+
This command never saves a draft or publishes.`);
|
|
284
|
+
}
|
|
285
|
+
|
|
271
286
|
function printAuthHelp() {
|
|
272
287
|
console.log("Usage:");
|
|
273
288
|
console.log(" qcplay-cli auth [login|status|logout] [--local] [--backend <url>] [--auth-page <url>]");
|
|
@@ -743,6 +758,25 @@ async function installSkills() {
|
|
|
743
758
|
};
|
|
744
759
|
}
|
|
745
760
|
|
|
761
|
+
async function detectLarkSkills() {
|
|
762
|
+
const installed = [];
|
|
763
|
+
const missing = [];
|
|
764
|
+
for (const name of LARK_SKILL_NAMES) {
|
|
765
|
+
const skillFile = path.join(SKILLS_DIR, name, "SKILL.md");
|
|
766
|
+
(await pathExists(skillFile) ? installed : missing).push(name);
|
|
767
|
+
}
|
|
768
|
+
return { installed, missing };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function printLarkSkillStatus(status) {
|
|
772
|
+
if (status.missing.length === 0) {
|
|
773
|
+
console.log(chalk.green("Feishu Skills detected: lark-shared, lark-sheets, lark-doc, lark-wiki"));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
console.log(chalk.yellow(`Feishu Skills incomplete; missing: ${status.missing.join(", ")}`));
|
|
777
|
+
console.log(chalk.gray("Install command: npx skills add larksuite/cli -g -y"));
|
|
778
|
+
}
|
|
779
|
+
|
|
746
780
|
async function saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode = false) {
|
|
747
781
|
await writeJson(CONFIG_FILE, {
|
|
748
782
|
cli: "qcplay-cli",
|
|
@@ -1079,6 +1113,139 @@ function parseDistributionOptions(args) {
|
|
|
1079
1113
|
return { file: positional[0], options };
|
|
1080
1114
|
}
|
|
1081
1115
|
|
|
1116
|
+
function parseEditPageOptions(args) {
|
|
1117
|
+
const options = {
|
|
1118
|
+
project: "",
|
|
1119
|
+
region: "",
|
|
1120
|
+
platform: "",
|
|
1121
|
+
target: "body",
|
|
1122
|
+
find: "",
|
|
1123
|
+
replacement: "",
|
|
1124
|
+
configFile: "",
|
|
1125
|
+
browserChannel: ""
|
|
1126
|
+
};
|
|
1127
|
+
const valueFlags = new Map([
|
|
1128
|
+
["--project", "project"],
|
|
1129
|
+
["--region", "region"],
|
|
1130
|
+
["--platform", "platform"],
|
|
1131
|
+
["--target", "target"],
|
|
1132
|
+
["--find", "find"],
|
|
1133
|
+
["--replace", "replacement"],
|
|
1134
|
+
["--config-file", "configFile"],
|
|
1135
|
+
["--browser-channel", "browserChannel"]
|
|
1136
|
+
]);
|
|
1137
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1138
|
+
const current = args[index];
|
|
1139
|
+
const key = valueFlags.get(current);
|
|
1140
|
+
if (!key) throw new Error(`未知参数: ${current}`);
|
|
1141
|
+
const value = args[index + 1];
|
|
1142
|
+
if (!value || value.startsWith("--")) throw new Error(`${current} 缺少参数值`);
|
|
1143
|
+
options[key] = value;
|
|
1144
|
+
index += 1;
|
|
1145
|
+
}
|
|
1146
|
+
for (const key of ["project", "region", "platform", "find", "replacement"]) {
|
|
1147
|
+
if (!String(options[key]).trim()) throw new Error(`edit-page 缺少 --${key === "replacement" ? "replace" : key}`);
|
|
1148
|
+
}
|
|
1149
|
+
if (!new Set(["title", "body"]).has(String(options.target).trim().toLowerCase())) {
|
|
1150
|
+
throw new Error("--target 只能是 title 或 body");
|
|
1151
|
+
}
|
|
1152
|
+
return options;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/*
|
|
1156
|
+
function parseDiscordDeleteOptions(args) {
|
|
1157
|
+
const positional = [];
|
|
1158
|
+
const options = { project: "", region: "", platform: "Discord" };
|
|
1159
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1160
|
+
const current = args[index];
|
|
1161
|
+
if (current === "--project" || current === "--region" || current === "--platform") {
|
|
1162
|
+
const next = args[index + 1];
|
|
1163
|
+
if (!next || next.startsWith("--")) throw new Error(`${current} 缂哄皯鍙傛暟鍊糮);
|
|
1164
|
+
options[current.slice(2)] = next;
|
|
1165
|
+
index += 1;
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (current.startsWith("--")) throw new Error(`鏈煡鍙傛暟: ${current}`);
|
|
1169
|
+
positional.push(current);
|
|
1170
|
+
}
|
|
1171
|
+
if (positional.length !== 1) throw new Error("discord delete 闇€瑕佹寚瀹氫竴涓枃绔犳枃浠?");
|
|
1172
|
+
if (!options.project || !options.region) throw new Error("discord delete 闇€瑕佹寚瀹氭爣椤圭洰鍜屽尯鍩?");
|
|
1173
|
+
return { file: positional[0], options };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function deleteDiscordCommand(file, options) {
|
|
1177
|
+
const parsedArticle = await parseArticleFile(file);
|
|
1178
|
+
const allEntries = await loadPlatformEntries({});
|
|
1179
|
+
const candidates = entriesForProject(allEntries, options.project, options.region);
|
|
1180
|
+
const selectedEntries = selectRequestedEntries(candidates, [options.platform]);
|
|
1181
|
+
const entry = selectedEntries.find(candidate => candidate.platformKey === "discord");
|
|
1182
|
+
if (!entry || entry.publisher !== "discord-webhook") {
|
|
1183
|
+
throw new Error(`${options.project} / ${options.region} 娌℃湁鍙敤鐨?Discord Webhook 閰嶇疆`);
|
|
1184
|
+
}
|
|
1185
|
+
const article = {
|
|
1186
|
+
title: parsedArticle.payload.article_title,
|
|
1187
|
+
markdown: parsedArticle.markdown,
|
|
1188
|
+
html: parsedArticle.payload.article_content,
|
|
1189
|
+
payload: parsedArticle.payload,
|
|
1190
|
+
meta: parsedArticle.meta
|
|
1191
|
+
};
|
|
1192
|
+
const record = await findDiscordMessageRecord(article, entry);
|
|
1193
|
+
if (!record) {
|
|
1194
|
+
throw new Error("娌℃湁鎵惧埌杩欑瘒鏂囩珷鐨?Discord 鍙戝竷璁板綍锛涚幇鏈夊彂甯冩棤娉曠敤 CLI 鑷姩瀹氫綅");
|
|
1195
|
+
}
|
|
1196
|
+
const result = await deleteDiscordMessages(entry, record.messageIds);
|
|
1197
|
+
await removeDiscordMessageRecord(article, entry);
|
|
1198
|
+
console.log(chalk.green(`Discord 消息已删除: ${result.deleted} 条`));
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
}
|
|
1202
|
+
*/
|
|
1203
|
+
|
|
1204
|
+
function parseDiscordDeleteOptions(args) {
|
|
1205
|
+
const positional = [];
|
|
1206
|
+
const options = { project: "", region: "", platform: "Discord" };
|
|
1207
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1208
|
+
const current = args[index];
|
|
1209
|
+
if (current === "--project" || current === "--region" || current === "--platform") {
|
|
1210
|
+
const next = args[index + 1];
|
|
1211
|
+
if (!next || next.startsWith("--")) throw new Error(`${current} requires a value`);
|
|
1212
|
+
options[current.slice(2)] = next;
|
|
1213
|
+
index += 1;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (current.startsWith("--")) throw new Error(`Unknown option: ${current}`);
|
|
1217
|
+
positional.push(current);
|
|
1218
|
+
}
|
|
1219
|
+
if (positional.length !== 1) throw new Error("discord delete requires exactly one article file");
|
|
1220
|
+
if (!options.project || !options.region) throw new Error("discord delete requires --project and --region");
|
|
1221
|
+
return { file: positional[0], options };
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function deleteDiscordCommand(file, options) {
|
|
1225
|
+
const parsedArticle = await parseArticleFile(file);
|
|
1226
|
+
const allEntries = await loadPlatformEntries({});
|
|
1227
|
+
const candidates = entriesForProject(allEntries, options.project, options.region);
|
|
1228
|
+
const selectedEntries = selectRequestedEntries(candidates, [options.platform]);
|
|
1229
|
+
const entry = selectedEntries.find(candidate => candidate.platformKey === "discord");
|
|
1230
|
+
if (!entry || entry.publisher !== "discord-webhook") {
|
|
1231
|
+
throw new Error(`${options.project} / ${options.region} has no usable Discord Webhook configuration`);
|
|
1232
|
+
}
|
|
1233
|
+
const article = {
|
|
1234
|
+
title: parsedArticle.payload.article_title,
|
|
1235
|
+
markdown: parsedArticle.markdown,
|
|
1236
|
+
html: parsedArticle.payload.article_content,
|
|
1237
|
+
payload: parsedArticle.payload,
|
|
1238
|
+
meta: parsedArticle.meta
|
|
1239
|
+
};
|
|
1240
|
+
const record = await findDiscordMessageRecord(article, entry);
|
|
1241
|
+
if (!record) {
|
|
1242
|
+
throw new Error("No Discord publish record found for this article; older messages cannot be located automatically");
|
|
1243
|
+
}
|
|
1244
|
+
const result = await deleteDiscordMessages(entry, record.messageIds);
|
|
1245
|
+
await removeDiscordMessageRecord(article, entry);
|
|
1246
|
+
console.log(chalk.green(`Discord messages deleted: ${result.deleted}`));
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1082
1249
|
function applyPublishOverrides(parsedArticle, options) {
|
|
1083
1250
|
const title = normalizeText(options.title);
|
|
1084
1251
|
const cover = normalizeText(options.cover);
|
|
@@ -1206,6 +1373,10 @@ function getNpmCommand() {
|
|
|
1206
1373
|
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
1207
1374
|
}
|
|
1208
1375
|
|
|
1376
|
+
function getNpxCommand() {
|
|
1377
|
+
return process.platform === "win32" ? "npx.cmd" : "npx";
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1209
1380
|
function runCommand(command, args) {
|
|
1210
1381
|
return new Promise((resolve, reject) => {
|
|
1211
1382
|
const child = spawn(command, args, {
|
|
@@ -1224,6 +1395,11 @@ function runCommand(command, args) {
|
|
|
1224
1395
|
});
|
|
1225
1396
|
}
|
|
1226
1397
|
|
|
1398
|
+
async function installLarkCli() {
|
|
1399
|
+
console.log(chalk.cyan("Installing/updating Feishu CLI and skills..."));
|
|
1400
|
+
await runCommand(getNpxCommand(), ["@larksuite/cli@latest", "install"]);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1227
1403
|
function delay(ms) {
|
|
1228
1404
|
return new Promise(resolve => {
|
|
1229
1405
|
setTimeout(resolve, ms);
|
|
@@ -1676,6 +1852,7 @@ async function installCommand(options) {
|
|
|
1676
1852
|
console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
|
|
1677
1853
|
|
|
1678
1854
|
const skillsInstalled = await installSkills();
|
|
1855
|
+
await installLarkCli();
|
|
1679
1856
|
if (skillsInstalled.available) {
|
|
1680
1857
|
console.log(chalk.green("✔ Skills 已安装"));
|
|
1681
1858
|
console.log(`Skills 目录: ${chalk.gray(SKILLS_DIR)}`);
|
|
@@ -1683,6 +1860,7 @@ async function installCommand(options) {
|
|
|
1683
1860
|
console.log(chalk.yellow("! 未找到 Skills 模板,已跳过"));
|
|
1684
1861
|
}
|
|
1685
1862
|
|
|
1863
|
+
printLarkSkillStatus(await detectLarkSkills());
|
|
1686
1864
|
await saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode);
|
|
1687
1865
|
console.log(chalk.green("✔ 配置文件已生成"));
|
|
1688
1866
|
console.log(chalk.gray(`认证后端: ${authBackendUrl}`));
|
|
@@ -1732,6 +1910,7 @@ async function updateCommand() {
|
|
|
1732
1910
|
const latestPackage = readPackageMetadata();
|
|
1733
1911
|
await updateStoredConfigVersion(latestPackage.version);
|
|
1734
1912
|
const skillsInstalled = await installSkills();
|
|
1913
|
+
await installLarkCli();
|
|
1735
1914
|
|
|
1736
1915
|
console.log("");
|
|
1737
1916
|
console.log(chalk.green("CLI 更新完成"));
|
|
@@ -1741,6 +1920,7 @@ async function updateCommand() {
|
|
|
1741
1920
|
console.log(chalk.gray(`版本: ${latestPackage.version}`));
|
|
1742
1921
|
}
|
|
1743
1922
|
printSkillsSyncResult(skillsInstalled);
|
|
1923
|
+
printLarkSkillStatus(await detectLarkSkills());
|
|
1744
1924
|
console.log(chalk.green(rulesCreated ? "✔ 用户内容规则已创建" : "✔ 用户内容规则未覆盖"));
|
|
1745
1925
|
console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
|
|
1746
1926
|
console.log("");
|
|
@@ -2253,7 +2433,10 @@ async function parseArticleFile(file) {
|
|
|
2253
2433
|
}
|
|
2254
2434
|
|
|
2255
2435
|
const raw = await fs.promises.readFile(articleFile, "utf8");
|
|
2256
|
-
|
|
2436
|
+
const looksLikeQcplayHtml =
|
|
2437
|
+
/^(?:\uFEFF)?\s*(?:<!doctype\s+html\b|<html\b)/i.test(raw) &&
|
|
2438
|
+
/(?:id=["']qcplay-article-meta["']|data-qcplay-article-content=["']1["'])/i.test(raw);
|
|
2439
|
+
if (path.extname(articleFile).toLowerCase() === ".html" || looksLikeQcplayHtml) {
|
|
2257
2440
|
const { meta, richHtml } = parseRichArticleUploadFile(raw);
|
|
2258
2441
|
return {
|
|
2259
2442
|
articleFile,
|
|
@@ -2525,9 +2708,12 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2525
2708
|
parsedArticle = await parseArticleFile(file);
|
|
2526
2709
|
parsedArticle = applyPublishOverrides(parsedArticle, options);
|
|
2527
2710
|
}
|
|
2528
|
-
const allEntries = await loadPlatformEntries({ configFile: options.configFile });
|
|
2529
|
-
|
|
2530
2711
|
const project = inferDistributionProject(parsedArticle?.payload, options.project, parsedArticle?.meta);
|
|
2712
|
+
const allEntries = await loadPlatformEntries({
|
|
2713
|
+
configFile: options.configFile,
|
|
2714
|
+
project,
|
|
2715
|
+
region: options.region
|
|
2716
|
+
});
|
|
2531
2717
|
if (!project) {
|
|
2532
2718
|
if (options.listPlatforms) {
|
|
2533
2719
|
printPlatformEntries(allEntries);
|
|
@@ -2753,6 +2939,29 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2753
2939
|
}
|
|
2754
2940
|
}
|
|
2755
2941
|
|
|
2942
|
+
async function editPreparedPageCommand(options) {
|
|
2943
|
+
const allEntries = await loadPlatformEntries({ configFile: options.configFile });
|
|
2944
|
+
const candidates = entriesForProject(allEntries, options.project, options.region);
|
|
2945
|
+
const selected = selectRequestedEntries(candidates, [options.platform]);
|
|
2946
|
+
if (selected.length !== 1 || selected[0].publisher !== "browser") {
|
|
2947
|
+
throw new Error(`${options.platform} 没有可编辑的浏览器发布页面配置`);
|
|
2948
|
+
}
|
|
2949
|
+
const entry = selected[0];
|
|
2950
|
+
const result = await editPreparedPlatformPage(
|
|
2951
|
+
entry,
|
|
2952
|
+
{ target: options.target, find: options.find, replacement: options.replacement },
|
|
2953
|
+
{ browserChannel: options.browserChannel, onProgress: message => console.log(chalk.gray(` ${message}`)) }
|
|
2954
|
+
);
|
|
2955
|
+
console.log(
|
|
2956
|
+
JSON.stringify({
|
|
2957
|
+
event: "qcplay.page-edit-complete",
|
|
2958
|
+
platform: entry.platform,
|
|
2959
|
+
target: result.target,
|
|
2960
|
+
url: result.url
|
|
2961
|
+
})
|
|
2962
|
+
);
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2756
2965
|
async function publishArticleCommand(file, options, platformEntry = null, expectedTarget = "") {
|
|
2757
2966
|
if (!file) {
|
|
2758
2967
|
throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
|
|
@@ -2959,6 +3168,32 @@ async function main() {
|
|
|
2959
3168
|
return;
|
|
2960
3169
|
}
|
|
2961
3170
|
|
|
3171
|
+
if (command === "edit-page") {
|
|
3172
|
+
if (subcommand === "-h" || subcommand === "--help") {
|
|
3173
|
+
printEditPageHelp();
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
const options = parseEditPageOptions([subcommand, ...rest].filter(Boolean));
|
|
3177
|
+
await runWithErrorBanner("编辑已准备页面失败", () => editPreparedPageCommand(options));
|
|
3178
|
+
return;
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
if (command === "discord") {
|
|
3182
|
+
if (subcommand === "-h" || subcommand === "--help") {
|
|
3183
|
+
console.log("Usage:\n qcplay-cli discord delete <文章文件> --project <项目> --region <区域>");
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
if (subcommand !== "delete") {
|
|
3187
|
+
await runWithErrorBanner("Discord 操作失败", async () => {
|
|
3188
|
+
throw new Error(`未知 discord 命令: ${subcommand || ""}`);
|
|
3189
|
+
});
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
const parsed = parseDiscordDeleteOptions(rest);
|
|
3193
|
+
await runWithErrorBanner("Discord 删除失败", () => deleteDiscordCommand(parsed.file, parsed.options));
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3196
|
+
|
|
2962
3197
|
if (command === "rules") {
|
|
2963
3198
|
if (!subcommand || subcommand === "-h" || subcommand === "--help") {
|
|
2964
3199
|
printRulesHelp();
|
package/lib/platform-publish.js
CHANGED
|
@@ -12,7 +12,60 @@ import { applyContentRules } from "./content-rules.js";
|
|
|
12
12
|
import { stripWechatGuidanceHtml } from "./wechat-article.js";
|
|
13
13
|
|
|
14
14
|
const PLATFORM_CONFIG_URL = "https://t4blw8ys5w.feishu.cn/wiki/OqH7wkx9PiBaTYkKeWNc35SonGf";
|
|
15
|
+
const DOMESTIC_REGION = "国内";
|
|
16
|
+
const DOMESTIC_PLATFORM_ROWS = [
|
|
17
|
+
{
|
|
18
|
+
项目: "最强蜗牛",
|
|
19
|
+
区域: DOMESTIC_REGION,
|
|
20
|
+
平台: "TapTap",
|
|
21
|
+
页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=187376&group_id=227162&show_activity=1"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
项目: "提灯与地下城",
|
|
25
|
+
区域: DOMESTIC_REGION,
|
|
26
|
+
平台: "TapTap",
|
|
27
|
+
页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=149961&group_id=177149&show_activity=1"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
项目: "阿瑞斯病毒2",
|
|
31
|
+
区域: DOMESTIC_REGION,
|
|
32
|
+
平台: "TapTap",
|
|
33
|
+
页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=387841&group_id=663910&show_activity=1"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
项目: "迷途之光",
|
|
37
|
+
区域: DOMESTIC_REGION,
|
|
38
|
+
平台: "TapTap",
|
|
39
|
+
页面URL链接: "https://www.taptap.cn/creator/edit?type=moment&app_id=600170&show_activity=1"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
项目: "最强蜗牛",
|
|
43
|
+
区域: DOMESTIC_REGION,
|
|
44
|
+
平台: "好游快爆",
|
|
45
|
+
页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=12201&type=1"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
项目: "新仙剑",
|
|
49
|
+
区域: DOMESTIC_REGION,
|
|
50
|
+
平台: "好游快爆",
|
|
51
|
+
页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=24008&type=1"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
项目: "阿瑞斯病毒2",
|
|
55
|
+
区域: DOMESTIC_REGION,
|
|
56
|
+
平台: "好游快爆",
|
|
57
|
+
页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=20313&type=1"
|
|
58
|
+
}
|
|
59
|
+
];
|
|
60
|
+
const DOMESTIC_STANDARD_PLATFORM_ROWS = ["最强蜗牛", "提灯与地下城", "阿瑞斯病毒2", "新仙剑", "迷途之光", "魔卡少女樱"].flatMap(
|
|
61
|
+
项目 => [
|
|
62
|
+
{ 项目, 区域: DOMESTIC_REGION, 平台: "B站", 页面URL链接: "https://member.bilibili.com/platform/upload/text/new-edit" },
|
|
63
|
+
{ 项目, 区域: DOMESTIC_REGION, 平台: "微博", 页面URL链接: "https://card.weibo.com/article/v5/editor" },
|
|
64
|
+
{ 项目, 区域: DOMESTIC_REGION, 平台: "小红书", 页面URL链接: "https://creator.xiaohongshu.com/publish/publish?from=menu&target=image" }
|
|
65
|
+
]
|
|
66
|
+
);
|
|
15
67
|
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
68
|
+
const DISCORD_MESSAGE_STATE_FILE = path.join(os.homedir(), ".qcplay", "discord-messages.json");
|
|
16
69
|
const BROWSER_SUBMISSION_COOLDOWN_MS = 60_000;
|
|
17
70
|
const BROWSER_SUBMISSION_STATE_FILE = path.join(os.homedir(), ".qcplay", "browser-submissions.json");
|
|
18
71
|
const ARES_PROJECT = "阿瑞斯病毒2";
|
|
@@ -458,6 +511,10 @@ function runJson(command, args) {
|
|
|
458
511
|
});
|
|
459
512
|
}
|
|
460
513
|
|
|
514
|
+
function domesticPlatformRows() {
|
|
515
|
+
return normalizePlatformRows([...DOMESTIC_PLATFORM_ROWS, ...DOMESTIC_STANDARD_PLATFORM_ROWS]);
|
|
516
|
+
}
|
|
517
|
+
|
|
461
518
|
export async function loadPlatformEntries(options = {}) {
|
|
462
519
|
if (options.configFile) {
|
|
463
520
|
const configFile = path.resolve(process.cwd(), options.configFile);
|
|
@@ -472,6 +529,13 @@ export async function loadPlatformEntries(options = {}) {
|
|
|
472
529
|
return normalizePlatformRows(rows);
|
|
473
530
|
}
|
|
474
531
|
|
|
532
|
+
const domesticEntries = domesticPlatformRows();
|
|
533
|
+
const requestedRegion = normalizeKey(options.region || "");
|
|
534
|
+
const requestedProject = normalizeProjectKey(options.project || "");
|
|
535
|
+
if ((!requestedRegion || requestedRegion === normalizeKey(DOMESTIC_REGION)) && (!requestedProject || entriesForProject(domesticEntries, options.project).length > 0)) {
|
|
536
|
+
return domesticEntries;
|
|
537
|
+
}
|
|
538
|
+
|
|
475
539
|
const workbook = await runJson("lark-cli", [
|
|
476
540
|
"sheets",
|
|
477
541
|
"+workbook-info",
|
|
@@ -663,7 +727,11 @@ function postJson(urlValue, payload) {
|
|
|
663
727
|
reject(new Error(`Discord webhook 返回 ${response.statusCode}: ${responseBody.slice(0, 160)}`));
|
|
664
728
|
return;
|
|
665
729
|
}
|
|
666
|
-
|
|
730
|
+
try {
|
|
731
|
+
resolve(responseBody ? JSON.parse(responseBody) : {});
|
|
732
|
+
} catch {
|
|
733
|
+
resolve({});
|
|
734
|
+
}
|
|
667
735
|
});
|
|
668
736
|
}
|
|
669
737
|
);
|
|
@@ -672,6 +740,110 @@ function postJson(urlValue, payload) {
|
|
|
672
740
|
});
|
|
673
741
|
}
|
|
674
742
|
|
|
743
|
+
function deleteJson(urlValue) {
|
|
744
|
+
return new Promise((resolve, reject) => {
|
|
745
|
+
const url = new URL(urlValue);
|
|
746
|
+
const request = https.request(
|
|
747
|
+
url,
|
|
748
|
+
{
|
|
749
|
+
method: "DELETE",
|
|
750
|
+
headers: {
|
|
751
|
+
Accept: "application/json",
|
|
752
|
+
"User-Agent": "qcplay-cli"
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
response => {
|
|
756
|
+
const chunks = [];
|
|
757
|
+
response.on("data", chunk => chunks.push(chunk));
|
|
758
|
+
response.on("end", () => {
|
|
759
|
+
const responseBody = Buffer.concat(chunks).toString("utf8");
|
|
760
|
+
if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) {
|
|
761
|
+
reject(new Error(`Discord webhook 删除消息返回 ${response.statusCode}: ${responseBody.slice(0, 160)}`));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
resolve();
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
);
|
|
768
|
+
request.once("error", reject);
|
|
769
|
+
request.end();
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function discordMessageStateKey(entry, article) {
|
|
774
|
+
const sourceUrl = normalizeText(article.meta?.source_url || article.payload?.source_url);
|
|
775
|
+
return [entry.project, entry.region, entry.platformKey || "discord", sourceUrl || article.title].map(normalizeText).join("\u0000");
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function readDiscordMessageState() {
|
|
779
|
+
try {
|
|
780
|
+
const value = JSON.parse(await fs.promises.readFile(DISCORD_MESSAGE_STATE_FILE, "utf8"));
|
|
781
|
+
return Array.isArray(value?.records) ? value.records : [];
|
|
782
|
+
} catch {
|
|
783
|
+
return [];
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function writeDiscordMessageState(records) {
|
|
788
|
+
await fs.promises.mkdir(path.dirname(DISCORD_MESSAGE_STATE_FILE), { recursive: true, mode: 0o700 });
|
|
789
|
+
await fs.promises.writeFile(
|
|
790
|
+
DISCORD_MESSAGE_STATE_FILE,
|
|
791
|
+
`${JSON.stringify({ version: 1, records }, null, 2)}\n`,
|
|
792
|
+
{ encoding: "utf8", mode: 0o600 }
|
|
793
|
+
);
|
|
794
|
+
await fs.promises.chmod(DISCORD_MESSAGE_STATE_FILE, 0o600).catch(() => {});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function recordDiscordMessages(entry, article, messageIds) {
|
|
798
|
+
if (!messageIds.length) return;
|
|
799
|
+
const record = {
|
|
800
|
+
key: discordMessageStateKey(entry, article),
|
|
801
|
+
project: normalizeText(entry.project),
|
|
802
|
+
region: normalizeText(entry.region),
|
|
803
|
+
platform: normalizeText(entry.platform),
|
|
804
|
+
platformKey: entry.platformKey || "discord",
|
|
805
|
+
title: normalizeText(article.title),
|
|
806
|
+
sourceUrl: normalizeText(article.meta?.source_url || article.payload?.source_url),
|
|
807
|
+
messageIds,
|
|
808
|
+
createdAt: new Date().toISOString()
|
|
809
|
+
};
|
|
810
|
+
const records = (await readDiscordMessageState()).filter(item => item?.key !== record.key);
|
|
811
|
+
records.unshift(record);
|
|
812
|
+
await writeDiscordMessageState(records.slice(0, 100));
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export async function deleteDiscordMessages(entry, messageIds, options = {}) {
|
|
816
|
+
const webhook = discordWebhookFromEntry(entry);
|
|
817
|
+
if (!webhook) {
|
|
818
|
+
throw new Error("Discord 配置缺少 webhook,无法删除消息");
|
|
819
|
+
}
|
|
820
|
+
const ids = [...new Set(messageIds.map(value => normalizeText(value)).filter(Boolean))];
|
|
821
|
+
if (!ids.length) {
|
|
822
|
+
throw new Error("没有可删除的 Discord 消息 ID");
|
|
823
|
+
}
|
|
824
|
+
const remove = options.deleteJson || (messageId => {
|
|
825
|
+
const url = new URL(webhook);
|
|
826
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/messages/${encodeURIComponent(messageId)}`;
|
|
827
|
+
return deleteJson(url.toString());
|
|
828
|
+
});
|
|
829
|
+
for (const messageId of ids) {
|
|
830
|
+
await remove(messageId);
|
|
831
|
+
}
|
|
832
|
+
return { deleted: ids.length };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export async function findDiscordMessageRecord(article, entry) {
|
|
836
|
+
const key = discordMessageStateKey(entry, article);
|
|
837
|
+
const records = await readDiscordMessageState();
|
|
838
|
+
return records.find(item => item?.key === key) || null;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export async function removeDiscordMessageRecord(article, entry) {
|
|
842
|
+
const key = discordMessageStateKey(entry, article);
|
|
843
|
+
const records = (await readDiscordMessageState()).filter(item => item?.key !== key);
|
|
844
|
+
await writeDiscordMessageState(records);
|
|
845
|
+
}
|
|
846
|
+
|
|
675
847
|
export async function publishDiscord(entry, article, options = {}) {
|
|
676
848
|
const webhook = discordWebhookFromEntry(entry);
|
|
677
849
|
if (!webhook) {
|
|
@@ -683,21 +855,56 @@ export async function publishDiscord(entry, article, options = {}) {
|
|
|
683
855
|
: preparedArticle.markdown;
|
|
684
856
|
const chunks = splitDiscordContent(preparedArticle.title, markdown);
|
|
685
857
|
const send = options.postJson || postJson;
|
|
858
|
+
const messageIds = [];
|
|
686
859
|
for (const chunk of chunks) {
|
|
687
|
-
await send(webhook
|
|
860
|
+
const response = await send(`${webhook}${webhook.includes("?") ? "&" : "?"}wait=true`, {
|
|
861
|
+
content: chunk,
|
|
862
|
+
allowed_mentions: { parse: [] }
|
|
863
|
+
});
|
|
864
|
+
const messageId = normalizeText(response?.id);
|
|
865
|
+
if (messageId) messageIds.push(messageId);
|
|
866
|
+
}
|
|
867
|
+
let messageRecordSaved = false;
|
|
868
|
+
if (messageIds.length === chunks.length) {
|
|
869
|
+
try {
|
|
870
|
+
await recordDiscordMessages(entry, preparedArticle, messageIds);
|
|
871
|
+
messageRecordSaved = true;
|
|
872
|
+
} catch {
|
|
873
|
+
// A successful Discord post must not be reported as failed when local
|
|
874
|
+
// bookkeeping is unavailable; deletion can still be done manually.
|
|
875
|
+
}
|
|
688
876
|
}
|
|
689
|
-
return { messages: chunks.length };
|
|
877
|
+
return { messages: chunks.length, messageIds, messageRecordSaved };
|
|
690
878
|
}
|
|
691
879
|
|
|
692
880
|
function plainTextForPlatform(article, platformKey) {
|
|
693
881
|
const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
|
|
882
|
+
// WeChat exports decorative empty spans containing <br> for layout. They
|
|
883
|
+
// must not become real line breaks in native editors; preserve only breaks
|
|
884
|
+
// that sit inside a node with actual text or media content.
|
|
885
|
+
$("br").each((_, element) => {
|
|
886
|
+
const parent = $(element).parent();
|
|
887
|
+
const hasMeaningfulSibling = parent
|
|
888
|
+
.contents()
|
|
889
|
+
.toArray()
|
|
890
|
+
.some(node => {
|
|
891
|
+
if (node === element) return false;
|
|
892
|
+
if (node.type === "text") return normalizeText(node.data).trim().length > 0;
|
|
893
|
+
if (node.type !== "tag") return false;
|
|
894
|
+
return $(node).is("img") || normalizeText($(node).text()).trim().length > 0;
|
|
895
|
+
});
|
|
896
|
+
if (!hasMeaningfulSibling) {
|
|
897
|
+
$(element).remove();
|
|
898
|
+
}
|
|
899
|
+
});
|
|
694
900
|
$("br").replaceWith("\n");
|
|
695
901
|
$("p,section,div,h1,h2,h3,h4,h5,h6,li,blockquote,tr").each((_, element) => {
|
|
696
902
|
$(element).append("\n");
|
|
697
903
|
});
|
|
698
904
|
$("img").each((_, element) => {
|
|
699
905
|
const image = $(element);
|
|
700
|
-
image.
|
|
906
|
+
const source = image.attr("src") || image.attr("data-qcplay-src") || image.attr("data-src") || image.attr("data-original");
|
|
907
|
+
image.replaceWith(source ? `\n${source}\n` : "");
|
|
701
908
|
});
|
|
702
909
|
const markdown = normalizeText($.root().text() || article.markdown)
|
|
703
910
|
.replace(/[ \t]+\n/g, "\n")
|
|
@@ -1619,6 +1826,8 @@ export function resolveWeiboSuperTopic(article, entry = {}) {
|
|
|
1619
1826
|
}
|
|
1620
1827
|
|
|
1621
1828
|
export function resolveWeiboShareCopy(article, entry = {}) {
|
|
1829
|
+
const configured = weiboMeta(article, "weibo_share_copy", "weibo_share_text");
|
|
1830
|
+
if (configured) return configured;
|
|
1622
1831
|
const title = resolveWeiboArticleTitle(article);
|
|
1623
1832
|
const project = normalizeText(entry.project);
|
|
1624
1833
|
if (project === "最强蜗牛") {
|
|
@@ -1913,8 +2122,13 @@ export function browserPlatformPageUrl(entry, type = "topic") {
|
|
|
1913
2122
|
|
|
1914
2123
|
function imageSourcesFromArticle(article) {
|
|
1915
2124
|
const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
|
|
1916
|
-
return $("img
|
|
1917
|
-
.map((_, element) =>
|
|
2125
|
+
return $("img")
|
|
2126
|
+
.map((_, element) => {
|
|
2127
|
+
const image = $(element);
|
|
2128
|
+
return normalizeText(
|
|
2129
|
+
image.attr("src") || image.attr("data-qcplay-src") || image.attr("data-src") || image.attr("data-original")
|
|
2130
|
+
);
|
|
2131
|
+
})
|
|
1918
2132
|
.get()
|
|
1919
2133
|
.filter(Boolean);
|
|
1920
2134
|
}
|
|
@@ -2377,7 +2591,12 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
2377
2591
|
});
|
|
2378
2592
|
}
|
|
2379
2593
|
for (const element of tableImages) {
|
|
2380
|
-
const source = normalizeText(
|
|
2594
|
+
const source = normalizeText(
|
|
2595
|
+
$(element).attr("src") ||
|
|
2596
|
+
$(element).attr("data-qcplay-src") ||
|
|
2597
|
+
$(element).attr("data-src") ||
|
|
2598
|
+
$(element).attr("data-original")
|
|
2599
|
+
);
|
|
2381
2600
|
if (!source) continue;
|
|
2382
2601
|
const caption = normalizeText($(element).closest("figure").find("figcaption").first().text()) ||
|
|
2383
2602
|
normalizeText($(element).attr("alt") || $(element).attr("title")).slice(0, 50);
|
|
@@ -2407,7 +2626,12 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
2407
2626
|
}
|
|
2408
2627
|
if (tag === "img") {
|
|
2409
2628
|
flushHtml();
|
|
2410
|
-
const source = normalizeText(
|
|
2629
|
+
const source = normalizeText(
|
|
2630
|
+
element.attr("src") ||
|
|
2631
|
+
element.attr("data-qcplay-src") ||
|
|
2632
|
+
element.attr("data-src") ||
|
|
2633
|
+
element.attr("data-original")
|
|
2634
|
+
);
|
|
2411
2635
|
if (source) {
|
|
2412
2636
|
const caption = normalizeText(element.closest("figure").find("figcaption").first().text()) ||
|
|
2413
2637
|
normalizeText(element.attr("alt") || element.attr("title")).slice(0, 50);
|
|
@@ -2599,11 +2823,74 @@ function normalizeNumberedSectionHeadings(items) {
|
|
|
2599
2823
|
}
|
|
2600
2824
|
|
|
2601
2825
|
export function buildBilibiliRichContent(article) {
|
|
2602
|
-
|
|
2826
|
+
const content = buildPlatformRichContent(article, {
|
|
2603
2827
|
preserveBackground: true,
|
|
2604
2828
|
preserveBlockStyles: true,
|
|
2605
2829
|
structuredTables: true
|
|
2606
2830
|
});
|
|
2831
|
+
// Bilibili can drop the first paragraph's CSS alignment when it follows an
|
|
2832
|
+
// inline image node. Keep an explicit align attribute on that first text
|
|
2833
|
+
// block so the source formatting survives the editor's initial normalization.
|
|
2834
|
+
const firstTextIndex = content.items.findIndex(item => item.type === "html" && item.plain);
|
|
2835
|
+
if (firstTextIndex < 0) return content;
|
|
2836
|
+
const firstText = content.items[firstTextIndex];
|
|
2837
|
+
const alignment = normalizeText(firstText.align).toLowerCase();
|
|
2838
|
+
if (!alignment) return content;
|
|
2839
|
+
const $ = cheerio.load(firstText.html, null, false);
|
|
2840
|
+
const root = $.root().children().first();
|
|
2841
|
+
if (!root.length) return content;
|
|
2842
|
+
root.attr("align", alignment);
|
|
2843
|
+
return {
|
|
2844
|
+
...content,
|
|
2845
|
+
items: content.items.map((item, index) => (index === firstTextIndex ? { ...item, html: $.html(root) } : item))
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
async function enforceBilibiliFirstTextAlignment(bodyInput, content) {
|
|
2850
|
+
const firstText = content.items.find(item => item.type === "html" && item.plain);
|
|
2851
|
+
const alignment = normalizeText(firstText?.align).toLowerCase();
|
|
2852
|
+
const expectedText = normalizeText(firstText?.plain);
|
|
2853
|
+
if (!alignment || !expectedText || typeof bodyInput.evaluate !== "function") return false;
|
|
2854
|
+
return bodyInput
|
|
2855
|
+
.evaluate(
|
|
2856
|
+
(element, payload) => {
|
|
2857
|
+
const normalize = value => String(value || "").replace(/\s+/g, " ").trim();
|
|
2858
|
+
const blocks = [...element.querySelectorAll("p,h1,h2,h3,h4,h5,h6,div")].filter(block => {
|
|
2859
|
+
const text = normalize(block.textContent);
|
|
2860
|
+
return text === payload.text || text.includes(payload.text) || payload.text.includes(text);
|
|
2861
|
+
});
|
|
2862
|
+
// Prefer the smallest matching block. The outer editor container often
|
|
2863
|
+
// contains the same text, but formatting it does not update the actual
|
|
2864
|
+
// paragraph node stored by Bilibili's rich-text model.
|
|
2865
|
+
blocks.sort((left, right) => normalize(left.textContent).length - normalize(right.textContent).length);
|
|
2866
|
+
const target = blocks[0];
|
|
2867
|
+
if (!target) return false;
|
|
2868
|
+
const range = document.createRange();
|
|
2869
|
+
range.selectNodeContents(target);
|
|
2870
|
+
const selection = window.getSelection();
|
|
2871
|
+
selection.removeAllRanges();
|
|
2872
|
+
selection.addRange(range);
|
|
2873
|
+
try {
|
|
2874
|
+
document.execCommand("justifyCenter", false, null);
|
|
2875
|
+
} catch {
|
|
2876
|
+
// Some Chromium builds disable execCommand; the explicit style below
|
|
2877
|
+
// still provides a deterministic fallback for the editor DOM.
|
|
2878
|
+
}
|
|
2879
|
+
target.style.textAlign = payload.alignment;
|
|
2880
|
+
target.setAttribute("align", payload.alignment);
|
|
2881
|
+
target.setAttribute("data-align", payload.alignment);
|
|
2882
|
+
target.dispatchEvent(
|
|
2883
|
+
new InputEvent("input", {
|
|
2884
|
+
bubbles: true,
|
|
2885
|
+
inputType: "formatBlock",
|
|
2886
|
+
data: null
|
|
2887
|
+
})
|
|
2888
|
+
);
|
|
2889
|
+
return getComputedStyle(target).textAlign === payload.alignment;
|
|
2890
|
+
},
|
|
2891
|
+
{ text: expectedText, alignment }
|
|
2892
|
+
)
|
|
2893
|
+
.catch(() => false);
|
|
2607
2894
|
}
|
|
2608
2895
|
|
|
2609
2896
|
export function buildHaoyouRichContent(article) {
|
|
@@ -3135,7 +3422,7 @@ async function removeBilibiliFailedImage(bodyInput) {
|
|
|
3135
3422
|
});
|
|
3136
3423
|
}
|
|
3137
3424
|
|
|
3138
|
-
async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source, options = {}) {
|
|
3425
|
+
export async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source, options = {}) {
|
|
3139
3426
|
const deadline = Date.now() + 120000;
|
|
3140
3427
|
const startedAt = Date.now();
|
|
3141
3428
|
let stableChecks = 0;
|
|
@@ -3144,7 +3431,6 @@ async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source
|
|
|
3144
3431
|
const failed = await bodyInput
|
|
3145
3432
|
.locator('.upload-fail, .image-upload-error, [class*="upload-error"], [class*="retry"]')
|
|
3146
3433
|
.count();
|
|
3147
|
-
const pending = await pendingUploadCount(bodyInput);
|
|
3148
3434
|
if (failed > 0) {
|
|
3149
3435
|
const failureText = await bodyInput.evaluate(element => {
|
|
3150
3436
|
const nodes = [...element.querySelectorAll(
|
|
@@ -3162,7 +3448,10 @@ async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source
|
|
|
3162
3448
|
}
|
|
3163
3449
|
throw new Error(`B站正文图片上传失败: ${source}`);
|
|
3164
3450
|
}
|
|
3165
|
-
|
|
3451
|
+
// The B站 editor may keep a visible toolbar spinner after the image node
|
|
3452
|
+
// is inserted. Treat the new, stable image node as the upload receipt;
|
|
3453
|
+
// failure selectors above still catch explicit upload errors.
|
|
3454
|
+
if (count > previousCount && Date.now() - startedAt >= 1200) {
|
|
3166
3455
|
stableChecks += 1;
|
|
3167
3456
|
if (stableChecks >= 3) {
|
|
3168
3457
|
return;
|
|
@@ -3208,6 +3497,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
3208
3497
|
plain: plainParts.join("\n")
|
|
3209
3498
|
});
|
|
3210
3499
|
await page.waitForTimeout(100);
|
|
3500
|
+
await enforceBilibiliFirstTextAlignment(bodyInput, content);
|
|
3211
3501
|
options.onProgress?.("B站 文字正文已写入,正在回读校验");
|
|
3212
3502
|
options.onProgress?.(`B站 已写入 ${content.items.length} 个正文区块,准备处理 ${imageMarkers.length} 张正文图片`);
|
|
3213
3503
|
}
|
|
@@ -3271,6 +3561,9 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
3271
3561
|
}
|
|
3272
3562
|
}
|
|
3273
3563
|
|
|
3564
|
+
// Media insertion may rerender the first paragraph and drop its alignment.
|
|
3565
|
+
// Reapply it after all images are settled, immediately before final reading.
|
|
3566
|
+
await enforceBilibiliFirstTextAlignment(bodyInput, content);
|
|
3274
3567
|
options.onProgress?.("B站 正文媒体已处理,正在最终回读校验");
|
|
3275
3568
|
let state = null;
|
|
3276
3569
|
let previousTextLength = -1;
|
|
@@ -3844,6 +4137,85 @@ async function fillLocator(locator, value) {
|
|
|
3844
4137
|
await locator.fill(value);
|
|
3845
4138
|
}
|
|
3846
4139
|
|
|
4140
|
+
export function replaceFirstTextValue(value, find, replacement) {
|
|
4141
|
+
const source = String(value ?? "");
|
|
4142
|
+
const needle = String(find ?? "");
|
|
4143
|
+
const next = String(replacement ?? "");
|
|
4144
|
+
if (!needle) {
|
|
4145
|
+
throw new Error("待替换的原文不能为空");
|
|
4146
|
+
}
|
|
4147
|
+
const index = source.indexOf(needle);
|
|
4148
|
+
if (index < 0) {
|
|
4149
|
+
throw new Error("当前编辑页中未找到指定原文");
|
|
4150
|
+
}
|
|
4151
|
+
return `${source.slice(0, index)}${next}${source.slice(index + needle.length)}`;
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
async function replaceTextInEditable(locator, find, replacement) {
|
|
4155
|
+
const before = String(
|
|
4156
|
+
typeof locator.inputValue === "function"
|
|
4157
|
+
? await locator.inputValue().catch(() => "")
|
|
4158
|
+
: await locator.innerText().catch(() => "")
|
|
4159
|
+
);
|
|
4160
|
+
replaceFirstTextValue(before, find, replacement);
|
|
4161
|
+
const result = await locator.evaluate(
|
|
4162
|
+
(element, values) => {
|
|
4163
|
+
const source = "value" in element ? String(element.value ?? "") : String(element.innerText ?? element.textContent ?? "");
|
|
4164
|
+
const index = source.indexOf(values.find);
|
|
4165
|
+
if (index < 0) return { changed: false, reason: "not-found" };
|
|
4166
|
+
|
|
4167
|
+
if ("value" in element) {
|
|
4168
|
+
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
4169
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
4170
|
+
const next = `${source.slice(0, index)}${values.replacement}${source.slice(index + values.find.length)}`;
|
|
4171
|
+
if (setter) {
|
|
4172
|
+
setter.call(element, next);
|
|
4173
|
+
} else {
|
|
4174
|
+
element.value = next;
|
|
4175
|
+
}
|
|
4176
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
4177
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
4178
|
+
return { changed: true, value: next };
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
4182
|
+
let node;
|
|
4183
|
+
while ((node = walker.nextNode())) {
|
|
4184
|
+
const nodeIndex = node.data.indexOf(values.find);
|
|
4185
|
+
if (nodeIndex < 0) continue;
|
|
4186
|
+
const range = document.createRange();
|
|
4187
|
+
range.setStart(node, nodeIndex);
|
|
4188
|
+
range.setEnd(node, nodeIndex + values.find.length);
|
|
4189
|
+
range.deleteContents();
|
|
4190
|
+
const fragment = document.createDocumentFragment();
|
|
4191
|
+
const lines = values.replacement.replace(/\r\n?/g, "\n").split("\n");
|
|
4192
|
+
lines.forEach((line, lineIndex) => {
|
|
4193
|
+
if (lineIndex > 0) fragment.append(document.createElement("br"));
|
|
4194
|
+
if (line) fragment.append(document.createTextNode(line));
|
|
4195
|
+
});
|
|
4196
|
+
range.insertNode(fragment);
|
|
4197
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
4198
|
+
return { changed: true, value: String(element.innerText ?? element.textContent ?? "") };
|
|
4199
|
+
}
|
|
4200
|
+
return { changed: false, reason: "cross-node" };
|
|
4201
|
+
},
|
|
4202
|
+
{ find, replacement }
|
|
4203
|
+
);
|
|
4204
|
+
if (!result?.changed) {
|
|
4205
|
+
const detail = result?.reason === "cross-node" ? "原文跨越多个富文本节点,无法在不破坏排版的情况下替换" : "当前编辑页中未找到指定原文";
|
|
4206
|
+
throw new Error(detail);
|
|
4207
|
+
}
|
|
4208
|
+
const actual = String(
|
|
4209
|
+
typeof locator.inputValue === "function"
|
|
4210
|
+
? await locator.inputValue().catch(() => "")
|
|
4211
|
+
: await locator.innerText().catch(() => "")
|
|
4212
|
+
);
|
|
4213
|
+
if (!actual.includes(replacement)) {
|
|
4214
|
+
throw new Error("编辑器未回读到修改后的文本");
|
|
4215
|
+
}
|
|
4216
|
+
return actual;
|
|
4217
|
+
}
|
|
4218
|
+
|
|
3847
4219
|
async function verifyFilledValue(locator, expected, label, platform = "TapTap") {
|
|
3848
4220
|
let actual = "";
|
|
3849
4221
|
if (typeof locator.inputValue === "function") {
|
|
@@ -4052,6 +4424,60 @@ async function editorIsReady(page, spec) {
|
|
|
4052
4424
|
return Boolean(await firstVisible(page, spec.body));
|
|
4053
4425
|
}
|
|
4054
4426
|
|
|
4427
|
+
async function findPreparedEditorPage(context, spec) {
|
|
4428
|
+
const deadline = Date.now() + 10000;
|
|
4429
|
+
do {
|
|
4430
|
+
const pages = context.pages().filter(page => !(typeof page.isClosed === "function" && page.isClosed()));
|
|
4431
|
+
for (const page of [...pages].reverse()) {
|
|
4432
|
+
if (await editorIsReady(page, spec)) return page;
|
|
4433
|
+
}
|
|
4434
|
+
const page = pages.at(-1);
|
|
4435
|
+
if (page && typeof page.waitForTimeout === "function") {
|
|
4436
|
+
await page.waitForTimeout(200);
|
|
4437
|
+
} else {
|
|
4438
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
4439
|
+
}
|
|
4440
|
+
} while (Date.now() < deadline);
|
|
4441
|
+
return null;
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4444
|
+
export async function editPreparedPlatformPage(entry, revision, options = {}) {
|
|
4445
|
+
const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
|
|
4446
|
+
if (!spec || entry.publisher !== "browser") {
|
|
4447
|
+
throw new Error(`${entry.platform} 不支持编辑已准备页面`);
|
|
4448
|
+
}
|
|
4449
|
+
const target = normalizeKey(revision?.target || "body");
|
|
4450
|
+
if (!new Set(["title", "body"]).has(target)) {
|
|
4451
|
+
throw new Error("编辑位置只能是 title 或 body");
|
|
4452
|
+
}
|
|
4453
|
+
const find = String(revision?.find ?? "");
|
|
4454
|
+
const replacement = String(revision?.replacement ?? "");
|
|
4455
|
+
if (!find || !replacement) {
|
|
4456
|
+
throw new Error("编辑需要同时提供原文和新文");
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
const { chromium } = await import("playwright-core");
|
|
4460
|
+
const context = await reconnectDetachedBrowserContext(browserProfileDir(entry), chromium);
|
|
4461
|
+
if (!context) {
|
|
4462
|
+
throw new Error(`${entry.platform} 没有可继续编辑的已准备页面,请先重新准备该平台的发布页`);
|
|
4463
|
+
}
|
|
4464
|
+
try {
|
|
4465
|
+
const page = await findPreparedEditorPage(context, spec);
|
|
4466
|
+
if (!page) {
|
|
4467
|
+
throw new Error(`${entry.platform} 已准备页面中未找到标题和正文编辑器`);
|
|
4468
|
+
}
|
|
4469
|
+
const field = await waitForVisible(page, target === "title" ? spec.title : spec.body, 3000);
|
|
4470
|
+
if (!field) {
|
|
4471
|
+
throw new Error(`${entry.platform} 页面中未找到${target === "title" ? "标题" : "正文"}编辑器`);
|
|
4472
|
+
}
|
|
4473
|
+
const value = await replaceTextInEditable(field, find, replacement);
|
|
4474
|
+
options.onProgress?.(`${entry.platform}${target === "title" ? "标题" : "正文"}已修改并回读确认`);
|
|
4475
|
+
return { url: page.url(), target, value };
|
|
4476
|
+
} finally {
|
|
4477
|
+
await closeBrowserContext(context);
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4055
4481
|
async function openPlatformLogin(page, entry, spec) {
|
|
4056
4482
|
if (spec.loginPage) {
|
|
4057
4483
|
await page.goto(spec.loginPage, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
@@ -4084,6 +4510,24 @@ async function openPlatformLogin(page, entry, spec) {
|
|
|
4084
4510
|
return false;
|
|
4085
4511
|
}
|
|
4086
4512
|
|
|
4513
|
+
export function browserSessionLoginPrompt(platform) {
|
|
4514
|
+
return `${platform} 尚未登录。请仅在 CLI 刚打开的自动化浏览器窗口中完成登录(扫码或账号密码均可),不要改用其他浏览器;完成后保持该窗口打开并回复“已登录”。`;
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
async function waitForExisting(page, selectors, timeoutMs = 15000) {
|
|
4518
|
+
const deadline = Date.now() + timeoutMs;
|
|
4519
|
+
do {
|
|
4520
|
+
const locator = await firstExisting(page, selectors);
|
|
4521
|
+
if (locator) return locator;
|
|
4522
|
+
await page.waitForTimeout(200);
|
|
4523
|
+
} while (Date.now() < deadline);
|
|
4524
|
+
return null;
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
function browserSessionLoginFailure(platform) {
|
|
4528
|
+
return `${platform} 登录完成后仍未进入发布编辑器。请确认登录发生在 CLI 刚打开的自动化浏览器窗口中,并检查账号是否拥有发布权限`;
|
|
4529
|
+
}
|
|
4530
|
+
|
|
4087
4531
|
async function ensureBrowserLogin(page, entry, spec, promptUser) {
|
|
4088
4532
|
const alreadyLoggedIn = await openPlatformLogin(page, entry, spec);
|
|
4089
4533
|
if (alreadyLoggedIn) {
|
|
@@ -4091,7 +4535,7 @@ async function ensureBrowserLogin(page, entry, spec, promptUser) {
|
|
|
4091
4535
|
}
|
|
4092
4536
|
|
|
4093
4537
|
if (spec.manualLogin) {
|
|
4094
|
-
await promptUser(
|
|
4538
|
+
await promptUser(browserSessionLoginPrompt(entry.platform));
|
|
4095
4539
|
return true;
|
|
4096
4540
|
}
|
|
4097
4541
|
|
|
@@ -4705,7 +5149,7 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
|
|
|
4705
5149
|
await afterNavigation(page);
|
|
4706
5150
|
}
|
|
4707
5151
|
if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
|
|
4708
|
-
throw new Error("TapTap
|
|
5152
|
+
throw new Error(browserSessionLoginFailure("TapTap"));
|
|
4709
5153
|
}
|
|
4710
5154
|
const resumedDraft = await resumeMatchingTapTapDraft(page, spec, preparedArticle.title, editorUrl);
|
|
4711
5155
|
if (options.publishDraft) {
|
|
@@ -5212,7 +5656,7 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
|
|
|
5212
5656
|
await afterNavigation(page);
|
|
5213
5657
|
}
|
|
5214
5658
|
if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings), 15000))) {
|
|
5215
|
-
throw new Error(
|
|
5659
|
+
throw new Error(browserSessionLoginFailure("B站"));
|
|
5216
5660
|
}
|
|
5217
5661
|
|
|
5218
5662
|
let titleInput;
|
|
@@ -5356,7 +5800,10 @@ async function waitForWeiboArticleInlineImage(bodyInput, previousCount, source)
|
|
|
5356
5800
|
if (failed > 0) {
|
|
5357
5801
|
throw new Error(`微博文章正文图片上传失败: ${source}`);
|
|
5358
5802
|
}
|
|
5359
|
-
if (count > previousCount
|
|
5803
|
+
if (count > previousCount + 1) {
|
|
5804
|
+
throw new Error(`微博正文图片疑似重复插入: ${source}`);
|
|
5805
|
+
}
|
|
5806
|
+
if (count === previousCount + 1 && pending === 0) {
|
|
5360
5807
|
stableChecks += 1;
|
|
5361
5808
|
if (stableChecks >= 3) {
|
|
5362
5809
|
return;
|
|
@@ -5473,10 +5920,24 @@ async function visibleWeiboCropDialog(page) {
|
|
|
5473
5920
|
|
|
5474
5921
|
async function clearExistingWeiboArticleImages(page, scope) {
|
|
5475
5922
|
const items = scope.locator(".image-list .image-item");
|
|
5923
|
+
// The gallery list is populated asynchronously after the dialog opens. Give
|
|
5924
|
+
// existing items a short window to appear before treating the library as empty.
|
|
5925
|
+
let previousCount = await items.count().catch(() => 0);
|
|
5926
|
+
if (previousCount === 0) {
|
|
5927
|
+
const deadline = Date.now() + 5000;
|
|
5928
|
+
do {
|
|
5929
|
+
await page.waitForTimeout(200);
|
|
5930
|
+
previousCount = await items.count().catch(() => 0);
|
|
5931
|
+
if (previousCount > 0) break;
|
|
5932
|
+
} while (Date.now() < deadline);
|
|
5933
|
+
}
|
|
5934
|
+
if (previousCount === 0) {
|
|
5935
|
+
return 0;
|
|
5936
|
+
}
|
|
5476
5937
|
let cleared = 0;
|
|
5477
5938
|
while (cleared < 200) {
|
|
5478
|
-
const
|
|
5479
|
-
if (
|
|
5939
|
+
const currentCount = await items.count().catch(() => 0);
|
|
5940
|
+
if (currentCount === 0) {
|
|
5480
5941
|
return cleared;
|
|
5481
5942
|
}
|
|
5482
5943
|
const item = items.first();
|
|
@@ -5489,10 +5950,10 @@ async function clearExistingWeiboArticleImages(page, scope) {
|
|
|
5489
5950
|
}
|
|
5490
5951
|
await remove.click();
|
|
5491
5952
|
const deadline = Date.now() + 10000;
|
|
5492
|
-
let
|
|
5953
|
+
let afterDeleteCount = currentCount;
|
|
5493
5954
|
do {
|
|
5494
|
-
|
|
5495
|
-
if (
|
|
5955
|
+
afterDeleteCount = await items.count().catch(() => currentCount);
|
|
5956
|
+
if (afterDeleteCount < currentCount) {
|
|
5496
5957
|
break;
|
|
5497
5958
|
}
|
|
5498
5959
|
const text = typeof scope.innerText === "function" ? await scope.innerText().catch(() => "") : "";
|
|
@@ -5501,14 +5962,26 @@ async function clearExistingWeiboArticleImages(page, scope) {
|
|
|
5501
5962
|
}
|
|
5502
5963
|
await page.waitForTimeout(200);
|
|
5503
5964
|
} while (Date.now() < deadline);
|
|
5504
|
-
if (
|
|
5965
|
+
if (afterDeleteCount >= currentCount) {
|
|
5505
5966
|
throw new Error("微博头条文章图片库旧图片删除后未消失");
|
|
5506
5967
|
}
|
|
5507
|
-
cleared +=
|
|
5968
|
+
cleared += currentCount - afterDeleteCount;
|
|
5508
5969
|
}
|
|
5509
5970
|
throw new Error("微博头条文章图片库旧图片超过 200 张,已停止清理");
|
|
5510
5971
|
}
|
|
5511
5972
|
|
|
5973
|
+
async function waitForWeiboArticleImageList(page, scope, timeoutMs = 10000) {
|
|
5974
|
+
const list = scope.locator(".image-list");
|
|
5975
|
+
const deadline = Date.now() + timeoutMs;
|
|
5976
|
+
do {
|
|
5977
|
+
if ((await list.count().catch(() => 0)) > 0) {
|
|
5978
|
+
return true;
|
|
5979
|
+
}
|
|
5980
|
+
await page.waitForTimeout(200);
|
|
5981
|
+
} while (Date.now() < deadline);
|
|
5982
|
+
return false;
|
|
5983
|
+
}
|
|
5984
|
+
|
|
5512
5985
|
async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, source, options = {}) {
|
|
5513
5986
|
options.onProgress?.("正在打开图片库");
|
|
5514
5987
|
const imageButton = await weiboArticleImageButton(page);
|
|
@@ -5516,7 +5989,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5516
5989
|
throw new Error("微博头条文章编辑器中未找到图片库按钮");
|
|
5517
5990
|
}
|
|
5518
5991
|
await imageButton.click();
|
|
5519
|
-
const input = await
|
|
5992
|
+
const input = await waitForExisting(page, spec.imageInputs, 15000);
|
|
5520
5993
|
if (!input || typeof input.setInputFiles !== "function") {
|
|
5521
5994
|
throw new Error("微博头条文章图片库中未找到上传控件");
|
|
5522
5995
|
}
|
|
@@ -5524,7 +5997,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5524
5997
|
'xpath=ancestor::*[.//*[normalize-space(text())="图片库"] and .//*[normalize-space(text())="插入"]][1]'
|
|
5525
5998
|
);
|
|
5526
5999
|
const scope = (await modal.count().catch(() => 0)) > 0 ? modal : page;
|
|
5527
|
-
const structuredLibrary =
|
|
6000
|
+
const structuredLibrary = await waitForWeiboArticleImageList(page, scope, 10000);
|
|
5528
6001
|
if (structuredLibrary && options.clearExistingLibrary) {
|
|
5529
6002
|
options.onProgress?.("正在清理图片库旧图");
|
|
5530
6003
|
await clearExistingWeiboArticleImages(page, scope);
|
|
@@ -5545,6 +6018,9 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5545
6018
|
.catch(() => [])
|
|
5546
6019
|
: [];
|
|
5547
6020
|
const previousImages = await bodyInput.locator("img:not(.ProseMirror-separator)").count();
|
|
6021
|
+
if (options.clearExistingLibrary && (await previews.count().catch(() => 0)) !== 0) {
|
|
6022
|
+
throw new Error(`微博图片库旧图未清空,已停止上传: ${source}`);
|
|
6023
|
+
}
|
|
5548
6024
|
options.onProgress?.("正在上传正文图片");
|
|
5549
6025
|
await input.setInputFiles([payload]);
|
|
5550
6026
|
|
|
@@ -5591,10 +6067,12 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5591
6067
|
if (candidateIndexes.length === 0 && previewCount > previousPreviews) {
|
|
5592
6068
|
candidateIndexes = Array.from({ length: previewCount - previousPreviews }, (_, offset) => previousPreviews + offset);
|
|
5593
6069
|
}
|
|
5594
|
-
if (candidateIndexes.length === 0
|
|
5595
|
-
|
|
6070
|
+
if (candidateIndexes.length === 0) {
|
|
6071
|
+
throw new Error(`微博文章图片上传后未识别到本次上传的缩略图: ${source}`);
|
|
5596
6072
|
}
|
|
5597
|
-
if (
|
|
6073
|
+
if (options.clearExistingLibrary && previewCount === 1) {
|
|
6074
|
+
uploadedPreview = previews.first();
|
|
6075
|
+
} else if (structuredLibrary && candidateIndexes.length > 0) {
|
|
5598
6076
|
// Let the common click-and-verify path below perform the selection once.
|
|
5599
6077
|
uploadedPreview = previews.nth(candidateIndexes[0]);
|
|
5600
6078
|
}
|
|
@@ -5656,12 +6134,19 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5656
6134
|
await insertButton.click();
|
|
5657
6135
|
await page.waitForTimeout(300);
|
|
5658
6136
|
if (scope !== page && typeof scope.isVisible === "function" && (await scope.isVisible().catch(() => false))) {
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
6137
|
+
// A successful click normally closes the library or opens the crop dialog.
|
|
6138
|
+
// Only retry when neither happened and the editor image count is unchanged;
|
|
6139
|
+
// an unconditional second click can insert the same image twice.
|
|
6140
|
+
const cropStarted = await visibleWeiboCropDialog(page);
|
|
6141
|
+
const currentImages = await bodyInput.locator("img:not(.ProseMirror-separator)").count().catch(() => previousImages);
|
|
6142
|
+
if (!cropStarted && currentImages === previousImages) {
|
|
6143
|
+
const retryInsert = typeof scope.getByRole === "function"
|
|
6144
|
+
? scope.getByRole("button", { name: "插入", exact: true }).last()
|
|
6145
|
+
: insertButton;
|
|
6146
|
+
if ((await retryInsert.count().catch(() => 0)) > 0 && !(await weiboControlDisabled(retryInsert))) {
|
|
6147
|
+
await retryInsert.click({ force: true });
|
|
6148
|
+
await page.waitForTimeout(300);
|
|
6149
|
+
}
|
|
5665
6150
|
}
|
|
5666
6151
|
}
|
|
5667
6152
|
|
|
@@ -5717,10 +6202,15 @@ async function clearWeiboArticleBody(page, bodyInput) {
|
|
|
5717
6202
|
await page.waitForTimeout(100);
|
|
5718
6203
|
}
|
|
5719
6204
|
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
6205
|
+
const readBodyState = async () =>
|
|
6206
|
+
bodyInput
|
|
6207
|
+
.evaluate(element => ({
|
|
6208
|
+
text: String(element.innerText || element.textContent || "").trim(),
|
|
6209
|
+
images: element.querySelectorAll("img:not(.ProseMirror-separator)").length
|
|
6210
|
+
}))
|
|
6211
|
+
.catch(() => ({ text: "", images: 0 }));
|
|
6212
|
+
let remaining = await readBodyState();
|
|
6213
|
+
if (remaining.text || remaining.images > 0) {
|
|
5724
6214
|
await bodyInput.evaluate(element => {
|
|
5725
6215
|
element.replaceChildren(document.createElement("p"));
|
|
5726
6216
|
element.dispatchEvent(
|
|
@@ -5732,11 +6222,9 @@ async function clearWeiboArticleBody(page, bodyInput) {
|
|
|
5732
6222
|
);
|
|
5733
6223
|
});
|
|
5734
6224
|
await page.waitForTimeout(100);
|
|
5735
|
-
remaining = await
|
|
5736
|
-
.evaluate(element => String(element.innerText || element.textContent || "").trim())
|
|
5737
|
-
.catch(() => "");
|
|
6225
|
+
remaining = await readBodyState();
|
|
5738
6226
|
}
|
|
5739
|
-
if (
|
|
6227
|
+
if (remaining.text || remaining.images > 0) {
|
|
5740
6228
|
throw new Error("微博文章正文未能清空,已停止发布以避免复用旧草稿内容");
|
|
5741
6229
|
}
|
|
5742
6230
|
}
|
|
@@ -5954,7 +6442,7 @@ export async function insertWeiboArticleRichContent(page, bodyInput, article, sp
|
|
|
5954
6442
|
payloadCache.set(item.source, payload);
|
|
5955
6443
|
}
|
|
5956
6444
|
await uploadWeiboArticleInlineImage(page, activeBodyInput, spec, payload, item.source, {
|
|
5957
|
-
clearExistingLibrary:
|
|
6445
|
+
clearExistingLibrary: true,
|
|
5958
6446
|
onProgress: options.onProgress
|
|
5959
6447
|
});
|
|
5960
6448
|
}
|
|
@@ -6492,6 +6980,43 @@ async function findWeiboShareSubmitButton(page, dialog) {
|
|
|
6492
6980
|
return findIn(page);
|
|
6493
6981
|
}
|
|
6494
6982
|
|
|
6983
|
+
async function preparedWeiboArticleState(page, spec, article, title) {
|
|
6984
|
+
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 5000))) return null;
|
|
6985
|
+
const titleInput = await waitForVisible(page, spec.title, 3000);
|
|
6986
|
+
const bodyInput = await waitForVisible(page, spec.body, 3000);
|
|
6987
|
+
if (!titleInput || !bodyInput) return null;
|
|
6988
|
+
if (normalizeText(await readFilledValue(titleInput)) !== normalizeText(title)) return null;
|
|
6989
|
+
|
|
6990
|
+
const expected = buildWeiboRichContent(article);
|
|
6991
|
+
const state =
|
|
6992
|
+
typeof bodyInput.evaluate === "function"
|
|
6993
|
+
? await bodyInput
|
|
6994
|
+
.evaluate(element => ({
|
|
6995
|
+
text: element.innerText || element.textContent || "",
|
|
6996
|
+
images: element.querySelectorAll("img:not(.ProseMirror-separator)").length,
|
|
6997
|
+
links: element.querySelectorAll("a[href]").length,
|
|
6998
|
+
failedImages: element.querySelectorAll('[class*="upload-fail"], [class*="upload-error"]').length
|
|
6999
|
+
}))
|
|
7000
|
+
.catch(() => null)
|
|
7001
|
+
: null;
|
|
7002
|
+
const actualText = normalizedPresenceText(state?.text || normalizeText(await readFilledValue(bodyInput)));
|
|
7003
|
+
if (!actualText || state?.failedImages > 0) return null;
|
|
7004
|
+
if (state && state.images < expected.images.length) return null;
|
|
7005
|
+
const expectedFragments = expected.items
|
|
7006
|
+
.filter(item => item.type === "html" && item.plain)
|
|
7007
|
+
.map(item => normalizedPresenceText(item.plain))
|
|
7008
|
+
.filter(Boolean);
|
|
7009
|
+
if (expectedFragments.length > 0 && !hasOrderedTextFragments(actualText, expectedFragments)) return null;
|
|
7010
|
+
return {
|
|
7011
|
+
richContent: {
|
|
7012
|
+
rich: true,
|
|
7013
|
+
images: state?.images ?? expected.images.length,
|
|
7014
|
+
links: state?.links ?? expected.links.length,
|
|
7015
|
+
plainText: expected.plainText
|
|
7016
|
+
}
|
|
7017
|
+
};
|
|
7018
|
+
}
|
|
7019
|
+
|
|
6495
7020
|
async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
6496
7021
|
const progress = message => options.onProgress?.(message);
|
|
6497
7022
|
const preparedArticle = prepareWeiboArticleForProject(article, entry);
|
|
@@ -6506,6 +7031,24 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6506
7031
|
}
|
|
6507
7032
|
|
|
6508
7033
|
const editorUrl = weiboArticleEditorUrl(entry.url);
|
|
7034
|
+
if (options.directPublish) {
|
|
7035
|
+
const context = typeof page.context === "function" ? page.context() : null;
|
|
7036
|
+
const preparedPage = context ? await findPreparedEditorPage(context, spec) : null;
|
|
7037
|
+
if (preparedPage) page = preparedPage;
|
|
7038
|
+
}
|
|
7039
|
+
const directPrepared = options.directPublish
|
|
7040
|
+
? await preparedWeiboArticleState(page, spec, preparedArticle, title)
|
|
7041
|
+
: null;
|
|
7042
|
+
if (options.directPublish && !directPrepared) {
|
|
7043
|
+
throw new Error("微博待发布编辑页未找到与当前文章一致的已准备内容;请重新准备后再授权发布");
|
|
7044
|
+
}
|
|
7045
|
+
let richContent = directPrepared?.richContent || null;
|
|
7046
|
+
let cover = {};
|
|
7047
|
+
let column = "";
|
|
7048
|
+
if (options.directPublish) {
|
|
7049
|
+
progress("已复用当前微博待发内容,正在提交,不重复上传正文图片或封面");
|
|
7050
|
+
}
|
|
7051
|
+
if (!options.directPublish) {
|
|
6509
7052
|
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
6510
7053
|
await afterNavigation(page);
|
|
6511
7054
|
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec)))) {
|
|
@@ -6514,7 +7057,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6514
7057
|
await afterNavigation(page);
|
|
6515
7058
|
}
|
|
6516
7059
|
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 120000))) {
|
|
6517
|
-
throw new Error("
|
|
7060
|
+
throw new Error(browserSessionLoginFailure("微博"));
|
|
6518
7061
|
}
|
|
6519
7062
|
|
|
6520
7063
|
const draftMode = await ensureWeiboArticleDraft(page, title);
|
|
@@ -6531,12 +7074,13 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6531
7074
|
}
|
|
6532
7075
|
await fillStableWeiboField(page, spec.title, title, "标题");
|
|
6533
7076
|
const stableBodyInput = (await waitForVisible(page, spec.body, 3000)) || bodyInput;
|
|
6534
|
-
|
|
7077
|
+
richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
|
|
6535
7078
|
onProgress: progress
|
|
6536
7079
|
});
|
|
6537
7080
|
progress("正文已写入,正在设置封面");
|
|
6538
|
-
|
|
6539
|
-
|
|
7081
|
+
cover = await applyWeiboArticleCover(page, preparedArticle, settings, { onProgress: progress });
|
|
7082
|
+
column = await applyWeiboArticleColumn(page, settings.column, { onProgress: progress });
|
|
7083
|
+
}
|
|
6540
7084
|
|
|
6541
7085
|
const nextButton = await waitForVisibleButton(page, spec.next, 5000);
|
|
6542
7086
|
if (!nextButton) {
|
|
@@ -6633,7 +7177,26 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6633
7177
|
}
|
|
6634
7178
|
const shareSubmit = await findWeiboShareSubmitButton(page, shareComposer.dialog);
|
|
6635
7179
|
if (!shareSubmit) {
|
|
6636
|
-
|
|
7180
|
+
// The article submit can navigate to publish history while the share
|
|
7181
|
+
// composer is still being torn down. Treat that navigation as a
|
|
7182
|
+
// successful article submission instead of reporting a false failure.
|
|
7183
|
+
const historyDeadline = Date.now() + 3000;
|
|
7184
|
+
while (!/\/history\//i.test(page.url()) && Date.now() < historyDeadline) {
|
|
7185
|
+
await page.waitForTimeout(200);
|
|
7186
|
+
}
|
|
7187
|
+
if (/\/history\//i.test(page.url())) {
|
|
7188
|
+
progress("微博文章已提交,分享弹窗已自动关闭");
|
|
7189
|
+
confirmation = { confirmation: "navigation" };
|
|
7190
|
+
} else {
|
|
7191
|
+
throw new Error("微博分享发布弹窗中未找到“发布”按钮");
|
|
7192
|
+
}
|
|
7193
|
+
}
|
|
7194
|
+
if (confirmation) {
|
|
7195
|
+
progress("已收到微博发布成功回执");
|
|
7196
|
+
if (options.keepOpen) {
|
|
7197
|
+
await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
|
|
7198
|
+
}
|
|
7199
|
+
return { url: page.url(), status: "published", type: "article", title, ...richContent, ...cover, column, ...confirmation };
|
|
6637
7200
|
}
|
|
6638
7201
|
if (typeof shareSubmit.isDisabled === "function" && (await shareSubmit.isDisabled().catch(() => false))) {
|
|
6639
7202
|
throw new Error("微博分享发布弹窗中的“发布”按钮不可用");
|
package/package.json
CHANGED
|
@@ -60,6 +60,18 @@ qcplay publish --project 最强蜗牛 --list-platforms
|
|
|
60
60
|
|
|
61
61
|
默认 `qcplay publish` 不得进入正式发布或填入文章;它会先打开每个浏览器平台的真实编辑器页面,再返回 `qcplay.publish-action-required` 事件。Agent 收到该事件后,必须立即在当前 AI 对话提供“发布”“保存草稿箱”“取消发布”三个选项,不能用终端提问。用户明确选择发布后才添加 `--direct`;选择保存草稿箱后才添加 `--draft-only`。用户说“保存到草稿箱”“发布到草稿箱”时,直接使用 `--draft-only`,不得调用默认发布命令。`--draft-only` 填完内容后只保存原生草稿或停留在编辑页,且草稿页面保持打开。草稿成功后,CLI 会立即返回 `qcplay.draft-approval-required` 事件;Agent 必须立刻在 AI 对话提供“发布”“取消”两个选项,不能等待用户在网页中操作,也不能等待终端输入。用户明确要求先查看官网外平台的待发布稿时,增加 `--review-draft`(或 `--review`):CLI 填完标题、富文本、图片、封面和发布设置后,在最终提交前保持浏览器打开;必须回到当前 AI 对话询问用户“发布”“保存草稿”或“取消”,并等待明确回复,禁止要求用户在 CMD/终端输入确认或按 Enter。用户明确授权跳过二次确认时使用 `--direct`(或 `--no-review`),该参数仅跳过发布前预览确认,不跳过登录、验证码或安全验证。该参数不改变官网流程。TapTap 已保存草稿且用户明确要求发布时,使用 `--publish-draft`,只恢复标题匹配的已有草稿并提交,禁止再次覆盖正文、图片或封面。好游快爆没有原生草稿箱,只能保持已填好的编辑页供用户预览。
|
|
62
62
|
|
|
63
|
+
## 已准备页面的对话修改
|
|
64
|
+
|
|
65
|
+
当浏览器平台已经返回 `qcplay.publish-action-required`,且编辑页仍保持打开时,用户可在当前 AI 对话中明确说明平台、标题或正文位置、待替换原文及新文。此类单次修改不写入 `rule.docx` 或 `platform-rules.json`,也不改变本地 `article.md`。
|
|
66
|
+
|
|
67
|
+
AI 应将用户已明确的修改转换为一次受限页面编辑:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
qcplay-cli edit-page --project <项目> --region <区域> --platform <平台> --target <title|body> --find "原文" --replace "新文"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
该命令只连接已打开的待确认编辑页、替换第一处完全匹配的文本并回读验证;新文中的真实换行会保留为富文本换行。不保存草稿、不点击发布,也不会重新打开或覆盖编辑页。原文跨越多个富文本节点、未找到原文、页面已关闭或已发布时必须停止并反馈失败。用户未同时给出原文和新文,或未说明目标位置时,AI 应在当前对话中请求补充,不得猜测或修改规则文件。
|
|
74
|
+
|
|
63
75
|
## AI 对话授权与反馈
|
|
64
76
|
|
|
65
77
|
发布或保存草稿前,只要流程需要用户确认,必须在当前 AI 对话中询问并等待明确回复,不得唤起终端或要求用户在 CMD/终端输入“发布”“可以发布”或按 Enter。询问时说明目标平台、文章标题或文章 ID(不得包含 token),并提供以下动作:
|