import datetime import re from typing import Optional, Dict, List, Callable, Tuple from loguru import logger # --- 常量与正则表达式定义 (Constants and Regex Definitions) --- # 数字模式,兼容中文和阿拉伯数字 P_NUM = r"(\d+|[零一两二三四五六七八九十]+)" # 预编译的正则表达式 PATTERNS = { # 相对时间, e.g., "5分钟后" "DELTA": re.compile( r"^" r"((?P" + P_NUM + r") ?天)?" r"((?P" + P_NUM + r") ?个?小?时)?" r"((?P" + P_NUM + r") ?分钟?)?" r"((?P" + P_NUM + r") ?秒钟?)?" r" ?后 ?$" ), # 绝对时间 "YEAR": re.compile(r"(" + P_NUM + r") ?年"), "MONTH": re.compile(r"(" + P_NUM + r") ?月"), "DAY": re.compile(r"(" + P_NUM + r") ?[日号]"), "HOUR": re.compile(r"(" + P_NUM + r") ?[点时](半)?钟?"), "MINUTE": re.compile(r"(" + P_NUM + r") ?分(钟)?"), "SECOND": re.compile(r"(" + P_NUM + r") ?秒(钟)?"), "HMS_COLON": re.compile(r"(\d{1,2})[::](\d{1,2})([::](\d{1,2}))?"), "PM": re.compile(r"(下午|PM|晚上)"), # 相对日期 "TOMORROW": re.compile(r"明天"), "DAY_AFTER_TOMORROW": re.compile(r"后天"), "TODAY": re.compile(r"今天"), } # 中文数字到阿拉伯数字的映射 CHINESE_TO_ARABIC_MAP: Dict[str, int] = { '零': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 } # --- 核心工具函数 (Core Utility Functions) --- def parse_number(s: str) -> int: """ 将包含中文或阿拉伯数字的字符串解析为整数。 例如: "五" -> 5, "十五" -> 15, "二十三" -> 23, "12" -> 12。 返回 -1 表示解析失败。 """ if not s: return -1 s = s.strip().replace("两", "二") if s.isdigit(): return int(s) if s in CHINESE_TO_ARABIC_MAP: return CHINESE_TO_ARABIC_MAP[s] # 处理 "十" 在不同位置的情况 if s.startswith('十'): if len(s) == 1: return 10 num = CHINESE_TO_ARABIC_MAP.get(s[1]) return 10 + num if num is not None else -1 if s.endswith('十'): if len(s) == 2: num = CHINESE_TO_ARABIC_MAP.get(s[0]) return 10 * num if num is not None else -1 if '十' in s: parts = s.split('十') if len(parts) == 2: left = CHINESE_TO_ARABIC_MAP.get(parts[0]) right = CHINESE_TO_ARABIC_MAP.get(parts[1]) if left is not None and right is not None: return left * 10 + right return -1 # --- 时间解析器类 (Time Parser Class) --- class TimeParser: """ 一个用于解析自然语言时间描述的类。 """ def __init__(self, content: str): self.original_content: str = content self.content_to_parse: str = self._preprocess(content) self.now: datetime.datetime = datetime.datetime.now() # 将 t 作为结果构建器,初始化为今天的午夜 self.t: datetime.datetime = self.now.replace(hour=0, minute=0, second=0, microsecond=0) self.is_pm_specified: bool = False self.is_date_specified: bool = False self.is_time_specified: bool = False def _preprocess(self, content: str) -> str: """预处理字符串,移除不相关字符。""" content = re.sub(r"\s+", "", content) content = re.sub(r"[,,\.。::、]", "", content) return content def _consume_match(self, match: re.Match) -> str: """从待解析字符串中移除已匹配的部分。""" self.content_to_parse = self.content_to_parse.replace(match.group(0), "", 1) return match.group(0) def parse(self) -> Optional[datetime.datetime]: """ 主解析方法。 首先尝试解析相对时间(如“5分钟后”),失败则尝试解析绝对时间。 """ logger.debug(f"🎉 开始解析: '{self.original_content}' -> 清洗后: '{self.content_to_parse}'") if not self.content_to_parse: logger.debug("❌ 内容为空,无法解析。") return None # 1. 尝试相对时间解析 if (target_time := self._parse_relative_time()) is not None: return target_time # 2. 尝试绝对时间解析 if (target_time := self._parse_absolute_time()) is not None: return target_time logger.debug(f"❌ 所有解析模式均未匹配成功。") return None def _parse_relative_time(self) -> Optional[datetime.datetime]: """解析 'X天X小时X分钟后' 这种格式。""" if match := PATTERNS["DELTA"].match(self.content_to_parse): logger.debug("⏳ 匹配到相对时间模式 (DELTA)。") try: delta_parts = { "days": parse_number(match.group("days") or "0"), "hours": parse_number(match.group("hours") or "0"), "minutes": parse_number(match.group("minutes") or "0"), "seconds": parse_number(match.group("seconds") or "0"), } # 检查是否有无效的数字解析 if any(v < 0 for v in delta_parts.values()): logger.debug(f"❌ 解析时间片段为数字时失败: {delta_parts}") return None delta = datetime.timedelta(**delta_parts) if delta.total_seconds() == 0: logger.debug("❌ 解析出的时间增量为0。") return None target_time = self.now + delta logger.debug(f"✅ 相对时间解析成功 -> {target_time}") return target_time except (ValueError, TypeError) as e: logger.debug(f"❌ 解析相对时间时出错: {e}", exc_info=True) return None return None def _parse_absolute_time(self) -> Optional[datetime.datetime]: """解析一个指定的日期和时间。""" logger.debug(f"🎯 启动绝对时间解析,基准时间: {self.t}") # 定义解析步骤和顺序 # (pattern_key, handler_method) parsing_steps: List[Tuple[str, Callable[[re.Match], bool]]] = [ ("TOMORROW", self._handle_tomorrow), ("DAY_AFTER_TOMORROW", self._handle_day_after_tomorrow), ("TODAY", self._handle_today), ("YEAR", self._handle_year), ("MONTH", self._handle_month), ("DAY", self._handle_day), ("HMS_COLON", self._handle_hms_colon), ("PM", self._handle_pm), ("HOUR", self._handle_hour), ("MINUTE", self._handle_minute), ("SECOND", self._handle_second), ] for key, handler in parsing_steps: if match := PATTERNS[key].search(self.content_to_parse): if not handler(match): # 如果任何一个处理器返回False,说明解析失败 return None # 移除无意义的上午关键词 self.content_to_parse = self.content_to_parse.replace("上午", "").replace("AM", "").replace("凌晨", "") # 如果解析后还有剩余字符,说明有无法识别的部分 if self.content_to_parse.strip(): logger.debug(f"❌ 匹配失败,存在未解析的残留内容: '{self.content_to_parse.strip()}'") return None # 最终调整和检查 return self._finalize_datetime() # --- Handler Methods for Absolute Time Parsing --- def _handle_tomorrow(self, match: re.Match) -> bool: self.t += datetime.timedelta(days=1) self.is_date_specified = True logger.debug(f"📅 匹配到 '明天' -> {self.t.date()}, 消耗: '{self._consume_match(match)}'") return True def _handle_day_after_tomorrow(self, match: re.Match) -> bool: self.t += datetime.timedelta(days=2) self.is_date_specified = True logger.debug(f"📅 匹配到 '后天' -> {self.t.date()}, 消耗: '{self._consume_match(match)}'") return True def _handle_today(self, match: re.Match) -> bool: self.is_date_specified = True logger.debug(f"📅 匹配到 '今天', 日期基准不变, 消耗: '{self._consume_match(match)}'") return True def _handle_year(self, match: re.Match) -> bool: year = parse_number(match.group(1)) if year < 0: return False if year < 100: year += 2000 # 处理 "25年" -> 2025 if year < self.now.year: logger.debug(f"❌ 指定的年份 {year} 已过去。") return False self.t = self.t.replace(year=year) self.is_date_specified = True logger.debug(f"Y| 年份更新 -> {self.t.year}, 消耗: '{self._consume_match(match)}'") return True def _handle_month(self, match: re.Match) -> bool: month = parse_number(match.group(1)) if not (1 <= month <= 12): logger.debug(f"❌ 无效的月份: {month}") return False # 如果设置的月份在当前月份之前,且没有指定年份,则年份加一 if month < self.t.month and not self.is_date_specified: self.t = self.t.replace(year=self.t.year + 1) logger.debug(f"💡 月份小于当前月份,年份自动进位 -> {self.t.year}") self.t = self.t.replace(month=month) self.is_date_specified = True logger.debug(f"M| 月份更新 -> {self.t.month}, 消耗: '{self._consume_match(match)}'") return True def _handle_day(self, match: re.Match) -> bool: day = parse_number(match.group(1)) if not (1 <= day <= 31): logger.debug(f"❌ 无效的日期: {day}") return False try: # 如果日期小于当前日期,且只指定了日,则月份加一 if day < self.t.day and not self.is_date_specified: if self.t.month == 12: self.t = self.t.replace(year=self.t.year + 1, month=1) else: self.t = self.t.replace(month=self.t.month + 1) logger.debug(f"💡 日期小于当前日期,月份自动进位 -> {self.t.year}-{self.t.month}") self.t = self.t.replace(day=day) self.is_date_specified = True logger.debug(f"D| 日期更新 -> {self.t.day}, 消耗: '{self._consume_match(match)}'") return True except ValueError: logger.debug(f"❌ 日期 {day} 对于月份 {self.t.month} 无效 (例如2月30号)。") return False def _handle_hms_colon(self, match: re.Match) -> bool: h = int(match.group(1)) m = int(match.group(2)) s_str = match.group(4) # group(3) is with colon, group(4) is the number s = int(s_str) if s_str else 0 if not (0 <= h <= 23 and 0 <= m <= 59 and 0 <= s <= 59): logger.debug(f"❌ 无效的时间格式: H={h}, M={m}, S={s}") return False self.t = self.t.replace(hour=h, minute=m, second=s) self.is_time_specified = True logger.debug(f"T| 时分秒(冒号格式)更新 -> {self.t.time()}, 消耗: '{self._consume_match(match)}'") return True def _handle_pm(self, match: re.Match) -> bool: self.is_pm_specified = True logger.debug(f"PM| 匹配到下午/晚上, 消耗: '{self._consume_match(match)}'") return True def _handle_hour(self, match: re.Match) -> bool: hour = parse_number(match.group(1)) has_half = match.group(2) == '半' if not (0 <= hour <= 23): logger.debug(f"❌ 无效的小时: {hour}") return False minute = 30 if has_half else self.t.minute self.t = self.t.replace(hour=hour, minute=minute) self.is_time_specified = True logger.debug(f"H| 小时更新 -> {self.t.hour}{':30' if has_half else ''}, 消耗: '{self._consume_match(match)}'") return True def _handle_minute(self, match: re.Match) -> bool: minute = parse_number(match.group(1)) if not (0 <= minute <= 59): logger.debug(f"❌ 无效的分钟: {minute}") return False self.t = self.t.replace(minute=minute) self.is_time_specified = True logger.debug(f"M| 分钟更新 -> {self.t.minute}, 消耗: '{self._consume_match(match)}'") return True def _handle_second(self, match: re.Match) -> bool: second = parse_number(match.group(1)) if not (0 <= second <= 59): logger.debug(f"❌ 无效的秒: {second}") return False self.t = self.t.replace(second=second) self.is_time_specified = True logger.debug(f"S| 秒更新 -> {self.t.second}, 消耗: '{self._consume_match(match)}'") return True def _finalize_datetime(self) -> Optional[datetime.datetime]: """对解析出的时间进行最后的调整和检查。""" # 处理下午/晚上 if self.is_pm_specified and self.t.hour < 12: self.t = self.t.replace(hour=self.t.hour + 12) logger.debug(f"💡 根据 PM 标识,小时调整为 -> {self.t.hour}") # 如果没有指定任何时间或日期部分,则认为解析无效 if not self.is_date_specified and not self.is_time_specified: logger.debug("❌ 未能从输入中解析出任何有效的日期或时间部分。") return None # 如果最终计算出的时间点在当前时间之前,自动往后推 # 例如:现在是 15:00,说 "14点",应该是指明天的14点 if self.t < self.now: # 只有在明确指定了时间的情况下,才自动加一天 # 如果只指定了一个过去的日期(如“去年5月1号”),则不应该调整 if self.is_time_specified: self.t += datetime.timedelta(days=1) logger.debug(f"🔁 目标时间已过,自动调整为明天 -> {self.t}") logger.debug(f"✅ 解析成功,最终时间: {self.t}") return self.t # --- 公共接口 (Public Interface) --- def get_target_time(content: str) -> Optional[datetime.datetime]: """ 高级接口,用于将自然语言时间描述转换为 datetime 对象。 Args: content: 包含时间信息的字符串。 Returns: 一个 datetime 对象,如果解析失败则返回 None。 """ parser = TimeParser(content) return parser.parse()