☰
Python爬虫与可视化:从数据清洗到Flask+ECharts搭建商品销售分析系统
2026/10/18 12:32:15 网站建设 项目流程

简介:这是一份基于Python的商品销售数据分析可视化系统源码,面向毕业设计场景,适合计算机、电子商务等专业学生用于课题参考与功能复用。系统采用Django框架,并集成爬虫模块用于商品数据采集,属于前后端完整的可运行工程。压缩包共含1480个文件,大小约17.68MB,主要涵盖Python源码与pyc编译文件、Django模板及HTML页面、CSS/LESS/SCSS样式表、JavaScript交互脚本,以及大量图表展示所需的png/jpg/gif与svg等静态资源;另有JSON/XML配置、字体文件、PSD设计稿和说明文档,目录结构清晰,便于按模块定位代码。目前已有181人学习下载。源码已经过本地编译验证,配置相应Python环境即可运行,功能覆盖数据爬取、清洗、存储、分析与可视化展示等完整流程,可供用户直接用于课程设计、毕业设计演示或在此基础上扩展二次开发。

1. 从爬虫到图表:这个商品销售分析系统到底在解决什么

做电商和线下零售的数据分析,最常遇到的尴尬不是没数据,而是数据在别人手里。平台后台给你一堆汇总数,导出来的报表字段固定,想问“某个品类到底是被哪个价格区间的商品拉起来的”“每天哪个时段的下单转化最高”,后台报表根本没有这个维度。市面上买数据服务,小一点的公司又觉得成本不值。自己养爬虫去抓商品信息、销量、价格和评价,再做清洗入库,最后用可视化把结论落到图表上,这一整套流程,就是“基于Python的商品销售数据分析可视化系统”这个项目在做的事。

这套系统适合三类人。第一类是电商运营,想从公开页面了解竞品价位和销量分布;第二类是准备做数据分析入门项目的新手,需要一条从数据采集到展示的完整链路;第三类是独立开发者,给本地商户做轻量的销售看板。整套系统可以拆成三个环节:爬虫负责把数据抓下来,清洗和指标计算负责把数据变成能用的分析口径,Flask加ECharts负责把结果渲染成能直接看的图表。思路不复杂,但每个环节都有坑,参数不调好,结果就是一张看不出规律的花架子。

我建议你把“带爬虫”看成这个项目最值钱的部分,因为真正让这套系统和其他“假大屏Demo”拉开差距的,恰恰是数据是怎么来的,以及怎么保证它持续可用。接下来按“抓数据→洗数据→算指标→出图表”的顺序拆解,最后单独讲一轮踩坑记录,这些都是跑这套系统少不了的功课。

2. 商品数据爬虫:先想清楚抓什么、怎么防封、怎么断点续爬

2.1 爬虫在系统里的位置与合规边界

“带爬虫”这三个字意味着,整个可视化系统的上游,不是一个通过pip就能装好的现成库,而是一组需要自己维护的数据采集脚本。放在商品销售分析这个场景里,爬虫的任务很明确:定时去抓商品列表页的商品ID、标题、价格、月销量、店铺名、评论数、上架时间这些字段,然后再进详情页补抓SKU、规格、累计评价、折扣价之类更细的信息,最终得到一张可供分析的多维事实表。

在设计上要给自己留余地,不能一股脑把抓到的原始Json直接丢进数据库,后续清洗会很被动。常见做法是把爬虫拆成两层:第一层抓列表页,拿到的是一批商品ID集合;第二层拿着商品ID去请求详情页接口,拿到结构化字段。这个分层有几个好处:列表页只需要维护一个请求逻辑,详情页改版不会拖垮整体采集能力;商品ID天然具备去重属性,适合作为表主键;断点续爬只需要记录“已经抓到哪个商品ID”,不需要在网页解析上做文章。

合规这块,我的态度比较朴素:只抓公开可见的商品信息,不做商业化转售,控制请求频率,设置User-Agent模拟正常浏览器,看到robots.txt有明确禁止就绕开那个路径。技术上能爬到不等于该爬,频率控制在每秒1到2个请求,对个人学习项目足够用了。

2.2 列表页与详情页分层抓取的Python实现

用requests加lxml就能处理大多数电商列表页。目标页面是商品列表,每页有几十个商品,翻页参数常见的有两种:URL路径里的page数字,或者Query参数里的pageNo。抓取的核心逻辑是稳定解析HTML或JSON接口,并抽取统一结构化字段。

import requests import json import time import random from lxml import etree 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-Language": "zh-CN,zh;q=0.9", } def fetch_list_page(page_no): url = "https://example.com/mall/search" params = { "keyword": "运动鞋", "page": page_no, "pagesize": 60 } resp = requests.get(url, params=params, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.json() # 假设接口返回 JSON,很多商城列表页有 JSON 版接口 def extract_items(payload): items = [] data_list = payload.get("data", {}).get("list", []) for raw in data_list: items.append({ "item_id": raw["itemId"], "title": raw["title"], "price": float(raw["price"]), "month_sales": int(raw["monthSales"] or 0), "shop_name": raw["shopName"], "comment_count": int(raw["commentCount"] or 0), }) return items def crawl_all(): all_items = [] for page in range(1, 11): try: payload = fetch_list_page(page) page_items = extract_items(payload) all_items.extend(page_items) print(f"第{page}页抓到{len(page_items)}个商品") except Exception as e: print(f"第{page}页失败: {e}") time.sleep(random.uniform(1.0, 2.5)) return all_items

这段代码里有两个参数直接影响采集质量和速度。一是pagesize,多数接口允许每页60到100条,设置太大会触发风控,设置太小翻页次数多,这个项目里60条是一个不容易被盯上的值;二是time.sleep(random.uniform(1.0, 2.5)),随机延时是为了避免固定间隔的机器行为特征,固定1秒跑一百页,与随机1到2.5秒相比,后者被断开连接的概率明显低。

爬虫脚本的健壮性还要体现在失败重试上。上面这版代码只做了try-except打印,真正要长时间挂机跑,需要加一个指数退避重试逻辑:第一次失败等2秒,第二次等4秒,最多等32秒,超过次数就放弃当前页。这样处理的好处是,目标服务器短暂抖动时脚本不会直接崩溃,也不会在对方已经显式限流时还高频重试。

2.3 详情页补抓与断点续爬设计

列表页拿到的是商品概览信息,做销售分析最好有折扣价、上架时间、品牌、产地这些更细的字段。详情页的JSON接口通常嵌套很深,而且字段名在不同品类下不一致。我建议在详情解析层做字段容错,而不是假设每个商品都有全部字段。

import sqlite3 import os def fetch_detail(item_id): url = f"https://example.com/mall/detail?itemId={item_id}" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() data = resp.json().get("data", {}) return { "item_id": item_id, "discount_price": float(data.get("discountPrice", 0) or 0), "brand": data.get("brand", ""), "up_time": data.get("upTime", ""), "sku_count": len(data.get("skuList", [])), "category": data.get("category", ""), } def resume_from_db(db_path="sales.db"): conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute( "SELECT item_id FROM items " "WHERE detail_fetched = 0 LIMIT 50" ) pending_ids = [row[0] for row in cursor.fetchall()] conn.close() return pending_ids def backfill_detail(): pending = resume_from_db("sales.db") for item_id in pending: for attempt in range(3): try: detail = fetch_detail(item_id) save_detail_to_db(detail) break except Exception as e: print(f"商品{item_id}第{attempt+1}次失败: {e}") time.sleep(2 ** attempt)

断点续爬的实质是把“爬到哪个位置”这个状态外置到数据库里。resume_from_db函数每次都从items表里捞detail_fetched=0的商品,不管上次脚本是正常结束还是被Ctrl+C中断,下次启动都会从没做完的地方继续。这比在脚本内部维护一个进度list要可靠得多,因为你永远不知道爬虫什么时候会因为网络波动、内存异常、电脑重启而挂掉。

需要说清楚的一点是,详情页接口的数据字段变化比列表页更频繁。运气不好的时候,昨天还在的discountPrice字段今天改名成promotionPrice,解析层直接抛KeyError,整个脚本停掉。应对办法是在解析时用data.get("discountPrice", data.get("promotionPrice", 0))这种链式兜底,再不行就存一份原始JSON,后续重新解析时不用重新抓网络。

3. 数据清洗与入库:决定分析结果可信度的关键一环

3.1 电商销售数据的典型脏数据形态

爬虫抓下来的数据不能直接丢进分析层,这是做数据项目的基本觉悟。商品来源不同、页面展示口径不同,数据里带着各种“惊喜”。我做这个系统的第一版就踩过一个很典型的坑:有的商品详情页“月销量”字段是字符串格式,有的直接带“万”字后缀,还有的折扣价大于原价,明显是活动价先涨价再打折造成的。这类数据如果不处理,后面算所有价格相关的指标,结论都站不住脚。

典型脏数据大致有四类。第一类是缺失值,必填字段出现空值,比如商品ID缺失、品牌缺失;第二类是格式错乱,数值型字段混入逗号、单位、百分号,比如“12,000”和“1.2万”同时出现;第三类是业务不合理值,比如折扣价大于原价、月销量为负数、评论数大于销量十倍以上;第四类是重复记录,同一商品多次抓取,只更新了部分字段,造成数据表里同一个ItemId出现多行。

3.2 清洗两步走:类型标准化与业务规则校验

我的清洗策略分两个阶段。第一阶段不关心业务,只做类型标准化:把字符串数字转成float/int、把“万”单位统一转成实际数字、把日期字符串转成标准时间、把空字符串统一换成None。第二阶段做业务规则校验:折扣价大于原价的修正或剔除、月销量为负数置0、评论数超过销量50倍标记异常。

import pandas as pd import re def parse_sales_count(value): if value is None: return 0 value = str(value).replace(",", "").strip() if value.endswith("万"): return int(float(value[:-1]) * 10000) if value.endswith("+"): return int(value[:-1]) return int(float(value)) def clean_items_df(df): df["price"] = pd.to_numeric(df["price"], errors="coerce") df["month_sales"] = df["month_sales"].apply(parse_sales_count) df["discount_price"] = pd.to_numeric(df["discount_price"], errors="coerce") df["discount_price"] = df["discount_price"].fillna(df["price"]) # 折扣价不能大于原价,若异常则用原价替代 mask = (df["discount_price"] > df["price"]) & (df["price"] > 0) df.loc[mask, "discount_price"] = df.loc[mask, "price"] # 月销量负值置0 df.loc[df["month_sales"] < 0, "month_sales"] = 0 # 评论数缺失填充为0 df["comment_count"] = df["comment_count"].fillna(0).astype(int) df = df.drop_duplicates(subset=["item_id"], keep="last") return df

parse_sales_count函数是这个清洗阶段的核心。它把“1.2万”、“12,000”、“5000+”三种不同风格的销量格式统一成整数,保证后续聚合计算口径一致。pd.to_numeric配合errors="coerce"可以把“5,299元”这种带符号的字符串转成NaN,然后再统一填充,避免下游做平均值和排序时直接报错。

有一点容易漏:去重要用keep="last"。原因是详情页补抓的数据比列表页更新,同一ItemId在数据库里可能旧记录在前、新记录在后。保留后者,意味着折扣价、评论数等详情字段用的是最新一次采集结果。

3.3 SQLite入库方案:为什么项目阶段不选MySQL

商品数据清洗完,该落库了。原题标题带“源码.zip”,说明这是个教学和演示性质的项目,本地能跑通、依赖少、重启不丢数据是关键诉求。SQLite在这三个维度上比MySQL更有优势:零配置文件、Python标准库自带驱动、单文件便于备份和分发。

import sqlite3 from contextlib import closing DB_PATH = "sales.db" def init_db(): with closing(sqlite3.connect(DB_PATH)) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS items ( item_id TEXT PRIMARY KEY, title TEXT, price REAL, discount_price REAL, month_sales INTEGER, comment_count INTEGER, shop_name TEXT, brand TEXT, category TEXT, up_time TEXT, detail_fetched INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now', 'localtime')) ) """) conn.execute(""" CREATE INDEX IF NOT EXISTS idx_items_category ON items(category) """) conn.commit() def save_items_to_db(df): with closing(sqlite3.connect(DB_PATH)) as conn: df.to_sql("items", conn, if_exists="append", index=False, method="multi")

两个设计值得说明。一个是item_id设为主键,这天然拦截了重复采集产生的重复记录,第二次采集同一商品时不走INSERT走UPDATE,业务上叫“幂等写入”;另一个是detail_fetched字段,它是爬虫断点续爬的状态标记,也是清洗阶段的进度标记,改为1就代表详情感知完成。db初始化时顺手建了category索引,是因为后续“按品类聚合”是高频分析动作,索引能明显提升查询速度。

数据量到达什么样的情况才需要从SQLite迁到MySQL?我自己的判断线是单表超过500万行,或者需要多用户并发写入。个人项目跑个几千商品、几十万条记录,SQLite的查询速度完全够用,没必要给部署徒增复杂度。

3.4 清洗前后对比:一个可复验的验证动作

每次跑完清洗和入库,都要有个可复验的动作来确认数据没被洗坏。我的习惯是先看记录数和几个关键指标的极值、均值,再做一份“清洗前后对照表”,用排查问题的视角看差异是否合理。

指标清洗前清洗后说明
总记录数62106011去重掉了199条重复商品
价格为空的记录数1280统一用0填充
折扣价大于原价370用原价覆盖了异常折扣价
月销量最大值(件)8.7万87000单位做了归一化
价格均值358.2302.5剔除部分异常价后均值更接近实际

对照表里如果出现“总记录数减少比例过高”或者“均值明显不合理”,就要回头看清洗规则是不是太激进。比如把折扣价大于原价直接置成原价,有时候其实是跨平台店铺的定价策略,而不是数据错误。清洗的目标不是消灭所有异常,而是把异常控制在可解释范围内,让后面的分析口径站得住。

4. 销售分析与核心指标:从表格数据到业务洞察

4.1 搭建一套多维分析模型:指标、维度、粒度

清洗入库之后,数据只是“干净了”,离“能看”还差一个分析模型。做可视化不能想到哪个图就写哪个图,而是先用一套维度体系和指标口径把分析框架立起来。

商品销售数据分析的核心可以拆成三个维度:

  • 商品维度:SKU、品类、品牌、价格带
  • 时间维度:小时、日、周、月
  • 店铺维度:店铺名、店铺层级

指标口径要事前定清楚。销售额算的是实付金额(一般用折扣价而非原价),销售量的口径是“月销量”字段,客单价是销售额除以用户下单数(爬虫没有用户明细,可暂时用销量替代),折扣率是折扣价除以原价。这些口径一旦定下来,所有图表共用一套,不然后面“销售额最高”和“销量最高”的图互相矛盾,看板就失去了解释力。

4.2 三大核心分析场景:品类结构、价格带、时间规律

从电商运营的常见分析诉求来看,有三张图是销售看板里最高频出现的:类目销售结构、价格带分布、销售时间趋势。分别对应“哪个品类赚钱”“哪个价格段卖得好”“什么时候卖得好”这三个问题。

下面这段SQL实现了品类结构和价格带分布两个分析场景的聚合查询,直接作为可视化的数据源。

-- 品类销售汇总 SELECT category, COUNT(DISTINCT item_id) AS sku_count, SUM(month_sales) AS total_sales, ROUND(SUM(month_sales * discount_price), 2) AS total_gmv, ROUND(AVG(discount_price), 2) AS avg_price FROM items WHERE category IS NOT NULL AND category != '' GROUP BY category ORDER BY total_gmv DESC; -- 价格带分布 SELECT CASE WHEN discount_price < 50 THEN '0-50元' WHEN discount_price < 100 THEN '50-100元' WHEN discount_price < 200 THEN '100-200元' WHEN discount_price < 500 THEN '200-500元' ELSE '500元以上' END AS price_band, SUM(month_sales) AS band_sales, COUNT(DISTINCT item_id) AS band_sku_count FROM items WHERE discount_price > 0 GROUP BY price_band ORDER BY band_sales DESC;

第一段SQL给“品类结构”图表提供数据源,总销售额GMV是最核心的排序指标,avg_price用于判断该品类整体价位档次;第二段SQL用CASE表达式把连续价格离散化成价格带,输出一个适合做饼图或柱状图的结果集。价格带的边界定义要按品类灵活调整,卖数码产品和卖零食的,价格带分组差距很大,我这里的边界只是示例。

4.3 数据洞察怎么说人话:一个具体结论示例

工具只是前半程,可视化系统最终的价值是把结论“说人话”。举个例子,假设对“运动鞋”这个关键词的分析结果如下:

价格带销量占比SKU数均价
0-100元45%28%68元
100-200元32%30%149元
200-500元18%35%326元
500元以上5%7%720元

这不是一张图表就结束了,而是可以往下问两个问题:低价带用了不到三成的SKU数贡献了近一半销量,是否平台流量正在向低价商品倾斜?200到500元区间的SKU数最多但销量占比只有18%,是定价过高还是详情页卖点不足?把这个分析结果放到可视化看板上,就变成了“决策提示”,而不只是彩色柱子。爬虫抓到的数据毕竟是公开数据,真实因果需要结合平台内部数据验证,但方向性结论足够作为选品调整的参考。

5. 可视化系统搭建:Flask后端接口配上ECharts图表的完整实现

5.1 技术选型:不用前后端分离,也能出专业看板

可视化部分的技术选型,对这类个人项目和中小型系统,我推荐Flask加ECharts的组合。Flask是Python阵营里最轻量的Web框架,一个py文件就能起服务,自带开发服务器,和数据分析逻辑共享一套Python环境,不用额外起Node或Java服务。ECharts是纯前端的图表库,折线图、柱状图、饼图、地图、热力图都是配置式渲染,图表交互效果专业,不需要自己写Canvas。

整体架构是一条非常标准的三层链路:SQLite数据库存放清洗好的数据,Flask提供JSON接口按需查询数据,浏览器端用ECharts渲染图表并用Ajax拉取接口数据。这套架构对比用Jupyter Notebook做可视化的优势在于:Notebook适合自己做探索,但不适合交付给运营同事每天打开看,而Web页面任何人打开浏览器输入地址就能看到实时刷新的看板。

5.2 数据接口设计:接口先于页面,按图表需求倒推

做可视化之前,先定义JSON数据接口。每个图表对应一个接口,接口返回结构是固定的“字段名+数组”,前端拿到就能直接setOption,不用在前端做什么数据映射。

from flask import Flask, jsonify, request import sqlite3 app = Flask(__name__) DB_PATH = "sales.db" def query_db(sql, args=()): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute(sql, args) rows = cur.fetchall() conn.close() return rows @app.route("/api/category_sales") def category_sales(): rows = query_db(""" SELECT category, SUM(month_sales) AS total_sales, ROUND(SUM(month_sales * discount_price), 2) AS total_gmv FROM items WHERE category IS NOT NULL AND category != '' GROUP BY category ORDER BY total_gmv DESC LIMIT 10 """) return jsonify([{ "category": r["category"], "total_sales": r["total_sales"], "total_gmv": r["total_gmv"] } for r in rows]) @app.route("/api/price_band") def price_band(): rows = query_db(""" SELECT CASE WHEN discount_price < 50 THEN '0-50元' WHEN discount_price < 100 THEN '50-100元' WHEN discount_price < 200 THEN '100-200元' WHEN discount_price < 500 THEN '200-500元' ELSE '500元以上' END AS price_band, SUM(month_sales) AS band_sales FROM items WHERE discount_price > 0 GROUP BY price_band """) return jsonify([{ "price_band": r["price_band"], "band_sales": r["band_sales"] } for r in rows]) app.run(host="127.0.0.1", port=5000, debug=False)

这里有两个容易被忽略的细节。第一个是sqlite3.Row,它让查询结果可以用列名访问而不是数字下标,返回给JSON时就不用写一堆r[1]这种魔法数字;第二个是接口里做了LIMIT 10,前端图表只展示Top10品类,但这不意味着分析只分析前十个,而是看板主视图突出头部品类,明细仍然可以再开接口看全量。

5.3 前端图表渲染:Ajax拉数据,ECharts出图

前端页面不需要复杂框架,一个HTML加一个JS文件就能把整个看板撑起来。下面用一个组合图示例说明思路:柱状图表示各品类GMV,折线图表示销量,双Y轴展示不同量纲的数据。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>商品销售分析看板</title> <!-- 引入 ECharts CDN,也可以用本地文件避免外网依赖 --> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="categoryChart" style="width: 100%; height: 400px;"></div> <script> fetch("/api/category_sales") .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById("categoryChart")); chart.setOption({ tooltip: { trigger: "axis" }, legend: { data: ["销售额", "销量"] }, xAxis: { type: "category", data: data.map(item => item.category), axisLabel: { rotate: 20 } }, yAxis: [ { type: "value", name: "销售额" }, { type: "value", name: "销量" } ], series: [ { name: "销售额", type: "bar", data: data.map(item => item.total_gmv) }, { name: "销量", type: "line", yAxisIndex: 1, data: data.map(item => item.total_sales) } ] }); }); </script> </body> </html>

这段代码的关键在双Y轴设计。销售额和销量的数值量级差异很大,直接共用同一个Y轴,销量几千和销售额几十万会彻底压扁低量级的那条线。yAxis数组里第一个轴给柱状图用,第二个轴通过yAxisIndex: 1绑定给折线图,各走各的比例尺,两条曲线的趋势才能同时看清楚。

页面里几个交互配置也值得留意。axisLabel的rotate设置为20,解决品类名过长时文字互相覆盖的问题;tooltip的trigger设为axis,鼠标划过某品类时,同时显示该品类销售额和销量两个维度的值,信息密度更高。

5.4 图表模块对照表:按分析需求选图表类型

把分析需求和图表类型配对是可视化环节的核心功力,放一张对照表可以把选择过程标准化。

分析目标推荐图表配置要点
各品类销售额对比柱状图按GMV降序排列,便于一眼看出头部
整体销售趋势折线图X轴用日期,字段拆到天或周粒度
价格带销量占比饼图/环形图占比小到5%以下的组建议合并成“其他”
时段下单热力热力图X轴为小时,Y轴为星期
店铺销售排行横向条形图店铺名过长时横向展示可读性更好

这里有一个很多人做图表走过的弯路:把所有图表硬塞进一个页面,结果视觉上堆满信息,读图人不知道该看哪。做看板要克制,一页最多五个图,每个图只回答一个问题,把结论性文字放在图表标题上,比如“销量集中在0-100元价格带”,让看图的人直接读到结论,而不是自己从数据里再猜一遍。

6. 避坑指南:爬虫与可视化系统跑通必经的五个坑

6.1 反爬封禁:没有随机延时,爬虫活不过三小时

现象:爬虫脚本启动后正常跑了十几分钟,突然连续报429状态码或ConnectTimeout,再往后直接被拒绝连接。

原因:请求频率过高,UA特征太明显,触发对方风控策略。有些平台的封禁策略不是秒级检测,而是“一段时间内请求总数超过阈值”,所以前面看着正常,实际已经被盯上了。

解决:两件事必须同时做。第一是在每次请求之间加随机延时,用time.sleep(random.uniform(1, 3)),不要用固定间隔;第二是给requests配置重试策略,遇到429或5xx时指数退避,最多重试3次。另外检查一下UA里是否带了Python-requests的默认标记,最好换成完整浏览器的User-Agent和Accept-Language。

6.2 数据编码乱码:JSON里全是“\u56fe\u7247”这类转义序列

现象:抓回来的接口数据在终端打印中文显示正常,但写入SQLite后读出来全是“\u56fe”开头的乱码。

原因:一方面,响应内容实际是Unicode转义序列;另一方面,数据库连接的字符集设置与写入内容不一致。SQLite本身对编码不敏感,最常见的原因是requests.get返回的resp.text被错误解码,比如服务器返回UTF-8但resp.text按ISO-8859-1解码。

解决:在requests请求里显式指定编码,resp.encoding = resp.apparent_encoding或直接resp.encoding = "utf-8",而不是依赖requests去猜测。入库前用print抽检三条记录,确认中文肉眼可读后再批量写入。

6.3 SQLite并发写入报“database is locked”

现象:爬虫边爬边写入,同时另一个进程在跑清洗脚本,SQLite抛sqlite3.OperationalError: database is locked。

原因:SQLite的锁粒度是整个数据库文件,多个进程同时写同一库文件,写锁冲突直接报错。爬虫写入频繁,清洗脚本长时间占用读事务,就会撞锁。

解决:最简单的方式是调整连接超时和写入批次。conn = sqlite3.connect(DB_PATH, timeout=10)把等待锁的时间拉长到10秒,减少撞锁概率。更彻底的方式是爬虫只负责抓数据写到一个独立的pending.db,清洗时一次性把pending.db的数据读出来、清洗、再写入主库,从架构上避免两个进程同时写同一文件。

6.4 图表数据量级对不上:销售额和销量渲染后不在一个量级

现象:折线图显示月销量趋势,Y轴全是几十几百,但明细表里某单品的月销量上万。仔细对比后发现接口SQL里SUM(month_sales)只统计了当前页面的商品,不是全量商品。

原因:采集时只抓了前10页列表数据,总计几百个商品;而图表标题写的是“全平台当月销量趋势”,分析口径和数据覆盖范围不匹配。

解决:图表标题和数据覆盖范围必须写清楚。如果数据只有前10页,标题就写成“搜索关键词Top 10页商品销量趋势”,而不是“行业销量趋势”。另一个应对是保证采集的轮次覆盖足够多页,电商搜索结果通常关注前20到30页,给自己留足数据量。

6.5 日期字段时区错位:凌晨数据跑到了昨天

现象:按“小时维度”看销量分布,发现每天0点到2点的销量异常偏低,几乎为0,但业务方确认凌晨有订单产生。

原因:商品详情页的上架时间或数据时间使用的是UTC时区,Python的datetime.now()生成的是本地时间,两者混用导致统计口径错位,凌晨的订单被算到了前一天。

解决:统一全项目时间处理方式。入库时统一用datetime.now(timezone.utc)生成时间戳,前端渲染时再转成北京时间展示。SQL查询里做小时维度分组时,用strftime('%H', created_at)提取小时字段即可,前提是入库时的时间已经是统一时区的时间。

7. 从“看板”走向“决策”:加入销量预测和库存联动

可视化系统跑通,“能看数据”已经实现,下一步要解决的是“用数据指导备货”这个进阶方向。我给这套系统的下一版规划了两个扩展点,都不需要推倒重来,只是在现有Flask和SQLite架构上增加模块。

第一个扩展是简单销量预测。在现有items表基础上,增加一张daily_sales表,记录每个商品每天的销量,利用过去30天的日销量数据,用移动平均法预测未来7天的销量。这个模块适合写在Python脚本里定时执行,预测结果直接写入一个新表,前端图表增加一条“预测曲线”。移动平均虽然是所有预测算法里最基础的,但胜在稳定和可解释,对电商备货来说,比一个说不清原理的神经网络更靠谱。

第二个扩展是库存预警联动。当预测未来7天销量大于当前库存的50%时,在页面顶部高亮告警“某商品预计7天后缺货”,提示运营提前下单采购。这个功能并不需要复杂的算法,一行SQL join就能实现,但对看板的业务价值有本质提升——从“做了什么”变成“接下来该做什么”。

我自己的习惯是,每次给这类系统加扩展模块,都先写一个“最简版本”验证业务价值:一个脚本加一个图表,跑起来看到预测曲线与历史曲线的直觉一致性,再决定是否增加更多参数调优。直接上ARIMA或者Prophet,参数调试成本高,还不一定比移动平均更贴合电商周波动的场景。

这套系统的边界我心里有数:抓的是公开数据,分析只能基于可观测字段,无法还原平台内部真实的转化漏斗和用户画像。但用它来做竞品价格监控、品类选品参考、时间规律分析,已经能覆盖中小商家前期选品调研的大部分需求。希望这篇笔记能帮你把“带爬虫的商品销售分析可视化系统”从标题变成一个能跑通、能出图、能给人讲清楚的成品。祝顺利。

本文还有配套的精品资源,点击获取

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

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

立即咨询