使用 declarative-crawler 爬取知乎美圖 是筆者對 declarative-crawler 的具體實例講解,從屬於筆者的 程序猿的數據科學與機器學習實戰手冊。node
本部分源代碼參考這裏,對於 declarative-crawler 的分層架構與設計理念能夠參考筆者的前文 基於 Node.js 的聲明式可監控爬蟲網絡初探。這裏咱們仍是想以知乎簡單的列表-詳情頁爲例,講解 declarative-crawler 的基本用法。首先咱們來看下爬取的目標,譬如咱們搜索美女或者其餘主題,能夠獲得以下回答的列表頁:git
點擊某個回答以後咱們能夠進入以下的回答詳情頁,而咱們的目標就是將全部的圖片保存到本地。github
目前知乎是基於 React 構建的動態網絡,換言之,咱們不能夠直接使用 fetch 這樣的靜態抓取器。實際上 declarative-crawler 提供了多種類型的蜘蛛,譬如專門爬取靜態網頁的 HTMLSpider、爬取動態網頁的 HeadlessChromeSpider、爬取接口的 JSONSpider.js 以及爬取數據庫的 MySQLSpider 等等。而這裏咱們是須要以無界面瀏覽器將抓取到的靜態頁面、腳本、CSS 等協同渲染,而後獲得真實的網頁。這裏咱們使用 Headless Chrome 做爲渲染載體,筆者會在將來的文章中介紹如何使用 Headless Chrome,這裏咱們只須要利用預設好的 Docker 鏡像以服務的方式運行 Chrome 便可。實際上 HeadlessChromeSpider 就是對於 Chrome 遠程調試協議的封裝,以下代碼中咱們進行了簡單的導航到 URL,而後等待頁面加載完畢以後再抓取 HTML 值:web
CDP( { host: this.host, port: this.port }, client => { // 設置網絡與頁面處理句柄 const { Network, Page, Runtime } = client; Promise.all([Network.enable(), Page.enable(), Runtime.enable()]) .then(() => { return Page.navigate({ url }); }) .catch(err => { console.error(err); client.close(); }); Network.requestWillBeSent(params => { // console.log(params.request.url); }); Page.loadEventFired(() => { setTimeout(() => { Runtime.evaluate({ expression: "document.body.outerHTML" }).then(result => { resolve(result.result.value); client.close(); }); }, this.delay); }); } ).on("error", err => { console.error(err); });
不過這種方式並不能獲取到咱們想要的圖片信息,咱們能夠利用 Network 模塊監控全部的網絡請求,能夠發現由於知乎是根據滾動懶加載的方式加載圖片,在頁面加載完畢的事件觸發時,實際上只會在 img 標籤中加載好以下的一些小頭像:chrome
https://pic4.zhimg.com/a99b7a9933526403f0b012bd9c11dbbf_60w.jpg https://pic1.zhimg.com/151ee0138f8432d61977504615d0614c_60w.jpg https://pic2.zhimg.com/c2847b95e204cd6e23fca03d18610a65_60w.jpg https://pic2.zhimg.com/5f026494c8bcc7283770e84c37c1aa49_60w.jpg https://pic1.zhimg.com/4bd564be18599d169a6fab3b83f3c418_60w.jpg https://pic1.zhimg.com/16eb0d6650f962d8ff1b0b339a4563cc_60w.jpg https://pic1.zhimg.com/b6f5310d9fac7c173ce8e310f6196f38_60w.jpg https://pic3.zhimg.com/0aac046c829d37edcf0b9ba780dc2f92_60w.jpg https://pic3.zhimg.com/c4cdff37d72774768c202478c1adc1b6_60w.jpg https://pic1.zhimg.com/aa1dc6506f009530c701ae9ae283c424_60w.jpg https://pic4.zhimg.com/200c20e15a427b5a740bc7577c931133_60w.jpg https://pic4.zhimg.com/7be083ae4531db70b9bd9149dc30dd1b_60w.jpg https://pic2.zhimg.com/5261bc283c6c2ed2900a504e2677d365_60w.jpg https://pic1.zhimg.com/9a6762c751175966686bf93bf009ab30_60w.jpg https://pic4.zhimg.com/b1b92239d6718aa146b0669dc423e693_60w.jpg
針對這種狀況,咱們的第一個思路就是模擬用戶滾動,Chrome 爲咱們提供了 Input 模塊來遠程執行一些點擊、觸碰等模擬動做:docker
await Input.synthesizeScrollGesture({ x: 0, y: 0, yDistance: -10000, repeatCount: 10 });
不過這種方式性能較差,而且等待時間較長。另外一個思路就是借鑑 Web 測試中的 MonkeyTest,在界面中插入額外的腳本,不過由於知乎的 Content Security Policy 禁止插入未知源的腳本,所以這種方式也是不行。數據庫
最後咱們仍是把視角放到界面中,發現知乎是將全部懶加載的圖片放置到 noscript 標籤中,所以咱們能夠直接從 noscript 標籤中提取出懶加載的圖片地址而後保存。express
咱們首先須要聲明抓取某個主題下全部答案列表的蜘蛛,其基本使用以下:npm
/** * @function 知乎某個話題答案的爬蟲 */ export default class TopicSpider extends HeadlessChromeSpider { // 定義模型 model = { ".feed-item": { $summary: ".summary", $question: ".question_link" } }; /** * @function 默認解析函數 * @param pageObject * @param $ * @returns {Array} */ parse(pageObject: any, $: Element) { // 存放所有的抓取到的對象 let feedItems = []; for (let {$question, $summary} of pageObject[".feed-item"]) { feedItems.push({ questionTitle: $question.text(), questionHref: $question.attr("href"), answerHref: $($summary.find("a")).attr("href"), summary: $summary.text() }); } return feedItems; } }
聲明蜘蛛咱們最核心的是須要聲明模型,即頁面的 DOM 提取規則,這裏咱們底層使用的是 cherrio;而後聲明解析方法,即從 DOM 元素對象中提取出具體的數據。而後咱們可使用 Jest 編寫簡單的單元測試:瀏覽器
// @flow import TopicSpider from "../../spider/TopicSpider"; const expect = require("chai").expect; let topicSpider: TopicSpider = new TopicSpider() .setRequest("https://www.zhihu.com/topic/19552207/top-answers") .setChromeOption("120.55.83.19"); test("抓取知乎某個話題下答案列表", async done => { let answers = await topicSpider.run(false); expect(answers, "返回數據爲列表而且長度大於10").to.have.length.above(2); done(); });
對於答案頁的提取則稍微複雜了一點,由於咱們還須要聲明圖片下載器。在這裏的 parse 函數中咱們是對於全部的 img 標籤與 noscript 下包含的圖片連接進行了提取,最後調用內置的 downloadPersistor 來保存圖片:
/** * @function 專門用於爬取答案以及緩存的爬蟲 */ export default class AnswerAndPersistImageSpider extends HeadlessChromeSpider { // 定義模型 model = { // 抓取全部的默認 $imgs: "img", // 抓取全部的延遲加載的大圖 $noscript: "noscript" }; /** * @function 對提取出的頁面對象進行解析 * @param pageElement 存放頁面對象 * @param $ 整個頁面的 DOM 表示 * @returns {Promise.<Array>} */ async parse(pageElement: any, $: Element): any { // 存放全部圖片 let imgs = []; // 抓取全部默認圖片 for (let i = 0; i < pageElement["$imgs"].length; i++) { let $img = $(pageElement["$imgs"][i]); imgs.push($img.attr("src")); } // 抓取全部 noscript 中包含的圖片 for (let i = 0; i < pageElement["$noscript"].length; i++) { // 執行地址提取 let regexResult = imageRegex.exec($(pageElement["$noscript"][i]).text()); if (regexResult) { imgs.push(regexResult[0]); } } return imgs; } /** * @function 執行持久化操做 * @param imgs * @returns {Promise.<void>} */ async persist(imgs) { await downloadPersistor.saveImage(imgs); } }
一樣咱們能夠編寫相關的單元測試:
// @flow import AnswerAndPersistImageSpider from "../../spider/AnswerAndPersistImageSpider"; const expect = require("chai").expect; global.jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000000; // 初始化 let answerAndPersistImageSpider: AnswerAndPersistImageSpider = new AnswerAndPersistImageSpider() .setRequest("https://www.zhihu.com/question/29134042") .setChromeOption("120.55.83.19", null, 10 * 1000); test("抓取知乎某個問題中全部的圖片", async done => { let images = await answerAndPersistImageSpider.run(false); expect(images, "返回數據爲列表而且長度大於10").to.have.length.above(2); done(); }); test("抓取知乎某個問題中全部的圖片而且保存", async done => { let images = await answerAndPersistImageSpider.run(true); done(); });
負責採集和處理單頁面的蜘蛛編寫完畢以後,咱們須要編寫串聯多個蜘蛛的爬蟲:
export default class BeautyTopicCrawler extends Crawler { // 初始化爬蟲 initialize() { // 構建全部的爬蟲 let requests = [ { url: "https://www.zhihu.com/topic/19552207/top-answers" }, { url: "https://www.zhihu.com/topic/19606792/top-answers" } ]; this.setRequests(requests) .setSpider( new TopicSpider().setChromeOption("120.55.83.19", null, 10 * 1000) ) .transform(feedItems => { if (!Array.isArray(feedItems)) { throw new Error("爬蟲鏈接失敗!"); } return feedItems.map(feedItem => { // 判斷 URL 中是否存在 zhihu.com,若存在則直接返回 const href = feedItem.answerHref; if (!!href) { // 存在有效二級連接 return href.indexOf("zhihu.com") > -1 ? href : `https://www.zhihu.com${href}`; } }); }) .setSpider( new AnswerAndPersistImageSpider().setChromeOption( "120.55.83.19", null, 10 * 1000 ) ); } }
爬蟲最核心的即爲其 initialize 函數,這裏咱們須要輸入種子地址以及蜘蛛的串聯配置,而後交由爬蟲去自動執行。
爬蟲聲明完畢後,咱們便可以以服務端的方式運行整個爬蟲:
// @flow import CrawlerScheduler from "../../crawler/CrawlerScheduler"; import CrawlerServer from "../../server/CrawlerServer"; import BeautyTopicCrawler from "./crawler/BeautyTopicCrawler"; const crawlerScheduler: CrawlerScheduler = new CrawlerScheduler(); let beautyTopicCrawler = new BeautyTopicCrawler(); crawlerScheduler.register(beautyTopicCrawler); new CrawlerServer(crawlerScheduler).run().then( () => {}, error => { console.log(error); } );
服務啓動以後,咱們能夠訪問 3001 端口來獲取當前系統的狀態:
http://localhost:3001/ [ { name: "BeautyTopicCrawler", displayName: "Crawler", isRunning: false, lastStartTime: "2017-05-03T05:03:58.217Z" } ]
而後訪問 start 地址來啓動爬蟲:
http://localhost:3001/start
爬蟲啓動以後,咱們能夠查看具體的某個爬蟲對應的運行狀況:
http://localhost:3001/BeautyTopicCrawler { "leftRequest": 37, "spiders": [ { "name": "TopicSpider", "displayName": "Spider", "count": 2, "countByTime": { "0": 0, "59": 0 }, "lastActiveTime": "2017-05-03T04:56:31.650Z", "executeDuration": 13147.5, "errorCount": 0 }, { "name": "AnswerAndPersistImageSpider", "displayName": "Spider", "count": 1, "countByTime": { "0": 0, "59": 0 }, "lastActiveTime": "2017-05-03T04:56:44.513Z", "executeDuration": 159120, "errorCount": 0 } ] }
咱們也能夠經過預約義的監控界面來實時查看爬蟲運行情況(正在重製中,還沒有接入真實數據),能夠到根目錄的 ui 文件夾中運行:
yarn install npm start
便可以看到以下界面:
最後咱們也可以在本地的文件夾中查看到全部的抓取下來的圖片列表(默認爲 /tmp/images):