@qcplay/cli 1.0.19 → 1.0.20
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 +229 -0
- package/lib/platform-publish.js +305 -11
- 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("");
|
|
@@ -2753,6 +2933,29 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2753
2933
|
}
|
|
2754
2934
|
}
|
|
2755
2935
|
|
|
2936
|
+
async function editPreparedPageCommand(options) {
|
|
2937
|
+
const allEntries = await loadPlatformEntries({ configFile: options.configFile });
|
|
2938
|
+
const candidates = entriesForProject(allEntries, options.project, options.region);
|
|
2939
|
+
const selected = selectRequestedEntries(candidates, [options.platform]);
|
|
2940
|
+
if (selected.length !== 1 || selected[0].publisher !== "browser") {
|
|
2941
|
+
throw new Error(`${options.platform} 没有可编辑的浏览器发布页面配置`);
|
|
2942
|
+
}
|
|
2943
|
+
const entry = selected[0];
|
|
2944
|
+
const result = await editPreparedPlatformPage(
|
|
2945
|
+
entry,
|
|
2946
|
+
{ target: options.target, find: options.find, replacement: options.replacement },
|
|
2947
|
+
{ browserChannel: options.browserChannel, onProgress: message => console.log(chalk.gray(` ${message}`)) }
|
|
2948
|
+
);
|
|
2949
|
+
console.log(
|
|
2950
|
+
JSON.stringify({
|
|
2951
|
+
event: "qcplay.page-edit-complete",
|
|
2952
|
+
platform: entry.platform,
|
|
2953
|
+
target: result.target,
|
|
2954
|
+
url: result.url
|
|
2955
|
+
})
|
|
2956
|
+
);
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2756
2959
|
async function publishArticleCommand(file, options, platformEntry = null, expectedTarget = "") {
|
|
2757
2960
|
if (!file) {
|
|
2758
2961
|
throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
|
|
@@ -2959,6 +3162,32 @@ async function main() {
|
|
|
2959
3162
|
return;
|
|
2960
3163
|
}
|
|
2961
3164
|
|
|
3165
|
+
if (command === "edit-page") {
|
|
3166
|
+
if (subcommand === "-h" || subcommand === "--help") {
|
|
3167
|
+
printEditPageHelp();
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
const options = parseEditPageOptions([subcommand, ...rest].filter(Boolean));
|
|
3171
|
+
await runWithErrorBanner("编辑已准备页面失败", () => editPreparedPageCommand(options));
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
if (command === "discord") {
|
|
3176
|
+
if (subcommand === "-h" || subcommand === "--help") {
|
|
3177
|
+
console.log("Usage:\n qcplay-cli discord delete <文章文件> --project <项目> --region <区域>");
|
|
3178
|
+
return;
|
|
3179
|
+
}
|
|
3180
|
+
if (subcommand !== "delete") {
|
|
3181
|
+
await runWithErrorBanner("Discord 操作失败", async () => {
|
|
3182
|
+
throw new Error(`未知 discord 命令: ${subcommand || ""}`);
|
|
3183
|
+
});
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
const parsed = parseDiscordDeleteOptions(rest);
|
|
3187
|
+
await runWithErrorBanner("Discord 删除失败", () => deleteDiscordCommand(parsed.file, parsed.options));
|
|
3188
|
+
return;
|
|
3189
|
+
}
|
|
3190
|
+
|
|
2962
3191
|
if (command === "rules") {
|
|
2963
3192
|
if (!subcommand || subcommand === "-h" || subcommand === "--help") {
|
|
2964
3193
|
printRulesHelp();
|
package/lib/platform-publish.js
CHANGED
|
@@ -13,6 +13,7 @@ import { stripWechatGuidanceHtml } from "./wechat-article.js";
|
|
|
13
13
|
|
|
14
14
|
const PLATFORM_CONFIG_URL = "https://t4blw8ys5w.feishu.cn/wiki/OqH7wkx9PiBaTYkKeWNc35SonGf";
|
|
15
15
|
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
16
|
+
const DISCORD_MESSAGE_STATE_FILE = path.join(os.homedir(), ".qcplay", "discord-messages.json");
|
|
16
17
|
const BROWSER_SUBMISSION_COOLDOWN_MS = 60_000;
|
|
17
18
|
const BROWSER_SUBMISSION_STATE_FILE = path.join(os.homedir(), ".qcplay", "browser-submissions.json");
|
|
18
19
|
const ARES_PROJECT = "阿瑞斯病毒2";
|
|
@@ -663,7 +664,11 @@ function postJson(urlValue, payload) {
|
|
|
663
664
|
reject(new Error(`Discord webhook 返回 ${response.statusCode}: ${responseBody.slice(0, 160)}`));
|
|
664
665
|
return;
|
|
665
666
|
}
|
|
666
|
-
|
|
667
|
+
try {
|
|
668
|
+
resolve(responseBody ? JSON.parse(responseBody) : {});
|
|
669
|
+
} catch {
|
|
670
|
+
resolve({});
|
|
671
|
+
}
|
|
667
672
|
});
|
|
668
673
|
}
|
|
669
674
|
);
|
|
@@ -672,6 +677,110 @@ function postJson(urlValue, payload) {
|
|
|
672
677
|
});
|
|
673
678
|
}
|
|
674
679
|
|
|
680
|
+
function deleteJson(urlValue) {
|
|
681
|
+
return new Promise((resolve, reject) => {
|
|
682
|
+
const url = new URL(urlValue);
|
|
683
|
+
const request = https.request(
|
|
684
|
+
url,
|
|
685
|
+
{
|
|
686
|
+
method: "DELETE",
|
|
687
|
+
headers: {
|
|
688
|
+
Accept: "application/json",
|
|
689
|
+
"User-Agent": "qcplay-cli"
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
response => {
|
|
693
|
+
const chunks = [];
|
|
694
|
+
response.on("data", chunk => chunks.push(chunk));
|
|
695
|
+
response.on("end", () => {
|
|
696
|
+
const responseBody = Buffer.concat(chunks).toString("utf8");
|
|
697
|
+
if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) {
|
|
698
|
+
reject(new Error(`Discord webhook 删除消息返回 ${response.statusCode}: ${responseBody.slice(0, 160)}`));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
resolve();
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
);
|
|
705
|
+
request.once("error", reject);
|
|
706
|
+
request.end();
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function discordMessageStateKey(entry, article) {
|
|
711
|
+
const sourceUrl = normalizeText(article.meta?.source_url || article.payload?.source_url);
|
|
712
|
+
return [entry.project, entry.region, entry.platformKey || "discord", sourceUrl || article.title].map(normalizeText).join("\u0000");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function readDiscordMessageState() {
|
|
716
|
+
try {
|
|
717
|
+
const value = JSON.parse(await fs.promises.readFile(DISCORD_MESSAGE_STATE_FILE, "utf8"));
|
|
718
|
+
return Array.isArray(value?.records) ? value.records : [];
|
|
719
|
+
} catch {
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function writeDiscordMessageState(records) {
|
|
725
|
+
await fs.promises.mkdir(path.dirname(DISCORD_MESSAGE_STATE_FILE), { recursive: true, mode: 0o700 });
|
|
726
|
+
await fs.promises.writeFile(
|
|
727
|
+
DISCORD_MESSAGE_STATE_FILE,
|
|
728
|
+
`${JSON.stringify({ version: 1, records }, null, 2)}\n`,
|
|
729
|
+
{ encoding: "utf8", mode: 0o600 }
|
|
730
|
+
);
|
|
731
|
+
await fs.promises.chmod(DISCORD_MESSAGE_STATE_FILE, 0o600).catch(() => {});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function recordDiscordMessages(entry, article, messageIds) {
|
|
735
|
+
if (!messageIds.length) return;
|
|
736
|
+
const record = {
|
|
737
|
+
key: discordMessageStateKey(entry, article),
|
|
738
|
+
project: normalizeText(entry.project),
|
|
739
|
+
region: normalizeText(entry.region),
|
|
740
|
+
platform: normalizeText(entry.platform),
|
|
741
|
+
platformKey: entry.platformKey || "discord",
|
|
742
|
+
title: normalizeText(article.title),
|
|
743
|
+
sourceUrl: normalizeText(article.meta?.source_url || article.payload?.source_url),
|
|
744
|
+
messageIds,
|
|
745
|
+
createdAt: new Date().toISOString()
|
|
746
|
+
};
|
|
747
|
+
const records = (await readDiscordMessageState()).filter(item => item?.key !== record.key);
|
|
748
|
+
records.unshift(record);
|
|
749
|
+
await writeDiscordMessageState(records.slice(0, 100));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export async function deleteDiscordMessages(entry, messageIds, options = {}) {
|
|
753
|
+
const webhook = discordWebhookFromEntry(entry);
|
|
754
|
+
if (!webhook) {
|
|
755
|
+
throw new Error("Discord 配置缺少 webhook,无法删除消息");
|
|
756
|
+
}
|
|
757
|
+
const ids = [...new Set(messageIds.map(value => normalizeText(value)).filter(Boolean))];
|
|
758
|
+
if (!ids.length) {
|
|
759
|
+
throw new Error("没有可删除的 Discord 消息 ID");
|
|
760
|
+
}
|
|
761
|
+
const remove = options.deleteJson || (messageId => {
|
|
762
|
+
const url = new URL(webhook);
|
|
763
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/messages/${encodeURIComponent(messageId)}`;
|
|
764
|
+
return deleteJson(url.toString());
|
|
765
|
+
});
|
|
766
|
+
for (const messageId of ids) {
|
|
767
|
+
await remove(messageId);
|
|
768
|
+
}
|
|
769
|
+
return { deleted: ids.length };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export async function findDiscordMessageRecord(article, entry) {
|
|
773
|
+
const key = discordMessageStateKey(entry, article);
|
|
774
|
+
const records = await readDiscordMessageState();
|
|
775
|
+
return records.find(item => item?.key === key) || null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export async function removeDiscordMessageRecord(article, entry) {
|
|
779
|
+
const key = discordMessageStateKey(entry, article);
|
|
780
|
+
const records = (await readDiscordMessageState()).filter(item => item?.key !== key);
|
|
781
|
+
await writeDiscordMessageState(records);
|
|
782
|
+
}
|
|
783
|
+
|
|
675
784
|
export async function publishDiscord(entry, article, options = {}) {
|
|
676
785
|
const webhook = discordWebhookFromEntry(entry);
|
|
677
786
|
if (!webhook) {
|
|
@@ -683,21 +792,56 @@ export async function publishDiscord(entry, article, options = {}) {
|
|
|
683
792
|
: preparedArticle.markdown;
|
|
684
793
|
const chunks = splitDiscordContent(preparedArticle.title, markdown);
|
|
685
794
|
const send = options.postJson || postJson;
|
|
795
|
+
const messageIds = [];
|
|
686
796
|
for (const chunk of chunks) {
|
|
687
|
-
await send(webhook
|
|
797
|
+
const response = await send(`${webhook}${webhook.includes("?") ? "&" : "?"}wait=true`, {
|
|
798
|
+
content: chunk,
|
|
799
|
+
allowed_mentions: { parse: [] }
|
|
800
|
+
});
|
|
801
|
+
const messageId = normalizeText(response?.id);
|
|
802
|
+
if (messageId) messageIds.push(messageId);
|
|
688
803
|
}
|
|
689
|
-
|
|
804
|
+
let messageRecordSaved = false;
|
|
805
|
+
if (messageIds.length === chunks.length) {
|
|
806
|
+
try {
|
|
807
|
+
await recordDiscordMessages(entry, preparedArticle, messageIds);
|
|
808
|
+
messageRecordSaved = true;
|
|
809
|
+
} catch {
|
|
810
|
+
// A successful Discord post must not be reported as failed when local
|
|
811
|
+
// bookkeeping is unavailable; deletion can still be done manually.
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return { messages: chunks.length, messageIds, messageRecordSaved };
|
|
690
815
|
}
|
|
691
816
|
|
|
692
817
|
function plainTextForPlatform(article, platformKey) {
|
|
693
818
|
const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
|
|
819
|
+
// WeChat exports decorative empty spans containing <br> for layout. They
|
|
820
|
+
// must not become real line breaks in native editors; preserve only breaks
|
|
821
|
+
// that sit inside a node with actual text or media content.
|
|
822
|
+
$("br").each((_, element) => {
|
|
823
|
+
const parent = $(element).parent();
|
|
824
|
+
const hasMeaningfulSibling = parent
|
|
825
|
+
.contents()
|
|
826
|
+
.toArray()
|
|
827
|
+
.some(node => {
|
|
828
|
+
if (node === element) return false;
|
|
829
|
+
if (node.type === "text") return normalizeText(node.data).trim().length > 0;
|
|
830
|
+
if (node.type !== "tag") return false;
|
|
831
|
+
return $(node).is("img") || normalizeText($(node).text()).trim().length > 0;
|
|
832
|
+
});
|
|
833
|
+
if (!hasMeaningfulSibling) {
|
|
834
|
+
$(element).remove();
|
|
835
|
+
}
|
|
836
|
+
});
|
|
694
837
|
$("br").replaceWith("\n");
|
|
695
838
|
$("p,section,div,h1,h2,h3,h4,h5,h6,li,blockquote,tr").each((_, element) => {
|
|
696
839
|
$(element).append("\n");
|
|
697
840
|
});
|
|
698
841
|
$("img").each((_, element) => {
|
|
699
842
|
const image = $(element);
|
|
700
|
-
image.
|
|
843
|
+
const source = image.attr("src") || image.attr("data-qcplay-src") || image.attr("data-src") || image.attr("data-original");
|
|
844
|
+
image.replaceWith(source ? `\n${source}\n` : "");
|
|
701
845
|
});
|
|
702
846
|
const markdown = normalizeText($.root().text() || article.markdown)
|
|
703
847
|
.replace(/[ \t]+\n/g, "\n")
|
|
@@ -1913,8 +2057,13 @@ export function browserPlatformPageUrl(entry, type = "topic") {
|
|
|
1913
2057
|
|
|
1914
2058
|
function imageSourcesFromArticle(article) {
|
|
1915
2059
|
const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
|
|
1916
|
-
return $("img
|
|
1917
|
-
.map((_, element) =>
|
|
2060
|
+
return $("img")
|
|
2061
|
+
.map((_, element) => {
|
|
2062
|
+
const image = $(element);
|
|
2063
|
+
return normalizeText(
|
|
2064
|
+
image.attr("src") || image.attr("data-qcplay-src") || image.attr("data-src") || image.attr("data-original")
|
|
2065
|
+
);
|
|
2066
|
+
})
|
|
1918
2067
|
.get()
|
|
1919
2068
|
.filter(Boolean);
|
|
1920
2069
|
}
|
|
@@ -2377,7 +2526,12 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
2377
2526
|
});
|
|
2378
2527
|
}
|
|
2379
2528
|
for (const element of tableImages) {
|
|
2380
|
-
const source = normalizeText(
|
|
2529
|
+
const source = normalizeText(
|
|
2530
|
+
$(element).attr("src") ||
|
|
2531
|
+
$(element).attr("data-qcplay-src") ||
|
|
2532
|
+
$(element).attr("data-src") ||
|
|
2533
|
+
$(element).attr("data-original")
|
|
2534
|
+
);
|
|
2381
2535
|
if (!source) continue;
|
|
2382
2536
|
const caption = normalizeText($(element).closest("figure").find("figcaption").first().text()) ||
|
|
2383
2537
|
normalizeText($(element).attr("alt") || $(element).attr("title")).slice(0, 50);
|
|
@@ -2407,7 +2561,12 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
2407
2561
|
}
|
|
2408
2562
|
if (tag === "img") {
|
|
2409
2563
|
flushHtml();
|
|
2410
|
-
const source = normalizeText(
|
|
2564
|
+
const source = normalizeText(
|
|
2565
|
+
element.attr("src") ||
|
|
2566
|
+
element.attr("data-qcplay-src") ||
|
|
2567
|
+
element.attr("data-src") ||
|
|
2568
|
+
element.attr("data-original")
|
|
2569
|
+
);
|
|
2411
2570
|
if (source) {
|
|
2412
2571
|
const caption = normalizeText(element.closest("figure").find("figcaption").first().text()) ||
|
|
2413
2572
|
normalizeText(element.attr("alt") || element.attr("title")).slice(0, 50);
|
|
@@ -3135,7 +3294,7 @@ async function removeBilibiliFailedImage(bodyInput) {
|
|
|
3135
3294
|
});
|
|
3136
3295
|
}
|
|
3137
3296
|
|
|
3138
|
-
async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source, options = {}) {
|
|
3297
|
+
export async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source, options = {}) {
|
|
3139
3298
|
const deadline = Date.now() + 120000;
|
|
3140
3299
|
const startedAt = Date.now();
|
|
3141
3300
|
let stableChecks = 0;
|
|
@@ -3144,7 +3303,6 @@ async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source
|
|
|
3144
3303
|
const failed = await bodyInput
|
|
3145
3304
|
.locator('.upload-fail, .image-upload-error, [class*="upload-error"], [class*="retry"]')
|
|
3146
3305
|
.count();
|
|
3147
|
-
const pending = await pendingUploadCount(bodyInput);
|
|
3148
3306
|
if (failed > 0) {
|
|
3149
3307
|
const failureText = await bodyInput.evaluate(element => {
|
|
3150
3308
|
const nodes = [...element.querySelectorAll(
|
|
@@ -3162,7 +3320,10 @@ async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source
|
|
|
3162
3320
|
}
|
|
3163
3321
|
throw new Error(`B站正文图片上传失败: ${source}`);
|
|
3164
3322
|
}
|
|
3165
|
-
|
|
3323
|
+
// The B站 editor may keep a visible toolbar spinner after the image node
|
|
3324
|
+
// is inserted. Treat the new, stable image node as the upload receipt;
|
|
3325
|
+
// failure selectors above still catch explicit upload errors.
|
|
3326
|
+
if (count > previousCount && Date.now() - startedAt >= 1200) {
|
|
3166
3327
|
stableChecks += 1;
|
|
3167
3328
|
if (stableChecks >= 3) {
|
|
3168
3329
|
return;
|
|
@@ -3844,6 +4005,85 @@ async function fillLocator(locator, value) {
|
|
|
3844
4005
|
await locator.fill(value);
|
|
3845
4006
|
}
|
|
3846
4007
|
|
|
4008
|
+
export function replaceFirstTextValue(value, find, replacement) {
|
|
4009
|
+
const source = String(value ?? "");
|
|
4010
|
+
const needle = String(find ?? "");
|
|
4011
|
+
const next = String(replacement ?? "");
|
|
4012
|
+
if (!needle) {
|
|
4013
|
+
throw new Error("待替换的原文不能为空");
|
|
4014
|
+
}
|
|
4015
|
+
const index = source.indexOf(needle);
|
|
4016
|
+
if (index < 0) {
|
|
4017
|
+
throw new Error("当前编辑页中未找到指定原文");
|
|
4018
|
+
}
|
|
4019
|
+
return `${source.slice(0, index)}${next}${source.slice(index + needle.length)}`;
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
async function replaceTextInEditable(locator, find, replacement) {
|
|
4023
|
+
const before = String(
|
|
4024
|
+
typeof locator.inputValue === "function"
|
|
4025
|
+
? await locator.inputValue().catch(() => "")
|
|
4026
|
+
: await locator.innerText().catch(() => "")
|
|
4027
|
+
);
|
|
4028
|
+
replaceFirstTextValue(before, find, replacement);
|
|
4029
|
+
const result = await locator.evaluate(
|
|
4030
|
+
(element, values) => {
|
|
4031
|
+
const source = "value" in element ? String(element.value ?? "") : String(element.innerText ?? element.textContent ?? "");
|
|
4032
|
+
const index = source.indexOf(values.find);
|
|
4033
|
+
if (index < 0) return { changed: false, reason: "not-found" };
|
|
4034
|
+
|
|
4035
|
+
if ("value" in element) {
|
|
4036
|
+
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
4037
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
4038
|
+
const next = `${source.slice(0, index)}${values.replacement}${source.slice(index + values.find.length)}`;
|
|
4039
|
+
if (setter) {
|
|
4040
|
+
setter.call(element, next);
|
|
4041
|
+
} else {
|
|
4042
|
+
element.value = next;
|
|
4043
|
+
}
|
|
4044
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
4045
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
4046
|
+
return { changed: true, value: next };
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
4050
|
+
let node;
|
|
4051
|
+
while ((node = walker.nextNode())) {
|
|
4052
|
+
const nodeIndex = node.data.indexOf(values.find);
|
|
4053
|
+
if (nodeIndex < 0) continue;
|
|
4054
|
+
const range = document.createRange();
|
|
4055
|
+
range.setStart(node, nodeIndex);
|
|
4056
|
+
range.setEnd(node, nodeIndex + values.find.length);
|
|
4057
|
+
range.deleteContents();
|
|
4058
|
+
const fragment = document.createDocumentFragment();
|
|
4059
|
+
const lines = values.replacement.replace(/\r\n?/g, "\n").split("\n");
|
|
4060
|
+
lines.forEach((line, lineIndex) => {
|
|
4061
|
+
if (lineIndex > 0) fragment.append(document.createElement("br"));
|
|
4062
|
+
if (line) fragment.append(document.createTextNode(line));
|
|
4063
|
+
});
|
|
4064
|
+
range.insertNode(fragment);
|
|
4065
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
4066
|
+
return { changed: true, value: String(element.innerText ?? element.textContent ?? "") };
|
|
4067
|
+
}
|
|
4068
|
+
return { changed: false, reason: "cross-node" };
|
|
4069
|
+
},
|
|
4070
|
+
{ find, replacement }
|
|
4071
|
+
);
|
|
4072
|
+
if (!result?.changed) {
|
|
4073
|
+
const detail = result?.reason === "cross-node" ? "原文跨越多个富文本节点,无法在不破坏排版的情况下替换" : "当前编辑页中未找到指定原文";
|
|
4074
|
+
throw new Error(detail);
|
|
4075
|
+
}
|
|
4076
|
+
const actual = String(
|
|
4077
|
+
typeof locator.inputValue === "function"
|
|
4078
|
+
? await locator.inputValue().catch(() => "")
|
|
4079
|
+
: await locator.innerText().catch(() => "")
|
|
4080
|
+
);
|
|
4081
|
+
if (!actual.includes(replacement)) {
|
|
4082
|
+
throw new Error("编辑器未回读到修改后的文本");
|
|
4083
|
+
}
|
|
4084
|
+
return actual;
|
|
4085
|
+
}
|
|
4086
|
+
|
|
3847
4087
|
async function verifyFilledValue(locator, expected, label, platform = "TapTap") {
|
|
3848
4088
|
let actual = "";
|
|
3849
4089
|
if (typeof locator.inputValue === "function") {
|
|
@@ -4052,6 +4292,60 @@ async function editorIsReady(page, spec) {
|
|
|
4052
4292
|
return Boolean(await firstVisible(page, spec.body));
|
|
4053
4293
|
}
|
|
4054
4294
|
|
|
4295
|
+
async function findPreparedEditorPage(context, spec) {
|
|
4296
|
+
const deadline = Date.now() + 10000;
|
|
4297
|
+
do {
|
|
4298
|
+
const pages = context.pages().filter(page => !(typeof page.isClosed === "function" && page.isClosed()));
|
|
4299
|
+
for (const page of [...pages].reverse()) {
|
|
4300
|
+
if (await editorIsReady(page, spec)) return page;
|
|
4301
|
+
}
|
|
4302
|
+
const page = pages.at(-1);
|
|
4303
|
+
if (page && typeof page.waitForTimeout === "function") {
|
|
4304
|
+
await page.waitForTimeout(200);
|
|
4305
|
+
} else {
|
|
4306
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
4307
|
+
}
|
|
4308
|
+
} while (Date.now() < deadline);
|
|
4309
|
+
return null;
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
export async function editPreparedPlatformPage(entry, revision, options = {}) {
|
|
4313
|
+
const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
|
|
4314
|
+
if (!spec || entry.publisher !== "browser") {
|
|
4315
|
+
throw new Error(`${entry.platform} 不支持编辑已准备页面`);
|
|
4316
|
+
}
|
|
4317
|
+
const target = normalizeKey(revision?.target || "body");
|
|
4318
|
+
if (!new Set(["title", "body"]).has(target)) {
|
|
4319
|
+
throw new Error("编辑位置只能是 title 或 body");
|
|
4320
|
+
}
|
|
4321
|
+
const find = String(revision?.find ?? "");
|
|
4322
|
+
const replacement = String(revision?.replacement ?? "");
|
|
4323
|
+
if (!find || !replacement) {
|
|
4324
|
+
throw new Error("编辑需要同时提供原文和新文");
|
|
4325
|
+
}
|
|
4326
|
+
|
|
4327
|
+
const { chromium } = await import("playwright-core");
|
|
4328
|
+
const context = await reconnectDetachedBrowserContext(browserProfileDir(entry), chromium);
|
|
4329
|
+
if (!context) {
|
|
4330
|
+
throw new Error(`${entry.platform} 没有可继续编辑的已准备页面,请先重新准备该平台的发布页`);
|
|
4331
|
+
}
|
|
4332
|
+
try {
|
|
4333
|
+
const page = await findPreparedEditorPage(context, spec);
|
|
4334
|
+
if (!page) {
|
|
4335
|
+
throw new Error(`${entry.platform} 已准备页面中未找到标题和正文编辑器`);
|
|
4336
|
+
}
|
|
4337
|
+
const field = await waitForVisible(page, target === "title" ? spec.title : spec.body, 3000);
|
|
4338
|
+
if (!field) {
|
|
4339
|
+
throw new Error(`${entry.platform} 页面中未找到${target === "title" ? "标题" : "正文"}编辑器`);
|
|
4340
|
+
}
|
|
4341
|
+
const value = await replaceTextInEditable(field, find, replacement);
|
|
4342
|
+
options.onProgress?.(`${entry.platform}${target === "title" ? "标题" : "正文"}已修改并回读确认`);
|
|
4343
|
+
return { url: page.url(), target, value };
|
|
4344
|
+
} finally {
|
|
4345
|
+
await closeBrowserContext(context);
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4055
4349
|
async function openPlatformLogin(page, entry, spec) {
|
|
4056
4350
|
if (spec.loginPage) {
|
|
4057
4351
|
await page.goto(spec.loginPage, { waitUntil: "domcontentloaded", timeout: 60000 });
|
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),并提供以下动作:
|