@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 +1 -1
- package/bin/qcplay.js +112 -9
- package/lib/wechat-article.js +709 -24
- 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);
|
|
@@ -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 =
|
|
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
|
|
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:
|
|
1334
|
-
'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' +
|
|
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("");
|
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;
|
|
@@ -234,19 +405,439 @@ function escapeMarkdownText(value) {
|
|
|
234
405
|
return normalizeTextContent(value).replace(/([\\`*_\[\]])/g, "\\$1");
|
|
235
406
|
}
|
|
236
407
|
|
|
237
|
-
|
|
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
|
-
|
|
742
|
+
if (normalizeTextContent(node.data).trim()) {
|
|
743
|
+
bucket.push(node);
|
|
744
|
+
}
|
|
745
|
+
return bucket;
|
|
240
746
|
}
|
|
241
|
-
|
|
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
|
|
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("");
|
|
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 (
|
|
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
|
-
|
|
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\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
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
376
|
-
|
|
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
|
|
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
|
-
${
|
|
1144
|
+
${articleBody}
|
|
460
1145
|
`;
|
|
461
1146
|
}
|
|
462
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
|
|