前面實現了一個最基礎的爬取單網頁的爬蟲,這一篇則着手解決深度爬取的問題html
簡單來說,就是爬了一個網頁以後,繼續爬這個網頁中的連接java
背景比較簡單和明確,當爬了一個網頁以後,目標是不要就此打住,掃描這個網頁中的連接,繼續爬,因此有幾個點須要考慮:git
針對上面的幾點,結合以前的實現結構,在執行 doFetchPage
方法獲取網頁以後,還得作一些其餘的操做github
開始依然是先把功能點實現,而後再考慮具體的優化細節web
先加一個配置項,表示爬取頁面深度; 其次就是保存的結果,得有個容器來暫存, 因此在 SimpleCrawlJob
會新增兩個屬性緩存
/** * 批量查詢的結果 */ private List<CrawlResult> crawlResults = new ArrayList<>(); /** * 爬網頁的深度, 默認爲0, 即只爬取當前網頁 */ private int depth = 0;
由於有深度爬取的過程,因此須要修改一下爬取網頁的代碼,新增一個 doFetchNetxtPage
方法,進行迭代爬取網頁,這時,結果匹配處理方法也不能如以前的直接賦值了,稍微改一下便可, 改爲返回一個接過實例安全
/** * 執行抓取網頁 */ public void doFetchPage() throws Exception { doFetchNextPage(0, this.crawlMeta.getUrl()); this.crawlResult = this.crawlResults.get(0); } private void doFetchNextPage(int currentDepth, String url) throws Exception { HttpResponse response = HttpUtils.request(new CrawlMeta(url, this.crawlMeta.getSelectorRules()), httpConf); String res = EntityUtils.toString(response.getEntity()); CrawlResult result; if (response.getStatusLine().getStatusCode() != 200) { // 請求成功 result = new CrawlResult(); result.setStatus(response.getStatusLine().getStatusCode(), response.getStatusLine().getReasonPhrase()); result.setUrl(crawlMeta.getUrl()); this.crawlResults.add(result); return; } result = doParse(res); // 超過最大深度, 不繼續爬 if (currentDepth > depth) { return; } Elements elements = result.getHtmlDoc().select("a[href]"); for(Element element: elements) { doFetchNextPage(currentDepth + 1, element.attr("href")); } } private CrawlResult doParse(String html) { Document doc = Jsoup.parse(html); Map<String, List<String>> map = new HashMap<>(crawlMeta.getSelectorRules().size()); for (String rule : crawlMeta.getSelectorRules()) { List<String> list = new ArrayList<>(); for (Element element : doc.select(rule)) { list.add(element.text()); } map.put(rule, list); } CrawlResult result = new CrawlResult(); result.setHtmlDoc(doc); result.setUrl(crawlMeta.getUrl()); result.setResult(map); result.setStatus(CrawlResult.SUCCESS); return result; }
主要的關鍵代碼在 doFetchNextPage
中,這裏有兩個參數,第一個表示當前url屬於爬取的第幾層,爬完以後,判斷是否超過最大深度,若是沒有,則獲取出網頁中的全部連接,迭代調用一遍多線程
下面主要是獲取網頁中的跳轉連接,直接從jsoup的源碼中的example中獲取,獲取網頁中連接的方法app
// 未超過最大深度, 繼續爬網頁中的全部連接 result = doParse(res); Elements elements = result.getHtmlDoc().select("a[href]"); for(Element element: elements) { doFetchNextPage(currentDepth + 1, element.attr("href")); }
測試代碼和以前的差很少,惟一的區別就是指定了爬取的深度,返回結果就不截圖了,實在是有點多ide
/** * 深度爬 * @throws InterruptedException */ @Test public void testDepthFetch() throws InterruptedException { String url = "https://my.oschina.net/u/566591/blog/1031575"; CrawlMeta crawlMeta = new CrawlMeta(); crawlMeta.setUrl(url); SimpleCrawlJob job = new SimpleCrawlJob(1); job.setCrawlMeta(crawlMeta); Thread thread = new Thread(job, "crawlerDepth-test"); thread.start(); thread.join(); List<CrawlResult> result = job.getCrawlResults(); System.out.println(result); }
上面雖然是實現了目標,但問題卻有點多:
就好比上面的測試case,發現有122個跳轉連接,順序爬速度有點慢
連接中存在重複、頁面內錨點、js等各類狀況,並非都知足需求
最後的結果塞到List中,深度較多時,連接較多時,list可能被撐暴
過濾規則,能夠劃分爲兩種,正向的匹配,和逆向的排除
首先是修改配置類 CrawlMeta
, 新增兩個配置
/** * 正向的過濾規則 */ @Setter @Getter private Set<Pattern> positiveRegex = new HashSet<>(); /** * 逆向的過濾規則 */ @Setter @Getter private Set<Pattern> negativeRegex = new HashSet<>(); public Set<Pattern> addPositiveRegex(String regex) { this.positiveRegex.add(Pattern.compile(regex)); return this.positiveRegex; } public Set<Pattern> addNegativeRegex(String regex) { this.negativeRegex.add(Pattern.compile(regex)); return this.negativeRegex; }
而後在遍歷子連接時,判斷一下是否知足需求
// doFetchNextPage 方法 Elements elements = result.getHtmlDoc().select("a[href]"); String src; for(Element element: elements) { src = element.attr("href"); if (matchRegex(src)) { doFetchNextPage(currentDepth + 1, element.attr("href")); } } // 規則匹配方法 private boolean matchRegex(String url) { Matcher matcher; for(Pattern pattern: crawlMeta.getPositiveRegex()) { matcher = pattern.matcher(url); if (matcher.find()) { return true; } } for(Pattern pattern: crawlMeta.getNegativeRegex()) { matcher = pattern.matcher(url); if(matcher.find()) { return false; } } return crawlMeta.getPositiveRegex().size() == 0; }
上面主要是經過正則來進行過濾,暫不考慮正則帶來的開銷問題,至少是解決了一個過濾的問題
可是,可是,若是網頁中的連接是相對路徑的話,會怎麼樣
直接使用 Jsoup來測試一個網頁,看獲取的link地址爲何
// 獲取網頁中的全部連接 @Test public void testGetLink() throws IOException { String url = "http://chengyu.911cha.com/zishu_3_p1.html"; Connection httpConnection = HttpConnection.connect(url) .header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") .header("connection", "Keep-Alive") .header("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"); Document doc = httpConnection.get(); Elements links = doc.select("a[href]"); print("\nLinks: (%d)", links.size()); }
看下取出的連接
根據上面的測試,獲取的連接若是是相對地址,則會有問題,須要有一個轉化的過程,這個改動比較簡單,jsoup自己是支持的
改一行便可
// 解析爲documnet對象時,指定 baseUrl // 上面的代碼結構會作一點修改,後面會說到 Document doc = Jsoup.parse(html, url); // 獲取連接時,前面添加abs src = element.attr("abs:href");
當爬取的數據量較多時,將結果都保存在內存中,並非一個好的選擇,假色每一個網頁中,知足規則的是有10個,那麼depth=n, 則從第一個網頁出發,最終會獲得
1 + 10 + ... + 10^n = (10^(n+1) - 1) / 9
顯然在實際狀況中是不可取的,所以能夠改造一下,獲取數據後給一個回調,讓用戶本身來選擇如何處理結果,這時 SimpleCrawelJob
的結構基本上知足不了需求了
從新開始設計
AbstractJob
類中定義一個回調方法/** * 解析完網頁後的回調方法 * * @param crawlResult */ protected abstract void visit(CrawlResult crawlResult);
DefaultAbstractCrawlJob
實現爬取網頁邏輯的抽象類這個類實現爬取網頁的主要邏輯,也就是將以前的SimpleCrwalJob
的實現拷貝過來,區別是幹掉了返回結果; 順帶修了一個小bug 😢
/** * Created by yihui on 2017/6/29. */ @Getter @Setter @NoArgsConstructor public abstract class DefaultAbstractCrawlJob extends AbstractJob { /** * 配置項信息 */ private CrawlMeta crawlMeta; /** * http配置信息 */ private CrawlHttpConf httpConf = new CrawlHttpConf(); /** * 爬網頁的深度, 默認爲0, 即只爬取當前網頁 */ protected int depth = 0; public DefaultAbstractCrawlJob(int depth) { this.depth = depth; } /** * 執行抓取網頁 */ public void doFetchPage() throws Exception { doFetchNextPage(0, this.crawlMeta.getUrl()); } private void doFetchNextPage(int currentDepth, String url) throws Exception { CrawlMeta subMeta = new CrawlMeta(url, this.crawlMeta.getSelectorRules(), this.crawlMeta.getPositiveRegex(), this.crawlMeta.getNegativeRegex()); HttpResponse response = HttpUtils.request(subMeta, httpConf); String res = EntityUtils.toString(response.getEntity()); CrawlResult result; if (response.getStatusLine().getStatusCode() != 200) { // 請求成功 result = new CrawlResult(); result.setStatus(response.getStatusLine().getStatusCode(), response.getStatusLine().getReasonPhrase()); result.setUrl(crawlMeta.getUrl()); this.visit(result); return; } // 網頁解析 result = doParse(res, subMeta); // 回調用戶的網頁內容解析方法 this.visit(result); // 超過最大深度, 不繼續爬 if (currentDepth > depth) { return; } Elements elements = result.getHtmlDoc().select("a[href]"); String src; for(Element element: elements) { // 確保將相對地址轉爲絕對地址 src = element.attr("abs:href"); if (matchRegex(src)) { doFetchNextPage(currentDepth + 1, src); } } } private CrawlResult doParse(String html, CrawlMeta meta) { // 指定baseUrl, 不然利用 abs:href 獲取連接會出錯 Document doc = Jsoup.parse(html, meta.getUrl()); Map<String, List<String>> map = new HashMap<>(meta.getSelectorRules().size()); for (String rule : crawlMeta.getSelectorRules()) { List<String> list = new ArrayList<>(); for (Element element : doc.select(rule)) { list.add(element.text()); } map.put(rule, list); } CrawlResult result = new CrawlResult(); result.setHtmlDoc(doc); result.setUrl(meta.getUrl()); result.setResult(map); result.setStatus(CrawlResult.SUCCESS); return result; } private boolean matchRegex(String url) { Matcher matcher; for(Pattern pattern: crawlMeta.getPositiveRegex()) { matcher = pattern.matcher(url); if (matcher.find()) { return true; } } for(Pattern pattern: crawlMeta.getNegativeRegex()) { matcher = pattern.matcher(url); if(matcher.find()) { return false; } } return crawlMeta.getPositiveRegex().size() == 0; } }
SimpleCrawlJob
重寫這個簡單爬蟲任務的實現,由於主要邏輯在 DefaultAbstractCrawlJob
中已經實現了,因此直接繼承過來便可
主要關注的就是 visit
方法,這裏就是爬取網頁以後的回調,這個最簡單的爬蟲任務,就是將結果保存在內存中
/** * 最簡單的一個爬蟲任務 * <p> * Created by yihui on 2017/6/27. */ @Getter @Setter @NoArgsConstructor public class SimpleCrawlJob extends DefaultAbstractCrawlJob { /** * 存儲爬取的結果 */ private CrawlResult crawlResult; /** * 批量查詢的結果 */ private List<CrawlResult> crawlResults = new ArrayList<>(); public SimpleCrawlJob(int depth) { super(depth); } @Override protected void visit(CrawlResult crawlResult) { crawlResults.add(crawlResult); } public CrawlResult getCrawlResult() { if(crawlResults.size() == 0) { return null; } return crawlResults.get(0); } }
和以前沒有任何區別,先來個簡單的
@Test public void testDepthFetch() throws InterruptedException { String url = "http://chengyu.911cha.com/zishu_3_p1.html"; CrawlMeta crawlMeta = new CrawlMeta(); crawlMeta.setUrl(url); crawlMeta.addPositiveRegex("http://chengyu.911cha.com/zishu_3_p([0-9]+).html"); SimpleCrawlJob job = new SimpleCrawlJob(1); job.setCrawlMeta(crawlMeta); Thread thread = new Thread(job, "crawlerDepth-test"); thread.start(); thread.join(); List<CrawlResult> result = job.getCrawlResults(); System.out.println(result); }
運行截圖
直接使用 DefaultAbstractCrawl
抽象類的回調來進行測試
@Test public void testSelfCwralFetch() throws InterruptedException { String url = "http://chengyu.911cha.com/zishu_3_p1.html"; CrawlMeta crawlMeta = new CrawlMeta(); crawlMeta.setUrl(url); crawlMeta.addPositiveRegex("http://chengyu.911cha.com/zishu_3_p([0-9]+).html"); DefaultAbstractCrawlJob job = new DefaultAbstractCrawlJob(1) { @Override protected void visit(CrawlResult crawlResult) { System.out.println(crawlResult.getUrl()); } }; job.setCrawlMeta(crawlMeta); Thread thread = new Thread(job, "crawlerDepth-test"); thread.start(); thread.join(); System.out.println("over"); }
從上面能夠發現,重複爬取是比較浪費的事情,所以去重是很是有必要的;通常想法是將爬過的url都標記一下,每次爬以前判斷是否已經爬過了
依然先是採用最low的方法,搞一個Set來記錄全部爬取的url,由於具體的爬蟲任務設計的是多線程的,因此這個Set是要求多線程共享的
此外考慮到去重的手段比較多,咱們目前雖然只是採用的內存中加一個緩存表,但不妨礙咱們設計的時候,採用面向接口的方式
IStorage
接口提供存記錄,判斷記錄是否存在的方法
public interface IStorage { /** * 若爬取的URL不在storage中, 則寫入; 不然忽略 * * @param url 爬取的網址 * @return true 表示寫入成功, 即以前沒有這條記錄; false 則表示以前已經有記錄了 */ boolean putIfNotExist(String url, CrawlResult result); /** * 判斷是否存在 * @param url * @return */ boolean contains(String url); }
RamStorage
利用Map實現的內存存儲public class RamStorage implements IStorage { private Map<String, CrawlResult> map = new ConcurrentHashMap<>(); @Override public boolean putIfNotExist(String url, CrawlResult result) { if(map.containsKey(url)) { return false; } map.put(url, result); return true; } @Override public boolean contains(String url) { return map.containsKey(url); } }
StorageWrapper
封裝類這個封裝類要求多線程共享,因此咱們採用單例模式,保證只有一個實例
一個最原始的實現方式以下(暫不考慮其中比較猥瑣的storage實例化方式)
public class StorageWrapper { private static StorageWrapper instance = new StorageWrapper(); private IStorage storage; public static StorageWrapper getInstance() { return instance; } private StorageWrapper() { storage = new RamStorage(); } /** * 判斷url是否被爬取過 * * @param url * @return */ public boolean ifUrlFetched(String url) { return storage.contains(url); } /** * 爬完以後, 新增一條爬取記錄 * @param url * @param crawlResult */ public void addFetchRecord(String url, CrawlResult crawlResult) { storage.putIfNotExist(url, crawlResult); } }
這樣一個簡單的保存爬取歷史記錄的容器就有了,那麼在爬取時,就須要事前判斷一下
對應的 DefaultAbstractCrawlJob#doFetchNextPage
方法更新以下
// fixme 非線程安全 private void doFetchNextPage(int currentDepth, String url) throws Exception { if (StorageWrapper.getInstance().ifUrlFetched(url)) { return; } CrawlMeta subMeta = new CrawlMeta(url, this.crawlMeta.getSelectorRules(), this.crawlMeta.getPositiveRegex(), this.crawlMeta.getNegativeRegex()); HttpResponse response = HttpUtils.request(subMeta, httpConf); String res = EntityUtils.toString(response.getEntity()); CrawlResult result; if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { // 請求成功 result = new CrawlResult(); result.setStatus(response.getStatusLine().getStatusCode(), response.getStatusLine().getReasonPhrase()); result.setUrl(crawlMeta.getUrl()); this.visit(result); return; } // 網頁解析 result = doParse(res, subMeta); StorageWrapper.getInstance().addFetchRecord(url, result); // 回調用戶的網頁內容解析方法 this.visit(result); // 超過最大深度, 不繼續爬 if (currentDepth > depth) { return; } Elements elements = result.getHtmlDoc().select("a[href]"); String src; for(Element element: elements) { // 確保將相對地址轉爲絕對地址 src = element.attr("abs:href"); if (matchRegex(src)) { doFetchNextPage(currentDepth + 1, src); } } }
若是仔細看上面的方法,就會發如今多線程環境下,依然可能存在重複爬取的狀況
若有兩個CrawlJob
任務,若爬取的是同一個url,第一個任務爬取完,尚未回寫到Storage
時,第二個任務開始爬,這時,事前判斷沒有記錄,而後經過以後開始爬,這時就依然會出現重複爬的問題
要解決這個問題,一個簡單的方法就是加鎖,在判斷一個url沒有被爬時,到回寫一條爬取結果這段期間,加一個保護鎖
StorageWrapper
更新後以下
/** * Created by yihui on 2017/6/29. */ public class StorageWrapper { private static StorageWrapper instance = new StorageWrapper(); private IStorage storage; private Map<String, Lock> lockMap = new ConcurrentHashMap<>(); public static StorageWrapper getInstance() { return instance; } private StorageWrapper() { storage = new RamStorage(); } /** * 判斷url是否被爬取過; 是則返回true; 否這返回false, 並上鎖 * * @param url * @return */ public boolean ifUrlFetched(String url) { if(storage.contains(url)) { return true; } synchronized (this) { if (!lockMap.containsKey(url)) { // 不存在時,加一個鎖 lockMap.put(url, new ReentrantLock()); } this.lock(url); if (storage.contains(url)) { return true; } // System.out.println(Thread.currentThread() + " lock url: " + url); return false; } } /** * 爬完以後, 新增一條爬取記錄 * @param url * @param crawlResult */ public void addFetchRecord(String url, CrawlResult crawlResult) { try { if (crawlResult != null) { storage.putIfNotExist(url, crawlResult); this.unlock(url); } } catch (Exception e) { System.out.println(Thread.currentThread().getName() + " result: " + url + " e: " + e); } } private void lock(String url) { lockMap.get(url).lock(); } private void unlock(String url) { lockMap.get(url).unlock(); } }
使用處,稍稍變更以下
private void doFetchNextPage(int currentDepth, String url) throws Exception { CrawlResult result = null; try { // 判斷是否爬過;未爬取,則上鎖並繼續爬取網頁 if (StorageWrapper.getInstance().ifUrlFetched(url)) { return; } CrawlMeta subMeta = new CrawlMeta(url, this.crawlMeta.getSelectorRules(), this.crawlMeta.getPositiveRegex(), this.crawlMeta.getNegativeRegex()); HttpResponse response = HttpUtils.request(subMeta, httpConf); String res = EntityUtils.toString(response.getEntity()); if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { // 請求成功 result = new CrawlResult(); result.setStatus(response.getStatusLine().getStatusCode(), response.getStatusLine().getReasonPhrase()); result.setUrl(crawlMeta.getUrl()); this.visit(result); return; } // 網頁解析 result = doParse(res, subMeta); } finally { // 添加一條記錄, 並釋放鎖 StorageWrapper.getInstance().addFetchRecord(url, result); } // 回調用戶的網頁內容解析方法 this.visit(result); // 超過最大深度, 不繼續爬 if (currentDepth > depth) { return; } Elements elements = result.getHtmlDoc().select("a[href]"); String src; for(Element element: elements) { // 確保將相對地址轉爲絕對地址 src = element.attr("abs:href"); if (matchRegex(src)) { doFetchNextPage(currentDepth + 1, src); } } }
@Test public void testSelfCwralFetch() throws InterruptedException { String url = "http://chengyu.t086.com/gushi/1.htm"; CrawlMeta crawlMeta = new CrawlMeta(); crawlMeta.setUrl(url); crawlMeta.addPositiveRegex("http://chengyu.t086.com/gushi/[0-9]+\\.htm$"); DefaultAbstractCrawlJob job = new DefaultAbstractCrawlJob(1) { @Override protected void visit(CrawlResult crawlResult) { System.out.println("job1 >>> " + crawlResult.getUrl()); } }; job.setCrawlMeta(crawlMeta); String url2 = "http://chengyu.t086.com/gushi/2.htm"; CrawlMeta crawlMeta2 = new CrawlMeta(); crawlMeta2.setUrl(url2); crawlMeta2.addPositiveRegex("http://chengyu.t086.com/gushi/[0-9]+\\.htm$"); DefaultAbstractCrawlJob job2 = new DefaultAbstractCrawlJob(1) { @Override protected void visit(CrawlResult crawlResult) { System.out.println("job2 >>> " + crawlResult.getUrl()); } }; job2.setCrawlMeta(crawlMeta2); Thread thread = new Thread(job, "crawlerDepth-test"); Thread thread2 = new Thread(job2, "crawlerDepth-test2"); thread.start(); thread2.start(); thread.join(); thread2.join(); }
輸出以下
job2 >>> http://chengyu.t086.com/gushi/2.htm job1 >>> http://chengyu.t086.com/gushi/1.htm job1 >>> http://chengyu.t086.com/gushi/3.htm job2 >>> http://chengyu.t086.com/gushi/4.htm job1 >>> http://chengyu.t086.com/gushi/5.htm job1 >>> http://chengyu.t086.com/gushi/6.htm job1 >>> http://chengyu.t086.com/gushi/7.htm
這一篇的博文有點多,到這裏其實上面一些提出的問題尚未解決,留待下一篇博文來fix掉, 下面則主要說明下本篇的要點
這裏使用了迭代的思路,爬到一個網頁以後,判斷是否須要中止,不中止,則把該網頁中的連接撈出來,繼續爬;關鍵點
過濾,主要利用正則來匹配連接;這裏須要注意一下幾點
- 正向過濾 - 負向過濾
如何保證一個連接被爬了以後,不會被重複進行爬取?
- 記錄爬取歷史 - 注意多線程安全問題 - 加鎖(一把鎖會致使性能下降,這裏採用了一個url對應一個鎖,注意看實現細節,較多的坑)
遺留問題
源碼地址: https://github.com/liuyueyi/quick-crawler/releases/tag/v0.003
對應tag :v0.003