☰
基于Scrapy与Redis的威胁情报抓取系统:从毕设到APT知识图谱数据管道
2026/10/20 1:29:00 网站建设 项目流程

简介:本资源为基于Scrapy框架的威胁情报抓取与处理系统毕业设计文档,面向网络安全、数据采集与知识图谱方向的本科生及研究人员,帮助解决开源威胁情报数据快速提取、解析入库与可视化展示的难题。压缩包内仅含1个docx文件,约2.58MB,完整呈现系统从需求分析到实现的全过程。文档围绕爬虫模块、数据解析模块与数据展示模块展开,涵盖Scrapy爬虫技术、知识图谱与APT知识图谱建模、MySQL与Redis存储、Flask_admin+nginx后台管理及pyecharts可视化等关键技术点,并附有可行性分析、系统设计与开发环境说明。目前已有194人学习,适合需要参考完整项目结构、掌握威胁情报采集与知识图谱数据需求实现思路的读者,可据此快速理解系统架构并迁移至自身课题或工程实践。

1. 从一份毕设拆起:Scrapy 抓威胁情报到底能落地到什么程度

很多人第一次听到「威胁情报抓取系统」,脑子里浮现的是 SIEM 大屏和花哨的攻击地图。但真到动手阶段,卡住你的往往不是可视化,而是数据从哪来、怎么解析、怎么去重、怎么保证断点续爬不丢指纹。这份毕业设计文档给了一条很务实的路线:用 Scrapy + Redis 做爬取与去重,用 MySQL 存结构化字段,用 Flask-Admin + Nginx 做展示与导出,用 pyecharts 出统计图,最终目标是给 APT 知识图谱攒底层数据。它不追求「一键防御」,而是老老实实解决开源威胁站点和博客里半结构化、非结构化数据的采集与规整问题。适合谁?正在做安全方向毕设、想搭一个能跑通的情报采集原型、或者需要给知识图谱项目补数据管道的同学。下面我按「能复现」的标准,把这份文档里的关键路径拆开讲。

2. 爬虫模块怎么搭:Scrapy 与 Scrapy-Redis 的选型与骨架

2.1 为什么不是 requests + BeautifulSoup

小规模抓取用 requests 完全够,但威胁情报站点的特点是页面层级深、列表页与详情页交叉、字段分散在表格和正文里,而且你迟早会遇到「跑一半断了,重启后重复抓」的问题。Scrapy 基于 Twisted 异步框架,天然支持并发请求、中间件插拔和管道化处理,更重要的是它的调度器与去重指纹是内置的。文档里选的是 Scrapy + Redis 的组合,Redis 在这里承担两个角色:一是请求队列,二是已抓指纹集合。这样即使进程被 kill,重新启动后队列和去重状态还在,断点续爬不会变成「从头再来」。我一般会先把单机 Scrapy 跑通,再切到 Scrapy-Redis,因为分布式调度会掩盖掉很多单机阶段就该暴露的解析错误。

2.2 项目骨架与 Spider 编写要点

一个能跑的最小目录结构如下,重点是settings.py里 Redis 相关配置和items.py的字段定义:

scrapy startproject threat_intel cd threat_intel scrapy genspider apt_report example-threat-site.com
# threat_intel/items.py import scrapy class ThreatIntelItem(scrapy.Item): # 指示器类型:ip / domain / url / file_hash indicator_type = scrapy.Field() indicator_value = scrapy.Field() # 来源报告标题与链接,用于后续关联 APT 组织 report_title = scrapy.Field() report_url = scrapy.Field() # 发现时间,很多站点在详情页才给出 detect_time = scrapy.Field() # 原始描述文本,留给正则二次提取 raw_text = scrapy.Field()

字段设计要围绕「知识图谱底层数据」来定,不要看到什么抓什么。文档里反复强调 APT 知识图谱需要的是实体和关系:实体包括组织、报告、指示器;关系包括「利用」「包含」「归属」。所以 Item 里至少要能落出指示器值、类型、来源报告,后续才能在建表时做关联。Spider 的解析逻辑通常分两层:列表页提取详情页链接,详情页用 XPath 或 CSS 定位表格行和正文段落。这里有个血泪经验——威胁情报站点的 HTML 结构经常变,XPath 不要写得太深,尽量用「包含某关键词的表格行」这种相对定位,否则改版一次就要重写。

2.3 中间件与代理池的接入方式

文档里提到用中间件配合代理池反爬。代理池本身基于 Redis 存储可用代理,随机抽取。中间件里要做的是在请求发出前从 Redis 取一个代理塞进request.meta['proxy'],并在异常时把失效代理剔除。常见做法是单独写一个ProxyMiddleware:

# threat_intel/middlewares.py import redis import random class ProxyMiddleware: def __init__(self, redis_host, redis_port, redis_key): self.r = redis.Redis(host=redis_host, port=redis_port, decode_responses=True) self.key = redis_key @classmethod def from_crawler(cls, crawler): return cls( redis_host=crawler.settings.get('REDIS_HOST'), redis_port=crawler.settings.get('REDIS_PORT'), redis_key=crawler.settings.get('PROXY_REDIS_KEY'), ) def process_request(self, request, spider): proxies = self.r.smembers(self.key) if proxies: request.meta['proxy'] = random.choice(list(proxies))

参数说明:REDIS_HOST和REDIS_PORT指向你的 Redis 实例;PROXY_REDIS_KEY是存放代理的集合键名。注意代理池的维护是另一套逻辑,通常单独跑一个校验脚本,定时检测代理可用性并更新集合。中间件里不要做太重的校验,否则会拖慢请求速度。另外,Scrapy-Redis 的调度器配置要在settings.py里显式打开,否则你用的还是默认的内存队列:

# threat_intel/settings.py 关键项 SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" SCHEDULER_PERSIST = True # 断点续爬的关键,不清空队列和指纹 REDIS_URL = "redis://127.0.0.1:6379"

SCHEDULER_PERSIST = True是断点续爬的后悔药,设成 False 的话每次启动都会清空队列,之前抓过的指纹也没了,重复抓取会浪费大量时间。

3. 数据解析与入库:从非结构化文本到 MySQL 表结构

3.1 正则提取指示器的边界问题

威胁报告里的 IP、域名、文件哈希往往混在正文里,文档里明确说非结构化数据主要靠正则表达式提取。但正则写得太宽会把版本号当 IP、把正常链接当恶意域名。我一般会分类型写多组正则,并在提取后做一次白名单过滤:

import re # 提取 IPv4,排除 0.0.0.0 和 255.255.255.255 这类占位 IP_PATTERN = re.compile( r'\b(?!0\.0\.0\.0)(?!255\.255\.255\.255)' r'((25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}' r'(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\b' ) # 提取 MD5 / SHA1 / SHA256,按长度区分 HASH_PATTERN = re.compile(r'\b[a-fA-F0-9]{32,64}\b') def extract_indicators(text): indicators = [] for ip in IP_PATTERN.findall(text): indicators.append(('ip', ip[0])) for h in HASH_PATTERN.findall(text): # 根据长度判断哈希类型 if len(h) == 32: indicators.append(('md5', h)) elif len(h) == 40: indicators.append(('sha1', h)) elif len(h) == 64: indicators.append(('sha256', h)) return indicators

逻辑说明:IP 正则里用负向断言排除两个特殊地址,避免把占位符入库;哈希按长度区分类型,因为知识图谱里不同哈希类型的实体属性不同。参数上,findall返回的是分组结果,所以取ip[0]拿完整匹配。这里容易翻车的地方是正文里出现「示例 IP:192.168.1.1」这种内网地址,如果不过滤,入库后统计图会很难看。常见做法是加一个内网网段排除列表,或者至少在展示层做标记。

3.2 管道文件与 MySQL 表设计

管道负责把 Item 写进 MySQL。文档里用的是 SQLAlchemy 的 ORM,但爬虫侧我建议直接用pymysql或mysqlclient批量插入,速度更可控。表结构要围绕实体和关系来建,至少包括指示器表、报告表、组织表,以及指示器与报告的关联表:

# threat_intel/pipelines.py import pymysql class MysqlPipeline: def open_spider(self, spider): self.conn = pymysql.connect( host='127.0.0.1', port=3306, user='intel', password='your_password', database='threat_intel', charset='utf8mb4' ) self.cursor = self.conn.cursor() def process_item(self, item, spider): sql = """ INSERT INTO indicator (indicator_type, indicator_value, report_url, detect_time) VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE detect_time = VALUES(detect_time) """ self.cursor.execute(sql, ( item.get('indicator_type'), item.get('indicator_value'), item.get('report_url'), item.get('detect_time'), )) self.conn.commit() return item def close_spider(self, spider): self.cursor.close() self.conn.close()

参数说明:ON DUPLICATE KEY UPDATE依赖indicator_value上的唯一索引,这样重复抓取同一指示器时只更新时间,不会产生脏数据。字符集用utf8mb4是因为威胁报告里可能出现各种特殊字符。注意 MySQL 默认字符集在 Ubuntu 上装完后往往是latin1,文档里专门给了改配置的步骤,这一步不能省,否则中文报告标题入库会变问号。改完/etc/mysql/conf.d/mysql.cnf后重启服务,再用SHOW VARIABLES LIKE 'character%'确认。

3.3 定时脚本与 crontab 的配合

爬虫不可能只跑一次,文档里提到用 crontab 做定时。常见做法是写一个 shell 脚本,先激活虚拟环境,再执行scrapy crawl,最后把日志按日期归档:

#!/bin/bash cd /root/threat_intel source venv/bin/activate scrapy crawl apt_report -s LOG_FILE=logs/$(date +\%Y\%m\%d).log

然后在 crontab 里加一行0 3 * * * /root/run_spider.sh,每天凌晨三点跑一次。这里有个坑:crontab 的环境变量和登录 shell 不一样,scrapy命令可能找不到,所以脚本里要么用绝对路径,要么先source环境。另外日志文件按天切分,方便出问题时回溯是哪次抓取解析异常。

4. 展示与查询:Flask-Admin + Nginx + pyecharts 的落地细节

4.1 Flask-Admin 模型类与导出功能

展示模块的价值在于让不写代码的人也能查数据、导数据。Flask-Admin 绑定 SQLAlchemy 模型后,增删改查和导出 CSV 基本是白送的。关键是把模型类定义清楚,字段的verbose_name写成人能看懂的中文或英文标签:

from flask_admin.contrib.sqla import ModelView class IndicatorView(ModelView): column_list = ('indicator_type', 'indicator_value', 'report_url', 'detect_time') column_searchable_list = ('indicator_value', 'report_url') column_filters = ('indicator_type', 'detect_time') can_export = True export_types = ['csv'] admin.add_view(IndicatorView(Indicator, db.session, name='指示器'))

参数说明:column_searchable_list决定哪些字段能搜索,column_filters决定筛选器下拉里有哪些字段,can_export打开导出按钮。文档里演示了筛选 id 大于 200 再导出,实际使用中更常见的是按指示器类型和时间范围筛选。注意导出的是当前页面显示的字段,所以column_list要提前设计好,别把内部主键和冗余字段都放出去。

4.2 pyecharts 图表的生成时机

文档里统计图是在服务器启动时生成 HTML 文件,用绝对路径渲染。这个做法简单,但数据更新后图表不会自动刷新,需要重启服务或重新生成。常见改进是把生成逻辑放到定时任务里,每天爬取完成后重新生成一次。图表本身用 Page 组合多张图:

from pyecharts import options as opts from pyecharts.charts import Line, Pie, Page def render_dashboard(data): line = ( Line() .add_xaxis(data['dates']) .add_yaxis('新增指示器', data['counts']) .set_global_opts(title_opts=opts.TitleOpts(title='近30天指示器增长趋势')) ) pie = ( Pie() .add('', data['type_pairs']) .set_global_opts(title_opts=opts.TitleOpts(title='指示器类型比例')) ) page = Page() page.add(line, pie) page.render('/root/flask-admin/templates/dashboard.html')

逻辑说明:data从 MySQL 聚合查询来,按日期分组统计新增数量,按类型分组统计占比。绝对路径是因为 Flask-Admin 的模板目录可能不在当前工作目录下,用相对路径容易找不到文件。这里有个玄学问题——pyecharts 生成的 HTML 依赖它自带的 JS 资源,如果 Nginx 静态文件配置不对,图表会白屏。检查方法是浏览器控制台看有没有 404,有的话把 pyecharts 的 assets 目录也配到 Nginx 的静态路径里。

4.3 Nginx 反向代理与静态资源

Nginx 在这里做反向代理,把外部请求转发到 Flask 应用,同时托管静态文件。配置不复杂,但有两个点要注意:一是proxy_pass的地址要和 Flask 启动的 host、port 一致;二是静态资源目录的alias结尾斜杠要对齐,否则会 404。

server { listen 80; server_name your_domain_or_ip; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /root/flask-admin/static/; } }

参数说明:proxy_set_header把真实客户端 IP 传给 Flask,方便日志排查;alias指向静态文件实际目录。改完配置用nginx -t测试语法,再nginx -s reload生效。如果页面能打开但样式丢失,优先查静态资源路径和权限。

5. 避坑与排查:那些文档没写但一定会遇到的事

5.1 现象:爬虫跑一段时间后大量请求超时

原因通常是代理池里可用代理耗尽,或者目标站点对单一出口 IP 做了限速。中间件虽然随机取代理,但如果没有定时校验和补充,集合里的代理会逐渐失效。解决方式是单独跑一个代理校验脚本,每隔几分钟检测一次,把不可用的从 Redis 集合里移除,同时接入新的代理源。另外可以在settings.py里调低DOWNLOAD_DELAY和并发数,先保证稳定再谈速度。

5.2 现象:MySQL 入库中文变问号或乱码

原因是数据库、表、连接三处的字符集不一致。文档里给了改 MySQL 配置的步骤,但很多人只改了服务端,忘了建库建表时也要指定utf8mb4。解决方式是建库时显式声明CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,连接字符串里也带上charset='utf8mb4'。排查时用SHOW CREATE TABLE indicator看表的字符集,用SHOW VARIABLES LIKE 'character_set_%'看服务端设置。

5.3 现象:Scrapy-Redis 重启后重复抓取

原因是SCHEDULER_PERSIST设成了 False,或者 Redis 里的队列键被手动清了。另一个容易被忽略的点是DUPEFILTER_CLASS没配对,用的还是默认的内存去重,进程一退指纹就没了。解决方式是确认settings.py里调度器和去重类都指向scrapy_redis,并且SCHEDULER_PERSIST = True。如果确实需要重新抓,手动删 Redis 里对应的队列和指纹键即可,但别在正常运行时删。

5.4 现象:pyecharts 图表页面空白

原因通常是静态资源 404 或者生成 HTML 时数据为空导致图表初始化失败。先在浏览器控制台看 Network 面板有没有 JS 文件加载失败,有的话检查 Nginx 静态路径。如果资源正常但图表不显示,检查传给 pyecharts 的数据列表是不是空列表,空数据时部分图表类型会直接不渲染。解决方式是在生成图表前做一次数据非空判断,空数据时给个占位提示。

5.5 现象:Flask-Admin 导出 CSV 打开后列错位

原因是字段值里包含逗号或换行符,而导出时没有做转义。Flask-Admin 默认的 CSV 导出对这种情况处理不完善。解决方式是在模型类里重写导出逻辑,或者入库前就把字段里的逗号和换行替换掉。更稳妥的做法是导出时用csv模块的QUOTE_ALL模式,让每个字段都带引号。

6. 进阶技巧:把抓取结果往知识图谱方向再推一步

如果你已经跑通了上面的流程,下一步大概率是想让数据真正能支撑 APT 知识图谱,而不是散落在几张表里。这里分享一个我常用的验证方法:先不急着上图数据库,而是在 MySQL 里用关联查询模拟实体关系,看数据能不能串起来。具体做法是建一张apt_organization表存组织名和别名,再建一张report_organization关联表,把报告里提到的组织名和标准组织名做映射。映射这一步可以先用关键词匹配,比如报告标题里出现某组织别名就关联上,后续再考虑用相似度算法消歧。

-- 查询某个组织关联的所有指示器类型分布 SELECT i.indicator_type, COUNT(*) AS cnt FROM indicator i JOIN report_organization ro ON i.report_url = ro.report_url JOIN apt_organization ao ON ro.org_id = ao.id WHERE ao.name = '某组织别名' GROUP BY i.indicator_type ORDER BY cnt DESC;

这条查询能帮你快速判断某个组织相关的指示器是域名多还是哈希多,从而决定后续分析的重点方向。参数上,report_organization的report_url要和indicator表的report_url对齐,否则关联不上。如果发现关联结果为空,先检查报告 URL 是否在入库时被截断或做了归一化处理。

另一个实用技巧是给指示器加一个「首次发现时间」和「最后更新时间」字段,而不是只存一个detect_time。因为同一指示器可能在多份报告里出现,首次发现时间对判断攻击活动的时间线更有价值。实现方式是在管道里用INSERT ... ON DUPLICATE KEY UPDATE时,首次发现时间只在插入时写入,更新时间每次覆盖。这样统计图里「近 30 天新增」和「近 30 天活跃」就能分开看,前者看新指示器增长,后者看老指示器是否还在被引用。

最后说一个我踩过的坑:早期我直接把报告正文全文存进 MySQL 的TEXT字段,结果表体积涨得飞快,查询也变慢。后来改成只存提取后的指示器和报告摘要,正文原文按报告 URL 归档到文件系统,需要时再按 URL 去取。这个习惯从那以后我一直保留——凡是原始大文本,先进文件存储,数据库只留索引和结构化字段。希望这些经验能帮你在复现这份系统时少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询