@qcplay/cli 1.0.9 → 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 } 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);
@@ -1150,17 +1155,26 @@ function applyInlineMarkdown(text) {
1150
1155
  return storeToken(`<code>${escapeHtml(code)}</code>`);
1151
1156
  });
1152
1157
 
1158
+ output = output.replace(/\{\{qc-color:([^}]+)\}\}([\s\S]*?)\{\{\/qc-color\}\}/g, (_, rawColor, content) => {
1159
+ const color = normalizeArticleColor(rawColor);
1160
+ if (!color) {
1161
+ return content;
1162
+ }
1163
+ const plainText = content.replace(/\\([\\`*_\[\]])/g, "$1");
1164
+ return storeToken(`<span style="color:${escapeHtml(color)};">${escapeHtml(plainText)}</span>`);
1165
+ });
1166
+
1153
1167
  output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
1154
1168
  return storeToken(
1155
1169
  `<img src="${escapeHtml(url.trim())}" alt="${escapeHtml(alt.trim())}" ` +
1156
- '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;" />'
1157
1171
  );
1158
1172
  });
1159
1173
 
1160
1174
  output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
1161
1175
  return storeToken(
1162
1176
  `<a href="${escapeHtml(url.trim())}" target="_blank" rel="noreferrer" ` +
1163
- `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>`
1164
1178
  );
1165
1179
  });
1166
1180
 
@@ -1171,7 +1185,7 @@ function applyInlineMarkdown(text) {
1171
1185
  output = output.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
1172
1186
  output = output.replace(/(?<!_)_([^_]+)_(?!_)/g, "<em>$1</em>");
1173
1187
 
1174
- for (let index = 0; index < tokens.length; index += 1) {
1188
+ for (let index = tokens.length - 1; index >= 0; index -= 1) {
1175
1189
  output = output.replace(`@@MDTOKEN${index}@@`, tokens[index]);
1176
1190
  }
1177
1191
 
@@ -1179,12 +1193,23 @@ function applyInlineMarkdown(text) {
1179
1193
  }
1180
1194
 
1181
1195
  function markdownToHtml(markdown) {
1182
- 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");
1183
1207
  const html = [];
1184
1208
  let paragraph = [];
1185
1209
  let quote = [];
1186
1210
  let listType = "";
1187
1211
  let listItems = [];
1212
+ let tableLines = [];
1188
1213
  let inCodeBlock = false;
1189
1214
  let codeLines = [];
1190
1215
 
@@ -1194,7 +1219,7 @@ function markdownToHtml(markdown) {
1194
1219
  }
1195
1220
 
1196
1221
  html.push(
1197
- `<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;">` +
1198
1223
  `${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`
1199
1224
  );
1200
1225
  paragraph = [];
@@ -1242,7 +1267,57 @@ function markdownToHtml(markdown) {
1242
1267
  codeLines = [];
1243
1268
  }
1244
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
+
1245
1319
  function flushAll() {
1320
+ flushTable();
1246
1321
  flushParagraph();
1247
1322
  flushQuote();
1248
1323
  flushList();
@@ -1272,13 +1347,33 @@ function markdownToHtml(markdown) {
1272
1347
  continue;
1273
1348
  }
1274
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
+
1275
1370
  const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
1276
1371
  if (headingMatch) {
1277
1372
  flushAll();
1278
1373
  const level = headingMatch[1].length;
1279
1374
  const headingSize = level === 1 ? 24 : level === 2 ? 20 : 18;
1280
1375
  html.push(
1281
- `<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;">` +
1282
1377
  `${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`
1283
1378
  );
1284
1379
  continue;
@@ -1330,8 +1425,9 @@ function markdownToHtml(markdown) {
1330
1425
  flushCodeBlock();
1331
1426
  flushAll();
1332
1427
  return (
1333
- '<section style="max-width:677px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,' +
1334
- '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' +
1335
1431
  `${html.join("\n")}\n</section>`
1336
1432
  );
1337
1433
  }
@@ -1478,6 +1574,13 @@ async function importWechatArticleCommand(sourceUrl, file = "article.md") {
1478
1574
  console.log(targetFile);
1479
1575
  console.log(chalk.gray(`标题: ${article.title}`));
1480
1576
  console.log(chalk.gray(`已转存图片: ${article.imageCount} 张`));
1577
+ console.log(chalk.gray(`已提取正文: ${article.textSegmentCount} 段`));
1578
+ console.log(chalk.gray(`已保留文字颜色: ${article.colorCount} 处`));
1579
+ console.log(
1580
+ chalk.gray(
1581
+ `特殊内容: ${article.specialElementCount} 处,媒体: ${article.mediaCount} 处,表格: ${article.tableCount} 个`
1582
+ )
1583
+ );
1481
1584
  console.log("");
1482
1585
  console.log("请补充 cate_id 等业务字段,确认内容后执行:");
1483
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;
@@ -234,19 +405,439 @@ function escapeMarkdownText(value) {
234
405
  return normalizeTextContent(value).replace(/([\\`*_\[\]])/g, "\\$1");
235
406
  }
236
407
 
237
- function markdownForNode($, node, listDepth = 0) {
408
+ const NAMED_TEXT_COLORS = new Set([
409
+ "aqua",
410
+ "black",
411
+ "blue",
412
+ "cyan",
413
+ "fuchsia",
414
+ "gray",
415
+ "green",
416
+ "grey",
417
+ "lime",
418
+ "magenta",
419
+ "maroon",
420
+ "navy",
421
+ "olive",
422
+ "orange",
423
+ "pink",
424
+ "purple",
425
+ "red",
426
+ "silver",
427
+ "teal",
428
+ "transparent",
429
+ "white",
430
+ "yellow"
431
+ ]);
432
+
433
+ export function normalizeArticleColor(value) {
434
+ const color = String(value || "")
435
+ .trim()
436
+ .replace(/\s*!important\s*$/i, "")
437
+ .trim()
438
+ .toLowerCase();
439
+ if (/^#[0-9a-f]{3,4}(?:[0-9a-f]{3,4})?$/.test(color)) {
440
+ return color;
441
+ }
442
+ if (/^rgba?\(\s*(?:\d{1,3}%?\s*,\s*){2}\d{1,3}%?(?:\s*,\s*(?:0|1|0?\.\d+|\d{1,3}%))?\s*\)$/.test(color)) {
443
+ return color.replace(/\s+/g, " ");
444
+ }
445
+ return NAMED_TEXT_COLORS.has(color) ? color : "";
446
+ }
447
+
448
+ function elementTextColor(element) {
449
+ const style = [element.attr("style"), element.attr("data-style")].filter(Boolean).join(";");
450
+ let color = "";
451
+ for (const match of style.matchAll(/(?:^|;)\s*(?:color|-webkit-text-fill-color)\s*:\s*([^;]+)/gi)) {
452
+ color = normalizeArticleColor(match[1]) || color;
453
+ }
454
+ return (
455
+ color ||
456
+ normalizeArticleColor(element.attr("color")) ||
457
+ normalizeArticleColor(element.attr("data-color"))
458
+ );
459
+ }
460
+
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 = []) {
238
741
  if (node.type === "text") {
239
- return escapeMarkdownText(node.data);
742
+ if (normalizeTextContent(node.data).trim()) {
743
+ bucket.push(node);
744
+ }
745
+ return bucket;
240
746
  }
241
- if (node.type !== "tag") {
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) {
242
778
  return "";
243
779
  }
244
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
+ };
803
+ if (node.type === "text") {
804
+ const value = escapeMarkdownText(node.data);
805
+ if (value.trim()) {
806
+ context.capturedTextNodes.add(node);
807
+ }
808
+ if (!inheritedColor || !value.trim()) {
809
+ return value;
810
+ }
811
+ context.colorCount += 1;
812
+ context.colors.add(inheritedColor);
813
+ return `{{qc-color:${inheritedColor}}}${value}{{/qc-color}}`;
814
+ }
815
+ if (node.type === "script" || node.type === "style" || node.type === "comment") {
816
+ return "";
817
+ }
818
+ if (node.type !== "tag") {
819
+ return (node.children || [])
820
+ .map(child => markdownForNode($, child, listDepth, inheritedColor, context))
821
+ .join("");
822
+ }
823
+
245
824
  const element = $(node);
246
825
  const tag = String(node.tagName || node.name || "").toLowerCase();
247
- const children = () => element.contents().toArray().map(child => markdownForNode($, child, listDepth)).join("");
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("");
832
+ const children = () =>
833
+ backgroundMarkdown +
834
+ element
835
+ .contents()
836
+ .toArray()
837
+ .map(child => markdownForNode($, child, listDepth, textColor, context))
838
+ .join("");
248
839
 
249
- if (["script", "style", "noscript", "iframe", "video", "audio", "canvas", "svg", "form", "button", "input"].includes(tag)) {
840
+ if (tag === "script" || tag === "style") {
250
841
  return "";
251
842
  }
252
843
  if (tag === "br") {
@@ -255,7 +846,8 @@ function markdownForNode($, node, listDepth = 0) {
255
846
  if (tag === "img") {
256
847
  const src = element.attr("data-qcplay-src");
257
848
  if (!src) {
258
- return "";
849
+ const description = elementDescription(element);
850
+ return description ? `\n\n${escapeMarkdownText(description)}\n\n` : "";
259
851
  }
260
852
  const alt = escapeMarkdownText(element.attr("alt") || "图片");
261
853
  return `\n\n![${alt}](${src})\n\n`;
@@ -270,7 +862,7 @@ function markdownForNode($, node, listDepth = 0) {
270
862
  }
271
863
  if (tag === "a") {
272
864
  const value = children().trim();
273
- const href = element.attr("href");
865
+ const href = safeContentUrl(element);
274
866
  if (!value || !href) {
275
867
  return value;
276
868
  }
@@ -282,6 +874,16 @@ function markdownForNode($, node, listDepth = 0) {
282
874
  } catch {}
283
875
  return value;
284
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
+ }
285
887
  if (/^h[1-6]$/.test(tag)) {
286
888
  const value = children().trim();
287
889
  return value ? `\n\n${"#".repeat(Number(tag[1]))} ${value}\n\n` : "";
@@ -294,25 +896,40 @@ function markdownForNode($, node, listDepth = 0) {
294
896
  const value = children().trim();
295
897
  return value ? `\n\n${value.split("\n").map(line => `> ${line}`).join("\n")}\n\n` : "";
296
898
  }
899
+ if (tag === "table") {
900
+ return markdownForTable($, element, listDepth, textColor, context);
901
+ }
297
902
  if (tag === "ul" || tag === "ol") {
298
903
  const ordered = tag === "ol";
299
- const items = element.children("li").toArray().map((item, index) => {
300
- const value = $(item)
301
- .contents()
302
- .toArray()
303
- .map(child => markdownForNode($, child, listDepth + 1))
304
- .join("")
305
- .trim();
306
- const prefix = ordered ? `${index + 1}.` : "-";
307
- return `${" ".repeat(listDepth)}${prefix} ${value}`;
308
- });
309
- 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` : "";
310
920
  }
311
921
  if (tag === "li") {
312
922
  return children();
313
923
  }
314
924
 
925
+ if (tag.includes("-") || tag.startsWith("mp") || tag.startsWith("wx")) {
926
+ context.specialElementCount += 1;
927
+ }
315
928
  const value = children();
929
+ if (!value.trim()) {
930
+ const description = elementDescription(element);
931
+ return description ? `\n\n${escapeMarkdownText(description)}\n\n` : "";
932
+ }
316
933
  return BLOCK_TAGS.has(tag) && value.trim() ? `\n\n${value}\n\n` : value;
317
934
  }
318
935
 
@@ -372,15 +989,21 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
372
989
  throw new Error("微信页面中没有找到文章标题");
373
990
  }
374
991
 
375
- content.find("script,style,noscript,iframe,video,audio,canvas,svg,form,button,input").remove();
376
- 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));
377
994
  const imageElements = content.find("img").toArray();
378
995
  const sourceImages = imageElements.map(element => {
379
996
  const image = $(element);
380
997
  return image.attr("data-src") || image.attr("data-original") || image.attr("src") || "";
381
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);
382
1005
  const coverSource = $('meta[property="og:image"]').attr("content") || "";
383
- const allSources = [coverSource, ...sourceImages].filter(Boolean);
1006
+ const allSources = [coverSource, ...sourceImages, ...backgroundSources].filter(Boolean);
384
1007
  const uploaded = new Map();
385
1008
  const downloadImage = options.downloadImage || downloadWechatImage;
386
1009
  const uploadImage = options.uploadImage || uploadOfficialImage;
@@ -404,19 +1027,69 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
404
1027
  imageElements.forEach((element, index) => {
405
1028
  const rawSource = sourceImages[index];
406
1029
  if (!rawSource) {
407
- $(element).remove();
408
1030
  return;
409
1031
  }
410
1032
  const key = assertWechatImageUrl(rawSource, sourceUrl).toString();
411
1033
  $(element).attr("data-qcplay-src", uploaded.get(key));
412
1034
  });
413
1035
 
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
+ };
414
1075
  const markdown = normalizeMarkdown(
415
- content.contents().toArray().map(node => markdownForNode($, node)).join("")
1076
+ content
1077
+ .contents()
1078
+ .toArray()
1079
+ .map(node => markdownForNode($, node, 0, "", markdownContext))
1080
+ .join("")
416
1081
  );
417
1082
  if (!markdown) {
418
1083
  throw new Error("微信文章正文转换后为空");
419
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
+ }
420
1093
 
421
1094
  return {
422
1095
  title,
@@ -425,7 +1098,16 @@ export async function convertWechatHtml(html, pageUrl, options = {}) {
425
1098
  excerpt: $('meta[property="og:description"]').attr("content")?.trim() || "",
426
1099
  thumbnail: coverSource ? uploaded.get(assertWechatImageUrl(coverSource, sourceUrl).toString()) || "" : uploaded.values().next().value || "",
427
1100
  markdown,
1101
+ richHtml,
1102
+ richImageCount,
428
1103
  imageCount: uploaded.size,
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,
429
1111
  sourceUrl: sourceUrl.toString()
430
1112
  };
431
1113
  }
@@ -435,6 +1117,9 @@ function yamlString(value) {
435
1117
  }
436
1118
 
437
1119
  export function buildOfficialArticleMarkdown(article) {
1120
+ const articleBody = article.richHtml
1121
+ ? `${RICH_HTML_START}\n${article.richHtml}\n${RICH_HTML_END}`
1122
+ : article.markdown;
438
1123
  return `---
439
1124
  article_title: ${yamlString(article.title)}
440
1125
  thumbnail: ${yamlString(article.thumbnail)}
@@ -456,7 +1141,7 @@ index_move_img: ""
456
1141
  source_url: ${yamlString(article.sourceUrl)}
457
1142
  ---
458
1143
 
459
- ${article.markdown}
1144
+ ${articleBody}
460
1145
  `;
461
1146
  }
462
1147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qcplay/cli",
3
- "version": "1.0.9",
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