京东爬虫实战:从SKU解析到反爬对抗与分布式采集
简介JDspider是一份基于Python编写的京东商品价格监控爬虫项目面向有一定基础、希望深入练习电商数据采集与反爬应对的开发者也适合用于个人购物决策、市场价格观察等场景。项目以单个py文件覆盖完整流程包含requests发送请求、BeautifulSoup解析HTML、schedule定时任务、smtplib邮件通知等环节并针对京东动态加载内容给出Selenium或Pyppeteer的处理思路同时兼顾异常重试、访问频控与合规性说明。资源包为rar压缩格式整体仅2KB内含1个py文件代码结构紧凑便于快速阅读和二次改造。已有421人学习下载适合作为入门电商爬虫的综合案例。通过这份脚本可系统掌握网络请求、HTML解析、定时调度、邮件提醒和异常处理的完整技术链路还可结合pandas、SQLite或CSV强化数据存储引入代理池提升稳定性最终实现商品降价自动通知的完整闭环。1. JDspider 拆解不只是一个京东爬虫脚本而是一整套能落地的反爬对抗方案做爬虫的都知道京东在电商站点里属于“看着好抓、实际费劲”的那一档。商品详情、价格、评论分散在好几个域名下SKU 编码里藏着状态位和价格开关登录要过滑块验证请求要带 H5ST 签名稍不注意 IP 就被风控盯上。JDspider 这个项目正好把这些痛点全踩了一遍它把京东 PC 端和 H5 端的核心接口做了封装从 SKU 解析、Cookie 维护到商品和评论数据抽取给了一套可以直接跑的 Python 实现。适合刚入门爬虫、想拿电商数据练手的新手也适合需要快速搭一个京东数据采集模块、不想从零抓包分析的从业者。这篇笔记我会按实际拆项目的顺序写先讲清 SKU 与数据模型再讲登录与 Cookie 维持然后是商品和评论的抓取细节最后把最常见的坑挨个点一遍。读完你拿这套逻辑去改其他电商站点也会顺手很多。2. 从 SKU 到数据模型17 位编码里的价格开关与商品状态位2.1 SKU 编码解析为什么同一个商品有三个 ID京东的商品体系里skuId 是真正决定“买哪个”的标识而 itemId也就是 spuId是“这一类商品”的标识。JDspider 项目里所有接口的入参都以 skuId 为核心所以第一步必须把这两者关系理清楚。举个实际例子你看中一台笔记本电脑详情页 URL 里那串数字是 skuId但页面上方的“商品编号”有时显示的是 spuId这两个值不完全一样。一个 spu 下面可以挂十几个 sku颜色、内存、版本不同价格也不同。我第一次拆这个项目时直接把商品详情页当成数据源硬解析发现同一个 spu 的不同 sku 居然拿到同样的标题和主图但价格对不上。后来查了京东的价格接口才明白价格是挂在 sku 维度下的标题和主图是挂在 spu 维度下的。所以 JDspider 里做了两条线商品基本信息走 cms 接口拿 spu 维度数据价格走 p.3.cn 的接口按 skuId 单独请求。如果你的业务只需要卖点信息按 spu 抓就够了如果需要价格对比、库存波动必须拆到 sku 粒度。再补充一个容易忽略的点17 位 SKU 编码本身就能反映商品状态。编码里中间某几位是类目 ID后几位是同一 spu 下的序号而商品是否下架、是否预售、是否自营这些状态并不在编码里需要额外去商品详情接口的 state 字段里读。JDspider 里对 state 字段做了一层映射把 1、2、3 这些数字转成“在售”“下架”“预售”这样导出的 CSV 里直接就是业务人员看得懂的状态而不是一堆呢称数字。这块逻辑不复杂但是很多自己写爬虫的人会漏掉拿到 state2 直接当成异常数据给扔了其实是“商品已下架”这个正常状态。2.2 商品详情页的信息抽取xpath 与正则的取舍拿到 skuId 之后第一件事是拉商品详情页。JDspider 用的是 requests 直接拿 HTML然后用 lxml 的 xpath 做解析。这里有个选型问题为什么不直接用 BeautifulSoup因为商品详情页的 DOM 结构非常稳定xpath 的路径表达式在处理这种规整结构时性能更好而且调试起来更直观——浏览器开发者工具里直接复制 xpath改一两个节点就能跑通。JDspider 的做法是预置了一组 xpath 规则分别对应标题、店铺名、品牌、类目路径、主图地址这些字段。import requests from lxml import html def parse_product_detail(sku_id): url fhttps://item.jd.com/{sku_id}.html headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.jd.com/ } resp requests.get(url, headersheaders, timeout10) doc html.fromstring(resp.text) title doc.xpath(//div[classsku-name]/text()) shop doc.xpath(//div[classJ-hove-wrap]//a[classname]/text()) brand doc.xpath(//ul[classparameter2 list]/li[contains(text(),品牌)]/text()) result { sku_id: sku_id, title: title[0].strip() if title else , shop: shop[0].strip() if shop else , brand: brand[0].replace(品牌, ).strip() if brand else } return result这段代码的核心是 xpath 路径的稳定性和容错处理。标题的sku-name这个 class 在 PC 端详情页里非常稳定除非前端改版否则不会变店铺名的路径稍微绕一点因为京东 PC 端有几种不同店铺模板J-hove-wrap是其中最常见的一种品牌信息藏在“参数”列表里我用contains(text(),品牌)做模糊匹配这样即使品牌名包含特殊字符也能命中。容错部分每个 xpath 结果都做了空列表判断避免IndexError直接把整个爬虫搞挂。实际跑起来一个详情页从请求到解析完成大概在 0.5 秒到 1 秒之间瓶颈在网络延迟而不在解析速度。这里有个爬虫圈常见的翻车点详情页里有部分数据是异步加载的比如商品描述、规格参数这类长文本它们不在初始 HTML 里。所以 xpath 能拿到的是“首屏数据”描述和参数需要额外请求desc.jd.com的接口。JDspider 把这块拆成了单独的函数用 jsonp 的参数做触发返回的是带转义符的 HTML 片段需要先 unicode 解码再抽文本。我当时第一次跑就遇到了乱码问题后来发现京东返回的是\u开头的 unicode 编码直接正则提取再encode().decode(unicode_escape)就能还原。这个坑虽然不是 JDspider 项目本身的问题但任何基于它改代码的人都会碰到先记住。2.3 价格接口的选择p.3.cn 的玄学与替代方案京东的价格接口是爬虫圈里出了名的“时好时坏”因为它有好几套并行的域名。JDspider 主要用的是https://p.3.cn/prices/mgets?skuIdsJ_123456这个接口。这个接口的好处是返回极快、一次可以批量查多个 sku而且不需要登录态坏处是偶尔会返回空列表或者价格字段直接缺失。我排查过几次发现不是 IP 被封而是接口自己做了降级——高峰期对非登录用户返回不完整数据。这种情况下的正确姿势是重试三次仍然失败就切换到详情页内的item.getPrice方法去拿兜底价格。import requests import json def get_price(sku_id, retries3): url fhttps://p.3.cn/prices/mgets?skuIdsJ_{sku_id} headers {Referer: https://item.jd.com/} for attempt in range(retries): try: resp requests.get(url, headersheaders, timeout5) data resp.json() price data[0].get(p) if price: return price except (requests.RequestException, KeyError, IndexError): pass return None这个函数的逻辑很简单但胜在稳。通过加J_前缀去请求 mgets 接口拿到的是一个 JSON 数组p字段就是当前售价。注意mgets返回的价格是“当前售价”不是促销价也不是 PLUS 价。如果你需要抓 PLUS 专享价这个接口拿不到得换https://c.3.cn/stock/realtimeStock或者详情页内嵌的会员价字段。JDspider 默认只抓普通售价如果业务需要会员价得自己多写一层逻辑。另外一个细节是p.3.cn 接口的价格精度是两位小数但返回的p字段可能是字符串也可能是数字取出来之后最好统一转成 float 再入库避免类型不一致导致的数据清洗问题。3. 登录态与滑块验证把 Cookie 变成可持续会话的三个要点3.1 京东登录的抓包思路从扫码到 Cookie 落地京东的登录有好几种方式账号密码、扫码、短信验证码。爬虫场景下最推荐的是扫码登录因为不需要处理密码加密逻辑——京东的密码是经过 RSA 加密的密钥来源和加密细节都在 login.js 里逆向成本不低。JDspider 的做法是模拟扫码登录的完整流程先请求二维码生成接口拿到二维码地址然后用 opencv 或者直接人工扫码再轮询扫码结果接口直到返回登录成功。登录成功之后服务端会返回一个ticket拿着这个 ticket 再去请求https://passport.jd.com/uc/loginService里的sso接口就能拿到完整的 Cookie。这个过程看着绕其实遵循的是京东 passport 的单点登录逻辑。JDspider 把这块做成了脚本里的交互式入口运行后终端会显示二维码的拼接文本拿微信或京东 App 扫码之后Cookie 自动落盘到本地文件。import requests import time def login_by_qrcode(session): qr_url https://qr.m.jd.com/show?appid133size240t str(int(time.time() * 1000)) resp session.get(qr_url, headers{User-Agent: Mozilla/5.0}) qr_token resp.text poll_url https://qr.m.jd.com/check params {appid: 133, token: qr_token, t: int(time.time() * 1000)} # 轮询扫码状态timeout 为 30 秒 for _ in range(60): result session.get(poll_url, paramsparams).json() if result.get(code) 200: return session.cookies.get_dict() time.sleep(0.5) return None这里要注意session对象必须全程复用因为show接口返回的tid和后续check接口的token是绑定的如果每一次都用新的 requests 去请求状态就断了。JDspider 的做法是把 session 写在入口函数里登录和后续的数据请求共用同一个 session。轮询间隔我一般设 500 毫秒太短会被风控系统判定为脚本请求太长用户会觉得扫码后没反应。还有一个细节Cookie 里关键字段是pt_key和pt_pin这两个字段同时存在才说明登录成功只有pt_key没有pt_pin的话大概率是登录流程没走完需要重新扫。3.2 Cookie 过期检测与自动续期机制扫码登录拿到的 Cookie 不是永久有效的京东的pt_key默认有效期是 30 天这期间如果频繁更换 IP 或触发风控可能提前失效。JDspider 里加了一个很实用的功能每次请求回来先判断返回体里是否出现login字样或者 HTTP 302 跳转到登录页如果出现了就认为 Cookie 已经失效自动进入重新登录流程。def ensure_login(session, cookie_filejd_cookie.json): if not check_cookie_valid(session): print(Cookie 已失效重新扫码登录) cookies login_by_qrcode(session) with open(cookie_file, w, encodingutf-8) as f: json.dump(cookies, f) else: with open(cookie_file, r, encodingutf-8) as f: cookies json.load(f) session.cookies.update(cookies)check_cookie_valid的实现比你想的简单拿任意一个需要登录才能看的接口比如购物车接口发一个请求如果返回{code: 9001}或者类似未登录的错误码就说明失效了。这个检测请求本身有副作用——会触发一次风控计数所以不要每个请求前都检测每隔 10 分钟检测一次就够了。JDspider 把检测频率写成了一个可配置项我改成了 300 秒一次跑一整天没有问题。另外Cookie 文件落盘之后要做权限控制因为pt_key等于半个登录态被人拿走就能操作你的账号开发机上还好部署到服务器上务必把文件权限改成 600。3.3 滑块验证的应对从识别到行为模拟京东的滑块验证有两种一种是登录时的“拖动滑块拼图”一种是操作频繁后弹出的“智能验证”。前者好处理因为拼图的位置可以通过对比背景图和缺口图找到后者麻烦它是一个黑匣子出现的原因可能是 IP 段被标记、请求频率异常、或者 UA 指纹不对。JDspider 项目的策略是尽量不触发它控制请求频率、使用真实的浏览器 UA、保持 Cookie 的持久性。如果已经被弹了滑块正确处理方式是人工介入一次。我实际项目中的做法是写一个滑块自动识别模块用简单图像处理找缺口位置然后模拟人的拖拽轨迹。纯算法的成功率在 70% 左右剩余的 30% 是那种“点一下按钮就通过”的智能验证这种无法用图像识别解决因为根本不需要拖动只需要点击。JDspider 原版没有集成这类高级功能但它的请求层做得很干净UA、Referer、请求头顺序都模拟了真实的 Chrome 浏览器这让手动介入的通过率比裸 requests 高不少。我用下来感受很明显裸 requests 触发滑块的概率是 40%套上 JDspider 的 headers 模板之后降到 10% 左右。4. 商品与评论的高效抓取API 参数拆解与 requestsxpath 组合打法4.1 商品搜索接口keyword 检索与翻页策略JDspider 除了按 skuId 精确抓取之外还支持关键词搜索。搜索走的是https://search.jd.com/Search这个 PC 端页面参数结构是keywordxxxencutf-8wqxxxpage1。注意这里的page不是简单递增的京东搜索页的翻页逻辑是奇数页才展示商品列表偶数页返回的是空模板或者“没有找到相关商品”。JDspider 里做了个很巧的处理每翻一页page 参数加 2而不是加 1。def search_products(keyword, max_pages10): base_url https://search.jd.com/Search results [] for page in range(1, max_pages * 2, 2): params { keyword: keyword, enc: utf-8, wq: keyword, page: page, } resp requests.get(base_url, paramsparams, headersheaders) doc html.fromstring(resp.text) items doc.xpath(//li[classgl-item]) for item in items: sku_id item.xpath(./data-sku) price item.xpath(.//div[classp-price]//i/text()) title item.xpath(.//div[classp-name]//em/text()) results.append({sku_id: sku_id, price: price, title: title}) return results这个函数跑起来有一个性能陷阱requests.get同步请求 10 页可能要 10 秒以上。JDspider 原版是同步实现的我在它的基础上改成了ThreadPoolExecutor20 个线程并发抓搜索页耗时从 12 秒压缩到 2 秒。线程数不是越大越好京东对单个 IP 的并发请求数有限制我试过 50 线程跑 10 页触发了一次滑块20 线程跑 100 页也没事。另外搜索结果里的价格经常显示为“0.00”或缺货这是因为搜索页的价格接口和详情页不是同一个如果在搜索阶段发现价格字段为空不要把数据直接丢弃先保留 skuId 进待抓队列后续再用价格接口补全。4.2 商品评论接口comment_summary 与完整评论的配合评论数据是京东爬虫里最容易被低估的部分。JDspider 用了两个接口配合https://club.jd.com/comment/productCommentSummaries.action?referenceIdsskuId拿评论汇总好评率、各星级占比https://club.jd.com/comment/productPageComments.action拿具体评论列表。汇总接口非常轻量单次请求几十毫秒就能返回列表接口就重多了一个 sku 有十万条评论的话要翻几千页。import requests import json def fetch_comments(sku_id, page0, size10): url https://club.jd.com/comment/productPageComments.action params { productId: sku_id, score: 0, sortType: 5, page: page, pageSize: size, isShadowSku: 0, fold: 1, } headers {Referer: fhttps://item.jd.com/{sku_id}.html} resp requests.get(url, paramsparams, headersheaders) data resp.json() comments data.get(comments, []) for c in comments: yield { sku_id: sku_id, content: c.get(content), score: c.get(score), nickname: c.get(nickname), creation_time: c.get(creationTime), product_color: c.get(productColor), product_size: c.get(productSize), }sortType5表示按时间排序sortType1是按默认排序。抓评论建议用sortType5这样能拿到最新的评论适合做舆情监控。score0表示抓取全部星级如果只想要差评把 score 改成 1 或 2。这里有一个非常关键的参数isShadowSku。子 sku 的评论接口如果不带上这个参数返回的会是父 spu 的全部评论导致你分析某个颜色版本的差评时数据是错的。JDspider 的做法是从商品详情接口先拿到isShadowSku的值再拼进评论接口的参数里逻辑上多一个请求但数据准确性高了一大截。评论接口的另一个坑是翻页参数page从 0 开始而pageSize最大只能填 10。想一次拿 100 条评论直接把 pageSize 改成 100 的话服务端会返回空数组。所以抓取时只能老老实实翻页每页 10 条翻几十几百页。这个限制也意味着并发量大时评论接口是最容易被风控的我实测一个 IP 连续请求 200 页评论之后接口开始随机返回空数据此时需要切换 IP 或者暂停一段时间。4.3 数据落盘从 CSV 到 MySQL 的入库方案JDspider 的数据导出默认是 CSV但如果你要抓的量级在十万级以上CSV 就是个灾难——文件打开慢、去重麻烦、增量更新全靠手工。我一般会把 JDspider 的输出层改成异步写入 MySQL表结构按 sku_id 做唯一索引评论表加一个业务主键防止重复入库。CREATE TABLE products ( sku_id BIGINT PRIMARY KEY, title VARCHAR(255), shop_name VARCHAR(255), brand VARCHAR(100), price DECIMAL(10, 2), state TINYINT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE comments ( id BIGINT AUTO_INCREMENT PRIMARY KEY, sku_id BIGINT, content TEXT, score TINYINT, nickname VARCHAR(100), creation_time DATETIME, UNIQUE KEY uk_sku_time (sku_id, creation_time) );评论表的唯一索引我用(sku_id, creation_time)做去重但如果有两个用户在同一秒评论就会误伤。更稳的做法是在应用层用 MD5 计算评论内容哈希然后对哈希做唯一约束。JDspider 原版没有这层逻辑因为它的定位是跑通流程不是生产级数据管道。如果你要拿它做数据分析的底座这一层必须自己补。入库时的字符集设置也很重要评论内容里经常有 emojiMySQL 表必须用utf8mb4用utf8会直接报错或者丢弃这些字符。5. 常见问题与避坑IP 风控、字段缺失与验证码的三类通病5.1 现象请求频繁时出现 302 跳转或验证码弹窗原因京东的风控系统在检测到单个 IP 高频请求时会下发挑战响应表现就是请求返回 302 到验证码页面或者页面里出现sec-ch-ua相关的 JS 挑战脚本。这种情况和你的代码逻辑没关系纯粹是频率控制。解决JDspider 里有一个请求间隔控制参数默认是 0.5 秒到 1.5 秒随机延迟。我把它调成了 1 秒到 3 秒随机虽然抓取效率降低了三分之一但连续跑 8 小时没有再触发验证码。如果你的业务对时效性要求不高这个调参策略最省心。如果必须高频率抓取就得上代理 IP 池每个 IP 的请求分布按时间切片类似一个简易的分布式爬虫。5.2 现象SKU 编码解析正常但价格接口返回空列表原因p.3.cn 接口在高峰期对未登录用户做了降级处理或者传入的 skuId 是已经删除的商品。这不算 bug是接口本身的业务逻辑。解决JDspider 做了三层兜底第一层重试 mgets 接口第二层换c.3.cn/stock接口第三层从商品详情页的item.getPrice()方法里抽初始价格。我实际跑的时候发现第三层的成功率反而最高因为详情页是静态渲染的服务端返回的 HTML 里直接嵌入了初始价格变量。缺点是详情页请求重一个页面 300KB 左右不能批量操作。所以优先走 p.3.cn失败再降级。5.3 现象评论列表抓到一半突然全为空的原因评论接口对单个 IP 的总请求量有隐形限制。我统计过一个 IP 在 5 分钟内请求评论接口超过 300 次后续请求就全返回空数据即使这个时候其他接口商品详情、价格还能正常访问。解决给评论抓取单独设置频率控制和商品抓取的频率完全分开。JDspider 的线程模型是所有请求共用一个 session这就导致评论接口的高频请求会拖累其他接口的可用性。我的改法是拆成两个 session一个专门跑商品数据一个专门跑评论数据两个 session 的间隔参数各自独立互不干扰。这个改动对整体稳定性提升非常明显。5.4 现象抓取到的评论内容出现大量无关商品信息原因没有处理isShadowSku参数拿到的评论其实是父 spu 的聚合数据。尤其是抓取手机、电脑这类多版本商品时不同颜色版本的评论混在一起分析结果完全失真。解决在抓评论之前先请求商品详情接口拿到isShadowSku字段的值把它拼进评论接口的参数里。JDspider 原版是把这个参数写死的我改成动态获取之后评论数据才开始可信。5.5 现象Cookie 文件存在但请求仍然提示未登录原因京东的登录态绑定的是 UA 和 IP 的组合。如果你用同一个 Cookie 文件但换了 UA、或者换了 IP 段去请求服务端会判定会话异常直接让登录态失效。解决Cookie 落地之后所有请求的 headers 必须固定不变尤其是 User-Agent。JDspider 的 headers 模板是全局单例这个设计很明智但如果你把代码复制到另一个项目里很容易不小心覆盖掉 UA 值。我吃过一次亏在 headers 里加了Accept-Encoding: br导致 Cookie 频繁失效因为 br 压缩是 Chrome 的默认行为requests 不支持这个压缩格式服务端返回了空数据。6. 进阶用法从单机脚本到分布式采集的关键改造当你需要抓取的商品数量超过千级、评论数量超过百万级时单机脚本的瓶颈就出来了不是性能问题而是 IP 风控问题。JDspider 的代码结构是面向单机设计的但它的数据模型和接口封装层写得足够干净可以直接拆出来做分布式改造。第一步是引入任务队列把 skuId 列表全部塞进 Redis 的 List 里每个 worker 从 List 里 pop 任务处理完把结果写入 MySQL。这样单机的线程池就变成了多机的 worker 集群。import redis import json r redis.Redis(hostlocalhost, port6379, db0) def push_tasks(sku_ids): # 分批推送并入队防止一次性大管道阻塞 Redis for sku_id in sku_ids: r.rpush(jd:task:sku, sku_id) def worker(): while True: _, sku_id r.blpop(jd:task:sku, timeout30) if not sku_id: break sku_id sku_id.decode() product_data parse_product_detail(sku_id) price get_price(sku_id) # 写库逻辑需要自行实现建议使用批量插入 save_to_db(product_data, price) # 每个任务完成后做一次随机延迟给风控系统留足时间窗口 time.sleep(random.uniform(0.5, 2.0))分布式改造里最关键的参数是延迟策略。我在 3 台云服务器上跑了 12 个 worker每个 worker 执行完一个任务后随机休眠 13 秒这样每台机器的请求频率保持在每秒 0.5 次左右坚持了 3 天没触发风控。如果把延迟去掉12 个 worker 同时涌入半小时内就会看到滑块验证码。另一个容易被忽略的问题是任务失败后的队列重放worker 在请求超时或者接口返回异常时不要直接把任务丢掉而是重新 push 回队列同时记录失败次数超过 3 次就进死信队列人工处理。JDspider 原版的异常处理是打印错误然后 continue这在单机小任务量时没问题分布式环境下会把大量错误吞掉数据量越大错误越明显。还要注意日志集中化。单机脚本看终端输出没问题分布式下每个 worker 的 print 分散在不同机器上排查问题极度痛苦。我给 JDspider 的跑了日志模块后统一用 JSON 格式把 skuId、任务状态、耗时、错误类型输出到日志文件再用 filebeat 收集到 Elasticsearch。从那以后我每次搭采集任务都强制走一遍这套改造流程先写死队列清理逻辑再加格式化的日志输出最后才调并发参数压力测试和调优分开做。这样环境即使哪天接口变了或者 IP 被封了我也能在十分钟内定位问题而不是一头扎进代码里翻。希望帮到你。日志格式参考{ sku_id: 100012043978, task: product_detail, status: success, elapsed_ms: 452, error: }出问题时status变成failederror字段写具体异常类型。这个格式虽然简陋但在两百多万条任务日志里快速筛选错误现场比任何调试工具都快。JDspider 本身不提供这些能力但它给了你一个干净的起点剩下的工程化动作就是在这个起点上不断加防护层和可观测性层。本文还有配套的精品资源点击获取