利用scrapy爬取京东移动端的图片素材和商品信息

有一个练习项目需要一些带分类信息的商品测试图片,从现有的电商网站爬取是个不错的选择。刚好最近又在练习scrapy的使用,这一篇记录一下用scrapy爬取京东的图片素材并保存商品信息的思路。

文中代码共享在我的Github中JDcrawler项目

爬取目标

为什么选择京东?因为我需要的图片是手机版尺寸,而刚好京东支持手机网页打开的适配。

如下,点击Elements旁边的小按钮,调整显示为手机版本

1-market.png

可以看到这里有很详细的分类信息,点击其中一个小类,就可以查询具体商品信息,例如“零食”

2-subclass.png

爬取该页面商品的展示图同时保存一些分类信息到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

3-jsonp.png

点开看看请求的url,发现最后有一个回调函数名,这样就确认了京东是采用JSONP的方式去发起的异步请求。同时我又点开了另外几个大类的url,经过对比发现下图中红框部分就是用来区分不同一级分类的ID

4-request.png

最后点开response部分确认下

5-response.png

发现结果如下

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就能成功请求到页面

6-subclass.png

像这种滚动式加载的页面,通常第一页是直接显示出来的,而再向下滑动的时候到了某个位置会触发动态加载请求后面的数据。上面的几条JSONP请求也印证了这个猜想,其中的page参数就是页码数,同时也带上了callback回调函数。

这一次只是对第一页的内容进行了爬取,后面数据的爬取留作后续的改进措施。

而针对第一页的内容就比较容易了,直接利用xpath对html页面进行解析提取就可以了,如果对xpath不是很熟悉的朋友可以参考另一篇博客《爬虫Xpath语法详解》,当然使用正则表达式或者是BeautifulSoup都是可以达到目的的。静态页面的提取这里就不多分析了,无非就是定位到元素然后获取属性或者文字内容。

scrapy配置

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'

然后就可以开始正式开工了。

代码实现

item部分

需要获取的字段应该是可以最先确定下来的

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中。

第一个版本对数据的级联不是很熟悉,后续会被合并为一个类

spider部分

首先需要一个字典,存储各个一级分类对应的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去处理。

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格式。

结果展示

最后的结果是每个二级分类有一个自己的文件夹,里面存储着该分类下的图片

7-result.png

同时还有两个excel,分别存储着一级到二级的分类

8-cat.png

一级每个二级分类下的图片详细信息

9-subclass.png

后续改进

针对第二页开始商品的信息,jsonp的url应该还比较好构建,毕竟只有一个page查询参数要动态变一下。但是返回的json中的图片链接如下图所示并不完整,还缺少前缀

10-page.png

网页中真正的图片链接如下

11-image.png

本来以为又是和很多其他网站一样,通过js去动态生成前缀,后来发现不对,每次都是那几个前缀在不停变。然后查了下网站的头,发现可能是为了平衡流量,京东准备了好几个cdn供用户去下载,不管用哪个cdn的前缀都是可以拿到图片的。

12-prefetch.png

除了这个主要问题,就是文中已经提到的几个优化点:

  • imagePipeline的使用
  • 发送请求时候的callback参数传递
  • 一二级数据的合并

我会在Github中对这个项目持续更新,同时也会用博客的形式分享更多爬虫实战,欢迎大家关注。

总结

简单总结下知识点:

  • 异步加载结合跨域访问,要么是在xhr标签下查找(对应CORS技术),要么是在js标签下查找(对应JSONP技术)
  • json数据中的字段也许是经过了js处理之后才能被使用,尤其是下载链接,很多网站为了反爬加了哈希,例如shopee,不过京东暂时还没有
  • 静态网页直接用xpath获取信息即可,比起BeautifulSoup跟简洁一点

我是T型人小付,一位坚持终身学习的互联网从业者。喜欢我的博客欢迎在csdn上关注我,如果有问题欢迎在底下的评论区交流,谢谢。

  • 0
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值