【Java】爬蟲,能不能再詳細講講?萬字長文送給你!

midnight_time 2022-01-08 07:34:50 阅读数:183

java 能不能 能不 不能

前言

本文僅用於學習知識探討,絕無其它惡意。

前兩篇基礎文章鏈接:

《【Java】爬蟲,看完還爬不下來打我電話 》

《【Java】爬蟲,數據持久化到MongoDB》

本文打算再詳細的講講一些流程細節,另外,最後有寫到如何分析爬取下來的內容。

在開始正文之前,還要說清一件事:我是小白,能不能學會爬蟲?

答:學不會,別學了,放弃吧。趕緊拿起手機,打遊戲吧。這麼熱的天,哪凉快哪去,千萬別遭這個罪。

正文

靜態與動態網頁

​ 我所理解的靜態網頁就是,瀏覽器中右鍵查看源代碼,源碼內容與用戶看到的內容一致;動態網頁就是,源碼中找不到用戶所看到的內容(部分)。

​ 這就有意思了,源碼中都沒有的內容,那是怎麼呈現出來的?答案就是通過二次加載甚或多次加載得到。

​ 存在即合理,簡單想想就能想通,倘若我點進去一個網頁,加載半天一片空白,很大概率我是會不耐煩的。所以勢必要一部分一部分的加載進去,有些東西還需要我點擊“加載更多“之後才會加載。

爬靜態網頁的流程

  1. 第一步就是學習框架,Java首推Jsoup框架
  2. 省省吧,靜態網頁沒有第二步。
  3. 為了整體格式,這裏要凑够三行。

爬動態網頁的流程

  1. 第一步依然是學習框架,Java首推cdp4j框架
  2. 學會“抓包”。
  3. 可以了,爬蟲就這麼點東西,都凑不够三步。

第一步 學習cdp4j框架

​ 因為是全英文網站,可能有些人會產生抵觸的心理。但是,類名總能看懂吧:Code Samples地址

cdp4j樣例代碼這上面的代碼我挨個運行過一遍,最後用到我這個項目裏的只需要上面紅框4個即可。

第二步 學會“抓包“

​ 有些人吃包子還需要用筷子夾,我向來都是用手抓 :)

​ “抓包”這個動詞看起來很神秘,高深莫測。其實,就是看網頁。東北話:瞅!瞪大眼睛瞅就可以成為抓包了,只不過需要瞅對地方。這個對的地方就是瀏覽器開發者工具,穀歌瀏覽器是右鍵->檢查;火狐瀏覽器是右鍵->查看元素;搜狗、360瀏覽器是右鍵->審查元素;或者,統統按F12。推薦使用Chrome瀏覽器,因為好用,另外cdp4j框架使用前提就是安裝Chrome瀏覽器。這裏有個坑,請認准Chrome官網下載地址官方圖標:除此圖標之外,任何圖標都是假的Chrome。配色不一樣、中間不是藍點等等,都是假的Chrome。

​ Chrome瀏覽器安裝好並不能使用,需要點右上角的三個點,然後選設置,或者直接在地址欄輸入chrome://settings/

設置

​ 將搜索引擎設置為國內的搜索引擎,穀歌瀏覽器不能用穀歌搜索引擎,說起來也是令人欷歔(不知道國外的百度瀏覽器能否使用百度搜索引擎 : )

百度搜索引擎

進入開發者工具後,刷新一下頁面,然後先點擊Network再點擊Type按響應類型排序,如下圖:

抓包
​ 現在這個過程就叫“抓包”,至於能不能抓到,那看你有沒有那個眼力勁了。

​ 當我們點擊Type之後,Network響應的數據就會進行按類別進行排序。因為動態網頁和靜態網頁的區別就在於數據不是一口氣加載進來的,而是先加載瀏覽器地址欄URL,響應後再加載對應的脚本去後臺獲取JSON數據,解析JSON數據填充到頁面中進行渲染。既然是脚本,當然需要關注script類型的響應了,如下圖:

Script
​ 如果不能一眼看出來這些script類型響應了什麼內容,可以逐個點擊,之後會彈出一個展示框,選中Preview。最終,我發現了新聞列錶是通過下圖中紅框的鏈接得到的:

新聞列錶
​ 至此,網易新聞·國內新聞列錶這個“包”,已經被我們“抓”到了。單次抓包結束!

​ 不過,爬蟲程序邏輯還沒完。

​ 如果往下瀏覽網頁,當翻到距離底部不遠的時候,網易新聞(國內)會再次加載新聞列錶。這時候Network下對應就出現了新的響應內容。
加載更多
​ 直到出現 :-)已經到最後啦~, 就代錶我們只能獲取這麼多。每次加載70篇新聞,總共可以加載3次。也就是說,我們只能獲得網易新聞(國內)最近的3*70=210篇新聞,時間是近7天。對於“新聞”來說,210篇、近7天,差不多就够了,再多就不新了(我曾想掙紮一下拿到更久遠的數據,能力有限,沒整出來 :)
加載全部
​ 我們可以對每個響應逐個右鍵,Copy,Copy link address
copy
我已經給整出來了,瞪大眼睛瞅瞅有啥規律?

https://temp.163.com/special/00804KVA/cm_guonei.js?callback=data_callback (第一次加載)
https://temp.163.com/special/00804KVA/cm_guonei_02.js?callback=data_callback(第二次加載)
https://temp.163.com/special/00804KVA/cm_guonei_03.js?callback=data_callback(第三次加載)

​ 規律就是都包含這個正則錶達式

"cm_guonei[_0-9]*\\.js"

​ 但是,等等,我們是不是忽略了一個重要的事情,就是,怎麼才能獲取到響應的url?

​ 兩種方式:一、手動“抓包”,然後寫死在程序中。二、手動“抓包”,然後使用cdp4j進行獲取Network響應URL。

​ 為了鍛煉鍛煉我的正則錶達式 能力,我選擇了後者。

​ 還記得我在一開始用紅色框標記的4個官方樣例嗎?NetworkResponse.java

​ 主要代碼如下:
NetworkResponse

​ 通過這個Demo我們就能拿到後臺響應的url鏈接了,再用正則"cm_guonei[_0-9]*\.js"進行匹配就能篩選出我們想要的URL鏈接。

​ 一旦正則匹配成功,我們就可以自己手動構造3個url鏈接。下面代碼純屬我寫著玩,大家完全可以分析出來後手動寫死,沒必要費這麼大勁。

// 獲取Network響應的新聞列錶url
public static List<String> getNetworkResponseNewsListUrl(String url){

Launcher launcher = new Launcher();
List<String> newsListUrlList = new ArrayList<>();
try (SessionFactory factory = launcher.launch(asList("--disable-gpu", "--headless"))) {

String context = factory.createBrowserContext();
try (Session session = factory.create(context)) {

// 連通網絡
session.getCommand().getNetwork().enable();
// 事件監聽器
session.addEventListener(new EventListener() {

@Override
public void onEvent(Events event, Object value) {

if (NetworkResponseReceived.equals(event)) {

ResponseReceived rr = (ResponseReceived) value;
Response response = rr.getResponse();
// 從response對象中獲得url
String newsListUrl = response.getUrl();
// 正則匹配,檢測網站 https://regex101.com 匹配3條約用時2ms
Pattern pattern = Pattern.compile("cm_guonei[_0-9]*\\.js");
Matcher matcher = pattern.matcher(newsListUrl);
if (matcher.find()) {

newsListUrlList.add(newsListUrl);
newsListUrl = newsListUrl.replaceFirst("cm_guonei[_0-9]*\\.js","cm_guonei_02.js");
newsListUrlList.add(newsListUrl);
newsListUrl = newsListUrl.replaceFirst("cm_guonei[_0-9]*\\.js","cm_guonei_03.js");
newsListUrlList.add(newsListUrl);
}
}
}
});
// 監聽器寫在導航之前
// 一定要有連接超時設置,且不小於2s(多次測試得出結論,具體時間和網速有關系),否則無法獲取
session.navigate(url).wait(5 * 1000).waitDocumentReady(5 * 1000);
}
// 處理瀏覽器上下文,源碼:contexts.remove(browserContextId)
factory.disposeBrowserContext(context);
}
// 關閉後臺進程,由於曆史原因,關閉進程習慣使用kill
launcher.getProcessManager().kill();
return newsListUrlList;
}

​ 拿到獲取新聞列錶的響應鏈接後,我們就可以使用靜態網頁工具Jsoup進行獲取網頁了。這裏或許會有疑問,為啥又是cdp4j,又是Jsoup來回混用呢?答案很簡單,哪個工具擅長什麼就使用哪個唄。

​ Jsoup顯然是爬取靜態網頁中的哥哥!

獲取Network響應的新聞列錶JSON串

public static String getNewsListJsonStr(String url) throws IOException {

// 使用parse,請求連接時指明編碼GBK(試出來的),否則獲取的內容會亂碼
// 參考:https://www.sojson.com/blog/225.html
org.jsoup.nodes.Document doc = Jsoup.parse(new URL(url).openStream(), "GBK", url);
String body = doc.text();
// 零寬斷言,從body上截取路徑data_callback( )圓括號內數據
Pattern pattern = Pattern.compile("(?<=data_callback\\()(.*)(?=\\))");
Matcher matcher = pattern.matcher(body);
while(matcher.find()){

return matcher.group();
}
return "";
}

解析JSON

​ 通過上面代碼就可以拿到Network返回的JSON數據了,之後,我們可以先去一個在線網站解析一下,複制一下JSON內容區這個網站:在線解析JSON

​ 可以發現,最外層是1個JSONArray,裏面嵌套70個JSONObject

嵌套


JSON總體可以分為對象、集合(數組)兩大類,二者可以任意嵌套。

常用的工具就是Google的Gson,兩大功能:序列化、反序列化,官方給出教程如下

序列化,即對象轉為Json字符串

反序列化,即Json字符串轉為對象

Object序列化與反序列化

// 序列化
UserSimple userObject = new UserSimple(
"Norman",
"[email protected]",
26,
true
);
Gson gson = new Gson();
String userJson = gson.toJson(userObject);
/* 結果 ==> "{ "age": 26, "email": "[email protected]", "isDeveloper": true, "name": "Norman" }" */
// 反序列化
String userJson = "{'age':26,'email':'[email protected]','isDeveloper':true,'name':'Norman'}";
Gson gson = new Gson();
UserSimple userObject = gson.fromJson(userJson, UserSimple.class);
// 結果 ==> 就是上面那個userObject

Array序列化與反序列化

int[] ints = {
1, 2, 3, 4, 5};
String[] strings = {
"abc", "def", "ghi"};
Gson gson = new Gson();
// 序列化
gson.toJson(ints); // ==> [1,2,3,4,5]
gson.toJson(strings); // ==> ["abc", "def", "ghi"]
// 反序列化
int[] ints2 = gson.fromJson("[1,2,3,4,5]", int[].class);
// 結果 ==> ints2 will be same as ints

集合序列化與反序列化

Collection<Integer> ints = Lists.immutableList(1,2,3,4,5);
Gson gson = new Gson();
// 序列化
String json = gson.toJson(ints); // ==> json is [1,2,3,4,5]
// 反序列化 
Type collectionType = new TypeToken<Collection<Integer>>(){
}.getType();
Collection<Integer> ints2 = gson.fromJson(json, collectionType);
// 結果 ==> ints2 is same as ints

我原先一直是逐個屬性解析,然後再調用setter方法給對象屬性賦值。直到寫這篇博客,看到了官方教程,才發現原來可以一句代碼就轉成對象,一句代碼就轉成數組,一句代碼就轉成集合,Gson的操作可謂“神乎其技”,激動的差點沒掉下眼淚,悲傷逆流成河~~

有了上面的理論基礎我們就來實戰解析一下

首先明確目的,我們要做的是將Json字符串反序列化為集合

由於70和JSONObject太長,我就截取兩個進行反序列化一下,首先我們可以在在線解析JSON 中觀察一下每一個Object的構成,發現前面幾個都是普通的K:V(String:String)鍵值對,但是keywords是一個JSONArray,裏面存儲了多個對象。

觀察解析

因此,我們整個新聞列錶的JavaBean就應該是:

import java.util.List;
public class NewsListEntity {

String title;
String docurl;
String commenturl;
String time;
List<Keywords> keywords;
@Override
public String toString() {

return "NewsListEntity{" +
"title='" + title + '\'' +
", docurl='" + docurl + '\'' +
", commenturl='" + commenturl + '\'' +
", time='" + time + '\'' +
", keywords=" + keywords.toString() +
'}';
}
// 太長,省略
// Getter、Setter方法
}

這裏面需要用到Keywords這個類

public class Keywords {

String akey_link;
String keyname;
@Override
public String toString() {

return "Keywords{" +
"akey_link='" + akey_link + '\'' +
", keyname='" + keyname + '\'' +
'}';
}
// 太長,省略
// Getter、Setter方法
}

接下來就可以使用Gson神乎其技的反序列化進行解析了,這裏我寫了一個Junit單元測試

import cn.edu.heuet.getcomment.NewsListEntity;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.junit.Test;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
public class JsonDeserializationTest {

@Test
public void jsonStrDeserializationToList(){

String jsonStr = "[\n" +
" {\n" +
" \"title\": \"郭臺銘初選落敗後脫黨參選?幕僚"打假":沒考慮過\",\n" +
" \"digest\": \"\",\n" +
" \"docurl\": \"https://news.163.com/19/0716/11/EK72KBVC0001875N.html\",\n" +
" \"commenturl\": \"http://comment.tie.163.com/EK72KBVC0001875N.html\",\n" +
" \"tienum\": 0,\n" +
" \"tlastid\": \"<a href='http://news.163.com/'>新聞</a>\",\n" +
" \"tlink\": \"https://news.163.com/19/0716/11/EK72KBVC0001875N.html\",\n" +
" \"label\": \"其它\",\n" +
" \"keywords\": [\n" +
" {\n" +
" \"akey_link\": \"http://news.163.com/keywords/9/e/90ed53f094ed/1.html\",\n" +
" \"keyname\": \"郭臺銘\"\n" +
" },\n" +
" {\n" +
" \"akey_link\": \"http://news.163.com/keywords/6/e/67ef658754f2/1.html\",\n" +
" \"keyname\": \"柯文哲\"\n" +
" },\n" +
" {\n" +
" \"akey_link\": \"http://news.163.com/keywords/4/2/4e2d56fd56fd6c11515a/1.html\",\n" +
" \"keyname\": \"中國國民黨\"\n" +
" }\n" +
" ],\n" +
" \"time\": \"07/16/2019 11:46:59\",\n" +
" \"newstype\": \"article\",\n" +
" \"pics3\": [],\n" +
" \"channelname\": \"guonei\",\n" +
" \"imgurl\": \"http://cms-bucket.ws.126.net/2019/07/16/75d26d296b46451da73fdcdbb948b1b4.png\",\n" +
" \"add1\": \"\",\n" +
" \"add2\": \"\",\n" +
" \"add3\": \"\"\n" +
" },\n" +
" {\n" +
" \"title\": \"湖北十堰發現東漢古墓 墓主疑為"二十四孝"中黃香\",\n" +
" \"digest\": \"\",\n" +
" \"docurl\": \"https://news.163.com/19/0716/11/EK715I9S0001875N.html\",\n" +
" \"commenturl\": \"http://comment.tie.163.com/EK715I9S0001875N.html\",\n" +
" \"tienum\": 639,\n" +
" \"tlastid\": \"<a href='http://news.163.com/'>新聞</a>\",\n" +
" \"tlink\": \"https://news.163.com/19/0716/11/EK715I9S0001875N.html\",\n" +
" \"label\": \"其它\",\n" +
" \"keywords\": [\n" +
" {\n" +
" \"akey_link\": \"http://news.163.com/keywords/5/9/58934e3b/1.html\",\n" +
" \"keyname\": \"墓主\"\n" +
" },\n" +
" {\n" +
" \"akey_link\": \"http://news.163.com/keywords/5/e/53e45893/1.html\",\n" +
" \"keyname\": \"古墓\"\n" +
" },\n" +
" {\n" +
" \"akey_link\": \"http://news.163.com/keywords/5/4/53415830/1.html\",\n" +
" \"keyname\": \"十堰\"\n" +
" }\n" +
" ],\n" +
" \"time\": \"07/16/2019 11:21:25\",\n" +
" \"newstype\": \"article\",\n" +
" \"pics3\": [],\n" +
" \"channelname\": \"guonei\",\n" +
" \"imgurl\": \"http://cms-bucket.ws.126.net/2019/07/16/1280bf947be54fc7a087aee3fcafcef5.png\",\n" +
" \"add1\": \"\",\n" +
" \"add2\": \"\",\n" +
" \"add3\": \"\"\n" +
" }]";
Gson gson = new Gson();
// 反序列化
Type listType = new TypeToken<ArrayList<NewsListEntity>>(){
}.getType();
List<NewsListEntity> jsonList = gson.fromJson(jsonStr, listType);
System.out.println(jsonList.size());
for (NewsListEntity newsListEntity : jsonList){

System.out.println(newsListEntity.toString());
}
}
}

運行結果如下(那麼老長的字符串最後只解析出來倆對象(屬性有點多) : )

反序列化結果

至此,我們已經可以完整的獲取新聞列錶的信息了。

獲取新聞內容與評論內容

注意到新聞列錶裏,我們主要拿到了下面5個屬性

String title; // 新聞標題
String docurl; // 新聞鏈接
String commenturl; // 評論鏈接
String time; // 新聞發布時間
List<Keywords> keywords;// 新聞關鍵詞

你會發現,新聞鏈接進去之後,新聞內容是靜態的,不需要二次加載,因此使用Jsoup獲取即可。此處略去。

還有一點要說明,就是新聞列錶裏的標題和關鍵詞不一定是完整的,所以,要想獲取確保完整的新聞標題和關鍵詞,最好還是去新聞詳情頁獲取。同樣,都是靜態內容,技術較為簡單,此處略去。

關鍵是新聞評論,評論是動態加載的。因此我們還需要再次進行“抓包”!

進入評論鏈接 ==> 按下F12 並選中Network ==> 刷新頁面 ==> 點擊Type ==> 逐個查看script的Preview,可以發現有3個script非常可疑!

評論抓包

點擊一下,查看Preview,果真是評論。

抓到評論的包

再次將這三個script的鏈接複制下來找規律,兩個hotList,一個newList,我已經把變化找到了

變化
我們老師思考了一整天,發現最後變化的是將系統時間轉化成秒數,然後遞增1。我試驗了一下,確實是。

不過,經過我和我的小夥伴的探索,發現,很多參數都沒有用。在徹底理解這個URL鏈接的作用後,我專門發錶了一個說說:

說說截圖
經過簡化,整個URL其實只需要兩個參數limit和offset即可拿到評論:

http://comment.api.163.com/api/v1/products/a2869674571f77b5a0867c3d71db5856/threads/EK72KBVC0001875N/comments/newList?limit=30&offset=0

如果你接觸過Mysql的分頁查詢,對這兩個參數應該不陌生

offset就是偏移起始量,limit是偏移量,規律如下:

limit=30&offset=0 // 從0開始,往後取30條數據,得到[0-29]
limit=30&offset=30 // 從30開始,往後取30條數據, 得到[30-59]
limit=30&offset=60 // 從60開始,往後取30條數據, 得到[60-89]
limit=30&offset=90 // 從90開始,往後取30條數據, 得到[90-119]
......

你可以手動在瀏覽器地址欄進行嘗試,會發現每次取得數據都不一樣。經過對比查看,我們會發現,newList中的內容包含兩個hotList中的內容,這也符合常理,我們在最新評論中選取點贊量高的讓其成為最熱評論。不過,為了避免重複,我們只拿newList中的內容即可。

但是,等等,我們是不是又忽略了一個重要的事情,就是,怎麼才能獲取到響應的url?

​ 兩種方式:一、手動“抓包”,然後寫死在程序中。二、手動“抓包”,然後使用cdp4j進行獲取Network響應URL。

​ 為了鍛煉鍛煉我的正則錶達式 能力,我依然選擇了後者。還是通過官方給的樣例NetworkResponse.java

​ 主要代碼如下:

// 獲取網絡響應內容,通過正則得到評論的json地址
public static List<String> getCommentJsonUrlListFromUrl(final String url, int commentCount) {

Launcher launcher = new Launcher();
List<String> commentJsonUrlList = new ArrayList<>();
try (SessionFactory factory = launcher.launch(asList("--disable-gpu", "--headless"))) {

String context = factory.createBrowserContext();
try (Session session = factory.create(context)) {

// 連通網絡
session.getCommand().getNetwork().enable();
// 事件監聽器
session.addEventListener(new EventListener() {

@Override
public void onEvent(Events event, Object value) {

if (NetworkResponseReceived.equals(event)) {

ResponseReceived rr = (ResponseReceived) value;
Response response = rr.getResponse();
// 從response對象中獲得url
String commentJsonUrl = response.getUrl();
String originCommentJsonUrl = commentJsonUrl;
// 正則匹配,檢測網站 https://regex101.com 匹配3條約用時2ms
Pattern pattern = Pattern.compile(".*newList\\?ibc.*");
Matcher matcher = pattern.matcher(commentJsonUrl);
if (matcher.find()) {

// 頁碼總數 = 評論總數 / 每頁顯示個數
int pageCounts = commentCount / 30;
for (int i = 0; i <= pageCounts; i ++) {

// offset 譯為 偏移起始點, limit = 30 為 偏移量
commentJsonUrl = commentJsonUrl.replaceFirst("offset\\=[0-9]+", "offset=" + i*30);
commentJsonUrlList.add(commentJsonUrl);
}
}
}
}
});
// 監聽器寫在導航之前
// 一定要有連接超時設置,且不小於2s(多次測試得出結論,具體時間和網速有關系),否則無法獲取
session.navigate(url).wait(5 * 1000).waitDocumentReady(5 * 1000);
}
// 處理瀏覽器上下文,源碼:contexts.remove(browserContextId)
factory.disposeBrowserContext(context);
}
// 關閉後臺進程,由於曆史原因,關閉進程習慣使用kill
launcher.getProcessManager().kill();
return commentJsonUrlList;
}

上面的代碼中,我們一開始傳入兩個參數,一個是該新聞的評論鏈接,另一個是評論的總數。

評論鏈接我們已經在新聞列錶中獲取到了。關鍵是評論的總數,因為我們要計算後臺到底分了多少頁,這樣才能拿到全部評論。很不幸,這個評論總數是動態加載的,無法使用Jsoup獲取到,但不要緊,cdp4j可以呀!使用cdp4j可以輕松的獲取到新聞詳情渲染後的頁面。然後使用xPath或者CSS選擇器拿到評論總數即可。

通過上面的代碼,我們就能拿到評論所有分頁的鏈接,之後就是使用Jsoup獲取靜態的Json數據了,方法和獲取Network響應的新聞列錶JSON串相似,此處略去。

存儲到MongoDB

我在《【Java】爬蟲,數據持久化到MongoDB》中已經寫過了,這裏不再重寫。

大數據分析

既然已經把新聞內容和評論都爬下來,並且持久化到MongoDB了,接下來就可以進行分析了。

目前互聯網實戰應該是實時分析,比如說Spark之類的框架。但任何事情都是有起源的,不是說憑空就從石頭縫裏蹦出來的。實時分析就起源於離線分析,而離線分析開源版本就數Hadoop最流行了。

Hadoop運行的三種模式:單機、偽分布式、分布式

因為主要學習的知識是爬蟲,因此這裏就先簡單的使用單機模式。

一些資源我已經放到百度網盤了

鏈接:https://pan.baidu.com/s/1BZ0HLeTnEKVAUq7a8JcVsA
提取碼:mcmd

單機搭建環境Hadoop環境

這個可以參考資源中的輿情筆記,主要步驟就是解壓==>配置環境變量==>複制.dll文件到C://Windows/System32中==>重啟IDEA(一定要重啟,否則環境變量設置不起作用)

百度網盤中的資源只是Hadoop的bin,要想運行Hadoop,還需要在Maven中添加如下三個依賴

<!-- https://mvnrepository.com/artifact/org.apache.hadoop/hadoop-mapreduce-client-core -->
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-common</artifactId>
<version>2.6.4</version>
</dependency>
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-client</artifactId>
<version>2.6.4</version>
</dependency>
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-hdfs</artifactId>
<version>2.6.4</version>
</dependency>

還有就是Hadoop要與MongoDB交互,也需要添加依賴

<!-- https://mvnrepository.com/artifact/org.mongodb/mongo-hadoop-core -->
<dependency>
<groupId>org.mongodb.mongo-hadoop</groupId>
<artifactId>mongo-hadoop-core</artifactId>
<version>2.0.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.mongodb/mongo-java-driver -->
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongo-java-driver</artifactId>
<version>3.3.0</version>
</dependency>

MapReduce理論入門

大家可以參考我曾經寫過的《【大數據】圖解MapReduce計算平均分的流程》

MapReduce入門兩大經典程序:平均分計算、單詞統計,上面的博客寫了如何計算平均分,下面我詳細介紹一下如何進行單詞統計,先看一張圖

map與Reduce

Hadoop從分布式集群的HDFS(Hadoop Distribute File System 分布式文件系統)中獲取輸入數據(在Main方法中配置),每讀取一行,然後執行map()方法,代碼如下:

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import java.io.IOException;
public class WorCountMapper extends Mapper<LongWritable,Text, Text, LongWritable> {

Text tword = new Text();
LongWritable lone = new LongWritable(1);
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {

String line = value.toString();
String[] words = line.split(" ");
for(String word:words){

//輸出
tword.set(word);
context.write(tword ,lone);
}
}
}

map()方法主要就是對單詞按空格進行切割,轉出數組作為key,每個單詞都對應先對應一個value,值為1。

這樣的K:V寫到context上下文中,這樣直接分割得到的K:V就像一副打亂的撲克牌,非常散亂,如果直接交由reduce()處理,就需要程序員做像排序、整合這樣的複雜工作,好在MapReduce框架內部實現了自動整理,這個過程叫Shuffle。其過程就是對K:V先按照K進行排序,然後將K值相等的V合並到同一個K下面。就像我們打完一局撲克之後洗牌的動作一樣,因此叫Shuffle。

接下來就該進行reduce()了,同樣是框架要求,我們必須寫一個Reducer類

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
import java.io.IOException;
import java.util.Iterator;
public class WordCountReducer extends Reducer<Text, LongWritable, Text, LongWritable> {

private long count = 0;
private LongWritable sum = new LongWritable();
@Override//key 單詞 values {1,1,1....}
protected void reduce(Text tword, Iterable<LongWritable> values, Context context) throws IOException, InterruptedException {

// 這裏必須置0
count = 0;
Iterator<LongWritable> iters = values.iterator();
while(iters.hasNext()){

LongWritable l = iters.next();
count = count + l.get();
}
sum.set(count);
context.write(tword, sum);
}
}

reduce()的工作就是將Shuffle之後同一個key下的value個數統計一下,這裏使用了迭代器遍曆。

注意reduce()裏面的count使用前需要置0,因為多個key都要用到這段程序,如果不置0就會出現數量累加的情况。

最後該運行了,我們需要寫一個主類

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import java.io.IOException;
public class Main {

public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {

Configuration config = new Configuration();
Job job = Job.getInstance(config);
//用來打包的類
job.setJarByClass(Main.class);
//指出執行map任務和reducer任務的類
job.setMapperClass(WorCountMapper.class);
job.setReducerClass(WordCountReducer.class);
//指出map階段輸出的鍵值對的類型
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(LongWritable.class);
//指出reducer階段輸出的鍵值對的類型,也就是整個程序輸出的鍵值對的類型
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(LongWritable.class);
FileInputFormat.setInputPaths(job, new Path("D://data/input/"));//要處理的文件的路徑, 必須是存在的
FileOutputFormat.setOutputPath(job, new Path("D://data/output/"));//輸出結果的保存路徑, 必須是不存在的
boolean isSuccess = job.waitForCompletion(true);
System.exit(isSuccess?0:1);
}
}

仔細觀察WordCountMapper類和WordCountReducer類你會發現,他倆的寫法極其飄逸

extends Mapper<LongWritable,Text, Text, LongWritable>
extends Reducer<Text, LongWritable, Text, LongWritable>

一個繼承Mapper,一個繼承Reducer,怎麼個輸入,怎麼個輸出絲毫不管。這些工作都在Main類中做了。在Main類中,我們制定了Main類作為打包類,也就是程序的入口;又指定了Mapper和Reducer任務類;又指定了Map和Reduce階段各自的輸出類型;然後指定輸入文件路徑,輸出文件路徑,這裏很有意思的就是輸入文件路徑必須存在,且裏面有文本文件,否則報錯,輸出路徑必須不存在,如果存在就會報錯;最後,判斷任務執行是否完成,完成後退出程序。

然後我們可以去指定的輸出目錄查看結果了。

輸出目錄
雖然結果文件沒有後綴名,但是我們依然可以用記事本或者notepad++之類的軟件打開查看。下面是我的輸入文件及輸出結果內容

輸入內容:
I Love 123
123 Love 123
123 123 123
Love
輸出結果:
123 6
I 1
Love 3

日期統計

有了上面的單詞統計基礎,日期統計也就不難理解了。

public static class DaySumMapper extends Mapper<Object, BSONObject, Text, LongWritable> {

private Text tDay = new Text();
private LongWritable one = new LongWritable(1);
// 因為MongoDB中的數據都是K:V形式,因此入參也是K:V形式
@Override
protected void map(Object key, BSONObject value, Context context) throws IOException, InterruptedException {

Object o = value.get("time");
if(o != null){

String time = o.toString();
// 截取 yyyy/mm/dd
if(time.length() > 10){

String day = time.substring(0, 10);
tDay.set(day);
// 初次獲取,每個日期都是1個,之後會進行Shuffle操作
context.write(tDay, one);
}else{

tDay.set(time);
context.write(tDay, one);
}
}
}
}

這裏不容易理解的地方就是,為什麼map()方法的入參是Object key, BSONObject value?答案就是,因為我們在Main類中指定了入參的格式是MongoDB格式。而MongoDB中的數據都是K:V鍵值對形式。

// map輸入格式
job.setInputFormatClass(MongoInputFormat.class);

map之後,MapReduce會自動進行Shuffle操作,之後就是reduce方法了

public static class DaySumReducer extends Reducer<Text, LongWritable, Text, BSONWritable> {

private long count = 0;
@Override
protected void reduce(Text key, Iterable<LongWritable> values, Context context) throws IOException, InterruptedException {

BasicBSONObject res = new BasicBSONObject();
// shuffle後的每key都會執行一次reduce,必須重新置0
count = 0;
// 迭代Shuffle後的數據並累加
Iterator<LongWritable> its = values.iterator();
while (its.hasNext()){

count = count + its.next().get();
System.out.println(count);
}
res.put("count", count);
BSONWritable v = new BSONWritable(res);
// key代錶日期,怎麼進來,怎麼出去,不用動
context.write(key, v);
}
}

reduce有意思的地方就在於,需要輸出的日期經過Shuffle操作後已經排好序並且合並了,在reduce中不用對key進行任何操作。

之所以要把結果轉為BSONWritable類型,是因為我們還要把數據寫回MongoDB,讀寫MongoDB的配置寫在了,main方法中

public static void main(String[] args) throws Exception {

Configuration conf = new Configuration() ;
BasicConfigurator.configure();
// 配置MongoDB的輸入輸出路徑
MongoConfigUtil.setInputURI(conf,"mongodb://localhost:27017/test.news");
MongoConfigUtil.setOutputURI(conf,"mongodb://localhost:27017/test.daySum");
Job job = Job.getInstance(conf,"Mongo Connection") ;
// 指定打包類
job.setJarByClass(DaySum.class);
// 指定mapper 、 reducer
job.setMapperClass(DaySumMapper.class);
job.setReducerClass(DaySumReducer.class);
// map輸入格式
job.setInputFormatClass(MongoInputFormat.class);
// map輸出類型
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(LongWritable.class);
// reduce輸出類型
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(BSONWritable.class);
// reduce輸出格式
job.setOutputFormatClass(MongoOutputFormat.class);
// 退出程序
System.exit(job.waitForCompletion(true) ? 0 : 1);
}

程序運行結束後可以打開T3Studio查看一下結果

日期統計結果

關鍵詞個數統計

我們可以在一篇新聞詳情中右鍵查看源代碼,<head>中可以發現可以確保完整的title和keywords

標題和關鍵詞

在爬蟲階段我們就可以通過Jsoup把這些關鍵詞都拿到。在MongoDB數據庫中大概是下面這個樣子。
keywords

我們可以通過配置Map輸入數據路徑未MongoDB對應的Collection,從而拿到keywords,然後對Keywords按逗號進行切割,之後就可以按照單詞統計的思維進行關鍵詞個數統計了。具體代碼可以參考MapReduce入門時寫的WordCount。

文章正負面判斷

怎樣判斷一篇文章的正負性?

簡單的步驟是對文章內容進行分詞,將文章中的名詞、動詞、量詞、介詞之類的全挑出來,然後再根據現有的積極詞匯字典與消極詞匯字典進行匹配,統計積極與消極詞數量之比,如果積極詞占大多數,那麼就是一篇正面文章,反之就是負面文章。

提到分詞,看起來很難的技術已經被國人攻破了,並且在GitHub上開源,截止2019-07-16已經有5061個star,很厲害了。GitHub地址:Ansj中文分詞

不過作者也說了,這個庫並不完善,很多技術細節都沒有實現。也就先將就了,畢竟這個好用。

分詞使用的場景就是在我們獲取到新聞內容時,就對其進行分詞,和新聞內容,標題,評論等一同存入MongoDB數據庫。

其實只需要兩行代碼就能實現分詞功能

// 中文分詞
public static String ansj(String str){

Result result = ToAnalysis.parse(str);
return result.toString();
}

當我們在新聞詳情頁獲取到新聞內容後,就順手對其進行分詞,也就是調用上面的方法。

 // 分詞
String newsContentDivs = newsContent.replaceAll("[\\\\n\\s]","");
newsContentDivs = ansj(newsContentDivs);

最後和其他內容一起構成MongoDB一條Document,最後插入數據庫

構成Document

下圖是它存入數據庫後的樣子,每個詞後面都標記了詞性。

分詞結果

之後我們就可以在MapReduce中使用這個數據了,使用方法和關鍵詞個數統計差不多,只不過這個需要按照斜杠進行切割。具體代碼略去。

分好詞後,我們可以逐個和正負字典中的詞進行匹配並記錄正負值。關鍵代碼如下:

// Sentiment : 感情 統計正負感情詞匯個數
public static IntWritable judgeSentiment(List<Word> words, HashMap<String, Boolean> matchMap) {

int pos = 0;
int neg = 0;
for (Word w : words) {

// 查字典
Boolean b = matchMap.get(w.getWord());
if (b != null) {

if (b == true) {

pos++;
} else {

neg++;
}
}
}
return getGrade(pos, neg);
}
// 按正/負的商值判斷文章整體正負性
public static IntWritable getGrade(int pos, int neg) {

IntWritable current = new IntWritable(0);
if (neg == 0) {

current.set(3); // 默認積極
} else {

float fr = (float) pos / neg;
if (fr >= 3.0f) {
 // 積極
current.set(3);
} else if (fr > 2.0f && fr < 3.0f) {
 // 比較積極
current.set(2);
} else if (fr > 1.05f && fr < 2.0f) {
 // 中性
current.set(1);
} else if (fr > 0.5f && fr < 0.95f) {
 // 比較消極
current.set(-1);
} else if (fr > 0.333f && fr < 0.5f) {
 // 消極
current.set(-2);
} else if (fr < 0.333f) {
 // 極度消極
current.set(-3);
}
}
return current;
}

得到文章正負性結果後,再與文章標題,url進行組裝,一起插入數據庫中。結果如下圖所示:

文章正負性

前端展示

​ 數據庫中有了數據,最好還是展示到前端web頁面。

關於web後臺搭建,我曾經寫過一篇SpringBoot 與 SpringMVC搭建後臺的文章《【JavaEE】電商秒殺項目·第2章·基礎項目搭建》,過程寫的很詳細了,大家可以去看一下。

​ 另外,再介紹一個由眾多百度大神開發的數據可視化工具:ECharts

​ 這篇文章篇幅過長,我打算把前端展示另開一個新坑來寫。

最後

有些資源我都會發到GitHub倉庫,大家可以去clone

我的GitHub

和本篇文章有關的一些資源我已經放到百度網盤了

鏈接:https://pan.baidu.com/s/1BZ0HLeTnEKVAUq7a8JcVsA
提取碼:mcmd

版权声明:本文为[midnight_time]所创,转载请带上原文链接,感谢。 https://gsmany.com/2022/01/202201080734498795.html