☰
Scrapy爬虫实战:20分钟高效抓取10万条股吧评论数据
2026/10/2 1:20:00 网站建设 项目流程

简介:基于Scrapy框架的股吧评论采集爬虫,面向需要快速获取股票社区文本数据的Python学习者、数据分析和毕业设计开发者。压缩包仅9KB,共17个文件,包含7个Python源码、7个pyc编译缓存、2个txt说明文档及1个scrapy.cfg配置文件,Python脚本负责核心逻辑,pyc可免编译直接调用,txt说明配置流程,代码精简,下载即可运行。爬虫端整合了Spider解析规则、Item数据结构、Pipeline清洗存储及Downloader Middleware请求拦截,支持以XPath/CSS选择器定位评论区域,可配合requests、lxml与pandas完成抓取、解析和预处理。工程通过并发请求、download_delay延时及随机User-Agent/代理池等策略,达到20分钟采集10万条评论的效率,同时规避反爬机制。项目在Pipeline中实现了去除重复评论、处理中文编码以及将数据落盘或写入数据库的功能,开发者可在此基础上二次扩展。已有1732人学习下载。该工程完整覆盖从请求构造、页面解析、数据提取到存储入库的爬虫全流程,结构清晰,开箱即用,适合舆情分析、量化策略或爬虫方向毕业设计参考。

1. 爬取股吧评论的 Scrapy 爬虫:20 分钟 10 万条怎么来的

毕业论文要做股票评论情感分析,最缺的不是模型,而是一份量够大、字段够全的股吧评论数据。这份基于 Scrapy 框架的爬虫资源,解决的就是这个数据来源问题:下载解压、装好依赖、一条命令跑起来,20 分钟左右能拿到近 10 万条评论,字段覆盖股票代码、作者、正文、发布时间、阅读数和回复数,刚好喂给后续的 pandas 清洗和模型训练。它不是几百行演示脚本,而是一个完整的 Scrapy 工程,爬虫 Spider、Item 模型、Pipeline 存储、基础反爬配置都齐了,下载即可运行。适合正在做毕业设计、需要真实评论数据做支撑的学生,也适合想快速上手 Scrapy 工程化结构的 Python 工程师。新手照步骤跑能先出数,熟手直接换列表页 URL 就能复用到其他股票或同类论坛。

2. 拆解工程:目录结构、依赖清单与选型理由

2.1 先从目录开始认文件

把资源解压之后,先别急着启动,花几分钟把整个目录结构过一遍,能省掉后面一半以上的排错时间。这份资源和官方scrapy startproject生成的骨架基本一致,整体长这样:

guba_spider/ ├── scrapy.cfg ├── requirements.txt ├── run.py └── guba/ ├── __init__.py ├── items.py ├── middlewares.py ├── pipelines.py ├── settings.py └── spiders/ ├── __init__.py └── guba.py

scrapy.cfg是工程入口配置,它告诉 Scrapy 项目名是什么、settings 模块路径在哪、默认从哪里启动。一般不用动。requirements.txt记录依赖库;run.py是给不熟悉命令行的用户准备的启动脚本,本质上就是调用scrapy crawl。guba包下面,items.py定义评论的数据字段,pipelines.py处理数据落盘,settings.py控制并发、延时、请求头、Pipeline 开关,spiders/guba.py才是真正的爬虫解析逻辑。

刚上手的同学最容易犯的错,是觉得文件名不顺手,把pipelines.py改成storage.py,或者把spiders/guba.py挪到别处。启动时一旦报ModuleNotFoundError,不要瞎排查,回到settings.py看SPIDER_MODULES和ITEM_PIPELINES这两个配置项,Scrapy 就是照着里面的字符串路径去加载模块的,路径和实际文件必须一一对应。

2.2 run.py 和 scrapy crawl 是什么关系

run.py的内容很少,核心就是下面几行:

from scrapy.cmdline import execute if __name__ == "__main__": execute(["scrapy", "crawl", "guba"])

它等价于在终端里执行scrapy crawl guba。为什么还要单独放一个 run.py?主要是照顾 Windows 用户:有些同学不习惯在命令行里切换目录,IDE 里直接点运行更方便。但这里有个隐含前提——run.py 所在的工作目录必须是guba_spider根目录,如果 IDE 把工作目录设置成别的路径,启动就会报Unknown command: crawl或加载不到 settings。

我平时更喜欢用命令行,因为能顺手带参数。比如抓取过程想看更多调试信息,可以这样跑:

scrapy crawl guba --loglevel INFO

如果命令行本身报Unknown command,大概率是scrapy命令和当前 Python 环境不一致,常见做法是装完之后用python -m scrapy crawl guba,从模块方式调用,绕开系统命令入口的混乱。

2.3 为什么是 Scrapy 而不是 requests

很多人会问:抓股吧这种“列表页加翻页”的活,requests + BeautifulSoup不也能写吗?能写,但量级完全不同。requests 是同步阻塞模型,一个请求没返回,后面的代码全部等着;到两三万条数据时,耗时、失败重试、重复 URL、断点续爬这些问题会全部冒出来。Scrapy 的价值在于框架层面把这些工程问题提前解决了。

问题requests 脚本Scrapy 框架
并发请求需要自己写多线程内置异步调度
重复 URL手动维护集合内置 dupefilter
请求失败try/except 手写middleware 自动重试
数据存储与解析代码耦合Pipeline 解耦
请求间隔到处 sleepDOWNLOAD_DELAY 全局生效

这张表不是给 Scrapy 贴金,它的学习曲线确实比 requests 陡得多,难点集中在yield加回调的思维方式上。在 requests 里,代码是从上到下一行行执行;在 Scrapy 里,parse()方法 yield 一个 Request 出去,Scrapy 把它放进调度器,等响应回来再调用指定回调,整个执行流是异步的。第一次接触时很容易写出“先收集一百个页面,再统一解析”的代码,结果是内存越占越多、日志里迟迟不出数据。这份资源的写法是流式处理:解析一条、yield 一条、Pipeline 写一条,Item 随到随落盘,这种模式才是大规模抓取的正解。

2.4 依赖安装与虚拟环境

依赖就两样,常规情况下requirements.txt里也就两行:

scrapy pymysql

pymysql是给 MySQL 存储方案用的,只跑 JSON 输出的话可以不用装。安装时我建议先建独立虚拟环境,不要直接往系统 Python 里塞,否则说不定哪个第三方库把 Scrapy 底层依赖的版本顶掉,整个工程就起不来了。

python -m venv venv source venv/bin/activate # Windows 下执行 .\venv\Scripts\activate pip install -r requirements.txt

装完后用scrapy version验证一下,能输出版本号表示框架可用。Windows 上如果遇到Microsoft Visual C++ 14.0 is required,不要硬刚编译,换 Python 3.8 以上版本,或者直接用 Anaconda 建环境,conda 里是预编译好的二进制包,能省掉一堆麻烦。

2.5 allowed_domains 和 start_urls 先讲清楚

从目录过渡到代码之前,还有两个和爬虫执行强相关的配置需要理解:allowed_domains和start_urls。allowed_domains是域名白名单,Scrapy 默认不允许请求这个列表之外的域名,目的是防止爬虫被页面里的外链带着跑偏。start_urls是初始种子地址,scrapy crawl启动后,Spider 会先调度这些 URL,后续所有页面都从parse()里 yield 出来的 Request 延续。

股吧列表页的 URL 规则是list,{股票代码}.html,这份资源默认抓的是 600519,想换股票直接把start_urls改掉,比如换成:

start_urls = ["http://guba.eastmoney.com/list,000001.html"]

地址必须带http://或https://,否则启动后请求会直接报Missing scheme错误,这是一个特别容易被忽略的初始化坑。

3. 读懂核心代码:Item 字段、Pipeline 落库与选择器提取的取舍

3.1 items.py 里的字段为什么这样设计

items.py是数据模型,评论最终会被整理成这几个字段:

import scrapy class GubaCommentItem(scrapy.Item): stock_code = scrapy.Field() # 股票代码,如 600519 comment_id = scrapy.Field() # 评论唯一 ID,用于去重 author = scrapy.Field() # 发帖用户昵称 content = scrapy.Field() # 评论正文 post_time = scrapy.Field() # 发布时间 read_count = scrapy.Field() # 阅读数 reply_count = scrapy.Field() # 回复数 source_url = scrapy.Field() # 原始页面地址

每个字段对应后续数据分析的一个维度,author和post_time用来做时间序列分析,content用来做情感标注,read_count和reply_count可以作为评论热度的代理指标。字段定义上最容易忽略的是comment_id和source_url。没有comment_id,第二次重跑任务时无法判断哪些评论已经抓过;没有source_url,导师或其他人拿到数据后无法回追溯源,数据可信度会打折扣。

有人会觉得用普通 dict 存不就行了,何必定义 Item。区别在于 Item 有字段约束和统一的序列化入口,写进 Pipeline 时结构稳定,代码可读性也更好。毕设后期如果要接 SQLAlchemy,Item 转 ORM 模型只需要做个字段映射,比 dict 散落一地的写法省事得多。

3.2 Pipeline:先落 JSON,再考虑 MySQL

爬虫本体不负责存储,parse()把 Item yield 出来之后,真正写数据的是 Pipeline。这份资源默认的存储方式我建议先用 JSON Lines,一条评论一行,追加方便,解析也方便,出问题还能按行定位到具体条目。

import json class JsonPipeline: def open_spider(self, spider): self.file = open("comments.jsonl", "w", encoding="utf-8") def close_spider(self, spider): self.file.close() def process_item(self, item, spider): line = json.dumps(dict(item), ensure_ascii=False) + "\n" self.file.write(line) return item

这段代码有两个细节直接决定数据质量。第一,ensure_ascii=False必须写,否则中文全部变成\uXXXX转义序列,后续 pandas 读进来还得二次还原。第二,编码用utf-8,如果数据要给 Excel 用,建议换成utf-8-sig,否则打开 CSV 中文表头会有乱码。

如果你打算直接落在 MySQL 里,Pipeline 可以换成这样:

import pymysql class MysqlPipeline: def open_spider(self, spider): self.conn = pymysql.connect( host="localhost", user="root", password="yourpassword", database="guba", charset="utf8mb4", ) self.cursor = self.conn.cursor() def process_item(self, item, spider): sql = ( "INSERT INTO comment " "(stock_code, comment_id, author, content, post_time, " " read_count, reply_count, source_url) " "VALUES (%s, %s, %s, %s, %s, %s, %s, %s) " "ON DUPLICATE KEY UPDATE read_count=%s" ) values = ( item["stock_code"], item["comment_id"], item["author"], item["content"], item["post_time"], item["read_count"], item["reply_count"], item["source_url"], item["read_count"], ) self.cursor.execute(sql, values) self.conn.commit() return item

这段比 JSON 版本多做了两件事:用ON DUPLICATE KEY UPDATE处理重复评论,靠comment_id的唯一索引实现幂等写入;连接字符集显式设置成utf8mb4,避免 emoji 和生僻字入库时报编码错误。需要注意open_spider里要在表结构确定之后再跑,如果comment_id没有唯一索引,去重逻辑就不生效,十万条数据里会有大量重复。

3.3 选择器提取:CSS 与 XPath 的边界

Spider 核心逻辑在parse()方法里,实际提取选择器是 CSS 和 XPath 混着用:

import scrapy from ..items import GubaCommentItem class GubaSpider(scrapy.Spider): name = "guba" allowed_domains = ["guba.eastmoney.com"] start_urls = ["http://guba.eastmoney.com/list,600519.html"] def parse(self, response): for sel in response.css("div.articleh"): item = GubaCommentItem() item["stock_code"] = "600519" item["comment_id"] = sel.css("a::attr(id)").re_first(r"\d+") item["author"] = sel.css("span.athor a::text").get() item["content"] = sel.css("span.l3 a::text").get() item["post_time"] = sel.css("span.l5::text").get() item["source_url"] = response.urljoin( sel.css("span.l3 a::attr(href)").get() ) yield item next_page = response.css("a.page_next::attr(href)").get() if next_page: yield response.follow(next_page, callback=self.parse)

用 CSS 是因为它短,写起来快;但复杂结构我倾向 XPath。比如作者字段,如果页面上某个span同时包含多个文本节点,CSS 的::text只取第一个,而 XPath 可以精确控制:

item["author"] = sel.xpath(".//span[contains(@class,'athor')]//a/text()").get()

两种写法在简单场景下等价,差异主要体现在层级复杂的页面上。代码里有两处边界容易被忽略:span.l5::text和span.l5 a::text在发布时间带链接时解析结果不一样,要根据页面实际结构选;get()取不到时返回None,如果后续 Pipeline 直接item["content"]取字段不做判空,写文件会抛TypeError。

翻页部分用response.follow(next_page, callback=self.parse)把下一页请求调度出去,next_page为空时整个爬虫自然结束。有一点要特别提醒:如果列表页第一页和第二页的“下一页”按钮 class 不一致,第二页之后可能取不到a.page_next,爬虫会“静默结束”,日志里只看到一个很小的item_scraped_count。遇到这种情况,打开第二页源码看一下按钮的真实 class,再回代码里改选择器。

4. settings 调参与运行:10 万条数据背后的并发、延时与限速

4.1 settings.py 里的关键参数

Scrapy 默认配置偏保守,直接跑最多每秒几个请求,20 分钟拿不到预期数据量。要让这份资源发挥出“十万条”的能力,settings.py里这几个参数需要重点确认:

参数建议值说明
ROBOTSTXT_OBEYFalse公开列表页抓取,遵守 robots 反而会拒绝大部分请求
CONCURRENT_REQUESTS16同时调度的最大请求数
DOWNLOAD_DELAY0.2每个请求之间的基础间隔,单位秒
DEFAULT_REQUEST_HEADERS见下方代码主要是 UA 和 Referer
ITEM_PIPELINESJsonPipeline指定存储管道
LOG_LEVELINFO只看关键统计,不刷每个请求

对应配置大概是这样:

BOT_NAME = "guba" SPIDER_MODULES = ["guba.spiders"] ROBOTSTXT_OBEY = False CONCURRENT_REQUESTS = 16 DOWNLOAD_DELAY = 0.2 DEFAULT_REQUEST_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://guba.eastmoney.com/", "Accept-Language": "zh-CN,zh;q=0.9", } ITEM_PIPELINES = { "guba.pipelines.JsonPipeline": 300, } LOG_LEVEL = "INFO"

解释一下几个参数的逻辑。CONCURRENT_REQUESTS是并发上限,不是越大越好;单 IP 下并发过高,服务器会直接判定为异常流量。DOWNLOAD_DELAY是更关键的风控参数,它控制的是相邻请求的最小间隔,0.2 秒意味着单 IP 每秒最多发出 5 个请求,对股吧这种访问量极大的论坛来说,这个频率在边缘线上。

DEFAULT_REQUEST_HEADERS里Referer特意写成了股吧首页,模拟从站内页跳转过去的访问路径。很多站点会校验 Referer,缺失或跨域 Referer 的请求会返回 403。答辩时如果被问到“你做了哪些反爬处理”,这个配置就能作为具体案例展开。

4.2 启动命令与日志判断

进入guba_spider目录,确认当前目录下有scrapy.cfg,然后执行:

scrapy crawl guba

如果没报错,几分钟后日志里会出现类似这样的统计信息:

INFO: Crawled (200) <GET https://guba.eastmoney.com/list,600519.html> INFO: Crawled (200) <GET https://guba.eastmoney.com/list,600519_2.html>

200说明响应正常,item_scraped_count会随着日志持续增长。判断爬虫是否健康的标准很简单:看这个计数有没有稳定上涨。如果长时间不动,优先检查是不是翻页选择器失效,或者请求被风控返回了非正常页面。

中途想停的时候,别直接用 Ctrl+C 硬杀进程。Scrapy 收到中断信号后会把调度器里已排队的请求处理完,再正常关闭 Pipeline,数据不会写一半。硬杀进程的代价是 JSON 文件最后一行可能是断行,导入 pandas 时直接报解析错误。实在要立刻停,可以按两次 Ctrl+C 强制终止,但后续要把最后一行数据手工截掉。

4.3 性能估算与 AutoThrottle

20 分钟 10 万条评论,换算下来每秒钟要稳定产出 83 条左右。如果一页列表有 80 条评论,也就是每秒约 1 个列表页。这个目标并不苛刻,即使把CONCURRENT_REQUESTS降到 4、DOWNLOAD_DELAY升到 0.5 秒,单 IP 每秒也能抓 2 个页面,理论值在每秒 160 条以上,20 分钟的上限远超 10 万。真实瓶颈通常出在两个地方:目标服务器响应变慢,或者单 IP 高频请求被风控。

Scrapy 提供一个自动限速机制 AutoThrottle,可以避免固定延时那种“太规律反被识别”的问题:

AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 0.5 AUTOTHROTTLE_MAX_DELAY = 5.0

开启后,Scrapy 会根据目标服务器的响应时间动态调整请求间隔,响应慢就自动拉长,响应快就适当收紧,相当于把延时从“写死的参数”变成了“自适应策略”。毕设论文里写一句“基于 AutoThrottle 实现自适应限速,平衡采集效率与服务器压力”,比单纯贴一个固定延时值更有说服力。

我的建议是先小规模试跑:把CONCURRENT_REQUESTS改成 4,先抓三分钟看统计量,确认稳定之后再逐步加并发。不要开局直接 16 并发满速跑,一旦触发风控,后续被封的代价比慢跑几分钟大得多。

5. 避坑指南:五个高频问题的现象、原因与解决

5.1 选择器失效:Item 全空还在继续爬

现象:爬虫运行正常,item_scraped_count也在增长,但打开输出文件发现content和author全是空值。

原因:绝大多数情况是页面结构变了,或者触发了风控降级页面。服务器返回的不是正常列表页,而是一段验证脚本,选择器自然取不到任何字段。

解决:先用scrapy shell手动拉一个页面下来,验证选择器,不要直接在爬虫里反复试:

scrapy shell "http://guba.eastmoney.com/list,600519.html"

进入交互环境后执行response.css("div.articleh").get(),看容器是否存在;存在再一级级往里试。只要容器为空,问题基本不在选择器,而在请求本身。

5.2 动态加载评论抓不全

现象:抓到的评论数量比页面上肉眼可见的少,尤其是详情页里“展开更多评论”后面的内容完全缺失。

原因:股吧详情页的部分评论是 XHR 接口异步加载的,初始 HTML 只包含前几条,后续内容要浏览器执行 JavaScript 才会出现。Scrapy 本身不执行 JS,只解析静态 HTML 自然拿不到。

解决:不要一上来就上 Playwright 或 Selenium,先按 F12 打开开发者工具,切到 Network 面板筛选 XHR,把真正的评论接口找出来。这类接口通常直接返回 JSON,请求它比渲染浏览器快一个数量级。万一评论被套在 iframe 里,就先拿到 iframe 的真实地址,再直接请求 iframe 里的内容,和异步加载是同一套排查逻辑。

5.3 爬到一半开始出现 403 和验证码页

现象:前几千条很顺利,爬到某个时间点后日志里连续出现 403 状态码,返回内容变成一段 JS 校验逻辑,item_scraped_count彻底不动。

原因:单 IP 请求频率太高,触发了站点风控。触发原因往往不是总请求数,而是请求间隔太均匀,固定 0.2 秒的节奏很容易被识别成脚本。

解决:把DOWNLOAD_DELAY调大,并开启随机化延时:

DOWNLOAD_DELAY = 1.0 RANDOMIZE_DOWNLOAD_DELAY = True

同时把CONCURRENT_REQUESTS降到 4 到 8。如果是个人毕设,时间上不赶,放到凌晨时段跑,成功率会明显高。爬虫讲究细水长流,一次跑完不如分几次慢跑。

5.4 中文乱码与字段截断

现象:CSV 文件在 Excel 里打开,中文变成“求衡”这类乱码;或者写数据库时报Data too long for column。

原因:Excel 默认按 GBK 解码 UTF-8 文件;数据库表字段用了VARCHAR(50),一条 800 字的评论放不进去。

解决:写文件时把编码从utf-8改成utf-8-sig,Excel 打开就不再乱码。数据库建表时content字段直接用TEXT类型,连接串带上charset="utf8mb4",才能覆盖 emoji 和生僻字。这些看起来是小问题,但等到十万条数据入库之后,编码和截断错误会成片出现,越早处理越好。

5.5 重跑任务导致数据重复

现象:第一次跑了 6 万条,改了一点解析逻辑后重跑,数据量变成 12 万,里面一半是重复项。

原因:Scrapy 默认的去重过滤器只针对 URL,不针对内容。同一个 URL 不会重复抓,但不同 URL 下可能展示同一条评论,默认机制拦不住。

解决:能不能在存储层做幂等,关键看comment_id。写库时用唯一索引加ON DUPLICATE KEY UPDATE,不写 MySQL 的话,也可以在 Pipeline 里自己维护一个集合做内存去重:

from scrapy.exceptions import DropItem class DeduplicatePipeline: def __init__(self): self.seen_ids = set() def process_item(self, item, spider): comment_id = item.get("comment_id") if comment_id in self.seen_ids: raise DropItem(f"duplicate comment id: {comment_id}") self.seen_ids.add(comment_id) return item

这个集合方案在十万条量级完全够用,内存占用大概十几 MB;如果数据量冲到千万级,再换 Redis 布隆过滤器。毕设场景没必要为“可能的数据量”提前上重型组件。

6. 进阶验证:把“能跑”变成“能交差”的三个检查习惯

爬虫跑完不等于事情结束。交数据之前,我习惯做三个自查动作:数行数、查缺失、抽 20 条人工核对。

数行数是第一步。Pipeline 写的是 JSON Lines,一行一条评论,直接数行数就行:

wc -l comments.jsonl

如果数字和日志里item_scraped_count对不上,说明写入过程中丢过数据,回open_spider和process_item里找问题。查缺失用 pandas 一行代码就能看全貌:

import pandas as pd df = pd.read_json("comments.jsonl", lines=True) print(df.shape) print(df.isna().sum())

shape给出总数和字段维度,isna().sum()直接列出每个字段的缺失量。缺得多的字段说明对应选择器可能拿不到值,要回页面源码核对标签。最后抽 20 条原始记录,把content、post_time、author和网页上的内容肉眼对齐一遍,能拦住绝大多数“字段串位”之类的隐蔽错误。

再往深走一步,这份资源的 Pipeline 在后期可以迁移到 SQLAlchemy。热搜里“sqlalchemy 储存爬虫数据”的做法,本质就是把手写 SQL 换成 ORM,屏蔽不同数据库的方言差异:

from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker engine = create_engine("mysql+pymysql://user:pass@localhost/guba") Session = sessionmaker(bind=engine)

本地用 SQLite 调试、线上切 MySQL,只需要改连接字符串,process_item里换成 ORM 会话提交,其余不动。十万条量级下,SQLAlchemy 的批量写入和模型迁移优势会明显一些,论文里写“数据存储层基于 ORM 设计,支持数据库无缝切换”也比单写一段pymysql更有说服力。

碰上“下载即可运行”的资源,我反而比从零写更谨慎,因为代码不是自己一行行敲出来的,出问题时排错路径完全是另一套。从那以后我每次拿到别人的爬虫项目复现,都强制走一遍流程:先跑 50 条看日志判断解析是否正常,再跑全量看统计量,最后随机抽 20 条人工核对字段。这条流程也就十几分钟,但能挡掉八成以上的翻车点。希望帮到你。

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

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

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

立即咨询