sophhub 0.2.3 → 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.
- package/package.json +1 -1
- package/skills/consensus/skill.json +20 -0
- package/skills/consensus/src/SKILL.md +93 -0
- package/skills/deepwiki/skill.json +20 -0
- package/skills/deepwiki/src/SKILL.md +45 -0
- package/skills/deepwiki/src/_meta.json +6 -0
- package/skills/deepwiki/src/scripts/deepwiki.js +135 -0
- package/skills/feishu-bitable/skill.json +20 -0
- package/skills/feishu-bitable/src/CHECKLIST.md +150 -0
- package/skills/feishu-bitable/src/README.md +178 -0
- package/skills/feishu-bitable/src/SKILL.md +113 -0
- package/skills/feishu-bitable/src/_meta.json +6 -0
- package/skills/feishu-bitable/src/api.js +381 -0
- package/skills/feishu-bitable/src/bin/cli.js +284 -0
- package/skills/feishu-bitable/src/description.md +143 -0
- package/skills/feishu-bitable/src/examples/create-records.json +52 -0
- package/skills/feishu-bitable/src/examples/create-table.json +64 -0
- package/skills/feishu-bitable/src/package-lock.json +324 -0
- package/skills/feishu-bitable/src/package.json +33 -0
- package/skills/feishu-bitable/src/publish-config.json +14 -0
- package/skills/feishu-bitable/src/test-simple.js +61 -0
- package/skills/feishu-bitable/src/utils.js +261 -0
- package/skills/google-maps/skill.json +20 -0
- package/skills/google-maps/src/SKILL.md +237 -0
- package/skills/google-maps/src/_meta.json +6 -0
- package/skills/google-maps/src/lib/map_helper.py +912 -0
- package/skills/large-task-router/skill.json +20 -0
- package/skills/large-task-router/src/SKILL.md +79 -0
- package/skills/large-task-router/src/templates/plan.md +74 -0
- package/skills/skillhub/skill.json +11 -4
- package/skills/skillhub/src/SKILL.md +11 -1
- package/skills/sophnet-dailynews/skill.json +20 -0
- package/skills/sophnet-dailynews/src/SKILL.md +179 -0
- package/skills/sophnet-dailynews/src/cache.json +151 -0
- package/skills/sophnet-dailynews/src/sources.json +230 -0
- package/skills/sophnet-schedule/skill.json +20 -0
- package/skills/sophnet-schedule/src/ARCHITECTURE.md +321 -0
- package/skills/sophnet-schedule/src/IMPROVEMENTS.md +145 -0
- package/skills/sophnet-schedule/src/SKILL.md +1050 -0
- package/skills/sophnet-schedule/src/_meta.json +6 -0
- package/skills/sophnet-schedule/src/api/__init__.py +0 -0
- package/skills/sophnet-schedule/src/api/models.py +245 -0
- package/skills/sophnet-schedule/src/apps/add_event.py +237 -0
- package/skills/sophnet-schedule/src/apps/check_reminders.py +112 -0
- package/skills/sophnet-schedule/src/apps/check_roc.py +246 -0
- package/skills/sophnet-schedule/src/apps/generate_daily_plan.py +342 -0
- package/skills/sophnet-schedule/src/apps/import_events.py +216 -0
- package/skills/sophnet-schedule/src/apps/monitor_calendar_changes.py +140 -0
- package/skills/sophnet-schedule/src/apps/register_tasks.py +169 -0
- package/skills/sophnet-schedule/src/apps/sync_roc_to_gcal.py +174 -0
- package/skills/sophnet-schedule/src/compat.py +66 -0
- package/skills/sophnet-schedule/src/config/__init__.py +0 -0
- package/skills/sophnet-schedule/src/config/reminder_rules.yaml +96 -0
- package/skills/sophnet-schedule/src/config/roc_events.yaml +44 -0
- package/skills/sophnet-schedule/src/config/settings.py +133 -0
- package/skills/sophnet-schedule/src/config/task_registry.yaml +92 -0
- package/skills/sophnet-schedule/src/docs/FRONTEND_INTEGRATION_GUIDE.md +437 -0
- package/skills/sophnet-schedule/src/gcal/__init__.py +0 -0
- package/skills/sophnet-schedule/src/gcal/client.py +374 -0
- package/skills/sophnet-schedule/src/gcal/models.py +91 -0
- package/skills/sophnet-schedule/src/requirements.txt +6 -0
- package/skills/sophnet-schedule/src/scripts/setup_gcal_token.py +85 -0
- package/skills/sophnet-schedule/src/server.py +669 -0
- package/skills/sophnet-schedule/src/services/__init__.py +0 -0
- package/skills/sophnet-schedule/src/services/calendar_backend.py +139 -0
- package/skills/sophnet-schedule/src/services/conflict_detector.py +96 -0
- package/skills/sophnet-schedule/src/services/datetime_utils.py +117 -0
- package/skills/sophnet-schedule/src/services/event_classifier.py +100 -0
- package/skills/sophnet-schedule/src/services/event_diff.py +160 -0
- package/skills/sophnet-schedule/src/services/google_integration.py +500 -0
- package/skills/sophnet-schedule/src/services/job_store.py +100 -0
- package/skills/sophnet-schedule/src/services/local_event_store.py +266 -0
- package/skills/sophnet-schedule/src/services/reminder_planner.py +116 -0
- package/skills/sophnet-schedule/src/services/runtime_utils.py +31 -0
- package/skills/sophnet-schedule/src/services/table_parser.py +286 -0
- package/skills/sophnet-schedule/src/services/task_builder.py +167 -0
- package/skills/sophnet-schedule/src/services/time_window.py +72 -0
- package/skills/sophnet-stock/skill.json +20 -0
- package/skills/sophnet-stock/src/App-Plan.md +442 -0
- package/skills/sophnet-stock/src/README.md +214 -0
- package/skills/sophnet-stock/src/SKILL.md +236 -0
- package/skills/sophnet-stock/src/TODO.md +394 -0
- package/skills/sophnet-stock/src/_meta.json +6 -0
- package/skills/sophnet-stock/src/docs/ARCHITECTURE.md +408 -0
- package/skills/sophnet-stock/src/docs/CONCEPT.md +233 -0
- package/skills/sophnet-stock/src/docs/HOT_SCANNER.md +288 -0
- package/skills/sophnet-stock/src/docs/README.md +95 -0
- package/skills/sophnet-stock/src/docs/USAGE.md +465 -0
- package/skills/sophnet-stock/src/scripts/analyze_stock.py +2565 -0
- package/skills/sophnet-stock/src/scripts/dividends.py +365 -0
- package/skills/sophnet-stock/src/scripts/hot_scanner.py +582 -0
- package/skills/sophnet-stock/src/scripts/portfolio.py +548 -0
- package/skills/sophnet-stock/src/scripts/rumor_scanner.py +342 -0
- package/skills/sophnet-stock/src/scripts/test_stock_analysis.py +409 -0
- package/skills/sophnet-stock/src/scripts/watchlist.py +336 -0
- package/skills/xiaohongshu/skill.json +20 -0
- package/skills/xiaohongshu/src/SKILL.md +91 -0
- package/skills/xiaohongshu/src/_meta.json +6 -0
- package/skills/xiaohongshu/src/assets/card.html +216 -0
- package/skills/xiaohongshu/src/assets/cover.html +82 -0
- package/skills/xiaohongshu/src/assets/example.md +84 -0
- package/skills/xiaohongshu/src/assets/styles.css +318 -0
- package/skills/xiaohongshu/src/scripts/render_xhs_v2.py +737 -0
- package/skills/xiaohongshu/src/scripts/sign_server.py +158 -0
- package/skills/xiaohongshu/src/scripts/stealth.min.js +7 -0
- package/skills/xiaohongshu/src/scripts/xhs_tool.py +186 -0
- 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")
|