豆瓣Top250爬虫+数据分析可视化:从requests到pyecharts实战
简介这是一份面向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. 技术选型与模块拆分为什么是requestsSQLAlchemypyecharts2.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[classgrid_view]/li一次取出全部250条电影条目。BeautifulSoup不是不能用只是在这个场景下XPath的表达更短、更好改。请求头参数里真正影响结果的是User-Agent和Referer。UA用Chrome浏览器的完整字符串别用Python-requests这种默认UA豆瓣对无UA的请求基本秒拒。Referer填https://movie.douban.com/模拟从首页点进来的路径。Accept-Language顺手带上zh-CN,zh;q0.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(mysqlpymysql://root:密码localhost/douban?charsetutf8mb4) engine create_engine(sqlite:///douban_movie.db, echoFalse) Base declarative_base() SessionLocal sessionmaker(bindengine)engine的echoFalse别开开了会把每一条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;q0.9,en;q0.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[classgrid_view]/li) rows [] for it in items: rank it.xpath(.//em/text())[0] title it.xpath(.//span[classtitle]/text())[0] info_lines it.xpath(.//div[classbd]/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[classrating_num]/text())[0] people it.xpath(.//div[classstar]/span[4]/text())[0] quote it.xpath(.//span[classinq]/text()) link it.xpath(.//div[classhd]/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: }, timeout10) if resp.status_code 200: return resp.text if resp.status_code 418 and attempt retries - 1: print(fstart{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超时参数timeout10是必写的不加的话TCP连接卡住会让整个爬虫挂死。重试用线性退避第一次等5秒、第二次等10秒不上指数退避因为豆瓣的反爬窗口通常几秒就恢复了。params里带filter是豆瓣URL自带的参数不加也能访问但加上更贴近浏览器行为。3.4 断点续抓与重试Requests封装成带退避的版本爬250条数据一般不会被封但网络抖动可能导致某一页超时。断点续抓的做法是记录已经抓到的start值重新运行时跳过这些页码而不是从头再来。import json import os def load_progress(pathprogress.json): if os.path.exists(path): with open(path, r, encodingutf-8) as f: return json.load(f) return {done_pages: []} def save_progress(progress, pathprogress.json): with open(path, w, encodingutf-8) as f: json.dump(progress, f, ensure_asciiFalse, indent2)这段代码解决的问题是跑到第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, nameuq_rank),) id Column(Integer, primary_keyTrue, autoincrementTrue) rank Column(Integer, nullableFalse) 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, pathmovies.csv): with open(path, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesrows[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(rankr[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( )后不explodevalue_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页面内容是“Im 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 cant encode character程序直接崩溃。原因Windows默认控制台编码是GBK而豆瓣数据是UTF-8print中文的时候GBK编码器不认某些字符。解决写文件时用UTF-8就能避开但你想在控制台看日志就在脚本开头加两行import sys sys.stdout.reconfigure(encodingutf-8)这行代码在Python 3.7以上可用。如果你用的是老版本Python就得sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-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_optsopts.TitleOpts(title豆瓣Top250年份产量TOP10), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(rotate45)), ) ) 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_optsopts.TitleOpts(title豆瓣Top250类型分布)) ) pie.render(genre_pie.html)x轴标签rotate45是必写的否则年份挤成一团看不清。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 21增量爬虫的核心不是爬而是对比。爬之前先查数据库里已有的rank集合爬回来的排名如果已经存在就只更新评分和评价人数不存在才插入新纪录。250条数据量小这种做法完全够用。日志里每页打印抓取条数和耗时下次看板数据不对时先看crawler.log最后几行比猜原因快得多。说起增量更新我以前吃过一次亏手动跑的时候一切正常丢到cron里第二天一看数据库里数据翻倍了。排查半天发现是save_to_db里没查重cron每次跑都是全量插入唯一约束又没建好导致垃圾数据堆积。从那以后我每次写完抓取脚本都要强制走一遍“删库、重建、全量跑、增量跑、查重复数”这套流程确认五步全过才敢说这个脚本能交付。这次拆这个包我又走了一遍希望这个流程对你也有用希望帮到你。本文还有配套的精品资源点击获取