import axios from 'axios'; import * as cheerio from 'cheerio'; import { createPool } from 'mariadb'; import fs from 'fs'; import path from 'path'; function loadDatabaseConfig() { const envPath = path.join(process.cwd(), '.env'); if (fs.existsSync(envPath)) { console.log('读取 .env 文件...'); const envContent = fs.readFileSync(envPath, 'utf-8'); const lines = envContent.split('\n'); for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine.startsWith('DATABASE_URL=')) { const url = trimmedLine.substring('DATABASE_URL='.length).replace(/^"|"$/g, ''); console.log('从 .env 文件获取数据库配置成功!'); console.log(`DATABASE_URL: ${url}`); // 解析URL const parts = url.split('://')[1].split('@'); const auth = parts[0].split(':'); const hostAndDb = parts[1].split('/'); const hostAndPort = hostAndDb[0].split(':'); return { user: auth[0], password: auth[1], host: hostAndPort[0], port: parseInt(hostAndPort[1]), database: hostAndDb[1] }; } } } console.log('未找到 .env 文件或 DATABASE_URL 配置,使用默认配置'); return { host: '192.168.111.111', port: 3306, user: 'root', password: 'fullstack', database: 'iboard' }; } // 创建数据库连接池 const config = loadDatabaseConfig(); console.log('数据库配置:'); console.log(` 主机: ${config.host}`); console.log(` 端口: ${config.port}`); console.log(` 用户: ${config.user}`); console.log(` 数据库: ${config.database}`); let pool; async function initPool() { if (!pool) { pool = createPool({ host: config.host, port: config.port, user: config.user, password: config.password, database: config.database, connectionLimit: 5 }); try { const conn = await pool.getConnection(); console.log('数据库连接成功!'); conn.release(); } catch (error) { console.error('数据库连接失败:', error.message); throw error; } } return pool; } // 浙江数据开放网站URL(示例,实际需要根据真实网站修改) const ZHEJIANG_OPEN_DATA_URL = 'https://data.zj.gov.cn/'; // 爬虫名称,用于缓存表中的标识 const SCRAPER_NAME = 'sow-inventory'; // 最小更新间隔(毫秒),默认30分钟 const MIN_UPDATE_INTERVAL = 30 * 60 * 1000; // 从数据库读取缓存 async function readCache() { try { await initPool(); const result = await pool.query('SELECT * FROM ScraperCache WHERE scraperName = ?', [SCRAPER_NAME]); return result.length > 0 ? result[0] : { scraperName: SCRAPER_NAME, lastDataHash: null, lastUpdateAt: null }; } catch (error) { console.error('读取数据库缓存失败:', error.message); return { scraperName: SCRAPER_NAME, lastDataHash: null, lastUpdateAt: null }; } } // 写入数据库缓存 async function writeCache(lastDataHash) { try { await initPool(); await pool.query( 'INSERT INTO ScraperCache (scraperName, lastDataHash, lastUpdateAt) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE lastDataHash = VALUES(lastDataHash), lastUpdateAt = VALUES(lastUpdateAt)', [SCRAPER_NAME, lastDataHash, new Date()] ); } catch (error) { console.error('写入数据库缓存失败:', error.message); } } // 计算数据hash,用于检测数据是否变化 function computeDataHash(data) { const str = JSON.stringify(data); let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return hash.toString(); } // 获取数据库中最新的月份 async function getLatestMonth() { try { await initPool(); const result = await pool.query('SELECT MAX(month) as latestMonth FROM Econ_SowInventory'); return result[0].latestMonth || null; } catch (error) { console.error('查询最新月份失败:', error.message); return null; } } // 判断月份是否需要更新(只有比数据库中更新的月份才需要处理) function shouldUpdateMonth(month, latestDbMonth) { if (!latestDbMonth) return true; return month > latestDbMonth; } // 检查是否需要发起HTTP请求(基于更新间隔) function shouldSkipRequest(cache) { if (!cache.lastUpdateAt) return false; const now = new Date(); const lastUpdate = new Date(cache.lastUpdateAt); const interval = now - lastUpdate; return interval < MIN_UPDATE_INTERVAL; } // 能繁母猪数量数据爬取函数(增量更新版本) async function scrapeSowInventory(incremental = true) { try { await initPool(); console.log('='.repeat(50)); console.log('开始爬取浙江省能繁母猪数量数据...'); console.log(`增量更新模式: ${incremental ? '开启' : '关闭'}`); console.log('='.repeat(50)); // 0. 检查是否需要发起请求 if (incremental) { const cache = await readCache(); if (shouldSkipRequest(cache)) { const lastUpdate = cache.lastUpdateAt ? new Date(cache.lastUpdateAt) : null; const hoursDiff = lastUpdate ? ((new Date() - lastUpdate) / (1000 * 60 * 60)).toFixed(2) : '未知'; console.log('\n' + '='.repeat(50)); console.log('跳过请求:距离上次更新不足30分钟'); console.log(`上次更新时间: ${cache.lastUpdateAt}`); console.log(`距离现在: ${hoursDiff} 小时`); console.log('='.repeat(50)); return; } // 获取数据库中最新的月份 const latestDbMonth = await getLatestMonth(); if (latestDbMonth) { console.log(`数据库中最新月份: ${latestDbMonth}`); } else { console.log('数据库为空,将获取所有数据'); } } // 1. 发送HTTP请求获取页面内容 console.log('\n正在请求数据源...'); const response = await axios.get(ZHEJIANG_OPEN_DATA_URL, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); // 2. 解析HTML内容 const $ = cheerio.load(response.data); // 3. 提取数据(这里需要根据实际网站结构修改选择器) const allData = []; $('table').each((tableIndex, table) => { $(table).find('tr').each((rowIndex, row) => { if (rowIndex > 0) { // 跳过表头 const cells = $(row).find('td'); if (cells.length >= 2) { const month = $(cells[0]).text().trim(); const inventory = parseInt($(cells[1]).text().trim()); if (month && !isNaN(inventory)) { allData.push({ month, inventory }); } } } }); }); console.log(`页面总数据量: ${allData.length} 条`); // 4. 读取数据库缓存 const cache = await readCache(); // 5. 计算当前数据hash,检测数据是否变化 const currentDataHash = computeDataHash(allData); // 6. 如果是增量更新且数据未变化,跳过更新 if (incremental && cache.lastDataHash === currentDataHash) { console.log('\n' + '='.repeat(50)); console.log('数据未发生变化,跳过数据库更新!'); console.log(`上次更新时间: ${cache.lastUpdateAt || '未知'}`); console.log('='.repeat(50)); return; } // 7. 如果是增量更新,获取数据库中最新的月份用于过滤 let latestDbMonth = null; if (incremental) { latestDbMonth = await getLatestMonth(); } // 8. 过滤需要更新的数据 let dataToUpdate = allData; let skippedCount = 0; if (incremental && latestDbMonth) { dataToUpdate = allData.filter(item => shouldUpdateMonth(item.month, latestDbMonth)); skippedCount = allData.length - dataToUpdate.length; console.log(`\n增量更新: 跳过 ${skippedCount} 条已存在的数据`); console.log(`需要处理: ${dataToUpdate.length} 条新数据`); } // 9. 如果没有需要更新的数据 if (dataToUpdate.length === 0) { console.log('\n没有需要更新的数据!'); return; } // 10. 按月份排序(从旧到新) dataToUpdate.sort((a, b) => a.month.localeCompare(b.month)); console.log('\n开始处理数据...'); console.log('-'.repeat(50)); let newCount = 0; let updateCount = 0; let errorCount = 0; // 11. 存储数据到数据库 for (const item of dataToUpdate) { try { // 检查数据是否已存在 const existing = await pool.query('SELECT * FROM Econ_SowInventory WHERE month = ?', [item.month]); if (existing.length > 0) { // 检查数据是否真的需要更新(值变化了才更新) if (existing[0].inventory !== item.inventory) { await pool.query( 'UPDATE Econ_SowInventory SET inventory = ? WHERE month = ?', [item.inventory, item.month] ); updateCount++; console.log(`更新数据: ${item.month} - ${item.inventory} (原值: ${existing[0].inventory})`); } else { console.log(`数据未变化,跳过: ${item.month}`); } } else { await pool.query( 'INSERT INTO Econ_SowInventory (month, inventory) VALUES (?, ?)', [item.month, item.inventory] ); newCount++; console.log(`新增数据: ${item.month} - ${item.inventory}`); } } catch (error) { errorCount++; console.error(`处理数据 ${item.month} 时出错:`, error.message); } } // 12. 更新数据库缓存 await writeCache(currentDataHash); // 13. 输出统计信息 const now = new Date().toISOString(); console.log('\n' + '='.repeat(50)); console.log('数据爬取和存储完成!'); console.log('统计信息:'); console.log(` - 新增数据: ${newCount} 条`); console.log(` - 更新数据: ${updateCount} 条`); console.log(` - 跳过数据: ${skippedCount} 条`); console.log(` - 错误数量: ${errorCount} 条`); console.log(` - 本次更新时间: ${now}`); console.log('='.repeat(50)); } catch (error) { console.error('爬取数据时出错:', error.message); throw error; } finally { // 关闭数据库连接池 if (pool) { await pool.end(); console.log('数据库连接池已关闭'); } } } // 强制全量更新函数 async function scrapeSowInventoryFull() { console.log('警告: 即将执行全量更新,这将更新所有数据!'); console.log('如果只是想获取新数据,请使用 scrapeSowInventory() 函数。\n'); await scrapeSowInventory(false); } // 导出函数 export { scrapeSowInventory, scrapeSowInventoryFull }; // 如果直接运行此文件 if (import.meta.url === `file://${process.argv[1]}`) { // 默认执行增量更新 // 如果需要强制全量更新,使用: scrapeSowInventoryFull() scrapeSowInventory().catch(error => { console.error('执行失败:', error); process.exit(1); }); }