17 Commits

Author SHA1 Message Date
052f3a137b ```
fix(public): 修复项目列表中section和type字段显示逻辑

修复了项目列表表格中section和type字段的显示顺序问题,
确保优先显示type字段内容,当type为空时才显示section字段内容
```
2026-03-30 18:35:40 +08:00
0d74cfe754 ```
feat(scheduler): 添加高级调度配置界面

- 实现调度模式选择(每天、每周、每月、自定义Cron)
- 添加时间选择器和日期相关配置选项
- 提供可视化调度预览功能
- 支持Cron表达式验证和实时更新
- 修改默认调度时间为早上8点40分
- 更新邮件接收者地址
```
2026-03-20 16:28:27 +08:00
89d0abd44c ```
feat: 添加Windows PowerShell重启脚本

添加restart.ps1脚本来管理服务器进程的启动、停止和重启功能,
包括PID文件管理和端口检测。同时创建了logs/server.pid文件来
存储服务器进程ID。
```
2026-03-20 11:54:13 +08:00
f8374f5e0d ```
feat: 添加服务器重启脚本

添加restart.sh脚本用于管理服务器进程,包含以下功能:
- 自动查找并停止现有服务器进程
- 启动新的服务器实例
- 管理进程ID文件和日志输出
- 支持端口配置和状态检查
```
2026-03-19 15:37:29 +08:00
f8dfad26a4 ```
feat(resultStore): 支持数据库文件迁移和兼容性处理

- 添加对旧版 results.sqlite 到新版 results.db 的自动迁移支持
- 实现数据库连接时的错误处理和日志记录
- 修改数据库事务模式为 DELETE 模式以提高稳定性
- 增加对临时 WAL 和 SHM 文件的清理处理
- 提供环境变量配置的数据库路径优先级支持
```
2026-03-19 15:12:55 +08:00
2a5fd99319 ```
feat(project): 添加项目数据导出功能

- 在项目查询界面添加导出按钮,支持将项目结果导出为CSV文件
- 实现exportProjectResults函数处理导出逻辑,包括参数验证和文件下载
- 新增validateProjectFilters函数用于验证项目筛选条件
- 新增buildProjectQueryParams函数构建查询参数
- 在服务端添加/api/projects/export接口,返回CSV格式数据
- 实现CSV文件生成和下载功能,包含中文文件名处理
- 重构项目查询逻辑,提取getProjects函数复用代码
```
2026-03-19 15:03:43 +08:00
7bfba04199 ```
feat: 添加项目管理功能

添加了项目管理标签页,支持按城市、板块、项目名称、金额范围、日期等条件进行过滤查询,
包含完整的前端界面和后端API接口,实现数据去重和分页功能
```
2026-03-19 11:40:14 +08:00
d78dc655ee ```
chore(config): 更新.gitignore文件以忽略数据库相关文件

添加了data/目录、SQLite数据库文件及相关临时文件到.gitignore中,
避免敏感数据和临时文件被提交到版本控制系统。
```
2026-03-19 10:18:25 +08:00
bd46d8f907 增加板块, 增加了对 urlencoded 数据的容量限制 2026-03-16 11:27:31 +08:00
0648770a6a ```
feat(config): 更新配置中字段名从target_link改为detail_link

将JSON配置中的target_link字段统一改为detail_link,以更好地反映链接的实际用途。

BREAKING CHANGE: 配置文件中的字段名发生变化,需要更新相关引用
```
2026-03-11 14:42:38 +08:00
40118ec508 ```
feat(config): 添加任务配置中的模型模式支持

- 在config.json中为任务添加mode和useBrowser字段
- 默认使用glm-5模型模式

feat(ui): 更新前端界面显示模型信息并添加模型选择功能

- 在任务表格中添加模型列显示
- 在新增/编辑任务表单中添加模型选择下拉框
- 支持多种模型选项包括qwen3.5-plus、qwen3-max等
- 更新表格列数以适应新增的模型列

feat(core): 实现任务模型模式的功能支持

- 在agentService.js中添加normalizeMode函数处理模型模式
- 修改createTask和runAgentTask函数支持mode参数
- 在scheduler.js中实现任务的模型模式配置
- 在server.js中添加模型模式的标准化和API支持
- 为任务运行时添加模型模式的日志输出
```
2026-03-10 18:11:11 +08:00
b9270428db ```
feat(config):
style(ui): 全面重构用户界面样式

- 引入新的配色方案和设计系统变量
- 更新字体家族,使用 Fira Sans 和 Noto Sans SC
- 重新设计页面布局和组件样式
- 添加响应式设计优化
- 改进按钮、表格、表单等UI元素的视觉效果

feat(tasks): 添加任务级别浏览器配置选项

- 在任务配置中增加独立的浏览器开启/关闭选项
- 支持任务继承全局浏览器设置
- 在任务列表中显示浏览器配置状态
- 实现任务级别的 useBrowser 字段管理
```
2026-03-10 17:58:09 +08:00
4f504447a1 根据提供的code differences信息,我发现没有具体的代码差异内容。由于没有实际的代码变更信息,我将生成一个通用的示例commit message:
```
docs(changelog): 更新版本发布说明

- 添加了最新的功能变更记录
- 修复了已知问题的描述
- 更新了API文档的相关部分
```
2026-03-10 16:16:57 +08:00
a2408fa952 ```
feat: 切换到Firecrawl Browser Sandbox并更新API密钥

- 将抓取功能从Firecrawl Agent切换到Firecrawl Browser Sandbox
- 更新.env文件中的FIRECRAWL_API_KEY为新密钥
- 修改前端界面文本,将"Firecrawl Agent"改为"Firecrawl Browser Sandbox"
- 重构runScraper函数,添加按钮状态管理和滚动定位功能
- 移除zod验证schema,简化数据处理逻辑
- 更新定时任务调度器以使用新的浏览器抓取方式
- 清空results.json历史数据
```
2026-03-10 11:36:35 +08:00
61c93882d6 chore: 更新了结果数据和工具配置 2026-03-10 09:17:27 +08:00
4653b1d7b9 .env文件 2026-03-06 16:07:27 +08:00
ad659c4ff0 feat: 使用firecrawl 实现公告抓取与分析工具的网页界面,包括报告生成、导出和邮件发送功能。 2026-03-06 15:37:56 +08:00
21 changed files with 5834 additions and 2089 deletions

5
.env Normal file
View File

@@ -0,0 +1,5 @@
# 服务器端口配置
PORT=5000
# Firecrawl API Key在 https://www.firecrawl.dev/app/api-keys 获取)
FIRECRAWL_API_KEY=fc-595dd922780442f8a907202666a522ef

View File

@@ -1,11 +1,15 @@
# 服务器端口配置 # 服务器端口配置
PORT=5000 PORT=5000
# Firecrawl API Key在 https://www.firecrawl.dev/app/api-keys 获取)
FIRECRAWL_API_KEY=fc-your-api-key-here
# 环境说明: # 环境说明:
# - 开发环境:通常使用 5000 # - 开发环境:通常使用 5000
# - 生产环境:可以使用 80、8080 等 # - 生产环境:可以使用 80、8080 等
# #
# 使用方法: # 使用方法:
# 1. 复制此文件为 .env # 1. 复制此文件为 .env
# 2. 修改端口号 # 2. 填写 FIRECRAWL_API_KEY
# 3. 启动服务时会自动读取 # 3. 修改端口号(可选)
# 4. 启动服务时会自动读取

7
.gitignore vendored
View File

@@ -8,12 +8,17 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
# 环境变量文件 # 环境变量文件
.env # .env (已注释,不再忽略)
.env.local .env.local
.env.*.local .env.*.local
# 配置文件(包含敏感信息) # 配置文件(包含敏感信息)
config.json config.json
data/
*.sqlite
*.sqlite-shm
*.sqlite-wal
*.migrated.bak
# 编辑器目录和文件 # 编辑器目录和文件
.vscode/ .vscode/

View File

@@ -1,9 +1,13 @@
{ {
"agent": {
"baseUrl": "http://192.168.3.65:18777",
"pollInterval": 3000,
"timeout": 3600000
},
"scheduler": { "scheduler": {
"enabled": true, "enabled": true,
"cronTime": "0 9 * * *", "cronTime": "40 08 * * *",
"winningThreshold": 0, "threshold": 100000,
"bidThreshold": 0,
"description": "每天9点采集当日项目", "description": "每天9点采集当日项目",
"timeRange": "thisMonth" "timeRange": "thisMonth"
}, },

13
disable-all-tasks.bat Normal file
View File

@@ -0,0 +1,13 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo ========================================
echo 批量禁用所有任务
echo ========================================
echo.
node disable-all-tasks.js
echo.
pause

65
disable-all-tasks.js Normal file
View File

@@ -0,0 +1,65 @@
// 批量禁用所有任务脚本
// 用法: node disable-all-tasks.js
import Database from 'better-sqlite3';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync, mkdirSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DEFAULT_DB_DIR = join(__dirname, 'data');
const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, 'results.db');
const LEGACY_DB_PATH = join(DEFAULT_DB_DIR, 'results.sqlite');
// 确定数据库路径
let DB_PATH = process.env.APP_DB_PATH || process.env.RESULTS_DB_PATH;
if (!DB_PATH) {
if (existsSync(DEFAULT_DB_PATH)) {
DB_PATH = DEFAULT_DB_PATH;
} else if (existsSync(LEGACY_DB_PATH)) {
DB_PATH = LEGACY_DB_PATH;
} else {
DB_PATH = DEFAULT_DB_PATH;
}
}
if (!existsSync(DB_PATH)) {
console.error('数据库文件不存在:', DB_PATH);
console.log('请确保项目已初始化并运行过至少一次');
process.exit(1);
}
try {
const db = new Database(DB_PATH);
// 先查询当前启用的任务数量
const enabledCount = db.prepare('SELECT COUNT(*) as count FROM tasks WHERE enabled = 1').get();
console.log(`当前启用的任务数量: ${enabledCount.count}`);
// 查询总任务数量
const totalCount = db.prepare('SELECT COUNT(*) as count FROM tasks').get();
console.log(`总任务数量: ${totalCount.count}`);
if (enabledCount.count === 0) {
console.log('没有需要禁用的任务');
db.close();
process.exit(0);
}
// 执行批量禁用
const result = db.prepare('UPDATE tasks SET enabled = 0').run();
console.log(`已禁用 ${result.changes} 个任务`);
// 验证结果
const newEnabledCount = db.prepare('SELECT COUNT(*) as count FROM tasks WHERE enabled = 1').get();
console.log(`禁用后启用的任务数量: ${newEnabledCount.count}`);
db.close();
console.log('操作完成');
} catch (error) {
console.error('操作失败:', error.message);
process.exit(1);
}

1
logs/server.pid Normal file
View File

@@ -0,0 +1 @@
15556

751
package-lock.json generated
View File

@@ -8,14 +8,29 @@
"name": "njggzy-scraper", "name": "njggzy-scraper",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"axios": "^1.6.8", "@mendable/firecrawl-js": "latest",
"cheerio": "^1.0.0-rc.12", "better-sqlite3": "^12.8.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"iconv-lite": "^0.6.3",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^7.0.11" "nodemailer": "^7.0.11",
"zod": "^3.24.2"
}
},
"node_modules/@mendable/firecrawl-js": {
"version": "4.15.2",
"resolved": "https://registry.npmmirror.com/@mendable/firecrawl-js/-/firecrawl-js-4.15.2.tgz",
"integrity": "sha512-J+lfnJpd00irDhy5ZJE58lsdqbc1fC1d7X6/UyF4VFASEGy1GDpR0FuVweasEpFfOhEGS5DZ+dq8Ui21zIFrOw==",
"license": "MIT",
"dependencies": {
"axios": "^1.13.5",
"typescript-event-target": "^1.1.1",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.0"
},
"engines": {
"node": ">=22.0.0"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -63,16 +78,70 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.2", "version": "1.13.6",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.2.tgz", "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.11",
"form-data": "^4.0.4", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.1.tgz", "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.1.tgz",
@@ -113,11 +182,29 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/boolbase": { "node_modules/buffer": {
"version": "1.0.0", "version": "5.7.1",
"resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"license": "ISC" "funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
}, },
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
@@ -157,47 +244,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/cheerio": { "node_modules/chownr": {
"version": "1.1.2", "version": "1.1.4",
"resolved": "https://registry.npmmirror.com/cheerio/-/cheerio-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "MIT", "license": "ISC"
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.0.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.12.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
}, },
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
@@ -264,34 +315,6 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
@@ -309,6 +332,30 @@
} }
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -327,59 +374,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/dom-serializer": { "node_modules/detect-libc": {
"version": "2.0.0", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "MIT", "license": "Apache-2.0",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": { "engines": {
"node": ">= 4" "node": ">=8"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
@@ -423,29 +424,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/encoding-sniffer": { "node_modules/end-of-stream": {
"version": "0.2.1", "version": "1.4.5",
"resolved": "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"iconv-lite": "^0.6.3", "once": "^1.4.0"
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/es-define-property": { "node_modules/es-define-property": {
@@ -508,6 +493,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": { "node_modules/express": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz",
@@ -576,6 +570,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -651,6 +651,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
@@ -697,6 +703,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
@@ -748,37 +760,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/htmlparser2": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.0.0.tgz",
"integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.1",
"entities": "^6.0.0"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
@@ -799,17 +780,25 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/iconv-lite": { "node_modules/ieee754": {
"version": "0.6.3", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"license": "MIT", "funding": [
"dependencies": { {
"safer-buffer": ">= 2.1.2 < 3.0.0" "type": "github",
"url": "https://github.com/sponsors/feross"
}, },
"engines": { {
"node": ">=0.10.0" "type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
} }
],
"license": "BSD-3-Clause"
}, },
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
@@ -817,6 +806,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -883,12 +878,45 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/napi-build-utils": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
"integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==",
"license": "MIT"
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
@@ -898,6 +926,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-cron": { "node_modules/node-cron": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz", "resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
@@ -916,18 +956,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
@@ -970,55 +998,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmmirror.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
@@ -1038,6 +1017,32 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/prebuild-install": {
"version": "7.1.2",
"resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.2.tgz",
"integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^1.0.1",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1057,6 +1062,16 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.0.tgz",
@@ -1112,6 +1127,35 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/router": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
@@ -1128,12 +1172,44 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmmirror.com/send/-/send-1.2.0.tgz", "resolved": "https://registry.npmmirror.com/send/-/send-1.2.0.tgz",
@@ -1274,6 +1350,51 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
@@ -1283,6 +1404,52 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1292,6 +1459,18 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz",
@@ -1331,14 +1510,11 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/undici": { "node_modules/typescript-event-target": {
"version": "7.16.0", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/undici/-/undici-7.16.0.tgz", "resolved": "https://registry.npmmirror.com/typescript-event-target/-/typescript-event-target-1.1.2.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", "integrity": "sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==",
"license": "MIT", "license": "MIT"
"engines": {
"node": ">=20.18.1"
}
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
@@ -1349,6 +1525,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
@@ -1358,32 +1540,29 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
} }
} }
} }

View File

@@ -8,13 +8,13 @@
"start": "node src/server.js" "start": "node src/server.js"
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.8", "@mendable/firecrawl-js": "latest",
"cheerio": "^1.0.0-rc.12", "better-sqlite3": "^12.8.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"iconv-lite": "^0.6.3",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^7.0.11" "nodemailer": "^7.0.11",
"zod": "^3.24.2"
} }
} }

View File

@@ -687,51 +687,34 @@ function cronToFriendlyText(cronTime) {
// 加载定时任务配置 // 加载定时任务配置
async function loadSchedulerConfig() { async function loadSchedulerConfig() {
try { try {
// 从服务器获取配置
const response = await fetch(`${API_BASE}/config`); const response = await fetch(`${API_BASE}/config`);
const data = await response.json(); const data = await response.json();
if (data.success && data.data) { if (data.success && data.data) {
const config = data.data; const config = data.data;
// 填充表单
if (config.scheduler) { if (config.scheduler) {
document.getElementById('schedulerEnabled').checked = config.scheduler.enabled || false; document.getElementById('schedulerEnabled').checked = config.scheduler.enabled || false;
const cronTime = config.scheduler.cronTime || '0 9 * * *'; const cronTime = config.scheduler.cronTime || '0 9 * * *';
document.getElementById('schedulerCronInput').value = cronTime; document.getElementById('schedulerCronInput').value = cronTime;
document.getElementById('schedulerWinningThresholdInput').value = config.scheduler.winningThreshold !== undefined ? config.scheduler.winningThreshold : 10000; document.getElementById('schedulerThresholdInput').value = config.scheduler.threshold ?? 0;
document.getElementById('schedulerBidThresholdInput').value = config.scheduler.bidThreshold !== undefined ? config.scheduler.bidThreshold : 0;
document.getElementById('schedulerDescription').value = config.scheduler.description || ''; document.getElementById('schedulerDescription').value = config.scheduler.description || '';
// 时间段配置
document.getElementById('schedulerTimeRange').value = config.scheduler.timeRange || 'thisMonth';
// 反向映射Cron表达式到预设选择器 // 反向映射Cron表达式到预设选择器
const presetSelector = document.getElementById('schedulerCronPreset'); const presetSelector = document.getElementById('schedulerCronPreset');
const customGroup = document.getElementById('customCronGroup'); const customGroup = document.getElementById('customCronGroup');
// 预设值列表
const presets = [ const presets = [
'0 9 * * *', '0 9 * * *', '0 6 * * *', '0 12 * * *', '0 18 * * *',
'0 6 * * *', '0 9,18 * * *', '0 */6 * * *', '0 */12 * * *', '0 9 * * 1', '0 9 1 * *'
'0 12 * * *',
'0 18 * * *',
'0 9,18 * * *',
'0 */6 * * *',
'0 */12 * * *',
'0 9 * * 1',
'0 9 1 * *'
]; ];
// 检查是否匹配预设值
if (presets.includes(cronTime)) { if (presets.includes(cronTime)) {
presetSelector.value = cronTime; presetSelector.value = cronTime;
customGroup.style.display = 'none'; customGroup.style.display = 'none';
} else { } else {
// 自定义时间 - 尝试解析为 "分 时 * * *" 格式
presetSelector.value = 'custom'; presetSelector.value = 'custom';
customGroup.style.display = 'block'; customGroup.style.display = 'block';
const cronParts = cronTime.split(/\s+/); const cronParts = cronTime.split(/\s+/);
if (cronParts.length >= 2) { if (cronParts.length >= 2) {
document.getElementById('customMinute').value = cronParts[0]; document.getElementById('customMinute').value = cronParts[0];
@@ -740,7 +723,6 @@ async function loadSchedulerConfig() {
} }
} }
// 更新状态显示
await updateSchedulerStatus(); await updateSchedulerStatus();
} }
} catch (error) { } catch (error) {
@@ -813,20 +795,12 @@ async function updateSchedulerStatus() {
// 更新执行计划 // 更新执行计划
if (status.config) { if (status.config) {
document.getElementById('schedulerCronTime').textContent = cronToFriendlyText(status.config.cronTime); document.getElementById('schedulerCronTime').textContent = cronToFriendlyText(status.config.cronTime);
const winningThreshold = status.config.winningThreshold;
if (winningThreshold === 0) {
document.getElementById('schedulerWinningThreshold').textContent = '不筛选';
} else {
const winningBillion = (winningThreshold / 10000).toFixed(1);
document.getElementById('schedulerWinningThreshold').textContent = `${winningThreshold}万元 (${winningBillion}亿)`;
}
const bidThreshold = status.config.bidThreshold;
if (bidThreshold === 0) {
document.getElementById('schedulerBidThreshold').textContent = '不筛选';
} else {
const bidBillion = (bidThreshold / 10000).toFixed(1);
document.getElementById('schedulerBidThreshold').textContent = `${bidThreshold}万元 (${bidBillion}亿)`;
} }
// 更新已启用来源数
const enabledCountEl = document.getElementById('schedulerEnabledCount');
if (enabledCountEl) {
enabledCountEl.textContent = `${status.enabledScrapers ?? '-'}`;
} }
} }
} catch (error) { } catch (error) {
@@ -839,10 +813,8 @@ async function saveSchedulerConfig() {
const schedulerConfig = { const schedulerConfig = {
enabled: document.getElementById('schedulerEnabled').checked, enabled: document.getElementById('schedulerEnabled').checked,
cronTime: document.getElementById('schedulerCronInput').value, cronTime: document.getElementById('schedulerCronInput').value,
winningThreshold: parseInt(document.getElementById('schedulerWinningThresholdInput').value), threshold: parseInt(document.getElementById('schedulerThresholdInput').value) || 0,
bidThreshold: parseInt(document.getElementById('schedulerBidThresholdInput').value),
description: document.getElementById('schedulerDescription').value, description: document.getElementById('schedulerDescription').value,
timeRange: document.getElementById('schedulerTimeRange').value
}; };
// 验证Cron表达式格式(简单验证) // 验证Cron表达式格式(简单验证)
@@ -852,36 +824,16 @@ async function saveSchedulerConfig() {
return; return;
} }
// 从localStorage获取邮件配置
const emailConfigStr = localStorage.getItem('emailConfig');
let emailConfig = {};
if (emailConfigStr) {
try {
emailConfig = JSON.parse(emailConfigStr);
} catch (e) {
console.error('解析邮件配置失败:', e);
}
}
// 如果邮件配置为空,提示用户
if (!emailConfig.smtpHost || !emailConfig.smtpUser) {
if (confirm('检测到邮件配置未完成,定时任务需要邮件配置才能发送报告。\n\n是否继续保存定时任务配置(不保存邮件配置)?')) {
// 继续保存,但不包含邮件配置
} else {
return;
}
}
// 构建完整配置对象
const fullConfig = {
scheduler: schedulerConfig,
email: emailConfig
};
showSchedulerStatus('正在保存配置...', 'info'); showSchedulerStatus('正在保存配置...', 'info');
try { try {
// 先获取当前服务器配置(保留 email/scrapers 等字段)
const getResponse = await fetch(`${API_BASE}/config`);
const getData = await getResponse.json();
const currentCfg = (getData.success && getData.data) ? getData.data : {};
const fullConfig = { ...currentCfg, scheduler: schedulerConfig };
const response = await fetch(`${API_BASE}/config`, { const response = await fetch(`${API_BASE}/config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -892,7 +844,6 @@ async function saveSchedulerConfig() {
if (data.success) { if (data.success) {
showSchedulerStatus('配置已保存,定时任务已重新加载!', 'success'); showSchedulerStatus('配置已保存,定时任务已重新加载!', 'success');
// 刷新状态显示
await updateSchedulerStatus(); await updateSchedulerStatus();
} else { } else {
showSchedulerStatus(`保存失败: ${data.error}`, 'error'); showSchedulerStatus(`保存失败: ${data.error}`, 'error');
@@ -1112,7 +1063,7 @@ async function generateCombinedReport() {
function displayCombinedReport(winningReport, bidReport, container) { function displayCombinedReport(winningReport, bidReport, container) {
const html = ` const html = `
<!-- 中标公示部分 --> <!-- 中标公示部分 -->
<div class="summary" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);"> <div class="summary" style="background: linear-gradient(135deg, #0f6ecd 0%, #0ea5a4 100%);">
<h2>中标公示报告</h2> <h2>中标公示报告</h2>
<div class="stat"> <div class="stat">
<div class="stat-label">总项目数</div> <div class="stat-label">总项目数</div>

File diff suppressed because it is too large Load Diff

1579
public/results.html Normal file

File diff suppressed because it is too large Load Diff

100
restart.ps1 Normal file
View File

@@ -0,0 +1,100 @@
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$LogDir = Join-Path $ScriptDir 'logs'
$PidFile = Join-Path $LogDir 'server.pid'
$LogFile = Join-Path $LogDir 'server.log'
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
function Get-PortFromEnvFile {
$envFile = Join-Path $ScriptDir '.env'
if (-not (Test-Path $envFile)) {
return $null
}
$line = Get-Content $envFile | Where-Object { $_ -match '^PORT=' } | Select-Object -Last 1
if (-not $line) {
return $null
}
return ($line -replace '^PORT=', '').Trim()
}
function Get-ProjectServerProcesses {
Get-CimInstance Win32_Process | Where-Object {
$_.Name -eq 'node.exe' -and
$_.CommandLine -like '*src/server.js*' -and
$_.CommandLine -like "*$ScriptDir*"
}
}
function Stop-ExistingServer {
$processes = @(Get-ProjectServerProcesses)
if ($processes.Count -eq 0) {
Write-Host "No existing server process found for $ScriptDir"
return
}
$ids = $processes | ForEach-Object { $_.ProcessId }
Write-Host ("Stopping existing server process(es): " + ($ids -join ', '))
foreach ($process in $processes) {
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 2
}
function Start-Server {
$port = if ($env:PORT) { $env:PORT } else { Get-PortFromEnvFile }
if (-not $port) { $port = '5000' }
Write-Host "Starting server from $ScriptDir on port $port"
$command = "Set-Location -LiteralPath '$ScriptDir'; node src/server.js *>> '$LogFile'"
$process = Start-Process -FilePath 'powershell.exe' `
-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $command `
-WindowStyle Hidden `
-PassThru
Set-Content -Path $PidFile -Value $process.Id
Write-Host "Started PID: $($process.Id)"
return $port
}
function Show-Status {
param(
[string]$Port
)
Start-Sleep -Seconds 2
Write-Host ''
Write-Host 'Active project server process(es):'
$processes = @(Get-ProjectServerProcesses)
if ($processes.Count -eq 0) {
Write-Host 'None'
} else {
$processes | Select-Object ProcessId, Name, CommandLine | Format-Table -AutoSize
}
Write-Host ''
Write-Host 'Port check:'
Get-NetTCPConnection -LocalPort ([int]$Port) -State Listen -ErrorAction SilentlyContinue |
Select-Object LocalAddress, LocalPort, OwningProcess | Format-Table -AutoSize
Write-Host ''
Write-Host 'Recent log output:'
if (Test-Path $LogFile) {
Get-Content $LogFile -Tail 30
} else {
Write-Host 'No log file yet.'
}
}
Stop-ExistingServer
$port = Start-Server
Show-Status -Port $port

83
restart.sh Normal file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$SCRIPT_DIR/logs"
PID_FILE="$LOG_DIR/server.pid"
LOG_FILE="$LOG_DIR/server.log"
mkdir -p "$LOG_DIR"
PORT="${PORT:-}"
if [[ -z "$PORT" && -f "$SCRIPT_DIR/.env" ]]; then
PORT="$(grep -E '^PORT=' "$SCRIPT_DIR/.env" | tail -n 1 | cut -d '=' -f 2- | tr -d '\r' || true)"
fi
PORT="${PORT:-5000}"
find_project_server_pids() {
pgrep -f "node src/server.js" | while read -r pid; do
[[ -n "$pid" ]] || continue
[[ -d "/proc/$pid" ]] || continue
local cwd
cwd="$(readlink -f "/proc/$pid/cwd" 2>/dev/null || true)"
if [[ "$cwd" == "$SCRIPT_DIR" ]]; then
echo "$pid"
fi
done
}
stop_existing_server() {
local pids
pids="$(find_project_server_pids || true)"
if [[ -z "$pids" ]]; then
echo "No existing server process found for $SCRIPT_DIR"
return
fi
echo "Stopping existing server process(es): $pids"
while read -r pid; do
[[ -n "$pid" ]] || continue
kill "$pid" 2>/dev/null || true
done <<< "$pids"
sleep 2
local remaining
remaining="$(find_project_server_pids || true)"
if [[ -n "$remaining" ]]; then
echo "Force killing remaining process(es): $remaining"
while read -r pid; do
[[ -n "$pid" ]] || continue
kill -9 "$pid" 2>/dev/null || true
done <<< "$remaining"
fi
}
start_server() {
cd "$SCRIPT_DIR"
echo "Starting server from $SCRIPT_DIR on port $PORT"
nohup node src/server.js >> "$LOG_FILE" 2>&1 &
local pid=$!
echo "$pid" > "$PID_FILE"
echo "Started PID: $pid"
}
show_status() {
sleep 2
echo
echo "Active project server process(es):"
find_project_server_pids || true
echo
echo "Port check:"
ss -lntp 2>/dev/null | grep ":$PORT" || true
echo
echo "Recent log output:"
tail -n 30 "$LOG_FILE" 2>/dev/null || true
}
stop_existing_server
start_server
show_status

1
scrapegraph-service/.env Normal file
View File

@@ -0,0 +1 @@


151
src/agentService.js Normal file
View File

@@ -0,0 +1,151 @@
/**
* Agent API 服务封装
* 调用本地部署的 agent 进行公告抓取
*/
const DEFAULT_BASE_URL = 'http://192.168.3.65:18625';
const DEFAULT_POLL_INTERVAL = 3000; // 3秒轮询
const DEFAULT_TIMEOUT = 3600000; // 1小时超时
const FETCH_TIMEOUT = 30000; // 单次 fetch 30秒超时
const MAX_FETCH_RETRIES = 5; // 网络错误最多重试5次
const DEFAULT_MODE = 'qwen3.5-plus';
function normalizeMode(value) {
if (typeof value === 'string' && value.trim()) return value.trim();
return DEFAULT_MODE;
}
function generateTaskId() {
return `task-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 带超时和重试的 fetch
*/
async function fetchWithRetry(url, fetchOptions, retries = MAX_FETCH_RETRIES, logPrefix = '[Agent]') {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
const res = await fetch(url, { ...fetchOptions, signal: controller.signal });
clearTimeout(timer);
return res;
} catch (err) {
const isLast = attempt === retries;
console.warn(`${logPrefix} fetch 失败 (${attempt}/${retries}): ${err.message}`);
if (isLast) throw err;
await sleep(3000 * attempt); // 递增等待
}
}
}
/**
* 创建 agent 任务
*/
async function createTask(prompt, options = {}) {
const baseUrl = options.baseUrl || DEFAULT_BASE_URL;
const mode = normalizeMode(options.mode);
const taskId = generateTaskId();
const logPrefix = options.logPrefix || '[Agent]';
const res = await fetchWithRetry(`${baseUrl}/agent/createTask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId, prompt, mode }),
}, MAX_FETCH_RETRIES, logPrefix);
if (!res.ok) {
throw new Error(`创建任务失败: HTTP ${res.status}`);
}
return { taskId };
}
/**
* 检查任务状态
* 返回空/null 表示任务还在运行,返回 { success, message, data } 表示完成
*/
async function checkTask(taskId, options = {}) {
const baseUrl = options.baseUrl || DEFAULT_BASE_URL;
const logPrefix = options.logPrefix || '[Agent]';
const res = await fetchWithRetry(`${baseUrl}/agent/checkTask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId }),
}, MAX_FETCH_RETRIES, logPrefix);
if (!res.ok) {
throw new Error(`检查任务失败: HTTP ${res.status}`);
}
const text = await res.text();
console.log(`${logPrefix} checkTask(${taskId}) 返回:`, text ? text.substring(0, 500) : '(空)');
if (!text || text.trim() === '' || text.trim() === 'null') {
return null; // 任务还在运行
}
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* 运行 agent 任务:创建 + 轮询直到完成
* 返回 { results: [{ type, project_name, amount_yuan, date, detail_link }] }
*/
export async function runAgentTask(prompt, options = {}) {
const baseUrl = options.baseUrl || DEFAULT_BASE_URL;
const mode = normalizeMode(options.mode);
const pollInterval = options.pollInterval || DEFAULT_POLL_INTERVAL;
const timeout = options.timeout || DEFAULT_TIMEOUT;
const logPrefix = options.logPrefix || '[Agent]';
console.log(`${logPrefix} 创建任务...`);
console.log(`${logPrefix} 使用 mode: ${mode}`);
const { taskId } = await createTask(prompt, { baseUrl, mode, logPrefix });
console.log(`${logPrefix} 任务已创建: ${taskId}`);
const startTime = Date.now();
while (true) {
if (Date.now() - startTime > timeout) {
throw new Error(`任务超时 (${timeout / 1000}秒): ${taskId}`);
}
await sleep(pollInterval);
const result = await checkTask(taskId, { baseUrl, logPrefix });
if (result === null) {
const elapsed = Math.round((Date.now() - startTime) / 1000);
console.log(`${logPrefix} 任务进行中... (${elapsed}秒)`);
continue;
}
if (result.success) {
console.log(`${logPrefix} 任务完成: ${result.message}`);
const data = result.data || {};
const rawResults = Array.isArray(data.results) ? data.results : [];
const results = rawResults.map(item => {
if (!item || typeof item !== 'object') return item;
const detailLink = item.detail_link || item.target_link;
const { target_link, ...rest } = item;
return detailLink ? { ...rest, detail_link: detailLink } : rest;
});
console.log(`${logPrefix} 获取到 ${results.length} 条结果`);
return { results };
} else {
throw new Error(`任务失败: ${result.message || '未知错误'}`);
}
}
}
export { generateTaskId, createTask, checkTask };

View File

@@ -730,3 +730,172 @@ function generateReportHtml(report) {
</html> </html>
`; `;
} }
// ========== 通用抓取结果邮件(定时任务使用) ==========
export async function sendScraperResultsEmail(emailConfig, results) {
try {
const transporter = nodemailer.createTransport({
host: emailConfig.smtpHost,
port: emailConfig.smtpPort || 587,
secure: emailConfig.smtpPort === 465,
auth: {
user: emailConfig.smtpUser,
pass: emailConfig.smtpPass,
},
});
const htmlContent = generateScraperResultsHtml(results);
const successCount = results.filter(r => !r.error).length;
const info = await transporter.sendMail({
from: `"公告采集系统" <${emailConfig.smtpUser}>`,
to: emailConfig.recipients,
subject: `公告采集结果报告(${successCount}条) - ${new Date().toLocaleDateString('zh-CN')}`,
html: htmlContent,
});
return { success: true, messageId: info.messageId };
} catch (error) {
console.error('发送抓取结果邮件失败:', error);
throw new Error(`邮件发送失败: ${error.message}`);
}
}
function generateScraperResultsHtml(results) {
const successResults = results.filter(r => !r.error);
const failResults = results.filter(r => r.error);
const generatedAt = new Date().toLocaleString('zh-CN');
// Flatten all successful source items into one table.
const allRows = [];
for (const r of successResults) {
const items = r.data?.results || r.data?.result || [];
for (const item of items) {
const hasAmount = typeof item.amount_yuan === 'number' || !!item.amount;
const amountText =
typeof item.amount_yuan === 'number'
? `${item.amount_yuan.toLocaleString('zh-CN')} CNY`
: (item.amount || 'N/A');
allRows.push({
section: [r.section, r.subsection].filter(Boolean).join(' / ') || r.city || '-',
type: item.type || r.type || '-',
title: item.project_name || item.title || '-',
date: item.date || '-',
amount: amountText,
hasAmount,
url: item.detail_link || item.target_link || item.url || '',
});
}
}
allRows.sort((a, b) => {
if (a.date === b.date) return 0;
return a.date > b.date ? -1 : 1;
});
const totalItems = allRows.length;
// 行颜色交替
const rowHtml = allRows.length === 0
? `<tr><td colspan="6" style="text-align:center;color:#999;padding:30px;font-size:14px;">暂无数据</td></tr>`
: allRows.map((row, i) => `
<tr style="background:${i % 2 === 0 ? '#fff' : '#f7f8ff'};">
<td style="padding:9px 12px;border-bottom:1px solid #eaecf5;white-space:nowrap;color:#555;font-size:13px;">${row.section}</td>
<td style="padding:9px 12px;border-bottom:1px solid #eaecf5;white-space:nowrap;">
<span style="display:inline-block;padding:2px 8px;background:#e8f4fd;color:#1a73c8;border-radius:10px;font-size:11px;font-weight:600;">${row.type}</span>
</td>
<td style="padding:9px 12px;border-bottom:1px solid #eaecf5;font-size:13px;max-width:320px;">${row.title}</td>
<td style="padding:9px 12px;border-bottom:1px solid #eaecf5;white-space:nowrap;font-size:13px;color:#555;">${row.date}</td>
<td style="padding:9px 12px;border-bottom:1px solid #eaecf5;white-space:nowrap;font-size:13px;font-weight:600;color:${row.hasAmount ? '#e67e22' : '#aaa'};">${row.amount}</td>
<td style="padding:9px 12px;border-bottom:1px solid #eaecf5;text-align:center;">
${row.url
? `<a href="${row.url}" target="_blank" style="color:#667eea;font-size:12px;text-decoration:none;white-space:nowrap;">查看 →</a>`
: '<span style="color:#ccc;font-size:12px;">-</span>'
}
</td>
</tr>`).join('');
// 失败来源列表
const failHtml = failResults.length === 0 ? '' : `
<div style="margin-top:24px;">
<div style="font-size:14px;font-weight:600;color:#c0392b;margin-bottom:10px;">⚠️ 抓取失败的来源(${failResults.length} 个)</div>
${failResults.map(r => `
<div style="background:#fdeaea;border-left:3px solid #e74c3c;padding:10px 14px;border-radius:4px;margin-bottom:8px;font-size:13px;">
<strong>${r.city || ''}${r.section ? ' · ' + r.section : ''}${r.type ? ' · ' + r.type : ''}</strong>
<div style="color:#999;font-size:12px;margin-top:4px;">${r.url}</div>
<div style="color:#c0392b;margin-top:4px;">❌ ${r.error}</div>
</div>`).join('')}
</div>`;
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>公告采集结果报告</title>
</head>
<body style="font-family:'PingFang SC','Microsoft YaHei',Arial,sans-serif;line-height:1.6;color:#333;margin:0;padding:20px;background:#f0f2f8;">
<div style="max-width:960px;margin:0 auto;background:white;border-radius:10px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1);">
<!-- 标题栏 -->
<div style="background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);padding:24px 30px;color:white;">
<h1 style="margin:0;font-size:20px;font-weight:700;">📋 公告采集结果报告</h1>
<div style="margin-top:6px;opacity:.85;font-size:13px;">生成时间:${generatedAt}</div>
</div>
<!-- 统计栏 -->
<div style="display:flex;gap:0;border-bottom:1px solid #eaecf5;">
<div style="flex:1;padding:16px 24px;text-align:center;border-right:1px solid #eaecf5;">
<div style="font-size:28px;font-weight:700;color:#667eea;">${totalItems}</div>
<div style="font-size:12px;color:#888;margin-top:2px;">公告总数</div>
</div>
<div style="flex:1;padding:16px 24px;text-align:center;border-right:1px solid #eaecf5;">
<div style="font-size:28px;font-weight:700;color:#1a8a4a;">${successResults.length}</div>
<div style="font-size:12px;color:#888;margin-top:2px;">成功来源</div>
</div>
<div style="flex:1;padding:16px 24px;text-align:center;border-right:1px solid #eaecf5;">
<div style="font-size:28px;font-weight:700;color:#e67e22;">${allRows.filter(r => r.hasAmount).length}</div>
<div style="font-size:12px;color:#888;margin-top:2px;">有金额</div>
</div>
<div style="flex:1;padding:16px 24px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:${failResults.length > 0 ? '#c0392b' : '#aaa'};">${failResults.length}</div>
<div style="font-size:12px;color:#888;margin-top:2px;">失败来源</div>
</div>
</div>
<!-- 公告汇总表格 -->
<div style="padding:24px 30px;">
<div style="font-size:15px;font-weight:600;color:#333;margin-bottom:14px;">公告汇总(共 ${totalItems} 条)</div>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;">
<th style="padding:10px 12px;text-align:left;font-weight:600;white-space:nowrap;">板块</th>
<th style="padding:10px 12px;text-align:left;font-weight:600;white-space:nowrap;">类型</th>
<th style="padding:10px 12px;text-align:left;font-weight:600;">公告标题</th>
<th style="padding:10px 12px;text-align:left;font-weight:600;white-space:nowrap;">发布日期</th>
<th style="padding:10px 12px;text-align:left;font-weight:600;white-space:nowrap;">项目金额</th>
<th style="padding:10px 12px;text-align:center;font-weight:600;white-space:nowrap;">详情</th>
</tr>
</thead>
<tbody>
${rowHtml}
</tbody>
</table>
</div>
${failHtml}
<div style="margin-top:24px;padding-top:16px;border-top:1px solid #eaecf5;color:#aaa;font-size:12px;text-align:center;">
本报告由公告采集系统自动生成 · ${generatedAt}
</div>
</div>
</div>
</body>
</html>
`;
}

View File

@@ -0,0 +1,275 @@
const DEFAULT_SCRAPER_PROMPT = '提取页面上今日的招标公告信息,包括:标题、项目金额(可能为合同预估价/最高投标限价等、发布日期YYYY-MM-DD格式、详情页完整URL';
const PAYLOAD_MARKER = '__FC_PAYLOAD__';
function pad2(value) {
return String(value).padStart(2, '0');
}
function formatDate(year, month, day) {
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function getTodayInShanghai() {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
}
function parseTargetDate(prompt) {
const text = String(prompt || '');
if (!text) return null;
const fullDate = text.match(/(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})日?/);
if (fullDate) {
return formatDate(fullDate[1], fullDate[2], fullDate[3]);
}
if (/(今天|今日|当日)/.test(text)) {
return getTodayInShanghai();
}
return null;
}
function normalizeDate(input) {
if (!input) return '';
const text = String(input).trim();
if (!text) return '';
let m = text.match(/(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})日?/);
if (m) return formatDate(m[1], m[2], m[3]);
m = text.match(/(\d{1,2})[-/.月](\d{1,2})日?/);
if (m) {
const currentYear = Number(getTodayInShanghai().slice(0, 4));
return formatDate(currentYear, m[1], m[2]);
}
return '';
}
function extractDateFromText(text) {
if (!text) return '';
const m = String(text).match(/(20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)|(\d{1,2}[-/.月]\d{1,2}日?)/);
return m ? normalizeDate(m[0]) : '';
}
function extractAmountFromText(text) {
if (!text) return null;
const m = String(text).match(/([0-9][0-9,.\s]*(?:亿元|万元|万|元))/);
if (!m) return null;
return m[1].replace(/\s+/g, '').trim();
}
function cleanText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function toFiniteNumber(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function parsePayloadFromText(rawText) {
if (!rawText) return null;
const text = String(rawText);
const markerIndex = text.lastIndexOf(PAYLOAD_MARKER);
if (markerIndex >= 0) {
const tail = text.slice(markerIndex + PAYLOAD_MARKER.length);
const firstLine = tail.split(/\r?\n/).find(line => line.trim());
if (firstLine) {
try {
return JSON.parse(firstLine.trim());
} catch {
// Continue fallback parsing.
}
}
}
try {
return JSON.parse(text.trim());
} catch {
// Continue fallback parsing.
}
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean).reverse();
for (const line of lines) {
try {
return JSON.parse(line);
} catch {
// Try next line.
}
}
return null;
}
function parseBrowserExecutePayload(executeResult) {
const sources = [executeResult?.result, executeResult?.stdout]
.filter(value => typeof value === 'string' && value.trim().length > 0);
for (const source of sources) {
const payload = parsePayloadFromText(source);
if (payload && typeof payload === 'object') return payload;
}
return { items: [] };
}
function splitKeywords(input) {
return String(input || '')
.split(/[、/,|\s]+/)
.map(item => item.trim())
.filter(item => item.length >= 2);
}
function filterByTypeIfPossible(items, type) {
const keywords = splitKeywords(type);
if (keywords.length === 0) return items;
const filtered = items.filter(item => {
const haystack = `${item.title} ${item.context || ''}`;
return keywords.some(keyword => haystack.includes(keyword));
});
return filtered.length > 0 ? filtered : items;
}
function normalizeItems(rawItems, targetDate, scraperType) {
const dedup = new Map();
for (const raw of rawItems) {
const title = cleanText(raw?.title);
const url = cleanText(raw?.url);
if (!title || !url) continue;
const context = cleanText(raw?.context);
const date = normalizeDate(raw?.date) || extractDateFromText(context);
const amount = cleanText(raw?.amount) || extractAmountFromText(context) || null;
const key = `${title}@@${url}`;
if (!dedup.has(key)) {
dedup.set(key, { title, amount, date, url, context });
}
}
let items = Array.from(dedup.values());
items = filterByTypeIfPossible(items, scraperType);
if (targetDate) {
items = items.filter(item => item.date === targetDate);
}
return items
.map(({ title, amount, date, url }) => ({ title, amount, date, url }))
.slice(0, 100);
}
function buildBrowserScript(url) {
return `
const targetUrl = ${JSON.stringify(url)};
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(1500);
const payload = await page.evaluate(() => {
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const blockedTitles = new Set(['首页', '尾页', '上一页', '下一页', '更多', '详情', '查看', '返回', '跳转']);
const links = Array.from(document.querySelectorAll('a[href]'));
const rows = [];
const seen = new Set();
for (const a of links) {
const href = a.getAttribute('href') || '';
if (!href || href.startsWith('javascript:') || href.startsWith('#')) continue;
const title = normalize(a.textContent);
if (!title || title.length < 6 || title.length > 180) continue;
if (blockedTitles.has(title)) continue;
let absoluteUrl = '';
try {
absoluteUrl = new URL(href, location.href).href;
} catch {
continue;
}
const container = a.closest('tr,li,article,section,div,p,dd,dt') || a.parentElement;
const context = normalize(container ? container.textContent : title);
const dateMatch = context.match(/(20\\d{2}[-/.年]\\d{1,2}[-/.月]\\d{1,2}日?)|(\\d{1,2}[-/.月]\\d{1,2}日?)/);
const amountMatch = context.match(/([0-9][0-9,.\\s]*(?:亿元|万元|万|元))/);
const key = (title + '@@' + absoluteUrl).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
rows.push({
title,
url: absoluteUrl,
date: dateMatch ? dateMatch[0] : '',
amount: amountMatch ? amountMatch[0].replace(/\\s+/g, '') : null,
context,
});
}
return {
pageUrl: location.href,
items: rows.slice(0, 300),
};
});
console.log('${PAYLOAD_MARKER}' + JSON.stringify(payload));
JSON.stringify(payload);
`;
}
export async function runScraperWithBrowser(firecrawl, scraper, options = {}) {
const prefix = options.logPrefix || '[Browser]';
if (!scraper?.url) throw new Error('抓取 URL 不能为空');
const prompt = scraper.prompt || DEFAULT_SCRAPER_PROMPT;
const targetDate = parseTargetDate(prompt);
const ttl = toFiniteNumber(scraper.browserTtl, 180);
const activityTtl = toFiniteNumber(scraper.browserActivityTtl, 90);
const session = await firecrawl.browser({ ttl, activityTtl });
if (!session?.success || !session.id) {
throw new Error(session?.error || '创建 Browser 会话失败');
}
let executeResult;
try {
executeResult = await firecrawl.browserExecute(session.id, {
code: buildBrowserScript(scraper.url),
language: 'node',
});
} finally {
try {
await firecrawl.deleteBrowser(session.id);
} catch (closeError) {
console.warn(`${prefix} 会话关闭失败: ${closeError.message}`);
}
}
if (!executeResult?.success) {
throw new Error(executeResult?.error || executeResult?.stderr || 'Browser 执行失败');
}
const payload = parseBrowserExecutePayload(executeResult);
const rawItems = Array.isArray(payload.items) ? payload.items : [];
const items = normalizeItems(rawItems, targetDate, scraper.type);
console.log(`${prefix} URL=${scraper.url} raw=${rawItems.length} normalized=${items.length}${targetDate ? ` targetDate=${targetDate}` : ''}`);
return {
items,
targetDate,
pageUrl: payload.pageUrl || scraper.url,
};
}

817
src/resultStore.js Normal file
View File

@@ -0,0 +1,817 @@
import Database from 'better-sqlite3';
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DEFAULT_DB_DIR = join(__dirname, '..', 'data');
const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, 'results.db');
const LEGACY_DB_PATH = join(DEFAULT_DB_DIR, 'results.sqlite');
const DB_PATH = resolveDbPath();
const CONFIG_PATH = join(__dirname, '..', 'config.json');
const MAX_RESULT_RECORDS = 500;
const DEFAULT_TASK_MODE = 'qwen3.5-plus';
const TASK_COLUMNS = ['id', 'city', 'plate_name', 'prompt', 'enabled', 'mode', 'created_at', 'updated_at'];
let db = null;
let initialized = false;
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function removeFileIfExists(filePath) {
if (!existsSync(filePath)) return;
unlinkSync(filePath);
}
function migrateLegacyDbIfNeeded(nextPath, legacyPath) {
if (existsSync(nextPath) || !existsSync(legacyPath)) return nextPath;
const legacyDb = new Database(legacyPath);
try {
legacyDb.pragma('wal_checkpoint(TRUNCATE)');
} catch (_error) {
// Ignore checkpoint failures and still attempt to switch to single-file mode.
}
legacyDb.pragma('journal_mode = DELETE');
legacyDb.close();
try {
renameSync(legacyPath, nextPath);
} catch (_error) {
copyFileSync(legacyPath, nextPath);
}
removeFileIfExists(`${legacyPath}-shm`);
removeFileIfExists(`${legacyPath}-wal`);
return nextPath;
}
function resolveDbPath() {
const explicitPath = process.env.APP_DB_PATH || process.env.RESULTS_DB_PATH;
if (explicitPath) return explicitPath;
mkdirSync(DEFAULT_DB_DIR, { recursive: true });
try {
return migrateLegacyDbIfNeeded(DEFAULT_DB_PATH, LEGACY_DB_PATH);
} catch (error) {
if (existsSync(LEGACY_DB_PATH)) {
console.warn(`[resultStore] Legacy database migration skipped: ${error.message}`);
return LEGACY_DB_PATH;
}
throw error;
}
}
function generateResultId() {
return `result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
function generateTaskId() {
return `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
}
function getDefaultJsonConfig() {
return {
agent: {
baseUrl: '',
pollInterval: 3000,
timeout: 300000,
},
scheduler: {
enabled: false,
cronTime: '0 9 * * *',
threshold: 100000,
description: '',
timeRange: 'thisMonth',
},
email: {
smtpHost: '',
smtpPort: 587,
smtpUser: '',
smtpPass: '',
recipients: '',
},
};
}
function normalizeJsonConfig(input = {}) {
const defaults = getDefaultJsonConfig();
const agent = input.agent || {};
const scheduler = input.scheduler || {};
const email = input.email || {};
return {
agent: {
baseUrl: typeof agent.baseUrl === 'string' ? agent.baseUrl : defaults.agent.baseUrl,
pollInterval: Number.isFinite(agent.pollInterval) ? agent.pollInterval : defaults.agent.pollInterval,
timeout: Number.isFinite(agent.timeout) ? agent.timeout : defaults.agent.timeout,
},
scheduler: {
enabled: scheduler.enabled === true,
cronTime: typeof scheduler.cronTime === 'string' && scheduler.cronTime.trim()
? scheduler.cronTime
: defaults.scheduler.cronTime,
threshold: Number.isFinite(scheduler.threshold) ? scheduler.threshold : defaults.scheduler.threshold,
description: typeof scheduler.description === 'string' ? scheduler.description : defaults.scheduler.description,
timeRange: typeof scheduler.timeRange === 'string' && scheduler.timeRange.trim()
? scheduler.timeRange
: defaults.scheduler.timeRange,
},
email: {
smtpHost: typeof email.smtpHost === 'string' ? email.smtpHost : defaults.email.smtpHost,
smtpPort: Number.isFinite(email.smtpPort) ? email.smtpPort : defaults.email.smtpPort,
smtpUser: typeof email.smtpUser === 'string' ? email.smtpUser : defaults.email.smtpUser,
smtpPass: typeof email.smtpPass === 'string' ? email.smtpPass : defaults.email.smtpPass,
recipients: typeof email.recipients === 'string' ? email.recipients : defaults.email.recipients,
},
};
}
function normalizeTaskMode(value) {
if (typeof value === 'string' && value.trim()) return value.trim();
return DEFAULT_TASK_MODE;
}
function buildTaskRecord(task = {}) {
return {
id: task.id || generateTaskId(),
city: task.city || '',
plateName: task.plateName || '',
prompt: task.prompt || '',
enabled: task.enabled !== false,
mode: normalizeTaskMode(task.mode),
};
}
function buildResultRecord(result = {}) {
return {
id: result.id || generateResultId(),
taskId: result.taskId || null,
city: result.city || null,
scrapedAt: result.scrapedAt || new Date().toISOString(),
error: result.error || null,
data: result.data ?? null,
};
}
function parseTaskRow(row) {
return {
id: row.id,
city: row.city,
plateName: row.plate_name,
prompt: row.prompt,
enabled: row.enabled === 1,
mode: normalizeTaskMode(row.mode),
};
}
function parseResultRow(row) {
return {
id: row.id,
taskId: row.task_id,
city: row.city,
scrapedAt: row.scraped_at,
error: row.error,
data: row.data_json ? JSON.parse(row.data_json) : null,
};
}
function getDb() {
if (db) return db;
mkdirSync(dirname(DB_PATH), { recursive: true });
db = new Database(DB_PATH);
try {
db.pragma('journal_mode = DELETE');
} catch (error) {
console.warn(`[resultStore] Database journal mode unchanged: ${error.message}`);
}
return db;
}
function ensureSchema() {
getDb().exec(`
CREATE TABLE IF NOT EXISTS results (
id TEXT PRIMARY KEY,
task_id TEXT,
city TEXT,
scraped_at TEXT NOT NULL,
error TEXT,
data_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_results_scraped_at ON results (scraped_at DESC);
CREATE INDEX IF NOT EXISTS idx_results_city ON results (city);
CREATE INDEX IF NOT EXISTS idx_results_task_id ON results (task_id);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
city TEXT,
plate_name TEXT,
prompt TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
mode TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
function ensureTasksTableShape() {
const columns = getDb().prepare(`PRAGMA table_info(tasks)`).all();
const columnNames = columns.map((column) => column.name);
const hasLegacyBrowserColumn = columnNames.includes('use_browser');
const matchesExpectedShape =
columnNames.length === TASK_COLUMNS.length &&
TASK_COLUMNS.every((column, index) => columnNames[index] === column);
if (!hasLegacyBrowserColumn && matchesExpectedShape) return;
getDb().exec(`
BEGIN;
ALTER TABLE tasks RENAME TO tasks_legacy;
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
city TEXT,
plate_name TEXT,
prompt TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
mode TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO tasks (id, city, plate_name, prompt, enabled, mode, created_at, updated_at)
SELECT
id,
city,
plate_name,
prompt,
COALESCE(enabled, 1),
COALESCE(mode, '${DEFAULT_TASK_MODE}'),
COALESCE(created_at, datetime('now')),
COALESCE(updated_at, datetime('now'))
FROM tasks_legacy;
DROP TABLE tasks_legacy;
COMMIT;
`);
}
function trimResults(limit = MAX_RESULT_RECORDS) {
getDb().prepare(`
DELETE FROM results
WHERE id NOT IN (
SELECT id
FROM results
ORDER BY scraped_at DESC, rowid DESC
LIMIT ?
)
`).run(limit);
}
function readJsonIfExists(filePath) {
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, 'utf-8'));
}
function stripTasksFromConfig(config) {
if (!config || typeof config !== 'object') return getDefaultJsonConfig();
const { agent, scheduler, email } = config;
return normalizeJsonConfig({ agent, scheduler, email });
}
function ensureJsonConfigExists() {
if (existsSync(CONFIG_PATH)) return;
writeFileSync(CONFIG_PATH, JSON.stringify(getDefaultJsonConfig(), null, 2), 'utf-8');
}
function queryBaseRows({ city, taskId }) {
const clauses = [];
const params = [];
if (city) {
clauses.push('city = ?');
params.push(city);
}
if (taskId) {
clauses.push('task_id = ?');
params.push(taskId);
}
const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const sql = `
SELECT id, task_id, city, scraped_at, error, data_json
FROM results
${whereSql}
ORDER BY scraped_at DESC, rowid DESC
`;
return getDb().prepare(sql).all(...params).map(parseResultRow);
}
function matchSection(record, section) {
if (!section) return true;
if (record.section === section || record.subsection === section) return true;
const items = record.data?.results || [];
return items.some((item) => item.section === section || item.subsection === section);
}
function matchType(record, type) {
if (!type) return true;
if (record.type === type) return true;
const items = record.data?.results || [];
return items.some((item) => item.type === type);
}
function normalizeProjectName(item) {
if (!item || typeof item !== 'object') return '';
const candidates = [
item.project_name,
item.projectName,
item.title,
item.name,
item.bidName,
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.replace(/\s+/g, ' ').trim();
}
}
return '';
}
function dedupeRowsByProjectName(rows) {
const seenProjectNames = new Set();
const dedupedRows = [];
for (const row of rows) {
const items = Array.isArray(row.data?.results) ? row.data.results : [];
if (items.length === 0) continue;
const uniqueItems = [];
for (const item of items) {
const projectName = normalizeProjectName(item);
if (!projectName) {
uniqueItems.push(item);
continue;
}
if (seenProjectNames.has(projectName)) continue;
seenProjectNames.add(projectName);
uniqueItems.push(item);
}
if (uniqueItems.length === 0) continue;
dedupedRows.push({
...row,
data: {
...(row.data || {}),
results: uniqueItems,
total: uniqueItems.length,
},
});
}
return dedupedRows;
}
function pickProjectLink(item) {
const candidates = [item?.detail_link, item?.target_link, item?.url, item?.href];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
}
return '';
}
function parseProjectSection(item) {
if (typeof item?.section === 'string' && item.section.trim()) return item.section.trim();
if (typeof item?.type === 'string' && item.type.trim()) {
return item.type.split(/[-/]/)[0].trim();
}
return '';
}
function parseProjectAmount(item) {
if (typeof item?.amount_yuan === 'number' && Number.isFinite(item.amount_yuan)) {
return item.amount_yuan;
}
if (typeof item?.amount === 'number' && Number.isFinite(item.amount)) {
return item.amount;
}
return null;
}
function projectSortValue(project) {
const dateValue = Date.parse(project.date || '');
const scrapedAtValue = Date.parse(project.scrapedAt || '');
return {
dateValue: Number.isFinite(dateValue) ? dateValue : 0,
scrapedAtValue: Number.isFinite(scrapedAtValue) ? scrapedAtValue : 0,
};
}
function compareProjectsDesc(a, b) {
const aValue = projectSortValue(a);
const bValue = projectSortValue(b);
if (bValue.dateValue !== aValue.dateValue) return bValue.dateValue - aValue.dateValue;
if (bValue.scrapedAtValue !== aValue.scrapedAtValue) return bValue.scrapedAtValue - aValue.scrapedAtValue;
return (a.projectName || '').localeCompare(b.projectName || '', 'zh-CN');
}
function normalizeSearchText(value) {
if (typeof value !== 'string') return '';
return value.trim().toLowerCase();
}
function includesSearchText(source, keyword) {
if (!keyword) return true;
return normalizeSearchText(source).includes(keyword);
}
function parseNumberFilter(value) {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseDateFilter(value) {
if (typeof value !== 'string' || !value.trim()) return null;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
function matchProjectFilters(project, filters = {}) {
const cityKeyword = normalizeSearchText(filters.city);
const sectionKeyword = normalizeSearchText(filters.section);
const projectNameKeyword = normalizeSearchText(filters.projectName);
const minAmount = parseNumberFilter(filters.minAmount);
const maxAmount = parseNumberFilter(filters.maxAmount);
const startDate = parseDateFilter(filters.startDate);
const endDate = parseDateFilter(filters.endDate);
const projectDate = parseDateFilter(project.date);
if (!includesSearchText(project.city, cityKeyword)) return false;
if (!includesSearchText(project.section || project.type, sectionKeyword)) return false;
if (!includesSearchText(project.projectName, projectNameKeyword)) return false;
if (minAmount !== null && (project.amountYuan === null || project.amountYuan < minAmount)) return false;
if (maxAmount !== null && (project.amountYuan === null || project.amountYuan > maxAmount)) return false;
if (startDate !== null && (projectDate === null || projectDate < startDate)) return false;
if (endDate !== null && (projectDate === null || projectDate > endDate)) return false;
return true;
}
function buildProjectList(rows, {
dedupeByName = false,
city,
section,
projectNameKeyword,
minAmount,
maxAmount,
startDate,
endDate,
} = {}) {
const seenProjectNames = new Set();
const projects = [];
for (const row of rows) {
const items = Array.isArray(row.data?.results) ? row.data.results : [];
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const projectName = normalizeProjectName(item);
if (!projectName) continue;
const projectSection = parseProjectSection(item);
if (city && row.city !== city) continue;
if (section && projectSection !== section) continue;
if (dedupeByName) {
if (seenProjectNames.has(projectName)) continue;
seenProjectNames.add(projectName);
}
projects.push({
id: `${row.id}:${index}`,
resultId: row.id,
taskId: row.taskId,
city: row.city || '',
section: projectSection,
type: typeof item?.type === 'string' ? item.type : '',
projectName,
amountYuan: parseProjectAmount(item),
date: typeof item?.date === 'string' ? item.date : '',
detailLink: pickProjectLink(item),
scrapedAt: row.scrapedAt,
raw: item,
});
}
}
return projects
.filter((project) => matchProjectFilters(project, {
city,
section,
projectName: projectNameKeyword,
minAmount,
maxAmount,
startDate,
endDate,
}))
.sort(compareProjectsDesc);
}
export function initResultsStore() {
if (initialized) return;
ensureSchema();
ensureTasksTableShape();
ensureJsonConfigExists();
initialized = true;
}
export function loadConfig() {
initResultsStore();
const jsonConfig = normalizeJsonConfig(readJsonIfExists(CONFIG_PATH) || getDefaultJsonConfig());
return {
...clone(jsonConfig),
tasks: listTasks(),
};
}
export function saveConfig(config) {
initResultsStore();
const jsonConfig = stripTasksFromConfig(config);
writeFileSync(CONFIG_PATH, JSON.stringify(jsonConfig, null, 2), 'utf-8');
return {
...clone(jsonConfig),
tasks: listTasks(),
};
}
export function listTasks() {
initResultsStore();
return getDb()
.prepare(`
SELECT id, city, plate_name, prompt, enabled, mode
FROM tasks
ORDER BY rowid DESC
`)
.all()
.map(parseTaskRow);
}
export function getTaskById(id) {
initResultsStore();
const row = getDb()
.prepare(`
SELECT id, city, plate_name, prompt, enabled, mode
FROM tasks
WHERE id = ?
`)
.get(id);
return row ? parseTaskRow(row) : null;
}
export function createTask(task) {
initResultsStore();
const record = buildTaskRecord(task);
getDb().prepare(`
INSERT INTO tasks (id, city, plate_name, prompt, enabled, mode, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
`).run(
record.id,
record.city,
record.plateName,
record.prompt,
record.enabled ? 1 : 0,
record.mode,
);
return record;
}
export function updateTask(id, patch) {
initResultsStore();
const current = getTaskById(id);
if (!current) return null;
const next = buildTaskRecord({ ...current, ...patch, id });
getDb().prepare(`
UPDATE tasks
SET city = ?,
plate_name = ?,
prompt = ?,
enabled = ?,
mode = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
next.city,
next.plateName,
next.prompt,
next.enabled ? 1 : 0,
next.mode,
id,
);
return next;
}
export function deleteTaskById(id) {
initResultsStore();
const result = getDb().prepare('DELETE FROM tasks WHERE id = ?').run(id);
return result.changes > 0;
}
export function appendResult(result) {
initResultsStore();
const record = buildResultRecord(result);
getDb().prepare(`
INSERT INTO results (id, task_id, city, scraped_at, error, data_json)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
record.id,
record.taskId,
record.city,
record.scrapedAt,
record.error,
record.data === null ? null : JSON.stringify(record.data),
);
trimResults();
return record;
}
export function listResults({ city, section, type, taskId, page = 1, pageSize = 20, projectMode = false } = {}) {
initResultsStore();
let results = queryBaseRows({ city, taskId });
if (section) results = results.filter((record) => matchSection(record, section));
if (type) results = results.filter((record) => matchType(record, type));
if (projectMode) results = dedupeRowsByProjectName(results);
const normalizedPage = Math.max(1, parseInt(page, 10) || 1);
const normalizedPageSize = Math.max(1, parseInt(pageSize, 10) || 20);
const start = (normalizedPage - 1) * normalizedPageSize;
return {
total: results.length,
page: normalizedPage,
pageSize: normalizedPageSize,
data: results.slice(start, start + normalizedPageSize),
};
}
export function deleteResultById(id) {
initResultsStore();
const result = getDb().prepare('DELETE FROM results WHERE id = ?').run(id);
return result.changes > 0;
}
export function clearResults() {
initResultsStore();
getDb().prepare('DELETE FROM results').run();
}
export function getResultFilters({ projectMode = false } = {}) {
initResultsStore();
const rows = projectMode ? dedupeRowsByProjectName(queryBaseRows({})) : queryBaseRows({});
const cities = [...new Set(rows.map((row) => row.city).filter(Boolean))];
const sections = new Set();
const types = new Set();
for (const row of rows) {
if (row.section) sections.add(row.section);
if (row.subsection) sections.add(row.subsection);
if (row.type) types.add(row.type);
for (const item of row.data?.results || []) {
if (item.section) sections.add(item.section);
if (item.subsection) sections.add(item.subsection);
if (item.type) types.add(item.type);
}
}
return {
cities,
sections: [...sections],
types: [...types],
};
}
export function listProjects({
city,
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
page = 1,
pageSize = 20,
dedupeByName = true,
} = {}) {
initResultsStore();
const projects = getProjects({
dedupeByName,
city,
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
});
const normalizedPage = Math.max(1, parseInt(page, 10) || 1);
const normalizedPageSize = Math.max(1, parseInt(pageSize, 10) || 20);
const start = (normalizedPage - 1) * normalizedPageSize;
return {
total: projects.length,
page: normalizedPage,
pageSize: normalizedPageSize,
data: projects.slice(start, start + normalizedPageSize),
};
}
export function getProjects({
city,
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
dedupeByName = true,
} = {}) {
initResultsStore();
return buildProjectList(queryBaseRows({}), {
dedupeByName,
city,
section,
projectNameKeyword: projectName,
minAmount,
maxAmount,
startDate,
endDate,
});
}
export function getProjectFilters({ dedupeByName = true } = {}) {
initResultsStore();
const projects = getProjects({ dedupeByName });
const cities = [...new Set(projects.map((project) => project.city).filter(Boolean))];
const sections = [...new Set(projects.map((project) => project.section).filter(Boolean))];
return {
cities,
sections,
};
}
export function getResultsDbPath() {
return DB_PATH;
}

View File

@@ -1,606 +1,177 @@
import 'dotenv/config';
import cron from 'node-cron'; import cron from 'node-cron';
import { readFileSync } from 'fs'; import { sendScraperResultsEmail } from './emailService.js';
import { fileURLToPath } from 'url'; import { runAgentTask } from './agentService.js';
import { dirname, join } from 'path'; import { initResultsStore, loadConfig, appendResult } from './resultStore.js';
import axios from 'axios';
import * as cheerio from 'cheerio';
import iconv from 'iconv-lite';
import { sendCombinedReportEmail } from './emailService.js';
const __filename = fileURLToPath(import.meta.url); const DEFAULT_TASK_MODE = 'qwen3.5-plus';
const __dirname = dirname(__filename);
// 加载配置文件 let currentScheduledTask = null;
function loadConfig() {
try { function normalizeTaskMode(value) {
const configPath = join(__dirname, '..', 'config.json'); if (typeof value === 'string' && value.trim()) return value.trim();
const configContent = readFileSync(configPath, 'utf-8'); return DEFAULT_TASK_MODE;
return JSON.parse(configContent);
} catch (error) {
console.error('加载配置文件失败:', error.message);
console.error('请确保 config.json 文件存在并配置正确');
return null;
}
} }
// 根据时间范围类型获取开始和结束日期 async function runTask(task, agentCfg) {
function getDateRangeByType(timeRange) { const mode = normalizeTaskMode(task.mode);
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
let startDate, endDate; console.log(`[Scheduler][Agent] ${task.city}: start`);
endDate = `${year}-${month}-${day}`; // 结束日期都是今天 console.log(`[Scheduler][Agent] ${task.city}: mode=${mode}`);
switch (timeRange) { const { results } = await runAgentTask(task.prompt, {
case 'today': baseUrl: agentCfg.baseUrl,
// 今日 mode,
startDate = `${year}-${month}-${day}`; pollInterval: agentCfg.pollInterval,
break; timeout: agentCfg.timeout,
logPrefix: `[Scheduler][Agent][${task.city}]`,
case 'thisWeek': {
// 本周 (从周一开始)
const dayOfWeek = now.getDay(); // 0是周日,1是周一
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // 计算到周一的天数差
const monday = new Date(now);
monday.setDate(now.getDate() - diff);
const weekYear = monday.getFullYear();
const weekMonth = String(monday.getMonth() + 1).padStart(2, '0');
const weekDay = String(monday.getDate()).padStart(2, '0');
startDate = `${weekYear}-${weekMonth}-${weekDay}`;
break;
}
case 'thisMonth':
default:
// 本月
startDate = `${year}-${month}-01`;
break;
}
return { startDate, endDate };
}
// 南京市公共资源交易平台 - 交通水务中标结果公示
const BASE_URL = 'https://njggzy.nanjing.gov.cn/njweb/jtsw/069008/';
// 南京市公共资源交易平台 - 交通水务招标公告
const BID_ANNOUNCE_BASE_URL = 'https://njggzy.nanjing.gov.cn/njweb/jtsw/069001/';
const http = axios.create({
responseType: 'arraybuffer',
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
}); });
function pickEncoding(contentType = '') { console.log(`[Scheduler][Agent] ${task.city}: ${results.length} results`);
const match = /charset=([^;]+)/i.exec(contentType);
if (!match) return 'utf-8';
const charset = match[1].trim().toLowerCase();
if (charset.includes('gb')) return 'gbk';
return charset;
}
async function fetchHtml(url) { return appendResult({
const res = await http.get(url); taskId: task.id,
const encoding = pickEncoding(res.headers['content-type']); city: task.city,
const html = iconv.decode(res.data, encoding || 'utf-8'); scrapedAt: new Date().toISOString(),
return html; data: { results, total: results.length },
}
function getPageUrl(pageIndex) {
if (pageIndex === 1) {
return `${BASE_URL}moreinfosl3.html`;
}
return `${BASE_URL}${pageIndex}.html`;
}
// 解析列表页HTML提取中标结果信息
function parseList(html) {
const $ = cheerio.load(html);
const items = [];
$('li.ewb-info-item2').each((_, row) => {
const $row = $(row);
const cells = $row.find('div.ewb-info-num2');
if (cells.length >= 5) {
const bidNo = $(cells[0]).find('p').attr('title') || $(cells[0]).find('p').text().trim();
const projectName = $(cells[1]).find('p').attr('title') || $(cells[1]).find('p').text().trim();
const bidName = $(cells[2]).find('p').attr('title') || $(cells[2]).find('p').text().trim();
const winningPrice = $(cells[3]).find('p').text().trim(); // 中标价格
const winningDate = $(cells[4]).find('p').text().trim(); // 中标日期
const onclick = $row.attr('onclick') || '';
const hrefMatch = onclick.match(/window\.open\(['"]([^'"]+)['"]\)/);
let href = '';
if (hrefMatch) {
href = hrefMatch[1];
if (href.startsWith('/')) {
href = `https://njggzy.nanjing.gov.cn${href}`;
}
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(winningDate)) return;
const price = parseFloat(winningPrice);
if (isNaN(price)) return;
items.push({
bidNo,
title: projectName,
bidName,
winningBid: { // 中标金额
amount: price,
unit: '万元'
},
date: winningDate,
href
}); });
} }
});
return items;
}
function isDateInRange(dateStr, startDate, endDate) {
if (!dateStr) return false;
const date = new Date(dateStr);
if (isNaN(date.getTime())) return false;
if (startDate && date < new Date(startDate)) return false;
if (endDate && date > new Date(endDate)) return false;
return true;
}
async function fetchListByDateRange(startDate, endDate, maxPages = 50) {
const allItems = [];
let shouldContinue = true;
let pageIndex = 1;
console.log(`开始按时间范围采集: ${startDate || '不限'}${endDate || '不限'}`);
while (shouldContinue && pageIndex <= maxPages) {
const pageUrl = getPageUrl(pageIndex);
console.log(`正在采集第 ${pageIndex} 页: ${pageUrl}`);
try {
const html = await fetchHtml(pageUrl);
const items = parseList(html);
if (items.length === 0) {
console.log(`${pageIndex} 页没有数据,停止采集`);
break;
}
let hasItemsInRange = false;
let allItemsBeforeRange = true;
for (const item of items) {
if (isDateInRange(item.date, startDate, endDate)) {
allItems.push(item);
hasItemsInRange = true;
allItemsBeforeRange = false;
} else if (startDate && new Date(item.date) < new Date(startDate)) {
allItemsBeforeRange = allItemsBeforeRange && true;
} else {
allItemsBeforeRange = false;
}
}
if (allItemsBeforeRange && startDate) {
console.log(`${pageIndex} 页所有项目都早于起始日期,停止采集`);
shouldContinue = false;
}
console.log(`${pageIndex} 页找到 ${items.length} 条,符合条件 ${hasItemsInRange ? '有' : '无'}`);
pageIndex++;
if (shouldContinue && pageIndex <= maxPages) {
await new Promise(resolve => setTimeout(resolve, 500));
}
} catch (err) {
console.error(`采集第 ${pageIndex} 页失败: ${err.message}`);
break;
}
}
console.log(`总共采集了 ${pageIndex - 1} 页,找到 ${allItems.length} 条符合条件的公告`);
return allItems;
}
// ========== 招标公告采集函数 ==========
// 获取招标公告分页URL
function getBidAnnouncePageUrl(pageIndex) {
if (pageIndex === 1) {
return `${BID_ANNOUNCE_BASE_URL}moreinfo5dc.html`;
}
return `${BID_ANNOUNCE_BASE_URL}${pageIndex}.html`;
}
// 解析招标公告列表页HTML
function parseBidAnnounceList(html) {
const $ = cheerio.load(html);
const items = [];
$('li.ewb-info-item2').each((_, row) => {
const $row = $(row);
const onclick = $row.attr('onclick') || '';
const hrefMatch = onclick.match(/window\.open\(['"]([^'"]+)['"]\)/);
if (!hrefMatch) return;
let href = hrefMatch[1];
if (href.startsWith('/')) {
href = `https://njggzy.nanjing.gov.cn${href}`;
}
const $titleP = $row.find('.ewb-info-num2').first().find('p');
const title = $titleP.attr('title') || $titleP.text().trim();
const $dateP = $row.find('.ewb-info-num2').last().find('p');
const dateText = $dateP.text().trim();
const dateMatch = dateText.match(/\d{4}-\d{2}-\d{2}/);
const date = dateMatch ? dateMatch[0] : '';
if (title && date) {
items.push({
title,
date,
href,
estimatedAmount: null
});
}
});
return items;
}
// 解析招标公告详情页,获取合同估算价
async function fetchBidAnnounceDetail(url) {
try {
const html = await fetchHtml(url);
const $ = cheerio.load(html);
const bodyText = $('body').text();
const amountMatch = bodyText.match(/合同估算价[:]\s*([\d,]+\.?\d*)\s*元/);
let estimatedAmount = null;
if (amountMatch) {
const amountStr = amountMatch[1].replace(/,/g, '');
estimatedAmount = parseFloat(amountStr);
}
const bidCodeMatch = bodyText.match(/标段编码[:]\s*([A-Za-z0-9\-]+)/);
const bidCode = bidCodeMatch ? bidCodeMatch[1] : null;
const tendereeMatch = bodyText.match(/招标人[为是][:]?\s*([^\s,,。]+)/);
const tenderee = tendereeMatch ? tendereeMatch[1] : null;
const durationMatch = bodyText.match(/计划工期[:]\s*(\d+)\s*日历天/);
const duration = durationMatch ? parseInt(durationMatch[1]) : null;
return { estimatedAmount, bidCode, tenderee, duration, url };
} catch (error) {
console.error(`获取招标详情失败 ${url}: ${error.message}`);
return { estimatedAmount: null, url };
}
}
// 按时间范围采集招标公告
async function fetchBidAnnounceByDateRange(startDate, endDate, maxPages = 20) {
const allItems = [];
let shouldContinue = true;
let pageIndex = 1;
console.log(`开始采集招标公告: ${startDate || '不限'}${endDate || '不限'}`);
while (shouldContinue && pageIndex <= maxPages) {
const pageUrl = getBidAnnouncePageUrl(pageIndex);
console.log(`正在采集招标公告第 ${pageIndex} 页: ${pageUrl}`);
try {
const html = await fetchHtml(pageUrl);
const items = parseBidAnnounceList(html);
if (items.length === 0) {
console.log(`${pageIndex} 页没有数据,停止采集`);
break;
}
let hasItemsInRange = false;
let allItemsBeforeRange = true;
for (const item of items) {
if (isDateInRange(item.date, startDate, endDate)) {
allItems.push(item);
hasItemsInRange = true;
allItemsBeforeRange = false;
} else if (startDate && new Date(item.date) < new Date(startDate)) {
allItemsBeforeRange = allItemsBeforeRange && true;
} else {
allItemsBeforeRange = false;
}
}
if (allItemsBeforeRange && startDate) {
console.log(`${pageIndex} 页所有项目都早于起始日期,停止采集`);
shouldContinue = false;
}
console.log(`${pageIndex} 页找到 ${items.length} 条,符合条件 ${hasItemsInRange ? '有' : '无'}`);
pageIndex++;
if (shouldContinue && pageIndex <= maxPages) {
await new Promise(resolve => setTimeout(resolve, 500));
}
} catch (err) {
console.error(`采集第 ${pageIndex} 页失败: ${err.message}`);
break;
}
}
console.log(`总共采集了 ${pageIndex - 1} 页,找到 ${allItems.length} 条符合条件的招标公告`);
// 获取详情(合同估算价)
if (allItems.length > 0) {
console.log(`开始获取 ${allItems.length} 条招标公告的详情...`);
for (let i = 0; i < allItems.length; i++) {
const item = allItems[i];
console.log(`获取详情 ${i + 1}/${allItems.length}: ${item.title.substring(0, 30)}...`);
const detail = await fetchBidAnnounceDetail(item.href);
item.estimatedAmount = detail.estimatedAmount;
item.bidCode = detail.bidCode;
item.tenderee = detail.tenderee;
item.duration = detail.duration;
if (i < allItems.length - 1) {
await new Promise(resolve => setTimeout(resolve, 300));
}
}
console.log('招标公告详情获取完成');
}
return allItems;
}
// 定时任务执行函数
async function executeScheduledTask(config) { async function executeScheduledTask(config) {
try { try {
console.log('========================================'); console.log('========================================');
console.log('定时任务开始执行(综合采集)'); console.log('Scheduler started');
console.log('执行时间:', new Date().toLocaleString('zh-CN')); console.log('Time:', new Date().toLocaleString('zh-CN'));
console.log('========================================'); console.log('========================================');
const timeRange = config.scheduler.timeRange || 'thisMonth'; const tasks = (config.tasks || []).filter((task) => task.enabled);
const { startDate, endDate } = getDateRangeByType(timeRange); const agentCfg = config.agent || {};
const winningThreshold = config.scheduler.winningThreshold !== undefined ? config.scheduler.winningThreshold : 10000; // 中标阈值默认1亿(10000万元)
const bidThreshold = config.scheduler.bidThreshold !== undefined ? config.scheduler.bidThreshold : 0; // 招标阈值默认0(不筛选)
const timeRangeNames = { if (tasks.length === 0) {
'today': '今日', console.log('No enabled tasks, skip');
'thisWeek': '本周',
'thisMonth': '本月'
};
console.log(`采集时间段: ${timeRangeNames[timeRange] || '本月'}`);
console.log(`采集时间范围: ${startDate}${endDate}`);
console.log(`中标金额阈值: ${winningThreshold}万元 (${(winningThreshold / 10000).toFixed(2)}亿元)`);
console.log(`招标金额阈值: ${bidThreshold}万元 ${bidThreshold === 0 ? '(不筛选)' : `(${(bidThreshold / 10000).toFixed(2)}亿元)`}`);
// ========== 1. 采集中标公示 ==========
console.log('\n========== 采集中标公示 ==========');
const winningItems = await fetchListByDateRange(startDate, endDate, 50);
// 筛选大于阈值的中标项目
const winningFiltered = winningItems.filter((item) => {
return item.winningBid && item.winningBid.amount > winningThreshold;
});
const winningTotal = winningFiltered.reduce(
(sum, item) => sum + (item.winningBid?.amount || 0),
0
);
console.log(`中标公示: 采集 ${winningItems.length} 条,符合阈值 ${winningFiltered.length}`);
// 生成中标报告
const winningReport = {
summary: {
total_count: winningItems.length,
filtered_count: winningFiltered.length,
threshold: `${winningThreshold}万元`,
total_amount: `${winningTotal.toFixed(2)}万元`,
generated_at: new Date().toISOString(),
date_range: { startDate, endDate },
},
projects: winningFiltered.map((item) => ({
bidNo: item.bidNo,
title: item.title,
bidName: item.bidName,
date: item.date,
winningBid: item.winningBid,
url: item.href,
})),
};
// ========== 2. 采集招标公告 ==========
console.log('\n========== 采集招标公告 ==========');
const bidItems = await fetchBidAnnounceByDateRange(startDate, endDate, 20);
// 筛选招标项目根据阈值筛选阈值为0时不筛选只要求有金额
const bidFiltered = bidItems.filter(item => {
if (!item.estimatedAmount) return false;
if (bidThreshold === 0) return true; // 阈值为0时不筛选
return item.estimatedAmount / 10000 > bidThreshold; // 估算价是元,阈值是万元,需要转换
});
const bidTotal = bidFiltered.reduce(
(sum, item) => sum + (item.estimatedAmount || 0),
0
);
console.log(`招标公告: 采集 ${bidItems.length} 条,有金额 ${bidFiltered.length}`);
// 生成招标报告
const bidReport = {
summary: {
total_count: bidItems.length,
filtered_count: bidFiltered.length,
has_amount_count: bidFiltered.length,
threshold: bidThreshold === 0 ? '无' : `${bidThreshold}万元`,
total_amount: `${(bidTotal / 10000).toFixed(2)}万元`,
total_amount_yuan: bidTotal,
generated_at: new Date().toISOString(),
date_range: { startDate, endDate },
report_type: '招标公告'
},
projects: bidFiltered.map((item) => ({
title: item.title,
bidCode: item.bidCode,
tenderee: item.tenderee,
date: item.date,
duration: item.duration,
estimatedAmount: item.estimatedAmount ? {
amount: item.estimatedAmount,
amountWan: (item.estimatedAmount / 10000).toFixed(2),
unit: '元'
} : null,
url: item.href,
})),
};
// ========== 3. 检查是否有数据需要发送 ==========
if (winningFiltered.length === 0 && bidFiltered.length === 0) {
console.log('\n========================================');
console.log('暂无符合条件的项目,不发送邮件');
console.log('========================================');
return; return;
} }
// ========== 4. 发送综合邮件 ========== console.log(`Enabled tasks: ${tasks.length}`);
console.log('\n========================================');
console.log('正在发送综合报告邮件...');
const emailConfig = config.email;
const result = await sendCombinedReportEmail(emailConfig, winningReport, bidReport); const results = [];
for (const task of tasks) {
try {
console.log(`\n---------- Task: ${task.city} ----------`);
const record = await runTask(task, agentCfg);
results.push(record);
console.log('Task completed');
} catch (error) {
console.error(`Task failed: ${error.message}`);
const errorRecord = appendResult({
taskId: task.id,
city: task.city,
scrapedAt: new Date().toISOString(),
error: error.message,
data: null,
});
results.push(errorRecord);
}
}
const successCount = results.filter((item) => !item.error).length;
const failCount = results.filter((item) => item.error).length;
console.log('\n========== Scheduler finished ==========');
console.log(`Success: ${successCount}, Failed: ${failCount}`);
if (successCount === 0) {
console.log('No successful results, skip email');
return;
}
if (config.email?.smtpHost && config.email?.smtpUser) {
console.log('\nSending email...');
try {
const emailResult = await sendScraperResultsEmail(config.email, results);
console.log('Email sent:', emailResult.messageId);
} catch (error) {
console.error('Email failed:', error.message);
}
} else {
console.log('Email config incomplete, skip email');
}
console.log('邮件发送成功!');
console.log('收件人:', emailConfig.recipients);
console.log('MessageId:', result.messageId);
console.log(`内容: 中标公示 ${winningFiltered.length} 条,招标公告 ${bidFiltered.length}`);
console.log('========================================'); console.log('========================================');
console.log('定时任务执行完成');
console.log('========================================');
} catch (error) { } catch (error) {
console.error('========================================'); console.error('========================================');
console.error('定时任务执行失败:', error.message); console.error('Scheduler failed:', error.message);
console.error(error.stack); console.error(error.stack);
console.error('========================================'); console.error('========================================');
} }
} }
// 存储当前的定时任务
let currentScheduledTask = null;
// 初始化定时任务
export function initScheduler() { export function initScheduler() {
initResultsStore();
const config = loadConfig(); const config = loadConfig();
if (!config.scheduler?.enabled) {
if (!config) { console.log('Scheduler disabled');
console.error('无法启动定时任务: 配置文件加载失败');
return;
}
if (!config.scheduler || !config.scheduler.enabled) {
console.log('定时任务已禁用');
return;
}
if (!config.email || !config.email.smtpHost || !config.email.smtpUser) {
console.error('无法启动定时任务: 邮件配置不完整');
console.error('请在 config.json 中配置邮件信息');
return; return;
} }
const cronTime = config.scheduler.cronTime || '0 9 * * *'; const cronTime = config.scheduler.cronTime || '0 9 * * *';
const enabledCount = (config.tasks || []).filter((task) => task.enabled).length;
console.log('========================================'); console.log('========================================');
console.log('定时任务已启动'); console.log('Scheduler enabled:', cronTime);
console.log('执行计划:', cronTime); console.log(`Enabled tasks: ${enabledCount}`);
console.log('中标阈值:', config.scheduler.winningThreshold, '万元'); if (config.email?.recipients) {
console.log('招标阈值:', config.scheduler.bidThreshold, '万元', config.scheduler.bidThreshold === 0 ? '(不筛选)' : ''); console.log('Recipients:', config.email.recipients);
console.log('收件人:', config.email.recipients); }
console.log('========================================'); console.log('========================================');
// 如果已有任务在运行,先停止
if (currentScheduledTask) { if (currentScheduledTask) {
currentScheduledTask.stop(); currentScheduledTask.stop();
console.log('已停止旧的定时任务');
} }
// 创建定时任务 currentScheduledTask = cron.schedule(
currentScheduledTask = cron.schedule(cronTime, () => { cronTime,
executeScheduledTask(config); () => {
}, { executeScheduledTask(loadConfig());
timezone: 'Asia/Shanghai' },
}); { timezone: 'Asia/Shanghai' },
);
} }
// 重新加载配置并重启定时任务
export function reloadScheduler() { export function reloadScheduler() {
console.log('重新加载定时任务配置...'); console.log('Reloading scheduler...');
// 停止当前任务
if (currentScheduledTask) { if (currentScheduledTask) {
currentScheduledTask.stop(); currentScheduledTask.stop();
currentScheduledTask = null; currentScheduledTask = null;
console.log('已停止当前定时任务');
} }
// 重新初始化
initScheduler(); initScheduler();
} }
// 停止定时任务
export function stopScheduler() { export function stopScheduler() {
if (currentScheduledTask) { if (!currentScheduledTask) return false;
currentScheduledTask.stop(); currentScheduledTask.stop();
currentScheduledTask = null; currentScheduledTask = null;
console.log('定时任务已停止'); console.log('Scheduler stopped');
return true; return true;
} }
return false;
}
// 获取定时任务状态
export function getSchedulerStatus() { export function getSchedulerStatus() {
const config = loadConfig(); const config = loadConfig();
const enabledTasks = (config.tasks || []).filter((task) => task.enabled).length;
return { return {
isRunning: currentScheduledTask !== null, isRunning: currentScheduledTask !== null,
config: config ? { enabledTasks,
config: {
enabled: config.scheduler?.enabled || false, enabled: config.scheduler?.enabled || false,
cronTime: config.scheduler?.cronTime || '0 9 * * *', cronTime: config.scheduler?.cronTime || '0 9 * * *',
winningThreshold: config.scheduler?.winningThreshold !== undefined ? config.scheduler.winningThreshold : 10000, description: config.scheduler?.description || '',
bidThreshold: config.scheduler?.bidThreshold !== undefined ? config.scheduler.bidThreshold : 0, },
timeRange: config.scheduler?.timeRange || 'thisMonth',
} : null,
}; };
} }
// 手动执行任务(用于测试)
export async function runTaskNow() { export async function runTaskNow() {
const config = loadConfig(); initResultsStore();
if (!config) { await executeScheduledTask(loadConfig());
throw new Error('配置文件加载失败');
}
await executeScheduledTask(config);
} }

File diff suppressed because it is too large Load Diff