简介:这套基于Python爬虫与Django+Vue的电商比价系统源码,属于高分期末大作业/毕业设计项目,主要面向计算机相关专业学生与从业者,用于课程设计、期末大作业或毕业设计参考。系统围绕电商商品比价场景,涉及爬虫数据采集、Django后端接口开发、Vue前端展示及前后端联调等关键环节,可帮助读者完整理解一个前后端分离项目的实现路径。资源包共660个文件,压缩包约6.71MB,其中包含265个js、115个html、90个css等前端资源,25个py后端脚本,以及png、gif、json、svg等图片与数据文件,目录结构清晰,便于按模块阅读和修改。目前已有455人学习下载。项目源码经导师评审96分以上并严格调试,可直接运行,适合需要快速搭建同类比价系统或学习爬虫与Web开发结合的同学深入研读。
1. 为什么比价系统的难点不在爬虫,而在数据流水线
做电商比价系统的人不少,真正跑到答辩现场的却不多,多数死在同一处:三个平台抓回来的同一件商品,标题、规格、价格格式都对不上,比价表根本没脸拿出来。这个基于 Python 爬虫、Django 后端、Vue 前端的电商比价系统,把采集、落库、接口、展示串成一条完整流水线:爬虫只负责从公开页面取回原始数据,Django 负责商品归一化和历史价格记录,Vue 把多平台价格渲染成列表和走势图。它适合两类人——要交毕业设计项目的在校生,以及想练前后端分离加数据采集完整链路的新手。真正难的不是某个框架怎么调,而是让这条数据流水线稳定地跑上一个月,价格不出错、记录不爆炸、页面不白屏。
2. 先把系统拆开:爬虫、Django、Vue 的职责边界与核心表结构
2.1 为什么是 requests 加 XPath、Django、Vue 的组合
选型先别贪多。比价系统最核心的动作是“定时抓页面 → 清洗 → 存库 → 查询 → 展示”,每一步都有成熟方案,但组合起来要能在一学期内做完,还得在答辩时讲得清楚。
抓取层面,常见做法是 requests 加 lxml 的 XPath,而不是一上来就上 Scrapy。Scrapy 的调度器、下载中间件、Item Pipeline 确实是加分项,但调试链路长,很多人卡在 selector 和信号处理上,三周过去还没抓到一条干净数据。requests 加 XPath 直白到可以逐行讲:发请求、解析 HTML、取节点,每一步都能在黑板上画出来。等毕设答辩完,想上 Scrapy 或分布式爬虫,只要把 parse 函数平移过去就行,这个后路留得住。
后端选 Django,是因为比价系统天生就是“一堆商品、一堆价格、一堆平台”的关联查询,Django 的 ORM、Admin 后台、迁移工具能省掉大量重复 CRUD。Flask 轻,但用户管理、后台编辑、数据统计都要自己拼,时间成本不划算。前端选 Vue 的理由更简单:组件化适合拆页面,配 ECharts 画价格走势图几乎是现成的。整套数据流是:定时任务触发爬虫脚本 → requests 抓公开页面 → XPath 提取标题和价格 → 清洗归一化 → Django ORM 写入数据库 → DRF 暴露 JSON 接口 → Vue 页面拉接口渲染比价列表和趋势图。
2.2 四张核心表:价格记录别只想着“一张大表”
比价系统最忌讳把商品和价格塞进一张表。商品是相对稳定的,价格每天都在变,混在一起会让查询越来越慢,历史曲线也画不出来。常见的做法是拆成四张表:Product 存归一化后的商品,Platform 存平台信息,ProductSku 存“某个平台上某个商品”的关系,PriceRecord 存每一次价格快照。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| Product | spu_code(唯一)、name、brand、category、latest_min_price | spu_code 是清洗后生成的商品指纹 |
| Platform | name、base_url | 平台A、平台B,爬虫按平台名分流 |
| ProductSku | product(外键)、platform(外键)、platform_pid、url、current_price、last_seen_at | platform_pid 是平台自己的商品ID,做去重键 |
| PriceRecord | sku(外键)、price、recorded_at、source_title | 每一条都是历史快照,不能只留当前价 |
spu_code 是全系统的关键。它由“品牌 + 型号 + 关键规格”洗成统一指纹,比如 iPhone 15 256G 在平台A叫“Apple iPhone 15 256GB”,平台B叫“苹果15 256G 全网通”,清洗后都归到同一个 spu_code,比价才有意义。表结构定好之后,后续所有代码都是围绕这张表转,改表比改代码痛苦得多,提前想清楚。
2.3 项目目录怎么摆:爬虫与 Django 要不要放一个 app
建项目时,很多人习惯把爬虫写进 Django 的 view 里。这不是不能用,但耦合太重:爬虫是脚本,接口是服务,混在一起部署和调试都别扭。我一般会建两个 app,一个叫 products 管理商品和价格API,一个叫 spider 只放采集脚本,spider 不进 DRF 路由,通过 manage.py command 或独立脚本触发。
price_compare/ ├── manage.py ├── config/ # Django 项目配置 │ ├── settings.py │ └── urls.py ├── products/ # 商品、价格模型与 API │ ├── models.py │ ├── serializers.py │ └── views.py ├── spider/ # 采集脚本,不对外暴露 │ ├── collectors/ │ │ ├── platform_a.py │ │ └── platform_b.py │ ├── clean.py # 清洗与指纹 │ └── run.py # 手动执行入口 └── frontend/ # Vue 工程(独立目录) └── src/这样拆的好处是:爬虫挂掉不影响接口服务,接口被刷也不影响采集任务。目录结构定了,前后端联调时接口路径只需要对着 products 的 urls 看,不用在 Spider 的脚本里找接口。
2.4 定时更新与增量策略:第一次全量,之后只抓变动
数据流水线稳定性的第一关是“别每次都从头抓到尾”。第一次跑全量是没办法的,之后要做增量。两个策略配合:一是爬虫侧记录每个 platform_pid 的最后抓取时间,本次只抓有“更新时间”或“销量变化”的条目;二是价格写入侧做去重——当前价格与上次一致时,只更新 ProductSku.last_seen_at,不写新的 PriceRecord。价格变化了才插入一条新记录,这样短期波动不会把数据库撑爆。
Django 侧配合很简单,用 queryset 的 update 就能完成,ProductSku.objects.filter(id=sku_id).update(last_seen_at=now)。注意这里用 update 而不是先查再 save,因为 update 是直接 SQL 更新,不会触发模型的 save 方法,性能好很多;如果你依赖 save 里的自定义逻辑,才需要走对象实例。定时任务用系统 crontab 或者 APScheduler 都行,毕设阶段建议 crontab,少一个常驻进程,部署省心。
3. 爬虫落地的第一版:requests 加 XPath 抓价格,附清洗代码
3.1 目标平台怎么选:公开页面优先,别给自己挖坑
爬虫写得再漂亮,目标选错了也白搭。常见做法是选两个公开、无登录墙、无强验证码的站点——比如图书、数码产品类的公开目录页。不要碰需要登录后可见价格的平台,也不要碰价格藏在接口加密参数里的站点,那不是毕设阶段该啃的硬骨头。
合规边界要提前说清楚:只抓 robots.txt 允许的路径,控制请求频率,不抓个人隐私页面,不做商业转售。比价系统在毕设场景里的定位是技术演示,数据量控制在几百条商品就够跑通闭环。遵守频率限制还有一个实际好处——不容易被对方封 IP,后端能稳定抓到答辩那天。
3.2 第一版抓取代码:requests 会话、XPath 提取、text() 的坑
先写一个最小可用的采集脚本,只抓列表页的标题和价格。requests 加 lxml 足够,不需要上浏览器渲染。下面这段是完整的抓取函数:请求头、超时、编码探测都做了,解析部分用 XPath 定位商品卡片节点。
# spider/collectors/base.py import time import requests 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 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_page(url: str) -> str: """抓取页面 HTML,返回字符串。""" session = requests.Session() # 复用连接,减少握手 resp = session.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() # 4xx/5xx 直接抛异常 resp.encoding = resp.apparent_encoding # 避免中文乱码 return resp.text这个函数有三个注意点。Session 复用连接池,抓几十页比反复新建连接快不少;resp.encoding = resp.apparent_encoding是因为部分站点不声明 charset,不设置的话中文直接变问号;timeout=10必须给,不然某个页面卡住,整个采集任务就挂在那一页上。
接下来是解析。商品列表页结构一般是:外层卡片节点循环,卡片内分别取标题、价格、链接。用 XPath 的text()方法取节点文本,注意//span/text()只取直接子文本,如果价格标签里包了嵌套节点,要用.//span//text()再拼接。
def parse_list(html: str): """从列表页 HTML 中提取商品标题与价格。""" root = etree.HTML(html) items = [] # 假设每个商品卡片的 CSS 类名是 item-card for card in root.xpath('//div[contains(@class, "item-card")]'): # .// 表示从当前节点往下找,text() 取直接子文本 title_node = card.xpath('.//a[contains(@class, "title")]//text()') price_node = card.xpath('.//span[contains(@class, "price")]//text()') if not title_node or not price_node: continue # 宁可跳过,也别塞脏数据 title = "".join(title_node).strip() price_text = "".join(price_node).strip() items.append({"title": title, "price_text": price_text}) return items这里最容易翻车的是text()只取一层。标题标签里嵌套了<span>的时候,//a[@class="title"]/text()返回空列表,换成//a[contains(@class,"title")]//text()才能拿到全部子文本。另一个坑是缩进和换行会被一并取进来,strip()必加,遇到空节点直接continue跳过,数据宁可少一条也不能错。
3.3 清洗与商品指纹:价格转数字、标题归一化
抓回来的原始数据长这样:标题="Apple iPhone 15 256GB 蓝色 全网通5G",价格="¥6,499.00"。直接存库的话,PriceRecord 里的 price 是字符串,排序和画图全废。清洗要做三件事:价格转 float、标题去噪、生成 spu_code 指纹。
# spider/clean.py import re import hashlib def clean_price(raw: str) -> float: """把 ,¥、¥、空格去掉,转成 float。""" cleaned = re.sub(r"[¥¥,\s]", "", raw) try: return round(float(cleaned), 2) except ValueError: return 0.0 # 异常价格置 0,入库前再过滤 def normalize_title(title: str) -> str: """去空白、统一括号、提取品牌与型号关键词。""" title = re.sub(r"\s+", " ", title).strip() title = title.replace("(", "(").replace(")", ")") return title def make_spu_code(brand: str, model: str, spec: str) -> str: """品牌 + 型号 + 核心规格,拼指纹。""" raw = f"{brand}|{model}|{spec}".lower() raw = re.sub(r"[^a-z0-9|]", "", raw) return hashlib.md5(raw.encode()).hexdigest()[:16]clean_price 里置 0.0 是个防御动作,后面入库前还要过滤一次price > 0,防止脏数据直接进比价表。make_spu_code 把品牌、型号、规格拼起来再取 MD5 前 16 位,是为了让同款商品在不同平台产生相同指纹——平台A和平台B都用这个函数处理,只要关键词提取一致,spu_code 就一致。
这里要提一句规格提取。只靠整段标题做指纹,iPhone 15 256G和iPhone 15 128G会被识别成两个商品,这没问题;但如果两个平台一个写“256G”一个写“256GB”,常规做法是先做同义词映射再生成指纹,映射表放在 spider/clean.py 里集中维护,别散落在各处。毕设阶段维护几十个同义词就够了。
3.4 限速与断点续抓:别让爬虫跑成 DDoS
写完抓取和清洗,还得管住请求节奏。两个平台交替抓取时,每个请求之间 sleep 一个随机间隔,模拟人工浏览节奏。
# spider/run.py import time import random from collectors.base import fetch_page from clean import clean_price, normalize_title def run(platform: str, urls: list): seen = set() # 已抓过的 URL,断点续抓用 for url in urls: if url in seen: continue try: html = fetch_page(url) items = parse_list(html) # 处理 items:清洗、生成指纹、写库 except Exception as exc: print(f"[warn] 抓取失败: {url} -> {exc}") continue finally: seen.add(url) time.sleep(random.uniform(1.5, 3.5))sleep 区间我一般取 1.5 到 3.5 秒,太快容易被封,太慢跑完要等半天。seen 集合只是内存去重,进程一重启就丢;想做得严谨一点,可以把已抓 URL 存到 SQLite 或 redis,毕设阶段用集合够用。抓取失败只打日志不中断整个任务,这是爬虫能跑上一个月的前提——失败的是个别页面,不是整轮任务。
4. 用 Django 组装价格历史 API,Vue 侧用趋势图完成闭环
4.1 先把 Django 项目建起来:两个 app 与迁移
Django 侧从零开始,顺序很重要。先建项目、再建 app、最后写模型,每一步都有一个检查点。命令就是热词里常搜到的那几条:django-admin startproject、python manage.py startapp products。注意 products 负责模型和 API,spider 只是脚本集,两个 app 职责不混。
mkdir price_compare && cd price_compare django-admin startproject config . python manage.py startapp products python manage.py startapp spider# products/models.py from django.db import models class Product(models.Model): spu_code = models.CharField(max_length=64, unique=True) name = models.CharField(max_length=255) brand = models.CharField(max_length=64) latest_min_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) created_at = models.DateTimeField(auto_now_add=True) class Platform(models.Model): name = models.CharField(max_length=32, unique=True) base_url = models.URLField() class ProductSku(models.Model): product = models.ForeignKey(Product, related_name="skus", on_delete=models.CASCADE) platform = models.ForeignKey(Platform, related_name="skus", on_delete=models.CASCADE) platform_pid = models.CharField(max_length=128) url = models.URLField() current_price = models.DecimalField(max_digits=10, decimal_places=2) last_seen_at = models.DateTimeField(auto_now=True) class Meta: unique_together = ("platform", "platform_pid") class PriceRecord(models.Model): sku = models.ForeignKey(ProductSku, related_name="records", on_delete=models.CASCADE) price = models.DecimalField(max_digits=10, decimal_places=2) recorded_at = models.DateTimeField() source_title = models.CharField(max_length=255, blank=True) class Meta: index_together = [("recorded_at", "price")]创建 app 后第一件事是去 settings.py 的 INSTALLED_APPS 里把 products 和 spider 加进去,然后跑python manage.py makemigrations products和python manage.py migrate。模型字段要注意两个点:价格用 DecimalField 而不是 FloatField,浮点误差在比价场景里会直接体现在排序上;ProductSku 的 unique_together 保证同一平台同一商品不会重复入库。Django 执行查询后如果发现数据不对,比如要删除某平台过期商品,直接.filter(platform=xxx).delete()就行,但要注意 queryset 的 delete 不会触发每个模型实例的 delete 方法,有联动逻辑时先查出来再遍历删。
4.2 把比价结果暴露成接口:DRF 序列化与视图
模型建好之后,前端要拿数据,走 DRF 最省事。列表页需要看到:商品名、各平台最低价、最低价来自哪个平台、最近更新时间。详情页需要看到:该商品在各平台的价格历史,用来画曲线。
# products/serializers.py from rest_framework import serializers from .models import Product, ProductSku class SkuSerializer(serializers.ModelSerializer): platform_name = serializers.CharField(source="platform.name", read_only=True) class Meta: model = ProductSku fields = ["id", "platform_name", "url", "current_price", "last_seen_at"] class ProductSerializer(serializers.ModelSerializer): skus = SkuSerializer(many=True, read_only=True) min_price = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True) class Meta: model = Product fields = ["id", "name", "brand", "spu_code", "min_price", "skus"]视图层用 ModelViewSet 加一个自定义 action,前端调/api/products/rank/拿按最低价排序的商品列表,调/api/products/<id>/trend/拿某个商品的价格历史。
# products/views.py from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response from django.db.models import Min from .models import Product, PriceRecord from .serializers import ProductSerializer class ProductViewSet(viewsets.ModelViewSet): queryset = Product.objects.prefetch_related("skus") serializer_class = ProductSerializer @action(detail=False, methods=["get"]) def rank(self, request): qs = Product.objects.annotate( min_price=Min("skus__current_price") ).order_by("min_price") page = self.paginate_queryset(qs) return self.get_paginated_response( ProductSerializer(page, many=True).data ) @action(detail=True, methods=["get"]) def trend(self, request, pk=None): product = self.get_object() sku_ids = product.skus.values_list("id", flat=True) records = (PriceRecord.objects .filter(sku_id__in=sku_ids) .order_by("recorded_at")) data = [{ "platform": r.sku.platform.name, "price": float(r.price), "recorded_at": r.recorded_at.strftime("%Y-%m-%d %H:%M"), } for r in records] return Response(data)rank 接口用annotate(min_price=Min(...))做聚合排序,一次查询出最低价,避免在 Python 里手写比较逻辑。trend 接口把 PriceRecord 全部返回,前端根据自己的时区渲染;这里strftime已经把时间转成字符串,后续时区问题就好处理了。DRF 的路由在 urls.py 里注册router.register("products", ProductViewSet)即可。
4.3 Vue 侧环境与接口联调:proxy 转发和 axios 封装
Vue 环境用 npm 创建,安装 axios 和 echarts。这里最常踩坑的是跨域——Vue 开发服务器跑在 5173,Django 跑在 8000,直接请求必定跨域。常见做法是在 vite.config.js 里配 proxy,把所有/api开头请求转发到 Django。
// frontend/vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true, } } } })proxy 配好之后,前端写接口路径时统一写/api/products/rank/,不要写完整 URL。下面是 axios 封装,把 baseURL 固定为/api,超时设 15 秒,后端要是有登录鉴权,请求头里再带 token 即可。
// frontend/src/api/index.js import axios from 'axios' const client = axios.create({ baseURL: '/api', timeout: 15000 }) export function fetchRank() { return client.get('/products/rank/') } export function fetchTrend(productId) { return client.get(`/products/${productId}/trend/`) }4.4 路由与价格趋势图:ECharts 让比价结果看得见
Vue 侧建两个页面:商品列表页和商品详情页,路由配置就用热词里常搜的 vue-router 基础写法。列表页展示商品名、最低价、平台来源,点击进入详情页。详情页的核心不是表格,而是 ECharts 价格趋势图——这是整个系统最直观的亮点。
// frontend/src/views/ProductDetail.vue 关键部分 import * as echarts from 'echarts' import { fetchTrend } from '../api' async function loadTrend(productId) { const { data } = await fetchTrend(productId) const byPlatform = {} for (const r of data) { if (!byPlatform[r.platform]) byPlatform[r.platform] = [] byPlatform[r.platform].push([r.recorded_at, r.price]) } const option = { title: { text: '各平台价格走势' }, tooltip: { trigger: 'axis' }, legend: { data: Object.keys(byPlatform) }, xAxis: { type: 'time' }, yAxis: { type: 'value', scale: true }, series: Object.entries(byPlatform).map(([name, points]) => ({ name, type: 'line', showSymbol: false, smooth: true, data: points })) } return option }这段把接口返回的扁平价格记录按平台分组,每个平台生成一条折线。xAxis 用type: 'time',数据直接用"2025-01-01 10:00"字符串,ECharts 会自动解析。yAxis 加scale: true,当价格波动很小时曲线不会贴着 0 轴,视觉对比更明显。到这里,前后端闭环已经通了:爬虫写入数据,Django 聚合成接口,Vue 渲染出列表和曲线。
5. 比价系统最常见的五个翻车点与排查记录
5.1 爬虫返回空列表:改版、验证页、乱码三连击
现象:列表页抓取函数跑完,items 是空列表,或者全部被 continue 跳过。这是爬虫新手第一周的标配翻车现场。原因通常有三种:目标页在维护期改版,CSS 类名变了,XPath 失效;服务器做了简单的反爬,返回的是验证页或空白页,不是商品页;页面编码没设置对,中文全成乱码,XPath 匹配不到节点。
解决:先把html落盘,用浏览器打开文件看真实结构。with open("debug.html", "w") as f: f.write(html),然后用浏览器开发者工具重新定位类名。接着检查resp.status_code和resp.text[:200],确认拿到的确实是目标列表页。编码问题在前面已经用 apparent_encoding 兜底了,一般不会到这一步。
提示:调试 XPath 别靠猜。把 HTML 存到文件,在浏览器控制台用
$x()试表达式,试通了再写进代码,效率高一倍以上。
5.2 价格走势图时间差八小时:时区把曲线整乱了
现象:前端 ECharts 画出来的价格曲线,横轴时间和实际对不上,或者某天的价格点落到了另一天。原因:Django 默认USE_TZ=True,模型里的 DateTimeField 存的是 UTC 时间;前端浏览器和 ECharts 默认用本地时区展示,如果直接把带Z后缀的 ISO 字符串传给前端,时间就会偏移 8 小时。
解决:在后端序列化时就把时间格式化成不含时区信息的本地时间字符串。我在 trend 接口里用strftime("%Y-%m-%d %H:%M")就是为了让前端不再二次转时区。如果你想在 DRF 全局统一,就在 serializer 的 DateTimeField 上指定format="%Y-%m-%d %H:%M"。前端拿到的就是已经格式化好的字符串,ECharts 直接按本地时间处理,曲线就和实际一致了。
5.3 两个平台价格相同却是不同规格:比价表成了误导表
现象:平台A和平台B都显示 6499,但一个是 256G,一个是 512G,放在同一行里比价毫无意义。原因:spu_code 只用了品牌加型号,没把容量、版本这类关键规格纳进去。毕设答辩时被问“这两台机器配置一样吗”,答不上来场面会很尴尬。
解决:在生成 spu_code 前,先用正则把容量、年份等关键规格提取出来。针对数码产品,常见做法是维护一组关键词正则:容量(128G/256G/512G/1T)、版本(标准版/Pro/Ultra)、颜色不参与指纹。make_spu_code里把 spec 字段带上,这样 256G 和 512G 会生成不同的 spu_code,各行其道,不再串味。规格提取逻辑写在 spider/clean.py 一个函数里,维护和答辩讲解都方便。
5.4 PriceRecord 价格记录爆炸:两周跑出十万条废数据
现象:每天定时抓一次,数据库里同一个商品同一价格重复了十几条,历史曲线被拉得又长又平,查询越来越慢。原因:写入逻辑是“抓到就 insert”,没有对比价格是否变化。这是增量策略没落到位的最典型后果。
解决:写入前先查该 sku 的最新记录,价格相同就只更新 last_seen_at,价格变了才插入 PriceRecord。Django 里可以这样写:
# spider/write.py latest = (PriceRecord.objects .filter(sku=sku) .order_by("-recorded_at") .first()) if latest is None or latest.price != current_price: PriceRecord.objects.create(sku=sku, price=current_price, recorded_at=timezone.now()) else: sku.last_seen_at = timezone.now() sku.save(update_fields=["last_seen_at"])注意这里用的是latest.price != current_price,价格如果是 DecimalField,直接比相等是可靠的。查询最新记录用order_by("-recorded_at")加first(),比filter(...).last()语义更明确。如果还想更稳,可以在 PriceRecord 表上加唯一约束unique_together=(sku, recorded_at),同一次采集只写一条,彻底防止重复。
5.5 自己做的接口被别人脚本循环抓:前端防爬的思路也要懂
现象:Django 服务跑了一周,突然 CPU 飙高,数据库连接被占满,查看 access log 发现同一个 IP 在疯狂刷接口。原因:DRF 接口默认没有任何限流,谁拿到路径都能调用。热度关键词里常看到“前端如何防止爬虫”“java controller 层如何防护”,这套思路在后端同样适用,而且比价系统的数据就是你爬来的,防别人爬自己是必修课。
解决:分三步,难度从低到高。第一步,DRF 配置限流,给匿名用户加AnonRateThrottle,把频率限制在每分钟 60 次以内。第二步,给接口加简单 Token 鉴权,前端登录后带着 token 请求,匿名请求直接 401。第三步,前端不要把敏感数据平铺在源码里,详情数据一律走后端接口按需下发,不要为了省事把整库数据打成静态 JSON 放进前端工程——这样等于把比价结果白送别人。毕设阶段做到前两步已经够讲。
注意:爬虫抓取时要遵守目标站点的频率限制,自己暴露接口时同样要限流。做了采集又做防采集,这一对矛盾在答辩时反而是加分项,说明你想到了全链路。
6. 让价格曲线成为答辩亮点:从批量入库到冒烟验证
毕设评审老师大概率会问三个问题:比价结果怎么保证最新、价格趋势怎么算的、接口靠不靠谱。前两个靠第 3 章和第 4 章的代码回答,最后一个靠一段简单的测试代码回答。写一个 SmokeTest,用 Django 的 TestCase 直接打接口,不依赖前端,验证 rank 和 trend 两个接口返回正常。
# products/tests.py from rest_framework.test import APITestCase from django.utils import timezone from .models import Product, Platform, ProductSku, PriceRecord class SmokeTest(APITestCase): def setUp(self): p = Product.objects.create(spu_code="abc123", name="测试商品", brand="Test") pf = Platform.objects.create(name="平台A", base_url="https://a.example") sku = ProductSku.objects.create(product=p, platform=pf, platform_pid="P001", url="https://a.example/1", current_price=99.9) PriceRecord.objects.create(sku=sku, price=99.9, recorded_at=timezone.now()) def test_rank_returns_200(self): resp = self.client.get("/api/products/rank/") self.assertEqual(resp.status_code, 200) self.assertGreater(len(resp.data["results"]), 0) def test_trend_returns_records(self): resp = self.client.get("/api/products/1/trend/") self.assertEqual(resp.status_code, 200) self.assertGreaterEqual(len(resp.data), 1)这组测试的价值在于:答辩现场你不需要打开浏览器演示,跑一遍python manage.py test就能说明接口是通的,数据写入是有效的。这比现场点页面更有说服力。
再看一个让曲线更“高级”的补充算法:在 trend 接口里加一个“近 30 天均价”的返回字段,用 Django ORM 的聚合一次算出来。
from django.db.models import Avg from datetime import timedelta avg_price = (PriceRecord.objects .filter(sku_id__in=sku_ids, recorded_at__gte=timezone.now() - timedelta(days=30)) .aggregate(avg=Avg("price"))["avg"])前端拿到 avg_price 后,在图表上画一条横向虚线,标注“近30天均价”,用户一眼就能看出当前价格是高于还是低于平均线。这个交互成本极低,但视觉上是整套系统最抢眼的地方。
我自己的习惯是,把 SmokeTest 放在最前面写,而不是最后补。先写测试再写代码,至少能保证每一轮改动没把接口弄坏。以前我也是一股脑把价格全部插入数据库,图表画出来密密麻麻,后来改成“价格变动才记录”之后,数据量小了一个量级,曲线也干净了。如果说这题有什么后悔药,就是表结构和写入策略在动手前先花半天想清楚,别等到跑了半个月再回头改。希望帮到你——按这个路径把爬虫、Django、Vue 三段串起来,你的比价系统也能稳稳跑到答辩那天。
本文还有配套的精品资源,点击获取