Vue 2.0 起步(7) 大结局:公众号文章抓取 - 微信公众
上一篇:Vue 2.0 起步(6) 后台管理Flask-Admin - 微信公众号RSS
总算赶在2017年春节前把这个项目完结了!
第7篇新知识点不多,主要是综合应用Flask、Vue
本篇关键字:编程式导航
编程式路由
vue-router
Python爬虫
Flask
本篇完成功能:
- 上传订阅列表时,Python抓取公众号主页上的文章列表
- 点击右侧导航栏某一公众号,左侧显示它所包含的文章列表
- 点击顶部菜单(订阅文章),左侧显示所有公众号的文章列表,按更新时间排列
- 左侧显示某一公众号文章列表时,点击更新,可以检查是否有最新发表的公众号文章
演示网站:
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
来异步抓取:
/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
异步抓取了,而是同步等待爬虫结果。客户会看到动态的同步图标,直到更新完毕。
/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
部署时要注意:
- 数据库更新了,heroku run bash -> python manage.py clear_A -> python manage.py deploy
- heroku.com dashboard里,加上一个系统变量 WXUIN,转成文章永久链接用。如何计算,谷歌之
- 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>之前?