天天看點

不會python?那就換一種姿勢爬蟲!Java爬蟲技術總結

—本部落格為原創内容,轉載需注明本人—

前幾天有個師妹将要畢業,需要準備畢業論文,但是論文調研需要資料資料,上知網一查,十幾萬條資料!指導老師讓她手動copy收集,十幾萬的資料手動copy要浪費多少時間啊,然後她就找我幫忙。我想了一下,寫個爬蟲程式去爬下來或許是個不錯的解決方案呢!之前一直聽其他人說爬蟲最好用python,但是我是一名Java工程師啊!魯迅曾說過,學python救不了中國人,但是Java可以!

                                  ​

好啦,開個玩笑,主要是她急着要,我單獨學一門語言去做爬蟲,有點不現實,然後我就用了Java,去知乎看一下,發現原來Java也有很多開源的爬蟲api嘛,然後就是開始幹了,三天時間寫好程式,可以爬資料下來,下面分享一下技術總結,感興趣的朋友可以一起交流一下!

在分享技術之前,先簡單說一下爬蟲的原理吧。網絡爬蟲聽起來很高大上,其實就是原理很簡單,說的通俗一點就是,程式向指定連接配接送出請求,伺服器傳回完整的html回來,程式拿到這個html之後就進行解析,解析的原理就是定位html元素,然後将你想要的資料拿下來。

那再看一下Java開源的爬蟲API,挺多的,具體可以點選連結看一下:推薦一些優秀的開源Java爬蟲項目

因為我不是要在實際的項目中應用,是以我選擇非常輕量級易上手的 crawler4j 。感興趣的可以去github看看它的介紹,我這邊簡單介紹一下怎麼應用。用起來非常簡單,現在maven導入依賴。

<dependency>
            <groupId>edu.uci.ics</groupId>
            <artifactId>crawler4j</artifactId>
            <version>4.2</version>
        </dependency>           

自定義爬蟲類繼承插件的WebCrawler類,然後重寫裡面shouldVisit和Visit方法。

package com.chf;

import edu.uci.ics.crawler4j.crawler.Page;
import edu.uci.ics.crawler4j.crawler.WebCrawler;
import edu.uci.ics.crawler4j.parser.HtmlParseData;
import edu.uci.ics.crawler4j.url.WebURL;

import java.util.Set;
import java.util.regex.Pattern;

/**
 * @author:chf
 * @description: 自定義爬蟲類需要繼承WebCrawler類,決定哪些url可以被爬以及處理爬取的頁面資訊
 * @date:2019/3/8
 **/
public class MyCraeler extends WebCrawler {

    /**
     * 正則比對指定的字尾檔案
     */
    private final static Pattern FILTERS = Pattern.compile(".*(\\.(css|js|bmp|gif|jpe?g" + "|png|tiff?|mid|mp2|mp3|mp4"
            + "|wav|avi|mov|mpeg|ram|m4v|pdf" + "|rm|smil|wmv|swf|wma|zip|rar|gz))$");

    /**
     * 這個方法主要是決定哪些url我們需要抓取,傳回true表示是我們需要的,傳回false表示不是我們需要的Url
     * 第一個參數referringPage封裝了目前爬取的頁面資訊
     * 第二個參數url封裝了目前爬取的頁面url資訊
     */
    @Override
    public boolean shouldVisit(Page referringPage, WebURL url) {
        String href = url.getURL().toLowerCase();  // 得到小寫的url
        return !FILTERS.matcher(href).matches()   // 正則比對,過濾掉我們不需要的字尾檔案
                && href.startsWith("http://r.cnki.net/kns/brief/result.aspx");  // url必須是http://www.java1234.com/開頭,規定站點
    }

    /**
     * 當我們爬到我們需要的頁面,這個方法會被調用,我們可以盡情的處理這個頁面
     * page參數封裝了所有頁面資訊
     */
    @Override
    public void visit(Page page) {
        String url = page.getWebURL().getURL();  // 擷取url
        System.out.println("URL: " + url);

        if (page.getParseData() instanceof HtmlParseData) {  // 判斷是否是html資料
            HtmlParseData htmlParseData = (HtmlParseData) page.getParseData(); // 強制類型轉換,擷取html資料對象
            String text = htmlParseData.getText();  // 擷取頁面純文字(無html标簽)
            String html = htmlParseData.getHtml();  // 擷取頁面Html
            Set<WebURL> links = htmlParseData.getOutgoingUrls();  // 擷取頁面輸對外連結接

            System.out.println("純文字長度: " + text.length());
            System.out.println("html長度: " + html.length());
            System.out.println("輸對外連結接個數: " + links.size());
        }
    }
}
           

然後定義一個Controller來執行你的爬蟲類

package com.chf;

import edu.uci.ics.crawler4j.crawler.CrawlConfig;
import edu.uci.ics.crawler4j.crawler.CrawlController;
import edu.uci.ics.crawler4j.fetcher.PageFetcher;
import edu.uci.ics.crawler4j.robotstxt.RobotstxtConfig;
import edu.uci.ics.crawler4j.robotstxt.RobotstxtServer;

/**
 * @author:chf
 * @description: 爬蟲機器人控制器
 * @date:2019/3/8
 **/
public class Controller {
    public static void main(String[] args) throws Exception {
        String crawlStorageFolder = "C:/Users/94068/Desktop/logs/crawl"; // 定義爬蟲資料存儲位置
        int numberOfCrawlers =2; // 定義7個爬蟲,也就是7個線程

        CrawlConfig config = new CrawlConfig(); // 定義爬蟲配置
        config.setCrawlStorageFolder(crawlStorageFolder); // 設定爬蟲檔案存儲位置
        /*
         * 最多爬取多少個頁面
         */
        config.setMaxPagesToFetch(1000);
        //爬取二進制檔案
//        config.setIncludeBinaryContentInCrawling(true);
        //爬取深度
        config.setMaxDepthOfCrawling(1);

        /*
         * 執行個體化爬蟲控制器
         */
        PageFetcher pageFetcher = new PageFetcher(config); // 執行個體化頁面擷取器
        RobotstxtConfig robotstxtConfig = new RobotstxtConfig(); // 執行個體化爬蟲機器人配置 比如可以設定 user-agent

        // 執行個體化爬蟲機器人對目标伺服器的配置,每個網站都有一個robots.txt檔案 規定了該網站哪些頁面可以爬,哪些頁面禁止爬,該類是對robots.txt規範的實作
        RobotstxtServer robotstxtServer = new RobotstxtServer(robotstxtConfig, pageFetcher);
        // 執行個體化爬蟲控制器
        CrawlController controller = new CrawlController(config, pageFetcher, robotstxtServer);

        /**
         * 配置爬蟲種子頁面,就是規定的從哪裡開始爬,可以配置多個種子頁面
         */
        controller.addSeed("http://r.cnki.net/kns/brief/result.aspx?dbprefix=gwkt");

        /**
         * 啟動爬蟲,爬蟲從此刻開始執行爬蟲任務,根據以上配置
         */
        controller.start(MyCraeler.class, numberOfCrawlers);
    }
}
           

直接運作main方法,你的第一個爬蟲程式就完成了,非常容易上手。

那接下來我們說一下程式的應用,我需要抓取中國知網上2016-2017兩年的中國專利資料。

那麼說一下這個應用的幾個難點。

1.知網的接口使用asp.net做的,每次請求接口都要傳目前的cookies,接口不直接傳回資料,而是傳回HTML界面

2.資料量過于龐大,而且需要爬取的是動态資源資料,需要輸入條件檢索之後,才能有資料

3.資料檢索是内部用js進行跳轉,直接通路連結沒有資料出來

4.這個是最難的,知網做了反爬蟲設定,當點選了15次下一頁之後,網頁提示輸入驗證碼,才能繼續下一頁的操作

那接下來就根據以上的難點來一步一步的想解決方案吧。

首先就是資料檢索是内部用js進行跳轉,直接通路連結沒有資料出來,這就表示上面的crawler4j沒有用了,因為他是直接通路連接配接去拿html代碼然後解析拿資料的。然後我再網上查了一下資料,發現Java有一個HtmlUtil。他相當于一個Java的浏覽器,這簡直是一個神器啊,通路到網頁之後還能對傳回來的網頁進行操作,我用個工具類來建立它

<!-- 擷取js動态生成之後的html -->
        <dependency>
            <groupId>net.sourceforge.htmlunit</groupId>
            <artifactId>htmlunit</artifactId>
            <version>2.29</version>
        </dependency>           
package com.chf.Utils;

import com.gargoylesoftware.htmlunit.BrowserVersion;
import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
import com.gargoylesoftware.htmlunit.NicelyResynchronizingAjaxController;
import com.gargoylesoftware.htmlunit.WebClient;
import com.gargoylesoftware.htmlunit.html.HtmlPage;

import java.io.IOException;
import java.net.MalformedURLException;

/**
 * @author:chf
 * @description:模拟浏覽器執行各種操作
 * @date:2019/3/20
 **/
public class HtmlUtil {
        /*
         * 啟動JS
         */
        public static WebClient iniParam_Js() {
            final WebClient webClient = new WebClient(BrowserVersion.CHROME);
            // 啟動JS
            webClient.getOptions().setJavaScriptEnabled(true);
            //将ajax解析設為可用
            webClient.getOptions().setActiveXNative(true);
            //設定Ajax的解析器
            webClient.setAjaxController(new NicelyResynchronizingAjaxController());
            // 禁止CSS
            webClient.getOptions().setCssEnabled(false);
            // 啟動用戶端重定向
            webClient.getOptions().setRedirectEnabled(true);
            // JS遇到問題時,不抛出異常
            webClient.getOptions().setThrowExceptionOnScriptError(false);
            // 設定逾時
            webClient.getOptions().setTimeout(10000);
            //禁止下載下傳照片
            webClient.getOptions().setDownloadImages(false);
            return webClient;
        }

        /*
         * 禁止JS
         */
        public static WebClient iniParam_NoJs() {
            final WebClient webClient = new WebClient(BrowserVersion.CHROME);
            // 禁止JS
            webClient.getOptions().setJavaScriptEnabled(false);
            // 禁止CSS
            webClient.getOptions().setCssEnabled(false);
            // 将傳回錯誤狀态碼錯誤設定為false
            webClient.getOptions().setThrowExceptionOnFailingStatusCode(false);
            // 啟動用戶端重定向
            webClient.getOptions().setRedirectEnabled(true);
            // 設定逾時
            webClient.getOptions().setTimeout(5000);
            //禁止下載下傳照片
            webClient.getOptions().setDownloadImages(false);
            return webClient;
        }

        /**
         * 根據url擷取頁面,這裡需要加載JS
         * @param url
         * @return 網頁
         * @throws FailingHttpStatusCodeException
         * @throws MalformedURLException
         * @throws IOException
         */
        public static HtmlPage getPage_Js(String url) throws FailingHttpStatusCodeException, MalformedURLException, IOException{
            final WebClient webClient = iniParam_Js();
            HtmlPage page = webClient.getPage(url);
            //webClient.waitForBackgroundJavaScriptStartingBefore(5000);
            return page;
        }

        /**
         * 根據url擷取頁面,這裡不加載JS
         * @param url
         * @return 網頁
         * @throws FailingHttpStatusCodeException
         * @throws MalformedURLException
         * @throws IOException
         */
        public static HtmlPage getPage_NoJs(String url) throws FailingHttpStatusCodeException, MalformedURLException, IOException {
            final WebClient webClient = iniParam_NoJs();
            HtmlPage page = webClient.getPage(url);
            return page;
        }

}
           

有了這個HtmlUtil,基本已經解決了大部分問題,我這裡的操作邏輯是先用HtmlUtil通路知網,然後用定位器找到條件,輸入搜尋條件,然後點選檢索按鈕,用Java程式模拟人在浏覽器的操作。

//擷取用戶端,禁止JS
        WebClient webClient = HtmlUtil.iniParam_Js();
        //擷取搜尋頁面,搜尋頁面包含多個學者,機構通常是非完全比對,姓名是完全比對的,我們需要對所有的學者進行比對操作
        HtmlPage page = webClient.getPage(orgUrl);

        // 根據名字得到一個表單,檢視上面這個網頁的源代碼可以發現表單的名字叫“f”
        final HtmlForm form = page.getFormByName("Form1");

        // 同樣道理,擷取”檢 索“這個按鈕
        final HtmlButtonInput button = form.getInputByValue("檢 索");
        // 得到搜尋框
        final HtmlTextInput from = form.getInputByName("publishdate_from");
        final HtmlTextInput to = form.getInputByName("publishdate_to");
        //設定搜尋框的value
        from.setValueAttribute("2016-01-01");
        to.setValueAttribute("2016-12-31");
        // 設定好之後,模拟點選按鈕行為。
        final HtmlPage nextPage = button.click();

        HtmlAnchor date=nextPage.getAnchorByText("申請日");
        final HtmlPage secondPage = date.click();
        HtmlAnchor numNow=secondPage.getAnchorByText("50");
        final HtmlPage thirdPage = numNow.click();
           

上述代碼的thirdPage就是最終有資料的html頁面。

那下面就是爬蟲最關鍵的一個地方,解析爬下來的html代碼,分析html代碼的話,我就不在這裡分析,html基礎不好的朋友可以去w3cshool補一下,我這裡直接說HtmlUtil定位html元素的的方法吧。上面的代碼可以看到HtmlUtil可以通過value,text,id,name定位元素,如果上面這些都定位不了元素的話,那就使用Xpath來定位。

//解析知網原網頁,擷取清單的所有連結
        List<HtmlAnchor> anchorList=thirdPage.getByXPath("//table[@class=\'GridTableContent\']/tbody/tr/td/a[@class=\'fz14\']");
           

那拿到清單資料之後呢,我就用HtmlUtil一個個點選進去,進去專利的詳情頁。

這裡面的專利名,申請日期,申請人和位址就是我要爬的資料,因為詳情頁的html比較複雜,我使用了Java一個比較好用的html解析器jsoup

<!-- jsoup的支援 -->
        <dependency>
            <groupId>org.jsoup</groupId>
            <artifactId>jsoup</artifactId>
            <version>1.7.3</version>
        </dependency>           
private static PatentDoc analyzeDetailPage(String detailPage) {
        PatentDoc pc=new PatentDoc();
        Document doc = Jsoup.parse(detailPage);

        Element title=doc.select("td[style=font-size:18px;font-weight:bold;text-align:center;]").first();
        Elements table=doc.select("table[id=box]>tbody>tr>td");

        for (Element td:table) {
            if (td.attr("width").equals("471") && td.attr("bgcolor").equals("#FFFFFF") && td.attr("class").equals("checkItem")){
                String patentNo=td.text().replace("&nbsp;","");
                pc.setPatentNo(patentNo);
            }
            if (td.attr("width").equals("294") && td.attr("bgcolor").equals("#FFFFFF")){
                String patentDate=td.text().replace("&nbsp;","");
                pc.setPatentDate(patentDate);
            }
            if (td.attr("bgcolor").equals("#FFFFFF") && td.attr("class").equals("checkItem")){
                String patentPerson=td.text().replace("&nbsp;","");
                pc.setPatentPerson(patentPerson);
            }
            if (td.attr("bgcolor").equals("#f8f0d2") && td.text().equals(" 【位址】")){
                int index=table.indexOf(td);
                String patentAdress=table.get(index+1).text().replace("&nbsp;","");
                pc.setPatentAdress(patentAdress);
                break;
            }
        }
        pc.setPatentName(title.text());
        return pc;
    }           

解析完之後呢,将資料封裝到對象裡,然後将對象存在一個List裡,全部資料解析完之後,就把資料導出的csv檔案中。

String path = "C://exportParent";
        String fileName = "導出專利";
        String fileds[] = new String[] { "patentName", "patentPerson","patentDate", "patentNo","patentAdress"};// 設定列英文名(也就是實體類裡面對應的列名)
        CSVUtils.createCSVFile(resultList, fileds, map, path,fileName);
        resultList.clear();           

這樣爬蟲程式就基本寫好了,運作一下發現效率太慢了,爬一頁清單的資料加導出,花了1分多鐘,然後我優化了一下程式,将解析和導出業務邏輯開一條線程來做,主線程負責操作HtmlUtil和傳回Html。

//建立線程池管理線程
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
//利用線程池開啟線程解析首頁的資料
fixedThreadPool.execute(new AnalyzedTask(lastOnePage,18));           
package com.chf.enilty;

import com.chf.Utils.CSVUtils;
import com.gargoylesoftware.htmlunit.html.HtmlAnchor;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;

/**
 * @author:chf
 * @description: 解析詳情并導出出的線程
 * @date:2019/3/20
 **/
public class AnalyzedTask implements Runnable{

    //建立傳回結果對象集
    List<PatentDoc> resultList=new ArrayList<>();

    private HtmlPage lastOnePage =null;

    private int curPage=0;

    public AnalyzedTask(HtmlPage lastOnePage,int curPage) {
        this.lastOnePage = lastOnePage;
        this.curPage=curPage;
    }

    @Override
    public void run() {
        /** 擷取目前系統時間*/
        long startTime =  System.currentTimeMillis();
        System.out.println("線程開始第"+curPage+"頁的解析資料。");
        //解析首頁的資料
        try {
            startAnalyzed(lastOnePage);
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("第"+curPage+"頁資料解析完成。耗時:"+((System.currentTimeMillis()-startTime)/1000)+"s");
    }

    //開始解析清單資料
    private void startAnalyzed(HtmlPage thirdPage) throws Exception {
        //解析知網原網頁,擷取清單的所有連結
        List<HtmlAnchor> anchorList=thirdPage.getByXPath("//table[@class=\'GridTableContent\']/tbody/tr/td/a[@class=\'fz14\']");

        //周遊點選連結,抓取資料
        for (HtmlAnchor anchor:anchorList) {
            HtmlPage detailPage = anchor.click();
            PatentDoc pc=analyzeDetailPage(detailPage.asXml());
            resultList.add(pc);
        }

        LinkedHashMap map = new LinkedHashMap();
        map.put("1", "專利名");
        map.put("2", "申請人");
        map.put("3", "申請日期");
        map.put("4", "申請号");
        map.put("5", "申請位址");

        String path = "C://exportParent";
        String fileName = "導出專利";
        String fileds[] = new String[] { "patentName", "patentPerson","patentDate", "patentNo","patentAdress"};// 設定列英文名(也就是實體類裡面對應的列名)
        CSVUtils.createCSVFile(resultList, fileds, map, path,fileName);
        resultList.clear();
    }

    private PatentDoc analyzeDetailPage(String detailPage) {
        PatentDoc pc=new PatentDoc();
        Document doc = Jsoup.parse(detailPage);

        Element title=doc.select("td[style=font-size:18px;font-weight:bold;text-align:center;]").first();
        Elements table=doc.select("table[id=box]>tbody>tr>td");

        for (Element td:table) {
            if (td.attr("width").equals("471") && td.attr("bgcolor").equals("#FFFFFF") && td.attr("class").equals("checkItem")){
                String patentNo=td.text().replace("&nbsp;","");
                pc.setPatentNo(patentNo);
            }
            if (td.attr("width").equals("294") && td.attr("bgcolor").equals("#FFFFFF")){
                String patentDate=td.text().replace("&nbsp;","");
                pc.setPatentDate(patentDate);
            }
            if (td.attr("bgcolor").equals("#FFFFFF") && td.attr("class").equals("checkItem")){
                String patentPerson=td.text().replace("&nbsp;","");
                pc.setPatentPerson(patentPerson);
            }
            if (td.attr("bgcolor").equals("#f8f0d2") && td.text().equals(" 【位址】")){
                int index=table.indexOf(td);
                String patentAdress=table.get(index+1).text().replace("&nbsp;","");
                pc.setPatentAdress(patentAdress);
                break;
            }
        }
        pc.setPatentName(title.text());
        return pc;
    }
}
           

現在再跑程式,速度快了一點,也能把資料爬下來了,項目源碼可以在我的github下載下傳:項目源碼,感興趣的同學可以下載下傳來跑一下。有問題的可以在評論區交流,小弟我沒什麼經驗,如果有什麼問題還請指出,大家一起交流。

現在還有個難點沒有解決就是知網的驗證碼驗證,我這邊想到的一個笨方法是縮小搜尋範圍,減少資料量進而減少點選下一頁的次數來跳過驗證碼驗證,不過這個需要手動改條件,重複跑很多次程式,如果有大佬有好的解決方案也可提出來。謝謝啦!

不會python?那就換一種姿勢爬蟲!Java爬蟲技術總結