简介:这是一套基于Python与Django框架开发的京东商品比价系统,配套request爬虫与数据库,面向计算机专业学生及开发者,可用于毕业设计、课程设计或项目开发练手。系统实现了注册登录、商品收藏、十五天内价格折线图展示、按品类推荐降幅比最大商品以及跳转购买等核心功能,覆盖爬虫采集、数据存储与前端可视化完整链路。资源包共2001个文件,以1174个py源码与368个pyc编译文件为主体,另含128个html模板、88个mo与88个po国际化文件、82个js及19个css样式文件,并附带sql建表脚本与json配置,压缩包约16.2MB,目录结构清晰,便于按模块阅读与二次开发。目前已有75人学习下载。源码经过严格测试,读者可据此掌握Django项目分层设计、request爬虫解析与价格趋势分析思路,并在此基础上扩展比价维度或接入其他电商平台。
1. 从零搭一套京东商品比价系统:为什么我不建议你直接抄现成源码
很多人做毕业设计或课程设计时,第一反应是去搜「基于python+Django实现的京东商品比价系统」的现成源码,下载下来改改就交差。我当年也这么干过,结果答辩时被问「你的爬虫怎么处理反爬」「比价逻辑为什么用这个字段做匹配」,当场卡壳。后来我自己从头搭了一遍,才发现这套系统真正的难点不在Django的增删改查,而在数据采集的稳定性和商品匹配的准确性。
这套系统本质上解决一个问题:同一件商品在京东不同店铺、不同时段的价格差异,用户想一眼看到最低价和历史走势。适合谁?适合正在做毕业设计、课程设计,或者想练手「爬虫+Web全栈」的开发者。你需要会一点Python基础、了解HTTP请求、能装MySQL。整套方案的核心链路是:requests采集商品数据 → 清洗入库 → Django提供接口和页面 → 前端展示比价结果。下面我按实际搭建顺序,把每一步的参数、坑和验证方法讲清楚。
2. 爬虫层怎么设计:requests抓京东商品数据的三个关键决策
2.1 为什么选requests而不是Scrapy
做课程设计时,很多人纠结用requests还是Scrapy。我的判断标准很简单:如果你只需要抓商品搜索列表和详情页这两类页面,requests完全够用,而且代码量少、调试直观。Scrapy适合大规模分布式抓取,但引入的中间件、管道、调度器概念会让毕设的复杂度失控。
具体到京东,商品数据分两块:搜索列表页(含商品名、价格、店铺、评论数)和详情页(含规格参数、历史价格)。搜索列表页的URL结构通常是https://search.jd.com/Search?keyword=关键词&page=页码,详情页是https://item.jd.com/商品ID.html。我一般先用requests抓列表页拿到商品ID列表,再逐个抓详情页。
import requests import time import random 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", "Referer": "https://search.jd.com/", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_search_page(keyword, page=1): """抓取京东搜索列表页,返回HTML文本""" url = "https://search.jd.com/Search" params = { "keyword": keyword, "enc": "utf-8", "page": page * 2 - 1, # 京东页码是奇数递增 } try: resp = requests.get(url, headers=HEADERS, params=params, timeout=10) resp.encoding = "utf-8" if resp.status_code == 200: return resp.text else: print(f"状态码异常: {resp.status_code}") return None except requests.RequestException as e: print(f"请求失败: {e}") return None def random_sleep(): """随机延时,降低被封风险""" time.sleep(random.uniform(1.5, 3.5))这段代码有三个参数值得说。page参数京东用的是奇数序列,第1页传1,第2页传3,所以代码里做了page * 2 - 1的转换。timeout=10是必须的,不设超时遇到慢响应会卡死整个采集流程。random_sleep的区间我调过很多次,1.5到3.5秒是比较稳的,低于1秒连续请求十几次就会触发验证码。
2.2 商品ID提取与详情页解析
拿到搜索列表HTML后,用正则或BeautifulSoup提取商品ID。京东列表页的商品链接格式是//item.jd.com/12345678.html,用正则r'item\.jd\.com/(\d+)\.html'就能批量提取。
import re from bs4 import BeautifulSoup def extract_product_ids(html): """从搜索页HTML中提取商品ID列表""" if not html: return [] pattern = re.compile(r'item\.jd\.com/(\d+)\.html') ids = pattern.findall(html) # 去重并保持顺序 seen = set() unique_ids = [] for pid in ids: if pid not in seen: seen.add(pid) unique_ids.append(pid) return unique_ids def parse_detail_page(html): """解析详情页,提取商品名、价格、店铺""" soup = BeautifulSoup(html, "html.parser") result = {} # 商品名 name_tag = soup.select_one("div.sku-name") result["name"] = name_tag.get_text(strip=True) if name_tag else "" # 价格通常在页面JS变量中,这里用正则兜底 price_match = re.search(r'"p":"([\d.]+)"', html) result["price"] = float(price_match.group(1)) if price_match else 0.0 # 店铺名 shop_tag = soup.select_one("div.J-hove-wrap a.name") result["shop"] = shop_tag.get_text(strip=True) if shop_tag else "" return result这里有个血泪经验:京东详情页的价格不是直接写在HTML标签里的,而是通过JS异步加载。用requests抓到的静态HTML里,价格往往藏在<script>标签的JSON变量中。我一般用正则从脚本里抠,匹配"p":"价格"这个模式。如果正则匹配不到,说明页面结构变了或者被反爬了,这时候要看返回的HTML里有没有验证码相关的内容。
2.3 反爬应对:请求头、代理池和频率控制
京东的反爬不算最狠,但裸奔肯定不行。三个层面要做:请求头伪装、IP轮换、请求频率控制。请求头里User-Agent和Referer是必带的,缺了Referer会被识别为异常请求。IP轮换在毕设环境里可以用免费代理,但稳定性差,我建议课程设计阶段先用单IP+低频策略跑通,答辩时再讲代理池的扩展方案。
频率控制除了random_sleep,还要注意不要并发太高。我试过用concurrent.futures开10个线程同时抓,结果三分钟就被封了。后来改成单线程串行,每抓20个商品休息10秒,稳定跑完500个商品没问题。
提示:采集频率建议控制在每分钟不超过20次请求,连续采集超过200条后主动暂停30秒。这个阈值不是官方文档写的,是我多次测试后总结的经验值。
3. Django数据层与比价逻辑:模型设计和商品匹配的实操细节
3.1 数据库表结构设计
比价系统的核心表有三张:商品表、价格记录表、店铺表。商品表存商品的基本信息,价格记录表存每次采集到的价格(带时间戳),店铺表存店铺信息。这样设计的好处是同一商品在不同时间、不同店铺的价格都能追溯。
# models.py from django.db import models class Shop(models.Model): name = models.CharField(max_length=200, unique=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "shop" class Product(models.Model): jd_id = models.CharField(max_length=50, unique=True, db_index=True) name = models.CharField(max_length=500) category = models.CharField(max_length=100, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "product" class PriceRecord(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="prices") shop = models.ForeignKey(Shop, on_delete=models.CASCADE) price = models.DecimalField(max_digits=10, decimal_places=2) captured_at = models.DateTimeField(auto_now_add=True, db_index=True) class Meta: db_table = "price_record" ordering = ["-captured_at"]jd_id字段加了unique=True和db_index=True,因为比价时频繁按商品ID查询,索引能显著提速。price用DecimalField而不是FloatField,价格计算不能有浮点误差。captured_at加索引是为了按时间范围查历史价格时走索引扫描。
3.2 商品匹配:同名不同店怎么归并
比价系统最头疼的问题:同一款商品在京东有几十个店铺在卖,商品标题略有差异(比如「包邮」「正品」等前缀后缀),怎么判断它们是同一件商品?我的做法是提取商品标题中的关键规格词做匹配。
import re def normalize_title(title): """归一化商品标题,提取核心规格""" # 去掉常见营销词 noise_words = ["包邮", "正品", "旗舰店", "专卖店", "新品", "热卖", "限时"] for w in noise_words: title = title.replace(w, "") # 提取型号、容量、颜色等规格信息 specs = re.findall(r'[\u4e00-\u9fa5A-Za-z0-9]+(?:GB|TB|寸|英寸|代|款)', title) # 去掉空格和特殊符号 core = re.sub(r'[\s\-_/]+', '', title) return core, specs def is_same_product(title_a, title_b): """判断两个标题是否指向同一商品""" core_a, specs_a = normalize_title(title_a) core_b, specs_b = normalize_title(title_b) # 规格词完全一致且核心词重合度超过80% if set(specs_a) != set(specs_b): return False common = set(core_a) & set(core_b) similarity = len(common) / max(len(set(core_a)), 1) return similarity > 0.8这个匹配逻辑不是100%准确,但课程设计够用了。实际跑的时候,我建议先按商品ID精确匹配(同一个jd_id肯定是同一商品),对于不同jd_id但标题相似的,再用上面的方法做模糊匹配。匹配阈值0.8是我调了几次的结果,低于0.7会误合并,高于0.9会漏掉很多。
3.3 比价查询接口与历史价格走势
Django视图层提供两个核心接口:实时最低价查询和历史价格走势。实时最低价就是按商品分组,取每个商品最新一条价格记录中的最小值。历史走势是按时间范围返回某商品的价格序列。
# views.py from django.db.models import Min, Max from django.http import JsonResponse from .models import Product, PriceRecord def lowest_price(request, product_id): """查询某商品当前各店铺最低价""" records = PriceRecord.objects.filter( product_id=product_id ).select_related("shop").order_by("-captured_at") # 取每个店铺最新一条记录 latest_by_shop = {} for r in records: if r.shop_id not in latest_by_shop: latest_by_shop[r.shop_id] = r if not latest_by_shop: return JsonResponse({"error": "无价格数据"}, status=404) result = [ {"shop": r.shop.name, "price": float(r.price), "time": r.captured_at.strftime("%Y-%m-%d %H:%M")} for r in latest_by_shop.values() ] result.sort(key=lambda x: x["price"]) return JsonResponse({"product_id": product_id, "lowest": result[0], "all": result}) def price_trend(request, product_id): """查询某商品历史价格走势""" days = int(request.GET.get("days", 30)) from django.utils import timezone from datetime import timedelta since = timezone.now() - timedelta(days=days) records = PriceRecord.objects.filter( product_id=product_id, captured_at__gte=since ).values("captured_at", "price").order_by("captured_at") trend = [ {"date": r["captured_at"].strftime("%m-%d"), "price": float(r["price"])} for r in records ] return JsonResponse({"product_id": product_id, "trend": trend})select_related("shop")是必须的,不然每个价格记录都会单独查一次店铺表,N+1查询问题会让接口响应从50ms涨到2秒。latest_by_shop字典去重逻辑保证了每个店铺只取最新价格,避免旧数据干扰比价结果。
4. 避坑与排查:这套系统最容易翻车的五个地方
4.1 爬虫返回空数据或验证码页面
现象:requests返回200状态码,但HTML里提取不到商品ID,或者页面标题变成「验证」相关字样。
原因:请求频率过高触发风控,或者请求头缺少关键字段。京东对User-Agent和Referer的校验比较严,缺Referer时大概率返回验证页。
解决:先降低频率到每分钟10次以下,补全Referer为https://search.jd.com/,User-Agent用真实浏览器的完整字符串。如果还是不行,换IP或等30分钟再试。
4.2 价格字段解析为0或None
现象:详情页解析出的价格是0.0,但页面上明明显示有价格。
原因:京东详情页价格通过JS异步加载,requests拿到的静态HTML里没有价格标签,价格藏在<script>的JSON变量中,且变量名可能变化。
解决:不要只依赖BeautifulSoup选择器,同时用正则从整个HTML中搜索价格模式。我一般会写多个正则兜底,比如r'"p":"([\d.]+)"'、r'"price":"([\d.]+)"'、r'jd-price[^>]*>([\d.]+)<',按顺序尝试。
4.3 商品匹配误合并导致比价结果混乱
现象:比价页面把不同型号的商品显示成同一件,最低价明显不合理。
原因:标题归一化时去掉了太多关键词,或者相似度阈值设得太低。
解决:把阈值从0.7调到0.85以上,同时在归一化时保留型号、容量、颜色等规格词。更稳妥的做法是优先用jd_id精确匹配,模糊匹配只作为补充。
4.4 Django接口响应慢,页面加载超过5秒
现象:比价列表页打开很慢,Django日志显示大量重复SQL查询。
原因:外键关联查询没有用select_related,导致N+1问题。价格记录表数据量大时,没有按时间过滤也会拖慢查询。
解决:所有涉及外键的查询都加select_related或prefetch_related。价格走势接口必须加时间范围过滤,默认查最近30天,不要全表扫描。
4.5 数据库存了大量重复价格记录
现象:同一商品同一店铺在几分钟内有多条价格记录,数据表膨胀很快。
原因:采集任务重复执行,或者没有做去重判断。
解决:入库前先查最近一条记录,如果价格相同且时间间隔小于1小时,跳过不存。在PriceRecord表上加联合唯一约束(product+shop+captured_at的日期部分),从数据库层面兜底。
5. 让比价更准:一个被忽略的采集时间窗口技巧
前面讲的都是怎么把系统跑起来,这一章说一个让比价结果更可信的细节:采集时间窗口的选择。很多人做比价系统,采集时间随机,今天早上抓一次,明天晚上抓一次,然后拿这些数据画价格走势图。结果曲线剧烈波动,看起来像商品在疯狂调价,实际上只是京东的日常促销节奏在干扰。
我踩过这个坑。早期我每天早上9点抓一次,发现价格普遍偏高;后来改成晚上8点抓,价格又偏低。查了几天数据才明白,京东的价格在一天内有明显的波动规律:凌晨到上午10点通常是日常价,下午2点到4点有一波小促销,晚上8点到10点是全天最低价区间,大促期间(比如每月中旬的品类日)波动更剧烈。
所以我的做法是:固定每天三个时间点采集——上午10点、下午3点、晚上9点。这样得到的价格序列既能反映日常价,又能捕捉到促销价,比价结果更有参考价值。具体实现就是在采集脚本外面套一层定时任务,用schedule库或者系统的crontab。
import schedule import time def collect_job(): """执行一轮采集""" keywords = ["手机", "笔记本电脑", "耳机"] for kw in keywords: html = fetch_search_page(kw) ids = extract_product_ids(html) for pid in ids[:20]: # 每轮每个关键词只抓前20个 detail_html = fetch_detail_page(pid) data = parse_detail_page(detail_html) save_to_db(pid, data) random_sleep() # 每天三个时间点执行 schedule.every().day.at("10:00").do(collect_job) schedule.every().day.at("15:00").do(collect_job) schedule.every().day.at("21:00").do(collect_job) while True: schedule.run_pending() time.sleep(60)这个时间窗口策略不是官方文档写的,是我对比了两周数据后总结的。如果你要做价格预警功能(比如低于某个阈值时通知),建议把阈值设在晚上9点采集到的价格基础上再降5%,因为那个时间点的价格已经接近底部,再降的空间有限。
验证方法也简单:连续采集一周后,把同一商品的价格按时间点分组求均值,如果三个时间点的均值差异超过10%,说明你的采集时间窗口选对了,数据有区分度;如果三个均值几乎一样,要么是商品本身价格稳定,要么是采集频率不够,需要增加时间点。
最后说个习惯:我每次改完采集逻辑,都会先拿10个商品跑一轮,人工核对抓到的价格和京东页面上显示的是否一致。这个动作花不了五分钟,但能避免跑了几千条数据后才发现解析全错。希望帮到你。
本文还有配套的精品资源,点击获取