@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.
- package/README.md +23 -3
- package/bin/qcplay.js +282 -14
- package/lib/wechat-article.js +638 -20
- package/package.json +1 -1
- package/templates/skills/qcplay-publish-article/SKILL.md +134 -149
package/lib/wechat-article.js
CHANGED
|
@@ -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
|
|
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
|
|
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\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 (
|
|
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
|
-
|
|
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\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
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
436
|
-
|
|
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
|
-
|
|
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, "",
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
${
|
|
1144
|
+
${articleBody}
|
|
527
1145
|
`;
|
|
528
1146
|
}
|
|
529
1147
|
|