@lark-people/cli 0.0.17 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,894 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ resolve_date.py - 确定性日期解析脚本
4
+
5
+ 将中文/英文相对日期表达(如"下周六""下个月3号""last Wednesday")解析为绝对日期。
6
+ 仅使用 Python 3 标准库,无第三方依赖。
7
+
8
+ 用法:
9
+ python3 resolve_date.py --expr "下周六" --anchor "2026-05-29"
10
+ python3 resolve_date.py --expr "下个月3号" --anchor "2026-05-29"
11
+ python3 resolve_date.py --expr "6月13日" --anchor "2026-05-29"
12
+ python3 resolve_date.py --expr "本周三" --anchor "2026-06-15" # → 2026-06-17(允许落在未来)
13
+ python3 resolve_date.py --expr "本周" --anchor "2026-06-15" --clamp-to-anchor
14
+
15
+ 输出: JSON 格式
16
+ 成功: {"success": true, "resolved_date": "2026-06-06", "is_future": false, ...}
17
+ 失败: {"success": false, "reason": "...", ...}
18
+
19
+ 关于"未来日期":
20
+ 脚本本身不拒绝未来日期,只通过 `is_future` 字段标识。
21
+ 调用方应根据语义自行判断:
22
+ - "明天/明年/下月X号/下周一" 等显式未来意图 → 应拒绝调用接口
23
+ - "本周X/本月N号"等 ISO 周/月内某天恰好落在未来 → 仍可调用接口(接口会返回"无记录")
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import re
29
+ import sys
30
+ from datetime import date, timedelta
31
+ from typing import Dict, List, Optional, Tuple
32
+
33
+ # 中文数字映射
34
+ CN_NUM = {
35
+ "零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
36
+ "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10,
37
+ "十一": 11, "十二": 12, "十三": 13, "十四": 14, "十五": 15,
38
+ "十六": 16, "十七": 17, "十八": 18, "十九": 19, "二十": 20,
39
+ "二十一": 21, "二十二": 22, "二十三": 23, "二十四": 24, "二十五": 25,
40
+ "二十六": 26, "二十七": 27, "二十八": 28, "二十九": 29, "三十": 30,
41
+ "三十一": 31,
42
+ }
43
+
44
+ WEEKDAY_MAP = {
45
+ "一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6, "天": 6, "末": 5,
46
+ "1": 0, "2": 1, "3": 2, "4": 3, "5": 4, "6": 5, "7": 6,
47
+ }
48
+
49
+ WEEKDAY_NAMES = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
50
+
51
+ # 英文 weekday: monday=0, sunday=6
52
+ EN_WEEKDAY = {
53
+ "mon": 0, "monday": 0,
54
+ "tue": 1, "tues": 1, "tuesday": 1,
55
+ "wed": 2, "weds": 2, "wednesday": 2,
56
+ "thu": 3, "thur": 3, "thurs": 3, "thursday": 3,
57
+ "fri": 4, "friday": 4,
58
+ "sat": 5, "saturday": 5,
59
+ "sun": 6, "sunday": 6,
60
+ }
61
+
62
+
63
+ EN_MONTH = {
64
+ "jan": 1,
65
+ "january": 1,
66
+ "feb": 2,
67
+ "february": 2,
68
+ "mar": 3,
69
+ "march": 3,
70
+ "apr": 4,
71
+ "april": 4,
72
+ "may": 5,
73
+ "jun": 6,
74
+ "june": 6,
75
+ "jul": 7,
76
+ "july": 7,
77
+ "aug": 8,
78
+ "august": 8,
79
+ "sep": 9,
80
+ "sept": 9,
81
+ "september": 9,
82
+ "oct": 10,
83
+ "october": 10,
84
+ "nov": 11,
85
+ "november": 11,
86
+ "dec": 12,
87
+ "december": 12,
88
+ }
89
+
90
+ # 英文数字词(用于 "two days ago" / "three weeks ago" 等表达)
91
+ EN_NUM = {
92
+ "one": 1, "an": 1, "a": 1,
93
+ "two": 2, "three": 3, "four": 4, "five": 5,
94
+ "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
95
+ "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14,
96
+ "fifteen": 15, "sixteen": 16, "seventeen": 17, "eighteen": 18,
97
+ "nineteen": 19, "twenty": 20, "thirty": 30,
98
+ }
99
+
100
+
101
+ def en_to_num(s: str) -> Optional[int]:
102
+ """阿拉伯数字或英文数字词转 int"""
103
+ s = s.strip().lower()
104
+ if s.isdigit():
105
+ return int(s)
106
+ return EN_NUM.get(s)
107
+
108
+
109
+ def cn_to_num(s: str) -> Optional[int]:
110
+ """将中文数字或阿拉伯数字字符串转为 int"""
111
+ s = s.strip()
112
+ if s.isdigit():
113
+ return int(s)
114
+ if s in CN_NUM:
115
+ return CN_NUM[s]
116
+ # 处理 "十X" 模式
117
+ if s.startswith("十") and len(s) == 2:
118
+ tail = CN_NUM.get(s[1])
119
+ if tail is not None:
120
+ return 10 + tail
121
+ if len(s) == 3 and s[1] == "十":
122
+ head = CN_NUM.get(s[0])
123
+ tail = CN_NUM.get(s[2])
124
+ if head is not None and tail is not None:
125
+ return head * 10 + tail
126
+ return None
127
+
128
+
129
+ def _normalize(expr: str) -> str:
130
+ """统一同义词,便于后续正则匹配。仅做无歧义替换。"""
131
+ s = expr.strip()
132
+ # 只删除中文/全角空格;保留 ASCII 空格用于英文短语匹配
133
+ s = s.replace(" ", "")
134
+ s = s.replace(",", "").replace(",", "")
135
+ # 同义词归一: 礼拜/拜/星期 → 周
136
+ s = re.sub(r"星期", "周", s)
137
+ s = re.sub(r"礼拜", "周", s)
138
+ s = re.sub(r"(?<![上下本这个那])拜(?=[一二三四五六日天\d])", "周", s)
139
+ # 这周/这个周 → 本周(统一前缀)
140
+ s = re.sub(r"^这(?=周)", "本", s)
141
+ s = re.sub(r"^这个(?=周)", "本个", s)
142
+ # 今儿/明儿/昨儿 → 今天/明天/昨天
143
+ s = s.replace("今儿", "今天").replace("明儿", "明天").replace("昨儿", "昨天")
144
+ return s
145
+
146
+
147
+ def get_monday(d: date) -> date:
148
+ """获取 d 所在周的周一(ISO 8601)"""
149
+ return d - timedelta(days=d.weekday())
150
+
151
+
152
+ def resolve(expr: str, anchor: date, *, clamp_to_anchor: bool = False) -> Dict:
153
+ """
154
+ 尝试解析日期表达式,返回结果字典。
155
+ 成功: {"success": True, "resolved_date": "YYYY-MM-DD", "resolved_weekday": "星期X", "type": "single"/"range"}
156
+ 范围: {"success": True, "resolved_date_from": ..., "resolved_date_to": ..., "type": "range"}
157
+ 失败: {"success": False, "reason": "..."}
158
+ """
159
+ expr = _normalize(expr)
160
+
161
+ # === 0. 范围表达(优先解析,如“上周一到上周五”“2026-06-01~2026-06-07”) ===
162
+ m = re.match(r"^(.+?)(?:到|至|~|~|—|–)(.+)$", expr)
163
+ if m:
164
+ left = m.group(1).strip()
165
+ right = m.group(2).strip()
166
+
167
+ # 右侧若是裸“周X”,尝试继承左侧的周前缀(上周/下周/本周/这周/上上周/下下周)
168
+ if re.match(r"^周[一二三四五六日天\d]$", right):
169
+ m2 = re.search(r"(上上|下下|上|下|本|这)(?:个)?周", left)
170
+ if m2:
171
+ right = f"{m2.group(0)}{right[-1]}"
172
+
173
+ left_r = resolve(left, anchor, clamp_to_anchor=clamp_to_anchor)
174
+ right_r = resolve(right, anchor, clamp_to_anchor=clamp_to_anchor)
175
+ if left_r.get("success") and right_r.get("success"):
176
+ if left_r.get("type") == "single" and right_r.get("type") == "single":
177
+ d1 = date.fromisoformat(left_r["resolved_date"])
178
+ d2 = date.fromisoformat(right_r["resolved_date"])
179
+ d_from = min(d1, d2)
180
+ d_to = max(d1, d2)
181
+ result = _range(d_from, d_to)
182
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
183
+ if left_r.get("type") == "range" and right_r.get("type") == "range":
184
+ d1 = date.fromisoformat(left_r["resolved_date_from"])
185
+ d2 = date.fromisoformat(left_r["resolved_date_to"])
186
+ d3 = date.fromisoformat(right_r["resolved_date_from"])
187
+ d4 = date.fromisoformat(right_r["resolved_date_to"])
188
+ result = _range(min(d1, d2, d3, d4), max(d1, d2, d3, d4))
189
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
190
+
191
+ extracted = _extract_weekday_exprs(expr)
192
+ if len(extracted) > 1:
193
+ resolved = []
194
+ for item in extracted:
195
+ result = resolve(item, anchor, clamp_to_anchor=clamp_to_anchor)
196
+ if result.get("success") and result.get("type") == "single":
197
+ resolved.append({
198
+ "expression": item,
199
+ "resolved_date": result["resolved_date"],
200
+ "resolved_weekday": result["resolved_weekday"],
201
+ })
202
+ if len(resolved) > 1:
203
+ return {
204
+ "success": True,
205
+ "type": "multiple",
206
+ "resolved_dates": resolved,
207
+ }
208
+
209
+ # === 1. 绝对日期格式 ===
210
+
211
+ # YYYY-MM-DD / YYYY.MM.DD / YYYY/MM/DD
212
+ m = re.match(r"^(\d{4})[-./](\d{1,2})[-./](\d{1,2})$", expr)
213
+ if m:
214
+ return _single(date(int(m.group(1)), int(m.group(2)), int(m.group(3))))
215
+
216
+ # YYYY年M月D日
217
+ m = re.match(r"^(\d{4})年(\d{1,2})月(\d{1,2})[日号]?$", expr)
218
+ if m:
219
+ return _single(date(int(m.group(1)), int(m.group(2)), int(m.group(3))))
220
+
221
+ # M.D / M/D (无年份,用 anchor 年)
222
+ m = re.match(r"^(\d{1,2})[./](\d{1,2})$", expr)
223
+ if m:
224
+ month = int(m.group(1))
225
+ day = int(m.group(2))
226
+ year = _infer_year_for_month(anchor, month)
227
+ return _single(date(year, month, day))
228
+
229
+ # M-D (无年份,用 anchor 年)
230
+ m = re.match(r"^(\d{1,2})-(\d{1,2})$", expr)
231
+ if m:
232
+ month = int(m.group(1))
233
+ day = int(m.group(2))
234
+ year = _infer_year_for_month(anchor, month)
235
+ return _single(date(year, month, day))
236
+
237
+ # M月D日 / M月D号
238
+ m = re.match(r"^(\d{1,2})月(\d{1,2})[日号]?$", expr)
239
+ if m:
240
+ month = int(m.group(1))
241
+ day = int(m.group(2))
242
+ year = _infer_year_for_month(anchor, month)
243
+ return _single(date(year, month, day))
244
+
245
+ # 五月 / 5月(仅月份)
246
+ m = re.match(r"^(\d{1,2}|[一二三四五六七八九十十一十二]+)月份?$", expr)
247
+ if m:
248
+ month = cn_to_num(m.group(1))
249
+ if month is not None and 1 <= month <= 12:
250
+ year = _infer_year_for_month(anchor, month)
251
+ first = date(year, month, 1)
252
+ last = _last_day_of_month(year, month)
253
+ result = _range(first, last)
254
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
255
+
256
+ # 今年X月 / 去年X月 / 明年X月
257
+ m = re.match(r"^(今年|本年|去年|上一年|明年|下一年)(\d{1,2}|[一二三四五六七八九十十一十二]+)月份?$", expr)
258
+ if m:
259
+ year_word = m.group(1)
260
+ month = cn_to_num(m.group(2))
261
+ if month is not None and 1 <= month <= 12:
262
+ if year_word in ("今年", "本年"):
263
+ year = anchor.year
264
+ elif year_word in ("去年", "上一年"):
265
+ year = anchor.year - 1
266
+ else:
267
+ year = anchor.year + 1
268
+ first = date(year, month, 1)
269
+ last = _last_day_of_month(year, month)
270
+ result = _range(first, last)
271
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
272
+
273
+ # YYYY年X月(含 month 后缀)
274
+ m = re.match(r"^(\d{4})年(\d{1,2})月份?$", expr)
275
+ if m:
276
+ year = int(m.group(1))
277
+ month = int(m.group(2))
278
+ first = date(year, month, 1)
279
+ last = _last_day_of_month(year, month)
280
+ result = _range(first, last)
281
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
282
+
283
+ # May / June(仅月份)
284
+ key = expr.strip().lower()
285
+ if key in EN_MONTH:
286
+ month = EN_MONTH[key]
287
+ year = _infer_year_for_month(anchor, month)
288
+ first = date(year, month, 1)
289
+ last = _last_day_of_month(year, month)
290
+ result = _range(first, last)
291
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
292
+
293
+ # YYYYMMDD
294
+ m = re.match(r"^(\d{8})$", expr)
295
+ if m:
296
+ return _single(date(int(expr[:4]), int(expr[4:6]), int(expr[6:8])))
297
+
298
+ # === 2. 相对天数 ===
299
+
300
+ if expr in ("今天", "今日", "本日", "当天", "当日"):
301
+ return _single(anchor)
302
+ if expr in ("明天", "明日"):
303
+ return _single(anchor + timedelta(days=1))
304
+ if expr in ("昨天", "昨日"):
305
+ return _single(anchor - timedelta(days=1))
306
+ if expr in ("后天",):
307
+ return _single(anchor + timedelta(days=2))
308
+ if expr in ("前天",):
309
+ return _single(anchor - timedelta(days=2))
310
+ if expr in ("大后天",):
311
+ return _single(anchor + timedelta(days=3))
312
+ if expr in ("大前天",):
313
+ return _single(anchor - timedelta(days=3))
314
+
315
+ # 英文相对天
316
+ low = expr.lower()
317
+ if low in ("today",):
318
+ return _single(anchor)
319
+ if low in ("tomorrow", "tmr", "tmrw"):
320
+ return _single(anchor + timedelta(days=1))
321
+ if low in ("yesterday",):
322
+ return _single(anchor - timedelta(days=1))
323
+ if low in ("the day before yesterday",):
324
+ return _single(anchor - timedelta(days=2))
325
+ if low in ("the day after tomorrow",):
326
+ return _single(anchor + timedelta(days=2))
327
+
328
+ # N天后/前(中文)
329
+ m = re.match(r"^(\d+|[一二三四五六七八九十]+)天[后以之]?[后]?$", expr)
330
+ if m and "前" not in expr:
331
+ n = cn_to_num(m.group(1))
332
+ if n is not None:
333
+ return _single(anchor + timedelta(days=n))
334
+ m = re.match(r"^(\d+|[一二三四五六七八九十]+)天[前以之]?前$", expr)
335
+ if m:
336
+ n = cn_to_num(m.group(1))
337
+ if n is not None:
338
+ return _single(anchor - timedelta(days=n))
339
+ # 简化:N天后/N天前 直接匹配
340
+ m = re.match(r"^(\d+|[一二三四五六七八九十]+)天后$", expr)
341
+ if m:
342
+ n = cn_to_num(m.group(1))
343
+ if n is not None:
344
+ return _single(anchor + timedelta(days=n))
345
+ m = re.match(r"^(\d+|[一二三四五六七八九十]+)天前$", expr)
346
+ if m:
347
+ n = cn_to_num(m.group(1))
348
+ if n is not None:
349
+ return _single(anchor - timedelta(days=n))
350
+
351
+ # 一周后 / 两周后
352
+ m = re.match(r"^(\d+|[一二两三四五六七八九十]+)周[后以]", expr)
353
+ if m:
354
+ n = cn_to_num(m.group(1))
355
+ if n is not None:
356
+ return _single(anchor + timedelta(weeks=n))
357
+ m = re.match(r"^(\d+|[一二两三四五六七八九十]+)周前", expr)
358
+ if m:
359
+ n = cn_to_num(m.group(1))
360
+ if n is not None:
361
+ return _single(anchor - timedelta(weeks=n))
362
+
363
+ # N 个月后/前(取月份偏移,日相同;若超月末则裁剪到月末)
364
+ m = re.match(r"^(\d+|[一二两三四五六七八九十]+)个?月[后以]", expr)
365
+ if m:
366
+ n = cn_to_num(m.group(1))
367
+ if n is not None:
368
+ return _single(_clamp_day(_month_offset(anchor, n), anchor.day))
369
+ m = re.match(r"^(\d+|[一二两三四五六七八九十]+)个?月前", expr)
370
+ if m:
371
+ n = cn_to_num(m.group(1))
372
+ if n is not None:
373
+ return _single(_clamp_day(_month_offset(anchor, -n), anchor.day))
374
+
375
+ # === 3. 本/上/下周X ===
376
+
377
+ monday = get_monday(anchor)
378
+
379
+ # 本周末 / 这周末
380
+ if expr in ("本周末", "这周末"):
381
+ sat = monday + timedelta(days=5)
382
+ sun = monday + timedelta(days=6)
383
+ return _range(sat, sun)
384
+ if expr in ("下周末",):
385
+ sat = monday + timedelta(days=12)
386
+ sun = monday + timedelta(days=13)
387
+ return _range(sat, sun)
388
+ if expr in ("上周末",):
389
+ sat = monday + timedelta(days=-2)
390
+ sun = monday + timedelta(days=-1)
391
+ return _range(sat, sun)
392
+
393
+ # 本周/这周 (范围)
394
+ if expr in ("本周", "这周", "这一周"):
395
+ result = _range(monday, monday + timedelta(days=6))
396
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
397
+ if expr in ("上周", "上一周"):
398
+ return _range(monday - timedelta(days=7), monday - timedelta(days=1))
399
+ if expr in ("下周", "下一周"):
400
+ return _range(monday + timedelta(days=7), monday + timedelta(days=13))
401
+ if expr in ("上上周",):
402
+ return _range(monday - timedelta(days=14), monday - timedelta(days=8))
403
+ if expr in ("下下周",):
404
+ return _range(monday + timedelta(days=14), monday + timedelta(days=20))
405
+
406
+ # 周X:未显式指定上/下/下下周时,默认按本周X处理
407
+ m = re.match(r"^周([一二三四五六日天\d])$", expr)
408
+ if m:
409
+ wd = WEEKDAY_MAP.get(m.group(1))
410
+ if wd is not None:
411
+ return _single(monday + timedelta(days=wd))
412
+
413
+ # 本周X / 这周X / 这个周X
414
+ m = re.match(r"^[本这](个)?周([一二三四五六日天\d])$", expr)
415
+ if m:
416
+ wd = WEEKDAY_MAP.get(m.group(2))
417
+ if wd is not None:
418
+ return _single(monday + timedelta(days=wd))
419
+
420
+ # 上周X / 上个周X
421
+ m = re.match(r"^上(个)?周([一二三四五六日天\d])$", expr)
422
+ if m:
423
+ wd = WEEKDAY_MAP.get(m.group(2))
424
+ if wd is not None:
425
+ return _single(monday - timedelta(days=7) + timedelta(days=wd))
426
+
427
+ # 下周X / 下个周X
428
+ m = re.match(r"^下(个)?周([一二三四五六日天\d])$", expr)
429
+ if m:
430
+ wd = WEEKDAY_MAP.get(m.group(2))
431
+ if wd is not None:
432
+ return _single(monday + timedelta(days=7) + timedelta(days=wd))
433
+
434
+ # 上上周X
435
+ m = re.match(r"^上上(个)?周([一二三四五六日天\d])$", expr)
436
+ if m:
437
+ wd = WEEKDAY_MAP.get(m.group(2))
438
+ if wd is not None:
439
+ return _single(monday - timedelta(days=14) + timedelta(days=wd))
440
+
441
+ # 下下周X
442
+ m = re.match(r"^下下(个)?周([一二三四五六日天\d])$", expr)
443
+ if m:
444
+ wd = WEEKDAY_MAP.get(m.group(2))
445
+ if wd is not None:
446
+ return _single(monday + timedelta(days=14) + timedelta(days=wd))
447
+
448
+ # 上N周X / 下N周X (N=2/3/...,支持中文与阿拉伯)
449
+ m = re.match(r"^(上|下)(\d+|[一二两三四五六七八九十]+)周([一二三四五六日天\d])$", expr)
450
+ if m:
451
+ sign = -1 if m.group(1) == "上" else 1
452
+ n = cn_to_num(m.group(2))
453
+ wd = WEEKDAY_MAP.get(m.group(3))
454
+ if n is not None and wd is not None:
455
+ return _single(monday + timedelta(days=sign * 7 * n + wd))
456
+
457
+ # 上N周 / 下N周(范围)
458
+ m = re.match(r"^(上|下)(\d+|[一二两三四五六七八九十]+)周$", expr)
459
+ if m:
460
+ sign = -1 if m.group(1) == "上" else 1
461
+ n = cn_to_num(m.group(2))
462
+ if n is not None:
463
+ base = monday + timedelta(days=sign * 7 * n)
464
+ return _range(base, base + timedelta(days=6))
465
+
466
+ # === 3.5 英文 weekday ===
467
+ # last/this/next Monday 等
468
+ m = re.match(r"^(last|this|next)\s+(\w+)$", low)
469
+ if m:
470
+ rel = m.group(1)
471
+ wd = EN_WEEKDAY.get(m.group(2))
472
+ if wd is not None:
473
+ offset = {"last": -7, "this": 0, "next": 7}[rel]
474
+ return _single(monday + timedelta(days=offset + wd))
475
+ # 裸英文 weekday(如 "Monday"),按本周X
476
+ if low in EN_WEEKDAY:
477
+ wd = EN_WEEKDAY[low]
478
+ return _single(monday + timedelta(days=wd))
479
+
480
+ # === 3.6 英文相对周/月/年(范围) ===
481
+ # this/current week, last/previous week, next week
482
+ if low in ("this week", "current week", "the week"):
483
+ result = _range(monday, monday + timedelta(days=6))
484
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
485
+ if low in ("last week", "previous week", "past week"):
486
+ return _range(monday - timedelta(days=7), monday - timedelta(days=1))
487
+ if low in ("next week", "the following week"):
488
+ return _range(monday + timedelta(days=7), monday + timedelta(days=13))
489
+
490
+ # this/last/next month
491
+ if low in ("this month", "current month", "the month"):
492
+ first = anchor.replace(day=1)
493
+ last = _last_day_of_month(anchor.year, anchor.month)
494
+ result = _range(first, last)
495
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
496
+ if low in ("last month", "previous month", "past month"):
497
+ prev = _month_offset(anchor, -1)
498
+ first = prev.replace(day=1)
499
+ last = _last_day_of_month(first.year, first.month)
500
+ return _range(first, last)
501
+ if low in ("next month", "the following month"):
502
+ nxt = _month_offset(anchor, 1)
503
+ first = nxt.replace(day=1)
504
+ last = _last_day_of_month(first.year, first.month)
505
+ return _range(first, last)
506
+
507
+ # this/last/next year
508
+ if low in ("this year", "current year"):
509
+ result = _range(date(anchor.year, 1, 1), date(anchor.year, 12, 31))
510
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
511
+ if low in ("last year", "previous year"):
512
+ return _range(date(anchor.year - 1, 1, 1), date(anchor.year - 1, 12, 31))
513
+ if low in ("next year",):
514
+ return _range(date(anchor.year + 1, 1, 1), date(anchor.year + 1, 12, 31))
515
+
516
+ # first half / second half of the year
517
+ if low in ("first half", "first half year", "first half of the year", "h1"):
518
+ result = _range(date(anchor.year, 1, 1), date(anchor.year, 6, 30))
519
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
520
+ if low in ("second half", "second half year", "second half of the year", "h2"):
521
+ result = _range(date(anchor.year, 7, 1), date(anchor.year, 12, 31))
522
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
523
+
524
+ # === 3.7 英文相对天/周/月(数量 + 单位 + ago/later/from now/from today) ===
525
+ # N day(s) ago / N days later / N days from now / in N days
526
+ m = re.match(
527
+ r"^(\d+|[a-z]+)\s+(day|days|week|weeks|month|months|year|years)\s+"
528
+ r"(ago|before|earlier|later|after|from\s+now|from\s+today|hence)$",
529
+ low,
530
+ )
531
+ if m:
532
+ n = en_to_num(m.group(1))
533
+ unit = m.group(2)
534
+ direction = m.group(3)
535
+ if n is not None:
536
+ sign = -1 if direction in ("ago", "before", "earlier") else 1
537
+ if unit.startswith("day"):
538
+ return _single(anchor + timedelta(days=sign * n))
539
+ if unit.startswith("week"):
540
+ return _single(anchor + timedelta(weeks=sign * n))
541
+ if unit.startswith("month"):
542
+ return _single(_clamp_day(_month_offset(anchor, sign * n), anchor.day))
543
+ if unit.startswith("year"):
544
+ return _single(date(anchor.year + sign * n, anchor.month, min(
545
+ anchor.day, _last_day_of_month(anchor.year + sign * n, anchor.month).day
546
+ )))
547
+
548
+ # in N days/weeks/months
549
+ m = re.match(r"^in\s+(\d+|[a-z]+)\s+(day|days|week|weeks|month|months|year|years)$", low)
550
+ if m:
551
+ n = en_to_num(m.group(1))
552
+ unit = m.group(2)
553
+ if n is not None:
554
+ if unit.startswith("day"):
555
+ return _single(anchor + timedelta(days=n))
556
+ if unit.startswith("week"):
557
+ return _single(anchor + timedelta(weeks=n))
558
+ if unit.startswith("month"):
559
+ return _single(_clamp_day(_month_offset(anchor, n), anchor.day))
560
+ if unit.startswith("year"):
561
+ return _single(date(anchor.year + n, anchor.month, min(
562
+ anchor.day, _last_day_of_month(anchor.year + n, anchor.month).day
563
+ )))
564
+
565
+ # the day before yesterday / the day after tomorrow(已在前面 alias 部分处理过 yesterday/tomorrow)
566
+ if low in ("day before yesterday",):
567
+ return _single(anchor - timedelta(days=2))
568
+ if low in ("day after tomorrow",):
569
+ return _single(anchor + timedelta(days=2))
570
+
571
+ # === 3.8 英文 月份+日(含/不含年份) ===
572
+ # 支持: "May 5", "May 5th", "May 5, 2026", "May 5 2026", "5 May", "5th May 2026", "Jan 31"
573
+ # 序数后缀 st/nd/rd/th 直接吃掉
574
+ low_clean = re.sub(r"(\d+)(st|nd|rd|th)\b", r"\1", low)
575
+
576
+ # Month Day[, Year]
577
+ m = re.match(
578
+ r"^([a-z]+)\.?\s+(\d{1,2})(?:\s*,\s*|\s+)?(\d{4})?$", low_clean
579
+ )
580
+ if m and m.group(1) in EN_MONTH:
581
+ month = EN_MONTH[m.group(1)]
582
+ day = int(m.group(2))
583
+ year = int(m.group(3)) if m.group(3) else _infer_year_for_month(anchor, month)
584
+ try:
585
+ return _single(date(year, month, day))
586
+ except ValueError:
587
+ pass
588
+
589
+ # Day Month[, Year]
590
+ m = re.match(
591
+ r"^(\d{1,2})\s+([a-z]+)\.?(?:\s*,\s*|\s+)?(\d{4})?$", low_clean
592
+ )
593
+ if m and m.group(2) in EN_MONTH:
594
+ month = EN_MONTH[m.group(2)]
595
+ day = int(m.group(1))
596
+ year = int(m.group(3)) if m.group(3) else _infer_year_for_month(anchor, month)
597
+ try:
598
+ return _single(date(year, month, day))
599
+ except ValueError:
600
+ pass
601
+
602
+ # === 4. 月份相关 ===
603
+
604
+ # 本月 / 这个月
605
+ if expr in ("本月", "这个月", "当月"):
606
+ first = anchor.replace(day=1)
607
+ last = _last_day_of_month(anchor.year, anchor.month)
608
+ result = _range(first, last)
609
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
610
+
611
+ # 上月 / 上个月
612
+ if expr in ("上月", "上个月"):
613
+ prev = _month_offset(anchor, -1)
614
+ first = prev.replace(day=1)
615
+ last = _last_day_of_month(first.year, first.month)
616
+ return _range(first, last)
617
+
618
+ # 下月 / 下个月
619
+ if expr in ("下月", "下个月"):
620
+ nxt = _month_offset(anchor, 1)
621
+ first = nxt.replace(day=1)
622
+ last = _last_day_of_month(first.year, first.month)
623
+ return _range(first, last)
624
+
625
+ # 下个月N号 / 下月N号 / 下个月N日
626
+ m = re.match(r"^下(个)?月(\d{1,2}|[一二三四五六七八九十二十三十]+)[号日]?$", expr)
627
+ if m:
628
+ day_num = cn_to_num(m.group(2))
629
+ if day_num is not None:
630
+ nxt = _month_offset(anchor, 1)
631
+ return _single(date(nxt.year, nxt.month, day_num))
632
+
633
+ # 上个月N号 / 上月N号
634
+ m = re.match(r"^上(个)?月(\d{1,2}|[一二三四五六七八九十二十三十]+)[号日]?$", expr)
635
+ if m:
636
+ day_num = cn_to_num(m.group(2))
637
+ if day_num is not None:
638
+ prev = _month_offset(anchor, -1)
639
+ return _single(date(prev.year, prev.month, day_num))
640
+
641
+ # 本月N号 / 这个月N号
642
+ m = re.match(r"^[本这](个)?月(\d{1,2}|[一二三四五六七八九十二十三十]+)[号日]?$", expr)
643
+ if m:
644
+ day_num = cn_to_num(m.group(2))
645
+ if day_num is not None:
646
+ return _single(date(anchor.year, anchor.month, day_num))
647
+
648
+ # 月初 / 月末 / 月底
649
+ if expr in ("月初",):
650
+ return _single(anchor.replace(day=1))
651
+ if expr in ("月末", "月底"):
652
+ return _single(_last_day_of_month(anchor.year, anchor.month))
653
+ if expr in ("上月初", "上个月初"):
654
+ prev = _month_offset(anchor, -1)
655
+ return _single(prev.replace(day=1))
656
+ if expr in ("上月末", "上月底", "上个月末", "上个月底"):
657
+ prev = _month_offset(anchor, -1)
658
+ return _single(_last_day_of_month(prev.year, prev.month))
659
+ if expr in ("下月初", "下个月初"):
660
+ nxt = _month_offset(anchor, 1)
661
+ return _single(nxt.replace(day=1))
662
+ if expr in ("下月末", "下月底", "下个月末", "下个月底"):
663
+ nxt = _month_offset(anchor, 1)
664
+ return _single(_last_day_of_month(nxt.year, nxt.month))
665
+
666
+ # 年初/年末
667
+ if expr in ("年初", "今年初"):
668
+ return _single(date(anchor.year, 1, 1))
669
+ if expr in ("年末", "年底", "今年末", "今年底"):
670
+ return _single(date(anchor.year, 12, 31))
671
+
672
+ # === 5. 年份相关 ===
673
+
674
+ if expr in ("今年", "本年", "本年度"):
675
+ result = _range(date(anchor.year, 1, 1), date(anchor.year, 12, 31))
676
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
677
+ if expr in ("明年", "下一年"):
678
+ return _range(date(anchor.year + 1, 1, 1), date(anchor.year + 1, 12, 31))
679
+ if expr in ("去年", "上一年"):
680
+ return _range(date(anchor.year - 1, 1, 1), date(anchor.year - 1, 12, 31))
681
+
682
+ # 上半年 / 下半年
683
+ if expr in ("上半年",):
684
+ result = _range(date(anchor.year, 1, 1), date(anchor.year, 6, 30))
685
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
686
+ if expr in ("下半年",):
687
+ result = _range(date(anchor.year, 7, 1), date(anchor.year, 12, 31))
688
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
689
+
690
+ # 明年N月 / 明年N月N日
691
+ m = re.match(r"^明年(\d{1,2})月(\d{1,2})?[日号]?$", expr)
692
+ if m:
693
+ month = int(m.group(1))
694
+ if m.group(2):
695
+ return _single(date(anchor.year + 1, month, int(m.group(2))))
696
+ else:
697
+ first = date(anchor.year + 1, month, 1)
698
+ last = _last_day_of_month(first.year, first.month)
699
+ result = _range(first, last)
700
+ return _maybe_clamp_range(result, anchor, clamp_to_anchor)
701
+
702
+ # === 6. 本月第N个周X ===
703
+ m = re.match(r"^本月第([一二三四五\d])个?周([一二三四五六日天\d])$", expr)
704
+ if m:
705
+ nth = cn_to_num(m.group(1))
706
+ wd = WEEKDAY_MAP.get(m.group(2))
707
+ if nth is not None and wd is not None:
708
+ result = _nth_weekday_of_month(anchor.year, anchor.month, wd, nth)
709
+ if result:
710
+ return _single(result)
711
+
712
+ # 下个月第N个周X
713
+ m = re.match(r"^下(个)?月第([一二三四五\d])个?周([一二三四五六日天\d])$", expr)
714
+ if m:
715
+ nth = cn_to_num(m.group(2))
716
+ wd = WEEKDAY_MAP.get(m.group(3))
717
+ if nth is not None and wd is not None:
718
+ nxt = _month_offset(anchor, 1)
719
+ result = _nth_weekday_of_month(nxt.year, nxt.month, wd, nth)
720
+ if result:
721
+ return _single(result)
722
+
723
+ # === 无法解析 ===
724
+ return {"success": False, "reason": f"无法解析表达式 '{expr}',不在已知模式中"}
725
+
726
+
727
+ def _infer_year_for_month(anchor: date, month: int) -> int:
728
+ """无年份月份推断:默认当年;若 month > anchor.month,回退为去年同月。"""
729
+ return anchor.year - 1 if month > anchor.month else anchor.year
730
+
731
+
732
+ def _maybe_clamp_range(result: Dict, anchor: date, clamp_to_anchor: bool) -> Dict:
733
+ """若需要,将 range 的结束日期裁剪到 anchor(用于“本月/本周/某月”等避免包含未来)。"""
734
+ if not clamp_to_anchor:
735
+ return result
736
+ if not (result.get("success") and result.get("type") == "range"):
737
+ return result
738
+
739
+ d_from = date.fromisoformat(result["resolved_date_from"])
740
+ d_to = date.fromisoformat(result["resolved_date_to"])
741
+ if d_to <= anchor:
742
+ return result
743
+ if anchor < d_from:
744
+ return {
745
+ "success": False,
746
+ "reason": "解析出的日期范围全部在未来(相对 anchor)",
747
+ }
748
+
749
+ clamped = _range(d_from, anchor)
750
+ clamped["clamped_to_anchor"] = True
751
+ return clamped
752
+
753
+
754
+ def _extract_weekday_exprs(expr: str) -> List[str]:
755
+ """从完整句子中提取周X类日期表达;裸周X默认本周X。"""
756
+ patterns = [
757
+ r"(?:上上|下下|上|下|本|这)(?:个)?周[一二三四五六日天\d]",
758
+ r"周[一二三四五六日天\d]",
759
+ ]
760
+ found: List[str] = []
761
+ occupied: List[Tuple[int, int]] = []
762
+
763
+ for pattern in patterns:
764
+ for match in re.finditer(pattern, expr):
765
+ span = match.span()
766
+ if any(not (span[1] <= start or span[0] >= end) for start, end in occupied):
767
+ continue
768
+ found.append(match.group(0))
769
+ occupied.append(span)
770
+
771
+ return found
772
+
773
+
774
+ def _single(d: date, anchor: Optional[date] = None) -> Dict:
775
+ """构造单日期成功结果"""
776
+ out = {
777
+ "success": True,
778
+ "type": "single",
779
+ "resolved_date": d.isoformat(),
780
+ "resolved_weekday": WEEKDAY_NAMES[d.weekday()],
781
+ }
782
+ if anchor is not None:
783
+ out["is_future"] = d > anchor
784
+ out["is_past"] = d < anchor
785
+ return out
786
+
787
+
788
+ def _range(d_from: date, d_to: date, anchor: Optional[date] = None) -> Dict:
789
+ """构造日期范围成功结果"""
790
+ out = {
791
+ "success": True,
792
+ "type": "range",
793
+ "resolved_date_from": d_from.isoformat(),
794
+ "resolved_date_from_weekday": WEEKDAY_NAMES[d_from.weekday()],
795
+ "resolved_date_to": d_to.isoformat(),
796
+ "resolved_date_to_weekday": WEEKDAY_NAMES[d_to.weekday()],
797
+ }
798
+ if anchor is not None:
799
+ out["fully_in_future"] = d_from > anchor
800
+ out["partially_in_future"] = d_to > anchor and d_from <= anchor
801
+ return out
802
+
803
+
804
+ def _last_day_of_month(year: int, month: int) -> date:
805
+ """获取某月最后一天"""
806
+ if month == 12:
807
+ return date(year, 12, 31)
808
+ return date(year, month + 1, 1) - timedelta(days=1)
809
+
810
+
811
+ def _clamp_day(month_first: date, day: int) -> date:
812
+ """把 (year, month) 月内的某天 clamp 到合法范围(处理 1/31 → 2/28 等)。"""
813
+ last = _last_day_of_month(month_first.year, month_first.month)
814
+ return date(month_first.year, month_first.month, min(day, last.day))
815
+
816
+
817
+ def _month_offset(d: date, offset: int) -> date:
818
+ """月份偏移,返回目标月的第一天"""
819
+ month = d.month + offset
820
+ year = d.year
821
+ while month > 12:
822
+ month -= 12
823
+ year += 1
824
+ while month < 1:
825
+ month += 12
826
+ year -= 1
827
+ return date(year, month, 1)
828
+
829
+
830
+ def _nth_weekday_of_month(year: int, month: int, weekday: int, nth: int) -> Optional[date]:
831
+ """获取某月第 nth 个星期 weekday(weekday: 0=周一, 6=周日)"""
832
+ first = date(year, month, 1)
833
+ # 找到该月第一个目标 weekday
834
+ diff = (weekday - first.weekday()) % 7
835
+ first_target = first + timedelta(days=diff)
836
+ # 第 nth 个
837
+ result = first_target + timedelta(weeks=nth - 1)
838
+ if result.month != month:
839
+ return None
840
+ return result
841
+
842
+
843
+ def main():
844
+ parser = argparse.ArgumentParser(description="解析中文日期表达式为绝对日期")
845
+ parser.add_argument("--expr", required=True, help="用户日期表达,如'下周六'、'下个月3号'")
846
+ parser.add_argument("--anchor", required=True, help="锚点日期,YYYY-MM-DD格式,通常为今天")
847
+ parser.add_argument(
848
+ "--clamp-to-anchor",
849
+ action="store_true",
850
+ help="若解析出日期范围,将结束日期裁剪到 anchor(避免包含未来日期)",
851
+ )
852
+ args = parser.parse_args()
853
+
854
+ try:
855
+ anchor = date.fromisoformat(args.anchor)
856
+ except ValueError:
857
+ result = {
858
+ "success": False,
859
+ "reason": f"锚点日期格式错误: '{args.anchor}',需要 YYYY-MM-DD",
860
+ }
861
+ print(json.dumps(result, ensure_ascii=False))
862
+ sys.exit(1)
863
+
864
+ result = resolve(args.expr, anchor, clamp_to_anchor=args.clamp_to_anchor)
865
+
866
+ # 附加锚点信息
867
+ result["anchor_date"] = anchor.isoformat()
868
+ result["anchor_weekday"] = WEEKDAY_NAMES[anchor.weekday()]
869
+ result["expression"] = args.expr
870
+ result["clamp_to_anchor"] = bool(args.clamp_to_anchor)
871
+ if result["success"]:
872
+ result["method"] = "rule_based"
873
+ # 统一附加 is_future / fully_in_future / partially_in_future
874
+ if result.get("type") == "single":
875
+ d = date.fromisoformat(result["resolved_date"])
876
+ result["is_future"] = d > anchor
877
+ result["is_past"] = d < anchor
878
+ elif result.get("type") == "range":
879
+ d_from = date.fromisoformat(result["resolved_date_from"])
880
+ d_to = date.fromisoformat(result["resolved_date_to"])
881
+ result["fully_in_future"] = d_from > anchor
882
+ result["partially_in_future"] = d_to > anchor and d_from <= anchor
883
+ elif result.get("type") == "multiple":
884
+ for item in result.get("resolved_dates", []):
885
+ d = date.fromisoformat(item["resolved_date"])
886
+ item["is_future"] = d > anchor
887
+ item["is_past"] = d < anchor
888
+
889
+ print(json.dumps(result, ensure_ascii=False))
890
+ sys.exit(0 if result["success"] else 1)
891
+
892
+
893
+ if __name__ == "__main__":
894
+ main()