有一个练习项目需要一些带分类信息的商品测试图片,从现有的电商网站爬取是个不错的选择。刚好最近又在练习scrapy的使用,这一篇记录一下用scrapy爬取京东的图片素材并保存商品信息的思路。
文中代码共享在我的Github中JDcrawler项目。
为什么选择京东?因为我需要的图片是手机版尺寸,而刚好京东支持手机网页打开的适配。
如下,点击Elements
旁边的小按钮,调整显示为手机版本
可以看到这里有很详细的分类信息,点击其中一个小类,就可以查询具体商品信息,例如“零食”
爬取该页面商品的展示图同时保存一些分类信息到excel即达到目的。
现在的网页基本上都是动态加载,也就是在不改变url的前提下,通过ajax方式异步去服务器获取数据来更改前端的展示。下面的分析我们就能看到京东也是这样实现的。
动态加载情况下,不能直接通过页面的DOM元素来爬取,而是要模拟浏览器去后端服务器请求数据。不过不用担心,因为通常都可以直接找到请求url的构建规律,所以构建url并不难。
但是网页请求后端数据会遇到一个叫做跨域请求的问题,服务器只会对自己信任的域名发来的请求进行响应。实现跨域请求目前主流的方式有两种:CORS和JSONP,具体讲解可以参考另一篇博客《JQuery中ajax操作和跨站访问详解(后端Django版本)》,这里就不展开了。下面我们也会看到京东使用的是JSONP方式进行的请求。
主要思路分为两步:
不管是点击哪个大类来获取其子类信息,浏览器的url都没有变,所以可以确定是采用动态加载的方式获取到的子类信息。
点击一个新的大类,然后观察Network
中的网络请求,发现如下图所示有一些xhr请求。xhr全称是XMLHttpRequest,如果前端采用异步方式(例如ajax)向服务端发起请求,都是以xhr的方式被记录下来。但是查看这几个xhr请求的response都不是想要的信息。
然后看到下图中有一个JS请求,但是看请求的url是一个后端api,并不是简单的js
点开看看请求的url,发现最后有一个回调函数名,这样就确认了京东是采用JSONP的方式去发起的异步请求。同时我又点开了另外几个大类的url,经过对比发现下图中红框部分就是用来区分不同一级分类的ID
最后点开response部分确认下
发现结果如下
bjsonp2 (
{
"msg": "success",
"currentTimeStr": "2020-08-26 10:51:53",
"code": "0",
"biTestId": "0",
"biDisplayTmpr": "",
"list": [...], // 21 items
"advertId": "00962577",
"currentTimeVal": 1598410313915,
"impl": "matjsf",
"returnMsg": "success",
"subCode": "0",
"transParam": "",
"channelPoint": {
"babelChannel": "",
"pageId": "990893"
}
}
)
将json数据做为函数参数进行返回,确实是JSONP的做法。
所以想获取子分类信息就容易了,分别对感兴趣的几大类查看对应的一级分类ID,然后伪造请求就可以了。而将JSONP转为存JSON数据也有两种方式,要么直接在请求的url中删除callback,要么在返回中利用正则表达式提取出真正的JSON数据。我们这里采用第一种方式。
方式一参考:https://blog.csdn.net/zzk1995/article/details/52160179
方式二惨开:https://segmentfault.com/q/1010000007547979
下面点击某个具体子类,例如“零食”,会发现url变成了如下所示的样子
https://so.m.jd.com/ware/search.action?keyword=%E9%9B%B6%E9%A3%9F%20%E4%BA%AC%E4%B8%9C%E8%B6%85%E5%B8%82
这里如果用中文表示就是keyword=零食%20京东超市,至于为什么要编码,如何编码可以参考另一篇博客《网址url中的百分号是什么编码以及如何用python实现url编码》
所以从上一步获取到子类keyword的内容,然后编码后构建url就能成功请求到页面
像这种滚动式加载的页面,通常第一页是直接显示出来的,而再向下滑动的时候到了某个位置会触发动态加载请求后面的数据。上面的几条JSONP请求也印证了这个猜想,其中的page
参数就是页码数,同时也带上了callback
回调函数。
这一次只是对第一页的内容进行了爬取,后面数据的爬取留作后续的改进措施。
而针对第一页的内容就比较容易了,直接利用xpath对html页面进行解析提取就可以了,如果对xpath不是很熟悉的朋友可以参考另一篇博客《爬虫Xpath语法详解》,当然使用正则表达式或者是BeautifulSoup都是可以达到目的的。静态页面的提取这里就不多分析了,无非就是定位到元素然后获取属性或者文字内容。
scrapy的基本使用这里不赘述,官方文档说的很详细。
如果能力足够,建议优先英文文档,中文涉及到翻译,进度不一定及时
首先创建一个scrapy项目
scrapy startproject JD
因为想要获取的是移动端资源,所以需要配置下settings.py中DEFAULT_REQUEST_HEADERS项,加上下面的内容
'User-Agent': 'user-agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
按照scrapy框架的模块设计,spider部分负责生成request以及解析response,item部分以ORM的方式去声明一些字段,pipeline则是对spider部分提取的item对象进行处理。
首先创建spider,这里的域名会被放到allowed_domains
中
scrapy genspider jd 'jd.com'
然后就可以开始正式开工了。
需要获取的字段应该是可以最先确定下来的
class JdItem(scrapy.Item):
# define the fields for your item here like:
# name = scrapy.Field()
name = scrapy.Field()
img_url = scrapy.Field()
subclass = scrapy.Field()
item_id = scrapy.Field()
class CatItem(scrapy.Item):
category = scrapy.Field()
subclass = scrapy.Field()
因为涉及到一二级数据的级联,这里创建了两个类,分别会被存储到不同的excel中。
第一个版本对数据的级联不是很熟悉,后续会被合并为一个类
首先需要一个字典,存储各个一级分类对应的ID。然后才能分别构建jsonp的url(去掉callback部分的)去获取二级分类的json信息。
class JdSpider(scrapy.Spider):
name = 'jd'
allowed_domains = ['jd.com']
categories = {
'休闲零食': '2200962565',
'水饮冲调':'2200962579',
'粮油调味':'2200962577',
'中外名酒':'2200962567',
'进口食品':'2200962566',
'纸品湿巾':'2200962748',
'个人护理':'2200962580',
'生活电器':'2200962563',
'家居日用':'2200962569',
'家庭清洁':'2200962751',
'衣物清洁':'2200962750',
'新鲜水果':'2200962573',
}
base_url_1 = 'https://api.m.jd.com/client.action?functionId=getTrackBabelAdvert&body=%7B%22advertId%22%3A%'
base_url_2 = '%22%2C%22moduleId%22%3A18796127%2C%22activityId%22%3A%2200381161%22%2C%22pageId%22%3A%22990893%22%2C%22transParam%22%3A%22%7B%5C%22bsessionId%5C%22%3A%5C%221115d9d3-39ae-4cc0-a825-2af9c49e2d9f%5C%22%2C%5C%22babelChannel%5C%22%3A%5C%22%5C%22%2C%5C%22actId%5C%22%3A%5C%2200381161%5C%22%2C%5C%22enActId%5C%22%3A%5C%222rUzGMirroT1PbGPjrc5sckJjrju%5C%22%2C%5C%22pageId%5C%22%3A%5C%22990893%5C%22%2C%5C%22encryptCouponFlag%5C%22%3A%5C%221%5C%22%2C%5C%22requestChannel%5C%22%3A%5C%22h5%5C%22%7D%22%2C%22secCatTransParam%22%3A%22%22%2C%22resType%22%3A%22%22%2C%22mitemAddrId%22%3A%22%22%2C%22geo%22%3A%7B%22lng%22%3A%22%22%2C%22lat%22%3A%22%22%7D%2C%22addressId%22%3A%22%22%2C%22posLng%22%3A%22%22%2C%22posLat%22%3A%22%22%2C%22focus%22%3A%22%22%2C%22innerAnchor%22%3A%22%22%2C%22cv%22%3A%222.0%22%7D&screen=750*1334&client=wh5&clientVersion=1.0.0&sid=&uuid=15978604453521585399959&area='
这里从一级ID位置将url分为了两部分,便于后面进行拼接。
然后是获取二级分类信息
def start_requests(self):
if not os.path.isdir(r'C:\Users\Admin\ScrapyProjects\JD\result'):
os.makedirs(r'C:\Users\Admin\ScrapyProjects\JD\result')
for cat in self.categories:
url = self.base_url_1 + self.categories[cat] + self.base_url_2
yield scrapy.Request(url, callback=self.subclass_parse)
如果最终存放结果的目录不存在这里先创建,然后构建url发起请求,响应由另一个方法subclass_parse
来处理。
def subclass_parse(self, response):
### get the category name from above, to save in excel later
request_url = response.request.url
cat_id = request_url[len(self.base_url_1):(len(request_url) - len(self.base_url_2))]
for k, v in self.categories.items():
if v == cat_id:
category = k
subclass_list = json.loads(response.text)['list']
for subclass in subclass_list:
item = CatItem()
item['category'] = category
item['subclass'] = subclass['name']
yield item
keyword = subclass['jump']['params']['keyWord']
url = 'https://so.m.jd.com/ware/search.action?keyword=' + quote(keyword)
yield scrapy.Request(url, callback=self.parse)
这里一共完成了俩个任务,首先是通过请求url中的ID部分找到对应的一级分类,这个是item中的一个字段。这里在一开始不知道发送请求的时候还可以使用cb_kwargs
给回调函数传递字典参数,所以操作的有些繁琐,后续会优化。然后是从获取到的json数据中获取想要的几个字段,yield item
会将item对象传递给pipeline,而yield scrapy.Request
则会根据获取到的关键字信息获取具体的商品列表页面。同样,这里也是可以用cb_kwargs
将一二级ID传递下去,以后了优化。
def parse(self, response):
itemList = response.xpath('//div[@class="search_prolist_item"]')
subclass = response.xpath('//title/text()').extract()[0].split(' ')[0]
for node in itemList[0:4]: # only the info of the first 4 items can be retrieved
item = JdItem()
item['name'] = node.xpath('.//div[@class="search_prolist_title"]/text()').extract()[0].strip()
item['img_url'] = node.xpath('.//div[@class="search_prolist_cover"]/img[@class="photo"]/@src').extract()[0]
item['subclass'] = subclass
item['item_id'] = node.xpath('./@skuid').extract()[0]
yield item
这里就没有太多可说的,在静态网页中用xpath查找元素获取信息。需要注意的是xpath返回的都是list对象,同时还要用extract()
方法来转换为字符串。
同时发现只有前4个商品的信息可以被爬下来,这个也是后续优化的工作之一。
最后交给pipeline去处理。
这里的处理设计两部分,存储到excel和下载图片。
class JdPipeline(object):
def __init__(self):
self.wb1 = Workbook()
self.wb2 = Workbook()
self.ws1 = self.wb1.active
self.ws2 = self.wb2.active
self.ws1.append(['category', 'subclass']) # title
self.ws2.append(['subclass', 'item_id', 'name', 'img_url']) # title
def process_item(self, item, spider):
item = dict(item)
if 'category' in item:
self.ws1.append([item['category'], item['subclass']])
elif 'name' in item:
path = os.path.join(r'C:\Users\Admin\ScrapyProjects\JD\result', item['subclass'])
if not os.path.isdir(path):
os.makedirs(path)
with open(os.path.join(path, item['item_id']) + '.png', 'wb') as f:
response = requests.get('http:' + item['img_url'])
f.write(response.content)
self.ws2.append([item['subclass'], item['item_id'], item['name'], item['img_url']])
return item
def close_spider(self, spider):
self.wb1.save(r'C:\Users\Admin\ScrapyProjects\JD\result\category.xlsx')
self.wb2.save(r'C:\Users\Admin\ScrapyProjects\JD\result\item.xlsx')
这里对excel的操作是通过openpyxl来实现的,除了上面的方法还可以用isinstance
来判断是哪个item类。而图片的下载是自己用requests库完成的,当然scrapy有自己的ImagePipeline可以用,以后再尝试。注意这里的图片只能保存为png格式。
最后的结果是每个二级分类有一个自己的文件夹,里面存储着该分类下的图片
同时还有两个excel,分别存储着一级到二级的分类
一级每个二级分类下的图片详细信息
针对第二页开始商品的信息,jsonp的url应该还比较好构建,毕竟只有一个page查询参数要动态变一下。但是返回的json中的图片链接如下图所示并不完整,还缺少前缀
网页中真正的图片链接如下
本来以为又是和很多其他网站一样,通过js去动态生成前缀,后来发现不对,每次都是那几个前缀在不停变。然后查了下网站的头,发现可能是为了平衡流量,京东准备了好几个cdn供用户去下载,不管用哪个cdn的前缀都是可以拿到图片的。
除了这个主要问题,就是文中已经提到的几个优化点:
我会在Github中对这个项目持续更新,同时也会用博客的形式分享更多爬虫实战,欢迎大家关注。
简单总结下知识点:
我是T型人小付,一位坚持终身学习的互联网从业者。喜欢我的博客欢迎在csdn上关注我,如果有问题欢迎在底下的评论区交流,谢谢。