ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python+Django京东商品比价系统实战:爬虫抓取与价格快照

Python+Django京东商品比价系统实战:爬虫抓取与价格快照 简介基于PythonDjango的京东商品比价系统毕业设计项目包面向计算机相关专业本专科学生、准备毕业设计的开发者以及需要快速完成课程设计或期末大作业的学习者。项目已获导师指导并通过98分评估下载后可直接运行无需额外修改覆盖商品采集、比价核心逻辑、用户前端展示、后台管理与数据库设计等模块。压缩包内共2001个文件主要为Python源码与pyc编译文件同时包含HTML模板、JS/CSS前端资源、多语言翻译文件mo/po、SQL数据库脚本与模型权重整体体积16.32MB目录组织清晰便于检索和二次开发。目前已有212人学习下载适合用作理解Django项目架构、电商比价业务逻辑的参考样例。配套资料完整可辅助撰写毕业设计文档、梳理设计思路或进行功能扩展同时可通过源码了解爬虫调度、模板渲染与Django ORM的配合方式深入掌握项目从模型到视图的完整链路。1. 京东商品比价系统拆解爬虫抓价格、Django 出页面毕业设计为什么都选它拿到“基于 PythonDjango 的京东商品比价系统”这个标题时第一反应是这是一个典型的“爬虫 Web 展示”闭环项目。它要解决的事情很具体——用户输入一个商品关键词系统去京东抓取相关商品的价格存进数据库再通过 Django 页面展示出来让用户看到谁便宜、价格涨了还是跌了。对计算机相关专业的毕业生来说这个题目恰好覆盖了网络请求、数据解析、数据库设计、Web 后端四个必修点工作量适中演示效果又直观所以成了毕业设计里的常客。这套源码包里的东西拆开看无非是三块爬虫脚本负责抓数据Django 工程负责存数据和出页面再加上一份说明文档和数据库文件。对 django 项目实战新手来说最大的价值不是“能跑”而是把一条完整的数据链路看明白商品从京东网页变成数据库里的记录再从记录变成页面上可排序、可筛选的比价列表。下面我按自己做这类项目的顺序把数据抓取、存储、查询、排错四个环节逐个拆开讲最后落到怎么把这个源码包改造成真正能用的定时任务。2. 价格数据从哪来Requests 抓京东搜索页的代码与四个必调参数2.1 静态页面抓价格正则与 BeautifulSoup 的最小实现京东商品比价系统的数据源绝大多数实现都落在“搜索列表页”而不是商品详情页。原因很简单搜索页一次能拿到几十个商品的标题、链接和店铺名信息密度高抓一轮就能填满数据库。常见的做法是用 Requests 请求搜索页再用 BeautifulSoup 解析 HTML代码量控制在几十行以内。# spider.py —— 搜索页抓取与解析的最小实现 import requests from bs4 import BeautifulSoup HEADERS { # 京东对无 UA 的请求基本直接拒绝这一行别省 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_search_page(keyword: str, page: int 1): url https://search.jd.com/Search params {keyword: keyword, page: page} resp requests.get(url, paramsparams, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text def parse_search_page(html: str) - list[dict]: soup BeautifulSoup(html, html.parser) items [] # 搜索结果里每个商品 li 都带># price_api.py —— 模拟前端 XHR 请求拿价格 import requests import json PRICE_API https://p.3.cn/prices/mgets def fetch_prices(sku_ids: list[str]) - dict[str, dict]: # 接口一次最多支持几十个商品 ID按 30 个一批拆分比较稳 result {} for i in range(0, len(sku_ids), 30): batch sku_ids[i:i 30] params { skuIds: ,.join(fJ_{sid} for sid in batch), pduid: 1600000000000, # 前端会带一个时间戳这里给个固定值即可 } resp requests.get(PRICE_API, paramsparams, timeout10) resp.raise_for_status() for item in resp.json(): # 返回结构里 p 是促销价m 是市场原价 result[item[id].replace(J_, )] { price: item.get(p), origin_price: item.get(m), } return result这个接口的返回是 JSON 数组每条记录对应一个商品 ID其中p字段是当前促销价m字段是划线原价。比价系统里“原价”和“现价”两个概念就是从这里拆出来的。必须提醒一句京东的接口路径和参数在不同时期会变如果你在自己电脑上请求失败以浏览器 Network 面板里实际抓到的请求为准路径变了就换路径参数变了就换参数。2.3 请求频率与反爬UA、超时、重试和限速的四个参数爬虫写出来能跑是一回事能连续跑一个小时不封 IP 是另一回事。京东的搜索页和价格接口对高频请求都有限制常见的表现是前几十个请求正常之后突然返回空页面或者跳到验证页面。这个项目是毕业设计演示时抓几百条数据就够了没必要把频率拉满但四个参数必须配好。# 带重试与限速的请求封装 import time import requests from requests.adapters import HTTPAdapter session requests.Session() session.headers.update(HEADERS) # 挂上适配器连接池保留 10 个连接失败自动重试 2 次 adapter HTTPAdapter(pool_connections10, pool_maxsize10, max_retries2) session.mount(https://, adapter) def safe_get(url: str, **kwargs): for attempt in range(3): try: resp session.get(url, timeout10, **kwargs) if resp.status_code 200 and len(resp.text) 500: return resp # 页面长度异常短大概率是反爬拦截页 if len(resp.text) 500: raise RuntimeError(short page) except Exception as exc: wait 2 ** attempt # 指数退避2s、4s time.sleep(wait) return None四个参数的实际作用pool_maxsize控制并发连接数比价系统单线程跑就够了别调大max_retries让网络抖动时自动重试但要配合指数退避不然失败后立刻重试只会加重被封概率timeout必须写防止请求黑洞每次请求之间的sleep建议 1 到 2 秒这个项目里 30 个商品一轮抓下来也就一分钟完全够演示用。记住一个原则比价系统是“低频小批”场景不是“高频大批”场景。3. Django 侧怎么接住数据商品表、价格快照表与自定义管理命令3.1 商品表与价格快照表两张表解决“历史价”问题数据抓下来之后怎么存决定了比价功能能做多深。很多毕业设计只做一张商品表把当前价格存进去再次抓取时直接覆盖。这样页面能显示“当前价格”但“降价提醒”“历史最低价”这类功能全都做不了。我的经验是拆成两张表一张存商品静态信息一张存价格快照每次抓取都追加一条记录不覆盖。# products/models.py from django.db import models class Product(models.Model): sku_id models.CharField(max_length32, uniqueTrue, verbose_name京东商品ID) title models.CharField(max_length255, verbose_name商品标题) shop models.CharField(max_length128, blankTrue, verbose_name店铺) url models.URLField(verbose_name商品链接) created_at models.DateTimeField(auto_now_addTrue, verbose_name首次收录时间) class Meta: ordering [-created_at] class PriceSnapshot(models.Model): product models.ForeignKey(Product, on_deletemodels.CASCADE, related_nameprices, verbose_name商品) price models.DecimalField(max_digits10, decimal_places2, verbose_name促销价) origin_price models.DecimalField(max_digits10, decimal_places2, nullTrue, blankTrue, verbose_name划线价) crawled_at models.DateTimeField(auto_now_addTrue, verbose_name抓取时间) class Meta: ordering [crawled_at] # 同一个商品同一秒只保留一条快照避免重复抓取刷出一堆相同记录 constraints [ models.UniqueConstraint(fields[product, crawled_at], nameuniq_product_time) ]两张表的关系是典型的一对多一个商品对应多条价格快照。商品表的sku_id加唯一约束保证同一个京东商品不会重复入库价格快照表通过外键关联商品crawled_at记录抓取时间。字段类型上价格必须用DecimalField而不是FloatField浮点数存价格会有 0.1 0.2 不等于 0.3 的问题比价页面算最低价时容易出现 0.01 元的误差。加唯一约束是为了后续定时任务重复执行时不产生垃圾数据。3.2 用 Django 管理命令把爬虫接进项目爬虫脚本和 Django 工程是两个独立进程时最麻烦的是数据库连接配置要写两份。更省事的做法是把爬虫写成一个 Django 自定义管理命令直接复用项目的settings.py里的数据库配置和模型定义。这样在项目根目录执行python manage.py fetch_jd爬虫就跑起来了。# products/management/commands/fetch_jd.py from django.core.management.base import BaseCommand, CommandError from products.spider import fetch_search_page, parse_search_page, fetch_prices from products.models import Product, PriceSnapshot from django.utils import timezone class Command(BaseCommand): help 抓取京东商品数据并写入价格快照 def add_arguments(self, parser): parser.add_argument(--keyword, requiredTrue, help搜索关键词) parser.add_argument(--pages, typeint, default1, help抓取页数) def handle(self, *args, **options): keyword options[keyword] pages options[pages] total 0 for page in range(1, pages 1): html fetch_search_page(keyword, page) items parse_search_page(html) sku_ids [item[sku_id] for item in items] price_map fetch_prices(sku_ids) for item in items: price_info price_map.get(item[sku_id], {}) if not price_info.get(price): continue product, _ Product.objects.get_or_create( sku_iditem[sku_id], defaults{ title: item[title], shop: item[shop], url: item[url], }, ) PriceSnapshot.objects.create( productproduct, priceprice_info[price], origin_priceprice_info.get(origin_price) or None, ) total 1 self.stdout.write(f第 {page} 页完成累计 {total} 条) self.stdout.write(self.style.SUCCESS(f抓取完成共写入 {total} 条快照))管理命令的入口是handle方法参数通过add_arguments声明。--keyword指定搜索词--pages控制抓多少页。代码里用get_or_create保证商品表不重复价格快照则每次直接新增。注意crawled_at用了auto_now_addTrue插入时自动取当前时间这保证了同一商品多次抓取会留下多个时间点后面的价格曲线就靠这些时间点画出来。3.3 SQLite 还是 MySQL毕业设计场景的选型理由Django 默认用 SQLite比价系统这个数据量用 SQLite 完全够。按一次抓取 30 个商品、每天跑两次来算一整年也就两万多条价格快照SQLite 处理这种量级毫无压力。好处是零配置数据库就是一个文件交作业时把.db文件一起打包老师在任意电脑上都能直接跑起来看效果。唯一要注意的是 SQLite 不支持并发写多个爬虫进程同时写库会报 “database is locked”解决办法是只跑一个抓取进程。如果导师要求用 MySQL只需要改settings.py里的 DATABASES 配置模型代码不用动。Django 的 ORM 屏蔽了底层数据库差异这是个很实在的好处——先拿 SQLite 把功能跑通最后再切 MySQL 交差两头都不耽误。切换时要执行python manage.py makemigrations和python manage.py migrate重新建表别直接把 SQLite 的 .db 文件丢给 MySQL。4. 比价逻辑的落地姿势查询筛选、价格区间与最低价排序4.1 商品列表的查询与筛选Q 对象和价格区间的组合数据入库后比价页面的核心就是一个带筛选条件的查询。用户常见操作是输入关键词、设置价格上限、按价格排序。这里的坑是“按价格排序”不能直接对商品表排序因为商品表现在没有单一的价格字段价格都在快照表里。正确做法是子查询取每个商品最新一条快照的价格来排序。# products/views.py —— 列表接口关键查询逻辑 from django.db.models import OuterRef, Subquery, Q from products.models import Product, PriceSnapshot def query_products(keyword: str, max_priceNone, sort_byprice_asc): qs Product.objects.all() if keyword: # icontains 做模糊匹配Q 对象把标题和店铺两个字段合并搜索 qs qs.filter(Q(title__icontainskeyword) | Q(shop__icontainskeyword)) # 子查询取每个商品最新一条快照的价格 latest_price PriceSnapshot.objects.filter( productOuterRef(pk) ).order_by(-crawled_at).values(price)[:1] qs qs.annotate(current_priceSubquery(latest_price)) if max_price: qs qs.filter(current_price__ltemax_price) if sort_by price_asc: qs qs.order_by(current_price) elif sort_by price_desc: qs qs.order_by(-current_price) elif sort_by newest: qs qs.order_by(-created_at) return qsOuterRef和Subquery是这里的关键。OuterRef(pk)引用外层商品表的主键内层查询取当前商品最新一条快照的价格最后用annotate把结果作为current_price字段挂在商品上。这样排序、筛选都基于这个虚拟字段不需要维护冗余的价格列。用Q对象合并标题和店铺两个搜索条件比分开写两个filter要清晰也方便以后加更多筛选维度。价格筛选放在annotate之后执行Django 会把子查询嵌进 WHERE 条件里生成的 SQL 性能尚可几千条数据时响应在毫秒级。4.2 价格曲线的实现从价格快照算最低价与平均价比价系统里最出效果的功能是价格曲线——打开商品详情页看到过去一周的价格走势。实现不复杂取出该商品所有价格快照按时间排序传给前端画折线图。但有几个细节会影响数据可信度京东的促销价经常变同一个商品一天内可能抓出多个价格有些商品会短暂缺货价格接口返回 0 或者空值这些脏数据必须过滤。# products/views.py —— 商品详情页价格统计 from django.db.models import Avg, Min, Max from products.models import PriceSnapshot def product_price_stats(product_id: int): snapshots (PriceSnapshot.objects .filter(product_idproduct_id, price__gt0) .order_by(crawled_at)) stats snapshots.aggregate( avg_priceAvg(price), min_priceMin(price), max_priceMax(price), ) # 提取最近 30 条快照用于前端画曲线 recent list(snapshots.values(crawled_at, price)[-30:]) return { avg: round(stats[avg_price], 2) if stats[avg_price] else None, min: stats[min_price], max: stats[max_price], points: recent, }注意filter(price__gt0)这一步价格接口偶尔返回 0 或空值如果不过滤最低价会算成 0 元比价页面直接翻车。aggregate一次算出平均值、最低价、最高价三个指标SQL 里只跑一次聚合查询性能没问题。recent[-30:]在 Python 层截断列表只取最近 30 个点避免前端一次性渲染几百个点导致卡顿。数据量再大时应该改用 SQL 的LIMIT但对毕业设计来说 Python 切片够用了。4.3 页面渲染的取舍服务端渲染还是轻量接口比价系统这种内部工具型项目页面渲染有两种选择Django 模板直接渲染或者返回 JSON 交给前端框架。我倾向于前者。理由是这个项目没有复杂的交互模板 jQuery 就能把所有功能做完不需要引入 Node.js 构建链路。对只有一个 Django 后端的部署环境来说少一个前端工程就少一个部署故障点。# products/views.py —— 模板渲染的列表视图 from django.shortcuts import render from django.core.paginator import Paginator def product_list_view(request): keyword request.GET.get(q, ) max_price request.GET.get(max_price, ) sort_by request.GET.get(sort, price_asc) qs query_products(keyword, max_pricemax_price or None, sort_bysort_by) paginator Paginator(qs, 20) # 每页 20 条 page_obj paginator.get_page(request.GET.get(page)) return render(request, products/list.html, { page_obj: page_obj, keyword: keyword, })模板渲染的好处是搜索条件直接拼在 URL 的 query string 里刷新、分享、收藏都不会丢状态。Paginator是 Django 自带的分页器传入查询集和每页条数即可。这里有个实战细节query_products返回的是带子查询的 QuerySet分页时 Django 会自动执行 COUNT 查询数据量大时 COUNT 会有点慢但是几千条数据不用在意。5. 排错避坑爬虫空页面、价格字段三形态、主键冲突与查询重复5.1 返回 200 却抓到空页面反爬可能不在状态码上现象请求京东搜索页状态码是 200但解析出来的商品列表是空的页面里全是验证码或者滑块逻辑。 原因京东对异常流量的拦截不是返回 403而是返回一个“安全验证”页面状态码仍然 200。代码里只看status_code判断成功就会把拦截页当成正常页面解析。 解决在解析前检查页面长度和特征内容。正常搜索页 HTML 至少有 50KB且包含gl-item字样拦截页通常不到 10KB且包含verify或“安全验证”关键词。我在safe_get里已经写了len(resp.text) 500的判断实际项目里建议把阈值提高到 20KB并且检测li.gl-item是否存在不存在就视为被拦截等几秒再重试。5.2 价格字段的三种形态格式化字符串、隐藏标签与接口 JSON现象从商品详情页解析价格时有时拿到的是“¥3999.00”字符串有时是空标签有时是 JSON三个页面的解析规则对不上。 原因京东不同位置的页面渲染价格的方式不同。搜索页价格走 XHR 接口详情页首屏价格在 HTML 里但活动价、plus 价在额外的异步接口里部分商品的价格标签初始是空的JavaScript 加载后才填充。 解决统一走 XHR 接口。不论页面来源都通过价格接口按商品 ID 批量取价解析代码只处理一种数据格式。详情页里的“京喜价”“plus 价”这类特殊价格比价系统不需要覆盖只取接口返回的p字段即可。记住一个原则解析“用户最终看到的价格”而不是“第一个出现在 HTML 里的数字”。5.3 重复抓取导致数据膨胀唯一约束与幂等写入现象定时抓取任务跑了三天数据库里同一商品出现几百条相同价格的快照价格曲线变成一根直线统计页面变慢。 原因抓取任务没有做幂等控制每次执行都无脑插入快照。京东商品价格在促销期间可能几小时不变重复抓取产生大量冗余数据。 解决价格快照表加唯一约束或者在写入前查一下“该商品最近一条快照的价格是否相同”。我的做法是两种都做数据库层加UniqueConstraint兜底防止同一秒重复插入应用层在写入前对比该商品最近一条快照价格没变就不插入。这样既省钱又省历史曲线上的噪点。5.4 按价格排序时商品重复出现多表关联查询的 distinct 陷阱现象列表页按价格升序排序时同一个商品出现两次或三次点进去是同一个链接。 原因对商品表filter(prices__price__lte1000)这类跨表过滤时Django 会 JOIN 价格快照表商品有多少条快照就产生多少行结果。排序字段来自子查询时问题尤其隐蔽。 解决跨表过滤后加.distinct()或者像我第 4 章的写法那样完全绕开 JOIN改用子查询取最新价格。子查询方案在数据量变大后性能更好因为 JOIN 后的中间表行数等于快照总数而快照是持续增长的。实测中几千条快照时两种方案都没问题但子查询的 SQL 执行计划更稳定。5.5 Django 开发服务器在演示时卡死SQLite 锁与静态文件路径现象演示到一半页面转圈开发服务器控制台报database is locked。 原因SQLite 同一时刻只允许一个写操作。Windows 下杀毒软件扫描文件、演示机开了多个浏览器标签页同时触发写入都会造成锁等待。开发服务器默认启动了自动重载代码变更时重启也可能和正在执行的爬虫产生写冲突。 解决演示前先停掉所有后台抓取任务再启动python manage.py runserver。如果必须要边抓边看把 SQLite 换成 MySQL或者至少把timeout参数加进数据库连接配置让 Django 在锁等待时多等 5 秒而不是立刻报错。6. 把源码包跑成自己的小工具定时抓取与正确性验证6.1 从手动执行到定时抓取crontab 与 Windows 计划任务毕业设计答辩时手动跑一次抓取再展示页面完全够用。但如果真的想把这个系统当成一个小工具来用需要让抓取任务定时自动执行。Django 项目里最轻量的做法不是装 Celery而是用系统自带的计划任务调用管理命令。Linux 下用 crontab一行配置搞定# 每天 9 点和 21 点各抓一次“机械键盘”记录日志方便排查 0 9,21 * * * cd /home/user/jd_price /usr/bin/python3 manage.py fetch_jd --keyword 机械键盘 --pages 2 /home/user/jd_price/logs/spider.log 21Windows 下则用任务计划程序操作里选择“启动程序”程序填python.exe的完整路径参数填manage.py fetch_jd --keyword 机械键盘起始位置填项目根目录。这里最容易被忽略的是“起始位置”不填的话 Django 找不到manage.py和settings.py。日志重定向很重要定时任务跑挂了你看不到报错日志是唯一的排查入口。6.2 用一条命令自测比价结果单元测试验证核心逻辑拿到源码包后别急着改功能先写一个冒烟测试验证核心逻辑。重点测两个地方价格解析能不能过滤脏数据、排序能不能保证商品不重复。Django 的TestCase自带测试数据库跑完自动清理不会污染开发数据。# products/tests.py from django.test import TestCase from products.models import Product, PriceSnapshot from products.views import query_products, product_price_stats class PriceCompareTest(TestCase): def setUp(self): self.p1 Product.objects.create(sku_id1001, titleA 牌机械键盘, shopA 店, urlhttp://example.com/1001) self.p2 Product.objects.create(sku_id1002, titleB 牌机械键盘, shopB 店, urlhttp://example.com/1002) PriceSnapshot.objects.create(productself.p1, price300, origin_price400) PriceSnapshot.objects.create(productself.p2, price250, origin_price350) def test_price_asc_sorts_correctly(self): qs query_products(keyword机械键盘, sort_byprice_asc) self.assertEqual(list(qs.values_list(sku_id, flatTrue)), [1002, 1001]) def test_stats_ignore_zero_price(self): PriceSnapshot.objects.create(productself.p1, price0) stats product_price_stats(self.p1.pk) self.assertEqual(stats[min], 300)这个测试代码我希望每个拿到源码包的人都先跑一遍python manage.py test products。它能证明两件事——数据链路是通的过滤逻辑是有效的。实话实说我自己第一次写这类项目时根本没想过写测试后来改价格过滤条件时把最低价算成了 0 元在页面上一眼暴露。补上测试之后改完代码跑一次就知道有没有把功能改坏。这种“后悔药”成本极低收益却比想象中大得多。如果你打算在这个源码包基础上改功能第一个建议就是先写这十分钟的测试。希望帮到你。本文还有配套的精品资源点击获取
返回列表