Compare commits
21 Commits
a904137b60
...
agent_new3
| Author | SHA1 | Date | |
|---|---|---|---|
| 052f3a137b | |||
| 0d74cfe754 | |||
| 89d0abd44c | |||
| f8374f5e0d | |||
| f8dfad26a4 | |||
| 2a5fd99319 | |||
| 7bfba04199 | |||
| d78dc655ee | |||
| bd46d8f907 | |||
| 0648770a6a | |||
| 40118ec508 | |||
| b9270428db | |||
| 4f504447a1 | |||
| a2408fa952 | |||
| 61c93882d6 | |||
| 4653b1d7b9 | |||
| ad659c4ff0 | |||
| e3766b86be | |||
| ed03bd2032 | |||
| fb70356f5d | |||
| f2c856ab05 |
5
.env
Normal file
5
.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 服务器端口配置
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
# Firecrawl API Key(在 https://www.firecrawl.dev/app/api-keys 获取)
|
||||||
|
FIRECRAWL_API_KEY=fc-595dd922780442f8a907202666a522ef
|
||||||
@@ -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. 启动服务时会自动读取
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: Deploy Vue App
|
|||||||
# 触发条件:推送到 master 分支时执行
|
# 触发条件:推送到 master 分支时执行
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['master']
|
branches: ["南京公共资源交易中心"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IP: 106.15.181.192
|
IP: 106.15.181.192
|
||||||
@@ -13,11 +13,20 @@ jobs:
|
|||||||
# 运行在已注册的 runner 上(确保 runner 标签匹配,默认无标签可省略)
|
# 运行在已注册的 runner 上(确保 runner 标签匹配,默认无标签可省略)
|
||||||
runs-on: [test]
|
runs-on: [test]
|
||||||
steps:
|
steps:
|
||||||
# 步骤 4:部署到服务器
|
# 步骤 1:拉取代码
|
||||||
- name: Deploy to server
|
- name: Checkout code
|
||||||
|
uses: https://gitee.com/skr2005/checkout@v4
|
||||||
|
# 步骤 2:停止服务
|
||||||
|
- name: Stop service
|
||||||
|
run: net stop tool-node
|
||||||
|
|
||||||
|
# 步骤 3:拷贝源码到目标目录
|
||||||
|
- name: Copy source to target
|
||||||
|
run: xcopy /E /I /Y "${{ github.workspace }}\*" "D:\tools\tool-node\"
|
||||||
|
|
||||||
|
# 步骤 4:安装依赖并启动服务
|
||||||
|
- name: Install and start service
|
||||||
run: |
|
run: |
|
||||||
cd D:\tools\tool-node
|
cd D:\tools\tool-node
|
||||||
npm install
|
npm install
|
||||||
git pull
|
|
||||||
net stop tool-node
|
|
||||||
net start tool-node
|
net start tool-node
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
13
config.json
13
config.json
@@ -1,10 +1,15 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"baseUrl": "http://192.168.3.65:18777",
|
||||||
|
"pollInterval": 3000,
|
||||||
|
"timeout": 3600000
|
||||||
|
},
|
||||||
"scheduler": {
|
"scheduler": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"cronTime": "0 9 * * *",
|
"cronTime": "40 08 * * *",
|
||||||
"threshold": 100000,
|
"threshold": 100000,
|
||||||
"description": "每天9点采集当日大于10亿的项目",
|
"description": "每天9点采集当日项目",
|
||||||
"timeRange": "today"
|
"timeRange": "thisMonth"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"smtpHost": "smtp.qq.com",
|
"smtpHost": "smtp.qq.com",
|
||||||
|
|||||||
13
disable-all-tasks.bat
Normal file
13
disable-all-tasks.bat
Normal 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
65
disable-all-tasks.js
Normal 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
1
logs/server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
15556
|
||||||
751
package-lock.json
generated
751
package-lock.json
generated
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
513
public/app.js
513
public/app.js
@@ -90,7 +90,7 @@ function displayList(items, container) {
|
|||||||
<div class="meta">标段编号: ${item.bidNo}</div>
|
<div class="meta">标段编号: ${item.bidNo}</div>
|
||||||
<div class="meta">标段名称: ${item.bidName}</div>
|
<div class="meta">标段名称: ${item.bidName}</div>
|
||||||
<div class="meta">发布日期: ${item.date}</div>
|
<div class="meta">发布日期: ${item.date}</div>
|
||||||
<span class="budget">${item.budget.amount}${item.budget.unit}</span>
|
<span class="budget">${item.winningBid.amount}${item.winningBid.unit}</span>
|
||||||
${item.href ? `<br><a href="${item.href}" target="_blank">查看详情 →</a>` : ''}
|
${item.href ? `<br><a href="${item.href}" target="_blank">查看详情 →</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@@ -189,7 +189,7 @@ function displayReport(report, container) {
|
|||||||
<div class="meta">标段编号: ${project.bidNo || '-'}</div>
|
<div class="meta">标段编号: ${project.bidNo || '-'}</div>
|
||||||
<div class="meta">标段名称: ${project.bidName || '-'}</div>
|
<div class="meta">标段名称: ${project.bidName || '-'}</div>
|
||||||
<div class="meta">发布日期: ${project.date}</div>
|
<div class="meta">发布日期: ${project.date}</div>
|
||||||
<span class="budget">${project.budget.amount}${project.budget.unit}</span>
|
<span class="budget">${project.winningBid.amount}${project.winningBid.unit}</span>
|
||||||
${project.url ? `<br><a href="${project.url}" target="_blank">查看详情 →</a>` : ''}
|
${project.url ? `<br><a href="${project.url}" target="_blank">查看详情 →</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@@ -202,11 +202,35 @@ function displayReport(report, container) {
|
|||||||
async function exportReport() {
|
async function exportReport() {
|
||||||
if (!currentReport) return;
|
if (!currentReport) return;
|
||||||
|
|
||||||
// 检查docx库是否加载
|
// 按需动态加载docx库
|
||||||
if (!window.docx) {
|
if (!window.docx) {
|
||||||
alert('Word导出库正在加载中,请稍后再试...');
|
try {
|
||||||
|
// 显示加载提示
|
||||||
|
const loadingMsg = document.createElement('div');
|
||||||
|
loadingMsg.textContent = '正在加载导出库...';
|
||||||
|
loadingMsg.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;padding:20px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.15);z-index:9999;';
|
||||||
|
document.body.appendChild(loadingMsg);
|
||||||
|
|
||||||
|
// 动态加载docx库
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://cdn.jsdelivr.net/npm/docx@7.8.2/build/index.js';
|
||||||
|
script.onload = resolve;
|
||||||
|
script.onerror = () => {
|
||||||
|
// 降级到unpkg
|
||||||
|
script.src = 'https://unpkg.com/docx@7.8.2/build/index.js';
|
||||||
|
script.onload = resolve;
|
||||||
|
script.onerror = reject;
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.removeChild(loadingMsg);
|
||||||
|
} catch (error) {
|
||||||
|
alert('导出库加载失败,请检查网络连接后重试');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const report = currentReport;
|
const report = currentReport;
|
||||||
const { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } = window.docx;
|
const { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } = window.docx;
|
||||||
@@ -319,7 +343,7 @@ async function exportReport() {
|
|||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: [
|
children: [
|
||||||
new TextRun({ text: '合同估算价: ', bold: true }),
|
new TextRun({ text: '合同估算价: ', bold: true }),
|
||||||
new TextRun({ text: `${project.budget.amount}${project.budget.unit}` })
|
new TextRun({ text: `${project.winningBid.amount}${project.winningBid.unit}` })
|
||||||
],
|
],
|
||||||
spacing: { after: 50 }
|
spacing: { after: 50 }
|
||||||
}),
|
}),
|
||||||
@@ -356,8 +380,8 @@ async function exportReport() {
|
|||||||
|
|
||||||
// ========== 邮件功能 ==========
|
// ========== 邮件功能 ==========
|
||||||
|
|
||||||
// 保存邮件配置到localStorage
|
// 保存邮件配置到服务器
|
||||||
function saveEmailConfig() {
|
async function saveEmailConfig() {
|
||||||
const config = {
|
const config = {
|
||||||
smtpHost: document.getElementById('smtpHost').value,
|
smtpHost: document.getElementById('smtpHost').value,
|
||||||
smtpPort: parseInt(document.getElementById('smtpPort').value) || 587,
|
smtpPort: parseInt(document.getElementById('smtpPort').value) || 587,
|
||||||
@@ -367,18 +391,95 @@ function saveEmailConfig() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 验证配置
|
// 验证配置
|
||||||
if (!config.smtpHost || !config.smtpUser || !config.smtpPass || !config.recipients) {
|
if (!config.smtpHost || !config.smtpUser || !config.recipients) {
|
||||||
showEmailStatus('请填写所有必填项', 'error');
|
showEmailStatus('请填写SMTP服务器、发件人邮箱和收件人', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存到localStorage
|
// 如果密码为空,可能是要保持原密码不变
|
||||||
|
const smtpPassInput = document.getElementById('smtpPass');
|
||||||
|
if (!config.smtpPass && smtpPassInput.placeholder.includes('已配置')) {
|
||||||
|
// 使用占位符表示保持原密码
|
||||||
|
config.smtpPass = '***已配置***';
|
||||||
|
} else if (!config.smtpPass) {
|
||||||
|
showEmailStatus('请填写SMTP密码', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showEmailStatus('正在保存配置...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先获取当前服务器配置
|
||||||
|
const getResponse = await fetch(`${API_BASE}/config`);
|
||||||
|
const getData = await getResponse.json();
|
||||||
|
|
||||||
|
let fullConfig = { email: config };
|
||||||
|
|
||||||
|
// 如果服务器有其他配置(如scheduler),保留它们
|
||||||
|
if (getData.success && getData.data) {
|
||||||
|
fullConfig = {
|
||||||
|
...getData.data,
|
||||||
|
email: config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到服务器
|
||||||
|
const saveResponse = await fetch(`${API_BASE}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(fullConfig)
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveData = await saveResponse.json();
|
||||||
|
|
||||||
|
if (saveData.success) {
|
||||||
|
// 同时保存到localStorage作为备份
|
||||||
localStorage.setItem('emailConfig', JSON.stringify(config));
|
localStorage.setItem('emailConfig', JSON.stringify(config));
|
||||||
showEmailStatus('邮件配置已保存', 'success');
|
showEmailStatus('邮件配置已保存到服务器', 'success');
|
||||||
|
} else {
|
||||||
|
showEmailStatus(`保存失败: ${saveData.error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showEmailStatus(`保存失败: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从localStorage加载邮件配置
|
// 从服务器加载邮件配置
|
||||||
function loadEmailConfig() {
|
async function loadEmailConfig() {
|
||||||
|
try {
|
||||||
|
// 从服务器获取配置
|
||||||
|
const response = await fetch(`${API_BASE}/config`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.data && data.data.email) {
|
||||||
|
const config = data.data.email;
|
||||||
|
document.getElementById('smtpHost').value = config.smtpHost || '';
|
||||||
|
document.getElementById('smtpPort').value = config.smtpPort || 587;
|
||||||
|
document.getElementById('smtpUser').value = config.smtpUser || '';
|
||||||
|
// 如果密码是占位符,保持输入框为空或显示占位符
|
||||||
|
document.getElementById('smtpPass').value = config.smtpPass === '***已配置***' ? '' : (config.smtpPass || '');
|
||||||
|
if (config.smtpPass === '***已配置***') {
|
||||||
|
document.getElementById('smtpPass').placeholder = '***已配置*** (留空保持不变)';
|
||||||
|
}
|
||||||
|
document.getElementById('recipients').value = config.recipients || '';
|
||||||
|
|
||||||
|
// 同时保存到localStorage作为备份
|
||||||
|
localStorage.setItem('emailConfig', JSON.stringify(config));
|
||||||
|
} else {
|
||||||
|
// 如果服务器没有配置,尝试从localStorage加载
|
||||||
|
const configStr = localStorage.getItem('emailConfig');
|
||||||
|
if (configStr) {
|
||||||
|
const config = JSON.parse(configStr);
|
||||||
|
document.getElementById('smtpHost').value = config.smtpHost || '';
|
||||||
|
document.getElementById('smtpPort').value = config.smtpPort || 587;
|
||||||
|
document.getElementById('smtpUser').value = config.smtpUser || '';
|
||||||
|
document.getElementById('smtpPass').value = config.smtpPass || '';
|
||||||
|
document.getElementById('recipients').value = config.recipients || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('从服务器加载邮件配置失败:', error);
|
||||||
|
// 失败时尝试从localStorage加载
|
||||||
const configStr = localStorage.getItem('emailConfig');
|
const configStr = localStorage.getItem('emailConfig');
|
||||||
if (configStr) {
|
if (configStr) {
|
||||||
try {
|
try {
|
||||||
@@ -389,7 +490,8 @@ function loadEmailConfig() {
|
|||||||
document.getElementById('smtpPass').value = config.smtpPass || '';
|
document.getElementById('smtpPass').value = config.smtpPass || '';
|
||||||
document.getElementById('recipients').value = config.recipients || '';
|
document.getElementById('recipients').value = config.recipients || '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载邮件配置失败:', e);
|
console.error('从localStorage加载邮件配置失败:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,50 +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('schedulerThresholdInput').value = config.scheduler.threshold || 10000;
|
document.getElementById('schedulerThresholdInput').value = config.scheduler.threshold ?? 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];
|
||||||
@@ -637,7 +723,6 @@ async function loadSchedulerConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新状态显示
|
|
||||||
await updateSchedulerStatus();
|
await updateSchedulerStatus();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -673,9 +758,14 @@ function updateCustomCron() {
|
|||||||
cronInput.value = `${minute} ${hour} * * *`;
|
cronInput.value = `${minute} ${hour} * * *`;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
loadEmailConfig();
|
// 并行加载配置,提高加载速度
|
||||||
loadSchedulerConfig();
|
Promise.all([
|
||||||
|
loadEmailConfig().catch(err => console.error('加载邮件配置失败:', err)),
|
||||||
|
loadSchedulerConfig().catch(err => console.error('加载定时任务配置失败:', err))
|
||||||
|
]).then(() => {
|
||||||
|
console.log('配置加载完成');
|
||||||
|
});
|
||||||
|
|
||||||
// 添加自定义时间输入框的事件监听
|
// 添加自定义时间输入框的事件监听
|
||||||
const customHour = document.getElementById('customHour');
|
const customHour = document.getElementById('customHour');
|
||||||
@@ -705,8 +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 thresholdBillion = (status.config.threshold / 10000).toFixed(1);
|
}
|
||||||
document.getElementById('schedulerThreshold').textContent = `${status.config.threshold}万元 (${thresholdBillion}亿)`;
|
|
||||||
|
// 更新已启用来源数
|
||||||
|
const enabledCountEl = document.getElementById('schedulerEnabledCount');
|
||||||
|
if (enabledCountEl) {
|
||||||
|
enabledCountEl.textContent = `${status.enabledScrapers ?? '-'} 个`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -719,9 +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,
|
||||||
threshold: parseInt(document.getElementById('schedulerThresholdInput').value),
|
threshold: parseInt(document.getElementById('schedulerThresholdInput').value) || 0,
|
||||||
description: document.getElementById('schedulerDescription').value,
|
description: document.getElementById('schedulerDescription').value,
|
||||||
timeRange: document.getElementById('schedulerTimeRange').value
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 验证Cron表达式格式(简单验证)
|
// 验证Cron表达式格式(简单验证)
|
||||||
@@ -731,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' },
|
||||||
@@ -771,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');
|
||||||
@@ -834,3 +906,306 @@ function showSchedulerStatus(message, type) {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 招标公告列表功能 ==========
|
||||||
|
let currentBidListPage = 1;
|
||||||
|
|
||||||
|
// 获取招标公告列表
|
||||||
|
async function fetchBidList(pageNum) {
|
||||||
|
const page = pageNum || parseInt(document.getElementById('bidListPage').value) || 1;
|
||||||
|
const loading = document.getElementById('bidListLoading');
|
||||||
|
const results = document.getElementById('bidListResults');
|
||||||
|
const pagination = document.getElementById('bidListPagination');
|
||||||
|
|
||||||
|
currentBidListPage = page;
|
||||||
|
document.getElementById('bidListPage').value = page;
|
||||||
|
|
||||||
|
loading.classList.add('active');
|
||||||
|
results.innerHTML = '';
|
||||||
|
pagination.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/bid-announce/list?page=${page}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
displayBidList(data.data, results);
|
||||||
|
updateBidListPagination(page, data.data.length > 0);
|
||||||
|
} else {
|
||||||
|
results.innerHTML = `<div class="error">错误: ${data.error}</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
|
||||||
|
} finally {
|
||||||
|
loading.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToBidListPage(page) {
|
||||||
|
if (page < 1) return;
|
||||||
|
fetchBidList(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBidListPagination(page, hasData) {
|
||||||
|
const pagination = document.getElementById('bidListPagination');
|
||||||
|
const currentPageSpan = document.getElementById('bidCurrentPage');
|
||||||
|
const prevBtn = document.getElementById('bidPrevPage');
|
||||||
|
const firstBtn = document.getElementById('bidFirstPage');
|
||||||
|
const nextBtn = document.getElementById('bidNextPage');
|
||||||
|
|
||||||
|
if (hasData) {
|
||||||
|
pagination.style.display = 'flex';
|
||||||
|
currentPageSpan.textContent = page;
|
||||||
|
|
||||||
|
prevBtn.disabled = page <= 1;
|
||||||
|
firstBtn.disabled = page <= 1;
|
||||||
|
nextBtn.disabled = !hasData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayBidList(items, container) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = '<p>没有找到招标公告</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="simple-list">
|
||||||
|
<h3 style="margin-bottom: 15px; color: #e67e22;">找到 ${items.length} 条招标公告</h3>
|
||||||
|
${items.map((item, index) => `
|
||||||
|
<div class="list-item" style="border-left-color: #e67e22;">
|
||||||
|
<h3>${index + 1}. ${item.title}</h3>
|
||||||
|
<div class="meta">发布日期: ${item.date}</div>
|
||||||
|
${item.href ? `<a href="${item.href}" target="_blank" style="color: #e67e22;">查看详情 →</a>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 综合报告功能 ==========
|
||||||
|
let currentWinningReport = null;
|
||||||
|
let currentBidReport = null;
|
||||||
|
|
||||||
|
// 初始化报告日期
|
||||||
|
function initReportDates() {
|
||||||
|
const today = new Date();
|
||||||
|
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
|
||||||
|
document.getElementById('startDate').value = firstDayOfMonth.toISOString().split('T')[0];
|
||||||
|
document.getElementById('endDate').value = today.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成综合报告(同时包含中标和招标)
|
||||||
|
async function generateCombinedReport() {
|
||||||
|
const startDate = document.getElementById('startDate').value;
|
||||||
|
const endDate = document.getElementById('endDate').value;
|
||||||
|
const maxPages = parseInt(document.getElementById('maxPages').value) || 10;
|
||||||
|
const winningThreshold = parseFloat(document.getElementById('reportThreshold').value) * 10000 || 0; // 转换为元
|
||||||
|
const bidThreshold = parseFloat(document.getElementById('bidReportThreshold').value) * 10000 || 0;
|
||||||
|
|
||||||
|
if (!startDate && !endDate) {
|
||||||
|
alert('请至少填写开始日期或结束日期');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = document.getElementById('reportLoading');
|
||||||
|
const loadingText = document.getElementById('reportLoadingText');
|
||||||
|
const results = document.getElementById('reportResults');
|
||||||
|
const sendBtn = document.getElementById('sendEmailBtn');
|
||||||
|
|
||||||
|
loading.classList.add('active');
|
||||||
|
results.innerHTML = '';
|
||||||
|
sendBtn.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 先获取中标报告
|
||||||
|
loadingText.textContent = '正在采集中标公示...';
|
||||||
|
const winningResponse = await fetch(`${API_BASE}/report-daterange`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ startDate, endDate, threshold: winningThreshold, maxPages })
|
||||||
|
});
|
||||||
|
const winningData = await winningResponse.json();
|
||||||
|
|
||||||
|
// 2. 再获取招标报告
|
||||||
|
loadingText.textContent = '正在采集招标公告...';
|
||||||
|
const bidResponse = await fetch(`${API_BASE}/bid-announce/report`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ startDate, endDate, maxPages, threshold: bidThreshold })
|
||||||
|
});
|
||||||
|
const bidData = await bidResponse.json();
|
||||||
|
|
||||||
|
// 3. 显示综合报告
|
||||||
|
if (winningData.success && bidData.success) {
|
||||||
|
currentWinningReport = winningData.data;
|
||||||
|
currentBidReport = bidData.data;
|
||||||
|
displayCombinedReport(winningData.data, bidData.data, results);
|
||||||
|
sendBtn.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
let errorMsg = '';
|
||||||
|
if (!winningData.success) errorMsg += `中标报告错误: ${winningData.error}\n`;
|
||||||
|
if (!bidData.success) errorMsg += `招标报告错误: ${bidData.error}`;
|
||||||
|
results.innerHTML = `<div class="error">${errorMsg}</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
|
||||||
|
} finally {
|
||||||
|
loading.classList.remove('active');
|
||||||
|
loadingText.textContent = '正在生成报告...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示综合报告
|
||||||
|
function displayCombinedReport(winningReport, bidReport, container) {
|
||||||
|
const html = `
|
||||||
|
<!-- 中标公示部分 -->
|
||||||
|
<div class="summary" style="background: linear-gradient(135deg, #0f6ecd 0%, #0ea5a4 100%);">
|
||||||
|
<h2>中标公示报告</h2>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">总项目数</div>
|
||||||
|
<div class="stat-value">${winningReport.summary.total_count}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">符合条件</div>
|
||||||
|
<div class="stat-value">${winningReport.summary.filtered_count}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">总金额</div>
|
||||||
|
<div class="stat-value">${winningReport.summary.total_amount}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">阈值</div>
|
||||||
|
<div class="stat-value">${winningReport.summary.threshold}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${winningReport.projects.length === 0 ? '<p style="color: #999; margin-bottom: 20px;">暂无符合条件的中标项目</p>' : `
|
||||||
|
<h3 style="margin-bottom: 15px;">中标项目列表 (${winningReport.projects.length} 条)</h3>
|
||||||
|
<div class="simple-list" style="margin-bottom: 30px;">
|
||||||
|
${winningReport.projects.map((project, index) => `
|
||||||
|
<div class="list-item">
|
||||||
|
<h3>${index + 1}. ${project.title}</h3>
|
||||||
|
<div class="meta">标段编号: ${project.bidNo || '-'}</div>
|
||||||
|
<div class="meta">标段名称: ${project.bidName || '-'}</div>
|
||||||
|
<div class="meta">发布日期: ${project.date}</div>
|
||||||
|
<span class="budget">${project.winningBid.amount}${project.winningBid.unit}</span>
|
||||||
|
${project.url ? `<br><a href="${project.url}" target="_blank">查看详情 →</a>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<!-- 招标公告部分 -->
|
||||||
|
<div class="summary" style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); margin-top: 30px;">
|
||||||
|
<h2>招标公告报告</h2>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">总公告数量</div>
|
||||||
|
<div class="stat-value">${bidReport.summary.total_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">有金额信息</div>
|
||||||
|
<div class="stat-value">${bidReport.summary.has_amount_count || bidReport.summary.filtered_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">金额阈值</div>
|
||||||
|
<div class="stat-value">${bidReport.summary.threshold || '无'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">合同估算总额</div>
|
||||||
|
<div class="stat-value">${bidReport.summary.total_amount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${bidReport.projects.length === 0 ? '<p style="color: #999;">暂无符合条件的招标项目</p>' : `
|
||||||
|
<h3 style="margin-bottom: 15px; color: #e67e22;">招标项目详情 (${bidReport.projects.length} 条)</h3>
|
||||||
|
<div class="simple-list">
|
||||||
|
${bidReport.projects.map((item, index) => `
|
||||||
|
<div class="list-item" style="border-left-color: #e67e22;">
|
||||||
|
<h3>${index + 1}. ${item.title}</h3>
|
||||||
|
<div class="meta">发布日期: ${item.date}</div>
|
||||||
|
${item.bidCode ? `<div class="meta">标段编码: ${item.bidCode}</div>` : ''}
|
||||||
|
${item.tenderee ? `<div class="meta">招标人: ${item.tenderee}</div>` : ''}
|
||||||
|
${item.duration ? `<div class="meta">工期: ${item.duration} 日历天</div>` : ''}
|
||||||
|
${item.estimatedAmount ? `
|
||||||
|
<span class="budget" style="background: #e67e22;">合同估算价: ${item.estimatedAmount.amountWan} 万元</span>
|
||||||
|
` : ''}
|
||||||
|
<br><a href="${item.url}" target="_blank" style="color: #e67e22;">查看详情 →</a>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送综合报告邮件
|
||||||
|
async function sendCombinedReportByEmail() {
|
||||||
|
if (!currentWinningReport && !currentBidReport) {
|
||||||
|
alert('请先生成报告');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从localStorage获取邮件配置
|
||||||
|
const emailConfigStr = localStorage.getItem('emailConfig');
|
||||||
|
if (!emailConfigStr) {
|
||||||
|
alert('请先在"邮件配置"页面配置邮件服务器信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let emailConfig;
|
||||||
|
try {
|
||||||
|
emailConfig = JSON.parse(emailConfigStr);
|
||||||
|
} catch (e) {
|
||||||
|
alert('邮件配置解析失败,请重新配置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emailConfig.smtpHost || !emailConfig.smtpUser || !emailConfig.smtpPass || !emailConfig.recipients) {
|
||||||
|
alert('邮件配置不完整,请检查SMTP服务器、用户名、密码和收件人');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`确定要将综合报告发送到以下邮箱吗?\n\n${emailConfig.recipients}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendBtn = document.getElementById('sendEmailBtn');
|
||||||
|
const originalText = sendBtn.textContent;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.textContent = '正在发送...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/send-combined-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
emailConfig,
|
||||||
|
winningReport: currentWinningReport,
|
||||||
|
bidReport: currentBidReport
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert('综合报告邮件发送成功!');
|
||||||
|
} else {
|
||||||
|
alert(`邮件发送失败: ${data.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`请求失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时初始化报告日期
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
initReportDates();
|
||||||
|
});
|
||||||
|
|||||||
1974
public/index.html
1974
public/index.html
File diff suppressed because it is too large
Load Diff
1579
public/results.html
Normal file
1579
public/results.html
Normal file
File diff suppressed because it is too large
Load Diff
100
restart.ps1
Normal file
100
restart.ps1
Normal 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
83
restart.sh
Normal 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
1
scrapegraph-service/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
151
src/agentService.js
Normal file
151
src/agentService.js
Normal 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 };
|
||||||
@@ -21,7 +21,7 @@ export async function sendReportEmail(emailConfig, report) {
|
|||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
from: `"公告采集系统" <${emailConfig.smtpUser}>`,
|
from: `"公告采集系统" <${emailConfig.smtpUser}>`,
|
||||||
to: emailConfig.recipients,
|
to: emailConfig.recipients,
|
||||||
subject: `采购公告分析报告 - ${new Date().toLocaleDateString('zh-CN')}`,
|
subject: `交通水务中标结果报告 - ${new Date().toLocaleDateString('zh-CN')}`,
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,6 +35,523 @@ export async function sendReportEmail(emailConfig, report) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发送招标公告报告邮件
|
||||||
|
export async function sendBidAnnounceReportEmail(emailConfig, report) {
|
||||||
|
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 = generateBidAnnounceReportHtml(report);
|
||||||
|
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"公告采集系统" <${emailConfig.smtpUser}>`,
|
||||||
|
to: emailConfig.recipients,
|
||||||
|
subject: `交通水务招标公告报告 - ${new Date().toLocaleDateString('zh-CN')}`,
|
||||||
|
html: htmlContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: info.messageId,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送招标公告邮件失败:', error);
|
||||||
|
throw new Error(`邮件发送失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成招标公告报告HTML
|
||||||
|
function generateBidAnnounceReportHtml(report) {
|
||||||
|
const { summary, projects } = report;
|
||||||
|
|
||||||
|
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>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #e67e22;
|
||||||
|
border-bottom: 3px solid #e67e22;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
background: linear-gradient(135deg, #e67e22 0%, #d35400 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.summary h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.project-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.project-item {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-left: 4px solid #e67e22;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.project-item h3 {
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.project-meta {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
.amount {
|
||||||
|
display: inline-block;
|
||||||
|
background: #e67e22;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.project-link {
|
||||||
|
color: #e67e22;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>南京公共资源交易平台 - 交通水务招标公告报告</h1>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<h2>报告摘要</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">总公告数量</div>
|
||||||
|
<div class="stat-value">${summary.total_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">有金额信息</div>
|
||||||
|
<div class="stat-value">${summary.has_amount_count || summary.filtered_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">符合筛选</div>
|
||||||
|
<div class="stat-value">${summary.filtered_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">金额阈值</div>
|
||||||
|
<div class="stat-value">${summary.threshold || '无'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">合同估算总额</div>
|
||||||
|
<div class="stat-value">${summary.total_amount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${summary.date_range ? `
|
||||||
|
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid rgba(255,255,255,0.2);">
|
||||||
|
<div class="stat-label">时间范围</div>
|
||||||
|
<div style="font-size: 14px; margin-top: 5px;">
|
||||||
|
${summary.date_range.startDate || '不限'} 至 ${summary.date_range.endDate || '不限'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>招标项目详情</h2>
|
||||||
|
<div class="project-list">
|
||||||
|
${projects.length === 0 ? '<p style="color: #999; text-align: center; padding: 20px;">暂无符合条件的项目</p>' : ''}
|
||||||
|
${projects.map((project, index) => `
|
||||||
|
<div class="project-item">
|
||||||
|
<h3>${index + 1}. ${project.title}</h3>
|
||||||
|
<div class="project-meta">
|
||||||
|
<strong>发布日期:</strong> ${project.date}
|
||||||
|
${project.bidCode ? ` | <strong>标段编码:</strong> ${project.bidCode}` : ''}
|
||||||
|
</div>
|
||||||
|
${project.tenderee ? `
|
||||||
|
<div class="project-meta">
|
||||||
|
<strong>招标人:</strong> ${project.tenderee}
|
||||||
|
${project.duration ? ` | <strong>工期:</strong> ${project.duration}日历天` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${project.estimatedAmount ? `
|
||||||
|
<div class="amount">
|
||||||
|
合同估算价: ${project.estimatedAmount.amountWan} 万元
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<a href="${project.url}" class="project-link" target="_blank">${project.url}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>报告生成时间: ${new Date(summary.generated_at).toLocaleString('zh-CN')}</p>
|
||||||
|
<p>本报告由公告采集系统自动生成</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送综合报告邮件(中标+招标)
|
||||||
|
export async function sendCombinedReportEmail(emailConfig, winningReport, bidReport) {
|
||||||
|
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 = generateCombinedReportHtml(winningReport, bidReport);
|
||||||
|
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"公告采集系统" <${emailConfig.smtpUser}>`,
|
||||||
|
to: emailConfig.recipients,
|
||||||
|
subject: `交通水务综合报告 - ${new Date().toLocaleDateString('zh-CN')}`,
|
||||||
|
html: htmlContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: info.messageId,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送综合邮件失败:', error);
|
||||||
|
throw new Error(`邮件发送失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成综合报告HTML(中标+招标)
|
||||||
|
function generateCombinedReportHtml(winningReport, bidReport) {
|
||||||
|
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>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 3px solid #667eea;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.section-title.winning {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.section-title.bid {
|
||||||
|
background: linear-gradient(135deg, #e67e22 0%, #d35400 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.summary.winning {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.summary.bid {
|
||||||
|
background: linear-gradient(135deg, #e67e22 0%, #d35400 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.project-list {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.project-item {
|
||||||
|
background: #f9f9f9;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.project-item.winning {
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
}
|
||||||
|
.project-item.bid {
|
||||||
|
border-left: 4px solid #e67e22;
|
||||||
|
}
|
||||||
|
.project-item h3 {
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.project-meta {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
.amount {
|
||||||
|
display: inline-block;
|
||||||
|
color: white;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.amount.winning {
|
||||||
|
background: #667eea;
|
||||||
|
}
|
||||||
|
.amount.bid {
|
||||||
|
background: #e67e22;
|
||||||
|
}
|
||||||
|
.project-link {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.project-link.winning {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
.project-link.bid {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>南京公共资源交易平台 - 交通水务综合报告</h1>
|
||||||
|
|
||||||
|
${bidReport ? `
|
||||||
|
<!-- 招标公告部分 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title bid">招标公告</div>
|
||||||
|
<div class="summary bid">
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">总公告数量</div>
|
||||||
|
<div class="stat-value">${bidReport.summary.total_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">有金额信息</div>
|
||||||
|
<div class="stat-value">${bidReport.summary.has_amount_count || bidReport.summary.filtered_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">符合筛选</div>
|
||||||
|
<div class="stat-value">${bidReport.summary.filtered_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">金额阈值</div>
|
||||||
|
<div class="stat-value">${bidReport.summary.threshold || '无'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">合同估算总额</div>
|
||||||
|
<div class="stat-value">${bidReport.summary.total_amount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${bidReport.summary.date_range ? `
|
||||||
|
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.2); font-size: 13px;">
|
||||||
|
时间范围: ${bidReport.summary.date_range.startDate || '不限'} 至 ${bidReport.summary.date_range.endDate || '不限'}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="project-list">
|
||||||
|
${bidReport.projects.length === 0 ? '<p class="no-data">暂无符合条件的招标项目</p>' : ''}
|
||||||
|
${bidReport.projects.map((project, index) => `
|
||||||
|
<div class="project-item bid">
|
||||||
|
<h3>${index + 1}. ${project.title}</h3>
|
||||||
|
<div class="project-meta"><strong>发布日期:</strong> ${project.date}</div>
|
||||||
|
${project.bidCode ? `<div class="project-meta"><strong>标段编码:</strong> ${project.bidCode}</div>` : ''}
|
||||||
|
${project.tenderee ? `<div class="project-meta"><strong>招标人:</strong> ${project.tenderee}</div>` : ''}
|
||||||
|
${project.duration ? `<div class="project-meta"><strong>工期:</strong> ${project.duration}日历天</div>` : ''}
|
||||||
|
${project.estimatedAmount ? `
|
||||||
|
<div class="amount bid">合同估算价: ${project.estimatedAmount.amountWan} 万元</div>
|
||||||
|
` : ''}
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<a href="${project.url}" class="project-link bid" target="_blank">${project.url}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${winningReport ? `
|
||||||
|
<!-- 中标公示部分 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title winning">中标结果公示</div>
|
||||||
|
<div class="summary winning">
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">总项目数</div>
|
||||||
|
<div class="stat-value">${winningReport.summary.total_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">符合条件</div>
|
||||||
|
<div class="stat-value">${winningReport.summary.filtered_count} 条</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">金额阈值</div>
|
||||||
|
<div class="stat-value">${winningReport.summary.threshold}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">总金额</div>
|
||||||
|
<div class="stat-value">${winningReport.summary.total_amount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${winningReport.summary.date_range ? `
|
||||||
|
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.2); font-size: 13px;">
|
||||||
|
时间范围: ${winningReport.summary.date_range.startDate || '不限'} 至 ${winningReport.summary.date_range.endDate || '不限'}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="project-list">
|
||||||
|
${winningReport.projects.length === 0 ? '<p class="no-data">暂无符合条件的中标项目</p>' : ''}
|
||||||
|
${winningReport.projects.map((project, index) => `
|
||||||
|
<div class="project-item winning">
|
||||||
|
<h3>${index + 1}. ${project.title}</h3>
|
||||||
|
<div class="project-meta"><strong>中标日期:</strong> ${project.date}</div>
|
||||||
|
${project.bidNo ? `<div class="project-meta"><strong>标段编号:</strong> ${project.bidNo}</div>` : ''}
|
||||||
|
${project.winningBid ? `
|
||||||
|
<div class="amount winning">中标金额: ${project.winningBid.amount.toFixed(2)} ${project.winningBid.unit}</div>
|
||||||
|
` : ''}
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<a href="${project.url}" class="project-link winning" target="_blank">${project.url}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>报告生成时间: ${new Date().toLocaleString('zh-CN')}</p>
|
||||||
|
<p>本报告由公告采集系统自动生成</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// 生成HTML格式的报告
|
// 生成HTML格式的报告
|
||||||
function generateReportHtml(report) {
|
function generateReportHtml(report) {
|
||||||
const { summary, projects } = report;
|
const { summary, projects } = report;
|
||||||
@@ -45,7 +562,7 @@ function generateReportHtml(report) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>采购公告分析报告</title>
|
<title>交通水务中标结果报告</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
font-family: 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
@@ -147,7 +664,7 @@ function generateReportHtml(report) {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>南京公共工程建设中心 - 采购公告分析报告</h1>
|
<h1>南京公共资源交易平台 - 交通水务中标结果报告</h1>
|
||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<h2>报告摘要</h2>
|
<h2>报告摘要</h2>
|
||||||
@@ -186,15 +703,17 @@ function generateReportHtml(report) {
|
|||||||
<div class="project-item">
|
<div class="project-item">
|
||||||
<h3>${index + 1}. ${project.title}</h3>
|
<h3>${index + 1}. ${project.title}</h3>
|
||||||
<div class="project-meta">
|
<div class="project-meta">
|
||||||
<strong>发布日期:</strong> ${project.date}
|
<strong>中标日期:</strong> ${project.date}
|
||||||
${project.publish_time ? ` | <strong>发布时间:</strong> ${project.publish_time}` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
${project.budget ? `
|
${project.winningBid ? `
|
||||||
|
<div class="budget">
|
||||||
|
中标金额: ${project.winningBid.amount.toFixed(2)} ${project.winningBid.unit}
|
||||||
|
</div>
|
||||||
|
` : (project.budget ? `
|
||||||
<div class="budget">
|
<div class="budget">
|
||||||
预算金额: ${project.budget.amount.toFixed(2)} ${project.budget.unit}
|
预算金额: ${project.budget.amount.toFixed(2)} ${project.budget.unit}
|
||||||
${project.budget.originalUnit !== project.budget.unit ? ` (原始: ${project.budget.originalUnit})` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : '')}
|
||||||
<div style="margin-top: 10px;">
|
<div style="margin-top: 10px;">
|
||||||
<a href="${project.url}" class="project-link" target="_blank">${project.url}</a>
|
<a href="${project.url}" class="project-link" target="_blank">${project.url}</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|||||||
275
src/firecrawlBrowserScraper.js
Normal file
275
src/firecrawlBrowserScraper.js
Normal 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
817
src/resultStore.js
Normal 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;
|
||||||
|
}
|
||||||
421
src/scheduler.js
421
src/scheduler.js
@@ -1,396 +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 { sendReportEmail } 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/fjsz/068001/068001002/';
|
|
||||||
|
|
||||||
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 = '') {
|
|
||||||
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) {
|
|
||||||
const res = await http.get(url);
|
|
||||||
const encoding = pickEncoding(res.headers['content-type']);
|
|
||||||
const html = iconv.decode(res.data, encoding || 'utf-8');
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPageUrl(pageIndex) {
|
|
||||||
if (pageIndex === 1) {
|
|
||||||
return `${BASE_URL}moreinfo.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 estimatedPrice = $(cells[3]).find('p').text().trim();
|
|
||||||
const publishDate = $(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(publishDate)) return;
|
|
||||||
|
|
||||||
const price = parseFloat(estimatedPrice);
|
|
||||||
if (isNaN(price)) return;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
bidNo,
|
|
||||||
title: projectName,
|
|
||||||
bidName,
|
|
||||||
budget: {
|
|
||||||
amount: price,
|
|
||||||
unit: '万元'
|
|
||||||
},
|
|
||||||
date: publishDate,
|
|
||||||
href
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
console.log(`[Scheduler][Agent] ${task.city}: ${results.length} results`);
|
||||||
|
|
||||||
|
return appendResult({
|
||||||
|
taskId: task.id,
|
||||||
|
city: task.city,
|
||||||
|
scrapedAt: new Date().toISOString(),
|
||||||
|
data: { results, total: results.length },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定时任务执行函数
|
|
||||||
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 threshold = config.scheduler.threshold || 10000; // 默认1亿(10000万元)
|
|
||||||
|
|
||||||
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(`金额阈值: ${threshold}万元 (${(threshold / 10000).toFixed(2)}亿元)`);
|
|
||||||
|
|
||||||
// 采集列表(直接包含合同估算价)
|
|
||||||
const items = await fetchListByDateRange(startDate, endDate, 50);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
console.log('暂无公告数据');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 筛选大于阈值的项目
|
console.log(`Enabled tasks: ${tasks.length}`);
|
||||||
const filtered = items.filter((item) => {
|
|
||||||
return item.budget && item.budget.amount > threshold;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('========================================');
|
const successCount = results.filter((item) => !item.error).length;
|
||||||
console.log(`筛选结果: 找到 ${filtered.length} 个大于 ${threshold}万元 的项目`);
|
const failCount = results.filter((item) => item.error).length;
|
||||||
|
console.log('\n========== Scheduler finished ==========');
|
||||||
|
console.log(`Success: ${successCount}, Failed: ${failCount}`);
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (successCount === 0) {
|
||||||
console.log('暂无符合条件的大额项目');
|
console.log('No successful results, skip email');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算总金额
|
if (config.email?.smtpHost && config.email?.smtpUser) {
|
||||||
const total = filtered.reduce(
|
console.log('\nSending email...');
|
||||||
(sum, item) => sum + (item.budget?.amount || 0),
|
try {
|
||||||
0
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
// 生成报告
|
|
||||||
const report = {
|
|
||||||
summary: {
|
|
||||||
total_count: items.length,
|
|
||||||
filtered_count: filtered.length,
|
|
||||||
threshold: `${threshold}万元`,
|
|
||||||
total_amount: `${total.toFixed(2)}万元`,
|
|
||||||
generated_at: new Date().toISOString(),
|
|
||||||
date_range: { startDate, endDate },
|
|
||||||
},
|
|
||||||
projects: filtered.map((item) => ({
|
|
||||||
bidNo: item.bidNo,
|
|
||||||
title: item.title,
|
|
||||||
bidName: item.bidName,
|
|
||||||
date: item.date,
|
|
||||||
budget: item.budget,
|
|
||||||
url: item.href,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 发送邮件
|
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('正在发送邮件报告...');
|
|
||||||
const emailConfig = config.email;
|
|
||||||
|
|
||||||
const result = await sendReportEmail(emailConfig, report);
|
|
||||||
|
|
||||||
console.log('邮件发送成功!');
|
|
||||||
console.log('收件人:', emailConfig.recipients);
|
|
||||||
console.log('MessageId:', result.messageId);
|
|
||||||
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.threshold, '万元');
|
if (config.email?.recipients) {
|
||||||
console.log('收件人:', config.email.recipients);
|
console.log('Recipients:', 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 * * *',
|
||||||
threshold: config.scheduler?.threshold || 10000,
|
description: config.scheduler?.description || '',
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
821
src/server.js
821
src/server.js
@@ -1,474 +1,495 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import axios from 'axios';
|
import {
|
||||||
import * as cheerio from 'cheerio';
|
initResultsStore,
|
||||||
import iconv from 'iconv-lite';
|
loadConfig,
|
||||||
import { sendReportEmail } from './emailService.js';
|
saveConfig,
|
||||||
|
listTasks,
|
||||||
|
getTaskById,
|
||||||
|
createTask,
|
||||||
|
updateTask,
|
||||||
|
deleteTaskById,
|
||||||
|
listResults,
|
||||||
|
listProjects,
|
||||||
|
getProjects,
|
||||||
|
deleteResultById,
|
||||||
|
clearResults,
|
||||||
|
getResultFilters,
|
||||||
|
getProjectFilters,
|
||||||
|
appendResult,
|
||||||
|
} from './resultStore.js';
|
||||||
import { initScheduler, runTaskNow, reloadScheduler, getSchedulerStatus } from './scheduler.js';
|
import { initScheduler, runTaskNow, reloadScheduler, getSchedulerStatus } from './scheduler.js';
|
||||||
|
import { runAgentTask } from './agentService.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 5000;
|
const PORT = process.env.PORT || 5000;
|
||||||
|
const DEFAULT_TASK_MODE = 'qwen3.5-plus';
|
||||||
|
const MASKED_PASSWORD = '***已配置***';
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|
||||||
// 南京市公共资源交易平台 - 房建市政招标公告
|
function normalizeTaskMode(value) {
|
||||||
const BASE_URL = 'https://njggzy.nanjing.gov.cn/njweb/fjsz/068001/068001002/';
|
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||||
|
return DEFAULT_TASK_MODE;
|
||||||
// 获取分页URL
|
|
||||||
function getPageUrl(pageIndex) {
|
|
||||||
if (pageIndex === 1) {
|
|
||||||
return `${BASE_URL}moreinfo.html`;
|
|
||||||
}
|
|
||||||
return `${BASE_URL}${pageIndex}.html`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查日期是否在范围内
|
function buildTaskPayload(body = {}, { partial = false } = {}) {
|
||||||
function isDateInRange(dateStr, startDate, endDate) {
|
const payload = {};
|
||||||
if (!dateStr) return false;
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
if (isNaN(date.getTime())) return false;
|
|
||||||
|
|
||||||
if (startDate && date < new Date(startDate)) return false;
|
if (!partial || Object.prototype.hasOwnProperty.call(body, 'city')) {
|
||||||
if (endDate && date > new Date(endDate)) return false;
|
payload.city = body.city || '';
|
||||||
return true;
|
}
|
||||||
|
|
||||||
|
if (!partial || Object.prototype.hasOwnProperty.call(body, 'plateName')) {
|
||||||
|
payload.plateName = body.plateName || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partial || Object.prototype.hasOwnProperty.call(body, 'prompt')) {
|
||||||
|
payload.prompt = body.prompt || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partial || Object.prototype.hasOwnProperty.call(body, 'enabled')) {
|
||||||
|
payload.enabled = body.enabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partial || Object.prototype.hasOwnProperty.call(body, 'mode')) {
|
||||||
|
payload.mode = normalizeTaskMode(body.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按时间范围采集多页列表
|
function maskConfigSecrets(config) {
|
||||||
async function fetchListByDateRange(startDate, endDate, maxPages = 50) {
|
const next = { ...config };
|
||||||
const allItems = [];
|
if (config.email) {
|
||||||
let shouldContinue = true;
|
next.email = {
|
||||||
let pageIndex = 1;
|
...config.email,
|
||||||
|
smtpPass: config.email.smtpPass ? MASKED_PASSWORD : '',
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const http = axios.create({
|
function mergeConfigWithExistingSecrets(incoming = {}) {
|
||||||
responseType: 'arraybuffer',
|
const current = loadConfig();
|
||||||
timeout: 15000,
|
const next = {
|
||||||
headers: {
|
...current,
|
||||||
'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',
|
...incoming,
|
||||||
},
|
agent: { ...(current.agent || {}), ...(incoming.agent || {}) },
|
||||||
});
|
scheduler: { ...(current.scheduler || {}), ...(incoming.scheduler || {}) },
|
||||||
|
email: { ...(current.email || {}), ...(incoming.email || {}) },
|
||||||
function pickEncoding(contentType = '') {
|
|
||||||
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) {
|
|
||||||
const res = await http.get(url);
|
|
||||||
const encoding = pickEncoding(res.headers['content-type']);
|
|
||||||
const html = iconv.decode(res.data, encoding || 'utf-8');
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析列表页HTML,提取招标信息
|
|
||||||
function parseList(html) {
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
const items = [];
|
|
||||||
|
|
||||||
// 解析南京公共资源交易平台的列表结构
|
|
||||||
// <li class="ewb-info-item2 clearfix" onclick="window.open('详情URL');">
|
|
||||||
$('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 estimatedPrice = $(cells[3]).find('p').text().trim();
|
|
||||||
const publishDate = $(cells[4]).find('p').text().trim();
|
|
||||||
|
|
||||||
// 从onclick提取详情链接
|
|
||||||
const onclick = $row.attr('onclick') || '';
|
|
||||||
const hrefMatch = onclick.match(/window\.open\(['"]([^'"]+)['"]\)/);
|
|
||||||
let href = '';
|
|
||||||
if (hrefMatch) {
|
|
||||||
href = hrefMatch[1];
|
|
||||||
// 转换为绝对URL
|
|
||||||
if (href.startsWith('/')) {
|
|
||||||
href = `https://njggzy.nanjing.gov.cn${href}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证日期格式 (YYYY-MM-DD)
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(publishDate)) return;
|
|
||||||
|
|
||||||
// 解析合同估算价
|
|
||||||
const price = parseFloat(estimatedPrice);
|
|
||||||
if (isNaN(price)) return;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
bidNo, // 标段编号
|
|
||||||
title: projectName, // 项目名称
|
|
||||||
bidName, // 标段名称
|
|
||||||
budget: {
|
|
||||||
amount: price,
|
|
||||||
unit: '万元'
|
|
||||||
},
|
|
||||||
date: publishDate,
|
|
||||||
href
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 路由
|
|
||||||
|
|
||||||
// 获取列表
|
|
||||||
app.get('/api/list', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const page = parseInt(req.query.page) || 1;
|
|
||||||
const pageUrl = getPageUrl(page);
|
|
||||||
|
|
||||||
const html = await fetchHtml(pageUrl);
|
|
||||||
const items = parseList(html);
|
|
||||||
res.json({ success: true, data: items, page });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ success: false, error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 按时间范围获取列表
|
|
||||||
app.post('/api/list-daterange', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { startDate, endDate, maxPages = 50 } = req.body;
|
|
||||||
const items = await fetchListByDateRange(startDate, endDate, maxPages);
|
|
||||||
res.json({ success: true, data: items });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ success: false, error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 生成报告
|
|
||||||
app.post('/api/report', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { limit = 50, threshold = 50 } = req.body;
|
|
||||||
|
|
||||||
// 采集列表
|
|
||||||
const items = [];
|
|
||||||
let pageIndex = 1;
|
|
||||||
const maxPagesToFetch = Math.ceil(limit / 10) + 1;
|
|
||||||
|
|
||||||
while (items.length < limit && pageIndex <= maxPagesToFetch) {
|
|
||||||
const pageUrl = getPageUrl(pageIndex);
|
|
||||||
console.log(`正在采集第 ${pageIndex} 页: ${pageUrl}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const html = await fetchHtml(pageUrl);
|
|
||||||
const pageItems = parseList(html);
|
|
||||||
|
|
||||||
if (pageItems.length === 0) {
|
|
||||||
console.log(`第 ${pageIndex} 页没有数据,停止采集`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push(...pageItems);
|
|
||||||
pageIndex++;
|
|
||||||
|
|
||||||
if (items.length < limit && pageIndex <= maxPagesToFetch) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`采集第 ${pageIndex} 页失败: ${err.message}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = items.slice(0, limit);
|
|
||||||
|
|
||||||
// 按阈值筛选
|
|
||||||
const filtered = results.filter((item) => {
|
|
||||||
return item.budget && item.budget.amount > threshold;
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = filtered.reduce(
|
|
||||||
(sum, item) => sum + (item.budget?.amount || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const report = {
|
|
||||||
summary: {
|
|
||||||
total_count: results.length,
|
|
||||||
filtered_count: filtered.length,
|
|
||||||
threshold: `${threshold}万元`,
|
|
||||||
total_amount: `${total.toFixed(2)}万元`,
|
|
||||||
generated_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
projects: filtered.map((item) => ({
|
|
||||||
bidNo: item.bidNo,
|
|
||||||
title: item.title,
|
|
||||||
bidName: item.bidName,
|
|
||||||
date: item.date,
|
|
||||||
budget: item.budget,
|
|
||||||
url: item.href,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json({ success: true, data: report });
|
if (next.email?.smtpPass === MASKED_PASSWORD) {
|
||||||
|
next.email.smtpPass = current.email?.smtpPass || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectQueryFilters(query = {}) {
|
||||||
|
return {
|
||||||
|
city: query.city,
|
||||||
|
section: query.section,
|
||||||
|
projectName: query.projectName,
|
||||||
|
minAmount: query.minAmount,
|
||||||
|
maxAmount: query.maxAmount,
|
||||||
|
startDate: query.startDate,
|
||||||
|
endDate: query.endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExportTimestamp(date = new Date()) {
|
||||||
|
const pad = (value) => String(value).padStart(2, '0');
|
||||||
|
return [
|
||||||
|
date.getFullYear(),
|
||||||
|
pad(date.getMonth() + 1),
|
||||||
|
pad(date.getDate()),
|
||||||
|
'-',
|
||||||
|
pad(date.getHours()),
|
||||||
|
pad(date.getMinutes()),
|
||||||
|
pad(date.getSeconds()),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvCell(value) {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
const stringValue = String(value);
|
||||||
|
if (/[",\r\n]/.test(stringValue)) {
|
||||||
|
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return stringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectsCsv(projects = []) {
|
||||||
|
const columns = [
|
||||||
|
['城市', (project) => project.city || ''],
|
||||||
|
['板块', (project) => project.section || project.type || ''],
|
||||||
|
['项目名称', (project) => project.projectName || ''],
|
||||||
|
['金额(元)', (project) => Number.isFinite(project.amountYuan) ? project.amountYuan : ''],
|
||||||
|
['发布日期', (project) => project.date || ''],
|
||||||
|
['详情链接', (project) => project.detailLink || ''],
|
||||||
|
];
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
columns.map(([header]) => escapeCsvCell(header)).join(','),
|
||||||
|
...projects.map((project) => columns.map(([, getter]) => escapeCsvCell(getter(project))).join(',')),
|
||||||
|
];
|
||||||
|
|
||||||
|
return `\uFEFF${lines.join('\r\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isRunning = false;
|
||||||
|
let runningStatus = null;
|
||||||
|
|
||||||
|
async function runTask(task) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const agentCfg = config.agent || {};
|
||||||
|
const mode = normalizeTaskMode(task.mode);
|
||||||
|
|
||||||
|
console.log(`[Agent] ${task.city}: start`);
|
||||||
|
console.log(`[Agent] ${task.city}: mode=${mode}`);
|
||||||
|
|
||||||
|
const { results } = await runAgentTask(task.prompt, {
|
||||||
|
baseUrl: agentCfg.baseUrl,
|
||||||
|
mode,
|
||||||
|
pollInterval: agentCfg.pollInterval,
|
||||||
|
timeout: agentCfg.timeout,
|
||||||
|
logPrefix: `[Agent][${task.city}]`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return appendResult({
|
||||||
|
taskId: task.id,
|
||||||
|
city: task.city,
|
||||||
|
scrapedAt: new Date().toISOString(),
|
||||||
|
data: { results, total: results.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTaskInBackground(task) {
|
||||||
|
runningStatus = {
|
||||||
|
taskId: task.id,
|
||||||
|
city: task.city,
|
||||||
|
startTime: Date.now(),
|
||||||
|
current: 0,
|
||||||
|
total: 1,
|
||||||
|
finished: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
runTask(task)
|
||||||
|
.then((record) => {
|
||||||
|
runningStatus = { ...runningStatus, finished: true, result: record, current: 1 };
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('任务执行失败:', error.message);
|
||||||
|
runningStatus = { ...runningStatus, finished: true, error: error.message, current: 1 };
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isRunning = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTasksInBackground(tasks) {
|
||||||
|
runningStatus = {
|
||||||
|
taskId: null,
|
||||||
|
city: null,
|
||||||
|
startTime: Date.now(),
|
||||||
|
current: 0,
|
||||||
|
total: tasks.length,
|
||||||
|
finished: false,
|
||||||
|
error: null,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const task of tasks) {
|
||||||
|
runningStatus = { ...runningStatus, taskId: task.id, city: task.city };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await runTask(task);
|
||||||
|
runningStatus.results.push(record);
|
||||||
|
} catch (error) {
|
||||||
|
const errorRecord = appendResult({
|
||||||
|
taskId: task.id,
|
||||||
|
city: task.city,
|
||||||
|
scrapedAt: new Date().toISOString(),
|
||||||
|
error: error.message,
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
runningStatus.results.push(errorRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
runningStatus.current += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
runningStatus.finished = true;
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
runningStatus = { ...runningStatus, finished: true, error: error.message };
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isRunning = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/results', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { city, section, type, page = 1, pageSize = 20, taskId, view } = req.query;
|
||||||
|
const projectMode = view === 'projects';
|
||||||
|
const result = listResults({ city, section, type, page, pageSize, taskId, projectMode });
|
||||||
|
res.json({ success: true, ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: error.message });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 按时间范围生成报告
|
app.delete('/api/results/:id', (req, res) => {
|
||||||
app.post('/api/report-daterange', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const { startDate, endDate, threshold = 50, maxPages = 50 } = req.body;
|
const deleted = deleteResultById(req.params.id);
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ success: false, error: '未找到结果' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 按时间范围采集列表
|
app.delete('/api/results', (_req, res) => {
|
||||||
const items = await fetchListByDateRange(startDate, endDate, maxPages);
|
try {
|
||||||
|
clearResults();
|
||||||
|
res.json({ success: true, message: '已清空所有结果' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (items.length === 0) {
|
app.get('/api/results/filters', (_req, res) => {
|
||||||
return res.json({
|
try {
|
||||||
|
const projectMode = _req.query.view === 'projects';
|
||||||
|
res.json({ success: true, data: getResultFilters({ projectMode }) });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 20 } = req.query;
|
||||||
|
const filters = getProjectQueryFilters(req.query);
|
||||||
|
const result = listProjects({
|
||||||
|
...filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
dedupeByName: true,
|
||||||
|
});
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/export', (req, res) => {
|
||||||
|
try {
|
||||||
|
const projects = getProjects({
|
||||||
|
...getProjectQueryFilters(req.query),
|
||||||
|
dedupeByName: true,
|
||||||
|
});
|
||||||
|
const csv = buildProjectsCsv(projects);
|
||||||
|
const filename = `projects-${formatExportTimestamp()}.csv`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
|
||||||
|
res.send(csv);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/filters', (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: getProjectFilters({ dedupeByName: true }) });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/tasks', (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: listTasks() });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/tasks', (req, res) => {
|
||||||
|
try {
|
||||||
|
const task = createTask(buildTaskPayload(req.body));
|
||||||
|
res.json({ success: true, data: task });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/tasks/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const task = updateTask(req.params.id, buildTaskPayload(req.body, { partial: true }));
|
||||||
|
if (!task) {
|
||||||
|
return res.status(404).json({ success: false, error: '未找到该任务' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: task });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/tasks/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const deleted = deleteTaskById(req.params.id);
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ success: false, error: '未找到该任务' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/tasks/status', (_req, res) => {
|
||||||
|
if (!runningStatus) {
|
||||||
|
return res.json({ success: true, data: { isRunning: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.round((Date.now() - runningStatus.startTime) / 1000);
|
||||||
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
summary: {
|
isRunning,
|
||||||
total_count: 0,
|
elapsed,
|
||||||
filtered_count: 0,
|
city: runningStatus.city,
|
||||||
threshold: `${threshold}万元`,
|
current: runningStatus.current,
|
||||||
total_amount: '0.00万元',
|
total: runningStatus.total,
|
||||||
generated_at: new Date().toISOString(),
|
finished: runningStatus.finished,
|
||||||
date_range: { startDate, endDate },
|
error: runningStatus.error,
|
||||||
},
|
results: runningStatus.finished
|
||||||
projects: [],
|
? (runningStatus.results || (runningStatus.result ? [runningStatus.result] : []))
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/tasks/:id/run', (req, res) => {
|
||||||
|
if (isRunning) {
|
||||||
|
return res.status(409).json({ success: false, error: '当前已有任务在运行,请稍后再试' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按阈值筛选
|
try {
|
||||||
const filtered = items.filter((item) => {
|
const task = getTaskById(req.params.id);
|
||||||
return item.budget && item.budget.amount > threshold;
|
if (!task) {
|
||||||
});
|
return res.status(404).json({ success: false, error: '未找到该任务' });
|
||||||
|
}
|
||||||
|
|
||||||
const total = filtered.reduce(
|
isRunning = true;
|
||||||
(sum, item) => sum + (item.budget?.amount || 0),
|
runTaskInBackground(task);
|
||||||
0
|
res.json({ success: true, message: `任务“${task.city}”已开始执行` });
|
||||||
);
|
} catch (error) {
|
||||||
|
isRunning = false;
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const report = {
|
app.post('/api/tasks/run', (req, res) => {
|
||||||
summary: {
|
if (isRunning) {
|
||||||
total_count: items.length,
|
return res.status(409).json({ success: false, error: '当前已有任务在运行,请稍后再试' });
|
||||||
filtered_count: filtered.length,
|
}
|
||||||
threshold: `${threshold}万元`,
|
|
||||||
total_amount: `${total.toFixed(2)}万元`,
|
|
||||||
generated_at: new Date().toISOString(),
|
|
||||||
date_range: { startDate, endDate },
|
|
||||||
},
|
|
||||||
projects: filtered.map((item) => ({
|
|
||||||
bidNo: item.bidNo,
|
|
||||||
title: item.title,
|
|
||||||
bidName: item.bidName,
|
|
||||||
date: item.date,
|
|
||||||
budget: item.budget,
|
|
||||||
url: item.href,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json({ success: true, data: report });
|
try {
|
||||||
|
let tasks = listTasks();
|
||||||
|
|
||||||
|
if (Array.isArray(req.body?.ids) && req.body.ids.length > 0) {
|
||||||
|
const idSet = new Set(req.body.ids);
|
||||||
|
tasks = tasks.filter((task) => idSet.has(task.id));
|
||||||
|
} else {
|
||||||
|
tasks = tasks.filter((task) => task.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return res.json({ success: true, data: [], message: '没有可运行的任务' });
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
runTasksInBackground(tasks);
|
||||||
|
res.json({ success: true, message: `${tasks.length} 个任务已开始执行` });
|
||||||
|
} catch (error) {
|
||||||
|
isRunning = false;
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/config', (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: maskConfigSecrets(loadConfig()) });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/config', (req, res) => {
|
||||||
|
try {
|
||||||
|
saveConfig(mergeConfigWithExistingSecrets(req.body));
|
||||||
|
reloadScheduler();
|
||||||
|
res.json({ success: true, message: '配置已保存' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: error.message });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 发送报告邮件
|
|
||||||
app.post('/api/send-email', async (req, res) => {
|
app.post('/api/send-email', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { emailConfig, report } = req.body;
|
const { emailConfig, report } = req.body;
|
||||||
|
if (!emailConfig?.smtpHost || !emailConfig?.smtpUser || !emailConfig?.smtpPass) {
|
||||||
// 验证必需的配置参数
|
return res.status(400).json({ success: false, error: '邮件配置不完整' });
|
||||||
if (!emailConfig || !emailConfig.smtpHost || !emailConfig.smtpUser || !emailConfig.smtpPass) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: '邮件配置不完整,请填写SMTP服务器、用户名和密码',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (!emailConfig.recipients?.trim()) {
|
||||||
if (!emailConfig.recipients || emailConfig.recipients.trim() === '') {
|
return res.status(400).json({ success: false, error: '请指定收件人' });
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: '请至少指定一个收件人',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!report) {
|
if (!report) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, error: '没有报告数据' });
|
||||||
success: false,
|
|
||||||
error: '没有可发送的报告数据',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送邮件
|
const { sendReportEmail } = await import('./emailService.js');
|
||||||
const result = await sendReportEmail(emailConfig, report);
|
const result = await sendReportEmail(emailConfig, report);
|
||||||
|
res.json({ success: true, message: '邮件发送成功', messageId: result.messageId });
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: '邮件发送成功',
|
|
||||||
messageId: result.messageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('发送邮件API错误:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取配置
|
|
||||||
app.get('/api/config', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { readFileSync } = await import('fs');
|
|
||||||
const { join } = await import('path');
|
|
||||||
const { fileURLToPath } = await import('url');
|
|
||||||
const { dirname } = await import('path');
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
const configPath = join(__dirname, '..', 'config.json');
|
|
||||||
|
|
||||||
const configContent = readFileSync(configPath, 'utf-8');
|
|
||||||
const config = JSON.parse(configContent);
|
|
||||||
|
|
||||||
// 不返回敏感信息(密码)
|
|
||||||
if (config.email && config.email.smtpPass) {
|
|
||||||
config.email.smtpPass = '***已配置***';
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, data: config });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: error.message });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新配置
|
app.get('/api/scheduler/status', (_req, res) => {
|
||||||
app.post('/api/config', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const { writeFileSync, readFileSync } = await import('fs');
|
res.json({ success: true, data: getSchedulerStatus() });
|
||||||
const { join } = await import('path');
|
|
||||||
const { fileURLToPath } = await import('url');
|
|
||||||
const { dirname } = await import('path');
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
const configPath = join(__dirname, '..', 'config.json');
|
|
||||||
|
|
||||||
const newConfig = req.body;
|
|
||||||
|
|
||||||
// 读取旧配置以保留敏感信息
|
|
||||||
const oldConfigContent = readFileSync(configPath, 'utf-8');
|
|
||||||
const oldConfig = JSON.parse(oldConfigContent);
|
|
||||||
|
|
||||||
// 如果密码字段是占位符,保留原密码
|
|
||||||
if (newConfig.email && newConfig.email.smtpPass === '***已配置***') {
|
|
||||||
newConfig.email.smtpPass = oldConfig.email?.smtpPass || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存配置
|
|
||||||
writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf-8');
|
|
||||||
|
|
||||||
// 重新加载定时任务(如果定时任务配置有变化)
|
|
||||||
reloadScheduler();
|
|
||||||
|
|
||||||
res.json({ success: true, message: '配置已保存并重新加载定时任务' });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: error.message });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取定时任务状态
|
app.post('/api/run-scheduled-task', (_req, res) => {
|
||||||
app.get('/api/scheduler/status', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const status = getSchedulerStatus();
|
runTaskNow().catch((error) => console.error('定时任务执行失败:', error));
|
||||||
res.json({ success: true, data: status });
|
res.json({ success: true, message: '定时任务已在后台触发' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: error.message });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 手动触发定时任务的API(用于测试)
|
initResultsStore();
|
||||||
app.post('/api/run-scheduled-task', async (req, res) => {
|
|
||||||
try {
|
|
||||||
console.log('手动触发定时任务...');
|
|
||||||
// 在后台执行任务,不阻塞响应
|
|
||||||
runTaskNow().catch(err => {
|
|
||||||
console.error('定时任务执行失败:', err);
|
|
||||||
});
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: '定时任务已触发,正在后台执行...'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running at http://localhost:${PORT}`);
|
console.log(`Server running at http://localhost:${PORT}`);
|
||||||
|
|
||||||
// 启动定时任务
|
|
||||||
console.log('正在初始化定时任务...');
|
|
||||||
initScheduler();
|
initScheduler();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user