@qcplay/cli 1.0.13 → 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 +309 -3
- package/bin/qcplay.js +852 -74
- package/lib/content-rules.js +807 -0
- package/lib/lark-article.js +294 -0
- package/lib/platform-publish.js +6617 -0
- package/lib/wechat-article.js +414 -28
- package/package.json +8 -2
- package/templates/skills/qcplay-publish-article/SKILL.md +144 -2
- package/templates/skills/qcplay-publish-article/article/article.md +17 -0
- 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",
|
|
@@ -34,6 +37,8 @@ const SAFE_RICH_TAGS = new Set([
|
|
|
34
37
|
"b",
|
|
35
38
|
"blockquote",
|
|
36
39
|
"br",
|
|
40
|
+
"col",
|
|
41
|
+
"colgroup",
|
|
37
42
|
"del",
|
|
38
43
|
"div",
|
|
39
44
|
"em",
|
|
@@ -303,7 +308,10 @@ export function parseWechatUrl(value) {
|
|
|
303
308
|
function assertWechatImageUrl(value, pageUrl) {
|
|
304
309
|
const url = new URL(value, pageUrl);
|
|
305
310
|
const hostname = url.hostname.toLowerCase();
|
|
306
|
-
const allowedHost =
|
|
311
|
+
const allowedHost =
|
|
312
|
+
hostname === "mmbiz.qpic.cn" ||
|
|
313
|
+
hostname.endsWith(".mmbiz.qpic.cn") ||
|
|
314
|
+
(hostname === "res.wx.qq.com" && url.pathname.startsWith("/t/wx_fed/we-emoji/res/assets/"));
|
|
307
315
|
if (url.protocol !== "https:" || !allowedHost) {
|
|
308
316
|
throw new Error(`文章包含不受支持的图片地址: ${url.origin}`);
|
|
309
317
|
}
|
|
@@ -311,6 +319,22 @@ function assertWechatImageUrl(value, pageUrl) {
|
|
|
311
319
|
return url;
|
|
312
320
|
}
|
|
313
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
|
+
|
|
314
338
|
function extensionForImage(contentType, sourceUrl) {
|
|
315
339
|
const normalizedType = String(contentType || "").split(";", 1)[0].trim().toLowerCase();
|
|
316
340
|
const extensions = {
|
|
@@ -330,6 +354,27 @@ function extensionForImage(contentType, sourceUrl) {
|
|
|
330
354
|
throw new Error(`不支持的图片类型: ${normalizedType || "未知"}`);
|
|
331
355
|
}
|
|
332
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
|
+
|
|
333
378
|
export async function downloadWechatImage(sourceUrl) {
|
|
334
379
|
const response = await requestBuffer(sourceUrl, {
|
|
335
380
|
maxBytes: MAX_IMAGE_BYTES,
|
|
@@ -350,17 +395,50 @@ export async function downloadWechatImage(sourceUrl) {
|
|
|
350
395
|
};
|
|
351
396
|
}
|
|
352
397
|
|
|
353
|
-
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) {
|
|
354
430
|
const target = new URL(uploadUrl);
|
|
355
|
-
const extension =
|
|
356
|
-
|
|
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}`;
|
|
357
435
|
const boundary = `----qcplay-${randomBytes(12).toString("hex")}`;
|
|
358
436
|
const header = Buffer.from(
|
|
359
437
|
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
|
|
360
|
-
`Content-Type: ${
|
|
438
|
+
`Content-Type: ${media.contentType}\r\n\r\n`
|
|
361
439
|
);
|
|
362
440
|
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
363
|
-
const body = Buffer.concat([header,
|
|
441
|
+
const body = Buffer.concat([header, media.buffer, footer]);
|
|
364
442
|
const response = await requestBuffer(target, {
|
|
365
443
|
method: "POST",
|
|
366
444
|
body,
|
|
@@ -377,30 +455,170 @@ export async function uploadOfficialImage(image, uploadUrl = IMAGE_UPLOAD_URL) {
|
|
|
377
455
|
try {
|
|
378
456
|
result = JSON.parse(response.body.toString("utf8"));
|
|
379
457
|
} catch {
|
|
380
|
-
throw new Error(
|
|
458
|
+
throw new Error(`${kind === "video" ? "视频" : "图片"}上传接口返回了非 JSON 内容: ${response.body.toString("utf8").slice(0, 160)}`);
|
|
381
459
|
}
|
|
382
460
|
if (Number(result.code) !== 200 || !result.data?.path) {
|
|
383
|
-
throw new Error(result.message ||
|
|
461
|
+
throw new Error(result.message || `${kind === "video" ? "视频" : "图片"}上传失败,接口返回 code=${result.code ?? "未知"}`);
|
|
384
462
|
}
|
|
385
463
|
|
|
386
464
|
let uploadedUrl;
|
|
387
465
|
try {
|
|
388
466
|
uploadedUrl = new URL(result.data.path);
|
|
389
467
|
} catch {
|
|
390
|
-
throw new Error("
|
|
468
|
+
throw new Error(`${kind === "video" ? "视频" : "图片"}上传接口没有返回有效的媒体地址`);
|
|
391
469
|
}
|
|
392
470
|
if (!new Set(["http:", "https:"]).has(uploadedUrl.protocol)) {
|
|
393
|
-
throw new Error("
|
|
471
|
+
throw new Error(`${kind === "video" ? "视频" : "图片"}上传接口返回了不支持的媒体地址`);
|
|
394
472
|
}
|
|
395
473
|
return uploadedUrl.toString();
|
|
396
474
|
}
|
|
397
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
|
+
|
|
398
484
|
function normalizeTextContent(value) {
|
|
399
485
|
return String(value || "")
|
|
400
486
|
.replace(/\u00a0/g, " ")
|
|
401
487
|
.replace(/[\t\r\n ]+/g, " ");
|
|
402
488
|
}
|
|
403
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
|
+
|
|
404
622
|
function escapeMarkdownText(value) {
|
|
405
623
|
return normalizeTextContent(value).replace(/([\\`*_\[\]])/g, "\\$1");
|
|
406
624
|
}
|
|
@@ -459,7 +677,16 @@ function elementTextColor(element) {
|
|
|
459
677
|
}
|
|
460
678
|
|
|
461
679
|
function safeContentUrl(element) {
|
|
462
|
-
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
|
+
];
|
|
463
690
|
const candidates = [];
|
|
464
691
|
for (const attribute of attributes) {
|
|
465
692
|
candidates.push(element.attr(attribute));
|
|
@@ -526,6 +753,18 @@ function safeHttpUrl(value) {
|
|
|
526
753
|
}
|
|
527
754
|
}
|
|
528
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
|
+
|
|
529
768
|
function sanitizeInlineStyle(value, backgroundUrls = []) {
|
|
530
769
|
const declarations = [];
|
|
531
770
|
for (const declaration of String(value || "").split(";")) {
|
|
@@ -597,9 +836,62 @@ function isStandaloneImage(element) {
|
|
|
597
836
|
});
|
|
598
837
|
}
|
|
599
838
|
|
|
600
|
-
|
|
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
|
+
|
|
887
|
+
export function sanitizeArticleRichHtml(value, options = {}) {
|
|
601
888
|
const initial = cheerio.load(String(value || ""), null, false);
|
|
602
889
|
const existingRoot = initial('[data-qcplay-rich-root="1"]').first();
|
|
890
|
+
const requestedMaxWidth = Number.parseInt(
|
|
891
|
+
existingRoot.attr("data-qcplay-rich-max-width") || options.maxWidth || "677",
|
|
892
|
+
10
|
|
893
|
+
);
|
|
894
|
+
const maxWidth = Math.max(320, Math.min(Number.isFinite(requestedMaxWidth) ? requestedMaxWidth : 677, 1200));
|
|
603
895
|
const source = existingRoot.length ? existingRoot.html() || "" : initial.root().html() || "";
|
|
604
896
|
const $ = cheerio.load(source, null, false);
|
|
605
897
|
|
|
@@ -631,8 +923,16 @@ export function sanitizeArticleRichHtml(value) {
|
|
|
631
923
|
const transferredBackgrounds = String(originalAttributes["data-qcplay-background-srcs"] || "")
|
|
632
924
|
.split("|")
|
|
633
925
|
.filter(Boolean);
|
|
634
|
-
const backgroundUrls =
|
|
635
|
-
|
|
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
|
+
}
|
|
636
936
|
for (const attribute of Object.keys(originalAttributes)) {
|
|
637
937
|
element.removeAttr(attribute);
|
|
638
938
|
}
|
|
@@ -649,7 +949,7 @@ export function sanitizeArticleRichHtml(value) {
|
|
|
649
949
|
element.parent().attr("style") || ""
|
|
650
950
|
);
|
|
651
951
|
const src = safeHttpUrl(originalAttributes["data-qcplay-src"] || originalAttributes.src);
|
|
652
|
-
const alt =
|
|
952
|
+
const alt = meaningfulImageAlt(originalAttributes.alt);
|
|
653
953
|
if (!src) {
|
|
654
954
|
element.replaceWith(alt ? $("<span></span>").text(alt) : "");
|
|
655
955
|
continue;
|
|
@@ -712,8 +1012,8 @@ export function sanitizeArticleRichHtml(value) {
|
|
|
712
1012
|
continue;
|
|
713
1013
|
}
|
|
714
1014
|
|
|
715
|
-
if (tag === "font" &&
|
|
716
|
-
element.attr("color",
|
|
1015
|
+
if (tag === "font" && attributeColor) {
|
|
1016
|
+
element.attr("color", attributeColor);
|
|
717
1017
|
}
|
|
718
1018
|
if ((tag === "td" || tag === "th") && /^\d+$/.test(originalAttributes.colspan || "")) {
|
|
719
1019
|
element.attr("colspan", originalAttributes.colspan);
|
|
@@ -731,7 +1031,8 @@ export function sanitizeArticleRichHtml(value) {
|
|
|
731
1031
|
return "";
|
|
732
1032
|
}
|
|
733
1033
|
return (
|
|
734
|
-
|
|
1034
|
+
`<section data-qcplay-rich-root="1" data-qcplay-rich-max-width="${maxWidth}" ` +
|
|
1035
|
+
`style="box-sizing:border-box;width:100%;max-width:${maxWidth}px;min-width:0;` +
|
|
735
1036
|
'margin:0 auto;overflow:hidden;isolation:isolate;overflow-wrap:anywhere;word-break:break-word;">' +
|
|
736
1037
|
`${innerHtml}</section>`
|
|
737
1038
|
);
|
|
@@ -849,7 +1150,7 @@ function markdownForNode($, node, listDepth = 0, inheritedColor = "", context) {
|
|
|
849
1150
|
const description = elementDescription(element);
|
|
850
1151
|
return description ? `\n\n${escapeMarkdownText(description)}\n\n` : "";
|
|
851
1152
|
}
|
|
852
|
-
const alt = escapeMarkdownText(element.attr("alt")
|
|
1153
|
+
const alt = escapeMarkdownText(meaningfulImageAlt(element.attr("alt")));
|
|
853
1154
|
return `\n\n\n\n`;
|
|
854
1155
|
}
|
|
855
1156
|
if (tag === "strong" || tag === "b") {
|
|
@@ -988,8 +1289,31 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
988
1289
|
if (!title) {
|
|
989
1290
|
throw new Error("微信页面中没有找到文章标题");
|
|
990
1291
|
}
|
|
1292
|
+
const author = $("#js_name").first().text().trim() || $('meta[name="author"]').attr("content")?.trim() || "";
|
|
991
1293
|
|
|
992
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
|
+
}
|
|
993
1317
|
const sourceTextNodes = collectMeaningfulTextNodes(content.get(0));
|
|
994
1318
|
const imageElements = content.find("img").toArray();
|
|
995
1319
|
const sourceImages = imageElements.map(element => {
|
|
@@ -1002,22 +1326,42 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1002
1326
|
.map(element => ({ element, sources: backgroundImageSources($(element)) }))
|
|
1003
1327
|
.filter(entry => entry.sources.length > 0);
|
|
1004
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))));
|
|
1005
1331
|
const coverSource = $('meta[property="og:image"]').attr("content") || "";
|
|
1006
1332
|
const allSources = [coverSource, ...sourceImages, ...backgroundSources].filter(Boolean);
|
|
1007
1333
|
const uploaded = new Map();
|
|
1334
|
+
const ignored = new Set();
|
|
1008
1335
|
const downloadImage = options.downloadImage || downloadWechatImage;
|
|
1009
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;
|
|
1010
1341
|
|
|
1011
1342
|
for (const rawSource of allSources) {
|
|
1012
1343
|
const imageUrl = assertWechatImageUrl(rawSource, sourceUrl);
|
|
1013
1344
|
const key = imageUrl.toString();
|
|
1014
|
-
if (uploaded.has(key)) {
|
|
1345
|
+
if (uploaded.has(key) || ignored.has(key)) {
|
|
1015
1346
|
continue;
|
|
1016
1347
|
}
|
|
1017
|
-
const current =
|
|
1018
|
-
options.onProgress?.({ current, total:
|
|
1348
|
+
const current = ++processedSourceCount;
|
|
1349
|
+
options.onProgress?.({ mediaType: "image", current, total: uniqueSourceCount, sourceUrl: key });
|
|
1019
1350
|
try {
|
|
1020
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
|
+
}
|
|
1021
1365
|
uploaded.set(key, await uploadImage(image));
|
|
1022
1366
|
} catch (error) {
|
|
1023
1367
|
throw new Error(`第 ${current} 张图片处理失败: ${error.message}`);
|
|
@@ -1030,6 +1374,10 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1030
1374
|
return;
|
|
1031
1375
|
}
|
|
1032
1376
|
const key = assertWechatImageUrl(rawSource, sourceUrl).toString();
|
|
1377
|
+
if (ignored.has(key)) {
|
|
1378
|
+
$(element).remove();
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1033
1381
|
$(element).attr("data-qcplay-src", uploaded.get(key));
|
|
1034
1382
|
});
|
|
1035
1383
|
|
|
@@ -1038,9 +1386,30 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1038
1386
|
const key = assertWechatImageUrl(rawSource, sourceUrl).toString();
|
|
1039
1387
|
return uploaded.get(key);
|
|
1040
1388
|
});
|
|
1389
|
+
$(entry.element).attr("data-qcplay-background-processed", "1");
|
|
1041
1390
|
$(entry.element).attr("data-qcplay-background-srcs", urls.filter(Boolean).join("|"));
|
|
1042
1391
|
});
|
|
1043
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
|
+
|
|
1044
1413
|
const richHtml = sanitizeArticleRichHtml(content.html());
|
|
1045
1414
|
if (!richHtml) {
|
|
1046
1415
|
throw new Error("微信文章富文本转换后为空");
|
|
@@ -1058,7 +1427,9 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1058
1427
|
.join(" / ");
|
|
1059
1428
|
throw new Error(`微信文章有 ${missingRichTextNodes.length} 处正文未保留原始格式: ${preview}`);
|
|
1060
1429
|
}
|
|
1061
|
-
const sourceImageCount = sourceImages.filter(
|
|
1430
|
+
const sourceImageCount = sourceImages.filter(rawSource => {
|
|
1431
|
+
return rawSource && !ignored.has(assertWechatImageUrl(rawSource, sourceUrl).toString());
|
|
1432
|
+
}).length;
|
|
1062
1433
|
const richImageCount = richDocument("img").length;
|
|
1063
1434
|
if (richImageCount !== sourceImageCount) {
|
|
1064
1435
|
throw new Error(`微信文章图片结构不完整: 原文 ${sourceImageCount} 张,转换后 ${richImageCount} 张`);
|
|
@@ -1093,7 +1464,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1093
1464
|
|
|
1094
1465
|
return {
|
|
1095
1466
|
title,
|
|
1096
|
-
author
|
|
1467
|
+
author,
|
|
1097
1468
|
releaseDate: releaseDateFromWechatPage($, html),
|
|
1098
1469
|
excerpt: $('meta[property="og:description"]').attr("content")?.trim() || "",
|
|
1099
1470
|
thumbnail: coverSource ? uploaded.get(assertWechatImageUrl(coverSource, sourceUrl).toString()) || "" : uploaded.values().next().value || "",
|
|
@@ -1101,6 +1472,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1101
1472
|
richHtml,
|
|
1102
1473
|
richImageCount,
|
|
1103
1474
|
imageCount: uploaded.size,
|
|
1475
|
+
videoCount: uploadedVideos.size,
|
|
1104
1476
|
backgroundImageCount: backgroundSources.length,
|
|
1105
1477
|
colorCount: markdownContext.colorCount,
|
|
1106
1478
|
colors: [...markdownContext.colors],
|
|
@@ -1108,7 +1480,9 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
|
|
|
1108
1480
|
specialElementCount: markdownContext.specialElementCount,
|
|
1109
1481
|
tableCount: markdownContext.tableCount,
|
|
1110
1482
|
textSegmentCount: sourceTextNodes.length,
|
|
1111
|
-
sourceUrl: sourceUrl.toString()
|
|
1483
|
+
sourceUrl: sourceUrl.toString(),
|
|
1484
|
+
matchedContentRules,
|
|
1485
|
+
appliedContentRules: [...appliedContentRules]
|
|
1112
1486
|
};
|
|
1113
1487
|
}
|
|
1114
1488
|
|
|
@@ -1116,6 +1490,17 @@ function yamlString(value) {
|
|
|
1116
1490
|
return JSON.stringify(String(value || ""));
|
|
1117
1491
|
}
|
|
1118
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
|
+
|
|
1119
1504
|
export function buildOfficialArticleMarkdown(article) {
|
|
1120
1505
|
const articleBody = article.richHtml
|
|
1121
1506
|
? `${RICH_HTML_START}\n${article.richHtml}\n${RICH_HTML_END}`
|
|
@@ -1135,10 +1520,11 @@ is_index: "0"
|
|
|
1135
1520
|
release_time: ${yamlString(article.releaseDate)}
|
|
1136
1521
|
area: "1"
|
|
1137
1522
|
sort: "100"
|
|
1138
|
-
game_id:
|
|
1523
|
+
game_id: ${yamlString(article.gameId || gameIdForWechatArticle(article))}
|
|
1139
1524
|
index_pc_img: ""
|
|
1140
1525
|
index_move_img: ""
|
|
1141
1526
|
source_url: ${yamlString(article.sourceUrl)}
|
|
1527
|
+
distribution_project: ${yamlString(article.distributionProject || distributionProjectForWechatArticle(article))}
|
|
1142
1528
|
---
|
|
1143
1529
|
|
|
1144
1530
|
${articleBody}
|