Pythonでマインクラフトサーバーからステータスを取得して表示する

# はじめに
とある申請の条件として記事を1つ以上出す必要があったため、ステータスを取得するプログラムを書くことにしました。
# そもそもステータスって？
調べてみると[プロトコルを解説する記事](https://wiki.vg/Server_List_Ping)がありました。
## 各部位
![ステータス説明.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654072/03612914-558b-dde3-af70-3f06647713d6.png)
### アイコン
base64で返ってくるみたい
(今回のコードだと取得してない)
### プレイヤー数
0/20みたいなやつです。
オンライン数と最大数を取得できればよさそう
### MOTD
"Message of the day" の略らしいです。
まあサーバーの説明みたいな...?
### バージョン
画像には書かれてませんが、バージョンの名前とプロトコルバージョン(数字)が返ってくるみたいです。
# 実際にやってみる
## 必要なモジュール
```python
import socket #TCP通信とかをするやつ
import json #JSONをパースしてくれるやつ
import struct #バイナリを簡単にするやつ(?)
```
## VarInt
なにそれ聞いたこと無いんだけど()
ということで調べました。
検索でヒットした[Protocol Buffers: バイナリフォーマット(Wire Format)の中身](https://qiita.com/nozmiz/items/5f56b5e8e280c22121de#wire-type-0--varint)という記事に書かれていました。
例えば`25565`という数字は`1101 1101 1100 0111 0000 0001`になるそうです。
```python
def encode_varint(num):
    res = b""   
    while num:
        b = num & 127
        num = num >> 7
        if num != 0:
            b |= 128
        res += bytes([b])
    return res

def decode_varint(data):
    val = 0
    shift = 0
    for d in data:
        val |= (d & 127) << shift
        if not (d & 128):break
        shift += 7
    return val
```
## サーバーに接続する
今回は例として、n-mache.work:25565を対象としています。
接続先を変更する場合はhost変数の値を書き換えればいいです。
```python
host = "n-mache.work" #接続先のIPアドレスとか
port = 25565 #接続先のポートとか(基本的に25565)

s = socket.socket()
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
s.connect((host, port))
```
## handshake
今回は例としてプロトコルバージョンは1.20.4 (765)と指定しています。

1. 0x00 (Packet ID)
2. プロトコルバージョンのVarInt
3. ホスト名の長さのVarInt
3. ホスト名
4. ポートのVarInt
5. StateのVarInt (1を送ると「ステータス取得」、2を送ると「サーバー接続(ログイン)」になるっぽい)

の全てを1つにしたもの、送る前にバイト数も指定(例えば"test"なら"4test"みたいな感じ)
```python
protocol_version = 765

data = b"\x00"+encode_varint(protocol_version)+encode_varint(len(host.encode()))+host.encode()+struct.pack(">H", port)+encode_varint(1)
s.send(bytes([len(data)])+data)
```
## ステータスを取得する
```python
s.send(b"\x01\x00")

res = b""
while True:
    r = s.recv(1)
    if r == b"\x00":break
    res += r
size = decode_varint(res)
res = b""
while True:
    r = s.recv(1)
    res += r
    if not r[0] & 128:break
size = decode_varint(res)
status = json.loads(s.recv(size).decode())
```
これで取得は完了です。
## ステータスを表示する
これは例ですが、n-mache.work:25565の場合では
```json
{'version': {'name': 'Waterfall 1.8.x, 1.9.x, 1.10.x, 1.11.x, 1.12.x, 1.13.x, 1.14.x, 1.15.x, 1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x', 'protocol': 764}, 'players': {'max': 20, 'online': 0}, 'description': {'extra': [{'text': 'mache server'}], 'text': ''}, 'modinfo': {'type': 'FML', 'modList': []}}
```
というJSONが返却されます。
ここからversion, players, descriptionを取り出します。
```python
if "version" in status and "name" in status["version"]:
    print("バージョン: "+status["version"]["name"])
if "players" in status:
    print("プレイヤー数: "+str(status["players"].get("max","?"))+"人中"+str(status["players"].get("online","?"))+"人")
if "description" in status:
    motd = None
    if "text" in status["description"] and len(status["description"]["text"]) > 0:
        motd = status["description"]["text"]
    if motd == None and "extra" in status["description"]:
        motd = ""
        for extra in status["description"]["extra"]:
            if not "text" in extra:continue
            motd += extra["text"]
    if motd != None:
        print("MOTD: "+motd)
```
実行すると、こんな感じのが出力されます。
```
バージョン: Waterfall 1.8.x, 1.9.x, 1.10.x, 1.11.x, 1.12.x, 1.13.x, 1.14.x, 1.15.x, 1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x
プレイヤー数: 20人中0人
MOTD: mache server
```
# 全体のコード 
```python
import socket
import json
import struct

def encode_varint(num):
    res = b""
    while num:
        b = num & 127
        num = num >> 7
        if num != 0:
            b |= 128
        res += bytes([b])
    return res

def decode_varint(data):
    val = 0
    shift = 0
    for d in data:
        val |= (d & 127) << shift
        if not (d & 128):break
        shift += 7
    return val

host = "n-mache.work"
port = 25565

s = socket.socket()
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
s.connect((host, port))

protocol_version = 765

data = b"\x00"+encode_varint(protocol_version)+encode_varint(len(host.encode()))+host.encode()+struct.pack(">H", port)+encode_varint(1)
s.send(bytes([len(data)])+data)

s.send(b"\x01\x00")

res = b""
while True:
    r = s.recv(1)
    if r == b"\x00":break
    res += r
size = decode_varint(res)
res = b""
while True:
    r = s.recv(1)
    res += r
    if not r[0] & 128:break
size = decode_varint(res)
status = json.loads(s.recv(size).decode())

if "version" in status and "name" in status["version"]:
    print("バージョン: "+status["version"]["name"])
if "players" in status:
    print("プレイヤー数: "+str(status["players"].get("max","?"))+"人中"+str(status["players"].get("online","?"))+"人")
if "description" in status:
    motd = None
    if "text" in status["description"] and len(status["description"]["text"]) > 0:
        motd = status["description"]["text"]
    if motd == None and "extra" in status["description"]:
        motd = ""
        for extra in status["description"]["extra"]:
            if not "text" in extra:continue
            motd += extra["text"]
    if motd != None:
        print("MOTD: "+motd)
```
# 参考
- [Python ビット演算 超入門 - Qiita](https://qiita.com/7shi/items/41d262ca11ea16d85abc)
- [Protocol Buffers: バイナリフォーマット(Wire Format)の中身 - Qiita](https://qiita.com/nozmiz/items/5f56b5e8e280c22121de)
- [Server List Ping - wiki.vg](https://wiki.vg/Server_List_Ping)
- [Protocol version numbers - wiki.vg](https://wiki.vg/Protocol_version_numbers)
- [バイト列をパックされたバイナリデータとして解釈する - Pythonドキュメント](https://docs.python.org/ja/3/library/struct.html)