facebookのタイムラインへpythonから投稿する

自分自身やfacebookページのタイムラインへ、pythonから投稿してみよう。

残念なことにfacebookにはincoming webhookの仕組みがないため、投稿用のfacebookアプリを作らなければならない。

またfacebookを操作するにはアクセストークンが必要だが、facebookはOAuth認証が必須なため、CLIからアクセストークンを取得することは難しい（headlessなブラウザを使いレスポンスを解析すれば不可能ではないが）。

そこで今回は、localhost上にpythonでCGIサーバを起動し、OAuthのコールバックをCGIで処理してアクセストークンを取得することにする。

> facebook groupへも投稿できるようにしたかったが、そのためにはfacebook運営者によるfacebookアプリの承認が必要なようで、残念ながら今回は断念。

# 検証した環境
| |version|
|:--|:--|
|OS|Ubuntu 15.04 (3.19.0-26-generic)|
|Python|2.7.10|
|pip|7.1.2|
|requests|2.8.1|
|jinja2|2.8|
|facebook-sdk|0.4.0|
|facebook graph API|2.5|

# ソースコード

https://github.com/nmatsui/post_facebook_using_python

# facebookアプリの作成
まずfacebookアプリを作成する。

1 facebookの開発者ポータル https://developers.facebook.com/ から「Add A New App」を表示し、「ウェブサイト」を選択する

![fb_python_01.png](https://qiita-image-store.s3.amazonaws.com/0/37648/846334ec-6654-2022-3b0e-3bd8f3ae7455.png)

2 作成するfacebookアプリの名前（今回は **python_publisher** ）を入力して「Create New Facebook App ID」を押す

![fb_python_02.png](https://qiita-image-store.s3.amazonaws.com/0/37648/547db8f4-1964-9174-ed15-3e40cb43cba9.png)

3 カテゴリを選択して（今回は **ユーティリティ** ）「アプリIDを作成」を押す

![fb_python_03.png](https://qiita-image-store.s3.amazonaws.com/0/37648/39bbaf9c-6609-bd1e-4995-af922070edec.png)

4 OAuthでの認証コールバック用のURL（今回は **http://localhost:8000/cgi-bin/get_token** ）を入力して「Next」を押す

![fb_python_04.png](https://qiita-image-store.s3.amazonaws.com/0/37648/015c8970-bfc2-3359-a233-d2a0abcbaa6f.png)

5 facebookアプリの作成完了

![fb_python_05.png](https://qiita-image-store.s3.amazonaws.com/0/37648/be5fefcb-8c10-541e-dfb4-f9f291c4be06.png)

6 開発者ポータルの[トップページ](https://developers.facebook.com/)を再度表示し、「My Apps」から作成したアプリを選択する
> 作成したアプリが見当たらない場合、開発者ポータルをリロードする

![fb_python_06.png](https://qiita-image-store.s3.amazonaws.com/0/37648/2da2339f-c0fe-9dd7-2449-4509fa45fdfe.png)

7 App IDとApp Secretを確認する
> App Secretを確認する際に、パスワードの入力を求められる場合がある

![fb_python_07.png](https://qiita-image-store.s3.amazonaws.com/0/37648/ffaf3848-4094-5ffd-5bda-dfe22f17dd17.png)

# 設定ファイルの作成
作成したfacebookアプリの情報や、投稿するfacebookページの情報を記載した設定ファイルを作成する。CGIプログラムの都合上、設定ファイル名は **conf.json** で固定。

```json:conf.json
{
  "app": {
    "app_id": "facebookアプリのApp ID",
    "app_secret": "facebookアプリのApp Secret",
    "redirect_uri": "facebookアプリに設定したコールバックURL"
  },
  "page": {
    "name": "投稿するfacebookページの名前"
  }
}
```

# 環境準備
facebookアプリができたので、次はpython環境を準備する。

## ライブラリのインストール
conf.jsonと同じ場所にrequirements.txtを作成し、以下３つのライブラリをpipを用いてインストールする。

* requests
  * facebookのREST APIにアクセスする
* jinja2
  * HTMLを生成するためのテンプレートエンジン
* facebook-SDK
  * pythonからfacebookに投稿するためのFacebook Graph APIラッパー

```text:requirements.txt
requests
jinja2
facebook-sdk
```

```text:pipでインストール
$ pip install -r requirements.txt
```

## CGI用ディレクトリの作成
conf.jsonを配置したディレクトリに、CGIプログラムを置く **cgi-bin** ディレクトリとjinja2テンプレートを置く **templates** ディレクトリを作成する

```text:ディレクトリ作成
$ mkdir cgi-bin
$ mkdir templates
```

# CGIプログラムの作成
今回は次の二つのCGIを作成する

* index
  * facebookアプリのOAuth認証URLを生成してリンクを表示する
* get_token
  * facebookからコールバックされる
  * タイムラインへ投稿するためのアクセストークンを生成する

## アクセストークンに与える権限
今回は、以下3つの権限範囲を指定する。指定できる権限の詳細は[Permissions Reference - Facebook Login](https://developers.facebook.com/docs/facebook-login/permissions)を参照。

* **manage_pages**
  * facebookページを管理する権限
  * （facebookページのリストアップやaccess tokenの取得に用いる）
* **publish_pages**
  * facebookページに投稿する権限
* **publish_actions**
  * 投稿に関する権限
  * （facebookアプリが未承認の状態では、投稿できるのは自らのタイムラインのみ）

アクセストークンにはデフォルトで **public_profile** 権限（公開プロフィールを取得する権限）が与えられるため、今回取得するアクセストークンはこれら4つの権限範囲内での操作が許可される。

## index
facebookアプリのOAuth認証URLを生成し、ブラウザにそのリンクを表示する。

### OAuth認証URL生成CGI
**cgi-bin/index** は、conf.jsonを読み込み下記フォーマットのOAuth認証URLを生成する。

`https://www.facebook.com/dialog/oauth?redirect_uri=<facebookアプリに設定したコールバックURL&client_id=<facebookアプリのApp ID>&scope=<承認を与える権限>`

```py:cgi-bin/index
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import json
import urllib
from jinja2 import Environment, FileSystemLoader

CONF_FILE = 'conf.json'
BASE_URL = 'https://www.facebook.com/dialog/oauth'
SCOPE = 'manage_pages,publish_actions,publish_pages'
TPL_DIR = './templates'
TEMPLATE = 'index.tpl.html'


def create_url():
    with open(CONF_FILE, 'r') as f:
        conf = json.load(f)
    redirect_uri = urllib.quote_plus(conf['app']['redirect_uri'])
    url = BASE_URL + '?'
    url += 'redirect_uri=' + redirect_uri + '&'
    url += 'client_id=' + conf['app']['app_id'] + '&'
    url += 'scope=' + SCOPE
    return url


def main():
    params = {}
    try:
        url = create_url()
        params['isOK'] = True
        params['url'] = url
    except Exception as e:
        params['isOK'] = False
        params['error_type'] = type(e).__name__
        params['error_title'] = str(e)

    env = Environment(loader=FileSystemLoader(TPL_DIR, encoding='utf-8'))
    tpl = env.get_template(TEMPLATE)
    html = tpl.render(params)

    print('Content-type: text/html')
    print('\n')
    print(html.encode('utf-8'))

main()
```

コールバックURLはそのままだとURLパラメータとして渡せないので、`urllib.quote_plus()`でエスケープしている。

### OAuth認証URL表示テンプレート
**templates/index.tpl.html** は、生成したURLをリンクとして表示する。

```html+jinja:templates/index.tpl.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>index</title>
  </head>
  <body>
  {% if isOK %}
    <a href="{{ url }}">get token</a>
  {% else %}
    <b>次のエラーが発生しました</b><br/>
    [{{ error_type }}] {{ error_title }}
  {% endif %}
  </body>
</html>
```

## get_token
facebookのOAuth認証機能からコールバックされ、認証コードを取得する。認証コードを用いてfacebookのAPIから以下のアクセストークンを取得し、jsonファイルとして保存する。

* User Access Token
  * 自分自身（アプリ開発者）のアクセストークン
* Page Access Token
  * 設定ファイルで指定したfacebook pageのアクセストークン

### アクセストークン取得CGI
**cgi-bin/get_token** は、コールバックされた認証コードを用いてUser Access Tokenと指定されたfacebookページのPage Access Tokenを取得する。
取得したアクセストークンは、token.jsonに保存する。

```py:cgi-bin/get_token
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import cgi
import json
import re
import requests
from jinja2 import Environment, FileSystemLoader

CONF_FILE = 'conf.json'
TOKEN_FILE = 'token.json'
TOKEN_URL = 'https://graph.facebook.com/oauth/access_token'
ACCOUNT_URL = 'https://graph.facebook.com/me/accounts'
USER_ACCESS_TOKEN_PATTERN = r'access_token=([^&=]+)(&expires=\d+)?'
TPL_DIR = './templates'
TEMPLATE = 'get_token.tpl.html'


class TokenRetriever(object):

    def __init__(self, code):
        self.code = code
        with open(CONF_FILE, 'r') as f:
            self.conf = json.load(f)

    def get_token(self):
        user_access_token = self.__get_user_access_token()
        page_access_token = self.__get_page_access_token(user_access_token)
        token = {}
        token['user_access'] = user_access_token
        token['page_access'] = page_access_token
        token_json = json.dumps({'token': token}, indent=2, sort_keys=True)
        return token_json

    def __get_user_access_token(self):
        payload = {}
        payload['client_id'] = self.conf['app']['app_id']
        payload['client_secret'] = self.conf['app']['app_secret']
        payload['redirect_uri'] = self.conf['app']['redirect_uri']
        payload['code'] = self.code
        response = requests.get(TOKEN_URL, params=payload)
        m = re.match(USER_ACCESS_TOKEN_PATTERN, response.text)
        if m:
            return self.__exchange_token(m.group(1))
        else:
            raise LookupError('access_token does not exist')

    def __get_page_access_token(self, user_access_token):
        payload = {}
        payload['access_token'] = user_access_token
        response = requests.get(ACCOUNT_URL, params=payload)
        pages = filter(lambda p: p['name'] == self.conf['page']['name'],
                       json.loads(response.text)['data'])
        page_access_token = pages[0]['access_token']
        return self.__exchange_token(page_access_token)

    def __exchange_token(self, token):
        payload = {}
        payload['client_id'] = self.conf['app']['app_id']
        payload['client_secret'] = self.conf['app']['app_secret']
        payload['grant_type'] = 'fb_exchange_token'
        payload['fb_exchange_token'] = token
        response = requests.get(TOKEN_URL, params=payload)
        m = re.match(USER_ACCESS_TOKEN_PATTERN, response.text)
        if m:
            return m.group(1)
        else:
            raise LookupError('access_token does not exist')


def main():
    params = {}

    try:
        form = cgi.FieldStorage()
        if not form.has_key('code'):
            raise LookupError('QueryString "code" does not exist')

        token_retriever = TokenRetriever(form['code'].value)
        token_json = token_retriever.get_token()
        with open(TOKEN_FILE, 'w') as f:
            f.write(token_json)

        params['isOK'] = True
        params['token_file'] = TOKEN_FILE
        params['token_json'] = token_json
    except Exception as e:
        params['isOK'] = False
        params['error_type'] = type(e).__name__
        params['error_title'] = str(e)

    env = Environment(loader=FileSystemLoader(TPL_DIR, encoding='utf-8'))
    tpl = env.get_template(TEMPLATE)
    html = tpl.render(params)

    print('Content-type: text/html; charset=utf-8')
    print('\n')
    print(html.encode('utf-8'))

main()
```

facebookのREST APIを利用するために、`requests`ライブラリを利用している。なおfacebookのUser Access Tokenは、通常では取得後1〜2時間で有効期限が切れる。それではテストが面倒だったので、アクセストークン延長REST APIを用いて60日有効なトークンに交換している。

### アクセストークン表示テンプレート
**templates/get_token.tpl.html** は、取得したアクセストークンを表示する。

```html+jinja:templates/get_token.tpl.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>get_token</title>
  </head>
  <body>
  {% if isOK %}
    <b>User Access Token</b>と<b>Page Access Token</b>を取得しました。</br>
    トークンは次のJSONフォーマットで{{ token_file }}に書き出しました。
    <pre>{{ token_json }}</pre>
  {% else %}
    <b>次のエラーが発生しました。</b><br/>
    [{{ error_type }}] {{ error_title }}
  {% endif %}
  </body>
</html>
```

# CGIプログラムの動作とアクセストークン取得
では、CGIを動作させてアクセストークンを取得する。今回は検証なので、nginxやapache等のhttpdは用いずにpython2.7の`CGIHTTPServer`で代用する。

## CGIHTTPServer起動
conf.jsonやcgi-binディレクトリ、templatesディレクトリがあるディレクトリから、以下のコマンドでCGIHTTPServerを起動する。

```text:CGIHTTPServer起動
$ python -m CGIHTTPServer
```

## indexへアクセス
CGIHTTPServerを起動したPCのブラウザから、次のURLへアクセスする。

http://localhost:8000/cgi-bin/index

![fb_python_08.png](https://qiita-image-store.s3.amazonaws.com/0/37648/cd067ad9-3e74-7e52-322b-57610cf30b8a.png)

## OAuthで認証される
`get_token`をクリックするとfacebookアプリの認証が行われ、権限の承認を問われる。

1 ユーザに与える権限のチェック
承認する権限のチェックを行う。このfacebookアプリは未承認のため、グループへの書き込みなど権限の一部が無効化されている旨の警告が表示される。

![fb_python_09.png](https://qiita-image-store.s3.amazonaws.com/0/37648/e3177e1f-1942-0a9e-dc6c-ed8f705066cb.png)

2 投稿範囲のチェック
facebookアプリが自分に成り代わって投稿する公開範囲を指定する。

![fb_python_10.png](https://qiita-image-store.s3.amazonaws.com/0/37648/23d64f44-f489-0f7d-e98e-ef76c2baa578.png)

3 facebookページへの権限チェック
facebookページの管理権限と投稿権限のチェックを行う。facebookページへの投稿に関しては、未承認アプリでも警告は出ない。

![fb_python_11.png](https://qiita-image-store.s3.amazonaws.com/0/37648/e26ff6bb-17bf-0854-d159-1a406d6e63cd.png)

## アクセストークンの取得と表示
CGIがアクセストークンを取得して`token.json`に保存し、画面へも表示する。

![fb_python_12.png](https://qiita-image-store.s3.amazonaws.com/0/37648/25987a4d-b0ea-4f81-2069-e277344cb837.png)

```json:token.json
{
  "token": {
    "page_access": "取得したPage Access Token", 
    "user_access": "取得したUser Acdess Token"
  }
}
```

# facebookへの投稿
アクセストークンが取得できたので、pythonからfacebookへ投稿する。facebookのREST APIを直接操作しても良いが、今回は[Facebook SDK for Python](https://facebook-sdk.readthedocs.org)を使う。

## pythonスクリプト
SDKとアクセストークンを用いて自分自身とfacebookページのEndpointを取得し、メッセージを書き込む。自分のタイムラインとfacebookページのタイムラインでは、書き込みメソッドが異なることに注意。

```py:post.py
#!/usr/bin/env python
# -*- encode: utf-8 -*-

import sys
import json
import facebook


class Timeline:

    def __init__(self, token_file):
        with open(token_file, 'r') as f:
            token = json.load(f)['token']
            self.user_endpoint = facebook.GraphAPI(token['user_access'])
            self.page_endpoint = facebook.GraphAPI(token['page_access'])

    def post_me(self, msg):
        self.user_endpoint.put_object('me', 'feed', message=msg)
        print('posted to my timeline: %s' % msg)

    def post_page(self, msg):
        self.page_endpoint.put_wall_post(message=msg)
        print('posted to page timeline: %s' % msg)

if __name__ == '__main__':
    if len(sys.argv) != 4:
        print('usage: %s token_file [me|page] message' % sys.argv[0])
        exit(1)
    try:
        timeline = Timeline(sys.argv[1])
        if sys.argv[2] == 'me':
            timeline.post_me(sys.argv[3])
        elif sys.argv[2] == 'page':
            timeline.post_page(sys.argv[3])
        else:
            print '%s is invalid' % sys.argv[2]
    except (IOError, facebook.GraphAPIError) as e:
        print e
        exit(9)
```

## 自分のタイムラインへの投稿
自分のタイムラインへ投稿してみる。

```text:自分のタイムラインへの投稿
$ ./post.py token.json me "テスト投稿。これはpythonスクリプトからの投稿のテストです。"
posted to my timeline: テスト投稿。これはpythonスクリプトからの投稿のテストです。
```

![facebook_python_12.png](https://qiita-image-store.s3.amazonaws.com/0/37648/71a770e8-16ff-916d-1951-7fb9fadccac1.png)

## facebookページのタイムラインへの投稿
今回の実験用に作成したfacebookページのタイムラインへ投稿してみる。

```
$ ./post.py token.json page "テスト投稿。これはpythonスクリプトからの投稿のテストです。"
posted to page timeline: テスト投稿。これはpythonスクリプトからの投稿のテストです。
```

![fb_python_13.png](https://qiita-image-store.s3.amazonaws.com/0/37648/4d14204b-f7ab-23eb-b314-2b24ae0ea76c.png)

# 最後に
ただpythonからfacebookへ投稿したかっただけなのに、非常に長い道のりだったが、自分のタイムラインやfacebookページのタイムラインへ無事に投稿することができた。facebookもincoming webhookを準備してくれないかな？