生命在於折騰,寫一個前端資訊推送服務

Colafornia發表於2018-09-12
生命在於折騰,寫一個前端資訊推送服務

去年年底開始寫的一個小專案,斷斷續續做了些優化,在此簡單的記錄一下。

源頭

起源是之前一直沒什麼機會接觸到 Node 專案,工作中接觸到的也僅限於用 Node 寫指令碼,做一些小工具,與伺服器上跑的 Node 服務相差甚遠。所以想寫一個在伺服器上跑的 Node 小專案練手。

一直喜歡用 RSS 訂閱資訊這種方式,簡單高效,與其每天不定時地接收推送,開啟各網站 App 來接收資訊,不如自己拿到主動權集中在同一時間段統一閱讀。這樣避免了每天不定時接受資訊的焦慮堆積,但是又常常想不起來開啟?,過了一週開啟 Reeder,發現累積的未讀資訊又爆炸了,人真是很難滿足。

於是決定自己搞個資訊推送服務吧,滿足自己的核心訴求,每個工作日早上 10 點微信推送 RSS 前端資訊的更新,這樣就可以在每天抵達工位的時候舒舒服服瀏覽一下新鮮事,挑一些有用的存起來慢慢研讀。

專案倉庫: github.com/Colafornia/…

推送大概長這樣:

生命在於折騰,寫一個前端資訊推送服務

掃碼獲取推送服務:

生命在於折騰,寫一個前端資訊推送服務

現在推送源主要是各廠的知乎專欄,大佬們的個人部落格,掘金前端熱門文章,都是我自己的個人口味。

下面來講一下開發(與自己給自己加需求)歷程。

開始

最開始感覺這個需求是很簡單的,具體操作可以分解為:

  1. 寫一個配置檔案,把我想抓取的 RSS 源地址寫在裡面
  2. 找一個能解析 RSS 的 npm 包,遍歷配置檔案裡的源,解析之後處理資料
  3. 僅篩出在過去 24 小時內更新的文章,把資料處理一下,彙總成一段字串,用微信推送
  4. 以上寫出的指令碼通過定時任務跑起來,done!

最後選擇了 rss-parser 作為解析工具包,PushBear 作為推送服務,node-schedule 任務排程工具寫出來了一版。

然後就發現自己知識的匱乏了,沒有考慮到指令碼部署到伺服器上時,程式守護的問題,於是研習了一波 pm2,完美完成任務。

過渡

專案寫到這裡其實是可以湊和用了,但是看起來很 low 很難受。主要問題有:

  1. 當時 RSS 源大概有四五十個,一次性遍歷解析所有的源經常會有超時或者出錯的
  2. RSS 源寫在配置檔案裡,每次想新增、修改源都需要改程式碼,很 low
  3. PushBear 這個推送服務只能儲存三天內的推送,三天前,一週前的推送內容都看不了,這也很難受
  4. 掘金的 RSS 源內容不多,也不是按照熱門程度排序的(也可能是我姿勢不對?),不太符合要求

第一點稍微有點複雜,可能現在解決的方案依然很原始。出現第一個問題一是需要控制請求的併發數量,二是 RSS 源本身有一定的不穩定性。目前的解決方案是:

  1. 把抓取任務和推送任務分開,預留出可以迴圈抓取三次的時間,後面兩次只抓取之前失敗的源
  2. asyncmapLimittimeout 方法設定最大併發數量和超時時間

大致程式碼如下(有一些細節處理沒貼上來):

// 抓取定時器 ID
let fetchInterval = null;
// 抓取次數
let fetchTimes = 0;
function setPushSchedule () {
    schedule.scheduleJob('00 30 09 * * *', () => {
        // 抓取任務
        log.info('rss schedule fetching fire at ' + new Date());
        activateFetchTask();
    });

    schedule.scheduleJob('00 00 10 * * *', () => {
        // 傳送任務
        log.info('rss schedule delivery fire at ' + new Date());
        let message = makeUpMessage();
        log.info(message);
        sendToWeChat(message);
    });
}
function activateFetchTask() {
  fetchInterval = setInterval(fetchRSSUpdate, 120000);
  fetchRSSUpdate();
}
function fetchRSSUpdate() {
    fetchTimes++;
    if (toFetchList.length && fetchTimes < 4) {
        // 若抓取次數少於三次,且仍存在未成功抓取的源
        log.info(`第${fetchTimes}次抓取,有 ${toFetchList.length} 篇`);
        // 最大併發數為15,超時時間設定為 8000ms
        return mapLimit(toFetchList, 15, (source, callback) => {
            timeout(parseRSS(source, callback), 8000);
        })
    }
    log.info('fetching is done');
    clearInterval(fetchInterval);
    return fetchDataCb();
}
複製程式碼

這樣基本解決了 90% 以上的抓取問題,保證了指令碼的穩定性。

針對 RSS 源寫在配置檔案裡,每次想新增、修改源都需要改程式碼的問題,解決方法很簡單,把源配置寫到 MongoDB 裡也就好了,有一些 GUI 軟體可以直接在圖形介面來新增、修改資料。

為了解決推送服務只能儲存三天內的推送,決定新增一個每週五的周抓取任務,抓取一週內的新文章,把內容作為 issue 發到倉庫。也還算是一個解決方案。

生命在於折騰,寫一個前端資訊推送服務

針對掘金的 RSS 源問題,最後決定直接呼叫掘金的介面來取資料,這就可以隨心所欲按自己的需求來了,每天只抓取❤️點贊數在 70 以上的文章。

順便給抓取的文章時間範圍加了一個偏移值,避免篩掉質量好但是由於剛剛釋出點贊較少的文章。感覺自己棒棒噠~

function filterArticlesByDateAndCollection () {
    const threshold = 70;
    // articles 是已按❤️數由高到低排序的文章列表
    let results = articles.filter((article) => {
        // 偏移值五小時,避免篩掉質量好但是由於剛剛釋出點贊較少的文章
        return moment(article.createdAt).isAfter(moment(startTime).subtract(5, 'hours'))
            && moment(article.createdAt).isBefore(moment(endTime).subtract(5, 'hours'))
            && article.collectionCount > threshold;
    });
    // 掘金文章最多收錄 8 篇,避免資訊爆炸
    return results.slice(0, 8);
}
複製程式碼

在這個期間也充分感受到了日誌的重要性,在資料庫裡新增了一個表用來存每天的推送內容。

另外在 PushBear 上新新增了一個 Channel 來給自己推送日誌,每天在抓取任務完成後,先給我傳送一下抓取到的內容,如果發現有任何問題,我可以自己登伺服器緊急修復一下(這麼想來還是很 low ?)。

升級

做完以上改動之後,指令碼穩定地跑了快半年,這期間我也一直在忙著搬磚,沒什麼精力再來改造它。

一直沒做推廣,但某天突然發現已經有了三十多個使用者在訂閱這個服務,於是良心發現,本著對使用者負責(也是自己有了新的想練習的技術?),就又做了一次改造。

此時專案的問題有:

  1. 沒有文章去重,如果文章在知乎專欄發了,掘金也發了,作者個人部落格也發了的話,就相當於會重複出現幾次
  2. 推送的時間間隔不精確,都是當前時間的過去 24 小時來篩的
  3. 指令碼直連資料庫進行存取操作也不太好,感覺這個形式做成 server,對外暴露 api 更合理(等哪天想寫個 RSS 閱讀器也就用上了)
  4. 每次程式碼有更新,依賴有更新,都 ssh 上伺服器然後 npm install 感覺也不太專業,有提升空間(其實就是想用 docker 了)

1,2 問題很好解決,每次抓取之前先查一下日誌,上次推送的具體時間。每抓到新文章時,再與最近 7 天日誌裡的文章比對一下,重複的不放到抓取結果中,也就解決了。

對於問題 3,於是決定搭建 Koa Server,先把從 MongoDB 讀取推送源,存取推送日誌變成 api。

目錄結構如下,新增 ModelController。把 RSS 抓取指令碼與掘金爬蟲放到 task 檔案。

生命在於折騰,寫一個前端資訊推送服務

沒什麼難點,就可以呼叫 api 來獲取 RSS 源了:

生命在於折騰,寫一個前端資訊推送服務

此時想到了一個重要問題,身份驗證。肯定不能把所有 api 都隨意暴露出去,讓外界可以任意呼叫,這也就相當於把資料庫都暴露出去了。

最終決定用 JSON Web Token(縮寫 JWT) 作為認證方案,主要原因是 JWT 適合一次性、短時間的命令認證,目前我的服務僅限於伺服器端的 api 呼叫,每天的使用時間也不長,無需簽發有效期很長的令牌。

Koa 有一個 jwt 的中介軟體

// index.js
app.use(jwtKoa({ secret: config.secretKey }).unless({
    path: [/^\/api\/source/, /^\/api\/login/]
}))
複製程式碼

加上中介軟體後,除了 /api/source/api/login 介面就都需要經過 jwt 認證才能訪問了。

因此寫了一個 /api/login 介面,用於簽發令牌,拿到令牌之後,把令牌設定到請求頭裡就可以通過認證了:

// api/base.js
// 用於封裝 axios
// http request 攔截器
import axios from 'axios';
const config = require('../config');
const Instance = axios.create({
    baseURL: `http://localhost:${config.port}/api`,
    timeout: 3000,
    headers: {
        post: {
            'Content-Type': 'application/json',
        }
    }
});
Instance.interceptors.request.use(
    (config) => {
        // jwt 驗證
        const token = config.token;
        if (token) {
            config.headers['Authorization'] = `Bearer ${token}`
        }
        return config;
    },
    error => {
        return Promise.reject(error);
    }
);
複製程式碼

如果請求頭裡沒有正確的 token,則會返回 Authentication Error

至於問題 4,現在服務比較簡單,也只在一個機器上部署,手動登機器 npm install 問題還不大,如果機器很多,依賴項也複雜的話,很容易出問題,具體參見科普文:為什麼不能在伺服器上 npm install ?

於是決定基於 Docker 做構建部署。

FROM daocloud.io/node:8.4.0-onbuild
COPY package*.json ./
RUN npm install -g cnpm --registry=https://registry.npm.taobao.org
RUN cnpm install
RUN echo "Asia/Shanghai" > /etc/timezone
RUN dpkg-reconfigure -f noninteractive tzdata
COPY . .
EXPOSE 3001
CMD [ "npm", "start", "$value1", "$value2", "$value3"]
複製程式碼

用的比較簡單,主要就是負責安裝依賴,啟動服務。需要注意的主要有兩點:

  1. 國內拉去外網的映象很慢,像 Node 官方的映象我都拉了好久都沒拉下來,這樣的話推薦使用國內的映象,比如我用的 DaoCloud,還有阿里雲映象等等
  2. 由於推送服務是對時間敏感的,基礎映象的時區並不是國內時區,要手動設定一下

然後去 DaoCloud 等提供公有云服務的網站授權訪問 Github 倉庫,連線自己的主機,就可以實現持續整合,自動構建部署我們的映象了。具體步驟可參考基於 Docker 打造前端持續整合開發環境

daocloud

本次優化大概就到這裡了。接下來要做的可能是提供一個推送歷史檢視頁面,優先順序不是很高,有時間再做吧(順便練習一下 Nginx)。

現在的實現方案可能還是有很不合理的地方,歡迎提出建議。

相關文章