尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

BeautifulSoup实战:豆瓣Top250三源数据采集与可视化分析

BeautifulSoup实战:豆瓣Top250三源数据采集与可视化分析 简介这套基于Python BeautifulSoup的爬虫与数据分析系统源码适合计算机、数学、电子信息等专业学生完成课程设计、期末大作业或毕业设计。项目覆盖电影、图书、音乐三大类数据的采集、清洗与分析流程体现从网页解析到结果可视化的完整技术链路。压缩包共252个文件约15MB其中61个py源文件承载爬虫与核心分析逻辑52个pyc为可执行编译产物html/css/js文件构成结果展示界面png/jpg为截图与可视化图表xml/json/sqlite3存储采集与中间数据pdf与ipynb则提供说明文档和Notebook分析过程。目录留有前后端及数据库模块便于二次开发与调试。目前已有123人学习下载适合能够读懂Python代码、希望较快上手真实爬虫项目的读者作为参考借鉴。1. 为什么用 BeautifulSoup 做三源数据采集反而比框架更顺手拿到这个项目的第一反应我以为是套 Scrapy 或者 Playwright 写的打开源码才发现核心就是requests BeautifulSoup数据源锁定豆瓣系电影、图书、音乐三个入口。这个选型其实很聪明豆瓣这几个榜单的 HTML 结构相对稳定数据量级也远没到需要分布式爬虫或异步抓取的程度。用requests做同步请求用bs4做静态页面解析单机单线程就能在几分钟内拿到全量清单再把结果落到 CSV 里交给pandas分析。整个链路没有多余环节特别适合课程设计和期末大作业级别的项目——逻辑看得懂、调试不费劲、答辩有话说。我拆完这套源码之后最深的体会是这类爬虫分析系统真正的技术难点不在“爬”而在“解析规则的设计”和“分析维度的选择”。BeautifulSoup确实不像正则那么难写也不像XPath那样需要额外维护顺序关系但你得先理解目标页面的 DOM 结构才能写出稳定的选择器。这篇文章我会从项目源码的实际写法出发把从请求构造、选择器编写、数据清洗到可视化呈现的完整链路梳理一遍并给出可以直接替换运行的代码和参数说明。2. 请求构造与页面结构分析先想清楚 HTML 长什么样再动手2.1 为什么直接请求能拿到数据三个站点的静态结构特征豆瓣 Top250 系列页面movie.douban.com/top250、book.douban.com/top250、music.douban.com/top250在桌面端访问时返回的是服务端渲染的完整 HTML目标字段标题、评分、评价人数、主演、出版信息等都直接嵌在 DOM 里不需要额外请求接口。这一点决定了用requests就够用不必上 Selenium 或 Playwright 处理动态渲染。我用curl快速验证了一下响应体发现页面里每个条目都被包在li节点下内部有固定的class命名空间电影页是.item和.info图书页是.item和.info音乐页则是.item和.info——三个站点的外层容器结构出奇一致差异主要体现在字段排列上。这个发现意味着我们可以用一套解析框架只针对不同字段做微调。import requests from bs4 import BeautifulSoup 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, Referer: https://movie.douban.com/ } url https://movie.douban.com/top250?start0 resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, lxml) print(soup.select_one(.grid_view).get_text()[:300])这段代码的核心是用BeautifulSoup(resp.text, lxml)把 HTML 转成可查询的文档树。resp.encoding utf-8是必须的豆瓣页面里如果缺失这行中文会变成乱码。headers里三个字段的作用分别是User-Agent伪装浏览器身份Referer传递来源降低被拦截的概率Accept-Language确保服务端返回中文版本页面。实际运行项目源码时如果出现 418 或 403优先检查这三个值是否被目标站点更新过策略。2.2 分页参数 start 的本质偏移量而非页码豆瓣这套分页用start参数控制起始偏移位置每页默认 25 条。很多初学者会把start当作页码去写循环结果抓回来的数据要么重复、要么缺漏。正确的写法是把start当成“已抓取条数”的累计值每页抓完后从当前页面解析出的条目数加回去。目标页start 值覆盖条目区间第 1 页01 ~ 25第 2 页2526 ~ 50第 3 页5051 ~ 75第 N 页(N-1) × 25最后 25 条这个机制在豆瓣电影、图书、音乐三个 Top250 榜单上是统一的。写循环时不要硬编码页数上限而是解析当前页返回的条目数若小于 25 就说明已经到底这样能自动适配榜单长度变化。def fetch_all_pages(base_url, total_pages10): all_items [] for page in range(total_pages): start_offset page * 25 page_url f{base_url}?start{start_offset} resp requests.get(page_url, headersheaders, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, lxml) items soup.select(.grid_view .item) if not items: break all_items.extend(items) print(f已抓取 {len(all_items)} 条) return all_items循环里soup.select(.grid_view .item)用的是 CSS 选择器语法grid_view是榜单容器类名item是每个条目的卡片类名。停止条件放在if not items上比硬编码break更可靠——如果豆瓣某天把榜单结构调整了至少不会越界报错。timeout10给请求加了一个硬性上限防止单次请求长时间挂起拖垮整个任务。3. BeautifulSoup 解析实战用选择器把字段从 DOM 里抠干净3.1 电影、图书、音乐三个页面的字段映射表拿到整页文档树之后真正花时间的是字段提取。我梳理了项目源码里三个站点的解析逻辑它们虽然都挂在.item节点下但子元素的class命名有差异做一个映射表能减少重复试错数据源标题选择器评分选择器评价人数选择器补充信息选择器电影.hd .title.rating_num.star span:last-child.bd p导演/主演图书.hd .title.rating_num.star span:last-child.pub出版社/出版年音乐.hd .title.rating_num.star span:last-child.bd表演者/流派列表里.star span:last-child的含义是取classstar节点的最后一个span这个位置放的就是评价人数。直接取文本后你会得到类似 1295484人评价的字符串清洗时需要把非数字字符剥掉。电影页的导演和主演信息混在.bd p里用get_text()拿到的是整段文本还要按冒号和逗号切分才能拆出单项。3.2 一个能跑通的条目解析函数含清洗逻辑import re def parse_movie_item(item): title_tag item.select_one(.hd .title) title title_tag.get_text(stripTrue) if title_tag else rating_tag item.select_one(.rating_num) rating float(rating_tag.get_text(stripTrue)) if rating_tag else 0.0 star_tag item.select_one(.star span:last-child) star_text star_tag.get_text(stripTrue) if star_tag else 0人评价 rating_count int(re.sub(r\D, , star_text)) info_tag item.select_one(.bd p) info_text info_tag.get_text(stripTrue) if info_tag else actor_match re.search(r主演: (.*), info_text) actors actor_match.group(1).split( / ) if actor_match else [] quote_tag item.select_one(.quote .inq) quote quote_tag.get_text(stripTrue) if quote_tag else return { title: title, rating: rating, rating_count: rating_count, actors: 、.join(actors[:3]), quote: quote, }这里re.sub(r\D, , star_text)的作用是删除所有非数字字符把1295484人评价变成纯数字字符串再转int这是清洗阶段最常用的一招。rating转成float后方便后续做数值比较和排序。演员列表只取前三个是因为后面做词频统计或可视化时全量名单会导致标签过于冗余。quote经典台词是豆瓣电影页独有的字段拿来做文本分析时能补充一些题材特征。把这个函数应用到fetch_all_pages返回的每个.item节点上就能得到一条干净的字典记录。三个站点的解析函数结构一致差异只在补充信息字段的切分规则上图书页把导演/主演换成作者/出版社冒号前字段名不同分隔符同样是/音乐页的.bd里包含表演者和发行时间清洗时要把换行符\n替换为空格再切分3.3 解析失败时的定位方法先打印再下结论刚拿到这套源码时我把电影页的解析函数直接套到音乐数据上结果actor_match匹配不到任何内容演员字段全是空列表。定位问题的做法不是猜而是先把info_text打印出来看# 调试用定位解析字段失败的地方 for item in soup.select(.grid_view .item)[:3]: info_tag item.select_one(.bd p) print(repr(info_tag.get_text(stripTrue)) if info_tag else MISSING)repr()会保留字符串里的换行和空格用它能直观看到隐藏的\n和多余空白字符。豆瓣图书页的出版信息里通常有多个换行作者一行、出版社一行、出版年一行stripTrue只能去掉首尾空白行内的\n要靠replace(\n, )处理。我一般会写一个clean_text()辅助函数统一做strip、replace和连续空格合并def clean_text(raw): if not raw: return text raw.replace(\n, ).replace(\r, ) text re.sub(r\s, , text).strip() return text\s匹配所有空白字符空格、制表符、换行替换成一个普通空格后后续按 / 切分字段时不会因为多余空白导致误判。这个函数在处理三个站点的文本字段时都能通用建议直接放进项目公共模块里。4. 数据清洗与特征分析pandas 环节的常见坑和聚合思路4.1 从多页数据到 DataFrame编码与类型转换把所有解析结果收集到一个 list 之后用pandas.DataFrame(raw_records)就能直接建表。这一步有两个高频报错点一个是 CSV 写出时中文乱码需要指定encodingutf-8-sig另一个是评价人数列被当成字符串类型排序和聚合结果完全不对。import pandas as pd def records_to_dataframe(records): df pd.DataFrame(records) df[rating] pd.to_numeric(df[rating], errorscoerce) df[rating_count] pd.to_numeric(df[rating_count], errorscoerce) df df.dropna(subset[title, rating]) return df movie_df records_to_dataframe(movie_records) movie_df.to_csv(movie_top250.csv, indexFalse, encodingutf-8-sig)pd.to_numeric(..., errorscoerce)会把无法转换的值变成NaN这样后续dropna(subset[title, rating])就能把脏数据整行剔除。encodingutf-8-sig写出的 CSV 在 Excel 里打开时能正常显示中文这个细节在课程设计演示时很拿分。数据分析项目最容易翻车的点就在于字段类型没转干净就去做统计后面算出来的均值、排序全是错的到答辩时才发现数据对不上所以我现在每条数据进 DataFrame 之前都会先打印dtypes检查一遍。4.2 评分分布与评价人数聚类两个能写进报告的分析维度项目文档里给出的分析报告范本包含两个核心维度评分散点分布、评价人数与类型的聚类关系。前者直观反映榜单的口碑集中区间后者能看出不同题材所获得的大众关注度差异。对电影数据做分析时我会先算一下整体均值再按评分段分组统计数量import pandas as pd movie_df pd.read_csv(movie_top250.csv, encodingutf-8-sig) # 评分段分布 bins [0, 6, 7, 8, 9, 10] labels [6分以下, 6~7分, 7~8分, 8~9分, 9分以上] movie_df[rating_segment] pd.cut(movie_df[rating], binsbins, labelslabels) segment_count movie_df.groupby(rating_segment, observedFalse).size().reset_index(namecount) print(segment_count) # 评价人数 top10 影片 top10 movie_df.nlargest(10, rating_count)[[title, rating, rating_count]] print(top10)pd.cut()把连续分数离散成分段标签groupby后得到各分数段的数量这是展示“Top250 口碑集中在哪个区间”最直观的方法。nlargest(10, rating_count)按评价人数排序取前 10能支撑“高关注度影片与高评分是否完全重合”这类分析结论。observedFalse是为了避免groupby在分类数据上只显示有样本的分段保证每个分组键都保留。4.3 图书与音乐数据的合并分析统一标准字段项目里三个数据源是分别抓取、分开存储的但做综合分析时需要把它们合并到一张表里。这里有个设计取舍三个站点的“标题”含义不同电影名、书名、专辑名直接纵向拼接没有太大意义更适合的做法是统一成“名称”字段再新增一列“类型”做区分用pd.concat合并成一个总表movie_df[type] 电影 book_df[type] 图书 music_df[type] 音乐 common_cols [title, rating, rating_count, type] combined pd.concat([ movie_df[common_cols], book_df[common_cols], music_df[common_cols], ], ignore_indexTrue) type_stats combined.groupby(type).agg( avg_rating(rating, mean), median_rating(rating, median), total_votes(rating_count, sum), ).reset_index() print(type_stats)agg()里用元组指定列名和聚合函数avg_rating、median_rating、total_votes是输出列名。均值和中位数双指标同时展示是因为均值容易被极端值拉偏中位数能反映整体中间水平。这个合并表可以直接用来画分组箱线图或条形图也是项目源码里chart-3.html页面数据的主要来源。5. 可视化的数据通道从 pandas 到 HTML 图表的高效衔接5.1 项目自带的 notika 模板体系与图表数据注入这套项目的前端用了 notika 管理后台模板也就是style.css、bootstrap.min.css、notika-custom-icon.css这些文件名指示的环境页面入口是index.html和chart-3.html说明功能规划上把“数据看板”和“分析图表”拆成了两个页面。实际对接数据时不需要手动操作 DOM常见做法是后端先用pandas计算好聚合结果把结果转成 JSON 字符串注入到 HTML 模板里浏览器端用 ECharts 渲染。这个链路跳过数据库直接文件到页面减少了部署依赖也方便答辩时现场演示。如果环境里已经装好pyecharts也可以用它的Page组件一次性生成多个图表最终导出成独立 HTML。这个方案的好处是不需要手动编写前端 JS缺点是文件体积偏大、图表交互定制空间有限两种方案各有取舍我通常根据项目文档的原始框架来选择。5.2 把分析结果序列化成前端可读的 JSONimport json result { segment: segment_count.to_dict(orientrecords), top10: top10.to_dict(orientrecords), type_stats: type_stats.to_dict(orientrecords), } with open(analysis_result.json, w, encodingutf-8) as f: json.dump(result, f, ensure_asciiFalse, indent2)orientrecords是DataFrame.to_dict()最常用的参数它把每一行变成一个字典对象多条记录组成一个列表结构上正好能映射到 ECharts 的series.data。ensure_asciiFalse保证中文字段以明文写入 JSON不然浏览器端拿到的会是一串\u开头转义字符。前端拿到这份 JSON 后只需用chart.setOption({...})把数据挂到柱状图或饼图上就能完成整个“爬取 → 清洗 → 分析 → 可视化”的闭环。5.3 压测时需要注意的请求频率与限速策略我把完整抓取流程跑通之后还单独做了一次全量压测结果连续快速请求 50 页时触发了豆瓣的限流策略。这不是封 IP 级别的惩罚但响应时间会明显变长偶尔出现 418 状态码。解决思路不是上代理池——这个量级完全没必要而是在请求循环里加一个随机延迟import time import random for page in range(10): start_offset page * 25 resp requests.get(f{base_url}?start{start_offset}, headersheaders, timeout10) # 解析逻辑省略 time.sleep(random.uniform(2, 5))random.uniform(2, 5)让每次等待时间在 2 到 5 秒之间随机波动比固定time.sleep(3)更接近真实用户行为能有效降低请求特征的机械性。另一个容易被忽略的细节是保存数据时不要每页都调一次to_csv正确做法是先把所有记录收集到内存列表里全部抓完再一次性写文件这样既能减少磁盘 I/O也避免中途失败产生半截脏文件。本文还有配套的精品资源点击获取
返回列表