源碼地址:多線程爬蟲–抓取淘寶商品詳情頁URL
項目地址中包含了一份README,因此對於項目的介紹省去部分內容。這篇博客,主要講述項目的構建思路以及實現細節。
項目概述及成果
首先將本項目使用到技術羅列出來:
- MySQL數據庫進行數據持久化及對宕機情況的發生做簡單的處理
- Redis數據庫做IP代理池及部分已抓取任務的緩存
- 自制IP代理池
- 使用多線程執行任務(同步塊,讀寫鎖,等待與通知機制,線程優先級)
- HttpClient與Jsoup的使用
- 序列化與反序列化
- 布隆過濾器
之後會對其中使用到的技術進行詳細的解釋。
本項目如README中所述,還有許多不完善的地方,但IP代理池與任務抓取線程之間的調度與協作基本已無問題。也就是說,在此項目的框架上,如果你想修改其中代碼用作其他抓取任務,也是完全可以的。我抓取到的數據所保存的源文件也放在GitHub的README上供大家免費瀏覽與下載(近90000的商品ID)。
整體思路
- 首先你需要一個IP代理池
- 使用本機IP將淘寶中基本的商品分類抓取下來
- 頁面源鏈接:
https://www.taobao.com/tbhome/page/market-list
- 從頁面源鏈接中解析到的URL形如下:
https://s.taobao.com/search?q=羽絨服&style=grid
- 將諸如此類的URL
https://s.taobao.com/search?q=羽絨服&style=grid
作爲任務隊列,使用多線程對其進行抓取與解析(使用代理IP),解析的內容爲第4點- 我們需要分析每一種類的商品在淘寶中大概具有多少數量,爲此我解析出帶有頁面參數的URL,在第3點中URL的基礎上:
https://s.taobao.com/search?q=羽絨服&style=grid&s=44
,在瀏覽器中打開URL可發現此頁面爲此種類衣服的第二頁- 我們得到了每一種商品帶有頁面參數的URL,意味着我們可以得到此類商品中全部或部分的商品ID,有了商品ID,我們就可以進入商品詳情頁抓取我們想要的數據了
- 爲了實現第5點,我們先將第4點中抓取到的URL全部存儲進MySQL中
- 從MySQL中將待抓取URL全部取出,存儲到一個隊列中,使用多線程對此共享隊列進行操作,使用代理IP從待解析URL中解析出本頁面中包含的商品ID,並構建商品詳情頁URL
- 在第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進行網頁抓取時的策略作出瞭如下的改變:
- 當前代理IP如果解析當前任務失敗,則將此代理IP中的useCount變量進行加1,並將此代理IP進行序列化之後,重新丟進IP代理池,切換至其他代理IP
- 如果當前代理IP解析當前任務成功,則將此代理IP中的useCount變量置0,並且繼續使用此代理對其它任務進行抓取,直到任務解析失敗,然後重複第1點
- 如果發現從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。設置這一標誌位的主要目的是,對爬蟲系統做了一個簡單的宕機恢復。我們應當對已經抓取過的任務做一定的標記手段,以防止在系統突然死機或其他突發狀況下,需要重啓項目的情況。這個時候,我們當然不可能對所有的任務重新進行抓取。
對於這個問題的處理,我在項目中的實現思路如下:
- 在任務抓取線程:
thread-GoodsDetailsUrl-i
,主要用來解析商品ID的線程中,如果抓取完一個任務,就將這個任務先緩存到Redis數據庫中,畢竟如果直接將這個任務在MySQL中所在的行記錄中的flag置爲true的話,效率就有點低下了- 設置監控線程:
tagBasicPageURLs-cache
,監控緩存在Redis數據庫中已抓取過任務的數量,我設置的閾值是大於等於100,當然這個數字不絕對,因爲線程調度是不可控的。但爲了接近我所設置的這個閾值,我將此線程的優先級設置爲最高- 監控線程開始工作,期間使用同步塊保證任務抓取線程不得給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,目前博主還沒有想到比較好的解決辦法,相信日後會攻破它。