从爬虫到看板:Flask+Vue3+ECharts全栈新闻数据分析实战
简介:面向新闻数据获取与全链路分析需求,这份资源提供了基于Flask与Vue3构建的前后端分离式新闻数据分析平台完整实现,适合具备Python与前端基础、希望通过真实项目掌握爬虫采集、数据清洗、模型构建与可视化展示的开发者学习参考。包内共458个文件,约171.84MB,包含70个Python后端脚本、154个TypeScript与64个Vue前端组件、SQL数据库脚本及说明文档,还配有Excel数据样例、图表配置与Chromedriver等运行辅助文件,目录结构清晰,可直接导入MySQL和Redis后启动使用。项目以今日头条为目标源,完整覆盖Selenium数据爬取、Pandas清洗分析、NumPy建模及PyEcharts可视化输出等环节,针对常见运行环境(Python3.9、Node16、MySQL8、Redis6)给出了明确配置指引。目前已有197人学习或下载,是一份能帮助快速搭建新闻数据分析全流程的实战型资源。
1. 新闻数据分析平台:从爬虫到看板的完整闭环
说实话,接到这套资源的时候我第一反应是“又来一个爬虫包”。但真正把源码和数据库跑起来之后,我改变了判断——它解决的不是“怎么爬今日头条”这一个点,而是把「数据采集 → 清洗入库 → 后端建模 → 前端可视化」整条链路串通了。对于一个想学 Flask + Vue3 + ECharts 全栈开发,或者想把新闻数据变成可分析报表的从业者来说,这比单独看爬虫教程有用得多。项目核心是:用 Python 爬虫采集今日头条新闻流,把标题、来源、发布时间、评论数、热度字段落库,再通过 Flask 提供查询接口,最后在 Vue3 前端用 ECharts 呈现趋势曲线、来源占比和热词排行。适合正在做数据分析课设、公司内部舆情看板、或者想找一份完整全栈案例照着改的人。
2. 后端与数据模型:Flask 接口设计、新闻表结构和采集入库
2.1 Flask 还是 FastAPI:为什么这套资源用 Flask
我去年用一个 FastAPI 项目做了异步爬虫服务的改造,性能确实好,但代价是协程上下文、依赖注入、异步 ORM 这些概念把团队里两个刚入门的同事卡了好几天。这套新闻数据分析平台的定位是“能看懂、能改、能跑”,Flask 的同步路由写法更符合多数人已有的 Django/Flask 经验,而且网上能搜到的 Flask 教程、部署案例、报错解决方案数量是 FastAPI 的几倍。真要对比,我认为关键差异不在性能,而在生态成熟度和团队认知成本:爬虫采集本身是 IO 密集但低频的任务,Flask 默认的开发服务器加threaded=True足够应付;前端看板一次只拉几个聚合接口,也没有高并发压力。所以选 Flask 是合理的工程取舍,不是技术落后。如果你之后要把它改造成多用户高并发平台,再迁移 FastAPI 也不迟,路由和模型部分改动量可控。
2.2 表结构设计:article 主表字段与索引
我拆源码时第一个看的就是建表语句,因为新闻数据平台的核心资产是数据模型。这套资源用的是 MySQL,主表article的设计很有代表性。重点字段我整理了一张表:
| 字段名 | 类型 | 含义 | 备注 |
|---|---|---|---|
id | BIGINT | 自增主键 | 无业务含义 |
title | VARCHAR(255) | 新闻标题 | 全文索引候选字段 |
summary | TEXT | 摘要 | 爬虫截取的前 N 个字符 |
source | VARCHAR(64) | 来源媒体 | 例如“人民日报”“科技日报” |
category | VARCHAR(32) | 新闻分类 | 爬虫带入库 |
publish_time | DATETIME | 发布时间 | 需做时间归一化 |
comment_count | INT | 评论数 | 可能缺失,默认 0 |
like_count | INT | 点赞数 | 可能缺失,默认 0 |
view_count | INT | 阅读数 | 部分新闻拿不到 |
url | VARCHAR(500) | 原文链接 | 加唯一索引做去重 |
created_at | DATETIME | 入库时间 | 用于增量判断 |
这个设计的聪明之处在于把“展示型数据”和“分析型数据”都放了进去,热度分可以直接在 SQL 里算,也可以抽到 pandas 里算。我一般会在publish_time和source上各加一个普通索引,资源里可能只给了主键索引,你运行的时候按需补上就行。注意url的唯一索引必须要有,否则爬虫重复采集时表会越塞越脏。
2.3 爬虫采集:今日头条搜索接口的请求参数与入库代码
爬虫部分我单独抽出来看,核心逻辑是请求今日头条搜索接口、解析 JSON、逐条入库。代码不长,但有几个参数值得细讲。下面是核心采集函数的结构:
import requests import time import hashlib from datetime import datetime HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.toutiao.com/", "Accept": "application/json, text/plain, */*", } def fetch_news(keyword, offset=0, count=20): """ 请求今日头条搜索接口 参数说明: keyword: 搜索关键词 offset: 分页偏移量,翻页时每次 +count count: 单页数量,建议 20,太大容易触发校验 """ params = { "aid": "24", "app_name": "web_search", "keyword": keyword, "offset": str(offset), "count": str(count), "format": "json", "search_id": "", # 空字符串会让后端按当前请求重新生成 "timestamp": str(int(time.time() * 1000)), } resp = requests.get( "https://www.toutiao.com/api/search/content/", params=params, headers=HEADERS, timeout=10, ) resp_json = resp.json() return resp_json.get("data", [])这段代码的关键点有两个。第一,timestamp参数用毫秒级时间戳,这是很多请求签名逻辑的组成部分,如果你在代码里写死一个时间戳,服务端很可能返回无效响应;保持“每次请求都生成新的时间戳”这个习惯,能避免大多数偶发失败。第二,count不要贪大,头条搜索接口单次拉到 50 条以上容易被风控,20 条一页翻页是最稳的节奏。还有,返回的data里有些条目是广告或聚合卡片,入库前要过滤掉log_pb和article_type不匹配的数据,这一步我放到清洗章节处理。
拿到数据后的入库也很直接,先按url的 MD5 值判断是否已存在,避免重复采集。这个去重逻辑建议放在 Python 层而不是数据库层,因为爬虫是批量写入的,如果直接用数据库唯一索引去重会引发大量异常记录:
def save_articles(articles): for item in articles: url = item.get("url") or item.get("display_url", "") if not url: continue url_hash = hashlib.md5(url.encode()).hexdigest() exists = db.session.execute( "SELECT id FROM article WHERE url_hash = :uh", {"uh": url_hash} ).first() if exists: continue article = Article( title=item.get("title", "").strip(), summary=item.get("abstract", "").strip(), source=item.get("source", "未知"), category=item.get("tag", "综合"), publish_time=parse_time(item.get("publish_time")), comment_count=item.get("comment_count", 0) or 0, like_count=item.get("like_count", 0) or 0, view_count=item.get("view_count", 0) or 0, url=url, url_hash=url_hash, ) db.session.add(article) db.session.commit()这里parse_time()是时间解析函数,头条返回的时间格式有两种:一种是标准时间戳字符串,另一种是“今天 12:30”“30分钟前”这种相对时间。相对时间解析逻辑我在第 4 章专门讲,入库前必须处理完,否则DATETIME字段会因为类型不匹配直接报错。
2.4 接口层设计:/api/* 路由与前端对接约定
Flask 后端主要负责把统计结果以 JSON 形式给前端。我比较认可这套资源里把接口按业务拆分的做法,而不是让前端直接查原始表。核心接口有三个:/api/stats/overview返回新闻总数、来源数、时间范围;/api/stats/trend按日返回新闻数量;/api/stats/sources返回来源前 10 名。路由代码大致如下:
@app.route("/api/stats/trend") def stats_trend(): days = request.args.get("days", 7, type=int) rows = db.session.execute( """ SELECT DATE(publish_time) AS d, COUNT(*) AS cnt FROM article WHERE publish_time >= DATE_SUB(CURDATE(), INTERVAL :days DAY) GROUP BY DATE(publish_time) ORDER BY d """, {"days": days}, ).fetchall() return {"data": [{"date": str(r[0]), "count": r[1]} for r in rows]}接口约定很关键:前端只需要关心data数组,不需要关心 SQL。这样前后端可以并行开发,后端改了查询逻辑前端也不用动。如果你拿到源码后发现接口路径不一致,先看app.py里的路由前缀,这套资源的前端 axios 封装里会有对应的 baseURL,改成一致就行。
3. 前端看板:Vue3 + ECharts 的数据可视化做法
3.1 Vite 初始化与组合式 API 的目录组织
Vue3 项目用 Vite 初始化是当前主流做法,比 Vue CLI 冷启动快很多。资源里的前端目录是标准结构:src/views放页面组件,src/api放 axios 请求封装,src/components放图表组件。如果你手里拿到的是旧版本 Vue2 代码,我建议直接重建成 Vue3,你会明显感受到组合式 API 带来的组织便利。一个看板页面组件里,setup函数把数据请求和图表渲染放在一起,相关逻辑内聚性比data + methods + created强太多。
我刚上手 Vue3 时最不习惯的是ref和reactive的选择。经验是:普通标量和数组用ref,嵌套对象用reactive。图表配置对象我一般用ref({})包一层,因为 ECharts 的setOption需要整对象替换或 merge,用ref改起来符合直觉。
3.2 Axios 请求封装与 ECharts 事件绑定
前端看板的数据管道是axios -> response interceptor -> 页面 ref -> ECharts option。资源里把 axios 封装放进了src/api/request.js,超时时间设为 15 秒,baseURL指向后端的/api。这个封装值得学习的地方是错误处理统一拦截,后端如果抛 500,前端弹提示而不是白屏。
import axios from "axios"; const service = axios.create({ baseURL: "/api", timeout: 15000, }); service.interceptors.response.use( (response) => { return response.data; // 直接返回业务数据,省去一层 .data }, (error) => { console.error("[API ERROR]", error.response?.status, error.message); return Promise.reject(error); } ); export default service;为什么baseURL写成/api而不是http://localhost:5000/api?因为这样部署时 Nginx 做反向代理最省事,前端构建产物和后端接口同域,不会触发跨域问题。本地开发时你在vite.config.js里配一个 proxy 把/api转发到http://127.0.0.1:5000就行。这套资源的约定是对的,我见过太多人直接写死 IP,换台电脑就抓狂。
3.3 三个图表:折线、柱状、饼图的 option 配置
看板页面核心是三个图表。趋势折线图最常规,按时段展示新闻量;来源占比用饼图;媒体活跃度用横向柱状图。折线图的配置核心是xAxis.data和series.data对齐,常见翻车点是后端返回的日期是字符串数组,而前端图表的xAxis.data也必须是数组,两者格式不一致直接空白。
import * as echarts from "echarts"; import { ref, onMounted } from "vue"; import { getTrend } from "@/api/stats"; const trendOption = ref({}); async function loadTrend() { const res = await getTrend(7); const dates = res.data.map((item) => item.date); const counts = res.data.map((item) => item.count); trendOption.value = { tooltip: { trigger: "axis" }, grid: { left: 40, right: 20, bottom: 30, top: 40 }, xAxis: { type: "category", data: dates }, yAxis: { type: "value", name: "新闻量" }, series: [ { name: "新闻数量", type: "line", smooth: true, data: counts, areaStyle: { opacity: 0.1 }, }, ], }; } onMounted(loadTrend);注意areaStyle我只给了 0.1 的透明度,这样图表不会太“重”,看板整体视觉干净很多。另外 ECharts 的resize一定要监听,否则浏览器窗口变了图表会挤成一团。我在mounted里加window.addEventListener('resize', () => chartRef.value?.resize()),组件卸载时记得移除监听。
4. 数据清洗与分析:pandas 预处理、热度建模和统计口径
4.1 清洗规则:去重、剥 HTML、时间归一化
爬虫采下来的数据直接做分析会得出错误结论,因为头条搜索结果里混着广告、软文、重复推荐。这套资源里的清洗模块用 pandas 做了四步处理。第一步是丢弃title为空或长度小于 5 的记录;第二步按url_hash去重且保留首次入库的那条;第三步用正则剥离标题和摘要里的 HTML 标签、空格、不可见字符;第四步把发布时间统一成datetime类型。
import pandas as pd import re def clean_articles(df): # 1. 去空标题和明显广告标题 df = df[df["title"].str.len() >= 5] df = df[~df["title"].str.contains("广告|推广|荐", na=False)] # 2. 去重:保留首次入库 df = df.sort_values("created_at").drop_duplicates(subset="url_hash", keep="first") # 3. 去除HTML标签和多余空白 def strip_html(text): text = re.sub(r"<[^>]+>", "", str(text)) return re.sub(r"\s{2,}", " ", text).strip() df["summary"] = df["summary"].map(strip_html) # 4. 时间归一化 df["publish_time"] = df["publish_time"].map(normalize_time) return df时间归一化是个容易被低估的坑。头条接口给的时间如果是“10分钟前”,pandas 的to_datetime直接处理会报错。我惯用的normalize_time思路是:先判断是否为纯数字时间戳,再判断是否符合%Y-%m-%d %H:%M:%S格式,最后处理相对时间——“30分钟前”用now - timedelta(minutes=30),“昨天 08:00”用now.date() - timedelta(days=1)拼接。这部分代码在资源里应该有,如果没有,按我这个思路补全即可。
4.2 热度与词频建模:计算口径与阈值
数据洗干净之后才轮到“建立数据模型”这一步。这套资源里的热度模型不是简单排序,而是构造了一个综合热度分,公式我拆出来后发现是加权归一化:H = 0.4 * N(comment) + 0.3 * N(like) + 0.2 * N(view) + 0.1 * N(time_decay),其中N()是 Min-Max 归一化,time_decay用的是半衰期函数,发布时间越近权重越高。
def hot_score(row, now): # 各指标做 0-1 归一化 c = row["comment_count"] / (row["comment_count"] + 50) l = row["like_count"] / (row["like_count"] + 200) v = row["view_count"] / (row["view_count"] + 10000) age_hours = max((now - row["publish_time"]).total_seconds() / 3600, 1) t = 2 ** (-age_hours / 24) # 半衰期 24 小时 return 0.4 * c + 0.3 * l + 0.2 * v + 0.1 * t这个公式的工程意义在于避免单个指标失控——某条新闻可能阅读量巨大但评论极少,单看view_count会把它误判为爆款,加权后更接近真实热度。你拿这份源码去做数据分析时,如果想换成“评论主导”的口径,把系数改成0.5, 0.3, 0.1, 0.1就行。
词频分析则是用 jieba 分词后统计 Top N,注意要做停用词过滤,否则“我们”“什么”“一个”这类词会霸榜。资源里应该带了一份中文停用词表,如果没有,就从网上找一个常见版本放进去。
4.3 聚合查询和统计维度口径
统计口径直接决定看板有没有参考价值。这套资源的三个聚合维度是:按天统计新闻发布量、按来源统计条数占比、按分类统计新闻结构。其中“来源占比”我建议用条数而不是阅读量,因为阅读量缺失率太高,条形图比例会失真。后端聚合可以用 SQL 一次算完,也可以把表抽出来用 pandas 算,数据量在一万条以内差别不大。以下做法是资源里的常见写法——在 Flask 里直接执行 GROUP BY 查询返回列表:
@app.route("/api/stats/sources") def stats_sources(): rows = db.session.execute( """ SELECT source, COUNT(*) AS cnt FROM article GROUP BY source ORDER BY cnt DESC LIMIT 10 """ ).fetchall() return {"data": [{"name": r[0], "value": r[1]} for r in rows]}这里需要提醒一个统计陷阱:头条搜索接口返回的source字段偶尔是空字符串,直接分组统计会把一堆数据塞进“未知来源”。清洗阶段要么丢弃source为空的记录,要么统一替换为“未知”,否则饼图会出现一个巨大的灰色扇区。我拿到这套资源后第一件事就是把这条规则补进清洗脚本。
5. 实战避坑:签名校验失败、跨域拦截与图表空白 5 个排查记录
5.1 爬虫偶发返回 400:签名和时间戳参数
现象:爬虫跑了几分钟后突然连续报错,requests.exceptions.HTTPError: 400 Client Error,重试几次后又恢复正常。
原因:头条搜索接口对高频请求有风控逻辑,固定 User-Agent 且每个请求都带相同参数时,很容易被判定为脚本请求;另外search_id如果复用上一次的值,超过有效期会被拒绝。
解决:不要用固定search_id,每次请求置空让服务端重新生成;抓取间隔加随机延迟time.sleep(random.uniform(1, 3));如果连续 3 次 400,暂停 60 秒再继续。我在实际跑数据时还发现一个细节——把timestamp放到请求参数里而不是 headers,命中风控的概率会低一些。
5.2 前端请求跨域:OPTIONS 预检被拦
现象:本地启动前端npm run dev,打开页面后看板空白,浏览器控制台报Access to XMLHttpRequest has been blocked by CORS policy。
原因:Flask 后端默认不允许跨域,而vite.config.js里没配 proxy,前端直接请求http://127.0.0.1:5000。浏览器的预检请求OPTIONS被 Flask 默认的@app.route拦截,因为路由没注册 OPTIONS 方法。
解决:开发阶段在vite.config.js加 proxy 转发,不要把请求地址写死;如果非要走跨域,可以在 Flask 里加flask-cors并配置CORS(app, resources={r"/api/*": {"origins": "*"}})。我更推荐 proxy 方案,因为部署时同域天然没有跨域问题。
5.3 图表空白:容器高度或异步数据时机
现象:接口返回正常,trendOption.value也有数据,但图表区域一片空白,没有任何报错。
原因:ECharts 初始化发生在容器还没有高度的时候,或者setOption在数据异步返回之前执行了。很多新手在mounted里同步初始化图表,但 axios 请求是异步的,数据回来时图表已经用空数据渲染完。
解决:给图表容器设置固定高度(height: 400px),不要依赖父容器的高度撑开;在setOption前判断data.length > 0;如果用了多个图表,每个图表实例必须有唯一的ref绑定。最保险的写法是先echarts.init,再等请求结束调用setOption和resize。
5.4 中文乱码:连接串与建表排序规则
现象:前端页面显示的新闻标题全是???,SQL 查询结果也是乱码,但 MySQL 命令行里数据正常。
原因:数据库连接串没有指定charset=utf8mb4,建库时排序规则选成了latin1_swedish_ci。SQLAlchemy 的create_engine默认字符集不是 utf8mb4,中文在传输过程中被转成了问号。
解决:连接串里加?charset=utf8mb4,数据库和表统一COLLATE utf8mb4_unicode_ci。如果你拿到这套资源时数据库已经建好,可以执行ALTER TABLE article CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci补救,但经验是老数据可能已经损坏,不如清空重导。
5.5 Flask 开发服务器并发崩溃:复制进程与连接保持
现象:看板页面同时打开两三个标签页,后端突然报MySQL server has gone away,或者页面卡住十几秒。
原因:Flask 自带的开发服务器是单进程多线程模式,线程没开threaded=True时会排队处理请求;加上 PyMySQL 长连接在 MySQL 的wait_timeout超时后会被服务端断开,旧连接还在用就直接报错。
解决:开发调试时应用实例用app.run(debug=False, threaded=True);生产环境不要用 Flask 自带服务器,替换成 gunicorn;数据库会话请求结束时统一db.session.remove(),避免连接复用过期。这套资源的第 4 章提了 gunicorn 部署,我把启动配置放到第 6 章展开。
6. 部署与验证:gunicorn + Nginx 迁移清单和冒烟自检
6.1 gunicorn 启动与 Nginx 反代
本地跑通只是第一步,真的放到服务器上才会暴露问题。我惯用的部署结构是:gunicorn 起 Flask 后端,Nginx 同时反代后端/api和前端静态文件。后端启动命令如下:
gunicorn -w 2 -b 127.0.0.1:5000 app:app \ --timeout 60 \ --worker-class sync \ --access-logfile /var/log/news/app.log-w 2表示两个 worker 进程,小内存服务器别开太多,每个 worker 都会复制一份模型和数据上下文;--timeout 60是必须的,因为爬虫接口如果没做异步处理,单个请求超过 30 秒时 gunicorn 默认会杀 worker。注意app:app前面的app是文件名,后面是实例名,要对齐你的入口文件。
Nginx 配置核心片段:
server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:5000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { root /var/www/news-dist; try_files $uri $uri/ /index.html; } }proxy_pass后面的 URI 带不带/会影响转发路径,我踩过几次坑,这里写的是最稳妥的带/形式。前端构建产物直接放到/var/www/news-dist,Nginx 会优先处理location /下的静态文件。
6.2 一键构建脚本与 curl 冒烟自检
部署完最忌讳的是点开页面看一眼就宣布完成。我每次都会先在前端目录执行构建,再对后端接口做冒烟自检:
# 前端构建 cd frontend npm install npm run build # 后端接口冒烟 curl -s http://127.0.0.1:5000/api/stats/overview | head -c 200 curl -s http://127.0.0.1:5000/api/stats/trend?days=7 | head -c 200 curl -s http://127.0.0.1:5000/api/stats/sources | head -c 200三个接口都返回 JSON 后,再访问 Nginx 的静态页面,看图表是否正常渲染。如果接口 200 但图表空白,问题一定在前端构建产物路径或 API 请求地址上。把日志实时盯一下:
tail -f /var/log/news/app.log这套资源我拆完后的整体感受是:技术选型务实、代码结构清晰,对于想在 Flask 和 Vue3 之间打通全栈的人来说,是很好的参考模板。最后说一个我的习惯——部署完第一件事永远是看错误日志,设置好重启机制后再补一版勘察数据。从那以后,我每次交付项目都强制走一遍“构建 → 冒烟 → 日志确认”的流程,省掉了大量远程排查的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取