@qcplay/cli 1.0.14 → 1.0.16

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 = "https://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",
@@ -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 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) {
359
430
  const target = new URL(uploadUrl);
360
- const extension = extensionForImage(image.contentType, image.sourceUrl);
361
- 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}`;
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: ${image.contentType}\r\n\r\n`
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, image.buffer, footer]);
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(`图片上传接口返回了非 JSON 内容: ${response.body.toString("utf8").slice(0, 160)}`);
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 || `图片上传失败,接口返回 code=${result.code ?? "未知"}`);
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 = ["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
+ ];
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 = transferredBackgrounds.length > 0 ? transferredBackgrounds : backgroundImageSources(element);
645
- 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
+ }
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 = normalizeTextContent(originalAttributes.alt || "图片").trim();
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" && normalizeArticleColor(originalAttributes.color)) {
726
- element.attr("color", normalizeArticleColor(originalAttributes.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![${alt}](${src})\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 = uploaded.size + 1;
1029
- options.onProgress?.({ current, total: new Set(allSources).size, sourceUrl: key });
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,38 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1049
1386
  const key = assertWechatImageUrl(rawSource, sourceUrl).toString();
1050
1387
  return uploaded.get(key);
1051
1388
  });
1389
+ const retainedSources = entry.sources.filter(rawSource => {
1390
+ const key = assertWechatImageUrl(rawSource, sourceUrl).toString();
1391
+ return !ignored.has(key);
1392
+ });
1393
+ if (retainedSources.length === 0) {
1394
+ $(entry.element).css("background-image", "none");
1395
+ return;
1396
+ }
1397
+ $(entry.element).attr("data-qcplay-background-processed", "1");
1052
1398
  $(entry.element).attr("data-qcplay-background-srcs", urls.filter(Boolean).join("|"));
1053
1399
  });
1054
1400
 
1401
+ const uploadedVideos = new Map();
1402
+ const uniqueVideoSources = [...new Set(videoSources.filter(Boolean))];
1403
+ for (const rawSource of uniqueVideoSources) {
1404
+ const current = uploadedVideos.size + 1;
1405
+ options.onProgress?.({ mediaType: "video", current, total: uniqueVideoSources.length, sourceUrl: rawSource });
1406
+ try {
1407
+ const video = await downloadVideo(rawSource);
1408
+ uploadedVideos.set(rawSource, await uploadVideo(video));
1409
+ } catch (error) {
1410
+ throw new Error(`第 ${current} 个视频处理失败: ${error.message}`);
1411
+ }
1412
+ }
1413
+
1414
+ videoElements.forEach((element, index) => {
1415
+ const source = videoSources[index];
1416
+ if (source) {
1417
+ $(element).attr("data-qcplay-media-src", uploadedVideos.get(source));
1418
+ }
1419
+ });
1420
+
1055
1421
  const richHtml = sanitizeArticleRichHtml(content.html());
1056
1422
  if (!richHtml) {
1057
1423
  throw new Error("微信文章富文本转换后为空");
@@ -1069,7 +1435,9 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1069
1435
  .join(" / ");
1070
1436
  throw new Error(`微信文章有 ${missingRichTextNodes.length} 处正文未保留原始格式: ${preview}`);
1071
1437
  }
1072
- const sourceImageCount = sourceImages.filter(Boolean).length;
1438
+ const sourceImageCount = sourceImages.filter(rawSource => {
1439
+ return rawSource && !ignored.has(assertWechatImageUrl(rawSource, sourceUrl).toString());
1440
+ }).length;
1073
1441
  const richImageCount = richDocument("img").length;
1074
1442
  if (richImageCount !== sourceImageCount) {
1075
1443
  throw new Error(`微信文章图片结构不完整: 原文 ${sourceImageCount} 张,转换后 ${richImageCount} 张`);
@@ -1104,7 +1472,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1104
1472
 
1105
1473
  return {
1106
1474
  title,
1107
- author: $("#js_name").first().text().trim() || $('meta[name="author"]').attr("content")?.trim() || "",
1475
+ author,
1108
1476
  releaseDate: releaseDateFromWechatPage($, html),
1109
1477
  excerpt: $('meta[property="og:description"]').attr("content")?.trim() || "",
1110
1478
  thumbnail: coverSource ? uploaded.get(assertWechatImageUrl(coverSource, sourceUrl).toString()) || "" : uploaded.values().next().value || "",
@@ -1112,6 +1480,7 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1112
1480
  richHtml,
1113
1481
  richImageCount,
1114
1482
  imageCount: uploaded.size,
1483
+ videoCount: uploadedVideos.size,
1115
1484
  backgroundImageCount: backgroundSources.length,
1116
1485
  colorCount: markdownContext.colorCount,
1117
1486
  colors: [...markdownContext.colors],
@@ -1119,7 +1488,9 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
1119
1488
  specialElementCount: markdownContext.specialElementCount,
1120
1489
  tableCount: markdownContext.tableCount,
1121
1490
  textSegmentCount: sourceTextNodes.length,
1122
- sourceUrl: sourceUrl.toString()
1491
+ sourceUrl: sourceUrl.toString(),
1492
+ matchedContentRules,
1493
+ appliedContentRules: [...appliedContentRules]
1123
1494
  };
1124
1495
  }
1125
1496
 
@@ -1127,6 +1498,17 @@ function yamlString(value) {
1127
1498
  return JSON.stringify(String(value || ""));
1128
1499
  }
1129
1500
 
1501
+ function distributionProjectForWechatArticle(article) {
1502
+ const author = String(article.author || "").trim();
1503
+ if (isAresWechatAuthor(author)) return "阿瑞斯病毒2";
1504
+ return "";
1505
+ }
1506
+
1507
+ function gameIdForWechatArticle(article) {
1508
+ const author = String(article.author || "").trim();
1509
+ return isAresWechatAuthor(author) ? "41" : "39";
1510
+ }
1511
+
1130
1512
  export function buildOfficialArticleMarkdown(article) {
1131
1513
  const articleBody = article.richHtml
1132
1514
  ? `${RICH_HTML_START}\n${article.richHtml}\n${RICH_HTML_END}`
@@ -1146,10 +1528,11 @@ is_index: "0"
1146
1528
  release_time: ${yamlString(article.releaseDate)}
1147
1529
  area: "1"
1148
1530
  sort: "100"
1149
- game_id: "39"
1531
+ game_id: ${yamlString(article.gameId || gameIdForWechatArticle(article))}
1150
1532
  index_pc_img: ""
1151
1533
  index_move_img: ""
1152
1534
  source_url: ${yamlString(article.sourceUrl)}
1535
+ distribution_project: ${yamlString(article.distributionProject || distributionProjectForWechatArticle(article))}
1153
1536
  ---
1154
1537
 
1155
1538
  ${articleBody}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qcplay/cli",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
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
  },