@qcplay/cli 1.0.10 → 1.0.11

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 CHANGED
@@ -24,7 +24,7 @@
24
24
  qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.md
25
25
  ```
26
26
 
27
- 转换流程会读取微信文章标题、来源、发布日期和正文,将正文及封面图片逐张上传到官网图片服务,再生成带 Front Matter Markdown 草稿。输出固定为 `status: "0"`,并保留空的 `cate_id` 供发布前确认;任何图片下载或上传失败都会终止转换且不生成文章文件。
27
+ 转换流程会读取微信文章标题、来源、发布日期和完整正文,经过安全清理后保留微信原文的 HTML 层级、内联样式、字号、颜色、对齐、间距、媒体链接和表格,并转存正文图片、CSS 背景图及封面图。背景图片仍保留在原容器中,不会额外生成一张普通图片。正文保持微信的 `677px` 阅读宽度并兼容窄屏;独占一行的图片会使用块级布局,纯图片标题容器会转换为普通区块,避免官网 PC 标题样式造成相邻图片重叠。任何正文节点遗漏或图片处理失败都会终止转换,不生成不完整草稿。输出固定为 `status: "0"`,并保留空的 `cate_id` 供发布前确认。
28
28
 
29
29
  确认草稿内容和业务字段后再发布:
30
30
 
package/bin/qcplay.js CHANGED
@@ -9,7 +9,12 @@ import path from "path";
9
9
  import readline from "readline";
10
10
  import { fileURLToPath, pathToFileURL } from "url";
11
11
 
12
- import { buildOfficialArticleMarkdown, importWechatArticle, normalizeArticleColor } from "../lib/wechat-article.js";
12
+ import {
13
+ buildOfficialArticleMarkdown,
14
+ importWechatArticle,
15
+ normalizeArticleColor,
16
+ sanitizeArticleRichHtml
17
+ } from "../lib/wechat-article.js";
13
18
 
14
19
  const __filename = fileURLToPath(import.meta.url);
15
20
  const __dirname = path.dirname(__filename);
@@ -1162,14 +1167,14 @@ function applyInlineMarkdown(text) {
1162
1167
  output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
1163
1168
  return storeToken(
1164
1169
  `<img src="${escapeHtml(url.trim())}" alt="${escapeHtml(alt.trim())}" ` +
1165
- 'style="display:block;width:auto;max-width:100%;height:auto;margin:24px auto;" />'
1170
+ 'style="display:block;box-sizing:border-box;width:auto;max-width:100%;height:auto;object-fit:contain;margin:24px auto;" />'
1166
1171
  );
1167
1172
  });
1168
1173
 
1169
1174
  output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
1170
1175
  return storeToken(
1171
1176
  `<a href="${escapeHtml(url.trim())}" target="_blank" rel="noreferrer" ` +
1172
- `style="color:#576b95;text-decoration:none;">${escapeHtml(label.trim())}</a>`
1177
+ `style="color:#576b95;text-decoration:none;overflow-wrap:anywhere;word-break:break-all;">${escapeHtml(label.trim())}</a>`
1173
1178
  );
1174
1179
  });
1175
1180
 
@@ -1188,12 +1193,23 @@ function applyInlineMarkdown(text) {
1188
1193
  }
1189
1194
 
1190
1195
  function markdownToHtml(markdown) {
1191
- const lines = stripBom(markdown).replace(/\r\n/g, "\n").split("\n");
1196
+ const richHtmlBlocks = [];
1197
+ const normalizedMarkdown = stripBom(markdown)
1198
+ .replace(/\r\n/g, "\n")
1199
+ .replace(
1200
+ /<!--\s*qcplay-rich-html:start\s*-->([\s\S]*?)<!--\s*qcplay-rich-html:end\s*-->/gi,
1201
+ (_, richHtml) => {
1202
+ const index = richHtmlBlocks.push(richHtml) - 1;
1203
+ return `\n@@QCRICHHTML${index}@@\n`;
1204
+ }
1205
+ );
1206
+ const lines = normalizedMarkdown.split("\n");
1192
1207
  const html = [];
1193
1208
  let paragraph = [];
1194
1209
  let quote = [];
1195
1210
  let listType = "";
1196
1211
  let listItems = [];
1212
+ let tableLines = [];
1197
1213
  let inCodeBlock = false;
1198
1214
  let codeLines = [];
1199
1215
 
@@ -1203,7 +1219,7 @@ function markdownToHtml(markdown) {
1203
1219
  }
1204
1220
 
1205
1221
  html.push(
1206
- `<p style="margin:0 0 20px;font-size:16px;line-height:1.9;color:#3f3f3f;text-align:justify;">` +
1222
+ `<p style="margin:0 0 20px;font-size:16px;line-height:1.9;color:#3f3f3f;text-align:justify;overflow-wrap:anywhere;word-break:break-word;">` +
1207
1223
  `${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`
1208
1224
  );
1209
1225
  paragraph = [];
@@ -1251,7 +1267,57 @@ function markdownToHtml(markdown) {
1251
1267
  codeLines = [];
1252
1268
  }
1253
1269
 
1270
+ function parseTableLine(line) {
1271
+ return line
1272
+ .trim()
1273
+ .replace(/^\|/, "")
1274
+ .replace(/\|$/, "")
1275
+ .split(/(?<!\\)\|/)
1276
+ .map(cell => cell.trim().replace(/\\\|/g, "|"));
1277
+ }
1278
+
1279
+ function flushTable() {
1280
+ if (tableLines.length === 0) {
1281
+ return;
1282
+ }
1283
+
1284
+ const rows = tableLines.map(parseTableLine);
1285
+ const separator = rows[1];
1286
+ const isMarkdownTable =
1287
+ rows.length >= 2 && separator.every(cell => /^:?-{3,}:?$/.test(cell.replace(/\s/g, "")));
1288
+ if (!isMarkdownTable) {
1289
+ paragraph.push(...tableLines);
1290
+ tableLines = [];
1291
+ return;
1292
+ }
1293
+
1294
+ const columnCount = Math.max(...rows.map(row => row.length));
1295
+ const normalizeRow = row => [...row, ...Array(columnCount - row.length).fill("")];
1296
+ const header = normalizeRow(rows[0]);
1297
+ const body = rows.slice(2).map(normalizeRow);
1298
+ const cellStyle =
1299
+ "padding:10px 12px;border:1px solid #dfe3e8;text-align:left;vertical-align:top;" +
1300
+ "font-size:15px;line-height:1.7;overflow-wrap:anywhere;word-break:break-word;";
1301
+ html.push(
1302
+ '<div style="width:100%;margin:24px 0;overflow-x:auto;-webkit-overflow-scrolling:touch;">' +
1303
+ '<table style="width:100%;min-width:560px;border-collapse:collapse;table-layout:auto;color:#3f3f3f;">' +
1304
+ `<thead><tr>${header
1305
+ .map(cell => `<th style="${cellStyle}background:#f5f7f8;font-weight:600;">${applyInlineMarkdown(cell)}</th>`)
1306
+ .join("")}</tr></thead>` +
1307
+ `<tbody>${body
1308
+ .map(
1309
+ row =>
1310
+ `<tr>${row
1311
+ .map(cell => `<td style="${cellStyle}">${applyInlineMarkdown(cell)}</td>`)
1312
+ .join("")}</tr>`
1313
+ )
1314
+ .join("")}</tbody></table></div>`
1315
+ );
1316
+ tableLines = [];
1317
+ }
1318
+
1254
1319
  function flushAll() {
1320
+ flushTable();
1255
1321
  flushParagraph();
1256
1322
  flushQuote();
1257
1323
  flushList();
@@ -1281,13 +1347,33 @@ function markdownToHtml(markdown) {
1281
1347
  continue;
1282
1348
  }
1283
1349
 
1350
+ const richHtmlMatch = trimmed.match(/^@@QCRICHHTML(\d+)@@$/);
1351
+ if (richHtmlMatch) {
1352
+ flushAll();
1353
+ const sanitizedHtml = sanitizeArticleRichHtml(richHtmlBlocks[Number(richHtmlMatch[1])] || "");
1354
+ if (sanitizedHtml) {
1355
+ html.push(sanitizedHtml);
1356
+ }
1357
+ continue;
1358
+ }
1359
+
1360
+ if (/^\|.*\|$/.test(trimmed)) {
1361
+ flushParagraph();
1362
+ flushQuote();
1363
+ flushList();
1364
+ tableLines.push(trimmed);
1365
+ continue;
1366
+ }
1367
+
1368
+ flushTable();
1369
+
1284
1370
  const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
1285
1371
  if (headingMatch) {
1286
1372
  flushAll();
1287
1373
  const level = headingMatch[1].length;
1288
1374
  const headingSize = level === 1 ? 24 : level === 2 ? 20 : 18;
1289
1375
  html.push(
1290
- `<h${level} style="margin:32px 0 16px;font-size:${headingSize}px;line-height:1.5;font-weight:700;color:#24292f;">` +
1376
+ `<h${level} style="margin:32px 0 16px;font-size:${headingSize}px;line-height:1.5;font-weight:700;color:#24292f;overflow-wrap:anywhere;word-break:break-word;">` +
1291
1377
  `${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`
1292
1378
  );
1293
1379
  continue;
@@ -1339,8 +1425,9 @@ function markdownToHtml(markdown) {
1339
1425
  flushCodeBlock();
1340
1426
  flushAll();
1341
1427
  return (
1342
- '<section style="max-width:677px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,' +
1343
- 'PingFang SC,Hiragino Sans GB,Microsoft YaHei,sans-serif;letter-spacing:0;overflow-wrap:anywhere;">\n' +
1428
+ '<section style="box-sizing:border-box;width:100%;max-width:960px;min-width:0;margin:0 auto;padding:0 16px;' +
1429
+ 'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,' +
1430
+ 'sans-serif;letter-spacing:0;overflow-wrap:anywhere;word-break:break-word;">\n' +
1344
1431
  `${html.join("\n")}\n</section>`
1345
1432
  );
1346
1433
  }
@@ -1487,7 +1574,13 @@ async function importWechatArticleCommand(sourceUrl, file = "article.md") {
1487
1574
  console.log(targetFile);
1488
1575
  console.log(chalk.gray(`标题: ${article.title}`));
1489
1576
  console.log(chalk.gray(`已转存图片: ${article.imageCount} 张`));
1577
+ console.log(chalk.gray(`已提取正文: ${article.textSegmentCount} 段`));
1490
1578
  console.log(chalk.gray(`已保留文字颜色: ${article.colorCount} 处`));
1579
+ console.log(
1580
+ chalk.gray(
1581
+ `特殊内容: ${article.specialElementCount} 处,媒体: ${article.mediaCount} 处,表格: ${article.tableCount} 个`
1582
+ )
1583
+ );
1491
1584
  console.log("");
1492
1585
  console.log("请补充 cate_id 等业务字段,确认内容后执行:");
1493
1586
  console.log("");
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qcplay/cli",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "QCPlay CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -302,10 +302,13 @@ qcplay article import "https://mp.weixin.qq.com/s/..." article.md
302
302
  该命令会:
303
303
 
304
304
  ```txt
305
- 读取微信文章标题、作者、发布日期和正文
306
- 逐张下载正文与封面图片
305
+ 读取微信文章标题、作者、发布日期和完整正文
306
+ 逐张下载正文图片、CSS 背景图与封面图片
307
307
  上传图片到官网图片服务并替换链接
308
- 清理微信脚本、表单和交互节点
308
+ 清理脚本和危险属性,同时保留原文 HTML 层级、内联样式及特殊节点里的可读内容
309
+ 背景图片保留在原容器中,不得重复生成为普通图片
310
+ 生成保持微信 677px 阅读宽度并兼容窄屏的富 HTML 正文
311
+ 独占一行的图片使用块级布局,避免 PC 端标题行盒造成相邻图片重叠
309
312
  生成 status: "0" 的官网 Markdown 草稿
310
313
  ```
311
314