简介:本资源是一个面向Python初学者的Scrapy框架实战项目,聚焦抖音平台公开数据的采集与结构化存储,适用于刚掌握基础语法、希望通过真实场景理解分布式爬虫设计逻辑的学习者。项目通过抓取抖音搜索页的“热门挑战”与“热门音乐”入口,按参与人数筛选高热度话题并递进抓取关联视频信息,最终存入MongoDB,配合APScheduler实现每日凌晨定时更新,虽未覆盖全量数据但完整呈现了从目标分析、请求构造、中间件配置到数据管道落地的全流程开发链路。压缩包共29个文件,含14个核心Python源码(如spiders、pipelines、settings等模块)、5个XML配置文件(IDE及项目元数据)、1个README说明文档和1个LICENSE协议文件,整体仅30KB,轻量易读。已有1214人学习下载,读者可直接运行调试,深入理解User-Agent轮换、反爬应对策略、MongoDB异步写入及Scrapy项目目录组织规范。 最近翻到一个以前整理的抖音数据采集项目源码包,标题写着“基于python和scrapy框架的抖音数据爬虫项目源码.zip”,自己都愣了一下——这东西居然还在硬盘里躺着。想了想,干脆把它重新梳理一遍,顺便把整个设计思路、踩坑记录和可以复用的代码片段都写出来。如果你正准备用 Python 和 Scrapy 做短视频平台的数据采集,或者已经在做但总被各种反爬问题折磨,这篇文章应该能省你不少时间。
先说明一点:这个项目定位是采集公开可访问的数据,例如某个话题下的公开视频信息、公开评论内容、用户主页展示的基础资料等。所有涉及账号登录态、隐私数据、平台付费内容的部分,从一开始就做了隔离处理,项目里没有也不打算加上这些能力。技术本身是中性的,但使用边界得自己守住。
1. 这个项目到底在做什么:需求拆解与技术选型
1.1 需求拆解:采集"公开数据"而不是"破解数据"
动手写代码之前,最忌讳的就是拿到一个“爬抖音”的需求就直接开始写。你得先把需求拆清楚。我当初接到的原始需求是:给一个做短视频趋势分析的团队提供数据支撑,他们需要知道某些热门话题下视频的标题、作者昵称、点赞数、评论数、发布时间、视频无水印播放地址这些字段,用于做竞品分析和内容选题参考。
这里有个关键点:这些数据在 Web 端、分享页面上是公开展示的,任何人打开网页都能看到,采集这类公开信息并不涉及对平台安全机制的绕过。但如果你想要的数据是“某个用户设置了私密的收藏列表”“某个直播间的高热度实时数据”“付费课程的内容”,那就不属于这个项目的范围了。技术手段再强,也不能去碰这些边界。
拆解出具体字段后,还要想清楚采集频率和规模。在我这个项目里,目标是每天采集 5000 到 10000 条公开视频信息,单次任务运行时间控制在 2 小时以内,对实时性要求不高,但对稳定性和去重率要求比较高。这就决定了后面的技术选型。
1.2 为什么是 Python + Scrapy,而不是 requests + 手写循环
很多人一开始会写一个 requests 脚本,循环请求、解析、保存,看起来简单直接。但一旦需要采集的数据量上来,你会立刻遇到这些问题:
- 请求失败重试逻辑得自己写,而且容易写得很糙;
- 并发控制稍不注意就会被封;
- 爬虫跑挂了没人知道,重启后从头来;
- 数据去重、管道清洗、入库逻辑全塞在一个脚本里,维护成本爆炸。
Scrapy 解决的就是这些“工程化”问题。它的核心优势在于:
- 异步并发机制:Scrapy 底层基于 Twisted 异步框架,默认的并发数是 16,也就是说它可以在同一个进程里同时处理 16 个请求,而不是像普通 requests 脚本那样逐个等待。这个效率差距在数据量大了之后非常明显。
- 中间件体系:下载中间件可以统一处理 UA 轮换、代理切换、重试、请求头定制,不需要散落在业务代码里。
- Item Pipeline 管道:数据清洗、去重、入库可以拆成一个个独立的 Pipeline,代码结构清晰,后面加新功能只需要加一个 Pipeline 类。
- 内置去重机制:利用 scrapy 的 RFPDupeFilter 可以根据请求 URL 做指纹去重,大部分场景下够用了。
所以我的结论是:如果你只是临时跑几十条数据,requests 完全够;但如果是要持续、稳定、可维护地采集,直接上 Scrapy 是值得的,后面省下的维护时间远远超过你学框架的时间。
1.3 整体架构和技术栈
这个项目的最终架构是这样组织的:
| 模块 | 选择 | 说明 |
|---|---|---|
| 核心框架 | Scrapy 2.5+ | 负责调度、抓取、解析核心流程 |
| 数据解析 | Scrapy Selector + 正则 | 结合 XPath 和 CSS 选择器,特殊字段用正则处理 |
| 动态内容 | Playwright 按需接管 | 只有极少数页面是纯 JS 渲染时才启用,日常不用 |
| 数据存储 | MySQL 5.7 / 8.0 | 用于存储结构化数据,方便分析团队直接查库 |
| 去重存储 | Redis | 使用 scrapy-redis 的去重指纹模块做增量去重 |
| 定时调度 | crontab + shell 脚本 | 每天凌晨执行一次增量采集任务 |
| 监控告警 | 钉钉机器人 Webhook | 任务异常时发送通知 |
这套组合的好处是:Scrapy 负责主要抓取,Redis 负责去重和分布式扩展,MySQL 负责存储,每个组件都是围绕“稳定采集”这个目标服务的。后面所有的开发都是在这个骨架上往里填肉。
2. Scrapy 爬虫核心链路拆解:从请求到入库
2.1 抓包与接口分析:先看数据从哪来
做任何爬虫,第一步都不是写代码,而是搞清楚页面数据到底是怎么加载出来的。打开浏览器开发者工具,切到 Network 面板,刷新页面,你会看到大量请求。我通常会先过滤出 XHR/Fetch 请求,因为这些往往就是数据的真正来源。
抖音 Web 端和其他大多数现代站点一样,采用的是接口返回 JSON 的方式。页面上你看到的“标题、点赞数、评论数”,很多都来自一个固定的 API 接口,返回结构是标准的 JSON,包含视频描述、统计信息、作者信息、视频地址等。找到这个接口以后,不要急着写代码,先在浏览器里把请求头完整复制下来,逐个字段去理解。
我一般会重点看这几个字段:
User-Agent:客户端标识,平台端会用它判断请求来自浏览器还是脚本;Referer:来源页面,很多接口会校验这个字段是否合法;Cookie:如果是需要登录态的数据,Cookie 必不可少,但前面说了,这个项目不碰登录态;Request Method:是 GET 还是 POST,参数怎么传的。
这里要强调一个细节:浏览器调试面板里看到的请求头顺序并没什么影响,但请求头的完整性很重要。少了 Referer,或者 UA 用的是默认 Python-requests 的 UA,大概率直接返回异常。下面这段代码是我在项目里实际用的请求头配置方式:
def get_default_headers() -> dict: return { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36" ), "Referer": "https://www.douyin.com/", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "keep-alive", }另外,部分接口的响应是加密或混淆过的。遇到这种情况,我建议你先在 Network 面板里找到发起请求的 JS 文件,断点调试一下看看参数是怎么生成的。但提醒一句:如果你的目的是正常的数据分析,而不是专门研究逆向,遇到无解的加密参数时最好绕开,不要死磕。一个项目里总有替代方案,比如换一个数据源页面,或者直接用 Playwright 渲染后取值。我在这个项目里就遇到过解析参数的情况,后面会专门说。
2.2 爬虫主体代码实现与请求头处理
Scrapy 的爬虫类继承自scrapy.Spider,核心方法是start_requests()和parse()。start_requests()负责生成初始请求,parse()负责解析响应。
这个项目的爬虫主流程是这样的:
import scrapy from scrapy.http import JsonRequest class DouyinSpider(scrapy.Spider): name = "douyin_spider" def start_requests(self): # 从任务队列中读取要采集的话题或用户ID tasks = self.settings.get("TASK_LIST", []) for task in tasks: api_url = task["api_url"] yield JsonRequest( url=api_url, headers=self.get_headers(), callback=self.parse, dont_filter=False, errback=self.handle_error, ) def parse(self, response): data = response.json() video_list = data.get("data", {}).get("list", []) if not video_list: self.logger.warning("当前请求未返回视频列表,可能触发风控") return for item in video_list: yield self.extract_video_item(item) # 如果还有下一页,继续请求 next_cursor = data.get("data", {}).get("cursor", "") if next_cursor: yield JsonRequest( url=self.build_next_url(next_cursor), headers=self.get_headers(), callback=self.parse, )有几个注意点:
- JsonRequest 比 Request 更适合接口请求,因为它会默认把 Content-Type 设置为 application/json,有些后端接口会校验这个头。
- dont_filter 要按场景设置。对于翻页请求,URL 中 cursor 参数不同,不会重复;但对于某些返回同样 URL 的请求,需要思考是否需要去重。
- errback 一定要写。没有错误回调的爬虫,请求失败后你只能通过日志找问题,调试体验非常痛苦。我在 errback 里通常加上对 Response 状态码的判断,同时对超时类异常单独记录。
在解析字段时,我会把每个字段的获取代码独立成函数。例如获取视频封面图:
def extract_cover_url(self, item: dict) -> str: try: cover_url = item["video"]["cover"]["url_list"][0] except (KeyError, IndexError, TypeError): cover_url = "" return cover_url这样做的目的是避免某个字段解析异常导致整个 item 失败。数据采集项目里,脏数据是常态,防御性编程非常关键。
2.3 中间件:UA池、延迟与重试策略
Scrapy 的下载中间件(Downloader Middleware)是在请求发送给下载器之前、响应返回给爬虫之前执行的钩子。它在整个框架里属于“兵家必争之地”,很多爬虫稳定性问题都出在这里。
我这个项目里写了三个中间件,分别负责 UA 轮换、代理切换、请求延迟控制。先看 UA 轮换的代码:
import random from scrapy import signals class RandomUserAgentMiddleware: USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/121.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Edg/122.0", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36", ] def process_request(self, request, spider): request.headers["User-Agent"] = random.choice(self.USER_AGENTS) return NoneUA 池数量不需要太多,关键是不同 UA 的浏览器版本、操作系统不要全都一样,最好是 Chrome/Edge/Firefox 混着来。我第一次做的时候从网上找了一堆乱七八糟的 UA 列表,结果反而因为 UA 和系统环境不匹配被识别出来。现在只用这几个常用的,稳定很多。
请求延迟控制也很重要。Scrapy 本身有DOWNLOAD_DELAY配置,但它是固定延迟,不够灵活。我写了一个带随机抖动的延迟中间件:
class RandomDelayMiddleware: def __init__(self, min_delay, max_delay): self.min_delay = min_delay self.max_delay = max_delay @classmethod def from_crawler(cls, crawler): return cls( min_delay=crawler.settings.getfloat("RANDOM_DELAY_MIN", 1.0), max_delay=crawler.settings.getfloat("RANDOM_DELAY_MAX", 3.0), ) def process_request(self, request, spider): delay = random.uniform(self.min_delay, self.max_delay) spider.logger.debug(f"当前请求延迟 {delay:.2f}s") time.sleep(delay) return None之所以用随机延迟,是因为固定频率的请求在服务端看来很有规律,容易被识别;而完全无延迟的高并发又会压垮对方服务器。随机延时是在效率和安全性之间的折中方案。实际项目中,我把延迟范围设在 1 到 3 秒之间,单机单 IP 每天跑几千条数据没太大问题。
3. 数据解析与存储设计:字段、去重与增量更新
3.1 视频信息字段的抽取思路
拿到接口返回的 JSON 后,第一件事是把它存成 JSON 文件留档,然后再开始写解析逻辑。这样做的好处是,如果后面字段拿错了,可以直接对照原始数据排查,不用再重新发一次请求。
我在这个项目里定的字段表如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| aweme_id | string | 视频唯一 ID |
| title | string | 视频描述文字 |
| author_nickname | string | 作者昵称 |
| author_uid | string | 作者唯一 ID |
| like_count | int | 点赞数 |
| comment_count | int | 评论数 |
| share_count | int | 分享数 |
| create_time | datetime | 发布时间 |
| video_play_url | string | 无水印播放地址 |
| video_cover_url | string | 封面图地址 |
| topic_list | string | 关联话题,逗号分隔 |
| crawl_time | datetime | 抓取时间 |
其中video_play_url是从接口返回的地址里进一步解析出来的。很多情况下,接口返回的播放地址可能带有额外的 token 参数,这些参数会过期。因此存储时需要把完整地址存下来,同时也存一份纯地址,后面做数据清洗时再修正。
字段抽取有几个容易踩的坑:
like_count这类数字字段,接口里可能直接返回字符串,也可能是数字,需要统一转成 int;- 作者信息有时候是嵌套对象,直接取不到,要先做存在性判断;
- 话题列表可能是
[{"hashtag_name": "xxx"}]这种结构,需要先提取再进行拼接。
我在项目里封装了一个extract_video_item方法,专门处理这些坑:
def extract_video_item(self, item: dict) -> dict: author_info = item.get("author", {}) or {} topic_list_raw = item.get("text_extra", []) or [] topic_names = [ topic.get("hashtag_name", "") for topic in topic_list_raw if topic.get("hashtag_name") ] return { "aweme_id": item.get("aweme_id", ""), "title": item.get("desc", "").strip(), "author_nickname": author_info.get("nickname", ""), "author_uid": author_info.get("uid", ""), "like_count": int(item.get("statistics", {}).get("digg_count", 0) or 0), "comment_count": int(item.get("statistics", {}).get("comment_count", 0) or 0), "share_count": int(item.get("statistics", {}).get("share_count", 0) or 0), "create_time": datetime.fromtimestamp(item.get("create_time", 0)), "video_play_url": self.extract_play_url(item.get("video", {})), "video_cover_url": self.extract_cover_url(item.get("video", {})), "topic_list": ",".join(topic_names), "crawl_time": datetime.now(), }注意看,我在每个嵌套字段上都做了空值兜底。你不确定接口什么情况下会缺字段,所以最稳妥的方式是“把它当成一个随时会缺胳膊少腿的对象来处理”。
3.2 Item Pipeline 与 MySQL 存储
Scrapy 的 Item Pipeline 是一条流水线,item 会依次经过你定义的每个 Pipeline,每个 Pipeline 可以决定是继续传递还是丢弃。我把它切成三段:去重 Pipeline、清洗 Pipeline、存储 Pipeline。
看存储 Pipeline 的实现,这是这个项目里最基础的代码之一:
import pymysql from twisted.enterprise import adbapi class MySQLStorePipeline: def __init__(self, db_pool): self.db_pool = db_pool @classmethod def from_crawler(cls, crawler): db_config = { "host": crawler.settings.get("MYSQL_HOST", "localhost"), "port": crawler.settings.getint("MYSQL_PORT", 3306), "user": crawler.settings.get("MYSQL_USER", "root"), "password": crawler.settings.get("MYSQL_PASSWORD", ""), "database": crawler.settings.get("MYSQL_DB", "douyin"), "charset": "utf8mb4", } db_pool = adbapi.ConnectionPool("pymysql", **db_config) return cls(db_pool) def process_item(self, item, spider): # 使用线程池异步执行 SQL,避免阻塞爬虫主流程 query = """ INSERT INTO video_info (aweme_id, title, author_nickname, author_uid, like_count, comment_count, share_count, create_time, video_play_url, video_cover_url, topic_list, crawl_time) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ params = ( item["aweme_id"], item["title"], item["author_nickname"], item["author_uid"], item["like_count"], item["comment_count"], item["share_count"], item["create_time"], item["video_play_url"], item["video_cover_url"], item["topic_list"], item["crawl_time"], ) return self.db_pool.runQuery(query, params)我用了adbapi.ConnectionPool,这是 Twisted 提供给异步程序访问数据库的连接池方式。如果不这么做,在 Pipeline 里直接用 pymysql 执行同步 SQL 会阻塞事件循环,导致爬虫性能明显下降。
建表 SQL 长这样:
CREATE TABLE IF NOT EXISTS video_info ( id INT AUTO_INCREMENT PRIMARY KEY, aweme_id VARCHAR(64) NOT NULL, title VARCHAR(1024) DEFAULT '', author_nickname VARCHAR(255) DEFAULT '', author_uid VARCHAR(64) DEFAULT '', like_count INT DEFAULT 0, comment_count INT DEFAULT 0, share_count INT DEFAULT 0, create_time DATETIME DEFAULT NULL, video_play_url TEXT, video_cover_url TEXT, topic_list VARCHAR(2048) DEFAULT '', crawl_time DATETIME DEFAULT NULL, UNIQUE KEY uk_aweme_id (aweme_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;uk_aweme_id唯一键很重要。即使应用层去重没兜住,数据库层也能挡住重复数据,相当于多了一层防线。
3.3 去重策略与增量采集设计
采集任务最怕重复数据。同一个视频第一次采集的时候没有人知道,第二次再采的时候如果不做去重,数据库里就会出现大量重复记录,分析结果也会失真。
这个项目用的是“Redis 指纹 + Scrapy 默认去重”双保险。Scrapy 的RFPDupeFilter会对请求 URL 做 SHA1 指纹,如果 URL 相同则不再请求。但这里有个问题:抖音的翻页接口 URL 带有 cursor 参数,每次翻页 URL 都不一样,所以针对视频本身的去重不能依赖 URL,要在 Item 层面做。
我在去重 Pipeline 里用 Redis 的SADD命令,把视频aweme_id作为成员加入 Set 集合。如果返回值是 0,说明这个 ID 已经存在,就丢给DropItem异常处理:
from scrapy.exceptions import DropItem import redis class DuplicatePipeline: def __init__(self, redis_client): self.redis_client = redis_client @classmethod def from_crawler(cls, crawler): redis_client = redis.Redis( host=crawler.settings.get("REDIS_HOST", "localhost"), port=crawler.settings.getint("REDIS_PORT", 6379), db=crawler.settings.getint("REDIS_DB", 0), decode_responses=True, ) return cls(redis_client) def process_item(self, item, spider): added = self.redis_client.sadd("douyin:video_ids", item["aweme_id"]) if added == 0: raise DropItem(f"重复视频: {item['aweme_id']}") return item增量采集的核心思想就是:每次任务启动时,只需要从上次采集的结束位置继续,而不是每次都从第一页开始。我把每个任务的起始 cursor 存在 Redis 的 Hash 里,每次采集完成后更新:
def save_task_cursor(self, task_name: str, cursor: str): self.redis_client.hset("douyin:task_cursors", task_name, cursor) def get_task_cursor(self, task_name: str) -> str: return self.redis_client.hget("douyin:task_cursors", task_name) or "0"这样配合 crontab 定时任务,每天就能稳定地增量采集新数据,而不是每次全量扫描。
4. 反爬机制的规律性认识与合规底线
4.1 常见的反爬手段和对应的合理策略
任何有一定规模的平台都会做反爬,抖音也不例外。但反爬不是铁板一块,它是有规律可循的。这个项目实际踩过的问题,我整理成了下面几个大类,以及对应的处理策略:
| 反爬手段 | 现象 | 合理的应对思路 |
|---|---|---|
| 请求头校验 | 返回 403 或空数据 | 伪装完整浏览器请求头,尤其是 UA、Referer |
| 频率限制 | 请求过快后返回验证页 | 随机延迟、限制并发数、任务分散到不同时间段 |
| IP 封禁 | 后端返回“访问异常” | 降低单 IP 请求量,必要时使用代理池(注意合规性) |
| 签名参数 | 接口参数包含加密字段 | 分析 JS 生成逻辑,或改用渲染方式直接取数据 |
| 数据接口改版 | 原有字段消失或返回格式变化 | 建立接口监控,定期检查字段兼容性 |
这里要强调:我没有用任何“绕过验证码”“破解签名”的手段。遇到滑块验证这类强交互验证,我的策略是停止当前任务,降低频率,等待风控解除。因为这已经超出正常数据采集的边界了,继续硬刚得不偿失。
4.2 这些操作绝对不能碰
做爬虫的人多少都会遇到一些“灰色手段”,我给自己的项目立了几条红线,这些红线也是你如果要在公开展示源码时需要守住的底线:
- 不采集私密账号数据。设置为私密的账号,其内容本来就不打算对外公开展示,强行去拿属于越权。
- 不绕过登录态。平台要求登录才能看的内容,要么走官方开放 API,要么不要。不要用“模拟登录 + 保持 Cookie”的方式强行抓取需要登录才能访问的数据。
- 不破解验证码、不操作滑块。这些属于平台明确的风控对抗行为,一旦做了,就不仅仅是技术问题了。
- 不用于商业不正当竞争。采集到的公开数据如果用于抄袭、抹黑、批量搬运等行为,同样有法律风险。
- 不采集用户敏感个人信息。虽然公开页面可能有昵称和头像,但不要刻意去拼接、挖掘手机号、住址等隐私数据。
写爬虫的人应该比谁都清楚“数据是把双刃剑”。我把这些写进项目的 README 里,代码仓库里也放了一份《数据使用合规说明》。这不是做样子,而是真的在提醒自己:技术能力越强,越要谨慎。
4.3 合规红线:robots、频率与数据使用边界
合规不仅仅是“不碰私密数据”。即使你采集的数据全是公开的,也要考虑以下几个维度:
- robots 协议:虽然抖音的 robots.txt 可能没有明确允许爬虫,但作为从业者,你应该主动查看并理解站点声明。这里我不去深究法律定性,只说常识:即便技术上能爬到,也不代表应该高频抓取。
- 请求频率:不要让你的爬虫给对方服务器造成明显压力。正常浏览页面的频率是几秒一次,你的爬虫如果每秒钟发十几个请求,那就是另一种性质了。我在项目里设置的合理目标是:单 IP 每秒不超过 0.5 个请求,单日总量不超过一万。
- 数据使用边界:采集到的公开数据可以用于个人学习、学术研究,但如果要对外发布或商用,一定要确认数据的版权归属,尤其是视频内容和用户创作。很多人只关心“能不能爬到”,忽略了“爬到之后能不能用”,等收到侵权通知就晚了。
- 尊重“别碰”的提示:当接口开始返回风控页面或要求验证时,这就是平台在告诉你“你太快了、该停一下了”。正确做法是停下来,而不是去研究怎么绕过。我项目里的风控检测逻辑遇到这种情况会自动暂停任务并发送告警,而不是无限重试。
合规风险不是一篇文章能讲完的,但只要你守住上面几条,大部分坑都能避开。
5. 实操中踩过的坑与排查思路
5.1 请求超时与IP封禁的排查
这个项目最开始在本地跑的时候,经常出现请求超时。日志里全是twisted.web._newclient.TimeoutError: User timeout caused。第一反应是 IP 被限制了,但其实很多时候并不是,而是并发数太高加上网络抖动导致的。
排查步骤我整理成了固定的流程:
- 先用命令行 curl 模拟同一接口,看是否能正常返回。如果能,说明服务端没封你,问题出在爬虫配置。
- 看 Scrapy 日志里的下载失败统计。
scrapy crawl douyin_spider -s DOWNLOAD_TIMEOUT=15可以设置超时时间,但不要设置得太长,否则单次卡住会占住并发槽位。 - 检查
CONCURRENT_REQUESTS配置。我一开始为了追求速度,把它调到 32,结果频繁超时,降到 8 之后稳定很多。 - 看是否触发了代理。如果你通过代理池发请求,代理本身不稳定也会导致超时,这种情况要把超时时间定在 10 到 15 秒,并增加重试次数。
另外要注意,Scrapy 默认重试只会重试某些状态码。对于“返回 200 但实际是风控页面”的情况,重试机制是不生效的,必须自己在解析层判断。我在 parse 里面加了这样的逻辑:
if data.get("status_code") != 0 or "验证" in response.text: spider.crawler.engine.close_spider(self, reason="trigger_risk_control")这样一旦检测到风控,就停止爬虫,避免继续请求造成更严重的后果。
5.2 数据字段缺失与动态渲染
有一次我采集到的数据里,很多视频的封面图字段是空的。排查了一遍,发现接口在某些情况下不会返回video.cover,但在详情页里能看到封面。这就涉及到动态渲染问题。
抖音的 Web 端页面其实分两种:一种是接口直接返回的完整 JSON,一种是需要 JS 执行后才会渲染 DOM 的页面。如果你想从 DOM 里取数据,直接用 Scrapy 的 XPath 是拿不到的,因为 Scrapy 不具备执行 JS 的能力。这种情况下,我引入了 Playwright 来兜底,但只在 Scrapy 解析不到数据时才启用。
基本思路是:在下载中间件里判断当前请求是否属于“需要渲染”的 URL,如果是,则交给 Playwright 加载页面,等 Network 空闲后直接返回渲染后的 HTML。但这种方式开销很大,一个页面加载可能要好几秒,所以我在项目里默认关闭,只有特定任务才开启。日常采集的接口型任务完全不用它。
5.3 调试技巧:scrapy shell 与日志级别
最后分享一个非常实用的调试技巧。很多人写 Scrapy 爬虫都是直接scrapy crawl,报错了就看日志,但这样效率太低。我的习惯是先用scrapy shell单步调试:
scrapy shell "https://www.douyin.com/"进入 shell 后,你可以直接输入response.status查看返回码,用response.xpath(...)测试选择器,还能自由操作fetch()方法换 URL 调试。所有的解析逻辑,都应该先在这里验证通过,再写回爬虫代码里。
另外一个常用技巧是控制日志级别。Scrapy 默认的日志级别是 INFO,调试时把它调成 DEBUG 能看到更多请求细节:
scrapy crawl douyin_spider -s LOG_LEVEL=DEBUG但生产环境跑定时任务时,我坚持用 INFO,并且只保留 Error 级别的日志到文件,避免日志文件膨胀太快。日志配置在settings.py里这样写:
LOG_LEVEL = "INFO" LOG_FILE = "logs/scrapy.log" LOG_STDOUT = False如果你遇到解析字段死活取不到的情况,不要反复改代码跑全量,直接在 shell 里先确认 response.text 是否符合预期,再一步步找 XPath 或者 JSON 路径,这样能省至少一半的排查时间。
做这种项目,我的经验是:先把能跑通的“最小版本”做出来,再逐步加功能。一开始就追求什么都要,往往会陷入接口分析、反爬对抗的泥潭里出不来。我最初也只是先采集基本的视频 ID 和标题,跑通了之后才一步步加评论数、封面、话题这些字段。每一步加完都跑一遍验证,确保改动不会破坏前面已有的功能。
技术上的细节,我在项目源码包的 README 里写得更全,包括完整的依赖列表、MySQL 建表语句、Scrapy 配置说明、常见异常对照表。如果你也要做一个类似的采集项目,建议先把这篇文章里提到的几个重点——请求头完整性、延迟策略、去重机制、数据库唯一键、日志规范——梳理清楚,再开始动代码。这些地方做得扎实,你的爬虫才能真正从“能跑”进化到“能长期稳定地跑”。
本文还有配套的精品资源,点击获取