@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 +1 -1
- package/bin/qcplay.js +101 -8
- package/lib/wechat-article.js +638 -20
- package/package.json +1 -1
- package/templates/skills/qcplay-publish-article/SKILL.md +6 -3
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
|
-
|
|
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 {
|
|
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
|
|
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:
|
|
1343
|
-
'PingFang SC,Hiragino Sans GB,Microsoft YaHei,
|
|
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("");
|
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
|
|
package/package.json
CHANGED
|
@@ -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
|
|