简介:一份基于Scrapy框架的威胁情报抓取与处理系统的毕业设计文档,面向网络安全、Python爬虫及威胁情报方向的学生与研究者。内容围绕如何利用Scrapy爬虫高效采集开源威胁网站与博客数据,并完成解析、入库与展示,涉及知识图谱、APT知识图谱、Flask_admin+nginx、pyecharts等关键技术,覆盖系统分析与设计、爬虫模块、数据解析模块、数据展示模块等章节,适合作为相关课题设计、论文写作或项目开发的参考资料。资源为单个docx文件,大小2.58MB,共1份文档,结构完整,包含中英文摘要、目录及正文内容。已有194人学习下载,适用性较好。
1. 基于Scrapy框架的威胁情报抓取处理系统:从情报源到可查指标
威胁情报的价值本质上是时间的函数。攻击者更换一次 C2 域名、上线一个新的钓鱼页面,留给防御方反应的时间往往按小时计算。靠人工巡看安全公众号、抓包群和厂商公告的传统采集链路,字段格式不统一,落地到检测规则时还要先手工清洗一遍,这段周转本身就消耗了宝贵的处置窗口。基于Scrapy框架的威胁情报抓取以及处理系统,要解决的正是从情报源到可查询数据资产的工程化压缩:让 Scrapy 调度对安全博客、漏洞库、代码托管平台和厂商通告的分批抓取,用 Item Pipeline 完成字段抽取、格式标准化和置信度去重,最终写入可检索存储。这套系统适合安全分析团队和蓝队工程师,他们要的不是一个简单爬虫 demo,而是能把采集、清洗、入库固化为日常作业的完整链路。下文从工程搭建切入,逐步展开反爬应对、字段处理和验证方法。
2. Scrapy框架下威胁情报抓取系统的架构设计与工程搭建
2.1 威胁情报源的类型划分与Scrapy适配性评估
先说选型边界。不是所有威胁情报源都适合用 Scrapy 去抓,有些直接提供 JSON API 或历史下载包,用 requests 脚本反而更省事。决定用 Scrapy 的标准是:信息源本身是 HTML 页面流、有翻页或站内搜索语义、需要做频控和断点续爬。符合这个条件的典型情报源有下面几类:
| 情报源类型 | 典型表现形态 | 抓取方式 | Scrapy适配度 |
|---|---|---|---|
| RSS/Atom 订阅 | 厂商通告、安全博客更新 | XML 字段直接抽取 | 高 |
| 漏洞库 | NVD、CNVD 的列表+详情页 | 分页列表 + 详情页 | 高 |
| 代码托管仓库 | IoC 列表更新、README 变更 | 文件接口或仓库提交 API | 中 |
| 商务情报平台 | 混合渲染页面 | 通用爬虫或动态渲染 | 中 |
| 论坛/社区板块 | 分页帖子 | 通用爬虫 + 登录态 | 中低 |
选用 Scrapy 的价值在这些场景下才成立:列表页与详情页结构稳定、增量以天或小时为单位变化、请求量不大但条目众多时手工维护会失控。尤其是一类常见需求,比如把公众号相关文章的自动搜索监控抓取也纳入情报来源,虽然公众号网页是动态渲染,但通过 Scrapy 配合浏览器驱动,一样能落到同一套 Pipeline 里。
2.2 爬虫工程目录与Spider骨架实现
创建项目属于固定动作,但目录职责最好从第一天就分清楚:
scrapy startproject threat_intel cd threat_intel scrapy genspider -t crawl rss_feed feed.example.comstartproject生成项目骨架,genspider -t crawl用 CrawlSpider 模板创建一个名为 rss_feed 的爬虫。CrawlSpider 自带链接提取规则,适合列表页稳定的序列型情报源,省掉手写 parse 分发。
threat_intel/ ├── items.py # IoC 字段容器 ├── middlewares.py # 请求头切换、cookie 刷新 ├── pipelines.py # 清洗、去重、入库 ├── settings.py # 并发、限速、下载延迟配置 ├── spiders/ │ ├── rss_feed.py # RSS 源适配 │ ├── blog_crawler.py # 安全博客页面抓取 │ └── vuln_feed.py # 漏洞库增量抓取 └── requirements.txt如果把多个来源塞进同一个 Spider,后续改动一处字段就会牵连其他源,反而不利于维护。常见做法是每一类结构相近的情报源独立成 Spider,在 settings 里用SPIDER_LOADER_WARN_ONLY控制加载告警,避免无关源互相影响。
提示:Spider 文件命名建议直接使用情报源的域名或业务名,爬虫日志里按爬虫名过滤时能一眼定位是哪条链路出了问题。
下面这个样例是漏洞库爬虫,跑的是列表页到详情页的两段跳转:
# spiders/vuln_feed.py from urllib.parse import urljoin from datetime import date import scrapy from scrapy.loader import ItemLoader from threat_intel.items import IoCItem class VulnFeedSpider(scrapy.Spider): name = "vuln_feed" allowed_domains = ["nvd.nist.gov"] start_urls = ["https://nvd.nist.gov/vuln/data-feeds"] def parse(self, response): # 只挑列表页里 CVE 开头的条目,过滤掉导航和其他噪声 for row in response.css("table.news-table tr"): title = row.css("a::text").get() href = row.css("a::attr(href)").get() if href and title and "CVE" in title: yield scrapy.Request( urljoin(response.url, href), callback=self.parse_detail, meta={"title": title.strip()}, ) def parse_detail(self, response): # 详情页里摘 CVE 描述,不需要精确到全表 loader = ItemLoader(item=IoCItem(), response=response) loader.add_value("ioc_type", "cve") loader.add_value("ioc_value", response.url.split("/")[-1]) loader.add_css("description", "#vulnDetailTableView::text") loader.add_value("source", response.url) loader.add_value("last_seen", date.today().isoformat()) yield loader.load_item()这段代码刻意的点有两个:一是在 parse 里先用标题过滤再决定是否发详情页请求,能少一半无意义的页面抓取;二是用 ItemLoader 而不是手写字典,为后续 Pipeline 统一处理留了接缝。meta 传入的 title 能在详情页拿不到字段时用作兜底,不至于因为一个字段丢失丢掉整条记录。
2.3 请求调度、指纹去重与增量采集
Scrapy 内置的 RFPDupeFilter 默认按请求 URL 生成指纹,对纯 GET 的情报源够用,但同一个漏洞可能从两个情报源各抓一次,需要在入库前去重。更可靠的做法是建立业务指纹:
# pipelines.py 中自定义业务指纹示例 import hashlib def build_fingerprint(payload): raw = "|".join([ payload.get("ioc_type", ""), payload.get("ioc_value", ""), payload.get("source_url", ""), ]) return hashlib.sha256(raw.encode("utf-8")).hexdigest()指纹把类型、值和来源拼在一起做哈希。简洁设计里可以去掉source_url,让同一指标只保留一条记录;但威胁情报场景中来源信息是重要审计线索,一般会保留。调度侧做增量采集时,可以在 settings 里开启 JOBDIR:
# settings.py DUPEFILTER_CLASS = "scrapy.dupefilters.RFPDupeFilter" JOBDIR = "scheduler_state/vuln_feed" SCHEDULER_DEBUG = FalseJOBDIR 的作用是把未完成的请求队列持久化到磁盘,中断重启后能接着抓,不会重复请求已完成页面。SCHEDULER_DEBUG 平时建议置 False,不然每次调度都会输出大量调试日志,反而干扰对实际采集状态的判断。
3. 威胁情报抓取的中间件定制与反爬应对策略
3.1 请求头伪装与Cookie生命周期刷新
威胁情报源大多有基础的访问频率限制,少数还会校验 User-Agent 特征。抓取端需要做的是让采集机的请求看起来像普通浏览器行为,而不是一个固定不变的 curl 头。常见做法是维护一个 UA 池,在中间件中随机挑选并注入请求头:
# middlewares.py import random USER_AGENT_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0 Safari/537.36", ] class RandomUAProcessMiddleware: """在下载前为每个请求注入随机的 UA""" def process_request(self, request, spider): request.headers["User-Agent"] = random.choice(USER_AGENT_POOL) return None中间件在两个方向上有用:process_request在请求发出前改头,process_response在拿到响应后检查状态码。如果发现 403 或者页面要求登录,可以在process_response里直接丢弃响应并返回一个新请求,避免污染后续解析。
Cookie 过期是情报源抓取里最常见的隐性失效原因。首次登录后把 cookie 写入文件,Spider 开始时加载,发现过期时重新触发一次登录请求,再继续原有队列。需要注意,把登录和正文抓取放在同一个 Spider 里会让重试状态复杂,建议单独用一个小脚本维护登录态,正文 Spider 只负责消费。
3.2 动态渲染情报源的 Playwright 集成
部分情报平台是前后端分离,列表数据在 iframe 里异步加载,直接抓 HTML 只能拿到空壳。这类场景用 scrapy-playwright 扩展接管下载器是一个可靠方案。配置如下:
# settings.py DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" PLAYWRIGHT_BROWSER_TYPE = "chromium" PLAYWRIGHT_LAUNCH_OPTIONS = {"headless": True, "args": ["--disable-gpu"]}然后在请求里通过 meta 告诉下载器用 Playwright 渲染:
# spiders/dynamic_source.py async def parse_iframe(self, response): page = response.meta["playwright_page"] await page.wait_for_selector("table.ioc-list") html = await page.content() # 拿到渲染完成的 HTML 后再走解析 ...meta 里的 playwright 开关是精确到单请求的,不会让整个 Spider 所有请求都挂浏览器进程,对性能影响可控。playwright_include_page负责把浏览器页面句柄传给回调,让异步逻辑能在页面上下文里执行。注意,混用异步和同步解析时,用 Twisted 的 asyncio 反应器是必须的,否则事件循环无法驱动浏览器。
3.3 限速与重试参数场景化调优
Scrapy 默认参数是通用爬虫取向,对威胁情报采集机偏激进。情报源大多请求量不大,但采集连续性要求高,建议优先放宽延时、降低并发:
| 配置项 | 默认值 | 威胁情报采集建议 | 改动原因 |
|---|---|---|---|
| CONCURRENT_REQUESTS | 16 | 4~8 | 降低瞬时并发,避免触发频率封禁 |
| DOWNLOAD_DELAY | 0 | 2~5 | 单请求间隔固定时间 |
| AUTOTHROTTLE_ENABLED | False | True | 让采集机按源站响应速度自适应 |
| RETRY_TIMES | 2 | 3~5 | 情报源偶尔发生 5xx,重试即可恢复 |
| DOWNLOAD_TIMEOUT | 180 | 60 | 缩短单请求挂起时间 |
一次性把这些参数设置死并不合理,更好的做法是按源分组设置。Scrapy 支持在 Spider 内直接覆盖custom_settings,对响应慢、偶尔返回 504 的历史漏洞库,把 AUTOTHROTTLE_ENABLED 开启,并把 RETRY_HTTP_CODES 扩到 502、503、504;对厂商通告这类更新周期长的源,把 DOWNLOAD_DELAY 调大,减少空转请求。
需要留意的坑是 AUTOTHROTTLE 与 DOWNLOAD_DELAY 同时开启时的行为差异。开了 AUTOTHROTTLE 后,Scrapy 会动态计算延迟,DOWNLOAD_DELAY 会被覆盖,两者同时设并不冲突,但直觉上会误以为加了固定延时。若想要确定性行为,可以把 AUTOTHROTTLE_ENABLED 关掉,只保留固定 DOWNLOAD_DELAY。
4. 威胁情报处理管线:字段模型、数据清洗与置信度评估
4.1 情报指标字段模型与 Item 定义
抓取只解决了数据到达的问题,处理阶段决定数据是否可用。威胁情报的指标字段应该至少覆盖四类实体:IP、域名、哈希值、漏洞编号;在此基础上再附加来源、时间戳和描述上下文。Item 定义如下:
# items.py import scrapy class IoCItem(scrapy.Item): ioc_type = scrapy.Field() # ip / domain / hash / cve / url ioc_value = scrapy.Field() # 指标原文 confidence = scrapy.Field() # 0.0 - 1.0,表示这条指标可信程度 first_seen = scrapy.Field() # 首次发现时间 last_seen = scrapy.Field() # 最近一次活跃时间 source = scrapy.Field() # 来源页面 URL tags = scrapy.Field() # 标签,如 c2/phishing/malware description = scrapy.Field() # 自由文本上下文 fingerprint = scrapy.Field() # 去重指纹字段定义本身不复杂,真正的复杂度在于如何把不同情报源的文本映射到这套统一的字段模型里。一个漏洞详情页给出的可能是一大段描述文字,而不是干净的 CVE 编号。这里需要转角在提取阶段做正则收敛。
4.2 自由文本到标准 IoC 的清洗规则
从 HTML 里抓到的字段往往是这样的几种形态:带超链接的域名、混合了端口号的 IP、带前缀的哈希、夹在句式里的 CVE 编号。清洗函数的目标是把这些形态统一成标准值,并用 None 表示不可用:
# pipelines.py import ipaddress import re CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}", re.IGNORECASE) DOMAIN_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?)+$") def normalize_ioc(ioc_type: str, value: str): value = (value or "").strip() if ioc_type == "ip": try: return str(ipaddress.ip_address(value.split(":")[0])) # 去掉端口 except ValueError: return None if ioc_type == "cve": m = CVE_RE.search(value) return m.group(0).upper() if m else None if ioc_type == "domain": m = DOMAIN_RE.match(value) return m.group(0) if m else None return value函数里用正则和 ipaddress 库做了三层拦截:IP 先把端口剥掉再校验合法性,CVE 从任意文本里找回编号,域名只接受完整的多级域名格式。返回 None 的字段会走异常分支,丢弃或标记为待人工核验,不能直接进表,否则下游安全设备解析时会产生误报。
4.3 指纹去重与置信度加权
威胁情报的去重不是简单判断字段相等。同一域名今天被博客提到,明天又在漏洞库出现,如果不做交叉归一,30 天就会堆出几万条相似记录。前面的业务指纹方案再加一层指纹后,处理管线会把同一条 IoC 的不同来源记录合并,更新 last_seen 并提升置信度:
def merge_and_update(item, existing_record): item["confidence"] = max(item["confidence"], existing_record["confidence"]) item["first_seen"] = min(item["first_seen"], existing_record["first_seen"]) item["last_seen"] = max(item["last_seen"], existing_record["last_seen"]) return item置信度的初始值建议按来源类型给权重,而不是全部默认 1.0。常见做法是给来源打一个基线分,实操中比较实用的权重表长这样:
| 来源类型 | 基线置信度 | 说明 |
|---|---|---|
| 厂商官方通告 | 0.90 | 发布前经过内部确认 |
| 结构化漏洞库 | 0.85 | 有编号体系和收录流程 |
| 中大型安全博客 | 0.60 | 分析质量参差但有引用链 |
| 论坛/社交平台单帖 | 0.30 | 需要其他源交叉验证 |
这个权重表的作用是给下游查询排序,而不是直接丢弃低置信度记录。安全运营中低置信度情报也常包含有效线索,建议保留但标注,等有其他源提到时再自动升级。
5. 威胁情报的结构化存储与增量更新设计
5.1 存储选型:关系型与文档型数据库取舍
威胁情报存储选型要看查询场景。如果核心需求是按 IoC 字段精确匹配、按时间窗口扫最近活跃条目,用 MySQL 或 PostgreSQL 完全够,而且引入成本最低。如果还希望支持全文检索和聚合分析,Elasticsearch 更合适,但运维成本会明显上升。常见取舍如下:
| 存储方案 | 优势 | 劣势 | 适合场景 |
|---|---|---|---|
| SQLite | 零部署、单文件 | 并发写入弱 | 单机原型 |
| MySQL/PostgreSQL | 事务可靠、索引成熟 | 需要实例维护 | 团队级情报库 |
| Elasticsearch | 全文检索、聚合快 | 集群维护成本高 | 大规模查询与分析 |
| ClickHouse | 写入吞吐高、压缩好 | 事务能力弱 | 历史流水存档 |
见过不少团队一开始就上 ClickHouse,结果查询接口还没写,先被集群运维拖垮。威胁情报的日增量在千到万条级别,MySQL 绰绰有余;只有做长期统计报表时,才值得把历史数据归档到分析型引擎。
5.2 表结构与 UPSERT 更新策略
一张单表即可支撑情报存取。核心表设计如下:
CREATE TABLE threat_ioc ( id BIGINT AUTO_INCREMENT PRIMARY KEY, ioc_type VARCHAR(16) NOT NULL, ioc_value VARCHAR(512) NOT NULL, confidence DECIMAL(3,2) DEFAULT 0.00, first_seen DATETIME NOT NULL, last_seen DATETIME NOT NULL, source VARCHAR(1024), tags JSON, description TEXT, fingerprint CHAR(64) NOT NULL, UNIQUE KEY uk_fingerprint (fingerprint) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;fingerprint 是唯一键,这就是上一章提的指纹落地的位置。入库时用 INSERT ... ON DUPLICATE KEY UPDATE 实现幂等更新:
INSERT INTO threat_ioc (ioc_type, ioc_value, confidence, first_seen, last_seen, source, tags, description, fingerprint) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE last_seen = VALUES(last_seen), confidence = GREATEST(confidence, VALUES(confidence));这段 SQL 的逻辑是:如果指纹已存在,只更新 last_seen 到最新时间,并把置信度取较大值;不存在则整体插入。这样同一 IoC 多条来源不用先查一次再决定插入还是更新,减少一次往返,同时保证 first_seen 不会被新数据覆盖。
5.3 Pipeline 与存储层的衔接
将清洗结果写入 MySQL 的 Pipeline,核心是开连接、逐条入库、关闭连接:
# pipelines.py import json import pymysql class MysqlPipeline: def open_spider(self, spider): self.conn = pymysql.connect( host="127.0.0.1", user="threat", password="***", database="threat_intel", charset="utf8mb4", ) self.cursor = self.conn.cursor() def process_item(self, item, spider): self.cursor.execute( INSERT_SQL, (item["ioc_type"], item["ioc_value"], item["confidence"], item["first_seen"], item["last_seen"], item["source"], json.dumps(item["tags"]), item["description"], item["fingerprint"]), ) self.conn.commit() return item def close_spider(self, spider): self.cursor.close() self.conn.close()这个写法有个性能隐患:每行都 commit。规模上涨后应改为,在 open_spider 关闭自动提交,攒够 50 行或每隔 5 秒提交一次。批量提交能显著降低磁盘同步压力,也不影响数据完整性,顶多出现最后一批未提交的场景。
另外,Pipeline 在 settings.py 里ITEM_PIPELINES中的声明顺序就是执行顺序。清洗与入库如果分开写,不要把入库放在清洗之前,否则脏数据先进库再处理就晚了。
6. 威胁情报抓取处理系统的验证手段与调优技巧
6.1 采集质量的一次性核验
上线后第一件事,不是看日志有没有报错,而是验证抓下来的字段有没有悬空值。一条来自论坛的情报如果 description 字段全空、confidence 全是 0.3,说明清洗流程把太多内容挡掉了。用一条 SQL 就能看出来:
SELECT ioc_type, COUNT(*) AS cnt, SUM(confidence < 0.5) AS low_conf_cnt, SUM(description IS NULL OR description = '') AS empty_desc_cnt FROM threat_ioc GROUP BY ioc_type;low_conf_cnt 占比长期超过 60%,说明来源权重表该调整了;empty_desc_cnt 高,说明 Spider 的解析选择器没对上真实页面结构。顺带可以用 Scrapy 运行日志核对 item_scraped_count 是否符合预期,比如一次调度期望抓 1200 条,日志却只统计到 400 条,多半是哪个列表页被反爬拦截了。
6.2 定时调度与断点续爬的配合
威胁情报是周期性的,增量采集用 cron 比常驻进程更稳妥。常驻进程一旦脚本异常,整个采集服务就停了;cron 则每次独立启动,挂了下次还会再拉起来:
15 */2 * * * cd /opt/threat_intel && /usr/local/bin/scrapy crawl vuln_feed -s LOG_LEVEL=INFO -s JOBDIR=scheduler_state/vuln_feed >> /var/log/threat/vuln_feed.log 2>&1这个 cron 每两小时的 15 分启动一次。JOBDIR 让上次中断的请求队列直接继续,不用全量重抓;LOG_LEVEL=INFO 只记录关键事件,日志文件能控制在一个可以接受的大小。线上跑一段时间后,用grep -c "item_scraped_count" /var/log/threat/vuln_feed.log检查每天的调度是否都在产出新记录,一旦为零,就说明某个源的结构变了。
6.3 快速验证解析规则时的补盲技巧
调整选择器时反复重启爬虫很浪费时间。我会先在 Scrapy shell 里用单页面验证选择器命中情况,确认无误再整体跑。定位动态渲染页时,shell 里加上scrapy shell --nolog配合 Playwright 的 response 对象,能很快确认 iframe 里到底有没有目标表格。抓到新情报后,用 Python 直接对 fingerprint 字段做一轮随机抽样,检查入库样本和前几次调度的指纹重复比例,重复率若高于 30%,大多不是情报重复,而是去重指纹拼的字段在不同源之间格式不一致导致,需要对 normalize_ioc 涉及的边界条件做一次回归。
本文还有配套的精品资源,点击获取