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