ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Python爬虫实战:豆瓣Top250数据分析与可视化全流程

Python爬虫实战:豆瓣Top250数据分析与可视化全流程 简介基于Python爬取豆瓣电影Top250并完成数据分析与可视化的完整项目面向计算机相关专业正在做课程大作业的学生以及需要实战练习的Python学习者。资源共2000个文件以1830个Python源码文件为主涵盖爬虫抓取、数据清洗、统计分析、可视化绘图等核心模块同时包含C扩展、txt配置、HTML/CSS/JS前端展示文件、PDF文档等辅助内容整体压缩包约80.74MB文件层级清晰查找定位方便。目前已有942人学习下载。项目经导师指导并通过高分验收评审分98分全部源码已本地编译调试并稳定运行从豆瓣Top250页面抓取、字段解析、数据清洗到统计分析与可视化出图形成完整项目链路。附带文档说明还包括运行环境配置、使用步骤、设计思路和常见问题排查能帮助读者快速理解数据分析项目开发全流程借鉴代码组织方式与调试技巧对完成课程设计或毕业设计具有直接的参考价值。1. 为什么拿豆瓣Top250当爬虫和数据可视化练手项目数据规模与交付形态正好匹配拿“Python爬取豆瓣电影Top250数据分析与可视化”当成一个完整项目来做最大的好处不是页面难爬而是它刚好覆盖了从业者日常最常用的一套链路请求 → 解析 → 清洗 → 分析 → 出图。Top250只有250条数据单页25条共10页字段却足够丰富——排名、片名、导演、主演、年份、制片国家或地区、类型、评分、评价人数、引用语。这个规模意味着你不需要分布式爬虫、不需要消息队列一台普通笔记本用requests加BeautifulSoup就能稳定跑完但它又足够让你踩到真实项目里最常见的坑反爬校验、字段拆分、空值处理、中文字体乱码。很多入门教程跑完就结束了但真正到交付环节才发现问题爬到的数据洗不干净图表画出来没有业务含义代码改一处别处就崩。这篇笔记会把源码和文档说明里该有的模块拆开讲从单页请求到翻页循环从复合字段清洗到pandas指标计算再到matplotlib与pyecharts的分工最后给出一套自检和增量更新的验证方式。适合刚跑通Python基础、想做一个完整数据分析与可视化实践项目的人也适合已经写过爬虫、但想把代码从“能跑”提升到“能交付”的熟手。2. 请求与解析先用单页跑通再把start参数变成翻页循环2.1 请求头、延时与缓存把“反爬”从玄学变成工程纪律豆瓣Top250的反爬严格程度在同类网站里算温和的但不代表可以不带脑子请求。很多人一上来就写一个for循环连续请求10页结果第3页开始返回418或者一个“检测到异常访问”的提示页。这里的核心问题不是网站有多强而是你的请求特征太像脚本了。我一般的做法是三个动作同时做。第一固定一个完整的User-Agent和Accept-Language让请求头接近浏览器第二每页请求之间加随机延时把1秒到2秒的间隔打散第三把每次请求到的HTML缓存到本地目录下次跑的时候优先读缓存。第三点很多人忽略但它其实是整个爬虫实战里最有用的“后悔药”——豆瓣页面的榜单变化很慢你完全不需要每次重新请求一遍缓存还能帮你定位解析问题页面结构变了和代码写错了是两种完全不同的排错路径。import os import time import random 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 ), Accept-Language: zh-CN,zh;q0.9,en;q0.8, } CACHE_DIR html_cache def get_page(start: int, use_cache: bool True) - str: os.makedirs(CACHE_DIR, exist_okTrue) cache_file os.path.join(CACHE_DIR, ftop250_{start}.html) if use_cache and os.path.exists(cache_file): with open(cache_file, encodingutf-8) as f: return f.read() url fhttps://movie.douban.com/top250?start{start}filter resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() html resp.text with open(cache_file, w, encodingutf-8) as f: f.write(html) time.sleep(random.uniform(1.0, 2.0)) return html逻辑说明get_page先是构造带start参数的URLstart0对应第1页start25对应第2页每页25条。拿到响应后先写缓存再延时这样即使后续解析代码写错了重新调试时也不会再消耗请求次数。参数说明timeout设10秒避免某个页面卡住拖死整个脚本random.uniform(1.0, 2.0)把间隔控制在1到2秒之间这个频率远低于豆瓣的风控阈值足够稳定。不要为了赶时间把延时去掉这个项目的数据量决定了你省下的时间最多几秒但触发的风控可能要等几分钟甚至更久。提示缓存目录建议单独建不要和代码混在一起。文档说明里把这一步写清楚别人拿到源码后第一眼就知道哪些文件是缓存、哪些是源码。2.2 用BeautifulSoup提取Top250单页字段选择器、解析与翻页参数拿到HTML后真正核心的解析逻辑其实固定在单页里。Top250列表页的结构一直是ol.grid_view下的li每个li包含一部电影的完整信息标题在.hd下的.title里评分在.star下的.rating_num引用语在.quote下的span而导演、主演、年份、地区、类型全部塞在.info .bd下面的p标签里。这个p标签内部用br分隔成两行是需要单独处理的。from bs4 import BeautifulSoup import re def parse_page(html: str, start: int) - list[dict]: soup BeautifulSoup(html, lxml) items [] for li in soup.select(ol.grid_view li): rank int(li.select_one(.pic em).text) title_tag li.select_one(.hd .title) title title_tag.text.strip() if title_tag else rating_tag li.select_one(.star .rating_num) rating float(rating_tag.text) if rating_tag else 0.0 quote_tag li.select_one(.quote span) quote quote_tag.text.strip() if quote_tag else info_p li.select_one(.info .bd p) info_lines info_p.get_text( , stripTrue).split( ) # info_lines 大概是: # [导演: 某某 / 主演: 某某, 1994 / 美国 / 犯罪 剧情] person_line info_lines[0] if len(info_lines) 0 else meta_line info_lines[1] if len(info_lines) 1 else items.append({ rank: rank, title: title, rating: rating, quote: quote, person_line: person_line, meta_line: meta_line, }) return items逻辑说明先用select定位列表项再逐字段提取。这里的技巧是把导演主演单独留成一个原始字符串不要急着拆因为不同条目的主演人数不一样拆早了容易丢失信息。meta_line同样保留整行后面清洗阶段再统一按“/”拆分。参数说明select_one在目标不存在时返回None所以每个字段都做了兜底。rank用int转换rating用float转换万一豆瓣调整了评分显示精度这个转换也不会崩。get_text( , stripTrue)的作用是把p标签里的br换成空格再拼接这样两行文本还能用split( )分开如果不加这个参数br会直接把文本拼成一坨后续清洗就没法做了。3. 字段清洗与存储复合字段拆分、去重与CSV落盘的落地规则3.1 年份、制片地区、类型混在一行用正则和split拆干净爬到的数据里最脏的不是评分而是meta_line这一行文本。它长这样“1994 / 美国 / 犯罪 剧情”但有三种变体非常容易让人翻车年份后面带括号备注比如“1993 / 中国大陆 / 中国香港”制片国家或地区本身带斜杠比如“美国 / 英国”类型字段由两个英文单词组成比如“剧情 爱情”。如果无脑用split(/)然后取第0个元素当年份就会把“1993中国大陆”整个当成年份字符串。def clean_meta(meta_line: str): # 先用正则把年份摘出来年份一定是4位数字 year_match re.search(r(\d{4}), meta_line) year int(year_match.group(1)) if year_match else None # 去掉年份和可能带括号的备注剩下的才是“地区 / 类型” rest re.sub(r\d{4}.*?/, , meta_line) parts [p.strip() for p in rest.split(/)] country parts[0] if parts else genres [g.strip() for g in parts[1].split()] if len(parts) 1 else [] return year, country, genres逻辑说明先正则搜索4位连续数字作为年份把年份从原字符串里摘出去避免后续split被年份备注干扰。rest里可能还残留“中国大陆 / 中国香港”这样的多地区字段直接取第一个作为主要制片地区类型则用split()按空白切分因为豆瓣的类型字段中间是空格不是斜杠。参数说明re.search而不是re.findall因为一部电影只有一个年份。re.sub的替换模式里\d{4}.*?/是非贪婪匹配作用是从年份开始一直去掉到第一个斜杠这样即使年份后面跟着“(中国大陆)”也能一次清干净。genres用split()而不是split( )既能处理单个空格也能处理多个空格。主演字段同样需要单独处理。person_line长这样“导演: 弗兰克·德拉邦特 Frank Darabont 主演: 蒂姆·罗宾斯 Tim Robbins / 摩根·弗里曼 Morgan Freeman”里面中英文混排演员之间用斜杠分隔。def clean_person(person_line: str): director actors [] if 导演: in person_line: director_part person_line.split(导演:)[1].split(主演:)[0] director director_part.strip() if 主演: in person_line: actors_part person_line.split(主演:)[1] # 只要前3位主演 actors [a.strip() for a in actors_part.split(/)[:3]] return director, actors逻辑说明先按“导演:”切出导演段再按“主演:”切出演员段。演员只取前3位因为Top250条目里主演最多能达到六七个取全了会让后面的词频分析变得很碎。这里有一个隐藏坑有些老电影条目没有“主演:”字样只有导演所以两个if都必须独立判断不能把逻辑写成else。参数说明split(/)[:3]中的[:3]是硬编码如果某天想分析完整演员阵容改成不带切片即可。director的strip()不能省因为导演字段后面可能带着一个空格再拼接主演字段。3.2 从DataFrame到CSV编码、去重与断点续爬的落地写法清洗完单个条目后把全部10页数据汇总成DataFrame。这里要定一列统一字段顺序rank、title、year、country、genres、director、actors、rating、quote其中actors和genres是列表存CSV前需要转成字符串否则pandas会写出带引号的Python字面量。import pandas as pd def build_dataframe(all_items: list[dict]) - pd.DataFrame: cleaned [] for item in all_items: year, country, genres clean_meta(item[meta_line]) director, actors clean_person(item[person_line]) cleaned.append({ rank: item[rank], title: item[title], year: year, country: country, genres: /.join(genres), director: director, actors: /.join(actors), rating: item[rating], quote: item[quote], }) df pd.DataFrame(cleaned) df df.drop_duplicates(subset[rank]).sort_values(rank) df.to_csv(top250.csv, indexFalse, encodingutf-8-sig) return df逻辑说明清洗函数接收原始字段输出规整的单条记录。genres和actors用“/”重新拼回字符串是为了让CSV每一列都是可读文本而不是Python列表字面量。drop_duplicates按rank去重防止重复请求或断点续爬时产生重复行。参数说明encodingutf-8-sig是给Excel用户准备的不加这个参数Excel打开CSV时中文会乱码如果只给pandas自己读普通utf-8就够。sort_values(rank)保证输出顺序稳定后续分析图和榜单排名一致调试时方便对照原始网页。注意初版代码不要把清洗和分析混在同一个文件里。源码至少要拆成crawler.py、clean.py、analysis.py三个模块文档说明里写清楚运行顺序否则改一个字段格式就要连带改画图代码这是项目腐化最快的方式。4. 避坑与常见问题4条让爬取、清洗与分析翻车的真实记录4.1 请求返回418或解析结果为空列表现象、原因与排查顺序现象代码第一次跑通加了一个for循环请求10页跑到第4页突然打印出“418”状态码或者页面正常返回但select(ol.grid_view li)一个元素都选不到。原因连续请求触发了风控豆瓣返回了一个验证页面还有一种情况是你改了headers里的某个字段导致页面返回的是默认的静态模板而不是电影列表。这两个原因的表现形式完全一样都是“没有li”。解决先打印response.status_code和response.text的前200个字符确认是418还是200。如果是418说明频率太高把延时从random.uniform(1.0, 2.0)改成random.uniform(2.0, 4.0)如果返回200但没有li检查headers里是否漏了Accept-Language或者User-Agent被requests库覆盖成了默认值。我踩过一次最蠢的坑在for循环外面定义的session循环里却每次新建requests.get导致cookies没有复用服务端把每次请求都当成新连接风控阈值更低。4.2 正则拆年份“吞掉”了数据爬到了却洗丢了现象写正则的时候想匹配年份和地区用了re.findall(r\d{4}, meta_line)发现《霸王别姬》的年份输出是1993这没问题但用同一个正则去匹配带备注的条目比如“1993(中国香港) / 中国大陆”年份后面莫名多了一段字符串。原因正则\d{4}只匹配4位数字不匹配后面的括号备注但如果你在提取完后没有把备注部分从原字符串里remove掉下一步split(/)时“1993(中国香港)”就成了一个整体。解决按代码段的顺序来先re.search提取年份再re.sub把从年份开始的这截内容连同斜杠一起删掉最后才做split。这个顺序不能反过来——如果先split再提取年份遇到“美国 / 英国”这种地区本身带斜杠的条目年份索引就全乱了。我见过有人用切片取第0个元素当年份在Top250上大概90%的条目是对的剩下10%的条目错得毫无规律这种bug最可怕因为它不会报错。4.3 清洗后明明250条分析时只剩248条空值溯源现象CSV里检查行数是250但pandas读进来做groupby(year)之后年份分布的总和只有248。逐行排查发现是两条评分缺失的条目被dropna带走了。原因豆瓣Top250并不是每条都有“引用语”quote字段在解析时被赋了空字符串。清洗阶段如果写了df df.dropna()空字符串不会触发dropna但如果你把空字符串fillna(pd.NA)再dropna就会把这两条看似“不完整”的数据删掉。解决分析脚本里只对参与计算的列做空值处理比如df.dropna(subset[rating, year])绝不对整张表做全字段dropna。更稳的做法是清洗阶段把quote的空字符串统一替换成“无”这样无论是存CSV还是后续做词云都不会影响主链路。4.4 matplotlib中文乱码与坐标轴重叠可视化最常见的两座山现象画评分分布图标题和坐标轴文字全是方块画年份分布图X轴年份标签挤成一团什么都看不清。原因matplotlib默认字体不含中文字符需要显式指定字体年份从1939到2023跨度太大默认刻度策略不会自动旋转标签。解决绘图前先设置三行rcParams把字体切到SimHei并关闭unicode负号。如果运行环境是Linux服务器SimHei不存在改成Noto Sans CJK SC。X轴标签在刻度超过10个时加plt.xticks(rotation45)这一步不是美化是数据可读性的底线。下面的代码块是每次画图都要带的配置import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False逻辑说明font.sans-serif必须放在绘图代码之前且不能放在函数内部否则每次调用都需要重复设置。axes.unicode_minus控制负号显示不设置的话坐标轴负号会变成方块这个问题在评分分布图里特别明显。5. 数据分析与可视化用pandas算出结论、用图表把结论讲清楚的完整链路5.1 用pandas算评分分布、年代分布与地区Top10代码与参数数据清洗完成后的第一步不是画图而是先想清楚要回答哪几个问题。Top250这个数据能支撑的结论大致有四类评分分布是否集中在9分以上、哪个十年贡献的榜单影片最多、哪些国家或地区上榜最多、哪些导演多次上榜。每个问题对应一个pandas操作代码量不大但参数细节值得讲。import pandas as pd import numpy as np df pd.read_csv(top250.csv) # 1. 评分分布按0.1分间隔做分桶统计 df[rating_bin] pd.cut(df[rating], binsnp.arange(8.0, 10.0, 0.1)) rating_dist df[rating_bin].value_counts().sort_index() # 2. 年代分布按十年分桶 df[decade] (df[year] // 10) * 10 decade_dist df[decade].value_counts().sort_index() # 3. 地区分布把“美国 / 英国”拆成多行再计数 df_country df.assign(country_listdf[country].str.split(/)).explode(country_list) country_top10 df_country[country_list].str.strip().value_counts().head(10) # 4. 导演上榜频次 director_top10 df[director].value_counts().head(10)逻辑说明pd.cut用等距分箱把评分分成从8.0到9.9的区间这样才能看出“9.0到9.1”和“9.1到9.2”是否有断层。年代分桶用的是整除技巧year // 10 * 10把1994变成1990、2003变成2000天然形成十年粒度。地区分布里explode是关键原始数据里一部合拍片可能同时属于美国和英国只有拆成两行才能准确统计地区占比。参数说明np.arange(8.0, 10.0, 0.1)生成的区间是左闭右开9.9分会被归到9.9到10.0那一桶不会丢失。value_counts()默认按频次降序distinct排序时用sort_index()恢复区间顺序否则画出来的X轴是乱的。head(10)限制了输出条目Top250里上榜国家或地区有几十个只取前10画图避免条形图X轴被挤爆。5.2 静态图先对齐结论交互图再谈美观matplotlib与pyecharts的分工数据分析与可视化实践里最常见的错误是一上来就追美观图表样式调了一下午结论没验证。我的一般流程是先用matplotlib出四张静态图确认数据和结论对得上再用pyecharts做成可交付的HTML看板。后者不改变分析结论只改变展示层。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False fig, axes plt.subplots(2, 2, figsize(14, 10)) # 左上评分分布直方图 axes[0, 0].hist(df[rating], bins20, edgecolorwhite) axes[0, 0].set_title(Top250评分分布) # 右上年代分布柱状图 decade_dist.plot(kindbar, axaxes[0, 1]) axes[0, 1].set_title(上榜电影年代分布) # 左下地区Top10条形图 country_top10.plot(kindbarh, axaxes[1, 0]) axes[1, 0].set_title(制片地区Top10) # 右下评分与评价人数散点图 axes[1, 1].scatter(df[rating], df[rating_count]) axes[1, 1].set_xlabel(评分) axes[1, 1].set_ylabel(评价人数) plt.tight_layout() plt.savefig(top250_analysis.png, dpi150)逻辑说明四张图分别对应四个分析结论。评分分布直方图能直接看出9.0到9.5是不是密集区年代柱状图用来观察近几年上榜数量是增还是减地区条形图回答“哪个国家的电影最受榜单认可”右下角的散点图是把评分和评价人数放一起看高评分是否等于高热度。matplotlib的核心价值是快和糙让你在十分钟内确认数据没有异常。参数说明figsize(14, 10)控制总画布尺寸四个子图才不会挤在一起。dpi150是保存清晰度的下限低于这个值放大看会有锯齿。tight_layout()会自动调整子图间距不调用的话标题和坐标轴会互相压住。pyecharts的用法跟matplotlib完全不同。它生成的是HTML文件可以在浏览器里交互适合放进交付文档或可视化大屏。下面是用它画地区Top10的完整代码from pyecharts.charts import Bar from pyecharts import options as opts bar ( Bar() .add_xaxis(country_top10.index.tolist()) .add_yaxis(上榜数量, country_top10.values.tolist()) .set_global_opts( title_optsopts.TitleOpts(title豆瓣Top250制片地区分布), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(rotate45)), ) ) bar.render(country_top10.html)逻辑说明pyecharts的链式调用把数据、坐标轴和全局配置组合到一个对象里render输出独立HTML文件不需要本地起服务。rotate45是必须的Top10地区的名字比较长不旋转会重叠。这个HTML可以直接发给任何人双击就能看hover有数值提示。pyecharts适合做交付但我不建议把全部图表都交给它。散点图和直方图用pyecharts配置成本高matplotlib两行就画完要做可视化大屏时再把这几个HTML用Page对象组合起来一个页面放四五个图切换tab就能看不同维度。顺序很重要用matplotlib验证结论用pyecharts包装展示不要反着来。6. 验证与进阶把一次性脚本变成可复用的数据管道6.1 用自检脚本对全流程做断言长度、唯一性、空值与范围校验分析跑完不等于项目完工。我见过太多案例前一天清洗逻辑改了一个正则第二天重新生成图表时少了5条数据没人发现。给这个项目加一个自检函数成本很低但能挡住大部分回归问题。def validate(df: pd.DataFrame) - None: assert len(df) 250, f行数异常: {len(df)} assert df[rank].is_unique, rank列存在重复 assert df[rating].between(8.0, 10.0).all(), 评分超出合理范围 assert df[year].between(1930, 2030).all(), 年份超出合理范围 missing df[[rating, year, title]].isna().sum().sum() assert missing 0, f关键字段存在空值: {missing} print(自检通过: 250条数据完整字段无异常)逻辑说明自检脚本只验证不修改任何一项失败都会让程序中断并报出具体原因。行数校验是最基本的一道闸门防的是清洗阶段误删数据rank唯一性校验防的是翻页重复或去重失败评分和年份区间校验防的是正则拆错导致数据漂移。参数说明assert后面的提示信息要具体宁可写成“rank列存在重复”也不要只写“数据异常”。between(8.0, 10.0)是左闭右闭豆瓣Top250的最低分是8.2不会误伤。关键空值校验只查参与分析的列不查quote这类可有可无的字段这个设计跟第四章提到的空值坑呼应上了。6.2 增量更新与自动化从“跑一次”到“每周跑一次”如果这个项目只是跑一次出几张图那源码组织成什么样都无所谓但如果打算持续跟进榜单变化就需要考虑增量更新。常见做法是给爬虫脚本加一个force参数默认读缓存和已有CSV需要强制刷新时加上--force重新请求所有页面并覆盖CSV。我个人现在的习惯是清洗逻辑每改一次就顺手把自检函数跑一遍再决定要不要重新爬取。这个项目规模小不追求自动化调度但把“自检 缓存 参数化”这三个意识养成习惯后面接任何数据源都能复用这套骨架。我的项目文档里会写清楚三件事环境依赖怎么装、脚本按什么顺序运行、哪些字段是清洗后新增的派生字段。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表