☰
豆瓣Top250爬虫+数据分析可视化:从requests到pyecharts实战
2026/9/26 9:03:46 网站建设 项目流程

简介:这是一份面向Python爬虫与数据分析初学者的豆瓣电影综合实战项目。它演示了从网络数据采集、数据库存储、数据清洗与统计分析,到最终可视化展示的完整流程,适合希望系统练手数据技能、准备课程设计或个人作品集的读者。压缩包共14个文件,除爬虫脚本、数据分析脚本外,还包含数据库文件、Excel汇总表以及8张可视化图表;整体仅1.34MB,目录结构清晰,便于对照学习。项目抓取电影名称、评分、评论人数、类型、上映地区与时长等字段,清洗后生成词云、评分分布、类型与评分关系、评论人数与评分关系等图表,并配有建库脚本与格式转换脚本。已有6513人学习下载,覆盖从数据采集到展示的关键环节,能帮助读者快速掌握requests、pandas、matplotlib等常用工具的组合用法,并迁移到自己的数据分析任务中。

1. 从豆瓣Top250到可视化看板:一份能直接跑的Python爬虫+分析资源

搞Python数据分析的人,十有八九都把豆瓣电影Top250当成第一个练手目标。它页面结构规整、字段丰富,评分、地区、年份、类型都能挖,做完爬虫紧接着就能接Pandas统计和ECharts可视化,链路非常完整。这份“python豆瓣电影爬虫+数据分析可视化.zip”我拆过一遍,里面就是一条从requests抓页面、lxml解析、SQLAlchemy落库,到pyecharts出交互图的完整流程。适合刚学完Python基础、想用真实数据把爬虫和数据分析串起来的人,也适合做可视化大屏时缺一份干净电影数据的从业者。下面我把每一步怎么跑、参数怎么调、翻车的点在哪写清楚。

2. 技术选型与模块拆分:为什么是requests+SQLAlchemy+pyecharts

2.1 爬虫层:requests 不是唯一选择,但最适合入门

豆瓣Top250这种静态分页页面,用requests逐页拿HTML就够了,不用上scrapy。scrapy的蜘蛛框架、中间件、Item Pipeline对刚接触爬虫的人太重,学习成本全花在框架上,而不是数据上。requests加lxml的组合,代码量小,每一行都能看懂,出了问题也好排查。

这个包里的做法是requests.Session配lxml的etree.HTML解析。Session能自动保持Cookie,对豆瓣这种“第一次请求正常、第二次就418”的站点很关键。解析层我建议直接用XPath,不要用BeautifulSoup的find_all。豆瓣页面的class名大量复用,find嵌套写起来很累,XPath直接按属性路径取节点,比如//ol[@class="grid_view"]/li一次取出全部250条电影条目。BeautifulSoup不是不能用,只是在这个场景下XPath的表达更短、更好改。

请求头参数里,真正影响结果的是User-Agent和Referer。UA用Chrome浏览器的完整字符串,别用Python-requests这种默认UA,豆瓣对无UA的请求基本秒拒。Referer填https://movie.douban.com/,模拟从首页点进来的路径。Accept-Language顺手带上zh-CN,zh;q=0.9,否则返回的字段里的主演名字可能被翻译成让人一头雾水的英文音译。

2.2 存储层:SQLAlchemy 让 SQLite 和 MySQL 之间无缝切换

爬下来的数据存哪里,是很多人纠结的一点。这个包用的是SQLAlchemy ORM,这层抽象选得聪明。本地调试时用SQLite零配置,一个文件就是库;等数据量大了想换MySQL,只要改一行create_engine的连接串,模型代码不用动。

# database.py from sqlalchemy import create_engine from sqlalchemy.orm import declarative_base, sessionmaker # 本地用 SQLite,部署到服务器换成 MySQL 只需要改这一行 # engine = create_engine("mysql+pymysql://root:密码@localhost/douban?charset=utf8mb4") engine = create_engine("sqlite:///douban_movie.db", echo=False) Base = declarative_base() SessionLocal = sessionmaker(bind=engine)

engine的echo=False别开,开了会把每一条SQL都打到控制台,爬250条数据能刷屏几万行日志。declarative_base是SQLAlchemy 2.x推荐的做法,比老的declarative_base手动metaclass写法直观。如果你本地装的是1.4老版本,这个写法一样兼容。

存储选SQLAlchemy而不是直接写pandas.to_sql,原因在于去重。pandas的to_sql对已存在的数据没有原生的“查重再插入”,要么全表删了重来,要么靠主键冲突抛异常。ORM可以先查一遍再决定插入还是更新,这在增量抓取时是刚需。

2.3 可视化层:静态图出报告,交互图做大屏

可视化这层包里有两条线:matplotlib和pyecharts。matplotlib用来出报告用的静态图,比如评分直方图、年份产量柱状图,保存成PNG直接贴文档;pyecharts用来做网页交互图,鼠标悬停能看到每部电影的具体评分,适合放到本地看板或者可视化大屏里。两个库不冲突,matplotlib负责打印,pyecharts负责展示。

pyecharts底层是ECharts,生成的图是HTML文件,不需要服务器就能在浏览器打开。做可视化大屏的常见做法就是把多个图表拼到同一个HTML里,后面我会讲具体怎么拼。这里提醒一句:pyecharts和echarts混淆的坑在版本上,pyecharts是Python的封装库,ECharts是前端的JS库。写Python就装pyecharts,别去折腾原生ECharts。

3. 豆瓣电影爬虫落地:Top250抓取、解析与字段抽取

3.1 请求头与反爬预判:UA、Cookie、Referer 缺一不可

豆瓣的反爬强度属于“温和但有力”的类型。它不封你IP,但会用状态码和重定向恶心你。最常见的两个状态码:418和302。418说明服务器认定你是机器人,302说明它把你踢到登录页或者验证页。

# crawler.py import random import time import requests 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", "Referer": "https://movie.douban.com/", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } def make_session(): session = requests.Session() session.headers.update(HEADERS) return session

这段代码的逻辑很简单:Session对象统一管理请求头,避免每次请求重复传headers字典。真正要注意的是random.uniform——我没写在这里,但主循环里每两页之间要sleep 2到4秒,别写死sleep(2),固定间隔更容易被识别成脚本。这里的随机间隔是玄学,但确实有用,豆瓣对“每250ms一页”的请求和“每3秒一页”的请求,容忍度完全不一样。

Cookie这块,如果你打开浏览器能正常访问豆瓣,而脚本请求返回302,第一嫌疑就是Cookie缺失。先在浏览器里登录豆瓣(不需要真的登录账号,只要访问过页面拿到Cookie),从DevTools的Network面板里复制Cookie请求头,加进HEADERS。注意Cookie有时效,过期后重新复制一次就行。

3.2 页面解析:XPath抽字段,正则洗年份

Top250的每一页是25条电影,结构从.grid_view列表开始。每条li里有排名em、标题span.title、信息p.text、评分span.rating_num、评价人数span里的文本,以及一句话辣评span.inq。

# parser.py import re from lxml import etree def parse_page(html: str) -> list[dict]: tree = etree.HTML(html) items = tree.xpath('//ol[@class="grid_view"]/li') rows = [] for it in items: rank = it.xpath('.//em/text()')[0] title = it.xpath('.//span[@class="title"]/text()')[0] info_lines = it.xpath('.//div[@class="bd"]/p[1]/text()') info0 = info_lines[0].strip() if info_lines else "" info1 = info_lines[1].strip() if len(info_lines) > 1 else "" rating = it.xpath('.//span[@class="rating_num"]/text()')[0] people = it.xpath('.//div[@class="star"]/span[4]/text()')[0] quote = it.xpath('.//span[@class="inq"]/text()') link = it.xpath('.//div[@class="hd"]/a/@href')[0] director, actors = parse_director_actors(info0) year, region, genres = parse_basic_info(info1) rows.append({ "rank": int(rank), "title": title.strip(), "director": director, "actors": actors, "year": year, "region": region, "genres": genres, "rating": float(rating), "votes": int(re.sub(r"\D", "", people)), "quote": quote[0].strip() if quote else "", "detail_url": link, }) return rows

这里最容易翻车的是info1的格式。它长这样:1994 / 美国 / 犯罪 剧情,中间用空格分隔多个类型,而不是斜杠。年份、地区、类型混在一行文本里,直接split("/")拿不到干净的字段,必须二次正则。

def parse_director_actors(text: str): director_m = re.search(r"导演:\s*(.*?)(?:\s*/\s*主演:|$)", text) actors_m = re.search(r"主演:\s*(.*)", text) director = director_m.group(1).strip() if director_m else "未知" actors = actors_m.group(1).strip() if actors_m else "未知" return director, actors def parse_basic_info(text: str): parts = [p.strip() for p in text.split("/") if p.strip()] year, region, genres = None, [], [] for p in parts: if p.isdigit() and len(p) == 4: year = int(p) elif " " in p: genres.extend(p.split(" ")) else: region.append(p) return year, "/".join(region), " ".join(genres)

parse_basic_info的判定逻辑有个边界要说明:中国大陆、美国这些地区字段没有空格,会走进region分支;犯罪 剧情这种带空格的才是类型。但万一某一行地区字段也带了空格(比如中国 香港),它会被误判成类型。实际爬取时建议打印几条数据肉眼核对一下,脏数据在解析阶段发现比在分析阶段发现便宜得多。

3.3 主循环与限速:25条一页,别让服务器记住你

Top250共10页,start参数从0开始,每页25条,这是豆瓣分页的固定规律。主循环写法决定了整个抓取过程的稳定性。

# runner.py import time import random from crawler import make_session from parser import parse_page def fetch_page(session, start: int, retries: int = 3): url = "https://movie.douban.com/top250" for attempt in range(retries): try: resp = session.get(url, params={"start": start, "filter": ""}, timeout=10) if resp.status_code == 200: return resp.text if resp.status_code == 418 and attempt < retries - 1: print(f"start={start} 触发418,等待后重试") time.sleep(5 * (attempt + 1)) continue except requests.RequestException as e: print(f"请求异常: {e}") time.sleep(2) return None def crawl_all(): session = make_session() all_rows = [] for page in range(10): start = page * 25 html = fetch_page(session, start) if html: rows = parse_page(html) all_rows.extend(rows) print(f"第{page + 1}页抓到{len(rows)}条") time.sleep(random.uniform(2.0, 4.0)) return all_rows

超时参数timeout=10是必写的,不加的话TCP连接卡住会让整个爬虫挂死。重试用线性退避,第一次等5秒、第二次等10秒,不上指数退避,因为豆瓣的反爬窗口通常几秒就恢复了。params里带filter=""是豆瓣URL自带的参数,不加也能访问,但加上更贴近浏览器行为。

3.4 断点续抓与重试:Requests封装成带退避的版本

爬250条数据一般不会被封,但网络抖动可能导致某一页超时。断点续抓的做法是记录已经抓到的start值,重新运行时跳过这些页码,而不是从头再来。

import json import os def load_progress(path="progress.json"): if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) return {"done_pages": []} def save_progress(progress, path="progress.json"): with open(path, "w", encoding="utf-8") as f: json.dump(progress, f, ensure_ascii=False, indent=2)

这段代码解决的问题是:跑到第7页网络断了,重跑时前6页的值还在,不需要重复请求。progress.json就是黑匣子,记录每一页的状态,比人肉记页码靠谱。实际用的时候,在crawl_all的循环里判断str(page)是否在done_pages里,在就跳过,不在就抓,抓到就追加并保存。

4. 数据存储与预处理:SQLAlchemy建表、去重、清洗三件事

4.1 表结构设计:字段定完,后边分析就不用返工

爬虫返回的字段有11个,建表时每个字段的类型值得认真定一遍。排名是整数,评分是浮点,评价人数是整数,年份是整数,地区是字符串,类型因为一个电影可能挂多个标签,用空格拼接后存字符串。不要在建表阶段就把类型拆成多张关联表,250条数据不值得上规范化设计。

# models.py from sqlalchemy import Column, Integer, Float, String, Text, UniqueConstraint from database import Base class Movie(Base): __tablename__ = "douban_movie" __table_args__ = (UniqueConstraint("rank", name="uq_rank"),) id = Column(Integer, primary_key=True, autoincrement=True) rank = Column(Integer, nullable=False) title = Column(String(255)) director = Column(String(255)) actors = Column(Text) year = Column(Integer) region = Column(String(255)) genres = Column(String(255)) rating = Column(Float) votes = Column(Integer) quote = Column(String(255)) detail_url = Column(String(500))

rank加唯一约束是这个表的关键设计。豆瓣Top250的排名是固定唯一的,用它做去重键比用标题靠谱,因为标题可能同名,而排名不会重复。actors和quote用Text而不是String,因为主演列表可能超过255字符,quote则可能包含各种标点。detail_url虽然也是唯一值,但它不影响分析,没必要在上面建约束。

4.2 双写策略:CSV兜底 + SQLite落库

实际跑的时候,我一般会同时写CSV和数据库。CSV用utf-8-sig编码,带BOM头,这样Excel直接打开不会乱码;数据库用SQLAlchemy做正式存储,供后续pandas读取。双写的价值在于:CSV是后悔药,如果后面数据库文件损坏,CSV随时能重新导回。

# storage.py import csv import pandas as pd from sqlalchemy.orm import sessionmaker from database import engine, SessionLocal from models import Movie def save_to_csv(rows, path="movies.csv"): with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows) def save_to_db(rows): session = SessionLocal() added, updated = 0, 0 for r in rows: exist = session.query(Movie).filter_by(rank=r["rank"]).first() if exist: # 已存在就更新评分和评价人数,其他字段不动 exist.rating = r["rating"] exist.votes = r["votes"] updated += 1 else: session.add(Movie(**r)) added += 1 session.commit() session.close() print(f"新增{added}条,更新{updated}条")

这段代码的去重逻辑是“先查后插”,查一次再决定动作。250条数据这个量级没问题,但如果你把同样的模式套到几十万条数据上,先查后插会慢得让人崩溃,到时候要改成merge或批量INSERT ... ON DUPLICATE KEY UPDATE。这里不加,是因为这个包面对的就是250条。

4.3 类型字段展开:一部电影挂三个类型怎么统计

存储阶段把多个类型用空格拼在一个字段里,分析阶段要先把它拆开。Pandas里的explode是处理这种“一对多”标签的标准方法,但很多人会踩一个坑:直接对原始字符串做str.split(" ")后不explode,value_counts出来的是整串“犯罪 剧情”,而不是单个标签。

# analyze.py import pandas as pd from database import engine df = pd.read_sql("SELECT * FROM douban_movie", engine) df["genres_list"] = df["genres"].str.split(" ") genres_df = df.explode("genres_list") genre_top10 = genres_df["genres_list"].value_counts().head(10) print(genre_top10) year_series = df["year"].value_counts().sort_index() year_top10 = year_series.tail(10) print("产量最高的年份:", year_top10)

explode之后,一条“犯罪 剧情”的数据变成两行,一部电影在统计里被重复计入两个类型。这是类型占比分析的默认口径,不算错误,但你看结果时要清楚:所有类型的占比加起来会超过100%,因为一部电影可能归属多个类型。

年份字段在这一步很可能出现缺失值,豆瓣个别电影的年份解析失败会落到None。分析前先df["year"].isna().sum()看一眼,缺失超过5条就回去查解析逻辑,别直接dropna——那等于丢数据。

5. 避坑/常见问题:豆瓣反爬、编码、重复数据的五个实战记录

5.1 状态码418:请求头带全了还是被判机器人

现象:代码跑得好好的,突然连续返回418,页面内容是“I'm a teapot”的提示或者一个验证页面。

原因:418是豆瓣的“反爬试探”,它不直接封你,但明确告诉你这个IP、这个UA组合已经被标记了。触发它的通常是访问频率太高,或者UA字符串和Cookie里的浏览器版本不匹配。

解决:我的处理顺序是:先检查UA是不是Chrome最新版完整串,再检查Referer有没有配,然后把主循环的sleep时间从固定2秒改成random.uniform(2.0, 4.0)。如果还不行,在fetch_page里加一个基于418状态码的重试分支,等5秒再试,还是418就跳页,别硬刚。亲测硬刚只会让封禁时间拉长。

5.2 返回302跳登录页:Cookie失效与访问频率

现象:请求返回的不是200也不是418,而是302,重定向到了accounts.douban.com,拿到的HTML是登录页。

原因:302是豆瓣最暧昧的回应,它可能是Cookie过期、可能是命中风控,也可能是豆瓣觉得你需要登录才能看内容。最常见的原因是前几天浏览器手动登录过豆瓣,Cookie里的bid和dbcl2已过期。

解决:重新打开浏览器访问豆瓣首页,从DevTools复制最新Cookie到HEADERS里。如果复制Cookie还不行,把session.cookies清空再重新赋值。这里注意:requests.Session在多次请求之间会自动积攒Cookie,如果requests自己带上了过期的旧Cookie,它会覆盖你手动设置的Cookie。解决方法是每次启动时新开Session,而不是复用跑了一天的Session。

5.3 控制台打印中文报UnicodeEncodeError

现象:爬虫跑得好好的,一到print电影标题就抛UnicodeEncodeError: 'gbk' codec can't encode character,程序直接崩溃。

原因:Windows默认控制台编码是GBK,而豆瓣数据是UTF-8,print中文的时候GBK编码器不认某些字符。

解决:写文件时用UTF-8就能避开,但你想在控制台看日志,就在脚本开头加两行:

import sys sys.stdout.reconfigure(encoding="utf-8")

这行代码在Python 3.7以上可用。如果你用的是老版本Python,就得sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")。这个报错不影响爬虫本身,但会中断主循环,所以我建议任何涉及中文打印的脚本,第一行就做编码声明。

5.4 去重失效:唯一约束和数据清洗的顺序

现象:数据库里出现了排名重复的电影,明明建表时加了UniqueConstraint("rank")。

原因:SQLAlchemy的唯一约束在创建表时没生效。如果你先手动建过表,后来才在模型里加约束,这个约束不会同步到已存在的表。另一个原因是插入时用了session.add但没commit,事务回滚后数据被缓存,下次commit时把重复数据也写进去了。

解决:先查sqlite_master看表结构是否真的带UNIQUE约束。没有就删表重建,跑一遍Base.metadata.create_all(engine)。插入逻辑上,先查后插的双写写法和session.expire_all()配合使用,每次commit后清掉会话缓存,避免拿到旧数据。

5.5 类型统计虚高:explode之后占比超过100%

现象:做类型占比饼图,发现所有类型加起来是140%。

原因:这不是bug,是口径问题。一部电影如《霸王别姬》同时属于“剧情”和“同性”,explode后它被统计了两次,类型占比自然超过100%。

解决:在做占比图之前,先决定你要回答什么问题。如果是“Top250里哪些类型出现次数最多”,用explode后的频次;如果要做“各类型电影占全部电影的比例”,分母需要改成类型总数而不是电影总数,并且图表标题写明口径。这个坑不算错误,但汇报时不说清楚口径,看板会被质疑数据不准。

6. 进阶:评分分布、类型偏好与ECharts动态看板

6.1 先把统计口径固定成表

所有可视化都建立在统计结果上,而统计结果的口径如果不提前定死,画图就会反复返工。我在这个包里看到的口径表可以照抄:

图表口径计算公式
评分直方图每部电影一个评分,不分权重按0-10分区间分组计数
年份产量按上映年份分组计数缺失年份剔除,单独说明
类型TOP10一部电影可归属多个类型explode后按类型频次排序
评价人数TOP10按评价人数降序取前10无缺失值参与

评分直方图建议用pd.cut分箱,箱体边界设为[0,6,7,8,9,10],这样能直接看出“7分是分水岭”的结论,不用自己数原始值。年份产量则先过滤掉异常年份,比如把year < 1930的数据单独拉出来看,防止脏数据污染趋势线。

6.2 pyecharts出交互图,matplotlib出报告用图

pyecharts的图可以直接嵌到HTML里,浏览器打开就能交互。下面这个组合是看板的核心:

# dashboard.py from pyecharts.charts import Bar, Pie from pyecharts import options as opts def make_year_bar(year_top10): bar = ( Bar() .add_xaxis([str(y) for y in year_top10.index.tolist()]) .add_yaxis("上映数量", year_top10.values.tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="豆瓣Top250年份产量TOP10"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=45)), ) ) bar.render("year_bar.html") def make_genre_pie(genre_top10): pie = ( Pie() .add("", [list(z) for z in zip(genre_top10.index.tolist(), genre_top10.values.tolist())]) .set_global_opts(title_opts=opts.TitleOpts(title="豆瓣Top250类型分布")) ) pie.render("genre_pie.html")

x轴标签rotate=45是必写的,否则年份挤成一团看不清。pyecharts的add接口接收的是[(name1, value1), (name2, value2)]这样的元组列表,所以list(z)这一步不能省。两个图分开render成两个HTML,做可视化大屏时再用iframe把它们嵌到同一个页面里,比直接在pyecharts里拼Grid省事,也方便单独刷新某个图表。

6.3 定时增量更新:把脚本丢给cron前的检查清单

如果想让这个看板每周自动更新一次,把它丢给crontab之前检查这三件事:第一,脚本入口必须是无交互的,不能有input语句;第二,路径全部写绝对路径,否则cron的工作目录和手动跑不一样会让相对路径失效;第三,抓完要写日志。crontab一行就够了:

# 每周日凌晨2点跑一次增量爬虫 0 2 * * 0 cd /path/to/douban_project && /usr/bin/python3 crawl_incremental.py >> crawler.log 2>&1

增量爬虫的核心不是爬,而是对比。爬之前先查数据库里已有的rank集合,爬回来的排名如果已经存在就只更新评分和评价人数,不存在才插入新纪录。250条数据量小,这种做法完全够用。日志里每页打印抓取条数和耗时,下次看板数据不对时,先看crawler.log最后几行,比猜原因快得多。

说起增量更新,我以前吃过一次亏:手动跑的时候一切正常,丢到cron里第二天一看,数据库里数据翻倍了。排查半天发现是save_to_db里没查重,cron每次跑都是全量插入,唯一约束又没建好,导致垃圾数据堆积。从那以后,我每次写完抓取脚本,都要强制走一遍“删库、重建、全量跑、增量跑、查重复数”这套流程,确认五步全过才敢说这个脚本能交付。这次拆这个包我又走了一遍,希望这个流程对你也有用,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询