moji2002/purifai

GitHub: moji2002/purifai

一个零依赖、跨运行时的固定策略 HTML 转纯文本工具,能从可能恶意的 HTML 中提取可读文本并提供确定性的资源限制。

Stars: 0 | Forks: 0

# Purifai [![npm version](https://img.shields.io/npm/v/purifai.svg)](https://www.npmjs.com/package/purifai) [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/ad/ad5834178f7599af9fdda11629d49cae07f2997beec49821b2920eff5bfd50e7.svg)](https://github.com/moji2002/purifai/actions/workflows/ci.yml) [![gzip: 23.7 KiB](https://img.shields.io/badge/gzip-23.7_KiB-2f855a)](docs/benchmarks/v3.md) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) **从恶意 HTML 中提取可读文本——无需 DOM。** Purifai 是一个固定策略的 HTML 转文本转换器,适用于服务器、浏览器和 边缘 runtime。它保留有用的文档结构,丢弃非阅读正文,并在扫描时强制执行输入、输出、嵌套和保留 token 的限制。 ``` import { toText } from 'purifai'; const text = toText( '

Release

  • Fast
', ); console.log(text); // Release // // - Fast ``` 一个简单的标签移除器可能会从 script 正文中泄露 `alert(1)` 并折叠 剩余的文本。Purifai 会丢弃该正文并格式化阅读内容。 ## 选择 Purifai 的时机 - HTML 可能体积庞大、格式错误或包含恶意内容。 - 你需要可读的纯文本——而不是保留的标记或浏览器 DOM。 - 转换必须具有确定性的资源限制。 - 同一实现必须在 Node、Bun、Deno、Workers 和浏览器中运行。 - 流式传输应无论数据块边界如何都能产生相同的结果。 如果你需要选择器驱动的格式化、复杂的表格布局或允许列表的 安全 HTML,请跳转至[你应该选择哪个工具?](#which-tool-should-you-choose)。 ## 安装 ``` npm install purifai ``` Purifai v3 在 Node 环境中使用时需要 Node.js 22 或更高版本。它提供 ESM 和 CommonJS 导出,并且没有 runtime 依赖。 ## 快速开始 ``` import { toText } from 'purifai'; const text = toText('

Guide

Start here.

', { layout: 'readable', links: 'label', images: 'alt', }); // Guide // // Start here. ``` `toText` 返回一个 JavaScript 字符串。它不返回安全 HTML。 ## 安全输出 推荐使用文本 sink: ``` element.textContent = toText(untrustedHtml); ``` 如果唯一可用的 sink 是 HTML 文本节点,请显式转义文本: ``` import { escapeHtmlText, toText } from 'purifai'; element.innerHTML = escapeHtmlText(toText(untrustedHtml)); ``` `escapeHtmlText` 仅适用于 HTML 文本上下文。它不能使值在 属性、URL、JavaScript、CSS 或模板源中变得安全。显示的 URL 也 仍然是文本;将其移动到 `href` 需要单独的 URL 策略决定。 ## 为什么选择 Purifai 大多数 HTML 转文本工具要么针对最小化的标签移除进行优化,要么针对广泛的 格式化控制进行优化。Purifai 针对的是一个更狭窄的交集: | 需求 | Purifai 行为 | | --- | --- | | 对读者友好的输出 | 保留标题、段落、列表、引用、代码、简单表格、链接和图片替代文本 | | 非阅读内容 | 丢弃如 `script`、`style`、`template`、`iframe`、`svg` 和 `math` 等 body | | 恶意输入边界 | 在扫描期间强制执行输入、输出、深度和聚合保留 token 限制 | | 流式传输 | 使用原生 Web `TransformStream` 实现不随数据块变化的输出 | | 可移植性 | 不使用 DOM、文档树、Node 内置模块或 runtime 依赖 | | 可预测性 | 固定策略、经过验证的选项、明确的溢出行为和冻结的报告 | 这种固定的范围正是选择 Purifai 的原因。它故意不 保留标记、重建 CSS 布局、暴露自定义格式化器或分类 用户的意图。 ## 流式传输 `createTextTransform` 使用与 `toText` 相同的状态机进行增量转换。合并其 输出对于所有可能的输入分块方式都会产生完全相同的文本。 ``` import { createTextTransform } from 'purifai'; const response = await fetch('https://example.test/article'); if (response.body === null) throw new Error('Response has no body'); const transform = createTextTransform({ links: 'label-and-url' }); const readable = response.body .pipeThrough(new TextDecoderStream()) .pipeThrough(transform); for await (const chunk of readable) { consumeText(chunk); } const report = await transform.result; ``` 当违反限制时,流式转换总是会抛出异常。当 `readable` 和 `transform.result` 被拒绝时,某些输出可能 已经进入队列,因此除非你的应用程序明确接受,否则请丢弃部分输出。 ## 有界转换 当超过任何配置的限制时,`toText` 会抛出 `PurifaiLimitError`。 仅当有界前缀是可接受的结果时才使用 `convert`: ``` import { convert } from 'purifai'; const result = convert(largeHtml, { limits: { input: 1_000_000, output: 20_000, depth: 64, token: 65_536 }, overflow: 'truncate', }); result.text; result.truncatedBy; // 'input', 'output', 'depth', 'token', or null result.scanComplete; // false after truncation result.consumedInputCodeUnits; result.outputCodeUnits; result.droppedContainers; // e.g. { script: 2, style: 1 } ``` 截断是显式且确定性的,绝不会发出半个 UTF-16 代理对。`toText` 和 `createTextTransform` 从不进行静默截断。 ## 选项 未知的键和无效的值会抛出 `TypeError`;Purifai 不会随意猜测 配置错误。 | 选项 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `layout` | `'readable' \| 'compact'` | `'readable'` | 结构边界,或规范化的单空格文本 | | `links` | `'label' \| 'label-and-url' \| 'drop'` | `'label'` | 保留标签、追加已接受的显示 URL 或丢弃链接正文 | | `images` | `'alt' \| 'drop'` | `'alt'` | 发出解码后的非空 `alt` 文本,或忽略图片 | | `baseUrl` | `string \| URL` | 无 | 根据无凭证的 HTTP(S) 基础地址解析相对显示 URL | | `limits.input` | 非负安全整数 | `1_000_000` | 消耗的最大输入 UTF-16 代码单元 | | `limits.output` | 非负安全整数 | `250_000` | 发出的最大输出 UTF-16 代码单元 | | `limits.depth` | 非负安全整数 | `64` | 最大实时结构嵌套 | | `limits.token` | 非负安全整数 | `65_536` | 最大聚合保留 token 和属性代码单元 | | `overflow` | `'throw' \| 'truncate'` | `'throw'` | 仅限 `convert`;其他 API 总是抛出异常 | 所有四个限制都在无界的调用者控制状态累积之前强制执行。这些值 测量的是 JavaScript UTF-16 代码单元,而不是编码后的字节。 ### 显示 URL 策略 `label-and-url` 将目的地作为显示文本发出,从不作为活动链接。它 接受绝对的 `http:`、`https:` 和 `mailto:` URL。相对 URL 需要一个 经过验证的 HTTP(S) `baseUrl`。凭证、控制字符、有歧义的模式、 协议相对输入、前导反斜杠、不支持的模式和无效的 URL 在其可见标签保留的同时会被省略。 ## 提取策略 Purifai 移除源和非阅读正文,包括 `script`、`style`、 `template`、`iframe`、`noscript`、`noembed`、`noframes`、`svg` 和 `math`。它 保留选定的回退和表单文本,解码完整的固定的 WHATWG 字符引用集,保留字面量 `xmp`,并将 `plaintext` 视为文本 直到输入结束。 这是一种有界提取语法,而不是浏览器树构造。它不 重建 CSS 布局、浏览器 `innerText`、复杂的 `rowspan`/`colspan` 表格、 SVG/MathML 语义、选择器规则、自定义格式化器或浏览器等价的 畸形标记恢复。 ## 基准测试 已检查的类别基准测试固定了 `striptags@3.2.0` 和 `html-to-text@10.0.0`。它测量经过审查的可读性和正文移除 测试用例、隔离的预热中位数和 p95 延迟,以及全新进程的峰值 RSS。 在记录的 Apple M1 / Node 24 运行中,Purifai 通过了所有 11 个类别门控: - 8/8 的可读性测试用例和所有 5 个非阅读正文测试用例; - 在四个恶意语料库上的恶意输入 p95 低于 `html-to-text`;且 - 在所有五个内存语料库上的流式峰值 RSS 低于 `html-to-text`。 `striptags` 在一些简单的剥离情况下仍然更快。这并不是 Purifai 的 宣称。结果因机器、runtime 和语料库而异。 有关[完整的方法论、原始结果和表格](docs/benchmarks/v3.md),请参见相应内容。 使用 `pnpm run bench` 复现测量结果;使用 `pnpm run bench:check` 检查记录的发布门控。 ## 大小、可移植性和发布证明 完整的压缩 ESM runtime——包括所有 2,231 个固定的 WHATWG 实体 名称——在使用确定性的 `gzip -9` 压缩后为 23,689 字节。发布门控还检查 打包的导出、零 runtime 依赖、冷导入时间和保留的导入 堆。 相同的打包制品已在以下环境中进行测试: | Runtime | 发布覆盖率 | | --- | --- | | Node.js | 22、24 和 26;ESM 和 CommonJS | | Bun | ESM 和 CommonJS | | Deno | ESM | | Cloudflare Workers | 真实的 `workerd`,无 Node 兼容性 | | 浏览器 | Chromium、Firefox 和 WebKit | 发布资格认证还包括 10,000 个带种子的畸形输入案例、 对抗性扩展检查、带有阳性对照的安全 sink 测试、包 冒烟测试,以及绑定到标记的 GitHub 源提交的 npm OIDC 来源。 ## API 参考 ### `toText(html, options?) → string` 将一个 HTML 字符串转换为可读文本。对于无效的 输入或选项抛出 `TypeError`,对于违反的限制抛出 `PurifaiLimitError`。 ### `convert(html, options?) → ConversionResult` 返回文本以及一个冻结的报告,其中包含完成情况、截断情况、消耗的 输入、输出长度和丢弃的容器计数。它是唯一可以 返回故意截断的前缀的 API。 ### `createTextTransform(options?) → TextTransform` 返回一个原生的 `TransformStream`,带有用于 冻结转换报告的 `result` promise。限制失败会使用相同的错误对象同时拒绝流和 promise。 ### `escapeHtmlText(text) → string` 为 HTML 文本节点上下文无损地编码 `&`、`<`、`>`、`"` 和 `'`。 ### `PurifaiLimitError` 继承 `RangeError` 并暴露 `kind`、`limit` 和 `observed`。 ``` import { PurifaiLimitError, toText } from 'purifai'; try { toText(html, { limits: { input: 10_000 } }); } catch (error) { if (error instanceof PurifaiLimitError) { console.error(error.kind, error.limit, error.observed); } } ``` ## 你应该选择哪个工具? | 需求 | 选择 | | --- | --- | | 固定策略的可读文本、恶意输入边界和可移植的 Web 流式传输 | 选择 Purifai | | 选择器、自定义格式化器、高级表格、换行和广泛的格式控制 | 选择 `html-to-text` | | 最小的简单标签移除操作 | 选择稳定的 `striptags` | | 保留允许列表的安全 HTML 片段 | 选择 DOMPurify 或 `sanitize-html` | 这些类别不能互换。DOMPurify 和 `sanitize-html` 是 当必须保留安全标记时正确的类别。 ## 迁移和开发 V3 是一个全新的开始。有关每一个 已移除的导出和选项,请参阅 [v3 迁移指南](docs/migration-v3.md)。 - [可运行示例](examples) - [贡献者指南](CONTRIBUTING.md) - [项目笔记](https://worksonmy.dev/projects/purifai) - [问题](https://github.com/moji2002/purifai/issues) ## 许可证 [MIT](LICENSE)
标签:GNU通用公共许可证, HTML转文本, Node.js, 净化器, 数据可视化, 自定义脚本, 零依赖