ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电商比价系统实战:Python爬虫+Django+Vue全栈开发与价格归一化

电商比价系统实战:Python爬虫+Django+Vue全栈开发与价格归一化 简介这份资源是面向计算机相关专业学生与开发者的电商比价系统完整源码采用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;q0.9, } def fetch_list_page(base_url, page_num): 抓取一页商品列表返回商品字典列表 url f{base_url}?page{page_num} try: resp requests.get(url, headersHEADERS, timeout10) 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(stripTrue), price_text: price.get_text(stripTrue), 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 直接拒绝timeout10防止某个请求卡死整个脚本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_length50, uniqueTrue) base_url models.URLField() created_at models.DateTimeField(auto_now_addTrue) def __str__(self): return self.name class Product(models.Model): 商品用标题品牌做归一化标识 title models.CharField(max_length255, db_indexTrue) brand models.CharField(max_length100, blankTrue) category models.CharField(max_length100, blankTrue) normalized_key models.CharField(max_length255, db_indexTrue) created_at models.DateTimeField(auto_now_addTrue) def __str__(self): return self.title class PriceRecord(models.Model): 某商品在某平台某时刻的价格 product models.ForeignKey(Product, on_deletemodels.CASCADE, related_nameprices) platform models.ForeignKey(Platform, on_deletemodels.CASCADE) price models.DecimalField(max_digits10, decimal_places2) detail_url models.URLField(max_length500) captured_at models.DateTimeField(auto_now_addTrue) class Meta: indexes [ models.Index(fields[product, platform, -captured_at]), ]逻辑说明normalized_key是比价的关键字段它由商品标题去掉空格、标点、促销词后生成用来判断两个平台的商品是不是同一件。PriceRecord用外键关联商品和平台每次采集插一条新记录保留历史价格这样后面可以画价格走势。Meta.indexes里的复合索引是为了加速“查某商品最新价格”这个高频查询。参数说明max_digits10, decimal_places2表示最大 99999999.99够用。detail_url设max_length500有些站点 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_idproduct_id, platformOuterRef(platform) ).order_by(-captured_at) records PriceRecord.objects.filter( product_idproduct_id ).filter( captured_atSubquery(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)避免 N1 查询一次 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 classprice-table thead tr th平台/th th价格/th th采集时间/th th操作/th /tr /thead tbody tr v-for(item, index) in prices :keyitem.platform td{{ item.platform }}/td td :class{ lowest: index 0 } {{ item.price.toFixed(2) }} /td td{{ item.captured_at }}/td td a :hrefitem.url target_blank relnoopener查看/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加relnoopener是安全习惯防止新页面拿到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__icontainskeyword) 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, typestr, 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_idproduct_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如果直接入库比价结果全乱。这个习惯的本质是在数据进入数据库之前给它一个被看见的机会。爬虫和归一化是黑匣子你不看中间结果就不知道里面发生了什么。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进