1. 前言
這篇文章藏在心中已經好一段時日了,遲遲不敢動筆,主要是擔心不知道該如何去組織這樣一篇技術文章。
其實個人感覺技術性的文章是最難寫的,細節往往很難拿捏。有些技術細節沒有解釋到位,害怕讀的人難以理解。反之,有些簡單的東西怕講解太多,增加了不必要的篇章。
授人以魚不如授人以漁
故而,這篇文章我會盡量少貼代碼,多談思考過程。
2. 編碼的緣由
我最近在重新搭建自己的個人網站,想把自己在簡書寫的所有文章數據都導入到那個網站上。
這個時候有些朋友可能會不樂意了:“如果要獲取自己所寫的所有文章為什么要寫代碼?簡書不是已經提供了下載當前用戶所有文章的功能了嗎?“
兄弟說得在理,首先我得聲明簡書確實是一個很棒的寫作平臺,他能夠滿足絕大多數用戶的需求,但是對于我這種“懶”癌晚期的程序員來說卻還是有點不太夠。我有以下幾點考慮
- 把幾十篇文章一篇篇復制粘貼到個人網站的后臺,我想我會瘋掉的(我現在有68篇文章)。
- 當簡書的文章需要更新的時候,我不得不再去自己的網站后臺手動更新對應文章,這個工作有點重復了。
- 簡書下載的文章并沒有提供對應文章的
發布時間
,所屬文集
,字數
這些元數據,我并不想手動為每一篇文章設置對應的元數據值。
秉持著
Don't Repeat Yourself
這個原則,讓我們開始這次爬蟲之旅吧。
3. 功能介紹
簡單概括一下爬蟲的功能
- 向服務端發送請求獲取作者的所有文章。
- 從文章中提取出自己需要的數據(文章內容,標題,發布日期......)
- 組織數據,存儲到對應的數據庫中。
本文重點會放在頁面的請求,解析以及數據的組織上,至于如何把數據存進數據庫,不會多加講解。有興趣的可以直接看源碼。
4. 技術棧
我最后選擇使用node.js
來寫這個爬蟲,畢竟要重新去學習python或者ruby這些服務端語言需要耗費我不少時間。只是這樣當然這樣還不夠。
工欲善其事,必先利其器
為了少寫些代碼我還需要一個較為成熟的爬蟲框架。這里使用的是node-crawler。個人覺得這是一個比較cool的爬蟲框架,而且前端人員用起來定會覺得倍感親切-我們可以用我們最親切的jQuery語法來解析響應返回的頁面。
5. 數據模型
在要爬一樣東西之前,首先我們肯定得確認要爬取的東西是什么。這里為了簡化文章,我把需要爬取的內容定為以下三個字段(源碼里面可不止這幾個字段)。
- 文章標題
- 文章發布日期
- 文章內容(markdown格式)
我只需要想辦法從響應返回的頁面里提取出上面三種數據就可以了。至于最后把數據存到什么數據庫里面,怎么存,那便看個人喜好了。
6. 制定爬蟲策略
(1)基本信息爬取
首先進入對應作者的簡書主頁,類似這個頁面。我們會看到有一堆文章列表,看看能不能提取到我們需要的信息?
好吧,一大堆問題,除了文章標題之外其他內容似乎都難以爬取。不管怎么樣都得去文章詳情頁看看了
看來詳情頁面還算能夠滿足我的需求。故而,我決定基本信息的提取采用如下策略
- 通過爬蟲爬取用戶首頁的所有文章條目,提取出每一篇文章對應詳情頁的鏈接。
- 通過爬蟲框架發送請求,分別請求每一篇文章的詳情頁面。
- 解析詳情頁面的內容,提取出我們需要的信息。
(2)面對滾動加載,該如何獲得所有文章?
在進入特定作者的簡書主頁的時候,我發現其實簡書并沒有加載該作者所有的文章,他只是加載了文章列表的一部分。如果需要獲取更多的文章列表,需要向下滾動,向服務器端請求更多的頁面。
剛開始我是站在前端的立場上去考慮這個問題,真的很蛋疼。我最初的想法是使用一些庫如phantomjs去模擬瀏覽器的行為。我打算模擬瀏覽器滾動行為,等數據加載完成之后再繼續滾動,直到不再往服務端請求數據為止。我就真的這么做了,后來才發現這是個噩夢,這意味著我得做下面的事情:
- 加載頁面。
- 模擬瀏覽器滾動行為加載更多文章數據。
- 監聽請求行為,請求完成之后再繼續滾動。
- 判斷什么時候滾到最底部。
先不說有沒有程序庫支持上面的功能,以上策略明眼人一看就覺得不靠譜,估計也就只有我傻乎乎地跑去嘗試了。我多次嘗試后發現,要模擬滾動行為都相當困難,而且使用phantomjs
的時候開發環境經常會卡死。直覺告訴我或許還有更好的實現方式。當對一個問題百思不得其解的時候,或許可以嘗試跳出原來的思維定勢,從另一個角度去考慮這個問題。問題往往會簡單不少。
后來我從后端的角度上去考慮這個事情
問: 加載更多這個行為的本質是什么?
答: 向服務端發送請求,獲取更多頁面數據。
那我只需要知道頁面滾動的時候瀏覽器向服務端發送請求的url以及對應的參數,我不就可以用爬蟲來迭代發送這個請求,進而達到獲取完整的文章列表的目的了嗎?
OK,馬上去Network
看看,它可以看到我們發送的網絡請求。我在Network
下清除歷史記錄后滾動頁面,有以下發現。
服務端響應后返回的是渲染過的列表數據。果然跟我預料的一樣。然后我再看看完整的url
原來它是使用了分頁參數page
,來向服務端請求分頁數據。我通過代碼來封裝這個過程
var i = 1;
var queue = []
while( i < 10) {
var uriObject = {
uri: 'http://www.lxweimin.com/u/a8522ac98584?order_by=shared_at&&page=' + i,
queue.push(uriObject);
i ++;
}
這只是代碼片段的一部分,把10個url存進隊列數組里。最終也只能獲取10頁的數據,不過這就有個問題了如果真實數據不到10頁,那該如何處理?
我試著請求第100頁的數據,看看會發生什么。發現簡書的服務端返回了一個302
的狀態碼,然后瀏覽器跳轉到個人動態的頁面去了。
這個狀態碼很有用,我可以針對這個狀態碼判斷我們的請求的頁碼參數page
的值有沒有超出指定的頁數。
我可以預設更多的請求,如果請求返回這個狀態碼,則不對請求的數據進行任何處理(因為已經超出頁數的范圍)。反之則對返回的數據進行解析,提取出我們需要的關鍵數據。
當然這種做法相當粗暴,會發送許多不必要的請求。接下來有時間我會對這部分代碼進行優化。
7. 頁面解析
請求發送完了,等服務端響應之后我們便可獲取到我們需要的頁面了。接下來要做的就是對頁面結果進行解析,提取出我們需要的內容。上面也說過了 node-crawler
這個爬蟲框架內置了jQuery
,這讓我們頁面解析工作變得簡單。
(1)獲取文章詳情頁面的url
先來看看文章列表每個條目的html結構(簡書的程序員也做了注釋)。
我們只需要提取出 ul.note-list
里面的每一條li a.title
,然后再提取出它們的href
屬性對應的值,便是我們需要獲取的url了。這種操作對jQuery
來說簡直易如反掌。下面是我的代碼片段,我把所有url提出來后都存儲在articlesLink
這個數組里面,供后面的程序使用。
let articlesLink = [];
$('ul.note-list').find('li').each((i, item) => {
var $article = $(item);
let link = $article.find('.title').attr('href');
articlesLink.push(link);
})
(2)從詳情頁面中提取數據
再look look詳情頁面的結構
從頁面結構看,我們可以很簡單地提取出標題,還有發布日期這兩個字段的內容
let title = $article.find('.title').text();
let date = $article.find('.publish-time').text().replace('*', '');
有些更新過的文章,發布日期最后就會有個*號,避免干擾我需要把他們都處理掉。但是文章主體的提取就有一些問題了。
我最后期望得到的是markdown格式的字符串。這點我可以通過 to-markdown這個package把html轉換成markdown。但是現在問題是這個包似乎是無法解析div
這個標簽的。我考慮著把文章主體里面的所有div
標簽都刪掉,然后把處理過的字符串通過to-markdown轉換成對應的markdown格式的字符串,便可以得到我們期望的數據了。
既然有了jQuery
這個神器,實現起來不會很麻煩。不過我還想要刪除含有類名image-caption
的標簽-這個是簡書默認設置,有的時候它有點礙事,可以考慮刪掉。
以下是我的代碼片段:
var toMarkdown = require('to-markdown');
// 刪除圖片的標題
let $content = $article.find('.show-content');
$content.find('.image-caption').remove();
$content.find('div').each(function(i, item) {
var children = $(this).html();
$(this).replaceWith(children);
})
// 獲取markdown格式的文章
let articleBody = toMarkdown($content.html());
最后,只需要把他們放到對象里面:
let article;
article = {
title: title,
date: new Date(date),
articleBody: articleBody
}
至于如何把上面的數據存入數據庫,方法有很多,考慮到文章篇幅問題,這里就不多做敘述了。個人比較推崇MongoDB。它是一款目前用得最多的非關系型數據庫,靈活性很強。對于構建漸進式的應用,我覺得這是一個比較好的選擇。在經常修改的表結構的情景下,起碼你不用去維護一大堆的migrations
文件。
最后入庫結果如下,剛好是68篇文章
結尾
不知不覺這篇文章已經占去了好幾個小時,即便這篇文章我已經去除了不必要的代碼細節,著重對自己的思考過程以及遇到的問題進行了總結,但還是寫了不少字。目測總結能力還有待提高啊~~~~
注意:本文只是在總結一些經驗以及思考過程,github的源代碼也只是起參考作用,并不是即插即用的程序包。您可以根據您的情況來寫出滿足自己需求的爬蟲,相信你能做得比我更好。