sophhub 0.2.2 → 0.2.4

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.
Files changed (109) hide show
  1. package/package.json +1 -1
  2. package/skills/consensus/skill.json +20 -0
  3. package/skills/consensus/src/SKILL.md +93 -0
  4. package/skills/deepwiki/skill.json +20 -0
  5. package/skills/deepwiki/src/SKILL.md +45 -0
  6. package/skills/deepwiki/src/_meta.json +6 -0
  7. package/skills/deepwiki/src/scripts/deepwiki.js +135 -0
  8. package/skills/feishu-bitable/skill.json +20 -0
  9. package/skills/feishu-bitable/src/CHECKLIST.md +150 -0
  10. package/skills/feishu-bitable/src/README.md +178 -0
  11. package/skills/feishu-bitable/src/SKILL.md +113 -0
  12. package/skills/feishu-bitable/src/_meta.json +6 -0
  13. package/skills/feishu-bitable/src/api.js +381 -0
  14. package/skills/feishu-bitable/src/bin/cli.js +284 -0
  15. package/skills/feishu-bitable/src/description.md +143 -0
  16. package/skills/feishu-bitable/src/examples/create-records.json +52 -0
  17. package/skills/feishu-bitable/src/examples/create-table.json +64 -0
  18. package/skills/feishu-bitable/src/package-lock.json +324 -0
  19. package/skills/feishu-bitable/src/package.json +33 -0
  20. package/skills/feishu-bitable/src/publish-config.json +14 -0
  21. package/skills/feishu-bitable/src/test-simple.js +61 -0
  22. package/skills/feishu-bitable/src/utils.js +261 -0
  23. package/skills/flight-booking/skill.json +9 -2
  24. package/skills/flight-booking/src/scripts/flight_booking.py +2 -1
  25. package/skills/google-maps/skill.json +20 -0
  26. package/skills/google-maps/src/SKILL.md +237 -0
  27. package/skills/google-maps/src/_meta.json +6 -0
  28. package/skills/google-maps/src/lib/map_helper.py +912 -0
  29. package/skills/large-task-router/skill.json +20 -0
  30. package/skills/large-task-router/src/SKILL.md +79 -0
  31. package/skills/large-task-router/src/templates/plan.md +74 -0
  32. package/skills/skillhub/skill.json +11 -4
  33. package/skills/skillhub/src/SKILL.md +11 -1
  34. package/skills/sophnet-dailynews/skill.json +20 -0
  35. package/skills/sophnet-dailynews/src/SKILL.md +179 -0
  36. package/skills/sophnet-dailynews/src/cache.json +151 -0
  37. package/skills/sophnet-dailynews/src/sources.json +230 -0
  38. package/skills/sophnet-schedule/skill.json +20 -0
  39. package/skills/sophnet-schedule/src/ARCHITECTURE.md +321 -0
  40. package/skills/sophnet-schedule/src/IMPROVEMENTS.md +145 -0
  41. package/skills/sophnet-schedule/src/SKILL.md +1050 -0
  42. package/skills/sophnet-schedule/src/_meta.json +6 -0
  43. package/skills/sophnet-schedule/src/api/__init__.py +0 -0
  44. package/skills/sophnet-schedule/src/api/models.py +245 -0
  45. package/skills/sophnet-schedule/src/apps/add_event.py +237 -0
  46. package/skills/sophnet-schedule/src/apps/check_reminders.py +112 -0
  47. package/skills/sophnet-schedule/src/apps/check_roc.py +246 -0
  48. package/skills/sophnet-schedule/src/apps/generate_daily_plan.py +342 -0
  49. package/skills/sophnet-schedule/src/apps/import_events.py +216 -0
  50. package/skills/sophnet-schedule/src/apps/monitor_calendar_changes.py +140 -0
  51. package/skills/sophnet-schedule/src/apps/register_tasks.py +169 -0
  52. package/skills/sophnet-schedule/src/apps/sync_roc_to_gcal.py +174 -0
  53. package/skills/sophnet-schedule/src/compat.py +66 -0
  54. package/skills/sophnet-schedule/src/config/__init__.py +0 -0
  55. package/skills/sophnet-schedule/src/config/reminder_rules.yaml +96 -0
  56. package/skills/sophnet-schedule/src/config/roc_events.yaml +44 -0
  57. package/skills/sophnet-schedule/src/config/settings.py +133 -0
  58. package/skills/sophnet-schedule/src/config/task_registry.yaml +92 -0
  59. package/skills/sophnet-schedule/src/docs/FRONTEND_INTEGRATION_GUIDE.md +437 -0
  60. package/skills/sophnet-schedule/src/gcal/__init__.py +0 -0
  61. package/skills/sophnet-schedule/src/gcal/client.py +374 -0
  62. package/skills/sophnet-schedule/src/gcal/models.py +91 -0
  63. package/skills/sophnet-schedule/src/requirements.txt +6 -0
  64. package/skills/sophnet-schedule/src/scripts/setup_gcal_token.py +85 -0
  65. package/skills/sophnet-schedule/src/server.py +669 -0
  66. package/skills/sophnet-schedule/src/services/__init__.py +0 -0
  67. package/skills/sophnet-schedule/src/services/calendar_backend.py +139 -0
  68. package/skills/sophnet-schedule/src/services/conflict_detector.py +96 -0
  69. package/skills/sophnet-schedule/src/services/datetime_utils.py +117 -0
  70. package/skills/sophnet-schedule/src/services/event_classifier.py +100 -0
  71. package/skills/sophnet-schedule/src/services/event_diff.py +160 -0
  72. package/skills/sophnet-schedule/src/services/google_integration.py +500 -0
  73. package/skills/sophnet-schedule/src/services/job_store.py +100 -0
  74. package/skills/sophnet-schedule/src/services/local_event_store.py +266 -0
  75. package/skills/sophnet-schedule/src/services/reminder_planner.py +116 -0
  76. package/skills/sophnet-schedule/src/services/runtime_utils.py +31 -0
  77. package/skills/sophnet-schedule/src/services/table_parser.py +286 -0
  78. package/skills/sophnet-schedule/src/services/task_builder.py +167 -0
  79. package/skills/sophnet-schedule/src/services/time_window.py +72 -0
  80. package/skills/sophnet-stock/skill.json +20 -0
  81. package/skills/sophnet-stock/src/App-Plan.md +442 -0
  82. package/skills/sophnet-stock/src/README.md +214 -0
  83. package/skills/sophnet-stock/src/SKILL.md +236 -0
  84. package/skills/sophnet-stock/src/TODO.md +394 -0
  85. package/skills/sophnet-stock/src/_meta.json +6 -0
  86. package/skills/sophnet-stock/src/docs/ARCHITECTURE.md +408 -0
  87. package/skills/sophnet-stock/src/docs/CONCEPT.md +233 -0
  88. package/skills/sophnet-stock/src/docs/HOT_SCANNER.md +288 -0
  89. package/skills/sophnet-stock/src/docs/README.md +95 -0
  90. package/skills/sophnet-stock/src/docs/USAGE.md +465 -0
  91. package/skills/sophnet-stock/src/scripts/analyze_stock.py +2565 -0
  92. package/skills/sophnet-stock/src/scripts/dividends.py +365 -0
  93. package/skills/sophnet-stock/src/scripts/hot_scanner.py +582 -0
  94. package/skills/sophnet-stock/src/scripts/portfolio.py +548 -0
  95. package/skills/sophnet-stock/src/scripts/rumor_scanner.py +342 -0
  96. package/skills/sophnet-stock/src/scripts/test_stock_analysis.py +409 -0
  97. package/skills/sophnet-stock/src/scripts/watchlist.py +336 -0
  98. package/skills/xiaohongshu/skill.json +20 -0
  99. package/skills/xiaohongshu/src/SKILL.md +91 -0
  100. package/skills/xiaohongshu/src/_meta.json +6 -0
  101. package/skills/xiaohongshu/src/assets/card.html +216 -0
  102. package/skills/xiaohongshu/src/assets/cover.html +82 -0
  103. package/skills/xiaohongshu/src/assets/example.md +84 -0
  104. package/skills/xiaohongshu/src/assets/styles.css +318 -0
  105. package/skills/xiaohongshu/src/scripts/render_xhs_v2.py +737 -0
  106. package/skills/xiaohongshu/src/scripts/sign_server.py +158 -0
  107. package/skills/xiaohongshu/src/scripts/stealth.min.js +7 -0
  108. package/skills/xiaohongshu/src/scripts/xhs_tool.py +186 -0
  109. package/skills/xiaohongshu/src/workflow.py +185 -0
@@ -0,0 +1,139 @@
1
+ """
2
+ 轻量日历后端选择器。
3
+
4
+ 不引入完整 provider 抽象层,只集中管理:
5
+ - 当前模式判断
6
+ - 事件读取
7
+ - 事件创建
8
+ - 事件删除
9
+ - Google 同步触发
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+
17
+ from config.settings import (
18
+ GCAL_CALENDAR_ID,
19
+ GCAL_MAX_RETRIES,
20
+ GCAL_SSL_VERIFY,
21
+ GCAL_TIMEOUT,
22
+ GCAL_TOKEN_PATH,
23
+ PROXIES,
24
+ TIMEZONE,
25
+ )
26
+ from gcal.client import CalendarClient
27
+ from gcal.models import CalendarEvent
28
+ from services import local_event_store
29
+ from services.google_integration import get_active_mode, sync_local_events_to_google
30
+
31
+
32
+ @dataclass
33
+ class EventView:
34
+ event: CalendarEvent
35
+ provider: str
36
+ sync_state: str = ""
37
+
38
+
39
+ @dataclass
40
+ class EventCreateResult:
41
+ provider: str
42
+ event_id: str
43
+ sync_state: str = ""
44
+
45
+
46
+ def _new_google_client() -> CalendarClient:
47
+ return CalendarClient(
48
+ token_path=GCAL_TOKEN_PATH,
49
+ proxies=PROXIES,
50
+ calendar_id=GCAL_CALENDAR_ID,
51
+ timeout=GCAL_TIMEOUT,
52
+ max_retries=GCAL_MAX_RETRIES,
53
+ verify=GCAL_SSL_VERIFY,
54
+ )
55
+
56
+
57
+ def active_provider() -> str:
58
+ return get_active_mode()
59
+
60
+
61
+ def list_event_views(time_min: datetime, time_max: datetime) -> list[EventView]:
62
+ provider = active_provider()
63
+ if provider == "google":
64
+ client = _new_google_client()
65
+ return [
66
+ EventView(event=event, provider="google", sync_state="")
67
+ for event in client.list_events(time_min, time_max)
68
+ ]
69
+
70
+ records = local_event_store.list_records(time_min=time_min, time_max=time_max)
71
+ return [
72
+ EventView(
73
+ event=local_event_store.record_to_event(record),
74
+ provider="local",
75
+ sync_state=record.get("sync_state", "local_only"),
76
+ )
77
+ for record in records
78
+ ]
79
+
80
+
81
+ def list_events(time_min: datetime, time_max: datetime) -> list[CalendarEvent]:
82
+ return [item.event for item in list_event_views(time_min, time_max)]
83
+
84
+
85
+ def create_event(
86
+ summary: str,
87
+ start: datetime,
88
+ end: datetime,
89
+ location: str = "",
90
+ description: str = "",
91
+ remind_minutes: int = 0,
92
+ ) -> EventCreateResult:
93
+ provider = active_provider()
94
+ if provider == "google":
95
+ client = _new_google_client()
96
+ event_id = client.create_event(
97
+ summary=summary,
98
+ start=start,
99
+ end=end,
100
+ location=location,
101
+ description=description,
102
+ tz=TIMEZONE,
103
+ )
104
+ return EventCreateResult(provider="google", event_id=event_id, sync_state="")
105
+
106
+ record = local_event_store.create_event(
107
+ summary=summary,
108
+ start=start,
109
+ end=end,
110
+ location=location,
111
+ description=description,
112
+ remind_minutes=remind_minutes,
113
+ )
114
+ return EventCreateResult(
115
+ provider="local",
116
+ event_id=record["id"],
117
+ sync_state=record.get("sync_state", "local_only"),
118
+ )
119
+
120
+
121
+ def delete_event(event_id: str) -> bool:
122
+ provider = active_provider()
123
+ if event_id.startswith("local_") or provider == "local":
124
+ return local_event_store.delete_event(event_id)
125
+ client = _new_google_client()
126
+ client.delete_event(event_id)
127
+ return True
128
+
129
+
130
+ def get_event_status(event_id: str) -> str | None:
131
+ provider = active_provider()
132
+ if event_id.startswith("local_") or provider == "local":
133
+ return local_event_store.get_event_status(event_id)
134
+ client = _new_google_client()
135
+ return client.get_event_status(event_id)
136
+
137
+
138
+ def sync_local_events() -> dict:
139
+ return sync_local_events_to_google()
@@ -0,0 +1,96 @@
1
+ """
2
+ 日程冲突检测器。
3
+
4
+ 检测同一批事件中时间重叠的事件对。全天事件不参与检测。
5
+
6
+ 规则:
7
+ - A.start < B.end AND A.end > B.start → 有重叠
8
+ - 紧接场景(间隔 < 5 分钟)→ 额外警告(is_tight=True)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from datetime import timedelta
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).parent.parent))
19
+
20
+ from gcal.models import CalendarEvent
21
+
22
+ _TIGHT_MINUTES = 5 # 间隔小于此值视为"紧接"
23
+
24
+
25
+ @dataclass
26
+ class ConflictPair:
27
+ a: CalendarEvent
28
+ b: CalendarEvent
29
+ overlap_minutes: int # 0 表示紧接但不重叠
30
+ is_tight: bool = False # True = 无重叠但间隔 < 5 分钟
31
+
32
+ def format_text(self) -> str:
33
+ ta = f"{self.a.start.strftime('%H:%M')}–{self.a.end.strftime('%H:%M')}"
34
+ tb = f"{self.b.start.strftime('%H:%M')}–{self.b.end.strftime('%H:%M')}"
35
+ if self.is_tight:
36
+ gap = int((self.b.start - self.a.end).total_seconds() / 60)
37
+ return (
38
+ f"⚡ 紧接:【{self.a.summary}】({ta}) 结束后仅 {gap} 分钟"
39
+ f"【{self.b.summary}】({tb}) 就开始"
40
+ )
41
+ return (
42
+ f"⚠️ 冲突:【{self.a.summary}】({ta})"
43
+ f" 与 【{self.b.summary}】({tb})"
44
+ f" 重叠 {self.overlap_minutes} 分钟"
45
+ )
46
+
47
+
48
+ def detect_conflicts(
49
+ events: list[CalendarEvent],
50
+ tight_minutes: int = _TIGHT_MINUTES,
51
+ ) -> list[ConflictPair]:
52
+ """
53
+ 检测时间重叠或紧接的事件对。
54
+
55
+ 返回结果按事件开始时间升序排列。
56
+ 仅检测有确定时间的事件(跳过全天事件)。
57
+ """
58
+ timed = [
59
+ e for e in events
60
+ if not e.is_all_day and e.start is not None and e.end is not None
61
+ ]
62
+ timed.sort(key=lambda e: e.start)
63
+
64
+ pairs: list[ConflictPair] = []
65
+ for i in range(len(timed)):
66
+ for j in range(i + 1, len(timed)):
67
+ a, b = timed[i], timed[j]
68
+
69
+ # b 开始已超过 a 结束 + tight window → 不可能再有冲突/紧接
70
+ if b.start >= a.end + timedelta(minutes=tight_minutes):
71
+ break
72
+
73
+ if b.start < a.end:
74
+ # 有重叠
75
+ overlap_start = max(a.start, b.start)
76
+ overlap_end = min(a.end, b.end)
77
+ overlap_min = int((overlap_end - overlap_start).total_seconds() / 60)
78
+ if overlap_min > 0:
79
+ pairs.append(ConflictPair(a=a, b=b, overlap_minutes=overlap_min))
80
+ else:
81
+ # 无重叠但间隔 < tight_minutes
82
+ pairs.append(ConflictPair(a=a, b=b, overlap_minutes=0, is_tight=True))
83
+
84
+ return pairs
85
+
86
+
87
+ def format_conflicts_section(pairs: list[ConflictPair]) -> str:
88
+ """将冲突列表格式化为 Markdown 通知文本。"""
89
+ if not pairs:
90
+ return ""
91
+ lines = ["⚠️ **发现日程冲突:**", ""]
92
+ for p in pairs:
93
+ lines.append(f"- {p.format_text()}")
94
+ lines.append("")
95
+ lines.append("建议:检查 Google Calendar 并调整时间安排。")
96
+ return "\n".join(lines)
@@ -0,0 +1,117 @@
1
+ """
2
+ 共享的时间解析与时区工具。
3
+
4
+ 统一处理:
5
+ - 本地业务时区下的“今天/今年”
6
+ - 本地日期 + 时间组合
7
+ - offset-less ISO 时间按业务时区解释
8
+ - 跨午夜结束时间
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime, timedelta
14
+
15
+ from compat import ZoneInfo, make_aware
16
+
17
+ _DEFAULT_TZ = "Asia/Shanghai"
18
+
19
+
20
+ class DateTimeValidationError(ValueError):
21
+ """时间输入不合法。"""
22
+
23
+
24
+ def now_local(tz: str = _DEFAULT_TZ) -> datetime:
25
+ return datetime.now(ZoneInfo(tz))
26
+
27
+
28
+ def current_date_str(tz: str = _DEFAULT_TZ) -> str:
29
+ return now_local(tz).strftime("%Y-%m-%d")
30
+
31
+
32
+ def current_year(tz: str = _DEFAULT_TZ) -> int:
33
+ return now_local(tz).year
34
+
35
+
36
+ def parse_local_date(date_str: str, tz: str = _DEFAULT_TZ) -> datetime:
37
+ try:
38
+ dt = datetime.strptime(date_str.strip(), "%Y-%m-%d")
39
+ except ValueError as exc:
40
+ raise DateTimeValidationError(
41
+ f"日期格式错误:'{date_str}',应为 YYYY-MM-DD"
42
+ ) from exc
43
+ return make_aware(dt, tz)
44
+
45
+
46
+ def parse_local_time(time_str: str, base_date: datetime) -> datetime:
47
+ raw = time_str.strip()
48
+ for fmt in ("%H:%M", "%H:%M:%S"):
49
+ try:
50
+ parsed = datetime.strptime(raw, fmt)
51
+ return base_date.replace(
52
+ hour=parsed.hour,
53
+ minute=parsed.minute,
54
+ second=0,
55
+ microsecond=0,
56
+ )
57
+ except ValueError:
58
+ continue
59
+ raise DateTimeValidationError(f"时间格式错误:'{time_str}',应为 HH:MM")
60
+
61
+
62
+ def build_event_range(
63
+ date_str: str,
64
+ start_str: str,
65
+ end_str: str | None = None,
66
+ duration_minutes: int = 60,
67
+ tz: str = _DEFAULT_TZ,
68
+ ) -> tuple[datetime, datetime]:
69
+ if duration_minutes <= 0:
70
+ raise DateTimeValidationError("持续时间必须大于 0 分钟")
71
+
72
+ base = parse_local_date(date_str, tz)
73
+ start_dt = parse_local_time(start_str, base)
74
+
75
+ if end_str:
76
+ end_dt = parse_local_time(end_str, base)
77
+ if end_dt <= start_dt:
78
+ end_dt = end_dt + timedelta(days=1)
79
+ else:
80
+ end_dt = start_dt + timedelta(minutes=duration_minutes)
81
+
82
+ return start_dt, end_dt
83
+
84
+
85
+ def parse_iso_datetime(
86
+ value: str,
87
+ tz: str = _DEFAULT_TZ,
88
+ assume_local_if_naive: bool = True,
89
+ ) -> datetime:
90
+ raw = value.strip()
91
+ if not raw:
92
+ raise DateTimeValidationError("时间不能为空")
93
+
94
+ try:
95
+ dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
96
+ except ValueError as exc:
97
+ raise DateTimeValidationError(
98
+ f"时间格式错误:'{value}',应为 ISO 8601"
99
+ ) from exc
100
+
101
+ if dt.tzinfo is None:
102
+ if not assume_local_if_naive:
103
+ raise DateTimeValidationError(
104
+ f"时间缺少时区偏移:'{value}',请使用 ISO 8601 带时区格式"
105
+ )
106
+ return make_aware(dt, tz)
107
+
108
+ return dt
109
+
110
+
111
+ def local_date_window(date_str: str, tz: str = _DEFAULT_TZ) -> tuple[datetime, datetime]:
112
+ start = parse_local_date(date_str, tz)
113
+ return start, start + timedelta(days=1)
114
+
115
+
116
+ def local_date_label(dt: datetime, tz: str = _DEFAULT_TZ) -> str:
117
+ return dt.astimezone(ZoneInfo(tz)).strftime("%Y-%m-%d")
@@ -0,0 +1,100 @@
1
+ """
2
+ 事件分类器。
3
+
4
+ 根据事件标题关键词判断类型(sport / meal / family / medical / appointment)。
5
+ 分类规则从 config/reminder_rules.yaml 加载,支持运行时覆盖。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent.parent))
15
+
16
+ import yaml
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _DEFAULT_RULES_PATH = Path(__file__).parent.parent / "config" / "reminder_rules.yaml"
21
+
22
+ # yaml 不可用时的内置 fallback
23
+ _BUILTIN_KEYWORDS: dict[str, list[str]] = {
24
+ "sport": ["跑步", "晨跑", "健身", "游泳", "打球", "羽毛球", "乒乓球", "网球", "篮球", "足球", "瑜伽"],
25
+ "meal": ["聚餐", "饭局", "晚餐", "午餐", "火锅", "烧烤", "下午茶"],
26
+ "family": ["亲子", "接送", "接孩子", "送孩子", "家长会", "探望", "探亲", "回家"],
27
+ "medical": ["体检", "看病", "就医", "门诊", "复诊", "牙医", "口腔", "打疫苗"],
28
+ "appointment": ["预约", "约定", "美发", "理发", "活动", "讲座", "展览", "旅行", "课程"],
29
+ }
30
+ _BUILTIN_DEFAULT = "appointment"
31
+
32
+
33
+ def _load_rules(path: Path) -> dict:
34
+ try:
35
+ with open(path, encoding="utf-8") as f:
36
+ return yaml.safe_load(f) or {}
37
+ except Exception as e:
38
+ logger.warning(f"加载 reminder_rules.yaml 失败,使用内置规则: {e}")
39
+ return {}
40
+
41
+
42
+ class EventClassifier:
43
+ """
44
+ 事件分类器。
45
+
46
+ 用法:
47
+ clf = EventClassifier()
48
+ clf.classify("跑步晨练") # → "sport"
49
+ clf.classify("家庭聚餐") # → "meal"
50
+ clf.classify("牙医预约") # → "medical"
51
+ clf.classify("接孩子放学") # → "family"
52
+ clf.classify("理发") # → "appointment"
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ rules_path: Path | None = None,
58
+ keywords: dict[str, list[str]] | None = None,
59
+ default_type: str | None = None,
60
+ ):
61
+ data = _load_rules(rules_path or _DEFAULT_RULES_PATH)
62
+
63
+ # 优先级:外部传入 > yaml > 内置
64
+ if keywords:
65
+ self.keywords = keywords
66
+ elif data.get("keywords"):
67
+ self.keywords = {
68
+ k: [str(w) for w in v]
69
+ for k, v in data["keywords"].items()
70
+ }
71
+ else:
72
+ self.keywords = _BUILTIN_KEYWORDS
73
+
74
+ self.default_type: str = (
75
+ default_type or data.get("default_type", _BUILTIN_DEFAULT)
76
+ )
77
+
78
+ def classify(self, summary: str, description: str = "") -> str:
79
+ """
80
+ 返回事件类型:'sport' | 'meal' | 'family' | 'medical' | 'appointment'。
81
+ 按 sport → meal → family → medical → appointment 顺序匹配,匹配即返回。
82
+ """
83
+ text = (summary + " " + description).lower()
84
+ for event_type in ("sport", "meal", "family", "medical", "appointment"):
85
+ for kw in self.keywords.get(event_type, []):
86
+ if kw.lower() in text:
87
+ return event_type
88
+ return self.default_type
89
+
90
+
91
+ # 模块级默认实例,可直接 import 快速使用
92
+ _default: EventClassifier | None = None
93
+
94
+
95
+ def classify_event(summary: str, description: str = "") -> str:
96
+ """模块级快捷函数,使用默认分类器(延迟初始化)。"""
97
+ global _default
98
+ if _default is None:
99
+ _default = EventClassifier()
100
+ return _default.classify(summary, description)
@@ -0,0 +1,160 @@
1
+ """
2
+ 日程变化检测。
3
+
4
+ 对比两份事件快照,输出新增、修改、取消列表。
5
+
6
+ 核心规则:
7
+ - 日程自然结束后消失 → 不通知
8
+ - 日程被取消 → 通知(status=cancelled)
9
+ - 标题或时间有变化 → 通知(修改)
10
+ - 无任何变化 → 不通知
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import sys
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Callable
21
+
22
+ sys.path.insert(0, str(Path(__file__).parent.parent))
23
+
24
+ from gcal.models import CalendarEvent
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class EventDiffResult:
31
+ added: list[CalendarEvent] = field(default_factory=list)
32
+ removed: list[CalendarEvent] = field(default_factory=list)
33
+ modified: list[tuple[CalendarEvent, CalendarEvent]] = field(default_factory=list)
34
+
35
+ @property
36
+ def has_changes(self) -> bool:
37
+ return bool(self.added or self.removed or self.modified)
38
+
39
+ @property
40
+ def summary_text(self) -> str:
41
+ parts = []
42
+ if self.added: parts.append(f"新增 {len(self.added)} 个")
43
+ if self.removed: parts.append(f"取消 {len(self.removed)} 个")
44
+ if self.modified: parts.append(f"修改 {len(self.modified)} 个")
45
+ return "、".join(parts) if parts else "无变化"
46
+
47
+ def format_notification(self) -> str:
48
+ """生成发送给 用户 的通知文本。"""
49
+ lines = ["📅 **Google Calendar 日程变化通知**", ""]
50
+
51
+ if self.added:
52
+ lines.append("**新增日程:**")
53
+ for e in self.added[:5]:
54
+ lines.append(f"• {e.summary} — {_fmt_time(e.start)}")
55
+ if len(self.added) > 5:
56
+ lines.append(f" 还有 {len(self.added) - 5} 个…")
57
+ lines.append("")
58
+
59
+ if self.removed:
60
+ lines.append("**已取消:**")
61
+ for e in self.removed[:5]:
62
+ lines.append(f"• {e.summary} — {_fmt_time(e.start)}")
63
+ if len(self.removed) > 5:
64
+ lines.append(f" 还有 {len(self.removed) - 5} 个…")
65
+ lines.append("")
66
+
67
+ if self.modified:
68
+ lines.append("**已修改:**")
69
+ for prev, cur in self.modified[:3]:
70
+ if prev.summary != cur.summary:
71
+ lines.append(f"• 标题:{prev.summary} → {cur.summary}")
72
+ else:
73
+ lines.append(
74
+ f"• {cur.summary}:{_fmt_time(prev.start)} → {_fmt_time(cur.start)}"
75
+ )
76
+ if len(self.modified) > 3:
77
+ lines.append(f" 还有 {len(self.modified) - 3} 个…")
78
+
79
+ return "\n".join(lines)
80
+
81
+
82
+ def diff_events(
83
+ current: list[CalendarEvent],
84
+ last: list[CalendarEvent],
85
+ get_remote_status: Callable[[str], str | None] | None = None,
86
+ now: datetime | None = None,
87
+ ) -> EventDiffResult:
88
+ """
89
+ 对比当前事件列表与上次快照。
90
+
91
+ get_remote_status:
92
+ 可选回调,传入 event_id,返回 'confirmed' | 'cancelled' | None。
93
+ 传入时使用 API 查询精确判断取消/自然结束(推荐)。
94
+ 不传时退化为时间判断(event.is_past → 不报告)。
95
+ """
96
+ if now is None:
97
+ now = datetime.now(timezone.utc)
98
+
99
+ cur_map = {e.id: e for e in current if e.id}
100
+ last_map = {e.id: e for e in last if e.id}
101
+
102
+ added: list[CalendarEvent] = []
103
+ removed: list[CalendarEvent] = []
104
+ modified: list[tuple[CalendarEvent, CalendarEvent]] = []
105
+
106
+ # 新增
107
+ for eid, event in cur_map.items():
108
+ if eid not in last_map:
109
+ added.append(event)
110
+
111
+ # 消失:区分取消 vs 自然结束
112
+ for eid, event in last_map.items():
113
+ if eid not in cur_map:
114
+ if get_remote_status is not None:
115
+ status = get_remote_status(eid)
116
+ logger.debug(f"事件 {eid}({event.summary})远端状态: {status}")
117
+ if status == "cancelled":
118
+ removed.append(event)
119
+ else:
120
+ logger.info(f"'{event.summary}' 已自然结束,不通知")
121
+ else:
122
+ # 降级:用时间判断
123
+ if not event.is_past(now):
124
+ removed.append(event)
125
+ else:
126
+ logger.info(f"'{event.summary}' 已结束,不通知")
127
+
128
+ # 修改(标题、开始时间、结束时间)
129
+ for eid in cur_map.keys() & last_map.keys():
130
+ if _is_modified(cur_map[eid], last_map[eid]):
131
+ modified.append((last_map[eid], cur_map[eid]))
132
+
133
+ return EventDiffResult(added=added, removed=removed, modified=modified)
134
+
135
+
136
+ # ────────────────────────── 内部工具 ─────────────────────────────
137
+
138
+ def _is_modified(cur: CalendarEvent, prev: CalendarEvent) -> bool:
139
+ if cur.summary != prev.summary:
140
+ return True
141
+
142
+ def to_utc(dt: datetime) -> datetime:
143
+ if dt.tzinfo is None:
144
+ return dt.replace(tzinfo=timezone.utc)
145
+ return dt.astimezone(timezone.utc)
146
+
147
+ if to_utc(cur.start) != to_utc(prev.start):
148
+ return True
149
+ if to_utc(cur.end) != to_utc(prev.end):
150
+ return True
151
+ return False
152
+
153
+
154
+ def _fmt_time(dt: datetime) -> str:
155
+ import sys
156
+ from pathlib import Path
157
+ sys.path.insert(0, str(Path(__file__).parent.parent))
158
+ from compat import ZoneInfo, make_aware
159
+ bj = dt.astimezone(ZoneInfo("Asia/Shanghai"))
160
+ return bj.strftime("%m月%d日 %H:%M")