简介:一份聚焦电商数据采集的Python爬虫分析系统源码,面向高校学生、课程设计与毕业设计人群,适合用来学习爬虫开发与Django项目搭建。系统实现了对京东、淘宝、苏宁、亚马逊中国四个主流电商平台的商品信息抓取,字段涵盖商品名称、商家名称、商品ID、价格、评分、评论数量、图片、库存地址等,抓取结果可自动写入数据库,并支持按关键词、页数和搜索索引灵活控制采集范围。源码包共两千个文件,其中以一千七百三十七个Python源文件为核心,涵盖爬虫脚本、Django视图与模型;另有一百一十七个HTML模板用于后台页面展示,三十个JavaScript与九个CSS文件完善前端交互,四十九个TXT文档提供说明与配置参考,整体压缩后约一百三十六MB,结构清晰便于按模块研读。目前已有五十七人学习。对需要快速搭建电商数据采集与分析原型的读者,这套源码提供了完整的抓取、入库、展示闭环,是一份值得动手拆解的课程设计或毕业设计参考材料。
1. 为什么多平台电商爬虫必须做成 Django 工程
一次课程设计让我意识到,用单个脚本去抓京东、淘宝、苏宁和亚马逊中国并不是最难的,难的是:每个平台翻页参数不同,同一商品在不同平台价格标签不一样,抓到 10 万条数据后如果还是躺在 CSV 里,根本没法做竞品数据分析。于是我把这个爬虫做成了 Django 项目,抓取、清洗、入库、查询、分析都放在同一个工程里。对这个项目而言,Django 不只是后台框架,它把 requests 爬虫、ORM 存储和数据分析串成一条完整链路。适合正在做 python 作业、课程设计或毕业设计的人,也适合想知道电商竞品数据怎么落到数据库里做进一步分析的工程师。
2. 商品解析与并发抓取:requests 封装与平台差异适配
2.1 四个电商平台的请求差异
京东、淘宝、苏宁、亚马逊中国这四个平台的搜索 URL 和翻页字段几乎没有完全一致的。源码里把每个平台抽成了独立的 request builder,核心思想是“统一入口,各自实现”。以京东为例,搜索请求在抓包里通常是search.jd.com/Search带上keyword、page和enc=utf-8;淘宝更依赖 Cookie 和签名参数,q是关键词入口;苏宁经常把关键词拼在搜索路径里;亚马逊中国则习惯用k参数和b-page这类翻页字段。这些字段一旦改版就会失效,所以源码里把每个平台的请求构造集中在一个函数里,方便单独修改。
如果只写一个requests.get然后到处 copy,后面两个维护成本会很高。常见做法是每个平台一个build_params(platform, keyword, page, index)方法,返回平台对应的参数字典,这样上层调度逻辑完全不用感知每个平台的差异。
2.2 关键词、页数、搜索索引各负责什么
关键词就是搜索词,直接对应业务侧想监控的商品类目,例如“机械键盘”“洗面奶”。页数控制抓取深度,一般从第 1 页开始,抓完一页再翻下一页,直到达到预定的pages数量。搜索索引在源码里承担两个作用:有些平台用它定位排序方式,例如综合排序、销量排序;有些平台用它作为分页偏移量,比如先抓index=0的那批,再抓index=1的那批。抓这类数据时务必把关键词、页数、索引同时记录到数据库,否则后面做竞品数据分析时根本不知道这条记录来自哪个搜索条件。
这里的核心思路是让抓取任务可复现。常见错误是为了省事只存商品信息,不存keyword和platform字段,结果同一个商品被不同关键词抓到多次,去重逻辑无法判断该保留哪一条。
2.3 requests Session 封装与重试代码
直接使用requests.get会在每个请求新建连接,抓几十页后 TCP 连接开销很大。项目里一般会用requests.Session复用连接,再挂上 urllib3 的重试策略。
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def make_session(retries=3): session = requests.Session() retry_cfg = Retry( total=retries, backoff_factor=1, status_forcelist=[500, 502, 503, 504], allowed_methods=["GET"], ) adapter = HTTPAdapter(max_retries=retry_cfg) session.mount("https://", adapter) session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", }) return session session = make_session() def fetch_search_page(platform, keyword, page, index=0): # 平台差异集中在 build_params 中 params = build_params(platform, keyword, page, index) resp = session.get(entry_url(platform), params=params, timeout=8) resp.raise_for_status() return resp.text这段代码的关键点是:Retry 只对 GET 请求生效,因为电商搜索场景不允许请求被自动重复提交造成副作用;timeout=8是保守值,移动网络或平台慢时可以调到 15,但不要不给 timeout,否则一个卡死请求会让整个任务挂住。Session 保持 Cookie 对淘宝这类依赖 Cookie 的站点尤其重要。需要说明的是,build_params和entry_url需要按源码里的平台配置实现,京东、淘宝、苏宁、亚马逊各有不同的参数映射表。
| 参数 | 含义 | 常见取值 | 注意事项 |
|---|---|---|---|
| platform | 平台标识 | jd / taobao / suning / amazon | 影响 entry_url 和 build_params 分支 |
| keyword | 搜索关键词 | 任意字符串 | requests 会自动做 URL 编码 |
| page | 页面序号 | 1, 2, 3... | 部分平台 page 要乘以 2 |
| index | 搜索索引/排序偏移 | 0, 1, 2... | 不是所有平台都有,没有则留 0 |
注意:实际页面字段经常改版,以抓包结果为准,不要长期写死某一年份的参数名。
2.4 用线程池抓多页时别忽略限速
抓 100 页商品列表,逐页串行可能要几分钟;用concurrent.futures.ThreadPoolExecutor可以把时间缩短到几十秒。但并发数需要根据目标站点忍耐度决定。源码里我一般把线程数控制在 5 以下,再加上后面第 5 章的限速器,避免被平台识别成攻击流量。并发抓取时每个线程必须使用同一个 Session 吗?不,requests.Session不是严格线程安全的,常见做法是每个线程建一个独立 Session,或者用 thread-local 存放 Session。每一个抓取结果都通过同一个入库函数写入数据库,避免多个线程各自维护缓存。
这里再补充一个判断标准:如果你用 20 个线程抓 100 页但几乎没有报错,说明该平台给列表页的阈值很高;如果出现大量 418、429 或验证码页面,优先把线程数降下来,而不是去绕验证码。后面第 5 章会展开讲这类限速逻辑。
3. 数据入库与清洗:从 HTML 片段到可分析的 Product 表
3.1 为什么用 Django ORM 而不是 pandas
抓下来的数据经过 BeautifulSoup 或 lxml 解析后,是内存里的一组字典。如果只用 pandas 做分析,每次重跑脚本都要重新抓一遍,数据没有增量更新能力。Django ORM 的优势在项目里体现为三点:一是表结构由models.py统一管理,字段变化时用 migration 平滑升级;二是写入时可以直接做唯一键去重,避免重复记录;三是后面做按平台、关键词、时间聚合分析时,Django 的 query set 可以直接翻译成 SQL,效率比逐行遍历列表高。
所以这个项目的存储层选择了 SQLite/MySQL 加 Django ORM。开始抓取前先定义好模型。
3.2 Product 模型与字段设计
商品基本信息包括名称、商家、商品 ID、价格、评分、评论数量、图片和库存地址,这些在模型里都要有对应字段,并且把platform、keyword、product_id组合成唯一键。这样同一个商品在不同平台能独立存在,同一个平台抓同一个关键词也不会重复插入。
from django.db import models class Product(models.Model): platform = models.CharField(max_length=16, db_index=True) keyword = models.CharField(max_length=128, db_index=True) product_id = models.CharField(max_length=64) name = models.CharField(max_length=255) shop = models.CharField(max_length=128, blank=True) price = models.FloatField(null=True, blank=True) rating = models.FloatField(null=True, blank=True) comment_count = models.IntegerField(null=True, blank=True) image_url = models.URLField(blank=True) stock_address = models.CharField(max_length=128, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = [("platform", "product_id", "keyword")]字段设置上的关键点是:price 用 FloatField 而不是 DecimalField,原因是异步抓取过程中价格经常出现缺值,FloatField 的 null 处理更轻;如果做财务级精度,再升级成 DecimalField。rating 同样允许为空,因为苏宁部分商品不一定有评分。唯一键的第三个字段选keyword是个细节:同一个商品在不同关键词下可能出现在不同搜索结果中,如果unique_together里没有 keyword,第一次解析到这条商品后,第二次用另一个关键词抓到它时会被当作重复而跳过,导致搜索条件和商品关系的关联丢失。
3.3 清洗逻辑:价格、评论数和评分
平台的原始文本几乎不适合直接入库。价格可能出现“¥2,399”“2399元”,评论数可能出现“2.3万+”“--”,评分会有“4.8分”。这里要做两层清洗:第一层提取数字,第二层处理单位。
import re def clean_price(value): if not value: return None text = str(value).replace("¥", "").replace("元", "").replace(",", "").strip() try: return float(text) except ValueError: return None def clean_count(value): if not value or "暂无" in str(value): return 0 text = str(value).replace("+", "").strip() m = re.search(r"([\d.]+)(万)?", text) if not m: return 0 num = float(m.group(1)) if m.group(2) == "万": num *= 10000 return int(num) def clean_rating(value): if not value: return None m = re.search(r"([\d.]+)", str(value)) return float(m.group(1)) if m else Noneclean_price的目标是兼容人民币符号、中文字符和千分位逗号;float 转换失败时返回 None,而不是抛异常中断新一轮抓取。clean_count把“2.3万+”先拆出 2.3 再乘 10000,最后转成整数。clean_rating只取字符串中的第一个浮点数,所以“4.8分”和“4.8/5”都能得到 4.8。这三个函数全部设计成“异常输入返回空值”,这样调度循环不会因为一条脏数据崩掉。常见错误是清洗函数里抛 ValueError,导致一万条里一条数据出问题,整个循环停掉。这是处理脏数据的常见坑。
| 原始字段 | 清洗函数 | 输入示例 | 输出 |
|---|---|---|---|
| price | clean_price | "¥2,399" | 2399.0 |
| comment_count | clean_count | "2.3万+" | 23000 |
| comment_count | clean_count | "暂无评价" | 0 |
| rating | clean_rating | "4.8分" | 4.8 |
| rating | clean_rating | "-" | None |
3.4 入库与幂等去重
保存数据时使用update_or_create,它的语义是“存在就更新,不存在就插入”,前提是 objects 能通过唯一键定位到记录。
def save_product(data): filtered = { "name": data["name"], "shop": data.get("shop", ""), "price": clean_price(data.get("price")), "rating": clean_rating(data.get("rating")), "comment_count": clean_count(data.get("comment_count")), "image_url": data.get("image_url", ""), "stock_address": data.get("stock_address", ""), } obj, created = Product.objects.update_or_create( platform=data["platform"], product_id=data["product_id"], keyword=data["keyword"], defaults=filtered, ) return obj, createdupdate_or_create的三个定位字段来自唯一键,defaults里放动态变化的信息。第一次抓某关键词时created为 True,第二次再抓同一关键词同商品时created为 False,不会产生第二条记录。这种方式也让后续抓取任务具备幂等性:重跑一次任务不会把数据库撑爆。
数据入库后要验证一下字段分布,比如价格是否为负数、评论数是否超过合理范围。常见做法是抓完后直接执行一条聚合查询,看 price 为 NULL 的比例是否超过 20%,超过就说明解析器的价格选择器可能失效了。
4. Django 管理命令与查询 API:把爬虫变成可复用任务
4.1 用 Management Command 替代手动运行脚本
如果爬虫逻辑写在 views.py 里,每次抓取都要开 Web 服务,很不合理。Django 的 management command 能让抓取任务在命令行触发,同时复用项目的 settings、数据库配置和 ORM。源码里将抓取入口封装成python manage.py crawl,这在课程设计和毕业设计里也是比较容易讲清楚的结构。
命令文件的目录结构:
app/ management/ __init__.py commands/ __init__.py crawl.py两个__init__.py缺一不可,否则 Django 找不到命令。
4.2 支持 --platform、--keyword、--pages、--index 的命令实现
工程中需要把抓取流程参数化。下面是一个标准实现:
from django.core.management.base import BaseCommand from app.crawler import run_crawl class Command(BaseCommand): help = "抓取京东、淘宝、苏宁、亚马逊商品信息" def add_arguments(self, parser): parser.add_argument("--platform", required=True, choices=["jd", "taobao", "suning", "amazon"]) parser.add_argument("--keyword", required=True) parser.add_argument("--pages", type=int, default=1) parser.add_argument("--index", type=int, default=0) def handle(self, *args, **options): total = run_crawl( platform=options["platform"], keyword=options["keyword"], pages=options["pages"], index=options["index"], ) self.stdout.write(self.style.SUCCESS(f"完成,共写入/更新 {total} 条"))执行示例:
python manage.py crawl --platform jd --keyword "机械键盘" --pages 3 --index 0这里把--platform限定为四个固定值,避免手误。--pages控制抓多少页,--index传入搜索索引,帮助脚本在结果不完整时重新定位排序偏移。run_crawl的返回值用于命令成功后的反馈。需要注意:在写命令文件时,不能直接在 handle 里放一堆反复出现的解析逻辑,应把抓取循环放到app/crawler.py,方便单元测试。
4.3 基于 Django ORM 的竞品数据分析接口
数据抓到库里之后,最实用的功能是同一关键词在不同平台的均价对比。常见做法是提供一个 JSON 接口,返回聚合结果。
from django.http import JsonResponse from django.db.models import Avg, Max, Min, Count def price_compare(request): keyword = request.GET.get("keyword", "").strip() if not keyword: return JsonResponse({"error": "keyword is required"}, status=400) rows = (Product.objects.filter(keyword=keyword) .values("platform") .annotate( avg_price=Avg("price"), max_price=Max("price"), min_price=Min("price"), product_count=Count("id"), ) .order_by("platform")) return JsonResponse(list(rows), safe=False).values("platform")告诉 ORM 按 platform 分组;.annotate为每个组计算 avg_price、max_price、min_price 和 product_count。这几句会翻译成带 GROUP BY 的 SQL,比把全表拉进 Python 再循环统计快得多。返回的 JSON 可直接被前端表格或 pandas 继续处理,这也是“电商数据分析”在管理后台落地最快的方式。
| 参数 | 类型 | 说明 |
|---|---|---|
| keyword | 必填字符串 | 搜索关键词,为空时返回 400 |
| 分组字段 | platform | 京东、淘宝、苏宁、亚马逊四平台 |
| 聚合字段 | avg_price/max_price/min_price | 价格区间快速对比 |
| product_count | 聚合计数 | 可用于判断平台数据量是否足够 |
4.4 任务日志与断点续抓
命令式的爬虫跑起来之后,日志比 print 重要。否则后台执行命令时看不到输出,出了问题也不知道卡在哪一页。通常在 run_crawl 里记录每个平台每个页面的开始和结束。可以配合 Python logging 来做。
import logging logger = logging.getLogger("crawler") def run_crawl(platform, keyword, pages, index): from app.models import Product from app.parsers import parse_platform logger.info("start platform=%s keyword=%s pages=%d", platform, keyword, pages) total = 0 for page in range(1, pages + 1): html = fetch_search_page(platform, keyword, page, index) items = parse_platform(platform, html, keyword, page) for item in items: save_product(item) total += 1 logger.info("page=%d total=%d", page, total) return total这里fetch_search_page是第 2 章的会话函数,parse_platform按平台解析列表页。logger 输出会包含时间戳,方便后续用 grep 分析每个页面的耗时。断点续抓的逻辑也可以挂在这里:如果某页网络异常,记录到日志后继续下一页,而不是让整个命令中途退出。
5. 反爬应对与频率控制:requests 爬虫不只要会请求
5.1 平台常见的反爬信号
电商列表页最常见的反爬不是验证码,而是对请求身份和节奏的校验。缺 User-Agent 的请求很可能直接 403;短时间高频请求会触发 429 或回到验证码页;不带 Referer 的搜索请求在某些平台会被拒绝。这些都是 requests 爬虫层面的基础问题。源码里要求每个平台请求都走统一 Session,并在 headers 中带上 UA、Accept、Accept-Language、Referer。Referer 一般指向该平台首页,让请求看起来是从浏览器入口流入的。
有些平台还会检查 Cookie 中是否包含特定字段,例如淘宝需要先访问首页拿到几个必要 Cookie,再带 Cookie 去请求搜索页。这套流程在爬虫里通常体现为“预热请求”。但有一个边界要清楚:如果平台要求登录、验证码、滑块,不要尝试绕过,应该停止抓取并记录异常。课程设计级项目把公开可见数据抓下来分析没什么问题,但不能把“对抗验证码”作为卖点。
5.2 限速器:控制 QPS 而不是无限加并发
很多新手在遇到封禁时第一反应是加大线程数,这恰好和平台预期相反。requests 爬虫应该控制不同线程之间发请求的时间间隔。下面是一个简单的限速器,按函数级别生效。
import threading import time import random class RateLimiter: def __init__(self, min_interval=1.0, jitter=0.5): self.min_interval = min_interval self.jitter = jitter self.lock = threading.Lock() self.last_ts = 0.0 def wait(self): with self.lock: now = time.time() wait = self.min_interval + random.uniform(0, self.jitter) - (now - self.last_ts) if wait > 0: time.sleep(wait) self.last_ts = time.time() limiter = RateLimiter(min_interval=1.0, jitter=0.5)调用方式是在fetch_search_page发起请求之前先执行limiter.wait()。min_interval表示理论上的最小请求间隔,jitter添加 0~0.5 秒随机抖动,避免请求间隔过于规律而被识别。把锁放在限速器内部是为了多线程场景下多个线程不会同时通过等待点。极端情况下,如果线程数很大,这个限速器会排队,导致并发收益下降。所以实际配置里线程数一般不超过 5,限速间隔取 0.8 到 1.5 秒。
| 场景 | 线程数 | 请求间隔 | 说明 |
|---|---|---|---|
| 单平台快速演示 | 1 | 0.5s | 只抓 1~2 页,风险低 |
| 课程设计完整抓取 | 3 | 1.0s | 平衡速度与稳定性 |
| 多关键词批量监控 | 5 | 1.5s | 长时间运行时降低触发频率 |
| 分布式爬虫 | 多机 | 每机限速 | 需要额外队列和调度中心 |
5.3 失败重试但不是无限重试
requests 库的 Retry 在第 2 章已经出现,但这里需要明确重试边界。超时和 5xx 可以重试,403、429、验证码页面不能盲目重试。403 重试大概率还是 403;429 需要等待一段时间,如果 Retry 的 backoff 策略不合适,反而会加重被限速的印象。因此代码里对状态码做一个分级。
from requests.exceptions import HTTPError def fetch_with_limited_retry(url, params=None, session=None, max_attempts=3): for attempt in range(max_attempts): limiter.wait() try: resp = session.get(url, params=params, timeout=8) if resp.status_code == 403: logger.warning("403 forbidden, url=%s", resp.url) return None if resp.status_code in (429, 503): logger.warning("rate limited, wait longer, attempt=%d", attempt + 1) time.sleep(5 * (attempt + 1)) continue resp.raise_for_status() return resp.text except requests.Timeout: if attempt == max_attempts - 1: raise return None注意到 429 和 503 是加重退避等待,再进入下一轮循环;403 直接返回 None,不再浪费请求。timeout 异常保留在最后一轮向上抛,让外层抓到异常后记录是哪一页、哪个关键词出的问题。这样可以保证不重试封禁响应,也避免异常被静默吞掉。
5.4 合规提醒:robots、公开数据与使用范围
这一节不需要展开太大篇幅。抓取公开商品信息用于学习、课程设计、竞品价格统计是比较常见的场景,但要注意:目标站点的 robots.txt 如果明确禁止爬取,尽量只抓少量样例;不要抓取用户私人数据;不要批量下载图片后二次分发。源码中对图片 URL 只做存储,不主动下载,就是这个原因。频率控制也不是为了对抗平台,而是为了不干扰正常用户访问。
合规边界清晰之后,再上分布式爬虫才有意义。如果只是课程设计,单机加限速已经足够;如果想把数据量做到几十万上百万,才需要考虑把抓取任务拆分到多个机器,用一个共享队列做任务分发,这就是另一个话题了。
6. 抓取结果验证与运维技巧
6.1 用计数 SQL 验证抓取任务是否完成
抓完 3 页京东商品,最怕的不是代码 bug,而是解析器对页面结构判断错误,实际只入库了半页数据。验证方法很简单,直接查数据库:
python manage.py shell -c " from app.models import Product from django.db.models import Count for row in Product.objects.values('platform', 'keyword').annotate(total=Count('id')): print(row['platform'], row['keyword'], row['total']) "如果 total 接近 0,说明解析器没有匹配到商品列表;如果 total 明显小于 3 页应有的条数,检查是不是第二页和第三页 URL 构造错误,或者页面提示“无搜索结果”。这个技巧比看日志更直接,因为入库条数是最终产物。
6.2 抓取完成后核对价格异常值
入库后执行一次聚合:
from app.models import Product from django.db.models import Min, Max print(Product.objects.aggregate(lo=Min('price'), hi=Max('price')))如果发现价格最小值是 0,或者最大值达到千万级,多半是清洗函数把促销文案中的数字误判成了价格。这类异常要在全部数据进入分析前修掉,否则后面均价会被几个异常点拉偏。另外可以写一个临时脚本把所有价格为 0 或为空的商品打印出来,定位是哪个平台的问题。京东通常没有价格的商品很少,淘宝二手商品价格可能为 1,苏宁和亚马逊偶尔会有“暂无报价”。过滤规则要和业务对齐。
6.3 增量抓取的简单实现
重复执行同一关键词和页数不会重复入库,但会覆盖价格、评论数等动态字段。要想控制数据膨胀,可以在分析时加上created_at过滤。用一个查询仅取最近一次抓取的结果:
from django.utils import timezone from datetime import timedelta recent = Product.objects.filter( created_at__gte=timezone.now() - timedelta(hours=24) )如果后续需要精确处理同关键词的多次抓取,可以考虑增加task_id字段,把每次命令执行作为一个批次。这样即使同一个商品重复入库,也能通过task_id区分批次。注意在目前的唯一键设计下,同一个 task 的重复更新语义是覆盖,而不是追加,所以做时间序列趋势分析时需要在模型上再加created_at并取消 keyword 唯一约束。
6.4 排查页面结构变化的一个习惯
最后说一个省时间的动作:当抓取结果为空时,不要急着改代码,先打印原始 HTML 里是否存在目标关键字段。在fetch_search_page返回后随手执行:
python manage.py shell -c " from app.crawler import session html = session.get('https://search.jd.com/Search', params={'keyword': '机械键盘', 'enc': 'utf-8'}, timeout=8).text print(len(html)) print('机械键盘' in html) "如果 len(html) 只有几千字节,说明很可能被重定向到验证码或安全页;如果关键词在 html 里但解析器没提取到数据,问题就出在正则或 BeautifulSoup 选择器上。这个二分排查法能快速把问题定位到网络层还是解析层,是维护爬虫项目时最实用的习惯。
本文还有配套的精品资源,点击获取