☰
电商比价系统实战:Python爬虫+Django+Vue全栈开发与价格归一化
2026/10/11 11:23:24 网站建设 项目流程

简介:这份资源是面向计算机相关专业学生与开发者的电商比价系统完整源码,采用Python爬虫采集商品数据,后端基于Django、前端基于Vue构建,可作为期末课程设计、大作业或毕业设计参考,帮助解决从数据抓取到前后端联调的整站实现问题。压缩包共660个文件,约6.71MB,其中js、html、css等前端资源占比较大,配合png、gif、jpg等图片素材与json配置,另有25个py文件承载爬虫与Django业务逻辑,整体结构完整、层次清晰。目前已有455人学习下载,项目经导师评审并获96分以上成绩,且经过严格调试可正常运行。读者可据此掌握爬虫调度、比价逻辑、接口设计与Vue页面渲染的完整链路,理解前后端分离项目的目录组织与依赖配置,并借鉴其排错思路与模块划分方式,快速搭建自己的比价类应用。

1. 电商比价系统到底在比什么:从三个价格字段说起

很多人第一次做电商比价系统,脑子里想的是“爬遍全网、实时比价”,真动手才发现,比价的核心根本不是爬虫写得多花哨,而是同一件商品在不同平台上的价格,怎么被对齐到同一条记录里。我在一个毕业设计级别的项目里见过最典型的翻车:爬虫把某平台“¥199.00”和另一平台“199元起”当成两个价格存进数据库,前端一展示,用户看到同一款耳机差了一百块,其实是“起”字在作怪。

这个标题拆开看,技术栈是 Python 爬虫 + Django 后端 + Vue 前端,业务是电商比价。它适合两类人:一类是正在找毕业设计题目的同学,需要一套能跑通、能讲清楚、能答辩的系统;另一类是刚接触 Django 全栈的开发者,想拿一个真实业务场景练手。比价系统的好处是业务边界清晰——采集、清洗、存储、展示、比价,五步走完就是一个闭环,不像推荐系统那样玄学。

但我要先泼一盆冷水:比价系统的难点不在 Django 和 Vue,而在数据采集的稳定性和价格归一化。Django 和 Vue 只是把脏活累活的结果呈现出来。如果你把 70% 的精力花在前端页面上,最后答辩时老师问一句“你爬虫怎么处理反爬的”,场面会很难看。所以这篇笔记的节奏是:先把数据链路讲透,再落到 Django 模型和 Vue 展示,最后给一套能复现的最小实现。

2. 爬虫层怎么设计:从目标站点到结构化价格

2.1 选目标站点:先看 robots.txt 再谈采集

做毕业设计,第一件事不是写代码,是选站点。我的建议是:优先选有公开商品列表页、结构稳定、对爬虫容忍度高的站点。不要一上来就盯着某大型综合电商,那类站点的反爬策略足够写另一篇论文。常见做法是选一个垂直品类站点,比如图书、数码配件、办公用品,商品结构统一,价格字段明确。

选站点的三个检查项:

检查项合格标准不合格的表现
列表页分页URL 带 page 参数,规律明显滚动加载且参数加密
详情页价格价格在 HTML 源码里,非 JS 渲染价格由接口异步返回
robots.txt未禁止抓取商品页明确禁止 /product/ 路径

如果详情页价格是 JS 渲染的,你有两个选择:一是用 Selenium 或 Playwright 模拟浏览器,二是直接找它背后的价格接口。前者慢但稳,后者快但接口可能带签名。毕业设计级别,我一般建议用 requests + BeautifulSoup 先跑通静态站点,把整个链路走通,再考虑加浏览器渲染。

2.2 写一个能跑的最小爬虫:requests + BeautifulSoup

下面这段代码是一个最小可用的商品列表爬虫,目标是把列表页里的商品标题、价格、详情页链接抓下来。注意看注释里的参数含义。

import requests from bs4 import BeautifulSoup import time import random # 请求头:模拟浏览器,缺少 User-Agent 很多站点直接返回 403 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(base_url, page_num): """抓取一页商品列表,返回商品字典列表""" url = f"{base_url}?page={page_num}" try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding # 防止中文乱码 except requests.RequestException as e: print(f"[失败] 第 {page_num} 页:{e}") return [] soup = BeautifulSoup(resp.text, "html.parser") items = [] # 这里的 class 名要换成目标站点实际的 class for card in soup.select(".product-card"): title = card.select_one(".product-title") price = card.select_one(".product-price") link = card.select_one("a") if not (title and price and link): continue items.append({ "title": title.get_text(strip=True), "price_text": price.get_text(strip=True), "detail_url": link.get("href"), }) return items if __name__ == "__main__": BASE = "https://example-shop.test/list" all_items = [] for p in range(1, 4): # 先抓 3 页试水 all_items.extend(fetch_list_page(BASE, p)) time.sleep(random.uniform(1.5, 3.0)) # 随机间隔,降低被封风险 print(f"共抓取 {len(all_items)} 条")

这段代码的逻辑说明:HEADERS里的User-Agent是必须的,很多站点对空 UA 直接拒绝;timeout=10防止某个请求卡死整个脚本;resp.apparent_encoding是根据内容猜测编码,比直接写utf-8更稳;time.sleep(random.uniform(1.5, 3.0))是随机间隔,固定间隔容易被识别为爬虫。

参数怎么改:base_url换成你选的目标站点列表页地址;.product-card、.product-title、.product-price这三个选择器,用浏览器开发者工具在目标页面上右键“检查”,找到对应元素的 class 名替换。如果目标站点用的是 id 或 data 属性,选择器写法相应调整。

2.3 价格归一化:把“¥199.00”变成 199.00

爬下来的price_text是字符串,可能是“¥199.00”、“199元”、“199.00 起”、“促销价:199”。直接存字符串,后面比价没法算。必须做归一化。

import re from decimal import Decimal def normalize_price(raw): """把各种价格文本转成 Decimal,失败返回 None""" if not raw: return None # 去掉货币符号、中文、空格,只保留数字和小数点 cleaned = re.sub(r"[^\d.]", "", raw) # 处理多个小数点的情况,比如 "199.00.00" parts = cleaned.split(".") if len(parts) > 2: cleaned = parts[0] + "." + parts[1] try: value = Decimal(cleaned) # 过滤明显异常的价格,比如 0 或超过 100000 if value <= 0 or value > 100000: return None return value except Exception: return None

逻辑说明:re.sub(r"[^\d.]", "", raw)把非数字和非小数点的字符全部删掉,这样“¥199.00”变成“199.00”,“199元”变成“199”。Decimal比float更适合存价格,避免浮点误差。异常值过滤是必要的,有些站点会把“暂无报价”渲染成“0”,或者把“999999”当占位符。

参数说明:value > 100000这个上限根据你的品类调整,数码产品可以设到 50000,图书设到 500 就够。下限value <= 0基本通用。

3. Django 后端:模型设计与比价逻辑

3.1 三张表搞定商品、平台、价格记录

Django 的模型设计决定了后面比价查询好不好写。我见过有人把价格直接存在商品表里,结果同一商品多个平台的价格互相覆盖,比价功能直接废掉。正确做法是拆成三张表:商品表、平台表、价格记录表。

from django.db import models class Platform(models.Model): """电商平台,比如某垂直商城 A、某垂直商城 B""" name = models.CharField(max_length=50, unique=True) base_url = models.URLField() created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.name class Product(models.Model): """商品,用标题+品牌做归一化标识""" title = models.CharField(max_length=255, db_index=True) brand = models.CharField(max_length=100, blank=True) category = models.CharField(max_length=100, blank=True) normalized_key = models.CharField(max_length=255, db_index=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.title class PriceRecord(models.Model): """某商品在某平台某时刻的价格""" product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="prices") platform = models.ForeignKey(Platform, on_delete=models.CASCADE) price = models.DecimalField(max_digits=10, decimal_places=2) detail_url = models.URLField(max_length=500) captured_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ models.Index(fields=["product", "platform", "-captured_at"]), ]

逻辑说明:normalized_key是比价的关键字段,它由商品标题去掉空格、标点、促销词后生成,用来判断两个平台的商品是不是同一件。PriceRecord用外键关联商品和平台,每次采集插一条新记录,保留历史价格,这样后面可以画价格走势。Meta.indexes里的复合索引是为了加速“查某商品最新价格”这个高频查询。

参数说明:max_digits=10, decimal_places=2表示最大 99999999.99,够用。detail_url设max_length=500,有些站点 URL 带一堆参数,短了会截断。

3.2 比价查询:一条 ORM 拿到各平台最新价

比价的核心查询是:给定一个商品,拿到它在各平台的最新价格,按价格排序。用 Django ORM 可以这样写:

from django.db.models import OuterRef, Subquery from .models import Product, PriceRecord def get_latest_prices(product_id): """返回某商品在各平台的最新价格,按价格升序""" latest = PriceRecord.objects.filter( product_id=product_id, platform=OuterRef("platform") ).order_by("-captured_at") records = PriceRecord.objects.filter( product_id=product_id ).filter( captured_at=Subquery(latest.values("captured_at")[:1]) ).select_related("platform").order_by("price") return [ { "platform": r.platform.name, "price": float(r.price), "url": r.detail_url, "captured_at": r.captured_at.strftime("%Y-%m-%d %H:%M"), } for r in records ]

逻辑说明:OuterRef和Subquery配合,先按平台分组取每个平台最新的一条记录,再在外层过滤出这些记录。select_related("platform")避免 N+1 查询,一次 SQL 拿到平台名。order_by("price")让最便宜的排前面,前端直接展示。

参数说明:latest.values("captured_at")[:1]里的[:1]是取最新一条,不能省,否则子查询返回多行会报错。如果数据量大,可以在PriceRecord上加captured_at的索引。

3.3 用 Django 管理后台做数据审核

毕业设计答辩时,老师很可能问“你怎么保证爬下来的数据是对的”。这时候 Django admin 就是你的后悔药。把三个模型注册到 admin,加几个 list_display 和 search_fields,就能在后台快速抽查。

from django.contrib import admin from .models import Platform, Product, PriceRecord @admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display = ("title", "brand", "category", "created_at") search_fields = ("title", "normalized_key") list_filter = ("category",) @admin.register(PriceRecord) class PriceRecordAdmin(admin.ModelAdmin): list_display = ("product", "platform", "price", "captured_at") list_filter = ("platform", "captured_at") search_fields = ("product__title",) date_hierarchy = "captured_at"

逻辑说明:list_display控制列表页显示哪些字段,search_fields支持按标题搜索,date_hierarchy在价格记录页顶部加一个日期导航,方便按天抽查。这些配置不写也能跑,但答辩演示时能省很多解释时间。

4. Vue 前端:比价结果怎么展示才不挨骂

4.1 用 axios 拿数据,别在模板里写请求

Vue 前端最容易犯的错是把请求写在模板的插值里,或者每个组件各自请求一遍。正确做法是统一在api目录下封装请求,组件只负责展示。

// src/api/price.js import axios from 'axios' const api = axios.create({ baseURL: '/api', timeout: 8000, }) export function fetchProductPrices(productId) { return api.get(`/products/${productId}/prices/`) } export function fetchProductList(params) { return api.get('/products/', { params }) }

逻辑说明:axios.create创建一个实例,统一配置baseURL和超时。fetchProductPrices对应 Django 那边的比价查询接口。组件里只调这两个函数,不直接写 URL。

参数说明:timeout: 8000是 8 秒,比 Django 默认的请求超时短,避免前端一直转圈。params用于传分页和搜索关键词。

4.2 比价表格:把最低价标出来

比价结果展示的核心是让用户一眼看到哪个平台最便宜。用一个表格,价格列加条件样式。

<template> <table class="price-table"> <thead> <tr> <th>平台</th> <th>价格</th> <th>采集时间</th> <th>操作</th> </tr> </thead> <tbody> <tr v-for="(item, index) in prices" :key="item.platform"> <td>{{ item.platform }}</td> <td :class="{ lowest: index === 0 }"> ¥{{ item.price.toFixed(2) }} </td> <td>{{ item.captured_at }}</td> <td> <a :href="item.url" target="_blank" rel="noopener">查看</a> </td> </tr> </tbody> </table> </template> <script> import { fetchProductPrices } from '@/api/price' export default { data() { return { prices: [] } }, async created() { const productId = this.$route.params.id const { data } = await fetchProductPrices(productId) this.prices = data }, } </script> <style scoped> .lowest { color: #e4393c; font-weight: bold; } </style>

逻辑说明:index === 0判断最低价,因为后端已经按价格升序返回。toFixed(2)保证价格显示两位小数。target="_blank"加rel="noopener"是安全习惯,防止新页面拿到window.opener。

参数说明:this.$route.params.id从路由拿商品 ID,对应 Django 接口的productId。如果价格数据为空,表格显示空行,可以在tbody里加一个v-if提示“暂无数据”。

4.3 分页与搜索:别让前端一次拿一千条

商品列表页如果一次返回所有商品,页面会卡。Django 那边用分页器,Vue 这边传page和page_size。

# Django 视图 from django.core.paginator import Paginator from django.http import JsonResponse from .models import Product def product_list(request): page = int(request.GET.get("page", 1)) size = int(request.GET.get("page_size", 20)) keyword = request.GET.get("q", "").strip() qs = Product.objects.all().order_by("-created_at") if keyword: qs = qs.filter(title__icontains=keyword) paginator = Paginator(qs, size) page_obj = paginator.get_page(page) data = [ {"id": p.id, "title": p.title, "brand": p.brand} for p in page_obj ] return JsonResponse({ "items": data, "total": paginator.count, "page": page_obj.number, "pages": paginator.num_pages, })

逻辑说明:Paginator是 Django 内置的分页器,get_page会自动处理页码越界。title__icontains是不区分大小写的模糊搜索。返回的 JSON 里带total和pages,前端可以渲染分页控件。

参数说明:page_size默认 20,最大可以设到 100,再大就失去分页意义。q参数为空时不加过滤条件。

5. 避坑与排查:那些让我熬夜的坑

5.1 坑一:爬虫跑着跑着被封 IP

现象:前几页正常,后面全部返回 403 或验证码页面。

原因:请求频率固定、缺少 Referer、Cookie 被识别。很多站点会统计同一 IP 在短时间内的请求数。

解决:把time.sleep改成随机区间,比如random.uniform(2, 5);加上Referer头指向列表页;如果还不行,降低采集频率,或者只采集详情页不采集列表页(详情页 URL 从别处获取)。毕业设计级别,不要硬刚反爬,换个温和的站点更省时间。

5.2 坑二:同一商品被当成两个商品

现象:比价页面上,同一款耳机出现两行,价格一样但平台不同,用户以为系统坏了。

原因:normalized_key生成规则太简单,比如只去掉空格,没去掉“【限时】”“包邮”这类促销词。

解决:在生成normalized_key时,先去掉常见促销词和标点。可以维护一个停用词列表:

STOP_WORDS = ["限时", "包邮", "正品", "旗舰", "新品", "促销", "特价"] def make_normalized_key(title): text = title for w in STOP_WORDS: text = text.replace(w, "") text = re.sub(r"[\s\-_/【】\[\]()()]", "", text) return text.lower()

逻辑说明:先替换停用词,再去掉空格和括号类字符,最后转小写。这样“【限时】某品牌耳机 包邮”和“某品牌耳机”会归到同一个 key。

5.3 坑三:Django 接口返回慢,前端转圈

现象:商品列表页加载超过 5 秒,比价页更慢。

原因:PriceRecord表数据量大,get_latest_prices里的子查询没有走索引;或者select_related漏了。

解决:在PriceRecord的Meta里加复合索引["product", "platform", "-captured_at"];用django-debug-toolbar看 SQL 执行时间;如果还慢,把最新价格缓存到 Redis,设置 5 分钟过期。毕业设计不一定上 Redis,但索引一定要加。

5.4 坑四:Vue 打包后接口 404

现象:开发环境正常,npm run build后部署,接口全部 404。

原因:开发时用proxy转发,打包后没有配置反向代理。

解决:在vue.config.js里配置devServer.proxy只对开发有效,生产环境需要在 Nginx 或 Django 那边配。简单做法是让 Django 同时服务静态文件和 API,把 Vue 打包产物放到 Django 的static目录,用re_path兜底路由。

5.5 坑五:价格归一化把“1999”变成“199.9”

现象:某平台价格显示“1999”,归一化后变成 199.9,比价结果全错。

原因:re.sub(r"[^\d.]", "", raw)对“1999”没问题,但如果原始文本是“1,999”,去掉逗号后是“1999”,没问题;问题出在“1999.00”被误处理,或者某些站点用“.”做千分位。

解决:先判断原始文本里有没有逗号做千分位,如果有,先去掉逗号再处理小数点。更稳妥的做法是:如果字符串里只有一个“.”且后面跟两位数字,当小数点;如果有多个“.”,按最后一个当小数点,前面的当千分位去掉。

6. 进阶技巧:用定时任务和价格走势图把系统做厚

6.1 用 Django management command 做定时采集

手动跑爬虫脚本只能演示,真正让系统“活”起来,需要定时任务。Django 的management command是标准做法,配合系统的 crontab 或django-crontab插件。

# myapp/management/commands/collect_prices.py from django.core.management.base import BaseCommand from myapp.tasks import run_collection class Command(BaseCommand): help = "采集各平台商品价格" def add_arguments(self, parser): parser.add_argument("--platform", type=str, help="只采集指定平台") def handle(self, *args, **options): platform = options.get("platform") count = run_collection(platform) self.stdout.write(self.style.SUCCESS(f"采集完成,新增 {count} 条价格记录"))

逻辑说明:BaseCommand是 Django 自定义命令的基类,add_arguments支持传参,handle是执行入口。run_collection是你封装的采集函数,内部调用爬虫和归一化逻辑。

参数说明:--platform可选,不传就采集所有平台。部署时在 crontab 里加一行0 */6 * * * python manage.py collect_prices,每 6 小时跑一次。

6.2 价格走势图:用 ECharts 展示历史价格

比价系统只展示当前价格,说服力不够。加上历史价格走势,答辩时能多讲五分钟。后端加一个接口返回某商品的价格历史,前端用 ECharts 画折线图。

# Django 视图 from django.http import JsonResponse from .models import PriceRecord def price_history(request, product_id): records = PriceRecord.objects.filter( product_id=product_id ).select_related("platform").order_by("captured_at") data = {} for r in records: data.setdefault(r.platform.name, []).append({ "time": r.captured_at.strftime("%Y-%m-%d %H:%M"), "price": float(r.price), }) return JsonResponse({"series": data})

逻辑说明:按平台分组,每个平台一个数组,前端 ECharts 的series直接对应。setdefault避免重复判断 key 是否存在。

参数说明:captured_at格式化成字符串,ECharts 的 x 轴可以直接用。如果数据点太多,可以在后端按天聚合,取每天最低价。

6.3 一个我常用的验证习惯

每次改完爬虫或归一化逻辑,我不会直接跑全量,而是先跑一个--limit 10的小批量,把结果打印出来肉眼过一遍。具体做法是在采集函数里加一个limit参数,只处理前 N 条,输出title、price_text、normalized_price三列。这个习惯帮我省了很多后悔药——有次发现某平台把“¥199.00”写成“¥199,00”,归一化后变成 19900,如果直接入库,比价结果全乱。

这个习惯的本质是:在数据进入数据库之前,给它一个被看见的机会。爬虫和归一化是黑匣子,你不看中间结果,就不知道里面发生了什么。希望帮到你。

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

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

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

立即咨询