简介这是一份面向网络安全初学者与Python爬虫爱好者的实战项目源码包围绕CNVD、CNNVD、CVE三大权威漏洞库的数据采集与管理展开帮助读者理解爬虫、数据库与GUI整合开发的完整链路。包内共72个文件以py脚本、toc与pyc编译文件、csv与xlsx数据表、exe可执行程序及xml配置为主涵盖爬虫逻辑、数据存储、界面展示与打包发布等模块压缩包约137.95MB。项目通过requests与解析库抓取漏洞名称、编号、描述及影响程度等信息借助MySQL完成增删查改并用GUI实现漏洞预览、增量爬取与完整爬取同时涉及延时、代理等可持续运行策略。目前已有1584人学习下载适合想系统练习爬虫工程化、数据库操作与桌面工具开发的读者参考借鉴。1. 漏洞库爬取这件事难点从来不在 requests很多人第一次听到「python爬取漏洞库.zip」这个标题脑子里浮现的画面是写个 requests.get套个 BeautifulSoup循环翻页存成 CSV收工。真上手才发现翻车点根本不在语法层面——漏洞库站点普遍有反爬策略、分页参数藏在 POST 表单里、详情页字段结构随版本漂移、时间字段格式五花八门还有最要命的你爬下来的数据到底能不能用、字段对不对得上、增量更新会不会重复。这个标题真正指向的是一套「稳定、可增量、字段可对齐」的漏洞情报采集流水线而不是一段一次性脚本。它适合安全运营、漏洞管理、资产风险排查方向的从业者也适合想拿真实数据练爬虫工程化的开发者。下面按我实际做过的路径从选型到落地到踩坑一层层拆开讲。2. 先想清楚爬什么、怎么存漏洞库采集的选型与数据模型2.1 漏洞库的三种典型结构决定了你的爬取策略不同漏洞库的页面组织方式差异很大直接决定你是用列表页详情页两级抓取还是单页全量解析。常见做法是先把目标站点归成三类第一类是「列表页带摘要 详情页带完整字段」比如多数厂商自己的漏洞公告页。列表页通常只有标题、编号、发布时间详情页才有影响版本、修复建议、CVSS 评分。这类必须两级抓取列表页负责发现新条目详情页负责补全字段。第二类是「单页表格全量展示」一页里就是一张大表字段齐全但分页靠查询参数。这类可以只抓列表但要注意分页参数是 GET 还是 POST很多站点用 POST 提交查询条件直接拼 URL 拿不到第二页。第三类是「接口返回 JSON」页面只是前端渲染的外壳。这类最省事但也最容易失效——接口路径和参数会随前端改版变动需要定期校验。我一般会先打开浏览器开发者工具看 Network 面板里翻页时到底发了什么请求。如果是 XHR 返回 JSON优先走接口如果是整页 HTML再考虑解析 DOM。这个判断花十分钟能省后面几小时的调试。2.2 字段模型先定死别等爬完再补很多人爬到一半发现字段不够用回头重爬。正确顺序是先定数据模型再写爬虫。一个能落地的漏洞记录至少包含这些字段字段名类型说明vuln_idstr漏洞编号唯一键titlestr漏洞标题severitystr危险等级统一成 high/medium/lowcvss_scorefloat评分没有就留空publish_datedate发布时间统一格式affectedtext影响范围描述fix_suggestiontext修复建议source_urlstr详情页地址crawl_timedatetime采集时间用于增量判断字段定好后存储用 SQLite 就够起步量大了再换 PostgreSQL。关键是 vuln_id 要建唯一索引配合 INSERT OR IGNORE 或 ON CONFLICT DO UPDATE 实现幂等写入这样重复爬同一页不会产生脏数据。import sqlite3 def init_db(pathvulns.db): conn sqlite3.connect(path) conn.execute( CREATE TABLE IF NOT EXISTS vulns ( vuln_id TEXT PRIMARY KEY, title TEXT, severity TEXT, cvss_score REAL, publish_date TEXT, affected TEXT, fix_suggestion TEXT, source_url TEXT, crawl_time TEXT ) ) conn.commit() return conn这段建表逻辑里vuln_id 作为主键是整套增量方案的地基。severity 用文本而不是数字是为了后续做规则匹配时更直观publish_date 存成 ISO 格式字符串方便排序和比较。crawl_time 每次写入都更新用来判断某条记录是不是最近才被重新采集过。2.3 请求层会话复用、限速与重试三件套请求层是最容易被忽视、也最容易导致封禁的地方。我的固定做法是用 requests.Session 复用连接加固定间隔的限速配指数退避重试。import requests import time from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(): session requests.Session() retry Retry( total3, backoff_factor1.5, status_forcelist[429, 500, 502, 503, 504], allowed_methods[GET, POST] ) adapter HTTPAdapter(max_retriesretry) session.mount(http://, adapter) session.mount(https://, adapter) session.headers.update({ User-Agent: Mozilla/5.0 (compatible; VulnCollector/1.0), Accept: text/html,application/json }) return session def fetch(session, url, delay1.2): time.sleep(delay) resp session.get(url, timeout15) resp.raise_for_status() return respRetry 的 backoff_factor 设 1.5意味着第一次重试等 1.5 秒第二次 3 秒第三次 4.5 秒给服务端留出恢复窗口。delay 参数是硬限速我一般设 1 到 2 秒具体看目标站点的响应速度。timeout 必须设否则遇到挂起的连接会一直卡住。User-Agent 里带上一个可识别的标识比伪装成浏览器更稳妥也方便对方在日志里识别你的采集行为。3. 解析与增量把列表页和详情页串成一条流水线3.1 列表页发现新条目分页终止条件怎么定列表页的核心任务是「发现」不是「解析全部字段」。我通常只从列表页拿三样东西详情页链接、漏洞编号、发布时间。分页终止条件不能只看「有没有下一页按钮」因为很多站点最后一页仍然渲染按钮。更可靠的做法是记录本页拿到的编号集合如果和上一页完全重合或者本页条目数为零就停止。def parse_list(html, base_url): from bs4 import BeautifulSoup from urllib.parse import urljoin soup BeautifulSoup(html, html.parser) items [] for row in soup.select(table.vuln-list tr): link row.select_one(a.detail-link) if not link: continue items.append({ vuln_id: row.get(data-id, ).strip(), url: urljoin(base_url, link.get(href, )), publish_date: row.select_one(td.date).get_text(stripTrue) }) return items这里用>def safe_text(node, default): return node.get_text(stripTrue) if node else default def parse_detail(html, url): from bs4 import BeautifulSoup soup BeautifulSoup(html, html.parser) return { title: safe_text(soup.select_one(h1.vuln-title)), severity: safe_text(soup.select_one(span.severity)), cvss_score: parse_score(safe_text(soup.select_one(span.cvss))), affected: safe_text(soup.select_one(div.affected)), fix_suggestion: safe_text(soup.select_one(div.fix)), source_url: url } def parse_score(text): try: return float(text) except (ValueError, TypeError): return Nonesafe_text 统一处理 None 节点parse_score 把无法转换的评分变成 None 而不是抛异常。这样即使某个字段缺失整条记录仍然能入库后续可以按缺失率判断解析规则是否需要更新。3.3 增量写入靠唯一键和采集时间做幂等增量更新的核心逻辑是新记录插入已有记录更新 crawl_time 和可能变化的字段。用 SQLite 的 ON CONFLICT 语法可以一条语句搞定。from datetime import datetime def upsert_vuln(conn, record): record[crawl_time] datetime.now().isoformat() conn.execute( INSERT INTO vulns (vuln_id, title, severity, cvss_score, publish_date, affected, fix_suggestion, source_url, crawl_time) VALUES (:vuln_id, :title, :severity, :cvss_score, :publish_date, :affected, :fix_suggestion, :source_url, :crawl_time) ON CONFLICT(vuln_id) DO UPDATE SET titleexcluded.title, severityexcluded.severity, cvss_scoreexcluded.cvss_score, affectedexcluded.affected, fix_suggestionexcluded.fix_suggestion, crawl_timeexcluded.crawl_time , record) conn.commit()excluded 是 SQLite 里代表「本次想插入的值」的关键字。这样写的好处是如果 vuln_id 已存在就更新字段和采集时间如果不存在就插入新行。整个流程不需要先查询再判断减少一次数据库往返。publish_date 故意不更新因为发布时间是固定属性不该被覆盖。3.4 主流程串起来从入口到入库的完整调用把上面几块拼起来主流程就是一个循环翻列表页、解析条目、对每条去详情页、解析字段、写入数据库。def run(base_url, list_url, max_pages50): conn init_db() session build_session() seen set() for page in range(1, max_pages 1): url f{list_url}?page{page} html fetch(session, url).text items parse_list(html, base_url) if not items: break ids {it[vuln_id] for it in items} if ids.issubset(seen): break seen.update(ids) for it in items: detail_html fetch(session, it[url]).text record parse_detail(detail_html, it[url]) record[vuln_id] it[vuln_id] record[publish_date] it[publish_date] upsert_vuln(conn, record) conn.close()max_pages 是安全上限防止分页逻辑异常导致无限循环。ids.issubset(seen) 判断本页编号是否全部出现过是比「有没有下一页按钮」更可靠的中止条件。每条详情页之间靠 fetch 里的 delay 控制节奏不会瞬间打满对方服务。4. 避坑与排查那些让我重爬过的真实问题4.1 现象翻到第二页返回的还是第一页数据原因分页参数名不对或者站点用 POST 提交查询条件GET 拼 page 参数被忽略。解决打开开发者工具确认翻页请求的真实参数名和提交方式如果是 POST把查询条件放进 data 字典而不是 URL。4.2 现象详情页解析出来全是空字符串原因站点改版后 DOM 结构变了原来的选择器匹配不到节点。解决在 parse_detail 里加字段缺失率统计当某个字段连续多页为空时打印告警人工核对选择器。不要等到入库后才发现整批数据是空的。4.3 现象同一漏洞在库里出现多条记录原因vuln_id 提取逻辑不稳定比如从标题正则提取时标题里带了空格或特殊字符导致编号不一致。解决优先用页面上的>SELECTORS { list_row: table.vuln-list tr, list_link: a.detail-link, detail_title: h1.vuln-title, detail_severity: span.severity, detail_cvss: span.cvss, detail_affected: div.affected, detail_fix: div.fix }这样做的代价是多一层间接但收益是维护成本大幅下降。我吃过一次亏某站点把详情页标题从 h1 改成 h2因为选择器写死在代码里我花了半小时才定位到问题。改成配置后同样的改动只需要改一行字典。验证方法上我一般会抽 10 条记录手工打开详情页逐字段核对。重点核对三类字段编号是否唯一、评分是否解析正确、修复建议是否完整。如果这三类都对得上整批数据的可信度就比较高。核对脚本可以写成对比表左边是数据库里的值右边是手工填的期望值差异一目了然。最后一个技巧是关于增量范围的不要每次都全量翻所有页。记录上次采集到的最大发布时间下次只翻到发布时间早于该值的页就停。这样日常增量可能只需要翻一两页大幅降低对目标站点的压力也降低自己被封的概率。这个优化在数据量上来之后收益非常明显我一般会在跑通全量之后立刻加上。希望帮到你。本文还有配套的精品资源点击获取