@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 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();
@@ -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
- resolve();
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, { content: chunk, allowed_mentions: { parse: [] } });
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
- return { messages: chunks.length };
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.replaceWith(image.attr("src") ? `\n${image.attr("src")}\n` : "");
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[src]")
1917
- .map((_, element) => normalizeText($(element).attr("src")))
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($(element).attr("src") || $(element).attr("data-src"));
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(element.attr("src") || element.attr("data-src"));
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
- if (count > previousCount && pending === 0 && Date.now() - startedAt >= 1200) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qcplay/cli",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
4
4
  "description": "QCPlay CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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),并提供以下动作: