简介:这是一套面向Python初学者的豆瓣评书爬虫实践项目,以评书信息抓取作为教学场景,帮助学习者掌握网络爬虫与数据整理的核心流程。资源内容覆盖Python基础语法、HTTP请求与响应、BeautifulSoup/Scrapy页面解析、Selenium处理动态加载,以及借助pandas将抓取结果写入Excel并完成清洗转换。代码结构清晰,适合入门研读,也便于在掌握基础后扩展抓取字段或切换其他目标网站。项目还提供了从基础语法到实战运行的学习路径,适合按步骤理解爬虫逻辑、动手实践抓取与存储。资源配套的代码与说明能帮助读者快速定位知识点。压缩包约14.06MB。目前已有142人学习,对于希望快速体验完整爬虫流程的初级开发者,是一份可跟随实践的学习素材。
1. 先从拿到 zip 说起:这是豆瓣爬虫,别急着解压就运行
拿到DouBanSpider-master.zip这个包,第一反应别是双击解压然后执行python demo.py。这个 GitHub 上最常见的豆瓣爬虫工程,目标通常是抓电影、图书或者短评数据,代码结构不复杂,但有一个极易踩的坑:依赖、数据库和请求频率全是写死的,直接跑大概率要么报错要么被反爬挡住。它适合两类人:一类是刚学爬虫,想用豆瓣练手的学生;另一类是产品、运营想拿一份电影评分数据做分析,但不想从零写请求解析的开发者。这篇笔记我会按拿到压缩包后真实会走的流程来拆:先看结构,再配环境,然后跑通最小闭环,最后告诉你我在改这个项目时踩过的几个硬坑。
2. 解压之后先别运行:目录结构和数据流决定你要改哪里
2.1 一个典型 master 包里有哪些文件,分别干什么
常见做法是把整个项目压缩成带master后缀的包,解压后你会看到一堆.py文件和可能的README.md。我习惯先打开README.md看两眼,虽然很多作者的 README 只有一行pip install requests beautifulsoup4 lxml,但至少能知道入口文件。没有 README 也不要紧,直接看文件命名:
demo.py或main.py:通常就是主入口,里面定义了爬虫类的实例化、启动函数。douban_spider.py/spider.py:核心爬虫类,包含requests请求逻辑、HTML 解析逻辑。parser.py/lxml_utils.py:页面解析辅助函数,比如把电影标题、评分、导演从 HTML 里抠出来。db.py/sqlite_helper.py:数据库操作,负责建表、插入、去重。config.py/settings.py:配置项,可能会放请求头、URL 模板、爬取页数上限。
拿到这些文件后,别急着补依赖。先打开配置文件,看作者把目标 URL 写在哪,爬的是电影还是图书,因为豆瓣不同频道的页面结构完全不同,解析代码不通用。我见过有人拿到电影版爬虫,改了 URL 就去爬图书,解析出来的字段全是空,这就是没分清楚结构。
2.2 主爬虫循环:请求、解析、入库的顺序不能乱
豆瓣爬虫的骨架通常长这样:一个for循环遍历页数,每页发出一个GET请求,拿到 HTML 后用解析器提取需要字段,最后把字段写入数据库。理解这个顺序,是因为后面所有调参都是围绕这三件事展开的。
for page in range(0, max_pages): url = base_url + f"?start={page * 20}&filter=" html = fetch_html(url, headers=headers, cookies=cookies) items = parse_html(html) save_to_db(items) time.sleep(1.5)这段代码逻辑不复杂,但有一个非常关键的参数:start。豆瓣的分页不是页码,是起始偏移量,每页固定 20 条,所以第 1 页是start=0,第 2 页是start=20,第 5 页是start=80。如果你直接把循环变量当页码传进去?start=page,前几页数据会重复,后面全部失真。
另一个容易忽略的是time.sleep(1.5)。这个延迟是保命用的,豆瓣对高频请求很敏感,1.5 秒是大多数爬虫能稳定运行的底线。太低了,比如 0.2 秒,跑几十页后会突然开始返回一个包含「检测到有异常请求」提示的 HTML 页面,这时候解析出来的是空列表,但程序不知道,还会继续往数据库里写空值。
2.3 数据表设计:字段跟着你的目标走,不要照抄原作者的字段
原项目的数据库表结构通常是写死的,比如作者默认抓电影的五件事:片名、年份、评分、导演、演员。如果你只需要片名和评分,照样建五列也没问题,但更合理的做法是先确认自己要什么字段,再决定要不要改表结构。我一般会先在本地 SQLite 里建一张过度宽表,把所有能抓的字段都放上去,后续分析再裁剪,这比反复改表方便得多。
以电影为例,常见字段清单如下:
| 字段名 | 类型 | 说明 | 来源 |
|---|---|---|---|
| movie_id | TEXT | 豆瓣条目唯一 ID,从 URL 提取 | 列表页详情链接 |
| title | TEXT | 中文片名 | 列表页 h1 或 title 标签 |
| year | TEXT | 上映年份 | 列表页 span.year |
| rating | REAL | 评分,保留一位小数 | 列表页 span.rating_num |
| director | TEXT | 导演名,多个用顿号分隔 | 详情页 |
| cast | TEXT | 主演列表,用顿号分隔 | 详情页 |
| intro | TEXT | 剧情简介,注意可能很长 | 详情页 |
| created_at | DATETIME | 插入时间,用于去重和排查 | 程序生成 |
原包如果只爬列表页,没有详情页请求逻辑,那导演和简介都抓不到。这时你有两个选择:要么接受列表页字段,要么在解析完列表后,再对每个电影链接发起详情页请求。后者会让爬虫时间翻倍,但数据完整度提升很大,具体取舍看你的分析目标。
3. 跑起来的三件套:虚拟环境、数据库、最小闭环命令
3.1 用 venv 隔离依赖,别把 requests 装到全局环境里
这个包依赖的第三方库很常见:requests、beautifulsoup4、lxml。但不同系统上默认的 Python 版本不同,有些项目的代码还是写的urllib2这种 Python 2 语法,拿到新环境里直接导入就报ModuleNotFoundError。我一般会先建一个干净的虚拟环境,避免把项目依赖和系统依赖搅在一起。
python3 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install requests beautifulsoup4 lxml这里的.venv目录名可以随便起,但不要用venv之外的奇怪名字,因为后续.gitignore通常已经忽略了.venv。如果代码里用了pymysql或者psycopg2连接 MySQL,记得一并安装,但前置条件是你先得有对应数据库服务。先跑 SQLite 版本是风险最低的,因为不需要额外安装数据库服务。
3.2 建库建表:SQLite 顶得住一万条数据,MySQL 留到上亿再说
豆瓣爬虫的数据量能到多少?如果只爬电影列表,豆瓣全站约几十万条,加上图书、音乐,百万级别。SQLite 对百万条数据完全能扛,而且零配置、单文件,非常适合个人分析和模型训练。MySQL 的优势在于多进程并发写入和远程访问,如果你打算部署到服务器上,让多个爬虫实例同时写库,才需要换 MySQL。
原项目如果默认用 SQLite,你会在db.py里看到类似sqlite3.connect('douban.db')的代码。如果默认用 MySQL,需要手动建库,我很不建议直接跑原项目的建表语句,因为字段类型可能不匹配。下面这个建表语句是电影数据的常用版本:
CREATE TABLE IF NOT EXISTS movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, movie_id TEXT UNIQUE, title TEXT NOT NULL, year TEXT, rating REAL, director TEXT, cast TEXT, intro TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );movie_id加UNIQUE约束非常关键,这是避免重复插入的核心手段。后续每次插入前先INSERT OR IGNORE或者ON CONFLICT,就能保证同一个电影只入库一次。原项目如果没做这层约束,你要自己加上,否则每次重新跑都会把数据复制一份。
3.3 第一个可运行命令:先限制页数跑通闭环
拿到原项目后,不要直接执行整个爬虫。先用head或者less看一眼主入口文件,找到控制页数的变量,把它改成 1 或者 2。然后执行:
python demo.py如果不出意外,第一次跑会失败,失败原因基本是这三类:缺少某个库、数据库文件没生成、请求头里的 User-Agent 过期。逐个解决后,你会看到终端里刷出几个条目,SQLite 文件也出现了。这时候用一条 SQL 验证数据是否正确:
SELECT title, rating, year FROM movies LIMIT 10;只要表里有数据,且 rating 不是 0 或空,就说明闭环跑通了。接下来再根据需求调整页数上限和延迟。
4. 豆瓣反爬的四个实操避坑点:从封 IP 到字段丢失
4.1 请求频率太快导致 302 跳转,爬到的全是验证页空数据
现象:爬虫跑到第 30 页后,连续几十条的title字段是缺失的,数据库里写入了一堆只有movie_id和空字符串的记录。打开抓包工具看,会发现响应状态码是 302,实际跳转到了/passport/block。
原因:豆瓣对单位时间内的请求数有限制,超过阈值后不再返回正常列表页,而是返回一个需要滑块验证的页面。爬虫解析器在这个页面上找不到电影标题,自然写入了空值。
解决:把延迟从1.5改为2.5到4之间的随机值,并且每次请求前用random.uniform抖动一下。另一个有效手段是给请求加Cookie,登录后的 Cookie 能显著提高阈值。我处理过这类问题后,会把请求函数改成一个带重试的版本,遇到状态码不是 200 时,先把当前页号记录下来,然后等 10 秒再重试,而不是直接跳过。这样最坏情况只是慢,但不会丢数据。
4.2 Cookie 失效后登录态丢失,短评和评分抓不全
现象:抓电影详情页时,前 500 条数据都正常,之后rating和短评数开始变成默认值 0,但电影标题还能抓到。
原因:豆瓣的有些字段是登录用户才能完整获取的,比如完整的短评列表、评分人数。Cookie 通常有一个有效期,超过后请求会退化为匿名访问,部分数据就取不到。但注意,不是所有页都依赖 Cookie,详情页的基础字段游客也能看,所以表现是「部分字段缺失」而非「整页失败」。
解决:在config.py里准备一个cookies字典,直接粘贴浏览器里的完整Cookie字符串。更规范的做法是使用requests.Session(),先登录一次,把session.cookies持久化到本地文件,下次爬取时加载。下面是一个常见的持久化写法:
import pickle import requests session = requests.Session() # 首次登录后保存 with open('cookies.pkl', 'wb') as f: pickle.dump(session.cookies, f) # 后续加载 with open('cookies.pkl', 'rb') as f: session.cookies.update(pickle.load(f))Cookie 过期是逃不开的,我习惯每周重登一次,然后把新的 Cookie 替换到项目里。另外注意,不要同时用多个账号的 Cookie 混在同一个请求里,这会导致 session 错乱,玄学一点说,还容易被判定为共享账号。
4.3 分页循环的 start 参数是 0-20-40,不是页码
现象:爬取 5 页,但最终数据库里只有 18 条独立movie_id,还有重复记录,总数只有预期的十分之一。
原因:列表页的分页规律不是?page=1&page=2,而是?start=0&start=20。如果按页码传start=1和start=2,第一页取的是偏移 1 到 20 条,第二页取 2 到 21 条,中间大量重叠。原项目如果默认按页数循环,但 URL 模板里写的是page_num或者其他变量,你没看仔细就直接用,就会出现这种翻车。
解决:把循环里的页码转换成偏移量,用(page_index - 1) * 20作为start值,同时确认page变量在循环里没有被别的函数修改。我见过一种隐蔽的写法:for page in range(1, max_pages + 1),但 URL 模板里却是?start=%s,直接把page传进去,最后爬到的是偏移从 1 开始的错位数据。排查时看数据库里movie_id是否连续,就能立刻定位到这个问题。
4.4 字符串解析翻车:用正则还是用 BeautifulSoup,决定你的字段能否拿全
现象:爬到的director一栏全是空,或者intro里混了大量的 HTML 标签,像是<p>、<a>残留。
原因:作者在parser.py里可能用了正则表达式提取字段,但豆瓣改版后,原来的正则匹配失效了。正则适合提取结构稳定的字段,比如网页尾部window.__DATA__这种,但豆瓣的列表页是服务端渲染,HTML 结构改动频繁,写死正则等于定时炸弹。
解决:把解析逻辑统一改成 BeautifulSoup + CSS 选择器或者 XPath。我是这样做的:先打开网页源码,把目标字段所在的容器div结构找出来,然后用.select('span.rating_num')这样的选择器去拿。下面是电影列表页的解析示例:
from bs4 import BeautifulSoup def parse_list_html(html): soup = BeautifulSoup(html, 'lxml') items = [] for item in soup.select('div.item'): title_tag = item.select_one('.hd a') rating_tag = item.select_one('.rating_num') year_tag = item.select_one('.hd .year') director_tag = item.select_one('.bd .color-blue') items.append({ 'movie_id': title_tag['href'].split('/')[4] if title_tag else None, 'title': title_tag.get_text().strip() if title_tag else None, 'year': year_tag.get_text().strip('()') if year_tag else None, 'rating': float(rating_tag.get_text().strip()) if rating_tag else None, 'director': director_tag.get_text().strip() if director_tag else None, }) return items注意.select_one在找不到元素时返回None,所以每个字段都要做空值兜底,否则一行数据因为缺一个标签就报AttributeError,整个爬虫中断。这是原项目最容易崩的地方,也是我加了最多try-except的地方。
4.5 重新运行会重复入库,需要去重逻辑
现象:第一次爬了 1000 条,过两天又爬一次,数据库涨到 1800 条,但实际新增只有 600 条,多出来的 200 条是原项目自己的去重逻辑没生效。
原因:很多简化版爬虫根本没有做去重,每次启动都是无条件INSERT。如果表里没有UNIQUE约束,重复数据就一直在涨。
解决:在数据库层加UNIQUE约束后用INSERT OR IGNORE。如果你的movie_id是从 URL 里提取的,但提取逻辑偶发返回空值,UNIQUE对空值不生效,空值可以插多次。所以还要在解析后加一道过滤:
if not item['movie_id']: continue另外,如果你想保留「重新爬取但更新评分」的能力,用INSERT INTO ... ON CONFLICT(movie_id) DO UPDATE SET rating=excluded.rating代替IGNORE,这样不会因为重复执行而丢失新评分。这是我在实际分析电影票房走势时最常用的一个改动。
5. 把原版改成自己的爬虫:换 URL、扩字段、换存储
5.1 从电影版改到图书版:改哪些位置最省力
很多人拿的是电影版DouBanSpider,但要爬的是豆瓣图书。两个频道结构相似,但不完全一样。图书列表页的容器类名不同,评分、作者、出版社字段的位置也不同。最省力的做法是:先手动打开图书列表页,用开发者工具查看元素的 CSS 选择器,再替换parser.py里的选择器和字段名。
具体需要改的位置只有三个:base_url、字段提取方法、建表语句。举例来说,电影列表页的条目容器是div.item,图书列表页是tr.item,标题字段从.hd a变成了.title a,出版社和价格在.pub里。如果你只把base_url改了,解析器不换,爬回来的数据全是None,这不算 bug,是结构差异。
5.2 字段扩展:把详情页和短评一起爬
原版通常只爬列表页,字段数固定。如果你想要每条电影的完整演职员表、短评内容,就要在列表解析之后,拿到movie_id,再拼接详情页 URL 发起第二次请求。这会让爬取时长从分钟级变成小时级,所以要把请求函数做成可复用的,并且加上线程池。
from concurrent.futures import ThreadPoolExecutor import requests def fetch_detail(movie_id): url = f"https://movie.douban.com/subject/{movie_id}/" resp = requests.get(url, headers=headers, timeout=10) # 解析详情页字段,返回 dict return parse_detail(resp.text) with ThreadPoolExecutor(max_workers=4) as pool: detail_map = dict(pool.map(lambda mid: (mid, fetch_detail(mid)), id_list))max_workers=4是我测试下来比较稳的并发数,再高容易触发反爬。注意fetch_detail里也要带延迟,否则等于把并发优势变成封号风险。这种改造要额外谨慎,因为详情页请求一旦高频,触发验证的概率是列表页的好几倍。
5.3 存 CSV 还是 SQLite:数据量小别折腾数据库
如果你只是做一次分析,拉三五页数据就够了,完全没有必要建 SQLite。直接用csv模块写文件更直观。原项目如果死板地只支持数据库,你可以自己写一个save_to_csv函数,在调用处替换掉save_to_db。下面是一个简单的 CSV 写入:
import csv def save_to_csv(items, filename='douban_movies.csv'): with open(filename, 'a', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=items[0].keys()) if f.tell() == 0: writer.writeheader() writer.writerows(items)注意编码用utf-8-sig,这样用 Excel 打开 CSV 时中文不会乱码。如果用默认的utf-8,Excel 会认为那是 ANSI 编码,第一列中文显示成乱码。这个坑我早期踩过好几次,后来写文件必然加-sig。
6. 让爬虫更稳的两招:断点续爬与请求重试
我最后把这个项目的表现提升一个档次的,是在fetch_html里加了「失败重试 + 当前进度落盘」。豆瓣爬虫最难受的事是跑到一半被中断,下次从头再跑,多花几个小时,还会重复写库。我的方案是维护一个已成功页号的集合,每次抓完一页就把页号追加到progress.txt,下次启动时读取它,跳过已完成的页。核心逻辑是:
import os import time import random def get_done_pages(path='progress.txt'): if not os.path.exists(path): return set() return set(int(line.strip()) for line in open(path)) def mark_page_done(page, path='progress.txt'): with open(path, 'a') as f: f.write(f"{page}\n") def fetch_with_retry(url, headers, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, headers=headers, timeout=8) if resp.status_code == 200: return resp.text elif resp.status_code == 302: time.sleep(10 * (attempt + 1)) except requests.exceptions.ConnectionError: time.sleep(5) return Nonefetch_with_retry把超时和 302 都当成临时失败处理,重试三次还不行就返回None,外层循环看到None后再等待更久或者结束爬虫。我习惯把主循环写成遇到连续 3 个None就自动停,因为这说明 IP 或者账号已经进入风控,硬爬只会浪费时间。
还有一个调节奏的「玄学参数」:每次请求之后time.sleep(random.uniform(1.5, 3.5))。这个比固定延迟更有效,因为固定节奏容易被简单的频率统计识别。路径上,我会把progress.txt放在和.db同级目录,爬完一次重新恢复时,只跑掉没跑完的那几页。这套组合改完后,哪怕运行中电脑休眠、网络断开,恢复后也是接着跑,不会从头开始。
希望这个从解压到收敛的流程能帮到你,至少下次再拿到类似的*-master.zip,你不会直接python main.py然后看着报错发呆了。
本文还有配套的精品资源,点击获取