Qiitadon の Streaming API を受信する時の注意点

> Mastodon のストリーミング API を使いたいが、Qiitadon で動かない。

## TL; DR

> ホストを `streaming.qiitadon.com`、ポートを `4000` に変更しましょう。

Qiita の [マストドン](https://www.google.co.jp/search?q=site:qiita.com+Mastodon%E3%81%A8%E3%81%AF&oq=Mastodon%E3%81%A8%E3%81%AF)・インスタンス「[Qiitadon](https://qiitadon.com)」は、Pawoo などと [Mastodon Streaming API](https://docs.joinmastodon.org/methods/timelines/streaming/) のエンドポイントが以下のように異なっています。

|項目|値|備考|
|:--:|:--:|:---|
|`ホスト名`|`streaming.qiitadon.com`|`qiitadon.com`は CDN を通すので、パススルー用にストリーミング専用のサブ・ドメインが用意されているようです。|
|`ポート`| `4000` |一般的には `443`|

### PHP のパッケージを作った

他のインスタンス（Mastodon サーバ）でも、もっと簡単に Mastodon Streaming API が使えるように[クラスにしてみた](https://github.com/KEINOS/Mastodon_StreamingAPI_Listener)のでよろしかったら使ってみて下さい。[Packagist にも公開している](https://packagist.org/packages/keinos/mastodon-streaming-api-listener)ので、`composer` でインストールできます。

- Javascript + Websocket なら[こちら](https://gist.github.com/woxtu/b8e200d7062979307588acc79be2101c)
  - [Qiitadon 新着 通知](https://gist.github.com/woxtu/b8e200d7062979307588acc79be2101c) | gist | @woxtu @ GitHub

動作の仕組み、詳細や **Pure PHP で自分で実装したい場合は TS; DR 参照**。

```bash:composerで
composer require keinos/mastodon-streaming-api-listener
```

```php:使い方
<?php

require_once __DIR__ . '/../vendor/autoload.php';

// サーバーの URL を指定するだけでポートなども自動で検知します。
$conf = [
    'url_host' => 'https://qiitadon.com/',
    'type_stream' => 'local', // LTL を指定。指定しない場合は 'public'（デフォルト）に設定されます。
];

$listener = new \KEINOS\MSTDN_TOOLS\Listener\Listener($conf);

foreach($listener as $event_name => $data_payload) {
    echo 'Event name: ' . $event_name . PHP_EOL;
    echo 'Data: '. PHP_EOL;
    print_r(json_decode($data_payload));
}
```

## TS; DR

この Qiitadon の Streaming API のアドレス `streaming.qiitadon.com` とポート `4000` は、ソースを覗いて見つけました。`view-source:https://qiitadon.com/web/timelines/public` の 26 行目です。

しかし、[Mastodon API の仕様](https://docs.joinmastodon.org/methods/)をよくよく読むと `instance` というエンドポイント（API のメソッドを実行する URL）がありました。その API を叩くとサラリと情報が JSON 形式で返ってきました。なんということでしょう。

### インスタンス（Mastodon サーバー）情報の API エンドポイント

```text:Qiitadonのinstanceエンドポイント（Mastodon情報取得URL）
https://qiitadon.com/api/v1/instance
```

> **instance**
> Informational endpoint to discover information about a Mastodon website.
> <sup>（ [instance](docs.joinmastodon.org/methods/instance/) | API Methods @ docs.joinmastodon.org より）</sup>
>
> （筆者訳）
> **instance**
>  Mastodon の Web サイトに関する情報を得るためのエンドポイント。

以下のリクエストすると、Qiitadon のインスタンス（サーバー）情報を取得できます。API を叩いて JSON から `urls` 要素を抜き出すと、ちゃんとポートが `4000` であることがわかります。[プロトコル](https://qiita.com/KEINOS/items/cb7f629268b90ef6493d)も `wss` であることがわかります。つまり SSL/TLS 通信の [WebSocket プロトコル](https://ja.wikipedia.org/wiki/WebSocket)ということです。

```shell-session
$ curl -s https://qiitadon.com/api/v1/instance | jq .urls
{
  "streaming_api": "wss://streaming.qiitadon.com:4000"
}
```

> <sup>🐒 &nbsp; `WebSocket` ライブラリ／パッケージを使って `HTTP/1.1 401 Unauthorized` のレスポンスが返ってくる場合は、アクセス・トークンを Qiitadon で発行して、リクエストのヘッダーに以下を加えてみてください。これはプロトコルを HTTP から WebSocket にアップグレードする際に、Mastodon はアクセス・トークンを必要とするからです。
>
> ```
> Authorization: Bearer [アクセストークン]
> ```

- 合わせて読みたい: [WebSocketについて調べてみた。](https://qiita.com/south37/items/6f92d4268fe676347160) @ Qiita
- 参考文献: [http://localhost:4000/ {"error":"Error: Missing access token"}](https://github.com/tootsuite/mastodon/issues/1016) | Issue #1016 | Mastodon @ GitHub

## PHP でストリーミングを受信するサンプル

### Pure PHP

[WebSocket のパッケージ](https://github.com/nicokaiser/php-websocket)を使ってもいいのですが、ここでは **Pure PHP + `fsockopen()` でソケットから生の情報を取得してみたい**と思います。`WebSocket` を使う場合はアクセス・トークンを必要とするからです。

以下は、簡単な動作を確認する PHP スクリプトです。実行後、数秒待つとストリームが始まります。

- Mastodon は SSL 接続が必須なので [PHP の OpenSSL が有効である必要があります](https://www.google.co.jp/search?q=site:qiita.com+PHP+OpenSSL+%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB&oq=PHP+OpenSSL+%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)）
- macOS の場合は何もインストールしなくても（デフォルトで）動きます。
    - もし Mac のファイアーウォールで跳ねられる場合は、[ターミナルに「フルディスクアクセス」の権限を与えてみて](https://qiita.com/KEINOS/items/0366f1c281b574a79cfb)ください。

```php:sample.php
<?php

const CRLF="\r\n";

// ソケットの準備
$hostname = 'ssl://streaming.qiitadon.com';
$port     = 4000;
$timeout  = 5; //sec
$errno    = '';
$errstr   = '';

// ソケットオープン
$fp = fsockopen($hostname, $port, $errno, $errstr, $timeout);

if (! $fp) {
    die("Error: {$errstr} ({$errno})");
}

// リクエストヘッダの準備
$method     = 'GET';
$endpoint   = '/api/v1/streaming/public';
$host       = 'qiitadon.com'; //streaming.qiitadon.com でない
$user_agent = 'qithub-bot';

$req = [
    "{$method} {$endpoint} HTTP/1.1",
    "Host: {$host}",
    "User-Agent: {$user_agent}",
];

$glue = CRLF;
$req  = implode($glue, $req) . CRLF . CRLF;

// GETリクエストを送信
fwrite($fp, $req);

// ストリーミングの読み込み
while (! feof($fp)) {
    $read = fgets($fp);
    print_r($read);
    // バッファを出力（画面更新）
    @ob_flush();
    @flush();
}

fclose($fp);
```

上記スクリプトの簡易説明ですが、基本的に以下を行っています。

1. Qiitadon と通信できるように `fsockopen` ソケットを作り、ファイルとして読み書きできるようにファイル・ポインタを取得する。
2. その際の接続先アドレスを `streaming.qiitadon.com`、ポートを 4000 番、通信プロトコルを `ssl` に設定。（`ssl://streaming.qiitadon.com`）
3. ソケットに GET メソッドでデータを書き込む。（`fwrite(<ソケットのファイル・ポインタ>,<データ>)`）
4. `EOF`（データの終端）が送られて来るまでソケットを読みこむ。（`fgets(<ソケットのファイル・ポインタ>)`）

### TIPS

#### LTL を受信する

FTL（連合タイムライン）でなく、LTL（ローカル・タイムライン）のストリーミングが欲しい場合はエンドポイントは以下になります。`public:local` つまり `/public` が `/public/local` になります。

- `/api/v1/streaming/public/local`
    - [streaming](https://docs.joinmastodon.org/methods/timelines/streaming/) | timelines | methods @ Mastodon documentation

#### 流れてくるデータ

WebSocket を使わない場合は、[Server-Sent Events](https://developer.mozilla.org/ja/docs/Web/API/Server-sent_events/Using_server-sent_events) として送信されてきます。

SSL ポートにソケットを接続・オープンして、エンドポイントに `GET` リクエストすると HTTP ヘッダー情報（[ハンドシェイク](https://ja.wikipedia.org/wiki/%E3%83%8F%E3%83%B3%E3%83%89%E3%82%B7%E3%82%A7%E3%82%A4%E3%82%AF)後のレスポンス・ヘッダー）が流れた後、「データサイズ」（`chunk`） → 「情報」（`event` or `data`）→ 空行 の順で流れてきます。

`chunk size` は「情報」の長さで、情報 + "`\n`" のバイト長になります。なお、「情報」のタイプが `data:` の場合、本体データを `payload` と呼びます。

```text:情報タイプが"event"の場合のフォーマット
[chunk size]
event: [name]

```
```text:イベントの例
e  ← 下記 "event: update\n" のバイト数
event: update

```
```text:情報タイプが"data"の場合のフォーマット
[chunk size]
data: [payload]

```
```text:データの例（payloadがチャンクでない／分割されてない場合）
588  ← 下記 "data: {"id":...}\n" のバイト数
data: {"id":...}

```

「情報」が長い場合は、分割されて送信されます。その場合も「データサイズ」→「情報」で流れてきます。受け手は分割されたデータを結合して使う必要があります。

```text:データの例（payloadがチャンク／分割されてる場合）
5a3  ← 下記行のバイト数
data: {"id":"10 ... ssing.png
9a   ← 下記行のバイト数
","followe ... "tags":[],"emojis":[]}


```

注意すべきは改行コードです。基本的に `CRLF`（`\r\n`）ですが、「情報」がチャンクでない（分割されていない）場合は `LF`（`\n`）になります。また、その時「情報」のタイプが `data` であった場合は、さらに `\n`（LF, `x0a`）が追加されます。

そのため、条件による**改行の組み合わせが複雑なので、受け取ったデータは `CRLF` を `LF` に統一すると楽**です。

このようにソケットの生情報をゴリゴリ解析して実装するのも楽しいのですが、やはり [WebSocket の PHP クライアントのライブラリ](https://github.com/search?q=WebSocket+PHP+client)を使う方が楽です。しかし、**WebSocket の場合は、アクセストークンが必須**なので、ライブラリを作ってみました。（注意：サーバが "whitelist-mode" になっている場合は、

- [Simple Mastodon Streaming API Listener](https://github.com/KEINOS/Mastodon_StreamingAPI_Listener) @ GitHub
- Composer install: `$ composer require keinos/mastodon-streaming-api-listener`

## 動作確認済み環境

- macOS HighSierra（OSX 10.13.6）
    - `php --version`: PHP 7.2.6 (cli) 
- macOS Mojave（OSX 10.14.6） + [ターミナルのフルディスクアクセス付き](https://qiita.com/KEINOS/items/0366f1c281b574a79cfb)
    - `php --version`: PHP 7.1.33 (cli) 

## 参考文献
- [Streamingサーバーに接続できない Issue #54](https://github.com/increments/mastodon/issues/54) @ increments @ GitHub
- [PHP で Mastodon の Streaming API を受信する。](https://qiita.com/yyano/items/841f79266faf2dc8b6dc) @ Qiita
- Qiitadon酒場 | PHP チャンネル @ Discord
- `view-source:https://qiitadon.com/web/timelines/public` のソースの26行目