从PDF文件中提取文本听起来很简单,但真正尝试去做的时候就会发现其实并不容易。对于JavaScript开发者来说,由于需要选择各种不同的库,这个过程会更加具有挑战性。
在我开发SaaS应用程序的过程中,就遇到了这个问题。我查阅了StackOverflow、Reddit和Quora等网站,但却没有找到满意的解决方案。有些方法不切实际,而另一些方法则需要进行复杂的配置。
经过一番尝试后,我想:“算了,还是自己编写一个PDF解析器吧。”在Claude和Node.js的帮助下,我为自己的SaaS应用程序开发了一个自定义的PDF解析器。
在这个教程中,我会向你们展示我是如何使用Node.js来构建这个自定义PDF解析器的,同时也会告诉你们如何才能做到同样的事情。
目录
为什么要开发自定义的PDF文本提取器?
你可能会问自己:“既然已经有了各种现成的库,为什么还要自己编写一个PDF解析器呢?”
常见的JavaScript PDF解析器都存在各种优缺点。以下是对几种常用选项的简要对比:
| 库名称 | 文本提取功能 | TypeScript支持情况 | 依赖项 | 布局/表格处理能力 | 最适合的场景 |
| pdf-parse | 仅提供基础功能 | 部分支持文本提取 | 无依赖项 | 布局处理能力较弱 | 适用于快速、简单的文本提取场景 |
| pdfjs-dist | 功能较为强大 | 全面支持文本提取 | 无依赖项 | 布局处理能力一般 | 适合自定义解析和渲染需求 |
| pdf2json | 输出JSON格式数据 | 部分支持文本提取 | 无依赖项 | 适用于结构化数据的处理 | 适合导出结构化数据 |
| pdf-text-extract | 仅用于文本提取 | 无依赖项 | 需要Poppler库才能使用 | 基础功能 | 可通过CLI或简单脚本进行操作 |
这些库在特定场景下表现良好,但自行开发解析器仍然具有以下优势:
-
你可以根据自身项目需求选择合适的技术栈
-
只需添加项目真正需要的功能即可
好消息是,你完全可以根据自己的项目需求开发专用的JavaScript解析器,而无需依赖外部库或调整为其他生态系统设计的库。
自定义解析器能让你完全掌控代码逻辑,同时避免不必要的功能冗余。
我们将要开发的示例
以下是我们文本提取工具的实际运行截图:

先决条件
为了顺利完成本教程,我假设你已满足以下条件:
-
你的机器上已经安装了Node.js。如果还没有安装,可以访问Node.js官方网站进行下载。
-
你知道如何编写基本的TypeScript代码。
项目设置
在本节中,你将完成项目的配置。该项目使用的是Node.js与TypeScript,而非普通的JavaScript。
如果你还不了解如何为Node.js配置TypeScript,不用担心——本节会为你详细说明操作步骤。
初始化Node.js项目
打开你希望存放项目的文件夹,然后创建一个新的Node.js项目:
npm init -y
接下来安装所需的包:
npm install cors express-fileupload pdf-parse
-
cors:用于实现跨源资源共享,使你的API能够接收来自不同域名或端口的请求。 -
express-fileupload:Express框架中的中间件,用于处理文件上传功能,便于处理PDF文件的上传操作。 -
pdf-parse:一个轻量级的PDF解析库,可用于从PDF文件中提取文本和元数据。 -
express:Node.js的核心Web框架,负责处理路由、中间件以及服务器配置等工作。
现在,让我们继续进行安装操作:
npm install -D typescript ts-node @types/node @types/express nodemon prettier dotenv @types/cors @types/express-fileupload
-D选项会指示npm将这些库作为开发依赖项进行安装。
-
ts-node:允许你直接在Node.js环境中运行TypeScript代码,而无需先将其编译成JavaScript。 -
@types/node:为Node.js的核心模块(如fs、path和http)提供TypeScript类型定义。 -
@types/express:为Express.js框架及其中间件提供TypeScript类型定义。 -
nodemon:每当你对代码进行修改并保存时,它会自动重启你的开发服务器。 -
prettier:这是一个代码格式化工具,能够确保整个项目的代码风格统一且易于阅读。
在Node.js应用中配置TypeScript
首先,我们来生成一个tsconfig.json文件:
npx tsc --init
TypeScript项目会使用tsconfig.json文件来管理项目的配置设置。该配置文件位于项目的根目录下。
运行命令后,你应该会看到这样一个tsconfig.json文件:
{
// 访问https://aka.ms/tsconfig了解更多关于此文件的信息
"compilerOptions": {
// 文件结构配置
"rootDir": "./src",
"outDir": "./dist",
// 环境设置
// 详情请参阅https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": [],
// 对于Node.js环境
"lib": ["esnext"],
"types": ["node"],
// 需要执行npm install -D @types/node命令来安装这些类型定义
// 其他输出配置
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// 更严格的类型检查选项
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// 代码风格选项
"noImplicitReturns": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"no UnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
// 推荐使用的选项
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffect Imports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
}
将 `"node"` 添加到 `types` 数组中,具体方法如下:
"types": ["node"]
然后使用以下代码修改你的 `package.json` 文件:
{
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "nodemon --watch src --ext ts,json --exec \"node --loader ts-node/esm src/server.ts\"",
"build": "tsc",
"start": "node src/server.js"
},
"type": "module"
}
这样就能确保你的应用程序的入口文件是 TypeScript 文件,因此你可以使用 `import` 语句而不是 `require` 语句。
在下一节中,你将构建 PDF 解析器。
核心实现:构建解析器
配置完 Node.js 应用程序后,下一步就是构建 PDF 解析器。
在你的 Node.js 项目中创建一个新目录,并编写一个名为 `server.ts` 的文件。
现在,导入构建 PDF 解析器所需的包:
import express, { type Request, type Response } from "express";
import fileUpload, { type UploadedFile } from "express-fileupload";
import { PDFParse } from "pdf-parse";
import cors from "cors";
const app = express();
const PORT = process.env.PORT || 8080;
让我们来了解一下这些包的作用:
-
fileUpload是用于在 Express 应用中处理文件上传的模块。`UploadedFile` 是表示上传文件的 TypeScript 类型。 -
PDFParse是核心的 PDF 解析模块,它提供了解析 PDF 文件的基本功能。 -
cors用于保护应用程序免受来自未指定源地址的请求的攻击。 -
你通过 `
const app = express();` 这一行代码创建了一个 Express 应用程序。 -
PORT表示你的应用程序应该运行在哪个端口上。
配置 CORS 中间件
配置 CORS 可以允许来自指定源地址的请求,从而保护你的应用程序免受攻击。
app.use(
cors({
origin: ["http://localhost:3000", "https://yourwebsite.com"],
})
);
实现文件上传中间件
为了在 API 中处理文件上传,你需要使用 `express-fileupload` 中间件。这个中间件会拦截所有的文件上传请求,并通过 `req.files` 变量使这些文件可供后续代码使用。
你可以对传入的文件进行检查,比如检查文件的大小和文件的数量。
import fileUpload, { type UploadedFile } from "express-fileupload";
app.use(
fileUpload({
limits: { fileSize: 50 * 1024 * 1024 }, // 最大文件大小为50 MB
abortOnLimit: true,
})
);
主要配置选项:
-
fileSize:设置允许的最大文件大小(本例中为50 MB) -
abortOnLimit:当设置为true时,会自动拒绝超过大小限制的文件上传,并阻止进一步处理这些文件
这样设置的重要性在于:
-
安全性:通过限制文件大小,可以防止服务器因处理过大文件而负担过重。
-
性能:能够自动拒绝过大的PDF文件,从而避免在处理过程中消耗过多资源。
-
用户体验:对于过大文件,系统会给出明确的错误提示,方便用户了解问题所在。
创建解析逻辑
解析逻辑是用于解析PDF文件的核心功能。它是一个异步函数,能够从PDF缓冲区中提取文本内容和元数据。
async function parsePDF(file: Uint8Array) {
const parser = new PDFParse(file);
const data = await parser.getText();
const info = await parser.getInfo({ parsePageInfo: true });
return { text: data?.text || "", info, numpages: info?.pages || 0 };
}
让我们来看看代码中具体发生了什么:
-
该函数接受一个包含原始PDF文件数据的
Uint8Array缓冲区作为参数。 -
使用这个PDF缓冲区创建了一个新的
PDFParse对象。 -
调用
getText()方法来提取PDF文件中的所有文本内容。 -
同时调用
getInfo()方法,并设置parsePageInfo: true,以获取文档的元信息,包括页数。 -
最终返回一个对象,其中包含以下内容:
-
text:提取出的文本内容(如果没有找到文本,则返回空字符串) -
info:文档的元数据,如作者、标题、创建日期等 -
numpages:PDF文件的总页数
-
为什么解析逻辑是异步的?
无论是getText()还是getInfo(),都属于异步操作。它们都需要一定的时间来解析PDF文件的内容,因此使用await可以确保这些操作完成后再返回结果。这样就能避免在处理大型PDF文件时导致服务器堵塞。
创建PDF上传与处理端点
现在你已经拥有了核心的parsePDF()函数,接下来需要创建一个端点,该端点能够接收文件上传,并使用这个函数来处理这些文件。
app.post("/upload", async (req: Request, res: Response) => {
try {
if (!req.files || !("file" in req.files)) {
return res.status(400).json({
error: "没有分享PDF文件。",
body: `请求体内容为:${JSON.stringify(req.body)}`,
});
}
const pdfFile = req.files.file as UploadedFile;
const unit8ArrayData = new Uint8Array(pdfFile?.data);
const result = await parsePDF(unit8ArrayData);
console.log("PDF文件解析成功,结果为:", result);
res.json({ result, success: true });
} catch (error) {
console.error("处理PDF文件时出现错误:", error);
if (error instanceof Error) {
return res.status(500).json({
error: error.message,
success: false,
});
}
res.status(500).json({
error: "由于未知原因,无法处理PDF文件。",
success: false,
});
}
});
让我们来详细分析这段代码中发生的作用:
-
你在 `
/upload` 路由上定义了一个处理 PDF 文件上传的函数。该函数使用 `req.files` 来获取上传的文件,并检查请求中是否包含 “file” 字段。 -
该函数会提取上传的 PDF 文件,并将其转换为 `
Uint8Array` 类型,因为 `parsePDF()` 函数需要这种格式才能进行 PDF 解析操作。 -
你使用了 try-catch 块来实现全面的错误处理机制:
-
会将错误信息记录到控制台,以便于调试。
-
当错误属于 `Error` 类时,会返回具体的错误信息。
-
对于其他意外故障,也会返回相应的错误响应,并确保客户端收到的响应中始终包含 `success: false` 这一字段,以保证一致性。
-
这个路由处理器创建了一个用于处理 PDF 文件的接口:它能够验证输入数据、高效地处理文件上传操作,并提供清晰的错误反馈信息。
启动你的服务器
最后一步就是启动 Express 服务器,确认它是否正常运行。
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`🚀 服务器正在 http://localhost:${PORT} 上运行`);
});
-
app.listen():将 Express 服务器绑定到指定的端口上,然后开始监听传入的请求。 -
端口号配置:如果设置了 `PORT` 环境变量,服务器就会使用该值;否则默认使用 `8080` 端口。
-
回调函数:当服务器启动后,这个回调函数会向控制台输出服务器的地址。
使用以下命令来启动你的服务器:
npm run dev
如果服务器成功启动,你会在控制台中看到如下信息:
🚀 服务器正在 http://localhost:8080 上运行
现在,你的 PDF 解析器 API 已经可以接收文件上传并处理 PDF 文件了。
你可以通过 Postman 或其他喜欢的 API 客户端,访问该接口来验证它的功能是否正常。

恭喜!你成功开发出了一个自定义的 PDF 解析器。
这个解析器对于简单的 PDF 处理任务来说已经足够使用了,但你也可以进一步扩展它的功能,使其更加强大。
在接下来的章节中,你会添加一些额外的功能,比如处理损坏的文件。
添加针对特定页面的提取功能
在处理较大的PDF文件时,提取整个文件的内容往往既效率低下又没有必要。这一功能允许用户指定特定的页面范围,从而仅从这些页面中提取文本。这样一来,解析器在面对实际应用场景时就会变得更加灵活、高效。
例如,用户可能只想从一份100页的报告中提取第5页到第10页的内容。通过在你的接口中添加可选的查询参数startPage和endPage,用户就可以精确地控制自己想要解析PDF文档中的哪些部分。
在本节中,你将创建一个专门用于提取特定页面内容的函数,以及一个用于处理请求参数的接口。
创建用于提取特定页面内容的函数
这个专门用于提取特定页面内容的函数是整个系统的核心部分,它的作用就是解析用户指定的那些页面。
// 用于从指定页面范围中提取文本的函数
async function parsePageRangeFromPDF(
file: Uint8Array,
startPage: number,
endPage: number,
) {
const parser = new PDFParse(file);
const info = await parser.getInfo({ parsePageInfo: true });
const totalPages = Array.isArray(info?.pages)
? info.pages.length
: (info?.pages as number) || 0;
if (startPage < 1 || endPage > totalPages || startPage > endPage) {
throw new Error(
`无效的页面范围。该PDF文件共有${totalPages}页。请提供有效的页面范围,确保开始页数大于或等于1,结束页数小于或等于总页数,并且开始页数小于结束页数。`
);
}
const data = await parser.getText();
const lines = data?.text?.split("\n") || [];
// 注意:pdf-parse库并不提供直接的页面筛选功能,因此getText()方法会返回文档中的所有文本。
// 如果你需要准确提取指定范围内的页面内容,可以考虑使用其他PDF处理库。
return { text: data?.text || "", startPage, endPage, totalPages };
}
让我们来详细分析一下这段代码的功能:
-
你定义了一个
async函数parsePageRangeFromPDF,它的作用是从PDF文档中提取指定页面范围内的文本。这个函数接受一个Uint8Array类型的PDF文件对象,以及两个表示起始页码和结束页码的数值参数。 -
该函数使用了
PDFParse库来分析PDF文件的结构,首先通过parser.getInfo()方法获取文档的元数据信息,包括总页数。然后它会检查用户请求的页面范围是否在文档的有效范围内。 -
验证通过后,函数会使用
parser.getText()方法提取所有文本内容,并将这些文本按行分割开来。最后,它返回一个对象,其中包含了提取到的文本、以及关于请求的页面范围和总页数的元数据信息。
这个函数为从PDF文件中提取特定页面内容提供了便捷且可靠的解决方案,同时还包含了完善的验证机制和错误处理功能。
创建用于提取特定页面内容的接口
现在你已经创建了用于解析PDF文档中指定页面内容的函数,接下来就需要创建一个接口,用来接收用户上传的PDF文件并执行相应的解析操作。
// 用于提取指定页面范围内的PDF文本的端点
app.post("/upload-page-range", async (req: Request, res: Response) => {
try {
if (!req.files || !("file" in req.files)) {
return res.status(400).json({
error: "没有分享PDF文件。",
});
}
// 从查询参数或请求体中获取页面范围
const startPage = parseInt(
(req.query.startPage as string) || (req.body?.startPage as string) || "1"
);
const endPage = parseInt(
(req.query.endPage as string) || (req.body?.endPage as string) || "1"
);
if (isNaN(startPage) || isNaN(endPage)) {
return res.status(400).json({
error: "页面范围无效。请提供有效的整数作为startPage和endPage。",
});
}
const pdfFile = req.files.file as UploadedFile;
const unit8ArrayData = new Uint8Array(pdfFile?.data);
const result = await parsePageRangeFromPDF(
unit8ArrayData,
startPage,
endPage
);
console.log(
`成功提取了从${startPage}到${endPage}的页面内容:`,
result
);
res.json({ result, success: true });
} catch (error) {
console.error("处理PDF文件时出现错误:", error);
if (error instanceof Error) {
return res.status(400).json({ error: error.message, success: false });
}
res.status(500).json({
error: "由于未知原因,无法处理PDF文件。",
success: false,
});
}
});
让我们来分析一下这段代码中发生的操作:
-
你在 `
/upload-page-range` 路由上定义了一个处理程序,该程序可以从上传的 PDF 文件中提取特定页码范围内的文本。首先,这个处理程序会通过 `req.files` 来检查请求中是否包含 PDF 文件;如果没有文件,就会返回 400 错误。 -
该函数会从查询参数或请求体中获取 `startPage` 和 `endPage` 的值,如果这两个参数都没有被指定,它会使用默认值 “1”。然后,它会通过 `isNaN()` 函数来验证这些值是否为有效的整数,这样就能确保对页码范围请求的处理更加可靠。
-
一旦 PDF 文件被转换成缓冲区,它就会被传递给 `parsePageRangeFromPDF()` 函数,以便提取用户所要求的页面内容。API 会返回提取到的文本以及相关的页码范围信息;如果出现错误,也会明确指出错误类型:验证失败会导致 400 错误,服务器问题则会导致 500 错误。
这个接口为客户端提供了一种专门用于处理 PDF 文件的机制,使用户能够只提取特定页码范围内的文本,而无需处理整个文档。
现在,你可以使用查询参数来指定要提取的页面了:
curl -F "file=@yourfile.pdf" "http://localhost:8080/upload-page-range?startPage=5&endPage=7"
或者也可以通过请求体来传递这些参数:
curl -X POST -F "file=@yourfile.pdf" \
-F "startPage=5" \
-F "endPage=7" \
http://localhost:8080/upload-page-range
在下一节中,你将添加一个专门用于获取上传文件元数据的接口。
添加仅提供元数据的轻量级接口
创建这样一个仅提供元数据的轻量级接口,可以让用户快速验证和查看 PDF 文件的内容,而无需对整个文档进行处理。
这在处理文件之前预览文件信息时非常有用。
创建元数据提取函数
添加一个仅用于获取文档信息的新函数:
async function getPDFMetadata(file: Uint8Array) {
const parser = new PDFParse(file);
const info = await parser.getInfo({ parsePageInfo: true });
return {
title: info?.info?.Title || "N/A",
author: info?.info?.Author || "N/A",
subject: info?.info?.Subject || "N/A",
creator: info?.info?.Creator || "N/A",
producer: info?.info?.Producer || "N/A",
creationDate: convertPDFDateToReadable(info?.info?.CreationDate || "N/A"),
modificationDate: convertPDFDateToReadable(info?.info?.ModDate || "N/A"),
pages: info?.total || 0,
};
}
让我们来分析一下这段代码中发生的操作:
-
你定义了一个名为`getPDFMetadata`的`async`函数,该函数用于从PDF文档中提取并处理元数据。这个函数接受一个`Uint8Array`类型的PDF文件缓冲区,并使用`PDFParse`库来获取文档信息。
-
该函数会提取PDF文档中的关键元数据字段,包括标题、作者、主题、创建者和制作者等;当这些字段缺失时,会返回“N/A”作为默认值。这样一来,即使某些PDF文件缺少部分信息,该函数也能始终返回一个完整的元数据对象。
-
你还实现了一个名为`convertPDFDateToReadable`的辅助函数,用于将PDF文档中特殊的日期格式转换成人类可读的形式。这个函数会返回一个结构化对象,其中包含所有提取到的元数据以及总页数。
这个实用函数为提取和规范处理PDF元数据提供了便捷的方式,使人们能够以标准化的格式轻松获取文档的作者信息、创建日期和页数等数据。
以下是`convertPDFDateToReadable`函数的实现代码:
function convertPDFDateToReadable(pdfDateString: string): string {
try {
// 如果日期字符串以“D:”开头,则去除这个前缀
let dateStr = pdfDateString.startsWith("D:")
? pdfDateString.slice(2)
: pdfDateString;
// 提取日期和时间成分(格式为YYYYMMDDHHmmss)
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
const hour = dateStr.substring(8, 10);
const minute = dateStr.substring(10, 12);
const second = dateStr.substring(12, 14);
// 验证日期成分的有效性
const monthNum = parseInt(month);
const dayNum = parseInt(day);
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
throw new Error("无效的日期值");
}
// 返回格式为dd/mm/yyyy的日期字符串
return `${day}/${month}/${year}`;
} catch (error) {
console.error("在转换PDF日期时发生错误:", error);
return "无效的日期";
}
}
创建元数据端点
创建一个POST端点,该端点接受文件上传,并仅返回元数据信息:
app.post("/metadata", async (req: Request, res: Response) => {
try {
if (!req.files || !("file" in req.files)) {
return res.status(400).json({
error: "没有上传PDF文件。",
});
}
const pdfFile = req.files.file as UploadedFile;
const unit8ArrayData = new Uint8Array(pdfFile?.data);
const metadata = await getPDFMetadata(unit8ArrayData);
console.log("成功提取了PDF元数据:", metadata);
res.json({ metadata, success: true });
} catch (error) {
console.error("在提取元数据时发生错误:", error);
if (error instanceof Error) {
return res.status(500).json({ error: error.message, success: false });
}
res.status(500).json({
error: "由于未知原因,无法提取元数据。",
success: false,
});
}
});
让我们来详细分析这段代码中发生的操作:
-
你在 `
/metadata` 路由上定义了一个处理程序,该程序会从上传的 PDF 文件中提取元数据并将其返回。首先,该程序会使用 `req.files` 来验证请求中是否确实存在 PDF 文件;如果没有文件被提供,就会返回一个带有明确错误信息的 400 错误码。 -
随后,这个函数会将上传的 PDF 文件转换为 `
Uint8Array` 格式,因为你之前创建的 `getPDFMetadata()` 工具函数需要这种格式的数据。这种转换能确保 PDF 数据具备被 PDF 解析库处理的正确格式。 -
在成功提取元数据后,该处理程序会将结果记录下来,并以结构化的 JSON 格式返回给客户端。完善的错误处理机制能够捕获处理过程中出现的任何问题,在遇到问题时就会返回相应的 500 错误码及描述性信息,同时确保响应格式的一致性。
这个接口为提取 PDF 文件的元数据提供了专门的支持,比如文件标题、作者、创建日期和页数等信息。这样一来,用户就可以轻松分析 PDF 文档的属性,而无需逐个解析文件的全部内容。
现在,你可以仅提取上传文件的元数据了:
curl -X POST -F "file=@document.pdf" http://localhost:8080/metadata
你收到的响应应该如下所示:
{
"metadata": {
"title": "MSA",
"author": "N/A",
"subject": "N/A",
"creator": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/144.0.0.0 Safari/537.36",
"producer": "Skia/PDF m144",
"creationDate": "22/01/2026",
"modificationDate": "22/01/2026",
"pages": 26
},
"success": true
}
添加搜索/查找功能
在处理较大的 PDF 文件时,手动查找特定信息会非常耗时。搜索功能允许用户在 PDF 文件中查找关键词,并立即得到结果显示哪些页面包含了该关键词以及该关键词出现了多少次。
这一功能对于研究、合规性检查或文档分析等工作来说尤为有用。
例如,用户可能希望在一份长达50页的财务报告中找到所有包含“发票”这个词的内容,或者在一份法律文件中查找“第3.2条”这一条款。通过提供一个专门用于搜索的接口,该接口能够接受PDF文件和关键词作为输入,你就能帮助客户快速浏览大量文档,而无需逐一阅读每一页的内容。
在本节中,你将创建一个搜索函数,该函数能够在PDF文本中查找关键词,并且还会创建一个接收文件上传以及搜索查询的接口。
创建搜索/查找功能
搜索函数是核心工具,它能够在PDF文档中找到关键词,并返回关于这些关键词位置的详细信息。
async function searchPDFText(
file: Uint8Array,
searchQuery: string,
caseSensitive: boolean = false
) const parser = new PDFParse(file);
const info = await parser.getInfo({ parsePageInfo: true });
const totalPages = Array.isArray(info?.pages)
? info.pages.length
: (info?.pages as number) || 0;
const results = {
query: searchQuery,
caseSensitive,
matchCount: 0,
matches: [] as Array<{
page: number;
text: string;
position: number;
}>,
};
// 从所有页面中提取文本
for (let page = 1; page <= totalPages; page++) {
const data = await parser.getText();
const pageText = data?.text || "";
// 根据大小写敏感设置来确定搜索文本
const searchText = caseSensitive ? searchQuery : searchQuery.toLowerCase();
const compareText = caseSensitive ? pageText : pageText.toLowerCase();
let searchIndex = 0;
while ((searchIndex = compareText.indexOf(searchText, searchIndex)) !== -1) {
// 提取上下文内容(搜索文本前后各100个字符)
const startContext = Math.max(0, searchIndex - 50const endContext = Math.min(pageText.length, searchIndex + searchQuery.length + 50;
const contextText = pageText.substring(startContext, endContext);
results.matches.push({
page,
text: contextText.trim(),
position: searchIndex,
});
results.matchCount++;
searchIndex += searchText.length;
}
}
return results;
}
让我们来分析一下这段代码中发生了什么:
-
你定义了一个名为`searchPDFText`的`async`函数,该函数能够在PDF文档中执行文本搜索,并且可以选择是否区分大小写。这个函数接受一个PDF文件缓冲区、一个搜索查询字符串,以及一个`caseSensitive`参数,默认值为`false`,这样就可以实现更加灵活的搜索功能。
-
该函数使用了`PDFParse`库来首先从PDF元数据中提取总页数。然后它会创建一个结果对象,用于记录搜索查询信息、大小写设置、匹配项的总数以及每个匹配项的具体位置、上下文文本等信息。
-
对于PDF中的每一页,该函数都会提取文本,并根据`caseSensitive`参数的设置来执行区分大小写或不区分大小写的搜索。当找到匹配项时,它会截取每个匹配项前后各100个字符作为上下文内容,并将页面编号、位置以及上下文文本记录在结果对象中。
这个函数构建了一个功能强大的PDF搜索工具,它能够在文档中定位特定的文本内容,同时为每一个匹配项提供相应的上下文信息。因此,它在文档分析和内容检索应用中非常有用。
创建搜索/查找端点
现在你已经创建了这个搜索函数,接下来需要创建一个端点,该端点能够接收文件上传以及搜索查询字符串,并且也支持区分大小写的搜索功能。
app.post("/search", async (req: Request, res: Response) => {
try {
if (!req.files || !("file" in req.files)) {
return res.status(400).json({
error: "没有上传PDF文件。,
});
}
// 获取搜索查询字符串和选项
const query = (req.query.query as string) || (req.body?.query as string;
const caseSensitive =
(req.query.caseSensitive as string) === "true" ||
req.body?.caseSensitive === true;
if (!query || query.trim() === "") {
return res.status(400).json({
error: "需要提供搜索查询字符串。,
});
}
const pdfFile = req.files.file as UploadedFile;
const unit8ArrayData = new Uint8Array(pdfFile?.data);
const results = await searchPDFText(unit8ArrayData, query, caseSensitive);
if (results.matchCount === 0) {
return res.json({
result: results,
success: true,
message: "没有找到匹配项。,
});
}
console.log(`找到了;
res.json({ result: results, success: true });
} catch (error) {
console.error("搜索PDF时出现错误:, error);
if (error instanceof Error) {
return res.status(400).json({ error: error.message, success: false });
}
res.status(500).json({
error: "由于未知错误,搜索PDF失败。,
success: false,
});
}
});
让我们来详细分析这段代码中发生的操作:
-
你在 `
/search` 路由上定义了一个处理程序,该程序能够对上传的 PDF 文档进行全文搜索。在开始处理之前,系统会先进行验证,确保用户提供了 PDF 文件和搜索查询参数;如果缺少其中任何一项或这些参数为空,系统就会返回包含详细错误信息的 400 错误响应。 -
该程序会从查询参数或请求体中提取搜索查询内容以及 `
caseSensitive` 选项,并会对布尔值参数进行类型转换。随后,它会将上传的 PDF 文件转换为 `Uint8Array` 类型的缓冲区,并将这些数据连同搜索参数一起传递给 `searchPDFText()` 函数进行处理。 -
根据搜索结果,该处理程序会返回相应的响应:如果没有找到匹配项,就会返回包含 “未找到匹配结果” 信息的成功响应;如果找到了匹配项,就会返回全部搜索结果。在错误处理方面,系统会区分客户端错误(例如输入无效导致的 400 错误)和服务器端错误(例如处理失败导致的 500 错误)。
这个接口为开发者提供了一个功能强大的 PDF 搜索 API,允许客户端根据可配置的区分大小写设置,在文档中查找特定文本,从而为文档分析应用提供精确的结果。
现在,你可以通过查询参数在 PDF 文件中搜索关键词了。
搜索 “example”(不区分大小写):
curl -F "file=@document.pdf" "http://localhost:8080/search?query=example"
搜索 “Example”(区分大小写):
curl -F "file=@document.pdf" "http://localhost:8080/search?query=Example&caseSensitive=true"
你也可以使用请求体来进行搜索:
curl -X POST -F "file=@document.pdf" \
-F "query=PDF" \
-F "caseSensitive=true" \
http://localhost:8080/search
你收到的响应应该如下所示:
{
"result": {
"query": "PDF",
"caseSensitive": false,
"matchCount": 3,
"matches": [
{
"page": 1,
"text": "...这是一份 PDF 文档。PDF 格式是...",
"position": 10
},
{
"page": 2,
"text": "...了解更多关于 PDF 标准的信息...",
"position": 25
}
]
},
"success": true
}
您现在已经为自己的PDF解析器添加了三个重要的功能。
在下一节中,我们将探讨如何处理一些特殊情况。
处理特殊情况与最佳实践
在构建自定义PDF解析器时,如果您希望打造一个更加稳定、可靠的解析工具,那么有一些特殊情况是需要特别注意的。
以下是一些需要留意的特殊情况:
损坏或格式错误的PDF文件
有些用户可能会上传损坏的PDF文件,也就是那些结构无效或头部信息被破坏的PDF文件。这类文件在处理过程中很可能会导致错误。
您可以将解析操作放在try-catch块中,以便优雅地处理这些错误。同时,您还需要提供清晰的错误提示,以便用户能够区分文件损坏与其他类型的错误。
受密码保护的PDF文件
PDF文件可以被设置用户密码或所有者密码进行加密。不过,pdf-parse>工具对这类文件的支持比较有限,因此这会带来一些问题。
解决这个问题的方法有两种:
-
实现一种机制,拒绝处理受密码保护的PDF文件。
-
接受用户提供的密码,以便解密这些文件。
基于图像扫描生成的PDF文件
从扫描文档生成的PDF文件实际上只是图片,并不包含可提取的文本。如果直接尝试解析这类文件,那么得到的结果将会是空内容或非常少的文字。
您可以通过实现OCR技术来从这些扫描PDF文件中提取文本。
特殊字符与编码问题
用户可能会上传包含特殊字符、Unicode符号或非拉丁字母集的PDF文件。如果您的文本提取功能不支持这些字符,那么用户的文件内容就会丢失很多。
因此,您需要确保自己的文本提取工具能够处理UTF-8编码以及各种不同的字符集。
最佳实践
在构建自定义PDF解析器时,以下是一些值得遵循的最佳实践:
1. 在处理文件之前先验证其合法性:
function validatePDFFile(pdfFile: UploadedFile): { valid: boolean; error?: string } {
// 检查MIME类型
if (pdfFile.mimetype !== "application/pdf") {
return { valid: false, error: "无效的MIME类型。预期格式应为application/pdf" };
}
// 检查文件大小
const maxSize = 50 * 1024 * 1024; // 50MB
if (pdfFile.size > maxSize) {
return { valid: false, error: "文件大小超过了50MB的限制" };
}
// 检查文件是否为空
if (pdfFile.size === 0) {
return { valid: false, error: "文件内容为空" };
}
// 检查文件的签名
const data = new Uint8Array(pdfFile.data as ArrayBuffer);
const header = String.fromCharCode(...data.slice(0, 4));
if (header !== "%PDF") {
return { valid: false, error: "文件格式不正确" };
}
return { valid: true };
}
2. 实施请求超时机制,以避免服务器出现卡顿现象。
// 为耗时较长的PDF处理操作设置超时时间
const parseWithTimeout = (file: Uint8Array, timeoutMs = 30000) => {
return Promise.race([
parsePDF(file),
new Promise((_, reject) => {
setTimeout(() => reject(new Error("PDF解析超时)), timeoutMs);
}),
]);
};
3. 实施速率限制机制,以防止被滥用。你可以使用express-rate-limit库为你的Express应用程序添加速率限制功能。
import rateLimit from 'express-rate-limit';
const app = express();
// 创建速率限制中间件
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 每个IP在15分钟内最多只能发送100次请求
message: '该IP发送的请求过多,请15分钟后重试',
standardHeaders: true, // 启用标准的速率限制头部信息
legacyHeaders: false, // 禁用旧的X-RateLimit-*头部信息
});
// 将速率限制中间件应用到所有请求上
app.use(limiter);
4. 对每个关键词或搜索查询进行清洗处理,以防止注入攻击的发生。
单元测试你的PDF解析器
在开发PDF处理工具时,进行测试是非常重要的,因为现实世界中的PDF文件在结构、编码和复杂程度上存在很大差异。Jest为测试Express端点提供了出色的框架,能够确保你的数据提取逻辑在不同场景下都能可靠地运行。
配置Jest测试
我创建的测试套件使用了Jest与Supertest(一个用于HTTP请求断言的库)来模拟对API端点的请求,而无需实际运行服务器。
首先,安装Jest、Supertest及其类型声明文件:
npm install --save-dev jest @types/jest supertest @types/supertest ts-jest
然后更新你的package.json文件,添加Jest的配置信息:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"extensionsToTreatAsEsm": [".ts"],
"moduleNameMapper": {
"(\\.{1,2}/.*)\\.js$": "$1"
},
"transform": {
"...+.tsx?$": [
"ts-jest",
{
"useESM": true,
"tsconfig": {
"module": "esnext"
}
]
]
}
}
}
了解测试结构
该测试文件涵盖了对所有接口端点的全面测试。例如,/upload-page-range这个接口端的测试既验证了正常处理流程,也检查了错误处理机制:
describe("POST /upload-page-range", () => {
it("在没有提供文件时应返回错误", async () => {
const response = await request(app)
.post("/upload-page-range")
.query({ startPage: 1, endPage: 2 });
expect(response.status).toBe(400);
expect(response.body.error).toBe("没有分享PDF文件。");
});
it("对于无效的页面范围,也应返回错误", async () => {
const mockPdfBuffer = Buffer.from("%PDF-1.4 mock pdf");
const response = await request(app)
.post("/upload-page-range")
.query({ startPage: "invalid", endPage: 2 })
.attach("file", mockPdfBuffer, "test.pdf");
expect(response.status).toBe(400);
expect(response.body.error).toContain("页面范围必须是有效的整数。");
});
it("应该能够从指定的页面范围中提取文本", async () => {
const mockPdfBuffer = Buffer.from("%PDF-1.4 mock pdf");
const response = await request(app)
.post("/upload-page-range")
.query({ startPage: 1, endPage: 2 })
.attach("file", mockPdfBuffer, "test.pdf");
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.result.startPage).toBe(1);
expect(response.body.result.endPage).toBe(2);
});
});
请注意,这些测试是使用模拟数据来进行的,并不需要真实的PDF文件。这种做法使得测试具有以下优点:
-
速度快:无需进行磁盘读写操作,测试可以在几毫秒内完成。
-
可靠性高:不会受到可能发生变化的外部文件的影响。
-
针对性强
:每个测试都专注于验证特定的功能行为,而不是文件处理流程本身。
由于使用了模拟数据,所有测试用例都能得到一致的结果,这样你就可以确保你的接口逻辑是正确的,能够正确地处理响应、验证参数的有效性,并且能够返回恰当的错误信息。
运行测试
你可以使用以下命令来执行测试套件:
# 一次性运行所有测试
npm test
# 在开发模式下以实时监控模式运行测试
npm run test:watch
# 生成代码覆盖率报告
npm test -- --coverage
如果测试成功,那就说明所有的接口端点——/upload、/metadata、/search以及/upload-page-range——都能正确处理有效请求、拒绝无效输入,并且能以预期的格式返回数据。
部署你的PDF解析API
当所有的测试都通过后,你就可以将这个Express应用部署到生产环境中了。部署过程会取决于你所使用的托管平台,但以下是一些基本的步骤:
在本地运行
要启动开发服务器,请执行以下命令:
npm run dev
此命令会使用ts-node和Nodemon从server.ts文件中运行服务器。API接口的访问地址为http://localhost:8080。
您可以使用curl来测试这些API端点:
# 测试健康检查功能
curl http://localhost:8080/
# 上传并解析PDF文件
curl -F "file=@sample.pdf" http://localhost:8080/upload
# 提取特定页面内容
curl -F "file=@sample.pdf" "http://localhost:8080/upload-page-range?startPage=1&endPage=5"
# 搜索文本
curl -F "file=@sample.pdf" "http://localhost:8080/search?query=invoice"
# 仅获取元数据
curl -F "file=@sample.pdf" http://localhost:8080/metadata
生产环境部署
在将应用部署到生产环境之前,请先编译TypeScript代码:
npm run build
之后再启动编译后的服务器:
npm start
对于Heroku、AWS或DigitalOcean等云平台,请确保环境变量已设置正确(尤其是PORT变量)。由于该API不维护任何状态信息,因此它可以实现水平扩展——每个请求都会独立处理。
您还可以考虑添加以下生产环境优化措施:
-
速率限制:使用express-rate-limit来防止滥用行为
-
日志记录:采用Winston或Pino等工具进行结构化日志记录
-
监控功能:配置Sentry等服务来实现错误跟踪
-
数据库存储:将提取结果保存到MongoDB或PostgreSQL中,以便日后查询
-
缓存机制:为经常被访问的PDF文件缓存元数据,以减少处理开销
下一步操作:将其集成到您的SaaS系统中
这款PDF解析工具现已成为一款可供生产环境使用的API,您可以将它集成到任何需要文档处理功能的SaaS平台中。以下是具体的实施步骤:
首先克隆该代码库,并根据您的实际需求进行定制。您可以添加以下功能:
-
支持更多类型的文档格式(如DOCX、XLSX以及图片文件)
-
提供批量处理接口,以便同时处理多个文件
-
支持Webhook机制,实现异步处理
-
支持用户认证及为不同用户设置使用限额
-
提供高级文本提取功能(如识别表格、表单内容及结构化数据)
结论
开发一个可用于实际生产的PDF解析器,不仅能让你完全掌控文档处理过程,还能为未来的功能扩展留下足够的灵活性。
你已经学会了如何构建这样的Express API,它能够实现完整的数据提取、页面范围筛选、文本搜索以及元数据检索等功能;同时,这些API还配备了强大的错误处理机制和验证规则,因此适用于任何文档处理工具。
这套经过测试且可实际部署的技术基础,无论你是在开发SaaS产品,还是为现有系统添加PDF处理功能,都完全可以用于各种实际应用中。
在将这些技术原理应用到你的项目中时,不妨考虑探索一些高级库,比如pdfjs-dist或pdf-lib,同时继续遵循你在这里掌握的那些验证规则和模块化设计原则。



