@qcplay/cli 1.0.18 → 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.
- package/bin/qcplay.js +400 -56
- package/lib/platform-publish.js +977 -79
- 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 +41 -12
- package/templates/skills/qcplay-wechat-data/SKILL.md +101 -0
|
@@ -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, "<")
|
|
9
|
+
.replace(/>/g, ">")
|
|
10
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|
package/lib/wechat-article.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
|
|
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.
|
|
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",
|