React + Flask + Python + MongoDBで作るRSSリーダー

これまで2回に渡ってReactについて学んできた。

testpy.hatenablog.com testpy.hatenablog.com

僕は普段、Pythonを使って機械学習やデータ解析のコードを実装してるのだが、 Webアプリ化したいな、できればReactで実現できたらいいな、と思うことが度々あった。 そこでPythonistaのために、Reactを使った簡単なWebアプリ作成記事があればと、 今(2016/10/31現在)は亡きReactチュートリアル日本語版 をベースに、MongoDBに格納したRSSPythonで読み込み、Flask経由でクライアントに送り、 Reactで描画してみたので、コードを載せておく。 ただし説明はほとんどないので、バリバリのReact初心者の方は、上の記事でベースを固めてから読んでみて下さい。 ちなみに、Reactチュートリアルのソースはまだあります

見た目

こんな感じにする。

f:id:Shoto:20161030013827p:plain

ファイル構造

react-tutorial
├── gigazine_rss.py  // GIGAZINEのRSSをMongoDBに格納
├── node_modules
├── package.json
├── public
│   ├── css
│   │   └── base.css  // RSSを見やすいように加工
│   ├── index.html
│   └── scripts
│       └── example.js  // シンプルなDOMをReactで作成
└── server.py  // Flaskを用いてクライアントとサーバーを連携

データフロー

  1. index.htmlにアクセスする
  2. index.htmlがexample.jsを呼び出す
  3. example.jsがserver.jsを呼び出す
  4. server.jsがgigazine_rss.pyを呼び出す
  5. gigazine_rss.pyがGIGAZINE RSSを取得してMongoDBに格納する
  6. server.jsがgigazine_rss.py経由でMongoDBに格納したRSSを読み込む
  7. server.jsがexample.jsにRSSを渡す
  8. example.jsがRSSのDOMを作成する
  9. DOMがindex.htmlに描画される
index.html
  |
example.js
  |
server.py
  |
gigazine_rss.py <-> GIGAZINE RSS
  |
MongoDB

index.html

Reactはここではなく、exmaple.jsに記述する。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>React Tutorial</title>
    <!-- Not present in the tutorial. Just for basic styling. -->
    <link rel="stylesheet" href="css/base.css" />
    <script src="https://unpkg.com/react@15.3.0/dist/react.js"></script>
    <script src="https://unpkg.com/react-dom@15.3.0/dist/react-dom.js"></script>
    <script src="https://unpkg.com/babel-standalone@6.15.0/babel.min.js"></script>
    <script src="https://unpkg.com/jquery@3.1.0/dist/jquery.min.js"></script>
    <script src="https://unpkg.com/remarkable@1.7.1/dist/remarkable.min.js"></script>
  </head>
  <body>
    <div id="content"></div>
    <script type="text/babel" src="scripts/example.js"></script>
  </body>
</html>

example.js

チュートリアルでは、CommentBox、CommentList、Commetがあったが、 ここではCommentをRssに置き換えている。 またBoxとListで十分表現可能で、公式のDocs にも、そのように書けと書いてあるので、RssBoxとRssListのみとした。

var RssBox = React.createClass({
  loadRssFromServer: function() {
    $.ajax({
      url: this.props.url,
      dataType: 'json',
      cache: false,
      success: function(data) {
        this.setState({data: data});
      }.bind(this),
      error: function(xhr, status, err) {
        console.error(this.props.url, status, err.toString());
      }.bind(this)
    });
  },
  getInitialState: function() {
    return {data: []};
  },
  componentDidMount: function() {
    this.loadRssFromServer();
    setInterval(this.loadRssFromServer, this.props.pollInterval);
  },
  render: function() {
    return (
      <div className="rssBox">
        <h1 className="siteTitle">GIGAZINE RSS</h1>
        <RssList data={this.state.data} />
      </div>
    );
  }
});

var RssList = React.createClass({
  render: function() {
    var rssNodes = this.props.data.map(function (rss) {
      return (
        <div className="rss" key={rss.id}>
          <h3 className="title">
            <a href={rss.link}>
              {rss.title}
            </a>
          </h3>
          <p className="updated">{rss.updated}</p>
          <p className="summary">{rss.summary}</p>
        </div>
      );
    });
    return (
      <div className="rssList">
        {rssNodes}
      </div>
    );
  }
});

ReactDOM.render(
  <RssBox url="/api/rss" pollInterval={2000000} />,
  document.getElementById('content')
);

server.py

gigazine_rss.pyを呼び出して、GIAZINE RSSの保存と読み込みを行っている。

import json
import os
import time
from flask import Flask, Response, request

from gigazine_rss import Gigazine_RSS

app = Flask(__name__, static_url_path='', static_folder='public')
app.add_url_rule('/', 'root', lambda: app.send_static_file('index.html'))  # client and server side with MongoDB

@app.route('/api/rss', methods=['GET', 'POST'])
def comments_handler():
    Gigazine_RSS().save()
    rss = Gigazine_RSS().read()

    return Response(
        json.dumps(rss),
        mimetype='application/json',
        headers={
            'Cache-Control': 'no-cache',
            'Access-Control-Allow-Origin': '*'
        }
    )


if __name__ == '__main__':
    app.run(port=int(os.environ.get("PORT", 3000)), debug=True)

gigazine_rss.py

testpy.hatenablog.com

この記事を1ファイルで実行できるようにした。

# -*- coding: utf-8 -*-
import sys
import json
import nltk
import numpy
import feedparser
import urllib2
from bs4 import BeautifulSoup
import re
import pymongo

reload(sys)
sys.setdefaultencoding('utf-8')

DATABASE_NAME = 'gigazine'
COLLECTION_NAME = 'rss'

class Gigazine_RSS:
    def __init__(self):
        pass


    def save(self):
        rss = self.__get_rss()
        self.__save_rss(rss)

        return rss


    def __get_rss(self):
        rss_url = 'http://feed.rssad.jp/rss/gigazine/rss_2.0'
        articles = feedparser.parse(rss_url)

        rss = []
        for e in articles.entries:
            dic = {
                'id': e.id,
                'updated': e.updated,
                'title': e.title,
                'link': e.link,
                'summary': self.__getTextOnly(BeautifulSoup(e.summary))
            }
            rss.append(dic)

        return rss


    # Extract the text from an HTML page (no tags)
    def __getTextOnly(self, soup):
        v = soup.string  # Split by tags and check whether nested tags
        if v == None:  # If tags are nested
            c = soup.contents  # Eliminate outmost tags
            resulttext = ''
            for t in c:
                subtext = self.__getTextOnly(t)
                # If the subtext is null(u''), don't append it
                if len(subtext) > 0:
                    resulttext += subtext + '\n'
            return resulttext
        else:
            return v.strip()  # Eliminate '\n'


    def __save_rss(self, data):
        client = pymongo.MongoClient('localhost', 27017)
        db = client[DATABASE_NAME]
        co = db[COLLECTION_NAME]
        co.drop()

        co.insert(data)


    def read(self):
        client = pymongo.MongoClient('localhost', 27017)
        db = client[DATABASE_NAME]
        co = db[COLLECTION_NAME]

        data = [d for d in co.find()]

        rss = []
        for c in co.find():
            c.pop('_id', None)
            rss.append(c)

        return rss


if __name__ == '__main__':
    Gigazine_RSS().save()
    #Gigazine_RSS().read()

base.css

見やすいようにセンタリングやコントラストなどの調整をした。

body {
  background: #fff;
  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-size: 15px;
  line-height: 1.7;
  margin: 0;
  padding: 30px;
}

a {
  color: #4183c4;
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

code {
  background-color: #f8f8f8;
  border: 1px solid #ddd;
  border-radius: 3px;
  font-family: "Bitstream Vera Sans Mono", Consolas, Courier, monospace;
  font-size: 12px;
  margin: 0 2px;
  padding: 0 5px;
}

h1, h2, h3, h4 {
  font-weight: bold;
  margin: 0 0 15px;
  padding: 0;
}

h1 {
  font-size: 2.5em;
}

h2 {
  border-bottom: 1px solid #eee;
  font-size: 2em;
}

h3 {
  font-size: 1.5em;
}

h4 {
  font-size: 1.2em;
}

p, ul {
  margin: 15px 0;
}

ul {
  padding-left: 30px;
}

.rssBox {
  width: 600px;
  margin: auto;
}

.updated {
  color: #999;
}

.siteTitle {
  text-align: center;
  margin: 20px 0 40px;
}

.summary {
  margin: 0 0 40px;
}