@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.
- package/README.md +58 -329
- package/bin/qcplay.js +416 -54
- package/lib/platform-publish.js +1173 -177
- package/lib/rich-article-upload.js +116 -0
- package/lib/wechat-article.js +126 -29
- package/lib/wechat-data.js +155 -0
- package/package.json +2 -1
- package/templates/skills/qcplay-publish-article/SKILL.md +46 -18
- 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,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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
430
|
-
const
|
|
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
|
|
499
|
+
export function uploadOfficialImage(image, uploadUrl) {
|
|
477
500
|
return uploadOfficialMedia(image, "image", uploadUrl);
|
|
478
501
|
}
|
|
479
502
|
|
|
480
|
-
export function uploadOfficialVideo(video, uploadUrl
|
|
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 === "
|
|
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
|
-
|
|
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.
|
|
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",
|