逆向解析,基于Java与Selenium自动化获取全国公共资源交易平台招投标详情数据
1. 项目背景与需求分析
全国公共资源交易平台作为招投标信息的官方发布渠道,每天产生大量有价值的工程、采购、产权交易数据。这些数据对企业市场分析、竞争对手监测具有重要参考价值。但平台采用动态加载技术,传统爬虫难以直接获取完整信息。我曾为某建筑集团搭建数据采集系统时发现,直接解析接口返回的JSON数据缺失关键字段,而详情页的招标文件、资质要求等核心内容需要通过模拟点击才能获取完整HTML。
2. 技术选型与环境准备
2.1 核心工具链组合
经过多次对比测试,最终确定的技术方案是:
- Selenium WebDriver:处理动态页面交互(实测ChromeDriver兼容性最佳)
- WebMagic:作为爬虫框架管理请求队列和管道
- HtmlUnit:辅助快速检测页面变更(但遇到验证码时仍需切换回Selenium)
// Maven依赖配置示例 <dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-java</artifactId> <version>4.1.4</version> </dependency> <dependency> <groupId>us.codecraft</groupId> <artifactId>webmagic-core</artifactId> <version>0.7.3</version> </dependency>2.2 反爬应对策略
平台常见的防护措施包括:
- IP频次限制:每个请求间隔3秒以上
- UserAgent验证:需模拟主流浏览器标识
- 动态参数签名:观察到的
TIMEBEGIN_SHOW等时间戳参数需按规则生成
// 请求头伪装示例 chromeOptions.addArguments("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64 x64)"); chromeOptions.addArguments("--disable-blink-features=AutomationControlled");3. 页面结构逆向分析
3.1 省级行政区划解析
通过分析页面DOM树发现,省份数据直接硬编码在<select id="provinceId">中。但市级数据通过AJAX动态加载,需要提取省级编码作为请求参数:
// 省份数据提取示例 List<WebElement> options = driver.findElements(By.cssSelector("#provinceId option")); Map<String, String> provinceMap = options.stream() .filter(e -> !e.getAttribute("value").equals("0")) .collect(Collectors.toMap( e -> e.getText(), e -> e.getAttribute("value") ));3.2 动态标签定位技巧
招标公告通常隐藏在<li id="t_0101">标签内,需先触发点击事件才能加载内容。这里有个坑:必须等待iframe完全加载后再操作,否则会抛出NoSuchElementException:
new WebDriverWait(driver, Duration.ofSeconds(10)) .until(ExpectedConditions.frameToBeAvailableAndSwitchToIt( By.id("iframe0101") ));4. 核心采集流程实现
4.1 列表页数据获取
通过抓包分析找到真实数据接口dealList_find.jsp,其关键参数包括:
DEAL_PROVINCE:省级编码(如山东为370000)TIMEBEGIN/TIMEEND:时间范围(通常限制10天内)PAGENUMBER:分页参数
// POST请求构造示例 HttpPost post = new HttpPost("http://deal.ggzy.gov.cn/ds/deal/dealList_find.jsp"); List<NameValuePair> params = new ArrayList<>(); params.add(new BasicNameValuePair("DEAL_PROVINCE", "370000")); params.add(new BasicNameValuePair("TIMEBEGIN", "2026-07-01")); post.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));4.2 详情页深度提取
获取到详情页URL后,需要处理两个技术难点:
- 多标签页切换:使用
windowHandles管理浏览器标签 - 内容iframe嵌套:先定位外层div再切换到内部iframe
// 多标签页处理示例 String mainWindow = driver.getWindowHandle(); for (String handle : driver.getWindowHandles()) { if (!handle.equals(mainWindow)) { driver.switchTo().window(handle); break; } }5. 数据存储与优化
5.1 本地化存储方案
采用HTML+元数据分离存储模式:
- 原始HTML保存为
[项目ID].html - 结构化数据存入MySQL,包含字段:
CREATE TABLE tender_data ( id VARCHAR(32) PRIMARY KEY, title TEXT, province VARCHAR(20), publish_date DATE, html_path VARCHAR(255), url_hash CHAR(64) );
5.2 性能优化实践
- 请求间隔随机化:在3-5秒间随机休眠
- 失败重试机制:对502/504状态码自动重试
- 代理IP池:使用Luminati等商业服务(需企业级预算)
// 指数退避重试示例 int retries = 0; while (retries < 3) { try { return httpClient.execute(post); } catch (SocketTimeoutException e) { Thread.sleep((long) Math.pow(2, retries) * 1000); retries++; } }6. 完整代码结构
项目采用模块化设计:
src/ ├── main/ │ ├── java/ │ │ ├── model/ # 数据模型 │ │ ├── parser/ # 页面解析器 │ │ ├── pipeline/ # 存储管道 │ │ └── scheduler/ # 任务调度 │ └── resources/ │ ├── proxy.list # 代理IP列表 │ └── regions.json # 行政区划配置核心处理器示例:
public class GGZYProcessor implements PageProcessor { private Site site = Site.me() .setRetryTimes(3) .setSleepTime(3000 + new Random().nextInt(2000)); @Override public void process(Page page) { if (page.getUrl().regex("dealList_find").match()) { // 列表页解析逻辑 } else { // 详情页处理逻辑 page.putField("html", page.getHtml().xpath("//div[@id='mycontent']")); } } @Override public Site getSite() { return site; } }7. 常见问题解决方案
动态元素加载失败:采用显式等待结合JS重试机制
public WebElement safeFind(By locator) { try { return new WebDriverWait(driver, 10) .until(d -> d.findElement(locator)); } catch (TimeoutException e) { ((JavascriptExecutor)driver).executeScript( "arguments[0].scrollIntoView()", driver.findElement(locator) ); return driver.findElement(locator); } }验证码触发:通过控制浏览器窗口大小避免触发(实测1920x1080最稳定)
driver.manage().window().setSize(new Dimension(1920, 1080));8. 法律合规要点
- Robots协议遵守:检查
/robots.txt禁止爬取的目录 - 数据使用限制:不得用于商业倒卖等违规用途
- 访问频率控制:单IP请求量不超过行业公认合理范围
建议在代码中加入合规性检查:
public boolean checkCompliance() { try { Document doc = Jsoup.connect("https://www.ggzy.gov.cn/robots.txt") .timeout(5000) .get(); return !doc.text().contains("Disallow: /ds/"); } catch (IOException e) { return false; } }