@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.
@@ -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 IMAGE_UPLOAD_URL = "http://api.qingcigame.com/novel/time/literature/avatar";
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 = hostname === "mmbiz.qpic.cn" || hostname.endsWith(".mmbiz.qpic.cn");
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 uploadOfficialImage(image, uploadUrl = IMAGE_UPLOAD_URL) {
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 = extensionForImage(image.contentType, image.sourceUrl);
356
- const filename = `wechat-${Date.now()}-${randomBytes(5).toString("hex")}${extension}`;
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: ${image.contentType}\r\n\r\n`
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, image.buffer, footer]);
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(`图片上传接口返回了非 JSON 内容: ${response.body.toString("utf8").slice(0, 160)}`);
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 || `图片上传失败,接口返回 code=${result.code ?? "未知"}`);
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 = ["href", "src", "data-src", "data-url", "data-link", "data-video-url", "data-audio-url"];
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
- export function sanitizeArticleRichHtml(value) {
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 = transferredBackgrounds.length > 0 ? transferredBackgrounds : backgroundImageSources(element);
635
- const sanitizedStyle = sanitizeInlineStyle(originalAttributes.style || originalAttributes["data-style"], backgroundUrls);
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 = normalizeTextContent(originalAttributes.alt || "图片").trim();
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" && normalizeArticleColor(originalAttributes.color)) {
716
- element.attr("color", normalizeArticleColor(originalAttributes.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
- '<section data-qcplay-rich-root="1" style="box-sizing:border-box;width:100%;max-width:677px;min-width:0;' +
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![${alt}](${src})\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 = uploaded.size + 1;
1018
- options.onProgress?.({ current, total: new Set(allSources).size, sourceUrl: key });
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(Boolean).length;
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: $("#js_name").first().text().trim() || $('meta[name="author"]').attr("content")?.trim() || "",
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: "39"
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}