☰
京东食品销售数据分析系统:Python爬虫+Flask+ECharts实战
2026/9/28 12:52:53 网站建设 项目流程

1. 项目为什么长这样:需求拆解与技术选型

如果你和我一样,经常在京东买零食、牛奶、米面粮油,大概率会产生一个念头:同一个食品类目里,到底什么价位的商品卖得最好?头部品牌占了多少份额?价格高一点的东西是不是真的没人买?这些问题一个个翻商品页是看不出来的,所以我直接用 Python + Flask 做了套京东食品销售数据分析系统,前面用爬虫抓数据,中间用数据库存数据,后面用可视化大屏把统计结果展示出来。

这套系统适合两类人参考。第一类是刚学完 Python 基础、想做一个完整闭环项目的初学者,孤立地写爬虫、画图表很多人都做过,但把"抓取—落库—聚合统计—接口—图表"串起来,才是真正练筋骨。第二类是课程设计或面试作品需求的人,这个结构改一改就能变成"某某平台商品数据分析系统",因为数据链路是完整的,不是只展示几张图片。

选型阶段我犹豫过好几轮。爬虫框架方面,Scrapy 功能确实强,但我们的数据规模只是几万条,用 requests + BeautifulSoup 就完全够用,代码直白、调试方便,出现问题一眼就能看到是哪个环节。Web 框架方面,Django 自带 Admin 后台和 ORM 很香,但项目只需要几个接口和几个页面,Flask 更轻量,几个文件就能跑起来。数据库方面,MySQL 得先装服务、配账号,本地演示实在没必要,SQLite 单文件、零配置,配上 SQLAlchemy 以后想迁 Postgres 也不麻烦。可视化方面我更慎重,一开始试过让 Flask 直接把 Matplotlib 生成的图片返回给前端,效果太"静态"了,没有悬停、没有联动,后来换了 ECharts,数据以 JSON 从接口返回,浏览器端渲染,交互体验完全是一个档次。

整个系统拆成四个模块:爬虫模块负责从京东搜索页拿商品列表、调价格接口拿价格;存储模块负责去重、更新、把数据写进 SQLite;分析模块负责价格带分布、品牌排名、热度相关性的聚合统计;展示模块由 Flask 提供接口,前端 ECharts 画大屏。数据流是单向的,每个节点可以单独测试,这对排错特别重要——图表数据不对,你可以一层层往后退,先看接口 JSON,再看数据库表,最后缩小到爬虫还是清洗的问题。

2. 爬虫模块:搜索页、价格接口与数据清洗

2.1 为什么从搜索页入手而不是类目页

京东食品相关的分类页有很多,零食在上一个类目,粮油调味在另一个类目,生鲜又单独一套。每个分类页的 URL 规则、筛选参数都不一样,硬啃分类页会让爬虫代码很快失控。我的做法是直接用搜索页,把关键词当成"类目"来用。比如我想看坚果零食,就搜"坚果零食",想对比米面粮油,就搜"大米"或"食用油"。

搜索页的优势在于结构统一:只要替换 keyword 和 page 参数,返回的 HTML 卡片结构是一致的,解析逻辑可以复用。更妙的是,搜索词天然可以作为后续分析的维度,我可以横向比较"坚果零食"和"自热火锅"两个关键词下的商品数量、平均价格、评论总量,这就等于自定义了一套简易类目体系。

URL 的构造大概是这样,核心参数就三个:

url = "https://search.jd.com/Search" params = { "keyword": keyword, "enc": "utf8", "page": page_no, "sort": "sort_totalsales15_x_desc", }

sort参数很关键。sort_totalsales15_x_desc表示按销量降序,sort_commentcount_desc表示按评论数降序,sort_rank是综合排序。做销售分析时我会优先按销量排序抓前面的商品,因为后面的长尾商品数据价值不大。京东翻页有个特点,某些页面版本会用奇数页码翻页(1、3、5),你抓包看一次 URL 怎么变就清楚了,我在代码里直接用page_no += 1,因为实测搜索页接受连续页码,只是有的版本会有重复内容,后文会讲去重怎么处理。

2.2 请求头与抓取节奏

爬取任何网站,第一件事是把自己伪装成一个正常的浏览器。User-Agent、Referer、Accept-Language 这三个头至少要有,否则服务器很容易拒绝请求。我的请求头是这样配置的:

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,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://www.jd.com/", }

请求节奏上我给自己立了几条规矩。第一,单次任务最多抓几十页,绝不贪多;第二,每次请求之间随机睡 2 到 5 秒,不是固定间隔,因为固定间隔更容易被识别成脚本;第三,请求失败后做退避重试,失败 3 次就放弃当前页记入日志。这套系统定位是个人学习和数据分析,数据量够看趋势就行,没必要也不应该高频压榨目标服务器。实际上我在实际运行里一个关键词抓 5 页、共 150 条商品,8 个关键词也就 1200 条记录,分析粒度已经完全够用。

2.3 页面解析和价格接口的配合

搜索页 HTML 里,每个商品卡片是一个li.gl-item标签,上面有>def parse_search_page(html): soup = BeautifulSoup(html, "lxml") items = [] for li in soup.select("li.gl-item"): sku = li.get("data-sku") if not sku: continue title_tag = li.select_one(".p-name a") shop_tag = li.select_one(".p-shop a") or li.select_one(".p-shopnum a") comment_tag = li.select_one(".p-commit a") title = title_tag.get("title") if title_tag else "" shop = shop_tag.get("title") or shop_tag.get_text() if shop_tag else "" comment_text = comment_tag.get_text() if comment_tag else "" items.append({ "sku": sku, "title": title, "shop": shop, "comment_text": comment_text, }) return items

这里有个核心知识:搜索页 HTML 里通常没有价格字段,价格是页面加载后用 JavaScript 异步请求接口渲染的。我们在爬虫里复现这个请求,直接调公开的价格接口就行:

def fetch_price(sku_list): url = "https://p.3.cn/prices/mgets" params = {"skuIds": ",".join("J_" + s for s in sku_list)} resp = requests.get(url, params=params, headers=HEADERS, timeout=5) data = resp.json() price_map = {} for item in data: sku = item.get("id", "").replace("J_", "") price_map[sku] = item.get("p") return price_map

这个接口把多个商品的 ID 拼在一起用逗号分隔,一次能返回多个价格,比逐条访问详情页高效得多。价格字段拿到的是一个字符串,比如 "29.90",后续要转成 float 再存库。

评论数在搜索页里通常是"2万+条评价"之类的文本,我单独写了一个解析函数处理:

def parse_comment_count(text): text = text.replace("条评价", "").replace("评价", "").strip() if not text: return 0 if "万" in text: return int(float(text.replace("万", "")) * 10000) return int(text)

万一搜索页的评论数会被登录限制挡住,也可以改用京东评论概要接口,传referenceIds=商品ID,返回的就是带着 CommentCount 字段的 JSON,逻辑上更干净。我在系统里优先用搜索页字段,拿不到时再走这个补充接口。

2.4 同一商品出现在多个关键词下的去重

实际抓下来你会发现,同一个商品会在"坚果零食""每日坚果""零食大礼包"多个关键词下面反复出现。如果直接把每次抓取都插入数据库,数据会大量冗余。我的策略是在数据库中把sku设为唯一键,同一商品被抓到第二次时,不新增记录,而是更新价格和评论数。这样既能维护"商品主档",又能记录它是在哪个关键词下首次出现的,后续分析成本低很多。

清洗阶段还要注意几个脏数据:价格字段偶尔为空,就跳过该商品或在分析时过滤;评论数解析失败就置 0,保证字段类型一致;标题里的品牌名不单独解析,先存原文,等分析阶段再用规则提取。清洗原则很朴素——宁可字段为空,不让错误数据混进统计。

3. 存储层设计:SQLite + SQLAlchemy 的数据建模细节

3.1 为什么选 SQLite + SQLAlchemy

存储层我选择 SQLite 的原因前面提了一部分,这里补充技术判断:这是典型的"单机、单进程、小规模"应用场景,数据量最多几十万行,SQLite 完全可以撑住,而且数据库就是一个.db文件,备份、迁移、删库都很直观。如果用 MySQL,光环境配置就能劝退一批初学者。

但我没有直接用 sqlite3 裸写 SQL,而是用 SQLAlchemy 做 ORM。原因有两个:第一,ORM 让我们用 Python 对象的方式操作数据,代码可读性好;第二,以后数据量大了想切换到 PostgreSQL,只需要改连接字符串和少量方言差异,业务代码基本不用动。这也是很多生产项目的迁移路径。

3.2 数据表结构设计

我建了两张表,一张是商品主表,另一张是抓取日志表。商品表核心字段如下:

from datetime import datetime from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class Product(Base): __tablename__ = "jd_products" id = Column(Integer, primary_key=True, autoincrement=True) sku = Column(String(32), unique=True, nullable=False, index=True) title = Column(String(500)) shop = Column(String(200)) price = Column(Float) comment_count = Column(Integer, default=0) keyword = Column(String(50), index=True) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)

商品 ID、标题、店铺、价格、评论数、搜索关键词是分析必需字段,创建时间和更新时间用于追溯数据时间线。sku设唯一约束是去重的关键,keyword和price、comment_count都建索引,因为后面要频繁按关键词分组、按价格排序、按评论数统计。

抓取日志表用于记录每次任务跑了哪些关键词、多少页、抓到多少条、是否成功,一旦哪天数据量异常,能快速定位是哪次任务出了问题。

3.3 增量和重复数据的处理

既然sku唯一,插入策略就很明确:先查这个 sku 是否存在,不存在就新增,存在就把价格和评论数更新成最新值。我用一个 upsert 函数统一处理:

def upsert_product(session, item): product = session.query(Product).filter_by(sku=item["sku"]).first() if product is None: session.add(Product(**item)) else: product.price = item["price"] product.comment_count = item["comment_count"] product.shop = item["shop"] product.updated_at = datetime.now()

批量入库时有一个经验:不要每写一条就session.commit(),那会让数据库忙死。正确的做法是每攒 200 条或者每处理完一个页面 commit 一次,然后session.close(),批量事务的性能差异非常大。如果哪次任务中途异常,直接在except里session.rollback(),保证前面成功的数据不会白写。

SQLite 对并发写支持有限,但我们的系统只有一个爬虫进程写库、Flask 进程读库,读多写少的场景完全没问题。如果你后续要同时多线程抓取,再考虑用check_same_thread=False或直接上 MySQL。

4. 分析维度:价格带、品牌集中度与热度关系

4.1 评论数当作销量代理:能用但有边界

京东并不是所有商品都公开销量数字,尤其是食品类,页面上最显眼的是"评价数"。我在系统里用评论数当销量代理变量,这是公开数据层面最可用的替代指标,但头脑要清醒:评论数是一个累计值,不代表当前销量,也不等于实际支付订单量,可能存在刷评、老品积累等干扰。

用的时候我加了两条约束。第一,只用来做横向相对比较,比如"哪个品牌热度高""哪个价格带讨论多",不把评论数当成精确销量;第二,配合价格、店铺、标题一起看,避免单指标误判。这套系统的定位是趋势观察,不是商业决策依据,这个边界我在设计之初就想清楚了。

4.2 价格带分布:把连续价格变成区间

价格是连续变量,直接统计没有意义,我把它分箱成几个区间:0-20元、20-50元、50-100元、100-200元、200元以上。这样能看到食品电商的典型格局。分箱查询在 SQLAlchemy 里用case表达式实现,比把所有价格拉进 Python 再循环高效得多:

from sqlalchemy import case, func price_band = case( [ (Product.price < 20, "0-20元"), (Product.price < 50, "20-50元"), (Product.price < 100, "50-100元"), (Product.price < 200, "100-200元"), ], else_="200元以上", ).label("price_band") rows = ( session.query(price_band, func.count(Product.id), func.avg(Product.comment_count)) .group_by(price_band) .all() )

拿到的结果大概是这样的示意数据:

价格带商品数平均评论数评论总数
0-20元2403.2万768万
20-50元4206.8万2856万
50-100元3104.5万1395万
100-200元1202.1万252万
200元以上600.8万48万

这个分布很典型:食品类目的主力价格带在 20 到 100 元之间,200 元以上商品虽然单价高,但评论总量明显萎缩。做可视化时,我会把商品数和评论总数两个指标同时挂在柱状图上,看得出"哪个价位带竞争最激烈"和"哪个价位带吃到了最大热度"是两件事。

4.3 品牌 Top 排名与头部集中度

食品类的品牌辨识度非常高,标题里往往带着品牌名,比如"三只松鼠""百草味""良品铺子"。我在分析阶段写了一个简单的品牌提取规则:对标题按空格切分,取首段作为品牌候选,再和一份手工维护的食品品牌表做匹配,匹配不上的归到"其他"。

排名统计用分组聚合就够:

brand_stat = ( session.query(Product.shop, func.count(Product.id), func.sum(Product.comment_count)) .group_by(Product.shop) .order_by(func.sum(Product.comment_count).desc()) .limit(15) .all() )

实际结果通常符合直觉:坚果零食类目下,三只松鼠、百草味、良品铺子三家的商品数和评论数合计能占到相当高的比例,长尾品牌尽管数量多,热度却被头部吸走。这就是电商常用的"头部集中度"概念,在系统里我用一个横向条形图展示 Top10 品牌,比看数据表直观得多。

4.4 价格与热度的散点关系

散点图是判断两个连续变量关系的最快方式。我把横轴设为价格,纵轴设为评论数,发现两个明显特征:一是绝大多数点挤在 50 元以内的区域,二是评论数极高的爆款几乎都集中在低价带。这种负相关关系在坚果零食里特别明显,但换成粮油米面这种复购率高的品类,趋势又会不一样。所以我给系统加了"按关键词切换图表"的功能,散点图可以只看某一个关键词下的商品,这样能回避把不同品类混在一起统计带来的误导。

做散点图时数据点数不要直接全部传给前端,不然页面会卡。我通常在接口层先做一次降采样,比如每 100 条取 1 条,或者只保留评论数排名前 80% 的商品,保证视觉效果的前提下控制了传输量。

5. Flask 接口:把数据库统计变成前端能吃的 JSON

5.1 项目目录与蓝图划分

后端我用蓝图的方案拆模块,目录大概长这样:

jd_sales/ app.py # Flask 入口 models.py # SQLAlchemy 模型 stats_api.py # 统计接口蓝图 crawler/ jd_spider.py # 爬虫逻辑 templates/ index.html # 入口页 dashboard.html # 可视化大屏页 static/ echarts/ # 本地 ECharts 文件 js/dashboard.js

app.py只做三件事:创建 Flask 实例、初始化数据库会话、注册蓝图。职责单一,避免把所有路由堆在入口文件里,这在项目变大以后会非常救命。

蓝图注册的代码很常规:

from flask import Flask from stats_api import stats_api_bp app = Flask(__name__) app.register_blueprint(stats_api_bp) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, debug=True)

5.2 核心接口和聚合查询写法

接口设计围绕大屏需要的图表来定义,不追求大而全,够图表用的就是好接口。我设计了五个核心接口:

  • /api/summary:总商品数、平均价格、评论总数,用于顶部 KPI 卡片
  • /api/price_distribution:各价格带商品数和评论数,用于柱状图
  • /api/brand_top:头部品牌排名,用于横向条形图
  • /api/scatter:价格与评论数散点数据,用于相关性观察
  • /api/keyword_compare:各关键词的商品数、均价、评论总量,用于对比不同子类目

/api/summary的实现很简单,聚合函数直接映射成 JSON:

@stats_api_bp.route("/api/summary") def summary(): total = session.query(func.count(Product.id)).scalar() avg_price = session.query(func.avg(Product.price)).scalar() total_comments = session.query(func.sum(Product.comment_count)).scalar() return jsonify({ "total": total, "avg_price": round(avg_price or 0, 2), "total_comments": int(total_comments or 0), })

注意聚合结果可能是None,比如数据库为空时avg()返回None,直接jsonify会报错或者返回 null,所以我养成了所有聚合字段都做or 0兜底的习惯。

/api/price_distribution的查询逻辑就是上一节那段case表达式,查询结果先转成列表再返回。前端拿到的是形如[{"range": "20-50元", "count": 420, "avg_comment": 68000}]的 JSON,ECharts 可以直接消费。

5.3 接口性能:不要在 Python 里做大循环

我刚写完第一版时图省事,把session.query(Product).all()拿到 Python 里,用for循环做统计。数据量只有几百条时没问题,等抓了几千条就开始卡顿。后来把所有统计逻辑都改成 SQL 聚合,Python 只负责把查询结果格式化。这是使用 ORM 的一个核心原则:能下推到数据库的运算绝不下放到 Python,分组、求和、排序、分页都是数据库的强项。

另一个经验是给接口加简单的数据量上限,比如散点接口限制最多返回 3000 个点,避免用户把几万条数据一次性加载到浏览器。真需要看全量时再通过筛选条件缩小范围,这对前后端都是保护。

6. ECharts 接入:从接口 JSON 到可交互大屏

6.1 为什么放弃 Matplotlib 图片方案

很多 Flask 教程教的是后端用 Matplotlib 生成 PNG,前端img src引用,我第一版就是这么做的。结果发现三个问题:第一,图片是静态的,鼠标放上去看不到具体数值,交互体验差;第二,图表样式跟页面风格很难统一,深色大屏和白色 matplotlib 图片放在一起非常突兀;第三,每次筛选条件变化都要重新生成图片,服务器压力大。

ECharts 是纯前端方案,后端只提供数据,渲染完全在浏览器完成,交互、动画、主题都成熟。接入方式也简单,一个script标签引入 echarts.min.js,一个div容器,几行setOption就能出图。

6.2 模板直出与 Ajax 异步,两种接入方式

第一种是模板直出。Flask 渲染dashboard.html时把数据直接塞进页面里的 JavaScript 变量:

<div id="brandChart" style="width:100%; height:400px;"></div> <script> const BRAND_DATA = {{ brand_data | tojson }}; const chart = echarts.init(document.getElementById('brandChart')); chart.setOption({ xAxis: { type: 'category', inverse: true }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: BRAND_DATA.map(d => d.total_comments) }] }); </script>

这种方式的好处是页面打开就有数据,不用等待二次请求,缺点是一旦数据形态变化,得重新刷新页面才能看到新结果。

第二种是 Ajax 异步加载。页面先空载,JavaScript 用fetch请求接口再填充图表:

fetch('/api/price_distribution') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('priceChart')); chart.setOption({ xAxis: { type: 'category', data: data.map(d => d.range) }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: data.map(d => d.count), itemStyle: { color: '#5B9DFF' } }] }); });

我个人更推荐第二种,理由是大屏页面往往不止一个图表,异步加载可以让每个图表独立更新、独立报错,某个接口挂了不至于整个页面白屏。后期要加筛选器,只要在fetch里加上查询参数再重新setOption就行,改动很小。

6.3 大屏布局与常见配置细节

大屏布局我用了很朴素的三层结构:顶部一排 KPI 卡片显示总商品数、平均价格、评论总数;中间一排左右两张图,左边价格带柱状图,右边品牌 Top10 横向条形图;下面一排放散点图和关键词对比图,宽度按百分比自适应。

ECharts 有几个容易踩的配置细节。第一,容器必须有明确高度,height: 400px或者height: 50vh都行,但绝对不能没有高度,否则图表初始化出空白。第二,xAxis类型要选对,价格带这种文本标签用category,连续数值用value;第三,反应在散点图上的数值如果差别太大,给yAxis加type: 'log'对数轴,否则低价商品点全都挤成一团看不见。深色大屏配色我一般用#001529底、#5B9DFF柱、#36E2BE折线,再用textStyle统一字色,整体看起来专业不少。

7. 踩坑实录:编码、抽风接口、空白图表与部署

7.1 页面编码和选择器失效

第一个坑是编码。京东搜索页返回的是 UTF-8,但某些内嵌模块可能带着其他编码声明,直接resp.text偶尔会乱码。解决办法是显式设置resp.encoding = "utf-8",或者直接用resp.content交给 BeautifulSoup 自己检测编码。第二个坑是选择器失效,京东前端偶尔会调页面结构,p-name这些 class 可能变。我的经验是抓数据时不依赖全链路选择器,优先用>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询