淘宝垂直爬虫之关键字搜索(实战+源码+可视化)

1. 前言

上一篇博客 "爬虫让我再次在女同学面前长脸了~(现实版真实案例)" 说到了帮女同学批量下载试题,我把文章同步到了CSDN,竟然有41个赞 + 21个评论 + 155个收藏,难道大家和我的目的都一样:爬虫 liao mei ?

在这里插入图片描述

本篇文章主要介绍如何“快速”抓取淘宝商品信息,从几个维度统计并且进行可视化,本次案例使用关键字 “安踏篮球鞋男鞋”,(仅仅是因为我最近买了一双鞋子,然后就想到了这个商品而已)

2. 故事的背景

没有故事,没有背景,就是突然想。。。

3. 爬虫的分类?

先来接地气几个词,一般来说爬虫可以分为 【通用爬虫 、垂直爬虫】,那么是如何定义的呢?

  • 通用爬虫:通用爬虫不需要理会网站哪些资源是需要的,哪些是不需要的,一并抓取并将其文本部分做索引(比如:百度爬虫、搜狗爬虫。。。)
  • 垂直爬虫:垂直爬虫往往在某一领域具有其专注性,并且垂直爬虫往往只需要其中一部分具有垂直性的资源,所以垂直爬虫相比通用爬虫更加精确,这也是比较常见的爬虫。

如果上面的解释还不够接地气的话,举个简单例子
小明想找一个女朋友,但是小明对女朋友要求为 null,只要 [ 是一个女的都行 ] 那种,这就可以理解为通用爬虫;
但是小明如果眼光比较挑,要求女朋友必须是 [ 身高165+,体重90,大眼睛,长头发,小蛮腰 ],这就可以理解为垂直爬虫。

4. 淘宝对爬虫都有哪些限制?

淘宝爬虫,很多文章都在讲解淘宝登录,然后分析ua参数等,以前我也很执着去分析过,真的挺难,后面我就没有继续跟下去了,就使用浏览器驱动实现登录,如 selenium (需要修改参数,否则淘宝能识别) 或者 JxBrowser 等等。。。

除开淘宝登录来看,淘宝的底线还是比较低的,暂时没有很高防线,你携带 cookie 即可,没错就是携带“饼干”给淘宝,他就给你通行了,所以本篇博客是通过携带 cookie 进行快速获取到数据的,(个人:有时间就去学习逆向分析,但是真正实际爬的话,使用最低的成本获取最高的效益,巧劲最大化

在这里插入图片描述

题外话,虽然说一般的大厂不轻易封IP (误伤太大),但如果真的想海量采集淘宝商品数据的话,还必须要花点时间研究反爬策略的,否则很难进行海量抓取,因为文章后面有可能是有点触发反爬了 (后面再说)

5. 抓包

如果在电脑首次上淘宝的话,你想搜索商品是必须要求登录的,淘宝的要求登录就不是我上一篇文章(撩妹佳文)那种假登录限制了,而且整体接口都要求必须携带凭证(cookie)访问的了,这里介绍一个巧劲,因为如果使用登录的 cookie 访问淘宝的话,淘宝实际上是知道你是谁的,有你访问的记录的,然后你上淘宝可能会给你推送商品的,甚至对你账号进行限制 、反爬等,因此写一个爬虫没必要搭上那么大的风险吧,所以登录完成之后,就退出,这个时候浏览器还是可以正常搜索产品的,意思就是说不要使用登录的 cookie 进行爬取数据。。。

五个抓包步骤如下

在这里插入图片描述

观察 json 结构:复制上面的抓包的数据,然后进行 json 视图查看

在这里插入图片描述

如何分页?

上面的抓包只是一页的数据,那么如何抓取淘宝搜索其他页呢?
我可以直接告诉你的是,通过在 url 传递一个 s={最后一个商品的位置} 实现的分页...

在这里插入图片描述

好吧 我们来简单看一下吧:

  • 在上面抓包中,我们并没有看到 url 传递了 s 这个参数
  • 然后我们先来点击一下 "下一页",
    多点几次观察几次,你就知道了 s 参数的变化了
在这里插入图片描述
在这里插入图片描述

观察 s 变化,第二页s=44,第三页s=88,第四页s=132,...

首页没有 s 参数
第二页:https://s.taobao.com/search?q=%E5%AE%89%E8%B8%8F%E7%AF%AE%E7%90%83%E9%9E%8B%E7%94%B7%E9%9E%8B&imgfile=&js=1&stats_click=search_radio_all%3A1&initiative_id=staobaoz_20200705&ie=utf8&bcoffset=3&ntoffset=3&p4ppushleft=1%2C48&s=44
第三页:https://s.taobao.com/search?q=%E5%AE%89%E8%B8%8F%E7%AF%AE%E7%90%83%E9%9E%8B%E7%94%B7%E9%9E%8B&imgfile=&js=1&stats_click=search_radio_all%3A1&initiative_id=staobaoz_20200705&ie=utf8&bcoffset=0&ntoffset=6&p4ppushleft=1%2C48&s=88
第三页:https://s.taobao.com/search?q=%E5%AE%89%E8%B8%8F%E7%AF%AE%E7%90%83%E9%9E%8B%E7%94%B7%E9%9E%8B&imgfile=&js=1&stats_click=search_radio_all%3A1&initiative_id=staobaoz_20200705&ie=utf8&bcoffset=0&ntoffset=6&p4ppushleft=1%2C48&s=132

来到这里,可以去掉其他不必要的参数,结论是:https://s.taobao.com/search?q={url encode keywords}&s={开始位置}

6. 编码

经过上面繁琐的步骤,终于可以着手编码了,因为爬取数据之后需要进行可视化,所以数据肯定是需要持久化的,为了方便就保存到 csv 文件中。

逗号分隔值(Comma-Separated Values,CSV,有时也称为字符分隔值,因为分隔字符也可以不是逗号),其文件以纯文本形式存储表格数据(数字和文本)。纯文本意味着该文件是一个字符序列,不含必须像二进制数字那样被解读的数据。

在代码里注释得很清楚的了,这里直接贴代码出来即可

为了简洁,我贴出主要的代码,想要获取整体源码项目的话,关注微信公告号( it_loading 回复 "淘宝爬虫" 即可)

FastHttpClient.java

https://blog.csdn.net/JinglongSource/article/details/107136862

TaoBaoSpider1.java

import cn.shaines.spider.util.FastHttpClient;
import cn.shaines.spider.util.FastHttpClient.Response;
import cn.shaines.spider.util.FastHttpClient.ResponseHandler;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONPath;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.classic.methods.HttpGet;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;

/**
 * @author for.houyu@qq.com
 */
@Slf4j
public class TaoBaoSpider1 {

    private static final String listUrlTemplate = "https://s.taobao.com/search?q=${keywords}&s=${index}";
    private static String cookie = "enc=l5xju4OJMzTwwtBkVJWbvFPcQ%2B6n6%2FWdRaE4iECxQOQiEtA45RBOyXQu0gZbOcIVEO6oxrRWvtu6mgSv4JZa8w%3D%3D; thw=cn; hng=CN%7Czh-CN%7CCNY%7C156; sgcookie=ESMDjIxrMIKwi48qmS8xP; tfstk=cCOGBNOa3dW_xoWHFf16ovEkfpHRZKNVqIRBTCGS8wNPBsdFiO2UUAat-GxMM-1..; tracknick=; cna=8XsbF0X0cVkCAbcg0Pt/qWLy; t=bb72251ba9e04aa4ffe5119a746b1f35; v=0; cookie2=1d43fd087e0cf8b4268f2e8ddcd4aea0; _tb_token_=e30835115bdfe; alitrackid=www.taobao.com; lastalitrackid=www.taobao.com; JSESSIONID=07CB217DBB9367EF2E0CCF7AF29AA9A0; isg=BDo6WaueMbQrb7zN4rxs3mhmi2Bc677FZ6MxoUQ_y0y9N99xLH6N1UYBh8PrpzZd; l=eBMkD1V4QZHXKy_vBO5whurza77ONdAfCsPzaNbMiInca6ZFN1uJuNQqG5uBldtjgtfj7etrb3kJjRUpziUdg2HvCbKrCyCk6Yp6-";

    private FastHttpClient httpClient;
    private BufferedWriter writer;
    private Set<String> filter;

    public static void main(String[] args) throws Exception {
        // 搜索关键字
        final String keywords = "安踏篮球鞋男鞋";
        // 每页44条数据
        final int limit = 44;
        TaoBaoSpider1 spider = new TaoBaoSpider1();
        spider.init(keywords);
        for (int page = 0; page <= 99; page++) {
            log.info("正在准备下载页数: {}", page + 1);
            String html = spider.getListHtml(keywords, page * limit);
            List<Goods> list  = spider.parse(html);
            log.info("解析得到数量: [{}]", list.size());
            if (list.isEmpty()) {
                break;
            }
            list = spider.doFilter(list);
            log.info("过滤后数量: [{}]", list.size());
            list.forEach(v -> {
                List<String> row = Arrays.asList(v.getRaw_title(), v.getView_price(), v.getView_fee(), v.getNick(), v.getItem_loc(), v.getView_sales(), v.getPic_url(), v.getDetail_url(), v.getComment_url(), v.getShopLink(), "_" + v.getNid(), "_" + v.getPid());
                spider.writeRow(row);
            });
            // 睡眠3 ~ 10秒
            Thread.sleep(ThreadLocalRandom.current().nextLong(3000, 10000));
            log.info("\r\n");
        }
    }

    private List<Goods> doFilter(List<Goods> list) {
        list = list.stream().filter(v -> !filter.contains(v.getNid())).collect(Collectors.toList());
        filter.addAll(list.stream().map(Goods::getNid).collect(Collectors.toSet()));
        return list;
    }

    /**
     * 写入一行数据
     * @param row 一行数据
     */
    protected void writeRow(List<String> row) {
        // 写入一行数据, csv的一行格式为,分割的, 但是这里使用","分割,主要就是为了统一作为字符串
        // 如:
        //      "姓名","年龄"
        //      "张三","123"
        String line = row.stream().map(v -> v.replace("\"", "\"\"").replace(",", ",,")).collect(Collectors.joining("\",\"", "\"", "\""));
        try {
            writer.write(line);
            writer.newLine();
            writer.flush();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 解析html
     * @param html the html
     */
    protected List<Goods> parse(String html) {
        if (StringUtils.isEmpty(html) || (html.contains("登录页面") && html.contains("全登陆不允许iframe嵌入"))) {
            throw new RuntimeException("获取列表HTML失败,请检查,如更新cookie等...");
        }
        String script = Arrays.stream(StringUtils.substringsBetween(html, "<script", "</script>"))
                // 过滤包含 g_page_config 和 auctions 的 script 脚本
                .filter(v -> v.contains("g_page_config") && v.contains("itemlist"))
                // 获取第一个符合条件的脚本(原则来上说这里只能返回一个, 否则说明上面的过滤不严谨)
                .findFirst()
                // 如果没有匹配的就抛异常, 说明解析页面失败
                .orElseThrow(() -> new RuntimeException("解析页面失败,请检查更新代码"));
        // 观察 script 内部其实是一个json格式的字符串, 因此找到分割点进行切割字符串返回一个json串
        String json = StringUtils.substringBetween(script, "g_page_config", "g_srp_init");
        json = json.substring(json.indexOf("{"), json.lastIndexOf("}") + 1);
        // 使用 JSONPath 实现快速解析json, 这里的意思是查找n级 itemlist 下的 n级auctions (说明:alibaba fastjson 就有 JSONPath 的实现)
        Object eval = JSONPath.eval(JSON.parseObject(json), "$..itemlist..auctions");
        if (!(eval instanceof List)) {
            throw new RuntimeException("解析JSON列表失败, 请检查更新代码");
        }
        List<JSONObject> auctions = (List<JSONObject>) eval;
        // 转换为目标对象 Goods
        List<Goods> result = auctions.stream().map(v -> v.toJavaObject(Goods.class)).collect(Collectors.toList());
        // result.forEach(System.out::println);
        return result;
    }

    /**
     * 根据关键字获取列表HTML
     * @param keywords 关键字 如: 安踏篮球鞋男鞋
     * @param index 开始位置, 如0, 每页显示44条数据, 因此0, 44, 88, ...
     */
    protected String getListHtml(String keywords, int index) {
        // 中文进行URL编码
        keywords = FastHttpClient.encodeURLText(keywords, StandardCharsets.UTF_8);
        String url = listUrlTemplate.replace("${keywords}", keywords).replace("${index}", index + "");
        // 创建一个GET请求
        HttpGet httpGet = httpClient.buildGet(url);
        // 执行请求, 获取响应
        Response<String> response = httpClient.execute(httpGet, ResponseHandler.ofString());
        return response.getData();
    }

    /**
     * 资源初始化
     */
    protected void init(String keywords) {
        // 初始化 httpClient 并且设置 cookie 每次请求都会携带cookie信息
        httpClient = FastHttpClient.builder().setCookie(cookie).build();
        filter = new HashSet<>(1024);
        try {
            // 初始化 CSV 文件呢
            File file = Paths.get(System.getProperty("user.dir"), "temp", "spider", keywords + ".csv").toFile();
            file.getParentFile().mkdirs();
            writer = new BufferedWriter(new FileWriter(file, false));
            // 准备header
            List<String> header = Arrays.asList("标题", "单价", "运费", "店名", "发货地址", "售量", "首页图", "明细地址", "评论地址", "购买地址", "nid", "pid");
            this.writeRow(header);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    public static class Goods {
        /** nid */
        private String nid;
        /** pid */
        private String pid;
        /** 标题 */
        private String raw_title;
        /** 首页图 */
        private String pic_url;
        /** 明细地址 */
        private String detail_url;
        /** 单价 */
        private String view_price;
        /** 运费 */
        private String view_fee;
        /** 发货地址 */
        private String item_loc;
        /** 售量 */
        private String view_sales;
        /** 店名 */
        private String nick;
        /** 评论地址 */
        private String comment_url;
        /** 购买地址 */
        private String shopLink;

        public String getNid() {
            return nid;
        }

        public void setNid(String nid) {
            this.nid = nid;
        }

        public String getPid() {
            return pid;
        }

        public void setPid(String pid) {
            this.pid = pid;
        }

        public String getRaw_title() {
            return raw_title;
        }

        public void setRaw_title(String raw_title) {
            this.raw_title = raw_title;
        }

        public String getPic_url() {
            return pic_url;
        }

        public void setPic_url(String pic_url) {
            this.pic_url = pic_url;
        }

        public String getDetail_url() {
            return detail_url;
        }

        public void setDetail_url(String detail_url) {
            this.detail_url = detail_url;
        }

        public String getView_price() {
            return view_price;
        }

        public void setView_price(String view_price) {
            this.view_price = view_price;
        }

        public String getView_fee() {
            return view_fee;
        }

        public void setView_fee(String view_fee) {
            this.view_fee = view_fee;
        }

        public String getItem_loc() {
            return item_loc;
        }

        public void setItem_loc(String item_loc) {
            this.item_loc = item_loc;
        }

        public String getView_sales() {
            return view_sales;
        }

        public void setView_sales(String view_sales) {
            this.view_sales = view_sales;
        }

        public String getNick() {
            return nick;
        }

        public void setNick(String nick) {
            this.nick = nick;
        }

        public String getComment_url() {
            return comment_url;
        }

        public void setComment_url(String comment_url) {
            this.comment_url = comment_url;
        }

        public String getShopLink() {
            return shopLink;
        }

        public void setShopLink(String shopLink) {
            this.shopLink = shopLink;
        }

        @Override
        public String toString() {
            final StringBuilder sb = new StringBuilder("Goods{");
            sb.append("nid='").append(nid).append('\'');
            sb.append(", pid='").append(pid).append('\'');
            sb.append(", raw_title='").append(raw_title).append('\'');
            sb.append(", pic_url='").append(pic_url).append('\'');
            sb.append(", detail_url='").append(detail_url).append('\'');
            sb.append(", view_price='").append(view_price).append('\'');
            sb.append(", view_fee='").append(view_fee).append('\'');
            sb.append(", item_loc='").append(item_loc).append('\'');
            sb.append(", view_sales='").append(view_sales).append('\'');
            sb.append(", nick='").append(nick).append('\'');
            sb.append(", comment_url='").append(comment_url).append('\'');
            sb.append(", shopLink='").append(shopLink).append('\'');
            sb.append('}');
            return sb.toString();
        }
    }

}

7. 运行代码

效果如下

在这里插入图片描述

文件保存路径默认在项目路径 $/temp/spider/xxx.csv

在这里插入图片描述
在这里插入图片描述

一共爬了2074条数据,爬到大概50页的时候,发现返回的数据都是重复的了(根据nid过滤)
大概猜测原因:

  • nid 字段不具备唯一性过滤
  • 淘宝反爬
  • 淘宝限制返回数量(虽然写着99页,实际真实客户也不可能是真的翻了99页找商品...)
  • ...

8. 数据可视化

以下可视化数据仅供学习参考,不具备任何依据判断,不承担任何责任。

1. 淘宝 - 安踏篮球鞋男鞋 - 发货地词云图

在这里插入图片描述

2. 淘宝 - 安踏篮球鞋男鞋 - 店铺售量冲浪榜

仅展示店铺售量1900+以上店铺,不上榜的店铺纳入 “其他”

在这里插入图片描述

3. 淘宝 - 安踏篮球鞋男鞋 - 价格&售量表1

在这里插入图片描述

4. 淘宝 - 安踏篮球鞋男鞋 - 价格&售量表2

在这里插入图片描述

9. 扩展

上面的代码完成一个关键字的搜索,顶多可以算一个爬虫,并不能算一个完整的爬虫系统,一个爬虫系统应该具备以下的几个组件。(待完善)

在这里插入图片描述

获取源码,请关注我的公众号 :IT加载中(it_loading)
回复 "淘宝爬虫"即可


公众号:IT加载中(it_loading)
CSDN:https://blog.csdn.net/JinglongSource
博客:https://shaines.cn/
邮箱:for.houyu@qq.com

程序员 [ 后宇 ],是一个关注编程,热爱技术的Java后端开发者,热衷于 [ Java后端 ],[ 数据爬虫领域 ]。不定期分享 I T 技能和干货!!欢迎关注 “IT加载中”,一个只出 干货 和 实战 的公众号。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271