资讯动态

从零构建电商比价系统:Django+爬虫+数据可视化实战

发布时间:2026/8/14 2:50:49 来源:尧图企业网站定制
最近在帮一个做电商的朋友处理一个需求他想知道同一款商品在不同平台上的价格波动情况但手动去各个网站查不仅效率低还容易错过促销节点。我第一反应是这不就是个典型的比价需求吗用爬虫抓数据存起来再做个可视化看板不就行了听起来挺简单。但真正动手时我发现事情没那么单纯。如果只是写个一次性脚本抓完数据就扔那确实简单。可朋友需要的是个能长期运行、数据准确、并且他自己也能看懂趋势的“系统”。这意味着从数据抓取、存储、清洗、分析到展示每一个环节都不能是临时凑合的。你需要考虑反爬策略、数据一致性、任务调度、以及如何把原始数据变成一眼就能看懂的图表。这让我意识到很多技术教程只教你怎么用requests发个请求或者用Django建个模型但很少告诉你如何把这些零散的技术点串成一个能解决实际商业问题的、健壮的系统。今天我就以“电商比价系统”为例拆解一下从零到一构建这样一个数据驱动型应用的全过程。核心不在于某个库用得有多炫而在于如何让技术栈的每一环都扎实地服务于业务逻辑并且具备可维护性和可扩展性。1. 系统目标拆解比价不只是爬虫更是数据管道在开始写代码之前我们必须先想清楚这个“比价系统”到底要做什么。如果目标模糊后续的技术选型和架构设计很容易跑偏。1.1 核心业务流程与数据流一个完整的比价系统其核心是构建一条自动化的数据管道Data Pipeline。这条管道大致分为四个阶段采集Crawl Parse从目标电商网站获取商品页面HTML并从中提取出商品名称、价格、店铺、促销信息等结构化数据。存储Store将提取出的数据持久化到数据库中并记录抓取时间以便追踪价格历史。处理与分析Process Analyze对存储的原始数据进行清洗如处理缺失值、异常值、转换并计算关键指标如最低价、平均价、价格波动幅度、历史价格曲线等。展示与告警Visualize Alert通过Web界面或报表直观展示商品价格对比和趋势。更进一步可以设置价格阈值当某平台价格低于设定值时触发通知如邮件、钉钉消息。你的代码、数据库表设计、乃至任务调度都应该围绕这条数据流来组织。很多新手项目失败就是因为把90%的精力花在了“采集”上而忽略了数据如何被有效地存储、分析和使用。1.2 技术栈选型背后的逻辑基于上述流程我们选择的技术栈就有了明确的依据Django它不仅仅是一个Web框架。我们选择它主要是因为其强大的ORM对象关系映射、自带的后台管理Admin以及清晰的项目结构MVT。ORM能让我们用Python类来操作数据库极大地简化了数据存储和查询的逻辑Admin后台在开发初期是查看和管理爬取数据的绝佳工具无需额外开发。Requests 解析库如lxml, BeautifulSoupRequests是HTTP客户端的事实标准简单易用。对于页面解析如果页面结构复杂但规整lxml配合XPath性能更高如果页面结构随意BeautifulSoup的容错性更好。对于现代大量使用JavaScript渲染的网站可能需要引入Selenium或Playwright但这会显著增加复杂度和资源消耗应作为备选方案。MySQL关系型数据库适合存储结构化的商品信息、价格历史记录。利用其事务特性可以保证数据写入的完整性。对于超大规模历史数据后期可考虑按时间分表或迁移到时序数据库但初期MySQL完全够用。数据分析与可视化核心在Pandas和Matplotlib/Plotly。Pandas用于数据清洗、转换和聚合分析其DataFrame是内存中处理表格数据的利器。Matplotlib是基础绘图库Plotly则能生成交互性更强的网页图表与Django集成更友好。这个技术栈的搭配体现了“用合适的工具解决特定层次的问题”的原则并且它们都是Python生态内成熟、文档丰富的库学习成本和整合难度相对较低。2. 奠基用Django ORM设计可演进的数据模型数据库设计是系统的基石。设计时不仅要满足当前需求还要为未来的扩展留有余地。2.1 核心数据表设计我们至少需要两张核心表Product商品和PriceRecord价格记录。这是一种典型的一对多关系。# models.py from django.db import models class Platform(models.Model): 电商平台表 name models.CharField(max_length50, uniqueTrue, verbose_name平台名称) base_url models.URLField(verbose_name平台基础URL) # 可以增加平台特定的配置如请求头、编码等 class Meta: verbose_name 电商平台 verbose_name_plural verbose_name def __str__(self): return self.name class Product(models.Model): 商品信息表 name models.CharField(max_length255, verbose_name商品名称) # 商品唯一标识可以是型号、SKU或拼接平台与商品ID sku models.CharField(max_length100, db_indexTrue, verbose_name商品SKU/标识) url models.URLField(verbose_name商品链接) platform models.ForeignKey(Platform, on_deletemodels.CASCADE, verbose_name所属平台) image_url models.URLField(blankTrue, nullTrue, verbose_name图片链接) category models.CharField(max_length100, blankTrue, verbose_name商品分类) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间) updated_at models.DateTimeField(auto_nowTrue, verbose_name更新时间) class Meta: verbose_name 商品 verbose_name_plural verbose_name # 同一平台下SKU应该唯一 unique_together [platform, sku] def __str__(self): return f{self.name} ({self.platform.name}) class PriceRecord(models.Model): 价格历史记录表 product models.ForeignKey(Product, on_deletemodels.CASCADE, related_nameprice_records, verbose_name关联商品) price models.DecimalField(max_digits10, decimal_places2, verbose_name价格) # 记录促销信息如“满减”、“秒杀” promo_info models.CharField(max_length200, blankTrue, verbose_name促销信息) stock_status models.CharField(max_length50, default有货, verbose_name库存状态) crawled_at models.DateTimeField(auto_now_addTrue, verbose_name抓取时间) class Meta: verbose_name 价格记录 verbose_name_plural verbose_name # 排序方便查询最新价格 ordering [-crawled_at] # 同一商品在同一时间点不应该有重复记录根据业务精度可以是分钟级 unique_together [product, crawled_at] def __str__(self): return f{self.product.name}: ¥{self.price} at {self.crawled_at}设计要点分离平台信息将Platform独立建表便于管理多个平台未来增加平台时只需新增记录无需修改代码结构。商品唯一性unique_together [platform, sku]确保不会重复存储同一平台的同一商品。价格与商品分离PriceRecord表专门记录价格快照。这种设计使得查询某个商品的历史价格、计算价格波动变得非常高效。时间戳crawled_at精确记录抓取时间是分析时间序列数据的关键。2.2 利用Django Admin快速搭建数据管理后台在admin.py中注册模型你就能立即获得一个功能强大的后台。# admin.py from django.contrib import admin from .models import Platform, Product, PriceRecord admin.register(Platform) class PlatformAdmin(admin.ModelAdmin): list_display (name, base_url) admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display (name, sku, platform, category, updated_at) list_filter (platform, category) search_fields (name, sku) admin.register(PriceRecord) class PriceRecordAdmin(admin.ModelAdmin): list_display (product, price, promo_info, stock_status, crawled_at) list_filter (stock_status, crawled_at) # 价格记录可能很多设置只读以提高性能 readonly_fields (product, price, promo_info, stock_status, crawled_at)这个后台在开发阶段用于验证爬虫数据是否正确入库、手动补录或修正数据价值巨大。它省去了你为数据管理编写CRUD页面的时间。3. 核心攻坚构建稳健、可维护的网络爬虫爬虫是系统的数据入口也是最容易出问题的环节。我们不能只写一个能跑的脚本而要写一个能长期稳定运行的“数据采集服务”。3.1 请求与解析的工程化封装首先将请求和解析逻辑模块化、类化。# crawlers/base_crawler.py import requests from requests.exceptions import RequestException, Timeout import logging from time import sleep from urllib.parse import urljoin logger logging.getLogger(__name__) class BaseCrawler: 爬虫基类定义公共接口和工具方法 def __init__(self, platform_name, base_headersNone): self.platform_name platform_name self.session requests.Session() default_headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 } if base_headers: default_headers.update(base_headers) self.session.headers.update(default_headers) def fetch_page(self, url, methodGET, **kwargs): 发送HTTP请求包含基础错误处理和重试机制 max_retries 3 for attempt in range(max_retries): try: resp self.session.request(method, url, timeout10, **kwargs) resp.raise_for_status() # 检查HTTP状态码 # 检查编码避免乱码 if resp.encoding ISO-8859-1: resp.encoding resp.apparent_encoding or utf-8 return resp except Timeout: logger.warning(f[{self.platform_name}] 请求超时 ({attempt1}/{max_retries}): {url}) if attempt max_retries - 1: sleep(2 ** attempt) # 指数退避 except RequestException as e: logger.error(f[{self.platform_name}] 请求失败: {url}, 错误: {e}) break # 非超时错误直接退出重试循环 return None def parse_product_detail(self, html, product_url): 解析商品详情页提取价格等信息。子类必须重写此方法。 raise NotImplementedError(子类必须实现 parse_product_detail 方法) def crawl_product(self, product_url): 爬取单个商品页面的完整流程 resp self.fetch_page(product_url) if resp is None: return None return self.parse_product_detail(resp.text, product_url)# crawlers/jd_crawler.py (示例) from lxml import etree from .base_crawler import BaseCrawler import re class JDCrawler(BaseCrawler): def __init__(self): # 京东可能需要特定的请求头 headers { Referer: https://www.jd.com/, } super().__init__(platform_name京东, base_headersheaders) def parse_product_detail(self, html, product_url): 解析京东商品页 try: tree etree.HTML(html) # 使用XPath定位元素这里路径是示例实际需要分析页面 name tree.xpath(//div[classsku-name]/text()) name name[0].strip() if name else 未知商品 # 价格可能在脚本中需要正则提取 price_match re.search(rp:(\d\.?\d*), html) price float(price_match.group(1)) if price_match else 0.0 # 促销信息 promo tree.xpath(//div[contains(class, promo-words)]/text()) promo_info promo[0].strip() if promo else # 库存状态 stock tree.xpath(//div[idstore-prompt]/strong/text()) stock_status stock[0].strip() if stock else 有货 return { name: name, price: price, promo_info: promo_info, stock_status: stock_status, url: product_url } except Exception as e: logger.error(f[京东] 解析页面失败: {product_url}, 错误: {e}) return None关键点基类封装BaseCrawler处理了网络请求、重试、超时、编码等通用且易出错的环节。会话保持使用requests.Session()可以复用TCP连接提升效率并保持Cookies。错误处理与日志完善的异常捕获和日志记录是线上爬虫的“生命线”。没有日志出了问题你根本不知道死在哪里。子类实现每个平台的解析逻辑单独写成子类。这样当某个网站的页面结构变化时你只需要修改对应的子类不会影响其他平台。3.2 应对反爬策略从简单到复杂反爬是爬虫的永恒课题。策略应由简到繁避免过度设计。基础措施User-Agent使用常见的浏览器UA。请求间隔在批量抓取时使用time.sleep(random.uniform(1, 3))增加随机延迟避免请求过快。Referer模拟从站内跳转过来的请求。中级措施处理CookiesSession会自动管理。对于需要登录的页面可以先模拟登录获取Cookies。IP代理池当遇到IP封锁如429状态码时这是最有效的解决方案。可以使用付费代理服务或自建代理池。代码上只需在fetch_page方法中为请求配置代理参数proxies。处理动态内容如果价格数据由JavaScript异步加载简单的requests无法获取。此时需要引入Selenium或Playwright来模拟浏览器行为。但这会大幅增加资源开销和运行时间应仅作为最后手段。高级策略解析API通过浏览器开发者工具的“网络”选项卡寻找页面加载时调用的数据接口XHR/Fetch。直接请求这些API接口效率远高于解析HTML。验证码遇到验证码通常意味着触发了网站的反爬机制。首要任务是优化请求策略如放慢速度、使用代理来避免触发。万不得已时可考虑接入打码平台。重要提醒在编写和运行爬虫前务必阅读目标网站的robots.txt文件并尊重其爬取规则。控制抓取频率避免对目标网站服务器造成过大压力。4. 数据流转与任务调度连接爬虫、数据库与业务逻辑爬虫类写好了数据模型也定义了现在需要写一个“服务层”来把它们粘合起来并管理抓取任务。4.1 编写数据服务层创建一个服务模块负责调用爬虫并将结果存入数据库。# services/price_crawl_service.py import logging from django.utils import timezone from crawlers.jd_crawler import JDCrawler from crawlers.tb_crawler import TBCrawler # 假设有淘宝爬虫 from ..models import Product, PriceRecord, Platform logger logging.getLogger(__name__) class PriceCrawlService: _crawler_map { 京东: JDCrawler, 淘宝: TBCrawler, # ... 其他平台 } classmethod def get_or_create_product(cls, product_info, platform_obj): 根据爬取到的信息获取或创建Product对象 product, created Product.objects.get_or_create( platformplatform_obj, skucls._generate_sku(product_info[url]), # 需要实现一个从URL生成SKU的方法 defaults{ name: product_info[name], url: product_info[url], category: product_info.get(category, ), image_url: product_info.get(image_url, ), } ) if not created: # 如果商品已存在可以更新部分信息如名称可能变化 product.name product_info[name] product.url product_info[url] product.save() return product staticmethod def _generate_sku(url): 一个简单的示例从URL中提取商品ID作为SKU # 例如京东URL: https://item.jd.com/123456.html import re match re.search(r/(\d)\.html, url) return match.group(1) if match else url classmethod def crawl_and_save_single(cls, product_url, platform_name): 爬取单个商品URL并保存结果 crawler_class cls._crawler_map.get(platform_name) if not crawler_class: logger.error(f不支持的平台: {platform_name}) return False try: platform_obj, _ Platform.objects.get_or_create(nameplatform_name) crawler crawler_class() product_info crawler.crawl_product(product_url) if not product_info: logger.warning(f爬取失败: {product_url}) return False product cls.get_or_create_product(product_info, platform_obj) # 创建价格记录 PriceRecord.objects.create( productproduct, priceproduct_info[price], promo_infoproduct_info.get(promo_info, ), stock_statusproduct_info.get(stock_status, 有货), crawled_attimezone.now() ) logger.info(f成功抓取并保存: {product.name} - ¥{product_info[price]}) return True except Exception as e: logger.exception(f处理商品URL时发生未预期错误: {product_url}, 错误: {e}) return False这个服务类封装了从爬取到存储的完整业务逻辑使视图或任务调度代码更加清晰。4.2 实现任务调度比价系统需要定期执行。我们有多种选择Django Management Command最Django的方式。创建一个自定义命令python manage.py crawl_prices然后通过系统的CronLinux或计划任务Windows来定时调用它。适合对实时性要求不高的场景如每小时一次。# management/commands/crawl_prices.py from django.core.management.base import BaseCommand from services.price_crawl_service import PriceCrawlService from django.conf import settings class Command(BaseCommand): help 定时爬取所有监控商品的价格 def handle(self, *args, **options): # 从数据库或配置文件中读取需要监控的商品URL列表 monitored_items [ {url: https://item.jd.com/100000000001.html, platform: 京东}, {url: https://detail.tmall.com/item.htm?id200000000002, platform: 淘宝}, ] for item in monitored_items: success PriceCrawlService.crawl_and_save_single(item[url], item[platform]) if success: self.stdout.write(self.style.SUCCESS(f成功处理: {item[url]})) else: self.stdout.write(self.style.WARNING(f处理失败: {item[url]}))Celery Redis/RabbitMQ这是生产环境更推荐的方式尤其是当抓取任务多、耗时长时。Celery是分布式任务队列你可以将抓取任务异步化并设置定时任务Celery Beat。它提供了更好的失败重试、任务监控和水平扩展能力。APScheduler一个纯Python的轻量级任务调度库可以直接在Django应用启动时运行。它比Celery简单适合中小型项目。选择哪种方案取决于你的数据量、实时性要求和运维复杂度。对于初学者从Django Command Cron开始是最稳妥的。5. 从数据到洞察利用Pandas进行分析与可视化数据存进MySQL后真正的价值在于分析。Django ORM适合做CRUD但复杂的数据聚合分析还是Pandas更顺手。5.1 使用Pandas进行数据分析我们可以写一个工具函数将指定商品的历史价格数据从数据库加载到DataFrame中进行分析。# services/price_analysis_service.py import pandas as pd from django.db import connection from django.utils import timezone from datetime import timedelta class PriceAnalysisService: staticmethod def get_price_history_dataframe(product_id, days30): 获取某个商品最近N天的价格数据返回Pandas DataFrame from ..models import PriceRecord # 使用Django ORM查询然后转换为DataFrame end_date timezone.now() start_date end_date - timedelta(daysdays) records PriceRecord.objects.filter( product_idproduct_id, crawled_at__gtestart_date ).select_related(product).values( crawled_at, price, promo_info, stock_status ).order_by(crawled_at) if not records: return pd.DataFrame() df pd.DataFrame.from_records(records) df[crawled_at] pd.to_datetime(df[crawled_at]) df.set_index(crawled_at, inplaceTrue) return df staticmethod def analyze_product_price(product_id): 分析单个商品的价格数据返回统计指标 df PriceAnalysisService.get_price_history_dataframe(product_id) if df.empty: return {} analysis { current_price: df[price].iloc[-1], min_price: df[price].min(), max_price: df[price].max(), avg_price: df[price].mean(), price_std: df[price].std(), # 价格波动标准差 last_lowest_date: df[price].idxmin().strftime(%Y-%m-%d %H:%M), price_trend: 稳定 if df[price].std() 5 else 波动较大, # 简单趋势判断 } # 计算近期价格变化例如最近一次 vs 24小时前 if len(df) 1: recent_change df[price].iloc[-1] - df[price].iloc[-2] analysis[recent_change] round(recent_change, 2) analysis[change_percent] round((recent_change / df[price].iloc[-2]) * 100, 2) return analysis staticmethod def compare_products(product_ids): 比较多个商品可能是同一商品在不同平台的当前价格 from ..models import Product, PriceRecord # 获取每个商品的最新价格记录 latest_records PriceRecord.objects.filter( product_id__inproduct_ids ).distinct(product).order_by(product, -crawled_at) # 注意上述distinct用法可能因数据库而异另一种写法是使用子查询 comparison_data [] for record in latest_records: comparison_data.append({ product_name: record.product.name, platform: record.product.platform.name, latest_price: record.price, promo: record.promo_info, stock: record.stock_status, time: record.crawled_at, }) return pd.DataFrame(comparison_data)5.2 在Django视图中集成可视化有了分析结果和DataFrame我们可以用Matplotlib或Plotly生成图表并在Django模板中展示。# views.py from django.shortcuts import render from django.http import JsonResponse import io, base64 import matplotlib.pyplot as plt import matplotlib matplotlib.use(Agg) # 设置后端避免GUI错误 from services.price_analysis_service import PriceAnalysisService from .models import Product def product_price_chart(request, product_id): 生成商品价格历史折线图 df PriceAnalysisService.get_price_history_dataframe(product_id) if df.empty: return JsonResponse({error: No data}) plt.figure(figsize(10, 6)) plt.plot(df.index, df[price], markero, linestyle-, linewidth2) plt.title(f商品价格历史趋势) plt.xlabel(抓取时间) plt.ylabel(价格 (元)) plt.grid(True, linestyle--, alpha0.7) plt.xticks(rotation45) plt.tight_layout() # 将图表保存为字节流并编码为base64 buffer io.BytesIO() plt.savefig(buffer, formatpng) buffer.seek(0) image_png buffer.getvalue() buffer.close() graphic base64.b64encode(image_png).decode(utf-8) # 也可以直接返回图片响应 # return HttpResponse(image_png, content_typeimage/png) # 这里返回base64字符串方便在HTML中嵌入 return JsonResponse({chart_image: fdata:image/png;base64,{graphic}}) def price_dashboard(request): 价格监控仪表盘视图 # 获取所有被监控的商品 monitored_products Product.objects.all().select_related(platform) product_list [] for prod in monitored_products: analysis PriceAnalysisService.analyze_product_price(prod.id) product_list.append({ product: prod, analysis: analysis, latest_record: prod.price_records.first() # 因为ordering是-crawled_at第一条即最新 }) # 获取跨平台比价数据例如同一个SKU在不同平台 # 这里需要你根据业务逻辑来分组例如按商品名称或自定义分组ID # comparison_df PriceAnalysisService.compare_products([...]) context { product_list: product_list, # comparison_df: comparison_df.to_html(classestable table-striped) if not comparison_df.empty else None, } return render(request, price_dashboard.html, context)对应的模板price_dashboard.html可以使用Bootstrap等前端框架来美化通过AJAX调用product_price_chart视图来加载图表。6. 系统优化与部署考量一个能跑起来的原型和一个能稳定运行的系统之间隔着许多工程细节。6.1 性能与稳定性优化数据库优化索引为PriceRecord表的product_id和crawled_at字段添加复合索引能极大加速按商品查询历史价格的效率。归档价格记录会随时间快速增长。可以定期如每月将超过一年的历史数据迁移到归档表或者直接删除以保持主表查询性能。爬虫优化异步抓取使用asyncioaiohttp可以实现异步HTTP请求在I/O等待时切换任务大幅提升抓取大量商品页面的速度。连接池requests.Session本身会保持连接池。在使用异步或分布式爬虫时更需要管理好连接池避免对目标网站造成DoS攻击。请求去重与优先级队列使用Redis的集合Set实现URL去重使用有序集合ZSet实现优先级队列管理待抓取任务。错误处理与监控Sentinel哨兵任务创建一个单独的任务定期检查核心爬虫任务是否成功执行失败时发送告警邮件、钉钉/webhook。详细日志记录每个抓取任务的开始、结束、成功、失败及原因。使用Django的logging模块将日志输出到文件并配置日志轮转。6.2 部署与持续运行环境隔离使用virtualenv或pipenv创建独立的Python环境。使用Docker进行容器化部署是更现代和一致的选择。配置管理将数据库密码、API密钥、代理IP列表等敏感信息放在环境变量或settings.py之外的配置文件中如.env切勿硬编码。进程管理如果使用Celery需要用supervisor或systemd来管理Celery Worker和Beat进程确保它们崩溃后能自动重启。数据备份定期备份MySQL数据库。可以利用云数据库的自动备份功能或自行编写脚本。构建一个电商比价系统从技术上看是多个成熟组件的拼接但真正的挑战在于如何让这些组件协同工作形成一个稳定、可维护、能应对变化的数据流水线。它不是一个一蹴而就的脚本而是一个需要持续迭代和维护的“产品”。从这个项目出发你可以延伸到更广阔的领域比如监控竞品库存、自动跟踪全网好评率、甚至结合价格预测模型进行采购决策。技术的价值最终体现在它如何精准地解决一个具体的业务问题上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价