Java網絡爬蟲(十四)--多線程爬蟲(抓取淘寶商品詳情頁URL)

源碼地址:多線程爬蟲–抓取淘寶商品詳情頁URL

項目地址中包含了一份README,因此對於項目的介紹省去部分內容。這篇博客,主要講述項目的構建思路以及實現細節。


項目概述及成果

首先將本項目使用到技術羅列出來:

  1. MySQL數據庫進行數據持久化及對宕機情況的發生做簡單的處理
  2. Redis數據庫做IP代理池及部分已抓取任務的緩存
  3. 自制IP代理池
  4. 使用多線程執行任務(同步塊,讀寫鎖,等待與通知機制,線程優先級)
  5. HttpClient與Jsoup的使用
  6. 序列化與反序列化
  7. 布隆過濾器

之後會對其中使用到的技術進行詳細的解釋。

本項目如README中所述,還有許多不完善的地方,但IP代理池與任務抓取線程之間的調度與協作基本已無問題。也就是說,在此項目的框架上,如果你想修改其中代碼用作其他抓取任務,也是完全可以的。我抓取到的數據所保存的源文件也放在GitHub的README上供大家免費瀏覽與下載(近90000的商品ID)。


整體思路

  1. 首先你需要一個IP代理池
  2. 使用本機IP將淘寶中基本的商品分類抓取下來
    • 頁面源鏈接:https://www.taobao.com/tbhome/page/market-list
    • 從頁面源鏈接中解析到的URL形如下:https://s.taobao.com/search?q=羽絨服&style=grid
  3. 將諸如此類的URLhttps://s.taobao.com/search?q=羽絨服&style=grid作爲任務隊列,使用多線程對其進行抓取與解析(使用代理IP),解析的內容爲第4點
  4. 我們需要分析每一種類的商品在淘寶中大概具有多少數量,爲此我解析出帶有頁面參數的URL,在第3點中URL的基礎上:https://s.taobao.com/search?q=羽絨服&style=grid&s=44,在瀏覽器中打開URL可發現此頁面爲此種類衣服的第二頁
  5. 我們得到了每一種商品帶有頁面參數的URL,意味着我們可以得到此類商品中全部或部分的商品ID,有了商品ID,我們就可以進入商品詳情頁抓取我們想要的數據了
  6. 爲了實現第5點,我們先將第4點中抓取到的URL全部存儲進MySQL中
  7. 從MySQL中將待抓取URL全部取出,存儲到一個隊列中,使用多線程對此共享隊列進行操作,使用代理IP從待解析URL中解析出本頁面中包含的商品ID,並構建商品詳情頁URL
  8. 在第7點中解析商品ID的時候,同時使用布隆過濾器,對重複ID進行過濾,並將已經抓取過的URL任務放入Redis緩存中,等達到合適的閾值時,將存儲在MySQL中對應的URL行記錄中的flag置爲true,表示此URL已經被抓取過,等到下一次重啓系統,可以不用對此URL進行抓取

實現細節(省略大量實現代碼,如有需要請閱讀源碼)

IP代理池

我們先從IP代理池說起,在這個項目中所運用到的IP代理池與我在Java網絡爬蟲(十一)–重構定時爬取以及IP代理池(多線程+Redis+代碼優化)這一篇博客中所講述的IP代理池的實現思想有一些細小的差別。

  • 差別1:不再使用定時更新IP代理池的方法

由於是將IP代理池真正的運用到一個工程中,因此定時更新IP代理池的方法已經不可取。我們的IP代理池作爲一個生產者,衆多線程都要使用其中的代理IP,我們就可以認爲這些線程都爲消費者,根據多線程中經典的生產者與消費者模型,在沒有足夠的產品供消費者使用的時候,生產者就應該開始進行生產。也就是說,IP代理池的更新變爲,當池中已經沒有足夠的代理IP供衆多線程使用的時候,IP代理池就應該開始進行更新。而在IP代理池進行更新的時候,衆多線程作爲消費者,也只能等待。

具體的代碼實現如下:

// 創建生產者(ip-proxy-pool)與消費者(thread-tagBasicPageURL-i)等待/通知機制所需的對象鎖
Object lock = new Object();

生產者—IP代理池

/**
 * Created by hg_yi on 17-8-11.
 *
 * @Description: IP代理池的整體構建邏輯
 */
public class MyTimeJob extends TimerTask {
    // IP代理池線程是生產者,此鎖用來實現等待/通知機制,實現生產者與消費者模型
    private final Object lock;

    MyTimeJob(Object lock) {
        this.lock = lock;
    }

    @Override
    public void run() {
        ... ...

        // 如果IP代理池中沒有IP信息,則IP代理池進行工作
        while (true) {
            while (myRedis.isEmpty()) {
                synchronized (lock) {
                    ... ...

                    lock.notifyAll();
                }
            }
        }
    }
}

消費者—thread-tagBasicPageURL-i

/**
 * @Author: spider_hgyi
 * @Date: Created in 下午1:01 18-2-1.
 * @Modified By:
 * @Description: 得到帶有分頁參數的主分類搜索頁面的URL
 */
public class TagBasicPageCrawlerThread implements Runnable {
    private final Object lock;              // 有關生產者、消費者的鎖
    ... ...

    public TagBasicPageCrawlerThread(Queue<String> tagBasicUrls, Object lock, Queue<String> tagBasicPageUrls,
                                     Object taskLock) {
        this.tagBasicUrls = tagBasicUrls;
        this.lock = lock;
        this.tagBasicPageUrls = tagBasicPageUrls;
        this.taskLock = taskLock;
    }

    @Override
    public void run() {
        ... ...

        // 此flag用於--->如果IP可以進行抓取,則一直使用此IP,不在IP代理池中重新拿取新IP的邏輯判斷
        boolean flag = true;

        // 每個URL用單獨的代理IP進行分析
        while (true) {
            if (flag) {
                synchronized (lock) {
                    while (myRedis.isEmpty()) {
                        try {
                            System.out.println("當前線程:" + Thread.currentThread().getName() + ", " +
                                    "發現ip-proxy-pool已空, 開始進行等待... ...");
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                    ipMessage = myRedis.getIPByList();
                }
            }
            ... ...    
        }
    }
}

從上面的代碼中,我們可以清楚的看到等待/通知機制的經典範式:

等待方(僞代碼)

synchronized(對象) {
    while(條件不滿足) {
        對象.wait();
    }
    對應的邏輯處理
}

通知方(僞代碼)

synchronized(對象) {
     改變條件
     對象.notifyAll();
}

關於等待/通知機制更詳細的使用,參考這篇博客:Java線程之間的通信(等待/通知機制)

  • 差別2:不再給每個線程分配固定數目的任務。將任務放在共享隊列中,供線程使用

在重構IP代理池的那一版本中,我將待抓取任務平分給了多個線程,每個線程將自己拿到的那些任務執行完畢即可。在將IP代理池運用到工程中的時候,我並沒有那樣做,而是維護了一個任務隊列,每個線程都可以在這個任務隊列中取任務,直到隊列爲空爲止。這就改善了在多個線程平分任務的這種情況下,由於一個線程需要完成多個任務,而這多個任務間不是併發執行的缺點。

具體的代碼實現如下(我們只需要注意其中的saveIP方法,方法參數urls就是共享任務隊列):

/**
 * Created by hg_yi on 17-8-11.
 *
 * @Description: 抓取xici代理網的分配線程
 * 抓取不同頁面的xici代理網的html源碼,就使用不同的代理IP,在對IP進行過濾之後進行合併
 */
public class CreateIPProxyPool {
    ... ...

    public void saveIP(Queue<String> urls, Object taskLock) {
        ... ...

        while (true) {
            /**
             * 隨機挑選代理IP(本步驟由於其他線程有可能在位置確定之後對ipMessages數量進行
             * 增加,雖說不會改變已經選擇的ip代理的位置,但合情合理還是在對共享變量進行讀寫的時候要保證
             * 其原子性,否則極易發生髒讀)
             */
            ... ...

            // 任務隊列是共享變量,對其的讀寫必須進行正確的同步
            synchronized (taskLock) {
                if (urls.isEmpty()) {
                    System.out.println("當前線程:" + Thread.currentThread().getName() + ", 發現任務隊列已空");
                    break;
                }
                url = urls.poll();
            }
            ... ...
        }
    }
}

IP代理池在項目中是如何對抗反爬蟲的

我在使用IP代理池對抗反爬蟲的時候,對IP代理池還做了些許改變:修改了IPMessage類結構。看過我關於IP代理池項目博客的同學應該清楚IPMessage這個類是做什麼的,就是用來存儲有關代理IP信息的。類結構如下:

/**
 * Created by hg_yi on 17-8-11.
 *
 * @Description: IPMessage JavaBean
 */
public class IPMessage implements Serializable {
    private static final long serialVersionUID = 1L;
    private String IPAddress;
    private String IPPort;
    private String IPType;
    private String IPSpeed;
    private int useCount;            // 使用計數器,連續三十次這個IP不能使用,就將其從IP代理池中進行清除

    public IPMessage() { this.useCount = 0; }

    public IPMessage(String IPAddress, String IPPort, String IPType, String IPSpeed) {
        this.IPAddress = IPAddress;
        this.IPPort = IPPort;
        this.IPType = IPType;
        this.IPSpeed = IPSpeed;
        this.useCount = 0;
    }

    public int getUseCount() {
        return useCount;
    }

    public void setUseCount() {
        this.useCount++;
    }

    public void initCount() {
        this.useCount = 0;
    }

    ... ...
}

可以看到,我給其中添加了useCount這一成員變量。我在使用xici代理網上的IP時發現,大部分的代理IP一次不能使用並不代表每次都不可使用,因此我在用代理IP進行網頁抓取時的策略作出瞭如下的改變:

  1. 當前代理IP如果解析當前任務失敗,則將此代理IP中的useCount變量進行加1,並將此代理IP進行序列化之後,重新丟進IP代理池,切換至其他代理IP
  2. 如果當前代理IP解析當前任務成功,則將此代理IP中的useCount變量置0,並且繼續使用此代理對其它任務進行抓取,直到任務解析失敗,然後重複第1點
  3. 如果發現從IP代理池中取出的代理IP的useCount變量數值已爲30,則對此代理IP進行捨棄,並切換至其他代理IP

具體的代碼實現如下:

  • 捨棄代理IP,flag用於判斷是否需要從IP代理池中拿取新的IP
/**
 * @Author: spider_hgyi
 * @Date: Created in 下午4:25 18-2-6.
 * @Modified By:
 * @Description: 負責解析帶有頁面參數的商品搜索頁url,得到本頁面中的商品id
 */
public class GoodsDetailsUrlThread implements Runnable {
    private final Object lock;                      // 用於與 ip-proxy-pool 進行協作的鎖
    ... ...

    @Override
    public void run() {
        ... ...
        boolean flag = true;

        while (true) {
            if (flag) {
                synchronized (lock) {
                    while (myRedis.isEmpty()) {
                        try {
                            System.out.println("當前線程:" + Thread.currentThread().getName() + ", " +
                                    "發現ip-proxy-pool已空, 開始進行等待... ...");
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                    ipMessage = myRedis.getIPByList();
                }
            }

            if (ipMessage.getUseCount() >= 30) {
                System.out.println("當前線程:" + Thread.currentThread().getName() + ", 發現此ip:" +
                        ipMessage.getIPAddress() + ":" + ipMessage.getIPPort() + ", 已經連續30次不能使用, 進行捨棄");
                continue;
            }
            ... ...
        }
    }
}
  • 當前代理IP解析任務成功(失敗),useCount置0(++),並持續使用此代理IP抓取新任務(將代理IP丟進IP代理池並拿取新IP)
/**
 * @Author: spider_hgyi
 * @Date: Created in 下午4:25 18-2-6.
 * @Modified By:
 * @Description: 負責解析帶有頁面參數的商品搜索頁url,得到本頁面中的商品id
 */
public class GoodsDetailsUrlThread implements Runnable {
    ... ...

    @Override
    public void run() {
        ... ...

        while (true) {
            if (flag) {
                synchronized (lock) {
                    while (myRedis.isEmpty()) {
                        try {
                            System.out.println("當前線程:" + Thread.currentThread().getName() + ", " +
                                    "發現ip-proxy-pool已空, 開始進行等待... ...");
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                    ipMessage = myRedis.getIPByList();
                }
            }

            ... ...

            if (html != null) {
                ... ...
                flag = false;
            } else {
                // 當前任務解析失敗,將當前任務重新放入任務隊列中,並將flag置爲true
                synchronized (tagBasicPageUrls) {
                    tagBasicPageUrls.offer(tagBasicPageUrl);
                }
                flag = true;
            }
        }
    }
}
/**
 * Created by hg_yi on 17-5-23.
 *
 * @Description: 對淘寶頁面的請求,得到頁面的源碼
 * setConnectTimeout:設置連接超時時間,單位毫秒.
 * setSocketTimeout:請求獲取數據的超時時間,單位毫秒.如果訪問一個接口,
 * 多少時間內無法返回數據,就直接放棄此次調用。
 */
public class HttpRequest {
    // 成功抓取淘寶頁面計數器
    public static int pageCount = 0;

    // 使用代理IP進行網頁的獲取
    public static String getHtmlByProxy(String requestUrl, IPMessage ipMessage, Object lock) {
        ... ...

        try {
            ... ...

            // 得到服務響應狀態碼
            if (statusCode == 200) {
                ... ...
            } else {
                ... ...
            }
            // 只要能返回狀態碼,沒有出現異常,則此代理IP就可使用
            ipMessage.initCount();
        } catch (IOException e) {
            ... ...
            ipMessage.setUseCount();
            synchronized (lock) {
                myRedis.setIPToList(ipMessage);
            }
        } finally {
            ... ...
        }

        return html;
    }
}

布隆過濾器

在這篇博客中,詳細的介紹了布隆過濾器的實現原理:海量URL去重之布隆過濾器,我在將布隆過濾器應用到項目中的時候,有些方法發生了改變。

之所以將布隆過濾器在這裏單獨提出來,是因爲想給大家提供自己之前寫的有關布隆過濾器的實現原理。搞清楚原理之後,大家再看項目中布隆過濾器的相關實現,也就會輕鬆許多。


監控線程—tagBasicPageURLs-cache

這個線程的主要作用是將Redis數據庫中緩存的,已經成功解析過的任務,將其對應MySQL中所在的行記錄中的flag位設置爲true。在前面也說了,我將任務隊列保存在了MySQL數據庫中,其中對應的每一條記錄,都有一個額外的標誌位,flag。設置這一標誌位的主要目的是,對爬蟲系統做了一個簡單的宕機恢復。我們應當對已經抓取過的任務做一定的標記手段,以防止在系統突然死機或其他突發狀況下,需要重啓項目的情況。這個時候,我們當然不可能對所有的任務重新進行抓取。

對於這個問題的處理,我在項目中的實現思路如下:

  1. 在任務抓取線程:thread-GoodsDetailsUrl-i,主要用來解析商品ID的線程中,如果抓取完一個任務,就將這個任務先緩存到Redis數據庫中,畢竟如果直接將這個任務在MySQL中所在的行記錄中的flag置爲true的話,效率就有點低下了
  2. 設置監控線程:tagBasicPageURLs-cache,監控緩存在Redis數據庫中已抓取過任務的數量,我設置的閾值是大於等於100,當然這個數字不絕對,因爲線程調度是不可控的。但爲了接近我所設置的這個閾值,我將此線程的優先級設置爲最高
  3. 監控線程開始工作,期間使用同步塊保證任務抓取線程不得給Redis數據庫中添加新的已經抓取成功的任務,以達到監控線程與任務抓取線程對Redis數據庫操作之間的互斥性

具體的代碼實現如下:

監控線程—tagBasicPageURLs-cache

/**
 * @Author: spider_hgyi
 * @Date: Created in 上午11:51 18-2-6.
 * @Modified By:
 * @Description: 處理緩存的線程,將 tag-basic-page-urls 中存在的url標記進MySQL數據庫中
 */
public class TagBasicPageURLsCacheThread implements Runnable {
    private final Object tagBasicPageURLsCacheLock;

    public TagBasicPageURLsCacheThread(Object tagBasicPageURLsCacheLock) {
        this.tagBasicPageURLsCacheLock = tagBasicPageURLsCacheLock;
    }

    public static void start(Object tagBasicPageURLsCacheLock) {
        Thread thread = new Thread(new TagBasicPageURLsCacheThread(tagBasicPageURLsCacheLock));
        thread.setName("tagBasicPageURLs-cache");
        thread.setPriority(MAX_PRIORITY);           // 將這個線程的優先級設置最大,允許出現誤差
        thread.start();
    }

    @Override
    public void run() {
        MyRedis myRedis = new MyRedis();
        MySQL mySQL = new MySQL();

        while (true) {
            synchronized (tagBasicPageURLsCacheLock) {
                while (myRedis.tagBasicPageURLsCacheIsOk()) {
                    System.out.println("當前線程:" + Thread.currentThread().getName() + ", " +
                            "準備開始將 tag-basic-page-urls-cache 中的url在MySQL中進行標記");

                    List<String> tagBasicPageURLs = myRedis.getTagBasicPageURLsFromCache();
                    System.out.println("tagBasicPageURLs-size: " + tagBasicPageURLs.size());

                    // 將MySQL數據庫中對應的url標誌位置爲true
                    mySQL.setFlagFromTagsSearchUrl(tagBasicPageURLs);
                }
            }
        }
    }
}

任務抓取線程—thread-GoodsDetailsUrl-i:(截取了部分代碼)

... ...
// 將tagBasicPageUrl寫進Redis數據庫
synchronized (tagBasicPageURLsCacheLock) {
    System.out.println("當前線程:" + Thread.currentThread().getName() + ",準備將tagBasicPageUrl寫進Redis數據庫,tagBasicPageUrl:" + tagBasicPageUrl);
    myRedis.setTagBasicPageURLToCache(tagBasicPageUrl);
}
... ...

MyRedis中的tagBasicPageURLsCacheIsOk()方法

// 判斷 tagBasicPageURLs-cache 中的url數量是否達到100條
public boolean tagBasicPageURLsCacheIsOk() {
    tagBasicPageURLsCacheReadWriteLock.readLock().lock();
    Long flag = jedis.llen("tag-basic-page-urls-cache");
    tagBasicPageURLsCacheReadWriteLock.readLock().unlock();

    return flag >= 100;
}

其實,我爲什麼會稱自己對宕機情況的發生做了簡單的處理:這個解決方案並不完美,可以說存在很大的瑕疵。

我在將已經緩存至Redis數據庫中,並解析完成的任務URL通過監控線程—tagBasicPageURLs-cache進行MySQL中相關標誌位置true的時候,設置的是當Redis數據庫中緩存的任務數量達到100及以上的時候,這個監控線程纔會啓動。

那麼就會出現一種情況:Redis數據庫中的URL數量沒有達到100及以上,這個時候系統發生宕機,那麼這些已經抓取過的URL在MySQL中所對應的flag標誌位就不會被置爲true。也就是說,在我們下次重新啓動該系統的時候,這些已經抓取過的URL還會被重新抓取,並且每次存在的誤差並無法嚴格判定,有可能沒有誤差,有可能誤差達到了百條左右。

針對這個bug,目前博主還沒有想到比較好的解決辦法,相信日後會攻破它。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章