Java爬蟲


Java爬蟲學習

轉載請聲明!!本文如有錯誤歡迎指正,感激不盡。

聲明:爬蟲有風險,學習需謹慎。切勿使用爬蟲惡意爬取破壞他人項目或應用。

一、概述

1.1 介紹

​ 網絡爬蟲也叫網絡機器人,可以代替人們自動的進行數據信息的采集與整理。它是一種按照一定的規則,自動地抓取萬維網信息的程序或者腳本,可以自動采集所有其能夠訪問到的頁面內容,以獲取相關數據。

​ 從功能上來講,爬蟲一般分為數據采集,處理,儲存三個部分。爬蟲從一個或若干初始網頁的URL開始,獲得初始網頁上的URL,在抓取網頁的過程中,不斷從當前頁面上抽取新的URL放入隊列,直到滿足系統的一定停止條件。

1.2 入門

  1. 環境准備

    jdk1.8、IDEA、maven

  2. 導入依賴

    <dependencies>
        <dependency>
         <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.3</version>
        </dependency>
    
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
            <version>1.7.21</version>
        </dependency>
    </dependencies>
    
  3. log4j.properties

    log4j.rootLogger=WARN,A1
    log4j.logger.cn.itcast = DEBUG
    
    log4j.appender.A1=org.apache.log4j.ConsoleAppender
    log4j.appender.A1.layout=org.apache.log4j.PatternLayout
    log4j.appender.A1.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss,SSS} - %-4r %-5p [%t] %C:%L %x - %m%n
    
  4. 代碼

    package com.loserfromlazy.TestPa;
    
    import org.apache.http.client.methods.CloseableHttpResponse;
    import org.apache.http.client.methods.HttpGet;
    import org.apache.http.impl.client.CloseableHttpClient;
    import org.apache.http.impl.client.HttpClients;
    import org.apache.http.util.EntityUtils;
    
    
    public class Test1 {
        public static void main(String[] args) throws Exception {
            //創建對象
            CloseableHttpClient httpClient = HttpClients.createDefault();
            //get請求
            HttpGet httpGet = new HttpGet("http://www.itcast.cn");
            //獲取響應
            CloseableHttpResponse response = httpClient.execute(httpGet);
            //判斷是否訪問成功
            if (response.getStatusLine().getStatusCode()==200){
                String content = EntityUtils.toString(response.getEntity(), "UTF-8");
                System.out.println(content);
            }
        }
    }
    

二、HttpClient

​ 雖然在 JDK 的 java.net 包中已經提供了訪問 HTTP 協議的基本功能,但是對於大部分應用程序來說,JDK 庫本身提供的功能還不夠豐富和靈活。HttpClient 是 Apache Jakarta Common 下的子項目,用來提供高效的、最新的、功能豐富的支持 HTTP 協議的客戶端編程工具包,並且它支持 HTTP 協議最新的版本和建議。

HTTPClient功能介紹

  • 實現了所有HTTP方法
  • 支持自動轉向
  • 支持https協議
  • 支持代理服務器

2.1 HTTP方法

http編程的基本步驟:

  1. 創建 HttpClient 的一個實例.
  2. 創建某個方法(DeleteMethod,EntityEnclosingMethod,ExpectContinueMethod,GetMethod,HeadMethod,MultipartPostMethod,OptionsMethod,PostMethod,PutMethod,TraceMethod)的一個實例,一般可用要目標URL為參數。
  3. 讓 HttpClient 執行這個方法.
  4. 讀取應答信息.
  5. 釋放連接.
  6. 處理應答.

get無參

public static void doGet()throws Exception {
    //創建對象
    CloseableHttpClient httpClient = HttpClients.createDefault();

    //創建get請求
    HttpGet httpGet = new HttpGet("http://news.baidu.com/");
    /** 設置超時時間、請求時間、socket時間都為5秒,允許重定向 */
//        RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(5000)
//                .setConnectionRequestTimeout(5000)
//                .setSocketTimeout(5000)
//                .setRedirectsEnabled(true)
//                .build();
//        httpGet.setConfig(requestConfig);
    //發送請求
    CloseableHttpResponse response = httpClient.execute(httpGet);
    //判斷響應碼為200
    if (response.getStatusLine().getStatusCode()==200){
        String content = EntityUtils.toString(response.getEntity());
        System.out.println(content);
    }
}

get有參

public static void doGet(String url) throws Exception{
    //創建對象
    CloseableHttpClient httpClient = HttpClients.createDefault();

    //創建get請求
    HttpGet httpGet = new HttpGet(url);
    //發送請求
    CloseableHttpResponse response = httpClient.execute(httpGet);
    //判斷響應碼為200
    if (response.getStatusLine().getStatusCode()==200){
        String content = EntityUtils.toString(response.getEntity());
        System.out.println(content);
    }
}

post無參

在執行完方法后需要釋放連接,以下代碼包括了釋放連接

public static void doPost()throws Exception{
    //創建對象
    CloseableHttpClient httpClient = HttpClients.createDefault();
    //創建post對象
    HttpPost httpPost = new HttpPost("http://news.baidu.com/");
    //發起請求
    CloseableHttpResponse response = null;
    try {
        //使用HttpClient發起請求
        response = httpClient.execute(httpPost);

        //判斷響應狀態碼是否為200
        if (response.getStatusLine().getStatusCode() == 200) {
            //如果為200表示請求成功,獲取返回數據
            String content = EntityUtils.toString(response.getEntity(), "UTF-8");
            //打印數據長度
            System.out.println(content);
        }

    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        //釋放連接
        if (response == null) {
            try {
                response.close();
            } catch (IOException e) {

                e.printStackTrace();
            }
            httpClient.close();
        }
    }
}

post有參

public static void doPost(String url)throws Exception{
    //創建對象
    CloseableHttpClient httpClient = HttpClients.createDefault();
    //創建post對象
    HttpPost httpPost = new HttpPost(url);
    //創建存放參數的list集合
    List<NameValuePair> params = new ArrayList<>();
    params.add(new BasicNameValuePair("keys","java"));
    //創建表單數據Entity
    UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(params,"utf-8");
    //將表單數據設置到HTTPPost中
    httpPost.setEntity(formEntity);
    //發起請求
    CloseableHttpResponse response = httpClient.execute(httpPost);
    //判斷狀態碼
    if (response.getStatusLine().getStatusCode()==200){
        String content = EntityUtils.toString(response.getEntity());
        System.out.println(content);
    }
}

2.2 連接池

如果每次請求都要創建HttpClient,會有頻繁創建和銷毀的問題,可以使用連接池來解決這個問題。

斷點測試發現每個httpClient都不一樣

public class TestPool {
    public static void main(String[] args) {
        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
        cm.setMaxTotal(200);//設置最大連接數
        cm.setDefaultMaxPerRoute(20);//設置每個主機的並發數
        doGet(cm);
        doGet(cm);
    }

    public static void doGet(PoolingHttpClientConnectionManager cm){
        CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(cm).build();

        HttpGet httpGet = new HttpGet("http://www.itcast.cn/");

        CloseableHttpResponse response = null;

        try {
            response = httpClient.execute(httpGet);

            // 判斷狀態碼是否是200
            if (response.getStatusLine().getStatusCode() == 200) {
            // 解析數據
                String content = EntityUtils.toString(response.getEntity(), "UTF-8");
                System.out.println(content.length());
            }


        } catch (Exception e) {
            e.printStackTrace();
        } finally {
        //釋放連接
            if (response == null) {
                try {
                    response.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                //不能關閉HttpClient
                //httpClient.close();
            }
        }
    }

}

2.3 請求參數

有時候因為網絡,或者目標服務器的原因,請求需要更長的時間才能完成,我們需要自定義相關時間

/** 設置超時時間、請求時間、socket時間都為5秒,允許重定向 */
//        RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(5000)//創建連接最長時間
//                .setConnectionRequestTimeout(5000)//獲取連接的最長時間
//                .setSocketTimeout(5000)//數據傳輸最長時間
//                .setRedirectsEnabled(true)
//                .build();
//        httpGet.setConfig(requestConfig);

2.4 案例HttpClient分段下載

需求:需要下載一些庫里的文件,文件以url形式存儲,然后通過httpclient將其下載出來使用,但由於服務器很慢,導致一個300M左右的文件需要下載一個小時左右,非常慢,所以采用多線程分段下載對原有httpclient單線程下載進行修改。

原有httpclient單線程下載代碼

//查詢數據庫的數據,獲取文件地址
List<TbData> tbData = tbDataMapper.selectList(null);
//創建網絡請求
CloseableHttpClient httpClient = HttpClients.createDefault();
//創建計數器
AtomicInteger integer = new AtomicInteger(1);
//批量處理
tbData.forEach(data -> {
    //判斷文件路徑是否為空
    if (StringUtils.isBlank(data.getFileUrl())) {
        System.out.println("路徑為空");
    }
    //構建所需的對象
    HttpGet httpGet = new HttpGet(data.getFileUrl());
    CloseableHttpResponse response = null;
    InputStream content = null;
    FileOutputStream fileOutputStream = null;
    try {
        //執行請求進行下載
        System.out.println("正在下載" + data.getName());
        response = httpClient.execute(httpGet);
        //判斷響應碼為200,即下載成功
        if (response.getStatusLine().getStatusCode() == 200) {
            //將文件保存到本地
            HttpEntity entity = response.getEntity();
            content = entity.getContent();
            File file = new File("D:\\test");
            fileOutputStream = new FileOutputStream(file + "\\" + data.getName() + "_vid" + data.getId() + ".apk");
            byte[] bytes = new byte[1024];
            while (true) {
                int read = content.read(bytes);
                if (read == -1) {
                    break;
                }
                fileOutputStream.write(bytes, 0, read);
            }
            System.out.println("第" + integer + "個文件下載完成");
        } else {
            System.out.println("第" + integer + "個文件獲取連接失敗,文件名" + adatapp.getName());
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    integer.addAndGet(1);
});

經了解,在http請求中,可以使用Range進行分段下載,所以可以創建多個線程,來分段下載。

Range,是在 HTTP/1.1(http://www.w3.org/Protocols/rfc2616/rfc2616.html)里新增的一個 header field,也是現在眾多號稱多線程下載工具(如 FlashGet、迅雷等)實現多線程下載的核心所在。

Range

用於請求頭中,指定第一個字節的位置和最后一個字節的位置,一般格式:

Range:(unit=first byte pos)-[last byte pos]

Content-Range

用於響應頭,指定整個實體中的一部分的插入位置,他也指示了整個實體的長度。在服務器向客戶返回一個部分響應,它必須描述響應覆蓋的范圍和整個實體長度。一般格式:

Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity legth]

請求下載整個文件:

  1. GET /test.rar HTTP/1.1
  2. Connection: close
  3. Host: 116.1.219.219
  4. Range: bytes=0-801 //一般請求下載整個文件是bytes=0- 或不用這個頭

一般正常回應

  1. HTTP/1.1 200 OK
  2. Content-Length: 801
  3. Content-Type: application/octet-stream
  4. Content-Range: bytes 0-800/801 //801:文件總大小

所以如果想多線程現在,可以像下面一樣:

表示頭500個字節:Range: bytes=0-499
表示第二個500字節:Range: bytes=500-999

.......

代碼實現:

首先創建DownloadUtil類

/**
 * <p>
 * DownloadUtil
 * </p>
 *
 * @author Loserfromlazy
 * @since 2021/12/6
 */
public class DownloadUtil {
	//服務器地址
    private String serverUrl;
	//線程池
    private ExecutorService threadPool;
	//下載的文件存儲的本地路徑
    private String fileName;
    //線程下載成功標志
    private static int flag = 0;
    //線程計數同步輔助
    private CountDownLatch latch;

    public DownloadUtil(String serverPath, String localPath,String fileName) {
        this.serverUrl = serverPath;
        this.localUrl = localPath;
        this.fileName=fileName;
    }

    public DownloadUtil() {
    }
	/**
	* 下載方法
	*/
    public boolean executeDownload() throws IOException {
        try {
            //創建網絡請求
            CloseableHttpClient httpClient = HttpClients.createDefault();
            //創建請求配置
            RequestConfig requestConfig = RequestConfig.custom()
                    .setConnectionRequestTimeout(5000).build();
            HttpGet httpGet = new HttpGet(serverUrl);
            httpGet.setHeader("Connection", "Keep-Alive");
            httpGet.setConfig(requestConfig);
            //獲取請求狀態
            CloseableHttpResponse response = httpClient.execute(httpGet);
            int code = response.getStatusLine().getStatusCode();
            if (code != 200 && code != 206) {
                System.out.println("路徑為空");
                return false;
            }
            //獲取文件大小
            long contentLength = response.getEntity().getContentLength();
            //創建RandomAccessFile,采用rwd模式
            RandomAccessFile raf = new RandomAccessFile(fileName, "rwd");
            //指定創建的文件的長度
            raf.setLength(contentLength);
            raf.close();
            //分割文件
            //當前線程池中線程數目,這里暫時寫死
            int partCount = 10;
            //每一個線程下載的長度
            int partSize = (int) (contentLength / partCount);
            latch = new CountDownLatch(partCount);
            //獲取線程池
            threadPool = ThreadPool.getInstance();
            for (int threadId = 1; threadId <= partCount; threadId++) {
                // 每一個線程下載的開始位置
                long startIndex = (threadId - 1) * partSize;
                // 每一個線程下載的開始位置
                long endIndex = startIndex + partSize - 1;
                if (threadId == partCount) {
                    //最后一個線程下載的長度稍微長一點
                    endIndex = contentLength;
                }
                System.out.println("線程" + threadId + "下載:" + startIndex + "字節~" + endIndex + "字節");
                //新建線程,通過線程池執行
                threadPool.execute(new DownLoadThread(threadId, startIndex, endIndex, latch,fileName));
            }
            //等待線程執行完畢
            latch.await();
            if (flag == 0) {
                return true;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    /**
     * 內部線程類
     *
     * @author Loserfromlazy
     * @date 2021/12/6 14:44
     */
    public class DownLoadThread implements Runnable {
        //線程ID
        private int threadId;
        //下載起始位置
        private long startIndex;
        //下載結束位置
        private long endIndex;
		//下載的文件存儲的本地路徑
        private String fileName;
		//線程計數輔助
        private CountDownLatch latch;

        public DownLoadThread(int threadId, long startIndex, long endIndex, CountDownLatch latch, String fileName) {
            this.threadId = threadId;
            this.startIndex = startIndex;
            this.endIndex = endIndex;
            this.latch = latch;
            this.fileName = fileName;
        }

        @Override
        public void run() {
            try {
                System.out.println("線程" + threadId + "正在下載...");
                //創建網絡請求
                CloseableHttpClient httpClient = HttpClients.createDefault();
                //創建請求配置
                RequestConfig requestConfig = RequestConfig.custom()
                        .setConnectionRequestTimeout(5000).build();
                HttpGet httpGet = new HttpGet(serverUrl);
                httpGet.setHeader("Connection", "Keep-Alive");
                //請求服務器下載部分的文件的指定位置
                httpGet.setHeader("Range", "bytes=" + startIndex + "-" + endIndex);
                httpGet.setConfig(requestConfig);
                CloseableHttpResponse response = httpClient.execute(httpGet);
                int code = response.getStatusLine().getStatusCode();
                System.out.println("線程" + threadId + "請求返回code=" + code);
                InputStream is = response.getEntity().getContent();//返回資源
                RandomAccessFile raf = new RandomAccessFile(fileName, "rwd");
                //隨機寫文件的時候從哪個位置開始寫
                raf.seek(startIndex);//定位文件
                int len = 0;
                byte[] buffer = new byte[1024];
                while ((len = is.read(buffer)) != -1) {
                    raf.write(buffer, 0, len);
                }
                is.close();
                raf.close();
                System.out.println("線程" + threadId + "下載完畢");
            } catch (Exception e) {
                //線程下載出錯
                DownloadUtil.flag = 1;
                System.out.println(e.getMessage());
            } finally {
                //計數值減一
                latch.countDown();
            }
        }
    }

    /**
     * 下載文件執行器
     */
    public synchronized static boolean downLoad(String serverPath, String localPath,String fileName) {
        ReentrantLock lock = new ReentrantLock();
        lock.lock();
        DownloadUtil m = new DownloadUtil(serverPath, localPath,fileName);
        long startTime = System.currentTimeMillis();
        boolean flag = false;
        try {
            flag = m.executeDownload();
            long endTime = System.currentTimeMillis();
            if (flag) {
                System.out.println("文件下載結束,共耗時" + (endTime - startTime) + "ms");
                return true;
            }
            System.out.println("文件下載失敗");
            return false;
        } catch (Exception ex) {
            System.out.println(ex.getMessage());
            return false;
        } finally {
            DownloadUtil.flag = 0; // 重置下載狀態
            if (!flag) {
                File file = new File(localPath);
                file.delete();
            }
            lock.unlock();
        }
    }
}

獲取線程池工具類:

/**
 * <p>
 * ThreadPool
 * </p>
 *
 * @author Loserfromlzy
 * @since 2021/12/6
 */
public class ThreadPool {
	//創建固定大小線程池,生產環境或實際項目禁止使用以下方式創建線程池!!!這里僅為了演示方便
    private static final ExecutorService executorService = Executors.newFixedThreadPool(10);

    private ThreadPool(){}
	//餓漢式單例
    public static ExecutorService getInstance(){
        return executorService;
    }
}

拓展CountDownLatch和RandomAccessFile

CountDownLatch

CountDownLatch是在java1.5被引入,存在於java.util.cucurrent包下。

CountDownLatch這個類使一個線程等待其他線程各自執行完畢后再執行。它是通過一個計數器來實現的,計數器的初始值是線程的數量。每當一個線程執行完畢后,計數器的值就-1,當計數器的值為0時,表示所有線程都執行完畢,然后在閉鎖上等待的線程就可以恢復工作了。

它其中最主要使用的方法源碼如下:

總的來說就是調用await進行等待,直到count降為0,而countDown可以使count減一。

/**
 * Causes the current thread to wait until the latch has counted down to
 * zero, unless the thread is {@linkplain Thread#interrupt interrupted}.
 *
 * <p>If the current count is zero then this method returns immediately.
 *
 * <p>If the current count is greater than zero then the current
 * thread becomes disabled for thread scheduling purposes and lies
 * dormant until one of two things happen:
 * <ul>
 * <li>The count reaches zero due to invocations of the
 * {@link #countDown} method; or
 * <li>Some other thread {@linkplain Thread#interrupt interrupts}
 * the current thread.
 * </ul>
 *
 * <p>If the current thread:
 * <ul>
 * <li>has its interrupted status set on entry to this method; or
 * <li>is {@linkplain Thread#interrupt interrupted} while waiting,
 * </ul>
 * then {@link InterruptedException} is thrown and the current thread's
 * interrupted status is cleared.
 *
 * @throws InterruptedException if the current thread is interrupted
 *         while waiting
 */
public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}
/**
     * Decrements the count of the latch, releasing all waiting threads if
     * the count reaches zero.
     *
     * <p>If the current count is greater than zero then it is decremented.
     * If the new count is zero then all waiting threads are re-enabled for
     * thread scheduling purposes.
     *
     * <p>If the current count equals zero then nothing happens.
     */
    public void countDown() {
        sync.releaseShared(1);
    }

RandomAccessFile

RandomAccessFile既可以讀取文件內容,也可以向文件輸出數據。同時,RandomAccessFile支持“隨機訪問”的方式,程序快可以直接跳轉到文件的任意地方來讀寫數據。

由於RandomAccessFile可以自由訪問文件的任意位置,所以如果需要訪問文件的部分內容,而不是把文件從頭讀到尾,使用RandomAccessFile將是更好的選擇。

與OutputStream、Writer等輸出流不同的是,RandomAccessFile允許自由定義文件記錄指針,RandomAccessFile可以不從開始的地方開始輸出,因此RandomAccessFile可以向已存在的文件后追加內容。如果程序需要向已存在的文件后追加內容,則應該使用RandomAccessFile。

RandomAccessFile的方法雖然多,但它有一個最大的局限,就是只能讀寫文件,不能讀寫其他IO節點。

RandomAccessFile的一個重要使用場景就是網絡請求中的多線程下載及斷點續傳。

RandomAccessFile一共有4種模式。

**"r" : ** 以只讀方式打開。調用結果對象的任何 write 方法都將導致拋出 IOException。
"rw": 打開以便讀取和寫入。
"rws": 打開以便讀取和寫入。相對於 "rw","rws" 還要求對“文件的內容”或“元數據”的每個更新都同步寫入到基礎存儲設備。
"rwd" : 打開以便讀取和寫入,相對於 "rw","rwd" 還要求對“文件的內容”的每個更新都同步寫入到基礎存儲設備。

RandomAccessFile既可以讀文件,也可以寫文件,所以類似於InputStream的read()方法,以及類似於OutputStream的write()方法,RandomAccessFile都具備。除此之外,RandomAccessFile具備兩個特有的方法,來支持其隨機訪問的特性。

long getFilePointer( ):返回文件記錄指針的當前位置

void seek(long pos ):將文件指針定位到pos位置

三、 Jsoup

​ jsoup 是一款Java 的HTML解析器,可直接解析某個URL地址、HTML文本內容。它提供了一套非常省力的API,可通過DOM,CSS以及類似於jQuery的操作方法來取出和操作數據。

​ jsoup主要功能如下:

  1. 從一個url、文件、或字符串中解析html
  2. 使用dom或css選擇器來查找、取出數據
  3. 可操作html元素屬性文本

jsoup依賴:

<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.10.3</version>
</dependency>
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.7</version>
</dependency>
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.6</version>
</dependency>

3.1 jsoup解析

3.1.1 解析url

​ jsoup可以直接輸入url,他會發起並獲取數據封裝為Document對象,雖然可以代替HTTPClient但是實際開發中需要使用多線程連接池代理等,jsoup對這些支持不好,所以一般將jsoup作為html解析工具。

/**
* 測試jsoup解析url
* @throws Exception
*/
@Test
public void testJsoupUrl() throws Exception{
    //解析url地址
    Document document = Jsoup.parse(new URL("http://www.itcast.cn"),1000);
    //獲取title
    Element title = document.getElementsByTag("title").first();
    System.out.println(title.text());
}

3.1.2 解析字符串

先准備一份簡單的html,之后

/**
* 測試jsoup解析字符串
* @throws Exception
*/
@Test
public void testJsoupString() throws Exception{
    //讀取文件
    String file = FileUtils.readFileToString(new File("E:\\jsouptext.html"), "UTF-8");
    //解析字符串
    Document document = Jsoup.parse(file);
    //獲取title
    Element title = document.getElementsByTag("title").first();
    System.out.println(title.text());
}

3.1.3 解析文件

​ document也可以直接解析文件

    /**
     * 測試jsoup解析文件
     * @throws Exception
     */
    @Test
    public void testJsoupFile() throws Exception{
        //解析文件
        Document document = Jsoup.parse(new File("E:\\jsouptext.html"), "UTF-8");
        //獲取title
        Element title = document.getElementsByTag("title").first();
        System.out.println(title.text());
    }

3.2 使用DOM遍歷文檔

獲取元素

  1. 根據id查詢元素getElementById
  2. 根據標簽查詢元素getElementByTag
  3. 根據class查詢元素getElementByClass
  4. 根據屬性查詢元素getElementByAttribute
/**
* 測試jsoupDOM獲取元素
* @throws Exception
*/
@Test
public void testJsoupDOM() throws Exception{
    //解析文件
    Document document = Jsoup.parse(new File("E:\\jsouptext.html"), "UTF-8");
    //1. 根據id查詢元素
    Element element1 = document.getElementById("city_bj");
    //2.根據標簽獲取元素
    Element element2 = document.getElementsByTag("title").first();
    //3.根據class獲取元素
    Element element3 = document.getElementsByClass("s_name").last();
    //4.根據屬性獲取元素
    Element element4 = document.getElementsByAttribute("abc").first();
    Element element5 = document.getElementsByAttributeValue("class","city_con").first();
    System.out.println(element1.text());
    System.out.println(element2.text());
    System.out.println(element3.text());
    System.out.println(element4.text());
    System.out.println(element5.text());
}

元素中獲取數據

  1. 從元素中獲取id
  2. 從元素中獲取className
  3. 從元素中獲取屬性值attr
  4. 從元素中獲取所有屬性值attributes
  5. 從元素中獲取文本內容text
/**
* 測試jsoupDOM元素中獲取數據
* @throws Exception
*/
@Test
public void testJsoupDOMData() throws Exception{
    //解析文件
    Document document = Jsoup.parse(new File("E:\\jsouptext.html"), "UTF-8");
    //獲取element元素
    Element element = document.getElementById("test");
    //1.元素中獲取id
    String str1 = element.id();
    //2.從元素中獲取className
    String str2 = element.className();
    //3.從元素中獲取屬性值attr
    String str3 = element.attr("id");
    //4.從元素中獲取所有屬性值attributes
    String str4 = element.attributes().toString();
    //5.從元素中獲取文本內容text
    String str5 = element.text();
    System.out.println(str1);
    System.out.println(str2);
    System.out.println(str3);
    System.out.println(str4);
    System.out.println(str5);

}

3.3 使用選擇器語法查找元素

3.3.1 Selector選擇器

tagname:通過標簽查找元素:比如:span

id:通過id查找元素,比如:#id1

.class:通過class名稱查找元素:比如:.class_1

[attribute]:通過屬性查找元素

[attr=value]:利用屬性值查找元素,比如[class=s_name]

/**
* 測試jsoup選擇器
* @throws Exception
*/
@Test
public void testJsoupSelector() throws Exception{
    //解析文件
    Document document = Jsoup.parse(new File("E:\\jsouptext.html"), "UTF-8");
    //tagname: 通過標簽查找元素,比如:span
    Elements span = document.select("span");
    for (Element element:span) {
        System.out.println(element.text());
    }
    System.out.println("##########");
    //#id: 通過ID查找元素,比如:#city_bjj
    String str = document.select("#city_bj").text();
    System.out.println(str);
    //.class: 通過class名稱查找元素,比如:.class_a
    str = document.select(".class_a").text();
    System.out.println(str);
    //[attribute]: 利用屬性查找元素,比如:[abc]
    str = document.select("[abc]").text();
    System.out.println(str);
    //[attr=value]: 利用屬性值來查找元素,比如:[class=s_name]
    str = document.select("[class=s_name]").text();
    System.out.println(str);

    //組合使用
    //el#id: 元素+ID,比如: h3#city_bj
    str = document.select("h3#city_bj").text();

    //el.class: 元素+class,比如: li.class_a
    str = document.select("li.class_a").text();

    //el[attr]: 元素+屬性名,比如: span[abc]
    str = document.select("span[abc]").text();

    //任意組合,比如:span[abc].s_name
    str = document.select("span[abc].s_name").text();

    //ancestor child: 查找某個元素下子元素,比如:.city_con li 查找"city_con"下的所有li
    str = document.select(".city_con li").text();

    //parent > child: 查找某個父元素下的直接子元素,
    //比如:.city_con > ul > li 查找city_con第一級(直接子元素)的ul,再找所有ul下的第一級li
    str = document.select(".city_con > ul > li").text();

    //parent > * 查找某個父元素下所有直接子元素.city_con > *
    str = document.select(".city_con > *").text();

}

四、 WebMagic

​ 是一款Java爬蟲框架,其底層是HttpClient和Jsoup。WebMagic項目代碼分為核心和拓展兩部分。核心部分是一個精簡的、模塊化的爬蟲實現,而擴展部分則包括一些便利的、實用性的功能。

4.1 架構介紹

​ WebMagic的結構分為Downloader、PageProcessor、Scheduler、Pipeline四大組件,並由Spider將它們彼此組織起來。這四大組件對應爬蟲生命周期中的下載、處理、管理和持久化等功能。WebMagic的設計參考了Scapy,但是實現方式更Java化一些。

​ 而Spider則將這幾個組件組織起來,讓它們可以互相交互,流程化的執行,可以認為Spider是一個大的容器,它也是WebMagic邏輯的核心。

四個組件

  1. Downloader

    Downloader負責從互聯網上下載頁面,以便后續處理。WebMagic默認使用了Apache HttpClient作為下載工具。

  2. PageProcessor

    PageProcessor負責解析頁面,抽取有用信息,以及發現新的鏈接。WebMagic使用Jsoup作為HTML解析工具,並基於其開發了解析XPath的工具Xsoup。

  3. Scheduler

    Scheduler負責管理待抓取的URL,以及一些去重的工作。WebMagic默認提供了JDK的內存隊列來管理URL,並用集合來進行去重。也支持使用Redis進行分布式管理。

  4. Pipeline

    Pipeline負責抽取結果的處理,包括計算、持久化到文件、數據庫等。WebMagic默認提供了“輸出到控制台”和“保存到文件”兩種結果處理方案。Pipeline定義了結果保存的方式,如果你要保存到指定數據庫,則需要編寫對應的Pipeline。對於一類需求一般只需編寫一個Pipeline。

用於數據流轉的對象

  1. Request

    Request是對URL地址的一層封裝,一個Request對應一個URL地址。它是PageProcessor與Downloader交互的載體,也是PageProcessor控制Downloader唯一方式。除了URL本身外,它還包含一個Key-Value結構的字段extra。你可以在extra中保存一些特殊的屬性,然后在其他地方讀取,以完成不同的功能。例如附加上一個頁面的一些信息等。

  2. Page

    Page代表了從Downloader下載到的一個頁面——可能是HTML,也可能是JSON或者其他文本格式的內容。Page是WebMagic抽取過程的核心對象,它提供一些方法可供抽取、結果保存等。

  3. ResultItems

    ResultItems相當於一個Map,它保存PageProcessor處理的結果,供Pipeline使用。它的API與Map很類似,值得注意的是它有一個字段skip,若設置為true,則不應被Pipeline處理。

4.2 入門案例

加入依賴

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>cn.xxx</groupId>
<artifactId>crawler-webmagic</artifactId>
<version>1.0-SNAPSHOT</version>

<dependencies>
<!--WebMagic-->
<dependency>
<groupId>us.codecraft</groupId>
<artifactId>webmagic-core</artifactId>
<version>0.7.4</version>
</dependency>
<dependency>
<groupId>us.codecraft</groupId>
<artifactId>webmagic-extension</artifactId>
<version>0.7.4</version>
</dependency>
</dependencies>

</project>

配置文件

WebMagic使用slf4j-log4j12作為slf4j的實現。

添加log4j.properties配置文件

log4j.rootLogger=INFO,A1

log4j.appender.A1=org.apache.log4j.ConsoleAppender
log4j.appender.A1.layout=org.apache.log4j.PatternLayout
log4j.appender.A1.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss,SSS} [%t] [%c]-[%p] %m%n

Java類

public class JobProcessor implements PageProcessor {

    public void process(Page page) {
        page.putField("author", page.getHtml().css("div.mt>h1").all());
        }
        private Site site = Site.me();
        public Site getSite() {
        return site;
    }

	public static void main(String[] args) {
            Spider.create(new JobProcessor())
    //初始訪問url地址
    .addUrl("https://www.jd.com/moreSubject.aspx")
    .run();
    }
}

4.3 WebMagic功能

4.3.1 實現PageProcessor

抽取元素Selectable

WebMagic主要使用了三種抽取技術:XPath、正則表達式和CSS選擇器。另外,對於JSON格式的內容,可使用JsonPath進行解析。

  1. XPath

    以上是獲取屬性class=mt的div標簽,里面的h1標簽的內容

    page.getHtml().xpath("//div[@class=mt]/h1/text()")
    
  2. CSS選擇器

    CSS選擇器是與XPath類似的語言。在之前已經學習了Jsoup的選擇器,它比XPath寫起來要簡單一些,但是如果寫復雜一點的抽取規則,就相對要麻煩一點。

    div.mt>h1表示class為mt的div標簽下的直接子元素h1標簽

    page.getHtml().css("div.mt>h1").toString()
    

    可是使用:nth-child(n)選擇第幾個元素,如下選擇第一個元素

    page.getHtml().css("div#news_div > ul > li:nth-child(1) a").toString()
    

    注意:需要使用>,就是直接子元素才可以選擇第幾個元素

  3. 正則表達式則是一種通用的文本抽取語言。在這里一般用於獲取url地址。

抽取元素API

Selectable相關的抽取元素鏈式API是WebMagic的一個核心功能。使用Selectable接口,可以直接完成頁面元素的鏈式抽取,也無需去關心抽取的細節。

在剛才的例子中可以看到,page.getHtml()返回的是一個Html對象,它實現了Selectable接口。這個接口包含的方法分為兩類:抽取部分和獲取結果部分。

方法 說明 示例
xpath(String xpath) 使用xpath選擇 html.xpath("div[@class='title']")
$(String selector) 使用Css選擇器選擇 html.$("div.title")
$(String selector,String attr) 使用Css選擇器選擇 html.$("div.title","text")
css(String selector) 功能同$(),使用css選擇器選擇 html.css("div.title")
links() 選擇所有鏈接 html.links()
regex(String regex) 使用正則表達式抽取 html.regex("\(.\*?)\")

這部分抽取API返回的都是一個Selectable接口,意思是說,是支持鏈式調用的.

//先獲取class為news_div的div
//再獲取里面的所有包含文明的元素
List<String> list = page.getHtml()
        .css("div#news_div")
        .regex(".*文明.*").all();

抽取結果API

當鏈式調用結束時,我們一般都想要拿到一個字符串類型的結果。這時候就需要用到獲取結果的API了。

我們知道,一條抽取規則,無論是XPath、CSS選擇器或者正則表達式,總有可能抽取到多條元素。WebMagic對這些進行了統一,可以通過不同的API獲取到一個或者多個元素。

方法 說明 示例
get() 返回一條String類型的結果 String link=html.links().get()
toString() 同get(),返回一條String類型的結果 String link= html.links.toString()
all() 返回所有抽取的結果 List links=html.links().all()

當有多條數據的時候,使用get()和toString()都是獲取第一個url地址。

String str = page.getHtml()
        .css("div#news_div")
        .links().regex(".*[0-3]$").toString();

String get = page.getHtml()
        .css("div#news_div")
        .links().regex(".*[0-3]$").get();

獲取鏈接

有了處理頁面的邏輯,我們的爬蟲就接近完工了,但是現在還有一個問題:一個站點的頁面是很多的,一開始我們不可能全部列舉出來,於是如何發現后續的鏈接,是一個爬蟲不可缺少的一部分.

下面的例子就是獲取https://www.jd.com/moreSubject.aspx這個頁面中所有符合https://www.jd.com/news.\w+?.*正則表達式的url地址並將這些鏈接加入到待抓取的隊列中去。

public void process(Page page) {
    page.addTargetRequests(page.getHtml().links()
            .regex("(https://www.jd.com/news.\\w+?.*)").all());
System.out.println(page.getHtml().css("div.mt>h1").all());
}

public static void main(String[] args) {
    Spider.create(new JobProcessor())
            .addUrl("https://www.jd.com/moreSubject.aspx")
            .run();
}

4.3.2 使用Pipeline保存結果

WebMagic用於保存結果的組件叫做Pipeline。我們現在通過“控制台輸出結果”這件事也是通過一個內置的Pipeline完成的,它叫做ConsolePipeline

那么,我現在想要把結果用保存到文件中,怎么做呢?只將Pipeline的實現換成"FilePipeline"就可以了。

public static void main(String[] args) {
    Spider.create(new JobProcessor())
//初始訪問url地址
.addUrl("https://www.jd.com/moreSubject.aspx")
  .addPipeline(new FilePipeline("D:/webmagic/"))
            .thread(5)//設置線程數
.run();
}

Pipeline的接口定義如下:

public interface Pipeline {

// ResultItems保存了抽取結果,它是一個Map結構,
// 在page.putField(key,value)中保存的數據,
//可以通過ResultItems.get(key)獲取
public void process(ResultItems resultItems, Task task);
}

可以看到,Pipeline其實就是將PageProcessor抽取的結果,繼續進行了處理的,其實在Pipeline中完成的功能,你基本上也可以直接在PageProcessor實現,那么為什么會有Pipeline?有幾個原因:

1.為了模塊分離

“頁面抽取”和“后處理、持久化”是爬蟲的兩個階段,將其分離開來,一個是代碼結構比較清晰,另一個是以后也可能將其處理過程分開,分開在獨立的線程以至於不同的機器執行。

2.Pipeline的功能比較固定,更容易做成通用組件

每個頁面的抽取方式千變萬化,但是后續處理方式則比較固定,例如保存到文件、保存到數據庫這種操作,這些對所有頁面都是通用的。

在WebMagic里,一個Spider可以有多個Pipeline,使用Spider.addPipeline()即可增加一個Pipeline。這些Pipeline都會得到處理,例如可以使用

spider.addPipeline(new ConsolePipeline()).addPipeline(new FilePipeline())

實現輸出結果到控制台,並且保存到文件的目標。

WebMagic中就已經提供了控制台輸出、保存到文件、保存為JSON格式的文件幾種通用的Pipeline。

說明 備注
ConsolePipeline 輸出結果到控制台 抽取結果需要實現toString方法
FilePipeline 保存結果到文件 抽取結果需要實現toString方法
JsonFilePipeline JSON格式保存結果到文件
ConsolePageModelPipeline (注解模式)輸出結果到控制台
FilePageModelPipeline (注解模式)保存結果到文件
JsonFilePageModelPipeline (注解模式)JSON格式保存結果到文件 想持久化的字段需要有getter方法

4.3.3 爬蟲的配置啟動和終止

Spider

Spider是爬蟲啟動的入口。在啟動爬蟲之前,我們需要使用一個PageProcessor創建一個Spider對象,然后使用run()進行啟動。

同時Spider的其他組件(Downloader、Scheduler、Pipeline)都可以通過set方法來進行設置。

方法 說明 示例
create(PageProcessor) 創建Spider Spider.create(new GithubRepoProcessor())
addUrl(String…) 添加初始的URL spider .addUrl("http://webmagic.io/docs/")
thread(n) 開啟n個線程 spider.thread(5)
run() 啟動,會阻塞當前線程執行 spider.run()
start()/runAsync() 異步啟動,當前線程繼續執行 spider.start()
stop() 停止爬蟲 spider.stop()
addPipeline(Pipeline) 添加一個Pipeline,一個Spider可以有多個Pipeline spider .addPipeline(new ConsolePipeline())
setScheduler(Scheduler) 設置Scheduler,一個Spider只能有個一個Scheduler spider.setScheduler(new RedisScheduler())
setDownloader(Downloader) 設置Downloader,一個Spider只能有個一個Downloader spider .setDownloader( new SeleniumDownloader())
get(String) 同步調用,並直接取得結果 ResultItems result = spider .get("http://webmagic.io/docs/")
getAll(String…) 同步調用,並直接取得一堆結果 List results = spider .getAll(" http://webmagic.io/docs/", " http://webmagic.io/xxx")

爬蟲配置site

Site.me()可以對爬蟲進行一些配置配置,包括編碼、抓取間隔、超時時間、重試次數等。在這里我們先簡單設置一下:重試次數為3次,抓取間隔為一秒。

private Site site = Site.me()
        .setCharset("UTF-8")//編碼
.setSleepTime(1)//抓取間隔時間
.setTimeOut(1000*10)//超時時間
.setRetrySleepTime(3000)//重試時間
.setRetryTimes(3);//重試次數

站點本身的一些配置信息,例如編碼、HTTP頭、超時時間、重試策略等、代理等,都可以通過設置Site對象來進行配置。

方法 說明 示例
setCharset(String) 設置編碼 site.setCharset("utf-8")
setUserAgent(String) 設置UserAgent site.setUserAgent("Spider")
setTimeOut(int) 設置超時時間, 單位是毫秒 site.setTimeOut(3000)
setRetryTimes(int) 設置重試次數 site.setRetryTimes(3)
setCycleRetryTimes(int) 設置循環重試次數 site.setCycleRetryTimes(3)
addCookie(String,String) 添加一條cookie site.addCookie("dotcomt_user","code4craft")
setDomain(String) 設置域名,需設置域名后,addCookie才可生效 site.setDomain("github.com")
addHeader(String,String) 添加一條addHeader site.addHeader("Referer","https://github.com")
setHttpProxy(HttpHost) 設置Http代理 site.setHttpProxy(new HttpHost("127.0.0.1",8080))

4.3.4 Scheduler組件

Scheduler是WebMagic中進行URL管理的組件。一般來說,Scheduler包括兩個作用:

  • 對待抓取的URL隊列進行管理。
  • 對已抓取的URL進行去重。

WebMagic內置了幾個常用的Scheduler。如果你只是在本地執行規模比較小的爬蟲,那么基本無需定制Scheduler。

說明 備注
DuplicateRemovedScheduler 抽象基類,提供一些模板方法 繼承它可以實現自己的功能
QueueScheduler 使用內存隊列保存待抓取URL
PriorityScheduler 使用帶有優先級的內存隊列保存待抓取URL 耗費內存較QueueScheduler更大,但是當設置了request.priority之后,只能使用PriorityScheduler才可使優先級生效
FileCacheQueueScheduler 使用文件保存抓取URL,可以在關閉程序並下次啟動時,從之前抓取到的URL繼續抓取 需指定路徑,會建立.urls.txt和.cursor.txt兩個文件
RedisScheduler 使用Redis保存抓取隊列,可進行多台機器同時合作抓取 需要安裝並啟動redis

去重部分被單獨抽象成了一個接口:DuplicateRemover,從而可以為同一個Scheduler選擇不同的去重方式,以適應不同的需要,目前提供了兩種去重方式。

說明
HashSetDuplicateRemover 使用HashSet來進行去重,占用內存較大
BloomFilterDuplicateRemover 使用BloomFilter來進行去重,占用內存較小,但是可能漏抓頁面

RedisScheduler是使用Redis的set進行去重,其他的Scheduler默認都使用HashSetDuplicateRemover來進行去重。

如果要使用BloomFilter,必須要加入以下依賴:

<!--WebMagic對布隆過濾器的支持-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0</version>
</dependency>
public static void main(String[] args) {
    Spider.create(new JobProcessor())
//初始訪問url地址
.addUrl("https://www.jd.com/moreSubject.aspx")
            .addPipeline(new FilePipeline("D:/webmagic/"))
            .setScheduler(new QueueScheduler()
                    .setDuplicateRemover(new BloomFilterDuplicateRemover(10000000)))//參數設置需要對多少條數據去重
            .thread(1)//設置線程數
.run();
}

修改public void process(Page page)方法,添加一下代碼

//每次加入相同的url,測試去重
page.addTargetRequest("https://www.jd.com/news.html?id=36480");

三種去重方式

  • HashSet

使用java中的HashSet不能重復的特點去重。優點是容易理解。使用方便。

缺點:占用內存大,性能較低。

  • Redis去重

使用Redis的set進行去重。優點是速度快(Redis本身速度就很快),而且去重不會占用爬蟲服務器的資源,可以處理更大數據量的數據爬取。

缺點:需要准備Redis服務器,增加開發和使用成本。

  • 布隆過濾器(BloomFilter)

使用布隆過濾器也可以實現去重。優點是占用的內存要比使用HashSet要小的多,也適合大量數據的去重操作。

缺點:有誤判的可能。沒有重復可能會判定重復,但是重復數據一定會判定重復。

布隆過濾器 (Bloom Filter)是由Burton Howard Bloom於1970年提出,它是一種space efficient的概率型數據結構,用於判斷一個元素是否在集合中。在垃圾郵件過濾的黑白名單方法、爬蟲(Crawler)的網址判重模塊中等等經常被用到。

哈希表也能用於判斷元素是否在集合中,但是布隆過濾器只需要哈希表的1/8或1/4的空間復雜度就能完成同樣的問題。布隆過濾器可以插入元素,但不可以刪除已有元素。其中的元素越多,誤報率越大,但是漏報是不可能的。

4.4 爬蟲分類

網絡爬蟲按照系統結構和實現技術,大致可以分為以下幾種類型:通用網絡爬蟲、聚焦網絡爬蟲、增量式網絡爬蟲、深層網絡爬蟲。實際的網絡爬蟲系統通常是幾種爬蟲技術相結合實現的

4.4.1 通用網絡爬蟲

通用網絡爬蟲又稱全網爬蟲(Scalable Web Crawler),爬行對象從一些種子 URL 擴充到整個 Web,主要為門戶站點搜索引擎和大型 Web 服務提供商采集數據。

這類網絡爬蟲的爬行范圍和數量巨大,對於爬行速度和存儲空間要求較高,對於爬行頁面的順序要求相對較低,同時由於待刷新的頁面太多,通常采用並行工作方式,但需要較長時間才能刷新一次頁面。

簡單的說就是互聯網上抓取所有數據。

4.4.2 聚焦網絡爬蟲

聚焦網絡爬蟲(Focused Crawler),又稱主題網絡爬蟲(Topical Crawler),是指選擇性地爬行那些與預先定義好的主題相關頁面的網絡爬蟲。

和通用網絡爬蟲相比,聚焦爬蟲只需要爬行與主題相關的頁面,極大地節省了硬件和網絡資源,保存的頁面也由於數量少而更新快,還可以很好地滿足一些特定人群對特定領域信息的需求。

簡單的說就是互聯網上只抓取某一種數據。

4.4.3 增量式網絡爬蟲

增量式網絡爬蟲(Incremental Web Crawler)是指對已下載網頁采取增量式更新和只爬行新產生的或者已經發生變化網頁的爬蟲,它能夠在一定程度上保證所爬行的頁面是盡可能新的頁面。

和周期性爬行和刷新頁面的網絡爬蟲相比,增量式爬蟲只會在需要的時候爬行新產生或發生更新的頁面,並不重新下載沒有發生變化的頁面,可有效減少數據下載量,及時更新已爬行的網頁,減小時間和空間上的耗費,但是增加了爬行算法的復雜度和實現難度。

簡單的說就是互聯網上只抓取剛剛更新的數據。

4.4.4 Deep Web爬蟲

Web 頁面按存在方式可以分為表層網頁(Surface Web)和深層網頁(Deep Web,也稱 Invisible Web Pages 或 Hidden Web)。

表層網頁是指傳統搜索引擎可以索引的頁面,以超鏈接可以到達的靜態網頁為主構成的 Web 頁面。

Deep Web 是那些大部分內容不能通過靜態鏈接獲取的、隱藏在搜索表單后的,只有用戶提交一些關鍵詞才能獲得的 Web 頁面。

五、網頁去重

在4.3.4中介紹了對url的去重,避免同樣的url下載多次。其實不光url需要去重,我們對下載的內容也需要去重。

5.1 去重方案

指紋碼對比

最常見的去重方案是生成文檔的指紋門。例如對一篇文章進行MD5加密生成一個字符串,我們可以認為這是文章的指紋碼,再和其他的文章指紋碼對比,一致則說明文章重復。但是這種方式是完全一致則是重復的,如果文章只是多了幾個標點符號,那仍舊被認為是重復的,這種方式並不合理。

BloomFilter

這種方式就是我們之前對url進行去重的方式,使用在這里的話,也是對文章進行計算得到一個數,再進行對比,缺點和方法1是一樣的,如果只有一點點不一樣,也會認為不重復,這種方式不合理。

KMP算法

KMP算法是一種改進的字符串匹配算法。KMP算法的關鍵是利用匹配失敗后的信息,盡量減少模式串與主串的匹配次數以達到快速匹配的目的。能夠找到兩個文章有哪些是一樣的,哪些不一樣。

這種方式能夠解決前面兩個方式的“只要一點不一樣就是不重復”的問題。但是它的時空復雜度太高了,不適合大數據量的重復比對。

還有一些其他的去重方式:最長公共子串、后綴數組、字典樹、DFA等等,但是這些方式的空復雜度並不適合數據量較大的工業應用場景。我們需要找到一款性能高速度快,能夠進行相似度對比的去重方案

Google 的 simhash 算法產生的簽名,可以滿足要求。

5.2 SimHash

這里僅做介紹

simhash是由 Charikar 在2002年提出來的,為了便於理解盡量不使用數學公式,分為這幾步:

1、分詞,把需要判斷文本分詞形成這個文章的特征單詞。

2、hash,通過hash算法把每個詞變成hash值,比如“美國”通過hash算法計算為 100101,“51區”通過hash算法計算為 101011。這樣我們的字符串就變成了一串串數字。

3、加權,通過 2步驟的hash生成結果,需要按照單詞的權重形成加權數字串,“美國”的hash值為“100101”,通過加權計算為“4 -4 -4 4 -4 4”

“51區”計算為“ 5 -5 5 -5 5 5”。

4、合並,把上面各個單詞算出來的序列值累加,變成只有一個序列串。“美國”的“4 -4 -4 4 -4 4”,“51區”的“ 5 -5 5 -5 5 5”把每一位進行累加,“4+5 -4+-5 -4+5 4+-5 -4+5 4+5” -> “9 -9 1 -1 1 9”

5、降維,把算出來的“9 -9 1 -1 1 9”變成 0 1 串,形成最終的simhash簽名。

我們把庫里的文本都轉換為simhash簽名,並轉換為long類型存儲,空間大大減少。現在我們雖然解決了空間,但是如何計算兩個simhash的相似度呢?

我們通過海明距離(Hamming distance)就可以計算出兩個simhash到底相似不相似。兩個simhash對應二進制(01串)取值不同的數量稱為這兩個simhash的海明距離。舉例如下: 10101 和 00110 從第一位開始依次有第一位、第四、第五位不同,則海明距離為3。對於二進制字符串的a和b,海明距離為等於在a XOR b運算結果中1的個數(普遍算法)。

六、代理

有些網站不允許爬蟲進行數據爬取,因為會加大服務器的壓力。其中一種最有效的方式是通過ip+時間進行鑒別,因為正常人不可能短時間開啟太多的頁面,發起太多的請求。

我們使用的WebMagic可以很方便的設置爬取數據的時間。但是這樣會大大降低我們爬取數據的效率,如果不小心ip被禁了,會讓我們無法爬去數據,那么我們就有必要使用代理服務器來爬取數據.

6.1 代理服務器

代理(英語:Proxy),也稱網絡代理,是一種特殊的網絡服務,允許一個網絡終端(一般為客戶端)通過這個服務與另一個網絡終端(一般為服務器)進行非直接的連接。

提供代理服務的電腦系統或其它類型的網絡終端稱為代理服務器(英文:Proxy Server)。一個完整的代理請求過程為:客戶端首先與代理服務器創建連接,接着根據代理服務器所使用的代理協議,請求對目標服務器創建連接、或者獲得目標服務器的指定資源。

6.2 使用代理

WebMagic使用的代理APIProxyProvider。因為相對於Site的“配置”,ProxyProvider定位更多是一個“組件”,所以代理不再從Site設置,而是由HttpClientDownloader設置。

API 說明
HttpClientDownloader.setProxyProvider(ProxyProvider proxyProvider) 設置代理

ProxyProvider有一個默認實現:SimpleProxyProvider。它是一個基於簡單Round-Robin的、沒有失敗檢查的ProxyProvider。可以配置任意個候選代理,每次會按順序挑選一個代理使用。它適合用在自己搭建的比較穩定的代理的場景。

如果需要根據實際使用情況對代理服務器進行管理(例如校驗是否可用,定期清理、添加代理服務器等),只需要自己實現APIProxyProvider即可。

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import us.codecraft.webmagic.Page;
import us.codecraft.webmagic.Site;
import us.codecraft.webmagic.Spider;
import us.codecraft.webmagic.downloader.HttpClientDownloader;
import us.codecraft.webmagic.processor.PageProcessor;
import us.codecraft.webmagic.proxy.Proxy;
import us.codecraft.webmagic.proxy.SimpleProxyProvider;

@Component
public class ProxyTest implements PageProcessor {
        
        @Scheduled(fixedDelay = 10000)
        public void testProxy() {
            HttpClientDownloader httpClientDownloader = new HttpClientDownloader();
            httpClientDownloader.setProxyProvider(SimpleProxyProvider.from(new Proxy("39.137.77.68",80)));
            
            Spider.create(new ProxyTest())
                    .addUrl("xxx")//設置請求地址
                    .setDownloader(httpClientDownloader)
                    .run();
        }
        
        @Override
        public void process(Page page) {
                //打印獲取到的結果以測試代理服務器是否生效
                System.out.println(page.getHtml());
        }
        
        private Site site = new Site();
        @Override
        public Site getSite() {
        return site;
        }
}



免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM