
简介面向需要完成期末课程设计或系统学习Python爬虫与Web开发的初中级学习者此项目基于PyCharm、Flask与MySQL实现了B站番剧排行榜从动态爬取、数据清洗、入库存储到可视化展示的完整流程。压缩包共1481个文件约6.89MB核心为713个py源码与712个pyc编译文件另含exe辅助工具、txt说明文档、HTML/CSS/JS前端页面及venv虚拟环境配置等其中xml记录配置wheel与installer支持依赖重建目录按爬虫模块、数据持久化、静态资源与启动脚本划分导入即可运行。目前已有187人学习使用可作为课程设计、毕业设计雏形或综合实训案例。项目并非零散代码而是包含反爬处理、MySQL建表、Flask路由与Echarts图表交互的完整工程。通过解析番剧名称、排名、播放量等数据结构读者能掌握网络请求解析、数据库建模、后端API设计和前端可视化联动等核心技能并在实际调优中体会用户代理设置、数据安全等细节。1. Python 动态爬取番剧排行榜并做可视化课程设计最常见的命题与最稳的落地方案Python 动态爬取番剧排行榜并进行可视化处理这个名字几乎每年都会出现在各种 Python 课程设计、期末大作业和毕设选题列表里。表面上看是爬虫 Web 展示实际上考察的是完整的数据链路能不能定位动态加载的接口、能不能把脏数据清洗入库、能不能用 Flask 把数据用图表呈现出来。很多同学卡在第一步——用 requests 直接抓静态 HTML 拿到空数据或者抓到了却存不进 MySQL更常见的是图表面板一片空白。这份资源就是带着你走通全流程的实操版本requests 动态解析番剧排行榜 JSON 接口PyMySQL 写入 MySQLFlask 提供查询接口ECharts 渲染排行榜最后一键在 PyCharm 里跑起来。适合正在做课程设计、以及想快速复制一个完整 Web 爬虫可视化项目的入门开发者。2. 爬虫侧定位番剧排行榜的 JSON 接口用 requests 动态解析并清洗字段2.1 先判断目标站点的数据加载方式源码里有数据还是接口返回不要一上来就写代码。先用浏览器开发者工具看一遍你要爬的页面。以番剧排行榜页面为例打开 Network 面板刷新页面筛选 XHR 请求。你会发现首屏的数据并不是写在 html 里的而是通过一个 JSON 接口异步加载的。这就是「动态爬取」的真正含义你抓到的 response.text 里没有排行榜数据数据在接口返回的 JSON 里。我一般会做三步确认打开页面右键「查看网页源代码」搜番剧名或排名数字。搜不到就说明是动态加载。打开开发者工具 Network刷新页面找名字里带 rank、list、bangumi 之类的 XHR 请求。逐个查看响应内容找到包含完整排行榜数据的那个 JSON 链接。常见做法是直接复制该请求的 URL先用浏览器访问看返回的 JSON 结构。然后截取关键字段排名、标题、播放量、弹幕数、番剧评分、更新话数、封面图链接等。一个典型的 JSON 结构如下{ code: 0, data: { list: [ { rank: 1, title: 番剧A, play: 1234.5万, danmaku: 10.2万, score: 9.8, episode: 更新至第12话 } ] } }注意这里的播放量是字符串形式带单位不能直接进数据库的数值字段。后续清洗就是要处理这种数据。2.2 构造请求头与分页参数把字典列表清洗成结构化记录确认接口后用 requests 模拟请求。这里有个关键点很多排行榜接口对请求头里的 User-Agent、Referer 有校验缺了可能返回 412 或空数据。我一般会带上浏览器完整的请求头至少包含 User-Agent 和 Referer。import requests import json def fetch_rank(): url https://example.com/api/rank/list headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Referer: https://example.com/, Accept: application/json, text/plain, */* } params { pn: 1, # 页码 ps: 20, # 每页条数 type: daily } resp requests.get(url, headersheaders, paramsparams, timeout10) data resp.json() if data.get(code) 0: return data[data][list] return []逻辑说明requests.get 的 params 参数会自动把字典拼到 URL 后面不需要手动拼接字符串。headers 里的 Referer 是告诉服务器「我从哪个页面跳转过来」很多反爬逻辑会校验这个字段。timeout 需要设置避免网络异常时程序卡死。拿到的原始数据是字典列表但字段可能有缺失或格式不规范。比如播放量可能是 1234.5万需要转换成数值排名可能是字符串有些番剧没有评分默认为 0。清洗这一步决定了数据入库后能否排序、能否做图表def clean_data(raw_items): cleaned [] for item in raw_items: play_str item.get(play, 0) play_num parse_count(play_str) danmaku_str item.get(danmaku, 0) danmaku_num parse_count(danmaku_str) cleaned.append({ rank: int(item.get(rank, 0)), title: item.get(title, ).strip(), play: play_num, danmaku: danmaku_num, score: float(item.get(score, 0) or 0), episode: item.get(episode, ) }) return cleaned def parse_count(text): 把 1.2万 转成 12000支持 万/亿 单位 text text.replace(,, ) if 亿 in text: return int(float(text.replace(亿, )) * 100000000) if 万 in text: return int(float(text.replace(万, )) * 10000) try: return int(text) except ValueError: return 0参数说明parse_count 里对不同数量级做了区分播放量和弹幕数在番剧排行里最常见的是「万」这个单位。如果你的数据量级到「亿」也做了兼容。score 字段用float(item.get(score, 0) or 0)是因为有些项 score 可能是空字符串或 None直接 float 会抛异常。这里的or 0能把空值统一替换成 0。2.3 断点续爬与异常重试让爬虫跑完不翻车爬虫最怕跑到一半挂了然后从头再来。课程设计虽然数据量不大但网络波动、服务器限流都会导致请求失败。常见做法是加一个重试装饰器同时把已经爬到的数据先写入临时文件或数据库。import time from functools import wraps def retry(max_retries3, delay2): 简单的重试机制捕获 requests 异常和 JSON 解析异常 def decorator(func): wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except (requests.RequestException, json.JSONDecodeError) as e: print(f第 {attempt 1} 次请求失败: {e}) if attempt max_retries - 1: time.sleep(delay) return None return wrapper return decorator retry(max_retries3, delay2) def safe_fetch_rank(): # 2.2 中的请求代码 pass逻辑说明这里用装饰器统一处理异常比在每次请求里写 try/except 要干净。delay 参数表示失败后等待几秒再重试避免连续请求被服务器判定为攻击。max_retries 控制最多尝试次数。需要注意如果你的请求返回了正常响应但数据为空这种不算异常需要在业务层判断。另外如果接口有分页比如一共有 10 页可以用循环爬取并在每页之间 sleep 一个随机时间import random for page in range(1, 11): items safe_fetch_rank(page) cleaned clean_data(items) # 此处可以写入数据库或保存为 json time.sleep(random.uniform(1, 3)) # 随机延时降低被识别风险随机延时不是玄学是爬虫基础礼仪。固定延时更容易被流量特征识别。这段代码放在爬虫主流程里每爬完一页保存一次即使中断下次也能按页码跳过已爬取的数据。3. Flask MySQL把爬取结果存进数据库并设计可视化接口3.1 建库建表排名、番剧名、播放量、弹幕数、评分、更新时间数据清洗完之后下一步就是写入 MySQL。这是课程设计的核心考察点之一需要建库、建表、配置连接、插入数据。先给出一份建表语句CREATE DATABASE IF NOT EXISTS bangumi_rank DEFAULT CHARACTER SET utf8mb4; USE bangumi_rank; CREATE TABLE IF NOT EXISTS anime_rank ( id INT AUTO_INCREMENT PRIMARY KEY, rank INT NOT NULL COMMENT 排名, title VARCHAR(255) NOT NULL COMMENT 番剧标题, play BIGINT DEFAULT 0 COMMENT 播放量, danmaku BIGINT DEFAULT 0 COMMENT 弹幕数, score DECIMAL(3,2) DEFAULT 0 COMMENT 评分, episode VARCHAR(50) DEFAULT COMMENT 更新话数, crawl_date DATE NOT NULL COMMENT 抓取日期, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 入库时间, UNIQUE KEY uk_rank_date (rank, crawl_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT番剧排行榜数据;关键点说明utf8mb4必须用源数据里有日文、特殊符号utf8 存不下部分字符。rank字段单独看起来没问题但如果每天爬一次昨天的第 1 名和今天的第 1 名不是同一部番所以加了crawl_date字段区分。UNIQUE KEY uk_rank_date (rank, crawl_date)是防止同一天重复抓取同排名的数据配合INSERT ... ON DUPLICATE KEY UPDATE实现幂等写入。Python 端连接 MySQL 推荐用 PyMySQL不是 mysql-connector。原因很简单PyMySQL 是纯 Python 实现pip install pymysql 就能用不需要装系统的 C 库课程设计环境里最不容易出错。import pymysql db_config { host: localhost, user: root, password: your_password, database: bangumi_rank, charset: utf8mb4, cursorclass: pymysql.cursors.DictCursor } def get_connection(): return pymysql.connect(**db_config)参数说明cursorclass 设为 DictCursor 之后查询结果会返回字典列表方便后续转 JSON。charset 必须和建库时的字符集一致否则中文写入可能乱码。插入数据的推荐写法是使用参数化 SQL不要用 f-string 拼接因为番剧标题里可能包含引号、反斜杠直接拼接不仅危险而且容易出错def insert_anime_rank(items, crawl_date): conn get_connection() try: with conn.cursor() as cursor: sql INSERT INTO anime_rank (rank, title, play, danmaku, score, episode, crawl_date) VALUES (%s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE title VALUES(title), play VALUES(play), danmaku VALUES(danmaku), score VALUES(score), episode VALUES(episode) rows [ (item[rank], item[title], item[play], item[danmaku], item[score], item[episode], crawl_date) for item in items ] cursor.executemany(sql, rows) conn.commit() except Exception as e: conn.rollback() print(f插入失败: {e}) finally: conn.close()逻辑说明executemany 批量插入性能比逐条插入好很多。ON DUPLICATE KEY UPDATE表示当 rank crawl_date 重复时更新播放量、弹幕数等字段而不是报错。这样爬虫可以反复跑同一个日期不会产生重复数据。3.2 Flask 路由设计与 JSON 输出接口数据库有了数据接下来就是 Flask 后端。课程设计通常要求「网页可视化」所以 Flask 既要能渲染页面模板也要提供数据接口给前端图表使用。推荐的做法是一个路由返回页面另一个路由返回 JSON。from flask import Flask, render_template, jsonify, request from datetime import date, timedelta app Flask(__name__) app.route(/) def index(): return render_template(index.html) app.route(/api/rank) def rank_api(): limit request.args.get(limit, 20, typeint) date_param request.args.get(date, , typestr) conn get_connection() try: with conn.cursor() as cursor: if date_param: sql SELECT rank, title, play, danmaku, score, episode, crawl_date FROM anime_rank WHERE crawl_date %s ORDER BY rank ASC LIMIT %s cursor.execute(sql, (date_param, limit)) else: # 没有日期参数则取最新一天 sql SELECT crawl_date FROM anime_rank ORDER BY crawl_date DESC LIMIT 1 cursor.execute(sql) row cursor.fetchone() latest_date row[crawl_date] if row else date.today() sql SELECT rank, title, play, danmaku, score, episode, crawl_date FROM anime_rank WHERE crawl_date %s ORDER BY rank ASC LIMIT %s cursor.execute(sql, (latest_date, limit)) result cursor.fetchall() return jsonify({code: 0, data: result}) finally: conn.close() if __name__ __main__: app.run(debugTrue, port5000)这里有两个值得注意的地方request.args.get用于读取前端传参limit 控制返回条数date 指定日期。不传 date 时自动取最新一天的记录这是一个很实用的默认逻辑不然前端每次都要传日期。conn.close()放在 finally 里确保数据库连接不会泄漏。以上是基础路由。如果你想展示多日趋势比如最近 7 天的播放量变化可以再加一个接口app.route(/api/rank/trend) def rank_trend_api(): title request.args.get(title, , typestr) days request.args.get(days, 7, typeint) if not title: return jsonify({code: 1, msg: missing title}) start_date date.today() - timedelta(daysdays - 1) conn get_connection() try: with conn.cursor() as cursor: sql SELECT crawl_date, play, danmaku, score FROM anime_rank WHERE title %s AND crawl_date %s ORDER BY crawl_date ASC cursor.execute(sql, (title, start_date)) result cursor.fetchall() return jsonify({code: 0, data: result}) finally: conn.close()这个接口用于折线图查询指定番剧最近 N 天的播放量变化。前端需要传 title爬虫清洗数据时把标题 str.strip() 过确保不会因为空格差异导致查不到。3.3 解决中文乱码和时区显示的坑中文乱码是 MySQL Flask 场景下最常踩的坑。现象是接口返回的 JSON 里中文显示为\uXXXX或者页面上显示问号。其实有两层要检查第一层数据库连接字符集。如果建库时没指定 utf8mb4或者连接时 charset 写成了 utf8都会乱码。统一用 utf8mb4。第二层Flask 的 JSON 响应。默认情况下jsonify会对中文进行 Unicode 转义浏览器和 requests 都能识别但人直接看接口地址时会看到一串\u。这不影响前端渲染但调试时不直观。如果你希望接口返回明文中文可以在创建 app 后设置app Flask(__name__) app.config[JSON_AS_ASCII] False注意Flask 2.3 及以上版本中JSON_AS_ASCII被移除了改用app.json.ensure_ascii False。写代码前先确认 Flask 版本import flask print(flask.__version__)如果版本较新用app.json.ensure_ascii False这样返回的 JSON 里就是title: BanG Dream!这种明文不会转成\uXXXX。时区显示的问题相对隐蔽。MySQL 的 TIMESTAMP 类型默认存的是 UTC 时间如果服务器的系统时区和前端展示时区不一致页面显示的时间可能比本地时间早 8 小时。解决方式有两种一是建表时使用DATETIME代替TIMESTAMP因为 DATETIME 不涉及时区转换二是在连接配置里指定时间db_config { ... init_command: SET time_zone 8:00 }我更推荐第一种用 DATETIME 存储抓取时间除非你要做跨时区统计分析。课程设计场景统一用本地时间即可。crawl_date 是 DATE 类型不包含时间不会有时区问题。4. 可视化页面用 ECharts 把排行榜变成可交互图表4.1 前端页面结构与数据拉取后端接口就绪后前端页面用 ECharts 来实现图表。为什么选 ECharts 而不是 D3、Chart.js因为在课程设计里 ECharts 上手最快、图表类型最全、中文文档最多而且单文件引入不需要构建工具。把echarts.min.js放到static/js/目录下页面直接引即可。页面结构很简单一个目录表格 一个柱状图 一个折线图。数据统一从/api/rank接口拉取。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title番剧排行榜可视化/title script src/static/js/echarts.min.js/script style body { font-family: Microsoft YaHei, sans-serif; padding: 20px; } .chart { width: 100%; height: 450px; margin-top: 20px; } table { width: 100%; border-collapse: collapse; margin-top: 20px; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } /style /head body h2番剧排行榜最新一天/h2 div idbarChart classchart/div div idlineChart classchart/div table idrankTable thead tr th排名/th th番剧名称/th th播放量/th th弹幕数/th th评分/th th更新状态/th /tr /thead tbody/tbody /table script // 拉取接口数据的函数 async function fetchRankData() { const resp await fetch(/api/rank?limit20); const data await resp.json(); if (data.code 0) { return data.data; } return []; } function parsePlay(value) { // 将数值格式化为 万 为单位的字符串 if (value 10000) return (value / 10000).toFixed(1) 万; return String(value); } async function initCharts() { const rows await fetchRankData(); const titles rows.map(r r.title); const plays rows.map(r r.play); // 柱状图播放量排行 const barChart echarts.init(document.getElementById(barChart)); barChart.setOption({ title: { text: 播放量 Top20 }, tooltip: {}, grid: { left: 3%, right: 4%, bottom: 3%, containLabel: true }, xAxis: { type: category, data: titles, axisLabel: { rotate: 30, interval: 0 } }, yAxis: { type: value, name: 播放量 }, series: [{ name: 播放量, type: bar, data: plays, itemStyle: { color: #5470c6 } }] }); // 折线图最近7天播放量趋势默认选择第一部番剧 const firstTitle rows.length 0 ? rows[0].title : ; await initLineChart(firstTitle); renderTable(rows); } async function initLineChart(title) { const resp await fetch(/api/rank/trend?title${encodeURIComponent(title)}days7); const data await resp.json(); const lineChart echarts.init(document.getElementById(lineChart)); if (data.code ! 0 || data.data.length 0) { lineChart.setOption({ title: { text: 暂无趋势数据 } }); return; } lineChart.setOption({ title: { text: 《${title}》近7天播放量趋势 }, tooltip: { trigger: axis }, xAxis: { type: category, data: data.data.map(d d.crawl_date) }, yAxis: { type: value }, series: [{ name: 播放量, type: line, data: data.data.map(d d.play), smooth: true }] }); } function renderTable(rows) { const tbody document.querySelector(#rankTable tbody); tbody.innerHTML ; rows.forEach(r { const tr document.createElement(tr); tr.innerHTML td${r.rank}/td td${r.title}/td td${parsePlay(r.play)}/td td${parsePlay(r.danmaku)}/td td${r.score}/td td${r.episode}/td ; tbody.appendChild(tr); }); } initCharts(); /script /body /html逻辑说明fetchRankData使用 async/await 请求接口避免回调嵌套。encodeURIComponent用于处理番剧标题中的特殊字符比如「/」「#」直接拼 URL 会出错。renderTable动态生成表格行播放量用parsePlay转成「万」单位让表格更易读。4.2 ECharts 柱状图/折线图配置上面的代码里柱状图配置了xAxis.axisLabel.rotate: 30是因为番剧标题较长默认显示会重叠。interval: 0表示强制显示所有类目否则 ECharts 会自动跳着显示排行榜会缺中间几部。折线图开启smooth: true让曲线平滑视觉效果更适合演示。还有一个常被忽略的点ECharts 图表的容器 div 必须有明确的高度否则图表初始化后高度为 0看起来像是空白。上面代码里.chart类设置了height: 450px。在 PyCharm 的 Flask 项目里模板渲染出来如果图表不显示先检查浏览器开发者工具里容器元素的高度。补充一个刷新页面的逻辑。如果你的爬虫是手动触发页面加载后数据是最新的。但如果你在后台跑了定时爬虫页面不会自动更新。可以在前端加一个定时刷新setInterval(() { initCharts(); }, 60000); // 每分钟刷新一次图表或者更优雅的做法是用 WebSocket但课程设计场景没必要。定时器简单粗暴演示时能看到数据变化。4.3 排行榜表格与筛选功能表格除了展示数据还可以加上简单的筛选。比如只显示评分大于 9.0 的番剧或者按播放量排序。前端筛选不需要后端参与直接用 JavaScript 操作数组就行function filterTable(rows, minScore) { return rows.filter(r r.score minScore); }但要注意如果数据量很大前端筛选不如后端加参数。课程设计的 20 条数据随便筛没问题。更好的做法是在接口层面支持min_score参数这样筛选逻辑可以复用。修改爬虫或 Flask 路由都可以这里我建议在 Flask 路由里加参数app.route(/api/rank) def rank_api(): limit request.args.get(limit, 20, typeint) min_score request.args.get(min_score, 0, typefloat) ... if min_score 0: sql AND score %s cursor.execute(sql, (date_param, min_score, limit)) else: cursor.execute(sql, (date_param, limit))常见做法是只返回当日数据前端再根据条件过滤。两种都行看你们项目怎么设计。如果你需要做「按日期切换排行榜」可以在页面顶部加一个日期选择器切换到指定日期的数据。5. 避坑与常见问题排查从爬不到数据到图表空白5.1 请求返回 412 或空数据反爬校验与请求头缺失现象用浏览器能正常打开接口用 requests 请求却返回 412 Precondition Failed或者返回{code: -403}、空列表。原因目标站点对请求头做了严格校验尤其是 Referer、User-Agent、Cookie。部分接口还会校验请求参数顺序或签名。对于课程设计来说最可能的坑是 Referer 没带或者 User-Agent 太像爬虫。解决完整复制浏览器请求头中的关键字段。如果还不行加入 Cookie并在每次请求前先访问一次页面获取新的 Cookie。以下是增强版的请求头headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Referer: https://www.bilibili.com/, Origin: https://www.bilibili.com, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9, Connection: keep-alive, Cookie: 你的浏览器 Cookie }提示Cookie 有时效性如果长时间运行可能过期。建议在爬虫里定期更新或者使用 requests.Session 维持会话。5.2 MySQL 连接报错Cant connect / 模块缺失 / 密码错误现象一ModuleNotFoundError: No module named pymysql原因虚拟环境里没安装 PyMySQL。很多人在 PyCharm 里给项目设置了新的虚拟环境但终端直接跑 python 时用的是全局 Python两者包不互通。解决在 PyCharm 的 Terminal 里执行pip install pymysql flask requests然后重启 Python 解释器。如果是命令行运行确认pip list里有这些包。现象二pymysql.err.OperationalError: (2003, Cant connect to MySQL server on localhost)原因MySQL 服务没启动或者端口不是 3306。Windows 下 MySQL 服务不一定随开机启动。解决检查 MySQL 服务。Windows 可以用net start mysql启动服务名看你安装时设置的名字。Linux 用systemctl status mysql查看。确认端口netstat -ano | findstr 3306如果端口被改过连接配置里要对应修改。现象三RuntimeError: cryptography is required for sha256_password or caching_sha2_password原因MySQL 8.0 默认使用 caching_sha2_password 认证插件PyMySQL 需要 cryptography 库支持加密传输。解决pip install cryptography。这是 MySQL 8 最常见的坑之一。如果你用的 MySQL 5.7 则不会遇到。5.3 Flask 页面打不开 / 端口被占用 / 模板找不到现象运行app.run(debugTrue)后浏览器访问http://127.0.0.1:5000显示连接拒绝或者终端报Address already in use。原因端口被其他程序占用。5000 端口是 macOS 的 AirPlay 接收器默认端口Windows 上也可能被其他开发服务占用。解决换一个端口或者杀掉占用进程。建议直接用 5001戏剧性地避免冲突app.run(debugTrue, port5001)访问地址变成http://127.0.0.1:5001。现象TemplateNotFound: index.html。原因Flask 默认从项目的templates目录找模板。如果你的模板放在其他位置或者 PyCharm 的运行目录不对就会找不到。解决检查项目结构。标准结构应该是your_project/ ├── app.py ├── templates/ │ └── index.html ├── static/ │ ├── js/ │ │ └── echarts.min.js │ └── css/ │ └── style.css └── requirements.txt如果app.py和templates不在同级目录可以手动指定app Flask(__name__, template_folderweb/templates, static_folderweb/static)5.4 图表空白但接口有数据现象浏览器直接访问/api/rank能看到 JSON 数据但页面的图表区域是空白。打开开发者工具Console 有报错。原因最常见的有三种。一是 ECharts 的 JS 文件没加载成功检查 Network 里 echarts.min.js 的状态可能是路径错误或文件未下载。二是图表容器高度为 0CSS 没生效。三是数据数组为空setOption里传了空数组图表渲染不出来。解决逐一排除。先看 Console 报什么错如果是echarts is not defined说明 JS 文件没引对。如果是Cannot read property setOption of undefined说明 echarts.init 没找到容器多半是容器 id 写错了。如果没报错但空白右键检查容器元素的 height 是否为 0。也可以写一个测试console.log(document.getElementById(barChart).clientHeight);如果输出是 0在 CSS 里给该容器加最小高度。提示ECharts 的setOption不会主动报数据为空的错它只是不画任何图形。建议在setOption前打印一下数据确认rows.length 0。5.5 中文乱码或插入报错现象数据库里中文正常但页面表格显示乱码或者插入时出错Incorrect string value: \xE8...。原因连接字符集、数据库字符集、页面编码三者不一致。最常见的是建库时用了默认的 latin1或者连接时 charset 写成 utf8不是 utf8mb4。解决统一使用 utf8mb4。确认方法SHOW CREATE TABLE anime_rank;看到DEFAULT CHARSETutf8mb4才算正确。如果已经建错可以修改库和表字符集ALTER DATABASE bangumi_rank CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE anime_rank CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;页面端加上meta charsetUTF-8。Flask 的render_template默认会按文件编码读取确保 HTML 文件保存为 UTF-8 格式。6. 进阶把爬虫做成定时任务并用 PyCharm 一键调试整个项目6.1 用 APScheduler 做定时爬取与更新课程设计做到这里基础流程已经通了。如果要加分可以把手动爬虫改成定时自动爬取。用APScheduler的 BackgroundScheduler在 Flask 启动时一并启动调度器这样每过一段时间爬一次页面上的数据会自动更新。from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger import atexit scheduler BackgroundScheduler() def scheduled_crawl(): 定时任务入口爬取当日排行榜并入库 print(开始定时爬虫任务...) raw_items fetch_rank() if not raw_items: print(爬取失败等待下一轮) return cleaned clean_data(raw_items) insert_anime_rank(cleaned, date.today()) print(f抓取完成共 {len(cleaned)} 条) # 在 Flask 启动前配置调度器 scheduler.add_job( funcscheduled_crawl, triggerIntervalTrigger(hours6), # 每6小时爬一次 idrank_crawl_job, replace_existingTrue ) scheduler.start() atexit.register(lambda: scheduler.shutdown()) if __name__ __main__: app.run(debugTrue, port5001)逻辑说明BackgroundScheduler是后台线程调度不影响 Flask 主线程。IntervalTrigger(hours6)表示每 6 小时执行一次。atexit.register在应用退出时关闭调度器避免进程残留。注意数据库的crawl_date字段使用date.today()如果程序跨天运行第二次爬取会写入新日期的记录这样趋势图就能看到多天数据。如果同一天重复爬取ON DUPLICATE KEY UPDATE会更新播放量等字段保持当天数据最新。6.2 验证数据一致性用 SQL 检查排行完整性定时任务跑多了之后要确认数据有没有漏爬、重复、异常值。我一般会写一个小的验证脚本用 SQL 查几个维度-- 1. 检查今天的数据条数 SELECT COUNT(*) FROM anime_rank WHERE crawl_date CURDATE(); -- 2. 检查排名是否有重复或跳号 SELECT rank, COUNT(*) AS cnt FROM anime_rank WHERE crawl_date CURDATE() GROUP BY rank HAVING cnt 1; -- 3. 检查播放量是否为负数 SELECT * FROM anime_rank WHERE play 0; -- 4. 检查是否存在排名缺失比如没有第5名 SELECT a.rank FROM (SELECT 1 AS rank UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5) a LEFT JOIN anime_rank b ON a.rank b.rank AND b.crawl_date CURDATE() WHERE b.rank IS NULL;如果第 2 条查出有重复排名说明清洗阶段rank字段有重复或者是接口返回本身就重复。如果第 4 条查出缺失说明某些番剧没有进入排行榜前几名但接口还有分页需要扩大抓取范围。6.3 从开发到演示PyCharm 配置与项目结构建议最后一步是把整个项目在 PyCharm 里跑通并演示。有几个配置能让演示更顺畅第一给数据库填入测试数据。如果你的爬虫因为反爬暂时抓不到数据可以手动插入几条示例数据确保图表有东西展示。课程设计演示时最怕的就是现场网络波动爬取失败页面白板。先准备一份种子数据脚本-- seed.sql INSERT INTO anime_rank (rank, title, play, danmaku, score, episode, crawl_date) VALUES (1, 示例番剧A, 12345678, 234567, 9.7, 更新至第12话, CURDATE()), (2, 示例番剧B, 9876543, 123456, 9.5, 全12话, CURDATE()), ... ON DUPLICATE KEY UPDATE play VALUES(play);第二运行配置设为app.py环境变量里设置FLASK_ENVdevelopment。在 PyCharm 右上角点击 Edit Configurations选择 PythonModule name 填flask也可以但直接指定脚本路径更直观。第三项目根目录添加requirements.txt方便别人复现requests2.31.0 Flask3.0.0 PyMySQL1.1.0 APScheduler3.10.4如果不确定版本可以不写死版本号只写包名让 pip 自动安装最新版。我在做这个项目时踩过一个印象很深的坑因为沉迷于写爬虫忽略了数据库字段类型导致play字段存的是字符串 1.2万ECharts 柱状图把所有值都当 category 类型纵轴永远是 0。后来又发现top20里有的番剧标题过长不旋转 30 度的话直接重叠成一团黑。从那以后我每次把爬虫数据写入数据库之前都会强制先打印cleaned[0]确认数值字段都是int或float再执行 insert。如果你现在也在做类似的项目建议你也这样检查一遍能省下不少调试时间。希望帮到你。本文还有配套的精品资源点击获取