Pythonのjson.loads()でundefinedがパース(デコード)出来なかったので魔改造

状況(というか概要)の説明

Python3 でスクレイピングを行っており、htmlの中に<script>タグを使って書かれた代入処理のJSON文字列を抜き出し、それをPython内でパースする という処理を行っていた。 しかしどうやら、JSON文字列内のvalue(値)として undefined が含まれていると例外が発生する、というもの。

多分仕様なんだろうとは思うんですけど。

あと、今回やろうとしている事は JSON文字列を Python上のdictに変換する デコード ですが、この記事中では便宜上 パース と表現させてください (強いて言えばJavaScript畑の人間なもので..)

先に結論

黒魔術に手を出すほかなかった。
或いは、イケてないけど undefined を無理矢理 raplace() するのも視野に。

状況をもう少し詳しく

例えばこんなページが https://example.com/index.html にあったとして

<!doctype html>
<html>
    <head></head>
    <body>
    </body>
    <script>
        window.Hoge={"aaa":undefined}
    </script>
</html>

ここの {"aaa":undefined} だけJSON文字列風に抜き取って Python上でdictとして扱いたかった訳なんです。

で、これを抜き出す処理をPython3と幾つかのモジュールを用いて書くと、こんな感じになります

import json
import requests
from bs4 import BeautifulSoup

res = requests.get("https://example.com/index.html")
soup = BeautifulSoup(res.text , "html.parser")
target_element = _soup.select_one("script")
temp_text = target_element.text.replace("window.Hoge=" , "" , 1) # ここめっちゃ無理矢理
json_dict = json.loads(temp_text)

print(json_dict)

この処理を通そうとすると、json.loads() の実行時に、例外 json.decoder.JSONDecodeError が発生します

パース出来ていない模様

上のソースだと回りくどいので要所だけ抜き出します。

↓ 結局やりたいことはこういう事なのですが、これで例外が出ると。

json_dict = json.loads('{"aaa":undefined}')

ですが、以下のようなものであればパースが通ります

json_dict = json.loads('{"aaa":1234}')
json_dict = json.loads('{"aaa":"undefined"}')
json_dict = json.loads('{"aaa":false}')
json_dict = json.loads('{"aaa":null}')
json_dict = json.loads('{"aaa":NaN}')

お気づきでしょうか… undefined だけ、文字列扱いでないとエラーを起こす事に 😱

自前で用意したJSON(の文字列)をパース…等であれば正直どうとでもなるのですが、外部サイトが対象となるとそうもいきません。しかもまぁ 本来<script>タグ内で代入させている処理から無理矢理JSONを引っ張り出そうとしている訳なので、余計に(+_+)

仕様上はどうなのか

「は~~面倒くせえ」 と思いながらも、ちょっとググったりして掘り下げてみました

python.org > Python3 > json > json.loads()

公式ドキュメント(の日本語版)をざっと見るに、変換表に基づいて JSON↔PythonのDict に変換しているようです。

※ ページから抜粋

JSON Python
object dict
array list
string str
number (int) int
number (real) 浮動小数点数
true True
false False
null None

この表に加え、Infinity , -Infinity , NaN といったものも、なんか RFCうんたら みたいなのとかで言及されたりしてたんですが(面倒でちゃんと読んでないし理解できない) ざっと眺めた限り、まぁ上手い事色々やってくれるみたいです。

が、undefined は対象外の模様….
このドキュメントページ上を undefined で検索かけましたが、1件もヒットしませんでした。もしかしてオプションのようなものが用意されているどころか、触れられてすらいない!?

JSONDecoder() ってのがあるけど

ドキュメントをもう少し眺めていると、JSONDecoder() というのを見かけました。
Python データ構造に対する拡張可能な JSON エンコーダ なのだとか。

python.org > Python3 > json > json.JSONDecoder()

ここを色々弄ってundefinedの受け皿を作れるかな?と思ったけど、これを用いるだけでは不十分でした。

元の関数改造しなきゃ多分ダメ?

Python の組み込み json モジュールの中に scanner.py というファイルがあります。 ちょろっと中をのぞくと def py_make_scanner(context) という処理があり、そこには以下のようなパース処理っぽいものがありました

....
(略)
elif nextchar == '[':
    return parse_array((string, idx + 1), _scan_once)
elif nextchar == 'n' and string[idx:idx + 4] == 'null':
    return None, idx + 4
elif nextchar == 't' and string[idx:idx + 4] == 'true':
    return True, idx + 4
elif nextchar == 'f' and string[idx:idx + 5] == 'false':
    return False, idx + 5

m = match_number(string, idx)
if m is not None:
    integer, frac, exp = m.groups()
    if frac or exp:
        res = parse_float(integer + (frac or '') + (exp or ''))
    else:
        res = parse_int(integer)
    return res, m.end()
elif nextchar == 'N' and string[idx:idx + 3] == 'NaN':
    return parse_constant('NaN'), idx + 3
elif nextchar == 'I' and string[idx:idx + 8] == 'Infinity':
    return parse_constant('Infinity'), idx + 8
elif nextchar == '-' and string[idx:idx + 9] == '-Infinity':
    return parse_constant('-Infinity'), idx + 9
else:
    raise StopIteration(idx)
(略)
....

結論から言うと、このあたりに関係する一連の処理を後付けで上書きさせつつ、それを用いる事で無理矢理対応させることに成功しています

黒魔術

正直、継承ですとかそういった物を使わずに、元の処理を上書きするのは禁忌魔法みたいなものだと思うのですが、使いどころを限定しているのもあって「オリジナルのパーサを適切(←?)に弄る方がまだマシ」という考えに落ち着きました。

こちらの方法を参考にしています
Qiita - Pythonでライブラリの処理を上書きする

import re

# 黒魔術ここから
NUMBER_RE = re.compile(
    r'(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?',
    (re.VERBOSE | re.MULTILINE | re.DOTALL))

def py_make_scanner(context):
    parse_object = context.parse_object
    parse_array = context.parse_array
    parse_string = context.parse_string
    match_number = NUMBER_RE.match
    strict = context.strict
    parse_float = context.parse_float
    parse_int = context.parse_int
    parse_constant = context.parse_constant
    object_hook = context.object_hook
    object_pairs_hook = context.object_pairs_hook
    memo = context.memo

    def _scan_once(string, idx):
        try:
            nextchar = string[idx]
        except IndexError:
            raise StopIteration(idx) from None

        if nextchar == '"':
            return parse_string(string, idx + 1, strict)
        elif nextchar == '{':
            return parse_object((string, idx + 1), strict,
                _scan_once, object_hook, object_pairs_hook, memo)
        elif nextchar == '[':
            return parse_array((string, idx + 1), _scan_once)
        elif nextchar == 'n' and string[idx:idx + 4] == 'null':
            return None, idx + 4
        elif nextchar == 't' and string[idx:idx + 4] == 'true':
            return True, idx + 4
        elif nextchar == 'f' and string[idx:idx + 5] == 'false':
            return False, idx + 5
        # ▼ ここの2行を追加している ▼
        elif nextchar == 'u' and string[idx:idx + 9] == 'undefined':
            return None, idx + 9

        m = match_number(string, idx)
        if m is not None:
            integer, frac, exp = m.groups()
            if frac or exp:
                res = parse_float(integer + (frac or '') + (exp or ''))
            else:
                res = parse_int(integer)
            return res, m.end()
        elif nextchar == 'N' and string[idx:idx + 3] == 'NaN':
            return parse_constant('NaN'), idx + 3
        elif nextchar == 'I' and string[idx:idx + 8] == 'Infinity':
            return parse_constant('Infinity'), idx + 8
        elif nextchar == '-' and string[idx:idx + 9] == '-Infinity':
            return parse_constant('-Infinity'), idx + 9
        else:
            raise StopIteration(idx)

    def scan_once(string, idx):
        try:
            return _scan_once(string, idx)
        finally:
            memo.clear()

    return scan_once

# 対象を指定して上書き
json.scanner.make_scanner = py_make_scanner

# 黒魔術ここまで

この上記処理を用いて、オリジナルの処理を上書きすれば、下ごしらえが出来ます。

上書き処理の解説

上書きを行うのは、オリジナルの処理 py_make_scanner() 内に

elif nextchar == 'u' and string[idx:idx + 9] == 'undefined':
            return None, idx + 9

の条件処理を単純に加えたかったからです。
これにより undefinedNone にする為の受け皿処理が用意されます。

import re

は、後続の処理 py_make_scanner() 内部で使われている

NUMBER_RE = re.compile(
    r'(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?',
    (re.VERBOSE | re.MULTILINE | re.DOTALL))

を定義するのに必要です。

json.scanner.make_scanner = py_make_scanner

の部分では、上書き先の関数名が違いますが、まぁ内部処理的にこれ (make_scanner) が呼び出されるので、こちらを名指しで上書きしています。

ファイル位置

VSCodeでPython書いているときに、F12 辺りで定義開けるのでそこからたどるのが早そうですが、ファイル的には一応
C:\Users\[ユーザー名]\AppData\Local\Programs\Python\Python37\Lib\json
の中に当該処理が含まれていました。 Pythonのバージョンによってフォルダ位置はちょっとだけ異なりそうです。

( 流石にファイルの直接編集はしたくないですね。精々処理内での関数上書きが関の山かなと )

使い方

先程少し触れた JSONDecoder() を用います
python.org > Python3 > json > json.JSONDecoder()

json_dict = json.loads('{"aaa":undefined}')
# ↓ こっちで変換させる
json_dict = json.JSONDecoder().decode('{"aaa":undefined}')

このような方法を用いる事で、文字列でない value undefinedNone に変換させたオブジェクト..もとい dict へとパース出来ました。

余談

追加した2行分の処理を見てもらうと分かるのですが、undefined として検出した個所を
None として決め打ちで返しています。

elif nextchar == 'u' and string[idx:idx + 9] == 'undefined':
    return None, idx + 9

False が良ければ代わりにそれを返しても良いですし、変な話 ここを 123 とかいう値を返すように仕向ければ、 undefined の value が全てint123 で格納されるようになります。

全体図

スクレイピング処理は置いときまして、先の黒魔術含めた似非JSONパース処理全体を書いておきます

要点をまとめると

という感じです

import re
import json

# ----- 黒魔術ここから -----
NUMBER_RE = re.compile(
    r'(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?',
    (re.VERBOSE | re.MULTILINE | re.DOTALL))

def py_make_scanner(context):
    parse_object = context.parse_object
    parse_array = context.parse_array
    parse_string = context.parse_string
    match_number = NUMBER_RE.match
    strict = context.strict
    parse_float = context.parse_float
    parse_int = context.parse_int
    parse_constant = context.parse_constant
    object_hook = context.object_hook
    object_pairs_hook = context.object_pairs_hook
    memo = context.memo

    def _scan_once(string, idx):
        try:
            nextchar = string[idx]
        except IndexError:
            raise StopIteration(idx) from None

        if nextchar == '"':
            return parse_string(string, idx + 1, strict)
        elif nextchar == '{':
            return parse_object((string, idx + 1), strict,
                _scan_once, object_hook, object_pairs_hook, memo)
        elif nextchar == '[':
            return parse_array((string, idx + 1), _scan_once)
        elif nextchar == 'n' and string[idx:idx + 4] == 'null':
            return None, idx + 4
        elif nextchar == 't' and string[idx:idx + 4] == 'true':
            return True, idx + 4
        elif nextchar == 'f' and string[idx:idx + 5] == 'false':
            return False, idx + 5
        # ▼ ここの2行を追加している ▼
        elif nextchar == 'u' and string[idx:idx + 9] == 'undefined':
            return None, idx + 9

        m = match_number(string, idx)
        if m is not None:
            integer, frac, exp = m.groups()
            if frac or exp:
                res = parse_float(integer + (frac or '') + (exp or ''))
            else:
                res = parse_int(integer)
            return res, m.end()
        elif nextchar == 'N' and string[idx:idx + 3] == 'NaN':
            return parse_constant('NaN'), idx + 3
        elif nextchar == 'I' and string[idx:idx + 8] == 'Infinity':
            return parse_constant('Infinity'), idx + 8
        elif nextchar == '-' and string[idx:idx + 9] == '-Infinity':
            return parse_constant('-Infinity'), idx + 9
        else:
            raise StopIteration(idx)

    def scan_once(string, idx):
        try:
            return _scan_once(string, idx)
        finally:
            memo.clear()

    return scan_once

# 対象を指定して上書き
json.scanner.make_scanner = py_make_scanner

# ----- 黒魔術ここまで -----

# 下ごしらえが出来たので、これでパースを試してみてください
json_dict = json.JSONDecoder().decode('{"aaa":undefined}')
print(json_dict)

所感

探し方が下手なだけかもしれないんですが、それっぽいワードでググっても解決策っぽいものに当たりませんでした。
やってることがニッチなのか、それとも undefined をパースしようというのが罰当たり(規約・規格から外れた行為)なのかは自分でも良く分かっていません。

まぁドキュメントをざっとなぞっても見かけないという事は、少なくとも搦め手の類なのかも知れませんね。

そもそも '{"aaa":undefined}' の形式がJSONとして適しているのか正直不安ではありますが、もし適している場合、やはり value の undefinedNone 辺りに変換されないかな~と思わざるを得ません。

PythonやJSONに詳しい方から見たら、今回自分の取った行動はどう感じるのか、少し気にはなる所です。