简介:面向高校计算机、软件工程等专业毕业设计场景的完整项目包,围绕豆瓣电影数据爬取、清洗分析与可视化展开,采用Flask搭建后端、ECharts负责图表呈现,并以pandas处理数据、MySQL存储结果,适合需要可运行源码、数据库脚本与论文材料的学生参考与二次开发。压缩包共1054个文件,约30.07MB,以688个Python源码、157个pyc缓存、15个HTML页面、11个JavaScript脚本及SQL脚本、CSV数据、论文文档等为主,涵盖爬虫、后端接口、前端页面、依赖环境与数据库文件。已有256人学习。读者可据此了解从Pycharm环境搭建、目标站点数据抓取、字段清洗入库到多角度动态图表展示的完整链路,并借助现成目录结构快速定位爬虫模块、Flask路由、ECharts配置与SQL建表脚本;论文材料可用于梳理系统设计、功能实现与测试说明,减少从零搭建和反复调试的成本。
1. 一套能直接跑通答辩链路的 python毕业设计:豆瓣电影 python数据分析 与 python数据可视化 到底交付了什么
前阵子帮人看一份 python毕业设计,题目是豆瓣电影榜单的数据分析与可视化,拿到手的目录挺唬人,结果跑起来第一步就卡在数据库连不上。翻回去看才发现,对方只下载了源码没看 sql 脚本里的字符集配置。这套 Flask + ECharts + 爬虫 + pandas 的组合,放在 python数据分析 类毕业设计里算是很标准的一条链路:采集层把榜单页拉下来,pandas 做清洗和衍生字段,MySQL 落库,Flask 出聚合接口,ECharts 在页面上画图。
它解决的不是「学不学得会 pandas」的问题,而是「一条完整数据流怎么串起来还不散架」的问题。适合两类人:正在做 python数据可视化 课题、需要一份能改能扩的骨架的;以及写过脚本但从没把采集-清洗-入库-接口-前端走过一遍的初中级开发。源码、sql 脚本、论文文档是打包在一起的,论文那部分当参考结构看,别当标准答案抄。
2. 采集层拆解:榜单页怎么变成一张干净的 DataFrame
2.1 为什么这套方案不用 Scrapy 也不用 Selenium
先看数据源形态。榜单页是服务端直出的静态 HTML,一页 25 条,翻页靠 URL 上的 start 参数(0、25、50…),总共十页 250 条。这种结构下上 Scrapy 是杀鸡用牛刀——你得为它配 middleware、pipeline、settings,调试成本远超收益;上 Selenium 更没必要,它要拉起完整浏览器内核,单机跑十页的性能开销比 requests 高一个数量级,还得处理驱动版本对齐这种玄学问题。
我一般的判断标准是:目标页面在浏览器里禁用 JavaScript 后仍能看到完整内容,就用 requests + BeautifulSoup;只有关键数据靠 XHR 异步加载、或者必须执行 JS 才渲染的,才考虑无头浏览器。这份资源走的是前者,脚本两百行以内能读完,改起来门槛低,答辩时被问到「你这爬虫怎么设计的」也讲得清。
页面结构上,列表项挂在ol.grid_view下的li里,每项内部有几个关键节点:em存排名序号,span.title存片名(注意它会出现多次,第一个是中文名,第二个通常是外文原名,取错就只剩一半数据),span.rating_num存评分,span.inq存一句短评,div.bd > p的第一行是导演和主演,第二行是「年份 / 国家 / 类型」用斜杠分隔的字符串。真正需要拆分清洗的就是这最后一行。
选型理由说清楚了,接下来落到能跑的代码。这份脚本我按「抓取 → 解析 → 落盘」三段拆开,方便你单测某一段。
2.2 requests + BeautifulSoup 的可复现采集脚本
# spider/douban_spider.py import csv import random import time import requests from bs4 import BeautifulSoup BASE_URL = "https://movie.douban.com/top250" HEADERS = { # UA 必须带,缺少时服务端可能返回精简页,解析结果为空 "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": BASE_URL, } PAGE_SIZE = 25 # 每页固定 25 条,翻页靠 start 偏移 MAX_PAGE = 10 # 十页共 250 条 SLEEP_RANGE = (2, 5) # 请求间隔秒数区间,别设成 0.1 def fetch(url, retry=3): """带退避重试的抓取,失败不抛出,返回 None 交给上层判断""" for i in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200 and "grid_view" in resp.text: resp.encoding = resp.apparent_encoding # 中文页面必须显式指定 return resp.text print(f"[warn] 状态码异常 {resp.status_code}, 第 {i + 1} 次重试") except requests.RequestException as e: print(f"[warn] 请求异常 {e}, 第 {i + 1} 次重试") time.sleep(3 * (i + 1)) # 线性退避:3s、6s、9s return None def parse_page(html): """把一页 HTML 解析成字典列表,字段与下游 CSV 表头一一对应""" soup = BeautifulSoup(html, "lxml") rows = [] for li in soup.select("ol.grid_view > li"): titles = [t.get_text(strip=True) for t in li.select("span.title")] rank = li.select_one("em").get_text(strip=True) rating = li.select_one("span.rating_num").get_text(strip=True) quote_node = li.select_one("span.inq") info_lines = li.select_one("div.bd > p").get_text("\n", strip=True).split("\n") staff = info_lines[0] if info_lines else "" meta = info_lines[1] if len(info_lines) > 1 else "" # meta 形如 "1994 / 美国 / 犯罪 剧情",按斜杠切三段 parts = [p.strip() for p in meta.split("/")] year = parts[0] if len(parts) > 0 else "" country = parts[1] if len(parts) > 1 else "" genre = parts[2] if len(parts) > 2 else "" rows.append({ "rank": rank, "title": titles[0] if titles else "", "original_title": titles[1] if len(titles) > 1 else "", "staff": staff, "year": year, "country": country, "genre": genre, "rating": rating, "quote": quote_node.get_text(strip=True) if quote_node else "", }) return rows def main(): all_rows = [] for page in range(MAX_PAGE): url = f"{BASE_URL}?start={page * PAGE_SIZE}&filter=" html = fetch(url) if html is None: print(f"[error] 第 {page + 1} 页抓取失败,跳过") continue rows = parse_page(html) # 解析条数对不上时立刻停下来,别把脏数据灌进库 if len(rows) != PAGE_SIZE: print(f"[error] 第 {page + 1} 页只解析到 {len(rows)} 条,疑似页面结构变更") all_rows.extend(rows) print(f"[info] 第 {page + 1} 页完成,累计 {len(all_rows)} 条") time.sleep(random.uniform(*SLEEP_RANGE)) with open("data/raw_movies.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=list(all_rows[0].keys())) writer.writeheader() writer.writerows(all_rows) print(f"[done] 写入 {len(all_rows)} 条") if __name__ == "__main__": main()逐段说逻辑。fetch里的retry参数控制重试次数,默认 3 次,每次间隔线性增长,目的是给服务端一点缓冲,也避免短时间高频请求把 IP 送进限制名单。resp.encoding = resp.apparent_encoding这行别删,requests 对中文页面的默认编码判断经常翻车,不显式设置会出现「片名全是问号」的经典故障。
parse_page里titles取的是一个列表而不是单个值,因为同一个li下span.title会出现两到三次,第一次中文名、第二次原名、有时还有第三次别名。取titles[0]和titles[1]是成本最低的兼容做法。info_lines用get_text("\n")按换行切,比正则匹配稳,因为那一块内部还有<br>标签。
SLEEP_RANGE建议保持在 2 到 5 秒,250 条数据总共十来页,多等几分钟换稳定,比反复重跑划算。PAGE_SIZE和MAX_PAGE这两个参数直接决定总量,如果你只想跑前 3 页做演示,把MAX_PAGE改成 3 即可,下游清洗脚本不用动。
2.3 先落 CSV 再入库:一份后悔药
很多人图省事,抓一条INSERT一条。我不建议这么干,理由有三个。第一,解析出错的时候你没法回溯——数据已经进库了,字段错位你只能重爬。第二,清洗逻辑迭代时,每次都要重新走一遍网络请求,调试一轮十分钟,效率极低。第三,答辩现场网络不稳,你至少还有一份本地文件能演示。
所以这份资源的链路是「抓取 → CSV → 清洗 → MySQL」,中间留了一层文件快照。除了 CSV,我还会额外把每页的原始 HTML 存到data/raw_html/page_{n}.html,Parser 改一行就能离线重跑,不用碰网络。这个习惯是踩坑踩出来的:有一回页面结构调整,把「类型」从第二行挪到了别处,如果没有原始 HTML,我只能重爬一遍,而那次正好赶上请求被限。
增量更新也基于这层快照做。判断新数据的方式是拿 CSV 里的title + year组合去重,而不是拿rank,因为榜单排名会变动,但一部片的身份不会。重跑时把新抓到的行 append 进总表,再用 pandas 的drop_duplicates(subset=["title", "year"], keep="last")去重,keep="last"保证新抓的覆盖旧的。
3. 清洗与入库:pandas 管道和 MySQL 表结构怎么对齐
3.1 表结构设计:两张表还是宽表
先说结论,这套资源用的是「主表 + 类型子表」的两表结构。原因是类型字段是典型的多值字段,一部片同时属于「剧情 / 犯罪 / 悬疑」,直接塞进一个 varchar 里,后面做类型分布统计就得在 SQL 里用LIKE '%剧情%'硬匹配,既慢又不准,还容易把「剧情片」和「喜剧剧情」混在一起。
主表脚本大致是这么写的:
-- sql/schema.sql CREATE DATABASE IF NOT EXISTS douban_analysis DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE douban_analysis; DROP TABLE IF EXISTS movie_info; CREATE TABLE movie_info ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, rank_no SMALLINT NOT NULL COMMENT '榜单名次', title VARCHAR(128) NOT NULL COMMENT '中文片名', original_title VARCHAR(128) DEFAULT NULL COMMENT '外文原名', staff VARCHAR(255) DEFAULT NULL COMMENT '导演/主演原始串', release_year SMALLINT DEFAULT NULL COMMENT '上映年份,解析失败为 NULL', country VARCHAR(64) DEFAULT NULL, rating DECIMAL(3,1) NOT NULL COMMENT '评分,必须用 DECIMAL 不能用 VARCHAR', quote VARCHAR(255) DEFAULT NULL, crawl_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_title_year (title, release_year), KEY idx_rating (rating), KEY idx_year (release_year) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; DROP TABLE IF EXISTS movie_genre; CREATE TABLE movie_genre ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, movie_id INT UNSIGNED NOT NULL, genre VARCHAR(32) NOT NULL, KEY idx_movie (movie_id), KEY idx_genre (genre) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个参数值得单独说。rating用DECIMAL(3,1)而不是VARCHAR,这是排序正确性的生死线——用字符串存「9.7」和「10.0」,ORDER BY出来的顺序会让你怀疑人生,因为字符串比较是逐字符来的。UNIQUE KEY uk_title_year是幂等写入的基础,重复跑脚本不会把库灌成双份。utf8mb4而不是utf8,因为外文原名里可能出现四字节字符,用utf8会在插入时报Incorrect string value。
movie_genre不设外键约束,是刻意的。批量写入时外键检查会显著拖慢速度,而且清洗阶段难免出现脏movie_id,有外键会让整批INSERT直接失败。用应用层保证一致性,配合跑完之后的孤儿数据检查 SQL 兜底,实际更好用。
3.2 pandas 清洗管道的五个动作
CSV 到入库之间是这段清洗代码,核心是把year、country、genre三个字段从字符串拆干净,并补上类型子表需要的行:
# etl/clean.py import re import pandas as pd RAW = "data/raw_movies.csv" GENRE_OUT = "data/clean_genre.csv" INFO_OUT = "data/clean_movie.csv" def extract_year(text): """从 '1994(美国)' 或 '1994' 里抠四位年份,失败返回 None""" if not isinstance(text, str): return None m = re.search(r"(19|20)\d{2}", text) return int(m.group()) if m else None def split_genre(text): """'犯罪 剧情' -> ['犯罪', '剧情'],同时兼容顿号和斜杠分隔""" if not isinstance(text, str) or not text.strip(): return [] parts = re.split(r"[\/\s、]+", text.strip()) return [p for p in parts if p] def main(): df = pd.read_csv(RAW, encoding="utf-8-sig") # 1) 类型转换:评分转 float,名次转 int,失败置 NaN 而不是抛错 df["rating"] = pd.to_numeric(df["rating"], errors="coerce") df["rank_no"] = pd.to_numeric(df["rank"], errors="coerce").astype("Int64") # 2) 年份抽取:原始串里常带括号说明,用正则统一 df["release_year"] = df["year"].apply(extract_year) # 3) 缺失值处理:年份缺失的记录直接丢,评分为 0 的视为异常也丢 before = len(df) df = df.dropna(subset=["release_year"]) df = df[df["rating"] > 0] print(f"[clean] 缺失过滤:{before} -> {len(df)}") # 4) 去重:以 title + release_year 为业务主键 df = df.drop_duplicates(subset=["title", "release_year"], keep="last") # 5) 炸开类型字段,生成子表 df["genre_list"] = df["genre"].apply(split_genre) genre_df = df.explode("genre_list").dropna(subset=["genre_list"]) genre_df = genre_df.rename(columns={"genre_list": "genre"})[["title", "release_year", "genre"]] df[["rank_no", "title", "original_title", "staff", "release_year", "country", "rating", "quote"]].to_csv(INFO_OUT, index=False, encoding="utf-8-sig") genre_df.to_csv(GENRE_OUT, index=False, encoding="utf-8-sig") print(f"[done] 主表 {len(df)} 行,类型子表 {len(genre_df)} 行") if __name__ == "__main__": main()errors="coerce"这个参数是整段里最关键的容错开关,它把无法转换的值变成NaN而不是抛异常,后续用dropna统一处理,管道不会因为一条脏数据整体中断。反过来如果你希望「脏数据必须暴露」,就改成errors="raise",适合数据源已经稳定的场景。
explode是 pandas 里做一对多拆表的利器,一行带列表的 DataFrame 直接炸成多行,配合dropna就能去掉空类型。这一步产出的genre_df是后续「类型分布饼图」和「类型雷达图」的唯一数据源,不用再回主表做字符串匹配。
keep="last"的语义是保留最后一次出现的记录,配合增量采集就能实现「新数据覆盖旧数据」。如果你的场景是历史快照都要留,那就改成在主表上增加crawl_time维度、去掉唯一约束,两种做法各有适用面。
3.3 批量写入:to_sql 舒服但慢,executemany 才是正解
十页 250 条数据用df.to_sql其实也能跑,但一旦你把采集范围扩到几千条,to_sql逐行拼INSERT的写法就会明显拖时间。我在这份资源里改成用executemany一次性提交:
# etl/load.py import pandas as pd import pymysql CONN = dict(host="127.0.0.1", port=3306, user="root", password="your_password", database="douban_analysis", charset="utf8mb4", autocommit=False) def load_movies(csv_path): df = pd.read_csv(csv_path, encoding="utf-8-sig") rows = [ (int(r.rank_no), r.title, r.original_title, r.staff, int(r.release_year), r.country, float(r.rating), r.quote) for r in df.itertuples(index=False) ] sql = ("INSERT INTO movie_info " "(rank_no, title, original_title, staff, release_year, country, rating, quote) " "VALUES (%s, %s, %s, %s, %s, %s, %s, %s) " "ON DUPLICATE KEY UPDATE rating = VALUES(rating), rank_no = VALUES(rank_no)") conn = pymysql.connect(**CONN) try: with conn.cursor() as cur: cur.executemany(sql, rows) # 一次网络往返提交全部行 conn.commit() print(f"[load] 主表写入 {len(rows)} 行") except Exception: conn.rollback() # 失败整批回滚,不留半截数据 raise finally: conn.close()ON DUPLICATE KEY UPDATE这段是幂等写入的核心,依赖建表时的uk_title_year唯一索引。重复跑脚本时,命中唯一键就更新评分和名次,没命中就插入,不会产生重复行。autocommit=False加上显式commit/rollback,保证一批要么全成功要么全失败。
批次大小上,executemany一次塞几千行没问题,但超过一万行建议切块,每块 2000 行提交一次,避免单条 SQL 包过大被服务端拒绝。写到子表时要注意先查主表拿到movie_id,用title + release_year做映射,别指望自增主键能对上。
4. Flask 接口层:聚合结果怎么变成 ECharts 吃得下的 JSON
4.1 蓝图划分与接口清单
后端不用把所有路由堆在app.py里,按业务拆蓝图更清晰:api/basic.py放基础统计,api/rank.py放榜单类,api/search.py放筛选。这份资源实际用到的接口和对应图表大致是这么对应的:
| 接口路径 | 返回内容 | 对应图表 |
|---|---|---|
/api/rating/dist | 评分区间与数量 | 直方图 |
/api/genre/top | 类型及影片数 TOP10 | 横向柱状图 |
/api/year/trend | 各年份平均分与数量 | 双轴折线柱状 |
/api/country/top | 国家/地区占比 | 饼图 |
/api/movie/rank | 评分 TOP20 明细 | 榜单表格 |
/api/quote/wordcloud | 短评词频 | 词云 |
路由本身不复杂,麻烦的是数据契约要保持一致。我统一成三段式返回:{"code": 0, "data": {...}, "msg": "ok"},前端只认code === 0才渲染。这个约定看起来琐碎,但少了它,前端在每个接口里都要写一遍if (res && res.data)的判断,改起来就是灾难。
4.2 聚合查询:SQL 直出还是 pandas 二次加工
原则很简单:能在 SQL 里聚合完的,不要拉回 Python 再做。原因是大结果集走网络、走 ORM 序列化都很慢,而 MySQL 的GROUP BY背后是索引和临时表优化。比如年份趋势这条:
# api/basic.py from flask import Blueprint, jsonify import pymysql bp = Blueprint("basic", __name__, url_prefix="/api") SQL_YEAR_TREND = """ SELECT release_year AS year, COUNT(*) AS cnt, ROUND(AVG(rating), 2) AS avg_rating FROM movie_info WHERE release_year BETWEEN 1930 AND 2025 GROUP BY release_year HAVING cnt >= 1 ORDER BY year """ @bp.route("/year/trend") def year_trend(): conn = pymysql.connect(**CONN) try: with conn.cursor(pymysql.cursors.DictCursor) as cur: cur.execute(SQL_YEAR_TREND) rows = cur.fetchall() # 离散年份补零,避免折线图出现断点 years = [r["year"] for r in rows] full = list(range(min(years), max(years) + 1)) lookup = {r["year"]: r for r in rows} data = [lookup.get(y, {"cnt": 0, "avg_rating": None}) for y in full] return jsonify({ "code": 0, "data": { "years": full, "counts": [d["cnt"] for d in data], "avgRatings": [d["avg_rating"] for d in data], }, "msg": "ok", }) finally: conn.close()DictCursor这个游标类型让结果直接是字典,省掉手动zip(columns, row)的活。补零那一段容易被忽略,但它是必做的——年份有断层时,ECharts 的类目轴会跳过缺失项,看起来像数据丢了,实际上是前端不知道这个位置该画什么。
反过来,有些统计 SQL 写起来就很不划算。比如短评词云要先分词再统计词频,jieba只能在 Python 里跑,这时候就得把quote字段拉回来做二次加工。判断依据是「计算能否被下推为一次聚合」,能就走 SQL,不能就走 pandas。
4.3 ECharts 的数据契约:从 JSON 到 option
前端拿到的结构必须能直接塞进option,中间不要有太多转换。以类型分布为例,后端返回{"genres": [...], "counts": [...]},前端只需要做一次 map:
// static/js/genre_top.js fetch('/api/genre/top') .then(res => { // 后端统一返回 {code, data, msg},先做状态判断再取 data if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); }) .then(({ code, data }) => { if (code !== 0) return; const chart = echarts.init(document.getElementById('genreChart')); chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: 80, right: 24, top: 16, bottom: 24 }, xAxis: { type: 'value', name: '影片数' }, yAxis: { type: 'category', data: data.genres.reverse(), // 横向柱状图从大到小,视觉上要从上往下 axisLabel: { fontSize: 12 } }, series: [{ type: 'bar', data: data.counts.reverse(), barWidth: '55%', itemStyle: { color: '#5b8ff9', borderRadius: [0, 4, 4, 0] } }] }); }) .catch(err => console.error('[chart] 类型图加载失败', err));reverse()这两处必须成对出现,只反一个会导致柱子和标签错位,这是新手最容易翻的车之一。grid.left设成 80 是为了给中文类型名留出空间,设太小会被截断成省略号。
更稳妥的做法是把echarts.init放在 DOM 就绪之后,并且给容器一个明确的 CSS 高度。我在base.css里统一写了.chart-box { width: 100%; height: 380px; },容器没高度时init出来的画布是 0×0,页面上什么都不显示,控制台也不报错,这种静默失败排查起来最费时间。
4.4 筛选联动:下拉框怎么驱动整块看板
联动做不好,图表就是六张互不相关的静态图。这套资源的做法是维护一个全局筛选状态{year: null, genre: null, country: null},任何一个下拉变化时,把所有接口带上同样的 query 参数重发一遍。接口侧用统一的参数拼装函数处理:
def build_filter(year=None, genre=None, country=None): """返回 (where 片段, 参数列表),所有接口共用同一套过滤逻辑""" clauses, params = [], [] if year: clauses.append("release_year = %s") params.append(year) if country: clauses.append("country LIKE %s") params.append(f"%{country}%") where = ("WHERE " + " AND ".join(clauses)) if clauses else "" return where, params关键在于「同一套过滤逻辑复用」,而不是每个接口各写一遍if year:。参数用%s占位而不是字符串拼接,country LIKE %s这种写法既避免了注入,也保留了模糊匹配能力。前端那边做个 300ms 的防抖,避免用户连续切下拉时把服务端打出一串无意义请求。
5. 避坑与排查:四条把演示现场从翻车边缘拉回来的记录
5.1 状态码 200 但一条都没解析出来
现象:脚本跑完不报错,raw_movies.csv只有表头,print出来的累计条数是 0。
原因:页面确实返回了 200,但内容不是正常列表页。常见有两种:一是缺少User-Agent导致服务端返回精简版页面;二是短时间高频请求后被重定向到验证页,而重定向同样返回 200。另外resp.encoding没设置时,grid_view这个选择器仍能匹配,但中文全变问号。
解决:第一,检查HEADERS里 UA 和Referer是否带全;第二,在fetch里加一条判定"grid_view" in resp.text,不满足就重试,这条比只看状态码靠谱;第三,把原始 HTML 落盘,用浏览器打开看一眼实际拿到了什么。这个习惯救过我不止一次,比在控制台里猜高效得多。
5.2 评分排序错乱:字符串排序的锅
现象:按评分降序查出来的榜单,9.7 排在 9.10 后面,甚至 10.0 掉到了中间。
原因:rating字段建表时用了VARCHAR,MySQL 按字符逐位比较,"9.7" 的第一位 '9' 大于 "10.0" 的第一位 '1',所以 9 开头的全部排在 10 前面。这就是黑匣子式的错误——数据看着没错,排序结果不对,不查字段类型根本发现不了。
解决:改成DECIMAL(3,1),长度 3 表示最多三位(含小数点),精度 1 表示一位小数。改完之后索引idx_rating才真正生效。如果表里已有脏数据,先ALTER TABLE改类型,MySQL 会尝试隐式转换,转换失败的会变 0,跑一遍SELECT * FROM movie_info WHERE rating = 0把它们挑出来重洗。
5.3 入库报 Incorrect string value
现象:主表能写,子表写到某一条时整批失败,报错Incorrect string value: '\xF0\x9F...'。
原因:字段字符集是utf8(三字节),而某些外文原名里含有四字节字符(emoji 或部分生僻字)。utf8mb4才是真正的 UTF-8,MySQL 里的utf8是个兼容三字节的历史遗留。
解决:建库、建表、连接串三处都要写utf8mb4,缺一处就白改。, charset="utf8mb4"这行连接参数经常被漏掉,连接层用了 latin1,即便表是 utf8mb4 也会在传输时损坏。改完之后跑一次全量重灌,别指望半截数据能修复。
5.4 页面跑起来了,图表一片空白
现象:Flask 控制台显示接口全部 200,浏览器 Network 里 JSON 正常返回,但六个图表位置全是空的,Console 也没有红色报错。
原因:八成是容器高度问题。ECharts 初始化时会读取容器的clientWidth和clientHeight计算画布尺寸,如果父级用了 flex 布局而子级没给显式高度,clientHeight会是 0,画出来就是一块 0×0 的画布。它不抛异常,所以你只能看到一个安静的白板。
解决:给每个图表容器加固定的height,别依赖100%往上撑。echarts.init之前用document.getElementById(id).clientHeight打一行日志确认不是 0。另外记得在window.resize时调用chart.resize(),否则窗口拉大之后图还是原来那个尺寸。
6. 最后一章:上线前跑一遍数据一致性校验,比多画两张图有用
演示前我固定会做一件事:跑一遍三端对账。采集层 CSV 的行数、入库后主表的行数、接口返回的条数,三个数字必须能对上,对不上就定位到具体是哪一层丢的。这个动作花不了五分钟,但能挡掉答辩现场最尴尬的那种情况——老师随手点一个筛选条件,图表空了,你现场排查。
校验脚本我一般写成这样,直接丢进项目根目录:
# tools/check_consistency.py import pandas as pd import pymysql RAW = "data/raw_movies.csv" df = pd.read_csv(RAW, encoding="utf-8-sig") raw_cnt = len(df) conn = pymysql.connect(host="127.0.0.1", user="root", password="your_password", database="douban_analysis", charset="utf8mb4") with conn.cursor() as cur: cur.execute("SELECT COUNT(*) FROM movie_info") db_cnt = cur.fetchone()[0] cur.execute("SELECT COUNT(*) FROM movie_info WHERE release_year IS NULL") null_year = cur.fetchone()[0] cur.execute("""SELECT COUNT(*) FROM movie_genre g LEFT JOIN movie_info m ON g.movie_id = m.id WHERE m.id IS NULL""") orphan = cur.fetchone()[0] conn.close() print(f"CSV={raw_cnt} DB={db_cnt} 年份缺失={null_year} 孤儿类型行={orphan}") assert orphan == 0, "存在孤儿类型行,先查 load_genre 的 movie_id 映射" assert db_cnt <= raw_cnt, "库中条数多于原始数据,检查是否漏了去重"三个断言的语义分别是:类型子表不能出现主表里不存在的movie_id,主表条数不可能超过原始 CSV(去重只会让它变少),年份缺失要能解释清楚来源。这种校验比写单元测试省事,也比肉眼检查可靠。
第二个习惯是压测接口响应时间。用curl -w "@curl-format.txt" -o /dev/null -s http://127.0.0.1:5000/api/year/trend连打二十次,看稳定值落在哪个量级。250 条数据下所有接口都应该在 50ms 以内,如果某个接口跑到 500ms,八成是GROUP BY的字段没建索引,或者连接池没配、每次请求都重新建连。加索引这件事收益很直接,movie_info上的idx_year和idx_rating就是为了这个准备的。
第三个技巧是给看板做一条「三分钟演示路径」:先看总览(评分分布 + 类型 TOP),再点一个年份看趋势联动,最后落到 TOP20 榜单明细。这条路径上的三个接口提前预热一次,避免首次请求触发连接建立导致页面卡顿。这套顺序我改过好几版,最终留下的是最能体现「数据分析链路完整」那条——不是为了好看,是为了让讲的人有节奏、听的人不掉线。
血泪经验是这么来的:早前有一次演示,我在现场才发现库里的数据是三天前抓的,趋势图最后一段是空的,只能临时解释成「数据更新有延迟」。从那以后我每次把项目交出去之前,都强制走一遍「重跑采集 → 重跑清洗 → 全量重灌 → 三端对账 → 接口压测」这个五步循环,一步不省。数据类项目最怕的不是功能少,是你自己都不知道库里现在装的是什么。
希望帮到你。
本文还有配套的精品资源,点击获取