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,374 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google Calendar API 统一访问层。
|
|
3
|
+
|
|
4
|
+
第三方依赖:
|
|
5
|
+
- Google Calendar API v3(REST)
|
|
6
|
+
- HTTP 代理:由 proxies 参数注入(默认读 config/settings.py)
|
|
7
|
+
- Token 文件:gcalcli 兼容格式
|
|
8
|
+
字段:client_id / client_secret / refresh_token / token_uri
|
|
9
|
+
|
|
10
|
+
所有方法返回结构化的 CalendarEvent 对象,上层不再接触原始 API 数据。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import requests
|
|
24
|
+
except ImportError: # pragma: no cover - handled lazily for local mode
|
|
25
|
+
requests = None
|
|
26
|
+
|
|
27
|
+
from .models import CalendarEvent
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_EVENTS_URL = "https://www.googleapis.com/calendar/v3/calendars/{cal_id}/events"
|
|
32
|
+
_CAL_LIST_URL = "https://www.googleapis.com/calendar/v3/users/me/calendarList"
|
|
33
|
+
_TOKEN_REFRESH = "https://oauth2.googleapis.com/token"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CalendarAuthError(Exception):
|
|
37
|
+
"""Token 鉴权相关错误。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CalendarAPIError(Exception):
|
|
41
|
+
"""Google Calendar API 请求错误。"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CalendarClient:
|
|
45
|
+
"""
|
|
46
|
+
Google Calendar 访问客户端。
|
|
47
|
+
|
|
48
|
+
用法示例:
|
|
49
|
+
from config.settings import GCAL_TOKEN_PATH, PROXIES
|
|
50
|
+
from gcal.client import CalendarClient
|
|
51
|
+
from services.time_window import get_today_window
|
|
52
|
+
|
|
53
|
+
client = CalendarClient(GCAL_TOKEN_PATH, PROXIES)
|
|
54
|
+
start, end = get_today_window()
|
|
55
|
+
events = client.list_events(start, end)
|
|
56
|
+
|
|
57
|
+
Token 过期时自动刷新并写回文件,无需手动管理。
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
token_path: str | Path,
|
|
63
|
+
proxies: dict[str, str] | None = None,
|
|
64
|
+
calendar_id: str = "primary",
|
|
65
|
+
timeout: int = 10,
|
|
66
|
+
max_retries: int = 3,
|
|
67
|
+
verify: bool | str = True,
|
|
68
|
+
):
|
|
69
|
+
self.token_path = Path(token_path)
|
|
70
|
+
self.proxies = proxies or {}
|
|
71
|
+
self.calendar_id = calendar_id
|
|
72
|
+
self.timeout = timeout
|
|
73
|
+
self.max_retries = max_retries
|
|
74
|
+
self.verify = verify
|
|
75
|
+
self._access_token: str | None = None
|
|
76
|
+
|
|
77
|
+
# ──────────────────────────── Token ───────────────────────────
|
|
78
|
+
|
|
79
|
+
def _load_token_file(self) -> dict:
|
|
80
|
+
try:
|
|
81
|
+
with open(self.token_path) as f:
|
|
82
|
+
return json.load(f)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
raise CalendarAuthError(f"Token 文件不存在: {self.token_path}")
|
|
85
|
+
except json.JSONDecodeError as e:
|
|
86
|
+
raise CalendarAuthError(f"Token 文件格式错误: {e}")
|
|
87
|
+
|
|
88
|
+
def refresh_token(self) -> str:
|
|
89
|
+
"""刷新 access_token 并写回文件,返回新 token。"""
|
|
90
|
+
req = _require_requests()
|
|
91
|
+
data = self._load_token_file()
|
|
92
|
+
resp = req.post(
|
|
93
|
+
data.get("token_uri", _TOKEN_REFRESH),
|
|
94
|
+
data={
|
|
95
|
+
"client_id": data["client_id"],
|
|
96
|
+
"client_secret": data["client_secret"],
|
|
97
|
+
"refresh_token": data["refresh_token"],
|
|
98
|
+
"grant_type": "refresh_token",
|
|
99
|
+
},
|
|
100
|
+
proxies=self.proxies,
|
|
101
|
+
verify=self.verify,
|
|
102
|
+
timeout=self.timeout,
|
|
103
|
+
)
|
|
104
|
+
if resp.status_code != 200:
|
|
105
|
+
raise CalendarAuthError(
|
|
106
|
+
f"Token 刷新失败 {resp.status_code}: {resp.text[:200]}"
|
|
107
|
+
)
|
|
108
|
+
new_token = resp.json()["access_token"]
|
|
109
|
+
data["token"] = new_token
|
|
110
|
+
with open(self.token_path, "w") as f:
|
|
111
|
+
json.dump(data, f, indent=2)
|
|
112
|
+
self._access_token = new_token
|
|
113
|
+
logger.debug("Token 刷新成功")
|
|
114
|
+
return new_token
|
|
115
|
+
|
|
116
|
+
def _get_token(self) -> str:
|
|
117
|
+
if not self._access_token:
|
|
118
|
+
self.refresh_token()
|
|
119
|
+
return self._access_token # type: ignore[return-value]
|
|
120
|
+
|
|
121
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
122
|
+
return {"Authorization": f"Bearer {self._get_token()}"}
|
|
123
|
+
|
|
124
|
+
# ──────────────────────────── HTTP ────────────────────────────
|
|
125
|
+
|
|
126
|
+
def _get(self, url: str, params: dict | None = None) -> dict:
|
|
127
|
+
"""GET 请求,带 token 自动刷新和指数退避重试。"""
|
|
128
|
+
req = _require_requests()
|
|
129
|
+
refreshed = False
|
|
130
|
+
for attempt in range(self.max_retries):
|
|
131
|
+
try:
|
|
132
|
+
resp = req.get(
|
|
133
|
+
url,
|
|
134
|
+
headers=self._auth_headers(),
|
|
135
|
+
params=params,
|
|
136
|
+
proxies=self.proxies,
|
|
137
|
+
verify=self.verify,
|
|
138
|
+
timeout=self.timeout,
|
|
139
|
+
)
|
|
140
|
+
if resp.status_code == 200:
|
|
141
|
+
return resp.json()
|
|
142
|
+
if resp.status_code == 401 and not refreshed:
|
|
143
|
+
logger.info("Token 已过期,自动刷新…")
|
|
144
|
+
self.refresh_token()
|
|
145
|
+
refreshed = True
|
|
146
|
+
continue
|
|
147
|
+
if resp.status_code >= 500 and attempt < self.max_retries - 1:
|
|
148
|
+
time.sleep(2 ** attempt)
|
|
149
|
+
continue
|
|
150
|
+
raise CalendarAPIError(
|
|
151
|
+
f"API 错误 {resp.status_code}: {resp.text[:200]}"
|
|
152
|
+
)
|
|
153
|
+
except (req.ConnectionError, req.Timeout) as e:
|
|
154
|
+
if attempt < self.max_retries - 1:
|
|
155
|
+
time.sleep(2 ** attempt)
|
|
156
|
+
continue
|
|
157
|
+
raise CalendarAPIError(f"网络请求失败: {e}") from e
|
|
158
|
+
raise CalendarAPIError("所有重试均失败")
|
|
159
|
+
|
|
160
|
+
def _post(self, url: str, payload: dict) -> dict:
|
|
161
|
+
req = _require_requests()
|
|
162
|
+
resp = req.post(
|
|
163
|
+
url,
|
|
164
|
+
headers={**self._auth_headers(), "Content-Type": "application/json"},
|
|
165
|
+
json=payload,
|
|
166
|
+
proxies=self.proxies,
|
|
167
|
+
verify=self.verify,
|
|
168
|
+
timeout=self.timeout,
|
|
169
|
+
)
|
|
170
|
+
if resp.status_code not in (200, 201):
|
|
171
|
+
raise CalendarAPIError(
|
|
172
|
+
f"POST 失败 {resp.status_code}: {resp.text[:200]}"
|
|
173
|
+
)
|
|
174
|
+
return resp.json()
|
|
175
|
+
|
|
176
|
+
# ──────────────────────────── 公开 API ────────────────────────
|
|
177
|
+
|
|
178
|
+
def list_calendars(self) -> list[dict]:
|
|
179
|
+
"""返回所有日历(原始 API 数据)。"""
|
|
180
|
+
return self._get(_CAL_LIST_URL).get("items", [])
|
|
181
|
+
|
|
182
|
+
def list_events(
|
|
183
|
+
self,
|
|
184
|
+
time_min: datetime,
|
|
185
|
+
time_max: datetime,
|
|
186
|
+
) -> list[CalendarEvent]:
|
|
187
|
+
"""
|
|
188
|
+
返回 [time_min, time_max) 范围内的结构化事件列表。
|
|
189
|
+
两个参数必须是 timezone-aware datetime。
|
|
190
|
+
"""
|
|
191
|
+
url = _EVENTS_URL.format(cal_id=self.calendar_id)
|
|
192
|
+
params = {
|
|
193
|
+
"singleEvents": "true",
|
|
194
|
+
"orderBy": "startTime",
|
|
195
|
+
"timeMin": _to_rfc3339(time_min),
|
|
196
|
+
"timeMax": _to_rfc3339(time_max),
|
|
197
|
+
}
|
|
198
|
+
items = self._get(url, params).get("items", [])
|
|
199
|
+
return [_parse_event(item) for item in items]
|
|
200
|
+
|
|
201
|
+
def get_event_status(self, event_id: str) -> str | None:
|
|
202
|
+
"""
|
|
203
|
+
查询单个事件的 status 字段。
|
|
204
|
+
返回 'confirmed' | 'cancelled' | None(事件不存在或查询失败)。
|
|
205
|
+
|
|
206
|
+
用途:区分"日程自然结束消失"(不通知)与"被取消"(通知)。
|
|
207
|
+
"""
|
|
208
|
+
url = _EVENTS_URL.format(cal_id=self.calendar_id) + f"/{event_id}"
|
|
209
|
+
try:
|
|
210
|
+
return self._get(url).get("status", "confirmed")
|
|
211
|
+
except CalendarAPIError as e:
|
|
212
|
+
if "404" in str(e):
|
|
213
|
+
return None
|
|
214
|
+
logger.warning(f"查询事件 {event_id} 状态失败: {e}")
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
def create_event(
|
|
218
|
+
self,
|
|
219
|
+
summary: str,
|
|
220
|
+
start: datetime,
|
|
221
|
+
end: datetime | None = None,
|
|
222
|
+
location: str = "",
|
|
223
|
+
description: str = "",
|
|
224
|
+
tz: str = "Asia/Shanghai",
|
|
225
|
+
) -> str:
|
|
226
|
+
"""
|
|
227
|
+
创建日历事件。
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
summary: 事件标题
|
|
231
|
+
start: 开始时间(timezone-aware)
|
|
232
|
+
end: 结束时间;None 时默认持续 1 小时
|
|
233
|
+
location: 地点(可选)
|
|
234
|
+
description: 备注(可选)
|
|
235
|
+
tz: 时区字符串
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
创建成功后的 Google Calendar 事件 ID。
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
CalendarAPIError: 创建失败时抛出。
|
|
242
|
+
"""
|
|
243
|
+
if end is None:
|
|
244
|
+
end = start + timedelta(hours=1)
|
|
245
|
+
payload: dict[str, Any] = {
|
|
246
|
+
"summary": summary,
|
|
247
|
+
"start": {"dateTime": _to_rfc3339(start), "timeZone": tz},
|
|
248
|
+
"end": {"dateTime": _to_rfc3339(end), "timeZone": tz},
|
|
249
|
+
}
|
|
250
|
+
if location:
|
|
251
|
+
payload["location"] = location
|
|
252
|
+
if description:
|
|
253
|
+
payload["description"] = description
|
|
254
|
+
result = self._post(_EVENTS_URL.format(cal_id=self.calendar_id), payload)
|
|
255
|
+
event_id = result.get("id", "")
|
|
256
|
+
logger.info(f"事件已创建: {summary} (id={event_id})")
|
|
257
|
+
return event_id
|
|
258
|
+
|
|
259
|
+
def create_allday_event(
|
|
260
|
+
self,
|
|
261
|
+
summary: str,
|
|
262
|
+
date: str,
|
|
263
|
+
description: str = "",
|
|
264
|
+
location: str = "",
|
|
265
|
+
) -> str:
|
|
266
|
+
"""
|
|
267
|
+
创建全天事件(用于 ROC 周年、生日、体检等)。
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
summary: 事件标题
|
|
271
|
+
date: 日期字符串 "YYYY-MM-DD"
|
|
272
|
+
description: 备注(可选)
|
|
273
|
+
location: 地点(可选)
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Google Calendar 事件 ID。
|
|
277
|
+
"""
|
|
278
|
+
from datetime import date as _date
|
|
279
|
+
d = datetime.strptime(date, "%Y-%m-%d")
|
|
280
|
+
next_day = (d + timedelta(days=1)).strftime("%Y-%m-%d")
|
|
281
|
+
payload: dict[str, Any] = {
|
|
282
|
+
"summary": summary,
|
|
283
|
+
"start": {"date": date},
|
|
284
|
+
"end": {"date": next_day},
|
|
285
|
+
}
|
|
286
|
+
if description:
|
|
287
|
+
payload["description"] = description
|
|
288
|
+
if location:
|
|
289
|
+
payload["location"] = location
|
|
290
|
+
result = self._post(_EVENTS_URL.format(cal_id=self.calendar_id), payload)
|
|
291
|
+
event_id = result.get("id", "")
|
|
292
|
+
logger.info(f"全天事件已创建: {summary} ({date}) id={event_id}")
|
|
293
|
+
return event_id
|
|
294
|
+
|
|
295
|
+
def delete_event(self, event_id: str) -> None:
|
|
296
|
+
"""
|
|
297
|
+
删除(取消)一个日历事件。
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
event_id: Google Calendar 事件 ID
|
|
301
|
+
|
|
302
|
+
Raises:
|
|
303
|
+
CalendarAPIError: 删除失败时抛出(404 = 事件不存在也视为成功)。
|
|
304
|
+
"""
|
|
305
|
+
req = _require_requests()
|
|
306
|
+
url = _EVENTS_URL.format(cal_id=self.calendar_id) + f"/{event_id}"
|
|
307
|
+
resp = req.delete(
|
|
308
|
+
url,
|
|
309
|
+
headers=self._auth_headers(),
|
|
310
|
+
proxies=self.proxies,
|
|
311
|
+
verify=self.verify,
|
|
312
|
+
timeout=self.timeout,
|
|
313
|
+
)
|
|
314
|
+
if resp.status_code in (200, 204, 404):
|
|
315
|
+
logger.info(f"事件已删除: {event_id}")
|
|
316
|
+
return
|
|
317
|
+
raise CalendarAPIError(f"删除失败 {resp.status_code}: {resp.text[:200]}")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ─────────────────────────── 内部工具函数 ─────────────────────────
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _to_rfc3339(dt: datetime) -> str:
|
|
324
|
+
if dt.tzinfo is None:
|
|
325
|
+
raise ValueError("datetime 必须携带时区信息,不能把 naive datetime 当作 UTC")
|
|
326
|
+
return dt.isoformat().replace("+00:00", "Z")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _parse_event(item: dict[str, Any]) -> CalendarEvent:
|
|
330
|
+
"""将 Google Calendar API 原始事件对象解析为 CalendarEvent。"""
|
|
331
|
+
start_raw = item.get("start", {})
|
|
332
|
+
end_raw = item.get("end", {})
|
|
333
|
+
is_all_day = "date" in start_raw and "dateTime" not in start_raw
|
|
334
|
+
|
|
335
|
+
if is_all_day:
|
|
336
|
+
start = _date_str_to_dt(start_raw["date"])
|
|
337
|
+
end = _date_str_to_dt(end_raw.get("date", start_raw["date"]))
|
|
338
|
+
else:
|
|
339
|
+
start = _parse_dt(start_raw.get("dateTime", ""))
|
|
340
|
+
end = _parse_dt(end_raw.get("dateTime", start_raw.get("dateTime", "")))
|
|
341
|
+
|
|
342
|
+
return CalendarEvent(
|
|
343
|
+
id = item.get("id", ""),
|
|
344
|
+
summary = item.get("summary", "无标题"),
|
|
345
|
+
start = start,
|
|
346
|
+
end = end,
|
|
347
|
+
status = item.get("status", "confirmed"),
|
|
348
|
+
is_all_day = is_all_day,
|
|
349
|
+
location = item.get("location", ""),
|
|
350
|
+
description = item.get("description", ""),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _parse_dt(s: str) -> datetime:
|
|
355
|
+
if not s:
|
|
356
|
+
return datetime.now(timezone.utc)
|
|
357
|
+
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
358
|
+
if dt.tzinfo is None:
|
|
359
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
360
|
+
return dt
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _date_str_to_dt(s: str) -> datetime:
|
|
364
|
+
import sys
|
|
365
|
+
from pathlib import Path
|
|
366
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
367
|
+
from compat import make_aware
|
|
368
|
+
return make_aware(datetime.strptime(s, "%Y-%m-%d"), "Asia/Shanghai")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _require_requests():
|
|
372
|
+
if requests is None:
|
|
373
|
+
raise CalendarAPIError("缺少 requests 依赖,请先安装 requirements.txt")
|
|
374
|
+
return requests
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Google Calendar 事件数据模型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CalendarEvent:
|
|
11
|
+
"""
|
|
12
|
+
结构化的 Google Calendar 事件。
|
|
13
|
+
start / end 始终是 timezone-aware datetime。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
summary: str
|
|
18
|
+
start: datetime
|
|
19
|
+
end: datetime
|
|
20
|
+
status: str = "confirmed" # "confirmed" | "cancelled" | "tentative"
|
|
21
|
+
is_all_day: bool = False
|
|
22
|
+
location: str = ""
|
|
23
|
+
description: str = ""
|
|
24
|
+
|
|
25
|
+
# ── 业务判断 ──────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
def is_past(self, now: datetime | None = None) -> bool:
|
|
28
|
+
"""事件是否已经结束。"""
|
|
29
|
+
if now is None:
|
|
30
|
+
now = datetime.now(timezone.utc)
|
|
31
|
+
return _aware(self.end) < _aware(now)
|
|
32
|
+
|
|
33
|
+
def duration_minutes(self) -> int:
|
|
34
|
+
return int((self.end - self.start).total_seconds() / 60)
|
|
35
|
+
|
|
36
|
+
# ── 快照序列化 ─────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return {
|
|
40
|
+
"id": self.id,
|
|
41
|
+
"summary": self.summary,
|
|
42
|
+
"start": self.start.isoformat(),
|
|
43
|
+
"end": self.end.isoformat(),
|
|
44
|
+
"status": self.status,
|
|
45
|
+
"is_all_day": self.is_all_day,
|
|
46
|
+
"location": self.location,
|
|
47
|
+
"description": self.description,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, d: dict) -> CalendarEvent:
|
|
52
|
+
return cls(
|
|
53
|
+
id = d["id"],
|
|
54
|
+
summary = d.get("summary", "无标题"),
|
|
55
|
+
start = _parse_iso(d["start"]),
|
|
56
|
+
end = _parse_iso(d["end"]),
|
|
57
|
+
status = d.get("status", "confirmed"),
|
|
58
|
+
is_all_day = d.get("is_all_day", False),
|
|
59
|
+
location = d.get("location", ""),
|
|
60
|
+
description = d.get("description", ""),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# ── 标准协议 ───────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def __eq__(self, other: object) -> bool:
|
|
66
|
+
if not isinstance(other, CalendarEvent):
|
|
67
|
+
return False
|
|
68
|
+
return self.id == other.id
|
|
69
|
+
|
|
70
|
+
def __hash__(self) -> int:
|
|
71
|
+
return hash(self.id)
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
t = self.start.strftime("%m-%d %H:%M") if not self.is_all_day else self.start.strftime("%m-%d 全天")
|
|
75
|
+
return f"<CalendarEvent {t} {self.summary!r}>"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ─────────────────────────── 内部工具 ────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _aware(dt: datetime) -> datetime:
|
|
82
|
+
if dt.tzinfo is None:
|
|
83
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
84
|
+
return dt
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_iso(s: str) -> datetime:
|
|
88
|
+
dt = datetime.fromisoformat(s)
|
|
89
|
+
if dt.tzinfo is None:
|
|
90
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
91
|
+
return dt
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google Calendar OAuth 授权 & token.json 生成工具。
|
|
3
|
+
|
|
4
|
+
用法:
|
|
5
|
+
python3 scripts/setup_gcal_token.py --client-id YOUR_ID --client-secret YOUR_SECRET
|
|
6
|
+
|
|
7
|
+
完成浏览器授权后,自动生成 ~/.config/gcalcli/token.json(sophnet-schedule 所需格式)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
20
|
+
except ImportError:
|
|
21
|
+
print("缺少依赖,请先安装:pip3 install google-auth-oauthlib")
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
SCOPES = ["https://www.googleapis.com/auth/calendar"]
|
|
25
|
+
TOKEN_DIR = Path.home() / ".config" / "gcalcli"
|
|
26
|
+
TOKEN_PATH = TOKEN_DIR / "token.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main():
|
|
30
|
+
parser = argparse.ArgumentParser(description="生成 Google Calendar token.json")
|
|
31
|
+
parser.add_argument("--client-id", required=True, help="OAuth Client ID")
|
|
32
|
+
parser.add_argument("--client-secret", required=True, help="OAuth Client Secret")
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--output", default=str(TOKEN_PATH), help=f"输出路径 (默认 {TOKEN_PATH})"
|
|
35
|
+
)
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
flow = InstalledAppFlow.from_client_config(
|
|
39
|
+
client_config={
|
|
40
|
+
"installed": {
|
|
41
|
+
"client_id": args.client_id,
|
|
42
|
+
"client_secret": args.client_secret,
|
|
43
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
44
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
45
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
46
|
+
"redirect_uris": ["http://localhost"],
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
scopes=SCOPES,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
print("即将打开浏览器进行 Google 授权...")
|
|
53
|
+
print("如果浏览器没有自动打开,请复制下方链接手动打开。\n")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
credentials = flow.run_local_server(
|
|
57
|
+
port=0, open_browser=False, prompt="consent"
|
|
58
|
+
)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f"\n授权失败: {e}")
|
|
61
|
+
print("\n常见原因:")
|
|
62
|
+
print(" 1. OAuth 客户端类型必须是 '桌面应用'(Desktop app)")
|
|
63
|
+
print(" 2. Google Calendar API 未启用")
|
|
64
|
+
print(" 3. 你的邮箱未添加为测试用户(如果 App 未发布)")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
token_data = {
|
|
68
|
+
"client_id": args.client_id,
|
|
69
|
+
"client_secret": args.client_secret,
|
|
70
|
+
"refresh_token": credentials.refresh_token,
|
|
71
|
+
"token": credentials.token,
|
|
72
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
output_path = Path(args.output)
|
|
76
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
with open(output_path, "w") as f:
|
|
78
|
+
json.dump(token_data, f, indent=2)
|
|
79
|
+
|
|
80
|
+
print(f"\n授权成功!Token 已保存到: {output_path}")
|
|
81
|
+
print("sophnet-schedule 现在可以正常访问 Google Calendar 了。")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|