首页 / 资讯中心 / 文章详情

Scrapy爬虫实战:从视频社区采集数据入库MySQL

Scrapy爬虫实战:从视频社区采集数据入库MySQL ★ FEATURED ARTICLE
做爬虫这个事儿最怕的不是代码写不出来而是换一个网站就懵逼。这次我以某视频创作社区下文统一叫目标站类似新片场这类公开的视频平台为场景用 Scrapy 把前 20 页视频基础数据抓下来再写进 MySQL。整个过程从页面分析、字段设计到代码落地我会完整梳理一遍。目标数据是视频标题、作者、分类、播放量、点赞数、评论数和发布时间这 7 个核心字段量级大概三四百条正好适合练手跑通之后再做可视化或者推荐系统也都有数据底座了。下面内容不是那种“代码丢出来能跑换个网站就废”的教程我会把每一步为什么这么做讲清楚包括我踩过的坑。1. 项目定位与整体方案设计1.1 这个爬虫到底要爬什么做爬虫的人多少都有这样的经历拿到一个网站第一反应是“直接 requests 干”拿回来一看收的全是空壳 HTML核心数据藏在接口里这时才想起 Scrapy 这种框架的意义。这次的项目说简单也简单就是把目标站前 20 页的视频基础数据抓下来保存进 MySQL。但简单背后其实有一整套需要想清楚的点抓哪些字段、用什么请求方式、怎么翻页、数据要不要清洗、入库怎么去重。为什么选前 20 页而不是整站因为整站数据量可能几十万条涉及到的反爬、存储、性能问题就复杂了前 20 页大概三四百条数据是小项目的最佳体量。既能走通全流程又不会因为战线太长劝退新手。目标字段我定义为下面这 7 个字段含义举例title视频标题城市夜景延时摄影author作者昵称某摄影师category视频分类风光 / 纪实 / 动画play_count播放量1.2万like_count点赞数356comment_count评论数18publish_time发布时间2025-03-12 10:30另外我还会带上视频详情 URL 和封面图地址方便后续点击跳转或做封面缩略展示。这类视频社区的数据天然适合做练手素材字段类型丰富有字符串、有数字、有时间还有“1.2万”这种带单位文本清洗起来很有意思翻页和筛选逻辑也很典型跟电商、资讯站没有本质区别。跑通一次后面换目标站基本就是改选择器和字段映射的事儿。1.2 为什么选 Scrapy 加 MySQL 这条技术路线先回答一个经常被问的问题requests 加 BeautifulSoup 不也能爬吗能但 Scrapy 的优势在于工程化。它把请求调度、并发控制、去重、日志、中间件、数据管道这些事都内置了你只负责写解析规则它负责把网络访问安排得明明白白。爬 20 页可能体会不出来如果爬 200 页不带并发框架的脚本就会慢得让人怀疑人生。Scrapy 的并发模型是基于 Twisted 异步框架的一个进程里能同时发多个请求效率比同步 for 循环高几个量级。它还自带 Request 去重机制同一个 URL 默认只请求一次避免重复抓取。配合 Downloader Middleware 和 Item Pipeline能做 UA 轮换、延迟控制、数据清洗和入库整个流程是模块化的改起来也方便。存储端选 MySQL是因为视频数据天然是结构化记录每条视频有明确的字段用关系型数据库存储和查询都直观。尤其后面想做“播放量 TOP10”“分类统计”这类 SQL 分析MySQL 的优势一下就出来了。相比之下存 JSON 文件虽然方便却少了查询能力存 MongoDB 也行但为了引入数据库概念、练 SQLMySQL 对大部分人是更顺手的路径。整个技术栈就是 Python 3.x 加 Scrapy 2.x 加 PyMySQL三个环节都清晰。2. 环境准备与数据规划2.1 开发环境与依赖安装这块没什么高深的但容易漏。我建议用虚拟环境隔离项目依赖避免把系统 Python 环境搞乱。python3 -m venv scraper_env source scraper_env/bin/activate pip install scrapy pymysqlScrapy 安装时在 Linux 上偶尔会缺 libxml2 之类的编译依赖Windows 上则可能缺 Microsoft C Build Tools报错基本都是编译错误按提示补齐构建工具就好。装完后用scrapy version验证我习惯顺手建一个项目目录保持代码整洁。2.2 目标页面结构与 URL 规律分析很多新手拿到网站就开写结果被反爬打回来才发现连页面结构都没看。正确顺序是先打开目标站按 F12 进入开发者工具切到 Network 面板刷新列表页观察网络请求。这里有个关键判断如果列表内容直接在 HTML 响应里直接解析 HTML 就好如果 HTML 里只有壳真实数据是从 XHR 接口返回的 JSON那就优先找接口。以视频列表页为例我的经验是列表页 URL 往往带页码参数例如示例地址https://example.com/video/list?page3typehotpage 从 1 递增到 20type 是排序方式。这种规则一旦确认翻页就只是拼 URL 的事。比这更稳的方法是看 Network 面板里的 XHR 请求很多站点列表是异步加载的接口地址结构往往是/api/video/list?page3返回 JSON字段更干净。本项目的思路是先判断目标站属于哪种场景两种方案在 Spider 里写法略有差异核心都是构造请求、解析响应。判断页面是静态渲染还是动态渲染可以这样操作右键查看网页源代码搜索任意一条视频标题。如果能在源码里搜到说明标题在 HTML 里直接解析如果搜不到说明数据是异步加载的要去 Network 里找 JSON 接口。这个动作看起来简单但能省下大量无用功。我见过有人对着动态渲染的页面硬写 CSS 选择器调试了两小时才发现问题根本不在选择器上。另外要随手记下关键信息UA 能不能直接访问、是否需要 Cookie、分页限制在哪。我见过有些站只在页面上显示前 50 页超出就返回空列表所以 20 页这个范围还要确认在服务端允许范围内别闷头爬到最后一无所获。2.3 数据库表结构设计表结构提前想好要比跑完数据再补救省事得多。我建议字段尽量覆盖源站展示的信息同时预留自增主键和抓取时间。CREATE TABLE IF NOT EXISTS video_info ( id INT AUTO_INCREMENT PRIMARY KEY, video_url VARCHAR(500) NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100) DEFAULT NULL, category VARCHAR(50) DEFAULT NULL, play_count INT DEFAULT 0, like_count INT DEFAULT 0, comment_count INT DEFAULT 0, duration VARCHAR(20) DEFAULT NULL, publish_time DATETIME DEFAULT NULL, cover_url VARCHAR(500) DEFAULT NULL, crawl_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_video_url (video_url) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;video_url 设唯一索引是为了后面去重这是这整套方案里最重要的一条设计。上传时间存 DATETIME 比较好排序如果源站给的是时间戳程序里先转换再入库。播放量、点赞数、评论量用 INT注意清洗的时候别让“1.2万”这种字符串混进来。crawl_time 用默认当前时间记录每批次抓取时间做增量分析的时候非常有用。字符集必须用 utf8mb4否则中文标题和作者名很容易乱码这几乎是每个人都会踩的坑。3. 爬虫核心代码拆解3.1 项目初始化与 settings 关键配置用命令行创建 Scrapy 项目scrapy startproject video_spider cd video_spider scrapy genspider video example.com生成的目录结构里主要关注 spiders 目录、settings.py、items.py 和 pipelines.py。我先说 settings.py 里几个和这个项目息息相关的配置项BOT_NAME video_spider SPIDER_MODULES [video_spider.spiders] NEWSPIDER_MODULE video_spider.spiders ROBOTSTXT_OBEY False CONCURRENT_REQUESTS 8 DOWNLOAD_DELAY 1.2 RANDOMIZE_DOWNLOAD_DELAY True DOWNLOAD_TIMEOUT 15 RETRY_TIMES 2 DEFAULT_REQUEST_HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: application/json, text/plain, */*, Referer: https://example.com/, } ITEM_PIPELINES { video_spider.pipelines.MysqlPipeline: 300, }ROBOTSTXT_OBEY 这个配置我要多说一句。默认是 TrueScrapy 会先请求 robots.txt 再决定能不能爬。我把它关掉是考虑到不少站点对爬虫访问没有明确允许而且练习本身就只访问公开页面。但这里必须强调关掉不等于可以乱来后续我在第 5.3 节再说合规边界。DOWNLOAD_DELAY 设 1.2 秒再加 RANDOMIZE_DOWNLOAD_DELAY让请求间隔在 1.2 秒上下浮动。这是应对限速最朴素也最有效的办法比盲目调高并发稳健得多。完整的浏览器 UA 也是必须的很多站点对无 UA 请求直接 403。3.2 列表页解析与翻页逻辑Spider 的写法上我推荐用 start_requests 直接构造 20 个 Request比在 parse 里翻页少一次回调跳转逻辑更直白import scrapy class VideoSpider(scrapy.Spider): name video_spider allowed_domains [example.com] max_page 20 def start_requests(self): for page in range(1, self.max_page 1): url fhttps://example.com/video/list?page{page}typehot yield scrapy.Request(url, callbackself.parse_list) def parse_list(self, response): # 如果响应是 JSON 接口直接 response.json() 解析 if application/json in response.headers.get(Content-Type, ): data response.json().get(data, []) for item in data: yield from self.build_item(item) return # 如果响应是 HTML用 CSS 选择器解析列表项 for block in response.css(div.video-card): item { title: block.css(h3.title::text).get(), author: block.css(span.author::text).get(), category: block.css(span.category::text).get(), play_count: block.css(span.play::text).get(), like_count: block.css(span.like::text).get(), comment_count: block.css(span.comment::text).get(), duration: block.css(span.duration::text).get(), publish_time: block.css(time::attr(datetime)).get(), video_url: block.css(a.video-link::attr(href)).get(), cover_url: block.css(img.cover::attr(src)).get(), } yield item这段代码同时考虑了 HTML 和 JSON 两种响应。判断响应类型我用的是 Content-Type 头但最稳妥的方式其实是看 URL 里是否带 api 或 json直接从 response.json() 是否抛异常来判断也可以。还有一种常见问题是列表项并不是直接在页面里而是嵌套在 iframe 或 Shadow DOM 里那就要找主文档中的 iframe src 再二次请求这个项目里先不展开。解析时 CSS 选择器写法和实际页面要核对。我的习惯是先在开发者工具里右键复制选择器再用命令行scrapy shell调试scrapy shell https://example.com/video/list?page1 response.css(div.video-card).extract_first()选择器你想用 XPath 也一样个人喜好。CSS 短一些XPath 更灵活比如要找“包含某个 class 且没有另一个 class”的元素时 XPath 表达力更强。但在这个项目里 CSS 足够用了。之后项目时间长了选择器几乎总会因为页面改版而失效所以我写解析都尽量留注释把字段对应哪块 UI 写清楚方便后面维护。另一种翻页写法是在 parse_list 里处理完当前页之后自己构造下一页的 Requestnext_page response.css(a.next::attr(href)).get() if next_page: yield scrapy.Request(response.urljoin(next_page), callbackself.parse_list)这种写法适合没有明确页码上限的站点但需要小心别真的无限爬下去。我这次选择 start_requests 直接生成 20 个请求就是因为它能把“最多 20 页”这个边界写死在代码里更可控。3.3 字段清洗与数据标准化数据清洗这步很多人会跳过直接入库结果后面统计时被“1.2万”和“1万2000”这类格式坑到。我建议单独写一个工具函数在 Spider 里调用import re from datetime import datetime def parse_count(text): if not text: return 0 text text.strip() if 万 in text: m re.search(r([\d.])万, text) if m: return int(float(m.group(1)) * 10000) return 0 m re.search(r\d, text) return int(m.group()) if m else 0 def parse_publish_time(text): if not text: return None for fmt in (%Y-%m-%d %H:%M:%S, %Y-%m-%d, %Y/%m/%d %H:%M, %Y-%m-%dT%H:%M:%S): try: return datetime.strptime(text.strip(), fmt) except ValueError: continue return Noneparse_count 就是冲着“1.2万”这种写法来的统一换算成整数后面 SQL 排序和聚合就正常了。这里的核心是先搜到小数和“万”的组合再乘 10000如果文本里混着“播放”这种后缀正则也能精确提取数字部分。时间字段常见格式很多有的带毫秒或 T 分隔我是把常见格式遍历一遍解析不出就把 None 交出去由数据库存 NULL。清洗之后数据才真正变成结构化记录。4. 写 MySQLPipeline 实现与去重方案4.1 自建 Pipeline 连接 MySQLScrapy 的 Item Pipeline 是一个逐级处理通道Spider 产出的每个 item 都会按 ITEM_PIPELINES 里配置的顺序依次经过各个 Pipeline 类。在这里我只定义了一个 MysqlPipeline作用就是把 item 写进数据库。import pymysql class MysqlPipeline: def __init__(self, db_config): self.conn pymysql.connect(**db_config) self.cursor self.conn.cursor() classmethod def from_crawler(cls, crawler): return cls(crawler.settings.get(MYSQL_CONFIG)) def process_item(self, item, spider): sql INSERT INTO video_info (video_url, title, author, category, play_count, like_count, comment_count, duration, publish_time, cover_url) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE play_count VALUES(play_count), like_count VALUES(like_count), comment_count VALUES(comment_count), crawl_time CURRENT_TIMESTAMP params ( item.get(video_url), item.get(title), item.get(author), item.get(category), int(item.get(play_count) or 0), int(item.get(like_count) or 0), int(item.get(comment_count) or 0), item.get(duration), item.get(publish_time), item.get(cover_url), ) try: self.cursor.execute(sql, params) self.conn.commit() except Exception as e: spider.logger.error(finsert error: {e}, item: {item.get(title)}) self.conn.rollback() return item def close_spider(self, spider): self.cursor.close() self.conn.close()from_crawler 这个类方法的作用是读取 settings 里的数据库配置在爬虫启动时初始化连接。MYSQL_CONFIG 在 settings.py 里这样写MYSQL_CONFIG { host: 127.0.0.1, port: 3306, user: root, password: your_password, database: video_db, charset: utf8mb4, }连接参数里一定要写 charsetutf8mb4不写的话即使表建成了 utf8mb4连接层也可能因为默认字符集不对而写入乱码。另外我建议在 process_item 里包一层 try/except因为 pymysql 有个坑执行 SQL 报错后连接如果处于异常状态后面所有执行都会跟着错。捕获异常、记录日志、回滚事务才能保证单条坏数据不会中断整轮爬取。数据量如果特别大每次都 commit 性能会比较差。改进方案是在 Pipeline 里攒一批再批量写def __init__(self, db_config): self.conn pymysql.connect(**db_config) self.cursor self.conn.cursor() self.batch [] def process_item(self, item, spider): params (...) # 同上面的 params self.batch.append(params) if len(self.batch) 50: self.cursor.executemany(sql, self.batch) self.conn.commit() self.batch.clear() return item def close_spider(self, spider): if self.batch: self.cursor.executemany(sql, self.batch) self.conn.commit() self.cursor.close() self.conn.close()executemany 配合 ON DUPLICATE KEY UPDATE 是可行的参数列表按顺序传进去就行。前 20 页这个量级其实不用批量但如果你后面扩展到几百页批量提交能明显减少事务开销。4.2 唯一键、去重与增量更新去重主要靠两层第一层是 Scrapy 内置的 URL 去重对同一爬虫进程内已经请求过的 Request 不再重复请求第二层是数据库的 video_url 唯一索引。跨批次抓取时比如这周跑一次下周又跑一次同一视频的 URL 会出现这时单纯靠 Scrapy 的 Request 去重就失效了因为新进程的内存里去重集合是空的。所以数据库唯一索引才是最终的兜底。SQL 里使用 ON DUPLICATE KEY UPDATE碰到重复的 video_url 就更新播放量、点赞数、评论数这三个变动频繁的字段同时把 crawl_time 更新为当前时间。这样既不会堆积重复记录又保留了“最后一次抓取时数据的快照”。如果还想记录历史变化趋势可以拆一张 video_snapshot 表每次抓取都 insert但这种历史表在数据量大时膨胀很快前 20 页场景没必要。增量更新还可以更优雅一些。比如记住上一次抓取的最大视频 ID 或最新发布时间下次就从这个时间点开始抓新数据。视频社区通常有“最新发布”这个排序维度可以在翻页判断条件里加一个时间断点当页面里出现早于上次抓取时间的视频就提前结束翻页。这样每条新视频只入库一次播放量、点赞数的更新则交给上面的 ON DUPLICATE KEY UPDATE。4.3 爬虫启动与入库验证启动爬虫的命令很简单scrapy crawl video_spider第一次跑建议加-s LOG_LEVELDEBUG看详细日志确认每个页面的请求状态和解析条数。跑完去数据库里验证SELECT COUNT(*) FROM video_info; SELECT title, author, play_count FROM video_info ORDER BY play_count DESC LIMIT 10; SELECT category, COUNT(*) FROM video_info GROUP BY category;第一句看总量是否接近预期20 页乘以每页条数第二句验证清洗后的数字排序对不对第三句验证分类字段是否规整。这三条 SQL 是我每次入库后必跑的“体检”。如果播放量排序出现“1.2万”排在“999”前面的情况不用怀疑parse_count 肯定没生效。5. 实战排查与避坑记录5.1 请求层面的异常与应对爬虫项目跑不起来首当其冲就是网络访问问题。最常见的几种403 Forbidden多半是 UA 或者请求头不完整。浏览器开发者工具里把完整的 Headers 复制过来设置到 DEFAULT_REQUEST_HEADERS 里再试。还有一种是第一次请求正常翻到第几页就开始限流这时调大 DOWNLOAD_DELAY 比调高并发更有效。我实测下来1.5 秒间隔跑 20 页大概要多花一分多钟但成功率几乎是 100%。连接超时或无限重试会拖慢整个爬虫。Scrapy 默认超时时间偏长我在 settings.py 里设置了 DOWNLOAD_TIMEOUT 15。另外设置 RETRY_TIMES 2 就好重试次数多了反而容易被封。还有如果看到大量 503 或者 429说明访问频率已经触发了服务端的限流机制这时候最正确的做法不是继续怼而是停下来、调大延迟过一段时间再继续。还有一个隐蔽的问题有些站点对特定排序参数的翻页请求返回 200 但内容为空。如果发现出来的 item 数量只有几十条先检查是不是没有带完整 Query 参数或者 page 从 0 开始而不是从 1 开始这类边界问题特别容易忽略。现象常见原因处理思路403UA 或请求头不完整复制浏览器完整请求头返回 200 但 item 为 0页面是 JS 动态渲染去 Network 找 XHR 接口爬到某页突然大量超时触发限流调大延迟降低并发日志显示请求被重定向缺少 Cookie 或参数检查登录态与必要参数5.2 解析和数据入库的坑解析的坑主要在选择器失效。视频卡片可能不是 div 而是 li标题不一定是 h3 而是带 class 的 a 标签。遇到解析不到值的情况第一时间用 scrapy shell 打开当前页一条条试选择器。确认是静态渲染还是动态渲染如果 Network 里根本没有请求返回业务数据说明内容是前端 JS 异步渲染的直接解析 HTML 永远拿不到这时就去翻 XHR 接口。入库的坑主要是编码、空值和重复。编码问题就是字符集没设 utf8mb4空值问题是某些字段在页面上缺失处理不好会把 None 存入 NOT NULL 字段而报错。如果你在建表时把 title 设成了 NOT NULL但页面上有个别视频标题为空清洗时就要兜底例如item.get(title) or 未知标题。重复问题我上节已经说过用 ON DUPLICATE KEY UPDATE 一劳永逸。还有一个很容易忽略的细节时间字段入库时如果 parse_publish_time 返回的是 datetime 对象PyMySQL 会正确识别如果你不小心传了一个格式不对的字符串MySQL 会报错。我的习惯是尽量在 Python 层把所有字段都校验好不让脏数据有机会进入 SQL。日志里看到DataError、IntegrityError这类异常先别急着改 SQL回到对应 item 字段上查比价快。5.3 关于学习用途的边界提醒最后说点技术之外的东西。爬虫是工具用好了是数据采集利器用不好就是给别人添乱甚至带来法律风险。即使是公开数据也要先把站点的 robots.txt 和服务条款读一遍。我的个人做法是只抓取公开可见的基础信息不碰涉及账号权限、隐私、付费才能看的内容控制请求频率尽量不给目标站造成压力抓下来的数据只用于学习和个人研究不用于商业用途更不会原样打包传播。如果目标是正经做数据服务正确的姿势是先看官方有没有开放 API有 API 就走 API没有再说。爬虫永远应该是最后手段而不是第一选择。这个项目里设置的 DOWNLOAD_DELAY 和 20 页上限本质上都是为了让数据采集保持在合理、克制的范围内。我实际跑完这个项目最大的感受是Scrapy 这种东西你真用起来才会明白它为什么把“去重”“延迟”“管道”都设计成内置组件。这种练习项目最重要的收获不是那几百条数据而是你对整个链路有了判断力看到一个新页面能在几分钟内判断出是直接解析还是找接口字段要怎么设计才能不返工数据入库前要在哪一步做清洗。最后分享一个小习惯每次跑完项目把访问过的 URL 样例、选择器、字段映射整理成一个简单的 README 放在项目目录里。这个动作看起来废时间但隔几个月你再打开这个项目那些当时觉得理所当然的规则已经忘光能救你的就是这些记录。爬虫代码本身不值钱值钱的是你在调试过程中沉淀下来的那堆“为什么”。
阅读完成 · 觉得有帮助?
咨询建站