platform-assistant/scripts/tender-fetcher/fetch-v1.2.js
Sisyphus afac18e843 feat(F002): W26 + W29.x + W30 + W31 平台助手全部改动首次 commit
- W26: NestJS API + Next.js Web + Prisma + SQLite scaffold
- W26: Lead 模型 + Crawler + 询价发布助手 (线索池 + 一键发布)
- W29.x: MCP 集成 (Integration 模型 + connectMcp + publish_rfq 5 工具端到端跑通)
- W30: 3 字段补充 (notes / quotationType / quotationDemand)
- W31 P1: 自动填 enterpriseId / userId (Prisma companyUserId 字段 + get_app_identity tool + lead.service.ts publishArgs)
- W31: detail 详情页 + admin UI 集成管理 (OpenAppListView + AuditLogListView)

关联: ~/Documents/02-平台开发/产品开发/F002-W31-交接文档.md
GIT-AUDIT-REPORT-2026-06-24.md
2026-06-24 11:07:11 +08:00

416 lines
15 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* 中电建阳光采购网 - 招采公告抓取脚本 v1.2
*
* v1.2 新增:
* - 支持多公告类型:招采公告、资格预审公告、采购预告、中标结果公示
* - 类型在配置区可自定义增减
*
* 改进点(继承v1.1):
* 1. 分页逻辑 - 自动遍历所有页
* 2. 去重机制 - 基于 systemId 持久化记录
* 3. 空附件处理 - pictureUrl 为空时跳过并告警
* 4. 稳定文件名 - 基于 systemId + 内容 hash
* 5. 并发下载 - 控制并发数,避免被限流
* 6. 失败重试 - 3次重试,间隔递增
* 7. 状态持久化 - downloaded.json 记录已下载文件
* 8. 差量更新 - 只抓自上次运行以来的新增公告
* 9. 详细报告 - 成功/失败/跳过数量统计
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const crypto = require('crypto');
// ========== 配置区 ==========
const KEYWORD = '水电十一局';
// 公告类型:可按需增减。支持的类型值见 API 返回的 announcementType 字段
const TYPES = ['招采公告', '资格预审公告', '采购预告', '中标结果公示'];
// 可选:留空则从上次抓取日期继续;有值则从指定日期开始
const START_DATE = '';
const END_DATE = new Date().toISOString().split('T')[0]; // 今天
const OUTPUT_DIR = path.join(process.env.HOME, 'desktop/招标公告', END_DATE);
const PDF_DIR = path.join(OUTPUT_DIR, 'pdf'); // PDF 附件
const HTML_DIR = path.join(OUTPUT_DIR, 'html'); // HTML 转换(待安装转换技能)
const STATE_FILE = path.join(OUTPUT_DIR, 'downloaded.json'); // 已下载记录
const LOG_FILE = path.join(OUTPUT_DIR, 'fetch.log'); // 运行日志
const CONCURRENCY = 3; // 并发下载数
const RETRY_TIMES = 3;
const RETRY_DELAY_BASE = 1000; // ms
// ==========================
// ---------- 工具函数 ----------
function log(msg, type = 'INFO') {
const ts = new Date().toISOString().replace('T', ' ').substring(0, 19);
const line = `[${ts}] [${type}] ${msg}`;
console.log(line);
fs.appendFileSync(LOG_FILE, line + '\n');
}
function loadState() {
try {
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
} catch {
return { lastRun: null, downloaded: {}, lastId: null };
}
}
function saveState(state) {
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}
function md5(str) {
return crypto.createHash('md5').update(str).digest('hex').substring(0, 8);
}
function sanitizeFilename(name, maxLen = 50) {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, '_').substring(0, maxLen);
}
function fileExtFromUrl(url) {
if (!url) return null;
const m = url.match(/fileType=(\d+)/);
const typeMap = { 2: '.pdf', 3: '.pdf', 4: '.pdf', 6: '.pdf', 7: '.zip' };
return m ? (typeMap[m[1]] || '.pdf') : (path.extname(new URL(url).pathname) || '.pdf');
}
// ---------- HTTP 请求 ----------
function request(url, options = {}, retries = RETRY_TIMES) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const reqOptions = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: options.method || 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
...options.headers,
},
timeout: 15000,
};
const req = protocol.request(reqOptions, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode === 301 || res.statusCode === 302) {
const location = res.headers.location;
if (location.startsWith('http')) {
request(location, options, retries).then(resolve).catch(reject);
} else {
const base = `${parsedUrl.protocol}//${parsedUrl.host}`;
request(base + location, options, retries).then(resolve).catch(reject);
}
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
try {
resolve(JSON.parse(data));
} catch {
resolve(data);
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
if (options.body) req.write(options.body);
req.end();
}).catch(err => {
if (retries > 0) {
const delay = RETRY_DELAY_BASE * (RETRY_TIMES - retries + 1);
return new Promise(r => setTimeout(r, delay))
.then(() => request(url, options, retries - 1));
}
throw err;
});
}
// ---------- API 调用 ----------
async function fetchAnnouncements(keyword, startDate, endDate, page = 1, pageSize = 50) {
const postData = JSON.stringify({
keyWords: keyword,
pageNum: page,
pageSize,
publishStartTime: startDate ? `${startDate} 00:00:00` : '',
publishEndTime: `${endDate} 23:59:59`,
});
return request('https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/list', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://bid.powerchina.cn',
'Referer': 'https://bid.powerchina.cn/search',
},
body: postData,
});
}
// ---------- 下载文件 ----------
async function downloadFile(fileUrl, destPath, retries = RETRY_TIMES) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(fileUrl);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const file = fs.createWriteStream(destPath);
const req = protocol.request({
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Referer': 'https://bid.powerchina.cn/',
},
timeout: 30000,
}, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
file.close();
downloadFile(res.headers.location.startsWith('http')
? res.headers.location
: `${parsedUrl.protocol}//${parsedUrl.host}${res.headers.location}`,
destPath, retries
).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
file.close();
reject(new Error(`Download HTTP ${res.statusCode}`));
return;
}
res.pipe(file);
file.on('finish', () => { file.close(); resolve(); });
});
req.on('error', (err) => { file.close(); reject(err); });
req.on('timeout', () => { req.destroy(); file.close(); reject(new Error('Download timeout')); });
req.end();
}).catch(err => {
if (retries > 0) {
const delay = RETRY_DELAY_BASE * (RETRY_TIMES - retries + 1);
return new Promise(r => setTimeout(r, delay))
.then(() => downloadFile(fileUrl, destPath, retries - 1));
}
throw err;
});
}
// ---------- 并发控制 ----------
async function runWithConcurrency(tasks, limit) {
const results = [];
for (let i = 0; i < tasks.length; i += limit) {
const batch = tasks.slice(i, i + limit);
const batchResults = await Promise.allSettled(batch.map(t => t()));
results.push(...batchResults);
}
return results;
}
// ---------- 生成公告清单 ----------
function buildAttachmentName(r, index) {
const ext = fileExtFromUrl(r.pictureUrl);
const dateStr = (r.publishTime || '').substring(0, 10) || END_DATE;
const shortTitle = sanitizeFilename(r.title, 40);
return `${dateStr}_${index}_${shortTitle}${ext}`;
}
function formatDate(dt) {
if (!dt) return '-';
// 兼容 "2026-04-13T18:55:00" 和 "2026-04-13 18:55" 两种格式
const normalized = dt.includes('T') ? dt.replace('T', ' ').substring(0, 16) : dt.substring(0, 16);
return normalized;
}
function generateAnnouncementList(announcements, sinceDate, endDate) {
const lines = [];
const count = announcements.length;
const today = new Date().toISOString().substring(0, 10);
// 标题
// 标题:去掉日期中的无效前导零(如 04→4),让范围更易读
const fmtDate = d => d.replace(/-0(\d)/g, '-$1');
const rangeLabel = sinceDate === END_DATE ? '当天' : `${fmtDate(sinceDate)}至${fmtDate(END_DATE)}`;
lines.push(`# ${KEYWORD} 近${rangeLabel}采购公告`);
lines.push('');
lines.push(`**筛选条件**:${KEYWORD} | ${sinceDate} ~ ${endDate} | ${TYPES.join('、')}`);
lines.push('');
lines.push(`**公告总数**:${count}条`);
lines.push('');
lines.push(`> 数据来源:中电建阳光采购网 https://bid.powerchina.cn`);
lines.push(`> 下载时间:${today}`);
lines.push('');
lines.push('---');
lines.push('');
announcements.forEach((r, i) => {
const n = i + 1;
const attachmentName = buildAttachmentName(r, n);
const deadlineReg = formatDate(r.registrationDeadline);
const deadlineSub = formatDate(r.submissionDeadline);
const bidTime = formatDate(r.bidOpenTime);
const publishTime = formatDate(r.publishTime);
lines.push(`## ${n}. ${r.title}`);
lines.push(`- **发布日期**:\`${publishTime}\``);
lines.push(`- **公告ID**:\`${r.id}\`(systemId: \`${r.systemId}\`)`);
lines.push(`- **详情链接**:https://bid.powerchina.cn/notice/detail?id=${r.id}`);
lines.push(`- **采购分类**:${r.titleTypeName || '-'}`);
lines.push(`- **报名截止**:\`${deadlineReg}\``);
lines.push(`- **投标截止**:\`${deadlineSub}\``);
lines.push(`- **开标时间**:\`${bidTime}\``);
lines.push(`- **附件**:\`pdf/${attachmentName}\``);
lines.push('');
});
return lines.join('\n');
}
// ---------- 主流程 ----------
async function main() {
// 初始化
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(PDF_DIR, { recursive: true });
fs.mkdirSync(HTML_DIR, { recursive: true });
if (!fs.existsSync(LOG_FILE)) fs.writeFileSync(LOG_FILE, '');
const state = loadState();
const stats = { total: 0, new: 0, skipDuplicate: 0, skipNoAttachment: 0, success: 0, failed: 0, retried: 0 };
log(`========== 开始抓取: ${KEYWORD} ==========`);
// 确定起始日期(差量:上次运行日期 或 配置的 START_DATE)
const sinceDate = START_DATE || (state.lastRun ? state.lastRun.split('T')[0] : null) || END_DATE;
if (START_DATE) {
log(`指定日期范围: ${START_DATE} ~ ${END_DATE}`);
} else if (state.lastRun) {
log(`差量模式: 自 ${state.lastRun.substring(0, 10)} 以来新增`);
} else {
log(`首次运行: ${sinceDate} ~ ${END_DATE}`);
}
// 分页获取所有公告
let page = 1;
let hasMore = true;
let allAnnouncements = [];
while (hasMore) {
log(`获取第 ${page} 页...`);
let data;
try {
data = await fetchAnnouncements(KEYWORD, sinceDate, END_DATE, page, 50);
} catch (err) {
log(`第 ${page} 页请求失败: ${err.message}`, 'ERROR');
break;
}
const rows = Array.isArray(data) ? data : (data.rows || []);
allAnnouncements.push(...rows);
stats.total += rows.length;
if (rows.length < 50 || !data.total || allAnnouncements.length >= data.total) {
hasMore = false;
} else {
page++;
await new Promise(r => setTimeout(r, 500)); // 防止请求过快
}
}
log(`共获取 ${stats.total} 条公告(分 ${page} 页)`);
// 过滤目标公告类型(由 TYPES 配置定义)
const caigou = allAnnouncements.filter(r => TYPES.includes(r.announcementType));
log(`其中 ${TYPES.join('、')}: ${caigou.length} 条`);
// 生成公告清单(所有本次抓到的招采公告,无论是否已下载)
const listMd = generateAnnouncementList(caigou, sinceDate, END_DATE);
const listFileName = `公告清单_${KEYWORD}_${sinceDate}-${END_DATE}.md`;
const listPath = path.join(OUTPUT_DIR, listFileName);
fs.writeFileSync(listPath, listMd, 'utf8');
log(`📄 公告清单已生成: ${listFileName}`);
// 构建下载任务(只下载未记录的)
const tasks = [];
for (const r of caigou) {
const systemId = r.systemId;
if (!systemId) {
stats.skipNoAttachment++;
log(`跳过(无systemId): ${r.title}`, 'WARN');
continue;
}
const key = `${systemId}_${r.announcementType}`;
if (state.downloaded[key]) {
stats.skipDuplicate++;
continue;
}
const picUrl = r.pictureUrl;
if (!picUrl) {
stats.skipNoAttachment++;
log(`跳过(无附件): ${r.title}`, 'WARN');
state.downloaded[key] = { id: r.id, title: r.title, skipped: true, deadline: r.submissionDeadline || '', time: new Date().toISOString() };
continue;
}
const ext = fileExtFromUrl(picUrl);
const dateStr = (r.publishTime || '').substring(0, 10) || END_DATE;
const shortTitle = sanitizeFilename(r.title, 40);
const fname = `${dateStr}_${systemId.substring(0, 8)}_${shortTitle}${ext}`;
const fpath = path.join(PDF_DIR, fname);
const shortName = sanitizeFilename(r.title, 20);
tasks.push(async () => {
log(`下载 [${shortName}]: ${fname}`);
try {
await downloadFile(picUrl, fpath);
const size = fs.statSync(fpath).size;
state.downloaded[key] = { id: r.id, title: r.title, deadline: r.submissionDeadline || '', path: `pdf/${fname}`, size, time: new Date().toISOString() };
stats.success++;
log(` ✅ ${fname} (${size} bytes)`);
} catch (err) {
stats.failed++;
state.downloaded[key] = { id: r.id, title: r.title, deadline: r.submissionDeadline || '', failed: true, error: err.message, time: new Date().toISOString() };
log(` ❌ ${fname}: ${err.message}`, 'ERROR');
}
});
}
// 并发执行
if (tasks.length > 0) {
log(`开始下载 ${tasks.length} 个附件(并发数: ${CONCURRENCY})...`);
await runWithConcurrency(tasks, CONCURRENCY);
}
// 更新状态
state.lastRun = new Date().toISOString();
state.lastKeyword = KEYWORD;
saveState(state);
// 输出统计
log(`========== 完成 ==========`);
log(`总计公告: ${stats.total} | ${TYPES.join('、')}: ${caigou.length}`);
log(`新增下载: ${stats.success} | 重复跳过: ${stats.skipDuplicate} | 无附件跳过: ${stats.skipNoAttachment} | 下载失败: ${stats.failed}`);
log(`状态已保存: ${STATE_FILE}`);
console.log('\n========== 汇总 ==========');
console.log(`✅ 成功: ${stats.success} | ❌ 失败: ${stats.failed} | ⏩ 重复: ${stats.skipDuplicate} | ⚠️ 无附件: ${stats.skipNoAttachment}`);
console.log(`📁 保存目录: ${OUTPUT_DIR}`);
console.log(`📄 公告清单: ${listFileName}`);
}
main().catch(err => { console.error('Fatal:', err); process.exit(1); });