聊到Python爬虫,绕不开的一个经典练手项目就是电商商品信息的抓取。我这次写的是京东商品价格及详情页抓取,属于一个偏入门的实战场景,目标是输入一个商品链接或SKU,自动拿到标题、价格、店铺、评价等基础信息,再整理成结构化结果。这类项目非常适合正在学Python、刚接触爬虫但不知道从哪开头的人,也适合做比价工具、价格监控、竞品分析前,先练一练数据采集基本功。
为什么选京东而不是淘宝或拼多多?因为京东的商品页URL规则非常简单清晰,价格信息又有相对稳定的接口,对新手友好,能让你把注意力放在“请求-解析-存储”这条主线上,而不是一开始就陷进各种加密参数里。本文会以我的实际抓取过程为线索,带上环境准备、请求头伪装、HTML解析、价格接口调用、批量采集以及遇到302、滑块验证码、编码乱码时的排查经验。每段都按“我当时是怎么想的,后来怎么做的”来写,尽量不说废话。
1. 项目整体设计与思路拆解
1.1 先搞清楚数据到底在哪
很多新手写爬虫,拿过URL就requests.get然后把HTML打印出来,发现页面里怎么找不到价格?这是因为现在的电商页面早就不是一次HTTP请求能拿全所有数据的结构。京东商品页里的标题、图片、店铺信息有一部分在服务端渲染的HTML里,但价格、促销、配送、库存这类实时性很强的字段,通常是通过额外的XHR接口异步加载,再由前端JavaScript填充到页面上的。
我刚开始做这个项目的时候,第一件事不是写代码,而是打开浏览器的开发者工具(F12),切到“Network”面板,清空请求记录,再刷新一次商品页。你会看到页面发起了几十个请求,按类型过滤一下,只留XHR或Fetch,就能看到一堆JSON接口。其中带price、stock、promotion这类关键字的请求,就是我们要重点关注的。
这个思路对整个爬虫生涯都通用:不管目标网站长什么样,先搞清楚数据源是渲染在HTML里,还是通过接口返回。搞错这一步,后面全是白干。
1.2 HTML解析和接口调用怎么选
理论上,数据从HTML里能拿,从接口里也能拿,但两者的稳定性和成本差别很大。
HTML解析的优点是“所见即所得”,浏览器里看到什么,HTML里一般就有对应节点。缺点是页面结构一旦改版,选择器全要重写,而且服务器返回的HTML里包含大量无关内容,正经解析起来繁琐。接口方案则是直接拿到结构化JSON,字段清晰、性能好、更新相对稳定,但前提是你得先找到接口、搞清楚参数怎么带,有的接口还会校验签名和Cookie。
我的建议很直接:详情页的基础信息,比如标题、店铺名,用HTML解析来做,练基本功;价格这类实时数据,优先找接口拿数据,因为价格接口通常返回的就是干净的数字,用正则或JSON解析都很方便。这两种能力以后换个平台也能复用,不算白学。
1.3 合规意识先讲清楚
写爬虫之前,一定要明白边界。本文的代码和思路只用于个人学习、技术研究和少量数据验证,不要用来大规模采集商业数据,更不要拿去卖钱。京东的robots协议对于目录的爬取有限制,实际生产环境中的数据采集还需要评估法律风险,并严格遵守目标网站的访问协议。
实际操作中,我会主动控制请求频率,比如每抓一个商品休息1到5秒,一天总量控制在几十条以内。这不是“胆小”,而是对自己和对目标服务器的基本尊重。爬虫技术本身是中性的,用得好是效率工具,用不好就是给自己找麻烦。
2. 环境准备与核心依赖安装
2.1 Python环境怎么搭最省事
做Python爬虫,我推荐直接用Python 3.8以上版本,太老的版本在处理一些第三方依赖时会有兼容性问题。如果你还没装Python,去官网下载安装包,安装时记得勾选“Add Python to PATH”,这一步虽然基础,但踩坑的人特别多。
装好之后,我习惯先建一个独立的虚拟环境,避免把系统Python搞乱。在项目目录下执行:
python -m venv venv然后激活环境。Windows下是:
venv\Scripts\activateMac或Linux下是:
source venv/bin/activate激活后提示符前面会出现(venv),说明已经进入虚拟环境了。接下来安装依赖就不会污染全局。
2.2 用到的库和国内镜像源
本次项目会用到的Python库不多,核心就四个:
- requests:发HTTP请求,爬虫入门必装
- lxml:高性能HTML/XML解析库,配XPath用
- beautifulsoup4:另一个解析HTML的选择,适合习惯CSS选择器的人
- pandas:最后把抓到的数据整理成表格,导出CSV或Excel时好用
安装命令是:
pip install requests lxml beautifulsoup4 pandas国内用户如果下载慢,建议临时指定清华镜像源:
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple requests lxml beautifulsoup4 pandas这个镜像源地址是公开的,速度比默认源快很多,我至今还在用。如果你在的公司或学校有内部源,那就更好了。
2.3 先封装一个带点“伪装”的请求模板
在正式写抓取逻辑前,我建议先把请求和响应的基础封装好。不要每次请求都临时拼headers,你会在调试时被自己乱写的代码搞疯。
下面这段是我常用的小模板,把超时、UA、重试都提前考虑好:
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def make_session(): session = requests.Session() retry = Retry( total=2, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504] ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) 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/120.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) return session session = make_session()注意这里的Retry只对连接问题和5xx错误生效,4xx不会自动重试。原因很简单,4xx是请求本身有问题,重试一万次也没用。
3. 详情页抓取实操:以标题、店铺、评价数为例
3.1 拆解商品URL和SKU的关系
京东的商品URL非常有规律,长这样:
https://item.jd.com/100012043978.html最后那串数字就是商品的SKU ID,是商品的唯一标识。只要你能拿到SKU列表,就能批量构造URL,这也是京东比较好抓的原因之一。反观淘宝的链接里带着一堆spm参数,一个页面一个样,新手看着就头大。
我习惯在代码里把SKU作为入口参数,而不是直接传整个URL,这样后面批量抓取时只需要传ID列表就能跑通。
3.2 请求商品页并确认响应状态
用前面封装好的session去请求详情页:
sku_id = "100012043978" url = f"https://item.jd.com/{sku_id}.html" resp = session.get(url, timeout=(3, 6)) print(resp.status_code) print(resp.encoding) print(resp.apparent_encoding)这里有个小细节:timeout我习惯传元组(3, 6),意思是连接超时3秒,读取超时6秒。只传一个数字的话,两个阶段用同一个值,不够灵活。请求返回后,先看状态码是不是200,再看响应编码。
京东页面返回的charset通常是utf-8,但保险起见,我会用resp.apparent_encoding来动态判断编码,或者直接手动设置为utf-8,然后再打印前500个字符确认一下HTML内容正常显示。这一步看着笨,但能省掉后面一堆玄学乱码问题。
3.3 用XPath提取标题和店铺名称
HTML拿到手后,解析方案我推荐lxml,性能好,XPath写起来也直观。先导入库:
from lxml import etree html = resp.text doc = etree.HTML(html)接下来是关键:怎么找到标题节点?我不会把选择器写得死板,而是给你一套定位方法。旧版京东页面的标题在class="sku-name"的h1标签里,店铺名称在class="name"的a标签里,类似这样:
title_nodes = doc.xpath("//h1[contains(@class, 'sku-name')]/text()") shop_nodes = doc.xpath("//div[contains(@class, 'name')]/a/@title")但页面结构是可能变的。所以我更推荐的做法是:先在浏览器里右键目标元素,选“检查”,看它所在的DOM层级,再回头写XPath。比如标题节点如果带class,就优先用class定位;如果层级很深,可以稍微放宽一点条件,用contains匹配部分类名,降低改版带来的影响。
还有一个很实际的问题:提取出来的文本可能带换行和大量空格。京东页面尤其如此,一个标题字符串里能塞进好多\n和空格。我写了段简单的清理逻辑:
def clean_text(text): return " ".join(text.split()) if text else ""text.split()会按空白字符切开再重新拼接,这样能一次性清掉所有多余换行和空格,很实用。
3.4 评价数和详情图,能拿多少拿多少
评价数通常在页面上表现为“100万+”这种格式,在源码里经常出现在带comment相关class的节点里。你可以先搜索“评论”或“评价”这几个字,再找附近的数字节点。这个字段不一定稳定,所以我一般把它定位为“可选字段”,拿不到就跳过,不影响主流程。
至于详情图,京东的详情页主图在经过懒加载处理后,很多时候img标签的src是一个占位符,真正的图片地址在>[ { "id": "J_100012043978", "p": "4999.00", "m": "5999.00", "op": "4999.00" } ]
不同字段含义不一样:p是促销价,也就是用户实际看到的价格;m是市场价,经常被划掉的那个;op是普通售价。以哪个为准,取决于你业务怎么定义“价格”。如果只是监控成交价,看p就对了。
4.2 构造请求并解析价格JSON
接口找到了,代码就简单了:
def fetch_price(sku_id): price_url = "https://p.3.cn/prices/mgets" params = { "skuIds": f"J_{sku_id}" } resp = session.get(price_url, params=params, timeout=(3, 6)) resp.raise_for_status() data = resp.json() if data: return data[0] return None用params传参,requests会自动把参数拼到URL后面,比自己手动拼字符串安全,能正确转义特殊字符。返回后直接调resp.json()解析,不用手动处理字符串。
这个接口胜在返回稳定、速度极快,而且支持一个请求传多个SKU,这就是后面并发和批量采集的基础。实测一下,单个SKU的响应基本在几十毫秒量级,一次请求传10个SKU也是几十毫秒,效率比逐个解析HTML高一个数量级。
4.3 把详情页和价格拼成一条完整记录
有了详情页信息和价格接口,就可以把两者合并成一条结构化数据。我用一个字典来组织:
def crawl_one_sku(sku_id): detail = {} # 详情页解析出的标题、店铺等 price = fetch_price(sku_id) record = { "sku_id": sku_id, "url": f"https://item.jd.com/{sku_id}.html", "title": detail.get("title", ""), "shop": detail.get("shop", ""), "price": price.get("p") if price else "", "market_price": price.get("m") if price else "", } return record字段里有英文有中文,但这不是给用户看的,是给后续数据处理程序看的,所以保持稳定和可预测才是最重要的。后面如果存数据库,这个字典的key直接当列名即可。
5. 批量抓取、限速与反爬应对
5.1 多商品批量抓取怎么写才靠谱
批量抓取前,先准备好一批SKU ID。你可以手工维护一个CSV,也可以用selenium之类从列表页自动提取,但那是另一个话题。我这次用一个简单的SKU列表演示:
sku_ids = ["100012043978", "100008348542", "100012129956"]批量抓的循环没什么花哨的,关键是节奏控制。我写了一个带随机延时的版本:
import time import random for sku_id in sku_ids: try: record = crawl_one_sku(sku_id) print(record) except Exception as e: print(f"抓取 {sku_id} 失败: {e}") time.sleep(random.uniform(1, 3))随机延时是为了避免固定间隔的机械感,因为固定间隔的请求在服务端看来更像机器行为。1到3秒的间隔对学习场景足够了。
5.2 并发能不能上?我建议先别急
很多人在掌握了单请求之后就急着上并发,觉得并发越高越快。理论上,Python网络IO密集型的爬虫多线程确实能提速,用concurrent.futures的ThreadPoolExecutor就能做:
from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers=5) as pool: futures = {pool.submit(crawl_one_sku, sku_id): sku_id for sku_id in sku_ids} for future in as_completed(futures): record = future.result() print(record)但我的经验是:新手阶段,并发带来的收益远没有你想象的那么大,反而会增加被封风险。京东对单IP的请求频率有一定限制,一旦触发风控,轻则请求变慢,重则直接弹滑块验证码。先老老实实把单线程流程跑通,观察一段时间确认没问题,再一点点增加并发数。爬虫不是越快越好,是越稳越好。
如果后续真要大规模抓取,要考虑的不只是线程池,而是更完整的调度方案,比如用Redis做队列、多节点分布式采集,但那已经属于分布式爬虫的范畴了,等把单机版跑利索了再去了解也不迟。
5.3 遇到302跳转、验证码和IP限制怎么办
批量抓的时候,最大的拦路虎是各种风控。最典型的情况是:请求发出去,返回的不是200,而是302跳转到一个验证页面,或者直接弹一个滑块。
我建议抓之前先在浏览器里打开一次商品页,手动过一遍验证,让浏览器留下正常访问的Cookie,再把这些Cookie抓出来,放到请求头里。requests会话第一次请求时带上这个Cookie,很多时候就能顺利通过验证。但要注意,Cookie是有时效的,过期之后需要重新获取。
如果连续请求被限制,最直接的应对是退一步:降低频率、增加延时、减少批次。至于IP代理,确实是一种常见方案,但学习阶段真不建议碰,一是花费高,二是代理池质量参差不齐,三是容易依赖这些手段而忽略了更本质的反爬规避思路。
再补充一点:一旦触发了滑块验证码,我个人建议直接停手,不要傻傻地去破解验证码。模拟点击、轨迹识别这些方案从技术上讲可以做,但涉及的安全合规边界非常模糊,而且成本极高,对学习项目来说完全不值当。把频率降下来,或者过段时间再跑,往往更实际。
5.4 数据存储:CSV是默认选项
抓到的数据如果只打印出来,那就白抓了。我会先用pandas整理成DataFrame,然后导出CSV:
import pandas as pd records = [] for sku_id in sku_ids: records.append(crawl_one_sku(sku_id)) df = pd.DataFrame(records) df.to_csv("jd_products.csv", index=False, encoding="utf-8-sig")注意编码用utf-8-sig而不是utf-8,这个细节一定要记住。utf-8-sig会在文件开头写入BOM标记,Excel打开时才不会乱码。我早期用utf-8导出CSV,发给朋友用Excel打开全乱,后来才知道是编码问题。这个小坑很典型,新手基本都遇到过。
6. 常见问题与排查技巧实录
6.1 问题速查表
我把这个项目里容易踩的坑整理成了一个速查表,遇到问题可以先对着查一遍。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 返回403 Forbidden | 请求头太少,被识别为爬虫 | 补全UA、Referer、Accept等请求头,使用Session维持会话 |
| 返回302跳转 | 触发了风控或登录校验 | 更换Cookie,降低请求频率,避免短时间高频访问 |
| 返回HTML但找不到价格节点 | 价格走异步接口返回 | 按4.1节的方法去Network里找价格接口 |
| 中文字符乱码 | 编码判断错误 | 设置resp.encoding = "utf-8"或使用apparent_encoding |
| 取到的标题带一堆换行和空格 | 页面源码包含大量空白字符 | 用" ".join(text.split())清理空白 |
| 图片URL取不到 | 图片在懒加载属性里 | 尝试@data-src、@data-lazy-img等属性 |
| 请求偶尔超时 | 网络波动或服务器稍慢 | 设置timeout=(3,6),增加重试逻辑 |
| 批量抓取时被封IP | 频率太高,触发反爬策略 | 降低并发、增加随机延时,必要时换IP池 |
| 页面改版后选择器失效 | 页面结构变化 | 重新打开开发者工具定位节点,更新XPath |
6.2 一个典型的排查过程实录
有一次我抓详情页,所有商品都正常,只有某一个SKU返回的标题是空的。我以为是选择器写错了,但其他商品都能取到,说明代码本身没问题。后来我拿这个SKU在浏览器里打开,发现商品已经是“已下架”状态,页面结构和其他正常商品完全不同,标题位置变成了一个下架提示。
这就引出一个重要经验:爬虫代码要始终对异常情况保持宽容。下架的、缺货的、秒杀未开始的、错误页面的,各种场景都可能在抓取过程中遇到。我的做法是给缺失值一个默认结果,比如标题为空字符串,而不是直接让程序崩溃。同时,每抓一个商品就打印一条日志,能第一时间定位是哪些SKU出了问题。
6.3 关于页面改版的一点心态
做爬虫,一定要接受一个事实:选择器不是一劳永逸的。网站前端改了class,你的代码就废了。这不是你写得不对,而是技术环境的常态。我之前维护过一个类似的采集脚本,平均每两三个月就要更新一轮页面选择器。
应对方式就是模块化。把页面解析的代码单独封装成一个函数,不要和请求逻辑、存储逻辑混在一起。这样页面改版后,只需要改解析那一小块逻辑,其他部分不用动。这种代码组织思维,比多会几个库重要得多。
7. 我个人实操中的一个小习惯
最后分享一个我自己一直在用的习惯:拿到一个目标页面后,先用curl或requests把页面源码完整下载下来存成一个HTML文件,然后用浏览器打开这个本地文件,配合开发者工具慢慢研究节点结构。这样做的好处是,后面怎么改代码都不会因为页面原始内容丢失而抓瞎,也方便对比网站前后改版时的结构差异。
这个项目本身还有很多可以扩展的方向,比如加上定时任务做价格监控、把数据落到MySQL里做后续分析、接入更友好的通知机制,每一个方向都能延展成一篇文章。不过对于刚起步的人来说,先把单页抓明白、把接口找清楚、把反爬应对形成肌肉记忆,就已经是很大的进步了。爬虫这条路没有太多捷径,多写、多调试、多看页面源码,慢慢就有感觉了。