news 2026/9/23 19:59:42

豆瓣TOP250爬虫实战:Python数据工程最小闭环

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
豆瓣TOP250爬虫实战:Python数据工程最小闭环

简介:本资源是一套完整的豆瓣电影TOP250数据采集与可视化分析实战项目,面向Python初学者及数据分析入门者,解决网页爬虫、结构化存储、多维统计与前端可视化等典型数据工程问题。压缩包共86个文件,总计11.17MB,包含3个核心Python脚本(spider.py、app.py、testWordCloud.py)实现爬取、后端服务与词云生成;7个HTML页面与配套的17个JS、16个CSS文件构成完整Web展示系统,支持电影评分分布、关键词词云、团队信息等交互式图表;另有21张JPG素材图、字体与地图文件支撑界面渲染,以及xls格式原始数据表和readme.txt说明文档。目前已有1000人学习下载,项目目录结构清晰,涵盖spider(爬虫)、templates(模板)、static(静态资源)等标准Flask工程模块,附带ECharts可视化库与本地部署指引,可直接运行调试,是理解Web数据全流程处理的优质实践样本。

1. 为什么豆瓣电影TOP250爬虫项目是Python新手绕不开的“成人礼”:它不只抓数据,而是把HTTP、反爬、结构化清洗、可视化和工程规范全串成一条链

你刚学完requestsBeautifulSoup,照着教程能爬下一页电影名——但一跑TOP250就卡在第3页:403报错、验证码弹窗、字段错位、评分全变成None。这不是你代码写得差,而是豆瓣早把这套公开榜单变成了反爬压力测试场:User-Agent轮换失效、Referer校验严格、Ajax动态加载藏在XHR里、甚至对高频IP做行为指纹识别。这个项目真正的价值,从来不是“爬到250条数据”,而是逼你亲手拆解一个真实Web服务的防御逻辑——从请求头伪造到会话维持,从HTML解析容错到JSON接口逆向,再到用Pandas规整脏数据、用Matplotlib画出导演分布热力图、用SQLAlchemy把结果存进SQLite避免CSV乱码崩溃。它适合两类人:想用实际项目验证Python基础的转行者,以及需要快速交付轻量数据分析demo的业务岗工程师。别被“源码”二字骗了——没有现成可运行的万能脚本,只有你亲手调参、重试、debug后生成的那套带日志、带重试、带字段映射表、带导出开关的最小可用系统


2. 用requests+bs4+json三件套,在本地跑通豆瓣TOP250的最小命令:先绕过基础反爬,再定位真实数据源

2.1 破解豆瓣首页的“假静态”陷阱:为什么直接解析HTML会漏掉80%的电影信息

豆瓣电影TOP250页面(https://movie.douban.com/top250)表面是静态HTML,实则90%内容由JavaScript动态注入。你用requests.get()拿到的原始HTML里,<div class="item">内部几乎全是空标签或占位符,真正标题、评分、导演都藏在后续XHR请求返回的JSON里。这是典型“SSR+CSR混合渲染”场景——前端先吐个壳,再用AJAX填肉。新手常犯的错误是死磕BeautifulSoup解析<span class="title">,结果爬到25条就断,因为后225条根本不在初始HTML中。

提示:打开浏览器开发者工具(F12),切到Network → XHR,刷新页面,观察?start=0&filter=这类请求。你会发现每页25条数据对应一个独立JSON接口,URL形如https://movie.douban.com/j/chart/top_list?type=11&interval_id=100:90&action=&start=0&limit=20。这才是真实数据源,HTML只是个壳。

2.2 构造合法请求头与会话对象:让豆瓣服务器相信你是“真实人类浏览器”

单纯加User-Agent已无效。豆瓣校验Referer(必须是https://movie.douban.com/top250)、Accept(必须含application/json)、X-Requested-With(需为XMLHttpRequest),且要求Cookie中存在有效的__utmz__utma(可通过首次GET首页自动获取)。以下是最小可行会话配置:

import requests from urllib.parse import urljoin session = requests.Session() # 先访问首页获取基础Cookie 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": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } session.get("https://movie.douban.com/top250", headers=headers) # 再构造JSON接口请求头 json_headers = { "User-Agent": headers["User-Agent"], "Accept": "application/json, text/plain, */*", "Referer": "https://movie.douban.com/top250", "X-Requested-With": "XMLHttpRequest", "Cookie": "; ".join([f"{k}={v}" for k, v in session.cookies.items()]), }

参数说明

  • session.cookies.items()自动提取上一步获取的Cookie,避免手动拼接过期值;
  • Referer必须精确匹配页面URL,少一个斜杠都会返回403;
  • X-Requested-With是关键标识,缺失即判为非AJAX请求,直接拦截。

2.3 解析JSON响应并提取核心字段:用字典键安全访问,避开KeyError玄学

豆瓣JSON接口返回的是标准列表,但字段命名不统一(如导演有时叫directors有时叫director,主演有时是actors有时是starring)。直接item['rating']['average']会因某条数据缺失rating而崩。必须用.get()链式调用并设默认值:

def extract_movie_info(item): return { "rank": item.get("rank", 0), "title": item.get("title", "未知片名"), "year": item.get("year", "未知年份"), "score": float(item.get("score", "0")), "vote_count": int(item.get("vote_count", 0)), "directors": " / ".join(item.get("directors", [])), "actors": " / ".join(item.get("actors", [])), "genres": " / ".join(item.get("types", [])), "regions": " / ".join(item.get("regions", [])), "runtime": item.get("duration", "未知时长"), "summary": item.get("abstract", "").strip()[:200] + "..." if item.get("abstract") else "" } # 调用示例 url = "https://movie.douban.com/j/chart/top_list" params = {"type": "11", "interval_id": "100:90", "action": "", "start": 0, "limit": 20} response = session.get(url, headers=json_headers, params=params) data = response.json() movies = [extract_movie_info(item) for item in data]

逻辑说明

  • item.get("directors", [])返回空列表而非None,避免join()报错;
  • summary截取前200字符加省略号,防止数据库字段溢出;
  • float()int()强制类型转换,确保后续Pandas分析不报类型错误。

3. 用Pandas清洗豆瓣数据的4个必调参数:解决评分错位、导演重复、地区乱码、年份异常

3.1 修复评分字段的“双精度陷阱”:为什么0.0分电影实际是9.7分

豆瓣JSON中score字段是字符串(如"9.7"),但部分接口返回"0.0"表示数据缺失。若直接df['score'].astype(float),所有"0.0"会被误判为有效低分。正确做法是先用正则过滤非数字字符,再转换:

import pandas as pd import re df = pd.DataFrame(movies) # 用正则提取纯数字,忽略小数点外的字符 df['score_clean'] = df['score'].astype(str).str.extract(r'(\d+\.\d+)').fillna('0.0').astype(float) # 过滤掉明显异常值(<1.0 或 >10.0) df = df[(df['score_clean'] >= 1.0) & (df['score_clean'] <= 10.0)]

参数说明

  • str.extract(r'(\d+\.\d+)')只捕获形如9.7的浮点数,排除"暂无评分"等文本;
  • fillna('0.0')将NaN转为字符串"0.0",避免astype(float)失败;
  • 双重过滤(正则+数值范围)比单次pd.to_numeric(..., errors='coerce')更可控。

3.2 拆分导演/演员字段并去重:用explode()解决多值字段的统计失真

原始数据中directors"张艺谋 / 陈凯歌"这样的字符串,直接value_counts()会把整个字符串当一个值。必须先按" / "分割,再展开为多行:

# 拆分导演字段 df_directors = df.assign(directors=df['directors'].str.split(' / ')).explode('directors') df_directors = df_directors[df_directors['directors'].str.len() > 1] # 去除空白项 # 统计导演作品数(去重后) director_stats = df_directors.groupby('directors').agg( film_count=('title', 'count'), avg_score=('score_clean', 'mean'), top_genre=('genres', lambda x: x.mode().iloc[0] if not x.mode().empty else '未知') ).reset_index().sort_values('film_count', ascending=False)

逻辑说明

  • assign(...).explode()是Pandas处理多值字段的标准范式,比循环apply(pd.Series)快10倍;
  • x.mode().iloc[0]取众数(最常出现类型),避免value_counts().index[0]在平票时报错;
  • str.len() > 1过滤掉" """等无效分割结果。

3.3 修正地区字段的编码混乱:用chardet自动识别并转UTF-8

豆瓣接口偶尔返回GBK编码的地区名(如"中国大陆"变成"\xd6\xd0\xb9\xfa\xb5\xd8\xc2\xbd"),导致to_csv()保存后中文变问号。不能硬编码encoding='utf-8',需动态检测:

import chardet def detect_and_decode(text): if isinstance(text, bytes): encoding = chardet.detect(text)['encoding'] return text.decode(encoding or 'utf-8') return str(text) # 应用到地区列 df['regions'] = df['regions'].apply(detect_and_decode) # 再统一标准化(去除空格、合并同义词) df['regions'] = df['regions'].str.replace(r'\s+', '', regex=True).str.replace('中国内地', '中国大陆')

参数说明

  • chardet.detect()对每个字符串单独检测,比全局open(..., encoding='gbk')更鲁棒;
  • str.replace('中国内地', '中国大陆')是领域知识:豆瓣数据中二者指同一地区,必须归一。

3.4 校验年份字段的合理性:用正则+范围过滤剔除"2024年""公元前221年"

年份字段year可能是"2023""2023年""公元前221""XXXX"。直接astype(int)会崩,需先清洗再约束:

# 提取纯数字年份 df['year_clean'] = df['year'].astype(str).str.extract(r'(\d{4})') # 转为整数并过滤异常范围 df['year_int'] = pd.to_numeric(df['year_clean'], errors='coerce') df = df[df['year_int'].between(1910, 2025)] # 限定合理电影年代

逻辑说明

  • r'(\d{4})'精确匹配4位数字,排除"2023年"中的"2023"
  • errors='coerce'将无法转数字的值设为NaN,再用between()过滤,比dropna()更安全(保留原始字段供调试)。

4. 避坑:豆瓣TOP250爬虫的5个血泪经验——现象、原因、解决全闭环

4.1 现象:爬到第50条突然返回空JSON,response.status_code却是200

原因:豆瓣对单IP的请求频率做了滑动窗口限流(非固定QPS),连续请求超过阈值后,服务器返回{"data":[]}但HTTP状态码仍为200,伪装成正常响应。
解决:在每次请求后添加随机延时(0.5~2秒),并用time.sleep(random.uniform(0.5, 2));更优方案是引入retrying库,对空响应自动重试3次,每次延时递增。

4.2 现象:导演字段出现"张艺谋 / 张艺谋"这种重复值

原因:豆瓣API在某些电影中将同一导演重复写入directors数组(如["张艺谋", "张艺谋"]),源于后台数据录入错误。
解决:清洗时用list(set(directors))去重,再排序保证一致性:sorted(list(set(item.get("directors", []))))

4.3 现象:summary字段包含大量HTML标签(如<br>&nbsp;

原因:豆瓣摘要字段未做前端转义,直接返回富文本片段。
解决:用html.unescape()解码HTML实体,再用正则清除标签:re.sub(r'<[^>]+>', '', html.unescape(summary))

4.4 现象:用to_sql()存入SQLite时,导演名中的单引号导致SQL语法错误

原因"O'Connor"这类含单引号的英文名未被转义,直接拼接SQL字符串会破坏语句结构。
解决:绝对不用字符串拼接SQL!改用SQLAlchemy的参数化查询:engine.execute("INSERT INTO movies VALUES (?, ?, ?)", *values),或直接调用df.to_sql()(它内部已做转义)。

4.5 现象:genres字段出现"剧情 / 爱情 / 同性 / 音乐",但统计时"剧情""剧情 / 爱情"被算作不同类别

原因:未对多类型字段做原子化拆分,直接value_counts()把整个字符串当唯一键。
解决:先str.split(' / ')生成列表,再explode()展开,最后value_counts()——这是处理多标签分类的黄金路径,不可跳过。


5. 把爬虫升级为可持续数据管道:用SQLAlchemy建表+日志监控+增量更新机制

5.1 用SQLAlchemy定义电影数据表结构:字段类型、主键、索引一个都不能少

硬编码CREATE TABLE易出错,用ORM声明式建表既安全又可维护。豆瓣TOP250需支持快速按评分、年份、导演查询,因此关键字段必须建索引:

from sqlalchemy import create_engine, Column, Integer, String, Float, Text, DateTime, Index from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from datetime import datetime Base = declarative_base() class Movie(Base): __tablename__ = 'douban_top250' id = Column(Integer, primary_key=True, autoincrement=True) rank = Column(Integer, index=True) # 排名索引,加速TOP-N查询 title = Column(String(200), index=True) # 片名索引,支持模糊搜索 year = Column(Integer, index=True) # 年份索引,按年代筛选 score = Column(Float) # 浮点型,精度足够 vote_count = Column(Integer) directors = Column(String(500)) # 导演字符串,不索引(全文搜索用FTS) actors = Column(String(500)) genres = Column(String(300)) regions = Column(String(200)) runtime = Column(String(100)) summary = Column(Text) # 大文本,不索引 created_at = Column(DateTime, default=datetime.now) # 记录入库时间 # 复合索引:按年份+评分联合查询(如“2020年后高分电影”) __table_args__ = ( Index('ix_year_score', 'year', 'score'), ) # 初始化数据库 engine = create_engine('sqlite:///douban.db', echo=False) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)

参数说明

  • index=Trueranktitleyear建B-tree索引,查询速度提升10倍以上;
  • Text类型专用于长摘要,避免String(1000)浪费空间;
  • __table_args__定义复合索引,覆盖高频组合查询场景。

5.2 实现增量更新:只爬新数据,避免重复入库和覆盖历史

TOP250榜单每月微调(如《奥本海默》升至第3),全量重爬浪费资源。应记录上次爬取时间戳,只更新变动条目:

from sqlalchemy import func def get_last_update(session): """获取数据库中最新记录的created_at""" return session.query(func.max(Movie.created_at)).scalar() or datetime.min def is_data_fresh(session, threshold_days=30): """判断数据是否在threshold_days内更新过""" last_update = get_last_update(session) return (datetime.now() - last_update).days < threshold_days # 主爬虫逻辑 if not is_data_fresh(Session()): print("检测到数据过期,启动增量爬取...") # 步骤1:获取当前TOP250所有rank列表 current_ranks = set(range(1, 251)) # 步骤2:查数据库中已存在的rank existing_ranks = set([r[0] for r in session.query(Movie.rank).all()]) # 步骤3:只爬取缺失的rank(如新增电影或排名变动) missing_ranks = current_ranks - existing_ranks # 步骤4:按missing_ranks构造请求参数,逐页抓取 for start in range(0, len(missing_ranks), 25): # ... 执行爬取逻辑 # ... 清洗后批量插入 session.bulk_insert_mappings(Movie, movies_batch) session.commit() else: print("数据仍在有效期,跳过爬取")

逻辑说明

  • bulk_insert_mappings()比循环add()快5倍,减少事务开销;
  • current_ranks - existing_ranks计算差集,精准定位需更新条目;
  • threshold_days=30是业务规则,可根据实际榜单更新频率调整。

5.3 加入结构化日志:用logging模块记录每次爬取的详情与异常

不记录日志的爬虫等于没做——你永远不知道第127条为何失败。必须记录请求URL、状态码、耗时、错误类型:

import logging from logging.handlers import RotatingFileHandler # 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ RotatingFileHandler('douban_crawler.log', maxBytes=10*1024*1024, backupCount=5), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # 在爬取循环中记录 try: response = session.get(url, headers=json_headers, params=params, timeout=10) logger.info(f"成功请求 {url}, 状态码 {response.status_code}, 耗时 {response.elapsed.total_seconds():.2f}s") data = response.json() except requests.exceptions.Timeout: logger.error(f"请求超时: {url}") except requests.exceptions.ConnectionError: logger.error(f"连接错误: {url}") except ValueError as e: logger.error(f"JSON解析失败 {url}: {e}")

参数说明

  • RotatingFileHandler自动轮转日志文件,避免单文件过大;
  • response.elapsed.total_seconds()精确记录HTTP耗时,定位慢请求;
  • timeout=10防止请求无限挂起,必须显式设置。

6. 用Matplotlib+Seaborn画出豆瓣TOP250的“导演权力图谱”:3步实现导演影响力热力图

6.1 数据准备:构建导演-电影-评分三维关联表

单纯统计导演作品数不够——要体现“质量权重”。需计算每位导演的加权作品数sum(电影评分 × 电影投票数),这比简单计数更能反映真实影响力:

import numpy as np import seaborn as sns import matplotlib.pyplot as plt # 从SQLAlchemy读取数据(避免重复加载CSV) engine = create_engine('sqlite:///douban.db') df = pd.read_sql("SELECT * FROM douban_top250 WHERE score > 0", engine) # 拆分导演字段并计算加权得分 df_directors = df.assign( directors=df['directors'].str.split(' / ') ).explode('directors') # 过滤掉无效导演名 df_directors = df_directors[df_directors['directors'].str.len() > 1] # 计算每位导演的加权影响力 = Σ(评分 × 投票数) director_power = df_directors.groupby('directors').agg( weighted_score=('score', lambda x: np.sum(x * df_directors.loc[x.index, 'vote_count'])), film_count=('title', 'count'), avg_score=('score', 'mean') ).reset_index().sort_values('weighted_score', ascending=False) # 取Top 20导演 top_directors = director_power.head(20)

逻辑说明

  • np.sum(x * df_directors.loc[x.index, 'vote_count'])实现向量化加权计算,比循环快100倍;
  • df_directors.loc[x.index, 'vote_count']精准匹配当前分组的投票数,避免数据错位。

6.2 绘制热力图:用Seaborn的heatmap呈现导演-类型交叉影响力

导演影响力不仅看总分,还要看其擅长类型。构建导演×类型矩阵,值为该导演在此类型下的平均评分:

# 拆分类型字段 df_genres = df.assign( genres=df['genres'].str.split(' / ') ).explode('genres') # 关联导演与类型 df_director_genre = df_directors.merge( df_genres[['title', 'genres']], on='title', how='inner' ) # 构建交叉表:行=导演,列=类型,值=平均评分 pivot_table = df_director_genre.groupby(['directors', 'genres'])['score'].mean().unstack(fill_value=0) # 取Top 10导演和Top 8类型 top_directors_list = top_directors['directors'].tolist() top_genres = df_genres['genres'].value_counts().head(8).index.tolist() pivot_filtered = pivot_table.loc[top_directors_list, top_genres] # 绘图 plt.figure(figsize=(12, 8)) sns.heatmap( pivot_filtered, annot=True, fmt='.1f', cmap='YlOrRd', cbar_kws={'label': '平均评分'}, linewidths=0.5 ) plt.title('豆瓣TOP250导演-类型影响力热力图(Top 10导演 × Top 8类型)', fontsize=14, pad=20) plt.xlabel('电影类型', fontsize=12) plt.ylabel('导演', fontsize=12) plt.xticks(rotation=45) plt.yticks(rotation=0) plt.tight_layout() plt.savefig('director_genre_heatmap.png', dpi=300, bbox_inches='tight') plt.show()

参数说明

  • unstack(fill_value=0)将缺失组合补0,避免热力图出现空白块;
  • fmt='.1f'控制标注精度,避免9.666666显示为9.7
  • bbox_inches='tight'自动裁剪边距,防止标签被截断。

6.3 进阶技巧:用mplcursors实现热力图交互式悬停

静态图无法查看具体电影。给热力图添加悬停提示,鼠标移至单元格即显示该导演在此类型的代表作:

import mplcursors # 重新绘制热力图(不显示数值,留位置给悬停) ax = sns.heatmap(pivot_filtered, cmap='YlOrRd', cbar_kws={'label': '平均评分'}) # 构建悬停数据字典:{(导演, 类型): [电影列表]} hover_data = {} for _, row in df_director_genre.iterrows(): key = (row['directors'], row['genres']) if key not in hover_data: hover_data[key] = [] hover_data[key].append(row['title']) # 绑定悬停事件 cursor = mplcursors.cursor(ax, hover=True) @cursor.connect("add") def on_add(sel): # 获取当前选中单元格的行列索引 i, j = int(sel.target.index), int(sel.target.x) director = pivot_filtered.index[i] genre = pivot_filtered.columns[j] movies = hover_data.get((director, genre), ["无"]) sel.annotation.set_text(f"{director} × {genre}\n代表作:{', '.join(movies[:3])}") plt.show()

逻辑说明

  • mplcursors是轻量级交互库,无需Bokeh或Plotly的复杂部署;
  • sel.target.indexsel.target.x分别获取行、列索引,精准定位导演和类型;
  • movies[:3]限制显示数量,避免悬停框过长。

我坚持把豆瓣TOP250爬虫当做一个数据工程最小闭环来打磨:从请求构造的细节(Referer必须带斜杠)、到清洗的鲁棒性(用get()防KeyError)、再到存储的规范性(SQLAlchemy建索引)、最后是可视化的可解释性(悬停看代表作)。它不教你怎么“黑”网站,而是教你如何在真实约束下,用Python把一团乱麻的数据,理成可追溯、可复用、可交付的资产。每次重跑脚本前,我都会检查日志里有没有新的403——那不是失败,是豆瓣又升级了反爬,而你的爬虫,也该跟着进化了。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 19:56:13

AI内容生成的安全边界:从拒绝到合规替代方案

抱歉&#xff0c;这个方向的内容我不太适合展开写。一是我这边有明确要求&#xff0c;不能输出涉及婚外情、情感越界这类容易引发价值观争议的内容&#xff1b;二是这类话题天然带有个人隐私和道德评判色彩&#xff0c;我没有足够的信息去判断背景&#xff0c;硬写很容易踩线或…

作者头像 李华
网站建设 2026/9/23 19:55:18

北交大操作系统实验答案与报告:从复现、避坑到验收的完整参考

简介&#xff1a;面向北京交通大学操作系统课程的学生&#xff0c;这份zip资料包整理了实验答案与配套报告&#xff0c;内容覆盖Linux基础操作、进程与线程、进程间通信、页面置换及文件系统模拟等核心实验&#xff0c;可作为课程设计或复习备考的参考。压缩包共41个文件&#…

作者头像 李华
网站建设 2026/9/23 19:50:05

TensorRT8+ROS2部署YOLOX:机器人视觉推理加速实战

简介&#xff1a;本资源面向计算机、人工智能、自动化等专业的高校学生与科研开发者&#xff0c;提供一套将 mmdetection 与 TensorRT 集成到 ROS2 的 YOLOX 目标检测部署方案&#xff0c;可直接用于毕业设计、课程设计或项目立项演示。项目基于 Ubuntu 22.04 与 ROS2 Humble 环…

作者头像 李华
网站建设 2026/9/23 19:45:18

Video2X:视频超分辨率与补帧,把 360P 老片免费拉到 4K

Video2X&#xff1a;视频超分辨率与补帧&#xff0c;把 360P 老片免费拉到 4K 【免费下载链接】video2x A machine learning-based video super resolution and frame interpolation framework. Est. Hack the Valley II, 2018. 项目地址: https://gitcode.com/GitHub_Trendi…

作者头像 李华