DEE2イベントページのJSON出力は非常にクセが強い

BMSイベント会場で最も大規模であるといってよいであろうDigital Emergency Exit 2(以下DEE2)のイベントシステムには、上のツイート群にあるように楽曲登録情報とイベントヘッドラインを取得できるJSON APIのようなものが存在している。大まかには以下の通り。

dictionary songlist{
  DOMString EventID;
  DOMString EventNAME;
  Song[] Data;
}

dictionary headline{
  DOMString EventID;
  DOMString EventNAME;
  Line[] Data;
}

dictionary Song{
  DOMString RegID;
  DOMString Team;
  ...
}

dictionary Song{
  DOMString Date;
  DOMString Num;
  ...
}

まあ実は今回この構造は比較的どうでもよくて、問題はこれを読み込むのが一筋縄ではいかなかったということ。

いつものようにPythonで読みたいのでいろいろ試してみるが、ツイートにあるBOFU2015のsonglistがまず読み込めない。

j=json.loads(
    requests.get('http://manbow.nothing.sh/event/event.cgi?action=JSONList&event=104&utf-8=on').content
)

ValueError: No JSON object could be decoded

これは辞書構造のエラーである。具体的には最後の部分がこうなっている。

{
  ...,
  "Data":[
    {...},
    {
      "RegID" : "456" , 
      ...,
      "UpdateTime" : "2015\/11\/04 00:44"
    }, ←これ
  ]
}

リスト末尾に余分なカンマがついているとjsonモジュールは読んでくれないらしい。1個くらい我慢してほしい。 原因ははっきりしてないけど、「エントリーIDが最大の作品が削除されている」という状況下で発生すると推測できる。BOFU2015ではnum=458まで発行されたようだが、現存する最大IDは456である。ちなみに全曲数は447。

content=requests.get('http://manbow.nothing.sh/event/event.cgi?action=JSONList&event=104&utf-8=on').content
j=json.loads(re.sub(r'}\s*,\s*]\s*}\s*\Z',r'}]}',content))

これで無事読み込めたわけだけどこれで終わるならこんな記事を書くわけがない。

§†гё∂㎡の情報を見る。

{
  "RegID": "86",
  "Team": "S.E.Q.uencer",
  "Artist": "EFB37-1",
  ...,
  "Title": "§†гё∂�u", ←これ
  ...
}

化けている。

これの原因は楽曲情報の生データ(CP932で持っているのだろう)をCP932ではなくShift_JISでデコード、UTF-8エンコードしたことによる。 ということで、正確なデータを得るためにはutf-8=onをクエリから外すべきである。

ここから泥沼の戦いが始まる。

content=requests.get('http://manbow.nothing.sh/event/event.cgi?action=JSONList&event=104').content
j=json.loads(re.sub(r'}\s*,\s*]\s*}\s*\Z',r'}]}',content).decode('cp932'))

ValueError: Invalid \escape: line 892 column 30 (char 36887)

892行目を見る。

890 ...,
891 "RegID" : "69" , 
892 "Team" : "第10回自称無名BMS作家が物申\す!反省会場はこちらです" , 
893 "Artist" : "Sena" , 
894 ...,

ダメ文字対策の'\'(0x5C)追記がしてある。伊達に2000年代序盤からイベントサイトやっていないんだなあと思う。いろいろな意味で。

contentを以下の感じに置換すればよい。

content=re.sub(
            '([\x81-\x9f\xe0-\xfc]'+r'\\)\\',
            r'\1',
            content
        )
j=json.loads(content.decode('cp932'))
len(j['Data'])
# == 447

読み込めた。§†гё∂㎡も、

for d in j['Data']:
    if ('RegID','86') in d.items():
        print json.dumps(d,ensure_ascii=False,indent=2)

{
  "RegID": "86",
  "Title": "§†гё∂㎡",
  ...
}

という感じに読めている。OK。

さて、ここまでやれば問題ないだろうと本当に思っていたんだけれど、headlineのほうが実は厄介。 例えば、BOFU2015のheadlineに以下のようなDataの要素がある。

{
  "Num" : "107" , 
  ...,
  "Title" : "ミルキーベリー・スウィートタイ�" , 
  ...
}

化けている、とは実は少し違う。どうやら2バイト文字を1バイト目で切ってしまっているらしい。

さらに性質が悪いことに、これをそのままdecode('cp932')すると、行末手前の'"'を巻き込んで\ufffdになり、 JSONとしての構造が破壊される。とりあえずの解決法として、'"'の手前に' 'を挿入し、 decode('cp932',errors='replace')してからそれを削除するという苦肉の策をとった。 見た目がきれいになるようにきちんと改行が入っていることに救われたと言う他ない。

content=requests.get('http://manbow.nothing.sh/event/event.cgi?action=JSONList&event=104&output=headline').content
content=re.sub(
            r'}\s*,\s*]\s*}\s*\Z',r'}]}',
            content
)
content=re.sub(
            '([\x81-\x9f\xe0-\xfc]'+r'\\)\\',
            r'\1',
            content
        )
content=re.sub(
            r'^(\s*\".*?\"\s*:\s*\".*)\"(\s*[,\]}]?\s*)$',
            r'\1 "\2',
            content,flags=re.MULTILINE
        )
content=content.decode('cp932',errors='replace')
content=re.sub(
            ur'^(\s*\".*?\"\s*:\s*\".*)[ \ufffd]\"(\s*[,\]}]?\s*)$',
            ur'\1"\2',
            content,flags=re.MULTILINE
        )
j=json.loads(content)
len(j['Data'])
# == 1000

これでheadlineも読めるようになった。控えめに言っても面倒すぎる。 あとこれはついでだけど、曲名などの中に文字参照が入ったままになっているため、json.loads前に先日作成したものを使って、

content=unescape_charref(content)

としてやれば、

{
  "RegID": "231",
  "Artist": "霧慧トァン",
  "Title": "<music:name=Goodbye My Soul/>",
  ...
}
=>
{
  "RegID": "231",
  "Artist": "霧慧トァン",
  "Title": "<music:name=Goodbye My Soul/>",
  ...
}

のように戻せる。

これでほとんどすべてのJSONをきちんと読めるようになった。けど、かなり重大な奴が隠れていた。

event=26のB-1 "Unrestricted"を見ると(字面からすでに想像できるかもしれないが)

{
  "EventID" : "26" , 
  "EventName" : "B-1 "Unrestricted"" , ←これ
  ...
}

まさかのダブルクォーテーションのエスケープ漏れ。他にもあるかもしれないので上と似たようにして対処する。

content=re.sub(
            ur'(^\s*\".*?\"\s*:\s*\")(.*\".*)(?=\"\s*[,\]}]?\s*$)',
            lambda s:s.group(1)+re.sub(
                ur'((^|[^\\])(?:\\\\)*)(?=\")',
                ur'\1'+ur'\\',
                s.group(2)
            ),
            content,flags=re.MULTILINE
        )

これはjson.loadsの直前に行えばよい。

また、event=105 BOFOON ULTIMATE 2015のheadlineに以下の内容を持つインプレがある。

{
  ...,
  "Info" : "このやっつけ感が正に (^^)"
  ...
}

'正に''(^^)'の間の空白はエスケープされていないタブ文字\tである。

タブ文字をタブ文字として文字列に格納するには、json.JSONDecoderを調整する必要がある。 今回は最も簡単に以下のようにした。

class DEE2JSONDecoder(json.JSONDecoder):
    def __init__(self,encoding=None,strict=False**kwgs):
        json.JSONDecoder.__init__(self,encoding,strict=False,**kwgs)

明示的にこのデコーダを使用するには、

j=json.loads(content,cls=DEE2JSONDecoder)

のようにすればよい。

以上を行った結果、http://manbow.nothing.sh/event/event.cgiからたどれるすべてのイベントについて無事に読み込むことができるようになった。もうやりたくない。

以上の操作を楽に行えるものを例によってgistに上げておくので、この辺に興味がある人は使ってほしい。

dee2.py · GitHub

正直なところ、中の人がsjis->cp932に書き換えてくれるだけでだいぶ楽になると思う。