@qcplay/cli 1.0.10 → 1.0.12

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.
@@ -11,6 +11,8 @@ const IMAGE_UPLOAD_URL = "http://api.qingcigame.com/novel/time/literature/avatar
11
11
  const MAX_REDIRECTS = 5;
12
12
  const MAX_HTML_BYTES = 8 * 1024 * 1024;
13
13
  const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
14
+ const RICH_HTML_START = "<!-- qcplay-rich-html:start -->";
15
+ const RICH_HTML_END = "<!-- qcplay-rich-html:end -->";
14
16
 
15
17
  const BLOCK_TAGS = new Set([
16
18
  "article",
@@ -25,6 +27,175 @@ const BLOCK_TAGS = new Set([
25
27
  "section"
26
28
  ]);
27
29
 
30
+ const SAFE_RICH_TAGS = new Set([
31
+ "a",
32
+ "article",
33
+ "aside",
34
+ "b",
35
+ "blockquote",
36
+ "br",
37
+ "del",
38
+ "div",
39
+ "em",
40
+ "figcaption",
41
+ "figure",
42
+ "font",
43
+ "footer",
44
+ "h1",
45
+ "h2",
46
+ "h3",
47
+ "h4",
48
+ "h5",
49
+ "h6",
50
+ "header",
51
+ "hr",
52
+ "i",
53
+ "img",
54
+ "li",
55
+ "main",
56
+ "nav",
57
+ "ol",
58
+ "p",
59
+ "s",
60
+ "section",
61
+ "small",
62
+ "span",
63
+ "strong",
64
+ "sub",
65
+ "sup",
66
+ "table",
67
+ "tbody",
68
+ "td",
69
+ "tfoot",
70
+ "th",
71
+ "thead",
72
+ "tr",
73
+ "u",
74
+ "ul"
75
+ ]);
76
+
77
+ const SAFE_STYLE_PROPERTIES = new Set([
78
+ "align-content",
79
+ "align-items",
80
+ "align-self",
81
+ "background",
82
+ "background-color",
83
+ "background-position",
84
+ "background-repeat",
85
+ "background-size",
86
+ "border",
87
+ "border-bottom",
88
+ "border-bottom-color",
89
+ "border-bottom-left-radius",
90
+ "border-bottom-right-radius",
91
+ "border-bottom-style",
92
+ "border-bottom-width",
93
+ "border-color",
94
+ "border-left",
95
+ "border-left-color",
96
+ "border-left-style",
97
+ "border-left-width",
98
+ "border-radius",
99
+ "border-right",
100
+ "border-right-color",
101
+ "border-right-style",
102
+ "border-right-width",
103
+ "border-style",
104
+ "border-top",
105
+ "border-top-color",
106
+ "border-top-left-radius",
107
+ "border-top-right-radius",
108
+ "border-top-style",
109
+ "border-top-width",
110
+ "border-width",
111
+ "bottom",
112
+ "box-shadow",
113
+ "box-sizing",
114
+ "clear",
115
+ "color",
116
+ "column-gap",
117
+ "display",
118
+ "filter",
119
+ "flex",
120
+ "flex-basis",
121
+ "flex-direction",
122
+ "flex-grow",
123
+ "flex-shrink",
124
+ "flex-wrap",
125
+ "float",
126
+ "font-family",
127
+ "font-size",
128
+ "font-style",
129
+ "font-variant",
130
+ "font-weight",
131
+ "gap",
132
+ "grid-auto-columns",
133
+ "grid-auto-flow",
134
+ "grid-auto-rows",
135
+ "grid-column",
136
+ "grid-row",
137
+ "grid-template-columns",
138
+ "grid-template-rows",
139
+ "height",
140
+ "justify-content",
141
+ "justify-items",
142
+ "justify-self",
143
+ "left",
144
+ "letter-spacing",
145
+ "line-height",
146
+ "list-style",
147
+ "list-style-position",
148
+ "list-style-type",
149
+ "margin",
150
+ "margin-bottom",
151
+ "margin-left",
152
+ "margin-right",
153
+ "margin-top",
154
+ "max-height",
155
+ "max-width",
156
+ "min-height",
157
+ "min-width",
158
+ "object-fit",
159
+ "object-position",
160
+ "opacity",
161
+ "order",
162
+ "overflow",
163
+ "overflow-wrap",
164
+ "overflow-x",
165
+ "overflow-y",
166
+ "padding",
167
+ "padding-bottom",
168
+ "padding-left",
169
+ "padding-right",
170
+ "padding-top",
171
+ "position",
172
+ "right",
173
+ "row-gap",
174
+ "table-layout",
175
+ "text-align",
176
+ "text-decoration",
177
+ "text-decoration-color",
178
+ "text-decoration-line",
179
+ "text-decoration-style",
180
+ "text-indent",
181
+ "text-overflow",
182
+ "text-transform",
183
+ "top",
184
+ "transform",
185
+ "transform-origin",
186
+ "vertical-align",
187
+ "visibility",
188
+ "white-space",
189
+ "width",
190
+ "word-break",
191
+ "word-spacing",
192
+ "z-index",
193
+ "-webkit-text-fill-color",
194
+ "-webkit-text-stroke",
195
+ "-webkit-text-stroke-color",
196
+ "-webkit-text-stroke-width"
197
+ ]);
198
+
28
199
  function requestBuffer(target, options = {}, redirectCount = 0) {
29
200
  const url = target instanceof URL ? target : new URL(target);
30
201
  const client = url.protocol === "https:" ? https : url.protocol === "http:" ? http : null;
@@ -287,9 +458,353 @@ function elementTextColor(element) {
287
458
  );
288
459
  }
289
460
 
290
- function markdownForNode($, node, listDepth = 0, inheritedColor = "", context = { colorCount: 0, colors: new Set() }) {
461
+ function safeContentUrl(element) {
462
+ const attributes = ["href", "src", "data-src", "data-url", "data-link", "data-video-url", "data-audio-url"];
463
+ const candidates = [];
464
+ for (const attribute of attributes) {
465
+ candidates.push(element.attr(attribute));
466
+ }
467
+ element.find("source").each((_, source) => {
468
+ candidates.push(source.attribs?.src, source.attribs?.["data-src"]);
469
+ });
470
+
471
+ for (const candidate of candidates.filter(Boolean)) {
472
+ try {
473
+ const url = new URL(candidate);
474
+ if (url.protocol === "http:" || url.protocol === "https:") {
475
+ return url.toString();
476
+ }
477
+ } catch {}
478
+ }
479
+ return "";
480
+ }
481
+
482
+ function elementDescription(element) {
483
+ const attributes = [
484
+ "title",
485
+ "aria-label",
486
+ "alt",
487
+ "value",
488
+ "placeholder",
489
+ "data-title",
490
+ "data-name",
491
+ "data-desc",
492
+ "data-description",
493
+ "data-content",
494
+ "data-text"
495
+ ];
496
+ for (const attribute of attributes) {
497
+ const value = normalizeTextContent(element.attr(attribute)).trim();
498
+ if (value) {
499
+ return value;
500
+ }
501
+ }
502
+ return "";
503
+ }
504
+
505
+ function backgroundImageSources(element) {
506
+ const sources = [
507
+ element.attr("data-background-src"),
508
+ element.attr("data-background"),
509
+ element.attr("data-bg")
510
+ ].filter(Boolean);
511
+ const style = [element.attr("style"), element.attr("data-style")].filter(Boolean).join(";");
512
+ if (/background/i.test(style)) {
513
+ for (const match of style.matchAll(/url\(\s*(['"]?)(https?:\/\/.*?)\1\s*\)/gi)) {
514
+ sources.push(match[2]);
515
+ }
516
+ }
517
+ return [...new Set(sources)];
518
+ }
519
+
520
+ function safeHttpUrl(value) {
521
+ try {
522
+ const url = new URL(String(value || ""));
523
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : "";
524
+ } catch {
525
+ return "";
526
+ }
527
+ }
528
+
529
+ function sanitizeInlineStyle(value, backgroundUrls = []) {
530
+ const declarations = [];
531
+ for (const declaration of String(value || "").split(";")) {
532
+ const divider = declaration.indexOf(":");
533
+ if (divider < 1) {
534
+ continue;
535
+ }
536
+ const property = declaration.slice(0, divider).trim().toLowerCase();
537
+ let propertyValue = declaration.slice(divider + 1).trim();
538
+ if (!SAFE_STYLE_PROPERTIES.has(property) || !propertyValue) {
539
+ continue;
540
+ }
541
+ if (/[<>{}]|(?:expression|javascript|vbscript|@import|behavior|-moz-binding)\s*[:(]?/i.test(propertyValue)) {
542
+ continue;
543
+ }
544
+ if (/url\s*\(/i.test(propertyValue)) {
545
+ continue;
546
+ }
547
+ if (property === "position" && /^(?:fixed|sticky)(?:\s*!important)?$/i.test(propertyValue)) {
548
+ propertyValue = "relative";
549
+ }
550
+ if (property === "z-index") {
551
+ const numericValue = Number.parseInt(propertyValue, 10);
552
+ if (!Number.isFinite(numericValue)) {
553
+ continue;
554
+ }
555
+ propertyValue = String(Math.max(-10, Math.min(100, numericValue)));
556
+ }
557
+ declarations.push(`${property}:${propertyValue}`);
558
+ }
559
+
560
+ const safeBackgrounds = backgroundUrls.map(safeHttpUrl).filter(Boolean);
561
+ if (safeBackgrounds.length > 0) {
562
+ const cssUrls = safeBackgrounds.map(url => `url("${url.replace(/["\\]/g, "\\$&")}")`).join(",");
563
+ declarations.push(`background-image:${cssUrls}`);
564
+ }
565
+ return declarations.join(";");
566
+ }
567
+
568
+ function mediaReplacementHtml(element) {
569
+ const link = safeContentUrl(element);
570
+ const tag = String(element.get(0)?.tagName || "").toLowerCase();
571
+ const fallback = tag.includes("audio") || tag === "mpvoice" ? "音频内容" : "视频内容";
572
+ const label = normalizeTextContent(element.text()).trim() || elementDescription(element) || fallback;
573
+ if (!link) {
574
+ return `<p>${cheerio.load("<span></span>", null, false)("span").text(label).toString()}</p>`;
575
+ }
576
+ const fragment = cheerio.load("<p><a></a></p>", null, false);
577
+ fragment("a").attr({ href: link, target: "_blank", rel: "noreferrer" }).text(label);
578
+ return fragment.root().html();
579
+ }
580
+
581
+ function isStandaloneImage(element) {
582
+ const node = element.get(0);
583
+ const parent = element.parent();
584
+ if (!node || !parent.length) {
585
+ return false;
586
+ }
587
+ return parent
588
+ .contents()
589
+ .toArray()
590
+ .filter(child => child !== node)
591
+ .every(child => {
592
+ if (child.type === "text") {
593
+ return !normalizeTextContent(child.data).trim();
594
+ }
595
+ const tag = String(child.tagName || child.name || "").toLowerCase();
596
+ return tag === "br";
597
+ });
598
+ }
599
+
600
+ export function sanitizeArticleRichHtml(value) {
601
+ const initial = cheerio.load(String(value || ""), null, false);
602
+ const existingRoot = initial('[data-qcplay-rich-root="1"]').first();
603
+ const source = existingRoot.length ? existingRoot.html() || "" : initial.root().html() || "";
604
+ const $ = cheerio.load(source, null, false);
605
+
606
+ $("script,style,object,embed,link,meta,base").remove();
607
+ $("audio,video,iframe,mp-common-videosnap,mpvoice,qqmusic").each((_, node) => {
608
+ $(node).replaceWith(mediaReplacementHtml($(node)));
609
+ });
610
+ $("*")
611
+ .contents()
612
+ .filter((_, node) => node.type === "comment")
613
+ .remove();
614
+
615
+ const elements = $("*").toArray().reverse();
616
+ for (const node of elements) {
617
+ const element = $(node);
618
+ const tag = String(node.tagName || node.name || "").toLowerCase();
619
+ if (!SAFE_RICH_TAGS.has(tag)) {
620
+ const contents = element.contents();
621
+ if (contents.length > 0) {
622
+ element.replaceWith(contents);
623
+ } else {
624
+ const description = elementDescription(element);
625
+ element.replaceWith(description ? $("<span></span>").text(description) : "");
626
+ }
627
+ continue;
628
+ }
629
+
630
+ const originalAttributes = { ...(node.attribs || {}) };
631
+ const transferredBackgrounds = String(originalAttributes["data-qcplay-background-srcs"] || "")
632
+ .split("|")
633
+ .filter(Boolean);
634
+ const backgroundUrls = transferredBackgrounds.length > 0 ? transferredBackgrounds : backgroundImageSources(element);
635
+ const sanitizedStyle = sanitizeInlineStyle(originalAttributes.style || originalAttributes["data-style"], backgroundUrls);
636
+ for (const attribute of Object.keys(originalAttributes)) {
637
+ element.removeAttr(attribute);
638
+ }
639
+ if (sanitizedStyle) {
640
+ element.attr("style", sanitizedStyle);
641
+ }
642
+ if (originalAttributes.title) {
643
+ element.attr("title", normalizeTextContent(originalAttributes.title).trim());
644
+ }
645
+
646
+ if (tag === "img") {
647
+ const standalone = isStandaloneImage(element);
648
+ const centeredParent = /(?:^|;)\s*text-align\s*:\s*center(?:\s*!important)?\s*(?:;|$)/i.test(
649
+ element.parent().attr("style") || ""
650
+ );
651
+ const src = safeHttpUrl(originalAttributes["data-qcplay-src"] || originalAttributes.src);
652
+ const alt = normalizeTextContent(originalAttributes.alt || "图片").trim();
653
+ if (!src) {
654
+ element.replaceWith(alt ? $("<span></span>").text(alt) : "");
655
+ continue;
656
+ }
657
+ element.attr("src", src).attr("alt", alt);
658
+ if (/^\d+(?:\.\d+)?$/.test(originalAttributes.width || "")) {
659
+ element.attr("width", originalAttributes.width);
660
+ }
661
+ if (/^\d+(?:\.\d+)?$/.test(originalAttributes.height || "")) {
662
+ element.attr("height", originalAttributes.height);
663
+ }
664
+ const responsiveStyle = sanitizedStyle
665
+ .split(";")
666
+ .filter(declaration => !/^(?:max-width|height|box-sizing|display):/i.test(declaration))
667
+ .join(";");
668
+ const imageStyle = [
669
+ responsiveStyle,
670
+ standalone ? "display:block!important" : "",
671
+ standalone && centeredParent ? "margin-left:auto!important" : "",
672
+ standalone && centeredParent ? "margin-right:auto!important" : "",
673
+ "max-width:100%!important",
674
+ "height:auto!important",
675
+ "box-sizing:border-box"
676
+ ]
677
+ .filter(Boolean)
678
+ .join(";");
679
+ element.attr("style", imageStyle);
680
+ continue;
681
+ }
682
+
683
+ if (tag === "a") {
684
+ const href = safeHttpUrl(originalAttributes.href || originalAttributes["data-link"] || originalAttributes["data-url"]);
685
+ if (href) {
686
+ element.attr({ href, target: "_blank", rel: "noreferrer" });
687
+ } else {
688
+ element.replaceWith(element.contents());
689
+ }
690
+ continue;
691
+ }
692
+
693
+ if (tag === "table") {
694
+ const tableStyle = sanitizedStyle
695
+ .split(";")
696
+ .filter(declaration => !/^(?:display|max-width|overflow-x):/i.test(declaration))
697
+ .concat(["display:block", "max-width:100%", "overflow-x:auto"])
698
+ .join(";");
699
+ element.attr("style", tableStyle);
700
+ }
701
+
702
+ if (/^h[1-6]$/.test(tag) && !normalizeTextContent(element.text()).trim() && element.find("img").length > 0) {
703
+ const replacement = $("<section></section>");
704
+ if (element.attr("style")) {
705
+ replacement.attr("style", element.attr("style"));
706
+ }
707
+ if (element.attr("title")) {
708
+ replacement.attr("title", element.attr("title"));
709
+ }
710
+ replacement.append(element.contents());
711
+ element.replaceWith(replacement);
712
+ continue;
713
+ }
714
+
715
+ if (tag === "font" && normalizeArticleColor(originalAttributes.color)) {
716
+ element.attr("color", normalizeArticleColor(originalAttributes.color));
717
+ }
718
+ if ((tag === "td" || tag === "th") && /^\d+$/.test(originalAttributes.colspan || "")) {
719
+ element.attr("colspan", originalAttributes.colspan);
720
+ }
721
+ if ((tag === "td" || tag === "th") && /^\d+$/.test(originalAttributes.rowspan || "")) {
722
+ element.attr("rowspan", originalAttributes.rowspan);
723
+ }
724
+ if (tag === "ol" && /^\d+$/.test(originalAttributes.start || "")) {
725
+ element.attr("start", originalAttributes.start);
726
+ }
727
+ }
728
+
729
+ const innerHtml = $.root().html().trim();
730
+ if (!innerHtml) {
731
+ return "";
732
+ }
733
+ return (
734
+ '<section data-qcplay-rich-root="1" style="box-sizing:border-box;width:100%;max-width:677px;min-width:0;' +
735
+ 'margin:0 auto;overflow:hidden;isolation:isolate;overflow-wrap:anywhere;word-break:break-word;">' +
736
+ `${innerHtml}</section>`
737
+ );
738
+ }
739
+
740
+ function collectMeaningfulTextNodes(node, bucket = []) {
741
+ if (node.type === "text") {
742
+ if (normalizeTextContent(node.data).trim()) {
743
+ bucket.push(node);
744
+ }
745
+ return bucket;
746
+ }
747
+ const tag = String(node.tagName || node.name || "").toLowerCase();
748
+ if (tag === "script" || tag === "style") {
749
+ return bucket;
750
+ }
751
+ for (const child of node.children || []) {
752
+ collectMeaningfulTextNodes(child, bucket);
753
+ }
754
+ return bucket;
755
+ }
756
+
757
+ function escapeTableCell(value) {
758
+ return String(value || "")
759
+ .replace(/\|/g, "\\|")
760
+ .replace(/\n+/g, " / ")
761
+ .trim();
762
+ }
763
+
764
+ function markdownForTable($, element, listDepth, textColor, context) {
765
+ const tableNode = element.get(0);
766
+ const rows = element
767
+ .find("tr")
768
+ .toArray()
769
+ .filter(row => $(row).closest("table").get(0) === tableNode)
770
+ .map(row =>
771
+ $(row)
772
+ .children("th,td")
773
+ .toArray()
774
+ .map(cell => escapeTableCell(markdownForNode($, cell, listDepth, textColor, context)))
775
+ )
776
+ .filter(row => row.length > 0);
777
+ if (rows.length === 0) {
778
+ return "";
779
+ }
780
+
781
+ const columnCount = Math.max(...rows.map(row => row.length));
782
+ const firstRowHasHeader = $(element.find("tr").get(0)).children("th").length > 0;
783
+ const normalizedRows = rows.map(row => [...row, ...Array(columnCount - row.length).fill("")]);
784
+ const header = firstRowHasHeader ? normalizedRows.shift() : Array(columnCount).fill("");
785
+ const lines = [
786
+ `| ${header.join(" | ")} |`,
787
+ `| ${Array(columnCount).fill("---").join(" | ")} |`,
788
+ ...normalizedRows.map(row => `| ${row.join(" | ")} |`)
789
+ ];
790
+ context.tableCount += 1;
791
+ return `\n\n${lines.join("\n")}\n\n`;
792
+ }
793
+
794
+ function markdownForNode($, node, listDepth = 0, inheritedColor = "", context) {
795
+ context ||= {
796
+ capturedTextNodes: new Set(),
797
+ colorCount: 0,
798
+ colors: new Set(),
799
+ mediaCount: 0,
800
+ specialElementCount: 0,
801
+ tableCount: 0
802
+ };
291
803
  if (node.type === "text") {
292
804
  const value = escapeMarkdownText(node.data);
805
+ if (value.trim()) {
806
+ context.capturedTextNodes.add(node);
807
+ }
293
808
  if (!inheritedColor || !value.trim()) {
294
809
  return value;
295
810
  }
@@ -297,21 +812,32 @@ function markdownForNode($, node, listDepth = 0, inheritedColor = "", context =
297
812
  context.colors.add(inheritedColor);
298
813
  return `{{qc-color:${inheritedColor}}}${value}{{/qc-color}}`;
299
814
  }
300
- if (node.type !== "tag") {
815
+ if (node.type === "script" || node.type === "style" || node.type === "comment") {
301
816
  return "";
302
817
  }
818
+ if (node.type !== "tag") {
819
+ return (node.children || [])
820
+ .map(child => markdownForNode($, child, listDepth, inheritedColor, context))
821
+ .join("");
822
+ }
303
823
 
304
824
  const element = $(node);
305
825
  const tag = String(node.tagName || node.name || "").toLowerCase();
306
826
  const textColor = elementTextColor(element) || inheritedColor;
827
+ const backgroundMarkdown = String(element.attr("data-qcplay-background-srcs") || "")
828
+ .split("|")
829
+ .filter(Boolean)
830
+ .map(src => `\n\n![背景图片](${src})\n\n`)
831
+ .join("");
307
832
  const children = () =>
833
+ backgroundMarkdown +
308
834
  element
309
835
  .contents()
310
836
  .toArray()
311
837
  .map(child => markdownForNode($, child, listDepth, textColor, context))
312
838
  .join("");
313
839
 
314
- if (["script", "style", "noscript", "iframe", "video", "audio", "canvas", "svg", "form", "button", "input"].includes(tag)) {
840
+ if (tag === "script" || tag === "style") {
315
841
  return "";
316
842
  }
317
843
  if (tag === "br") {
@@ -320,7 +846,8 @@ function markdownForNode($, node, listDepth = 0, inheritedColor = "", context =
320
846
  if (tag === "img") {
321
847
  const src = element.attr("data-qcplay-src");
322
848
  if (!src) {
323
- return "";
849
+ const description = elementDescription(element);
850
+ return description ? `\n\n${escapeMarkdownText(description)}\n\n` : "";
324
851
  }
325
852
  const alt = escapeMarkdownText(element.attr("alt") || "图片");
326
853
  return `\n\n![${alt}](${src})\n\n`;
@@ -335,7 +862,7 @@ function markdownForNode($, node, listDepth = 0, inheritedColor = "", context =
335
862
  }
336
863
  if (tag === "a") {
337
864
  const value = children().trim();
338
- const href = element.attr("href");
865
+ const href = safeContentUrl(element);
339
866
  if (!value || !href) {
340
867
  return value;
341
868
  }
@@ -347,6 +874,16 @@ function markdownForNode($, node, listDepth = 0, inheritedColor = "", context =
347
874
  } catch {}
348
875
  return value;
349
876
  }
877
+ if (["audio", "video", "iframe", "mp-common-videosnap", "mpvoice", "qqmusic"].includes(tag)) {
878
+ const value = children().trim();
879
+ const description = escapeMarkdownText(elementDescription(element) || (tag.includes("audio") || tag === "mpvoice" ? "音频内容" : "视频内容"));
880
+ const link = safeContentUrl(element);
881
+ context.mediaCount += 1;
882
+ if (link) {
883
+ return `\n\n[${value || description}](${link})\n\n`;
884
+ }
885
+ return value ? `\n\n${value}\n\n` : `\n\n${description}\n\n`;
886
+ }
350
887
  if (/^h[1-6]$/.test(tag)) {
351
888
  const value = children().trim();
352
889
  return value ? `\n\n${"#".repeat(Number(tag[1]))} ${value}\n\n` : "";
@@ -359,20 +896,40 @@ function markdownForNode($, node, listDepth = 0, inheritedColor = "", context =
359
896
  const value = children().trim();
360
897
  return value ? `\n\n${value.split("\n").map(line => `> ${line}`).join("\n")}\n\n` : "";
361
898
  }
899
+ if (tag === "table") {
900
+ return markdownForTable($, element, listDepth, textColor, context);
901
+ }
362
902
  if (tag === "ul" || tag === "ol") {
363
903
  const ordered = tag === "ol";
364
- const items = element.children("li").toArray().map((item, index) => {
365
- const value = markdownForNode($, item, listDepth + 1, textColor, context).trim();
366
- const prefix = ordered ? `${index + 1}.` : "-";
367
- return `${" ".repeat(listDepth)}${prefix} ${value}`;
368
- });
369
- return items.length ? `\n\n${items.join("\n")}\n\n` : "";
904
+ let itemIndex = 0;
905
+ const values = element
906
+ .contents()
907
+ .toArray()
908
+ .map(child => {
909
+ const childTag = String(child.tagName || child.name || "").toLowerCase();
910
+ if (childTag !== "li") {
911
+ return markdownForNode($, child, listDepth + 1, textColor, context).trim();
912
+ }
913
+ itemIndex += 1;
914
+ const value = markdownForNode($, child, listDepth + 1, textColor, context).trim();
915
+ const prefix = ordered ? `${itemIndex}.` : "-";
916
+ return `${" ".repeat(listDepth)}${prefix} ${value}`;
917
+ })
918
+ .filter(Boolean);
919
+ return values.length ? `\n\n${values.join("\n")}\n\n` : "";
370
920
  }
371
921
  if (tag === "li") {
372
922
  return children();
373
923
  }
374
924
 
925
+ if (tag.includes("-") || tag.startsWith("mp") || tag.startsWith("wx")) {
926
+ context.specialElementCount += 1;
927
+ }
375
928
  const value = children();
929
+ if (!value.trim()) {
930
+ const description = elementDescription(element);
931
+ return description ? `\n\n${escapeMarkdownText(description)}\n\n` : "";
932
+ }
376
933
  return BLOCK_TAGS.has(tag) && value.trim() ? `\n\n${value}\n\n` : value;
377
934
  }
378
935
 
@@ -432,15 +989,21 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
432
989
  throw new Error("微信页面中没有找到文章标题");
433
990
  }
434
991
 
435
- content.find("script,style,noscript,iframe,video,audio,canvas,svg,form,button,input").remove();
436
- content.find('[style*="display:none"],[style*="display: none"],[style*="visibility:hidden"],[style*="visibility: hidden"]').remove();
992
+ content.find("script,style").remove();
993
+ const sourceTextNodes = collectMeaningfulTextNodes(content.get(0));
437
994
  const imageElements = content.find("img").toArray();
438
995
  const sourceImages = imageElements.map(element => {
439
996
  const image = $(element);
440
997
  return image.attr("data-src") || image.attr("data-original") || image.attr("src") || "";
441
998
  });
999
+ const backgroundEntries = content
1000
+ .find("*")
1001
+ .toArray()
1002
+ .map(element => ({ element, sources: backgroundImageSources($(element)) }))
1003
+ .filter(entry => entry.sources.length > 0);
1004
+ const backgroundSources = backgroundEntries.flatMap(entry => entry.sources);
442
1005
  const coverSource = $('meta[property="og:image"]').attr("content") || "";
443
- const allSources = [coverSource, ...sourceImages].filter(Boolean);
1006
+ const allSources = [coverSource, ...sourceImages, ...backgroundSources].filter(Boolean);
444
1007
  const uploaded = new Map();
445
1008
  const downloadImage = options.downloadImage || downloadWechatImage;
446
1009
  const uploadImage = options.uploadImage || uploadOfficialImage;
@@ -464,24 +1027,69 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
464
1027
  imageElements.forEach((element, index) => {
465
1028
  const rawSource = sourceImages[index];
466
1029
  if (!rawSource) {
467
- $(element).remove();
468
1030
  return;
469
1031
  }
470
1032
  const key = assertWechatImageUrl(rawSource, sourceUrl).toString();
471
1033
  $(element).attr("data-qcplay-src", uploaded.get(key));
472
1034
  });
473
1035
 
474
- const colorContext = { colorCount: 0, colors: new Set() };
1036
+ backgroundEntries.forEach(entry => {
1037
+ const urls = entry.sources.map(rawSource => {
1038
+ const key = assertWechatImageUrl(rawSource, sourceUrl).toString();
1039
+ return uploaded.get(key);
1040
+ });
1041
+ $(entry.element).attr("data-qcplay-background-srcs", urls.filter(Boolean).join("|"));
1042
+ });
1043
+
1044
+ const richHtml = sanitizeArticleRichHtml(content.html());
1045
+ if (!richHtml) {
1046
+ throw new Error("微信文章富文本转换后为空");
1047
+ }
1048
+ const richDocument = cheerio.load(richHtml, null, false);
1049
+ const richText = normalizeTextContent(richDocument.root().text());
1050
+ const missingRichTextNodes = sourceTextNodes.filter(node => {
1051
+ const sourceText = normalizeTextContent(node.data).trim();
1052
+ return sourceText && !richText.includes(sourceText);
1053
+ });
1054
+ if (missingRichTextNodes.length > 0) {
1055
+ const preview = missingRichTextNodes
1056
+ .slice(0, 3)
1057
+ .map(node => normalizeTextContent(node.data).trim())
1058
+ .join(" / ");
1059
+ throw new Error(`微信文章有 ${missingRichTextNodes.length} 处正文未保留原始格式: ${preview}`);
1060
+ }
1061
+ const sourceImageCount = sourceImages.filter(Boolean).length;
1062
+ const richImageCount = richDocument("img").length;
1063
+ if (richImageCount !== sourceImageCount) {
1064
+ throw new Error(`微信文章图片结构不完整: 原文 ${sourceImageCount} 张,转换后 ${richImageCount} 张`);
1065
+ }
1066
+
1067
+ const markdownContext = {
1068
+ capturedTextNodes: new Set(),
1069
+ colorCount: 0,
1070
+ colors: new Set(),
1071
+ mediaCount: 0,
1072
+ specialElementCount: 0,
1073
+ tableCount: 0
1074
+ };
475
1075
  const markdown = normalizeMarkdown(
476
1076
  content
477
1077
  .contents()
478
1078
  .toArray()
479
- .map(node => markdownForNode($, node, 0, "", colorContext))
1079
+ .map(node => markdownForNode($, node, 0, "", markdownContext))
480
1080
  .join("")
481
1081
  );
482
1082
  if (!markdown) {
483
1083
  throw new Error("微信文章正文转换后为空");
484
1084
  }
1085
+ const missingTextNodes = sourceTextNodes.filter(node => !markdownContext.capturedTextNodes.has(node));
1086
+ if (missingTextNodes.length > 0) {
1087
+ const preview = missingTextNodes
1088
+ .slice(0, 3)
1089
+ .map(node => normalizeTextContent(node.data).trim())
1090
+ .join(" / ");
1091
+ throw new Error(`微信文章有 ${missingTextNodes.length} 处正文未被完整转换: ${preview}`);
1092
+ }
485
1093
 
486
1094
  return {
487
1095
  title,
@@ -490,9 +1098,16 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
490
1098
  excerpt: $('meta[property="og:description"]').attr("content")?.trim() || "",
491
1099
  thumbnail: coverSource ? uploaded.get(assertWechatImageUrl(coverSource, sourceUrl).toString()) || "" : uploaded.values().next().value || "",
492
1100
  markdown,
1101
+ richHtml,
1102
+ richImageCount,
493
1103
  imageCount: uploaded.size,
494
- colorCount: colorContext.colorCount,
495
- colors: [...colorContext.colors],
1104
+ backgroundImageCount: backgroundSources.length,
1105
+ colorCount: markdownContext.colorCount,
1106
+ colors: [...markdownContext.colors],
1107
+ mediaCount: markdownContext.mediaCount,
1108
+ specialElementCount: markdownContext.specialElementCount,
1109
+ tableCount: markdownContext.tableCount,
1110
+ textSegmentCount: sourceTextNodes.length,
496
1111
  sourceUrl: sourceUrl.toString()
497
1112
  };
498
1113
  }
@@ -502,6 +1117,9 @@ function yamlString(value) {
502
1117
  }
503
1118
 
504
1119
  export function buildOfficialArticleMarkdown(article) {
1120
+ const articleBody = article.richHtml
1121
+ ? `${RICH_HTML_START}\n${article.richHtml}\n${RICH_HTML_END}`
1122
+ : article.markdown;
505
1123
  return `---
506
1124
  article_title: ${yamlString(article.title)}
507
1125
  thumbnail: ${yamlString(article.thumbnail)}
@@ -523,7 +1141,7 @@ index_move_img: ""
523
1141
  source_url: ${yamlString(article.sourceUrl)}
524
1142
  ---
525
1143
 
526
- ${article.markdown}
1144
+ ${articleBody}
527
1145
  `;
528
1146
  }
529
1147