@qcplay/cli 1.0.13 → 1.0.15

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.
@@ -0,0 +1,294 @@
1
+ import { spawn } from "child_process";
2
+
3
+ import * as cheerio from "cheerio";
4
+
5
+ import { normalizeArticleColor, sanitizeArticleRichHtml } from "./wechat-article.js";
6
+
7
+ const LARK_DOCUMENT_PATH = /^\/(?:wiki|docx)\/([A-Za-z0-9_-]+)\/?$/;
8
+
9
+ function normalizeText(value) {
10
+ return String(value || "")
11
+ .replace(/\u00a0/g, " ")
12
+ .replace(/[\t\r\n ]+/g, " ")
13
+ .trim();
14
+ }
15
+
16
+ function appendStyle(element, declarations) {
17
+ const existing = String(element.attr("style") || "").trim().replace(/;+$/, "");
18
+ const added = declarations.filter(Boolean).join(";");
19
+ element.attr("style", [existing, added].filter(Boolean).join(";"));
20
+ }
21
+
22
+ function larkHostname(hostname) {
23
+ const value = String(hostname || "").toLowerCase();
24
+ return (
25
+ value === "feishu.cn" ||
26
+ value.endsWith(".feishu.cn") ||
27
+ value === "larksuite.com" ||
28
+ value.endsWith(".larksuite.com") ||
29
+ value === "larkoffice.com" ||
30
+ value.endsWith(".larkoffice.com")
31
+ );
32
+ }
33
+
34
+ export function parseLarkDocumentUrl(value) {
35
+ let url;
36
+ try {
37
+ url = new URL(String(value || ""));
38
+ } catch {
39
+ throw new Error("飞书文档链接无效");
40
+ }
41
+ if (url.protocol !== "https:" || !larkHostname(url.hostname) || !LARK_DOCUMENT_PATH.test(url.pathname)) {
42
+ throw new Error("仅支持飞书 Docx 或 Wiki 文档链接");
43
+ }
44
+ url.search = "";
45
+ url.hash = "";
46
+ return url;
47
+ }
48
+
49
+ function larkError(output, fallback) {
50
+ try {
51
+ const envelope = JSON.parse(output);
52
+ const message = normalizeText(envelope.error?.message);
53
+ const hint = normalizeText(envelope.error?.hint);
54
+ return [message, hint].filter(Boolean).join(";") || fallback;
55
+ } catch {
56
+ return normalizeText(output) || fallback;
57
+ }
58
+ }
59
+
60
+ function runLarkJson(args) {
61
+ return new Promise((resolve, reject) => {
62
+ const executable = process.platform === "win32" ? "lark-cli.cmd" : "lark-cli";
63
+ const child = spawn(executable, args, {
64
+ windowsHide: true,
65
+ shell: process.platform === "win32",
66
+ stdio: ["ignore", "pipe", "pipe"],
67
+ env: {
68
+ ...process.env,
69
+ LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1",
70
+ LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1"
71
+ }
72
+ });
73
+ const stdout = [];
74
+ const stderr = [];
75
+ child.stdout.on("data", chunk => stdout.push(chunk));
76
+ child.stderr.on("data", chunk => stderr.push(chunk));
77
+ child.once("error", error => reject(new Error(`无法启动 lark-cli: ${error.message}`)));
78
+ child.once("close", code => {
79
+ const output = Buffer.concat(stdout).toString("utf8").trim();
80
+ const diagnostics = Buffer.concat(stderr).toString("utf8").trim();
81
+ if (code !== 0) {
82
+ reject(new Error(`飞书文档读取失败: ${larkError(diagnostics || output, `lark-cli 退出码 ${code}`)}`));
83
+ return;
84
+ }
85
+ try {
86
+ resolve(JSON.parse(output));
87
+ } catch {
88
+ reject(new Error("飞书文档读取失败: lark-cli 没有返回有效 JSON"));
89
+ }
90
+ });
91
+ });
92
+ }
93
+
94
+ export async function fetchLarkDocument(value, options = {}) {
95
+ const sourceUrl = parseLarkDocumentUrl(value);
96
+ const run = options.runLarkJson || runLarkJson;
97
+ const envelope = await run([
98
+ "docs",
99
+ "+fetch",
100
+ "--doc",
101
+ sourceUrl.toString(),
102
+ "--detail",
103
+ "full",
104
+ "--as",
105
+ "user",
106
+ "--format",
107
+ "json"
108
+ ]);
109
+ if (envelope?.ok !== true) {
110
+ throw new Error(`飞书文档读取失败: ${larkError(JSON.stringify(envelope || {}), "返回状态异常")}`);
111
+ }
112
+ const document = envelope.data?.document;
113
+ if (!normalizeText(document?.content)) {
114
+ throw new Error("飞书文档正文为空");
115
+ }
116
+ return {
117
+ sourceUrl: sourceUrl.toString(),
118
+ documentId: normalizeText(document.document_id),
119
+ revisionId: document.revision_id,
120
+ xml: String(document.content)
121
+ };
122
+ }
123
+
124
+ function styleLarkElement($, node) {
125
+ const element = $(node);
126
+ const tag = String(node.tagName || node.name || "").toLowerCase();
127
+ const align = String(element.attr("align") || "").toLowerCase();
128
+ if (["left", "center", "right", "justify"].includes(align)) {
129
+ appendStyle(element, [`text-align:${align}`]);
130
+ }
131
+
132
+ const textColor = normalizeArticleColor(element.attr("text-color"));
133
+ const backgroundColor = normalizeArticleColor(element.attr("background-color"));
134
+ appendStyle(element, [textColor ? `color:${textColor}` : "", backgroundColor ? `background-color:${backgroundColor}` : ""]);
135
+
136
+ if (/^h[1-6]$/.test(tag)) {
137
+ const level = Number(tag.slice(1));
138
+ const sizes = [28, 24, 21, 19, 17, 16];
139
+ appendStyle(element, [
140
+ "margin:30px 0 14px",
141
+ `font-size:${sizes[level - 1]}px`,
142
+ "line-height:1.5",
143
+ "font-weight:700",
144
+ "color:#24292f"
145
+ ]);
146
+ } else if (tag === "p") {
147
+ appendStyle(element, [element.parents("td,th").length ? "margin:0 0 8px" : "margin:0 0 18px", "line-height:1.8"]);
148
+ } else if (tag === "blockquote") {
149
+ appendStyle(element, [
150
+ "margin:22px 0",
151
+ "padding:14px 18px",
152
+ "border-left:4px solid #3370ff",
153
+ "background-color:#f5f7fa",
154
+ "color:#3f4654"
155
+ ]);
156
+ } else if (tag === "table") {
157
+ const totalWidth = element
158
+ .find("col")
159
+ .toArray()
160
+ .reduce((sum, col) => sum + Number.parseInt($(col).attr("width") || "0", 10), 0);
161
+ appendStyle(element, [
162
+ "width:100%",
163
+ `min-width:${Math.max(560, Math.min(totalWidth || 560, 1200))}px`,
164
+ "margin:22px 0",
165
+ "border-collapse:collapse",
166
+ "table-layout:fixed",
167
+ "color:#24292f"
168
+ ]);
169
+ } else if (tag === "td" || tag === "th") {
170
+ appendStyle(element, [
171
+ "padding:10px 12px",
172
+ "border:1px solid #d9dce3",
173
+ "vertical-align:middle",
174
+ "font-size:15px",
175
+ "line-height:1.7",
176
+ "overflow-wrap:anywhere",
177
+ "word-break:break-word"
178
+ ]);
179
+ } else if (tag === "col") {
180
+ const width = Number.parseInt(element.attr("width") || "0", 10);
181
+ if (width > 0) {
182
+ appendStyle(element, [`width:${Math.min(width, 1600)}px`]);
183
+ }
184
+ } else if (tag === "ol" || tag === "ul") {
185
+ appendStyle(element, ["margin:8px 0 16px", "padding-left:1.8em", "line-height:1.8"]);
186
+ } else if (tag === "li") {
187
+ appendStyle(element, ["margin:5px 0"]);
188
+ } else if (tag === "a") {
189
+ appendStyle(element, ["color:#245bdb", "text-decoration:underline", "overflow-wrap:anywhere"]);
190
+ }
191
+ }
192
+
193
+ export function convertLarkDocumentXml(xml, sourceUrl = "") {
194
+ const $ = cheerio.load(`<qcplay-root>${String(xml || "")}</qcplay-root>`, { xmlMode: true }, false);
195
+ const root = $("qcplay-root").first();
196
+ if (!root.length) {
197
+ throw new Error("飞书文档 XML 结构无效");
198
+ }
199
+
200
+ const titleElement = root.children("title").first();
201
+ const title = normalizeText(titleElement.text());
202
+ if (!title) {
203
+ throw new Error("飞书文档缺少标题");
204
+ }
205
+ if (titleElement.length) {
206
+ titleElement.get(0).name = "h1";
207
+ }
208
+
209
+ root.find("ol").each((_, list) => {
210
+ const firstSequence = Number.parseInt($(list).children("li").first().attr("seq") || "1", 10);
211
+ if (firstSequence > 1) {
212
+ $(list).attr("start", String(firstSequence));
213
+ }
214
+ });
215
+
216
+ root.find("img").each((_, image) => {
217
+ const element = $(image);
218
+ const url = normalizeText(element.attr("url") || element.attr("href") || element.attr("src"));
219
+ if (!url) {
220
+ throw new Error(`飞书文档图片缺少可访问地址: ${normalizeText(element.attr("token")) || "未知图片"}`);
221
+ }
222
+ let imageUrl;
223
+ try {
224
+ imageUrl = new URL(url);
225
+ } catch {
226
+ throw new Error("飞书文档图片地址无效");
227
+ }
228
+ if (!new Set(["http:", "https:"]).has(imageUrl.protocol)) {
229
+ throw new Error("飞书文档图片地址仅支持 HTTP/HTTPS");
230
+ }
231
+ element.attr("src", imageUrl.toString());
232
+ const width = Number.parseInt(element.attr("width") || "0", 10);
233
+ const height = Number.parseInt(element.attr("height") || "0", 10);
234
+ appendStyle(element, [
235
+ "display:block",
236
+ "max-width:100%",
237
+ "height:auto",
238
+ "margin:20px auto",
239
+ width > 0 ? `width:${Math.min(width, 1600)}px` : ""
240
+ ]);
241
+ if (width > 0) element.attr("width", String(width));
242
+ if (height > 0) element.attr("height", String(height));
243
+ });
244
+
245
+ root.find("source").each((_, source) => {
246
+ const element = $(source);
247
+ const url = normalizeText(element.attr("url"));
248
+ const name = normalizeText(element.attr("name")) || "飞书文档附件";
249
+ const paragraph = $("<p></p>");
250
+ if (url) {
251
+ paragraph.append($("<a></a>").attr("href", url).text(name));
252
+ } else {
253
+ paragraph.text(name);
254
+ }
255
+ element.replaceWith(paragraph);
256
+ });
257
+
258
+ root.find("*").each((_, node) => styleLarkElement($, node));
259
+ root.find("*").each((_, node) => {
260
+ const element = $(node);
261
+ for (const attribute of ["id", "token", "seq", "text-color", "background-color", "align", "url", "name"]) {
262
+ element.removeAttr(attribute);
263
+ }
264
+ });
265
+
266
+ const richHtml = sanitizeArticleRichHtml(root.html() || "", { maxWidth: 960 });
267
+ if (!richHtml) {
268
+ throw new Error("飞书文档没有可转换的正文内容");
269
+ }
270
+ const excerpt = normalizeText(
271
+ root
272
+ .children()
273
+ .filter((_, node) => String(node.tagName || node.name || "").toLowerCase() !== "h1")
274
+ .first()
275
+ .text()
276
+ ).slice(0, 160);
277
+ return {
278
+ title,
279
+ author: "飞书文档",
280
+ releaseDate: "",
281
+ excerpt,
282
+ thumbnail: normalizeText(root.find("img").first().attr("src")),
283
+ markdown: "",
284
+ richHtml,
285
+ imageCount: root.find("img").length,
286
+ tableCount: root.find("table").length,
287
+ sourceUrl: parseLarkDocumentUrl(sourceUrl).toString()
288
+ };
289
+ }
290
+
291
+ export async function importLarkArticle(value, options = {}) {
292
+ const document = await (options.fetchDocument || fetchLarkDocument)(value, options);
293
+ return convertLarkDocumentXml(document.xml, document.sourceUrl || value);
294
+ }