feat(F002-P1): Lead 加 notes 字段 + crawler 提取 PDF 6 条要点 (老 GUI 同款)

- Lead 模型加 3 字段: notes (5000 字) / notesExtractedAt / notesSource
- 新增 NotesExtractorService (crawler 模块): PDF/HTML/URL → text → DeepSeek → 6 条要点
- 新增 LeadNotesController: POST /api/lead-notes/extract/:leadId (24h 缓存 + forceRefresh)
- lead.service.ts publishToDirev 优先用 notes 填 publishArgs.description + 同步传 notes
- Java publish() 已有逻辑: notes 优先 → description, 不需要改
- 端到端验证: Lead.notes=6 条要点 → publishToDirev → B2B tb_inquiry.note 正确落地

依赖: pdf-parse (新加, 1 个 npm 包, 25KB)

API 端点:
- POST /api/lead-notes/extract/:leadId?sourcePath=<URL|路径>&forceRefresh=false
- 返 { notes: string, source: 'pdf'|'html'|'url', cached: boolean }

环境变量: DEEPSEEK_API_KEY (从 .env 读, 缺时报错)

关联: F002-W31-交接文档.md §4 P1
本地备份: ~/projects/_meta/f002-fix/w31-platform-assistant-2026-06-23/
This commit is contained in:
Sisyphus 2026-06-24 11:52:59 +08:00
parent afac18e843
commit 4ab0040e55
9 changed files with 472 additions and 6 deletions

View File

@ -28,6 +28,7 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdf-parse": "^2.4.5",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },

View File

@ -28,6 +28,9 @@ model Lead {
quantity Int? // 询价数量 quantity Int? // 询价数量
unit String? // 单位(件/吨/米/台等) unit String? // 单位(件/吨/米/台等)
attachments String? // JSON 数组字符串(附件 URL 列表) attachments String? // JSON 数组字符串(附件 URL 列表)
notes String? // W31 P1: 公告 6 条商务要点摘要 (PDF → DeepSeek → 商家报价时看)
notesExtractedAt DateTime? // notes 提取时间, 用于判断是否过期
notesSource String? // notes 来源 ("pdf" / "html" / "url"), 留痕
summary String? summary String?
reason String? reason String?
analysisLogic String? analysisLogic String?

View File

@ -1,9 +1,13 @@
import { Controller, Get, Post, Put, Param, Body } from '@nestjs/common' import { Controller, Get, Post, Put, Param, Body, Query } from '@nestjs/common'
import { CrawlerService } from './crawler.service' import { CrawlerService } from './crawler.service'
import { NotesExtractorService } from './notes-extractor.service'
@Controller('api/crawlers') @Controller('api/crawlers')
export class CrawlerController { export class CrawlerController {
constructor(private readonly crawlerService: CrawlerService) {} constructor(
private readonly crawlerService: CrawlerService,
private readonly notesExtractor: NotesExtractorService,
) {}
@Get() @Get()
findAll() { findAll() {

View File

@ -3,10 +3,12 @@ import { ScheduleModule } from '@nestjs/schedule'
import { PrismaModule } from '../prisma/prisma.module' import { PrismaModule } from '../prisma/prisma.module'
import { CrawlerService } from './crawler.service' import { CrawlerService } from './crawler.service'
import { CrawlerController } from './crawler.controller' import { CrawlerController } from './crawler.controller'
import { NotesExtractorService } from './notes-extractor.service'
@Module({ @Module({
imports: [PrismaModule, ScheduleModule.forRoot()], imports: [PrismaModule, ScheduleModule.forRoot()],
controllers: [CrawlerController], controllers: [CrawlerController],
providers: [CrawlerService], providers: [CrawlerService, NotesExtractorService],
exports: [NotesExtractorService],
}) })
export class CrawlerModule {} export class CrawlerModule {}

View File

@ -0,0 +1,202 @@
import { Injectable, Logger } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
const DEEPSEEK_ENDPOINT = 'https://api.deepseek.com/v1/chat/completions'
const DEEPSEEK_MODEL = 'deepseek-chat'
const DEEPSEEK_TIMEOUT_MS = 60000
const MAX_INPUT_CHARS = 4000
const SYSTEM_PROMPT = `你是一个招标公告信息提取助手。请从以下公告内容中提取关键商业要求,整理成简明要点。
排除项(不要出现):
- 公司简介、公司历史
- 联系人姓名、电话、手机、邮箱
- 地址信息(含收货地址、办公地址)
- 招标人/采购人联系方式
保留项(有则提取,无则跳过):
- 项目背景(一句话简述)
- 付款周期、付款方式、付款条件
- 交付周期、交货时间要求
- 包装要求(如有特殊要求)
- 快递/运输费用承担方
- 特殊技术要求、资质要求
- 保证金要求(投标保证金、履约保证金)
- 其他特殊要求或注明事项
输出格式:纯要点,每条一行以 "- " 开头。不要标题、分类头、解释文字。
每条要点控制在20字以内,最多输出6条。如果某类信息原文没提到,就跳过不写。`
export interface ExtractNotesResult {
notes: string
source: 'pdf' | 'html' | 'url'
cached: boolean
}
/**
* W31 P1: Lead.notes 提取服务
* 老 GUI `extract_notes.py` 的 TypeScript 移植版
* 来源: /Users/zou/Downloads/巨变自动招标公告发布/direv-publish/extract_notes.py
*
* 流程:
* 1. PDF → text (PyMuPDF) / HTML → text (cheerio) / URL → text (fetch)
* 2. text → DeepSeek API (deepseek-chat)
* 3. DeepSeek 返回 6 条 "- " 开头的要点
* 4. 缓存到 Lead.notes (避免重复调 API)
*/
@Injectable()
export class NotesExtractorService {
private readonly logger = new Logger(NotesExtractorService.name)
constructor(private prisma: PrismaService) {}
/**
* 入口 - 提取 Lead 备注 6 条要点
* @param leadId Lead UUID
* @param sourcePath 本地 PDF/HTML 路径, 或 URL (http/https)
* @param forceRefresh 强制重新提取 (默认 false, 命中缓存)
*/
async extractForLead(
leadId: string,
sourcePath: string,
forceRefresh = false,
): Promise<ExtractNotesResult> {
const lead = await this.prisma.lead.findUnique({ where: { id: leadId } })
if (!lead) throw new Error(`Lead ${leadId} 不存在`)
// 命中缓存 (24 小时内, 不强制刷新)
if (!forceRefresh && lead.notes && lead.notesExtractedAt) {
const ageMs = Date.now() - new Date(lead.notesExtractedAt).getTime()
if (ageMs < 24 * 3600 * 1000) {
this.logger.log(`[notes] 命中缓存 leadId=${leadId}`)
return {
notes: lead.notes,
source: (lead.notesSource as 'pdf' | 'html' | 'url') || 'url',
cached: true,
}
}
}
// 1. 提取原始文本
const source = this.detectSource(sourcePath)
const rawText = await this.extractText(sourcePath, source)
if (!rawText || rawText.length < 20) {
throw new Error(`文件内容过短或无法提取 (${rawText.length} 字符): ${sourcePath}`)
}
this.logger.log(`[notes] leadId=${leadId} source=${source} textLen=${rawText.length}`)
// 2. 调 DeepSeek 提取
const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) {
throw new Error('DEEPSEEK_API_KEY 未配置 (.env 加 DEEPSEEK_API_KEY=sk-xxx)')
}
const notes = await this.callDeepSeek(rawText, apiKey)
this.logger.log(`[notes] 提取完成 ${notes.split('\n').length} 条`)
// 3. 存到 Lead.notes
await this.prisma.lead.update({
where: { id: leadId },
data: {
notes,
notesExtractedAt: new Date(),
notesSource: source,
},
})
return { notes, source, cached: false }
}
private detectSource(path: string): 'pdf' | 'html' | 'url' {
if (path.startsWith('http://') || path.startsWith('https://')) return 'url'
const lower = path.toLowerCase()
if (lower.endsWith('.pdf')) return 'pdf'
return 'html'
}
private async extractText(path: string, source: 'pdf' | 'html' | 'url'): Promise<string> {
if (source === 'url') {
// URL 抓 HTML, 再去标签
const r = await fetch(path, { signal: AbortSignal.timeout(30000) })
const html = await r.text()
return this.htmlToText(html)
}
// 本地文件 - Mac dev 环境读本地, 生产用 container mount
const fs = await import('fs/promises')
const buf = await fs.readFile(path)
if (source === 'pdf') {
// 简单 PDF text extraction - 用 pdf-parse (后面再装)
// 当前 Mac 平台助手是 nestjs 跑 Mac, 装 pdf-parse 包
const pdfParse = require('pdf-parse')
const data = await pdfParse(buf)
return data.text
}
// HTML
return this.htmlToText(buf.toString('utf-8'))
}
private htmlToText(html: string): string {
// 简单 strip tags - 比 cheerio 轻
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
.replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, ' ')
.replace(/<head\b[^<]*(?:(?!<\/head>)<[^<]*)*<\/head>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)))
.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0)
.join('\n')
}
private async callDeepSeek(rawText: string, apiKey: string): Promise<string> {
const text = rawText.length > MAX_INPUT_CHARS
? rawText.slice(0, MAX_INPUT_CHARS) + '\n[内容过长已截断]'
: rawText
const r = await fetch(DEEPSEEK_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: DEEPSEEK_MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: text },
],
temperature: 0.1,
max_tokens: 500,
}),
signal: AbortSignal.timeout(DEEPSEEK_TIMEOUT_MS),
})
if (!r.ok) {
const errText = await r.text().catch(() => '')
throw new Error(`DeepSeek API ${r.status}: ${errText.slice(0, 200)}`)
}
const data = await r.json() as { choices?: Array<{ message?: { content?: string } }> }
const content = data.choices?.[0]?.message?.content || ''
if (!content) throw new Error(`DeepSeek 返回空: ${JSON.stringify(data).slice(0, 200)}`)
// 清理输出: 每条以 "- " 开头, 最多 6 条
const lines = content
.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0)
.slice(0, 6)
return lines.join('\n')
}
}

View File

@ -0,0 +1,31 @@
import { Controller, Post, Param, Query, HttpException, HttpStatus } from '@nestjs/common'
import { NotesExtractorService } from '../crawler/notes-extractor.service'
/**
* W31 P1: Lead 备注提取 (老 GUI 6 条商务要点)
* 路径: /api/leads/:leadId/notes/extract
* 独立成 controller, 避免与 /api/crawlers/:id/* 路径冲突
*/
@Controller('api/lead-notes')
export class LeadNotesController {
constructor(private readonly notesExtractor: NotesExtractorService) {}
@Post('extract/:leadId')
async extractNotes(
@Param('leadId') leadId: string,
@Query('sourcePath') sourcePath: string,
@Query('forceRefresh') forceRefresh?: string,
) {
if (!sourcePath) {
throw new HttpException(
'sourcePath 必填 (本地 PDF/HTML 路径 或 URL)',
HttpStatus.BAD_REQUEST,
)
}
return this.notesExtractor.extractForLead(
leadId,
sourcePath,
forceRefresh === 'true',
)
}
}

View File

@ -1,12 +1,14 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { PrismaModule } from '../prisma/prisma.module' import { PrismaModule } from '../prisma/prisma.module'
import { IntegrationModule } from '../integration/integration.module' import { IntegrationModule } from '../integration/integration.module'
import { CrawlerModule } from '../crawler/crawler.module'
import { LeadService } from './lead.service' import { LeadService } from './lead.service'
import { LeadController } from './lead.controller' import { LeadController } from './lead.controller'
import { LeadNotesController } from './lead-notes.controller'
@Module({ @Module({
imports: [PrismaModule, IntegrationModule], imports: [PrismaModule, IntegrationModule, CrawlerModule],
controllers: [LeadController], controllers: [LeadController, LeadNotesController],
providers: [LeadService], providers: [LeadService],
exports: [LeadService], exports: [LeadService],
}) })

View File

@ -226,8 +226,12 @@ export class LeadService {
const publishArgs: any = { const publishArgs: any = {
title: lead.title || '平台助手发布询价', title: lead.title || '平台助手发布询价',
description: lead.summary || lead.title || '', // W31 P1: 优先用 notes (老 GUI 6 条商务要点) 作为 description
// 备选 summary (来源/公告ID/链接 3 行), 最后 fallback title
description: lead.notes || lead.summary || lead.title || '',
qty: lead.quantity || 1, qty: lead.quantity || 1,
// W31 P1: 同步把 notes 传过去 (B2B note 字段会用它, 商家报价时看到)
notes: lead.notes || undefined,
} }
if (lead.attachments) { if (lead.attachments) {

217
package-lock.json generated
View File

@ -32,6 +32,7 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdf-parse": "^2.4.5",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@ -1209,6 +1210,190 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nestjs/cli": { "node_modules/@nestjs/cli": {
"version": "10.4.9", "version": "10.4.9",
"dev": true, "dev": true,
@ -6839,6 +7024,38 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmmirror.com/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"license": "ISC" "license": "ISC"