资讯详情

Python实战:咸阳工商招标信息定向采集与增量抓取方案

📅 2026/10/16 4:18:20 | 华诺云谱 👁 阅读
Python实战:咸阳工商招标信息定向采集与增量抓取方案
1. 项目缘起与整体设计思路做招投标信息聚合这件事最早是因为帮一个做工程配套的朋友盯当地工商类招标公告。他每天要手动翻好几个信息发布渠道一条一条看费时费力还容易漏。后来我干脆帮他写了一套抓取方案把公开的招标信息自动归集到本地按关键词过滤后推送到他的工作台。这套方案的核心目标很明确把分散在多个公开页面上的招标公告稳定、结构化地采集下来并做初步的字段清洗和去重。这里说的“咸阳工商招标信息抓取”本质是一个垂直领域的定向数据采集项目。它要解决的不是“爬全网”而是“盯住一个特定区域、特定类型的公开信息源持续拿到增量数据”。适合谁来参考一是需要做本地商机监控的小团队或个人二是想学数据采集但不知道拿什么练手的新手——因为招标公告这类页面结构相对规整字段明确非常适合作为采集入门的实战对象。整体设计上我没有一上来就堆框架而是先想清楚三件事数据从哪来、要采哪些字段、采完怎么存。数据源方面公开的招标信息通常发布在各类公共资源交易或政务公开页面上页面形式无非两种列表页加详情页或者列表页直接带摘要。字段方面核心就那么几个公告标题、发布日期、招标单位、项目编号、预算金额、公告正文、原文链接。存储方面初期用SQLite就够了单机跑、零配置等数据量上来了再换MySQL或PostgreSQL也不迟。为什么这么设计因为采集项目最容易死在“过度设计”上。很多人一上来就上分布式、上消息队列、上各种中间件结果连一个列表页的分页逻辑都没跑通。我的原则是先用最小可行方案跑通全链路再按瓶颈逐步加码。这套思路在多个类似项目里反复验证过稳。提示采集公开信息时务必控制请求频率遵守目标站点的robots协议与相关服务条款只采集公开可见的数据不涉及任何非公开或需授权的内容。2. 核心细节解析与实操要点2.1 目标页面结构分析动手写代码之前先花二十分钟把目标页面“看透”。打开一个招标公告列表页按F12看网络请求重点确认几件事列表是服务端渲染还是前端异步加载分页是URL参数控制还是POST请求详情页的正文是静态HTML还是接口返回的JSON我遇到的情况是列表页通过URL参数翻页形如?page2详情页是标准HTML。这种结构最省事直接用请求库加解析库就能搞定。如果是前端异步加载就得去抓XHR接口找到真正的数据接口地址往往返回的是干净的JSON反而更好处理。判断方法很简单在浏览器里禁用JavaScript刷新页面。如果列表还能出来说明是服务端渲染如果空白那就是异步加载。这一步决定了你后面是解析HTML还是解析JSON方向错了后面全白搭。2.2 字段提取的定位策略字段提取最怕的是“写死”。今天用绝对路径XPath能取到明天页面改个class名就全废。我的做法是优先用语义化的选择器配合多重兜底。比如标题先尝试h1或带title字样的class取不到再退到页面title标签做截取。发布日期用正则去匹配\d{4}-\d{2}-\d{2}这类模式比依赖具体标签稳得多。预算金额这块要特别注意页面上可能写成“预算金额120万元”“采购预算 1200000元”“最高限价 壹佰贰拾万元”等多种形式。我的处理是先正则提取数字和单位再统一换算成“元”为单位的数值存储。中文大写金额的情况比较少见但确实存在遇到就单独写个转换函数别硬塞进主流程。字段优先定位方式兜底方案注意事项公告标题h1标签文本title标签截取去除首尾空白和多余换行发布日期正则匹配日期模式列表页摘要区提取统一转为YYYY-MM-DD格式招标单位含“招标人/采购人”关键词的节点正文首段匹配可能带“公司”“单位”后缀预算金额正则提取数字单位正文关键词定位注意万元与元的换算原文链接详情页a标签href拼接域名处理相对路径转绝对路径2.3 请求头与访问节奏控制请求头里最关键的是User-Agent别用默认的。默认UA一眼就能被识别为脚本很多站点会直接返回空内容或验证页。我一般设置一个常见浏览器的UA再带上Accept和Accept-Language让请求看起来正常一些。访问节奏是另一个重点。我的经验是单站点并发不超过2请求间隔随机在1到3秒之间。为什么加随机固定间隔的请求模式太规律容易被识别。随机间隔模拟人工浏览的节奏温和很多。另外遇到403或429状态码不要硬刚退避重试间隔翻倍最多重试三次就放弃这条记录日志后续人工看。import requests import time import random HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, } def fetch(url, retry3): for i in range(retry): try: resp requests.get(url, headersHEADERS, timeout15) if resp.status_code 200: return resp.text elif resp.status_code in (403, 429): time.sleep((i 1) * 5) except requests.RequestException: time.sleep((i 1) * 3) return None这段代码看着简单但里面的重试和退避逻辑是踩过坑才加上的。早期没做退避遇到限流直接失败数据就断了。3. 实操过程与核心环节实现3.1 环境准备与依赖安装环境这块我不喜欢搞太复杂。Python 3.8以上装三个库就够起步requests负责请求beautifulsoup4加lxml负责解析sqlite3是标准库自带不用装。如果后面要处理异步加载的页面再加playwright或selenium但初期用不上。pip install requests beautifulsoup4 lxml数据库建表语句也简单一张主表存公告字段包括自增ID、标题、发布日期、招标单位、预算金额、正文、原文链接、采集时间。给原文链接加唯一索引这是去重的关键。CREATE TABLE IF NOT EXISTS bids ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, publish_date TEXT, tenderee TEXT, budget REAL, content TEXT, source_url TEXT UNIQUE, created_at TEXT DEFAULT CURRENT_TIMESTAMP );source_url加UNIQUE约束插入时用INSERT OR IGNORE重复的自动跳过不用自己写去重逻辑省事又可靠。3.2 列表页翻页与链接收集翻页逻辑要先确认最大页数怎么获取。有的站点在页面底部显示总页数有的显示总条数有的啥都不显示只能靠“下一页”按钮是否存在来判断。我一般先请求第一页解析出总页数或总条数算出需要翻多少页然后循环请求。from bs4 import BeautifulSoup def parse_list(html): soup BeautifulSoup(html, lxml) links [] for a in soup.select(a[href*detail]): href a.get(href) if href: links.append(href) return links选择器a[href*detail]是个偷懒但有效的写法因为详情页链接通常带detail字样。如果站点结构不同换成对应的class选择器即可。收集到的链接可能是相对路径需要和域名拼接成绝对路径。翻页时有个细节先请求第一页拿到总页数再决定循环范围不要盲目一直翻到没有数据为止。后者容易在异常情况下陷入死循环也浪费请求。3.3 详情页解析与字段清洗详情页解析是重头戏。我的流程是拿到HTML后先提取正文区域再在正文区域内做字段匹配。正文区域通常有明显的容器比如div classcontent或article标签。定位到正文后标题取h1日期用正则招标单位找含“招标人”或“采购人”的文本节点。import re def parse_detail(html, url): soup BeautifulSoup(html, lxml) title_tag soup.find(h1) title title_tag.get_text(stripTrue) if title_tag else text soup.get_text(\n, stripTrue) date_match re.search(r(\d{4})[-年/](\d{1,2})[-月/](\d{1,2}), text) publish_date if date_match: y, m, d date_match.groups() publish_date f{y}-{int(m):02d}-{int(d):02d} budget_match re.search(r预算[金额]*[:]\s*([\d,.])\s*(万元|元), text) budget None if budget_match: num float(budget_match.group(1).replace(,, )) budget num * 10000 if budget_match.group(2) 万元 else num return { title: title, publish_date: publish_date, budget: budget, content: text, source_url: url, }日期正则里我用了[-年/]来兼容多种分隔符实际跑下来能覆盖绝大多数情况。预算金额的换算逻辑也在这里处理统一存成元。3.4 数据入库与增量采集入库用INSERT OR IGNORE配合唯一索引天然去重。每次采集完记录本次新增条数方便判断是否有新公告。import sqlite3 def save(conn, item): sql INSERT OR IGNORE INTO bids (title, publish_date, tenderee, budget, content, source_url) VALUES (?, ?, ?, ?, ?, ?) conn.execute(sql, ( item[title], item[publish_date], item.get(tenderee), item[budget], item[content], item[source_url] )) conn.commit()增量采集的关键是只翻前几页。因为公告是按时间倒序排列的新公告都在最前面。我一般只翻前3到5页遇到已存在的链接就停止翻页。这样每次运行只请求少量页面效率高对目标站点也友好。def crawl_incremental(conn, base_url, max_pages5): for page in range(1, max_pages 1): url f{base_url}?page{page} html fetch(url) if not html: break links parse_list(html) new_count 0 for link in links: full_url urljoin(base_url, link) if exists(conn, full_url): continue detail_html fetch(full_url) if detail_html: item parse_detail(detail_html, full_url) save(conn, item) new_count 1 time.sleep(random.uniform(1, 3)) if new_count 0: break这段逻辑里new_count 0就跳出循环意味着这一页全是已采集过的后面的页更老不用再翻了。实测下来这个策略能省掉大量无效请求。4. 常见问题与排查技巧实录4.1 采集到的内容为空或乱码这是最常见的问题原因通常有三个。一是编码没处理对requests的resp.text有时会猜错编码导致中文乱码。解决办法是手动指定编码或者用resp.content.decode(utf-8)。二是页面是异步加载的直接请求拿到的HTML里没有数据需要去抓XHR接口。三是被识别为脚本返回了验证页或空内容这时候要检查请求头和访问频率。排查顺序建议先打印resp.status_code和resp.text[:500]看返回的到底是什么。如果状态码200但内容不对多半是编码或异步问题如果状态码403或429就是被限流了。4.2 字段提取时有时无字段提取不稳定八成是选择器写得太死。比如用div ul li:nth-child(3) span这种绝对路径页面结构稍微一变就失效。我的经验是用属性选择器和文本关键词做定位比如找包含“招标人”字样的节点比找第几个div靠谱得多。另一个原因是页面本身格式不统一。同一个站点的不同公告模板可能略有差异。这时候要做的是收集多个样本页面找出共性写兼容性更强的正则或选择器而不是针对单个页面调。问题现象可能原因排查方法解决思路内容为空异步加载禁用JS刷新看是否空白抓XHR接口中文乱码编码识别错误打印resp.encoding手动指定utf-8403/429被限流查看状态码降频、加退避、换UA字段缺失选择器太死对比多个页面结构改用关键词定位重复数据去重失效检查唯一索引确认source_url唯一4.3 翻页翻到一半断了翻页中断通常是网络波动或目标站点临时不可用。我的处理是记录当前页码下次从断点继续。具体做法是在数据库里存一个采集状态表记录每个数据源最后成功采集的页码或最后一条公告的链接。下次启动时先读状态从断点开始。def get_last_page(conn, source): row conn.execute( SELECT last_page FROM crawl_state WHERE source ?, (source,) ).fetchone() return row[0] if row else 1这个状态表看着不起眼但在长期运行的项目里非常关键。没有它每次中断都得从头翻既慢又容易触发限流。4.4 实操避坑心得第一条心得先手动跑通一条完整链路再考虑批量。我见过太多人一上来就写循环结果一个字段解析错了几百条数据全废。正确做法是拿一条公告从请求到入库完整走一遍确认每个字段都对再放开批量。第二条日志要记全。每次请求的URL、状态码、耗时、解析结果条数都写进日志文件。出问题的时候日志是唯一的线索。我一般用Python的logging模块按天切分日志文件。第三条别追求一次写完。采集项目是迭代出来的先跑通核心字段再逐步加字段、加数据源、加清洗规则。想一次设计完美往往什么都做不出来。第四条定期检查目标页面结构。站点改版是常态我的做法是每周手动跑一次看新增数据是否正常。如果连续几天新增为0大概率是页面结构变了需要更新解析逻辑。5. 数据清洗与后续扩展方向5.1 正文清洗与关键信息提取原始正文里往往夹杂大量无关内容比如导航栏文字、页脚、广告位。清洗的目标是只保留公告主体内容。我的做法是先定位正文容器再剔除其中的script、style、以及含“上一篇”“下一篇”“相关推荐”等关键词的节点。def clean_content(soup): for tag in soup([script, style, nav, footer]): tag.decompose() for kw in [上一篇, 下一篇, 相关推荐, 友情链接]: for node in soup.find_all(stringre.compile(kw)): parent node.find_parent() if parent: parent.decompose() return soup.get_text(\n, stripTrue)清洗完之后还可以进一步提取结构化信息比如报名截止时间、开标时间、联系人等。这些字段用正则从正文里捞捞不到就留空不影响主流程。5.2 关键词过滤与商机推送采集只是第一步真正的价值在于过滤出跟自己相关的公告。我朋友做工程配套关注的关键词是“管网”“道路”“绿化”这类。做法很简单在入库后加一个过滤查询标题或正文命中关键词的标记为“重点关注”然后通过邮件或即时通讯工具推送给他。KEYWORDS [管网, 道路, 绿化, 照明] def mark_important(conn): for kw in KEYWORDS: conn.execute( UPDATE bids SET important 1 WHERE title LIKE ? OR content LIKE ?, (f%{kw}%, f%{kw}%) ) conn.commit()这个过滤逻辑可以做得更精细比如按预算金额区间过滤、按发布日期过滤。但核心思路就是在采集层之上加一层业务过滤让数据真正为人所用。5.3 定时任务与长期运行长期运行靠的是定时任务。Linux下用crontabWindows下用任务计划程序。我一般设置每小时跑一次增量采集每次只翻前几页几分钟就跑完。这样既能及时拿到新公告又不会给目标站点造成压力。0 * * * * /usr/bin/python3 /path/to/crawler.py /path/to/crawler.log 21日志重定向到文件方便事后排查。如果发现某次运行异常日志里会有记录。另外建议加一个简单的监控比如连续三次运行新增为0就发个提醒人工去看看是不是页面结构变了。5.4 后续可扩展的方向这套方案跑通之后扩展方向其实很多。一是多数据源聚合把周边区域的招标信息也纳入进来统一存储、统一过滤。二是数据可视化用简单的图表展示公告数量趋势、预算分布帮决策者快速把握行情。三是去重优化目前只按URL去重同一公告在不同站点发布的情况还没处理可以加标题相似度比对来做跨站去重。我个人在实际操作中的体会是采集项目的难点从来不在代码本身而在于对目标页面的理解深度和长期运行的稳定性维护。代码写一次就完了但页面会变、网络会抖、限流会来这些才是真正消耗精力的地方。所以别怕写得笨先把链路跑通再一点点加固比什么都强。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑