Vue 2.0 起步(7) 大結局:公眾號文章抓取 - 微信公眾號RSS

上一篇:Vue 2.0 起步(6) 后臺管理Flask-Admin - 微信公眾號RSS
總算趕在2017年春節前把這個項目完結了!
第7篇新知識點不多,主要是綜合應用Flask、Vue

本篇關鍵字編程式導航 編程式路由 vue-router Python爬蟲 Flask

本篇完成功能:

  1. 上傳訂閱列表時,Python抓取公眾號主頁上的文章列表
  2. 點擊右側導航欄某一公眾號,左側顯示它所包含的文章列表
  3. 點擊頂部菜單(訂閱文章),左側顯示所有公眾號的文章列表,按更新時間排列
  4. 左側顯示某一公眾號文章列表時,點擊更新,可以檢查是否有最新發表的公眾號文章

演示網站:
DEMO: http://vue2.heroku.com

注:bootstrap v4 alpha6更新了,界面還沒來及重新匹配,見諒!

最終完成圖:

單個公眾號文章列表.png
所有文章.png

下面依次介紹注意的知識點:

在此之前,先依照最新的models,更新數據庫結構。Article模型有更新

/app/models.py

# cd C:\git\vue-tutorial
# python manage.py db migrate -m "Article"
# python manage.py db upgrade

1. 上傳訂閱列表時,Python抓取公眾號主頁上的文章列表

我們在 Vue 2.0 起步(5) 訂閱列表上傳和下載 - 微信公眾號RSS 中,上傳訂閱列表時,服務器端是在mps.py里處理。我們添加:Flask異步調用函數fetchArticle(mp, 'async'),來爬蟲抓取公眾號主頁上的文章

/app/api_1_0/mps.py

@api.route('/mps', methods=['POST'])
@auth_token_required
def new_mps():
    email = request.get_json()['email']
    user = User.query.filter_by(email=email).first()
    Mps = Mp.from_json(request.json)
    subscribed_mps_weixinhao = [i.weixinhao for i in user.subscribed_mps]
    rsp = []
    for mp in Mps:
        mp_sql = Mp.query.filter_by(weixinhao=mp.weixinhao).first()
    # 如果不存在這個訂閱號,則添加到Mp,并訂閱
        if mp_sql is None:
                db.session.add(mp)
                user.subscribe(mp)
                rsp.append(mp.to_json())
                db.session.commit()
    # aync update Articles
                mp_sql = Mp.query.filter_by(weixinhao=mp.weixinhao).first() # 此mp跟初始的 mp已經是不同對像
                [ok, return_str] = fetchArticle(mp_sql, 'async')

    # 如果用戶沒有訂閱,則訂閱
        elif not mp.weixinhao in subscribed_mps_weixinhao:
                user.subscribe(mp_sql)
                rsp.append(mp.to_json())
                db.session.commit()
    # aync update Articles
                [ok, return_str] = fetchArticle(mp, 'async')

    return jsonify(rsp), 201, \
        {'Location': url_for('api.get_mps', id=mp.id, _external=True)}

這個爬蟲函數使用from threading import Thread來異步抓?。?/p>

/app/api_1_0/fetchArticles.py

注意:sogou.com搜索不能太頻繁,不然會要求輸入驗證碼。
看到服務器上有這個提示,要么等會再來,要么手動輸入驗證碼來立即解除限制

2. 點擊右側導航欄某一公眾號,左側顯示它所包含的文章列表

這里需要在vue-router里,添加新的路由,以顯示公眾號文章列表。
注意:新的路由path: '/article/:id'是動態的,可以匹配任意公眾號文章的視圖,比如/article/weixinhao1, /article/weixinhao2...
另外,取了個別名:name: 'article',在編程式路由跳轉時會用到

/src/main.js

import Articles from './components/Articles'
import Article from './components/Article'


const routes = [{
    path: '/',
    component: Home
},{
    path: '/articles',
    component: Articles
},{
    path: '/article/:id',
    name: 'article',
    component: Article
},{
    path: '/home',
    component: Home
},{
    path: '/search',
    component: Search
}]

我們在導航欄的每個公眾號上,添加@click="fetchArticles(mp.weixinhao, mp.mpName)",來觸發獲取文章的ajax請求。傳給服務器的參數是:weixinhao、headers:token。獲取到的數據,存入LocalStorage中。
注意:return this.$router.push({ name: 'article', params: { id: weixinhao, mpName: mpName }}),這是編程式路由跳轉,觀察瀏覽器的地址欄是不是變化了?而且帶入了我們想要的參數,供Article.vue使用

/src/components/Sidebar.vue

     methods: {
            fetchArticles(weixinhao, mpName){
//              return this.$router.push({ name: 'article', params: { id: weixinhao }})
            this.isFetching = true;
            this.$nextTick(function () { });
            this.$http.get('/api/v1.0/articles', {
                    params: {
                        email: this.username,
                            weixinhao: weixinhao
                    },
                    headers: {
                        'Content-Type': 'application/json; charset=UTF-8',
                        'Authentication-Token': this.token
                    }
                }).then((response) => {
                    // 響應成功回調
                this.isFetching = false;
                this.$nextTick(function () { });
                   var data = response.body, article_data;
                    if (!data.status == 'ok') {
 //                   return alert('文章 from server:\n' + JSON.stringify(data));
                return alert('獲取失敗,請重新上傳訂閱列表!\n' +data.status)
                    }
            article_data = {
                'mpName': mpName,
                'weixinhao': weixinhao,
                'articles': data.articles,
                'sync_time': data.sync_time
                }
            window.localStorage.setItem('weixinhao_'+weixinhao, JSON.stringify(article_data));
            // 必須要命名route name,否則,地址會不停地往后加 /article/XXX, /article/article/XXX
            return this.$router.push({ name: 'article', params: { id: weixinhao, mpName: mpName }})
                }, (response) => {
                    // 響應錯誤回調
                    alert('同步出錯了! ' + JSON.stringify(response))
                    if (response.status == 401) {
                        alert('登錄超時,請重新登錄');
                        this.is_login = false;
                        this.password = '';
                        window.localStorage.removeItem("user")
                    }
                });
            },

當然,服務器端需要對這個Ajax請求作出響應。檢查這個公眾號,如果不存在,則需要重新上傳訂閱列表。如果存在,則查詢服務器端對應的文章集合,和上次同步文章列表的時間。這個動作,不會重新去sogou.com抓取新的文章

/app/api_1_0/mps.py


@api.route('/articles')
@auth_token_required
def get_articles():
    # request.args.items().__str__()
#   time.sleep(3)
    weixinhao = request.args.get('weixinhao')
    print 'fetch articles of ', weixinhao
    mp = Mp.query.filter_by(weixinhao=weixinhao).first()
    if mp is not None:
        if request.args.get('action') == 'sync':
            print '================sync'
            if datetime.utcnow() - mp.sync_time > timedelta(seconds=60*5):
                [ok, return_str] = fetchArticle(mp, 'sync')
                print ok, return_str
                # 需要重新獲取mp對象,
                #DetachedInstanceError: Instance <Mp at 0x5d769b0> is not bound to a Session; attribute refresh operation cannot proceed
                mp = Mp.query.filter_by(weixinhao=weixinhao).first()
            else: 
                print '========== less than 5 mins, not to sync'
#           return jsonify(return_str)
        articles = Article.query.filter(Article.mp_id == mp.id)
        articles_list = [ a.to_json() for a in articles ]
        rsp = {
            'status': 'ok',
            'articles': articles_list,
            'sync_time': time.mktime(mp.sync_time.timetuple()) + 3600*8 # GMT+8 #建議用 time.time()代替!
        }
    #   print articles_list
        return jsonify(rsp)
    else:
        rsp = {
            'status': 'mp not found!'
        }
        return jsonify(rsp)

好了,數據取回來了,路由也跳轉了,顯示公眾號文章吧。
articleList用計算屬性,讀取LocalStorage中的值。
TODO: use vuex, 從store中取出數據

/src/components/Article.vue

       computed : {
            articleList() {
                // TODO: use vuex, 從store中取出數據
                var data = JSON.parse(window.localStorage.getItem('weixinhao_'+this.$route.params.id));
            if (data == null) return {'mpName':'', 'articles': [] };
                else {
                        return data;
                    }
            }

3. 點擊頂部菜單(訂閱文章),左側顯示所有公眾號的文章列表,按更新時間排列

如果想查看所有的公眾號的文章列表,則先在頂部菜單條上添加路由

/src/App.vue

          <li class="nav-item">
            <router-link to="/articles" class="nav-link"><i class="fa fa-flag"></i>訂閱文章</router-link>
          </li>

這個是總體顯示,邏輯比較簡單,也是用計算屬性讀取所有文章,再按發表時間,排一下序就行

/src/components/Articles.vue

computed : {
            articleList() {
                // TODO: use vuex, 從store中取出數據
                var storage = window.localStorage, data=[], mpName, articles;
             for(var i=0;i<storage.length;i++){
              //key(i)獲得相應的鍵,再用getItem()方法獲得對應的值
              if (storage.key(i).substr(0,10) == 'weixinhao_') {
                  mpName = JSON.parse(storage.getItem(storage.key(i))).mpName;
                  articles = JSON.parse(storage.getItem(storage.key(i))).articles
                for (let item of articles) {
                    item['mpName'] = mpName
                    data.push(item)
                }
                }
            }
            // 對所有文章按更新日期排序
            data.sort(function(a,b){
                    return b.timestamp-a.timestamp});
                    return data;
            }

4. 左側顯示某一公眾號文章列表時,點擊更新,可以檢查是否有最新發表的公眾號文章

大家注意到,我們的公眾號文章,第一次是在上傳訂閱列表時更新的。后續再次更新的話,可以由用戶來觸發。
我們帶入action: 'sync'參數,通知服務器,同步更新就行,不需要異步,本地LocalStorage里,已經有歷史數據。

/src/components/Articles.vue

     methods:{
    updateArticle(weixinhao, mpName) {
            this.isFetching = true;
            this.$nextTick(function () { });
            this.$http.get('/api/v1.0/articles', {
                params: {
                            weixinhao: weixinhao,
                            action: 'sync'
                    },
                    headers: {
                        'Content-Type': 'application/json; charset=UTF-8',
                        'Authentication-Token': this.token
                    }
                }).then((response) => {
                    // 響應成功回調
                this.isFetching = false;
                   var data = response.body, article_data;
//                   alert('文章 from server:\n' + JSON.stringify(data));
                    if (! data.status == 'ok') {
                return alert('獲取失敗,請重新上傳訂閱列表!\n' +data.status)
                    }
            article_data = {
                'mpName': mpName,
                'weixinhao': weixinhao,
                'articles': data.articles,
                'sync_time': data.sync_time
                }
            window.localStorage.setItem('weixinhao_'+weixinhao, JSON.stringify(article_data));
            // TODO: 這里可能用 vuex更好一點
                }, (response) => {
                    // 響應錯誤回調
                    alert('同步出錯了! ' + JSON.stringify(response))
                    if (response.status == 401) {
                        alert('登錄超時,請重新登錄');
                        this.is_login = false;
                        this.password = '';
                        window.localStorage.removeItem("user")
                    }
                });
           },      

服務器端,檢查上次這個公眾號更新時間,少于5分鐘,則不更新,以免太頻繁,給sogou.com封掉

/app/api_1_0/mps.py

       if request.args.get('action') == 'sync':
            print '================sync'
            if datetime.utcnow() - mp.sync_time > timedelta(seconds=60*5):
                [ok, return_str] = fetchArticle(mp, 'sync')
                print ok, return_str

爬蟲函數,看到參數是sync的話,就不再使用Thead異步抓取了,而是同步等待爬蟲結果??蛻魰吹絼討B的同步圖標,直到更新完畢。

/app/api_1_0/fetchArticles.py

        if sync == 'async':
            thr = Thread(target=article_search, args=[app, db, mp.weixinhao])
            thr.start()
            return ['ok', return_str]
        else:
            article_search(app, db, mp.weixinhao)
            return ['ok', u'同步完成!']

好了,總算從頭到尾,完整地做完一個項目了!
其實,這只是一個框架,把前端、后端、數據爬取等等,都跑通了一遍而已!如果是真正的項目,需要完善的東東很多!

大家踴躍評論哦!評論滿100上源碼 ;-)
(笑話而已,已經上傳了,自己找找哦)

DEMO: http://vue2.heroku.com

部署時要注意:

  1. 數據庫更新了,heroku run bash -> python manage.py clear_A -> python manage.py deploy
  2. heroku.com dashboard里,加上一個系統變量 WXUIN,轉成文章永久鏈接用。如何計算,谷歌之
  3. requirement.txt里,加上lxml==3.6.4,給BeatuifulSoup用

TODO:

  • 用移動端UI來改寫UI,適應手機訪問
  • 同級組件的數據共享,比如articleList,最好統統用vuex來訪問,LocalStorage有時會有不同步刷新的小bug
  • 添加功能:刪除已訂閱的公眾號
  • 后臺管理Flask-Admin,普通用戶也可以查看部分內容(現在只有admin有權限)
  • Bootstrap v4 alpha6發布了,UI有些改變,需要更新 main.js,重新cnpm i
  • Articles頁面,數據多了要分頁
  • Copyright div,build之后不顯示?需要手動放在 </body>之前?

祝大家在新的一年:紅紅火火!工作進步!學有所長!

http://www.lxweimin.com/p/ab778fde3b99

https://github.com/kevinqqnj/vue-tutorial
請使用新的template: https://github.com/kevinqqnj/flask-template-advanced

2019延伸閱讀推薦:帶你進入異步Django+Vue的世界 - Didi打車實戰(1)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容