Pythonで日付らしき文字列をdatetimeオブジェクトに変換する

これまでの記事で書いた日時関連の集大成として
日付らしき文字列をdatetimeオブジェクトに変換する処理を書いたので備忘録

dateutilのparserの再発明になってしまうが、大量の変換をやらなければならくて、ちょっと遅いのだ。
あと日本語の日付表記には完全非対応なのも何とかしたい。

過去に似たような[Qiita記事](https://qiita.com/kirin123kirin/items/6a22a292a0c56c5ff9c0)を書いたがメッチャ遅いのだ。その後も正規表現やライブラリで頑張ってみてもあまり変わらなかった。

アルゴリズムを探していたところ、たまたま[トライ木(データ構造)](https://ja.wikipedia.org/wiki/%E3%83%88%E3%83%A9%E3%82%A4_(%E3%83%87%E3%83%BC%E3%82%BF%E6%A7%8B%E9%80%A0))を目にして、日時関連の文字列を組んでやれば高速処理が可能ではないか？と思いついてやってみた。
結果、素人的にちょっと思いつくだけでも膨大なパターンになった。

それぞれ年月日時分秒のトライ木データをdictにまとめあげて、検索するという処理を備忘録として残しておく。

# ソース

０、こしらえたトライ木のjsonファイル一式からpython dictとしてロード

１，triefind : トライ木を検索

２，extractdate : 日付らしき文字列を抽出

３，guessdatetime : 与えられた文字列から日付らしき文字を探しpythonのdatetimeオブジェクトに変換し結果を返す

* parse : ハンドラーだが、より厳しい条件から緩い条件の順に試していって、良さげな結果が得られたら返す関数

の3つを組んでみた。

```python
# coding=utf8

import os
from datetime import datetime, time, timezone, timedelta
from operator import itemgetter
from json import load

ext = ".json"

dirpath = os.path.join(os.path.dirname(__file__), "dat")

with open(os.path.join(dirpath, "GG" + ext), "r") as f:
    GG = load(f)
with open(os.path.join(dirpath, "YYYY" + ext), "r") as f:
    YYYY = load(f)
with open(os.path.join(dirpath, "yy" + ext), "r") as f:
    yy = load(f)
with open(os.path.join(dirpath, "MM" + ext), "r") as f:
    MM = load(f)
with open(os.path.join(dirpath, "DD" + ext), "r") as f:
    DD = load(f)
with open(os.path.join(dirpath, "HH" + ext), "r") as f:
    HH = load(f)
with open(os.path.join(dirpath, "mi" + ext), "r") as f:
    mi = load(f)
with open(os.path.join(dirpath, "SS" + ext), "r") as f:
    SS = load(f)
with open(os.path.join(dirpath, "sss" + ext), "r") as f:
    sss = load(f)
with open(os.path.join(dirpath, "WW" + ext), "r") as f:
    WW = load(f)
#with open(os.path.join(dirpath, "ZZ" + ext), "r") as f:
#    ZZ = load(f)

with open(os.path.join(dirpath, "VALIDATOR" + ext), "r") as f:
    VALIDATOR = set(load(f))

NUMBERS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            '０', '１', '２', '３', '４', '５', '６', '７', '８', '９',
            '〇', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'}

def triefind(string, nodes):
    i = 0
    ret = [None] * len(nodes)
    checksum = 0
    strlen = len(string)

    for j, node in enumerate(nodes):
        for s in string[i:]:
            i += 1
            if (s == " " or s == "\u3000"):
                continue
            
            if (s not in node["children"]):
                i -= 1
                break
            node = node["children"][s]
        
        ret[j] = node["value"]

        if ret[j]:
            checksum += 1

        if (j > 2 and checksum == 0):
            return None
    
    return ret

def parse(string, dayfirst=False):
    numcount = sum(x in NUMBERS for x in string)
    offset = 12 if any(x in string for x in ("pm", "p.m", "p.m.", "PM", "P.M", "P.M.", "午後")) else 0
    
    if ((string[2] == ":" or numcount in [4, 6, 9]) and "/" not in string):
        # HmS
        r = triefind(string, (HH, mi, SS, sss))
        if (r and r[0] and r[1]):
            r[0] += offset
            return time(*itemgetter(0, 1, 2, 3)(r))

    # WDMYHmSZ
    if (string[2] not in NUMBERS):
        r = triefind(string, (WW, MM, DD, YYYY, HH, mi, SS, sss))
        if (r and r[3] and r[1] and r[2]):
            r[4] += offset
            return datetime(*itemgetter(3, 1, 2, 4, 5, 6, 7)(r))

        r = triefind(string, (WW, DD, MM, YYYY, HH, mi, SS, sss))
        if (r and r[3] and r[2] and r[1]):
            r[4] += offset
            return datetime(*itemgetter(3, 2, 1, 4, 5, 6, 7)(r))

    if (dayfirst is False):
        # YMDWHmSZ
        r = triefind(string, (YYYY, MM, DD, WW, HH, mi, SS, sss))
        if (r and r[0] and r[1] and r[2]):
            r[4] += offset
            return datetime(*itemgetter(0, 1, 2, 4, 5, 6, 7)(r))

        # YDMWHmSZ
        r = triefind(string, (YYYY, DD, MM, WW, HH, mi, SS, sss))
        if (r and r[0] and r[2] and r[1]):
            r[4] += offset
            return datetime(*itemgetter(0, 2, 1, 4, 5, 6, 7)(r))

    else:
        # YDMWHmSZ
        r = triefind(string, (YYYY, DD, MM, WW, HH, mi, SS, sss))
        if (r and r[0] and r[2] and r[1]):
            r[4] += offset
            return datetime(*itemgetter(0, 2, 1, 4, 5, 6, 7)(r))

        # YMDWHmSZ
        r = triefind(string, (YYYY, MM, DD, WW, HH, mi, SS, sss))
        if (r and r[0] and r[1] and r[2]):
            r[4] += offset
            return datetime(*itemgetter(0, 1, 2, 4, 5, 6, 7)(r))

    # DMy
    r = triefind(string, (DD, MM, yy, WW, HH, mi, SS, sss))
    if (r and r[0] and r[1] and r[2]):
        r[2] += 2000 if r[2] < 60 else 1900
        r[4] += offset
        return datetime(*itemgetter(2, 1, 0, 4, 5, 6, 7)(r))

    # MDWHm
    r = triefind(string, (MM, DD, WW, HH, mi, SS, sss))
    if (r and r[0] and r[1]):
        r[3] += offset
        return datetime(1900, *itemgetter(0, 1, 3, 4, 5, 6)(r))


    # GyMDWHmS
    r = triefind(string, (GG, yy, MM, DD, WW, HH, mi, SS))
    if (r and r[0] and r[2] and r[3]):
        r[5] += offset
        return datetime(r[0] + r[1] - 1, *itemgetter(2, 3, 5, 6, 7)(r), tzinfo=timezone(timedelta(hours=9)))

    raise ValueError("Cannot parse. Unknown date format. -> {}".format(string))


def extractdate(string, minlimit=3):
    i = 0

    for j, s in enumerate(string):
        if (s in VALIDATOR):
            i += 1
            continue

        if (i > minlimit):
            c = 0
            for k in range(j - i, j):
                c += string[k] in NUMBERS
                if (c > minlimit):
                    yield string[j - i:j].strip(" –-_()（）〈〉")
                    break
        i = 0
    if (i > minlimit and i == len(string)):
        yield string.strip(" –-_()（）〈〉")


def guessdatetime(string, dayfirst=False):
    try:
        return parse(next(extractdate(string)), dayfirst=dayfirst)
    except StopIteration:
        raise ValueError("Not Found date like string. -> {}".format(string))


```

![こしらえたトライ木zipファイル](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135714/a1045aea-ef85-3781-3999-56030b3de37a.png)

辞書は上記zipで保存し、ソースと同じフォルダにdatフォルダを作りjson全部解凍すればOK


# 簡易テスト結果

```python
guessdatetime('20060314') -> 2006-03-14 00:00:00
guessdatetime('2006/03/14') -> 2006-03-14 00:00:00
guessdatetime('14/03/2006') -> 2006-03-14 00:00:00
guessdatetime('03/14/2006') -> 2006-03-14 00:00:00
guessdatetime('2006/3/3') -> 2006-03-03 00:00:00
guessdatetime('2006-03-14') -> 2006-03-14 00:00:00
guessdatetime('03-14-2006') -> 2006-03-14 00:00:00
guessdatetime('14.03.2006') -> 2006-03-14 00:00:00
guessdatetime('03.14.2006') -> 2006-03-14 00:00:00
guessdatetime('03-Mar-06') -> 2006-03-03 00:00:00
guessdatetime('14-Mar-06') -> 2006-03-14 00:00:00
guessdatetime('03-Mar-2006') -> 2006-03-03 00:00:00
guessdatetime('14-Mar-2006') -> 2006-03-14 00:00:00
guessdatetime('14-03-2006') -> 2006-03-14 00:00:00
guessdatetime('20061403', dayfirst=True) -> 2006-03-14 00:00:00
guessdatetime('2006/14/03', dayfirst=True) -> 2006-03-14 00:00:00
guessdatetime('2006-14-03', dayfirst=True) -> 2006-03-14 00:00:00
guessdatetime('13 27 54 123') -> 13:27:54.123000
guessdatetime('13 27 54') -> 13:27:54
guessdatetime('13.27.54.123') -> 13:27:54.123000
guessdatetime('13.27.54') -> 13:27:54
guessdatetime('13:27:54.123') -> 13:27:54.123000
guessdatetime('13:27:54') -> 13:27:54
guessdatetime('1327') -> 13:27:00
guessdatetime('132754') -> 13:27:54
guessdatetime('132754123') -> 13:27:54.123000
guessdatetime('20060314 13:27:54.123') -> 2006-03-14 13:27:54.123000
guessdatetime('2006/03/14 13:27:54.123') -> 2006-03-14 13:27:54.123000
guessdatetime('14/03/2006 13:27:54.123') -> 2006-03-14 13:27:54.123000
guessdatetime('20060314 13:27:54') -> 2006-03-14 13:27:54
guessdatetime('2006/03/14 13:27:54') -> 2006-03-14 13:27:54
guessdatetime('14/03/2006 13:27:54') -> 2006-03-14 13:27:54
guessdatetime('2006-03-14 13:27:54.123') -> 2006-03-14 13:27:54.123000
guessdatetime('14-03-2006 13:27:54.123') -> 2006-03-14 13:27:54.123000
guessdatetime('2006-03-14 13:27:54') -> 2006-03-14 13:27:54
guessdatetime('14-03-2006 13:27:54') -> 2006-03-14 13:27:54
guessdatetime('2006-03-14T13:27:54') -> 2006-03-14 13:27:54
guessdatetime('2006-03-14T13:27:54.123') -> 2006-03-14 13:27:54.123000
guessdatetime('20060314T13:27:54') -> 2006-03-14 13:27:54
guessdatetime('20060314T13:27:54.123') -> 2006-03-14 13:27:54.123000
guessdatetime('14-03-2006T13:27:54.123') -> 2006-03-14 13:27:54
guessdatetime('14-03-2006T13:27:54') -> 2006-03-14 13:27:54
guessdatetime('20060314T1327') -> 2006-03-14 13:27:00
guessdatetime('20060314T132754') -> 2006-03-14 13:27:54
guessdatetime('20060314T132754123') -> 2006-03-14 13:27:54.123000
guessdatetime('2006-03-04T13:27:54+03:45') -> 2006-03-04 13:27:54
guessdatetime('2006-03-04T13:27+03:45') -> 2006-03-04 13:27:00
guessdatetime('04/Mar/2006:13:27:54 -0537') -> 2006-03-04 13:27:54
guessdatetime('Sat, 04 Mar 2006 13:27:54 GMT') -> 2006-03-04 13:27:54
guessdatetime('08-24-2001 20:10') -> 2001-08-24 20:10:00
guessdatetime('Friday, August 24th, 2001 20:10') -> 2001-08-24 20:10:00
guessdatetime('Fri Aug. 24, 2001 8:10 p.m.') -> 2001-08-24 20:10:00
guessdatetime('Aug. 24, 2001 20:10') -> 2001-08-24 20:10:00
guessdatetime('2001/08/24 20:10') -> 2001-08-24 20:10:00
guessdatetime('2001/08/24 2010') -> 2001-08-24 20:10:00
guessdatetime('2001年8月24日金曜日 20:10') -> 2001-08-24 20:10:00
guessdatetime('2001年8月24日(金) 20:10') -> 2001-08-24 20:10:00
guessdatetime('平成１３年８月２４日　午後八時十分') -> 2001-08-24 20:10:00+09:00
guessdatetime('平成13年08月24日PM 08:10') -> 2001-08-24 20:10:00+09:00
guessdatetime('H13年08月24日　PM08:10') -> 2001-08-24 20:10:00+09:00
guessdatetime('平13年08月24日　午後8:10') -> 2001-08-24 20:10:00+09:00
guessdatetime('平成13年08/24午後08:10') -> 2001-08-24 20:10:00+09:00
guessdatetime('平成元年０８月２４日　２０時１０分００秒') -> 1989-08-24 20:10:00+09:00
guessdatetime('3月 25 00:40') -> 1900-03-25 00:40:00
guessdatetime('11月 29  2018') -> 2018-11-29 00:00:00
guessdatetime('1月 16  2019') -> 2019-01-16 00:00:00
```


# 文章中の日付らしき文字列からdatetimeオブジェクトを作りまくる。
====== ここからWikipediaの織田信長から引用文をサンプルに含みます ======

```python

test = """
織田 信長（おだ のぶなが、天文3年5月12日〈1534年6月23日〉 - 天正10年6月2日〈1582年6月21日〉）は、
日本の戦国時代から安土桃山時代にかけての武将、戦国大名。三英傑の一人。

尾張国（現在の愛知県）の織田信秀の嫡男。家督争いの混乱を収めた後に、
桶狭間の戦いで今川義元を討ち取り、勢力を拡大した。足利義昭を奉じて上洛し、後には義昭を追放することで、
畿内を中心に独自の中央政権（「織田政権」[注釈 4]）を確立して天下人となった。
しかし天正10年6月2日（1582年6月21日）、重臣・明智光秀に謀反を起こされ、本能寺で自害した。
これまで信長の政権は、豊臣秀吉による豊臣政権、徳川家康が開いた江戸幕府へと引き継がれていく、
画期的なものであったとみなされてきた[2]。
しかし、政策の実態などから「中世社会の最終段階」ともしばしば評され[2]、
特に近年の歴史学界では信長の革新性を否定する研究が主流となっている[3][4]。


Oda Nobunaga (織田 信長, About this soundlisten; 3 Jul 1534 – 21 Jun, 1582) was
a Japanese daimyo and one of the leading figures of the Sengoku period. 
He is regarded as the first "Great Unifier" of Japan. 
His reputation in war gave him the nickname of "Demon Daimyo" or "Demon King".

Nobunaga was head of the very powerful Oda clan, 
and launched a war against other daimyos to unify Japan in the 1560s. 
Nobunaga emerged as the most powerful daimyo, 
overthrowing the nominally ruling shogun Ashikaga Yoshiaki and 
dissolving the Ashikaga Shogunate in 1573. He conquered most of Honshu island by 1580,
and defeated the Ikkō-ikki rebels by the 1580s.
Nobunaga's rule was noted for innovative military tactics, fostering free trade,
reform of Japan's civil government, and encouraging the start of the Momoyama historical art period, 
but also for the brutal suppression of opponents, eliminating those who refused to cooperate or
yield to his demands. Nobunaga was killed in the Honno-ji Incident in 1582 when his retainer 
Akechi Mitsuhide ambushed him in Kyoto and forced him to commit seppuku.
Nobunaga was succeeded by Toyotomi Hideyoshi who along with Tokugawa Ieyasu completed his war of
unification shortly afterwards.

"""

for orgtext in extractdate(test):
    try:
        print(orgtext, "->", parse(orgtext))
    except ValueError:
        print(orgtext[:100], "-> ValueError")

```

# 出力内容
```
天文3年5月12日 -> 1534-05-12 00:00:00+09:00
1534年6月23日 -> 1534-06-23 00:00:00
天正10年6月2日 -> 1582-06-02 00:00:00+09:00
1582年6月21日 -> 1582-06-21 00:00:00
天正10年6月2日（1582年6月21日 -> 1582-06-02 15:08:02+09:00
3 Jul 1534 -> 1534-07-03 00:00:00
21 Jun, 1582) was -> 1582-06-21 00:00:00
and launched a war against other daimyos to unify Japan in the 1560s. -> ValueError
dissolving the Ashikaga Shogunate in 1573. He con -> ValueError
uered most of Honshu island by 1580, -> ValueError
ikki rebels by the 1580s. -> ValueError
yield to his demands. Nobunaga was killed in the Honno-ji Incident in 1582 when his retainer -> ValueError
```


# 処理時間比較

遅くては本末転倒だからドキドキだった。。

```python

In [25]: import dateutil.parser

In [26]: %timeit dateutil.parser.parse('Sat, 04 Mar 2006 13:27:54 GMT')  
88.6 µs ± 141 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

In [27]: %timeit guessdatetime('Sat, 04 Mar 2006 13:27:54 GMT')  
17 µs ± 50.3 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)


In [28]: %timeit dateutil.parser.parse('2006-03-14 13:27:54.123')        
56.5 µs ± 47.2 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

In [29]: %timeit guessdatetime('2006-03-14 13:27:54.123')        
11.9 µs ± 31.9 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)


In [30]: %timeit dateutil.parser.parse('平成元年０８月２４日　２０時１０分００秒')
--> 649             raise ParserError("Unknown string format: %s", timestr)
    650
    651         if len(res) == 0:

ParserError: Unknown string format: 平成元年０８月２４日　２０時１０分００秒

In [31]: %timeit guessdatetime('平成元年０８月２４日　２０時１０分００秒')
28.6 µs ± 64.9 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)


```

1マイクロ秒を切るぐらいの性能が欲しかったが、
とりあえず糞データ含むログの分析を行いつつ、C＋＋でPythonライブラリを書いている。


あとタイムゾーンの処理は入れておらず簡単に実装できるのだが、タイムゾーンの詳細を理解できてないため中途半端に変換するのは一旦やめておこうと思う。
何やら奥が深そうなので、pytz？とかライブラリを使った方が良いのかとかがよくわからないため、、