@qcplay/cli 1.0.14 → 1.0.15
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 +200 -9
- package/bin/qcplay.js +552 -77
- package/lib/content-rules.js +807 -0
- package/lib/lark-article.js +1 -1
- package/lib/platform-publish.js +2414 -270
- package/lib/wechat-article.js +400 -25
- package/package.json +4 -1
- package/templates/skills/qcplay-publish-article/SKILL.md +38 -4
- package/templates/skills/qcplay-publish-article/article/article.md +3 -1
- package/templates/skills/qcplay-publish-article/article/west-article.md +22 -0
package/lib/wechat-article.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
import { randomBytes } from "crypto";
|
|
1
|
+
import { createHash, randomBytes } from "crypto";
|
|
2
2
|
import http from "http";
|
|
3
3
|
import https from "https";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import zlib from "zlib";
|
|
6
6
|
|
|
7
7
|
import * as cheerio from "cheerio";
|
|
8
|
+
import { applyContentRules, matchingContentRules, matchingDownloadedImageRules } from "./content-rules.js";
|
|
8
9
|
|
|
9
10
|
const WECHAT_HOST = "mp.weixin.qq.com";
|
|
10
|
-
const
|
|
11
|
+
const MEDIA_UPLOAD_URL = "https://api.qingcigame.com/novel/time/literature/avatar";
|
|
11
12
|
const MAX_REDIRECTS = 5;
|
|
12
13
|
const MAX_HTML_BYTES = 8 * 1024 * 1024;
|
|
13
14
|
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
|
15
|
+
const MAX_VIDEO_BYTES = 512 * 1024 * 1024;
|
|
14
16
|
const RICH_HTML_START = "<!-- qcplay-rich-html:start -->";
|
|
15
17
|
const RICH_HTML_END = "<!-- qcplay-rich-html:end -->";
|
|
18
|
+
const ARES_IGNORED_GIF_SHA256 = "138ed7ebf49170605efd81c99f516d648b43c22a972743680f6d974129e54da8";
|
|
16
19
|
|
|
17
20
|
const BLOCK_TAGS = new Set([
|
|
18
21
|
"article",
|
|
@@ -316,6 +319,22 @@ function assertWechatImageUrl(value, pageUrl) {
|
|
|
316
319
|
return url;
|
|
317
320
|
}
|
|
318
321
|
|
|
322
|
+
function assertWechatVideoUrl(value, pageUrl) {
|
|
323
|
+
const url = new URL(value, pageUrl);
|
|
324
|
+
const hostname = url.hostname.toLowerCase();
|
|
325
|
+
const allowedHost =
|
|
326
|
+
hostname === WECHAT_HOST ||
|
|
327
|
+
hostname.endsWith(".weixin.qq.com") ||
|
|
328
|
+
hostname.endsWith(".qq.com") ||
|
|
329
|
+
hostname === "qpic.cn" ||
|
|
330
|
+
hostname.endsWith(".qpic.cn");
|
|
331
|
+
if (url.protocol !== "https:" || !allowedHost) {
|
|
332
|
+
throw new Error(`视频地址不属于受支持的微信或腾讯媒体域名: ${url.origin}`);
|
|
333
|
+
}
|
|
334
|
+
url.hash = "";
|
|
335
|
+
return url;
|
|
336
|
+
}
|
|
337
|
+
|
|
319
338
|
function extensionForImage(contentType, sourceUrl) {
|
|
320
339
|
const normalizedType = String(contentType || "").split(";", 1)[0].trim().toLowerCase();
|
|
321
340
|
const extensions = {
|
|
@@ -335,6 +354,27 @@ function extensionForImage(contentType, sourceUrl) {
|
|
|
335
354
|
throw new Error(`不支持的图片类型: ${normalizedType || "未知"}`);
|
|
336
355
|
}
|
|
337
356
|
|
|
357
|
+
function extensionForVideo(contentType, sourceUrl) {
|
|
358
|
+
const normalizedType = String(contentType || "").split(";", 1)[0].trim().toLowerCase();
|
|
359
|
+
const extensions = {
|
|
360
|
+
"video/mp4": ".mp4",
|
|
361
|
+
"video/quicktime": ".mov",
|
|
362
|
+
"video/webm": ".webm",
|
|
363
|
+
"video/x-matroska": ".mkv",
|
|
364
|
+
"video/x-msvideo": ".avi",
|
|
365
|
+
"video/x-ms-wmv": ".wmv"
|
|
366
|
+
};
|
|
367
|
+
if (extensions[normalizedType]) {
|
|
368
|
+
return extensions[normalizedType];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const sourceExtension = path.extname(sourceUrl.pathname).toLowerCase();
|
|
372
|
+
if ([".mp4", ".mov", ".webm", ".mkv", ".avi", ".wmv"].includes(sourceExtension)) {
|
|
373
|
+
return sourceExtension;
|
|
374
|
+
}
|
|
375
|
+
throw new Error(`不支持的视频类型: ${normalizedType || "未知"}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
338
378
|
export async function downloadWechatImage(sourceUrl) {
|
|
339
379
|
const response = await requestBuffer(sourceUrl, {
|
|
340
380
|
maxBytes: MAX_IMAGE_BYTES,
|
|
@@ -355,17 +395,50 @@ export async function downloadWechatImage(sourceUrl) {
|
|
|
355
395
|
};
|
|
356
396
|
}
|
|
357
397
|
|
|
358
|
-
export async function
|
|
398
|
+
export async function downloadWechatVideo(sourceUrl) {
|
|
399
|
+
const videoUrl = assertWechatVideoUrl(sourceUrl, sourceUrl);
|
|
400
|
+
const response = await requestBuffer(videoUrl, {
|
|
401
|
+
maxBytes: MAX_VIDEO_BYTES,
|
|
402
|
+
validateRedirect: redirectUrl => assertWechatVideoUrl(redirectUrl, redirectUrl),
|
|
403
|
+
headers: {
|
|
404
|
+
Accept: "video/*,*/*;q=0.8",
|
|
405
|
+
Referer: "https://mp.weixin.qq.com/"
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
const headerContentType = String(response.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
|
|
409
|
+
let contentType = headerContentType;
|
|
410
|
+
if (!contentType.startsWith("video/")) {
|
|
411
|
+
const extension = extensionForVideo(headerContentType, response.url);
|
|
412
|
+
const types = {
|
|
413
|
+
".mp4": "video/mp4",
|
|
414
|
+
".mov": "video/quicktime",
|
|
415
|
+
".webm": "video/webm",
|
|
416
|
+
".mkv": "video/x-matroska",
|
|
417
|
+
".avi": "video/x-msvideo",
|
|
418
|
+
".wmv": "video/x-ms-wmv"
|
|
419
|
+
};
|
|
420
|
+
contentType = types[extension];
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
buffer: response.body,
|
|
424
|
+
contentType,
|
|
425
|
+
sourceUrl: response.url
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function uploadOfficialMedia(media, kind, uploadUrl = MEDIA_UPLOAD_URL) {
|
|
359
430
|
const target = new URL(uploadUrl);
|
|
360
|
-
const extension =
|
|
361
|
-
|
|
431
|
+
const extension = kind === "video"
|
|
432
|
+
? extensionForVideo(media.contentType, media.sourceUrl)
|
|
433
|
+
: extensionForImage(media.contentType, media.sourceUrl);
|
|
434
|
+
const filename = `wechat-${kind}-${Date.now()}-${randomBytes(5).toString("hex")}${extension}`;
|
|
362
435
|
const boundary = `----qcplay-${randomBytes(12).toString("hex")}`;
|
|
363
436
|
const header = Buffer.from(
|
|
364
437
|
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
|
|
365
|
-
`Content-Type: ${
|
|
438
|
+
`Content-Type: ${media.contentType}\r\n\r\n`
|
|
366
439
|
);
|
|
367
440
|
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
368
|
-
const body = Buffer.concat([header,
|
|
441
|
+
const body = Buffer.concat([header, media.buffer, footer]);
|
|
369
442
|
const response = await requestBuffer(target, {
|
|
370
443
|
method: "POST",
|
|
371
444
|
body,
|
|
@@ -382,30 +455,170 @@ export async function uploadOfficialImage(image, uploadUrl = IMAGE_UPLOAD_URL) {
|
|
|
382
455
|
try {
|
|
383
456
|
result = JSON.parse(response.body.toString("utf8"));
|
|
384
457
|
} catch {
|
|
385
|
-
throw new Error(
|
|
458
|
+
throw new Error(`${kind === "video" ? "视频" : "图片"}上传接口返回了非 JSON 内容: ${response.body.toString("utf8").slice(0, 160)}`);
|
|
386
459
|
}
|
|
387
460
|
if (Number(result.code) !== 200 || !result.data?.path) {
|
|
388
|
-
throw new Error(result.message ||
|
|
461
|
+
throw new Error(result.message || `${kind === "video" ? "视频" : "图片"}上传失败,接口返回 code=${result.code ?? "未知"}`);
|
|
389
462
|
}
|
|
390
463
|
|
|
391
464
|
let uploadedUrl;
|
|
392
465
|
try {
|
|
393
466
|
uploadedUrl = new URL(result.data.path);
|
|
394
467
|
} catch {
|
|
395
|
-
throw new Error("
|
|
468
|
+
throw new Error(`${kind === "video" ? "视频" : "图片"}上传接口没有返回有效的媒体地址`);
|
|
396
469
|
}
|
|
397
470
|
if (!new Set(["http:", "https:"]).has(uploadedUrl.protocol)) {
|
|
398
|
-
throw new Error("
|
|
471
|
+
throw new Error(`${kind === "video" ? "视频" : "图片"}上传接口返回了不支持的媒体地址`);
|
|
399
472
|
}
|
|
400
473
|
return uploadedUrl.toString();
|
|
401
474
|
}
|
|
402
475
|
|
|
476
|
+
export function uploadOfficialImage(image, uploadUrl = MEDIA_UPLOAD_URL) {
|
|
477
|
+
return uploadOfficialMedia(image, "image", uploadUrl);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function uploadOfficialVideo(video, uploadUrl = MEDIA_UPLOAD_URL) {
|
|
481
|
+
return uploadOfficialMedia(video, "video", uploadUrl);
|
|
482
|
+
}
|
|
483
|
+
|
|
403
484
|
function normalizeTextContent(value) {
|
|
404
485
|
return String(value || "")
|
|
405
486
|
.replace(/\u00a0/g, " ")
|
|
406
487
|
.replace(/[\t\r\n ]+/g, " ");
|
|
407
488
|
}
|
|
408
489
|
|
|
490
|
+
function isAresWechatAuthor(value) {
|
|
491
|
+
return /^阿瑞斯病毒(?:2)?$/.test(String(value || "").trim());
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function isIgnoredAresWechatGif(image, author) {
|
|
495
|
+
if (!isAresWechatAuthor(author) || !Buffer.isBuffer(image?.buffer)) return false;
|
|
496
|
+
const contentType = String(image.contentType || "").split(";", 1)[0].trim().toLowerCase();
|
|
497
|
+
if (contentType !== "image/gif") return false;
|
|
498
|
+
return createHash("sha256").update(image.buffer).digest("hex") === ARES_IGNORED_GIF_SHA256;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const WECHAT_GUIDANCE_BLOCKS = "p,li,blockquote,figcaption,h1,h2,h3,h4,h5,h6,div,section,aside,footer";
|
|
502
|
+
const WECHAT_GUIDANCE_PATTERNS = [
|
|
503
|
+
/(?:请|记得|欢迎|立即|点击|长按)?(?:关注|订阅).{0,12}(?:微信公众号|公众号)/,
|
|
504
|
+
/(?:长按|扫描|扫码).{0,20}(?:二维码|识别).{0,24}(?:公众号|微信|关注)/,
|
|
505
|
+
/(?:二维码|识别).{0,20}(?:长按|扫描|扫码).{0,24}(?:公众号|微信|关注)/,
|
|
506
|
+
/(?:点击|戳).{0,12}(?:阅读原文|下方名片|公众号卡片|蓝字)/,
|
|
507
|
+
/(?:点击|点亮|点个).{0,10}(?:在看|赞)|(?:转发|分享).{0,16}(?:朋友圈|微信群|微信好友)/,
|
|
508
|
+
/(?:点击).{0,12}(?:右上角|右下角).{0,16}(?:转发|分享|在看)/,
|
|
509
|
+
/(?:设为星标|星标(?:本)?公众号)/,
|
|
510
|
+
/(?:公众号|微信).{0,16}(?:后台回复|回复关键词|菜单栏)|(?:后台回复|回复关键词).{0,20}(?:领取|获取|查看|下载)/,
|
|
511
|
+
/(?:引流)?社群|(?:加入|添加|扫码加入).{0,16}(?:官方群|交流群|QQ群|社群)/,
|
|
512
|
+
/(?:官方)?游戏福利官.{0,20}(?:好友圈|好友)|(?:扫码|添加).{0,16}(?:游戏)?福利官(?:好友)?/,
|
|
513
|
+
/(?:官网|游戏内).{0,12}充值|充值.{0,12}(?:官网|入口)/,
|
|
514
|
+
/小姬有话说/
|
|
515
|
+
];
|
|
516
|
+
const WECHAT_MEDIA_GUIDANCE_PATTERN = /二维码|扫码|长按识别|公众号卡片|下方名片|社群|交流群|福利官|好友圈|求关注|星标|充值/;
|
|
517
|
+
|
|
518
|
+
function isWechatGuidanceText(value) {
|
|
519
|
+
const text = normalizeTextContent(value).trim();
|
|
520
|
+
return Boolean(text && text.length <= 240 && WECHAT_GUIDANCE_PATTERNS.some(pattern => pattern.test(text)));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function isImageOnlyBlock(element) {
|
|
524
|
+
if (!element?.length) return false;
|
|
525
|
+
if (element.is("img")) return true;
|
|
526
|
+
return !normalizeTextContent(element.text()).trim() && element.find("img").length > 0;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function stripWechatGuidanceHtml(value, options = {}) {
|
|
530
|
+
const $ = cheerio.load(String(value || ""), null, false);
|
|
531
|
+
const preserveTextPatterns = Array.isArray(options.preserveTextPatterns)
|
|
532
|
+
? options.preserveTextPatterns.filter(pattern => pattern instanceof RegExp)
|
|
533
|
+
: [];
|
|
534
|
+
const protectedBlocks = new Set();
|
|
535
|
+
const shouldPreserve = value => {
|
|
536
|
+
const text = normalizeTextContent(value).trim();
|
|
537
|
+
return Boolean(text && preserveTextPatterns.some(pattern => pattern.test(text)));
|
|
538
|
+
};
|
|
539
|
+
const preservationBlocks = $(WECHAT_GUIDANCE_BLOCKS)
|
|
540
|
+
.toArray()
|
|
541
|
+
.filter(element => shouldPreserve($(element).text()))
|
|
542
|
+
.filter(element => {
|
|
543
|
+
return !$(element)
|
|
544
|
+
.find(WECHAT_GUIDANCE_BLOCKS)
|
|
545
|
+
.toArray()
|
|
546
|
+
.some(child => shouldPreserve($(child).text()));
|
|
547
|
+
});
|
|
548
|
+
preservationBlocks.forEach(element => {
|
|
549
|
+
protectedBlocks.add(element);
|
|
550
|
+
});
|
|
551
|
+
const isProtected = element => {
|
|
552
|
+
if (!element) return false;
|
|
553
|
+
if (protectedBlocks.has(element)) return true;
|
|
554
|
+
return $(element)
|
|
555
|
+
.parents()
|
|
556
|
+
.toArray()
|
|
557
|
+
.some(parent => protectedBlocks.has(parent));
|
|
558
|
+
};
|
|
559
|
+
const matchingBlocks = $(WECHAT_GUIDANCE_BLOCKS)
|
|
560
|
+
.toArray()
|
|
561
|
+
.filter(element => !isProtected(element))
|
|
562
|
+
.filter(element => isWechatGuidanceText($(element).text()))
|
|
563
|
+
.filter(element => {
|
|
564
|
+
return !$(element)
|
|
565
|
+
.find(WECHAT_GUIDANCE_BLOCKS)
|
|
566
|
+
.toArray()
|
|
567
|
+
.some(child => isWechatGuidanceText($(child).text()));
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
for (const element of matchingBlocks) {
|
|
571
|
+
const block = $(element);
|
|
572
|
+
const text = normalizeTextContent(block.text()).trim();
|
|
573
|
+
let target = block;
|
|
574
|
+
if (/社群|官方群|交流群|QQ群|福利官|好友圈/.test(text)) {
|
|
575
|
+
for (const parentNode of block.parents("section,div").toArray()) {
|
|
576
|
+
const parent = $(parentNode);
|
|
577
|
+
const parentText = normalizeTextContent(parent.text()).trim();
|
|
578
|
+
const hasAssociatedImage =
|
|
579
|
+
isImageOnlyBlock(parent.prev()) || isImageOnlyBlock(parent.next()) || parent.find("img").length > 0;
|
|
580
|
+
if (parentText.length <= 500 && parent.find(WECHAT_GUIDANCE_BLOCKS).length <= 12 && hasAssociatedImage) {
|
|
581
|
+
target = parent;
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (WECHAT_MEDIA_GUIDANCE_PATTERN.test(text)) {
|
|
587
|
+
const previous = target.prev();
|
|
588
|
+
const next = target.next();
|
|
589
|
+
if (isImageOnlyBlock(previous) && !isProtected(previous.get(0))) previous.remove();
|
|
590
|
+
if (isImageOnlyBlock(next) && !isProtected(next.get(0))) next.remove();
|
|
591
|
+
}
|
|
592
|
+
target.remove();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
$("img").each((_, element) => {
|
|
596
|
+
if (isProtected(element)) return;
|
|
597
|
+
const image = $(element);
|
|
598
|
+
const description = [image.attr("alt"), image.attr("title"), image.attr("aria-label")]
|
|
599
|
+
.filter(Boolean)
|
|
600
|
+
.join(" ");
|
|
601
|
+
if (!/(?:公众号)?二维码|扫码关注|长按识别|引流社群|求关注|星标|官网充值/.test(description)) return;
|
|
602
|
+
const container = image.closest("p,figure,div,section").first();
|
|
603
|
+
if (container.length && isImageOnlyBlock(container)) {
|
|
604
|
+
container.remove();
|
|
605
|
+
} else {
|
|
606
|
+
image.remove();
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
$(WECHAT_GUIDANCE_BLOCKS)
|
|
611
|
+
.toArray()
|
|
612
|
+
.reverse()
|
|
613
|
+
.forEach(element => {
|
|
614
|
+
const block = $(element);
|
|
615
|
+
if (!normalizeTextContent(block.text()).trim() && block.find("img,table,hr").length === 0) {
|
|
616
|
+
block.remove();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
return $.root().html().trim();
|
|
620
|
+
}
|
|
621
|
+
|
|
409
622
|
function escapeMarkdownText(value) {
|
|
410
623
|
return normalizeTextContent(value).replace(/([\\`*_\[\]])/g, "\\$1");
|
|
411
624
|
}
|
|
@@ -464,7 +677,16 @@ function elementTextColor(element) {
|
|
|
464
677
|
}
|
|
465
678
|
|
|
466
679
|
function safeContentUrl(element) {
|
|
467
|
-
const attributes = [
|
|
680
|
+
const attributes = [
|
|
681
|
+
"data-qcplay-media-src",
|
|
682
|
+
"href",
|
|
683
|
+
"src",
|
|
684
|
+
"data-src",
|
|
685
|
+
"data-url",
|
|
686
|
+
"data-link",
|
|
687
|
+
"data-video-url",
|
|
688
|
+
"data-audio-url"
|
|
689
|
+
];
|
|
468
690
|
const candidates = [];
|
|
469
691
|
for (const attribute of attributes) {
|
|
470
692
|
candidates.push(element.attr(attribute));
|
|
@@ -531,6 +753,18 @@ function safeHttpUrl(value) {
|
|
|
531
753
|
}
|
|
532
754
|
}
|
|
533
755
|
|
|
756
|
+
function directWechatVideoUrl(value) {
|
|
757
|
+
const candidate = safeHttpUrl(value);
|
|
758
|
+
if (!candidate) return "";
|
|
759
|
+
const url = new URL(candidate);
|
|
760
|
+
const hostname = url.hostname.toLowerCase();
|
|
761
|
+
const extension = path.extname(url.pathname).toLowerCase();
|
|
762
|
+
if ([".mp4", ".mov", ".webm", ".mkv", ".avi", ".wmv"].includes(extension)) {
|
|
763
|
+
return candidate;
|
|
764
|
+
}
|
|
765
|
+
return hostname === "mpvideo.qpic.cn" || hostname.endsWith(".mpvideo.qpic.cn") ? candidate : "";
|
|
766
|
+
}
|
|
767
|
+
|
|
534
768
|
function sanitizeInlineStyle(value, backgroundUrls = []) {
|
|
535
769
|
const declarations = [];
|
|
536
770
|
for (const declaration of String(value || "").split(";")) {
|
|
@@ -602,6 +836,54 @@ function isStandaloneImage(element) {
|
|
|
602
836
|
});
|
|
603
837
|
}
|
|
604
838
|
|
|
839
|
+
function removeWechatImageLoadingPlaceholders($, content) {
|
|
840
|
+
const loadingClassNames = new Set([
|
|
841
|
+
"js_img_loading",
|
|
842
|
+
"wx_img_loading",
|
|
843
|
+
"wx_img_loading_msg",
|
|
844
|
+
"img_loading",
|
|
845
|
+
"img_loading_msg"
|
|
846
|
+
]);
|
|
847
|
+
const hasLoadingClass = element =>
|
|
848
|
+
String(element.attr("class") || "")
|
|
849
|
+
.split(/\s+/)
|
|
850
|
+
.map(className => className.toLowerCase())
|
|
851
|
+
.some(className => loadingClassNames.has(className));
|
|
852
|
+
|
|
853
|
+
// WeChat can wrap the real image in a loading node. Remove loader-only
|
|
854
|
+
// descendants, but never remove an ancestor that owns an image.
|
|
855
|
+
const candidates = content
|
|
856
|
+
.find("*")
|
|
857
|
+
.toArray()
|
|
858
|
+
.filter(node => hasLoadingClass($(node)))
|
|
859
|
+
.sort((left, right) => $(right).parents().length - $(left).parents().length);
|
|
860
|
+
for (const node of candidates) {
|
|
861
|
+
const element = $(node);
|
|
862
|
+
const tag = String(node.tagName || node.name || "").toLowerCase();
|
|
863
|
+
if (tag === "img") continue;
|
|
864
|
+
if (element.find("img").length === 0) {
|
|
865
|
+
element.remove();
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
element.find("*").each((_, child) => {
|
|
869
|
+
const childElement = $(child);
|
|
870
|
+
if (hasLoadingClass(childElement) && childElement.find("img").length === 0) {
|
|
871
|
+
childElement.remove();
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
const remainingClasses = String(element.attr("class") || "")
|
|
875
|
+
.split(/\s+/)
|
|
876
|
+
.filter(className => className && !loadingClassNames.has(className.toLowerCase()));
|
|
877
|
+
if (remainingClasses.length > 0) element.attr("class", remainingClasses.join(" "));
|
|
878
|
+
else element.removeAttr("class");
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function meaningfulImageAlt(value) {
|
|
883
|
+
const alt = normalizeTextContent(value).trim();
|
|
884
|
+
return /^(?:图片|图像|image|picture)$/i.test(alt) ? "" : alt;
|
|
885
|
+
}
|
|
886
|
+
|
|
605
887
|
export function sanitizeArticleRichHtml(value, options = {}) {
|
|
606
888
|
const initial = cheerio.load(String(value || ""), null, false);
|
|
607
889
|
const existingRoot = initial('[data-qcplay-rich-root="1"]').first();
|
|
@@ -641,8 +923,16 @@ export function sanitizeArticleRichHtml(value, options = {}) {
|
|
|
641
923
|
const transferredBackgrounds = String(originalAttributes["data-qcplay-background-srcs"] || "")
|
|
642
924
|
.split("|")
|
|
643
925
|
.filter(Boolean);
|
|
644
|
-
const backgroundUrls =
|
|
645
|
-
|
|
926
|
+
const backgroundUrls = Object.prototype.hasOwnProperty.call(originalAttributes, "data-qcplay-background-processed")
|
|
927
|
+
? transferredBackgrounds
|
|
928
|
+
: transferredBackgrounds.length > 0
|
|
929
|
+
? transferredBackgrounds
|
|
930
|
+
: backgroundImageSources(element);
|
|
931
|
+
let sanitizedStyle = sanitizeInlineStyle(originalAttributes.style || originalAttributes["data-style"], backgroundUrls);
|
|
932
|
+
const attributeColor = normalizeArticleColor(originalAttributes.color || originalAttributes["data-color"]);
|
|
933
|
+
if (tag !== "font" && attributeColor && !/(?:^|;)\s*(?:color|-webkit-text-fill-color)\s*:/i.test(sanitizedStyle)) {
|
|
934
|
+
sanitizedStyle = [sanitizedStyle, `color:${attributeColor}`].filter(Boolean).join(";");
|
|
935
|
+
}
|
|
646
936
|
for (const attribute of Object.keys(originalAttributes)) {
|
|
647
937
|
element.removeAttr(attribute);
|
|
648
938
|
}
|
|
@@ -659,7 +949,7 @@ export function sanitizeArticleRichHtml(value, options = {}) {
|
|
|
659
949
|
element.parent().attr("style") || ""
|
|
660
950
|
);
|
|
661
951
|
const src = safeHttpUrl(originalAttributes["data-qcplay-src"] || originalAttributes.src);
|
|
662
|
-
const alt =
|
|
952
|
+
const alt = meaningfulImageAlt(originalAttributes.alt);
|
|
663
953
|
if (!src) {
|
|
664
954
|
element.replaceWith(alt ? $("<span></span>").text(alt) : "");
|
|
665
955
|
continue;
|
|
@@ -722,8 +1012,8 @@ export function sanitizeArticleRichHtml(value, options = {}) {
|
|
|
722
1012
|
continue;
|
|
723
1013
|
}
|
|
724
1014
|
|
|
725
|
-
if (tag === "font" &&
|
|
726
|
-
element.attr("color",
|
|
1015
|
+
if (tag === "font" && attributeColor) {
|
|
1016
|
+
element.attr("color", attributeColor);
|
|
727
1017
|
}
|
|
728
1018
|
if ((tag === "td" || tag === "th") && /^\d+$/.test(originalAttributes.colspan || "")) {
|
|
729
1019
|
element.attr("colspan", originalAttributes.colspan);
|
|
@@ -860,7 +1150,7 @@ function markdownForNode($, node, listDepth = 0, inheritedColor = "", context) {
|
|
|
860
1150
|
const description = elementDescription(element);
|
|
861
1151
|
return description ? `\n\n${escapeMarkdownText(description)}\n\n` : "";
|
|
862
1152
|
}
|
|
863
|
-
const alt = escapeMarkdownText(element.attr("alt")
|
|
1153
|
+
const alt = escapeMarkdownText(meaningfulImageAlt(element.attr("alt")));
|
|
864
1154
|
return `\n\n\n\n`;
|
|
865
1155
|
}
|
|
866
1156
|
if (tag === "strong" || tag === "b") {
|
|
@@ -999,8 +1289,31 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
999
1289
|
if (!title) {
|
|
1000
1290
|
throw new Error("微信页面中没有找到文章标题");
|
|
1001
1291
|
}
|
|
1292
|
+
const author = $("#js_name").first().text().trim() || $('meta[name="author"]').attr("content")?.trim() || "";
|
|
1002
1293
|
|
|
1003
1294
|
content.find("script,style").remove();
|
|
1295
|
+
removeWechatImageLoadingPlaceholders($, content);
|
|
1296
|
+
const ruleArticle = { html: content.html(), meta: { source_url: sourceUrl.toString() } };
|
|
1297
|
+
const ruleEntry = {
|
|
1298
|
+
project: options.project || (isAresWechatAuthor(author) ? "阿瑞斯病毒2" : ""),
|
|
1299
|
+
region: options.region || "",
|
|
1300
|
+
platform: options.platform || "*",
|
|
1301
|
+
platformKey: options.platform || "*"
|
|
1302
|
+
};
|
|
1303
|
+
const matchedContentRules = options.contentRules
|
|
1304
|
+
? matchingContentRules(options.contentRules, ruleArticle, ruleEntry, { phase: "import" }).map(rule => rule.name)
|
|
1305
|
+
: [];
|
|
1306
|
+
const appliedContentRules = new Set();
|
|
1307
|
+
if (options.contentRules) {
|
|
1308
|
+
const rulePrepared = applyContentRules(
|
|
1309
|
+
ruleArticle,
|
|
1310
|
+
ruleEntry,
|
|
1311
|
+
options.contentRules,
|
|
1312
|
+
{ phase: "import" }
|
|
1313
|
+
);
|
|
1314
|
+
if (rulePrepared.html !== content.html()) content.html(rulePrepared.html);
|
|
1315
|
+
rulePrepared.appliedContentRules?.forEach(name => appliedContentRules.add(name));
|
|
1316
|
+
}
|
|
1004
1317
|
const sourceTextNodes = collectMeaningfulTextNodes(content.get(0));
|
|
1005
1318
|
const imageElements = content.find("img").toArray();
|
|
1006
1319
|
const sourceImages = imageElements.map(element => {
|
|
@@ -1013,22 +1326,42 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1013
1326
|
.map(element => ({ element, sources: backgroundImageSources($(element)) }))
|
|
1014
1327
|
.filter(entry => entry.sources.length > 0);
|
|
1015
1328
|
const backgroundSources = backgroundEntries.flatMap(entry => entry.sources);
|
|
1329
|
+
const videoElements = content.find("video,mp-common-videosnap").toArray();
|
|
1330
|
+
const videoSources = videoElements.map(element => directWechatVideoUrl(safeContentUrl($(element))));
|
|
1016
1331
|
const coverSource = $('meta[property="og:image"]').attr("content") || "";
|
|
1017
1332
|
const allSources = [coverSource, ...sourceImages, ...backgroundSources].filter(Boolean);
|
|
1018
1333
|
const uploaded = new Map();
|
|
1334
|
+
const ignored = new Set();
|
|
1019
1335
|
const downloadImage = options.downloadImage || downloadWechatImage;
|
|
1020
1336
|
const uploadImage = options.uploadImage || uploadOfficialImage;
|
|
1337
|
+
const downloadVideo = options.downloadVideo || downloadWechatVideo;
|
|
1338
|
+
const uploadVideo = options.uploadVideo || uploadOfficialVideo;
|
|
1339
|
+
const uniqueSourceCount = new Set(allSources.map(rawSource => assertWechatImageUrl(rawSource, sourceUrl).toString())).size;
|
|
1340
|
+
let processedSourceCount = 0;
|
|
1021
1341
|
|
|
1022
1342
|
for (const rawSource of allSources) {
|
|
1023
1343
|
const imageUrl = assertWechatImageUrl(rawSource, sourceUrl);
|
|
1024
1344
|
const key = imageUrl.toString();
|
|
1025
|
-
if (uploaded.has(key)) {
|
|
1345
|
+
if (uploaded.has(key) || ignored.has(key)) {
|
|
1026
1346
|
continue;
|
|
1027
1347
|
}
|
|
1028
|
-
const current =
|
|
1029
|
-
options.onProgress?.({ current, total:
|
|
1348
|
+
const current = ++processedSourceCount;
|
|
1349
|
+
options.onProgress?.({ mediaType: "image", current, total: uniqueSourceCount, sourceUrl: key });
|
|
1030
1350
|
try {
|
|
1031
1351
|
const image = await downloadImage(imageUrl);
|
|
1352
|
+
const matchedImageRules = options.contentRules
|
|
1353
|
+
? matchingDownloadedImageRules(image, ruleArticle, ruleEntry, options.contentRules, { phase: "import" })
|
|
1354
|
+
: [];
|
|
1355
|
+
const shouldIgnore =
|
|
1356
|
+
matchedImageRules.length > 0 ||
|
|
1357
|
+
(options.shouldIgnoreImage
|
|
1358
|
+
? await options.shouldIgnoreImage(image, { author, sourceUrl: key })
|
|
1359
|
+
: isIgnoredAresWechatGif(image, author));
|
|
1360
|
+
if (shouldIgnore) {
|
|
1361
|
+
matchedImageRules.forEach(rule => appliedContentRules.add(rule.name));
|
|
1362
|
+
ignored.add(key);
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1032
1365
|
uploaded.set(key, await uploadImage(image));
|
|
1033
1366
|
} catch (error) {
|
|
1034
1367
|
throw new Error(`第 ${current} 张图片处理失败: ${error.message}`);
|
|
@@ -1041,6 +1374,10 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1041
1374
|
return;
|
|
1042
1375
|
}
|
|
1043
1376
|
const key = assertWechatImageUrl(rawSource, sourceUrl).toString();
|
|
1377
|
+
if (ignored.has(key)) {
|
|
1378
|
+
$(element).remove();
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1044
1381
|
$(element).attr("data-qcplay-src", uploaded.get(key));
|
|
1045
1382
|
});
|
|
1046
1383
|
|
|
@@ -1049,9 +1386,30 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1049
1386
|
const key = assertWechatImageUrl(rawSource, sourceUrl).toString();
|
|
1050
1387
|
return uploaded.get(key);
|
|
1051
1388
|
});
|
|
1389
|
+
$(entry.element).attr("data-qcplay-background-processed", "1");
|
|
1052
1390
|
$(entry.element).attr("data-qcplay-background-srcs", urls.filter(Boolean).join("|"));
|
|
1053
1391
|
});
|
|
1054
1392
|
|
|
1393
|
+
const uploadedVideos = new Map();
|
|
1394
|
+
const uniqueVideoSources = [...new Set(videoSources.filter(Boolean))];
|
|
1395
|
+
for (const rawSource of uniqueVideoSources) {
|
|
1396
|
+
const current = uploadedVideos.size + 1;
|
|
1397
|
+
options.onProgress?.({ mediaType: "video", current, total: uniqueVideoSources.length, sourceUrl: rawSource });
|
|
1398
|
+
try {
|
|
1399
|
+
const video = await downloadVideo(rawSource);
|
|
1400
|
+
uploadedVideos.set(rawSource, await uploadVideo(video));
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
throw new Error(`第 ${current} 个视频处理失败: ${error.message}`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
videoElements.forEach((element, index) => {
|
|
1407
|
+
const source = videoSources[index];
|
|
1408
|
+
if (source) {
|
|
1409
|
+
$(element).attr("data-qcplay-media-src", uploadedVideos.get(source));
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1055
1413
|
const richHtml = sanitizeArticleRichHtml(content.html());
|
|
1056
1414
|
if (!richHtml) {
|
|
1057
1415
|
throw new Error("微信文章富文本转换后为空");
|
|
@@ -1069,7 +1427,9 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1069
1427
|
.join(" / ");
|
|
1070
1428
|
throw new Error(`微信文章有 ${missingRichTextNodes.length} 处正文未保留原始格式: ${preview}`);
|
|
1071
1429
|
}
|
|
1072
|
-
const sourceImageCount = sourceImages.filter(
|
|
1430
|
+
const sourceImageCount = sourceImages.filter(rawSource => {
|
|
1431
|
+
return rawSource && !ignored.has(assertWechatImageUrl(rawSource, sourceUrl).toString());
|
|
1432
|
+
}).length;
|
|
1073
1433
|
const richImageCount = richDocument("img").length;
|
|
1074
1434
|
if (richImageCount !== sourceImageCount) {
|
|
1075
1435
|
throw new Error(`微信文章图片结构不完整: 原文 ${sourceImageCount} 张,转换后 ${richImageCount} 张`);
|
|
@@ -1104,7 +1464,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1104
1464
|
|
|
1105
1465
|
return {
|
|
1106
1466
|
title,
|
|
1107
|
-
author
|
|
1467
|
+
author,
|
|
1108
1468
|
releaseDate: releaseDateFromWechatPage($, html),
|
|
1109
1469
|
excerpt: $('meta[property="og:description"]').attr("content")?.trim() || "",
|
|
1110
1470
|
thumbnail: coverSource ? uploaded.get(assertWechatImageUrl(coverSource, sourceUrl).toString()) || "" : uploaded.values().next().value || "",
|
|
@@ -1112,6 +1472,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1112
1472
|
richHtml,
|
|
1113
1473
|
richImageCount,
|
|
1114
1474
|
imageCount: uploaded.size,
|
|
1475
|
+
videoCount: uploadedVideos.size,
|
|
1115
1476
|
backgroundImageCount: backgroundSources.length,
|
|
1116
1477
|
colorCount: markdownContext.colorCount,
|
|
1117
1478
|
colors: [...markdownContext.colors],
|
|
@@ -1119,7 +1480,9 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1119
1480
|
specialElementCount: markdownContext.specialElementCount,
|
|
1120
1481
|
tableCount: markdownContext.tableCount,
|
|
1121
1482
|
textSegmentCount: sourceTextNodes.length,
|
|
1122
|
-
sourceUrl: sourceUrl.toString()
|
|
1483
|
+
sourceUrl: sourceUrl.toString(),
|
|
1484
|
+
matchedContentRules,
|
|
1485
|
+
appliedContentRules: [...appliedContentRules]
|
|
1123
1486
|
};
|
|
1124
1487
|
}
|
|
1125
1488
|
|
|
@@ -1127,6 +1490,17 @@ function yamlString(value) {
|
|
|
1127
1490
|
return JSON.stringify(String(value || ""));
|
|
1128
1491
|
}
|
|
1129
1492
|
|
|
1493
|
+
function distributionProjectForWechatArticle(article) {
|
|
1494
|
+
const author = String(article.author || "").trim();
|
|
1495
|
+
if (isAresWechatAuthor(author)) return "阿瑞斯病毒2";
|
|
1496
|
+
return "";
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function gameIdForWechatArticle(article) {
|
|
1500
|
+
const author = String(article.author || "").trim();
|
|
1501
|
+
return isAresWechatAuthor(author) ? "41" : "39";
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1130
1504
|
export function buildOfficialArticleMarkdown(article) {
|
|
1131
1505
|
const articleBody = article.richHtml
|
|
1132
1506
|
? `${RICH_HTML_START}\n${article.richHtml}\n${RICH_HTML_END}`
|
|
@@ -1146,10 +1520,11 @@ is_index: "0"
|
|
|
1146
1520
|
release_time: ${yamlString(article.releaseDate)}
|
|
1147
1521
|
area: "1"
|
|
1148
1522
|
sort: "100"
|
|
1149
|
-
game_id:
|
|
1523
|
+
game_id: ${yamlString(article.gameId || gameIdForWechatArticle(article))}
|
|
1150
1524
|
index_pc_img: ""
|
|
1151
1525
|
index_move_img: ""
|
|
1152
1526
|
source_url: ${yamlString(article.sourceUrl)}
|
|
1527
|
+
distribution_project: ${yamlString(article.distributionProject || distributionProjectForWechatArticle(article))}
|
|
1153
1528
|
---
|
|
1154
1529
|
|
|
1155
1530
|
${articleBody}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qcplay/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
4
4
|
"description": "QCPlay CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
"test:wechat": "node test/wechat-article.test.js",
|
|
30
30
|
"test:lark": "node test/lark-article.test.js",
|
|
31
31
|
"test:platforms": "node test/platform-publish.test.js",
|
|
32
|
+
"test:rules": "node test/content-rules.test.js",
|
|
33
|
+
"test:rules-cli": "node test/content-rules-cli.test.js",
|
|
34
|
+
"test:project-catalog": "node test/project-catalog.test.js",
|
|
32
35
|
"build:cli": "node --check bin/qcplay.js",
|
|
33
36
|
"pack:check": "npm pack --dry-run"
|
|
34
37
|
},
|