Commit 02b91fe5 authored by xuchentao's avatar xuchentao

feat: import Word articles with embedded images

parent f8a89584
Pipeline #432 passed with stage
in 17 seconds
...@@ -64,10 +64,32 @@ npm start ...@@ -64,10 +64,32 @@ npm start
| 发布 | `POST /api/cms/articles/:slug/unpublish` | 下架并生成静态页面 | | 发布 | `POST /api/cms/articles/:slug/unpublish` | 下架并生成静态页面 |
| 工具 | `POST /api/cms/preview` | Markdown 预览 | | 工具 | `POST /api/cms/preview` | Markdown 预览 |
| 工具 | `POST /api/cms/uploads` | 上传文章图片 | | 工具 | `POST /api/cms/uploads` | 上传文章图片 |
| 导入 | `POST /api/cms/imports/word` | 导入 `.docx` 为 Markdown 草稿并提取内嵌图片 |
| 构建 | `GET /api/cms/build` | 查询静态构建状态 | | 构建 | `GET /api/cms/build` | 查询静态构建状态 |
除会话登录接口外,外部程序可通过 `Authorization: Bearer <CMS_API_KEY>` 调用这些地址。 除会话登录接口外,外部程序可通过 `Authorization: Bearer <CMS_API_KEY>` 调用这些地址。
### 通过 API 导入 Word
Word 导入接口接收不超过 20MB 的 `.docx` Data URL,不支持旧版 `.doc`。文档第一个一级标题会作为默认文章标题并从正文中移除;也可以显式传入 `title` 覆盖。内嵌的 PNG、JPG、WEBP、GIF 图片会自动保存到上传目录并转成 Markdown 图片链接。导入只创建草稿,不会自动发布。
```http
POST /api/cms/imports/word
Authorization: Bearer <CMS_API_KEY>
Content-Type: application/json
```
```json
{
"fileName": "健康科普.docx",
"dataUrl": "data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,...",
"category": "健康科普",
"title": "可选的自定义标题"
}
```
成功后返回自动生成的 `slug``title``imageCount`、转换警告和草稿状态。确认内容后再调用 `POST /api/cms/articles/:slug/publish` 发布。
## 内容目录 ## 内容目录
- `src/content/articles/`:后台工作稿,包括草稿、待发布和已发布文章 - `src/content/articles/`:后台工作稿,包括草稿、待发布和已发布文章
......
This diff is collapsed.
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
"build:astro": "astro check && astro build", "build:astro": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"check": "astro check", "check": "astro check",
"test": "node --import tsx/esm --test tests/*.test.ts",
"start": "node --env-file=.env server.mjs", "start": "node --env-file=.env server.mjs",
"admin": "npm start", "admin": "npm start",
"serve": "npm run build && npm start" "serve": "npm run build && npm start"
...@@ -22,10 +23,16 @@ ...@@ -22,10 +23,16 @@
"@astrojs/sitemap": "^3.7.3", "@astrojs/sitemap": "^3.7.3",
"astro": "5.18.2", "astro": "5.18.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"marked": "^14.1.4" "mammoth": "^1.12.0",
"marked": "^14.1.4",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.6", "@astrojs/check": "^0.9.6",
"@types/turndown": "^5.0.6",
"docx": "^9.7.1",
"tsx": "^4.23.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }
...@@ -475,3 +475,11 @@ export async function saveUpload(dataUrl: unknown): Promise<string> { ...@@ -475,3 +475,11 @@ export async function saveUpload(dataUrl: unknown): Promise<string> {
await writeAtomic(path.join(UPLOADS_DIR, name), buffer); await writeAtomic(path.join(UPLOADS_DIR, name), buffer);
return `/uploads/${name}`; return `/uploads/${name}`;
} }
export async function removeUpload(url: string): Promise<void> {
const match = url.match(/^\/uploads\/([a-zA-Z0-9._-]+)$/);
if (!match) return;
await fs.unlink(path.join(UPLOADS_DIR, match[1])).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
listWorkingArticles, listWorkingArticles,
publishArticle, publishArticle,
readWorkingArticle, readWorkingArticle,
removeUpload,
removeCategory, removeCategory,
renameCategory, renameCategory,
safeSlug, safeSlug,
...@@ -20,6 +21,7 @@ import { ...@@ -20,6 +21,7 @@ import {
unpublishArticle, unpublishArticle,
writeArticle, writeArticle,
} from "./article-store"; } from "./article-store";
import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import";
import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs"; import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs";
const PASSWORD = process.env.CMS_PASSWORD || "admin"; const PASSWORD = process.env.CMS_PASSWORD || "admin";
...@@ -262,6 +264,35 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -262,6 +264,35 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
} }
} }
if (route === "imports/word" && method === "POST") {
const body = await bodyOf(request);
const fileName = String(body.fileName || "").trim();
const buffer = decodeWordDataUrl(body.dataUrl, fileName);
const uploadedUrls: string[] = [];
try {
const converted = await convertWordToMarkdown(buffer, fileName, async (dataUrl) => {
const url = await saveUpload(dataUrl);
uploadedUrls.push(url);
return url;
});
const title = String(body.title || "").trim() || converted.suggestedTitle;
const input = { title, category: body.category, body: converted.body };
const slug = await generateSlug(body.category);
const article = await withSiteBuildLock(() => writeArticle(slug, input, true));
return json({
ok: true,
slug,
title,
publishStatus: await getPublishStatus(article),
imageCount: converted.imageCount,
warnings: converted.warnings,
}, 201);
} catch (error) {
await Promise.allSettled(uploadedUrls.map((url) => removeUpload(url)));
throw error;
}
}
if (parts[0] === "articles" && parts[1]) { if (parts[0] === "articles" && parts[1]) {
const slug = requireSlug(parts[1]); const slug = requireSlug(parts[1]);
if (parts.length === 3 && parts[2] === "draft" && method === "DELETE") { if (parts.length === 3 && parts[2] === "draft" && method === "DELETE") {
......
import mammoth from "mammoth";
import TurndownService from "turndown";
import { gfm } from "turndown-plugin-gfm";
import { StoreError } from "./article-store";
export const MAX_WORD_FILE_SIZE = 20 * 1024 * 1024;
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const ACCEPTED_DOCX_MIMES = new Set([DOCX_MIME, "application/octet-stream", "application/zip"]);
const IMAGE_MIME_ALIASES: Record<string, string> = {
"image/jpg": "image/jpeg",
"image/x-png": "image/png",
};
const SUPPORTED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
export interface WordImportResult {
body: string;
suggestedTitle: string;
imageCount: number;
warnings: string[];
}
export function decodeWordDataUrl(dataUrl: unknown, fileName: unknown): Buffer {
const name = String(fileName || "").trim();
if (/\.doc$/i.test(name)) throw new StoreError("仅支持 .docx 文件,不支持旧版 .doc 格式");
if (!/\.docx$/i.test(name)) throw new StoreError("请选择 .docx 文件");
const match = String(dataUrl || "").match(/^data:([^;,]+);base64,([a-zA-Z0-9+/=\s]+)$/s);
if (!match || !ACCEPTED_DOCX_MIMES.has(match[1].toLowerCase())) {
throw new StoreError("Word 文件格式不正确,请上传 .docx 文件");
}
const buffer = Buffer.from(match[2].replace(/\s/g, ""), "base64");
if (!buffer.length || buffer.subarray(0, 2).toString("ascii") !== "PK") {
throw new StoreError("Word 文件无效或已经损坏");
}
if (buffer.length > MAX_WORD_FILE_SIZE) throw new StoreError("Word 文件不能超过 20MB", 413);
return buffer;
}
function titleFromFileName(fileName: string): string {
return fileName
.replace(/^.*[\\/]/, "")
.replace(/\.docx$/i, "")
.replace(/[_-]+/g, " ")
.trim() || "Word 导入文章";
}
function plainHeading(value: string): string {
return value
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[`*_~]/g, "")
.trim();
}
export async function convertWordToMarkdown(
buffer: Buffer,
fileName: string,
saveImage: (dataUrl: string) => Promise<string>,
): Promise<WordImportResult> {
let imageCount = 0;
let result: Awaited<ReturnType<typeof mammoth.convertToHtml>>;
try {
result = await mammoth.convertToHtml({ buffer }, {
styleMap: ["p[style-name='Title'] => h1:fresh"],
convertImage: mammoth.images.imgElement(async (image) => {
const mime = IMAGE_MIME_ALIASES[image.contentType] || image.contentType;
if (!SUPPORTED_IMAGE_MIMES.has(mime)) {
throw new StoreError(`Word 中包含不支持的图片格式:${image.contentType || "未知格式"}`);
}
const base64 = await image.readAsBase64String();
const src = await saveImage(`data:${mime};base64,${base64}`);
imageCount += 1;
return { src };
}),
});
} catch (error) {
if (error instanceof StoreError) throw error;
throw new StoreError(`Word 文件解析失败:${error instanceof Error ? error.message : "文件无效或已经损坏"}`);
}
const turndown = new TurndownService({
bulletListMarker: "-",
codeBlockStyle: "fenced",
headingStyle: "atx",
});
turndown.use(gfm);
let body = turndown.turndown(result.value).trim();
let suggestedTitle = titleFromFileName(fileName);
const firstHeading = body.match(/^#\s+(.+?)(?:\n{2,}|\n|$)/);
if (firstHeading) {
suggestedTitle = plainHeading(firstHeading[1]) || suggestedTitle;
body = body.slice(firstHeading[0].length).trim();
}
if (!body) throw new StoreError("Word 文件中没有可导入的正文内容");
return {
body,
suggestedTitle,
imageCount,
warnings: result.messages.map((message) => message.message),
};
}
declare module "turndown-plugin-gfm" {
import type TurndownService from "turndown";
export function gfm(service: TurndownService): void;
}
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { after } from "node:test";
import { Document, HeadingLevel, ImageRun, Packer, Paragraph, TextRun } from "docx";
const dataDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "sumeiqiao-word-import-"));
process.env.CMS_DATA_DIR = dataDirectory;
process.env.CMS_BUILD_LOCK = path.join(dataDirectory, "site-build.lock");
process.env.CMS_API_KEY = "word-import-test-key";
const { handleCmsApi } = await import("../src/lib/cms-api");
after(async () => {
await fs.rm(dataDirectory, { recursive: true, force: true });
});
async function wordDocument(): Promise<Buffer> {
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z1xkAAAAASUVORK5CYII=",
"base64",
);
const document = new Document({
sections: [{
children: [
new Paragraph({ text: "Word 导入测试", heading: HeadingLevel.HEADING_1 }),
new Paragraph({ children: [new TextRun("这是一段来自 Word 的正文。")] }),
new Paragraph({ children: [new ImageRun({ data: png, transformation: { width: 16, height: 16 }, type: "png" })] }),
],
}],
});
return Packer.toBuffer(document);
}
function apiRequest(body: Record<string, unknown>, key = "word-import-test-key"): Request {
return new Request("http://localhost/api/cms/imports/word", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
test("imports a docx with an embedded image as a Markdown draft", async () => {
const buffer = await wordDocument();
const response = await handleCmsApi(apiRequest({
fileName: "测试文章.docx",
category: "健康科普",
dataUrl: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${buffer.toString("base64")}`,
}), "imports/word");
assert.equal(response.status, 201);
const imported = await response.json();
assert.equal(imported.ok, true);
assert.equal(imported.title, "Word 导入测试");
assert.equal(imported.publishStatus, "new-draft");
assert.equal(imported.imageCount, 1);
const articleResponse = await handleCmsApi(new Request(`http://localhost/api/cms/articles/${imported.slug}`, {
headers: { Authorization: "Bearer word-import-test-key" },
}), `articles/${imported.slug}`);
assert.equal(articleResponse.status, 200);
const article = await articleResponse.json();
assert.equal(article.category, "健康科普");
assert.match(article.body, /这是一段来自 Word 的正文/);
assert.doesNotMatch(article.body, /^#\s+Word 导入测试/);
const imageUrl = article.body.match(/!\[[^\]]*\]\((\/uploads\/[^)]+)\)/)?.[1];
assert.ok(imageUrl);
const image = await fs.readFile(path.join(dataDirectory, imageUrl.replace(/^\/uploads\//, "uploads/")));
assert.equal(image.subarray(1, 4).toString("ascii"), "PNG");
});
test("requires API authentication", async () => {
const response = await handleCmsApi(apiRequest({}, "wrong-key"), "imports/word");
assert.equal(response.status, 401);
});
test("rejects legacy .doc files", async () => {
const response = await handleCmsApi(apiRequest({
fileName: "旧文章.doc",
dataUrl: "data:application/octet-stream;base64,AA==",
}), "imports/word");
assert.equal(response.status, 400);
assert.match((await response.json()).error, /不支持旧版 \.doc/);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment