
你有没有发现学爬虫最难的不是不会写代码而是不知道拿什么当练手对象。太简单的测试站练不出东西直接上电商平台又容易被反爬劝退。如果你和我一样卡在这一步豆瓣电影 Top250 几乎是绕不开的经典项目数据量适中、页面结构规整、反爬强度刚好卡在“有挑战但不会气死人”的位置爬完还能顺手做点数据分析。这篇文章完整记录了我从零实现这个爬虫的全过程从页面结构拆解、单页解析、分页全量抓取到实际运行中踩过的各种坑完整的可运行源码也会贴在文末你来照着敲就能跑通。1. 为什么我推荐用 Top250 做第一个完整爬虫项目1.1 页面静态、结构规整不需要啃 JS 逆向现在很多网站的数据都藏在接口返回的 JSON 里页面本身只是一个空壳需要通过动态渲染才能拿到数据。你要么去抓接口要么得分析一长串加密参数。对刚上手的人来说很容易在第一步就挫败。但豆瓣电影 Top250 不一样它的榜单列表是服务端渲染的静态 HTML所有电影信息都直接写在页面源代码里用最基础的requests发送 GET 请求获取到的 HTML 里就包含排名、片名、评分、评价人数这些完整数据。这意味着你不需要面对 Selenium、Playwright 等浏览器自动化工具也不需要理解什么是 JS 逆向。只要掌握requests加BeautifulSoup这一对组合就能在几分钟内看到真实的产出。对一个爬虫新手来说没有什么比“很快跑通并拿到数据”更能建立信心。1.2 数据字段丰富正好锻炼不同类型的解析技巧Top250 每部电影包含的信息很有意思既有纯文本的排名和标题也有数字类型的评分和评价人数还有可选的经典台词和详情页链接。这种字段组合涵盖了日常爬虫工作中大部分常见的解析场景字段示例特征解析注意点排名1纯数字文本标题中文名 / 外文名可能存在两个span.title导演与主演多条文本换行需要清洗空白与换行评分9.7数字文本评价人数2290537人评价需要提取数字部分经典台词一句话部分电影缺失需要判空详情链接/subject/xxxx/从href属性取值能把这些字段干净地提取出来你基本就掌握了静态页面上最常见的文本、属性、列表和缺省值处理。以后再抓其他网站很多逻辑是可以平移的。1.3 反爬强度刚好踩在“有挑战但不劝退”的线上完全裸奔的测试站虽然好抓但练不出什么东西。反过来那些上验证码、风控复杂的网站又会消耗大量精力。豆瓣的榜单页算是比较友好的直接请求大概率能拿到数据但它也会检查 User-Agent会因为你连续高频率请求而对 IP 做临时限流。这种“有点防御但不至于无解”的状态正好逼着你学会伪装请求头、控制请求节奏、处理异常状态码。换句话说把这个项目跑通你就补齐了一个正常爬虫该有的基本功请求、解析、清洗、存储、重试、节奏控制。这些能力在后续爬任何网站时都用得上。2. 动手前的准备环境、URL 规律和页面结构2.1 Python 环境与依赖安装我用的 Python 版本是 3.8代码本身不涉及过于花哨的语法你在 3.6 往上应该都没问题。安装依赖用 pip 一行命令pip install requests beautifulsoup4 lxml这里我明确建议安装lxml解析器它的解析速度比 Python 内置的html.parser快不少而且对 CSS 选择器支持得更好。如果你在.select()时碰到奇怪的解析结果优先检查是不是解析器选错了。测试环境的时候可以先在 Python 里执行import requests和from bs4 import BeautifulSoup确保都正常导入。2.2 Top250 的分页 URL 规律列表页第一页的 URL 是https://movie.douban.com/top250?start0filter你可能会好奇后面的filter是干什么的。它其实是豆瓣用来区分榜单和用户“看过”“未看”分组的一个参数抓取的时候保留这个参数更贴近浏览器默认请求不会影响榜单内容。重点在start参数上第 1 页start0第 2 页start25第 3 页start50第 10 页start225可以看出规律就是每页 25 条数据start每次递增 25。因此我们抓取时可以用range(0, 250, 25)生成 10 个起始值循环构造 URL。如果你不是从一开始抓而是想从某页开始只要调整起始数字就行非常灵活。这里也顺便提一下更稳妥的做法不是硬编码 10 页而是解析页面底部的“后页”链接判断是否还有下一页。不过就 Top250 这个固定榜单来说直接用range(0, 250, 25)就足够了。我下面给的完整代码采用的就是这个方式从第 0 页抓到第 225 页。2.3 HTML 结构关键节点定位在写解析代码之前我强烈建议你先用浏览器打开排行榜按 F12 进入开发者工具随便选中一部电影看看它的 DOM 结构。别看网上教程写了选择器就照抄网站改版是很正常的事你看到的 class 可能和我写的不太一样。当前榜单比较常见的一种结构大致是这样ol classgrid_view li div classitem div classpic em1/em a hrefhttps://movie.douban.com/subject/1292052/ img alt电影名 ... /a /div div classinfo div classhd a href... span classtitle电影中文名/span span classtitle / 外文名/span /a /div div classbd p class 导演: ...br 主演: ...br 上映年份 / 国家 / 类型 /p div classstar span classrating5-t/span span classrating_num propertyv:average9.7/span span propertyv:votes2290537人评价/span /div p classquote span classinq经典台词/span /p /div /div /div /li /ol整部电影被包裹在ol.grid_view下面的每个li里每一部就是一条独立记录。我们可以用soup.select(ol.grid_view li)拿到所有电影然后在每条记录里继续用 CSS 选择器定位具体字段。这个过程就是我们接下来要实现的解析逻辑。3. 单页抓取从 0 到 25 条数据的完整实现3.1 请求头设置与响应编码陷阱写爬虫的第一步是先把页面拉下来。很多人刚开始传一个不加任何请求头的 GET 请求结果发现返回的内容跟你用浏览器看到的完全不一样甚至直接收到 403。这就是因为服务器在检查你是谁。一个普通的requests默认 User-Agent 像是 Python 的三方库和浏览器完全不像很容易被识别出来。我在这类项目里一般会带这样一组请求头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: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.8,en-US;q0.6,en;q0.4, }这里最重要的就是User-Agent它直接决定了服务器对请求的第一印象。Accept和Accept-Language可以告诉服务器“我期望接收中文页面”从源头上减少乱码概率。不过就算请求头带了有时候还是可能出现解析出的中文字符变成乱码的情况。原因通常是requests从响应头推断的编码不对。遇到这种情况可以在拿到响应后强制指定编码resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8豆瓣页面的编码方式是 UTF-8我在这个项目里直接强制指定避免resp.text因为编码误判出现一屏幕乱码。这是一个很容易被忽略的细节但非常影响后面的解析。3.2 用 CSS 选择器定位并提取每条电影信息拿到干净的 HTML 之后解析就变得直白了。我的习惯是用select()定位列表集合再用select_one()定位单个字段。以起步的解析思路为例from bs4 import BeautifulSoup def parse_movies(html): soup BeautifulSoup(html, lxml) movies [] for item in soup.select(ol.grid_view li): rank item.select_one(div.pic em).text.strip() title item.select_one(span.title).text.strip() rating item.select_one(span.rating_num).text.strip() votes item.select(div.star span)[-1].text.strip() link item.select_one(div.pic a)[href] quote_el item.select_one(p.quote span) quote quote_el.text.strip() if quote_el else movies.append({ rank: rank, title: title, rating: rating, people_num: votes.replace(人评价, ).strip(), quote: quote, link: link, }) return movies这段代码里有几个细节值得专门说明。第一span.title这一项。电影条目的标题区域里通常有两个span.title第一个是中文名第二个是以斜杠开头的外文名。如果你用select_one(span.title)取到的是第一个中文名这正好是我们需要的。如果你想把外文名也抓下来就需要改成select(span.title)再取第二个但要注意一些国产片并没有外文名直接取第二个会报索引越界。第二评价人数的提取。我用了select(div.star span)[-1].text.strip()因为div.star下面的最后一个span通常就是“xxx人评价”这个元素。首页那两个填充星星的span并不影响这里的逻辑。这一行返回的文本类似“2290537人评价”我们再用replace(人评价, ).strip()把它清洗成纯数字。第三经典台词是可选字段。并不是每部电影都有p.quote。代码里先用select_one去拿元素如果拿不到就返回一个空字符串。这里如果不做判空直接调用.text整个脚本会带着AttributeError中断掉后续所有电影都抓不了。3.3 先跑通单页再跑全量我的习惯是先把上面这个单页解析函数跑通确认拿到的是我们想要的 25 条数据再去写循环翻页。这样做的理由是如果只盯着最后一屏 250 条数据去调试一旦某页出了问题你很难判断是翻页逻辑出错还是单页解析本身就错了。把问题范围拆得越小排错效率越高。html requests.get(url, headersHEADERS, timeout10).text movies parse_movies(html) print(len(movies), movies[0])看到第一页能输出 25 条记录我心里就有底了。接下来才进入全量抓取的阶段。4. 分页批量完整爬虫源码与节奏控制4.1 循环翻页的基本写法在单页解析函数确认无误之后翻页就是把start从 0 变到 225 的循环而已。外层用一个for循环迭代 10 次每次根据start构造 URL请求页面解析页面把结果追加到总列表里然后sleep一段时间再请求下一页。4.2 带重试的健壮请求函数在实际运行过程中网络请求不会每次都成功。可能是临时网络抖动也可能是服务端返回了 403 或 503。如果你只写一个简单的get()遇到这些问题就会直接抛异常或者返回一个错误页面导致整个程序中断。我给请求函数加了一层重试机制。核心逻辑是尝试请求指定次数如果成功返回 200 就立即返回文本如果失败先打印警告日志然后用time.sleep(5 * (attempt 1))做递增等待再继续下一次尝试。这种指数退避式的重试比立刻疯狂重试要温和得多。def fetch_page(session, url, retry_times3): for attempt in range(retry_times): try: resp session.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text logging.warning(status%s, attempt%s, resp.status_code, attempt 1) except requests.RequestException as exc: logging.warning(request error: %s, exc) time.sleep(5 * (attempt 1)) return None这样设计还有一个好处即使某一页连续几次都失败函数不会抛出异常把整个脚本打崩而是返回None我们可以在主循环里针对None做日志记录最后统计哪些页面抓取失败而不是面对一个中断的半成品。4.3 为什么我建议你先用单线程加 sleep很多新手看到这里有 10 页要抓第一反应是“我要上多线程用协程否则太慢了”。但实际真的没必要。10 页start哪怕每页之间睡 3 秒整个抓取时间也只有 30 多秒加上请求本身的开销一分钟之内一定能跑完。而一旦引入多线程事情会变得复杂你要考虑并发数、线程安全、请求频率是否过高等问题。更麻烦的是高频并发请求会成倍增加被服务器限流的概率。到时候你可能不仅要处理“抓不到数据”的问题还要花更多时间在“怎么不让 IP 被临时限流”上反而拖慢进度。我的做法是先单线程加time.sleep(2)把整个流程跑通确认数据完整再考虑优化。这也是一个比较健康的爬虫开发顺序先保证稳定再追求速度。4.4 完整源码下面就是我实际使用的完整脚本你复制到本地安装依赖后直接运行会在当前目录生成一个douban_top250.csv。# -*- coding: utf-8 -*- 豆瓣电影 Top250 爬虫示例 依赖requests, beautifulsoup4, lxml import csv import time import logging import requests from bs4 import BeautifulSoup logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s ) BASE_URL https://movie.douban.com/top250?start{start}filter 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: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.8,en-US;q0.6,en;q0.4, } def fetch_page(session, url, retry_times3): for attempt in range(retry_times): try: resp session.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text logging.warning(status%s, attempt%s, resp.status_code, attempt 1) except requests.RequestException as exc: logging.warning(request error: %s, exc) time.sleep(5 * (attempt 1)) return None def parse_movies(html): soup BeautifulSoup(html, lxml) movie_items soup.select(ol.grid_view li) movies [] for item in movie_items: rank_el item.select_one(div.pic em) title_el item.select_one(span.title) rating_el item.select_one(span.rating_num) stars_el item.select(div.star span) quote_el item.select_one(p.quote span) link_el item.select_one(div.pic a) if not (rank_el and title_el and rating_el and stars_el): continue rating_text rating_el.text.strip() votes_text stars_el[-1].text.strip() vote_num votes_text.replace(人评价, ).strip() movies.append({ rank: rank_el.text.strip(), title: title_el.text.strip(), rating: rating_text, people_num: vote_num, quote: quote_el.text.strip() if quote_el else , link: link_el[href] if link_el else , }) return movies def save_to_csv(movies, filenamedouban_top250.csv): fieldnames [rank, title, rating, people_num, quote, link] with open(filename, w, newline, encodingutf-8-sig) as fp: writer csv.DictWriter(fp, fieldnamesfieldnames) writer.writeheader() writer.writerows(movies) def main(): session requests.Session() all_movies [] for start in range(0, 250, 25): url BASE_URL.format(startstart) html fetch_page(session, url) if html: page_movies parse_movies(html) all_movies.extend(page_movies) logging.info(start%s, 抓取 %s 条累计 %s 条, start, len(page_movies), len(all_movies)) else: logging.error(start%s 请求失败, start) time.sleep(2) save_to_csv(all_movies) logging.info(完成共 %s 条已保存到 douban_top250.csv, len(all_movies)) if __name__ __main__: main()这里解释两个容易被忽略的设计。一是session requests.Session()我们全程使用同一个会话对象可以让请求之间共享连接减少不必要的握手开销也更接近浏览器的访问行为。二是save_to_csv里用了encodingutf-8-sig这个看起来不起眼的参数在 Windows 上打开 CSV 时非常重要后面踩坑部分会专门讲。如果跑完控制台日志显示“完成共 250 条”说明整个流程已经通顺了。5. 实战翻车记录我从这堆坑里爬出来的过程代码看着简单但真正在本地跑起来的时候我还真踩过不少坑。这一节写几个最有代表性的希望你跑的时候能避开。5.1 第一坑连续请求后返回 403页面变成了验证码我最早写第一版的时候没有加请求间隔也没有做重试就是三页连抓。结果前两页还好好的到第三页突然发现解析出来的数据是零打印一眼 HTML 才发现返回的根本不是排行榜页面而是一个验证码页面。这个问题本质上是因为短时间内的请求频率超出了服务器容忍的阈值。解决思路分成三层第一是伪装好User-Agent让我当前这套请求头看起来像普通浏览器第二是每抓一页之后主动sleep2 秒降低单位时间内的请求密度第三是给请求加上递增退避重试万一第一次失败了等待一会儿再试而不是立刻重试。经过这三层调整我的脚本再也没在单次运行时出现过验证码页面。5.2 第二坑部分电影没有经典台词直接AttributeError你可能会觉得既然 Top250 是高分电影那每部电影都应该有经典台词吧实际上并不是。我在解析时第一次用item.select_one(p.quote span).text.strip()结果刚跑到第四页就崩溃了日志里报AttributeError: NoneType object has no attribute text。这个坑的教训非常典型爬虫解析的 HTML 永远不要假设所有字段都存在。缺字段、字段为空、格式不一致是网络数据里最普通的情况。我的处理方式是先select_one拿到元素如果返回值是None就赋一个空字符串quote_el item.select_one(p.quote span) quote quote_el.text.strip() if quote_el else 同样的逻辑还适用于排名、标题、评分等关键字段。虽然这几个在页面上一定存在但加上一层判断不会有坏处反而能让脚本在遇到异常数据时不至于直接崩溃。5.3 第三坑CSV 用 Excel 打开全部乱码第一次成功生成 CSV 文件后我满心欢喜地双击打开结果中文全变成了类似电影的乱码。问题出在编码方式上。我用encodingutf-8写入的 CSV 在记事本等编辑器里看着很正常但 Excel 默认用的是系统本地编码解析文件对 UTF-8 的支持并不友好。解决方案是写入时改用utf-8-sig这个编码会在文件开头加入一个 BOM 头相当于告诉 Excel“这是一个 UTF-8 文件你按 UTF-8 解析就行”。这也是为什么前面完整代码里保存 CSV 时写入的不是utf-8而是utf-8-sig。如果你的程序不打算给 Excel 用而是给 pandas 解析那么utf-8或者utf-8-sig都没有问题。这件事只要能意识到“编码影响下游工具”就够了。5.4 第四坑标题里其实有两个span.title直接取列表容易越界我在扩展字段的时候想把外文名也抓下来于是把代码改成了titles item.select(span.title) title titles[0].text.strip() foreign_title titles[1].text.strip()刚开始跑得好好的结果跑到某些条目时又崩了依旧是 IndexError。原因很简单有些电影没有外文名整个列表只有一个span.title取titles[1]自然就越界了。处理办法是先判断len(titles)再决定要不要取第二个titles item.select(span.title) title titles[0].text.strip() if titles else foreign_title titles[1].text.strip() if len(titles) 1 else 这个习惯也建议你在后续爬其他网站时保留下来遇到“看起来应该有多个值”的字段先用len()做保护再取值。5.5 状态码 418 的提醒如果你在测试时发现服务器返回了 418不用紧张它本质上是在告诉你“你被识别成爬虫了”。这个状态码在很多技术讨论里带着一点玩笑性质但实际含义就是“我不打算把页面内容给你”。遇到 418 的处理方式和 403 类似检查请求头是否完整、降低请求频率、必要时把sleep时间拉长到 5 秒甚至更长。我在这个项目里的最终版本已经把这些措施都包含了正常跑是不会碰到 418 的。6. 让爬虫更有价值存储、可视化与生产级升级思路6.1 把数据存进 SQLite方便做本地查询CSV 很适合看但如果想快速做筛选和统计SQLite 是更方便的存储方案。Python 自带的sqlite3不需要额外安装在抓取结束后建一张表循环插入即可import sqlite3 conn sqlite3.connect(movies.db) conn.execute(CREATE TABLE IF NOT EXISTS top250 (rank INTEGER, title TEXT, rating REAL, people_num INTEGER, quote TEXT)) for m in all_movies: conn.execute( INSERT INTO top250 VALUES (?, ?, ?, ?, ?), (int(m[rank]), m[title], float(m[rating]), int(m[people_num]), m[quote]) ) conn.commit() conn.close()有了 SQLite 文件后续查询就灵活多了。比如想看评分 9.5 以上的电影有多少部、评价人数前十的片子分别是谁一条SELECT就能搞定。存数据库这一步能让这个练手项目真正变成一个可以反复使用的本地数据源。6.2 用 pandas 和 matplotlib 快速验证数据价值爬下来的 250 条数据如果只是躺在 CSV 里价值其实没有完全发挥。我习惯随手用 pandas 读一下再画两张简单的图数据有没有问题、分布是什么样的一眼就能看出来import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(douban_top250.csv) df[rating].hist(bins20) plt.title(Top250 评分分布) plt.show()比如rating列能转成float并成功画图就说明数据解析和清洗是干净的。如果你想看哪一年上榜的电影最多还可以从详情页抓年份字段或者把链接里的信息补充进原数据。到这里爬虫就不再是单纯的“拿数据”而是进入数据分析流程了。6.3 从练手到生产随机 UA、代理池与增量更新如果你不满足于 250 条数据还想把这个项目扩展成能长期运行的生产级爬虫一般可以在三个方向上升级。第一个方向是随机 User-Agent。把常见的浏览器 UA 放到列表里每次请求随机挑选一个避免固定 UA 在多次请求后被积累画像。第二个方向是接入代理池。当单个 IP 的请求频率被限制时通过代理池切换出网 IP可以降低单一来源的压力。不过这一步涉及自己搭代理池还是用服务的选型没有统一的答案。第三个方向是增量更新。榜单内容不会频繁变化你可以把已抓取的条目链接存在本地表里后续爬取时只处理新出现的链接避免重复抓取已有数据。关于增量更新有一个细节值得注意很多网站的热门榜单默认带着“按热度排序”的逻辑Top250 这种固定榜单相对稳定但如果你想抓的是实时热榜那每次排序都可能变化不能只靠链接去重还需要记录快照时间、排序变化分析逻辑会更复杂一些。6.4 爬虫礼仪与合规底线最后必须聊一点职业素养层面的东西。爬虫本身是一项很常规的技术但用在哪里、怎么用是有讲究的。我在写这类练手项目时坚持三个原则第一控制请求频率绝不去压测或频繁请求目标服务器你只想拉 10 页数据就别让人家服务器感受到明显压力第二抓取公开列表数据用于个人学习研究不拿来做商业交易或大规模公开分发第三如果目标站点在页面或协议里明确表示不希望被爬取尽量尊重对方的规则。说白了一个稳重的开发者不会因为“技术上能拿到”就去放任自己的脚本无限重试或并发轰炸。我们把 Top250 跑通、把数据拿下来练习目的是提升自己的工程能力而不是给别人添麻烦。保持这个心态以后面对任何爬虫项目你都不会跑偏。跑完这个项目之后我的建议是不要急着去爬更难的网站先把解析、存储、清洗这套基本功练熟。等 CSV 文件里的 250 条电影都能稳定出现在你眼前时你其实已经掌握了大多数静态爬虫项目的核心套路。之后不管是抓公开新闻、公开榜单还是做自己的数据小应用都可以用今天这套逻辑快速搭起来。