@qcplay/cli 1.0.17 → 1.0.19

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.
@@ -0,0 +1,116 @@
1
+ import * as cheerio from "cheerio";
2
+
3
+ import { sanitizeArticleRichHtml } from "./wechat-article.js";
4
+
5
+ function escapeHtmlTitle(value) {
6
+ return String(value || "")
7
+ .replace(/&/g, "&")
8
+ .replace(/</g, "&lt;")
9
+ .replace(/>/g, "&gt;")
10
+ .replace(/"/g, "&quot;");
11
+ }
12
+
13
+ export function buildRichArticleUploadFile(article, metadata = {}) {
14
+ const normalizedMetadata = {
15
+ article_title: String(metadata.article_title ?? article.title ?? ""),
16
+ thumbnail: String(metadata.thumbnail ?? article.thumbnail ?? ""),
17
+ move_thumbnail: String(metadata.move_thumbnail ?? metadata.thumbnail ?? article.thumbnail ?? ""),
18
+ article_excerpt: String(metadata.article_excerpt ?? article.excerpt ?? ""),
19
+ article_url: String(metadata.article_url ?? ""),
20
+ origin: String(metadata.origin ?? article.author ?? "微信公众号"),
21
+ status: String(metadata.status ?? "0"),
22
+ cate_id: String(metadata.cate_id ?? ""),
23
+ video_link: String(metadata.video_link ?? ""),
24
+ is_hot: String(metadata.is_hot ?? "0"),
25
+ is_index: String(metadata.is_index ?? "0"),
26
+ release_time: String(metadata.release_time ?? article.releaseDate ?? ""),
27
+ area: String(metadata.area ?? "1"),
28
+ sort: String(metadata.sort ?? "100"),
29
+ game_id: String(metadata.game_id ?? "39"),
30
+ index_pc_img: String(metadata.index_pc_img ?? ""),
31
+ index_move_img: String(metadata.index_move_img ?? ""),
32
+ source_url: String(metadata.source_url ?? article.sourceUrl ?? ""),
33
+ distribution_project: String(metadata.distribution_project ?? "")
34
+ };
35
+ const richHtml = sanitizeArticleRichHtml(article.richHtml || "");
36
+ if (!richHtml) {
37
+ throw new Error("文章没有可保存的富文本正文");
38
+ }
39
+ const metadataJson = JSON.stringify(normalizedMetadata).replace(/</g, "\\u003c");
40
+ return `<!doctype html>
41
+ <html lang="zh-CN">
42
+ <head>
43
+ <meta charset="utf-8">
44
+ <meta name="viewport" content="width=device-width, initial-scale=1">
45
+ <title>${escapeHtmlTitle(normalizedMetadata.article_title)}</title>
46
+ <script id="qcplay-article-meta" type="application/json">${metadataJson}</script>
47
+ </head>
48
+ <body>
49
+ <main data-qcplay-article-content="1">
50
+ ${richHtml}
51
+ </main>
52
+ </body>
53
+ </html>
54
+ `;
55
+ }
56
+
57
+ export function parseRichArticleUploadFile(raw) {
58
+ const $ = cheerio.load(String(raw || ""));
59
+ const metadataText = $("#qcplay-article-meta[type='application/json']").first().text().trim();
60
+ const content = $("[data-qcplay-article-content='1']").first().html();
61
+ if (!metadataText || content === null) {
62
+ throw new Error("HTML 文章文件不是 QCPlay 富文本导入格式,请使用 article import --format html 生成");
63
+ }
64
+ let meta;
65
+ try {
66
+ meta = JSON.parse(metadataText);
67
+ } catch {
68
+ throw new Error("HTML 文章文件中的 QCPlay 元数据格式无效");
69
+ }
70
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
71
+ throw new Error("HTML 文章文件中的 QCPlay 元数据格式无效");
72
+ }
73
+ const richHtml = sanitizeArticleRichHtml(content);
74
+ if (!richHtml) {
75
+ throw new Error("HTML 文章文件中的富文本正文不能为空");
76
+ }
77
+ return { meta, richHtml };
78
+ }
79
+
80
+ export function buildRichArticleUploadPayload(meta, richHtml, helpers) {
81
+ const { normalizeText, normalizeMappedValue, normalizeGameAndCategory, normalizeBoolLike, statusMap, areaMap, westLanguages } =
82
+ helpers;
83
+ const { gameId, category } = normalizeGameAndCategory(meta);
84
+ const payload = {
85
+ article_title: normalizeText(meta.article_title),
86
+ thumbnail: normalizeText(meta.thumbnail),
87
+ move_thumbnail: normalizeText(meta.move_thumbnail),
88
+ article_content: richHtml,
89
+ article_excerpt: normalizeText(meta.article_excerpt),
90
+ article_url: normalizeText(meta.article_url),
91
+ origin: normalizeText(meta.origin),
92
+ status: normalizeMappedValue(meta.status, statusMap) || "0",
93
+ cate_id: category,
94
+ video_link: normalizeText(meta.video_link),
95
+ is_hot: normalizeBoolLike(meta.is_hot, "0"),
96
+ is_index: normalizeBoolLike(meta.is_index, "0"),
97
+ release_time: normalizeText(meta.release_time),
98
+ area: normalizeMappedValue(meta.area, areaMap) || "1",
99
+ sort: normalizeText(meta.sort) || "1",
100
+ game_id: gameId,
101
+ is_index2: "0",
102
+ index_pc_img: normalizeText(meta.index_pc_img),
103
+ index_move_img: normalizeText(meta.index_move_img),
104
+ type: "1"
105
+ };
106
+ if (gameId === "64") {
107
+ payload.language = normalizeText(meta.language).toLowerCase();
108
+ if (!westLanguages.has(payload.language)) {
109
+ throw new Error("欧美蜗牛文章的 language 必须是 portuguese、german、spanish、italian、french 或 america_en");
110
+ }
111
+ }
112
+ if (!payload.article_title) throw new Error("article_title 不能为空");
113
+ if (!payload.thumbnail) throw new Error("thumbnail 不能为空");
114
+ if (!payload.article_content) throw new Error("文章正文不能为空");
115
+ return payload;
116
+ }
@@ -2,17 +2,20 @@ import { createHash, randomBytes } from "crypto";
2
2
  import http from "http";
3
3
  import https from "https";
4
4
  import path from "path";
5
- import zlib from "zlib";
6
5
 
7
6
  import * as cheerio from "cheerio";
8
7
  import { applyContentRules, matchingContentRules, matchingDownloadedImageRules } from "./content-rules.js";
8
+ import { buildRichArticleUploadFile } from "./rich-article-upload.js";
9
9
 
10
10
  const WECHAT_HOST = "mp.weixin.qq.com";
11
- const MEDIA_UPLOAD_URL = "https://api.qingcigame.com/novel/time/literature/avatar";
12
11
  const MAX_REDIRECTS = 5;
13
12
  const MAX_HTML_BYTES = 8 * 1024 * 1024;
14
13
  const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
15
14
  const MAX_VIDEO_BYTES = 512 * 1024 * 1024;
15
+ const MAX_IMAGES_PER_ARTICLE = 80;
16
+ const MAX_VIDEOS_PER_ARTICLE = 3;
17
+ const MAX_MEDIA_BYTES_PER_ARTICLE = 768 * 1024 * 1024;
18
+ const MAX_REQUEST_RETRIES = 2;
16
19
  const RICH_HTML_START = "<!-- qcplay-rich-html:start -->";
17
20
  const RICH_HTML_END = "<!-- qcplay-rich-html:end -->";
18
21
  const ARES_IGNORED_GIF_SHA256 = "138ed7ebf49170605efd81c99f516d648b43c22a972743680f6d974129e54da8";
@@ -201,7 +204,15 @@ const SAFE_STYLE_PROPERTIES = new Set([
201
204
  "-webkit-text-stroke-width"
202
205
  ]);
203
206
 
204
- function requestBuffer(target, options = {}, redirectCount = 0) {
207
+ function retryDelayMs(response, retryCount) {
208
+ const retryAfterSeconds = Number.parseFloat(String(response?.headers?.["retry-after"] || ""));
209
+ if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) {
210
+ return Math.min(Math.round(retryAfterSeconds * 1000), 10_000);
211
+ }
212
+ return 500 * 2 ** retryCount;
213
+ }
214
+
215
+ function requestBuffer(target, options = {}, redirectCount = 0, retryCount = 0) {
205
216
  const url = target instanceof URL ? target : new URL(target);
206
217
  const client = url.protocol === "https:" ? https : url.protocol === "http:" ? http : null;
207
218
  if (!client) {
@@ -216,7 +227,9 @@ function requestBuffer(target, options = {}, redirectCount = 0) {
216
227
  method: options.method || "GET",
217
228
  headers: {
218
229
  Accept: "text/html,application/xhtml+xml,image/avif,image/webp,image/*,*/*;q=0.8",
219
- "Accept-Encoding": "gzip, deflate, br",
230
+ // Media is kept uncompressed so a small compressed response cannot expand
231
+ // into an unbounded in-memory buffer before the size check runs.
232
+ "Accept-Encoding": "identity",
220
233
  "User-Agent":
221
234
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/138 Safari/537.36",
222
235
  ...options.headers
@@ -238,7 +251,17 @@ function requestBuffer(target, options = {}, redirectCount = 0) {
238
251
  reject(error);
239
252
  return;
240
253
  }
241
- requestBuffer(redirectUrl, options, redirectCount + 1).then(resolve, reject);
254
+ requestBuffer(redirectUrl, options, redirectCount + 1, retryCount).then(resolve, reject);
255
+ return;
256
+ }
257
+
258
+ const method = String(options.method || "GET").toUpperCase();
259
+ const retryableStatus = statusCode === 429 || statusCode >= 500;
260
+ if (retryableStatus && method === "GET" && retryCount < MAX_REQUEST_RETRIES) {
261
+ res.resume();
262
+ setTimeout(() => {
263
+ requestBuffer(url, options, redirectCount, retryCount + 1).then(resolve, reject);
264
+ }, retryDelayMs(res, retryCount));
242
265
  return;
243
266
  }
244
267
 
@@ -248,6 +271,13 @@ function requestBuffer(target, options = {}, redirectCount = 0) {
248
271
  return;
249
272
  }
250
273
 
274
+ const contentEncoding = String(res.headers["content-encoding"] || "identity").toLowerCase();
275
+ if (contentEncoding !== "identity") {
276
+ res.resume();
277
+ reject(new Error(`不支持压缩响应: ${contentEncoding} (${url})`));
278
+ return;
279
+ }
280
+
251
281
  const chunks = [];
252
282
  let receivedBytes = 0;
253
283
  res.on("data", chunk => {
@@ -260,20 +290,7 @@ function requestBuffer(target, options = {}, redirectCount = 0) {
260
290
  });
261
291
  res.on("end", () => {
262
292
  try {
263
- let body = Buffer.concat(chunks);
264
- const encoding = String(res.headers["content-encoding"] || "").toLowerCase();
265
- if (encoding === "gzip") {
266
- body = zlib.gunzipSync(body);
267
- } else if (encoding === "deflate") {
268
- body = zlib.inflateSync(body);
269
- } else if (encoding === "br") {
270
- body = zlib.brotliDecompressSync(body);
271
- }
272
- if (body.length > maxBytes) {
273
- reject(new Error(`解压后的响应内容超过 ${Math.floor(maxBytes / 1024 / 1024)} MB 限制: ${url}`));
274
- return;
275
- }
276
- resolve({ body, headers: res.headers, url });
293
+ resolve({ body: Buffer.concat(chunks), headers: res.headers, url });
277
294
  } catch (error) {
278
295
  reject(new Error(`读取响应失败 ${url}: ${error.message}`));
279
296
  }
@@ -426,8 +443,13 @@ export async function downloadWechatVideo(sourceUrl) {
426
443
  };
427
444
  }
428
445
 
429
- async function uploadOfficialMedia(media, kind, uploadUrl = MEDIA_UPLOAD_URL) {
430
- const target = new URL(uploadUrl);
446
+ async function uploadOfficialMedia(media, kind, uploadUrl, extraHeaders = {}) {
447
+ const configuredUploadUrl = String(uploadUrl || "").trim();
448
+ if (!configuredUploadUrl) {
449
+ throw new Error("未配置媒体上传地址,请通过后端媒体上传代理调用");
450
+ }
451
+
452
+ const target = new URL(configuredUploadUrl);
431
453
  const extension = kind === "video"
432
454
  ? extensionForVideo(media.contentType, media.sourceUrl)
433
455
  : extensionForImage(media.contentType, media.sourceUrl);
@@ -447,7 +469,8 @@ async function uploadOfficialMedia(media, kind, uploadUrl = MEDIA_UPLOAD_URL) {
447
469
  Accept: "application/json",
448
470
  "Accept-Encoding": "identity",
449
471
  "Content-Type": `multipart/form-data; boundary=${boundary}`,
450
- "Content-Length": String(body.length)
472
+ "Content-Length": String(body.length),
473
+ ...extraHeaders
451
474
  }
452
475
  });
453
476
 
@@ -473,14 +496,25 @@ async function uploadOfficialMedia(media, kind, uploadUrl = MEDIA_UPLOAD_URL) {
473
496
  return uploadedUrl.toString();
474
497
  }
475
498
 
476
- export function uploadOfficialImage(image, uploadUrl = MEDIA_UPLOAD_URL) {
499
+ export function uploadOfficialImage(image, uploadUrl) {
477
500
  return uploadOfficialMedia(image, "image", uploadUrl);
478
501
  }
479
502
 
480
- export function uploadOfficialVideo(video, uploadUrl = MEDIA_UPLOAD_URL) {
503
+ export function uploadOfficialVideo(video, uploadUrl) {
481
504
  return uploadOfficialMedia(video, "video", uploadUrl);
482
505
  }
483
506
 
507
+ export function uploadMediaToBackend(media, kind, backendUrl, accessToken) {
508
+ const target = new URL("/api/media/upload", backendUrl).toString();
509
+ const token = String(accessToken || "").trim();
510
+ if (!token) {
511
+ throw new Error("媒体上传需要有效的登录凭证");
512
+ }
513
+ return uploadOfficialMedia(media, kind, target, {
514
+ Authorization: `Bearer ${token}`
515
+ });
516
+ }
517
+
484
518
  function normalizeTextContent(value) {
485
519
  return String(value || "")
486
520
  .replace(/\u00a0/g, " ")
@@ -747,7 +781,7 @@ function backgroundImageSources(element) {
747
781
  function safeHttpUrl(value) {
748
782
  try {
749
783
  const url = new URL(String(value || ""));
750
- return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : "";
784
+ return url.protocol === "https:" ? url.toString() : "";
751
785
  } catch {
752
786
  return "";
753
787
  }
@@ -954,7 +988,7 @@ export function sanitizeArticleRichHtml(value, options = {}) {
954
988
  element.replaceWith(alt ? $("<span></span>").text(alt) : "");
955
989
  continue;
956
990
  }
957
- element.attr("src", src).attr("alt", alt);
991
+ element.attr("src", src).attr("alt", alt).attr("referrerpolicy", "no-referrer");
958
992
  if (/^\d+(?:\.\d+)?$/.test(originalAttributes.width || "")) {
959
993
  element.attr("width", originalAttributes.width);
960
994
  }
@@ -983,7 +1017,7 @@ export function sanitizeArticleRichHtml(value, options = {}) {
983
1017
  if (tag === "a") {
984
1018
  const href = safeHttpUrl(originalAttributes.href || originalAttributes["data-link"] || originalAttributes["data-url"]);
985
1019
  if (href) {
986
- element.attr({ href, target: "_blank", rel: "noreferrer" });
1020
+ element.attr({ href, target: "_blank", rel: "noopener noreferrer nofollow" });
987
1021
  } else {
988
1022
  element.replaceWith(element.contents());
989
1023
  }
@@ -1038,6 +1072,44 @@ export function sanitizeArticleRichHtml(value, options = {}) {
1038
1072
  );
1039
1073
  }
1040
1074
 
1075
+ export function analyzeArticleContentSafety(article = {}) {
1076
+ const sourceUrl = String(article.sourceUrl || article.meta?.source_url || article.payload?.source_url || "").trim();
1077
+ const richHtml = String(article.richHtml || article.html || "");
1078
+ const $ = cheerio.load(richHtml, null, false);
1079
+ const text = $.root().text().replace(/\s+/g, " ").trim();
1080
+ const externalHosts = new Set();
1081
+ $("a[href], img[src]").each((_, node) => {
1082
+ const rawUrl = $(node).attr("href") || $(node).attr("src") || "";
1083
+ try {
1084
+ const url = new URL(rawUrl);
1085
+ if (!/(?:^|\.)weixin\.qq\.com$|(?:^|\.)qpic\.cn$|(?:^|\.)qingcigame\.com$|(?:^|\.)qingcijoy\.com$/i.test(url.hostname)) {
1086
+ externalHosts.add(url.hostname.toLowerCase());
1087
+ }
1088
+ } catch {
1089
+ // Sanitized content only keeps absolute HTTPS URLs; malformed values are ignored here.
1090
+ }
1091
+ });
1092
+ const phoneMatches = text.match(/(?<!\d)(?:1\d{10}|(?:\+?86[-\s]?)?\d{3,4}[-\s]?\d{7,8})(?!\d)/g) || [];
1093
+ const idMatches = text.match(/(?<![\dA-Za-z])\d{17}[\dXx](?![\dA-Za-z])/g) || [];
1094
+ const warnings = [];
1095
+ if (externalHosts.size > 0) {
1096
+ warnings.push(`正文包含 ${externalHosts.size} 个非受管外链域名: ${[...externalHosts].slice(0, 3).join("、")}`);
1097
+ }
1098
+ if (phoneMatches.length > 0) {
1099
+ warnings.push(`正文疑似包含 ${phoneMatches.length} 个电话号码,请确认已获得公开与分发授权`);
1100
+ }
1101
+ if (idMatches.length > 0) {
1102
+ warnings.push(`正文疑似包含 ${idMatches.length} 个身份证号码,请在发布前人工处理`);
1103
+ }
1104
+ return {
1105
+ sourceUrl,
1106
+ externalHosts: [...externalHosts],
1107
+ phoneCount: phoneMatches.length,
1108
+ identityNumberCount: idMatches.length,
1109
+ warnings
1110
+ };
1111
+ }
1112
+
1041
1113
  function collectMeaningfulTextNodes(node, bucket = []) {
1042
1114
  if (node.type === "text") {
1043
1115
  if (normalizeTextContent(node.data).trim()) {
@@ -1337,7 +1409,11 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1337
1409
  const downloadVideo = options.downloadVideo || downloadWechatVideo;
1338
1410
  const uploadVideo = options.uploadVideo || uploadOfficialVideo;
1339
1411
  const uniqueSourceCount = new Set(allSources.map(rawSource => assertWechatImageUrl(rawSource, sourceUrl).toString())).size;
1412
+ if (uniqueSourceCount > MAX_IMAGES_PER_ARTICLE) {
1413
+ throw new Error(`微信文章图片数量超过 ${MAX_IMAGES_PER_ARTICLE} 张限制`);
1414
+ }
1340
1415
  let processedSourceCount = 0;
1416
+ let mediaBytes = 0;
1341
1417
 
1342
1418
  for (const rawSource of allSources) {
1343
1419
  const imageUrl = assertWechatImageUrl(rawSource, sourceUrl);
@@ -1349,6 +1425,10 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1349
1425
  options.onProgress?.({ mediaType: "image", current, total: uniqueSourceCount, sourceUrl: key });
1350
1426
  try {
1351
1427
  const image = await downloadImage(imageUrl);
1428
+ mediaBytes += Number(image.buffer?.length || 0);
1429
+ if (mediaBytes > MAX_MEDIA_BYTES_PER_ARTICLE) {
1430
+ throw new Error(`微信文章媒体总大小超过 ${Math.floor(MAX_MEDIA_BYTES_PER_ARTICLE / 1024 / 1024)} MB 限制`);
1431
+ }
1352
1432
  const matchedImageRules = options.contentRules
1353
1433
  ? matchingDownloadedImageRules(image, ruleArticle, ruleEntry, options.contentRules, { phase: "import" })
1354
1434
  : [];
@@ -1400,11 +1480,18 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1400
1480
 
1401
1481
  const uploadedVideos = new Map();
1402
1482
  const uniqueVideoSources = [...new Set(videoSources.filter(Boolean))];
1483
+ if (uniqueVideoSources.length > MAX_VIDEOS_PER_ARTICLE) {
1484
+ throw new Error(`微信文章视频数量超过 ${MAX_VIDEOS_PER_ARTICLE} 个限制`);
1485
+ }
1403
1486
  for (const rawSource of uniqueVideoSources) {
1404
1487
  const current = uploadedVideos.size + 1;
1405
1488
  options.onProgress?.({ mediaType: "video", current, total: uniqueVideoSources.length, sourceUrl: rawSource });
1406
1489
  try {
1407
1490
  const video = await downloadVideo(rawSource);
1491
+ mediaBytes += Number(video.buffer?.length || 0);
1492
+ if (mediaBytes > MAX_MEDIA_BYTES_PER_ARTICLE) {
1493
+ throw new Error(`微信文章媒体总大小超过 ${Math.floor(MAX_MEDIA_BYTES_PER_ARTICLE / 1024 / 1024)} MB 限制`);
1494
+ }
1408
1495
  uploadedVideos.set(rawSource, await uploadVideo(video));
1409
1496
  } catch (error) {
1410
1497
  throw new Error(`第 ${current} 个视频处理失败: ${error.message}`);
@@ -1470,7 +1557,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1470
1557
  throw new Error(`微信文章有 ${missingTextNodes.length} 处正文未被完整转换: ${preview}`);
1471
1558
  }
1472
1559
 
1473
- return {
1560
+ const article = {
1474
1561
  title,
1475
1562
  author,
1476
1563
  releaseDate: releaseDateFromWechatPage($, html),
@@ -1485,6 +1572,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1485
1572
  colorCount: markdownContext.colorCount,
1486
1573
  colors: [...markdownContext.colors],
1487
1574
  mediaCount: markdownContext.mediaCount,
1575
+ mediaBytes,
1488
1576
  specialElementCount: markdownContext.specialElementCount,
1489
1577
  tableCount: markdownContext.tableCount,
1490
1578
  textSegmentCount: sourceTextNodes.length,
@@ -1492,6 +1580,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1492
1580
  matchedContentRules,
1493
1581
  appliedContentRules: [...appliedContentRules]
1494
1582
  };
1583
+ return { ...article, contentSafety: analyzeArticleContentSafety(article) };
1495
1584
  }
1496
1585
 
1497
1586
  function yamlString(value) {
@@ -1521,7 +1610,7 @@ article_excerpt: ${yamlString(article.excerpt)}
1521
1610
  article_url: ""
1522
1611
  origin: ${yamlString(article.author || "微信公众号")}
1523
1612
  status: "0"
1524
- cate_id: ""
1613
+ cate_id: ${yamlString(article.categoryId || "")}
1525
1614
  video_link: ""
1526
1615
  is_hot: "0"
1527
1616
  is_index: "0"
@@ -1539,6 +1628,14 @@ ${articleBody}
1539
1628
  `;
1540
1629
  }
1541
1630
 
1631
+ export function buildImportedArticleHtml(article) {
1632
+ return buildRichArticleUploadFile(article, {
1633
+ game_id: article.gameId || gameIdForWechatArticle(article),
1634
+ cate_id: article.categoryId || "",
1635
+ distribution_project: article.distributionProject || distributionProjectForWechatArticle(article)
1636
+ });
1637
+ }
1638
+
1542
1639
  export async function importWechatArticle(value, options = {}) {
1543
1640
  const pageUrl = parseWechatUrl(value);
1544
1641
  const response = await requestBuffer(pageUrl, {
@@ -0,0 +1,155 @@
1
+ const SHANGHAI_TIME_ZONE = "Asia/Shanghai";
2
+
3
+ export const WECHAT_GAME_CATALOG = {
4
+ "25": "使魔计划",
5
+ "39": "最强蜗牛",
6
+ "33": "提灯与地下城",
7
+ "69": "新仙剑奇侠传之挥剑问情",
8
+ "35": "时光旅行社",
9
+ "74": "魔卡少女樱回忆钥匙",
10
+ "50": "迷途之光手游",
11
+ "47": "骑士冲呀",
12
+ "41": "阿瑞斯病毒"
13
+ };
14
+
15
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
16
+ const MONTH_PATTERN = /^\d{4}-\d{2}$/;
17
+
18
+ export function normalizeWechatGameId(value) {
19
+ const text = String(value ?? "").trim();
20
+ if (!WECHAT_GAME_CATALOG[text]) {
21
+ throw new Error(`不支持的微信公众号 game_id: ${text || "空"}`);
22
+ }
23
+ return text;
24
+ }
25
+
26
+ export function todayInShanghai(now = new Date()) {
27
+ const parts = new Intl.DateTimeFormat("en-CA", {
28
+ timeZone: SHANGHAI_TIME_ZONE,
29
+ year: "numeric",
30
+ month: "2-digit",
31
+ day: "2-digit"
32
+ }).formatToParts(now);
33
+ const values = Object.fromEntries(parts.filter(part => part.type !== "literal").map(part => [part.type, part.value]));
34
+ return `${values.year}-${values.month}-${values.day}`;
35
+ }
36
+
37
+ function parseDate(value, label) {
38
+ const text = String(value ?? "").trim();
39
+ if (!DATE_PATTERN.test(text)) {
40
+ throw new Error(`${label} 格式必须是 YYYY-MM-DD`);
41
+ }
42
+ const [year, month, day] = text.split("-").map(Number);
43
+ const date = new Date(Date.UTC(year, month - 1, day));
44
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
45
+ throw new Error(`${label} 不是有效日期`);
46
+ }
47
+ return date;
48
+ }
49
+
50
+ function formatDate(date) {
51
+ return date.toISOString().slice(0, 10);
52
+ }
53
+
54
+ function addDays(date, amount) {
55
+ const next = new Date(date);
56
+ next.setUTCDate(next.getUTCDate() + amount);
57
+ return next;
58
+ }
59
+
60
+ function compareDates(left, right) {
61
+ return left.getTime() - right.getTime();
62
+ }
63
+
64
+ export function validateWechatDateRange(beginDate, endDate, today = todayInShanghai()) {
65
+ const begin = parseDate(beginDate, "begin_date");
66
+ const end = parseDate(endDate, "end_date");
67
+ const todayDate = parseDate(today, "今天");
68
+ if (compareDates(end, begin) < 0) {
69
+ throw new Error("结束日期不能小于开始日期");
70
+ }
71
+ if (compareDates(end, todayDate) >= 0) {
72
+ throw new Error("结束日期不能大于或等于今天");
73
+ }
74
+ return { begin, end };
75
+ }
76
+
77
+ export function monthDateRange(month, today = todayInShanghai()) {
78
+ const value = String(month ?? "").trim();
79
+ if (!MONTH_PATTERN.test(value)) throw new Error("--month 格式必须是 YYYY-MM");
80
+ const [year, monthNumber] = value.split("-").map(Number);
81
+ if (monthNumber < 1 || monthNumber > 12) throw new Error("--month 不是有效月份");
82
+ const begin = `${value}-01`;
83
+ const lastDay = new Date(Date.UTC(year, monthNumber, 0));
84
+ let end = formatDate(lastDay);
85
+ const todayDate = parseDate(today, "今天");
86
+ if (lastDay.getTime() >= todayDate.getTime()) end = formatDate(addDays(todayDate, -1));
87
+ return validateWechatDateRange(begin, end, today);
88
+ }
89
+
90
+ export function splitWechatDateRange(beginDate, endDate, today = todayInShanghai()) {
91
+ const { begin, end } = validateWechatDateRange(beginDate, endDate, today);
92
+ const ranges = [];
93
+ let cursor = begin;
94
+ while (compareDates(cursor, end) <= 0) {
95
+ const monthEnd = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 0));
96
+ let chunkEnd = compareDates(monthEnd, end) < 0 ? monthEnd : end;
97
+ // The upstream API accepts at most 30 calendar dates. Keep 31-day months
98
+ // as two requests even though their date difference can be exactly 30.
99
+ const maxChunkEnd = addDays(cursor, 29);
100
+ if (compareDates(chunkEnd, maxChunkEnd) > 0) {
101
+ chunkEnd = maxChunkEnd;
102
+ }
103
+ ranges.push({ beginDate: formatDate(cursor), endDate: formatDate(chunkEnd) });
104
+ cursor = addDays(chunkEnd, 1);
105
+ }
106
+ return ranges;
107
+ }
108
+
109
+ function cleanCell(value) {
110
+ return String(value ?? "").replace(/[\r\n]+/g, " ").replaceAll("|", "\\|").trim();
111
+ }
112
+
113
+ function detailRows(article) {
114
+ const details = Array.isArray(article?.detail_list) ? article.detail_list : [];
115
+ if (details.length === 0) return [{ article, detail: {} }];
116
+ return details.map(detail => ({ article, detail: detail || {} }));
117
+ }
118
+
119
+ function metric(detail, key) {
120
+ return detail?.[key] ?? "";
121
+ }
122
+
123
+ export function renderWechatArticleMarkdown(reports) {
124
+ const lines = ["# 微信公众号文章数据", ""];
125
+ for (const report of reports) {
126
+ lines.push(`## ${cleanCell(report.gameName)}(game_id: ${report.gameId})`, "");
127
+ lines.push(`查询范围:${report.beginDate} 至 ${report.endDate}`, "");
128
+ const months = new Map();
129
+ for (const article of report.dayTotal) {
130
+ const month = cleanCell(article.ref_date).slice(0, 7) || "未分组";
131
+ if (!months.has(month)) months.set(month, []);
132
+ months.get(month).push(article);
133
+ }
134
+ if (months.size === 0) {
135
+ lines.push("暂无文章数据", "");
136
+ continue;
137
+ }
138
+ for (const [month, articles] of months) {
139
+ lines.push(`### ${month}`, "");
140
+ lines.push("| 发表日期 | 消息 ID | 标题 | 统计日期 | 阅读人数 | 分享人数 | 在看 | 点赞 | 留言 | 收藏 | 阅读后关注 | 赞赏(分) | 原文链接 |");
141
+ lines.push("| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |");
142
+ for (const article of articles) {
143
+ for (const { detail } of detailRows(article)) {
144
+ lines.push(`| ${cleanCell(article.ref_date)} | ${cleanCell(article.msgid)} | ${cleanCell(article.title)} | ${cleanCell(detail.stat_date)} | ${cleanCell(metric(detail, "read_user"))} | ${cleanCell(metric(detail, "share_user"))} | ${cleanCell(metric(detail, "zaikan_user"))} | ${cleanCell(metric(detail, "like_user"))} | ${cleanCell(metric(detail, "comment_count"))} | ${cleanCell(metric(detail, "collection_user"))} | ${cleanCell(metric(detail, "read_subscribe_user"))} | ${cleanCell(metric(detail, "praise_money"))} | ${cleanCell(article.content_url)} |`);
145
+ }
146
+ }
147
+ lines.push("");
148
+ }
149
+ }
150
+ return `${lines.join("\n").trim()}\n`;
151
+ }
152
+
153
+ export function buildWechatArticleJson(reports) {
154
+ return JSON.stringify(reports, null, 2) + "\n";
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qcplay/cli",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "QCPlay CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "test:auth": "node bin/qcplay.js auth",
28
28
  "test:publish": "node bin/qcplay.js www-article-list.store ../article.md",
29
29
  "test:wechat": "node test/wechat-article.test.js",
30
+ "test:wechat-data": "node test/wechat-data.test.js",
30
31
  "test:lark": "node test/lark-article.test.js",
31
32
  "test:platforms": "node test/platform-publish.test.js",
32
33
  "test:rules": "node test/content-rules.test.js",