爬虫+MySQL+Flask+ECharts数据采集与可视化项目实战
1. 先把这套爬虫MySQLFlaskECharts拆明白毕设题目一下来很多人第一反应是去github上找个现成模板把标题一改就交。我身边真有人这么干过答辩现场被问了一句你这个爬虫如果目标页面结构变了怎么办人就卡在那儿了。所以这篇东西我不想写成一份源码说明书而是按我自己的实操顺序把爬虫抓取、MySQL落库、Flask出接口、ECharts画图这四段链路掰开讲清楚每一段为什么这么选、参数为什么这么定、哪里最容易翻车。适合正在做数据采集与可视化类毕设的同学也适合刚接手一个要把网页上的零散信息变成能看的图这种需求的初级开发。哪怕你只会写点Python、SQL看得懂个大概、前端只碰过HTML顺着往下读也能把项目跑起来。先把这四个词翻译成人话。爬虫是派一个机器人去目标网页上把需要的内容抄回来MySQL是把抄回来的东西按表格归档方便以后翻查和统计Flask是一个很小的后端服务负责把数据库里的数据按前端要的格式递出去ECharts是前端画图工具把递过来的数字变成折线、柱状、饼图、地图。四者串起来就是一条完整的数据流水线采集→存储→服务→呈现。听着简单但这条流水线上每一环都有它的坑尤其是数据清洗和接口格式约定这两处做不好就是图能出来但数字是错的这种比跑不起来还危险因为答辩老师一眼看不出问题你自己也说不清。我见过最多的失败案例不是技术太难而是一开始就没想清楚要回答什么问题。比如想做某类商品价格趋势可视化那就得先明确是按天看还是按周看是看单品的价格曲线还是看整个品类的均价需不需要按地区分组这些决定了你爬虫要抓哪些字段、数据库要建几张表、接口要支持哪些查询参数。反过来如果先写爬虫抓到什么算什么最后大概率会发现少了一个维度只能回去重爬而那批数据可能已经没了。所以我的建议是先拿一张纸画出你要的那几张图每张图旁边写上这张图需要哪几个字段这张纸就是你整个项目的需求文档比任何模板都值钱。2. 技术选型为什么是这四件套而不是别的2.1 采集层requests解析库够用别一上来就上框架采集层最常见的纠结是用requests还是上Scrapy。我的判断标准很粗暴目标站点数量小于5个、页面结构相对固定、不需要分布式就用requests。原因很实在——Scrapy虽然功能全但它有自己的调度器、中间件、Item Pipeline一整套概念学习成本摆在那儿而你毕设的时间大头应该花在数据分析和可视化上而不是调试框架配置。用requests加一个HTML解析库一百来行代码就能把主流程跑通出问题也好定位报错栈是直的不用在框架的各个钩子里绕。当然有一种情况例外如果你要抓的页面有几十上百个列表页需要翻而且每个页面都要做去重、限速、失败重试那Scrapy的调度能力确实省事。但我还是建议先用requests写出单页抓取的逻辑跑通了再考虑要不要包一层。因为绝大多数人卡住的地方根本不是调度而是这个页面的数据是JS渲染出来的requests拿到的HTML里根本没有。这个坑换成Scrapy一样躲不掉。2.2 存储层为什么选MySQL而不是SQLite或文档库这是个答辩常被问的点。我的回答通常分三层第一SQLite胜在零配置但它不适合爬虫边写、Web边读的并发场景。SQLite在写入时会锁库如果爬虫正在批量插入几万条这时候前端一请求接口很容易碰上database is locked。你在本地演示时数据量小感觉不出来一旦补数据补到十万级就开始卡现场演示翻车概率不小。第二文档型数据库虽然存半结构化数据舒服但你的核心诉求是按维度聚合统计。比如按月份统计数量按类别算占比这类需求用SQL一句GROUP BY就完了换成文档库你得写聚合管道还得自己保证字段类型一致对毕设来说属于给自己加难度。第三MySQL的生态太成熟了图形化客户端、导入导出工具、慢查询日志一应俱全出了问题网上一搜一大把。选它的核心理由不是它最好而是它最不容易让你卡在一个没人遇到过的问题上。这句话我觉得挺重要做项目选型的时候社区资料密度本身就是一项硬指标。2.3 展示层Flask模板渲染还是前后端分离Flask有两条路一条是用render_template直接吐HTML另一条是只提供JSON接口前端单独写页面。毕设场景我强烈建议走前后端分离的轻量版——不是真的上Vue/React那一套工程化而是一个静态HTML页面 原生fetch调接口 ECharts渲染。理由有三个。一是这种结构让你在答辩时能清晰地区分后端逻辑和前端展示讲起来层次分明二是接口一旦定好前端改样式完全不影响后端你调图表配色的时候不用重启Python三是这种结构天然支持你后续加页面比如加一个数据明细表页面只要再写一个HTML就行。而如果用模板渲染Jinja2里嵌JavaScript的写法维护起来很别扭尤其是ECharts的option配置对象那么大一块混在模板里很容易出错。2.4 图表类型怎么挑别为了花哨牺牲可读性ECharts的示例库很诱人什么桑基图、关系图、3D散点一大堆新手很容易一股脑全用上。我的经验是一张图只回答一个问题。趋势问题用折线对比问题用柱状占比问题用饼图或环形图分布问题用散点或热力图排名问题用横向条形图。超过五个类别的饼图就别用了人眼分辨不出相近扇形的面积差换成横向条形图按大小排序信息传达效率高得多。下面这张表是我给这个项目定的图表与数据对应关系你可以直接照着改图表回答的问题依赖字段接口参数折线图数量随时间怎么变日期、计数?range30d横向条形图各类别谁多谁少类别、计数?top10环形图占比结构类别、占比无散点图两个指标有没有关系指标A、指标B?sample500数据卡片总量是多少总条数、更新时间无3. 爬虫层让数据抓得到且抓得对3.1 请求头、节流与重试三件必做的事很多人写爬虫只写一句requests.get(url)跑两次就被目标站点挡了然后开始怀疑人生。实际上正常的公开页面只要你把这三件事做了绝大多数情况都能顺利拿到数据。第一补全请求头。至少带上User-Agent让它看起来像一次正常的浏览器访问。我这里不展开讲绕过什么检测因为那属于另一条路而且容易越界我要说的是一个规范的User-Agent加上合理的Accept、Accept-Language本身就是HTTP客户端的礼貌行为跟伪装不是一回事。第二控制请求频率。我在每次请求之间固定sleep一个随机区间比如0.8到1.5秒。这个数字怎么来的假设目标站点的列表页有50页每页间隔1.2秒光翻页就要1分钟再加上解析时间和可能的重试整体两三分钟能跑完一轮。这个速度对个人项目完全够同时不会给对方服务器造成压力。别把间隔设成0.05秒那不是在优化性能是在给自己找麻烦。第三失败重试。网络抖动是常态不能因为一次超时就丢掉整批数据。我用一个简单的循环重试最多3次每次退避时间翻倍1秒、2秒、4秒。配合超时设置timeout(5, 15)即连接5秒、读取15秒避免某个请求挂死把整个流程拖住。import time, random, requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, } def fetch(url, retry3): for i in range(retry): try: resp requests.get(url, headersHEADERS, timeout(5, 15)) resp.raise_for_status() resp.encoding resp.apparent_encoding # 防止中文乱码 return resp.text except Exception as e: wait 2 ** i print(f第{i1}次失败: {e}, {wait}s 后重试) time.sleep(wait) return None def polite_sleep(): time.sleep(random.uniform(0.8, 1.5))注意resp.encoding resp.apparent_encoding这行一定要加。很多中文页面的响应头里声明的编码和实际编码不一致不加这句抓下来的标题全是乱码而且你在本地看终端可能显示正常存进数据库才发现是问号。3.2 解析策略优先找结构化数据别死磕DOM解析这一步有个优先级顺序我按可靠性从高到低排一下首选是页面里内嵌的JSON数据。现在很多页面会把初始数据直接塞在一个script标签里格式是JSON。这种数据最干净json.loads一下就拿到结构化对象不依赖任何class名。找它的方法是在浏览器里打开页面源码搜索关键词看看数据是不是以JSON形式存在。这一招能省掉后面80%的解析维护工作值得花十分钟去找。次选是稳定的语义化标签。比如表格用table、列表用ulli这类结构比一堆div套div要可靠得多。用BeautifulSoup的select配合标签和属性组合来定位。最后才是靠class名。这是最脆弱的一种因为class名经常是构建工具生成的哈希字符串对方前端一改版你就得重写。如果只能用这种方式我建议把选择器集中写在一个配置字典里而不是散落在代码各处这样改版时只需要改一处。from bs4 import BeautifulSoup SELECTORS { list_item: div.list-wrap div.item, title: h3.title a, meta: span.meta, link: h3.title a, } def parse_list(html): soup BeautifulSoup(html, html.parser) rows [] for node in soup.select(SELECTORS[list_item]): title_node node.select_one(SELECTORS[title]) if not title_node: continue rows.append({ title: title_node.get_text(stripTrue), link: title_node.get(href, ), meta: node.select_one(SELECTORS[meta]).get_text(stripTrue) if node.select_one(SELECTORS[meta]) else , }) return rows3.3 清洗规则脏数据不进库我给自己定了一条铁律进数据库之前所有字段必须过一遍清洗函数。因为一旦脏数据落库后面所有统计都是错的而且你很难反查是哪一条出了问题。清洗主要包括这几类空白与不可见字符strip()之后还要处理\xa0、\u200b这类零宽字符它们肉眼看不见但会让你的去重判断失效。数值字段页面上的1.2万要先统一成12000再入库别存字符串否则后面排序和求和全乱。时间字段统一转成YYYY-MM-DD HH:MM:SS格式或者只保留日期。这里要注意时区如果页面显示的是相对时间3小时前要么按抓取时间倒推要么干脆丢弃这个字段。我倾向于丢弃宁可少一个字段也不要引入一个算不准的字段。去重键给每条记录算一个唯一标识最简单的办法是把标题加链接拼起来做MD5。这样即使同一个页面被抓了两次数据库里也不会出现重复行。import hashlib, re def clean_text(s): if not s: return s re.sub(r[\xa0\u200b\u3000], , s) return re.sub(r\s, , s).strip() def to_number(s): s clean_text(s) if not s: return 0 m re.search(r([\d.])\s*(万|亿)?, s) if not m: return 0 num float(m.group(1)) unit m.group(2) if unit 万: num * 10000 elif unit 亿: num * 100000000 return int(num) def make_uid(title, link): return hashlib.md5(f{title}|{link}.encode(utf-8)).hexdigest()4. 数据库设计表结构决定了你后面有多省事4.1 主表加维度表的经典拆法毕设项目的数据库不用搞得很复杂两张表基本够用一张明细表存原始抓取记录一张维度表存类别、来源这类可枚举信息。这么拆的好处是前者只增不改后者可以随时补充统计的时候做关联即可。CREATE TABLE t_item ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, uid CHAR(32) NOT NULL COMMENT 去重标识, title VARCHAR(255) NOT NULL, category_id INT UNSIGNED NOT NULL DEFAULT 0, score INT NOT NULL DEFAULT 0, publish_dt DATE DEFAULT NULL, source_url VARCHAR(500) DEFAULT , created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_uid (uid), KEY idx_publish (publish_dt), KEY idx_category (category_id, publish_dt) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_category ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, UNIQUE KEY uk_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个细节值得说道。字符集必须是utf8mb4不要用utf8后者在某些字符上会截断。uid上建唯一索引这样插入时用INSERT IGNORE或者ON DUPLICATE KEY UPDATE就能自然实现重复不报错、自动跳过省掉先查后插的两步操作。idx_category建的是联合索引(category_id, publish_dt)因为最常见的查询是某个类别下按日期统计联合索引能让这类查询走覆盖索引速度差别很大。4.2 批量插入逐条insert是性能杀手一万条记录如果用for循环一条条execute在本地可能就要花十几秒甚至更久因为每次都要走一次网络往返和事务提交。改成批量插入速度能差一个数量级。def batch_insert(conn, rows, batch500): sql (INSERT IGNORE INTO t_item (uid, title, category_id, score, publish_dt, source_url) VALUES (%s, %s, %s, %s, %s, %s)) with conn.cursor() as cur: for i in range(0, len(rows), batch): chunk rows[i:i batch] cur.executemany(sql, chunk) conn.commit() print(f已写入 {min(i batch, len(rows))}/{len(rows)})batch取多少合适我的经验是500到1000之间。太小了还是慢太大了单条SQL包会很长超过max_allowed_packet限制就会报错而且一次失败要回滚全部不好定位问题。500是个比较稳妥的起点跑一次看耗时再微调。注意每批提交一次事务而不是全部塞进一个事务。这样即使中途因为网络问题断了已写入的部分还在不用从头再来。做长时间采集任务时这个设计能救你很多次。4.3 索引别乱加新手容易犯的另一个极端是每个字段都加索引。索引是有代价的占磁盘空间且每次写入都要维护写多读少的表加太多索引纯属自残。我的原则是只给出现在WHERE、GROUP BY、ORDER BY里的字段加索引并且优先考虑联合索引。加完之后用EXPLAIN看一眼执行计划如果type列出现ALL说明在走全表扫描那就得琢磨一下查询条件或者索引设计是不是有问题了。5. Flask后端接口定得好前端少吵架5.1 目录结构按职责分不按文件类型分我见过太多毕设项目的目录长这样所有.py堆在根目录模板扔templates静态文件扔static跑起来没问题但读代码的人得先猜半天哪个文件干什么。稍微组织一下会舒服很多project/ ├── app.py # 入口注册路由 ├── config.py # 数据库配置、常量 ├── dao/ │ ├── __init__.py │ └── item_dao.py # 所有SQL都在这 ├── api/ │ ├── __init__.py │ └── chart_api.py # 图表数据接口 ├── spider/ │ ├── fetch.py # 请求与重试 │ └── parse.py # 解析与清洗 ├── static/ │ ├── js/dashboard.js │ └── css/style.css └── templates/ └── dashboard.html这个结构的核心思想是把SQL集中在dao层。因为SQL是项目里最容易被反复修改的部分集中在几个文件里你改一次字段名就知道要动哪些地方不至于漏掉某个角落的查询。5.2 返回值格式统一前端才好写接口返回格式我固定成三件套code、msg、data。成功时code0失败时给个非零值和一句人话的msg。这样做的好处是前端只需要写一个统一的处理函数所有图表拿到数据的逻辑完全一样。from flask import Flask, jsonify, request from dao.item_dao import count_by_date, top_categories app Flask(__name__) app.route(/api/trend) def api_trend(): days request.args.get(range, 30) try: days min(int(days), 365) except ValueError: days 30 rows count_by_date(days) return jsonify({ code: 0, msg: ok, data: { x: [r[d] for r in rows], y: [r[c] for r in rows], } }) app.route(/api/category) def api_category(): top min(int(request.args.get(top, 10)), 50) rows top_categories(top) return jsonify({ code: 0, msg: ok, data: [{name: r[name], value: r[c]} for r in rows] }) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)这里有个小细节值得讲参数一定要做上限约束。range最多365天top最多50条。为什么因为如果前端传个range99999你的SQL会去扫全表然后返回几十万行浏览器直接卡死演示现场一片尴尬。这种防御性参数校验在毕设里几乎没人写但它特别能体现工程思维答辩被问到你怎么保证接口不被打爆时这就是现成的答案。5.3 加一层内存缓存让页面切换不卡图表页面一般有好几个图切来切去的时候如果每次都查库体验会很差。加一个极简的缓存就够了用一个字典存参数→结果时间戳超过60秒就重新查。import time from functools import wraps _cache {} def cached(ttl60): def deco(fn): wraps(fn) def wrapper(*args, **kwargs): key (fn.__name__, tuple(sorted(kwargs.items()))) now time.time() if key in _cache and now - _cache[key][1] ttl: return _cache[key][0] result fn(*args, **kwargs) _cache[key] (result, now) return result return wrapper return deco这种缓存当然谈不上严谨进程重启就没了多进程也不共享。但对毕设这种单机单进程的场景完全够用而且能用二十行代码讲清楚原理比上一套Redis要划算得多。能在答辩时说清我这个方案在什么规模下够用、什么规模下要换比硬上一套重方案更能加分。6. ECharts前端把数字变成能看懂的图6.1 一个页面多张图初始化和更新要分开ECharts最基础的用法是echarts.init(dom)然后setOption(option)。但一个页面放四五张图的时候我建议你把初始化和更新数据拆成两个函数初始化只调一次负责建实例和设静态配置标题、颜色、坐标轴样式更新数据只调setOption({series: [...]})不重建实例。这么做的原因很实际重复init会导致内存泄漏和事件重复绑定。你如果在定时刷新里每次都init一次跑半小时浏览器就卡了而且图表的点击事件会触发好几次。拆开之后刷新只是换数据DOM和事件都不用动。const trendChart echarts.init(document.getElementById(trend)); function initTrend() { trendChart.setOption({ title: { text: 近30天数量趋势, left: center }, tooltip: { trigger: axis }, grid: { left: 50, right: 30, top: 60, bottom: 40 }, xAxis: { type: category, boundaryGap: false, data: [] }, yAxis: { type: value }, series: [{ type: line, smooth: true, areaStyle: { opacity: 0.15 }, data: [] }] }); } async function refreshTrend(range 30) { const resp await fetch(/api/trend?range${range}); const json await resp.json(); if (json.code ! 0) { console.error(json.msg); return; } trendChart.setOption({ xAxis: { data: json.data.x }, series: [{ data: json.data.y }] }); } initTrend(); refreshTrend();注意setOption的第二个参数。默认是合并模式只传xAxis.data和series.data会跟原配置合并正好是我们要的效果。如果你确实需要整体替换传true即可但一般不建议容易把静态配置丢掉。6.2 大屏布局与自适应两个容易忽略的地方第一是容器高度。ECharts的容器必须有明确的宽高否则图表高度会变成0页面上什么都看不到。这个坑几乎每个新手都会踩一次症状是控制台没报错但区域一片空白。解决办法是给容器写死height: 320px或者用flex布局让父容器撑开。第二是窗口缩放。浏览器窗口一变化canvas不会自动重绘图表会变形或者留白。加一行监听就够了window.addEventListener(resize, () { [trendChart, categoryChart, pieChart].forEach(c c.resize()); });如果你做的是数据大屏那种全屏展示可以考虑按1920×1080设计稿写死尺寸再用CSS的transform: scale()整体缩放这样布局在各个分辨率下完全一致比费劲写媒体查询省事。但要注意缩放之后鼠标悬停的坐标可能对不上需要在配置里关掉一些依赖鼠标位置的交互。6.3 配色和字体别让图看起来像上个世纪ECharts默认配色其实不错但默认字号偏小投影仪上经常看不清。我一般做三处调整把全局字号调到14标题字号18并加粗把折线的线宽从2调到2.5配合浅色面积填充把网格线改成浅灰虚线避免抢主体数据的视觉焦点。图表配色的第一条原则是同一页面内的色板保持一致别每张图各配一套颜色那看起来就像拼凑的。7. 常见问题排查速查表这一节我按症状→可能原因→怎么验证→怎么解决的顺序整理都是我自己踩过或者帮别人看过的问题。症状可能原因快速验证解决方向抓到的是空列表数据由JS异步加载HTML里没有保存原始HTML搜关键词找页面内嵌JSON或改用浏览器渲染中文变问号响应编码未指定打印resp.encoding用apparent_encoding覆盖库里有重复行未设唯一键按title分组看count加uid唯一索引INSERT IGNORE接口404路由前缀不匹配、静态文件路径错浏览器直接访问接口地址检查app.route与fetch的URL图表空白容器高度为0检查元素的offsetHeight给容器定高或调整布局图表数据错位X轴顺序与数据顺序不一致分别打印x数组和series查询结果按时间升序排序首次加载很慢无索引、全表扫描EXPLAIN看type列加联合索引、限制返回条数定时刷新后卡顿重复init实例看内存曲线、事件触发次数初始化与更新分离日期统计少一天时区差异、DATE与DATETIME混用对比原始数据与入库值统一用DATE存日期维度演示时连不上数据库连接串写死本地、字符集不一致看异常栈的认证/编码信息配置抽到config、加charsetutf8mb4上面每一条我都想再啰嗦一句挑三个最容易忽略的说。日期统计少一天这个坑特别隐蔽。你抓下来的是2024-05-01 23:30:00存成UTC时间可能就变成2024-05-01 15:30:00如果你在Python里再用本地时区转一次可能就落到前一天去了。我的做法很简单从抓取到入库到出图日期只用一种格式全程做字符串截断不做时区转换。听起来很土但在单时区项目里最不容易出错。演示时连不上数据库十有八九是连接串写死在代码里换台机器密码不一样就崩了。解决办法是把配置抽到config.py密码从环境变量读读不到就用默认值。另外务必在连接串里带上charsetutf8mb4否则MySQL客户端可能用latin1去连中文又乱。图表数据错位通常是SQL返回的顺序和前端预期不一致。GROUP BY之后的顺序是数据库决定的不是你想的升序。所以凡是要画折线的查询一定要显式写ORDER BY别指望默认行为。8. 部署与答辩演示别在最后一公里翻车8.1 依赖锁定和一键启动答辩现场最怕的事就是在我电脑上好好的。所以我会做两件事一是把依赖写进requirements.txt并锁定主版本号比如flask3.0.0而不是flask避免现场装出来一个不兼容的新版本二是写一个极简的启动脚本把建库建表、初始化数据、启动服务三步串起来让评委或者同学拿到项目后能一条命令跑起来。# init.sh pip install -r requirements.txt mysql -u$DB_USER -p$DB_PASS sql/schema.sql python tools/seed_demo_data.py # 灌入离线样例数据 python app.pyseed_demo_data.py这个脚本值得单独说。它的作用是在没有网络或者目标站点不可用时往库里灌一批预置数据保证图表一定能显示出来。做演示的时候网络环境、对方站点状态你都控制不了但这个脚本你控制得了。我甚至建议把首屏要展示的那几张图对应的数据都灌满确保一打开页面就是完整效果。8.2 演示前的检查清单我通常会提前半小时过一遍这几项数据库服务是否已启动连接是否正常采集脚本是否已经跑过一次库里有没有数据首页能否正常打开五张图是否都渲染出来断网状态下页面是否还能显示验证接口走的是本地库浏览器缩放调到100%投影分辨率下文字是否可读把浏览器控制台的报错清空避免演示时一堆红字被看到。最后这条特别重要。很多人演示时控制台一打开一屏的404和warning虽然不影响功能但给评委的第一印象就打了折扣。花十分钟把这些清掉该补的favicon补上该删的调试输出删掉。8.3 被问到如果数据量涨十倍怎么办怎么答这个问题出现的概率很高。我的回答框架是分三层采集层改成增量抓取只抓上次之后的新内容存储层按时间分区或者按月分表把热数据留在主表查询层把高频聚合结果预计算成一张统计表接口直接读统计表而不是每次实时GROUP BY。这三条都是常规思路说清楚就行不需要真的实现。但我建议你至少把第一条做出来——增量抓取其实很好实现在表里记一个最大日期下次只抓大于它的数据改动量不到二十行却能实实在在体现你想过这个问题。9. 我个人在几个关键节点上的取舍体会写到这里代码层面的东西差不多讲完了我想说说几个当时纠结了很久的决定可能对正在犹豫的人有点参考价值。第一个是关于数据量。我一开始想抓够五十万条觉得数据多才显得项目有分量。后来发现五万条和五十万条对我的那几张图几乎没有区别因为图表最多展示前十条、前三十天。多出来的数据只会让爬虫跑得更久、让数据库更大、让备份更麻烦。最后我砍到八万条左右把省下来的时间全用在数据清洗和图表打磨上效果反而更好。所以我建议你先想清楚图需要多细的粒度够用就行别为了数字好看牺牲完成度。第二个是关于要不要做用户系统。很多毕设模板会带登录注册因为看起来完整。但登录跟数据可视化这个核心诉求其实没关系加了之后你要处理密码加密、会话管理、权限校验每一项都可能引入新问题。我最后的做法是不做登录但做一个简单的访问密码保护够体现安全意识又不至于把项目复杂度拉高一个量级。第三个是关于代码注释。我以前很抵触写注释觉得代码本身就能说明问题。后来把项目放了一个月再回来看发现自己写的SQL完全不知道在算什么。所以现在的习惯是每个SQL函数上方写一句这个查询回答什么问题每个清洗规则旁边写一句为什么要这么处理。这两类注释在答辩时特别有用因为老师翻代码时第一眼看的就是这些。第四个是关于可视化页面的信息密度。我最初做了一屏八张图的数据大屏自己看着挺唬人但让同学看对方第一句话是这都什么意思。后来砍到四张核心图每张图下面加一行小字说明这张图说明什么反馈立刻变了。图表的数量不等于信息量能让人三秒钟看懂一张图比堆八张看不懂的图有价值得多。如果你正在做类似的项目我建议你把时间分配大致按这个比例来采集和清洗占四成数据库和接口占三成前端可视化占两成剩下的一成留给部署和演示准备。很多人把八成时间砸在写爬虫上最后前端草草了事答辩时打开页面一片空白之前做的全白费。真正决定第一印象的永远是那张能正常显示、数字说得通的图。