@qcplay/cli 1.0.18 → 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 +629 -56
- package/lib/platform-publish.js +1282 -90
- package/lib/rich-article-upload.js +116 -0
- package/lib/wechat-article.js +104 -23
- package/lib/wechat-data.js +155 -0
- package/package.json +2 -1
- package/templates/skills/qcplay-publish-article/SKILL.md +53 -12
- package/templates/skills/qcplay-wechat-data/SKILL.md +101 -0
package/bin/qcplay.js
CHANGED
|
@@ -10,12 +10,15 @@ import readline from "readline";
|
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
+
analyzeArticleContentSafety,
|
|
14
|
+
buildImportedArticleHtml,
|
|
13
15
|
buildOfficialArticleMarkdown,
|
|
14
16
|
importWechatArticle,
|
|
15
17
|
normalizeArticleColor,
|
|
16
18
|
sanitizeArticleRichHtml,
|
|
17
19
|
uploadMediaToBackend
|
|
18
20
|
} from "../lib/wechat-article.js";
|
|
21
|
+
import { buildRichArticleUploadPayload, parseRichArticleUploadFile } from "../lib/rich-article-upload.js";
|
|
19
22
|
import { importLarkArticle, parseLarkDocumentUrl } from "../lib/lark-article.js";
|
|
20
23
|
import {
|
|
21
24
|
CONTENT_RULES_TEMPLATE,
|
|
@@ -24,9 +27,19 @@ import {
|
|
|
24
27
|
loadContentRules,
|
|
25
28
|
matchingContentRules
|
|
26
29
|
} from "../lib/content-rules.js";
|
|
30
|
+
import {
|
|
31
|
+
WECHAT_GAME_CATALOG,
|
|
32
|
+
buildWechatArticleJson,
|
|
33
|
+
normalizeWechatGameId,
|
|
34
|
+
monthDateRange,
|
|
35
|
+
renderWechatArticleMarkdown,
|
|
36
|
+
splitWechatDateRange,
|
|
37
|
+
todayInShanghai
|
|
38
|
+
} from "../lib/wechat-data.js";
|
|
27
39
|
import {
|
|
28
40
|
availableProjects,
|
|
29
41
|
bilibiliPublishingPlan,
|
|
42
|
+
editPreparedPlatformPage,
|
|
30
43
|
entriesForProject,
|
|
31
44
|
loadPlatformEntries,
|
|
32
45
|
openPublishPage,
|
|
@@ -37,7 +50,11 @@ import {
|
|
|
37
50
|
runPlatformPublishSequence,
|
|
38
51
|
selectRequestedEntries,
|
|
39
52
|
tapTapPublishingPlan,
|
|
40
|
-
|
|
53
|
+
deleteDiscordMessages,
|
|
54
|
+
findDiscordMessageRecord,
|
|
55
|
+
removeDiscordMessageRecord,
|
|
56
|
+
weiboPublishingPlan,
|
|
57
|
+
writePlatformPublishUrls
|
|
41
58
|
} from "../lib/platform-publish.js";
|
|
42
59
|
|
|
43
60
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -171,8 +188,10 @@ const ARTICLE_STATE_FILE = path.join(QCPLAY_DIR, "article-state.json");
|
|
|
171
188
|
const CONTENT_RULES_FILE = DEFAULT_CONTENT_RULES_FILE;
|
|
172
189
|
const ARTICLE_PREVIEW_BASE_URL = "https://snail.qingcigame.com/official/news-details.html";
|
|
173
190
|
const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
|
|
191
|
+
const LARK_SKILL_NAMES = ["lark-shared", "lark-sheets", "lark-doc", "lark-wiki"];
|
|
174
192
|
const WEST_ARTICLE_LANGUAGES = new Set(["portuguese", "german", "spanish", "italian", "french", "america_en"]);
|
|
175
193
|
const ARTICLE_PREVIEW_URLS = {
|
|
194
|
+
"33": "https://tideng.qingcigame.com/official/news.html",
|
|
176
195
|
"50": "https://mtzg.qingcijoy.com/news-details.html",
|
|
177
196
|
"69": "https://hjwq.qingcijoy.com/news-details.html",
|
|
178
197
|
"74": "https://sakura.qingcijoy.com/news-details.html",
|
|
@@ -226,12 +245,15 @@ Usage:
|
|
|
226
245
|
qcplay-cli article
|
|
227
246
|
qcplay-cli article init [file]
|
|
228
247
|
qcplay-cli article west-init [file]
|
|
229
|
-
qcplay-cli article import <wechat-or-feishu-url> [file] [--rules-file <json|txt|docx>] [--project <项目>] [--platform <平台>] [--backend <url>] [--local]
|
|
248
|
+
qcplay-cli article import <wechat-or-feishu-url> [file] [--format html|markdown] [--rules-file <json|txt|docx>] [--project <项目>] [--platform <平台>] [--backend <url>] [--local]
|
|
230
249
|
qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
|
|
231
250
|
qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
|
|
232
251
|
qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]
|
|
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>]
|
|
233
253
|
qcplay-cli publish [file] [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--project <项目>] [--region <区域>] [--platform <平台>]
|
|
234
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 <区域>
|
|
235
257
|
qcplay-cli rules [init|validate|list] [--rules-file <json>]
|
|
236
258
|
qcplay-cli www-article-list.store <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
|
|
237
259
|
qcplay-cli www-article-list.update [id] [--local] [--backend <url>] [--dry-run]
|
|
@@ -253,6 +275,14 @@ function printUpdateHelp() {
|
|
|
253
275
|
console.log(" qcplay-cli update");
|
|
254
276
|
}
|
|
255
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
|
+
|
|
256
286
|
function printAuthHelp() {
|
|
257
287
|
console.log("Usage:");
|
|
258
288
|
console.log(" qcplay-cli auth [login|status|logout] [--local] [--backend <url>] [--auth-page <url>]");
|
|
@@ -264,22 +294,105 @@ function printArticleHelp() {
|
|
|
264
294
|
qcplay-cli article
|
|
265
295
|
qcplay-cli article init [file]
|
|
266
296
|
qcplay-cli article west-init [file]
|
|
267
|
-
qcplay-cli article import <wechat-or-feishu-url> [file] [--rules-file <json>] [--project <项目>] [--backend <url>] [--local]
|
|
297
|
+
qcplay-cli article import <wechat-or-feishu-url> [file] [--format html|markdown] [--rules-file <json>] [--project <项目>] [--backend <url>] [--local]
|
|
268
298
|
qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
|
|
269
299
|
qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
|
|
270
300
|
qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]`);
|
|
271
301
|
}
|
|
272
302
|
|
|
303
|
+
function printWechatHelp() {
|
|
304
|
+
console.log(`Usage:
|
|
305
|
+
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>]
|
|
306
|
+
|
|
307
|
+
只读取微信公众号接口返回的 day_total,并按游戏和月份生成表格。
|
|
308
|
+
支持的 game_id: ${Object.entries(WECHAT_GAME_CATALOG).map(([id, name]) => `${id}(${name})`).join("、")}
|
|
309
|
+
当前日期作为结束日期时,必须填写到昨天;超过 30 天的范围会按月份和 30 天窗口自动拆分请求。`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function parseWechatArticleOptions(args) {
|
|
313
|
+
const options = {
|
|
314
|
+
gameIds: [],
|
|
315
|
+
beginDate: "",
|
|
316
|
+
endDate: "",
|
|
317
|
+
month: "",
|
|
318
|
+
output: "",
|
|
319
|
+
format: "markdown",
|
|
320
|
+
backend: undefined,
|
|
321
|
+
local: false
|
|
322
|
+
};
|
|
323
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
324
|
+
const current = args[index];
|
|
325
|
+
if (["--game-id", "--game", "--begin-date", "--start", "--end-date", "--end", "--month", "--output", "--format", "--backend"].includes(current)) {
|
|
326
|
+
const next = args[index + 1];
|
|
327
|
+
if (!next || next.startsWith("--")) throw new Error(`${current} 缺少参数值`);
|
|
328
|
+
if (current === "--game-id" || current === "--game") {
|
|
329
|
+
options.gameIds.push(...next.split(",").map(normalizeWechatGameId));
|
|
330
|
+
} else if (current === "--begin-date" || current === "--start") {
|
|
331
|
+
options.beginDate = next;
|
|
332
|
+
} else if (current === "--end-date" || current === "--end") {
|
|
333
|
+
options.endDate = next;
|
|
334
|
+
} else if (current === "--month") {
|
|
335
|
+
options.month = next;
|
|
336
|
+
} else if (current === "--output") {
|
|
337
|
+
options.output = next;
|
|
338
|
+
} else if (current === "--format") {
|
|
339
|
+
options.format = next.toLowerCase();
|
|
340
|
+
} else {
|
|
341
|
+
options.backend = next;
|
|
342
|
+
}
|
|
343
|
+
index += 1;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (current === "--local") {
|
|
347
|
+
options.local = true;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (current === "--all-games") {
|
|
351
|
+
options.gameIds = Object.keys(WECHAT_GAME_CATALOG);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
throw new Error(`未知参数: ${current}`);
|
|
355
|
+
}
|
|
356
|
+
options.gameIds = [...new Set(options.gameIds)];
|
|
357
|
+
if (options.gameIds.length === 0) throw new Error("请至少指定一个 --game-id");
|
|
358
|
+
if (options.month && (options.beginDate || options.endDate)) throw new Error("--month 不能与 --begin-date/--end-date 同时使用");
|
|
359
|
+
if (options.month) {
|
|
360
|
+
const range = monthDateRange(options.month, todayInShanghai());
|
|
361
|
+
options.beginDate = range.begin.toISOString().slice(0, 10);
|
|
362
|
+
options.endDate = range.end.toISOString().slice(0, 10);
|
|
363
|
+
}
|
|
364
|
+
if (!options.beginDate || !options.endDate) throw new Error("请指定 --month,或同时指定 --begin-date 和 --end-date");
|
|
365
|
+
if (!new Set(["markdown", "json"]).has(options.format)) throw new Error("--format 只能是 markdown 或 json");
|
|
366
|
+
return options;
|
|
367
|
+
}
|
|
368
|
+
|
|
273
369
|
function parseArticleImportOptions(args) {
|
|
274
370
|
const positional = [];
|
|
275
|
-
const options = { rulesFile: "", project: "", platform: "", backend: undefined, local: false };
|
|
371
|
+
const options = { rulesFile: "", project: "", platform: "", format: "html", backend: undefined, local: false };
|
|
276
372
|
for (let index = 0; index < args.length; index += 1) {
|
|
277
373
|
const current = args[index];
|
|
278
|
-
if (
|
|
374
|
+
if (
|
|
375
|
+
current === "--rules-file" ||
|
|
376
|
+
current === "--project" ||
|
|
377
|
+
current === "--platform" ||
|
|
378
|
+
current === "--format" ||
|
|
379
|
+
current === "--backend"
|
|
380
|
+
) {
|
|
279
381
|
const next = args[index + 1];
|
|
280
382
|
if (!next || next.startsWith("--")) throw new Error(`${current} 缺少参数值`);
|
|
281
|
-
|
|
282
|
-
|
|
383
|
+
if (current === "--format") {
|
|
384
|
+
options.format = next.toLowerCase();
|
|
385
|
+
} else {
|
|
386
|
+
const key =
|
|
387
|
+
current === "--rules-file"
|
|
388
|
+
? "rulesFile"
|
|
389
|
+
: current === "--project"
|
|
390
|
+
? "project"
|
|
391
|
+
: current === "--platform"
|
|
392
|
+
? "platform"
|
|
393
|
+
: "backend";
|
|
394
|
+
options[key] = next;
|
|
395
|
+
}
|
|
283
396
|
index += 1;
|
|
284
397
|
continue;
|
|
285
398
|
}
|
|
@@ -291,7 +404,14 @@ function parseArticleImportOptions(args) {
|
|
|
291
404
|
positional.push(current);
|
|
292
405
|
}
|
|
293
406
|
if (positional.length > 2) throw new Error(`未知参数: ${positional.slice(2).join(" ")}`);
|
|
294
|
-
|
|
407
|
+
if (!new Set(["markdown", "html"]).has(options.format)) {
|
|
408
|
+
throw new Error("--format 只能是 markdown 或 html");
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
sourceUrl: positional[0],
|
|
412
|
+
file: positional[1] || (options.format === "markdown" ? "article.md" : "article.html"),
|
|
413
|
+
options
|
|
414
|
+
};
|
|
295
415
|
}
|
|
296
416
|
|
|
297
417
|
function printPublishHelp() {
|
|
@@ -311,8 +431,10 @@ Options:
|
|
|
311
431
|
--config-file <json> 使用本地平台配置(主要用于测试)
|
|
312
432
|
--rules-file <json> 使用指定内容规则;默认 ~/.qcplay/platform-rules.json
|
|
313
433
|
--browser-channel <name> Playwright 浏览器通道,Windows 默认 msedge
|
|
314
|
-
--review-draft, --review
|
|
315
|
-
--direct, --no-review
|
|
434
|
+
--review-draft, --review 官网外平台先准备草稿,预览后由当前 AI 对话确认发布
|
|
435
|
+
--direct, --no-review 需配合 AI 对话确认标记后才可跳过发布前预览
|
|
436
|
+
--publish-draft 仅 TapTap:发布标题匹配的已有草稿,不重新写入内容
|
|
437
|
+
--draft-only, --draft 仅保存或停留在官网外平台草稿,不执行正式发布
|
|
316
438
|
--open-page 只打开真实浏览器发布页,供登录和页面流程测试,不读取或提交文章
|
|
317
439
|
--switch-account 先打开发布页切换账号,确认后继续当前发布流程
|
|
318
440
|
--keep-open 浏览器提交后等待用户确认再关闭
|
|
@@ -378,6 +500,56 @@ function parseRulesOptions(args) {
|
|
|
378
500
|
return options;
|
|
379
501
|
}
|
|
380
502
|
|
|
503
|
+
async function importWechatDataCommand(options) {
|
|
504
|
+
const config = await loadConfig();
|
|
505
|
+
const backendUrl = resolveAuthBackendUrl(options, config);
|
|
506
|
+
const reports = [];
|
|
507
|
+
for (const gameId of options.gameIds) {
|
|
508
|
+
const ranges = splitWechatDateRange(options.beginDate, options.endDate, todayInShanghai());
|
|
509
|
+
const dayTotal = new Map();
|
|
510
|
+
for (const range of ranges) {
|
|
511
|
+
const query = new URLSearchParams({
|
|
512
|
+
game_id: gameId,
|
|
513
|
+
begin_date: range.beginDate,
|
|
514
|
+
end_date: range.endDate
|
|
515
|
+
});
|
|
516
|
+
console.log(chalk.gray(`正在拉取${WECHAT_GAME_CATALOG[gameId]} ${range.beginDate} 至 ${range.endDate}`));
|
|
517
|
+
const response = await authenticatedRequest("GET", backendUrl, `/api/wechat/article?${query}`);
|
|
518
|
+
if (Number(response.code) !== 200) {
|
|
519
|
+
throw new Error(`${WECHAT_GAME_CATALOG[gameId]} ${range.beginDate} 至 ${range.endDate}:${response.message || `接口返回 code=${response.code ?? "未知"}`}`);
|
|
520
|
+
}
|
|
521
|
+
const items = Array.isArray(response.data?.day_total) ? response.data.day_total : [];
|
|
522
|
+
for (const item of items) {
|
|
523
|
+
const key = `${item.ref_date || ""}\u0000${item.msgid || ""}`;
|
|
524
|
+
dayTotal.set(key, item);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
reports.push({
|
|
528
|
+
gameId,
|
|
529
|
+
gameName: WECHAT_GAME_CATALOG[gameId],
|
|
530
|
+
beginDate: options.beginDate,
|
|
531
|
+
endDate: options.endDate,
|
|
532
|
+
ranges,
|
|
533
|
+
dayTotal: [...dayTotal.values()].sort((left, right) => {
|
|
534
|
+
const byDate = String(left.ref_date || "").localeCompare(String(right.ref_date || ""));
|
|
535
|
+
return byDate || String(left.msgid || "").localeCompare(String(right.msgid || ""));
|
|
536
|
+
})
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const output = options.format === "json" ? buildWechatArticleJson(reports) : renderWechatArticleMarkdown(reports);
|
|
541
|
+
if (options.output) {
|
|
542
|
+
const target = path.resolve(options.output);
|
|
543
|
+
if (await pathExists(target)) throw new Error(`输出文件已存在: ${target}`);
|
|
544
|
+
await ensureDir(path.dirname(target));
|
|
545
|
+
await fs.promises.writeFile(target, output, { encoding: "utf8", mode: 0o600 });
|
|
546
|
+
console.log(chalk.green(`微信公众号数据已生成: ${target}`));
|
|
547
|
+
console.log(`文章数据: ${reports.reduce((sum, report) => sum + report.dayTotal.length, 0)} 篇`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
process.stdout.write(output);
|
|
551
|
+
}
|
|
552
|
+
|
|
381
553
|
async function contentRulesCommand(action, args = []) {
|
|
382
554
|
const options = parseRulesOptions(args);
|
|
383
555
|
const file = path.resolve(options.rulesFile || CONTENT_RULES_FILE);
|
|
@@ -430,7 +602,7 @@ function printFeatures() {
|
|
|
430
602
|
console.log(" qcplay-cli auth permissions --key www-article-list.store");
|
|
431
603
|
console.log("");
|
|
432
604
|
console.log(chalk.cyan("3. 发布官网文章"));
|
|
433
|
-
console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.
|
|
605
|
+
console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.html");
|
|
434
606
|
console.log(" qcplay-cli www-article-list.store article.md");
|
|
435
607
|
console.log(" qcplay-cli article update [id]");
|
|
436
608
|
console.log(" qcplay-cli article delete [id]");
|
|
@@ -442,6 +614,9 @@ function printFeatures() {
|
|
|
442
614
|
console.log(chalk.cyan("5. 查看本地目录"));
|
|
443
615
|
console.log(" qcplay-cli where");
|
|
444
616
|
console.log("");
|
|
617
|
+
console.log(chalk.cyan("6. 拉取微信公众号数据"));
|
|
618
|
+
console.log(" qcplay-cli wechat article --game-id 39 --month 2026-08 --output wechat-2026-08.md");
|
|
619
|
+
console.log("");
|
|
445
620
|
}
|
|
446
621
|
|
|
447
622
|
function printWhere() {
|
|
@@ -583,6 +758,25 @@ async function installSkills() {
|
|
|
583
758
|
};
|
|
584
759
|
}
|
|
585
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
|
+
|
|
586
780
|
async function saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode = false) {
|
|
587
781
|
await writeJson(CONFIG_FILE, {
|
|
588
782
|
cli: "qcplay-cli",
|
|
@@ -814,6 +1008,9 @@ function parseDistributionOptions(args) {
|
|
|
814
1008
|
browserChannel: "",
|
|
815
1009
|
reviewDraft: false,
|
|
816
1010
|
directPublish: false,
|
|
1011
|
+
confirmed: false,
|
|
1012
|
+
draftOnly: false,
|
|
1013
|
+
publishDraft: false,
|
|
817
1014
|
keepOpen: false,
|
|
818
1015
|
openPage: false,
|
|
819
1016
|
switchAccount: false,
|
|
@@ -857,6 +1054,18 @@ function parseDistributionOptions(args) {
|
|
|
857
1054
|
options.directPublish = true;
|
|
858
1055
|
continue;
|
|
859
1056
|
}
|
|
1057
|
+
if (current === "--confirmed") {
|
|
1058
|
+
options.confirmed = true;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (current === "--draft-only" || current === "--draft") {
|
|
1062
|
+
options.draftOnly = true;
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
if (current === "--publish-draft") {
|
|
1066
|
+
options.publishDraft = true;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
860
1069
|
|
|
861
1070
|
const valueFlags = new Map([
|
|
862
1071
|
["--backend", "backend"],
|
|
@@ -904,6 +1113,139 @@ function parseDistributionOptions(args) {
|
|
|
904
1113
|
return { file: positional[0], options };
|
|
905
1114
|
}
|
|
906
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
|
+
|
|
907
1249
|
function applyPublishOverrides(parsedArticle, options) {
|
|
908
1250
|
const title = normalizeText(options.title);
|
|
909
1251
|
const cover = normalizeText(options.cover);
|
|
@@ -921,11 +1263,11 @@ function applyPublishOverrides(parsedArticle, options) {
|
|
|
921
1263
|
...parsedArticle.meta,
|
|
922
1264
|
...(cover
|
|
923
1265
|
? {
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1266
|
+
weibo_cover: cover,
|
|
1267
|
+
taptap_cover: cover,
|
|
1268
|
+
bilibili_cover: cover,
|
|
1269
|
+
xiaohongshu_images: cover
|
|
1270
|
+
}
|
|
929
1271
|
: {}),
|
|
930
1272
|
...(weiboColumn ? { weibo_column: weiboColumn } : {})
|
|
931
1273
|
};
|
|
@@ -1003,7 +1345,9 @@ function openBrowser(targetUrl) {
|
|
|
1003
1345
|
|
|
1004
1346
|
if (process.platform === "win32") {
|
|
1005
1347
|
command = "cmd";
|
|
1006
|
-
|
|
1348
|
+
// 关键修复:将 URL 中的 & 替换为 ^&
|
|
1349
|
+
const safeUrl = targetUrl.replace(/&/g, '^&');
|
|
1350
|
+
args = ["/c", "start", "", safeUrl];
|
|
1007
1351
|
} else if (process.platform === "darwin") {
|
|
1008
1352
|
command = "open";
|
|
1009
1353
|
args = [targetUrl];
|
|
@@ -1029,6 +1373,10 @@ function getNpmCommand() {
|
|
|
1029
1373
|
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
1030
1374
|
}
|
|
1031
1375
|
|
|
1376
|
+
function getNpxCommand() {
|
|
1377
|
+
return process.platform === "win32" ? "npx.cmd" : "npx";
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1032
1380
|
function runCommand(command, args) {
|
|
1033
1381
|
return new Promise((resolve, reject) => {
|
|
1034
1382
|
const child = spawn(command, args, {
|
|
@@ -1047,6 +1395,11 @@ function runCommand(command, args) {
|
|
|
1047
1395
|
});
|
|
1048
1396
|
}
|
|
1049
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
|
+
|
|
1050
1403
|
function delay(ms) {
|
|
1051
1404
|
return new Promise(resolve => {
|
|
1052
1405
|
setTimeout(resolve, ms);
|
|
@@ -1344,7 +1697,7 @@ function backendOriginsMatch(left, right) {
|
|
|
1344
1697
|
}
|
|
1345
1698
|
}
|
|
1346
1699
|
|
|
1347
|
-
async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
|
|
1700
|
+
async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic", metadata = {}) {
|
|
1348
1701
|
const authState = await loadAuthState();
|
|
1349
1702
|
await writeJson(ARTICLE_STATE_FILE, {
|
|
1350
1703
|
article_id: articleId,
|
|
@@ -1354,13 +1707,15 @@ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl
|
|
|
1354
1707
|
backend_url: backendUrl,
|
|
1355
1708
|
administrator_id: Number(authState.id || 0),
|
|
1356
1709
|
account: normalizeText(authState.account),
|
|
1710
|
+
game_id: normalizeText(metadata.game_id),
|
|
1711
|
+
cate_id: normalizeText(metadata.cate_id),
|
|
1357
1712
|
updated_at: Date.now()
|
|
1358
1713
|
});
|
|
1359
1714
|
}
|
|
1360
1715
|
|
|
1361
|
-
async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
|
|
1716
|
+
async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic", metadata = {}) {
|
|
1362
1717
|
try {
|
|
1363
|
-
await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target);
|
|
1718
|
+
await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target, metadata);
|
|
1364
1719
|
return "";
|
|
1365
1720
|
} catch (error) {
|
|
1366
1721
|
return normalizeText(error?.message) || String(error);
|
|
@@ -1497,6 +1852,7 @@ async function installCommand(options) {
|
|
|
1497
1852
|
console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
|
|
1498
1853
|
|
|
1499
1854
|
const skillsInstalled = await installSkills();
|
|
1855
|
+
await installLarkCli();
|
|
1500
1856
|
if (skillsInstalled.available) {
|
|
1501
1857
|
console.log(chalk.green("✔ Skills 已安装"));
|
|
1502
1858
|
console.log(`Skills 目录: ${chalk.gray(SKILLS_DIR)}`);
|
|
@@ -1504,6 +1860,7 @@ async function installCommand(options) {
|
|
|
1504
1860
|
console.log(chalk.yellow("! 未找到 Skills 模板,已跳过"));
|
|
1505
1861
|
}
|
|
1506
1862
|
|
|
1863
|
+
printLarkSkillStatus(await detectLarkSkills());
|
|
1507
1864
|
await saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode);
|
|
1508
1865
|
console.log(chalk.green("✔ 配置文件已生成"));
|
|
1509
1866
|
console.log(chalk.gray(`认证后端: ${authBackendUrl}`));
|
|
@@ -1553,6 +1910,7 @@ async function updateCommand() {
|
|
|
1553
1910
|
const latestPackage = readPackageMetadata();
|
|
1554
1911
|
await updateStoredConfigVersion(latestPackage.version);
|
|
1555
1912
|
const skillsInstalled = await installSkills();
|
|
1913
|
+
await installLarkCli();
|
|
1556
1914
|
|
|
1557
1915
|
console.log("");
|
|
1558
1916
|
console.log(chalk.green("CLI 更新完成"));
|
|
@@ -1562,6 +1920,7 @@ async function updateCommand() {
|
|
|
1562
1920
|
console.log(chalk.gray(`版本: ${latestPackage.version}`));
|
|
1563
1921
|
}
|
|
1564
1922
|
printSkillsSyncResult(skillsInstalled);
|
|
1923
|
+
printLarkSkillStatus(await detectLarkSkills());
|
|
1565
1924
|
console.log(chalk.green(rulesCreated ? "✔ 用户内容规则已创建" : "✔ 用户内容规则未覆盖"));
|
|
1566
1925
|
console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
|
|
1567
1926
|
console.log("");
|
|
@@ -1701,14 +2060,14 @@ function applyInlineMarkdown(text) {
|
|
|
1701
2060
|
output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
|
|
1702
2061
|
return storeToken(
|
|
1703
2062
|
`<img src="${escapeHtml(url.trim())}" alt="${escapeHtml(alt.trim())}" ` +
|
|
1704
|
-
|
|
2063
|
+
'style="display:block;box-sizing:border-box;width:auto;max-width:100%;height:auto;object-fit:contain;margin:24px auto;" />'
|
|
1705
2064
|
);
|
|
1706
2065
|
});
|
|
1707
2066
|
|
|
1708
2067
|
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
|
1709
2068
|
return storeToken(
|
|
1710
2069
|
`<a href="${escapeHtml(url.trim())}" target="_blank" rel="noreferrer" ` +
|
|
1711
|
-
|
|
2070
|
+
`style="color:#576b95;text-decoration:none;overflow-wrap:anywhere;word-break:break-all;">${escapeHtml(label.trim())}</a>`
|
|
1712
2071
|
);
|
|
1713
2072
|
});
|
|
1714
2073
|
|
|
@@ -1754,7 +2113,7 @@ function markdownToHtml(markdown) {
|
|
|
1754
2113
|
|
|
1755
2114
|
html.push(
|
|
1756
2115
|
`<p style="margin:0 0 20px;font-size:16px;line-height:1.9;color:#3f3f3f;text-align:justify;overflow-wrap:anywhere;word-break:break-word;">` +
|
|
1757
|
-
|
|
2116
|
+
`${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`
|
|
1758
2117
|
);
|
|
1759
2118
|
paragraph = [];
|
|
1760
2119
|
}
|
|
@@ -1766,9 +2125,9 @@ function markdownToHtml(markdown) {
|
|
|
1766
2125
|
|
|
1767
2126
|
html.push(
|
|
1768
2127
|
'<blockquote style="margin:24px 0;padding:14px 18px;border-left:4px solid #17b9c2;background:#f5f7f8;color:#57606a;">' +
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
2128
|
+
`<p style="margin:0;font-size:15px;line-height:1.8;">${quote
|
|
2129
|
+
.map(line => applyInlineMarkdown(line))
|
|
2130
|
+
.join("<br />")}</p></blockquote>`
|
|
1772
2131
|
);
|
|
1773
2132
|
quote = [];
|
|
1774
2133
|
}
|
|
@@ -1782,7 +2141,7 @@ function markdownToHtml(markdown) {
|
|
|
1782
2141
|
|
|
1783
2142
|
html.push(
|
|
1784
2143
|
`<${listType} style="margin:0 0 20px;padding-left:1.6em;font-size:16px;line-height:1.9;color:#3f3f3f;">` +
|
|
1785
|
-
|
|
2144
|
+
`${listItems.map(item => `<li style="margin:6px 0;">${applyInlineMarkdown(item)}</li>`).join("")}</${listType}>`
|
|
1786
2145
|
);
|
|
1787
2146
|
listType = "";
|
|
1788
2147
|
listItems = [];
|
|
@@ -1795,7 +2154,7 @@ function markdownToHtml(markdown) {
|
|
|
1795
2154
|
|
|
1796
2155
|
html.push(
|
|
1797
2156
|
'<pre style="margin:24px 0;padding:16px;overflow:auto;border-radius:4px;background:#f5f7f8;">' +
|
|
1798
|
-
|
|
2157
|
+
`<code>${escapeHtml(codeLines.join("\n"))}</code></pre>`
|
|
1799
2158
|
);
|
|
1800
2159
|
inCodeBlock = false;
|
|
1801
2160
|
codeLines = [];
|
|
@@ -1834,18 +2193,18 @@ function markdownToHtml(markdown) {
|
|
|
1834
2193
|
"font-size:15px;line-height:1.7;overflow-wrap:anywhere;word-break:break-word;";
|
|
1835
2194
|
html.push(
|
|
1836
2195
|
'<div style="width:100%;margin:24px 0;overflow-x:auto;-webkit-overflow-scrolling:touch;">' +
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
2196
|
+
'<table style="width:100%;min-width:560px;border-collapse:collapse;table-layout:auto;color:#3f3f3f;">' +
|
|
2197
|
+
`<thead><tr>${header
|
|
2198
|
+
.map(cell => `<th style="${cellStyle}background:#f5f7f8;font-weight:600;">${applyInlineMarkdown(cell)}</th>`)
|
|
2199
|
+
.join("")}</tr></thead>` +
|
|
2200
|
+
`<tbody>${body
|
|
2201
|
+
.map(
|
|
2202
|
+
row =>
|
|
2203
|
+
`<tr>${row
|
|
2204
|
+
.map(cell => `<td style="${cellStyle}">${applyInlineMarkdown(cell)}</td>`)
|
|
2205
|
+
.join("")}</tr>`
|
|
2206
|
+
)
|
|
2207
|
+
.join("")}</tbody></table></div>`
|
|
1849
2208
|
);
|
|
1850
2209
|
tableLines = [];
|
|
1851
2210
|
}
|
|
@@ -1908,7 +2267,7 @@ function markdownToHtml(markdown) {
|
|
|
1908
2267
|
const headingSize = level === 1 ? 24 : level === 2 ? 20 : 18;
|
|
1909
2268
|
html.push(
|
|
1910
2269
|
`<h${level} style="margin:32px 0 16px;font-size:${headingSize}px;line-height:1.5;font-weight:700;color:#24292f;overflow-wrap:anywhere;word-break:break-word;">` +
|
|
1911
|
-
|
|
2270
|
+
`${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`
|
|
1912
2271
|
);
|
|
1913
2272
|
continue;
|
|
1914
2273
|
}
|
|
@@ -2074,6 +2433,23 @@ async function parseArticleFile(file) {
|
|
|
2074
2433
|
}
|
|
2075
2434
|
|
|
2076
2435
|
const raw = await fs.promises.readFile(articleFile, "utf8");
|
|
2436
|
+
if (path.extname(articleFile).toLowerCase() === ".html") {
|
|
2437
|
+
const { meta, richHtml } = parseRichArticleUploadFile(raw);
|
|
2438
|
+
return {
|
|
2439
|
+
articleFile,
|
|
2440
|
+
payload: buildRichArticleUploadPayload(meta, richHtml, {
|
|
2441
|
+
normalizeText,
|
|
2442
|
+
normalizeMappedValue,
|
|
2443
|
+
normalizeGameAndCategory,
|
|
2444
|
+
normalizeBoolLike,
|
|
2445
|
+
statusMap: STATUS_MAP,
|
|
2446
|
+
areaMap: AREA_MAP,
|
|
2447
|
+
westLanguages: WEST_ARTICLE_LANGUAGES
|
|
2448
|
+
}),
|
|
2449
|
+
meta,
|
|
2450
|
+
markdown: ""
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2077
2453
|
const parsed = parseFrontMatter(raw);
|
|
2078
2454
|
return {
|
|
2079
2455
|
articleFile,
|
|
@@ -2106,7 +2482,7 @@ async function initArticleTemplate(file = "article.md", template = "article.md")
|
|
|
2106
2482
|
console.log("");
|
|
2107
2483
|
}
|
|
2108
2484
|
|
|
2109
|
-
async function importArticleCommand(sourceUrl, file = "article.
|
|
2485
|
+
async function importArticleCommand(sourceUrl, file = "article.html", options = {}) {
|
|
2110
2486
|
if (!sourceUrl) {
|
|
2111
2487
|
throw new Error("缺少文章来源链接,请提供微信公众号文章或飞书 Docx/Wiki 文档链接");
|
|
2112
2488
|
}
|
|
@@ -2151,14 +2527,22 @@ async function importArticleCommand(sourceUrl, file = "article.md", options = {}
|
|
|
2151
2527
|
}
|
|
2152
2528
|
});
|
|
2153
2529
|
}
|
|
2154
|
-
const
|
|
2530
|
+
const projectMetadata = importProjectMetadata(options.project);
|
|
2531
|
+
if (projectMetadata) {
|
|
2532
|
+
article = { ...article, ...projectMetadata };
|
|
2533
|
+
}
|
|
2534
|
+
const output = options.format === "html" ? buildImportedArticleHtml(article) : buildOfficialArticleMarkdown(article);
|
|
2155
2535
|
await ensureDir(path.dirname(targetFile));
|
|
2156
2536
|
await fs.promises.writeFile(targetFile, output, { encoding: "utf8", flag: "wx" });
|
|
2157
2537
|
|
|
2158
2538
|
console.log("");
|
|
2159
|
-
console.log(chalk.green(`${sourceLabel}
|
|
2539
|
+
console.log(chalk.green(`${sourceLabel}已转换为${options.format === "html" ? "HTML 富文本文章" : "官网文章草稿"}:`));
|
|
2160
2540
|
console.log(targetFile);
|
|
2161
2541
|
console.log(chalk.gray(`标题: ${article.title}`));
|
|
2542
|
+
const contentSafety = article.contentSafety || analyzeArticleContentSafety(article);
|
|
2543
|
+
for (const warning of contentSafety.warnings) {
|
|
2544
|
+
console.log(chalk.yellow(`内容安全提示: ${warning}`));
|
|
2545
|
+
}
|
|
2162
2546
|
if (sourceLabel === "飞书文档") {
|
|
2163
2547
|
console.log(chalk.gray(`已保留图片: ${article.imageCount} 张,表格: ${article.tableCount} 个`));
|
|
2164
2548
|
console.log(chalk.gray("已保留标题、列表、引用、颜色、高亮、对齐、列宽及合并单元格"));
|
|
@@ -2188,7 +2572,13 @@ async function importArticleCommand(sourceUrl, file = "article.md", options = {}
|
|
|
2188
2572
|
);
|
|
2189
2573
|
}
|
|
2190
2574
|
console.log("");
|
|
2191
|
-
console.log(
|
|
2575
|
+
console.log(
|
|
2576
|
+
options.format === "html"
|
|
2577
|
+
? "可直接用于官网或各分发平台,平台规则会在发布时按原有流程处理:"
|
|
2578
|
+
: sourceLabel === "飞书文档"
|
|
2579
|
+
? "请补充 thumbnail、cate_id 等业务字段,确认内容后执行:"
|
|
2580
|
+
: "请补充 cate_id 等业务字段,确认内容后执行:"
|
|
2581
|
+
);
|
|
2192
2582
|
console.log("");
|
|
2193
2583
|
console.log(` qcplay-cli www-article-list.store ${file}`);
|
|
2194
2584
|
console.log("");
|
|
@@ -2251,9 +2641,9 @@ ${chalk.cyan("发布命令:")}
|
|
|
2251
2641
|
|
|
2252
2642
|
qcplay-cli www-article-list.store article.md
|
|
2253
2643
|
|
|
2254
|
-
${chalk.cyan("
|
|
2644
|
+
${chalk.cyan("从微信推文拉取 HTML 富文本:")}
|
|
2255
2645
|
|
|
2256
|
-
qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.
|
|
2646
|
+
qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.html
|
|
2257
2647
|
|
|
2258
2648
|
${chalk.cyan("项目默认分类:")}
|
|
2259
2649
|
|
|
@@ -2277,6 +2667,21 @@ function inferDistributionProject(payload, explicitProject, meta = {}) {
|
|
|
2277
2667
|
return "";
|
|
2278
2668
|
}
|
|
2279
2669
|
|
|
2670
|
+
function importProjectMetadata(project) {
|
|
2671
|
+
const normalizedProject = normalizeText(project);
|
|
2672
|
+
if (!normalizedProject) return null;
|
|
2673
|
+
const gameId = normalizeMappedValue(normalizedProject, GAME_MAP);
|
|
2674
|
+
const game = gameCatalogEntry(gameId);
|
|
2675
|
+
if (!game) {
|
|
2676
|
+
throw new Error(`未知项目: ${normalizedProject}`);
|
|
2677
|
+
}
|
|
2678
|
+
return {
|
|
2679
|
+
gameId,
|
|
2680
|
+
categoryId: game.defaultCategory,
|
|
2681
|
+
distributionProject: normalizedProject
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2280
2685
|
function printPlatformEntries(entries) {
|
|
2281
2686
|
console.log("");
|
|
2282
2687
|
console.log(chalk.cyan("可用发布平台:"));
|
|
@@ -2324,9 +2729,25 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2324
2729
|
throw new Error("缺少文章文件,例如:qcplay-cli publish article.md --platform TapTap");
|
|
2325
2730
|
}
|
|
2326
2731
|
|
|
2327
|
-
|
|
2732
|
+
let selectedEntries = options.platforms.length
|
|
2328
2733
|
? selectRequestedEntries(candidates, options.platforms)
|
|
2329
2734
|
: await promptForPlatformEntries(candidates);
|
|
2735
|
+
if (options.draftOnly) {
|
|
2736
|
+
selectedEntries = selectedEntries.filter(entry => entry.platformKey !== "website");
|
|
2737
|
+
if (selectedEntries.length === 0) {
|
|
2738
|
+
throw new Error("--draft-only 仅适用于官网之外的游戏平台");
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
if (options.draftOnly && options.publishDraft) {
|
|
2742
|
+
throw new Error("--draft-only 与 --publish-draft 不能同时使用");
|
|
2743
|
+
}
|
|
2744
|
+
const needsActionSelection =
|
|
2745
|
+
!options.dryRun &&
|
|
2746
|
+
!options.openPage &&
|
|
2747
|
+
!options.switchAccount &&
|
|
2748
|
+
!options.draftOnly &&
|
|
2749
|
+
!(options.publishDraft && options.confirmed) &&
|
|
2750
|
+
!(options.directPublish && options.confirmed);
|
|
2330
2751
|
if (options.openPage) {
|
|
2331
2752
|
if (selectedEntries.some(entry => entry.publisher !== "browser")) {
|
|
2332
2753
|
throw new Error("--open-page 仅支持有真实浏览器发布页的平台,请使用 --platform 指定浏览器平台");
|
|
@@ -2376,8 +2797,11 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2376
2797
|
const matchedRules = matchingContentRules(contentRules, planningArticle, entry).map(rule => rule.name);
|
|
2377
2798
|
return {
|
|
2378
2799
|
...publicEntrySummary(entry),
|
|
2800
|
+
...(options.draftOnly ? { mode: "draft-only" } : {}),
|
|
2379
2801
|
...(matchedRules.length > 0 ? { contentRules: matchedRules } : {}),
|
|
2380
|
-
...(entry.platformKey === "taptap"
|
|
2802
|
+
...(entry.platformKey === "taptap"
|
|
2803
|
+
? { taptap: { ...tapTapPublishingPlan(planningArticle, entry), ...(options.draftOnly ? { draft: true } : {}) } }
|
|
2804
|
+
: {}),
|
|
2381
2805
|
...(entry.platformKey === "bilibili" ? { bilibili: bilibiliPublishingPlan(planningArticle, entry) } : {}),
|
|
2382
2806
|
...(entry.platformKey === "weibo" ? { weibo: weiboPublishingPlan(planningArticle, entry) } : {})
|
|
2383
2807
|
};
|
|
@@ -2396,7 +2820,17 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2396
2820
|
meta: parsedArticle.meta,
|
|
2397
2821
|
platformInputs
|
|
2398
2822
|
};
|
|
2399
|
-
const
|
|
2823
|
+
const contentSafety = analyzeArticleContentSafety(article);
|
|
2824
|
+
for (const warning of contentSafety.warnings) {
|
|
2825
|
+
console.log(chalk.yellow(`内容安全提示: ${warning}`));
|
|
2826
|
+
}
|
|
2827
|
+
const browserEntries = selected.filter(entry => entry.publisher === "browser");
|
|
2828
|
+
// Each browser platform needs its own explicit AI-conversation authorisation.
|
|
2829
|
+
// Preparing every selected editor before asking keeps the user waiting behind
|
|
2830
|
+
// long image uploads on later platforms and leaves no safe action boundary.
|
|
2831
|
+
const executionEntries = needsActionSelection ? browserEntries.slice(0, 1) : selected;
|
|
2832
|
+
const pendingApprovalEntries = needsActionSelection ? browserEntries.slice(1) : [];
|
|
2833
|
+
const { successes, failures, skipped } = await runPlatformPublishSequence(executionEntries, async entry => {
|
|
2400
2834
|
console.log("");
|
|
2401
2835
|
console.log(chalk.cyan(`正在发布到 ${entry.platform}(${entry.region})...`));
|
|
2402
2836
|
let result;
|
|
@@ -2405,13 +2839,34 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2405
2839
|
} else {
|
|
2406
2840
|
result = await publishPlatformEntry(entry, article, {
|
|
2407
2841
|
browserChannel: options.browserChannel,
|
|
2408
|
-
reviewDraft: options.reviewDraft,
|
|
2409
|
-
|
|
2842
|
+
reviewDraft: needsActionSelection || options.reviewDraft,
|
|
2843
|
+
// A review is always approved in the current AI conversation. Never
|
|
2844
|
+
// fall back to the legacy terminal confirmation path.
|
|
2845
|
+
awaitAIAction: needsActionSelection || options.reviewDraft,
|
|
2846
|
+
// The initial AI-authorisation preview prepares the complete native
|
|
2847
|
+
// editor draft, including body media. It still never clicks save or
|
|
2848
|
+
// publish; only the later explicit action may submit it. A platform
|
|
2849
|
+
// must not reach the action selection while any image is still
|
|
2850
|
+
// uploading, including editors that expose their fields after upload.
|
|
2851
|
+
previewTextOnly: false,
|
|
2852
|
+
directPublish: options.directPublish && options.confirmed,
|
|
2853
|
+
draftOnly: options.draftOnly,
|
|
2854
|
+
publishDraft: options.publishDraft,
|
|
2410
2855
|
keepOpen: options.keepOpen,
|
|
2411
2856
|
onProgress: message => console.log(chalk.gray(` ${message}`))
|
|
2412
2857
|
});
|
|
2413
|
-
if (
|
|
2414
|
-
console.log(chalk.green(
|
|
2858
|
+
if (result?.status === "draft") {
|
|
2859
|
+
console.log(chalk.green(`${entry.platform} 草稿已保存,浏览器保持打开`));
|
|
2860
|
+
if (result.approvalRequired) {
|
|
2861
|
+
console.log(
|
|
2862
|
+
JSON.stringify({
|
|
2863
|
+
event: "qcplay.draft-approval-required",
|
|
2864
|
+
platform: entry.platform,
|
|
2865
|
+
title: article.title,
|
|
2866
|
+
actions: result.approvalActions || ["publish", "cancel"]
|
|
2867
|
+
})
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2415
2870
|
return result;
|
|
2416
2871
|
}
|
|
2417
2872
|
if (entry.platformKey === "taptap" && result?.status === "scheduled") {
|
|
@@ -2425,6 +2880,42 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2425
2880
|
failures.forEach(({ entry, error }) => {
|
|
2426
2881
|
console.error(chalk.red(`${entry.platform} 发布失败: ${error.message || error}`));
|
|
2427
2882
|
});
|
|
2883
|
+
|
|
2884
|
+
if (needsActionSelection) {
|
|
2885
|
+
if (failures.length > 0) {
|
|
2886
|
+
throw new Error("待授权预览准备失败,请根据上面的错误处理后重试失败平台");
|
|
2887
|
+
}
|
|
2888
|
+
const pages = successes
|
|
2889
|
+
.map(({ entry, result }) => ({ platform: entry.platform, url: result?.url || "" }))
|
|
2890
|
+
.filter(item => item.url);
|
|
2891
|
+
const previewResults = successes.map(({ result }) => result || {});
|
|
2892
|
+
const imagesPending = previewResults.reduce((sum, result) => sum + Number(result.imagesPending || 0), 0);
|
|
2893
|
+
const requiresMediaBeforeText = previewResults.some(result => result.requiresMediaBeforeText);
|
|
2894
|
+
const contentPrepared = previewResults.every(result => result.contentPrepared !== false) && imagesPending === 0;
|
|
2895
|
+
if (!contentPrepared || requiresMediaBeforeText) {
|
|
2896
|
+
throw new Error("编辑器尚未完成正文或图片回读校验,暂不提供发布授权选项");
|
|
2897
|
+
}
|
|
2898
|
+
console.log(
|
|
2899
|
+
JSON.stringify({
|
|
2900
|
+
event: "qcplay.publish-action-required",
|
|
2901
|
+
title: previewResults.find(result => normalizeText(result.title))?.title || article.title,
|
|
2902
|
+
platforms: executionEntries.map(entry => entry.platform),
|
|
2903
|
+
...(pendingApprovalEntries.length > 0
|
|
2904
|
+
? { pendingPlatforms: pendingApprovalEntries.map(entry => entry.platform) }
|
|
2905
|
+
: {}),
|
|
2906
|
+
pages,
|
|
2907
|
+
contentPrepared,
|
|
2908
|
+
imagesPending,
|
|
2909
|
+
actions: ["publish", "save_draft", "cancel"]
|
|
2910
|
+
})
|
|
2911
|
+
);
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
const urlsFile = await writePlatformPublishUrls(file, successes);
|
|
2916
|
+
if (urlsFile) {
|
|
2917
|
+
console.log(chalk.green(`平台地址已写入: ${urlsFile}`));
|
|
2918
|
+
}
|
|
2428
2919
|
if (skipped.length > 0) {
|
|
2429
2920
|
console.error(chalk.yellow("已停止后续平台发布,请先处理当前浏览器平台的登录或页面问题。"));
|
|
2430
2921
|
}
|
|
@@ -2442,6 +2933,29 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2442
2933
|
}
|
|
2443
2934
|
}
|
|
2444
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
|
+
|
|
2445
2959
|
async function publishArticleCommand(file, options, platformEntry = null, expectedTarget = "") {
|
|
2446
2960
|
if (!file) {
|
|
2447
2961
|
throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
|
|
@@ -2561,11 +3075,20 @@ async function updateArticleStatusCommand(articleIdValue, targetStatus, options,
|
|
|
2561
3075
|
|
|
2562
3076
|
const response = await authenticatedRequest("PATCH", backendUrl, articleApiPath(target, "status"), payload);
|
|
2563
3077
|
const responseStatus = normalizeText(response.data?.status) || targetStatus;
|
|
3078
|
+
const recentState = await readJson(ARTICLE_STATE_FILE);
|
|
3079
|
+
const recentStateMatches =
|
|
3080
|
+
String(recentState.article_id || "") === String(articleId) &&
|
|
3081
|
+
(recentState.target || "domestic") === target;
|
|
3082
|
+
const previewPayload = recentStateMatches
|
|
3083
|
+
? { game_id: recentState.game_id, cate_id: recentState.cate_id }
|
|
3084
|
+
: {};
|
|
2564
3085
|
const previewUrl =
|
|
2565
3086
|
responseStatus === "1"
|
|
2566
|
-
? normalizeText(response.data?.preview_url) ||
|
|
3087
|
+
? normalizeText(response.data?.preview_url) ||
|
|
3088
|
+
(recentStateMatches ? normalizeText(recentState.preview_url) : "") ||
|
|
3089
|
+
(target === "domestic" ? articlePreviewUrl(articleId, previewPayload) : "")
|
|
2567
3090
|
: "";
|
|
2568
|
-
const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target);
|
|
3091
|
+
const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target, previewPayload);
|
|
2569
3092
|
|
|
2570
3093
|
console.log(chalk.green(response.message || (responseStatus === "2" ? "文章已删除" : "文章已上线")));
|
|
2571
3094
|
console.log(`文章 ID: ${articleId}`);
|
|
@@ -2639,6 +3162,32 @@ async function main() {
|
|
|
2639
3162
|
return;
|
|
2640
3163
|
}
|
|
2641
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
|
+
|
|
2642
3191
|
if (command === "rules") {
|
|
2643
3192
|
if (!subcommand || subcommand === "-h" || subcommand === "--help") {
|
|
2644
3193
|
printRulesHelp();
|
|
@@ -2654,6 +3203,26 @@ async function main() {
|
|
|
2654
3203
|
return;
|
|
2655
3204
|
}
|
|
2656
3205
|
|
|
3206
|
+
if (command === "wechat") {
|
|
3207
|
+
if (!subcommand || subcommand === "-h" || subcommand === "--help") {
|
|
3208
|
+
printWechatHelp();
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
if (subcommand !== "article") {
|
|
3212
|
+
await runWithErrorBanner("微信公众号数据失败", async () => {
|
|
3213
|
+
throw new Error(`未知 wechat 命令: ${subcommand}`);
|
|
3214
|
+
});
|
|
3215
|
+
return;
|
|
3216
|
+
}
|
|
3217
|
+
if (rest.includes("-h") || rest.includes("--help")) {
|
|
3218
|
+
printWechatHelp();
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
const parsed = parseWechatArticleOptions(rest);
|
|
3222
|
+
await runWithErrorBanner("微信公众号数据失败", () => importWechatDataCommand(parsed));
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
|
|
2657
3226
|
if (command === "article") {
|
|
2658
3227
|
if (!subcommand || subcommand === "-h" || subcommand === "--help") {
|
|
2659
3228
|
if (!subcommand) {
|
|
@@ -2677,6 +3246,10 @@ async function main() {
|
|
|
2677
3246
|
}
|
|
2678
3247
|
|
|
2679
3248
|
if (subcommand === "import") {
|
|
3249
|
+
if (rest.length === 1 && ["--help", "-h"].includes(rest[0])) {
|
|
3250
|
+
printArticleHelp();
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
2680
3253
|
const parsed = parseArticleImportOptions(rest);
|
|
2681
3254
|
await runWithErrorBanner("转换失败", () =>
|
|
2682
3255
|
importArticleCommand(parsed.sourceUrl, parsed.file, parsed.options)
|