news 2026/9/19 0:25:45

Python爬虫实战:招标信息定时抓取与关键词推送工具设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python爬虫实战:招标信息定时抓取与关键词推送工具设计

1. 招标信息爬取工具的整体设计思路

1.1 为什么我要自己动手做这个工具

做工程、做销售、做供应链的朋友应该都有体会,招标信息这东西,早半小时看到和晚半天看到,结果可能完全不一样。我最早是手动刷几个固定的招标网站,每天早上开电脑第一件事就是挨个点开看有没有新公告,项目一多就顶不住了。后来试过一些现成的聚合服务,要么更新慢,要么关键字段缺失,要么就是收费贵得离谱。折腾了一圈,我决定自己写一个招标信息爬取工具,把数据抓回来落到本地数据库,再按关键词推给自己。

这个工具的核心目标很明确:定时抓取指定招标网站的公告列表和详情页,提取标题、发布时间、地区、招标单位、预算金额、截止时间等字段,去重后存入数据库,并支持按关键词筛选推送。它适合有一定编程基础、想自己掌控数据源的从业者,也适合刚学爬虫想找一个真实项目练手的朋友。整条链路不复杂,但坑不少,下面我把设计思路、核心细节、实操过程和踩坑经验完整拆一遍。

1.2 整体架构与技术选型

我把整个工具拆成四层:调度层、抓取层、解析层、存储与通知层。调度层负责定时触发;抓取层负责发请求拿页面;解析层负责从HTML里抽字段;存储层负责去重入库,通知层负责把命中关键词的条目推给我。

技术选型上,我最终用的是Python + Requests + BeautifulSoup + SQLite + APScheduler这套组合。为什么这么选?第一,招标网站大多是服务端渲染的传统页面,不是那种全靠JS动态加载的单页应用,Requests直接拿HTML就够了,上Selenium属于杀鸡用牛刀,还慢。第二,BeautifulSoup解析静态HTML足够稳,学习成本低,正则兜底也方便。第三,SQLite零配置、单文件,本地跑完全够用,数据量到几十万条也不虚。第四,APScheduler做定时任务比写crontab更灵活,能直接在Python进程里控制。

如果你的目标站点是动态渲染的,那抓取层就得换成Playwright或者Selenium,这是后话,我在常见问题里会讲怎么判断。选型这件事没有绝对的对错,关键是匹配目标站点的技术形态和你自己的维护能力。我见过有人一上来就上Scrapy加分布式,结果站点一共就几百条数据,纯属给自己找麻烦。

1.3 数据字段设计与去重逻辑

字段设计直接决定了这个工具好不好用。我最终定的核心字段是这些:公告标题、公告类型(招标/中标/变更)、发布时间、所属地区、招标单位、预算金额、投标截止时间、详情页链接、数据来源站点、抓取时间。其中标题、发布时间、详情页链接是必填的,其他字段允许为空,因为不同站点披露的完整度差别很大。

去重是这个工具的生命线。招标网站经常出现同一条公告在列表页重复出现、或者详情页链接带一堆跟踪参数的情况。我的做法是用详情页链接的规范化结果做唯一键:先把URL里的query参数里那些明显的跟踪参数(比如utm_开头的、时间戳参数)剔掉,再取MD5作为主键。如果链接本身不稳定,就退而求其次用“标题+发布时间”的组合哈希。这里有个经验:千万别只用标题去重,因为不同地区同名的招标公告太常见了,我早期就因为这个漏掉过好几条真实的新公告。

2. 核心细节解析与实操要点

2.1 请求头的伪装与频率控制

招标网站基本都有反爬,最基础的一道就是检查请求头。裸奔的Requests请求User-Agent是python-requests/x.x.x,一眼就被识别。我的做法是维护一个真实浏览器的请求头池,每次请求随机取一个,重点字段包括User-AgentAcceptAccept-LanguageReferer。Referer尤其重要,很多站点会校验你是不是从列表页点进详情页的,所以抓详情页时Referer要设成对应的列表页地址。

频率控制是另一个关键。我的策略是列表页请求间隔2到5秒随机,详情页请求间隔1到3秒随机,并且单次任务连续请求不超过200次就主动休息30秒。为什么要随机而不是固定间隔?因为固定间隔的请求特征太明显,很容易被风控识别成机器。随机化之后,请求曲线更接近真人浏览。这里给个我实际在用的请求头配置片段:

import random USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", ] def build_headers(referer=None): headers = { "User-Agent": random.choice(USER_AGENTS), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } if referer: headers["Referer"] = referer return headers

注意:请求头池不要用网上随便抄的那种几十年前的老UA,很多站点会直接拒绝过旧的浏览器标识。定期更新成近一两年的主流版本。

2.2 列表页与详情页的解析策略

解析这块我踩过最大的坑就是把解析逻辑写死。一开始我针对某个站点写了一套CSS选择器,结果人家页面改版一次,整个工具就废了。后来我改成配置驱动:每个站点对应一个配置文件,里面写清楚列表页的条目选择器、各字段的选择器或正则、翻页规则。这样站点改版时我只需要改配置,不用动主程序。

列表页解析的核心是拿到每条公告的详情页链接和标题。这里要注意,很多站点的列表页链接是相对路径,需要和站点域名拼接成绝对路径。详情页解析则要处理字段缺失的情况,比如预算金额有时候在正文里,有时候在表格里,有时候压根没有。我的做法是多套选择器依次尝试,全部失败就留空并记录日志,绝不因为一个字段解析失败就中断整条记录。

翻页逻辑也要小心。有的站点是?page=2这种参数翻页,有的是/list_2.html这种路径翻页,还有的是POST请求翻页。我一般先手动翻两三页,观察URL变化规律,再决定用哪种方式。翻页的终止条件通常是“当前页没有新条目”或者“达到设定的最大页数”,我一般设最大50页,防止死循环。

2.3 数据清洗与字段标准化

抓回来的原始数据是很脏的。发布时间可能是“2024-01-15”、“2024/01/15”、“1月15日”甚至“三天前”这种相对时间。地区字段可能是“北京市朝阳区”、“北京”、“朝阳区”混着来。预算金额可能是“100万元”、“1,000,000元”、“预算金额:100万”各种写法。

我的清洗策略是统一转成标准格式再入库。时间统一转成YYYY-MM-DD格式,遇到相对时间就根据抓取时间反推。地区做一层映射,把“北京市”归一到“北京”。金额统一转成以“元”为单位的数字,遇到“万”就乘10000,遇到“亿”就乘100000000。这一步看起来琐碎,但直接决定了后面关键词筛选和统计的准确性。我建议清洗逻辑单独写成一个模块,方便单元测试,因为这里的边界情况特别多。

3. 实操过程与核心环节实现

3.1 环境准备与依赖安装

先把环境搭起来。我用的是Python 3.10,太老的版本有些库不兼容。依赖就四个核心库,一条命令搞定:

pip install requests beautifulsoup4 apscheduler lxml

lxml是BeautifulSoup的解析器后端,比默认的html.parser快很多,处理不规范HTML也更稳。数据库用Python内置的sqlite3,不用额外装。如果你想做关键词推送,再加一个requests就够了,因为大部分推送渠道都是HTTP接口。

目录结构我建议这样组织,清晰好维护:

bid_crawler/ ├── config/ │ ├── site_a.yaml # 站点A的解析配置 │ └── site_b.yaml # 站点B的解析配置 ├── crawler/ │ ├── fetcher.py # 请求封装 │ ├── parser.py # 解析逻辑 │ └── cleaner.py # 数据清洗 ├── storage/ │ └── db.py # 数据库操作 ├── notify/ │ └── pusher.py # 通知推送 ├── main.py # 调度入口 └── data.db # SQLite数据库文件

3.2 数据库表结构设计

表结构我设计得很简单,一张主表加一张抓取日志表。主表存公告数据,日志表记录每次抓取的结果,方便排查问题。

CREATE TABLE IF NOT EXISTS bid_info ( id TEXT PRIMARY KEY, -- 详情页链接的MD5 title TEXT NOT NULL, category TEXT, -- 招标/中标/变更 publish_date TEXT, region TEXT, tenderee TEXT, -- 招标单位 budget REAL, -- 预算,单位元 deadline TEXT, -- 投标截止时间 detail_url TEXT, source_site TEXT, crawl_time TEXT, keyword_hit INTEGER DEFAULT 0 -- 是否命中关键词 ); CREATE TABLE IF NOT EXISTS crawl_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, start_time TEXT, end_time TEXT, total_count INTEGER, new_count INTEGER, status TEXT, remark TEXT );

id用详情页链接的MD5做主键,天然去重,插入时用INSERT OR IGNORE,重复的直接跳过,不用先查再插,效率高很多。keyword_hit字段是给推送用的,抓取时先算好,推送时直接查这个字段为1的记录,省得每次重新匹配。

3.3 抓取主流程的实现

主流程就是“取列表、翻页、进详情、清洗、入库”这个循环。我把核心逻辑写成一个函数,每个站点调用一次。下面是我实际在用的简化版代码,去掉了站点特有的配置部分:

import time import random import hashlib from urllib.parse import urljoin, urlparse, urlunparse def crawl_site(site_config, session): base_url = site_config["base_url"] list_url = site_config["list_url"] max_pages = site_config.get("max_pages", 50) new_count = 0 total_count = 0 for page in range(1, max_pages + 1): page_url = build_page_url(list_url, page, site_config) resp = session.get(page_url, headers=build_headers(), timeout=15) if resp.status_code != 200: break items = parse_list(resp.text, site_config) if not items: break for item in items: total_count += 1 detail_url = urljoin(base_url, item["url"]) normalized = normalize_url(detail_url) record_id = hashlib.md5(normalized.encode()).hexdigest() if db.exists(record_id): continue time.sleep(random.uniform(1, 3)) detail_resp = session.get( detail_url, headers=build_headers(referer=page_url), timeout=15 ) detail = parse_detail(detail_resp.text, site_config) detail = clean_record(detail) detail["id"] = record_id detail["detail_url"] = detail_url detail["source_site"] = site_config["name"] detail["crawl_time"] = now_str() detail["keyword_hit"] = check_keywords(detail["title"]) db.insert(detail) new_count += 1 time.sleep(random.uniform(2, 5)) return total_count, new_count

这段代码里有几个细节值得说。normalize_url负责去掉跟踪参数,db.exists先查一次避免无谓的详情页请求,check_keywords在入库时就标记好关键词命中。整个流程是串行的,因为招标网站对并发很敏感,我实测并发超过3就容易触发风控,所以老老实实串行加随机延时最稳。

3.4 定时调度与关键词推送

调度用APScheduler,配置成每天早上7点和下午2点各跑一次,这两个时间点是招标公告发布的高峰。配置大概长这样:

from apscheduler.schedulers.blocking import BlockingScheduler scheduler = BlockingScheduler(timezone="Asia/Shanghai") scheduler.add_job(run_all_sites, "cron", hour=7, minute=0) scheduler.add_job(run_all_sites, "cron", hour=14, minute=0) scheduler.start()

关键词推送我做得比较简单:抓取完成后,查keyword_hit=1crawl_time是本次的记录,拼成一条消息推给自己。关键词列表放在配置文件里,支持“必须包含”和“排除”两组词。比如我关注“数据中心”相关的,但不想看“数据中心机房装修”这种,就设必须包含“数据中心”,排除“装修”。这个逻辑用简单的字符串匹配就够了,不用上正则,维护起来更直观。

4. 常见问题与排查技巧实录

4.1 抓不到数据怎么办

这是最高频的问题,我按排查顺序列一下。第一步看状态码,如果是403,基本是请求头或者IP被识别了,换UA、加Referer、降频率。如果是200但内容为空,第二步看返回的HTML,很可能页面是JS动态渲染的,Requests拿到的是空壳。判断方法很简单:把返回的HTML存下来用浏览器打开,如果浏览器里能看到内容而文件里没有,那就是动态渲染,得换Playwright。第三步看选择器,站点改版是家常便饭,用浏览器的开发者工具重新定位一下元素,更新配置。

还有一种隐蔽的情况:状态码200,HTML也有内容,但解析出来是空的。这通常是编码问题。有些站点声明的是GBK但实际是UTF-8,或者反过来。我的处理方式是先按响应头里的编码解,解出来有乱码就试GBK和UTF-8,取乱码最少的那个。Requests的resp.apparent_encoding能自动猜,但准确率一般,我一般手动兜底。

4.2 被限制访问的应对思路

频率降下来是第一原则。我见过有人为了快,把间隔设成0.1秒,结果IP被封了一天。除了降频,还可以分散请求时间,比如把一次抓取拆成多个时间段,每次抓一部分。另外,善用缓存:列表页和详情页的内容短期内不会变,可以本地缓存一份,重复请求时直接读缓存,既减轻对方服务器压力,也降低自己被限制的风险。

如果确实需要抓的量很大,可以考虑多来源分散,也就是同一个关键词从多个不同的招标网站抓,而不是死磕一个站点。这样单站点的请求量就降下来了,整体覆盖率反而更高。这个思路我觉得比研究怎么绕过限制更健康,也更可持续。

4.3 数据质量问题的排查

数据质量问题主要有三类:重复、缺失、错误。重复用主键去重基本能解决,但要注意URL规范化是否彻底,我建议把规范化后的URL也存一份,方便人工核对。缺失字段要看是站点本身没披露还是解析失败,前者无解,后者要补选择器。错误数据最麻烦,比如预算金额解析成了错误的数字,这种只能靠抽样人工核对。

我养成了一个习惯:每次抓取后随机抽10条,人工看一眼字段对不对。这个动作花不了几分钟,但能及早发现解析逻辑的问题。另外,我会定期统计各字段的空值率,如果某个字段空值率突然飙升,基本就是站点改版了,得赶紧去更新配置。

4.4 常见问题速查表

问题现象可能原因排查方向解决方式
状态码403请求头被识别检查UA和Referer换UA池、加Referer、降频率
状态码200但内容空JS动态渲染对比浏览器和返回HTML换Playwright或Selenium
解析结果为空选择器失效开发者工具重新定位更新站点配置
中文乱码编码不一致检查响应头编码手动试GBK/UTF-8
数据重复URL未规范化检查跟踪参数完善normalize_url
字段缺失站点未披露或解析失败看原始HTML补选择器或留空
抓取中断网络超时或异常看日志加try-except和重试
关键词漏推匹配逻辑问题检查关键词配置调整包含/排除规则

4.5 几个我踩过的坑

第一个坑是详情页请求没有加延时。我一开始只在列表页之间加了延时,详情页是连续请求的,结果抓了不到50条就被限制了。后来详情页也加上1到3秒随机延时,就再没出过问题。这个教训告诉我,任何请求都要有节奏,不能因为是详情页就放松。

第二个坑是用标题做去重键。前面提过,不同地区同名公告很常见,我因此漏掉过真实的新公告。后来改成URL的MD5,问题解决。如果你的目标站点URL不稳定,那就用“标题+发布时间+地区”的组合哈希,比单用标题靠谱得多。

第三个坑是忽略了时区问题。有些站点的发布时间是UTC,有些是本地时间,混在一起排序就乱了。我的做法是统一转成北京时间再入库,转换逻辑写在清洗模块里。这个坑不踩一次很难想到,但一旦数据量大了,时区混乱会让人非常头疼。

第四个坑是没有做异常重试。网络抖动是常态,一次请求失败不代表站点有问题。我后来给每个请求加了最多3次重试,重试间隔递增,成功率明显提升。但要注意,重试不能无脑加,如果是403这种明确的拒绝,重试只会加重限制,得先解决请求头问题。

5. 工具扩展与长期维护建议

5.1 从单站点到多站点的扩展

工具跑通单站点后,扩展多站点是自然而然的事。我的做法是把站点差异全部抽到配置文件里,主程序只认配置不认站点。新增一个站点,就是新增一个配置文件,然后跑一遍验证。配置里要写清楚:站点名称、域名、列表页URL模板、翻页方式、列表条目选择器、各字段选择器或正则、编码方式、请求间隔。这样扩展成本很低,维护也集中。

多站点带来的新问题是字段口径不一致。比如A站点的“发布时间”是公告发布日,B站点的是报名截止日,直接混在一起统计就错了。我的处理方式是在配置里标注每个字段的实际含义,清洗时统一映射到标准字段,含义对不上的就留空,绝不硬凑。数据准确性比字段完整度重要得多。

5.2 数据应用与二次开发

数据抓回来只是第一步,怎么用起来才是价值所在。我目前做了三件事:关键词推送、地区统计、预算趋势分析。关键词推送前面说了,地区统计就是按地区分组看公告数量,能看出哪些地方项目多。预算趋势分析是把预算金额按月汇总,看整体走势。这些分析用SQL就能做,不需要额外的BI工具。

如果你想做二次开发,我建议从数据导出入手,把数据导成Excel或者CSV,方便给不写代码的同事用。再进一步可以做简单的Web界面,用Flask或者FastAPI搭一个查询页,支持按关键词、地区、时间范围筛选。这个工作量不大,但实用性提升明显。我自己就搭了一个,同事直接浏览器打开就能查,省得我每次帮人导数据。

5.3 合规与可持续性

最后说一个容易被忽略但很重要的点:抓取行为要克制。控制频率、尊重站点的robots规则、不抓取非公开数据、不对外传播原始数据,这些都是基本的底线。工具是给自己用的,不是用来做数据倒卖的。我见过有人抓了一堆数据拿去卖,结果惹上麻烦,得不偿失。

从可持续性角度看,定期维护配置是必须的。站点改版不会提前通知你,只能靠定期检查。我的做法是每周花十分钟看一眼抓取日志,如果某个站点的抓取量突然归零或者骤降,就去看看是不是改版了。这个习惯坚持下来,工具就能长期稳定运行。说到底,爬虫工具不是一劳永逸的东西,它更像一个需要定期照看的盆栽,你用心维护,它就持续给你产出价值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 0:25:35

AD18模块Copy本质:ROOM与网络表协同迁移

1. AD18 PCB模块Copy操作的本质:不是复制粘贴,而是设计意图的精准迁移“AD18 PCB模块的Copy”这个标题乍看像一句普通指令,但放在实际工程场景里,它背后藏着一个高频却极易被误解的操作陷阱——很多人以为在Altium Designer 18&am…

作者头像 李华
网站建设 2026/9/19 0:24:30

一文讲透|一键生成论文工具测评:2026年最新推荐与对比

2026年真正好用的一键生成论文工具,核心看生成的论文质量、低AI味、格式正确、学术适配四大指标。综合实测,千笔AI、ThouPen、豆包、DeepSeek、Grammarly 是当前最值得推荐的梯队,覆盖从免费到付费、从中文到英文、从文科到理工的全场景需求。…

作者头像 李华
网站建设 2026/9/19 0:23:41

Nginx离线安装完全指南:依赖准备、编译配置与内网部署实战

1. 为什么要做离线安装,和在线安装差在哪先说结论:离线安装Nginx这件事,往往不是“想不想”的问题,而是“只能这么干”的问题。我在一线运维这几年,真正动手做离线安装的场景基本都是同一类:服务器在内网隔…

作者头像 李华
网站建设 2026/9/19 0:15:32

DCE容器云平台实战:从K8s多集群管理到业务部署与运维

简介:DCE容器云平台介绍PPT是一份聚焦企业级应用云平台的技术方案资料,面向技术决策者、架构师、运维及开发人员,系统讲解DaoCloud Enterprise(DCE)的核心价值与落地路径。资源包内仅含1个PPT文件,大小5.9M…

作者头像 李华
网站建设 2026/9/19 0:14:35

数据库范式例题实战:从1NF到BCNF的判断与分解

去年帮一个学弟复习数据库期末考试,他抱着范式那章的习题册愁眉苦脸,说定义背得滚瓜烂熟,一拿到新表还是不知道从哪下手。我问他拿到题目第一步干什么,他说“看它属于第几范式”。问题就出在这——数据库范式例题的正确打开方式&a…

作者头像 李华