@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.
@@ -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,16 +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
11
  const MAX_REDIRECTS = 5;
12
12
  const MAX_HTML_BYTES = 8 * 1024 * 1024;
13
13
  const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
14
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;
15
19
  const RICH_HTML_START = "<!-- qcplay-rich-html:start -->";
16
20
  const RICH_HTML_END = "<!-- qcplay-rich-html:end -->";
17
21
  const ARES_IGNORED_GIF_SHA256 = "138ed7ebf49170605efd81c99f516d648b43c22a972743680f6d974129e54da8";
@@ -200,7 +204,15 @@ const SAFE_STYLE_PROPERTIES = new Set([
200
204
  "-webkit-text-stroke-width"
201
205
  ]);
202
206
 
203
- 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) {
204
216
  const url = target instanceof URL ? target : new URL(target);
205
217
  const client = url.protocol === "https:" ? https : url.protocol === "http:" ? http : null;
206
218
  if (!client) {
@@ -215,7 +227,9 @@ function requestBuffer(target, options = {}, redirectCount = 0) {
215
227
  method: options.method || "GET",
216
228
  headers: {
217
229
  Accept: "text/html,application/xhtml+xml,image/avif,image/webp,image/*,*/*;q=0.8",
218
- "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",
219
233
  "User-Agent":
220
234
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/138 Safari/537.36",
221
235
  ...options.headers
@@ -237,7 +251,17 @@ function requestBuffer(target, options = {}, redirectCount = 0) {
237
251
  reject(error);
238
252
  return;
239
253
  }
240
- 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));
241
265
  return;
242
266
  }
243
267
 
@@ -247,6 +271,13 @@ function requestBuffer(target, options = {}, redirectCount = 0) {
247
271
  return;
248
272
  }
249
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
+
250
281
  const chunks = [];
251
282
  let receivedBytes = 0;
252
283
  res.on("data", chunk => {
@@ -259,20 +290,7 @@ function requestBuffer(target, options = {}, redirectCount = 0) {
259
290
  });
260
291
  res.on("end", () => {
261
292
  try {
262
- let body = Buffer.concat(chunks);
263
- const encoding = String(res.headers["content-encoding"] || "").toLowerCase();
264
- if (encoding === "gzip") {
265
- body = zlib.gunzipSync(body);
266
- } else if (encoding === "deflate") {
267
- body = zlib.inflateSync(body);
268
- } else if (encoding === "br") {
269
- body = zlib.brotliDecompressSync(body);
270
- }
271
- if (body.length > maxBytes) {
272
- reject(new Error(`解压后的响应内容超过 ${Math.floor(maxBytes / 1024 / 1024)} MB 限制: ${url}`));
273
- return;
274
- }
275
- resolve({ body, headers: res.headers, url });
293
+ resolve({ body: Buffer.concat(chunks), headers: res.headers, url });
276
294
  } catch (error) {
277
295
  reject(new Error(`读取响应失败 ${url}: ${error.message}`));
278
296
  }
@@ -763,7 +781,7 @@ function backgroundImageSources(element) {
763
781
  function safeHttpUrl(value) {
764
782
  try {
765
783
  const url = new URL(String(value || ""));
766
- return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : "";
784
+ return url.protocol === "https:" ? url.toString() : "";
767
785
  } catch {
768
786
  return "";
769
787
  }
@@ -970,7 +988,7 @@ export function sanitizeArticleRichHtml(value, options = {}) {
970
988
  element.replaceWith(alt ? $("<span></span>").text(alt) : "");
971
989
  continue;
972
990
  }
973
- element.attr("src", src).attr("alt", alt);
991
+ element.attr("src", src).attr("alt", alt).attr("referrerpolicy", "no-referrer");
974
992
  if (/^\d+(?:\.\d+)?$/.test(originalAttributes.width || "")) {
975
993
  element.attr("width", originalAttributes.width);
976
994
  }
@@ -999,7 +1017,7 @@ export function sanitizeArticleRichHtml(value, options = {}) {
999
1017
  if (tag === "a") {
1000
1018
  const href = safeHttpUrl(originalAttributes.href || originalAttributes["data-link"] || originalAttributes["data-url"]);
1001
1019
  if (href) {
1002
- element.attr({ href, target: "_blank", rel: "noreferrer" });
1020
+ element.attr({ href, target: "_blank", rel: "noopener noreferrer nofollow" });
1003
1021
  } else {
1004
1022
  element.replaceWith(element.contents());
1005
1023
  }
@@ -1054,6 +1072,44 @@ export function sanitizeArticleRichHtml(value, options = {}) {
1054
1072
  );
1055
1073
  }
1056
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
+
1057
1113
  function collectMeaningfulTextNodes(node, bucket = []) {
1058
1114
  if (node.type === "text") {
1059
1115
  if (normalizeTextContent(node.data).trim()) {
@@ -1353,7 +1409,11 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1353
1409
  const downloadVideo = options.downloadVideo || downloadWechatVideo;
1354
1410
  const uploadVideo = options.uploadVideo || uploadOfficialVideo;
1355
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
+ }
1356
1415
  let processedSourceCount = 0;
1416
+ let mediaBytes = 0;
1357
1417
 
1358
1418
  for (const rawSource of allSources) {
1359
1419
  const imageUrl = assertWechatImageUrl(rawSource, sourceUrl);
@@ -1365,6 +1425,10 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1365
1425
  options.onProgress?.({ mediaType: "image", current, total: uniqueSourceCount, sourceUrl: key });
1366
1426
  try {
1367
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
+ }
1368
1432
  const matchedImageRules = options.contentRules
1369
1433
  ? matchingDownloadedImageRules(image, ruleArticle, ruleEntry, options.contentRules, { phase: "import" })
1370
1434
  : [];
@@ -1416,11 +1480,18 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1416
1480
 
1417
1481
  const uploadedVideos = new Map();
1418
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
+ }
1419
1486
  for (const rawSource of uniqueVideoSources) {
1420
1487
  const current = uploadedVideos.size + 1;
1421
1488
  options.onProgress?.({ mediaType: "video", current, total: uniqueVideoSources.length, sourceUrl: rawSource });
1422
1489
  try {
1423
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
+ }
1424
1495
  uploadedVideos.set(rawSource, await uploadVideo(video));
1425
1496
  } catch (error) {
1426
1497
  throw new Error(`第 ${current} 个视频处理失败: ${error.message}`);
@@ -1486,7 +1557,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1486
1557
  throw new Error(`微信文章有 ${missingTextNodes.length} 处正文未被完整转换: ${preview}`);
1487
1558
  }
1488
1559
 
1489
- return {
1560
+ const article = {
1490
1561
  title,
1491
1562
  author,
1492
1563
  releaseDate: releaseDateFromWechatPage($, html),
@@ -1501,6 +1572,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1501
1572
  colorCount: markdownContext.colorCount,
1502
1573
  colors: [...markdownContext.colors],
1503
1574
  mediaCount: markdownContext.mediaCount,
1575
+ mediaBytes,
1504
1576
  specialElementCount: markdownContext.specialElementCount,
1505
1577
  tableCount: markdownContext.tableCount,
1506
1578
  textSegmentCount: sourceTextNodes.length,
@@ -1508,6 +1580,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1508
1580
  matchedContentRules,
1509
1581
  appliedContentRules: [...appliedContentRules]
1510
1582
  };
1583
+ return { ...article, contentSafety: analyzeArticleContentSafety(article) };
1511
1584
  }
1512
1585
 
1513
1586
  function yamlString(value) {
@@ -1537,7 +1610,7 @@ article_excerpt: ${yamlString(article.excerpt)}
1537
1610
  article_url: ""
1538
1611
  origin: ${yamlString(article.author || "微信公众号")}
1539
1612
  status: "0"
1540
- cate_id: ""
1613
+ cate_id: ${yamlString(article.categoryId || "")}
1541
1614
  video_link: ""
1542
1615
  is_hot: "0"
1543
1616
  is_index: "0"
@@ -1555,6 +1628,14 @@ ${articleBody}
1555
1628
  `;
1556
1629
  }
1557
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
+
1558
1639
  export async function importWechatArticle(value, options = {}) {
1559
1640
  const pageUrl = parseWechatUrl(value);
1560
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.18",
3
+ "version": "1.0.20",
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",