☰
Python爬虫+Django实现京东商品比价系统:从数据采集到Web展示
2026/10/28 1:05:04 网站建设 项目流程

简介:这是一套基于Python与Django开发的京东商品比价系统完整项目,适用于毕业设计、课程设计或个人项目二次开发,技术栈涵盖request爬虫、数据库设计与Web端交互。系统已通过严格测试,具备注册登录、商品收藏、十五天价格折线图、分类降幅推荐及跳转购买等核心功能,能够完整体现电商比价场景的爬虫、存储与可视化全流程。资源包共2001个文件,以Python源码(py与pyc)为主,另含HTML模板、CSS/JS前端样式、Django国际化翻译文件(po/mo)以及SQL脚本等,整体约16.2MB,目录结构清晰,适合快速导入运行并学习关键实现。目前已有75人学习浏览。相比直接复制代码,这套资料提供的是带工程化组织的完整数据库与前后端代码,可辅助理解爬虫调度、价格曲线绘制和推荐逻辑,也便于在现有基础上扩展新功能。

1. 京东商品比价系统:一个爬虫加 Web 展示的课设项目,能解决什么实际问题

同一个商品在京东自营、第三方店铺和不同促销节点下,价格差个几十上百块是常态。所谓京东商品比价系统,就是通过 requests 爬虫定时抓取京东商品页的价格与标题,把结果存进数据库,再通过 Django 把价格历史、最低价、价格走势展示成网页。对做毕业设计或课程设计的同学来说,这套系统的好处在于技术栈干净——Python、Django、requests、MySQL 全是高频考点,每一层都能单独写进报告里,而且爬虫和 Web 是两条清晰的主线,工作量容易拆分,演示时也直观。我下面按自己实际搭过一遍的顺序,从爬虫解析讲到 Django 模型再讲到数据库落地,最后把容易翻车的地方集中列出来。

2. 用 requests 把京东价格抓下来:从商品 ID 到解析落库的完整链路

2.1 为什么选 requests 而不是 Scrapy:课设场景的选型理由

很多教程一上来就推 Scrapy,但对课设项目来说,Scrapy 的学习曲线和调试成本经常超过收益。Scrapy 有 Item Pipeline、Middleware、Selector 一整套框架,数据量小的时候这些全是负担。requests 的写法贴近普通 Python 代码,逻辑是顺序的——请求一个页面、解析、存库、再请求下一个,出了问题用 print 或者日志就能定位,不用等整个爬虫跑完才发现中间某个 item 丢了。

requests 做京东爬虫的核心流程只有三步:构造请求头、发起 GET 请求、用 XPath 提取目标字段。下面这段是我在项目里最常用的最小实现:

import requests from lxml import html HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://www.jd.com/", } def fetch_item(sku_id): url = f"https://item.jd.com/{sku_id}.html" resp = requests.get(url, headers=HEADERS, timeout=5) resp.encoding = "utf-8" tree = html.fromstring(resp.text) title = tree.xpath("//div[@class='sku-name']/text()") return {"sku_id": sku_id, "title": title[0].strip() if title else ""} if __name__ == "__main__": print(fetch_item("100012043978"))

这段代码的逻辑很直接:把商品 ID 拼成链接,带上伪造的请求头访问,然后用 XPath 取出标题。参数里关键的是 HEADERS 中的 User-Agent 和 Referer——UA 用来模拟浏览器环境,Referer 填京东首页是为了让服务端认为请求是从站内跳转过去的。timeout 设 5 秒,避免某个商品页面卡住导致整个爬虫线程阻塞。

2.2 商品详情页解析:XPath 的 text 函数与价格字段的三种形态

京东的标题节点相对固定,但价格字段不是每次都出现在同一个位置,这是新手最容易懵的地方。常见的情况有三种:价格在<span class="price">里、价格通过p-price容器的i标签展示、价格根本不在 HTML 里而是由页面加载后的 JavaScript 异步填充。前两种用 XPath 的 text 函数能直接取到,第三种必须绕开静态页面去找接口。

def parse_price(tree): # 情况一:直接在价格节点内 price = tree.xpath("//span[@class='price']/text()") if price: return price[0].strip() # 情况二:在 p-price 的 i 标签里 price = tree.xpath("//div[contains(@class, 'p-price')]//i/text()") if price: return price[0].strip() # 情况三:都没有,说明价格是异步加载的 return None

这里用到了 xpath 的 text 函数来提取标签内部的文本内容。注意 xpath 的text()是和路径表达式连用的,不是独立函数,//span[@class='price']/text()返回的是一个列表,哪怕只有一个匹配结果也是列表,所以取[0]之前要注意检查空列表。contains 那个写法是京东页面里常见的形式,class 名经常是p-price加随机后缀,直接用精确等号匹配不到,必须用 contains 做模糊匹配。

如果回显的 price 为 None,说明这个商品走的是异步加载,需要到京东的价格接口去拿。常见的做法是请求https://p.3.cn/prices/mgets?skuIds=J_商品ID,这个接口返回 JSON,里面直接有p字段表示价格。这是我在项目里最常用的一条路,代码不复杂但能避开很多页面解析上的坑。

def fetch_price_from_api(sku_id): url = f"https://p.3.cn/prices/mgets" params = {"skuIds": f"J_{sku_id}"} resp = requests.get(url, params=params, headers=HEADERS, timeout=5) data = resp.json() if data and "p" in data[0]: return data[0]["p"] return None

这里用 params 而不是直接把参数拼进 URL,是因为 requests 会自动处理 URL 编码,京东接口对 J_ 前缀的传参要求比较严格,拼字符串时一旦漏了前缀或者多了空格就直接返回空数组。另外注意这个接口返回的价格是字符串类型,存数据库之前要做一步 Decimal 转换,否则后面查价格区间比较时会出类型错误。

2.3 请求头伪装与频率控制:让爬虫活过前十分钟的三条基本配置

爬虫写完了能跑,不代表能稳定跑。京东的反爬主要在风控层面,不是简单封 IP,而是检测请求特征的一致性。我见过最典型的翻车场景是:前几个商品抓得正常,第十个开始返回验证码页面,HTML 里全是空节点。原因就是请求频率太高,或者 User-Agent 太单一,被识别成脚本。

第一条配置是随机 User-Agent。不能只用一个 UA,要在每次请求时从列表里随机选一个。哪怕只有三四个 UA 轮着用,也比固定一个 UA 的效果好很多。第二条是请求间隔。不要用固定 sleep,固定间隔会被统计规律识别出来,随机 2 到 5 秒的浮动间隔更接近真实用户行为。第三条是异常后自动降速。当某次请求返回的状态码是 302 跳到登录页,或者页面里检测不到预期节点时,把当前线程的 sleep 时间翻倍。

import random import time UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15", ] def safe_fetch(url): headers = {"User-Agent": random.choice(UA_POOL), "Referer": "https://www.jd.com/"} try: resp = requests.get(url, headers=headers, timeout=5) if resp.status_code in (302, 403): time.sleep(random.uniform(5, 10)) else: time.sleep(random.uniform(2, 5)) return resp except requests.RequestException: time.sleep(10) return None

这段代码把频率控制直接写进了请求函数。注意异常处理用的是 requests.RequestException,这是 requests 所有网络异常的父类,能一次性兜住连接超时、读超时和 DNS 解析失败。如果只捕获 ConnectionError,碰到读超时还是会崩。sleep 放在请求之后而不是请求之前,是为了让每次请求之间的实际间隔包含网络耗时本身,延迟更自然,也不容易在日志统计里暴露出整齐的节奏。

3. Django 端把比价做厚:模型设计、查询优化与定时更新

3.1 三张核心表:商品、价格记录、用户关注,以及它们之间的关联

爬虫只是数据采集的入口,真正让系统像个"比价系统"的,是 Django 的模型层。我的建议是不要把所有字段塞进一张表,至少拆成三张:商品表存静态信息,价格记录表存历史价格,用户关注表存用户和商品的关系。商品和价格记录是一对多关系,用户和商品是多对多关系,这个关系理顺了,后面的查询和报表都好写。

from django.db import models class Product(models.Model): sku_id = models.CharField(max_length=32, unique=True, verbose_name="京东SKU") title = models.CharField(max_length=255, verbose_name="商品标题") shop_name = models.CharField(max_length=128, blank=True, verbose_name="店铺") url = models.URLField(verbose_name="商品链接") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") class Meta: ordering = ["created_at"] class PriceRecord(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="prices") price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="价格") is_promotion = models.BooleanField(default=False, verbose_name="是否促销价") checked_at = models.DateTimeField(auto_now_add=True, verbose_name="抓取时间") class UserFollow(models.Model): user = models.ForeignKey("auth.User", on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) target_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True)

模型设计的几个细节:sku_id 加 unique 约束防止爬虫重复入库,商品表重复抓取时用get_or_create更新标题而不是新建记录;PriceRecord 的外键设置了 related_name="prices",查商品时可以直接product.prices.all()拿到全部价格记录,不用再写 filter;DecimalField 而不是 FloatField 存价格,原因是浮点比较在 SQL 查询中会触发精度问题,比价系统要查最低价和价格区间,精度不对是致命的。

UserFollow 里的 target_price 是给"降价提醒"功能预留的字段,课设里做一个简单的列表页展示历史最低价就够了,但加上这个字段后,报告里可以多写一个"用户可设置期望价格,系统在价格降到目标以下时高亮提示"的功能点,答辩时能多一个亮点。

3.2 django 执行查询的写法:按商品查最新价格、按时间范围查价格波动

模型写完之后,关键是把查询写好。这里要提一个 Django 的经典坑:查最新价格时不能按 id 倒序然后 first(),这个写法在数据量大时性能极差。正确做法是,用价格记录表按 product 分组,每组取最新一条,这用窗口函数或者子查询才可以做到干净利落。

from django.db.models import Max, F, Subquery, OuterRef def get_latest_price(product): latest_record = PriceRecord.objects.filter( product=product ).order_by("-checked_at").first() return latest_record.price if latest_record else None def get_price_trend(product, days=30): from django.utils import timezone from datetime import timedelta start_time = timezone.now() - timedelta(days=days) records = PriceRecord.objects.filter( product=product, checked_at__gte=start_time ).order_by("checked_at") return [(r.checked_at, r.price) for r in records]

第一个查询把所有价格记录按时间倒序取第一条,就是最新价格。第二个查询用checked_at__gte做时间范围过滤,是 Django ORM 的标准写法。这里需要注意的是排序字段 checked_at 和 created_at 的区别——一条价格记录可能在同一秒内插入多条,created_at 精确到秒会出现并列,排序结果不稳定。checked_at 如果也在同一秒内,要再加一个 id 倒序做次级排序才能保证取到的确实是最后插入的那条。

django 执行查询还有一档要注意的:删除对象时要清理关联数据。PriceRecord 的外键设置的是 CASCADE 删除,所以删商品时价格记录会自动跟着删,但如果你删的是 UserFollow 记录而商品还在,这个操作会很安全——外键的另一端没有被误删。如果你没有设置 on_delete 参数,Django 会强制你在迁移时报错,这会在后面避坑章节详细说。

3.3 用 Celery 或 CRON 把爬虫挂成定时任务:增量更新价格

抓一次价格放入库不算完整的比价系统,比价的意义在于持续记录价格变化。最简单的方案是用 Linux 的 cron 定时跑一个 Django management command。这个方案的优点是不用额外引入消息队列和 worker 进程,课设演示时稳定性更高——Celery 虽然看起来高级,但 Windows 环境下 Celery 4 之后的版本对子进程支持有兼容性问题,答辩现场 celery worker 起不来的例子我见过太多次了。

# management/commands/fetch_prices.py from django.core.management.base import BaseCommand from products.models import Product, PriceRecord class Command(BaseCommand): help = "抓取所有已关注商品的最新价格" def handle(self, *args, **options): products = Product.objects.all() for product in products: price = fetch_price_from_api(product.sku_id) if price: PriceRecord.objects.create(product=product, price=price) self.stdout.write(f"[OK] {product.sku_id}: {price}") else: self.stdout.write(f"[FAIL] {product.sku_id}")

这个 command 可以放到 crontab 里每天定时执行,也可以手动在 Django 项目根目录跑python manage.py fetch_prices。注意我在 handle 里用 self.stdout.write 而不是 print,这是 Django 官方推荐的做法,因为 management command 的输出要被测试框架捕获时,print 是会绕过输出重定向的。command 里的 Product.objects.all() 如果商品数量上百个,顺序抓会太慢,常见做法是加一个--limit参数控制单次抓取数量,把全量更新拆成多次增量执行,这样即使某一次网络波动导致任务失败,损失也只是局部商品没更新。

4. 从零跑通这套系统:数据库迁移与初始化的顺序

4.1 创建 Django app 与注册模型

比价系统的常见项目结构是:一个主项目放 settings 和 urls,一个 products app 放模型和视图,爬虫代码放在 products 下的 management 命令或者独立 python 包里。这样分层的好处是,爬虫和 Web 可以分别独立测试,交报告的时候目录结构也清晰,不用解释一堆散落的文件。

django-admin startproject jd_compare cd jd_compare python manage.py startapp products

创建好 app 之后,必须在 settings.py 的 INSTALLED_APPS 里手动登记。新手最常犯的错误是忘记这一步,然后模型建好了、迁移命令也跑了,结果 migrate 提示没有任何迁移可以应用。检查方法很简单,在 INSTALLED_APPS 里找到'products.apps.ProductsConfig'或者简单的'products',两者都可以,后者是简写形式,Django 会自动找到 app 的配置类。

4.2 数据库迁移:makemigrations 与 migrate 的常见翻车点

Django 的模型变更和数据库结构同步是通过迁移文件来完成的。改过 models.py 之后,先makemigrations生成迁移文件,再migrate应用到数据库,这是标准两步。但实际操作中有一个高频坑:修改了模型字段后,makemigrations 提示需要为已有数据提供默认值。

python manage.py makemigrations products python manage.py migrate

出现这个提示的原因是新加的字段在已有数据行里没有值。常见做法有两种:如果字段本身允许为空,加null=True, blank=True让数据库不填值;如果不允许为空,就在迁移交互提示时给一个一次性默认值,比如默认填 0。不要把临时默认值一填就完事,要记住后期数据清洗时把默认值修正掉,否则数据库里会出现一批"假价格"记录,影响比价统计结果。

数据库本身的资源配置也是坑。用了 MySQL 的同学要注意,Django 默认用的是 MySQLdb 驱动,Python 3 环境要装的是 mysqlclient;用 PyMySQL 的话,需要在项目的__init__.py里加一段pymysql.install_as_MySQLdb(),不然后续迁移会报 module 找不到。

4.3 把爬虫数据导入数据库:三种方式对比与选择

爬虫抓下来的数据最终要进数据库。方式有几种,各有利弊。第一种是直接在爬虫脚本里调用 Django ORM——把 Django 的环境初始化加在爬虫开头,然后像写普通 Django 代码一样操作模型,这种方式简单直接,推荐课设使用。第二种是抓完导出 CSV 再用 loaddata 或手工导入,适合需要做数据清洗的场景。第三种是爬虫只负责产出 JSON 数据文件,Django 视图里解析文件入库,适合把爬虫和 Web 彻底解耦。

# 方式一示例:独立脚本里初始化 Django 环境 import os import django os.environ.setdefault("DJANGO_SETTINGS_MODULE", "jd_compare.settings") django.setup() from products.models import Product, PriceRecord def save_product(item_data): product, created = Product.objects.get_or_create( sku_id=item_data["sku_id"], defaults={"title": item_data["title"], "url": item_data["url"]} ) if not created and product.title != item_data["title"]: product.title = item_data["title"] product.save() PriceRecord.objects.create(product=product, price=item_data["price"])

这段里要注意 get_or_create 的语义:它先查询 sku_id 是否已存在,存在则直接返回已有对象并让 created 为 False,不存在才创建。已存在但标题变化的情况要单独处理——这里做了同步更新。这个"存在即更新"的逻辑是比价系统增量更新的关键,不能每次抓取都重新创建商品记录,否则同一商品会有多条重复记录,比价就变成垃圾数据比对了。

5. 避坑手册:京东反爬、数据库锁与 Django 部署的 5 个真实踩坑记录

5.1 现象:爬着爬着突然返回登录页,HTML 里找不到任何商品节点

原因:单 IP 请求频率超过阈值,被风控临时拦截。这个拦截不是封 IP,而是让请求返回一个验证跳转页面,UA 像是正常浏览器也照样被拦,频率才是关键。解决:把请求间隔从固定 2 秒改成 4 到 8 秒的随机值,同时增加 UA 随机池。如果是比赛或者毕设演示需要短时间内抓取大量商品,可以改用京东的价格 mgets 接口替代页面解析,接口的请求成本远低于页面,风控阈值也相对宽松。

5.2 现象:migrate 报错说表已存在,或者迁移记录和数据库结构对不上

原因:手动在数据库里建过表,然后又开始跑 Django 迁移,两张表的 schema 不一致;或者把别人项目里的迁移文件直接复制过来,但数据库里的 django_migrations 表没有对应记录。解决:开发阶段最干净的处理方式是删库重建——把数据库里的业务表全部 drop 掉,连 django_migrations 表也删掉,重新执行python manage.py migrate。正式数据不要这么干,要先备份再处理,课设阶段数据库里没有不可再生的数据,删库重建是最节省时间的后悔药。

5.3 现象:页面显示的价格和爬虫抓下来的价格对不上

原因:京东商品页的价格不是一次性由 HTML 返回的,而是页面加载后通过异步请求填充。按静态节点去解析,拿到的是初始占位价格或者空。解决:优先使用价

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

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

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

立即咨询