简介:这套基于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;q=0.9", "Referer": "https://movie.douban.com/" } url = "https://movie.douban.com/top250?start=0" resp = requests.get(url, headers=headers, timeout=10) 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 页 | 0 | 1 ~ 25 |
| 第 2 页 | 25 | 26 ~ 50 |
| 第 3 页 | 50 | 51 ~ 75 |
| 第 N 页 | (N-1) × 25 | 最后 25 条 |
这个机制在豆瓣电影、图书、音乐三个 Top250 榜单上是统一的。写循环时不要硬编码页数上限,而是解析当前页返回的条目数,若小于 25 就说明已经到底,这样能自动适配榜单长度变化。
def fetch_all_pages(base_url, total_pages=10): 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, headers=headers, timeout=10) 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更可靠——如果豆瓣某天把榜单结构调整了,至少不会越界报错。timeout=10给请求加了一个硬性上限,防止单次请求长时间挂起拖垮整个任务。
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的含义是取class="star"节点的最后一个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(strip=True) if title_tag else "" rating_tag = item.select_one(".rating_num") rating = float(rating_tag.get_text(strip=True)) if rating_tag else 0.0 star_tag = item.select_one(".star > span:last-child") star_text = star_tag.get_text(strip=True) 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(strip=True) 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(strip=True) 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(strip=True)) if info_tag else "MISSING")repr()会保留字符串里的换行和空格,用它能直观看到隐藏的\n和多余空白字符。豆瓣图书页的出版信息里通常有多个换行(作者一行、出版社一行、出版年一行),strip=True只能去掉首尾空白,行内的\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 写出时中文乱码,需要指定encoding="utf-8-sig";另一个是评价人数列被当成字符串类型,排序和聚合结果完全不对。
import pandas as pd def records_to_dataframe(records): df = pd.DataFrame(records) df["rating"] = pd.to_numeric(df["rating"], errors="coerce") df["rating_count"] = pd.to_numeric(df["rating_count"], errors="coerce") df = df.dropna(subset=["title", "rating"]) return df movie_df = records_to_dataframe(movie_records) movie_df.to_csv("movie_top250.csv", index=False, encoding="utf-8-sig")pd.to_numeric(..., errors="coerce")会把无法转换的值变成NaN,这样后续dropna(subset=["title", "rating"])就能把脏数据整行剔除。encoding="utf-8-sig"写出的 CSV 在 Excel 里打开时能正常显示中文,这个细节在课程设计演示时很拿分。数据分析项目最容易翻车的点就在于字段类型没转干净就去做统计,后面算出来的均值、排序全是错的,到答辩时才发现数据对不上,所以我现在每条数据进 DataFrame 之前都会先打印dtypes检查一遍。
4.2 评分分布与评价人数聚类:两个能写进报告的分析维度
项目文档里给出的分析报告范本包含两个核心维度:评分散点分布、评价人数与类型的聚类关系。前者直观反映榜单的口碑集中区间,后者能看出不同题材所获得的大众关注度差异。对电影数据做分析时,我会先算一下整体均值,再按评分段分组统计数量:
import pandas as pd movie_df = pd.read_csv("movie_top250.csv", encoding="utf-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"], bins=bins, labels=labels) segment_count = movie_df.groupby("rating_segment", observed=False).size().reset_index(name="count") 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,能支撑“高关注度影片与高评分是否完全重合”这类分析结论。observed=False是为了避免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_index=True) 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 把分析结果序列化成前端可读的 JSON
import json result = { "segment": segment_count.to_dict(orient="records"), "top10": top10.to_dict(orient="records"), "type_stats": type_stats.to_dict(orient="records"), } with open("analysis_result.json", "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2)orient="records"是DataFrame.to_dict()最常用的参数,它把每一行变成一个字典对象,多条记录组成一个列表,结构上正好能映射到 ECharts 的series.data。ensure_ascii=False保证中文字段以明文写入 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}", headers=headers, timeout=10) # 解析逻辑省略 time.sleep(random.uniform(2, 5))random.uniform(2, 5)让每次等待时间在 2 到 5 秒之间随机波动,比固定time.sleep(3)更接近真实用户行为,能有效降低请求特征的机械性。另一个容易被忽略的细节是保存数据时不要每页都调一次to_csv,正确做法是先把所有记录收集到内存列表里,全部抓完再一次性写文件,这样既能减少磁盘 I/O,也避免中途失败产生半截脏文件。
本文还有配套的精品资源,点击获取