ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python爬虫实战:从豆瓣Top250学会数据抓取、解析与存储

Python爬虫实战:从豆瓣Top250学会数据抓取、解析与存储 学爬虫绕不开豆瓣 Top 250这话放在今天依然没错。很多人第一次接触 Python 爬虫第一个完整跑通的项目就是抓豆瓣电影榜单我第一次完整写爬虫时抓的也是它。原因很简单页面结构规整、URL 规律明确、数据字段丰富又能把请求、解析、清洗、存储这条完整的链路走一遍。这篇文章就把整个实战过程拆开讲清楚从页面分析到最终落库包含完整代码和我在实抓过程中踩过的坑适合刚学完 Python 基础、想找个正经项目练手的读者。我用的是目前最主流的方案requests负责请求页面BeautifulSouplxml负责解析 HTML数据分别写入 CSV 和 SQLite。这个组合不算最前沿但胜在稳定、容易调试而且能帮你把“抓取→解析→存储”这条主线彻底弄明白。看完这篇文章换一个目标网站你也能自己上手拆解。1. 练手项目为什么要选豆瓣 Top 2501.1 这个榜单页面恰好覆盖爬虫的完整链路先说项目目标把豆瓣电影 Top 250 榜单上的电影数据抓下来包括排名、电影名、导演和主演、年份、地区、类型、评分、评价人数、一句话简介以及每部电影的详情页链接一共 250 条。数据量不大但很完整足够用来练习。选这个页面做实战有几层原因。第一页面是静态 HTML数据直接渲染在页面源码里不用处理 JavaScript 动态加载对初学者友好。第二榜单按“每页 25 条、一共 10 页”分页URL 参数规律极其明显适合拿来理解分页抓取的原理。第三每条电影记录有多个不同语义的字段抓下来之后要做清洗和类型转换这个过程能让你体会到“从 HTML 字符串到结构化数据”到底要经过哪些步骤。第四豆瓣的反爬不算特别激进只要正常控制频率、伪装好请求头就能顺利跑完整个项目。对比一下其他练手目标抓静态新闻列表太简单抓一线电商平台又容易触发严格的反爬机制一上来就把新手劝退。Top 250 这个难度阶梯刚好卡在“有点挑战但够得着”的位置。1.2 环境准备与核心依赖选型动手之前先确认环境。我假设你已经装好了 Python 3.8 以上的版本然后需要安装三个库pip install requests beautifulsoup4 lxmlrequests发起 HTTP 请求拿网页源码。它比 Python 自带的urllib好用太多自动处理编码、会话、请求头是爬虫入门的标配。beautifulsoup4解析 HTML 文档提供select()、find()这类易读的 API定位元素非常方便。lxmlHTML 解析器解析速度比 Python 标准库的html.parser快得多配合 BeautifulSoup 使用是经典组合。不建议一上来就学 Scrapy、Playwright 这类重框架。先把 requests BeautifulSoup 跑通把原理吃透后面升级到 Scrapy 时你才能理解它到底帮你解决了什么问题而不是照抄配置。1.3 先说合规边界爬虫不是“能抓到”就行动手前必须把红线划清楚。豆瓣的robots.txt限制了爬取路径Top 250 页面并没有被禁止抓取但这不等于可以随便暴力访问。实际操作时要注意三点请求频率控制在合理范围比如每抓一页间隔 3 到 5 秒不把数据用于商业用途不给对方服务器造成压力。公开可见的榜单数据用于个人学习、技术研究没有问题但抓取之后大规模公开传播、打包售卖就超出了合理使用的范畴。我的习惯是抓练习数据只跑一次存好结果就不再反复请求。这既是合规要求也是技术素养。2. 页面结构与 URL 规律数据到底藏在哪2.1 翻页参数 start 的规律打开豆瓣电影 Top 250 页面观察地址栏https://movie.douban.com/top250?start0filter点第二页地址变成https://movie.douban.com/top250?start25filter规律很清楚参数start控制起始位置每页 25 条所以第 N 页的start就是(N - 1) * 25。第 10 页是start225。后面那个filter参数留空即可不影响结果。这个分页方式在很多网站上都用掌握之后遇到同类列表页可以直接套用。2.2 用开发者工具定位字段拿到页面源码后不建议直接肉眼乱翻我用的是浏览器开发者工具。右键页面选择“检查”定位到任意一条电影条目先把结构摸清楚。Top 250 页面里每条电影是一个div外层类是item内部主要分两个区域左边div.pic是海报和排名右边div.info是电影详细信息。下面是简化后的结构div classitem div classpic em class1/em a hrefhttps://movie.douban.com/subject/1292052/ img src... alt肖申克的救赎 /a /div div classinfo div classhd a hrefhttps://movie.douban.com/subject/1292052/ span classtitle肖申克的救赎/span /a /div div classbd p 导演: 弗兰克·德拉邦特 Frank Darabontnbsp;nbsp;nbsp;主演: 蒂姆·罗宾斯 Tim Robbins /...br 1994nbsp;/nbsp;美国nbsp;/nbsp;犯罪 剧情 /p div classstar span classrating_num9.7/span span2803625人评价/span /div p classquote span classinq希望让人自由。/span /p /div /div /div注意几个关键定位点排名div.pic em电影名div.info div.hd a span.title中文名导演、演员、年份、地区、类型div.info div.bd p这些字段混在一起需要用分隔符拆分评分div.star span.rating_num评价人数div.star下面没有被单独标注类的span一句话简介p.quote span.inq详情页链接div.hd a的href理解了这个结构后面写解析代码就是按图索骥。2.3 解析库选型BeautifulSoup、XPath、正则怎么选解析方式通常有三条路BeautifulSoup 的select()、lxml 的 XPath、纯正则表达式。我在这类项目里优先用 BeautifulSoup原因很直接BeautifulSoup 选择器写起来和 CSS 选择器几乎一样可读性好配合浏览器开发者工具里复制的路径很容易定位。XPath 功能更强、性能更好但写起来可读性差一些调试成本高。正则表达式适合处理“不需要解析 HTML 结构、只想从字符串里抠出一小段内容”的场景比如从混合文本里提取年份和地区。在 Top 250 项目里我会这样分工select()负责定位结构化字段片名、评分、排名正则或字符串切割负责清洗p标签里混合的导演、年份、类型信息。这样每种工具都用在其最擅长的位置代码简洁、逻辑清晰。3. 请求这一步为什么一上来就 4033.1 User-Agent 与 headers 伪装初学者最容易踩的坑就是直接用requests.get(url)去抓结果返回 403。403 的意思是服务器理解请求但拒绝执行。原因很简单很多网站的反爬机制首先看请求头里的User-Agent如果识别出不是常见浏览器直接拒绝。我请求网页时固定带这样一组请求头HEADERS { 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive }其中User-Agent最重要最好用一个当前浏览器版本的完整 UA 字符串不要用太旧的。Accept-Language也很关键豆瓣会依据这个返回不同语言的页面设置为zh-CN能确保拿到中文电影名。实测下来只要这两项正确不带 Cookie 也能正常抓取。3.2 状态码背后的反爬信号请求之后先检查状态码这是排查问题的第一步。对于这个项目常见状态码的含义如下状态码含义怎么办200请求成功拿到页面正常解析403Forbidden服务器拒绝检查 UA、Referer降低频率418检测到异常请求停一会再试别硬刚429请求太快被限流增加间隔时间我在实测中就遇到过 418。那是我写了个循环没有加time.sleep()连续快速请求好几页结果豆瓣直接返回了 418。418 是“我是一个茶壶”的彩蛋状态码一堆网站拿它当反爬信号表示“我识别出你是脚本了”。这个状态下继续重试只会加重封禁正确做法是停下来等两三分钟再恢复抓取。3.3 请求频率与重试机制对于只有 10 页的 Top 250完全不需要像抓大型站点那样上代理池和分布式。我的建议是每抓一页time.sleep(3)十页也就半分钟既稳定又不会触发限流。另一个实用组件是重试机制。网络请求偶尔会出现超时或 5xx 错误直接跳过可能导致数据缺失。我习惯用requests配合简单的重试逻辑import time import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() retry Retry(total3, backoff_factor0.5, status_forcelist[500, 502, 503, 504]) adapter HTTPAdapter(max_retriesretry) session.mount(https://, adapter)用session而不是直接requests.get还有一个隐藏好处Session 会保持连接后续请求复用 TCP 连接效率更高多个页面连续抓取时更稳定。4. 数据解析与清洗把 HTML 变成结构化记录4.1 解析提取的完整代码页面拿到手下面进入核心环节解析。我先把完整解析代码写出来再逐段解释。from bs4 import BeautifulSoup def parse_page(html): soup BeautifulSoup(html, lxml) movies [] items soup.select(ol.grid_view li) for item in items: rank item.select_one(div.pic em).get_text(stripTrue) title item.select_one(div.info div.hd a span.title).get_text(stripTrue) link item.select_one(div.info div.hd a)[href] rating item.select_one(div.star span.rating_num).get_text(stripTrue) rating_people item.select_one(div.star span:nth-of-type(4)).get_text(stripTrue) quote_node item.select_one(p.quote span.inq) quote quote_node.get_text(stripTrue) if quote_node else p_text item.select_one(div.bd p).get_text( , stripTrue) director, year, area, genres parse_p_info(p_text) movies.append({ rank: int(rank), title: title, director: director, year: year, area: area, genres: genres, rating: float(rating), people: parse_people(rating_people), quote: quote, link: link }) return movies有几个地方要特别说明。第一select_one(div.star span:nth-of-type(4))取评价人数因为“评价人数”那个span没有独立 class只能靠位置定位。这个选择器在 BeautifulSoup 里是可用的但如果你觉得别扭也可以用item.select_one(.star).get_text()再正则提取数字两条路都行。第二p.quote不是每条电影都有——部分影片没有那句简短简介。代码里先判断quote_node是否为None否则直接.get_text()会抛异常。这类“有的字段不一定存在”的情况在真实爬虫里非常常见写代码时必须考虑。第三链接直接取a标签的href属性它的格式是https://movie.douban.com/subject/1292052/后面如果要抓详情页可以直接用。4.2 导演、年份、地区和类型的拆分p标签里的文本是整段混在一起的例如导演: 弗兰克·德拉邦特 Frank Darabont 主演: 蒂姆·罗宾斯 Tim Robbins / 摩根·弗里曼 Morgan Freeman ... 1994 / 美国 / 犯罪 剧情注意实际文本里导演和主演之间的空格是连续多个空格或nbsp;年份前的部分到年份这里是个分水岭。我写的parse_p_info用正则把这段文本拆开import re def parse_p_info(p_text): parts re.split(r\d{4}, p_text, maxsplit1) director_part parts[0] if parts else director_match re.search(r导演:\s*(.*?)(?:主演:|$), director_part) director director_match.group(1).strip() if director_match else tail if len(parts) 1: tail p_text[len(parts[0]):] tail_match re.match(r(\d{4})\s*/\s*([^/]?)\s*/\s*(.), tail) year tail_match.group(1).strip() if tail_match else area tail_match.group(2).strip() if tail_match else genres tail_match.group(3).strip().replace( , /) if tail_match else return director, year, area, genres这里re.split(r\d{4}, p_text, maxsplit1)用年份数字作为分隔点把文本切成“导演主演部分”和“年份地区类型部分”。年份以后的部分再用(\d{4})\s*/\s*([^/]?)\s*/\s*(.)匹配分别提取年份、地区、类型。类型字段可能包含多个词如“犯罪 剧情”我把它转成以/分隔的字符串后续存数据库更规范。这段逻辑是解析中最容易写糙的地方花点时间打磨是值得的。如果你只抓片名和评分代码可以短很多但抓下来的数据可用性会差很多。4.3 主循环与分页拼接解析函数写完主循环就简单了。10 页数据逐页抓取import time if __name__ __main__: all_movies [] for page in range(10): start page * 25 url fhttps://movie.douban.com/top250?start{start}filter resp session.get(url, headersHEADERS, timeout10) resp.encoding utf-8 if resp.status_code 200: page_movies parse_page(resp.text) all_movies.extend(page_movies) else: print(f第 {page 1} 页请求失败: {resp.status_code}) time.sleep(3)这里resp.encoding utf-8是一个小细节。豆瓣页面的编码就是 UTF-8但requests有时会根据响应头猜测不准确导致中文乱码。手动指定编码是最保险的做法。实测中加不加这一行测试结果差别很大。5. 数据落地CSV 与 SQLite 双写5.1 两种存储方式的取舍数据抓下来之后就要考虑怎么存。CSV 和 SQLite 是我在这个项目里同时用的两种方案各自的适用场景不同。CSV 的好处是可直接用 Excel/WPS 打开便于人工查看也方便后续导入 Pandas 做数据分析。250 条数据用 CSV 完全没压力。SQLite 的好处是查询灵活支持条件过滤、排序、聚合能练习数据库基本功。抓取爬虫数据用 SQLite 比用 MySQL 轻量数据库就是单个文件不用安装服务很适合单机项目。我的思路是先写 CSV 留一份可读备份再写 SQLite 方便后续做分析查询。二者共用同一个data字典列表代码维护成本很低。5.2 写入代码实现CSV 写入用csv.DictWriter要注意字段顺序用fieldnames控制import csv CSV_FILE douban_top250.csv FIELDNAMES [rank, title, director, year, area, genres, rating, people, quote, link] def write_csv(movies): with open(CSV_FILE, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnamesFIELDNAMES) writer.writeheader() for movie in movies: writer.writerow(movie)这里用utf-8-sig而非utf-8是经验点。前者带 BOM 头用 Excel 直接打开 CSV 时中文能正常显示不会乱码。如果之后要程序读取 CSV建议用utf-8或读取时指定encodingutf-8-sig两者兼容。SQLite 部分先建表再逐条插入import sqlite3 DB_FILE douban_top250.db def init_db(): conn sqlite3.connect(DB_FILE) conn.execute( CREATE TABLE IF NOT EXISTS movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, rank INTEGER UNIQUE, title TEXT, director TEXT, year INTEGER, area TEXT, genres TEXT, rating REAL, people INTEGER, quote TEXT, link TEXT ) ) conn.commit() conn.close() def write_sqlite(movies): conn sqlite3.connect(DB_FILE) conn.executemany( INSERT OR REPLACE INTO movies (rank, title, director, year, area, genres, rating, people, quote, link) VALUES (:rank, :title, :director, :year, :area, :genres, :rating, :people, :quote, :link) , movies) conn.commit() conn.close()rank INTEGER UNIQUE把排名设成唯一键后续重复跑脚本时INSERT OR REPLACE会更新已有记录不会生成重复数据。这个设计在增量更新场景里非常实用。5.3 入库后的简单校验数据存完不要急着高兴先做校验。我的习惯是用 SQL 做几个快速检查SELECT COUNT(*) FROM movies; -- 应该等于 250 SELECT year, COUNT(*) FROM movies GROUP BY year ORDER BY COUNT(*) DESC; SELECT rating FROM movies ORDER BY rating DESC LIMIT 5;还要检查缺失值SELECT * FROM movies WHERE title IS NULL OR title ;跑一遍这些查询只要几秒钟能快速发现解析逻辑的问题。比如我曾经漏掉解析quote字段导致大量空值就是靠这条缺值查询发现的。如果发现某些年份为 0 或类型为空回头检查parse_p_info的正则逻辑几乎百发百中。6. 踩坑实录实测中遇到的几个典型问题6.1 页面结构变化导致的解析失效爬虫最怕的不是反爬而是页面改版。某一天我的脚本突然解析不出电影名items列表为空一查原因豆瓣把ol.grid_view这个容器类名改了所有 CSS 选择器集体失效。这种问题没有一劳永逸的解法只能靠监控。我的经验是脚本里加一个“解析结果为空就告警”的逻辑比如解析出 0 条记录时打印日志、发送通知而不是默默存一个空文件。另外定位元素时尽量选择稳定的结构属性优先靠em、.title、.rating_num这类关键类名而不是某一层父容器的类名这样即使某个包裹层改名解析代码的容错率也高一些。6.2 请求频率过高被临时限制我有一次为了测试删掉了time.sleep(3)让 10 页连续请求结果第 4 页就返回 418。短暂等了两分钟后重新跑脚本恢复正常。这个经历让我意识到对 Top 250 这种小榜单而言数据量本身很小提速的收益几乎为零但触发限流的风险会陡增。所以别贪快抓完就好留一点时间间隔是成本最低的稳定保障。6.3 多线程提速的代价聊到爬虫难免有人问要不要用多线程对于 10 页数据我的建议是不要用。多线程确实能把抓取时间压缩到几秒但需要处理线程安全、连接数控制、频率限流调试复杂度上升收益却不明显。真要练多线程爬虫建议换一个更大的目标站点比如爬几千页的电商分类页再用ThreadPoolExecutor或asyncio去优化。在 Top 250 这个项目里顺序请求 3 秒间隔就是最优解。如果你确实想在这个项目里感受一下并发可以控制线程数为 2 到 4并且在每次请求之间保留随机延时import random import time from concurrent.futures import ThreadPoolExecutor def fetch_one(start): time.sleep(random.uniform(1, 3)) ...但我会坦白告诉你体验一下可以正式数据抓取还是建议顺序跑。最后再分享几个我在实战里的习惯每次写爬虫脚本我都会在开头加一个BASE_URL常量、中间加若干print日志、末尾对入库数据做数量校验。看起来不起眼但实际调试时能省大量时间。尤其是打印日志初学者容易忽略等脚本在数据量大的时候莫名其妙少了记录才知道有日志多好。Top 250 只是爬虫入门的第一站这个流程跑通之后你可以换着花样扩展加上电影详情页的抓取和分析影评数据把 CSV 导入 Pandas 做评分分布分析或者用 Flask 写一个简单接口展示榜单数据。核心的 request 和 parse 模块不动换数据源也只是改选择器和解析函数的事这套思维才是爬虫真正值钱的部分。
返回列表