はじめに
日本語の自然文から日時を取り出して、プログラムで扱える形に変換する小さなユーティリティを作りました。
たとえば、次のような入力を扱います。
明日の15時に会議
来週火曜日の21時
2025年11月3日 午前10時
11/20 18:30
このような表現を抽出し、最終的には ISO 8601 形式の文字列を含む辞書に変換します。
{"datetime": "2025-11-03T10:00:00+09:00"}
この記事では、個々の正規表現をすべて解説するのではなく、このユーティリティをどのような技術と考え方で実装しているかを説明します。
やっていること
TimeParser の役割は大きく 2 つです。
- 文章中から日時らしい表現を抜き出す
- 抜き出した表現を timezone 付きの日時文字列に変換する
コード上では、主に次の 2 つの関数に分かれています。
extract_all_time_expressions(text: str) -> list[str]
parse_natural_time(text: str) -> Optional[Tuple[Dict[str, Any], str]]
extract_all_time_expressions() は文章から日時表現を抽出します。
text = "締切は来週火曜日の21時、提出は三日後 18時です。"
expressions = extract_all_time_expressions(text)
print(expressions)
# ["来週火曜日の21時", "三日後 18時"]
parse_natural_time() は、抽出した表現を実際の日時に変換します。
parsed, desc = parse_natural_time("来週火曜日の21時")
print(parsed)
# {"datetime": "2025-11-25T21:00:00+09:00"}
print(desc)
# 2025年11月25日(火) 21:00
このように、抽出と解析を分けています。文章全体を一度に解析しようとすると、文章の文脈と日時表現の処理が混ざります。先に「日時っぽい部分」を抜き出してから、その部分だけを parse する方が実装を単純にできます。
なぜ正規表現で実装したか
自然言語処理と聞くと、形態素解析や機械学習を使う選択肢もあります。
ただ、このユーティリティで扱いたいのは、自由な文章理解ではなく日時表現の正規化です。
対象は次のような、比較的パターンが決まっている表現です。
30分後2時間後三日後 18時明日 9時来週火曜日の21時2025-11-202025年11月3日 午前10時11/20 18:302025.11.20 15時23分
この範囲であれば、正規表現と datetime の組み合わせで十分扱えます。
正規表現を選んだ理由は次の通りです。
- 外部依存を増やさずに済む
- 対応する表現と処理の対応が読みやすい
- 想定外の表現を受け入れすぎない
- テストケースを増やしながら対応範囲を広げやすい
もちろん、曖昧な自然言語を広く扱うには向いていません。たとえば「今度の会議のあと」や「月末くらい」のような表現は、このユーティリティの対象外です。
抽出処理
抽出処理では、日時表現に対応する正規表現を優先順に並べています。
patterns = [
r"\d+分後",
r"\d+時間後",
r"[0-9一二三四五六七八九十百零〇]+日後",
r"来週\s*[月火水木金土日]曜?日?の?\s*\d{1,2}[時:]\d{0,2}分?",
r"\d{4}年\s*\d{1,2}月\s*\d{1,2}日",
r"\d{4}-\d{1,2}-\d{1,2}",
]
実際のコードではこれより多くの pattern を持っていますが、考え方は同じです。
重要なのは、より具体的な pattern を先に置くことです。
たとえば、2025年11月3日 午前10時 という文字列に対して、先に 2025年11月3日 だけを拾ってしまうと、時刻部分を失います。そのため、日付 + 時刻の pattern を、日付のみの pattern より前に置いています。
また、重複した範囲を二重に抽出しないように、すでに使った文字位置を記録しています。
used_positions = set()
for pattern in patterns:
for match in re.finditer(pattern, text):
start, end = match.span()
if any(start < used_end and end > used_start
for used_start, used_end in used_positions):
continue
results.append((start, end, match.group()))
used_positions.add((start, end))
これにより、長い日時表現の一部だけが重複して返ることを防いでいます。
解析処理
解析処理も、基本は順序付きの pattern matching です。
parse_natural_time() の中で、相対時間、曜日表現、日付表現、時刻のみの表現を順番に試します。
match = re.match(r"(\d+)分後", text)
if match:
minutes = int(match.group(1))
target_time = now + timedelta(minutes=minutes)
return create_parse_result(target_time)
30分後 や 2時間後 のような相対表現では、現在時刻に timedelta を足します。
target_time = now + timedelta(minutes=minutes)
明日 9時 や 明後日の14時30分 のような表現では、日付部分を timedelta(days=...) でずらし、時刻部分を replace() で設定します。
target_time = now + timedelta(days=1)
target_time = target_time.replace(
hour=hour,
minute=minute,
second=0,
microsecond=0,
)
来週火曜日の21時 のような曜日表現では、曜日を数値に変換して差分日数を計算します。
weekday_map = {
"月": 0,
"火": 1,
"水": 2,
"木": 3,
"金": 4,
"土": 5,
"日": 6,
}
Python の datetime.weekday() は月曜日を 0、日曜日を 6 として扱うため、この形式に合わせています。
時刻表現の共通処理
時刻部分は、複数の表記を受け入れる必要があります。
15:3015時30分15時午後3時午前12時
これらは parse_time_with_ampm() という内部関数でまとめて処理しています。
def parse_time_with_ampm(time_text: str) -> Optional[Tuple[int, int]]:
match = re.match(r"午後\s*(\d{1,2})時?(\d{0,2})分?", time_text)
if match:
hour = int(match.group(1))
minute = int(match.group(2)) if match.group(2) else 0
if hour != 12:
hour += 12
return validate(hour, minute)
match = re.match(r"午前\s*(\d{1,2})時?(\d{0,2})分?", time_text)
if match:
hour = int(match.group(1))
minute = int(match.group(2)) if match.group(2) else 0
if hour == 12:
hour = 0
return validate(hour, minute)
午後表記では、午後3時 を 15:00 に変換します。ただし 午後12時 は 12 時のままです。
午前表記では、午前12時 を 00:00 に変換します。
また、時刻として不正な値はここで弾きます。
def validate(hour: int, minute: int) -> Optional[Tuple[int, int]]:
if not (0 <= minute < 60):
return None
if not (0 <= hour < 24):
return None
return (hour, minute)
時刻の validation を共通化しておくことで、どの日時 pattern から呼ばれても同じ基準でチェックできます。
漢数字への対応
日本語入力では、3日後 だけでなく 三日後 のような表現も出てきます。
そのため、簡単な漢数字を整数へ変換する関数を用意しています。
KANJI_DIGIT_MAP = {
"一": 1,
"二": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
}
現在は、一 から 九 に加えて、十 や 百 を含む簡単な表現も扱えるようにしています。
この部分も、自然言語処理ライブラリを使っているわけではありません。扱いたい入力範囲を決め、その範囲に必要な変換だけを実装しています。
日付フォーマットへの対応
日付は、複数の表記を受け入れます。
2025年11月20日
2025-11-20
2025/11/20
2025.11.20
11月20日
11/20
11.20
これらを parse した後は、すべて datetime オブジェクトに変換します。
target_time = datetime(year, month, day, hour, minute, tzinfo=TZ)
存在しない日付は、datetime の生成時に ValueError になります。
try:
target_time = datetime(year, month, day, hour, minute, tzinfo=TZ)
except ValueError:
return None
たとえば 2月30日 のような入力はここで失敗します。自前で月ごとの日数を細かく判定するより、標準ライブラリに任せる方が安全です。
timezone の扱い
TimeParser では、timezone を Asia/Tokyo に固定しています。
TIMEZONE = "Asia/Tokyo"
TZ = ZoneInfo(TIMEZONE)
返却する datetime 文字列には、timezone offset が含まれます。
2025-11-20T21:00:00+09:00
Windows 環境では zoneinfo.ZoneInfo("Asia/Tokyo") がそのまま使えない場合があります。そのため、Asia/Tokyo が見つからない場合は JST の固定 offset に fallback しています。
try:
TZ = ZoneInfo(TIMEZONE)
except ZoneInfoNotFoundError:
if TIMEZONE != "Asia/Tokyo":
raise
TZ = timezone(timedelta(hours=9), name="JST")
外部依存を増やさずに動かしたかったため、tzdata の追加を必須にはしていません。
返り値
parse に成功した場合は、辞書と表示用文字列の tuple を返します。
(
{"datetime": "2025-11-20T21:00:00+09:00"},
"2025年11月20日 21:00",
)
辞書側はプログラムで扱うための値です。表示用文字列は CLI やアプリ側でそのまま見せるための補助です。
parse に失敗した場合は None を返します。
result = parse_natural_time("日時ではない文章")
print(result)
# None
実装してみての考え方
このユーティリティは、自然言語を何でも理解する parser ではありません。
実装の方針は、次のようなものです。
- 入力範囲を決める
- 正規表現で候補を抽出する
- 具体的な pattern から順に parse する
-
datetimeで妥当性を検証する - ISO 8601 文字列に正規化する
自然言語処理として見ると素朴ですが、小さなアプリケーションの日時入力を扱うには十分実用的です。
特に、日時表現のようにフォーマットがある程度決まっている領域では、機械学習よりも正規表現と標準ライブラリの組み合わせの方が見通しよく実装できる場合があります。
まとめ
TimeParser は、日本語の日時表現を抽出して ISO 8601 形式へ変換する Python ユーティリティです。
実装では、主に次の技術を使っています。
-
reによる正規表現 matching - 優先順付き pattern list
-
datetime/timedeltaによる日時計算 -
zoneinfoによる timezone 付き datetime - 漢数字や曜日の小さな変換テーブル
大きな自然言語処理ライブラリは使っていません。対象を日時表現に絞り、必要な pattern を少しずつ増やしていくことで、軽量な parser として実装しています。