使用Angular與TypeScript構建Electron應用(四)
這一節我們只做兩件事,第一是建立相應的爬蟲系統,從網頁鏈接上提取合適的信息,第二則是將這些信息儲存在資料庫中,render需要展示時再查詢予以顯示。開始構建代碼前我們先思考一下這樣做的好處是什麼。
介紹
在news-feed應用中,我們把爬蟲邏輯放在客戶應用里而非服務端,這是正確的,考慮到用戶增加的情況下我們無法負擔所有的爬蟲任務,如果我們將這些任務進行合理的分配是最優的,利用一些客戶端資源。在生產環境里還可以考慮用戶每次爬取完畢後發送處理好的字元串發送回服務端進行存儲,甚至可以根據伺服器返回不同得資源來考慮返回給用戶不同的任務。雖然在news-feed中我們不會做這些事,但我們不妨考慮這樣的系統是如何工作的:
- 應用內部儲存一張映射表,可更新,作為當前應用的基礎爬蟲任務。
- 根據用戶下載應用IP不同分發不同的應用包,基礎資料庫的標識有一些區別。
- 根據用戶請求的標識+IP地址返回給用戶不同的爬蟲任務。
- 短時間的工作後將數據返回給服務端。
- 用戶每次查看的新聞一部分是自己客戶端爬取的,另一部分則從伺服器下載。
這樣的系統很有意思,積累眾多格式化數據資源後甚至可以轉為開發的新聞API供大家使用,不過它很複雜(你可以自己嘗試一下),目前我們希望應用的所有數據都能夠自行完成,為此我們至少需要一個資料庫存儲格式化數據,一段可配置的代碼爬取與分析數據。在做所有事情之前,我準備加入一個新的語法糖,以適應爬蟲任務。
配置Async
async是ES7的新語法,簡單的說,async是一個基於Generator的語法糖。如果你對Generator還不了解,建議先學習一些ES6基礎知識。爬蟲任務可能涉及到很多的非同步任務,但大多數時候我們更希望它們可以同步執行(並發過大很容易被網站屏蔽IP地址),async函數可以幫助我們輕鬆的用同步函數的方式寫非同步邏輯,而且它足夠簡單,學習它也是理所應當的,這是javascript的趨勢之一。
首先我們需要安裝一些必要的npm包:
npm i --save transform-async-to-generator syntax-async-functions transform-regeneratornpm i --save babel-core babel-polyfill babel-preset-es2016
這裡我希望代碼不要經過頻繁的轉碼,應用可以不考慮兼容性,所以我加入一些墊片使語法糖能夠正常工作即可。 在根文件夾下建立一個.babelrc文件:
{ "presets": ["es2016"], "plugins": ["transform-async-to-generator", "syntax-async-functions", "transform-regenerator"]}
並在根文件夾建立一個main.js,集合這些文件:
require(babel-core/register);require("babel-polyfill");require("./index");
從現在開始我們每次只需運行electron main.js就能夠輕鬆的啟動富含ES7語法糖的應用。當然,你可以引入任何語法,甚至是Gulp/Webpack編譯代碼,只要你開心。
安裝資料庫
作為一個桌面應用,數據存儲是必不可少的一環,但這裡並沒有使用已攜帶的瀏覽器存儲:
- 瀏覽器的各類存儲總是有限的。
- 它們很難存儲複雜結構的數據,你需要為此做很多轉換。
- 最大的局限在於不能夠隨意的釋放窗口對象,這會帶來很多的存儲丟失問題,這對未來的擴展必然有影響。
除此之外我們還可以選用一些流行的雲儲存,遠程資料庫等等,但我希望應用能夠在離線時正常工作,為此我們需要一個安裝簡單,在本地即時編譯的輕量級資料庫。
這裡我選用的流行的nedb,它的社區環境足夠好,有很多的使用者(保證庫能夠及時更新並解決各類問題),而且與electron能夠很好的結合。 安裝nedb:
npm i --save nedb
在根目錄的index.js中啟動資料庫:
const Datastore = require(nedb)global.Storage = new Datastore({filename: `${__dirname}/.database/news-feed.db`, autoload: true })
nedb有多種儲存方式,包括內存。這裡的autoload代表每次更新時都會更新資料庫的本地文件,將數據寫入硬碟。你也可以選擇每次使用loadDatabase來手動觸發寫入硬碟的動作。
構建爬蟲代碼
在動手之前我們先嘗試分析爬蟲代碼的邏輯:這裡至少需要一個實際工作的爬蟲函數,它從http請求得到數據並且開始分析html,最後存儲這些數據。不同的網站結構不同意味著需要不同的解析函數,但其中至少可以將基礎的http服務抽離出來(它們總是相同的),未來我們可以從服務端獲取一些解析代碼填充在這裡。
手動發起http請求與處理字元串工作量非常大,我們可以藉助一下庫來完成這些工作:
* https://github.com/request/requestnpm i --save request* https://github.com/cheeriojs/cheerionpm i --save cheerio
1.新建http請求函數
在/browser/task下新建base.js:
const req = require(request)module.exports = class Base { constructor (){ } static makeOptions (url){ return { url: url, port: 8080, method: GET, headers: { User-Agent: nodejs, Content-Type: application/json } } } static request (url){ return new Promise((resolve, reject) =>{ req(Base.makeOptions(url), (err, response, body) =>{ if (err) return reject(err) resolve(body) }) }) }}
Base類有兩個靜態方法,makeOptions負責根據url生成一個option對象,為每次請求設置配置項使用,當未來需要驗證token/cookie時我們再來擴充此方法,request返回一個Promise對象,顯然它會發起一個請求,但更多的作用是在使用時優先返回body而非response。這很重要。
也許你開始注意到,這兩個靜態函數完全不依賴this,它們僅僅是類的靜態方法,無需實例化即可使用,同時也能夠被繼承。這樣的目的在於暗示這些函數是完全不依賴狀態的純函數,它們總是返回相同的結果,也沒有副作用,這樣的函數在未來能夠被更好的閱讀與擴展。
2.新建爬蟲文件
假定這個文件只負責單個網站(例如http://ifeng.com)的功能,當然以後這樣的文件會越來越多,現在先為這些功能文件創建一個集合文件負責導出:// /browser/task/index.jsmodule.exports = { ifeng: require(./ifeng)}
在task文件夾下再創建一個ifeng.js:
const cheerio = require(cheerio)const Base = require(./base)module.exports = new class Self extends Base { constructor (){ super() this.url = http://news.ifeng.com/xijinping/ } start (){ global.Storage.count({}, (err, c) =>{ if (c || c > 0) return ; this.request() .then(res =>{ console.log(全部儲存完畢!); global.Storage.loadDatabase() }) .catch(err =>{ console.log(err); }) }) } async request (){ try{ const body = await Self.request(this.url) let links = await this.parseLink(body) for (let index = 1; index< links.length; index++){ const content = await Self.request(links[index -1]) const article = await this.parseContent(content) await this.saveContent(Object.assign({id: index}, article)) console.log(`第${index}篇文章:${article&&article.title}儲存完畢`); } } catch (err){ return Promise.reject(err) } } parseLink (html){ const $ = cheerio.load(html) return $(.con_lis > a) .map((i, el) => $(el) .attr(href)) } parseContent (html){ if (!html) return; const $ = cheerio.load(html) const title = $(title).text() const content = $(.yc_con_txt).html() return {title: title, content: content} } saveContent (article){ if (!article|| !article.title) return ; return global.Storage.insert(article) }}()
ifeng.js的主體是request函數,它做了以下幾件事:
- try代碼塊,捕獲await可能拋出的錯誤。
- 利用繼承的request靜態方法獲得基礎的列表文件,使用parseLink解析html獲得一個鏈接數組。cheerio是一個類似於JQuery的庫,可以幫助我們解析這些html文件。
- 循環體內,分別請求文章主體,利用parseContent分析文章並集合成對象,如果對象獲取成功,接下來還會為這篇文章對象合併一個序列號,便於後面的查詢/分類。
- 每次循環都插入一次資料庫。這樣做在於單次插入數據較多失敗時,neDB會使所有的數據回滾。當然這其中的量級你可以自己把握。在更大的應用里你可以抽象出一層類似於ORM的服務,專職於有效快速的存儲查詢,甚至是提供一些語法糖。
這裡的global.Storage.count是一個權宜之計,在未來完全前端代碼後再回過頭來解決它,目前我們只需要在根目錄的index.js里加入require(./browser/task/index).ifeng.start()即可使它工作起來:
OK,這一節的所有目標都已完成,下一節我們開始討論如何在Angular中構建一個合理的展示模塊並與資料庫通信。
推薦閱讀:
※一圖淺析electron架構
※Hve——一個你可能喜歡的靜態博客客戶端工具
※上傳一個nodeblink的demo,試玩一下
※bElectron bAPI Demos 項目解析
※Webpack實戰-構建 Electron 應用