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,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,6 @@
1
+ fastapi
2
+ pydantic
3
+ PyYAML
4
+ requests
5
+ urllib3
6
+ uvicorn
@@ -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()